Repository navigation
feat(scanner-imports): add ArmorCode findings support #2608
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+513
−4
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ec60666
feat(scanner-imports): add ArmorCode findings support
dy-armorcode 108bf80
Merge main and retain both scanner readers
mldangelo-oai 8645c4a
fix(sdk): include the ArmorCode reader in the package
mldangelo-oai 815e74a
fix(imports): verify ArmorCode finding repositories
mldangelo-oai File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 } }), | ||
| 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 }, | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.