diff --git a/docs/scanner-imports/README.md b/docs/scanner-imports/README.md index ac61b976f1..5080699c26 100644 --- a/docs/scanner-imports/README.md +++ b/docs/scanner-imports/README.md @@ -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 | diff --git a/docs/scanner-imports/armorcode.md b/docs/scanner-imports/armorcode.md new file mode 100644 index 0000000000..bdd0727ed5 --- /dev/null +++ b/docs/scanner-imports/armorcode.md @@ -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. diff --git a/docs/scanner-imports/schema-guide.md b/docs/scanner-imports/schema-guide.md index ce35d7680b..66315aa39a 100644 --- a/docs/scanner-imports/schema-guide.md +++ b/docs/scanner-imports/schema-guide.md @@ -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. | diff --git a/sdk/typescript/schemas/external-findings.schema.json b/sdk/typescript/schemas/external-findings.schema.json index 5fb9905e81..1830317b08 100644 --- a/sdk/typescript/schemas/external-findings.schema.json +++ b/sdk/typescript/schemas/external-findings.schema.json @@ -390,7 +390,8 @@ "sonar", "continuum", "checkmarx", - "trivy" + "trivy", + "armorcode" ], "title": "Provider", "type": "string" diff --git a/sdk/typescript/src/armorcode-findings.ts b/sdk/typescript/src/armorcode-findings.ts new file mode 100644 index 0000000000..a534cbcd55 --- /dev/null +++ b/sdk/typescript/src/armorcode-findings.ts @@ -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; + +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 }, + ); +} diff --git a/sdk/typescript/src/external-findings-read.ts b/sdk/typescript/src/external-findings-read.ts index 1bc0fb8a8b..56b98ed7e6 100644 --- a/sdk/typescript/src/external-findings-read.ts +++ b/sdk/typescript/src/external-findings-read.ts @@ -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", @@ -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") { diff --git a/sdk/typescript/src/external-import-models.ts b/sdk/typescript/src/external-import-models.ts index d19314b29a..26ef1ff684 100644 --- a/sdk/typescript/src/external-import-models.ts +++ b/sdk/typescript/src/external-import-models.ts @@ -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; diff --git a/sdk/typescript/tests-ts/armorcode-findings.test.ts b/sdk/typescript/tests-ts/armorcode-findings.test.ts new file mode 100644 index 0000000000..4d9ba5d33a --- /dev/null +++ b/sdk/typescript/tests-ts/armorcode-findings.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from "bun:test"; +import { normalizeArmorCodeFindings } from "../src/armorcode-findings.js"; + +describe("ArmorCode finding imports", () => { + test("normalizes findings and retains the source records", () => { + const result = normalizeArmorCodeFindings({ + findings: [ + { + findingId: "AC-42", + name: "Unsafe query construction", + severity: "HIGH", + description: "Untrusted input reaches a query.", + location: { filePath: "src/query.ts", startLine: 18 }, + status: "open", + }, + ], + }); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + source_finding_id: "AC-42", + evidence: { + title: "Unsafe query construction", + severity: "high", + locations: [{ path: "src/query.ts", line: 18 }], + source_data: { status: "open" }, + }, + }); + }); + + test("excludes colliding source IDs instead of selecting one", () => { + const result = normalizeArmorCodeFindings( + [ + { id: "same", title: "First" }, + { id: "same", title: "Second" }, + ], + "local", + ); + + expect(result.findings).toHaveLength(0); + expect(result.excluded).toHaveLength(2); + }); + + test("normalizes the ArmorCode findings API response envelope", () => { + const result = normalizeArmorCodeFindings({ + data: { + findings: [ + { + id: 1097722461, + title: "Container(s) run as root", + severity: "CRITICAL", + description: "Container is configured to run as root.", + mitigation: "Set runAsUser to a non-zero ID.", + findingUrl: "https://armorcode.example/findings/1097722461", + filePath: "deploy/workload.yaml", + lineNumber: 12, + cve: [], + status: "OPEN", + lastUpdated: 1792740664000, + }, + ], + }, + success: true, + }); + + expect(result.findings).toHaveLength(1); + expect(result.findings[0]).toMatchObject({ + source_finding_id: "1097722461", + evidence: { + title: "Container(s) run as root", + severity: "critical", + description: + "Container is configured to run as root.\n\nMitigation: Set runAsUser to a non-zero ID.", + url: "https://armorcode.example/findings/1097722461", + locations: [{ path: "deploy/workload.yaml", line: 12 }], + source_updated_at: 1792740664, + source_data: { status: "OPEN" }, + }, + }); + }); + + test("projects typed SAST metadata and keeps both scanner links", () => { + const result = normalizeArmorCodeFindings({ + data: { + findings: [ + { + id: 42, + title: "Buffer index access", + repositoryUrl: "https://github.com/example/repository", + severity: "HIGH", + description: "Query: Buffer index access", + mitigation: "Validate the buffer index.", + source: "Checkmarx", + status: "OPEN", + toolStatusOriginal: "TO VERIFY", + scanType: ["SAST"], + ruleId: ["CX-101"], + cwe: "CWE-129", + url: "https://scanner.example/findings/42", + findingUrl: "https://armorcode.example/findings/42", + }, + ], + }, + }); + + expect(result.findings[0]).toMatchObject({ + source_finding_id: "42", + evidence: { + url: "https://scanner.example/findings/42", + description: "Query: Buffer index access", + details: { + kind: "sast", + repository: { url: "https://github.com/example/repository" }, + source_status: "OPEN", + scanner_origin: "Checkmarx", + scanner_verdict: "TO VERIFY", + rule: { id: "CX-101" }, + weakness_ids: ["CWE-129"], + remediation_instructions: "Validate the buffer index.", + }, + source_data: { findingUrl: "https://armorcode.example/findings/42" }, + }, + }); + }); +});