Skip to content
Draft
33 changes: 33 additions & 0 deletions docs/scanner-imports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
1 change: 1 addition & 0 deletions sdk/typescript/scripts/package-dist-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const distModules = [
"external-findings-publish",
"external-import-contract",
"external-import-models",
"endor-findings",
"scanner-import",
"snyk-findings",
"wiz-findings",
Expand Down
8 changes: 4 additions & 4 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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.",
Expand Down
326 changes: 326 additions & 0 deletions sdk/typescript/src/endor-findings.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>)["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<ExternalFindings> {
return normalizeEndorFindings(await readScannerJson(path));
}
Loading
Loading