From c6818ca03f00e96fadd37ac8105f39ee203b1e38 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 21:37:39 +0000 Subject: [PATCH 1/4] feat: import Snyk Open Source SARIF findings --- docs/scanner-imports.md | 39 ++++ sdk/typescript/src/cli.ts | 9 +- .../src/external-findings-publish.ts | 13 +- sdk/typescript/src/index.ts | 2 + sdk/typescript/src/scanner-import.ts | 107 ++++++++++ sdk/typescript/src/snyk-findings.ts | 185 ++++++++++++++++++ .../external-findings-publish.test.ts | 41 ++++ sdk/typescript/tests-ts/snyk-findings.test.ts | 173 ++++++++++++++++ .../tests-ts/support/snyk-import-fixtures.ts | 58 ++++++ 9 files changed, 621 insertions(+), 6 deletions(-) create mode 100644 docs/scanner-imports.md create mode 100644 sdk/typescript/src/scanner-import.ts create mode 100644 sdk/typescript/src/snyk-findings.ts create mode 100644 sdk/typescript/tests-ts/snyk-findings.test.ts create mode 100644 sdk/typescript/tests-ts/support/snyk-import-fixtures.ts diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md new file mode 100644 index 000000000..09a44c07c --- /dev/null +++ b/docs/scanner-imports.md @@ -0,0 +1,39 @@ +# Import scanner findings + +`codex-security publish findings` can import a selected Snyk Open Source SARIF +2.1.0 report into a configured Cloud repository: + +```sh +codex-security publish findings selected.sarif --to cloud \ + --provider snyk --repository \ + --source-key --dry-run +``` + +Remove `--dry-run` to review and confirm the upload. The existing `--yes` option +allows noninteractive publication. Defaults and the Wiz/GitHub formats are +unchanged. This command imports saved evidence; it does not run Snyk, fetch links, +or start a Cloud assessment. + +Use a stable source key for one Snyk organization, project and branch. Do not +combine different projects that have the same manifest path. Identity uses the +Snyk advisory ID and the literal manifest URI set within that namespace; a moved +manifest is a new identity. Timestamped automation IDs and run indexes are retained +as provenance but never used as identity. All records with a duplicate identity +are excluded, with their positions shown in the preview. + +The mapper follows the public [Snyk Open Source SARIF formatter](https://github.com/snyk/cli/blob/main/src/lib/formatters/open-source-sarif-output.ts). +It keeps the original result, rule, dependency paths and report metadata. Severity +comes from Snyk's severity label, preserving critical versus high. Unrecognized +labels remain unknown. Suggested upgrades and synthetic line numbers remain in +the original evidence; they are not presented as verified fixes or source lines. +Absolute paths and traversal paths also stay in the original evidence without +being mapped to repository locations. + +Snyk Code, Container, IaC, license findings and external SARIF property files are +not supported by this profile. Gzip and UTF-8 BOM input are accepted. The existing +Cloud evidence limits still apply: oversized or invalid records are excluded, not +truncated. Review the preview's exclusions before proceeding. + +Tests use synthetic vendor-shaped fixtures, including repeated imports, changed +evidence, duplicate identities, 500 findings and malformed inputs. These tests do +not establish live Snyk tenant or deployed Cloud compatibility. diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index fb129585f..ae58ddc1a 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3238,15 +3238,16 @@ export async function main( }, }); publication.command("findings", { - description: "Preview and import selected Wiz or GitHub findings to Cloud.", - hint: "For GitHub, supply unfiltered JSON/JSONL saved by import github or import github-advisories. For Wiz, supply a package vulnerability JSON report, named SAST/repository-secret/IaC JSON collections with repository metadata, or normalized JSONL. Gzip is supported; CSV, scan events, workload secrets, and cloud configuration exports are not. --full-output requires --format json or --format jsonl.\nExample: codex-security publish findings selected-wiz.json --to cloud --repository https://github.com/example/project --provider wiz --source-key TENANT_ID/vulnerability-finding --dry-run --format json", + description: + "Preview and import selected Wiz, GitHub, or Snyk findings to Cloud.", + hint: "For Snyk, supply self-contained Open Source SARIF 2.1.0. For GitHub, supply unfiltered JSON/JSONL saved by import github or import github-advisories. For Wiz, supply a package vulnerability JSON report, named SAST/repository-secret/IaC JSON collections with repository metadata, or normalized JSONL. Gzip is supported; CSV, scan events, workload secrets, and cloud configuration exports are not. --full-output requires --format json or --format jsonl.\nExample: codex-security publish findings selected-wiz.json --to cloud --repository https://github.com/example/project --provider wiz --source-key TENANT_ID/vulnerability-finding --dry-run --format json", destructive: true, mcp: false, args: z.object({ file: z .string() .describe( - "Saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", + "Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", ), }), options: z.object({ @@ -3255,7 +3256,7 @@ export async function main( "Authorized Cloud repository ID or repository URL copied from Cloud.", ), provider: z - .enum(["wiz", "github"]) + .enum(["wiz", "github", "snyk"]) .describe("Provider that produced these findings."), sourceKey: optionValue("--source-key").describe( "Stable vendor tenant and finding namespace; not a project selection filter.", diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index aa6f0fe17..8f3e58f76 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -17,6 +17,7 @@ import { } from "./runtime.js"; import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; +import { readSnykFindings } from "./snyk-findings.js"; import type { ExternalFindings, ExternalPublicationInput, @@ -48,7 +49,7 @@ function cloudRepositoryUrlKey(value: string): string { /** CLI and plugin callers prepare the same immutable request before approval. */ export interface ExternalPublicationOptions { repository: string; - provider: "wiz" | "github"; + provider: "wiz" | "github" | "snyk"; sourceKey: string; } @@ -279,6 +280,12 @@ export async function prepareExternalPublication( options: ExternalPublicationOptions, dependencies: ExternalPublicationDependencies = {}, ): Promise { + if (options.provider === "snyk") + return prepareNormalizedExternalPublication( + await readSnykFindings(path), + options, + dependencies, + ); if (options.provider === "github") return prepareNormalizedExternalPublication( await readGitHubFindings(path), @@ -346,7 +353,9 @@ async function preparePublicationInput( throw new CodexSecurityError( options.provider === "wiz" ? "The source key must contain 1–512 UTF-8 bytes. Use a stable Wiz tenant/finding namespace, such as TENANT_ID/vulnerability-finding, across exports." - : "The source key must contain 1–512 UTF-8 bytes. Use a stable GitHub host/repository/finding namespace across imports.", + : options.provider === "snyk" + ? "The source key must contain 1–512 UTF-8 bytes. Use a stable Snyk organization/project/branch namespace across exports." + : "The source key must contain 1–512 UTF-8 bytes. Use a stable GitHub host/repository/finding namespace across imports.", ); } progress({ phase: "reading", completed: 0 }); diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 0e7bfb7e5..d8c236999 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -249,3 +249,5 @@ export { export type { NormalizedTarget, ScanMode, ScanTarget } from "./targets.js"; export type { AbsolutePath } from "./config-path.js"; export { BUNDLED_PLUGIN_VERSION, VERSION } from "./version.js"; + +export { normalizeSnykSarif, readSnykFindings } from "./snyk-findings.js"; diff --git a/sdk/typescript/src/scanner-import.ts b/sdk/typescript/src/scanner-import.ts new file mode 100644 index 000000000..f22ef4076 --- /dev/null +++ b/sdk/typescript/src/scanner-import.ts @@ -0,0 +1,107 @@ +import { hash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { gunzip } from "node:zlib"; +import { CodexSecurityError, errorMessage } from "./errors.js"; +import type { ExternalFinding, ExternalFindings } from "./external-findings.js"; + +export function record(value: unknown, label: string): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) + throw new Error(`${label} must be an object.`); + return value as Record; +} + +export function array(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) throw new Error(`${label} must be an array.`); + return value; +} + +export function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +export function requiredText(value: unknown, label: string): string { + const result = text(value); + if (!result) throw new Error(`${label} must be nonempty text.`); + return result; +} + +export function strings(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((v): v is string => text(v) !== null) + : []; +} + +/** Paths that cannot be attributed to a repository stay in original evidence. */ +export function relativePath(value: unknown): string | null { + const path = text(value)?.replaceAll("\\", "/"); + return path && + !path.startsWith("/") && + !path.includes("\0") && + !path.split("/")[0]!.includes(":") && + !path.split("/").includes("..") + ? path + : null; +} + +export function stableId(prefix: string, fields: unknown[]): string { + return `${prefix}:${hash("sha256", JSON.stringify(fields))}`; +} + +export async function readScannerJson(path: string): Promise { + let bytes = await readFile(path); + if (bytes[0] === 0x1f && bytes[1] === 0x8b) + bytes = await promisify(gunzip)(bytes); + try { + return JSON.parse(bytes.toString("utf8").replace(/^\uFEFF/u, "")); + } catch (error) { + throw new CodexSecurityError( + `Scanner JSON could not be parsed. ${errorMessage(error)}`, + { cause: error }, + ); + } +} + +/** Exclude every colliding record: choosing the first could overwrite another occurrence. */ +export function normalizeRecords( + records: T[], + normalize: (record: T) => ExternalFinding, +): ExternalFindings { + const result: ExternalFindings = { + read: records.length, + findings: [], + excluded: [], + }; + const mapped: { position: number; finding: ExternalFinding }[] = []; + const counts = new Map(); + for (const [index, value] of records.entries()) { + try { + const finding = normalize(value); + const id = finding.source_finding_id; + if (!id.trim() || id.includes("\0") || Buffer.byteLength(id) > 512) + throw new Error( + "Source finding ID must contain 1–512 UTF-8 bytes and no NUL characters.", + ); + mapped.push({ position: index + 1, finding }); + counts.set(id, (counts.get(id) ?? 0) + 1); + } catch (error) { + result.excluded.push({ + position: index + 1, + source_finding_id: null, + reason: errorMessage(error), + }); + } + } + for (const { position, finding } of mapped) { + if (counts.get(finding.source_finding_id)! > 1) + result.excluded.push({ + position, + source_finding_id: finding.source_finding_id, + reason: + "Ambiguous duplicate source identity; select one complete finding or separate source projects.", + }); + else result.findings.push(finding); + } + result.excluded.sort((a, b) => a.position - b.position); + return result; +} diff --git a/sdk/typescript/src/snyk-findings.ts b/sdk/typescript/src/snyk-findings.ts new file mode 100644 index 000000000..fee3ecf44 --- /dev/null +++ b/sdk/typescript/src/snyk-findings.ts @@ -0,0 +1,185 @@ +import { validateExternalEvidence } from "./external-import-contract.js"; +import type { ExternalFindings } from "./external-findings.js"; +import { + array, + normalizeRecords, + readScannerJson, + record, + relativePath, + requiredText, + stableId, + text, +} from "./scanner-import.js"; + +/** Snyk Open Source's formatter groups all dependency paths for an advisory in a project. */ +export function normalizeSnykSarif(input: unknown): ExternalFindings { + const document = record(input, "Snyk SARIF"); + if (document["version"] !== "2.1.0") + throw new Error("Expected Snyk Open Source SARIF 2.1.0."); + const entries = array(document["runs"], "SARIF runs").flatMap((value) => { + const run = record(value, "SARIF run"); + const driver = record( + record(run["tool"], "SARIF tool")["driver"], + "SARIF driver", + ); + if (driver["name"] !== "Snyk Open Source") + throw new Error( + "Only Snyk Open Source SARIF is supported; Code, Container, and IaC need separate mappings.", + ); + if (run["externalPropertyFileReferences"]) + throw new Error( + "External SARIF property files are unsupported; use a self-contained report.", + ); + const rules = array(driver["rules"], "SARIF rules").map((rule) => + record(rule, "SARIF rule"), + ); + const { results: _results, tool: _tool, ...runContext } = run; + const { rules: _rules, ...driverContext } = driver; + const { driver: _driver, ...toolContext } = record( + run["tool"], + "SARIF tool", + ); + runContext["tool"] = toolContext; + const { runs: _runs, ...documentContext } = document; + return array(run["results"], "SARIF results").map((result) => ({ + result, + rules, + runContext, + driverContext, + documentContext, + })); + }); + return normalizeRecords( + entries, + ({ result: value, rules, runContext, driverContext, documentContext }) => { + const result = record(value, "SARIF result"); + const id = requiredText(result["ruleId"], "Snyk advisory ruleId"); + const matches = rules.filter((rule) => rule["id"] === id); + if (matches.length !== 1) + throw new Error("Result must reference exactly one rule in its run."); + const rule = matches[0]!; + const index = rules.indexOf(rule); + if (result["ruleIndex"] !== undefined && result["ruleIndex"] !== index) + throw new Error("Result ruleIndex and ruleId disagree."); + if (result["rule"] !== undefined) { + const ref = record(result["rule"], "Rule reference"); + if ( + ref["toolComponent"] !== undefined || + (ref["id"] !== undefined && ref["id"] !== id) || + (ref["index"] !== undefined && ref["index"] !== index) || + (ref["guid"] !== undefined && ref["guid"] !== rule["guid"]) + ) + throw new Error( + "Result uses an inconsistent or external rule reference.", + ); + } + const help = requiredText( + record(rule["help"], "Rule help")["markdown"], + "Rule help markdown", + ); + const manager = /^\* Package Manager: (.+)$/mu.exec(help)?.[1] ?? null; + if (!manager || !/^\* Vulnerable module: .+$/mu.test(help)) + throw new Error( + "Expected the Snyk Open Source vulnerability profile; license findings are unsupported.", + ); + const title = requiredText( + record(rule["shortDescription"], "Rule shortDescription")["text"], + "Rule title", + ); + const severity = + /^(Critical|High|Medium|Low) severity - /u + .exec(title)?.[1] + ?.toLowerCase() ?? "unknown"; + const message = requiredText( + record(result["message"], "Result message")["text"], + "Result message text", + ); + const locations = array( + result["locations"] ?? [], + "Result locations", + ).map((v) => record(v, "Location")); + const manifests: string[] = []; + const packages: { + name: string; + ecosystem: string; + installed_version: string | null; + manifest_path: string | null; + fixed_versions: string[]; + }[] = []; + for (const location of locations) { + const physical = record( + location["physicalLocation"] ?? {}, + "Physical location", + ); + const artifact = record( + physical["artifactLocation"] ?? {}, + "Artifact location", + ); + const uri = text(artifact["uri"]); + if (uri) manifests.push(uri); + // Snyk's startLine may be a synthetic 1. Keep it in raw evidence, not as a verified source line. + for (const logical of array( + location["logicalLocations"] ?? [], + "Logical locations", + )) { + const coordinate = text( + record(logical, "Logical location")["fullyQualifiedName"], + ); + if (!coordinate) continue; + const split = coordinate.lastIndexOf("@"); + if (split <= 0) continue; + packages.push({ + name: coordinate.slice(0, split), + ecosystem: manager, + installed_version: coordinate.slice(split + 1) || null, + manifest_path: relativePath(uri), + fixed_versions: [], + }); + } + } + const manifestIds = [...new Set(manifests)].sort(); + const description = [message, help].join("\n\n"); + // Use the advisory group + literal manifest set within the caller's stable project/branch namespace. + // Automation IDs contain export timestamps and run indexes, so are provenance only. + return { + source_finding_id: stableId("snyk-sarif", [id, manifestIds]), + evidence: validateExternalEvidence({ + title, + description, + severity, + advisory_ids: [ + ...new Set([ + id, + ...(text( + record(rule["fullDescription"] ?? {}, "Full description")[ + "text" + ], + )?.match(/\bCVE-\d{4}-\d+\b/gu) ?? []), + ]), + ], + packages, + locations: [ + ...new Set( + manifestIds + .map(relativePath) + .filter((p): p is string => p !== null), + ), + ].map((path) => ({ path })), + source_data: structuredClone({ + document: documentContext, + run: runContext, + driver: driverContext, + rule, + result, + }), + }), + }; + }, + ); +} + +export async function readSnykFindings( + path: string, +): Promise { + return normalizeSnykSarif(await readScannerJson(path)); +} diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index f98239250..4d9623784 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2718,3 +2718,44 @@ test("normalized SDK publication rejects nonfinite raw evidence without JSON coe expect(input.findings[0]!.evidence.source_data.score).toBe(Infinity); expect(f.posts).toHaveLength(0); }); + +test("Snyk SARIF publish, replay and changed evidence preserve Cloud identity", async () => { + const { snykSarif } = await import("./support/snyk-import-fixtures.js"); + const input = snykSarif(); + const f = await fixture(input); + const settings = { + ...options, + provider: "snyk" as const, + sourceKey: "organization/project/main", + }; + const first = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(first.counts.created).toBe(1); + expect(first.verified).toBe(1); + const original = structuredClone([...f.reports.values()][0]!); + const second = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(second.counts.unchanged).toBe(1); + input.runs[0]!.results[0]!.message.text = "Updated source evidence"; + await writeFile(f.file, JSON.stringify(input)); + const third = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(third.counts.updated).toBe(1); + const updated = [...f.reports.values()][0]!; + expect(updated.id).toBe(original.id); + expect(updated.canonical_finding_id).toBe(original.canonical_finding_id); + expect(updated.version).toBe(2); +}); + +test("Snyk CLI dry run selects its adapter without writing an import", async () => { + const { snykSarif } = await import("./support/snyk-import-fixtures.js"); + const f = await fixture(snykSarif()); + const command = [...f.command, "--dry-run"]; + command[command.indexOf("--provider") + 1] = "snyk"; + const cli = createCliTest(main); + expect(await cli.runCli(command, f.cliDeps)).toBe(0); + expect(f.posts).toHaveLength(0); +}); diff --git a/sdk/typescript/tests-ts/snyk-findings.test.ts b/sdk/typescript/tests-ts/snyk-findings.test.ts new file mode 100644 index 000000000..7faa9f3bf --- /dev/null +++ b/sdk/typescript/tests-ts/snyk-findings.test.ts @@ -0,0 +1,173 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; +import { normalizeSnykSarif, readSnykFindings } from "../src/snyk-findings.js"; +import { snykSarif } from "./support/snyk-import-fixtures.js"; + +test("Snyk retains the full selected result, rule, producer and unknown context", () => { + const input = snykSarif(); + const before = structuredClone(input); + const output = normalizeSnykSarif(input); + expect(input).toEqual(before); + expect(output.read).toBe(1); + expect(output.excluded).toEqual([]); + const evidence = output.findings[0]!.evidence; + expect(evidence).toMatchObject({ + severity: "critical", + advisory_ids: ["SNYK-EXAMPLE-1", "CVE-2099-0001"], + packages: [ + { + name: "@example/package", + installed_version: "1.0.0", + ecosystem: "npm", + fixed_versions: [], + manifest_path: "package.json", + }, + ], + locations: [{ path: "package.json", line: null }], + source_updated_at: null, + }); + expect(evidence.source_data).toMatchObject({ + rule: before.runs[0]!.tool.driver.rules[0], + result: before.runs[0]!.results[0], + driver: { name: "Snyk Open Source", semanticVersion: "1.0.0" }, + run: { automationDetails: before.runs[0]!.automationDetails }, + }); + expect(evidence.description).toContain("Detailed paths"); +}); + +test("severity and export time changes retain identity; manifests remain distinct", () => { + const input = snykSarif(); + const original = normalizeSnykSarif(input).findings[0]!; + input.runs[0]!.automationDetails.id = "Snyk/Open Source/project/4/new-export"; + input.runs[0]!.tool.driver.rules[0]!.shortDescription.text = + "Low severity - synthetic vulnerability"; + const changed = normalizeSnykSarif(input).findings[0]!; + expect(changed.source_finding_id).toBe(original.source_finding_id); + expect(changed.evidence.severity).toBe("low"); + expect(changed.evidence.source_data).not.toEqual( + original.evidence.source_data, + ); + input.runs[0]!.results[0]!.locations[0]!.physicalLocation.artifactLocation.uri = + "other/package.json"; + expect(normalizeSnykSarif(input).findings[0]!.source_finding_id).not.toBe( + original.source_finding_id, + ); +}); + +test("unknown severity remains unknown, without inferring it from SARIF level or CVSS", () => { + const input = snykSarif(); + input.runs[0]!.tool.driver.rules[0]!.shortDescription.text = + "Synthetic vulnerability"; + expect(normalizeSnykSarif(input).findings[0]!.evidence.severity).toBe( + "unknown", + ); +}); + +test.each(["Snyk Code", "Snyk Container", "Snyk IaC", "Other"])( + "rejects unsupported producer %s", + (producer) => { + const input = snykSarif(); + input.runs[0]!.tool.driver.name = producer; + expect(() => normalizeSnykSarif(input)).toThrow("Only Snyk Open Source"); + }, +); + +test("licenses, malformed rules, conflicting references and external property files are not imported", () => { + const variants: Record[] = []; + for (const mutate of [ + (r: any) => + (r.tool.driver.rules[0].help.markdown = + "* Package Manager: npm\n* Module: example"), + (r: any) => (r.results[0].ruleIndex = 1), + (r: any) => (r.results[0].rule = { id: "different" }), + (r: any) => (r.results[0].rule = { index: true }), + (r: any) => (r.results[0].rule = { toolComponent: { index: 0 } }), + (r: any) => + r.tool.driver.rules.push(structuredClone(r.tool.driver.rules[0])), + ]) { + const input = snykSarif(); + mutate(input.runs[0]); + variants.push(input); + } + for (const input of variants) { + const result = normalizeSnykSarif(input); + expect(result.findings).toHaveLength(0); + expect(result.excluded).toHaveLength(1); + } + expect(() => + normalizeSnykSarif({ ...snykSarif(), version: "2.0.0" }), + ).toThrow("2.1.0"); + const input = snykSarif() as any; + input.runs[0].externalPropertyFileReferences = { + results: [{ location: { uri: "https://example.invalid/data" } }], + }; + expect(() => normalizeSnykSarif(input)).toThrow("self-contained"); +}); + +test("every ambiguous duplicate is excluded instead of keeping an arbitrary project", () => { + const input = snykSarif(); + input.runs.push(structuredClone(input.runs[0]!)); + const output = normalizeSnykSarif(input); + expect(output.findings).toEqual([]); + expect(output.excluded.map((e) => e.position)).toEqual([1, 2]); +}); + +test.each([ + "/tmp/package.json", + "C:\\work\\package.json", + "../package.json", + "https://example.invalid/package.json", +])( + "retains %s as evidence without treating it as a repository path", + (path) => { + const input = snykSarif(); + input.runs[0]!.results[0]!.locations[0]!.physicalLocation.artifactLocation.uri = + path; + const evidence = normalizeSnykSarif(input).findings[0]!.evidence; + expect(evidence.locations).toEqual([]); + expect(evidence.packages![0]!.manifest_path).toBeNull(); + expect(JSON.stringify(evidence.source_data)).toContain( + JSON.stringify(path), + ); + }, +); + +test("500 independent advisory groups survive normalization with stable identities after reordering", () => { + const input = snykSarif(); + const run = input.runs[0]!; + run.tool.driver.rules = Array.from({ length: 500 }, (_, i) => ({ + ...structuredClone(run.tool.driver.rules[0]!), + id: `SNYK-EXAMPLE-${i}`, + })); + run.results = run.tool.driver.rules.map((rule) => ({ + ...structuredClone(run.results[0]!), + ruleId: rule.id, + })); + const before = normalizeSnykSarif(input); + run.tool.driver.rules.reverse(); + run.results.reverse(); + const after = normalizeSnykSarif(input); + expect(before.findings).toHaveLength(500); + expect(after.excluded).toEqual([]); + expect(after.findings.map((f) => f.source_finding_id).sort()).toEqual( + before.findings.map((f) => f.source_finding_id).sort(), + ); +}); + +test("BOM and gzip files use the same parser and malformed files fail", async () => { + const dir = await mkdtemp(join(tmpdir(), "snyk-import-")); + try { + const path = join(dir, "report.sarif"); + await writeFile(path, gzipSync("\uFEFF" + JSON.stringify(snykSarif()))); + expect(await readSnykFindings(path)).toEqual( + normalizeSnykSarif(snykSarif()), + ); + await writeFile(path, "{"); + await expect(readSnykFindings(path)).rejects.toThrow("could not be parsed"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/sdk/typescript/tests-ts/support/snyk-import-fixtures.ts b/sdk/typescript/tests-ts/support/snyk-import-fixtures.ts new file mode 100644 index 000000000..2fa8e0901 --- /dev/null +++ b/sdk/typescript/tests-ts/support/snyk-import-fixtures.ts @@ -0,0 +1,58 @@ +// Synthetic field shapes from Snyk CLI's Open Source SARIF formatter. +export function snykSarif() { + return { + version: "2.1.0", + runs: [ + { + automationDetails: { id: "Snyk/Open Source/example/0/export-time" }, + tool: { + driver: { + name: "Snyk Open Source", + semanticVersion: "1.0.0", + rules: [ + { + id: "SNYK-EXAMPLE-1", + shortDescription: { + text: "Critical severity - synthetic vulnerability in @example/package", + }, + fullDescription: { + text: "(CVE-2099-0001) @example/package@1.0.0", + }, + help: { + markdown: + "* Package Manager: npm\n* Vulnerable module: @example/package\n* Introduced through: example\n#### Detailed paths\n* _Introduced through_: example › @example/package@1.0.0\nSynthetic evidence", + }, + properties: { + tags: ["security", "npm"], + "security-severity": "9.8", + unknown_numeric: 40000, + }, + }, + ], + }, + }, + results: [ + { + ruleId: "SNYK-EXAMPLE-1", + level: "error", + message: { text: "Synthetic dependency vulnerability" }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "package.json" }, + region: { startLine: 1 }, + }, + logicalLocations: [ + { fullyQualifiedName: "@example/package@1.0.0" }, + ], + }, + ], + fixes: [ + { description: { text: "Upgrade the introducing dependency" } }, + ], + }, + ], + }, + ], + }; +} From 698d4e4c33e135a3144ce241e79e3dce7f375591 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 21:49:56 +0000 Subject: [PATCH 2/4] fix: preserve scanner identities before evidence validation --- sdk/typescript/src/scanner-import.ts | 42 ++++++++++++++----- sdk/typescript/src/snyk-findings.ts | 20 ++++++++- sdk/typescript/tests-ts/snyk-findings.test.ts | 40 ++++++++++++++++++ 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/sdk/typescript/src/scanner-import.ts b/sdk/typescript/src/scanner-import.ts index f22ef4076..52051a811 100644 --- a/sdk/typescript/src/scanner-import.ts +++ b/sdk/typescript/src/scanner-import.ts @@ -38,7 +38,7 @@ export function relativePath(value: unknown): string | null { return path && !path.startsWith("/") && !path.includes("\0") && - !path.split("/")[0]!.includes(":") && + !path.includes(":") && !path.split("/").includes("..") ? path : null; @@ -52,8 +52,17 @@ export async function readScannerJson(path: string): Promise { let bytes = await readFile(path); if (bytes[0] === 0x1f && bytes[1] === 0x8b) bytes = await promisify(gunzip)(bytes); + let decoded: string; try { - return JSON.parse(bytes.toString("utf8").replace(/^\uFEFF/u, "")); + decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch (error) { + throw new CodexSecurityError( + `Scanner report is not valid UTF-8. ${errorMessage(error)}`, + { cause: error }, + ); + } + try { + return JSON.parse(decoded.replace(/^\uFEFF/u, "")); } catch (error) { throw new CodexSecurityError( `Scanner JSON could not be parsed. ${errorMessage(error)}`, @@ -66,23 +75,23 @@ export async function readScannerJson(path: string): Promise { export function normalizeRecords( records: T[], normalize: (record: T) => ExternalFinding, + identify: (record: T) => string, ): ExternalFindings { const result: ExternalFindings = { read: records.length, findings: [], excluded: [], }; - const mapped: { position: number; finding: ExternalFinding }[] = []; + const identified: { position: number; value: T; id: string }[] = []; const counts = new Map(); for (const [index, value] of records.entries()) { try { - const finding = normalize(value); - const id = finding.source_finding_id; + const id = identify(value); if (!id.trim() || id.includes("\0") || Buffer.byteLength(id) > 512) throw new Error( "Source finding ID must contain 1–512 UTF-8 bytes and no NUL characters.", ); - mapped.push({ position: index + 1, finding }); + identified.push({ position: index + 1, value, id }); counts.set(id, (counts.get(id) ?? 0) + 1); } catch (error) { result.excluded.push({ @@ -92,15 +101,28 @@ export function normalizeRecords( }); } } - for (const { position, finding } of mapped) { - if (counts.get(finding.source_finding_id)! > 1) + for (const { position, value, id } of identified) { + if (counts.get(id)! > 1) { result.excluded.push({ position, - source_finding_id: finding.source_finding_id, + source_finding_id: id, reason: "Ambiguous duplicate source identity; select one complete finding or separate source projects.", }); - else result.findings.push(finding); + continue; + } + try { + const finding = normalize(value); + if (finding.source_finding_id !== id) + throw new Error("Finding identity changed during normalization."); + result.findings.push(finding); + } catch (error) { + result.excluded.push({ + position, + source_finding_id: id, + reason: errorMessage(error), + }); + } } result.excluded.sort((a, b) => a.position - b.position); return result; diff --git a/sdk/typescript/src/snyk-findings.ts b/sdk/typescript/src/snyk-findings.ts index fee3ecf44..56f2a1970 100644 --- a/sdk/typescript/src/snyk-findings.ts +++ b/sdk/typescript/src/snyk-findings.ts @@ -11,6 +11,23 @@ import { text, } from "./scanner-import.js"; +function snykIdentity(value: unknown): string { + const result = record(value, "SARIF result"); + const id = requiredText(result["ruleId"], "Snyk advisory ruleId"); + const paths = array(result["locations"] ?? [], "Result locations") + .map((value) => { + const physical = record( + record(value, "Location")["physicalLocation"] ?? {}, + "Physical location", + ); + return text( + record(physical["artifactLocation"] ?? {}, "Artifact location")["uri"], + ); + }) + .filter((path): path is string => path !== null); + return stableId("snyk-sarif", [id, [...new Set(paths)].sort()]); +} + /** Snyk Open Source's formatter groups all dependency paths for an advisory in a project. */ export function normalizeSnykSarif(input: unknown): ExternalFindings { const document = record(input, "Snyk SARIF"); @@ -142,7 +159,7 @@ export function normalizeSnykSarif(input: unknown): ExternalFindings { // Use the advisory group + literal manifest set within the caller's stable project/branch namespace. // Automation IDs contain export timestamps and run indexes, so are provenance only. return { - source_finding_id: stableId("snyk-sarif", [id, manifestIds]), + source_finding_id: snykIdentity(result), evidence: validateExternalEvidence({ title, description, @@ -175,6 +192,7 @@ export function normalizeSnykSarif(input: unknown): ExternalFindings { }), }; }, + ({ result }) => snykIdentity(result), ); } diff --git a/sdk/typescript/tests-ts/snyk-findings.test.ts b/sdk/typescript/tests-ts/snyk-findings.test.ts index 7faa9f3bf..c0cd629f3 100644 --- a/sdk/typescript/tests-ts/snyk-findings.test.ts +++ b/sdk/typescript/tests-ts/snyk-findings.test.ts @@ -171,3 +171,43 @@ test("BOM and gzip files use the same parser and malformed files fail", async () await rm(dir, { recursive: true, force: true }); } }); + +test.each([0xfe, 0xff])( + "malformed UTF-8 byte %p cannot alter a finding identity", + async (byte) => { + const dir = await mkdtemp(join(tmpdir(), "snyk-invalid-utf8-")); + try { + const path = join(dir, "report.sarif"); + const report = JSON.stringify(snykSarif()); + const marker = report.indexOf("package.json"); + const bytes = Buffer.concat([ + Buffer.from(report.slice(0, marker)), + Buffer.from([byte]), + Buffer.from(report.slice(marker)), + ]); + for (const encoded of [bytes, gzipSync(bytes)]) { + await writeFile(path, encoded); + await expect(readSnykFindings(path)).rejects.toThrow("UTF-8"); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, +); +test("an invalid duplicate cannot make its sibling eligible for publication", () => { + for (const mutate of [ + (run: any) => (run.tool.driver.rules[0].help.markdown += "x".repeat(65536)), + (run: any) => delete run.tool.driver.rules[0].shortDescription, + (run: any) => (run.results[0].ruleIndex = 99), + ]) { + const input = snykSarif(); + input.runs.push(structuredClone(input.runs[0]!)); + mutate(input.runs[1]); + const output = normalizeSnykSarif(input); + expect(output.findings).toEqual([]); + expect(output.excluded).toHaveLength(2); + expect( + output.excluded.every((item) => item.reason.includes("duplicate")), + ).toBe(true); + } +}); From 2fa45619b2df979757213cdda649bc9359e14b7d Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 22:23:31 +0000 Subject: [PATCH 3/4] fix: include Snyk adapter modules in package validation --- sdk/typescript/scripts/package-dist-files.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 6edddbdf0..1d605e7a2 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -17,6 +17,8 @@ const distModules = [ "external-findings-publish", "external-import-contract", "external-import-models", + "scanner-import", + "snyk-findings", "wiz-findings", "codex-prompt", "codex-sdk-environment", From a54b1e85d2a3fccfacea31ed8dc0ad8ac948566e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 16:03:18 +0900 Subject: [PATCH 4/4] fix: preserve Snyk SARIF identity and repository binding --- docs/scanner-imports.md | 8 +- .../src/external-findings-publish.ts | 26 ++++--- sdk/typescript/src/snyk-findings.ts | 34 ++++++++- .../external-findings-publish.test.ts | 76 +++++++++++++++++++ sdk/typescript/tests-ts/snyk-findings.test.ts | 23 ++++++ 5 files changed, 154 insertions(+), 13 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 09a44c07c..2c1b55051 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -29,8 +29,14 @@ the original evidence; they are not presented as verified fixes or source lines. Absolute paths and traversal paths also stay in the original evidence without being mapped to repository locations. +Reported version-control repository URLs must match the selected Cloud repository +before a new upload is prepared. Existing immutable requests continue to use their +saved repository IDs when resuming, including after a repository rename. + Snyk Code, Container, IaC, license findings and external SARIF property files are -not supported by this profile. Gzip and UTF-8 BOM input are accepted. The existing +not supported by this profile. Indexed or base-relative artifact references are +excluded because this formatter profile uses direct manifest URIs. Gzip and UTF-8 +BOM input are accepted. The existing Cloud evidence limits still apply: oversized or invalid records are excluded, not truncated. Review the preview's exclusions before proceeding. diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index 38c962590..ebb17bf9a 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -25,7 +25,7 @@ import { } from "./runtime.js"; import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; -import { readSnykFindings } from "./snyk-findings.js"; +import { readSnykFindings, snykRepositoryUrls } from "./snyk-findings.js"; import type { ExternalFindings, ExternalPublicationInput, @@ -656,15 +656,21 @@ async function preparePublicationInput( let requests = saved?.requests; if (!requests) { for (const finding of parsed.findings) { - const sourceRepository = finding.evidence.details?.repository; - if ( - sourceRepository && - cloudRepositoryUrlKey(sourceRepository.url) !== - cloudRepositoryUrlKey(destination.url) - ) - throw new CodexSecurityError( - `Finding ${JSON.stringify(finding.source_finding_id)} belongs to ${JSON.stringify(sourceRepository.url)}, which does not match the selected Cloud repository ${JSON.stringify(destination.url)}. Verify the source repository mapping before publishing.`, - ); + const sourceRepositories = [ + finding.evidence.details?.repository?.url, + ...(options.provider === "snyk" + ? snykRepositoryUrls(finding.evidence.source_data) + : []), + ]; + for (const sourceRepository of sourceRepositories) + if ( + sourceRepository && + cloudRepositoryUrlKey(sourceRepository) !== + cloudRepositoryUrlKey(destination.url) + ) + throw new CodexSecurityError( + `Finding ${JSON.stringify(finding.source_finding_id)} belongs to ${JSON.stringify(sourceRepository)}, which does not match the selected Cloud repository ${JSON.stringify(destination.url)}. Verify the source repository mapping before publishing.`, + ); } let preparedCount = 0; progress({ diff --git a/sdk/typescript/src/snyk-findings.ts b/sdk/typescript/src/snyk-findings.ts index 56f2a1970..79a48cb7f 100644 --- a/sdk/typescript/src/snyk-findings.ts +++ b/sdk/typescript/src/snyk-findings.ts @@ -20,14 +20,44 @@ function snykIdentity(value: unknown): string { record(value, "Location")["physicalLocation"] ?? {}, "Physical location", ); - return text( - record(physical["artifactLocation"] ?? {}, "Artifact location")["uri"], + const artifact = record( + physical["artifactLocation"] ?? {}, + "Artifact location", ); + if ( + artifact["index"] !== undefined || + artifact["uriBaseId"] !== undefined + ) + throw new Error( + "Snyk SARIF artifact references are unsupported; use direct manifest URIs from the Snyk Open Source formatter.", + ); + return text(artifact["uri"]); }) .filter((path): path is string => path !== null); return stableId("snyk-sarif", [id, [...new Set(paths)].sort()]); } +/** @internal Repository claims retained in the adapter's original SARIF context. */ +export function snykRepositoryUrls(sourceData: unknown): string[] { + if ( + !sourceData || + typeof sourceData !== "object" || + Array.isArray(sourceData) + ) + return []; + const run = (sourceData as Record)["run"]; + if (run == null) return []; + return array( + record(run, "Snyk run context")["versionControlProvenance"] ?? [], + "Snyk version control provenance", + ).map((entry) => + requiredText( + record(entry, "Snyk repository provenance")["repositoryUri"], + "Snyk repository URI", + ), + ); +} + /** Snyk Open Source's formatter groups all dependency paths for an advisory in a project. */ export function normalizeSnykSarif(input: unknown): ExternalFindings { const document = record(input, "Snyk SARIF"); diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index 4d9623784..105d620b1 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2759,3 +2759,79 @@ test("Snyk CLI dry run selects its adapter without writing an import", async () expect(await cli.runCli(command, f.cliDeps)).toBe(0); expect(f.posts).toHaveLength(0); }); + +test.each([ + { uris: ["https://github.com/other/project"] }, + { + uris: [ + "https://github.com/example/project", + "https://github.com/other/project", + ], + }, + { uris: ["https://github.com:443/example/project"] }, +])( + "Snyk source repository provenance binds both publication entrypoints: %p", + async ({ uris }) => { + const { snykSarif } = await import("./support/snyk-import-fixtures.js"); + const { normalizeSnykSarif } = await import("../src/snyk-findings.js"); + const input: any = snykSarif(); + input.runs[0].versionControlProvenance = uris.map((repositoryUri) => ({ + repositoryUri, + })); + const f = await fixture(input); + const selected = { ...options, provider: "snyk" as const }; + await expect( + prepareExternalPublication(f.file, selected, f.deps), + ).rejects.toThrow("does not match the selected Cloud repository"); + await expect( + prepareNormalizedExternalPublication( + normalizeSnykSarif(input), + selected, + f.deps, + ), + ).rejects.toThrow("does not match the selected Cloud repository"); + expect(f.posts).toHaveLength(0); + expect(f.reports.size).toBe(0); + }, +); + +test("Snyk source repository binding is rechecked for a fresh import after completion", async () => { + const { snykSarif } = await import("./support/snyk-import-fixtures.js"); + const input: any = snykSarif(); + input.runs[0].versionControlProvenance = [ + { + repositoryUri: "https://GITHUB.COM/EXAMPLE/PROJECT.git", + revisionId: "synthetic-revision", + }, + ]; + const original = structuredClone(input); + const f = await fixture(input); + const selected = { ...options, provider: "snyk" as const }; + const first = await ( + await prepareExternalPublication(f.file, selected, f.deps) + ).publish(); + expect(first.verified).toBe(1); + expect([...f.reports.values()][0]!.evidence.source_data).toMatchObject({ + run: { + versionControlProvenance: original.runs[0].versionControlProvenance, + }, + }); + const changedDestination = { + ...f.deps, + fetch: async (url: string, init: RequestInit) => { + const response = await f.deps.fetch(url, init); + if (new URL(url).pathname.endsWith("/repositories")) { + const body = await response.json(); + body.data[0].url = "https://github.com/other/project"; + return Response.json(body); + } + return response; + }, + }; + const posted = f.posts.length; + await expect( + prepareExternalPublication(f.file, selected, changedDestination), + ).rejects.toThrow("does not match the selected Cloud repository"); + expect(f.posts).toHaveLength(posted); + expect(input).toEqual(original); +}); diff --git a/sdk/typescript/tests-ts/snyk-findings.test.ts b/sdk/typescript/tests-ts/snyk-findings.test.ts index c0cd629f3..9d9380b51 100644 --- a/sdk/typescript/tests-ts/snyk-findings.test.ts +++ b/sdk/typescript/tests-ts/snyk-findings.test.ts @@ -115,6 +115,29 @@ test("every ambiguous duplicate is excluded instead of keeping an arbitrary proj expect(output.excluded.map((e) => e.position)).toEqual([1, 2]); }); +test.each([ + { index: 0 }, + { uri: "package.json", index: 0 }, + { uri: "package.json", uriBaseId: "ROOT" }, +])( + "unresolved SARIF artifact references cannot identify a Snyk manifest: %p", + (artifact) => { + for (const manifest of ["one/package.json", "two/package.json"]) { + const input: any = snykSarif(); + input.runs[0].artifacts = [{ location: { uri: manifest } }]; + input.runs[0].originalUriBaseIds = { ROOT: { uri: "file:///outside/" } }; + input.runs[0].results[0].locations[0].physicalLocation.artifactLocation = + artifact; + const original = structuredClone(input); + const output = normalizeSnykSarif(input); + expect(output.findings).toEqual([]); + expect(output.excluded).toHaveLength(1); + expect(output.excluded[0]!.reason).toContain("artifact references"); + expect(input).toEqual(original); + } + }, +); + test.each([ "/tmp/package.json", "C:\\work\\package.json",