diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 2c1b55051..55dde6d3e 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -43,3 +43,36 @@ 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. +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. 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. +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/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 9a2dddaca..5c5c2ad83 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -21,6 +21,7 @@ const distModules = [ "external-findings-publish", "external-import-contract", "external-import-models", + "endor-findings", "scanner-import", "snyk-findings", "wiz-findings", diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 2865ebb79..0a42242d0 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3255,15 +3255,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({ @@ -3272,7 +3272,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..a3a327faa --- /dev/null +++ b/sdk/typescript/src/endor-findings.ts @@ -0,0 +1,326 @@ +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"; + +/** @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"); + if (root?.["version"] === "2.1.0" && root["runs"] !== undefined) + return normalizeEndorSarif(root); + 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) => { + 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 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", + ); + 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 : Math.floor(Date.parse(updated) / 1000), + advisory_ids: [ + ...new Set([...(advisory ? [advisory] : []), ...aliases]), + ], + packages, + locations, + 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 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 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 { + 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({ + 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 Map( + locations.map((location) => [JSON.stringify(location), location]), + ).values(), + ], + 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 ebb17bf9a..90b03c1a0 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -26,6 +26,7 @@ import { import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; import { readSnykFindings, snykRepositoryUrls } from "./snyk-findings.js"; +import { readEndorFindings, endorRepositoryUrls } from "./endor-findings.js"; import type { ExternalFindings, ExternalPublicationInput, @@ -57,7 +58,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; } @@ -288,6 +289,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), @@ -361,8 +368,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.", ); } @@ -661,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/src/index.ts b/sdk/typescript/src/index.ts index cbaa3409c..356a7805e 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -274,3 +274,9 @@ export type { } from "./validation-records.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..89918554b --- /dev/null +++ b/sdk/typescript/tests-ts/endor-findings.test.ts @@ -0,0 +1,421 @@ +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.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]!; + 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.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.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"], + (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(), + ); +}); + +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 (%p)", + (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); +}); + +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 105d620b1..688e05880 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2835,3 +2835,132 @@ test("Snyk source repository binding is rechecked for a fresh import after compl expect(f.posts).toHaveLength(posted); expect(input).toEqual(original); }); + +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); +}); + +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); +}); 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 }, + }, + }, + ], + }, + ], + }, + ], + }; +}