diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 55dde6d3e..dcf8b603a 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -76,3 +76,51 @@ Synthetic QA covers both formats and 500-record exports; live tenant verificatio 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. + +## Semgrep SAST + +Use Semgrep JSON or self-contained SARIF 2.1.0, with an explicit declaration of +the source repository. The publisher checks this URL against the selected Cloud +repository; it never fills missing source provenance from the destination. + +```sh +codex-security publish findings semgrep.json --to cloud \ + --provider semgrep --source-key ORGANIZATION/PROJECT/BRANCH \ + --source-repository-url https://github.com/example/project \ + --repository https://github.com/example/project --dry-run --format json +``` + +Review the findings and exclusions, then repeat without `--dry-run` and confirm +the upload. Semgrep requires a Cloud version that accepts the `semgrep` provider. + +- Scope: Semgrep SAST. Do not supply Supply Chain or Secrets exports; identifiable + records from these products are excluded. SARIF may omit product metadata, so + select a SAST-only scan when exporting it. +- Logged-in Platform fingerprints are stable across range and message changes. + JSON and SARIF reuse a matching fingerprint. Community Edition omits stable + fingerprints or emits the placeholder `requires login`; its fallback combines + the rule, literal path, and range. Moving a CE finding creates a new identity. + Keep a stable source key and review changed exports; this adapter cannot infer + cross-export equivalence for moved CE findings. +- SARIF supports direct artifact URIs and Semgrep's native `%SRCROOT%` marker + without a base definition. Native paths retain literal percent characters; + direct URIs without that marker are URI-decoded once. + Indexed artifacts, other URI bases and redefined + source roots remain in the original evidence without repository locations. + CE records with those references are excluded because a reliable fallback + identity cannot be derived. See the [native SARIF formatter](https://github.com/semgrep/semgrep/blob/51602ad27f09ef9342a5620601de5ef8f572fbc6/src/osemgrep/reporting/Sarif_output.ml#L410). +- Prefer JSON for original severity. Semgrep SARIF combines Critical and High + into diagnostic `error`, which this mapper displays as High. Both representations + retain the complete selected source evidence. +- Scan-wide JSON path inventories and timing tables are omitted from individual + findings; the selected raw result and remaining report metadata are retained. +- JSON scan errors, unsuccessful SARIF invocations, and SARIF scan-error + notifications are rejected. Ambiguous + duplicate identities and invalid records are excluded before publication. +- Original source suppressions and ignored status stay separate from Cloud + lifecycle state and Codex assessment. This adapter does not launch a scan, + evaluate rules, follow URLs, or read source paths from the report. + +Programmatic callers pass `{ sourceRepositoryUrl }` to +`normalizeSemgrepFindings`, `normalizeSemgrepSarif`, or `readSemgrepFindings`. +See the [official output documentation](https://docs.semgrep.dev/semgrep-appsec-platform/json-and-sarif). diff --git a/sdk/typescript/schemas/external-findings.schema.json b/sdk/typescript/schemas/external-findings.schema.json index c10ad248e..f04ad771b 100644 --- a/sdk/typescript/schemas/external-findings.schema.json +++ b/sdk/typescript/schemas/external-findings.schema.json @@ -378,7 +378,7 @@ "additionalProperties": false, "properties": { "provider": { - "enum": ["wiz", "endor", "snyk", "github"], + "enum": ["wiz", "endor", "snyk", "github", "semgrep"], "title": "Provider", "type": "string" }, diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 46fa9ff28..c116f5ff6 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -298,7 +298,7 @@ export async function externalPublication( ): Promise { const fromFile = await prepareExternalPublication(file, options); fromFile.preview.requests[0]?.source.provider satisfies - "wiz" | "endor" | "snyk" | "github" | undefined; + "wiz" | "endor" | "snyk" | "github" | "semgrep" | undefined; const prepared = await prepareNormalizedExternalPublication(input, options); return prepared.publish(); } diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 5c5c2ad83..9cb46c17e 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -23,6 +23,7 @@ const distModules = [ "external-import-models", "endor-findings", "scanner-import", + "semgrep-findings", "snyk-findings", "wiz-findings", "codex-prompt", diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index 0a42242d0..239cd1377 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, 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", + "Preview and import selected Wiz, GitHub, Snyk, Endor, or Semgrep SAST findings to Cloud.", + hint: "For Semgrep SAST, supply JSON or SARIF and --source-repository-url identifying the source repository. 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( - "Endor JSON/SARIF, Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", + "Semgrep SAST or Endor JSON/SARIF, Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", ), }), options: z.object({ @@ -3272,8 +3272,13 @@ export async function main( "Authorized Cloud repository ID or repository URL copied from Cloud.", ), provider: z - .enum(["wiz", "github", "snyk", "endor"]) + .enum(["wiz", "github", "snyk", "endor", "semgrep"]) .describe("Provider that produced these findings."), + sourceRepositoryUrl: optionValue("--source-repository-url") + .optional() + .describe( + "Required for Semgrep: declared source repository URL, verified against the Cloud destination.", + ), sourceKey: optionValue("--source-key").describe( "Stable vendor tenant and finding namespace; not a project selection filter.", ), diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index 90b03c1a0..ad1b3d192 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -26,13 +26,14 @@ import { import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; import { readSnykFindings, snykRepositoryUrls } from "./snyk-findings.js"; +import { readSemgrepFindings } from "./semgrep-findings.js"; import { readEndorFindings, endorRepositoryUrls } from "./endor-findings.js"; import type { ExternalFindings, ExternalPublicationInput, } from "./external-findings.js"; import { - repositoryUrlKey, + cloudRepositoryUrlKey, validateImportRequest, validateExternalEvidence, validateImportReceipt, @@ -49,16 +50,11 @@ import type { const MAX_REQUEST_BYTES = 5 * 1024 * 1024; -// Cloud's import catalog contains GitHub repositories, including custom Enterprise -// hosts. Match its case-insensitive URL identity without merging schemes or ports. -function cloudRepositoryUrlKey(value: string): string { - return repositoryUrlKey(value).toLowerCase(); -} - /** CLI and plugin callers prepare the same immutable request before approval. */ export interface ExternalPublicationOptions { repository: string; - provider: "wiz" | "github" | "snyk" | "endor"; + provider: "wiz" | "github" | "snyk" | "endor" | "semgrep"; + sourceRepositoryUrl?: string; sourceKey: string; } @@ -289,6 +285,14 @@ export async function prepareExternalPublication( options: ExternalPublicationOptions, dependencies: ExternalPublicationDependencies = {}, ): Promise { + if (options.provider === "semgrep") + return prepareNormalizedExternalPublication( + await readSemgrepFindings(path, { + sourceRepositoryUrl: options.sourceRepositoryUrl ?? "", + }), + options, + dependencies, + ); if (options.provider === "endor") return prepareNormalizedExternalPublication( await readEndorFindings(path), diff --git a/sdk/typescript/src/external-import-contract.ts b/sdk/typescript/src/external-import-contract.ts index b289e7425..47871d491 100644 --- a/sdk/typescript/src/external-import-contract.ts +++ b/sdk/typescript/src/external-import-contract.ts @@ -45,6 +45,12 @@ export function repositoryUrlKey(value: string): string { return `${parts[1]!.toLowerCase()}://${authority}${path}${parts[4]!}`; } +// Cloud's import catalog contains GitHub repositories, including custom Enterprise +// hosts. Match its case-insensitive URL identity without merging schemes or ports. +export function cloudRepositoryUrlKey(value: string): string { + return repositoryUrlKey(value).toLowerCase(); +} + // Cloud ImportModel applies these constraints to both requests and responses. // Its UTF-8 limits are not exported as JSON Schema character limits: even a // plain string field such as a receipt error message inherits the 512-byte bound. diff --git a/sdk/typescript/src/external-import-models.ts b/sdk/typescript/src/external-import-models.ts index d588d2f64..1f55518c0 100644 --- a/sdk/typescript/src/external-import-models.ts +++ b/sdk/typescript/src/external-import-models.ts @@ -5,7 +5,7 @@ export type Id = string; export type RepoConnectorId = string; export type EnvironmentId = string; export type ResetMarker = string; -export type Provider = "wiz" | "endor" | "snyk" | "github"; +export type Provider = "wiz" | "endor" | "snyk" | "github" | "semgrep"; export type SourceKey = string; export type ClientId = string; export type SourceFindingId = string; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 356a7805e..f394b5b71 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -280,3 +280,10 @@ export { normalizeEndorSarif, readEndorFindings, } from "./endor-findings.js"; + +export { + normalizeSemgrepFindings, + normalizeSemgrepSarif, + readSemgrepFindings, + type SemgrepImportOptions, +} from "./semgrep-findings.js"; diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts new file mode 100644 index 000000000..1c699d895 --- /dev/null +++ b/sdk/typescript/src/semgrep-findings.ts @@ -0,0 +1,427 @@ +import { + cloudRepositoryUrlKey, + validateExternalEvidence, +} from "./external-import-contract.js"; +import type { ExternalFindings } from "./external-findings.js"; +import { + array, + normalizeRecords, + readScannerJson, + record, + relativePath, + requiredText, + stableId, + text, +} from "./scanner-import.js"; + +export interface SemgrepImportOptions { + /** Source repository declared by the caller; never inferred from the destination. */ + sourceRepositoryUrl: string; +} + +function sourceRepository(options: SemgrepImportOptions): string { + const value = requiredText( + options?.sourceRepositoryUrl, + "Semgrep sourceRepositoryUrl (--source-repository-url)", + ); + const url = new URL(value); + if ( + !["http:", "https:"].includes(url.protocol) || + url.username || + url.password + ) + throw new Error( + "Use an HTTP(S) source repository URL without credentials.", + ); + return value; +} + +function ungatedText(value: unknown): string | null { + const supplied = text(value); + // The current CLI emits this placeholder for gated Community Edition fields. + return supplied === "requires login" ? null : supplied; +} + +function messageText(value: unknown): string | null { + if (typeof value !== "string") + throw new Error("Expected a Semgrep message string."); + return text(value); +} + +function severity(value: unknown): string { + const levels: Record = { + CRITICAL: "critical", + HIGH: "high", + MEDIUM: "medium", + LOW: "low", + ERROR: "high", + WARNING: "medium", + INFO: "low", + }; + return typeof value === "string" ? (levels[value] ?? "unknown") : "unknown"; +} + +function position(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 1) + throw new Error(`${label} must be a positive integer.`); + return value; +} + +function identity( + fingerprint: unknown, + rule: string, + path: string, + line: number, + column: number, + endLine: number, + endColumn: number, +): string { + const supplied = + typeof fingerprint === "string" && fingerprint !== "requires login" + ? text(fingerprint) + : null; + // CE omits fingerprints and source snippets. Its fallback is stable only while the range is stable. + return supplied + ? stableId("semgrep", [supplied]) + : stableId("semgrep-location", [ + rule, + path, + line, + column, + endLine, + endColumn, + ]); +} + +function jsonIdentity(value: unknown): string { + const result = record(value, "Semgrep result"); + const extra = record(result["extra"], "Semgrep extra"); + const supplied = ungatedText(extra["fingerprint"]); + if (supplied) return stableId("semgrep", [supplied]); + const start = record(result["start"], "Semgrep start"); + const end = record(result["end"], "Semgrep end"); + return identity( + null, + requiredText(result["check_id"], "Semgrep check_id"), + requiredText(result["path"], "Semgrep path"), + position(start["line"], "Start line"), + position(start["col"], "Start column"), + position(end["line"], "End line"), + position(end["col"], "End column"), + ); +} + +function sarifArtifactUri( + artifact: Record, + runContext: Record, +): string | null { + const base = artifact["uriBaseId"]; + // Native Semgrep uses %SRCROOT% without defining a base. Keep other references raw. + if ( + artifact["index"] !== undefined || + (base !== undefined && + (base !== "%SRCROOT%" || + record(runContext["originalUriBaseIds"] ?? {}, "SARIF URI bases")[ + base + ] !== undefined)) + ) + return null; + const uri = requiredText(artifact["uri"], "Semgrep artifact URI"); + // Semgrep's native formatter copies filesystem paths without URI encoding. + return base === "%SRCROOT%" ? uri : decodeURIComponent(uri); +} + +function sarifIdentity( + value: unknown, + runContext: Record, +): string { + const result = record(value, "Semgrep result"); + const supplied = ungatedText( + record(result["fingerprints"] ?? {}, "Semgrep fingerprints")[ + "matchBasedId/v1" + ], + ); + if (supplied) return stableId("semgrep", [supplied]); + const locations = array(result["locations"] ?? [], "Semgrep locations"); + if (locations.length !== 1) + throw new Error("Expected Semgrep's single primary result location."); + const physical = record( + record(locations[0], "Location")["physicalLocation"], + "Physical location", + ); + const uri = sarifArtifactUri( + record(physical["artifactLocation"], "Artifact location"), + runContext, + ); + if (uri === null) + throw new Error( + "Cannot derive a Semgrep CE identity from an indexed or unresolved base-relative artifact URI.", + ); + const region = record(physical["region"], "Semgrep region"); + const line = position(region["startLine"], "Start line"); + const column = position(region["startColumn"] ?? 1, "Start column"); + return identity( + null, + requiredText(result["ruleId"], "Semgrep ruleId"), + uri, + line, + column, + position(region["endLine"] ?? line, "End line"), + position(region["endColumn"] ?? column, "End column"), + ); +} + +export function normalizeSemgrepFindings( + input: unknown, + options: SemgrepImportOptions, +): ExternalFindings { + const document = record(input, "Semgrep report"); + if (document["version"] === "2.1.0" && document["runs"] !== undefined) + return normalizeSemgrepSarif(document, options); + const repositoryUrl = sourceRepository(options); + requiredText(document["version"], "Semgrep version"); + if (array(document["errors"] ?? [], "Semgrep errors").length) + throw new Error( + "The Semgrep report contains scan errors; supply a successful report before importing findings.", + ); + const { results, paths: _paths, time: _time, ...context } = document; + return normalizeRecords( + array(results, "Semgrep results"), + (value) => { + const result = record(value, "Semgrep result"); + const extra = record(result["extra"], "Semgrep extra"); + const metadata = record(extra["metadata"] ?? {}, "Semgrep metadata"); + const product = metadata["product"]; + const validation = extra["validation_state"]; + if ( + (product != null && product !== "sast") || + extra["sca_info"] != null || + (validation != null && validation !== "NO_VALIDATOR") + ) + throw new Error( + "This adapter supports Semgrep SAST; Supply Chain and Secrets reports need separate qualification.", + ); + const rule = requiredText(result["check_id"], "Semgrep check_id"); + const rawPath = requiredText(result["path"], "Semgrep path"); + const start = record(result["start"], "Semgrep start"); + const end = record(result["end"], "Semgrep end"); + const line = position(start["line"], "Start line"); + const column = position(start["col"], "Start column"); + const endLine = position(end["line"], "End line"); + const endColumn = position(end["col"], "End column"); + if (endLine < line || (endLine === line && endColumn < column)) + throw new Error("Semgrep source range ends before it starts."); + const path = relativePath(rawPath); + return { + source_finding_id: jsonIdentity(result), + evidence: validateExternalEvidence({ + title: rule, + description: messageText(extra["message"]), + severity: severity(extra["severity"]), + locations: path ? [{ path, line }] : [], + details: { + kind: "sast", + repository: { url: repositoryUrl }, + scanner_origin: "Semgrep", + rule: { id: rule }, + code: { + end_line: path ? endLine : null, + snippet: ungatedText(extra["lines"]), + }, + source_status: + typeof extra["is_ignored"] === "boolean" + ? extra["is_ignored"] + ? "ignored" + : "open" + : null, + }, + source_data: structuredClone({ report: context, result }), + }), + }; + }, + jsonIdentity, + ); +} + +export function normalizeSemgrepSarif( + input: unknown, + options: SemgrepImportOptions, +): ExternalFindings { + const repositoryUrl = sourceRepository(options); + const document = record(input, "Semgrep SARIF"); + if (document["version"] !== "2.1.0") + throw new Error("Expected Semgrep SARIF 2.1.0."); + const entries = array(document["runs"], "SARIF runs").flatMap((value) => { + const run = record(value, "SARIF run"); + for (const entry of array( + run["versionControlProvenance"] ?? [], + "Version control provenance", + )) { + const declared = text( + record(entry, "Repository provenance")["repositoryUri"], + ); + if ( + declared && + cloudRepositoryUrlKey(declared) !== cloudRepositoryUrlKey(repositoryUrl) + ) + throw new Error( + "SARIF source repository does not match the declared source repository URL.", + ); + } + const tool = record(run["tool"], "SARIF tool"); + const driver = record(tool["driver"], "SARIF driver"); + if ( + !["semgrep", "semgrep oss", "semgrep pro"].includes( + requiredText(driver["name"], "SARIF producer").toLowerCase(), + ) + ) + throw new Error("Expected a Semgrep SARIF producer."); + if (run["externalPropertyFileReferences"]) + throw new Error("Use a self-contained Semgrep SARIF report."); + for (const invocation of array( + run["invocations"] ?? [], + "Semgrep invocations", + )) { + const details = record(invocation, "Semgrep invocation"); + if (details["executionSuccessful"] === false) + throw new Error("Semgrep SARIF contains an unsuccessful invocation."); + if ( + array( + details["toolExecutionNotifications"] ?? [], + "Semgrep scan notifications", + ).length + ) + throw new Error("Semgrep SARIF contains scan error notifications."); + } + const rules = array(driver["rules"] ?? [], "Semgrep rules").map((r) => + record(r, "Semgrep 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"] ?? [], "Semgrep results").map((result) => ({ + result, + rules, + documentContext, + runContext, + driverContext, + toolContext, + })); + }); + return normalizeRecords( + entries, + ({ result: value, rules, ...context }) => { + const result = record(value, "Semgrep result"); + const properties = record( + result["properties"] ?? {}, + "Semgrep result properties", + ); + if ( + properties["exposure"] != null || + properties["dependencyPaths"] != null + ) + throw new Error( + "This adapter supports Semgrep SAST; Supply Chain reports need separate qualification.", + ); + const ruleId = requiredText(result["ruleId"], "Semgrep ruleId"); + const matches = rules.filter((r) => r["id"] === ruleId); + if (matches.length > 1 || (rules.length > 0 && matches.length !== 1)) + throw new Error( + "Semgrep result must reference exactly one rule in its run.", + ); + const rule = matches[0]; + const ruleIndex = rule === undefined ? undefined : rules.indexOf(rule); + if ( + result["ruleIndex"] !== undefined && + result["ruleIndex"] !== ruleIndex + ) + throw new Error("Semgrep ruleIndex and ruleId disagree."); + if (result["rule"] !== undefined) { + const ref = record(result["rule"], "Semgrep rule reference"); + if ( + ref["toolComponent"] !== undefined || + (ref["id"] !== undefined && ref["id"] !== ruleId) || + (ref["index"] !== undefined && ref["index"] !== ruleIndex) || + (ref["guid"] !== undefined && ref["guid"] !== rule?.["guid"]) + ) + throw new Error("Inconsistent or external Semgrep rule reference."); + } + const locations = array(result["locations"] ?? [], "Semgrep locations"); + if (locations.length !== 1) + throw new Error("Expected Semgrep's single primary result location."); + const physical = record( + record(locations[0], "Location")["physicalLocation"], + "Physical location", + ); + const artifact = record( + physical["artifactLocation"], + "Artifact location", + ); + const rawPath = sarifArtifactUri(artifact, context.runContext); + const region = record(physical["region"], "Semgrep region"); + const line = position(region["startLine"], "Start line"); + const column = position(region["startColumn"] ?? 1, "Start column"); + const endLine = position(region["endLine"] ?? line, "End line"); + const endColumn = position(region["endColumn"] ?? column, "End column"); + if (endLine < line || (endLine === line && endColumn < column)) + throw new Error("Semgrep source range ends before it starts."); + const message = record(result["message"], "Semgrep message"); + const configuration = record( + rule?.["defaultConfiguration"] ?? {}, + "Rule configuration", + ); + const level = text(result["level"]) ?? text(configuration["level"]); + // SARIF collapses severity categories; retain its diagnostic level and all original metadata. + const mappedSeverity = + level === "error" + ? "high" + : level === "warning" + ? "medium" + : level === "note" + ? "low" + : "unknown"; + const path = rawPath === null ? null : relativePath(rawPath); + return { + source_finding_id: sarifIdentity(result, context.runContext), + evidence: validateExternalEvidence({ + title: ruleId, + description: + text(message["markdown"]) ?? messageText(message["text"]), + severity: mappedSeverity, + locations: path ? [{ path, line }] : [], + details: { + kind: "sast", + repository: { url: repositoryUrl }, + scanner_origin: "Semgrep", + rule: { id: ruleId }, + code: { + end_line: path ? endLine : null, + snippet: ungatedText( + record(region["snippet"] ?? {}, "Snippet")["text"], + ), + }, + source_status: + Array.isArray(result["suppressions"]) && + result["suppressions"].length + ? "suppressed" + : null, + }, + source_data: structuredClone({ + ...context, + ...(rule ? { rule } : {}), + result, + }), + }), + }; + }, + ({ result, runContext }) => sarifIdentity(result, runContext), + ); +} + +export async function readSemgrepFindings( + path: string, + options: SemgrepImportOptions, +): Promise { + return normalizeSemgrepFindings(await readScannerJson(path), options); +} diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index 688e05880..070f75a5a 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2964,3 +2964,190 @@ test("Endor source repository binding preserves immutable requests across a repo expect(f.posts).toHaveLength(posted); expect(input).toEqual(original); }); + +test("Semgrep publication preserves identity across JSON and SARIF with explicit repository provenance", async () => { + const { semgrepJson, semgrepSarif } = + await import("./support/semgrep-import-fixtures.js"); + const f = await fixture(semgrepJson()); + const settings = { + ...options, + provider: "semgrep" as const, + sourceKey: "organization/project/main", + sourceRepositoryUrl: "https://github.com/example/project", + }; + const first = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(first.counts.created).toBe(1); + expect(first.verified).toBe(1); + const original = structuredClone([...f.reports.values()][0]!); + expect(original.source.provider).toBe("semgrep"); + expect(original.evidence.details?.kind).toBe("sast"); + expect( + ( + await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish() + ).counts.unchanged, + ).toBe(1); + await writeFile(f.file, JSON.stringify(semgrepSarif())); + const changed = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(changed.counts.updated).toBe(1); + const updated = [...f.reports.values()][0]!; + expect(updated.id).toBe(original.id); + expect(updated.canonical_finding_id).toBe(original.canonical_finding_id); + expect(updated.evidence.source_data).toMatchObject({ + result: semgrepSarif().runs[0]!.results[0], + }); +}); + +test("Semgrep native percent paths retain CE records through JSON-to-SARIF publication", async () => { + const { semgrepJson, semgrepSarif } = + await import("./support/semgrep-import-fixtures.js"); + const paths = [ + "src/my file.ts", + "src/my%20file.ts", + "src/100%.ts", + "src/100%25.ts", + ]; + const json: any = semgrepJson(); + const sarif: any = semgrepSarif(); + delete json.results[0].extra.fingerprint; + delete sarif.runs[0].results[0].fingerprints; + json.results = paths.map((path) => ({ ...json.results[0], path })); + const run = sarif.runs[0]; + run.results = paths.map((path) => { + const result = structuredClone(run.results[0]); + result.locations[0].physicalLocation.artifactLocation.uri = path; + return result; + }); + const f = await fixture(json); + const settings = { + ...options, + provider: "semgrep" as const, + sourceKey: "organization/project/main", + sourceRepositoryUrl: "https://github.com/example/project", + }; + const first = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(first.counts.created).toBe(paths.length); + expect(first.verified).toBe(paths.length); + const originalIds = [...f.reports.values()] + .map((report) => [report.id, report.canonical_finding_id]) + .sort(); + await writeFile(f.file, JSON.stringify(sarif)); + const changed = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(changed.counts.created).toBe(0); + expect(changed.counts.updated).toBe(paths.length); + expect(changed.verified).toBe(paths.length); + expect( + [...f.reports.values()] + .map((report) => [report.id, report.canonical_finding_id]) + .sort(), + ).toEqual(originalIds); + expect( + [...f.reports.values()] + .map((report) => report.evidence.locations?.[0]?.path) + .sort(), + ).toEqual(paths.toSorted()); + expect( + ( + await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish() + ).counts.unchanged, + ).toBe(paths.length); +}); + +test("Semgrep publication retains unsupported SARIF bases without repository locations", async () => { + const { semgrepSarif } = await import("./support/semgrep-import-fixtures.js"); + const input: any = semgrepSarif(); + const run = input.runs[0]; + run.results[0].locations[0].physicalLocation.artifactLocation.uriBaseId = + "ROOT"; + run.originalUriBaseIds = { ROOT: { uri: "file:///outside/" } }; + const f = await fixture(input); + const settings = { + ...options, + provider: "semgrep" as const, + sourceKey: "organization/project/main", + sourceRepositoryUrl: "https://github.com/example/project", + }; + const first = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(first.counts.created).toBe(1); + expect(first.verified).toBe(1); + const original = structuredClone([...f.reports.values()][0]!); + expect(original.evidence.locations).toEqual([]); + expect(original.evidence.source_data).toMatchObject({ + result: run.results[0], + runContext: { originalUriBaseIds: run.originalUriBaseIds }, + }); + expect(JSON.parse(f.posts[0]!).items[0].evidence.locations).toEqual([]); + + run.originalUriBaseIds.ROOT.uri = "subproject/"; + await writeFile(f.file, JSON.stringify(input)); + const changed = await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish(); + expect(changed.counts.updated).toBe(1); + const updated = [...f.reports.values()][0]!; + expect(updated.id).toBe(original.id); + expect(updated.canonical_finding_id).toBe(original.canonical_finding_id); + expect(updated.evidence.locations).toEqual([]); + expect(updated.evidence.source_data).toMatchObject({ + runContext: { originalUriBaseIds: run.originalUriBaseIds }, + }); + expect( + ( + await ( + await prepareExternalPublication(f.file, settings, f.deps) + ).publish() + ).counts.unchanged, + ).toBe(1); +}); + +test("Semgrep refuses missing source identity and a conflicting Cloud destination without uploads", async () => { + const { semgrepJson } = await import("./support/semgrep-import-fixtures.js"); + const f = await fixture(semgrepJson()); + await expect( + prepareExternalPublication( + f.file, + { ...options, provider: "semgrep" }, + f.deps, + ), + ).rejects.toThrow("sourceRepositoryUrl"); + await expect( + prepareExternalPublication( + f.file, + { + ...options, + provider: "semgrep", + sourceRepositoryUrl: "https://github.com/other/project", + }, + f.deps, + ), + ).rejects.toThrow(/repository/i); + expect(f.posts).toHaveLength(0); +}); + +test("Semgrep CLI dry-run preserves explicit source repository and does not upload", async () => { + const { semgrepJson } = await import("./support/semgrep-import-fixtures.js"); + const f = await fixture(semgrepJson()); + const command = [ + ...f.command, + "--dry-run", + "--source-repository-url", + "https://github.com/example/project", + ]; + command[command.indexOf("--provider") + 1] = "semgrep"; + 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/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts new file mode 100644 index 000000000..7dd2f28c6 --- /dev/null +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -0,0 +1,610 @@ +import { expect, test } from "bun:test"; +import { + normalizeSemgrepFindings as normalizeJson, + normalizeSemgrepSarif as normalizeSarif, +} from "../src/semgrep-findings.js"; +import { + semgrepJson, + semgrepSarif, +} from "./support/semgrep-import-fixtures.js"; + +test("Semgrep JSON preserves source severity, findings, source assertions and report metadata", () => { + const input = semgrepJson(); + const original = structuredClone(input); + const output = normalizeSemgrepFindings(input); + expect(input).toEqual(original); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence).toMatchObject({ + title: "example.synthetic-rule", + severity: "critical", + locations: [{ path: "src/example.ts", line: 3 }], + source_data: { + result: original.results[0], + report: { version: original.version, errors: [] }, + }, + }); + expect(output.findings[0]!.evidence).not.toHaveProperty("assessment"); +}); + +test("platform fingerprints survive line, severity and rule text updates", () => { + const input = semgrepJson(); + const first = normalizeSemgrepFindings(input).findings[0]!; + input.results[0]!.start.line = 9; + input.results[0]!.end.line = 9; + input.results[0]!.extra.severity = "LOW"; + input.results[0]!.extra.message = "Updated source message"; + const second = normalizeSemgrepFindings(input).findings[0]!; + expect(second.source_finding_id).toBe(first.source_finding_id); + expect(second.evidence).not.toEqual(first.evidence); + expect( + normalizeSemgrepSarif(semgrepSarif()).findings[0]!.source_finding_id, + ).toBe(first.source_finding_id); +}); + +test("Community Edition fallback is stable for identical ranges and distinct for moved findings", () => { + const input: any = semgrepJson(); + delete input.results[0].extra.fingerprint; + const first = normalizeSemgrepFindings(input).findings[0]!; + input.results[0].extra.message = "New message"; + expect(normalizeSemgrepFindings(input).findings[0]!.source_finding_id).toBe( + first.source_finding_id, + ); + input.results[0].start.line++; + input.results[0].end.line++; + expect( + normalizeSemgrepFindings(input).findings[0]!.source_finding_id, + ).not.toBe(first.source_finding_id); +}); + +test.each([ + ["ERROR", "high"], + ["WARNING", "medium"], + ["INFO", "low"], + ["CRITICAL", "critical"], + ["HIGH", "high"], + ["MEDIUM", "medium"], + ["LOW", "low"], + ["EXPERIMENT", "unknown"], +] as const)("maps severity %s to %s", (inputLevel, expected) => { + const input = semgrepJson(); + input.results[0]!.extra.severity = inputLevel!; + expect(normalizeSemgrepFindings(input).findings[0]!.evidence.severity).toBe( + expected, + ); +}); + +test("SARIF retains complete selected rule, suppressions and dataflow", () => { + const input = semgrepSarif(); + const output = normalizeSemgrepFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence).toMatchObject({ + severity: "high", + source_data: { + result: input.runs[0]!.results[0], + rule: input.runs[0]!.tool.driver.rules[0], + }, + }); +}); + +test("failed scans and unrelated SARIF producers fail before publishing partial results", () => { + const input: any = semgrepJson(); + input.errors = [{ message: "Scan incomplete" }]; + expect(() => normalizeSemgrepFindings(input)).toThrow("scan errors"); + const sarif = semgrepSarif(); + sarif.runs[0]!.invocations[0]!.executionSuccessful = false; + expect(() => normalizeSemgrepFindings(sarif)).toThrow("unsuccessful"); + sarif.runs[0]!.tool.driver.name = "other"; + expect(() => normalizeSemgrepFindings(sarif)).toThrow( + "Semgrep SARIF producer", + ); +}); + +test("all duplicate fingerprints are excluded even when one result is invalid", () => { + const input = semgrepJson(); + input.results.push(structuredClone(input.results[0]!)); + input.results[1]!.extra.message = "x".repeat(65537); + const output = normalizeSemgrepFindings(input); + expect(output.findings).toEqual([]); + expect(output.excluded).toHaveLength(2); + const sarif: any = semgrepSarif(); + sarif.runs[0].results.push(structuredClone(sarif.runs[0].results[0])); + delete sarif.runs[0].results[1].message; + expect(normalizeSemgrepFindings(sarif).findings).toEqual([]); +}); + +test("unknown paths stay raw and invalid ranges are excluded", () => { + const input = semgrepJson(); + input.results[0]!.path = "../outside.ts"; + expect( + normalizeSemgrepFindings(input).findings[0]!.evidence.locations, + ).toEqual([]); + input.results[0]!.start.line = 0; + expect(normalizeSemgrepFindings(input).findings).toEqual([]); +}); + +test("500 distinct fingerprints survive reordering", () => { + const input = semgrepJson(); + const original = input.results[0]!; + input.results = Array.from({ length: 500 }, (_, i) => ({ + ...structuredClone(original), + extra: { ...original.extra, fingerprint: `synthetic-${i}` }, + })); + const first = normalizeSemgrepFindings(input); + input.results.reverse(); + const second = normalizeSemgrepFindings(input); + expect(first.findings).toHaveLength(500); + expect(second.excluded).toEqual([]); + expect(first.findings.map((f) => f.source_finding_id).sort()).toEqual( + second.findings.map((f) => f.source_finding_id).sort(), + ); +}); + +const options = { sourceRepositoryUrl: "https://github.com/example/project" }; +const normalizeSemgrepFindings = (input: unknown) => + normalizeJson(input, options); +const normalizeSemgrepSarif = (input: unknown) => + normalizeSarif(input, options); + +test("requires source identity and rejects conflicting SARIF repository provenance", () => { + expect(() => + normalizeJson(semgrepJson(), { sourceRepositoryUrl: "" }), + ).toThrow("sourceRepositoryUrl"); + const input: any = semgrepSarif(); + input.runs[0].versionControlProvenance = [ + { repositoryUri: "https://github.com/other/project" }, + ]; + expect(() => normalizeSemgrepFindings(input)).toThrow("does not match"); +}); + +test.each([true, false])( + "direct SARIF URIs decode once and match JSON identity (fingerprint=%s)", + (fingerprint) => { + for (const path of ["src/my file.ts", "src/my%20file.ts"]) { + const json: any = semgrepJson(); + const sarif: any = semgrepSarif(); + json.results[0].path = path; + const artifact = + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation; + delete artifact.uriBaseId; + artifact.uri = path.split("/").map(encodeURIComponent).join("/"); + if (!fingerprint) { + delete json.results[0].extra.fingerprint; + delete sarif.runs[0].results[0].fingerprints; + } + const original = structuredClone(sarif); + const finding = normalizeSemgrepSarif(sarif).findings[0]!; + expect(finding.evidence.locations).toEqual([{ path, line: 3 }]); + expect(finding.source_finding_id).toBe( + normalizeSemgrepFindings(json).findings[0]!.source_finding_id, + ); + expect(finding.evidence.source_data).toMatchObject({ + result: original.runs[0].results[0], + }); + expect(sarif).toEqual(original); + } + }, +); + +test.each([true, false])( + "native SARIF preserves literal percent paths (fingerprint=%s)", + (fingerprint) => { + for (const path of [ + "src/my file.ts", + "src/my%20file.ts", + "src/100%.ts", + "src/%2Ffile.ts", + "src/literal%2e%2e.ts", + "src/caf%C3%A9.ts", + "src/café.ts", + ]) { + const json: any = semgrepJson(); + const sarif: any = semgrepSarif(); + json.results[0].path = path; + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri = + path; + if (!fingerprint) { + delete json.results[0].extra.fingerprint; + delete sarif.runs[0].results[0].fingerprints; + } + const original = structuredClone(sarif); + const output = normalizeSemgrepSarif(sarif); + expect(output.excluded).toEqual([]); + expect(output.findings).toHaveLength(1); + const finding = output.findings[0]!; + expect(finding.evidence.locations).toEqual([{ path, line: 3 }]); + expect(finding.source_finding_id).toBe( + normalizeSemgrepFindings(json).findings[0]!.source_finding_id, + ); + expect(finding.evidence.source_data).toMatchObject({ + result: original.runs[0].results[0], + }); + expect(sarif).toEqual(original); + } + }, +); + +test("native SARIF keeps CE percent paths distinct after reordering", () => { + const paths = [ + "src/my file.ts", + "src/my%20file.ts", + "src/100%.ts", + "src/100%25.ts", + ]; + const json: any = semgrepJson(); + const sarif: any = semgrepSarif(); + delete json.results[0].extra.fingerprint; + delete sarif.runs[0].results[0].fingerprints; + json.results = paths.map((path) => ({ ...json.results[0], path })); + const run = sarif.runs[0]; + run.results = paths.map((path) => { + const result = structuredClone(run.results[0]); + result.locations[0].physicalLocation.artifactLocation.uri = path; + return result; + }); + const expected = normalizeSemgrepFindings(json).findings; + expect(expected).toHaveLength(paths.length); + for (const results of [run.results, run.results.toReversed()]) { + run.results = results; + const output = normalizeSemgrepSarif(sarif); + expect(output.excluded).toEqual([]); + expect(output.findings).toHaveLength(paths.length); + expect(output.findings.map((x) => x.source_finding_id).sort()).toEqual( + expected.map((x) => x.source_finding_id).sort(), + ); + expect( + output.findings.map((x) => x.evidence.locations?.[0]?.path).sort(), + ).toEqual(paths.toSorted()); + } +}); + +test.each([ + "%2e%2e/outside.ts", + "%2Foutside.ts", + "C%3A%5Coutside.ts", + "src/%00.ts", +])( + "SARIF retains encoded unsafe URI %s without a repository location", + (uri) => { + const input: any = semgrepSarif(); + const artifact = + input.runs[0].results[0].locations[0].physicalLocation.artifactLocation; + delete artifact.uriBaseId; + artifact.uri = uri; + const output = normalizeSemgrepSarif(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([]); + expect(output.findings[0]!.evidence.details?.code?.end_line).toBeNull(); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: input.runs[0]!.results[0], + }); + }, +); + +test("SARIF matches custom GitHub Enterprise repository casing without merging ports or schemes", () => { + const input: any = semgrepSarif(); + input.runs[0].versionControlProvenance = [ + { repositoryUri: "https://git.example.test/Example/Project.git" }, + ]; + expect( + normalizeSarif(input, { + sourceRepositoryUrl: "https://GIT.EXAMPLE.TEST/example/project/", + }).findings, + ).toHaveLength(1); + for (const sourceRepositoryUrl of [ + "http://git.example.test/example/project", + "https://git.example.test:443/example/project", + "https://git.example.test/other/project", + ]) { + expect(() => normalizeSarif(input, { sourceRepositoryUrl })).toThrow( + "does not match", + ); + } +}); + +test.each([ + [ + "external base", + { uri: "src/example.ts", uriBaseId: "ROOT" }, + { ROOT: { uri: "file:///outside/" } }, + ], + [ + "subdirectory base", + { uri: "src/example.ts", uriBaseId: "PACKAGE" }, + { PACKAGE: { uri: "project-a/", uriBaseId: "%SRCROOT%" } }, + ], + [ + "redefined source root", + { uri: "src/example.ts", uriBaseId: "%SRCROOT%" }, + { "%SRCROOT%": { uri: "project-a/", uriBaseId: "ROOT" } }, + ], + [ + "explicit source-root URI", + { uri: "src/example.ts", uriBaseId: "%SRCROOT%" }, + { "%SRCROOT%": { uri: "file:///repo/subproject/" } }, + ], + ["indexed URI", { uri: "src/example.ts", index: 0 }, {}], + ["index only", { index: 0 }, {}], +] as const)( + "SARIF retains unsupported %s without inventing a path or CE identity", + (_label, artifact, bases) => { + const input: any = semgrepSarif(); + const expectedId = + normalizeSemgrepSarif(input).findings[0]!.source_finding_id; + const run = input.runs[0]; + run.originalUriBaseIds = structuredClone(bases); + run.artifacts = [{ location: { uri: "nested/actual.ts" } }]; + run.results[0].locations[0].physicalLocation.artifactLocation = + structuredClone(artifact); + const original = structuredClone(input); + const output = normalizeSemgrepSarif(input); + expect(output.excluded).toEqual([]); + expect(output.findings).toHaveLength(1); + expect(output.findings[0]!.source_finding_id).toBe(expectedId); + expect(output.findings[0]!.evidence.locations).toEqual([]); + expect(output.findings[0]!.evidence.details?.code?.end_line).toBeNull(); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: original.runs[0].results[0], + runContext: { + originalUriBaseIds: bases, + artifacts: original.runs[0].artifacts, + }, + }); + expect(input).toEqual(original); + + delete run.results[0].fingerprints; + const ce = normalizeSemgrepSarif(input); + expect(ce.findings).toEqual([]); + expect(ce.excluded).toHaveLength(1); + expect(ce.excluded[0]!.source_finding_id).toBeNull(); + expect(ce.excluded[0]!.reason).toContain("indexed or unresolved"); + }, +); + +test("SARIF unresolved bases cannot collide with a valid CE location", () => { + const input: any = semgrepSarif(); + const run = input.runs[0]; + delete run.results[0].fingerprints; + const expected = normalizeSemgrepSarif(input).findings[0]!; + const first = structuredClone(run.results[0]); + const second = structuredClone(run.results[0]); + first.locations[0].physicalLocation.artifactLocation.uriBaseId = "A"; + second.locations[0].physicalLocation.artifactLocation.uriBaseId = "B"; + run.originalUriBaseIds = { + A: { uri: "project-a/", uriBaseId: "%SRCROOT%" }, + B: { uri: "project-b/", uriBaseId: "%SRCROOT%" }, + }; + run.results.unshift(first, second); + for (const results of [run.results, run.results.toReversed()]) { + run.results = results; + const output = normalizeSemgrepSarif(input); + expect(output.findings).toHaveLength(1); + expect(output.findings[0]!.source_finding_id).toBe( + expected.source_finding_id, + ); + expect(output.findings[0]!.evidence.locations).toEqual( + expected.evidence.locations, + ); + expect(output.excluded).toHaveLength(2); + for (const excluded of output.excluded) { + expect(excluded.source_finding_id).toBeNull(); + expect(excluded.reason).toContain("indexed or unresolved"); + } + } +}); + +test.each([undefined, "%SRCROOT%"])( + "SARIF native root %s preserves CE identity across JSON and SARIF", + (base) => { + const json: any = semgrepJson(); + const input: any = semgrepSarif(); + delete json.results[0].extra.fingerprint; + delete input.runs[0].results[0].fingerprints; + input.runs[0].originalUriBaseIds = { OTHER: { uri: "file:///outside/" } }; + const artifact = + input.runs[0].results[0].locations[0].physicalLocation.artifactLocation; + if (base === undefined) delete artifact.uriBaseId; + else artifact.uriBaseId = base; + const output = normalizeSemgrepSarif(input); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.locations).toEqual([ + { path: "src/example.ts", line: 3 }, + ]); + expect(output.findings[0]!.source_finding_id).toBe( + normalizeSemgrepFindings(json).findings[0]!.source_finding_id, + ); + }, +); + +test("gated CE fingerprint placeholders use independent location identities", () => { + const input: any = semgrepSarif(); + delete input.runs[0].tool.driver.rules; + const first = input.runs[0]!.results[0]!; + first.fingerprints["matchBasedId/v1"] = "requires login"; + const second = structuredClone(first); + second.locations[0]!.physicalLocation.region.startLine = 6; + second.locations[0]!.physicalLocation.region.endLine = 6; + input.runs[0]!.results.push(second); + const output = normalizeSemgrepFindings(input); + expect(output.findings).toHaveLength(2); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.source_finding_id).not.toBe( + output.findings[1]!.source_finding_id, + ); +}); + +test("does not relabel Supply Chain or Secrets findings as SAST", () => { + for (const extra of [ + { sca_info: { reachable: true } }, + { validation_state: "CONFIRMED_VALID" }, + ]) { + const input: any = semgrepJson(); + Object.assign(input.results[0].extra, extra); + const output = normalizeSemgrepFindings(input); + expect(output.findings).toEqual([]); + expect(output.excluded[0]!.reason).toContain("separate qualification"); + } +}); + +test.each(["Semgrep PRO", "semgrep"])( + "accepts the native %s SARIF producer name", + (producer) => { + const input = semgrepSarif(); + input.runs[0]!.tool.driver.name = producer; + const output = normalizeSemgrepSarif(input); + expect(output.findings).toHaveLength(1); + expect(output.excluded).toEqual([]); + }, +); + +test.each(["omitted", "empty"])( + "accepts CE results with %s rule descriptors", + (descriptors) => { + const input: any = semgrepSarif(); + const run = input.runs[0]; + if (descriptors === "omitted") delete run.tool.driver.rules; + else run.tool.driver.rules = []; + run.results[0].fingerprints["matchBasedId/v1"] = "requires login"; + run.results[0].level = "warning"; + const known = normalizeSemgrepSarif(input); + expect(known.excluded).toEqual([]); + expect(known.findings).toHaveLength(1); + expect(known.findings[0]!.evidence.severity).toBe("medium"); + expect(known.findings[0]!.evidence.source_data).not.toHaveProperty("rule"); + delete run.results[0].level; + const unknown = normalizeSemgrepSarif(input); + expect(unknown.excluded).toEqual([]); + expect(unknown.findings[0]!.evidence.severity).toBe("unknown"); + expect(unknown.findings[0]!.source_finding_id).toBe( + known.findings[0]!.source_finding_id, + ); + }, +); + +test.each([ + { ruleIndex: 0 }, + { ruleIndex: -1 }, + { rule: { index: 0 } }, + { rule: { guid: "synthetic-rule-guid" } }, +])( + "rejects unresolved references when CE descriptors are absent (%p)", + (reference) => { + const input: any = semgrepSarif(); + delete input.runs[0].tool.driver.rules; + Object.assign(input.runs[0].results[0], reference); + const output = normalizeSemgrepSarif(input); + expect(output.findings).toEqual([]); + expect(output.excluded).toHaveLength(1); + }, +); + +test.each([ + ["secrets", undefined], + ["secrets", null], + ["sca", undefined], + ["sca", null], + ["secrets", "NO_VALIDATOR"], + ["sca", "NO_VALIDATOR"], +] as const)( + "excludes explicit %s product without validator metadata (%p)", + (product, validation) => { + const input: any = semgrepJson(); + input.results[0].extra.metadata = { product }; + input.results[0].extra.validation_state = validation; + const output = normalizeSemgrepFindings(input); + expect(output.findings).toEqual([]); + expect(output.excluded[0]!.reason).toContain("supports Semgrep SAST"); + }, +); + +test("accepts an explicit SAST product marker", () => { + const input: any = semgrepJson(); + input.results[0].extra.metadata = { product: "sast" }; + expect(normalizeSemgrepFindings(input).findings).toHaveLength(1); +}); + +test.each(["error", "warning", "note", undefined])( + "rejects native SARIF scan notifications even with executionSuccessful=true (%s)", + (level) => { + const input: any = semgrepSarif(); + input.runs[0].invocations = [ + { + executionSuccessful: true, + toolExecutionNotifications: [ + { level, message: { text: "A source file could not be analyzed" } }, + ], + }, + ]; + expect(() => normalizeSemgrepSarif(input)).toThrow( + "scan error notifications", + ); + }, +); + +test("large scan inventories and timing tables do not exclude a small finding", () => { + const input: any = semgrepJson(); + const paths = Array.from( + { length: 10000 }, + (_, index) => `path-inventory-entry-${index}/ordinary-source-file.ts`, + ); + input.paths = { scanned: paths }; + input.time = { targets: paths.map((path) => ({ path, parse_time: 0.001 })) }; + const output = normalizeSemgrepFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings).toHaveLength(1); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: input.results[0], + }); + expect( + JSON.stringify(output.findings[0]!.evidence.source_data), + ).not.toContain("path-inventory-entry-"); + expect(input.paths.scanned).toHaveLength(10000); +}); + +test.each([undefined, "sast"])( + "accepts the documented Code NO_VALIDATOR marker with product %p", + (product) => { + const input: any = semgrepJson(); + if (product) input.results[0].extra.metadata = { product }; + else delete input.results[0].extra.metadata; + input.results[0].extra.validation_state = "NO_VALIDATOR"; + const output = normalizeSemgrepFindings(input); + expect(output.findings).toHaveLength(1); + expect(output.excluded).toEqual([]); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: { extra: { validation_state: "NO_VALIDATOR" } }, + }); + }, +); + +test.each(["json", "sarif"])( + "preserves %s findings with empty native messages", + (format) => { + const input: any = format === "json" ? semgrepJson() : semgrepSarif(); + if (format === "json") input.results[0].extra.message = ""; + else input.runs[0].results[0].message = { text: "" }; + const output = normalizeSemgrepFindings(input); + expect(output.excluded).toEqual([]); + expect(output.findings).toHaveLength(1); + expect(output.findings[0]!.evidence.description).toBeNull(); + expect(output.findings[0]!.evidence.source_data).toMatchObject({ + result: + format === "json" + ? { extra: { message: "" } } + : { message: { text: "" } }, + }); + }, +); + +test.each(["json", "sarif"])( + "still excludes %s findings with missing or non-string messages", + (format) => { + for (const message of [undefined, null, 42]) { + const input: any = format === "json" ? semgrepJson() : semgrepSarif(); + if (format === "json") input.results[0].extra.message = message; + else input.runs[0].results[0].message = { text: message }; + const output = normalizeSemgrepFindings(input); + expect(output.findings).toEqual([]); + expect(output.excluded).toHaveLength(1); + } + }, +); diff --git a/sdk/typescript/tests-ts/support/semgrep-import-fixtures.ts b/sdk/typescript/tests-ts/support/semgrep-import-fixtures.ts new file mode 100644 index 000000000..772a2b038 --- /dev/null +++ b/sdk/typescript/tests-ts/support/semgrep-import-fixtures.ts @@ -0,0 +1,75 @@ +// Synthetic scanner output; no Semgrep rule implementations or real findings. +export function semgrepJson() { + return { + version: "1.100.0", + errors: [], + paths: { scanned: ["src/example.ts"] }, + results: [ + { + check_id: "example.synthetic-rule", + path: "src/example.ts", + start: { line: 3, col: 1, offset: 20 }, + end: { line: 3, col: 9, offset: 28 }, + extra: { + message: "Synthetic finding", + severity: "CRITICAL", + fingerprint: "synthetic-fingerprint", + is_ignored: true, + lines: "example()", + metadata: { + cwe: ["CWE-000"], + references: ["https://example.invalid/rule"], + }, + }, + }, + ], + }; +} + +export function semgrepSarif() { + return { + version: "2.1.0", + runs: [ + { + invocations: [{ executionSuccessful: true }], + tool: { + driver: { + name: "Semgrep OSS", + rules: [ + { + id: "example.synthetic-rule", + defaultConfiguration: { level: "error" }, + help: { text: "Synthetic rule help" }, + }, + ], + }, + }, + results: [ + { + ruleId: "example.synthetic-rule", + fingerprints: { "matchBasedId/v1": "synthetic-fingerprint" }, + message: { text: "Synthetic finding" }, + suppressions: [{ kind: "inSource" }], + codeFlows: [{ message: { text: "Synthetic dataflow" } }], + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: "src/example.ts", + uriBaseId: "%SRCROOT%", + }, + region: { + startLine: 3, + startColumn: 1, + endLine: 3, + endColumn: 9, + }, + }, + }, + ], + }, + ], + }, + ], + }; +}