Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/scanner-imports/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The guides cover local and Cloud imports unless an input is marked local only.
| Guide | `--provider` | Supported input |
| ----------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------- |
| [Amazon Inspector](inspector.md) | `inspector` | Inspector2 `ListFindings` JSON for one Code Security repository |
| [ArmorCode](armorcode.md) | `armorcode` | Findings API response JSON (`data.findings`) |
| [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
50 changes: 50 additions & 0 deletions docs/scanner-imports/armorcode.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Import ArmorCode findings

Import the JSON response from ArmorCode's `POST /api/findings` endpoint. Keep
the saved response in a private directory outside the checkout. 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.

For example, use your ArmorCode host and API key to save the API response:

```sh
curl -X POST "https://$HOST_URL/api/findings" \
-H 'accept: application/json' \
-H "authorization: Bearer $ARMORCODE_API_KEY" \
-H 'content-type: application/json' \
--data '{
"filters": {
"severity": ["Critical", "High"],
"status": ["OPEN", "CONFIRMED", "TRIAGE", "IN_PROGRESS"]
},
"maxSize": 100
}' > armorcode-findings.json
```

Preview locally before saving or publishing:

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

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`;
an ArmorCode status does not become a Codex assessment.
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 exclusions and confirm the repository and source key before
saving locally or publishing to Codex Security Cloud. API filters determine
which records the response contains; 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
171 changes: 171 additions & 0 deletions sdk/typescript/src/armorcode-findings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
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 { 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 [];
});
}

/** Normalize stable finding records while retaining the original ArmorCode row. */
export function normalizeArmorCodeFindings(
input: unknown,
target: ExternalImportTarget = "cloud",
): 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"];
}
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 repository = finding["repository"];
const repositoryUrl = text(
finding["sourceRepositoryUrl"] ??
finding["repositoryUrl"] ??
(isRecord(repository) ? repository["url"] : repository),
);
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 } }),
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 },
);
}
6 changes: 6 additions & 0 deletions sdk/typescript/src/external-findings-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,14 @@ import {
readVendorFindingsForPublication,
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 @@ -261,6 +263,10 @@ 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) };
}
if (provider === "github")
return { findings: await readGitHubFindings(path, target) };
if (provider === "wiz") {
Expand Down
3 changes: 2 additions & 1 deletion sdk/typescript/src/external-import-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ export type Provider =
| "sonar"
| "continuum"
| "checkmarx"
| "trivy";
| "trivy"
| "armorcode";
export type SourceKey = string;
export type ClientId = string;
export type SourceFindingId = string;
Expand Down
Loading
Loading