Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/scanner-imports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ workbench or send a supported report to Codex Security Cloud.

The `--provider` value selects the reader for a saved report. To see which
readers your installed CLI supports, run `codex-security publish findings --help`.
The guides cover local and Cloud imports unless an input is marked local only.
The guides cover local and Cloud imports unless a row limits its scope.

| Guide | `--provider` | Supported input |
| ----------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------- |
| [Amazon Inspector](inspector.md) | `inspector` | Inspector2 `ListFindings` JSON for one Code Security repository |
| [ArmorCode](armorcode.md) | `armorcode` | Findings API JSON for one repository (local verified, Cloud unverified) |
| [AWS Continuum](continuum.md) | `continuum` | Summaries or selected code finding details for one verified repository |
| [Black Duck SCA](blackduck.md) | `blackduck` | BOM-6 vulnerable components for one project version |
| [Checkmarx SAST](checkmarx.md) | `checkmarx` | Checkmarx One CLI JSON or self-managed CxSAST XML for one scan |
Expand Down
53 changes: 53 additions & 0 deletions docs/scanner-imports/armorcode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Import ArmorCode findings

Import a saved response from ArmorCode's `POST /api/findings` endpoint into the
local workbench. Cloud publication of ArmorCode findings is unverified. The reader
accepts the API's `data.findings` envelope, as well as a top-level `findings`
array for exported reports. Each finding needs a stable numeric or string `id`
and a `title`. Reuse the same source key for repeat imports from the same
ArmorCode organization and finding selection.

Choose SAST findings for one repository in ArmorCode. Set `ARMORCODE_QUERY` to
the path of the JSON request body for that selection. Severity and status
filters alone can return findings from multiple repositories. From the checkout
root, save the response in a private temporary directory outside the checkout.

```sh
report_dir=$(mktemp -d "${TMPDIR:-/tmp}/armorcode.XXXXXX")
curl --fail-with-body -X POST "https://$HOST_URL/api/findings" \
-H 'accept: application/json' \
-H "authorization: Bearer $ARMORCODE_API_KEY" \
-H 'content-type: application/json' \
--data-binary "@$ARMORCODE_QUERY" > "$report_dir/armorcode-findings.json"
```

Preview the same file against the selected checkout.

```sh
codex-security publish findings "$report_dir/armorcode-findings.json" \
--to local --repository . --provider armorcode \
--source-key ORG_ID/APP_ID --dry-run --format json
```

If any SAST row omits its repository URL, verify the report's repository in
ArmorCode and add `--source-repository-url URL` to the command. The import
checks declared repository URLs against the checkout's configured remotes.
A conflicting repository in the report causes the import to fail.

The import preserves the complete source record as evidence. It maps severity,
description, mitigation, finding URLs, CVE identifiers, file path and line
number, and the last-updated timestamp when present. The evidence URL uses the
source scanner's `url` when present. ArmorCode's `findingUrl` remains in the
retained source record. Findings whose `scanType` contains `SAST` also expose
their source status, scanner origin and verdict, rule IDs, CWE IDs, and
mitigation as typed SAST details. A record's `sourceRepositoryUrl`,
`repositoryUrl`, or repository URL is projected when present. Unknown severities
are retained as `unknown`. The local assessment starts as `not_assessed`.
Repeated IDs in a single input are excluded as ambiguous. Repeat imports with
the same provider, source key and finding ID update the existing observation.

Review the preview's findings, exclusions, repository and source key. Remove
`--dry-run` to save the findings, then run
`codex-security findings imported . --format json` to read them back.
Records omitted from a later import remain stored. Use the normalized field
names above when an export uses different field names.
1 change: 1 addition & 0 deletions docs/scanner-imports/schema-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ which changes can give an occurrence a different ID.
| Report | What identifies one record |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [Amazon Inspector](inspector.md), [Endor Labs](endor.md), [SonarQube Cloud](sonar.md) | Inspector finding ARN, Endor Finding UUID or Sonar issue key. Endor native JSON and SARIF use the same UUID. |
| [ArmorCode](armorcode.md) | ArmorCode finding ID (`id`) within the source key. |
| [AWS Continuum](continuum.md) | Continuum finding ARN. A list summary and a detailed response for that ARN identify the same finding. |
| [Tenable One](tenable.md) | Exact Tenable `finding_id` within the local checkout and a stable tenant/asset source key. Use the Code Repository asset UUID in the key. |
| [Checkmarx SAST](checkmarx.md) | Checkmarx One uses the similarity ID and result hash. CxSAST scopes the similarity ID and ordered flow to the project and query. |
Expand Down
3 changes: 2 additions & 1 deletion sdk/typescript/schemas/external-findings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@
"sonar",
"continuum",
"checkmarx",
"trivy"
"trivy",
"armorcode"
],
"title": "Provider",
"type": "string"
Expand Down
1 change: 1 addition & 0 deletions sdk/typescript/scripts/package-dist-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const distModules = [
"external-findings-import",
"external-findings-read",
"scanner-import",
"armorcode-findings",
"inspector-findings",
"continuum-findings",
"checkmarx-evidence",
Expand Down
192 changes: 192 additions & 0 deletions sdk/typescript/src/armorcode-findings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import type { ExternalImportTarget } from "./external-import-contract.js";
import { validateExternalEvidence } from "./external-import-contract.js";
import type { ExternalFindings } from "./external-findings.js";
import type { ExternalFindingEvidence } from "./external-import-models.js";
import { declaredScannerRepositoryUrl } from "./scanner-repository-url.js";
import { isRecord } from "./record.js";
import {
normalizeRecords,
record,
requiredText,
text,
} from "./scanner-import.js";

type ArmorCodeFinding = Record<string, unknown>;

function armorCodeId(value: unknown): string {
if (typeof value === "string")
return requiredText(value, "ArmorCode finding ID");
if (typeof value === "number" && Number.isSafeInteger(value))
return String(value);
throw new Error("ArmorCode finding ID must be text or a safe integer.");
}

function stringValues(value: unknown): string[] {
const values = Array.isArray(value) ? value : value == null ? [] : [value];
return values.flatMap((item) => {
if (typeof item === "string" && item.trim()) return [item];
if (typeof item === "number" && Number.isSafeInteger(item))
return [String(item)];
return [];
});
}

/** Recover the source repository independently of the finding's scan type. */
export function armorCodeRepositoryUrl(
finding: ArmorCodeFinding | null | undefined,
target: ExternalImportTarget,
): string | undefined {
const repository = finding?.["repository"];
const url =
text(finding?.["sourceRepositoryUrl"]) ??
text(finding?.["repositoryUrl"]) ??
text(isRecord(repository) ? repository["url"] : repository);
return url
? declaredScannerRepositoryUrl(url, "ArmorCode", target)
: undefined;
}

/** Normalize stable finding records while retaining the original ArmorCode row. */
export function normalizeArmorCodeFindings(
input: unknown,
target: ExternalImportTarget = "cloud",
sourceRepositoryUrl?: string,
): ExternalFindings {
const document = Array.isArray(input)
? { findings: input }
: record(input, "ArmorCode report");
const raw =
document["findings"] ??
document["issues"] ??
(isRecord(document["data"]) ? document["data"]["findings"] : undefined);
if (!Array.isArray(raw))
throw new Error("ArmorCode report must contain a findings array.");
const report = { ...document };
delete report["findings"];
delete report["issues"];
if (isRecord(report["data"])) {
const data = { ...report["data"] };
delete data["findings"];
if (Object.keys(data).length) report["data"] = data;
else delete report["data"];
}
const declaredUrl =
sourceRepositoryUrl === undefined
? undefined
: declaredScannerRepositoryUrl(sourceRepositoryUrl, "ArmorCode", target);
return normalizeRecords(
raw as ArmorCodeFinding[],
(finding) => {
const severity = (text(finding["severity"]) ?? "unknown").toLowerCase();
const locations = Array.isArray(finding["locations"])
? finding["locations"]
: finding["location"]
? [finding["location"]]
: text(finding["filePath"])
? [{ path: finding["filePath"], line: finding["lineNumber"] }]
: [];
const description = text(
finding["description"] ??
finding["details"] ??
finding["toolDescription"],
);
const mitigation = text(finding["mitigation"]);
const lastUpdated = finding["lastUpdated"];
const isSast =
Array.isArray(finding["scanType"]) &&
finding["scanType"].some(
(value) =>
typeof value === "string" && value.toLowerCase() === "sast",
);
const ruleIds = stringValues(finding["ruleId"]);
const weaknessIds = [
...stringValues(finding["cwe"]),
...stringValues(finding["cwesStrings"]),
];
const repositoryUrl =
armorCodeRepositoryUrl(finding, target) ?? declaredUrl;
if (isSast && !repositoryUrl)
throw new Error(
"ArmorCode SAST evidence requires its source repository. For a single-repository report, supply its verified --source-repository-url.",
);
const evidence: ExternalFindingEvidence = {
title: requiredText(
finding["title"] ?? finding["name"] ?? finding["findingName"],
"ArmorCode finding title",
),
severity: ([
"critical",
"high",
"medium",
"low",
"informational",
].includes(severity)
? severity
: "unknown") as ExternalFindingEvidence["severity"],
description: isSast
? description
: description && mitigation
? `${description}\n\nMitigation: ${mitigation}`
: (description ?? mitigation),
// ArmorCode's findingUrl remains available in source_data; url points
// to the originating scanner when ArmorCode provides both links.
url: text(finding["url"] ?? finding["findingUrl"]),
...(Array.isArray(finding["cve"]) && {
advisory_ids: finding["cve"].filter(
(value): value is string => typeof value === "string",
),
}),
locations: locations.flatMap((value) => {
if (!isRecord(value)) return [];
const path = text(
value["path"] ?? value["filePath"] ?? value["file"],
);
if (!path) return [];
const line =
value["line"] ?? value["startLine"] ?? value["lineNumber"];
return [
{
path,
line:
Number.isSafeInteger(line) && Number(line) > 0
? Number(line)
: null,
},
];
}),
...(typeof lastUpdated === "number" &&
Number.isSafeInteger(lastUpdated) && {
source_updated_at: Math.floor(lastUpdated / 1000),
}),
...(isSast && {
details: {
kind: "sast",
...(repositoryUrl && { repository: { url: repositoryUrl } }),
Comment thread
mldangelo-oai marked this conversation as resolved.
Resolved
source_status: text(
finding["status"] ?? finding["toolFindingStatus"],
),
scanner_origin: text(finding["source"]),
scanner_verdict: text(finding["toolStatusOriginal"]),
...(ruleIds.length > 0 && { rule: { id: ruleIds.join(", ") } }),
...(weaknessIds.length > 0 && {
weakness_ids: [...new Set(weaknessIds)],
}),
...(mitigation && { remediation_instructions: mitigation }),
},
}),
source_data: structuredClone(finding),
};
validateExternalEvidence(evidence, target);
return evidence;
},
(finding) =>
armorCodeId(
finding["source_finding_id"] ??
finding["findingId"] ??
finding["id"] ??
finding["finding_id"],
),
target,
{ report },
);
}
19 changes: 19 additions & 0 deletions sdk/typescript/src/external-findings-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { continuumReportRepositories } from "./continuum-findings.js";
import { veracodeReportRepositories } from "./veracode-findings.js";
import { inspectorReportRepository } from "./inspector-findings.js";
import { declaredScannerRepositoryUrl } from "./scanner-repository-url.js";
import { armorCodeRepositoryUrl } from "./armorcode-findings.js";
import { sonarRepositoryUrls } from "./sonar-findings.js";
import { isRecord } from "./record.js";
import { dependabotRepository } from "./github-dependabot-findings.js";
Expand Down Expand Up @@ -994,6 +995,10 @@ export async function prepareLocalImport(
if (semgrep.migration) Object.assign(finding.evidence, semgrep.migration);
}
const { evidence } = finding;
if (options.provider === "armorcode") {
const nativeUrl = armorCodeRepositoryUrl(evidence.source_data, "local");
if (nativeUrl) sources.set(nativeUrl, { url: nativeUrl });
}
if (options.provider === "github") {
const source = retainedGitHubRepository(
finding,
Expand Down Expand Up @@ -1242,6 +1247,20 @@ export async function prepareLocalImport(
throw new Error(
"The retained Checkmarx report repository does not match the finding repository.",
);
if (options.provider === "armorcode") {
if (options.sourceRepositoryUrl !== undefined) {
const url = declaredScannerRepositoryUrl(
options.sourceRepositoryUrl,
"ArmorCode",
"local",
);
sources.set(url, { url });
}
if (!sameRepositories(sources.keys()))
throw new CodexSecurityError(
"ArmorCode findings must belong to a single source repository.",
);
}
const sonarSources = sonarRepositoryUrls(
options.provider,
current.findings,
Expand Down
8 changes: 7 additions & 1 deletion sdk/typescript/src/external-findings-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "./private-finding-import.js";
import { readCloudCredentials } from "./cloud-publish.js";
import type { LocalExternalImportOptions } from "./external-findings-import.js";
import { armorCodeRepositoryUrl } from "./armorcode-findings.js";
import { declaredScannerRepositoryUrl } from "./scanner-repository-url.js";
import { tenableReportFormat } from "./tenable-findings.js";
import {
Expand Down Expand Up @@ -1418,6 +1419,8 @@ async function preparePublicationInput(
`Finding ${JSON.stringify(id)} belongs to ${JSON.stringify(url)}, which does not match the selected Cloud repository ${JSON.stringify(destination.url)}. Verify the source repository mapping before publishing.`,
);
}
if (provider === "armorcode")
requireDestination("ArmorCode source", declaredUrl);
if (report?.["format"] === guardianSelectionFormat)
requireDestination(
"Guardian source",
Expand Down Expand Up @@ -1478,6 +1481,7 @@ async function preparePublicationInput(
if (binding) requireDestination(binding.findingId, binding.url);
}
const checkOnlyProjected = !checkNative(
"armorcode",
"blackduck",
"inspector",
"veracode",
Expand Down Expand Up @@ -1511,7 +1515,9 @@ async function preparePublicationInput(
checkmarxRepository(finding)?.url,
evidence.details?.repository?.url,
];
if (provider === "wiz") {
if (provider === "armorcode") {
urls.push(armorCodeRepositoryUrl(evidence.source_data, "cloud"));
} else if (provider === "wiz") {
const native =
hasProjection ||
(!historical && isWizPackageFinding(evidence.source_data));
Expand Down
12 changes: 12 additions & 0 deletions sdk/typescript/src/external-findings-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ import { readCheckmarxFindings } from "./checkmarx-findings.js";
import { normalizeInspectorFindings } from "./inspector-findings.js";
import { isRecord } from "./record.js";
import { readWizSelection, wizFindingRepository } from "./wiz-findings.js";
import { normalizeArmorCodeFindings } from "./armorcode-findings.js";

export type LocalFindingProvider = Provider | "tenable";

/** File reader names mapped to the provider stored with each finding. */
export const EXTERNAL_FINDINGS_PROVIDERS = {
wiz: "wiz",
armorcode: "armorcode",
snyk: "snyk",
semgrep: "semgrep",
trivy: "trivy",
Expand Down Expand Up @@ -221,6 +223,16 @@ export async function readExternalFindings(
sourceRepositories.push({ url: options.sourceRepositoryUrl });
return { findings, sourceRepositories };
}
if (provider === "armorcode") {
const input = await readScannerJson(path);
return {
findings: normalizeArmorCodeFindings(
input,
target,
options.sourceRepositoryUrl,
),
};
}
if (provider === "github")
return { findings: await readGitHubFindings(path, target) };
if (provider === "wiz") {
Expand Down
Loading
Loading