From 4fe175fa153f7d3ea4011e8b9b44d0d0aad07ea8 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 21:52:07 +0000 Subject: [PATCH 1/8] feat: import Endor JSON and SARIF findings --- docs/scanner-imports.md | 23 ++ sdk/typescript/src/cli.ts | 8 +- sdk/typescript/src/endor-findings.ts | 233 ++++++++++++++++++ .../src/external-findings-publish.ts | 13 +- sdk/typescript/src/index.ts | 6 + .../tests-ts/endor-findings.test.ts | 152 ++++++++++++ .../external-findings-publish.test.ts | 46 ++++ .../tests-ts/support/endor-import-fixtures.ts | 77 ++++++ 8 files changed, 551 insertions(+), 7 deletions(-) create mode 100644 sdk/typescript/src/endor-findings.ts create mode 100644 sdk/typescript/tests-ts/endor-findings.test.ts create mode 100644 sdk/typescript/tests-ts/support/endor-import-fixtures.ts diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 09a44c07c..8db56bc69 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -37,3 +37,26 @@ 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. + +## Endor Labs + +Use the same command with `--provider endor`. Input may be an Endor Finding object, +an array, an unmasked `ListFindingsResponse` with `list.objects`, or endorctl SARIF +2.1.0. Choose a stable namespace/project/context source key and the matching Cloud +repository. JSON/SARIF detection is automatic; no additional flags or defaults change. + +Identity uses the native finding UUID, including SARIF's `properties.finding-uuid`. +The mapper follows the public [Finding model](https://github.com/endorlabs/endorlabs-sdk/blob/8a41756890c2dee3bb089fee881cae4f2adbde03/src/endorlabs/generated/models/finding_service.py) +and [endorctl SARIF sample](https://github.com/endorlabs/endor-azure-pipelines-task/blob/2333a2f4548c2a1012a7e5219fa836627a466e0f/task/scan_results.sarif). +Masked records lacking identity or project fields are excluded. + +JSON preserves the finding's severity and update timestamp. SARIF does not reliably +carry Endor's finding severity: diagnostic levels and CVSS remain original evidence, +and displayed severity stays unknown. Recommended versions, reachability, dismissals, +snoozes and other source assertions are retained without becoming Cloud assessments. +Synthetic SARIF line numbers are not promoted to verified code locations. + +Imports replace the complete source snapshot for an existing UUID. Preview before +switching between JSON and SARIF, since the two exports contain different evidence. +Synthetic QA covers both formats and 500-record exports; live tenant verification +is separate. diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index ae58ddc1a..b19f2c621 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3239,15 +3239,15 @@ export async function main( }); publication.command("findings", { 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", + "Preview and import selected Wiz, GitHub, Snyk, or Endor findings to Cloud.", + hint: "For Endor, supply native Finding JSON or endorctl SARIF 2.1.0. 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( - "Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", + "Endor JSON/SARIF, Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", ), }), options: z.object({ @@ -3256,7 +3256,7 @@ export async function main( "Authorized Cloud repository ID or repository URL copied from Cloud.", ), provider: z - .enum(["wiz", "github", "snyk"]) + .enum(["wiz", "github", "snyk", "endor"]) .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/endor-findings.ts b/sdk/typescript/src/endor-findings.ts new file mode 100644 index 000000000..d97abdebf --- /dev/null +++ b/sdk/typescript/src/endor-findings.ts @@ -0,0 +1,233 @@ +import { validateExternalEvidence } from "./external-import-contract.js"; +import type { ExternalFindings } from "./external-findings.js"; +import { + array, + normalizeRecords, + readScannerJson, + record, + relativePath, + requiredText, + strings, + text, +} from "./scanner-import.js"; + +/** Saved Endor Finding objects, an array, or an unmasked ListFindingsResponse. */ +export function normalizeEndorFindings(input: unknown): ExternalFindings { + const root = Array.isArray(input) ? null : record(input, "Endor report"); + if (root?.["version"] === "2.1.0" && root["runs"] !== undefined) + return normalizeEndorSarif(root); + const values = + root?.["list"] !== undefined + ? array( + record(root["list"], "Endor list")["objects"] ?? [], + "Endor list.objects", + ) + : Array.isArray(input) + ? input + : [input]; + return normalizeRecords( + values, + (value) => { + const finding = record(value, "Endor finding"); + const id = requiredText(finding["uuid"], "Endor finding uuid"); + const meta = record(finding["meta"], "Endor meta"); + const spec = record(finding["spec"], "Endor spec"); + requiredText(spec["project_uuid"], "Endor project_uuid"); + const name = text(spec["target_dependency_name"]); + const paths = [ + ...new Set( + strings(spec["dependency_file_paths"]) + .map(relativePath) + .filter((p): p is string => p !== null), + ), + ]; + const metadata = record( + spec["finding_metadata"] ?? {}, + "Endor finding_metadata", + ); + const vulnerability = record( + metadata["vulnerability"] ?? {}, + "Endor vulnerability", + ); + const advisory = text( + record(vulnerability["meta"] ?? {}, "Vulnerability meta")["name"], + ); + const aliases = strings( + record(vulnerability["spec"] ?? {}, "Vulnerability spec")["aliases"], + ); + const level = text(spec["level"]) + ?.replace(/^FINDING_LEVEL_/u, "") + .toLowerCase(); + const severity = + level && ["critical", "high", "medium", "low"].includes(level) + ? level + : "unknown"; + // A recommended version can refer to an introducing dependency; retain it in source_data. + const packages = name + ? (paths.length ? paths : [null]).map((path) => ({ + name, + ecosystem: + text(spec["ecosystem"]) + ?.replace(/^ECOSYSTEM_/u, "") + .toLowerCase() ?? null, + installed_version: text(spec["target_dependency_version"]), + manifest_path: path, + fixed_versions: [], + })) + : []; + const context = record(finding["context"] ?? {}, "Endor context"); + const updated = text(meta["update_time"]); + return { + source_finding_id: id, + evidence: validateExternalEvidence({ + title: requiredText(meta["name"], "Endor finding title"), + description: + [ + text(spec["summary"]), + text(spec["explanation"]), + text(spec["remediation"]), + ] + .filter(Boolean) + .join("\n\n") || text(meta["description"]), + severity, + branch: + context["type"] === "CONTEXT_TYPE_REF" ? text(context["id"]) : null, + source_updated_at: + updated === null ? null : Date.parse(updated) / 1000, + advisory_ids: [ + ...new Set([...(advisory ? [advisory] : []), ...aliases]), + ], + packages, + locations: paths.map((path) => ({ path })), + source_data: structuredClone(finding), + }), + }; + }, + (value) => + requiredText( + record(value, "Endor finding")["uuid"], + "Endor finding uuid", + ), + ); +} + +/** Endor's documented SARIF producer uses properties.finding-uuid, not its rule name. */ +export function normalizeEndorSarif(input: unknown): ExternalFindings { + const document = record(input, "Endor SARIF"); + if (document["version"] !== "2.1.0") + throw new Error("Expected Endor SARIF 2.1.0."); + const entries = array(document["runs"], "SARIF runs").flatMap((value) => { + const run = record(value, "SARIF run"); + const tool = record(run["tool"], "SARIF tool"); + const driver = record(tool["driver"], "SARIF driver"); + if (driver["name"] !== "endorctl") + throw new Error("Expected the endorctl SARIF producer."); + if (run["externalPropertyFileReferences"]) + throw new Error("Use a self-contained Endor SARIF report."); + const rules = array(driver["rules"] ?? [], "Endor rules").map((r) => + record(r, "Endor rule"), + ); + const { runs: _runs, ...documentContext } = document; + const { results: _results, tool: _tool, ...runContext } = run; + const { rules: _rules, ...driverContext } = driver; + const { driver: _driver, ...toolContext } = tool; + return array(run["results"] ?? [], "Endor results").map((result) => ({ + result, + rules, + documentContext, + runContext, + driverContext, + toolContext, + })); + }); + return normalizeRecords( + entries, + ({ result: value, rules, ...context }) => { + const result = record(value, "Endor result"); + const properties = record( + result["properties"], + "Endor result properties", + ); + const id = requiredText(properties["finding-uuid"], "Endor finding-uuid"); + requiredText(properties["project-uuid"], "Endor project-uuid"); + const ruleId = requiredText(result["ruleId"], "Endor ruleId"); + const matches = rules.filter((rule) => rule["id"] === ruleId); + if (matches.length !== 1) + throw new Error( + "Endor result must reference exactly one rule in its run.", + ); + const rule = matches[0]!; + if ( + result["ruleIndex"] !== undefined && + result["ruleIndex"] !== rules.indexOf(rule) + ) + throw new Error("Endor ruleIndex and ruleId disagree."); + if (result["rule"] !== undefined) { + const reference = record(result["rule"], "Endor rule reference"); + if ( + reference["toolComponent"] !== undefined || + (reference["id"] !== undefined && reference["id"] !== ruleId) || + (reference["index"] !== undefined && + reference["index"] !== rules.indexOf(rule)) || + (reference["guid"] !== undefined && + reference["guid"] !== rule["guid"]) + ) + throw new Error("Inconsistent or external Endor rule reference."); + } + const message = record(result["message"], "Endor message"); + const short = record( + rule["shortDescription"] ?? {}, + "Endor shortDescription", + ); + const full = record( + rule["fullDescription"] ?? {}, + "Endor fullDescription", + ); + const help = record(rule["help"] ?? {}, "Endor help"); + const paths = array(result["locations"] ?? [], "Endor locations") + .map((v) => { + const physical = record( + record(v, "Endor location")["physicalLocation"] ?? {}, + "Physical location", + ); + return relativePath( + record(physical["artifactLocation"] ?? {}, "Artifact location")[ + "uri" + ], + ); + }) + .filter((p): p is string => p !== null); + return { + source_finding_id: id, + evidence: validateExternalEvidence({ + title: text(short["text"]) ?? text(rule["name"]) ?? ruleId, + description: [ + text(message["markdown"]) ?? text(message["text"]), + text(full["text"]), + text(help["markdown"]) ?? text(help["text"]), + ] + .filter(Boolean) + .join("\n\n"), + // Diagnostic level and CVSS are not Endor's finding severity. Keep both as source evidence. + severity: "unknown", + locations: [...new Set(paths)].map((path) => ({ path })), + source_data: structuredClone({ ...context, rule, result }), + }), + }; + }, + ({ result }) => + requiredText( + record( + record(result, "Endor result")["properties"], + "Endor result properties", + )["finding-uuid"], + "Endor finding-uuid", + ), + ); +} + +export async function readEndorFindings( + path: string, +): Promise { + return normalizeEndorFindings(await readScannerJson(path)); +} diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index 8f3e58f76..7c080344a 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -18,6 +18,7 @@ import { import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; import { readSnykFindings } from "./snyk-findings.js"; +import { readEndorFindings } from "./endor-findings.js"; import type { ExternalFindings, ExternalPublicationInput, @@ -49,7 +50,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" | "snyk"; + provider: "wiz" | "github" | "snyk" | "endor"; sourceKey: string; } @@ -280,6 +281,12 @@ export async function prepareExternalPublication( options: ExternalPublicationOptions, dependencies: ExternalPublicationDependencies = {}, ): Promise { + if (options.provider === "endor") + return prepareNormalizedExternalPublication( + await readEndorFindings(path), + options, + dependencies, + ); if (options.provider === "snyk") return prepareNormalizedExternalPublication( await readSnykFindings(path), @@ -353,8 +360,8 @@ 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." - : options.provider === "snyk" - ? "The source key must contain 1–512 UTF-8 bytes. Use a stable Snyk organization/project/branch namespace across exports." + : options.provider === "snyk" || options.provider === "endor" + ? "The source key must contain 1–512 UTF-8 bytes. Use a stable scanner 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.", ); } diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index d8c236999..17e23d399 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -251,3 +251,9 @@ export type { AbsolutePath } from "./config-path.js"; export { BUNDLED_PLUGIN_VERSION, VERSION } from "./version.js"; export { normalizeSnykSarif, readSnykFindings } from "./snyk-findings.js"; + +export { + normalizeEndorFindings, + normalizeEndorSarif, + readEndorFindings, +} from "./endor-findings.js"; diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts new file mode 100644 index 000000000..0d4713673 --- /dev/null +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -0,0 +1,152 @@ +import { expect, test } from "bun:test"; +import { + normalizeEndorFindings, + normalizeEndorSarif, +} from "../src/endor-findings.js"; +import { endorFinding, endorSarif } from "./support/endor-import-fixtures.js"; + +test("Endor JSON preserves the complete source, UUID, severity, aliases and repository manifests", () => { + const input = endorFinding(); + const original = structuredClone(input); + const output = normalizeEndorFindings(input); + expect(input).toEqual(original); + expect(output.excluded).toEqual([]); + expect(output.findings[0]).toMatchObject({ + source_finding_id: input.uuid, + evidence: { + title: input.meta.name, + severity: "critical", + branch: "main", + source_updated_at: 1767225600, + advisory_ids: ["CVE-2099-0001", "GHSA-2222-3333-4444"], + locations: [ + { path: "package.json", line: null }, + { path: "package-lock.json", line: null }, + ], + source_data: original, + }, + }); + expect(output.findings[0]!.evidence.packages).toHaveLength(2); + expect(output.findings[0]!.evidence.packages![0]).toMatchObject({ + name: "@example/package", + installed_version: "1.0.0", + fixed_versions: [], + }); +}); + +test("arrays and ListFindingsResponse use the same identities and empty lists are empty", () => { + const input = endorFinding(); + expect(normalizeEndorFindings([input])).toEqual( + normalizeEndorFindings({ list: { objects: [input] } }), + ); + expect(normalizeEndorFindings({ list: { objects: [] } })).toEqual({ + read: 0, + findings: [], + excluded: [], + }); +}); + +test("changed severity, reachability, dismissal and dependency version retain UUID", () => { + const input = endorFinding(); + const original = normalizeEndorFindings(input).findings[0]!; + input.spec.level = "FINDING_LEVEL_LOW"; + input.spec.dismiss = true; + input.spec.target_dependency_version = "1.1.0"; + input.spec.reachable_paths = []; + const changed = normalizeEndorFindings(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, + ); +}); + +test("unknown levels stay unknown and main-context default is not mistaken for a branch", () => { + const input = endorFinding(); + input.spec.level = "FINDING_LEVEL_UNSPECIFIED"; + input.context = { type: "CONTEXT_TYPE_MAIN", id: "default" }; + expect(normalizeEndorFindings(input).findings[0]!.evidence).toMatchObject({ + severity: "unknown", + branch: null, + }); +}); + +test("duplicate UUIDs, masked records, bad timestamps and oversized evidence are excluded", () => { + const input = endorFinding(); + const duplicate = normalizeEndorFindings([input, input]); + expect(duplicate.findings).toEqual([]); + expect(duplicate.excluded).toHaveLength(2); + for (const invalid of [ + { ...input, uuid: "" }, + { uuid: input.uuid }, + { ...input, meta: { ...input.meta, update_time: "invalid" } }, + { ...input, spec: { ...input.spec, summary: "é".repeat(65536) } }, + ]) { + const output = normalizeEndorFindings([input, invalid]); + expect(output.excluded.length).toBeGreaterThan(0); + } +}); + +test("unsafe dependency paths remain in raw evidence", () => { + const input = endorFinding(); + input.spec.dependency_file_paths = [ + "../outside", + "/tmp/absolute", + "C:\\private\\manifest", + "safe/manifest.json", + ]; + const evidence = normalizeEndorFindings(input).findings[0]!.evidence; + expect(evidence.locations).toEqual([ + { path: "safe/manifest.json", line: null }, + ]); + expect(evidence.source_data).toEqual(input); +}); + +test("Endor SARIF UUID matches JSON and retains all source assertions without promoting them to Cloud assessments", () => { + const input = endorSarif(); + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.source_finding_id).toBe( + normalizeEndorFindings(endorFinding()).findings[0]!.source_finding_id, + ); + expect(output.findings[0]!.evidence).toMatchObject({ + severity: "unknown", + locations: [{ path: "package.json", line: null }], + source_data: { + result: input.runs[0]!.results[0], + rule: input.runs[0]!.tool.driver.rules[0], + }, + }); +}); + +test("SARIF requires vendor identity, producer and consistent rule references", () => { + for (const mutate of [ + (r: any) => delete r.results[0].properties["finding-uuid"], + (r: any) => delete r.results[0].properties["project-uuid"], + (r: any) => (r.results[0].ruleIndex = 1), + (r: any) => (r.results[0].rule = { id: "other" }), + (r: any) => + r.tool.driver.rules.push(structuredClone(r.tool.driver.rules[0])), + ]) { + const input = endorSarif(); + mutate(input.runs[0]); + expect(normalizeEndorSarif(input).findings).toEqual([]); + } + const input = endorSarif(); + input.runs[0]!.tool.driver.name = "other"; + expect(() => normalizeEndorSarif(input)).toThrow("endorctl"); +}); + +test("500 findings with distinct UUIDs survive a reordered second export", () => { + const input = Array.from({ length: 500 }, (_, i) => ({ + ...endorFinding(), + uuid: `finding-example-${i}`, + })); + const first = normalizeEndorFindings(input); + const second = normalizeEndorFindings(input.reverse()); + expect(first.findings).toHaveLength(500); + expect(second.excluded).toEqual([]); + expect(second.findings.map((f) => f.source_finding_id).sort()).toEqual( + first.findings.map((f) => f.source_finding_id).sort(), + ); +}); diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index 4d9623784..16c558adb 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2759,3 +2759,49 @@ 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("Endor JSON and SARIF reuse UUID identity through Cloud publication", async () => { + const { endorFinding, endorSarif } = + await import("./support/endor-import-fixtures.js"); + const input = endorFinding(); + const f = await fixture(input); + const settings = { + ...options, + provider: "endor" as const, + sourceKey: "namespace/project/main", + }; + const first = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(first.counts.created).toBe(1); + const original = structuredClone([...f.reports.values()][0]!); + expect( + ( + await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish() + ).counts.unchanged, + ).toBe(1); + const sarif = endorSarif(); + await writeFile(f.file, JSON.stringify(sarif)); + const changed = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(changed.counts.updated).toBe(1); + expect(changed.verified).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.evidence.source_data).toMatchObject({ + result: sarif.runs[0]!.results[0], + }); +}); +test("Endor CLI dry-run recognizes SARIF without writing imports", async () => { + const { endorSarif } = await import("./support/endor-import-fixtures.js"); + const f = await fixture(endorSarif()); + const command = [...f.command, "--dry-run"]; + command[command.indexOf("--provider") + 1] = "endor"; + 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/support/endor-import-fixtures.ts b/sdk/typescript/tests-ts/support/endor-import-fixtures.ts new file mode 100644 index 000000000..c19d0cc44 --- /dev/null +++ b/sdk/typescript/tests-ts/support/endor-import-fixtures.ts @@ -0,0 +1,77 @@ +// Synthetic records shaped after Endor's Finding model and its published SARIF sample. +export function endorFinding() { + return { + uuid: "finding-example-1", + meta: { + name: "Synthetic vulnerable dependency", + update_time: "2026-01-01T00:00:00Z", + }, + context: { type: "CONTEXT_TYPE_REF", id: "main" }, + spec: { + project_uuid: "project-example", + level: "FINDING_LEVEL_CRITICAL", + summary: "Synthetic dependency finding", + ecosystem: "ECOSYSTEM_NPM", + target_dependency_name: "@example/package", + target_dependency_version: "1.0.0", + proposed_version: "2.0.0", + dependency_file_paths: ["package.json", "package-lock.json"], + finding_categories: ["FINDING_CATEGORY_VULNERABILITY"], + finding_metadata: { + vulnerability: { + meta: { name: "CVE-2099-0001" }, + spec: { aliases: ["GHSA-2222-3333-4444"] }, + }, + }, + reachable_paths: [{ nodes: [{ function: "syntheticFunction" }] }], + dismiss: false, + }, + }; +} + +export function endorSarif() { + return { + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "endorctl", + semanticVersion: "1.0.0", + rules: [ + { + id: "synthetic-rule", + shortDescription: { text: "Synthetic finding" }, + fullDescription: { text: "Synthetic explanation" }, + help: { text: "Synthetic remediation" }, + }, + ], + }, + }, + results: [ + { + ruleId: "synthetic-rule", + message: { text: "Synthetic result" }, + properties: { + "finding-uuid": "finding-example-1", + "project-uuid": "project-example", + categories: ["VULNERABILITY"], + tags: ["REACHABLE_FUNCTION"], + "cvss-score": 9.8, + "finding-url": + "https://example.invalid/findings/finding-example-1", + }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "package.json" }, + region: { startLine: 1 }, + }, + }, + ], + }, + ], + }, + ], + }; +} From d9357289a211bac0b0a01c8ac415ba4983e59077 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 22:08:30 +0000 Subject: [PATCH 2/8] fix: reject partial Endor pages and preserve fractional timestamps --- docs/scanner-imports.md | 2 + sdk/typescript/src/endor-findings.ts | 30 +++++++---- .../tests-ts/endor-findings.test.ts | 51 +++++++++++++++++++ 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 8db56bc69..c7b4d560f 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -60,3 +60,5 @@ Imports replace the complete source snapshot for an existing UUID. Preview befor switching between JSON and SARIF, since the two exports contain different evidence. Synthetic QA covers both formats and 500-record exports; live tenant verification is separate. + +Endor list responses with a continuation cursor or token are rejected. Finish fetching the export or provide an explicitly selected array; a single unfinished page is not a complete import. Fractional timestamps are rounded down to Cloud epoch seconds while their original precision remains in source evidence. diff --git a/sdk/typescript/src/endor-findings.ts b/sdk/typescript/src/endor-findings.ts index d97abdebf..e39a0a98a 100644 --- a/sdk/typescript/src/endor-findings.ts +++ b/sdk/typescript/src/endor-findings.ts @@ -16,15 +16,25 @@ export function normalizeEndorFindings(input: unknown): ExternalFindings { const root = Array.isArray(input) ? null : record(input, "Endor report"); if (root?.["version"] === "2.1.0" && root["runs"] !== undefined) return normalizeEndorSarif(root); - const values = - root?.["list"] !== undefined - ? array( - record(root["list"], "Endor list")["objects"] ?? [], - "Endor list.objects", - ) - : Array.isArray(input) - ? input - : [input]; + const list = + root?.["list"] === undefined ? null : record(root["list"], "Endor list"); + if (list) { + const response = record(list["response"] ?? {}, "Endor list response"); + const token = response["next_page_token"]; + if ( + text(response["next_page_id"]) || + (token != null && token !== 0 && token !== "0" && token !== "") + ) { + throw new Error( + "The Endor list response has another page. Supply a complete export or an explicitly selected array.", + ); + } + } + const values = list + ? array(list["objects"] ?? [], "Endor list.objects") + : Array.isArray(input) + ? input + : [input]; return normalizeRecords( values, (value) => { @@ -93,7 +103,7 @@ export function normalizeEndorFindings(input: unknown): ExternalFindings { branch: context["type"] === "CONTEXT_TYPE_REF" ? text(context["id"]) : null, source_updated_at: - updated === null ? null : Date.parse(updated) / 1000, + updated === null ? null : Math.floor(Date.parse(updated) / 1000), advisory_ids: [ ...new Set([...(advisory ? [advisory] : []), ...aliases]), ], diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 0d4713673..701e65d48 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -150,3 +150,54 @@ test("500 findings with distinct UUIDs survive a reordered second export", () => first.findings.map((f) => f.source_finding_id).sort(), ); }); + +test.each(["2026-01-01T00:00:00.123Z", "2026-01-01T00:00:00.123456789Z"])( + "fractional Endor timestamps retain precise source evidence (%s)", + (timestamp) => { + const finding = endorFinding(); + finding.meta.update_time = timestamp; + for (const input of [ + finding, + [finding], + { list: { objects: [finding], response: { next_page_token: 0 } } }, + ]) { + const result = normalizeEndorFindings(input); + expect(result.excluded).toEqual([]); + expect(result.findings[0]!.evidence.source_updated_at).toBe(1767225600); + expect(result.findings[0]!.evidence.source_data).toMatchObject({ + meta: { update_time: timestamp }, + }); + } + }, +); + +test.each([1, "1", "opaque-next-page", -1, true])( + "rejects unfinished or invalid Endor page tokens (%s)", + (next_page_token) => { + expect(() => + normalizeEndorFindings({ + list: { objects: [endorFinding()], response: { next_page_token } }, + }), + ).toThrow("another page"); + }, +); + +test("rejects Endor cursor pages while accepting complete exports and selected arrays", () => { + const finding = endorFinding(); + expect(() => + normalizeEndorFindings({ + list: { objects: [finding], response: { next_page_id: "next-finding" } }, + }), + ).toThrow("another page"); + for (const next_page_token of [undefined, null, 0, "0", ""]) { + expect( + normalizeEndorFindings({ + list: { + objects: [finding], + response: { next_page_token, next_page_id: "" }, + }, + }).findings, + ).toHaveLength(1); + } + expect(normalizeEndorFindings([finding]).findings).toHaveLength(1); +}); From b45c4bbab7898f19bd8654f7cb11191517c01d9a Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 22:27:58 +0000 Subject: [PATCH 3/8] fix: include Endor adapter in package validation --- sdk/typescript/scripts/package-dist-files.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 1d605e7a2..f37ad93da 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -17,6 +17,7 @@ const distModules = [ "external-findings-publish", "external-import-contract", "external-import-models", + "endor-findings", "scanner-import", "snyk-findings", "wiz-findings", From 1b4b0a26556226980135153932c77c1c06bb3ac6 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 23:22:02 +0000 Subject: [PATCH 4/8] test: distinguish Endor pagination cases in JUnit --- sdk/typescript/tests-ts/endor-findings.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 701e65d48..0c16d3bd0 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -172,7 +172,7 @@ test.each(["2026-01-01T00:00:00.123Z", "2026-01-01T00:00:00.123456789Z"])( ); test.each([1, "1", "opaque-next-page", -1, true])( - "rejects unfinished or invalid Endor page tokens (%s)", + "rejects unfinished or invalid Endor page tokens (%p)", (next_page_token) => { expect(() => normalizeEndorFindings({ From 7404a307df5851fddc95dfcf498af5b73f9b83b9 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 12:07:18 +0900 Subject: [PATCH 5/8] fix: preserve Endor code finding locations --- sdk/typescript/src/endor-findings.ts | 31 ++++- .../tests-ts/endor-findings.test.ts | 107 ++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/endor-findings.ts b/sdk/typescript/src/endor-findings.ts index e39a0a98a..c019e9044 100644 --- a/sdk/typescript/src/endor-findings.ts +++ b/sdk/typescript/src/endor-findings.ts @@ -55,6 +55,35 @@ export function normalizeEndorFindings(input: unknown): ExternalFindings { spec["finding_metadata"] ?? {}, "Endor finding_metadata", ); + const codeLocations = ["sast_data", "ai_sast_data"].flatMap((field) => { + const data = record(metadata[field] ?? {}, `Endor ${field}`); + const location = record( + data["location"] ?? {}, + `Endor ${field} location`, + ); + const path = relativePath(location["relative_path"]); + if (path === null) return []; + const line = location["start_line"]; + return [ + { + path, + line: + typeof line === "number" && Number.isSafeInteger(line) && line > 0 + ? line + : null, + }, + ]; + }); + const locations = [ + ...paths + .filter( + (path) => !codeLocations.some((location) => location.path === path), + ) + .map((path) => ({ path })), + ...new Map( + codeLocations.map((location) => [JSON.stringify(location), location]), + ).values(), + ]; const vulnerability = record( metadata["vulnerability"] ?? {}, "Endor vulnerability", @@ -108,7 +137,7 @@ export function normalizeEndorFindings(input: unknown): ExternalFindings { ...new Set([...(advisory ? [advisory] : []), ...aliases]), ], packages, - locations: paths.map((path) => ({ path })), + locations, source_data: structuredClone(finding), }), }; diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 0c16d3bd0..9cfc473e4 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -46,6 +46,113 @@ test("arrays and ListFindingsResponse use the same identities and empty lists ar }); }); +test.each(["sast_data", "ai_sast_data"])( + "Endor %s preserves the primary source location and original evidence", + (field) => { + const base = endorFinding(); + const input = { + ...base, + spec: { + ...base.spec, + target_dependency_name: "", + dependency_file_paths: ["src/example.ts", "package.json"], + finding_metadata: { + [field]: { + location: { + relative_path: "src/example.ts", + start_line: 12, + end_line: 14, + }, + }, + }, + }, + }; + const original = structuredClone(input); + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.source_finding_id).toBe(input.uuid); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "package.json", line: null }, + { path: "src/example.ts", line: 12 }, + ]); + expect(output.findings[0]!.evidence.source_data).toEqual(original); + expect(input).toEqual(original); + }, +); + +test.each([12, 24])( + "Endor retains distinct code locations without duplicating identical locations (line %i)", + (secondLine) => { + const base = endorFinding(); + const location = { relative_path: "src/example.ts", start_line: 12 }; + const input = { + ...base, + spec: { + ...base.spec, + dependency_file_paths: [], + finding_metadata: { + sast_data: { location }, + ai_sast_data: { location: { ...location, start_line: secondLine } }, + }, + }, + }; + const expected = [{ path: "src/example.ts", line: 12 }]; + if (secondLine !== 12) + expected.push({ path: "src/example.ts", line: secondLine }); + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual(expected); + }, +); + +test.each([null, 0, -1, 1.5, "12"])( + "Endor retains a code path without inventing a line from %p", + (start_line) => { + const base = endorFinding(); + const input = { + ...base, + spec: { + ...base.spec, + dependency_file_paths: [], + finding_metadata: { + ai_sast_data: { + location: { relative_path: "src/example.ts", start_line }, + }, + }, + }, + }; + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "src/example.ts", line: null }, + ]); + expect(output.findings[0]!.evidence.source_data).toEqual(input); + }, +); + +test("unsafe code locations remain only in the original Endor evidence", () => { + const base = endorFinding(); + const input = { + ...base, + spec: { + ...base.spec, + dependency_file_paths: [], + finding_metadata: { + sast_data: { + location: { relative_path: "../outside.ts", start_line: 12 }, + }, + ai_sast_data: { + location: { relative_path: "C:\\private\\source.ts", start_line: 24 }, + }, + }, + }, + }; + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([]); + expect(output.findings[0]!.evidence.source_data).toEqual(input); +}); + test("changed severity, reachability, dismissal and dependency version retain UUID", () => { const input = endorFinding(); const original = normalizeEndorFindings(input).findings[0]!; From 006f980031d2ca5a7bb70ebae56ec4cb12da930f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 12:20:14 +0900 Subject: [PATCH 6/8] fix: decode Endor SARIF repository paths --- sdk/typescript/src/endor-findings.ts | 8 ++++++- .../tests-ts/endor-findings.test.ts | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/sdk/typescript/src/endor-findings.ts b/sdk/typescript/src/endor-findings.ts index c019e9044..f26b8b686 100644 --- a/sdk/typescript/src/endor-findings.ts +++ b/sdk/typescript/src/endor-findings.ts @@ -229,11 +229,17 @@ export function normalizeEndorSarif(input: unknown): ExternalFindings { record(v, "Endor location")["physicalLocation"] ?? {}, "Physical location", ); - return relativePath( + const uri = text( record(physical["artifactLocation"] ?? {}, "Artifact location")[ "uri" ], ); + if (uri === null) return null; + try { + return relativePath(decodeURIComponent(uri)); + } catch { + return null; + } }) .filter((p): p is string => p !== null); return { diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 9cfc473e4..6bacc04d7 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -226,6 +226,28 @@ test("Endor SARIF UUID matches JSON and retains all source assertions without pr }); }); +test.each([ + ["packages/my%20app/package.json", "packages/my app/package.json"], + ["packages/%E6%97%A5%E6%9C%AC/package.json", "packages/日本/package.json"], + ["packages/my%23app/package.json", "packages/my#app/package.json"], + ["packages/my%2520app/package.json", "packages/my%20app/package.json"], + ["%2e%2e/outside/package.json", null], + ["%2Ftmp/package.json", null], + ["C%3A%5Cprivate%5Cpackage.json", null], + ["packages/invalid%ZZ/package.json", null], +])("Endor SARIF decodes safe repository paths once (%s)", (uri, path) => { + const input = endorSarif(); + const result = input.runs[0]!.results[0]!; + result.locations[0]!.physicalLocation.artifactLocation.uri = uri!; + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual( + path === null ? [] : [{ path, line: null }], + ); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ result }); + expect(result.locations[0]!.physicalLocation.artifactLocation.uri).toBe(uri); +}); + test("SARIF requires vendor identity, producer and consistent rule references", () => { for (const mutate of [ (r: any) => delete r.results[0].properties["finding-uuid"], From 7a51f993bef2632eb9dc4909c40c093bad4e4f24 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 12:34:27 +0900 Subject: [PATCH 7/8] fix: retain Endor SARIF code finding lines --- docs/scanner-imports.md | 5 +- sdk/typescript/src/endor-findings.ts | 62 +++++++++++++------ .../tests-ts/endor-findings.test.ts | 38 ++++++++++++ 3 files changed, 84 insertions(+), 21 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index c7b4d560f..370a400fe 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -54,7 +54,10 @@ JSON preserves the finding's severity and update timestamp. SARIF does not relia carry Endor's finding severity: diagnostic levels and CVSS remain original evidence, and displayed severity stays unknown. Recommended versions, reachability, dismissals, snoozes and other source assertions are retained without becoming Cloud assessments. -Synthetic SARIF line numbers are not promoted to verified code locations. +JSON maps reported primary SAST and AI SAST paths and start lines. SARIF maps +reported source lines for results categorized as SAST or Secrets; other SARIF +line numbers remain only in the original evidence. Locations remain source claims, +not verified Cloud assessments. Imports replace the complete source snapshot for an existing UUID. Preview before switching between JSON and SARIF, since the two exports contain different evidence. diff --git a/sdk/typescript/src/endor-findings.ts b/sdk/typescript/src/endor-findings.ts index f26b8b686..11bfb0ee8 100644 --- a/sdk/typescript/src/endor-findings.ts +++ b/sdk/typescript/src/endor-findings.ts @@ -223,25 +223,43 @@ export function normalizeEndorSarif(input: unknown): ExternalFindings { "Endor fullDescription", ); const help = record(rule["help"] ?? {}, "Endor help"); - const paths = array(result["locations"] ?? [], "Endor locations") - .map((v) => { - const physical = record( - record(v, "Endor location")["physicalLocation"] ?? {}, - "Physical location", - ); - const uri = text( - record(physical["artifactLocation"] ?? {}, "Artifact location")[ - "uri" - ], - ); - if (uri === null) return null; - try { - return relativePath(decodeURIComponent(uri)); - } catch { - return null; - } - }) - .filter((p): p is string => p !== null); + const hasCodeLocations = strings(properties["categories"]).some( + (category) => category === "SAST" || category === "SECRETS", + ); + const locations = array( + result["locations"] ?? [], + "Endor locations", + ).flatMap((v) => { + const physical = record( + record(v, "Endor location")["physicalLocation"] ?? {}, + "Physical location", + ); + const uri = text( + record(physical["artifactLocation"] ?? {}, "Artifact location")[ + "uri" + ], + ); + if (uri === null) return []; + let path: string | null; + try { + path = relativePath(decodeURIComponent(uri)); + } catch { + return []; + } + if (path === null) return []; + const line = hasCodeLocations + ? record(physical["region"] ?? {}, "Endor region")["startLine"] + : null; + return [ + { + path, + line: + typeof line === "number" && Number.isSafeInteger(line) && line > 0 + ? line + : null, + }, + ]; + }); return { source_finding_id: id, evidence: validateExternalEvidence({ @@ -255,7 +273,11 @@ export function normalizeEndorSarif(input: unknown): ExternalFindings { .join("\n\n"), // Diagnostic level and CVSS are not Endor's finding severity. Keep both as source evidence. severity: "unknown", - locations: [...new Set(paths)].map((path) => ({ path })), + locations: [ + ...new Map( + locations.map((location) => [JSON.stringify(location), location]), + ).values(), + ], source_data: structuredClone({ ...context, rule, result }), }), }; diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 6bacc04d7..5893f81f1 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -248,6 +248,44 @@ test.each([ expect(result.locations[0]!.physicalLocation.artifactLocation.uri).toBe(uri); }); +test.each(["SAST", "SECRETS"])( + "Endor SARIF retains distinct reported code lines for %s findings", + (category) => { + const input = endorSarif(); + const result = input.runs[0]!.results[0]!; + result.properties.categories = [category]; + result.locations = [1, 42, 42].map((startLine) => ({ + physicalLocation: { + artifactLocation: { uri: "src/my%20module.ts" }, + region: { startLine }, + }, + })); + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "src/my module.ts", line: 1 }, + { path: "src/my module.ts", line: 42 }, + ]); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ result }); + }, +); + +test.each([0, -1, 1.5, "42", null, undefined])( + "Endor SARIF keeps unknown or invalid code lines as null (%p)", + (startLine) => { + const input = endorSarif(); + const result: any = input.runs[0]!.results[0]!; + result.properties.categories = ["SAST"]; + result.locations[0].physicalLocation.region = { startLine }; + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "package.json", line: null }, + ]); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ result }); + }, +); + test("SARIF requires vendor identity, producer and consistent rule references", () => { for (const mutate of [ (r: any) => delete r.results[0].properties["finding-uuid"], From 6ae9a3f292410d1f94543b41a974942bf41c5b65 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 16:25:44 +0900 Subject: [PATCH 8/8] Bind Endor SARIF repository claims and preserve unresolved locations --- docs/scanner-imports.md | 9 +- sdk/typescript/src/endor-findings.ts | 34 +++++++- .../src/external-findings-publish.ts | 5 +- .../tests-ts/endor-findings.test.ts | 51 ++++++++++++ .../external-findings-publish.test.ts | 83 +++++++++++++++++++ 5 files changed, 175 insertions(+), 7 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index b5f7b87e3..55dde6d3e 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -62,8 +62,13 @@ and displayed severity stays unknown. Recommended versions, reachability, dismis snoozes and other source assertions are retained without becoming Cloud assessments. JSON maps reported primary SAST and AI SAST paths and start lines. SARIF maps reported source lines for results categorized as SAST or Secrets; other SARIF -line numbers remain only in the original evidence. Locations remain source claims, -not verified Cloud assessments. +line numbers remain only in the original evidence. Indexed and base-relative SARIF +artifact references remain in the original evidence without being mapped to a +repository file. Locations remain source claims, not verified Cloud assessments. + +For new uploads, every declared SARIF repository URI must match the selected Cloud +repository. Saved requests remain bound to their original repository ID and can +resume after a repository rename. Imports replace the complete source snapshot for an existing UUID. Preview before switching between JSON and SARIF, since the two exports contain different evidence. diff --git a/sdk/typescript/src/endor-findings.ts b/sdk/typescript/src/endor-findings.ts index 11bfb0ee8..a3a327faa 100644 --- a/sdk/typescript/src/endor-findings.ts +++ b/sdk/typescript/src/endor-findings.ts @@ -11,6 +11,27 @@ import { text, } from "./scanner-import.js"; +/** @internal Repository claims retained in the adapter's original SARIF context. */ +export function endorRepositoryUrls(sourceData: unknown): string[] { + if ( + !sourceData || + typeof sourceData !== "object" || + Array.isArray(sourceData) + ) + return []; + const run = (sourceData as Record)["runContext"]; + if (run == null) return []; + return array( + record(run, "Endor run context")["versionControlProvenance"] ?? [], + "Endor version control provenance", + ).map((entry) => + requiredText( + record(entry, "Endor repository provenance")["repositoryUri"], + "Endor repository URI", + ), + ); +} + /** Saved Endor Finding objects, an array, or an unmasked ListFindingsResponse. */ export function normalizeEndorFindings(input: unknown): ExternalFindings { const root = Array.isArray(input) ? null : record(input, "Endor report"); @@ -234,11 +255,16 @@ export function normalizeEndorSarif(input: unknown): ExternalFindings { record(v, "Endor location")["physicalLocation"] ?? {}, "Physical location", ); - const uri = text( - record(physical["artifactLocation"] ?? {}, "Artifact location")[ - "uri" - ], + const artifact = record( + physical["artifactLocation"] ?? {}, + "Artifact location", ); + if ( + artifact["index"] !== undefined || + artifact["uriBaseId"] !== undefined + ) + return []; + const uri = text(artifact["uri"]); if (uri === null) return []; let path: string | null; try { diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index a36340a71..90b03c1a0 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -26,7 +26,7 @@ import { import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; import { readSnykFindings, snykRepositoryUrls } from "./snyk-findings.js"; -import { readEndorFindings } from "./endor-findings.js"; +import { readEndorFindings, endorRepositoryUrls } from "./endor-findings.js"; import type { ExternalFindings, ExternalPublicationInput, @@ -668,6 +668,9 @@ async function preparePublicationInput( ...(options.provider === "snyk" ? snykRepositoryUrls(finding.evidence.source_data) : []), + ...(options.provider === "endor" + ? endorRepositoryUrls(finding.evidence.source_data) + : []), ]; for (const sourceRepository of sourceRepositories) if ( diff --git a/sdk/typescript/tests-ts/endor-findings.test.ts b/sdk/typescript/tests-ts/endor-findings.test.ts index 5893f81f1..89918554b 100644 --- a/sdk/typescript/tests-ts/endor-findings.test.ts +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -368,3 +368,54 @@ test("rejects Endor cursor pages while accepting complete exports and selected a } expect(normalizeEndorFindings([finding]).findings).toHaveLength(1); }); + +test.each([ + { + artifact: { uri: "src/query.ts", uriBaseId: "ROOT" }, + base: "file:///outside/", + }, + { artifact: { uri: "src/query.ts", uriBaseId: "ROOT" }, base: "subproject/" }, + { artifact: { uri: "src/query.ts", index: 0 }, base: "subproject/" }, + { artifact: { index: 0 }, base: "subproject/" }, +])( + "Endor SARIF leaves unsupported artifact references unmapped: %p", + ({ artifact, base }) => { + const input: any = endorSarif(); + const run = input.runs[0]; + run.originalUriBaseIds = { ROOT: { uri: base } }; + run.artifacts = [{ location: { uri: "src/actual.ts" } }]; + const result = run.results[0]; + result.properties.categories = ["SAST"]; + result.locations = [ + { + physicalLocation: { + artifactLocation: artifact, + region: { startLine: 42 }, + }, + }, + { + physicalLocation: { + artifactLocation: { uri: "src/direct.ts" }, + region: { startLine: 8 }, + }, + }, + ]; + const original = structuredClone(input); + const output = normalizeEndorFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.source_finding_id).toBe( + result.properties["finding-uuid"], + ); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "src/direct.ts", line: 8 }, + ]); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: original.runs[0].results[0], + runContext: { + originalUriBaseIds: run.originalUriBaseIds, + artifacts: run.artifacts, + }, + }); + expect(input).toEqual(original); + }, +); diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index 5a3e171e2..688e05880 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2881,3 +2881,86 @@ test("Endor CLI dry-run recognizes SARIF without writing imports", 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"] }, +])( + "Endor source repository provenance binds both publication entrypoints: %p", + async ({ uris }) => { + const { endorSarif } = await import("./support/endor-import-fixtures.js"); + const { normalizeEndorFindings } = await import("../src/endor-findings.js"); + const input: any = endorSarif(); + input.runs[0].versionControlProvenance = uris.map((repositoryUri) => ({ + repositoryUri, + })); + const f = await fixture(input); + const selected = { ...options, provider: "endor" as const }; + await expect( + prepareExternalPublication(f.file, selected, f.deps), + ).rejects.toThrow("does not match the selected Cloud repository"); + await expect( + prepareNormalizedExternalPublication( + normalizeEndorFindings(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("Endor source repository binding preserves immutable requests across a repository rename", async () => { + const { endorSarif } = await import("./support/endor-import-fixtures.js"); + const input: any = endorSarif(); + 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: "endor" as const }; + f.state.loseResponse = true; + const first = await prepareExternalPublication(f.file, selected, f.deps); + await expect(first.publish()).rejects.toThrow("resume the saved request"); + 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 retry = await prepareExternalPublication( + f.file, + selected, + changedDestination, + ); + expect(retry.preview.resumed).toBe(true); + expect((await retry.publish()).status).toBe("complete"); + expect(f.posts[1]).toBe(f.posts[0]); + expect([...f.reports.values()][0]!.evidence.source_data).toMatchObject({ + runContext: { + versionControlProvenance: original.runs[0].versionControlProvenance, + }, + }); + 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); +});