From eb87eee729ae94f8966cd4cda831ca2a5689036f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 22:28:59 +0000 Subject: [PATCH 1/9] feat: import Semgrep SAST findings --- docs/scanner-imports.md | 38 + .../schemas/external-findings.schema.json | 768 +++++++++--------- sdk/typescript/src/cli.ts | 13 +- .../src/external-findings-publish.ts | 12 +- sdk/typescript/src/external-import-models.ts | 450 +++++----- sdk/typescript/src/index.ts | 7 + sdk/typescript/src/semgrep-findings.ts | 378 +++++++++ .../external-findings-publish.test.ts | 77 ++ .../tests-ts/semgrep-findings.test.ts | 186 +++++ .../support/semgrep-import-fixtures.ts | 75 ++ 10 files changed, 1390 insertions(+), 614 deletions(-) create mode 100644 sdk/typescript/src/semgrep-findings.ts create mode 100644 sdk/typescript/tests-ts/semgrep-findings.test.ts create mode 100644 sdk/typescript/tests-ts/support/semgrep-import-fixtures.ts diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index c7b4d560f..c9eccafe0 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -62,3 +62,41 @@ 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. +- 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. +- JSON scan errors and unsuccessful SARIF invocations 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..e5fe3c752 100644 --- a/sdk/typescript/schemas/external-findings.schema.json +++ b/sdk/typescript/schemas/external-findings.schema.json @@ -9,19 +9,19 @@ "title": "Client Id", "type": "string" }, - "source_finding_id": { - "maxLength": 512, - "minLength": 1, - "title": "Source Finding Id", - "type": "string" + "evidence": { + "$ref": "#/$defs/ImportedFindingEvidence" }, "expected_version": { "minimum": 0, "title": "Expected Version", "type": "integer" }, - "evidence": { - "$ref": "#/$defs/ImportedFindingEvidence" + "source_finding_id": { + "maxLength": 512, + "minLength": 1, + "title": "Source Finding Id", + "type": "string" } }, "required": [ @@ -36,39 +36,36 @@ "FindingImportReceipt": { "additionalProperties": false, "properties": { - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, - "object": { - "const": "security.finding_import", - "default": "security.finding_import", - "title": "Object", - "type": "string" - }, - "repository": { - "$ref": "#/$defs/ImportDestination" - }, - "source": { - "$ref": "#/$defs/ImportSource" - }, "actor": { "title": "Actor", "type": "string" }, + "counts": { + "$ref": "#/$defs/ImportCounts" + }, "created_at": { "title": "Created At", "type": "integer" }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, "item_count": { "maximum": 100, "minimum": 1, "title": "Item Count", "type": "integer" }, - "counts": { - "$ref": "#/$defs/ImportCounts" + "object": { + "const": "security.finding_import", + "default": "security.finding_import", + "title": "Object", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/ImportDestination" }, "results": { "items": { @@ -76,6 +73,9 @@ }, "title": "Results", "type": "array" + }, + "source": { + "$ref": "#/$defs/ImportSource" } }, "required": [ @@ -94,17 +94,6 @@ "FindingImportRequest": { "additionalProperties": false, "properties": { - "request_id": { - "format": "uuid", - "title": "Request Id", - "type": "string" - }, - "repository": { - "$ref": "#/$defs/ImportDestination" - }, - "source": { - "$ref": "#/$defs/ImportSource" - }, "items": { "items": { "$ref": "#/$defs/FindingImportItem" @@ -113,6 +102,17 @@ "minItems": 1, "title": "Items", "type": "array" + }, + "repository": { + "$ref": "#/$defs/ImportDestination" + }, + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "source": { + "$ref": "#/$defs/ImportSource" } }, "required": ["request_id", "repository", "source", "items"], @@ -122,27 +122,33 @@ "FindingImportResult": { "additionalProperties": false, "properties": { + "canonical_finding_id": { + "anyOf": [ + { + "pattern": "^acf_[0-9a-f]{32}$", + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Canonical Finding Id" + }, "client_id": { "title": "Client Id", "type": "string" }, - "outcome": { - "enum": ["created", "updated", "unchanged", "error"], - "title": "Outcome", - "type": "string" - }, - "source_report_id": { + "error": { "anyOf": [ { - "pattern": "^aif_[0-9a-f]{64}$", - "type": "string" + "$ref": "#/$defs/ImportItemError" }, { "type": "null" } ], - "default": null, - "title": "Source Report Id" + "default": null }, "observation_id": { "anyOf": [ @@ -157,10 +163,15 @@ "default": null, "title": "Observation Id" }, - "canonical_finding_id": { + "outcome": { + "enum": ["created", "updated", "unchanged", "error"], + "title": "Outcome", + "type": "string" + }, + "source_report_id": { "anyOf": [ { - "pattern": "^acf_[0-9a-f]{32}$", + "pattern": "^aif_[0-9a-f]{64}$", "type": "string" }, { @@ -168,7 +179,7 @@ } ], "default": null, - "title": "Canonical Finding Id" + "title": "Source Report Id" }, "version": { "anyOf": [ @@ -182,17 +193,6 @@ ], "default": null, "title": "Version" - }, - "error": { - "anyOf": [ - { - "$ref": "#/$defs/ImportItemError" - }, - { - "type": "null" - } - ], - "default": null } }, "required": ["client_id", "outcome"], @@ -208,10 +208,10 @@ "title": "Created", "type": "integer" }, - "updated": { + "error": { "default": 0, "minimum": 0, - "title": "Updated", + "title": "Error", "type": "integer" }, "unchanged": { @@ -220,10 +220,10 @@ "title": "Unchanged", "type": "integer" }, - "error": { + "updated": { "default": 0, "minimum": 0, - "title": "Error", + "title": "Updated", "type": "integer" } }, @@ -233,22 +233,22 @@ "ImportDestination": { "additionalProperties": false, "properties": { - "id": { + "environment_id": { "maxLength": 512, "minLength": 1, - "title": "Id", + "title": "Environment Id", "type": "string" }, - "repo_connector_id": { + "id": { "maxLength": 512, "minLength": 1, - "title": "Repo Connector Id", + "title": "Id", "type": "string" }, - "environment_id": { + "repo_connector_id": { "maxLength": 512, "minLength": 1, - "title": "Environment Id", + "title": "Repo Connector Id", "type": "string" }, "reset_marker": { @@ -286,24 +286,6 @@ "ImportRepository": { "additionalProperties": false, "properties": { - "id": { - "title": "Id", - "type": "string" - }, - "object": { - "const": "security.repository", - "default": "security.repository", - "title": "Object", - "type": "string" - }, - "repo_connector_id": { - "title": "Repo Connector Id", - "type": "string" - }, - "url": { - "title": "Url", - "type": "string" - }, "default_branch": { "anyOf": [ { @@ -316,8 +298,8 @@ "default": null, "title": "Default Branch" }, - "reset_marker": { - "title": "Reset Marker", + "id": { + "title": "Id", "type": "string" }, "import_environment_id": { @@ -331,6 +313,24 @@ ], "default": null, "title": "Import Environment Id" + }, + "object": { + "const": "security.repository", + "default": "security.repository", + "title": "Object", + "type": "string" + }, + "repo_connector_id": { + "title": "Repo Connector Id", + "type": "string" + }, + "reset_marker": { + "title": "Reset Marker", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" } }, "required": ["id", "repo_connector_id", "url", "reset_marker"], @@ -340,12 +340,6 @@ "ImportRepositoryPage": { "additionalProperties": false, "properties": { - "object": { - "const": "page", - "default": "page", - "title": "Object", - "type": "string" - }, "data": { "items": { "$ref": "#/$defs/ImportRepository" @@ -368,6 +362,12 @@ } ], "title": "Next" + }, + "object": { + "const": "page", + "default": "page", + "title": "Object", + "type": "string" } }, "required": ["data", "has_more", "next"], @@ -378,7 +378,7 @@ "additionalProperties": false, "properties": { "provider": { - "enum": ["wiz", "endor", "snyk", "github"], + "enum": ["wiz", "endor", "snyk", "github", "semgrep"], "title": "Provider", "type": "string" }, @@ -457,11 +457,10 @@ "ImportedConfigurationDetails": { "additionalProperties": false, "properties": { - "platform": { + "actual": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 65536, "type": "string" }, { @@ -469,7 +468,7 @@ } ], "default": null, - "title": "Platform" + "title": "Actual" }, "cloud_platform": { "anyOf": [ @@ -498,10 +497,11 @@ "default": null, "title": "Expected" }, - "actual": { + "platform": { "anyOf": [ { - "maxLength": 65536, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -509,7 +509,7 @@ } ], "default": null, - "title": "Actual" + "title": "Platform" } }, "title": "ImportedConfigurationDetails", @@ -519,50 +519,42 @@ "additionalProperties": false, "description": "Typed repository evidence; vendor state is not a Codex assessment.", "properties": { - "kind": { - "enum": ["sast", "secret", "iac", "advisory"], - "title": "Kind", - "type": "string" - }, - "repository": { + "advisory": { "anyOf": [ { - "$ref": "#/$defs/ImportedRepositoryReference" + "$ref": "#/$defs/ImportedAdvisoryContext" }, { "type": "null" } ] }, - "advisory": { + "code": { "anyOf": [ { - "$ref": "#/$defs/ImportedAdvisoryContext" + "$ref": "#/$defs/ImportedCodeContext" }, { "type": "null" } - ] + ], + "default": null }, - "source_status": { + "configuration": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, - "type": "string" + "$ref": "#/$defs/ImportedConfigurationDetails" }, { "type": "null" } ], - "default": null, - "title": "Source Status" + "default": null }, - "scanner_origin": { + "file_url": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 2048, "type": "string" }, { @@ -570,13 +562,17 @@ } ], "default": null, - "title": "Scanner Origin" + "title": "File Url" }, - "scanner_verdict": { + "kind": { + "enum": ["sast", "secret", "iac", "advisory"], + "title": "Kind", + "type": "string" + }, + "remediation_instructions": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 65536, "type": "string" }, { @@ -584,69 +580,61 @@ } ], "default": null, - "title": "Scanner Verdict" + "title": "Remediation Instructions" }, - "rule": { + "repository": { "anyOf": [ { - "$ref": "#/$defs/ImportedFindingRule" + "$ref": "#/$defs/ImportedRepositoryReference" }, { "type": "null" } - ], - "default": null - }, - "weakness_ids": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Weakness Ids", - "type": "array" + ] }, - "remediation_instructions": { + "rule": { "anyOf": [ { - "maxLength": 65536, - "type": "string" + "$ref": "#/$defs/ImportedFindingRule" }, { "type": "null" } ], - "default": null, - "title": "Remediation Instructions" + "default": null }, - "code": { + "scanner_origin": { "anyOf": [ { - "$ref": "#/$defs/ImportedCodeContext" + "maxLength": 512, + "minLength": 1, + "type": "string" }, { "type": "null" } ], - "default": null + "default": null, + "title": "Scanner Origin" }, - "secret": { + "scanner_verdict": { "anyOf": [ { - "$ref": "#/$defs/ImportedSecretDetails" + "maxLength": 512, + "minLength": 1, + "type": "string" }, { "type": "null" } ], - "default": null + "default": null, + "title": "Scanner Verdict" }, - "configuration": { + "secret": { "anyOf": [ { - "$ref": "#/$defs/ImportedConfigurationDetails" + "$ref": "#/$defs/ImportedSecretDetails" }, { "type": "null" @@ -654,10 +642,11 @@ ], "default": null }, - "file_url": { + "source_status": { "anyOf": [ { - "maxLength": 2048, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -665,7 +654,18 @@ } ], "default": null, - "title": "File Url" + "title": "Source Status" + }, + "weakness_ids": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Weakness Ids", + "type": "array" } }, "required": ["kind"], @@ -675,28 +675,36 @@ "ImportedFindingEvidence": { "additionalProperties": false, "properties": { - "title": { - "maxLength": 512, - "minLength": 1, - "title": "Title", - "type": "string" + "advisory_ids": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Advisory Ids", + "type": "array" }, - "severity": { - "enum": [ - "critical", - "high", - "medium", - "low", - "informational", - "unknown" + "branch": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } ], - "title": "Severity", - "type": "string" + "default": null, + "title": "Branch" }, - "description": { + "code_revision": { "anyOf": [ { - "maxLength": 65536, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -704,12 +712,12 @@ } ], "default": null, - "title": "Description" + "title": "Code Revision" }, - "url": { + "description": { "anyOf": [ { - "maxLength": 2048, + "maxLength": 65536, "type": "string" }, { @@ -717,9 +725,19 @@ } ], "default": null, - "title": "Url" + "title": "Description" }, - "advisory_ids": { + "details": { + "anyOf": [ + { + "$ref": "#/$defs/ImportedFindingDetails" + }, + { + "type": "null" + } + ] + }, + "image_digests": { "default": [], "items": { "maxLength": 512, @@ -727,54 +745,44 @@ "type": "string" }, "maxItems": 100, - "title": "Advisory Ids", + "title": "Image Digests", "type": "array" }, - "packages": { + "locations": { "default": [], "items": { - "$ref": "#/$defs/ImportedPackage" + "$ref": "#/$defs/ImportedLocation" }, "maxItems": 100, - "title": "Packages", + "title": "Locations", "type": "array" }, - "locations": { + "packages": { "default": [], "items": { - "$ref": "#/$defs/ImportedLocation" + "$ref": "#/$defs/ImportedPackage" }, "maxItems": 100, - "title": "Locations", + "title": "Packages", "type": "array" }, - "branch": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } + "severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "informational", + "unknown" ], - "default": null, - "title": "Branch" + "title": "Severity", + "type": "string" }, - "code_revision": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Code Revision" + "source_data": { + "additionalProperties": true, + "default": {}, + "title": "Source Data", + "type": "object" }, "source_scan_id": { "anyOf": [ @@ -790,17 +798,6 @@ "default": null, "title": "Source Scan Id" }, - "image_digests": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Image Digests", - "type": "array" - }, "source_updated_at": { "anyOf": [ { @@ -814,21 +811,24 @@ "default": null, "title": "Source Updated At" }, - "source_data": { - "additionalProperties": true, - "default": {}, - "title": "Source Data", - "type": "object" + "title": { + "maxLength": 512, + "minLength": 1, + "title": "Title", + "type": "string" }, - "details": { + "url": { "anyOf": [ { - "$ref": "#/$defs/ImportedFindingDetails" + "maxLength": 2048, + "type": "string" }, { "type": "null" } - ] + ], + "default": null, + "title": "Url" } }, "required": ["title", "severity"], @@ -852,7 +852,7 @@ "default": null, "title": "Id" }, - "short_id": { + "name": { "anyOf": [ { "maxLength": 512, @@ -864,9 +864,9 @@ } ], "default": null, - "title": "Short Id" + "title": "Name" }, - "name": { + "short_id": { "anyOf": [ { "maxLength": 512, @@ -878,7 +878,7 @@ } ], "default": null, - "title": "Name" + "title": "Short Id" } }, "title": "ImportedFindingRule", @@ -887,12 +887,6 @@ "ImportedLocation": { "additionalProperties": false, "properties": { - "path": { - "maxLength": 2048, - "minLength": 1, - "title": "Path", - "type": "string" - }, "line": { "anyOf": [ { @@ -905,6 +899,12 @@ ], "default": null, "title": "Line" + }, + "path": { + "maxLength": 2048, + "minLength": 1, + "title": "Path", + "type": "string" } }, "required": ["path"], @@ -914,12 +914,6 @@ "ImportedPackage": { "additionalProperties": false, "properties": { - "name": { - "maxLength": 512, - "minLength": 1, - "title": "Name", - "type": "string" - }, "ecosystem": { "anyOf": [ { @@ -934,6 +928,17 @@ "default": null, "title": "Ecosystem" }, + "fixed_versions": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Fixed Versions", + "type": "array" + }, "installed_version": { "anyOf": [ { @@ -948,42 +953,37 @@ "default": null, "title": "Installed Version" }, - "vulnerable_version_range": { + "manifest_path": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 2048, "type": "string" }, { "type": "null" } ], - "title": "Vulnerable Version Range" + "default": null, + "title": "Manifest Path" }, - "manifest_path": { + "name": { + "maxLength": 512, + "minLength": 1, + "title": "Name", + "type": "string" + }, + "vulnerable_version_range": { "anyOf": [ { - "maxLength": 2048, + "maxLength": 512, + "minLength": 1, "type": "string" }, { "type": "null" } ], - "default": null, - "title": "Manifest Path" - }, - "fixed_versions": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Fixed Versions", - "type": "array" + "title": "Vulnerable Version Range" } }, "required": ["name"], @@ -994,12 +994,6 @@ "additionalProperties": false, "description": "Vendor repository identity, resolved to the full source-control URL.", "properties": { - "url": { - "maxLength": 2048, - "minLength": 1, - "title": "Url", - "type": "string" - }, "id": { "anyOf": [ { @@ -1027,6 +1021,12 @@ ], "default": null, "title": "Name" + }, + "url": { + "maxLength": 2048, + "minLength": 1, + "title": "Url", + "type": "string" } }, "required": ["url"], @@ -1041,20 +1041,6 @@ "ImportedSecretDetails": { "additionalProperties": false, "properties": { - "type": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Type" - }, "confidence": { "anyOf": [ { @@ -1066,7 +1052,7 @@ ], "default": null }, - "validation_status": { + "introduced_commit": { "anyOf": [ { "maxLength": 512, @@ -1078,7 +1064,7 @@ } ], "default": null, - "title": "Validation Status" + "title": "Introduced Commit" }, "is_encrypted": { "anyOf": [ @@ -1104,7 +1090,7 @@ "default": null, "title": "Is Managed" }, - "introduced_commit": { + "type": { "anyOf": [ { "maxLength": 512, @@ -1116,7 +1102,21 @@ } ], "default": null, - "title": "Introduced Commit" + "title": "Type" + }, + "validation_status": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Validation Status" } }, "title": "ImportedSecretDetails", @@ -1125,11 +1125,6 @@ "LastImport": { "additionalProperties": false, "properties": { - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, "actor": { "title": "Actor", "type": "string" @@ -1137,6 +1132,11 @@ "created_at": { "title": "Created At", "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" } }, "required": ["id", "actor", "created_at"], @@ -1146,38 +1146,64 @@ "SourceReport": { "additionalProperties": false, "properties": { + "assessment": { + "$ref": "#/$defs/ImportedAssessment" + }, + "canonical_finding_id": { + "pattern": "^acf_[0-9a-f]{32}$", + "title": "Canonical Finding Id", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "environment_id": { + "title": "Environment Id", + "type": "string" + }, + "evidence": { + "$ref": "#/$defs/ImportedFindingEvidence" + }, "id": { "pattern": "^aif_[0-9a-f]{64}$", "title": "Id", "type": "string" }, + "last_import": { + "anyOf": [ + { + "$ref": "#/$defs/LastImport" + }, + { + "type": "null" + } + ], + "default": null + }, "object": { "const": "security.source_report", "default": "security.source_report", "title": "Object", "type": "string" }, + "observation_id": { + "pattern": "^aio_[0-9a-f]{64}$", + "title": "Observation Id", + "type": "string" + }, "origin": { "const": "imported", "default": "imported", "title": "Origin", "type": "string" }, - "repo_id": { - "title": "Repo Id", - "type": "string" - }, "repo_connector_id": { "title": "Repo Connector Id", "type": "string" }, - "environment_id": { - "title": "Environment Id", - "type": "string" - }, - "canonical_finding_id": { - "pattern": "^acf_[0-9a-f]{32}$", - "title": "Canonical Finding Id", + "repo_id": { + "title": "Repo Id", "type": "string" }, "source": { @@ -1187,40 +1213,14 @@ "title": "Source Finding Id", "type": "string" }, - "observation_id": { - "pattern": "^aio_[0-9a-f]{64}$", - "title": "Observation Id", - "type": "string" + "updated_at": { + "title": "Updated At", + "type": "integer" }, "version": { "minimum": 1, "title": "Version", "type": "integer" - }, - "evidence": { - "$ref": "#/$defs/ImportedFindingEvidence" - }, - "assessment": { - "$ref": "#/$defs/ImportedAssessment" - }, - "last_import": { - "anyOf": [ - { - "$ref": "#/$defs/LastImport" - }, - { - "type": "null" - } - ], - "default": null - }, - "created_at": { - "title": "Created At", - "type": "integer" - }, - "updated_at": { - "title": "Updated At", - "type": "integer" } }, "required": [ @@ -1243,12 +1243,6 @@ "SourceReportPage": { "additionalProperties": false, "properties": { - "object": { - "const": "page", - "default": "page", - "title": "Object", - "type": "string" - }, "data": { "items": { "$ref": "#/$defs/SourceReportSummary" @@ -1271,6 +1265,12 @@ } ], "title": "Next" + }, + "object": { + "const": "page", + "default": "page", + "title": "Object", + "type": "string" } }, "required": ["data", "has_more", "next"], @@ -1280,6 +1280,22 @@ "SourceReportSummary": { "additionalProperties": false, "properties": { + "assessment": { + "$ref": "#/$defs/ImportedAssessment" + }, + "canonical_finding_id": { + "pattern": "^acf_[0-9a-f]{32}$", + "title": "Canonical Finding Id", + "type": "string" + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "environment_id": { + "title": "Environment Id", + "type": "string" + }, "id": { "pattern": "^aif_[0-9a-f]{64}$", "title": "Id", @@ -1291,48 +1307,23 @@ "title": "Object", "type": "string" }, + "observation_id": { + "pattern": "^aio_[0-9a-f]{64}$", + "title": "Observation Id", + "type": "string" + }, "origin": { "const": "imported", "default": "imported", "title": "Origin", "type": "string" }, - "repo_id": { - "title": "Repo Id", - "type": "string" - }, "repo_connector_id": { "title": "Repo Connector Id", "type": "string" }, - "environment_id": { - "title": "Environment Id", - "type": "string" - }, - "canonical_finding_id": { - "pattern": "^acf_[0-9a-f]{32}$", - "title": "Canonical Finding Id", - "type": "string" - }, - "source": { - "$ref": "#/$defs/ImportSource" - }, - "source_finding_id": { - "title": "Source Finding Id", - "type": "string" - }, - "observation_id": { - "pattern": "^aio_[0-9a-f]{64}$", - "title": "Observation Id", - "type": "string" - }, - "version": { - "minimum": 1, - "title": "Version", - "type": "integer" - }, - "title": { - "title": "Title", + "repo_id": { + "title": "Repo Id", "type": "string" }, "severity": { @@ -1347,6 +1338,13 @@ "title": "Severity", "type": "string" }, + "source": { + "$ref": "#/$defs/ImportSource" + }, + "source_finding_id": { + "title": "Source Finding Id", + "type": "string" + }, "source_updated_at": { "anyOf": [ { @@ -1358,16 +1356,18 @@ ], "title": "Source Updated At" }, - "assessment": { - "$ref": "#/$defs/ImportedAssessment" - }, - "created_at": { - "title": "Created At", - "type": "integer" + "title": { + "title": "Title", + "type": "string" }, "updated_at": { "title": "Updated At", "type": "integer" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" } }, "required": [ @@ -1393,15 +1393,15 @@ "additionalProperties": false, "description": "Schema export root; transport endpoints return the individual models.", "properties": { - "request": { - "$ref": "#/$defs/FindingImportRequest" - }, "receipt": { "$ref": "#/$defs/FindingImportReceipt" }, "repositories": { "$ref": "#/$defs/ImportRepositoryPage" }, + "request": { + "$ref": "#/$defs/FindingImportRequest" + }, "source_report": { "$ref": "#/$defs/SourceReport" }, diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index b19f2c621..8c0f8b7b5 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -3239,15 +3239,15 @@ export async function main( }); publication.command("findings", { description: - "Preview and import selected Wiz, GitHub, 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({ @@ -3256,8 +3256,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 7c080344a..2377ea03d 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -18,6 +18,7 @@ import { import { readVendorFindingsForPublication } from "./wiz-findings.js"; import { readGitHubFindings } from "./github-findings.js"; import { readSnykFindings } from "./snyk-findings.js"; +import { readSemgrepFindings } from "./semgrep-findings.js"; import { readEndorFindings } from "./endor-findings.js"; import type { ExternalFindings, @@ -50,7 +51,8 @@ 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" | "endor"; + provider: "wiz" | "github" | "snyk" | "endor" | "semgrep"; + sourceRepositoryUrl?: string; sourceKey: string; } @@ -281,6 +283,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-models.ts b/sdk/typescript/src/external-import-models.ts index d588d2f64..d9b32d91b 100644 --- a/sdk/typescript/src/external-import-models.ts +++ b/sdk/typescript/src/external-import-models.ts @@ -1,240 +1,262 @@ /* Generated from schemas/external-findings.schema.json. Run pnpm generate:external-import-models. */ -export type RequestId = string; +export type Actor = string; +export type Created = number; +export type Error = number; +export type Unchanged = number; +export type Updated = number; +export type CreatedAt = number; export type Id = string; -export type RepoConnectorId = string; +export type ItemCount = number; +export type Object = "security.finding_import"; export type EnvironmentId = string; +export type Id1 = string; +export type RepoConnectorId = string; export type ResetMarker = string; -export type Provider = "wiz" | "endor" | "snyk" | "github"; -export type SourceKey = string; +export type CanonicalFindingId = string | null; export type ClientId = string; -export type SourceFindingId = string; -export type ExpectedVersion = number; -export type Title = string; -export type Severity = - "critical" | "high" | "medium" | "low" | "informational" | "unknown"; -export type Description = string | null; -export type Url = string | null; +export type Code = + "version_conflict" | "scope_conflict" | "stale_source_snapshot"; +export type Message = string; +export type ObservationId = string | null; +export type Outcome = "created" | "updated" | "unchanged" | "error"; +export type SourceReportId = string | null; +export type Version = number | null; +export type Results = FindingImportResult[]; +export type Provider = "wiz" | "endor" | "snyk" | "github" | "semgrep"; +export type SourceKey = string; +export type DefaultBranch = string | null; +export type Id2 = string; +export type ImportEnvironmentId = string | null; +export type Object1 = "security.repository"; +export type RepoConnectorId1 = string; +export type ResetMarker1 = string; +export type Url = string; +export type Data = ImportRepository[]; +export type HasMore = boolean; +export type Next = string | null; +export type Object2 = "page"; +export type ClientId1 = string; /** * @maxItems 100 */ export type AdvisoryIds = string[]; -export type Name = string; -export type Ecosystem = string | null; -export type InstalledVersion = string | null; -export type VulnerableVersionRange = string | null; -export type ManifestPath = string | null; +export type Branch = string | null; +export type CodeRevision = string | null; +export type Description = string | null; +export type Source = "repository" | "global"; +export type EndLine = number | null; +export type Snippet = string | null; +export type Actual = string | null; +export type CloudPlatform = string | null; +export type Expected = string | null; +export type Platform = string | null; +export type FileUrl = string | null; +export type Kind = "sast" | "secret" | "iac" | "advisory"; +export type RemediationInstructions = string | null; +export type Id3 = string | null; +export type Name = string | null; +export type Url1 = string; +export type Id4 = string | null; +export type Name1 = string | null; +export type ShortId = string | null; +export type ScannerOrigin = string | null; +export type ScannerVerdict = string | null; +export type ImportedSecretConfidence = string; +export type IntroducedCommit = string | null; +export type IsEncrypted = boolean | null; +export type IsManaged = boolean | null; +export type Type = string | null; +export type ValidationStatus = string | null; +export type SourceStatus = string | null; /** * @maxItems 100 */ -export type FixedVersions = string[]; +export type WeaknessIds = string[]; /** * @maxItems 100 */ -export type Packages = ImportedPackage[]; -export type Path = string; +export type ImageDigests = string[]; export type Line = number | null; +export type Path = string; /** * @maxItems 100 */ export type Locations = ImportedLocation[]; -export type Branch = string | null; -export type CodeRevision = string | null; -export type SourceScanId = string | null; +export type Ecosystem = string | null; /** * @maxItems 100 */ -export type ImageDigests = string[]; -export type SourceUpdatedAt = number | null; -export type Kind = "sast" | "secret" | "iac" | "advisory"; -export type Url1 = string; -export type Id1 = string | null; -export type Name1 = string | null; -export type Source = "repository" | "global"; -export type SourceStatus = string | null; -export type ScannerOrigin = string | null; -export type ScannerVerdict = string | null; -export type Id2 = string | null; -export type ShortId = string | null; -export type Name2 = string | null; +export type FixedVersions = string[]; +export type InstalledVersion = string | null; +export type ManifestPath = string | null; +export type Name2 = string; +export type VulnerableVersionRange = string | null; /** * @maxItems 100 */ -export type WeaknessIds = string[]; -export type RemediationInstructions = string | null; -export type EndLine = number | null; -export type Snippet = string | null; -export type Type = string | null; -export type ImportedSecretConfidence = string; -export type ValidationStatus = string | null; -export type IsEncrypted = boolean | null; -export type IsManaged = boolean | null; -export type IntroducedCommit = string | null; -export type Platform = string | null; -export type CloudPlatform = string | null; -export type Expected = string | null; -export type Actual = string | null; -export type FileUrl = string | null; +export type Packages = ImportedPackage[]; +export type Severity = + "critical" | "high" | "medium" | "low" | "informational" | "unknown"; +export type SourceScanId = string | null; +export type SourceUpdatedAt = number | null; +export type Title = string; +export type Url2 = string | null; +export type ExpectedVersion = number; +export type SourceFindingId = string; /** * @minItems 1 * @maxItems 100 */ export type Items = FindingImportItem[]; -export type Id3 = string; -export type Object = "security.finding_import"; -export type Actor = string; -export type CreatedAt = number; -export type ItemCount = number; -export type Created = number; -export type Updated = number; -export type Unchanged = number; -export type Error = number; -export type ClientId1 = string; -export type Outcome = "created" | "updated" | "unchanged" | "error"; -export type SourceReportId = string | null; -export type ObservationId = string | null; -export type CanonicalFindingId = string | null; -export type Version = number | null; -export type Code = - "version_conflict" | "scope_conflict" | "stale_source_snapshot"; -export type Message = string; -export type Results = FindingImportResult[]; -export type Object1 = "page"; -export type Id4 = string; -export type Object2 = "security.repository"; -export type RepoConnectorId1 = string; -export type Url2 = string; -export type DefaultBranch = string | null; -export type ResetMarker1 = string; -export type ImportEnvironmentId = string | null; -export type Data = ImportRepository[]; -export type HasMore = boolean; -export type Next = string | null; +export type RequestId = string; +export type State = "not_assessed"; +export type CanonicalFindingId1 = string; +export type CreatedAt1 = number; +export type EnvironmentId1 = string; export type Id5 = string; +export type Actor1 = string; +export type CreatedAt2 = number; +export type Id6 = string; export type Object3 = "security.source_report"; +export type ObservationId1 = string; export type Origin = "imported"; -export type RepoId = string; export type RepoConnectorId2 = string; -export type EnvironmentId1 = string; -export type CanonicalFindingId1 = string; +export type RepoId = string; export type SourceFindingId1 = string; -export type ObservationId1 = string; -export type Version1 = number; -export type State = "not_assessed"; -export type Id6 = string; -export type Actor1 = string; -export type CreatedAt1 = number; -export type CreatedAt2 = number; export type UpdatedAt = number; -export type Object4 = "page"; +export type Version1 = number; +export type CanonicalFindingId2 = string; +export type CreatedAt3 = number; +export type EnvironmentId2 = string; export type Id7 = string; -export type Object5 = "security.source_report_summary"; +export type Object4 = "security.source_report_summary"; +export type ObservationId2 = string; export type Origin1 = "imported"; -export type RepoId1 = string; export type RepoConnectorId3 = string; -export type EnvironmentId2 = string; -export type CanonicalFindingId2 = string; -export type SourceFindingId2 = string; -export type ObservationId2 = string; -export type Version2 = number; -export type Title1 = string; +export type RepoId1 = string; export type Severity1 = "critical" | "high" | "medium" | "low" | "informational" | "unknown"; +export type SourceFindingId2 = string; export type SourceUpdatedAt1 = number | null; -export type CreatedAt3 = number; +export type Title1 = string; export type UpdatedAt1 = number; +export type Version2 = number; export type Data1 = SourceReportSummary[]; export type HasMore1 = boolean; export type Next1 = string | null; +export type Object5 = "page"; /** * Schema export root; transport endpoints return the individual models. */ export interface ExternalFindingContracts { - request: FindingImportRequest; receipt: FindingImportReceipt; repositories: ImportRepositoryPage; + request: FindingImportRequest; source_report: SourceReport; source_reports: SourceReportPage; } -export interface FindingImportRequest { - request_id: RequestId; +export interface FindingImportReceipt { + actor: Actor; + counts: ImportCounts; + created_at: CreatedAt; + id: Id; + item_count: ItemCount; + object?: Object; repository: ImportDestination; + results: Results; source: ImportSource; - items: Items; +} +export interface ImportCounts { + created?: Created; + error?: Error; + unchanged?: Unchanged; + updated?: Updated; } export interface ImportDestination { - id: Id; - repo_connector_id: RepoConnectorId; environment_id: EnvironmentId; + id: Id1; + repo_connector_id: RepoConnectorId; reset_marker: ResetMarker; } +export interface FindingImportResult { + canonical_finding_id?: CanonicalFindingId; + client_id: ClientId; + error?: ImportItemError | null; + observation_id?: ObservationId; + outcome: Outcome; + source_report_id?: SourceReportId; + version?: Version; +} +export interface ImportItemError { + code: Code; + message: Message; +} export interface ImportSource { provider: Provider; source_key: SourceKey; } +export interface ImportRepositoryPage { + data: Data; + has_more: HasMore; + next: Next; + object?: Object2; +} +export interface ImportRepository { + default_branch?: DefaultBranch; + id: Id2; + import_environment_id?: ImportEnvironmentId; + object?: Object1; + repo_connector_id: RepoConnectorId1; + reset_marker: ResetMarker1; + url: Url; +} +export interface FindingImportRequest { + items: Items; + repository: ImportDestination; + request_id: RequestId; + source: ImportSource; +} export interface FindingImportItem { - client_id: ClientId; - source_finding_id: SourceFindingId; - expected_version: ExpectedVersion; + client_id: ClientId1; evidence: ImportedFindingEvidence; + expected_version: ExpectedVersion; + source_finding_id: SourceFindingId; } export interface ImportedFindingEvidence { - title: Title; - severity: Severity; - description?: Description; - url?: Url; advisory_ids?: AdvisoryIds; - packages?: Packages; - locations?: Locations; branch?: Branch; code_revision?: CodeRevision; - source_scan_id?: SourceScanId; + description?: Description; + details?: ImportedFindingDetails | null; image_digests?: ImageDigests; - source_updated_at?: SourceUpdatedAt; + locations?: Locations; + packages?: Packages; + severity: Severity; source_data?: SourceData; - details?: ImportedFindingDetails | null; -} -export interface ImportedPackage { - name: Name; - ecosystem?: Ecosystem; - installed_version?: InstalledVersion; - vulnerable_version_range?: VulnerableVersionRange; - manifest_path?: ManifestPath; - fixed_versions?: FixedVersions; -} -export interface ImportedLocation { - path: Path; - line?: Line; -} -export interface SourceData { - [k: string]: unknown; + source_scan_id?: SourceScanId; + source_updated_at?: SourceUpdatedAt; + title: Title; + url?: Url2; } /** * Typed repository evidence; vendor state is not a Codex assessment. */ export interface ImportedFindingDetails { + advisory?: ImportedAdvisoryContext | null; + code?: ImportedCodeContext | null; + configuration?: ImportedConfigurationDetails | null; + file_url?: FileUrl; kind: Kind; + remediation_instructions?: RemediationInstructions; repository?: ImportedRepositoryReference | null; - advisory?: ImportedAdvisoryContext | null; - source_status?: SourceStatus; + rule?: ImportedFindingRule | null; scanner_origin?: ScannerOrigin; scanner_verdict?: ScannerVerdict; - rule?: ImportedFindingRule | null; - weakness_ids?: WeaknessIds; - remediation_instructions?: RemediationInstructions; - code?: ImportedCodeContext | null; secret?: ImportedSecretDetails | null; - configuration?: ImportedConfigurationDetails | null; - file_url?: FileUrl; -} -/** - * Vendor repository identity, resolved to the full source-control URL. - */ -export interface ImportedRepositoryReference { - url: Url1; - id?: Id1; - name?: Name1; + source_status?: SourceStatus; + weakness_ids?: WeaknessIds; } /** * Where the advisory was read; applicability is assessed separately. @@ -242,11 +264,6 @@ export interface ImportedRepositoryReference { export interface ImportedAdvisoryContext { source: Source; } -export interface ImportedFindingRule { - id?: Id2; - short_id?: ShortId; - name?: Name2; -} /** * Optional range and snippet for the evidence's first source location. */ @@ -254,115 +271,98 @@ export interface ImportedCodeContext { end_line?: EndLine; snippet?: Snippet; } -export interface ImportedSecretDetails { - type?: Type; - confidence?: ImportedSecretConfidence | null; - validation_status?: ValidationStatus; - is_encrypted?: IsEncrypted; - is_managed?: IsManaged; - introduced_commit?: IntroducedCommit; -} export interface ImportedConfigurationDetails { - platform?: Platform; + actual?: Actual; cloud_platform?: CloudPlatform; expected?: Expected; - actual?: Actual; + platform?: Platform; } -export interface FindingImportReceipt { - id: Id3; - object?: Object; - repository: ImportDestination; - source: ImportSource; - actor: Actor; - created_at: CreatedAt; - item_count: ItemCount; - counts: ImportCounts; - results: Results; +/** + * Vendor repository identity, resolved to the full source-control URL. + */ +export interface ImportedRepositoryReference { + id?: Id3; + name?: Name; + url: Url1; } -export interface ImportCounts { - created?: Created; - updated?: Updated; - unchanged?: Unchanged; - error?: Error; +export interface ImportedFindingRule { + id?: Id4; + name?: Name1; + short_id?: ShortId; } -export interface FindingImportResult { - client_id: ClientId1; - outcome: Outcome; - source_report_id?: SourceReportId; - observation_id?: ObservationId; - canonical_finding_id?: CanonicalFindingId; - version?: Version; - error?: ImportItemError | null; +export interface ImportedSecretDetails { + confidence?: ImportedSecretConfidence | null; + introduced_commit?: IntroducedCommit; + is_encrypted?: IsEncrypted; + is_managed?: IsManaged; + type?: Type; + validation_status?: ValidationStatus; } -export interface ImportItemError { - code: Code; - message: Message; +export interface ImportedLocation { + line?: Line; + path: Path; } -export interface ImportRepositoryPage { - object?: Object1; - data: Data; - has_more: HasMore; - next: Next; +export interface ImportedPackage { + ecosystem?: Ecosystem; + fixed_versions?: FixedVersions; + installed_version?: InstalledVersion; + manifest_path?: ManifestPath; + name: Name2; + vulnerable_version_range?: VulnerableVersionRange; } -export interface ImportRepository { - id: Id4; - object?: Object2; - repo_connector_id: RepoConnectorId1; - url: Url2; - default_branch?: DefaultBranch; - reset_marker: ResetMarker1; - import_environment_id?: ImportEnvironmentId; +export interface SourceData { + [k: string]: unknown; } export interface SourceReport { + assessment?: ImportedAssessment; + canonical_finding_id: CanonicalFindingId1; + created_at: CreatedAt1; + environment_id: EnvironmentId1; + evidence: ImportedFindingEvidence; id: Id5; + last_import?: LastImport | null; object?: Object3; + observation_id: ObservationId1; origin?: Origin; - repo_id: RepoId; repo_connector_id: RepoConnectorId2; - environment_id: EnvironmentId1; - canonical_finding_id: CanonicalFindingId1; + repo_id: RepoId; source: ImportSource; source_finding_id: SourceFindingId1; - observation_id: ObservationId1; - version: Version1; - evidence: ImportedFindingEvidence; - assessment?: ImportedAssessment; - last_import?: LastImport | null; - created_at: CreatedAt2; updated_at: UpdatedAt; + version: Version1; } export interface ImportedAssessment { state?: State; } export interface LastImport { - id: Id6; actor: Actor1; - created_at: CreatedAt1; + created_at: CreatedAt2; + id: Id6; } export interface SourceReportPage { - object?: Object4; data: Data1; has_more: HasMore1; next: Next1; + object?: Object5; } export interface SourceReportSummary { + assessment?: ImportedAssessment; + canonical_finding_id: CanonicalFindingId2; + created_at: CreatedAt3; + environment_id: EnvironmentId2; id: Id7; - object?: Object5; + object?: Object4; + observation_id: ObservationId2; origin?: Origin1; - repo_id: RepoId1; repo_connector_id: RepoConnectorId3; - environment_id: EnvironmentId2; - canonical_finding_id: CanonicalFindingId2; + repo_id: RepoId1; + severity: Severity1; source: ImportSource; source_finding_id: SourceFindingId2; - observation_id: ObservationId2; - version: Version2; - title: Title1; - severity: Severity1; source_updated_at: SourceUpdatedAt1; - assessment?: ImportedAssessment; - created_at: CreatedAt3; + title: Title1; updated_at: UpdatedAt1; + version: Version2; } export type ExternalFindingEvidence = ImportedFindingEvidence; diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 17e23d399..f6751014d 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -257,3 +257,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..af59715f1 --- /dev/null +++ b/sdk/typescript/src/semgrep-findings.ts @@ -0,0 +1,378 @@ +import { + repositoryUrlKey, + 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 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 sarifIdentity(value: unknown): 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 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"), + requiredText( + record(physical["artifactLocation"], "Artifact location")["uri"], + "Semgrep artifact 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, ...context } = document; + return normalizeRecords( + array(results, "Semgrep results"), + (value) => { + const result = record(value, "Semgrep result"); + const extra = record(result["extra"], "Semgrep extra"); + if (extra["sca_info"] != null || extra["validation_state"] != null) + 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: requiredText(extra["message"], "Semgrep 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 && + repositoryUrlKey(declared) !== repositoryUrlKey(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"), + ) + ) + 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", + )) { + if ( + record(invocation, "Semgrep invocation")["executionSuccessful"] === + false + ) + throw new Error("Semgrep SARIF contains an unsuccessful invocation."); + } + 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) + throw new Error( + "Semgrep 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("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"] !== rules.indexOf(rule)) || + (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 = requiredText(artifact["uri"], "Semgrep artifact URI"); + 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 = relativePath(rawPath); + return { + source_finding_id: sarifIdentity(result), + evidence: validateExternalEvidence({ + title: ruleId, + description: + text(message["markdown"]) ?? + requiredText(message["text"], "Semgrep 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, result }), + }), + }; + }, + ({ result }) => sarifIdentity(result), + ); +} + +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 16c558adb..057fc5830 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -2805,3 +2805,80 @@ test("Endor CLI dry-run recognizes SARIF without writing imports", async () => { expect(await cli.runCli(command, f.cliDeps)).toBe(0); expect(f.posts).toHaveLength(0); }); + +test("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 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..6416ef4d3 --- /dev/null +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -0,0 +1,186 @@ +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("gated CE fingerprint placeholders use independent location identities", () => { + const input = semgrepSarif(); + 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"); + } +}); 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, + }, + }, + }, + ], + }, + ], + }, + ], + }; +} From a4c8e5e2fc75e65de43ee11042112e7e2f297634 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 22:38:08 +0000 Subject: [PATCH 2/9] fix: narrow Semgrep contract update and package its adapter --- .../schemas/external-findings.schema.json | 766 +++++++++--------- sdk/typescript/scripts/package-dist-files.mjs | 1 + sdk/typescript/src/external-import-models.ts | 448 +++++----- 3 files changed, 608 insertions(+), 607 deletions(-) diff --git a/sdk/typescript/schemas/external-findings.schema.json b/sdk/typescript/schemas/external-findings.schema.json index e5fe3c752..f04ad771b 100644 --- a/sdk/typescript/schemas/external-findings.schema.json +++ b/sdk/typescript/schemas/external-findings.schema.json @@ -9,19 +9,19 @@ "title": "Client Id", "type": "string" }, - "evidence": { - "$ref": "#/$defs/ImportedFindingEvidence" + "source_finding_id": { + "maxLength": 512, + "minLength": 1, + "title": "Source Finding Id", + "type": "string" }, "expected_version": { "minimum": 0, "title": "Expected Version", "type": "integer" }, - "source_finding_id": { - "maxLength": 512, - "minLength": 1, - "title": "Source Finding Id", - "type": "string" + "evidence": { + "$ref": "#/$defs/ImportedFindingEvidence" } }, "required": [ @@ -36,36 +36,39 @@ "FindingImportReceipt": { "additionalProperties": false, "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "object": { + "const": "security.finding_import", + "default": "security.finding_import", + "title": "Object", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/ImportDestination" + }, + "source": { + "$ref": "#/$defs/ImportSource" + }, "actor": { "title": "Actor", "type": "string" }, - "counts": { - "$ref": "#/$defs/ImportCounts" - }, "created_at": { "title": "Created At", "type": "integer" }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" - }, "item_count": { "maximum": 100, "minimum": 1, "title": "Item Count", "type": "integer" }, - "object": { - "const": "security.finding_import", - "default": "security.finding_import", - "title": "Object", - "type": "string" - }, - "repository": { - "$ref": "#/$defs/ImportDestination" + "counts": { + "$ref": "#/$defs/ImportCounts" }, "results": { "items": { @@ -73,9 +76,6 @@ }, "title": "Results", "type": "array" - }, - "source": { - "$ref": "#/$defs/ImportSource" } }, "required": [ @@ -94,6 +94,17 @@ "FindingImportRequest": { "additionalProperties": false, "properties": { + "request_id": { + "format": "uuid", + "title": "Request Id", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/ImportDestination" + }, + "source": { + "$ref": "#/$defs/ImportSource" + }, "items": { "items": { "$ref": "#/$defs/FindingImportItem" @@ -102,17 +113,6 @@ "minItems": 1, "title": "Items", "type": "array" - }, - "repository": { - "$ref": "#/$defs/ImportDestination" - }, - "request_id": { - "format": "uuid", - "title": "Request Id", - "type": "string" - }, - "source": { - "$ref": "#/$defs/ImportSource" } }, "required": ["request_id", "repository", "source", "items"], @@ -122,33 +122,27 @@ "FindingImportResult": { "additionalProperties": false, "properties": { - "canonical_finding_id": { - "anyOf": [ - { - "pattern": "^acf_[0-9a-f]{32}$", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Canonical Finding Id" - }, "client_id": { "title": "Client Id", "type": "string" }, - "error": { + "outcome": { + "enum": ["created", "updated", "unchanged", "error"], + "title": "Outcome", + "type": "string" + }, + "source_report_id": { "anyOf": [ { - "$ref": "#/$defs/ImportItemError" + "pattern": "^aif_[0-9a-f]{64}$", + "type": "string" }, { "type": "null" } ], - "default": null + "default": null, + "title": "Source Report Id" }, "observation_id": { "anyOf": [ @@ -163,15 +157,10 @@ "default": null, "title": "Observation Id" }, - "outcome": { - "enum": ["created", "updated", "unchanged", "error"], - "title": "Outcome", - "type": "string" - }, - "source_report_id": { + "canonical_finding_id": { "anyOf": [ { - "pattern": "^aif_[0-9a-f]{64}$", + "pattern": "^acf_[0-9a-f]{32}$", "type": "string" }, { @@ -179,7 +168,7 @@ } ], "default": null, - "title": "Source Report Id" + "title": "Canonical Finding Id" }, "version": { "anyOf": [ @@ -193,6 +182,17 @@ ], "default": null, "title": "Version" + }, + "error": { + "anyOf": [ + { + "$ref": "#/$defs/ImportItemError" + }, + { + "type": "null" + } + ], + "default": null } }, "required": ["client_id", "outcome"], @@ -208,10 +208,10 @@ "title": "Created", "type": "integer" }, - "error": { + "updated": { "default": 0, "minimum": 0, - "title": "Error", + "title": "Updated", "type": "integer" }, "unchanged": { @@ -220,10 +220,10 @@ "title": "Unchanged", "type": "integer" }, - "updated": { + "error": { "default": 0, "minimum": 0, - "title": "Updated", + "title": "Error", "type": "integer" } }, @@ -233,12 +233,6 @@ "ImportDestination": { "additionalProperties": false, "properties": { - "environment_id": { - "maxLength": 512, - "minLength": 1, - "title": "Environment Id", - "type": "string" - }, "id": { "maxLength": 512, "minLength": 1, @@ -251,6 +245,12 @@ "title": "Repo Connector Id", "type": "string" }, + "environment_id": { + "maxLength": 512, + "minLength": 1, + "title": "Environment Id", + "type": "string" + }, "reset_marker": { "maxLength": 512, "minLength": 1, @@ -286,6 +286,24 @@ "ImportRepository": { "additionalProperties": false, "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "object": { + "const": "security.repository", + "default": "security.repository", + "title": "Object", + "type": "string" + }, + "repo_connector_id": { + "title": "Repo Connector Id", + "type": "string" + }, + "url": { + "title": "Url", + "type": "string" + }, "default_branch": { "anyOf": [ { @@ -298,8 +316,8 @@ "default": null, "title": "Default Branch" }, - "id": { - "title": "Id", + "reset_marker": { + "title": "Reset Marker", "type": "string" }, "import_environment_id": { @@ -313,24 +331,6 @@ ], "default": null, "title": "Import Environment Id" - }, - "object": { - "const": "security.repository", - "default": "security.repository", - "title": "Object", - "type": "string" - }, - "repo_connector_id": { - "title": "Repo Connector Id", - "type": "string" - }, - "reset_marker": { - "title": "Reset Marker", - "type": "string" - }, - "url": { - "title": "Url", - "type": "string" } }, "required": ["id", "repo_connector_id", "url", "reset_marker"], @@ -340,6 +340,12 @@ "ImportRepositoryPage": { "additionalProperties": false, "properties": { + "object": { + "const": "page", + "default": "page", + "title": "Object", + "type": "string" + }, "data": { "items": { "$ref": "#/$defs/ImportRepository" @@ -362,12 +368,6 @@ } ], "title": "Next" - }, - "object": { - "const": "page", - "default": "page", - "title": "Object", - "type": "string" } }, "required": ["data", "has_more", "next"], @@ -457,10 +457,11 @@ "ImportedConfigurationDetails": { "additionalProperties": false, "properties": { - "actual": { + "platform": { "anyOf": [ { - "maxLength": 65536, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -468,7 +469,7 @@ } ], "default": null, - "title": "Actual" + "title": "Platform" }, "cloud_platform": { "anyOf": [ @@ -497,11 +498,10 @@ "default": null, "title": "Expected" }, - "platform": { + "actual": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 65536, "type": "string" }, { @@ -509,7 +509,7 @@ } ], "default": null, - "title": "Platform" + "title": "Actual" } }, "title": "ImportedConfigurationDetails", @@ -519,42 +519,50 @@ "additionalProperties": false, "description": "Typed repository evidence; vendor state is not a Codex assessment.", "properties": { - "advisory": { + "kind": { + "enum": ["sast", "secret", "iac", "advisory"], + "title": "Kind", + "type": "string" + }, + "repository": { "anyOf": [ { - "$ref": "#/$defs/ImportedAdvisoryContext" + "$ref": "#/$defs/ImportedRepositoryReference" }, { "type": "null" } ] }, - "code": { + "advisory": { "anyOf": [ { - "$ref": "#/$defs/ImportedCodeContext" + "$ref": "#/$defs/ImportedAdvisoryContext" }, { "type": "null" } - ], - "default": null + ] }, - "configuration": { + "source_status": { "anyOf": [ { - "$ref": "#/$defs/ImportedConfigurationDetails" + "maxLength": 512, + "minLength": 1, + "type": "string" }, { "type": "null" } ], - "default": null + "default": null, + "title": "Source Status" }, - "file_url": { + "scanner_origin": { "anyOf": [ { - "maxLength": 2048, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -562,17 +570,13 @@ } ], "default": null, - "title": "File Url" - }, - "kind": { - "enum": ["sast", "secret", "iac", "advisory"], - "title": "Kind", - "type": "string" + "title": "Scanner Origin" }, - "remediation_instructions": { + "scanner_verdict": { "anyOf": [ { - "maxLength": 65536, + "maxLength": 512, + "minLength": 1, "type": "string" }, { @@ -580,61 +584,69 @@ } ], "default": null, - "title": "Remediation Instructions" + "title": "Scanner Verdict" }, - "repository": { + "rule": { "anyOf": [ { - "$ref": "#/$defs/ImportedRepositoryReference" + "$ref": "#/$defs/ImportedFindingRule" }, { "type": "null" } - ] + ], + "default": null }, - "rule": { + "weakness_ids": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Weakness Ids", + "type": "array" + }, + "remediation_instructions": { "anyOf": [ { - "$ref": "#/$defs/ImportedFindingRule" + "maxLength": 65536, + "type": "string" }, { "type": "null" } ], - "default": null + "default": null, + "title": "Remediation Instructions" }, - "scanner_origin": { + "code": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, - "type": "string" + "$ref": "#/$defs/ImportedCodeContext" }, { "type": "null" } ], - "default": null, - "title": "Scanner Origin" + "default": null }, - "scanner_verdict": { + "secret": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, - "type": "string" + "$ref": "#/$defs/ImportedSecretDetails" }, { "type": "null" } ], - "default": null, - "title": "Scanner Verdict" + "default": null }, - "secret": { + "configuration": { "anyOf": [ { - "$ref": "#/$defs/ImportedSecretDetails" + "$ref": "#/$defs/ImportedConfigurationDetails" }, { "type": "null" @@ -642,11 +654,10 @@ ], "default": null }, - "source_status": { + "file_url": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 2048, "type": "string" }, { @@ -654,18 +665,7 @@ } ], "default": null, - "title": "Source Status" - }, - "weakness_ids": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Weakness Ids", - "type": "array" + "title": "File Url" } }, "required": ["kind"], @@ -675,44 +675,23 @@ "ImportedFindingEvidence": { "additionalProperties": false, "properties": { - "advisory_ids": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Advisory Ids", - "type": "array" - }, - "branch": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Branch" + "title": { + "maxLength": 512, + "minLength": 1, + "title": "Title", + "type": "string" }, - "code_revision": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } + "severity": { + "enum": [ + "critical", + "high", + "medium", + "low", + "informational", + "unknown" ], - "default": null, - "title": "Code Revision" + "title": "Severity", + "type": "string" }, "description": { "anyOf": [ @@ -727,17 +706,20 @@ "default": null, "title": "Description" }, - "details": { + "url": { "anyOf": [ { - "$ref": "#/$defs/ImportedFindingDetails" + "maxLength": 2048, + "type": "string" }, { "type": "null" } - ] + ], + "default": null, + "title": "Url" }, - "image_digests": { + "advisory_ids": { "default": [], "items": { "maxLength": 512, @@ -745,44 +727,54 @@ "type": "string" }, "maxItems": 100, - "title": "Image Digests", + "title": "Advisory Ids", "type": "array" }, - "locations": { + "packages": { "default": [], "items": { - "$ref": "#/$defs/ImportedLocation" + "$ref": "#/$defs/ImportedPackage" }, "maxItems": 100, - "title": "Locations", + "title": "Packages", "type": "array" }, - "packages": { + "locations": { "default": [], "items": { - "$ref": "#/$defs/ImportedPackage" + "$ref": "#/$defs/ImportedLocation" }, "maxItems": 100, - "title": "Packages", + "title": "Locations", "type": "array" }, - "severity": { - "enum": [ - "critical", - "high", - "medium", - "low", - "informational", - "unknown" + "branch": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } ], - "title": "Severity", - "type": "string" + "default": null, + "title": "Branch" }, - "source_data": { - "additionalProperties": true, - "default": {}, - "title": "Source Data", - "type": "object" + "code_revision": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Code Revision" }, "source_scan_id": { "anyOf": [ @@ -798,6 +790,17 @@ "default": null, "title": "Source Scan Id" }, + "image_digests": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Image Digests", + "type": "array" + }, "source_updated_at": { "anyOf": [ { @@ -811,24 +814,21 @@ "default": null, "title": "Source Updated At" }, - "title": { - "maxLength": 512, - "minLength": 1, - "title": "Title", - "type": "string" + "source_data": { + "additionalProperties": true, + "default": {}, + "title": "Source Data", + "type": "object" }, - "url": { + "details": { "anyOf": [ { - "maxLength": 2048, - "type": "string" + "$ref": "#/$defs/ImportedFindingDetails" }, { "type": "null" } - ], - "default": null, - "title": "Url" + ] } }, "required": ["title", "severity"], @@ -852,7 +852,7 @@ "default": null, "title": "Id" }, - "name": { + "short_id": { "anyOf": [ { "maxLength": 512, @@ -864,9 +864,9 @@ } ], "default": null, - "title": "Name" + "title": "Short Id" }, - "short_id": { + "name": { "anyOf": [ { "maxLength": 512, @@ -878,7 +878,7 @@ } ], "default": null, - "title": "Short Id" + "title": "Name" } }, "title": "ImportedFindingRule", @@ -887,6 +887,12 @@ "ImportedLocation": { "additionalProperties": false, "properties": { + "path": { + "maxLength": 2048, + "minLength": 1, + "title": "Path", + "type": "string" + }, "line": { "anyOf": [ { @@ -899,12 +905,6 @@ ], "default": null, "title": "Line" - }, - "path": { - "maxLength": 2048, - "minLength": 1, - "title": "Path", - "type": "string" } }, "required": ["path"], @@ -914,6 +914,12 @@ "ImportedPackage": { "additionalProperties": false, "properties": { + "name": { + "maxLength": 512, + "minLength": 1, + "title": "Name", + "type": "string" + }, "ecosystem": { "anyOf": [ { @@ -928,17 +934,6 @@ "default": null, "title": "Ecosystem" }, - "fixed_versions": { - "default": [], - "items": { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - "maxItems": 100, - "title": "Fixed Versions", - "type": "array" - }, "installed_version": { "anyOf": [ { @@ -953,37 +948,42 @@ "default": null, "title": "Installed Version" }, - "manifest_path": { + "vulnerable_version_range": { "anyOf": [ { - "maxLength": 2048, + "maxLength": 512, + "minLength": 1, "type": "string" }, { "type": "null" } ], - "default": null, - "title": "Manifest Path" - }, - "name": { - "maxLength": 512, - "minLength": 1, - "title": "Name", - "type": "string" + "title": "Vulnerable Version Range" }, - "vulnerable_version_range": { + "manifest_path": { "anyOf": [ { - "maxLength": 512, - "minLength": 1, + "maxLength": 2048, "type": "string" }, { "type": "null" } ], - "title": "Vulnerable Version Range" + "default": null, + "title": "Manifest Path" + }, + "fixed_versions": { + "default": [], + "items": { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "maxItems": 100, + "title": "Fixed Versions", + "type": "array" } }, "required": ["name"], @@ -994,6 +994,12 @@ "additionalProperties": false, "description": "Vendor repository identity, resolved to the full source-control URL.", "properties": { + "url": { + "maxLength": 2048, + "minLength": 1, + "title": "Url", + "type": "string" + }, "id": { "anyOf": [ { @@ -1021,12 +1027,6 @@ ], "default": null, "title": "Name" - }, - "url": { - "maxLength": 2048, - "minLength": 1, - "title": "Url", - "type": "string" } }, "required": ["url"], @@ -1041,6 +1041,20 @@ "ImportedSecretDetails": { "additionalProperties": false, "properties": { + "type": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Type" + }, "confidence": { "anyOf": [ { @@ -1052,7 +1066,7 @@ ], "default": null }, - "introduced_commit": { + "validation_status": { "anyOf": [ { "maxLength": 512, @@ -1064,7 +1078,7 @@ } ], "default": null, - "title": "Introduced Commit" + "title": "Validation Status" }, "is_encrypted": { "anyOf": [ @@ -1090,21 +1104,7 @@ "default": null, "title": "Is Managed" }, - "type": { - "anyOf": [ - { - "maxLength": 512, - "minLength": 1, - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "title": "Type" - }, - "validation_status": { + "introduced_commit": { "anyOf": [ { "maxLength": 512, @@ -1116,7 +1116,7 @@ } ], "default": null, - "title": "Validation Status" + "title": "Introduced Commit" } }, "title": "ImportedSecretDetails", @@ -1125,6 +1125,11 @@ "LastImport": { "additionalProperties": false, "properties": { + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, "actor": { "title": "Actor", "type": "string" @@ -1132,11 +1137,6 @@ "created_at": { "title": "Created At", "type": "integer" - }, - "id": { - "format": "uuid", - "title": "Id", - "type": "string" } }, "required": ["id", "actor", "created_at"], @@ -1146,64 +1146,38 @@ "SourceReport": { "additionalProperties": false, "properties": { - "assessment": { - "$ref": "#/$defs/ImportedAssessment" - }, - "canonical_finding_id": { - "pattern": "^acf_[0-9a-f]{32}$", - "title": "Canonical Finding Id", - "type": "string" - }, - "created_at": { - "title": "Created At", - "type": "integer" - }, - "environment_id": { - "title": "Environment Id", - "type": "string" - }, - "evidence": { - "$ref": "#/$defs/ImportedFindingEvidence" - }, "id": { "pattern": "^aif_[0-9a-f]{64}$", "title": "Id", "type": "string" }, - "last_import": { - "anyOf": [ - { - "$ref": "#/$defs/LastImport" - }, - { - "type": "null" - } - ], - "default": null - }, "object": { "const": "security.source_report", "default": "security.source_report", "title": "Object", "type": "string" }, - "observation_id": { - "pattern": "^aio_[0-9a-f]{64}$", - "title": "Observation Id", - "type": "string" - }, "origin": { "const": "imported", "default": "imported", "title": "Origin", "type": "string" }, + "repo_id": { + "title": "Repo Id", + "type": "string" + }, "repo_connector_id": { "title": "Repo Connector Id", "type": "string" }, - "repo_id": { - "title": "Repo Id", + "environment_id": { + "title": "Environment Id", + "type": "string" + }, + "canonical_finding_id": { + "pattern": "^acf_[0-9a-f]{32}$", + "title": "Canonical Finding Id", "type": "string" }, "source": { @@ -1213,14 +1187,40 @@ "title": "Source Finding Id", "type": "string" }, - "updated_at": { - "title": "Updated At", - "type": "integer" + "observation_id": { + "pattern": "^aio_[0-9a-f]{64}$", + "title": "Observation Id", + "type": "string" }, "version": { "minimum": 1, "title": "Version", "type": "integer" + }, + "evidence": { + "$ref": "#/$defs/ImportedFindingEvidence" + }, + "assessment": { + "$ref": "#/$defs/ImportedAssessment" + }, + "last_import": { + "anyOf": [ + { + "$ref": "#/$defs/LastImport" + }, + { + "type": "null" + } + ], + "default": null + }, + "created_at": { + "title": "Created At", + "type": "integer" + }, + "updated_at": { + "title": "Updated At", + "type": "integer" } }, "required": [ @@ -1243,6 +1243,12 @@ "SourceReportPage": { "additionalProperties": false, "properties": { + "object": { + "const": "page", + "default": "page", + "title": "Object", + "type": "string" + }, "data": { "items": { "$ref": "#/$defs/SourceReportSummary" @@ -1265,12 +1271,6 @@ } ], "title": "Next" - }, - "object": { - "const": "page", - "default": "page", - "title": "Object", - "type": "string" } }, "required": ["data", "has_more", "next"], @@ -1280,22 +1280,6 @@ "SourceReportSummary": { "additionalProperties": false, "properties": { - "assessment": { - "$ref": "#/$defs/ImportedAssessment" - }, - "canonical_finding_id": { - "pattern": "^acf_[0-9a-f]{32}$", - "title": "Canonical Finding Id", - "type": "string" - }, - "created_at": { - "title": "Created At", - "type": "integer" - }, - "environment_id": { - "title": "Environment Id", - "type": "string" - }, "id": { "pattern": "^aif_[0-9a-f]{64}$", "title": "Id", @@ -1307,23 +1291,48 @@ "title": "Object", "type": "string" }, - "observation_id": { - "pattern": "^aio_[0-9a-f]{64}$", - "title": "Observation Id", - "type": "string" - }, "origin": { "const": "imported", "default": "imported", "title": "Origin", "type": "string" }, + "repo_id": { + "title": "Repo Id", + "type": "string" + }, "repo_connector_id": { "title": "Repo Connector Id", "type": "string" }, - "repo_id": { - "title": "Repo Id", + "environment_id": { + "title": "Environment Id", + "type": "string" + }, + "canonical_finding_id": { + "pattern": "^acf_[0-9a-f]{32}$", + "title": "Canonical Finding Id", + "type": "string" + }, + "source": { + "$ref": "#/$defs/ImportSource" + }, + "source_finding_id": { + "title": "Source Finding Id", + "type": "string" + }, + "observation_id": { + "pattern": "^aio_[0-9a-f]{64}$", + "title": "Observation Id", + "type": "string" + }, + "version": { + "minimum": 1, + "title": "Version", + "type": "integer" + }, + "title": { + "title": "Title", "type": "string" }, "severity": { @@ -1338,13 +1347,6 @@ "title": "Severity", "type": "string" }, - "source": { - "$ref": "#/$defs/ImportSource" - }, - "source_finding_id": { - "title": "Source Finding Id", - "type": "string" - }, "source_updated_at": { "anyOf": [ { @@ -1356,17 +1358,15 @@ ], "title": "Source Updated At" }, - "title": { - "title": "Title", - "type": "string" + "assessment": { + "$ref": "#/$defs/ImportedAssessment" }, - "updated_at": { - "title": "Updated At", + "created_at": { + "title": "Created At", "type": "integer" }, - "version": { - "minimum": 1, - "title": "Version", + "updated_at": { + "title": "Updated At", "type": "integer" } }, @@ -1393,15 +1393,15 @@ "additionalProperties": false, "description": "Schema export root; transport endpoints return the individual models.", "properties": { + "request": { + "$ref": "#/$defs/FindingImportRequest" + }, "receipt": { "$ref": "#/$defs/FindingImportReceipt" }, "repositories": { "$ref": "#/$defs/ImportRepositoryPage" }, - "request": { - "$ref": "#/$defs/FindingImportRequest" - }, "source_report": { "$ref": "#/$defs/SourceReport" }, diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index f37ad93da..325b87afc 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -19,6 +19,7 @@ const distModules = [ "external-import-models", "endor-findings", "scanner-import", + "semgrep-findings", "snyk-findings", "wiz-findings", "codex-prompt", diff --git a/sdk/typescript/src/external-import-models.ts b/sdk/typescript/src/external-import-models.ts index d9b32d91b..1f55518c0 100644 --- a/sdk/typescript/src/external-import-models.ts +++ b/sdk/typescript/src/external-import-models.ts @@ -1,262 +1,240 @@ /* Generated from schemas/external-findings.schema.json. Run pnpm generate:external-import-models. */ -export type Actor = string; -export type Created = number; -export type Error = number; -export type Unchanged = number; -export type Updated = number; -export type CreatedAt = number; +export type RequestId = string; export type Id = string; -export type ItemCount = number; -export type Object = "security.finding_import"; -export type EnvironmentId = string; -export type Id1 = string; export type RepoConnectorId = string; +export type EnvironmentId = string; export type ResetMarker = string; -export type CanonicalFindingId = string | null; -export type ClientId = string; -export type Code = - "version_conflict" | "scope_conflict" | "stale_source_snapshot"; -export type Message = string; -export type ObservationId = string | null; -export type Outcome = "created" | "updated" | "unchanged" | "error"; -export type SourceReportId = string | null; -export type Version = number | null; -export type Results = FindingImportResult[]; export type Provider = "wiz" | "endor" | "snyk" | "github" | "semgrep"; export type SourceKey = string; -export type DefaultBranch = string | null; -export type Id2 = string; -export type ImportEnvironmentId = string | null; -export type Object1 = "security.repository"; -export type RepoConnectorId1 = string; -export type ResetMarker1 = string; -export type Url = string; -export type Data = ImportRepository[]; -export type HasMore = boolean; -export type Next = string | null; -export type Object2 = "page"; -export type ClientId1 = string; +export type ClientId = string; +export type SourceFindingId = string; +export type ExpectedVersion = number; +export type Title = string; +export type Severity = + "critical" | "high" | "medium" | "low" | "informational" | "unknown"; +export type Description = string | null; +export type Url = string | null; /** * @maxItems 100 */ export type AdvisoryIds = string[]; -export type Branch = string | null; -export type CodeRevision = string | null; -export type Description = string | null; -export type Source = "repository" | "global"; -export type EndLine = number | null; -export type Snippet = string | null; -export type Actual = string | null; -export type CloudPlatform = string | null; -export type Expected = string | null; -export type Platform = string | null; -export type FileUrl = string | null; -export type Kind = "sast" | "secret" | "iac" | "advisory"; -export type RemediationInstructions = string | null; -export type Id3 = string | null; -export type Name = string | null; -export type Url1 = string; -export type Id4 = string | null; -export type Name1 = string | null; -export type ShortId = string | null; -export type ScannerOrigin = string | null; -export type ScannerVerdict = string | null; -export type ImportedSecretConfidence = string; -export type IntroducedCommit = string | null; -export type IsEncrypted = boolean | null; -export type IsManaged = boolean | null; -export type Type = string | null; -export type ValidationStatus = string | null; -export type SourceStatus = string | null; +export type Name = string; +export type Ecosystem = string | null; +export type InstalledVersion = string | null; +export type VulnerableVersionRange = string | null; +export type ManifestPath = string | null; /** * @maxItems 100 */ -export type WeaknessIds = string[]; +export type FixedVersions = string[]; /** * @maxItems 100 */ -export type ImageDigests = string[]; -export type Line = number | null; +export type Packages = ImportedPackage[]; export type Path = string; +export type Line = number | null; /** * @maxItems 100 */ export type Locations = ImportedLocation[]; -export type Ecosystem = string | null; +export type Branch = string | null; +export type CodeRevision = string | null; +export type SourceScanId = string | null; /** * @maxItems 100 */ -export type FixedVersions = string[]; -export type InstalledVersion = string | null; -export type ManifestPath = string | null; -export type Name2 = string; -export type VulnerableVersionRange = string | null; +export type ImageDigests = string[]; +export type SourceUpdatedAt = number | null; +export type Kind = "sast" | "secret" | "iac" | "advisory"; +export type Url1 = string; +export type Id1 = string | null; +export type Name1 = string | null; +export type Source = "repository" | "global"; +export type SourceStatus = string | null; +export type ScannerOrigin = string | null; +export type ScannerVerdict = string | null; +export type Id2 = string | null; +export type ShortId = string | null; +export type Name2 = string | null; /** * @maxItems 100 */ -export type Packages = ImportedPackage[]; -export type Severity = - "critical" | "high" | "medium" | "low" | "informational" | "unknown"; -export type SourceScanId = string | null; -export type SourceUpdatedAt = number | null; -export type Title = string; -export type Url2 = string | null; -export type ExpectedVersion = number; -export type SourceFindingId = string; +export type WeaknessIds = string[]; +export type RemediationInstructions = string | null; +export type EndLine = number | null; +export type Snippet = string | null; +export type Type = string | null; +export type ImportedSecretConfidence = string; +export type ValidationStatus = string | null; +export type IsEncrypted = boolean | null; +export type IsManaged = boolean | null; +export type IntroducedCommit = string | null; +export type Platform = string | null; +export type CloudPlatform = string | null; +export type Expected = string | null; +export type Actual = string | null; +export type FileUrl = string | null; /** * @minItems 1 * @maxItems 100 */ export type Items = FindingImportItem[]; -export type RequestId = string; -export type State = "not_assessed"; -export type CanonicalFindingId1 = string; -export type CreatedAt1 = number; -export type EnvironmentId1 = string; +export type Id3 = string; +export type Object = "security.finding_import"; +export type Actor = string; +export type CreatedAt = number; +export type ItemCount = number; +export type Created = number; +export type Updated = number; +export type Unchanged = number; +export type Error = number; +export type ClientId1 = string; +export type Outcome = "created" | "updated" | "unchanged" | "error"; +export type SourceReportId = string | null; +export type ObservationId = string | null; +export type CanonicalFindingId = string | null; +export type Version = number | null; +export type Code = + "version_conflict" | "scope_conflict" | "stale_source_snapshot"; +export type Message = string; +export type Results = FindingImportResult[]; +export type Object1 = "page"; +export type Id4 = string; +export type Object2 = "security.repository"; +export type RepoConnectorId1 = string; +export type Url2 = string; +export type DefaultBranch = string | null; +export type ResetMarker1 = string; +export type ImportEnvironmentId = string | null; +export type Data = ImportRepository[]; +export type HasMore = boolean; +export type Next = string | null; export type Id5 = string; -export type Actor1 = string; -export type CreatedAt2 = number; -export type Id6 = string; export type Object3 = "security.source_report"; -export type ObservationId1 = string; export type Origin = "imported"; -export type RepoConnectorId2 = string; export type RepoId = string; +export type RepoConnectorId2 = string; +export type EnvironmentId1 = string; +export type CanonicalFindingId1 = string; export type SourceFindingId1 = string; -export type UpdatedAt = number; +export type ObservationId1 = string; export type Version1 = number; -export type CanonicalFindingId2 = string; -export type CreatedAt3 = number; -export type EnvironmentId2 = string; +export type State = "not_assessed"; +export type Id6 = string; +export type Actor1 = string; +export type CreatedAt1 = number; +export type CreatedAt2 = number; +export type UpdatedAt = number; +export type Object4 = "page"; export type Id7 = string; -export type Object4 = "security.source_report_summary"; -export type ObservationId2 = string; +export type Object5 = "security.source_report_summary"; export type Origin1 = "imported"; -export type RepoConnectorId3 = string; export type RepoId1 = string; +export type RepoConnectorId3 = string; +export type EnvironmentId2 = string; +export type CanonicalFindingId2 = string; +export type SourceFindingId2 = string; +export type ObservationId2 = string; +export type Version2 = number; +export type Title1 = string; export type Severity1 = "critical" | "high" | "medium" | "low" | "informational" | "unknown"; -export type SourceFindingId2 = string; export type SourceUpdatedAt1 = number | null; -export type Title1 = string; +export type CreatedAt3 = number; export type UpdatedAt1 = number; -export type Version2 = number; export type Data1 = SourceReportSummary[]; export type HasMore1 = boolean; export type Next1 = string | null; -export type Object5 = "page"; /** * Schema export root; transport endpoints return the individual models. */ export interface ExternalFindingContracts { + request: FindingImportRequest; receipt: FindingImportReceipt; repositories: ImportRepositoryPage; - request: FindingImportRequest; source_report: SourceReport; source_reports: SourceReportPage; } -export interface FindingImportReceipt { - actor: Actor; - counts: ImportCounts; - created_at: CreatedAt; - id: Id; - item_count: ItemCount; - object?: Object; +export interface FindingImportRequest { + request_id: RequestId; repository: ImportDestination; - results: Results; source: ImportSource; -} -export interface ImportCounts { - created?: Created; - error?: Error; - unchanged?: Unchanged; - updated?: Updated; + items: Items; } export interface ImportDestination { - environment_id: EnvironmentId; - id: Id1; + id: Id; repo_connector_id: RepoConnectorId; + environment_id: EnvironmentId; reset_marker: ResetMarker; } -export interface FindingImportResult { - canonical_finding_id?: CanonicalFindingId; - client_id: ClientId; - error?: ImportItemError | null; - observation_id?: ObservationId; - outcome: Outcome; - source_report_id?: SourceReportId; - version?: Version; -} -export interface ImportItemError { - code: Code; - message: Message; -} export interface ImportSource { provider: Provider; source_key: SourceKey; } -export interface ImportRepositoryPage { - data: Data; - has_more: HasMore; - next: Next; - object?: Object2; -} -export interface ImportRepository { - default_branch?: DefaultBranch; - id: Id2; - import_environment_id?: ImportEnvironmentId; - object?: Object1; - repo_connector_id: RepoConnectorId1; - reset_marker: ResetMarker1; - url: Url; -} -export interface FindingImportRequest { - items: Items; - repository: ImportDestination; - request_id: RequestId; - source: ImportSource; -} export interface FindingImportItem { - client_id: ClientId1; - evidence: ImportedFindingEvidence; - expected_version: ExpectedVersion; + client_id: ClientId; source_finding_id: SourceFindingId; + expected_version: ExpectedVersion; + evidence: ImportedFindingEvidence; } export interface ImportedFindingEvidence { + title: Title; + severity: Severity; + description?: Description; + url?: Url; advisory_ids?: AdvisoryIds; + packages?: Packages; + locations?: Locations; branch?: Branch; code_revision?: CodeRevision; - description?: Description; - details?: ImportedFindingDetails | null; - image_digests?: ImageDigests; - locations?: Locations; - packages?: Packages; - severity: Severity; - source_data?: SourceData; source_scan_id?: SourceScanId; + image_digests?: ImageDigests; source_updated_at?: SourceUpdatedAt; - title: Title; - url?: Url2; + source_data?: SourceData; + details?: ImportedFindingDetails | null; +} +export interface ImportedPackage { + name: Name; + ecosystem?: Ecosystem; + installed_version?: InstalledVersion; + vulnerable_version_range?: VulnerableVersionRange; + manifest_path?: ManifestPath; + fixed_versions?: FixedVersions; +} +export interface ImportedLocation { + path: Path; + line?: Line; +} +export interface SourceData { + [k: string]: unknown; } /** * Typed repository evidence; vendor state is not a Codex assessment. */ export interface ImportedFindingDetails { - advisory?: ImportedAdvisoryContext | null; - code?: ImportedCodeContext | null; - configuration?: ImportedConfigurationDetails | null; - file_url?: FileUrl; kind: Kind; - remediation_instructions?: RemediationInstructions; repository?: ImportedRepositoryReference | null; - rule?: ImportedFindingRule | null; + advisory?: ImportedAdvisoryContext | null; + source_status?: SourceStatus; scanner_origin?: ScannerOrigin; scanner_verdict?: ScannerVerdict; - secret?: ImportedSecretDetails | null; - source_status?: SourceStatus; + rule?: ImportedFindingRule | null; weakness_ids?: WeaknessIds; + remediation_instructions?: RemediationInstructions; + code?: ImportedCodeContext | null; + secret?: ImportedSecretDetails | null; + configuration?: ImportedConfigurationDetails | null; + file_url?: FileUrl; +} +/** + * Vendor repository identity, resolved to the full source-control URL. + */ +export interface ImportedRepositoryReference { + url: Url1; + id?: Id1; + name?: Name1; } /** * Where the advisory was read; applicability is assessed separately. @@ -264,6 +242,11 @@ export interface ImportedFindingDetails { export interface ImportedAdvisoryContext { source: Source; } +export interface ImportedFindingRule { + id?: Id2; + short_id?: ShortId; + name?: Name2; +} /** * Optional range and snippet for the evidence's first source location. */ @@ -271,98 +254,115 @@ export interface ImportedCodeContext { end_line?: EndLine; snippet?: Snippet; } +export interface ImportedSecretDetails { + type?: Type; + confidence?: ImportedSecretConfidence | null; + validation_status?: ValidationStatus; + is_encrypted?: IsEncrypted; + is_managed?: IsManaged; + introduced_commit?: IntroducedCommit; +} export interface ImportedConfigurationDetails { - actual?: Actual; + platform?: Platform; cloud_platform?: CloudPlatform; expected?: Expected; - platform?: Platform; + actual?: Actual; } -/** - * Vendor repository identity, resolved to the full source-control URL. - */ -export interface ImportedRepositoryReference { - id?: Id3; - name?: Name; - url: Url1; +export interface FindingImportReceipt { + id: Id3; + object?: Object; + repository: ImportDestination; + source: ImportSource; + actor: Actor; + created_at: CreatedAt; + item_count: ItemCount; + counts: ImportCounts; + results: Results; } -export interface ImportedFindingRule { - id?: Id4; - name?: Name1; - short_id?: ShortId; +export interface ImportCounts { + created?: Created; + updated?: Updated; + unchanged?: Unchanged; + error?: Error; } -export interface ImportedSecretDetails { - confidence?: ImportedSecretConfidence | null; - introduced_commit?: IntroducedCommit; - is_encrypted?: IsEncrypted; - is_managed?: IsManaged; - type?: Type; - validation_status?: ValidationStatus; +export interface FindingImportResult { + client_id: ClientId1; + outcome: Outcome; + source_report_id?: SourceReportId; + observation_id?: ObservationId; + canonical_finding_id?: CanonicalFindingId; + version?: Version; + error?: ImportItemError | null; } -export interface ImportedLocation { - line?: Line; - path: Path; +export interface ImportItemError { + code: Code; + message: Message; } -export interface ImportedPackage { - ecosystem?: Ecosystem; - fixed_versions?: FixedVersions; - installed_version?: InstalledVersion; - manifest_path?: ManifestPath; - name: Name2; - vulnerable_version_range?: VulnerableVersionRange; +export interface ImportRepositoryPage { + object?: Object1; + data: Data; + has_more: HasMore; + next: Next; } -export interface SourceData { - [k: string]: unknown; +export interface ImportRepository { + id: Id4; + object?: Object2; + repo_connector_id: RepoConnectorId1; + url: Url2; + default_branch?: DefaultBranch; + reset_marker: ResetMarker1; + import_environment_id?: ImportEnvironmentId; } export interface SourceReport { - assessment?: ImportedAssessment; - canonical_finding_id: CanonicalFindingId1; - created_at: CreatedAt1; - environment_id: EnvironmentId1; - evidence: ImportedFindingEvidence; id: Id5; - last_import?: LastImport | null; object?: Object3; - observation_id: ObservationId1; origin?: Origin; - repo_connector_id: RepoConnectorId2; repo_id: RepoId; + repo_connector_id: RepoConnectorId2; + environment_id: EnvironmentId1; + canonical_finding_id: CanonicalFindingId1; source: ImportSource; source_finding_id: SourceFindingId1; - updated_at: UpdatedAt; + observation_id: ObservationId1; version: Version1; + evidence: ImportedFindingEvidence; + assessment?: ImportedAssessment; + last_import?: LastImport | null; + created_at: CreatedAt2; + updated_at: UpdatedAt; } export interface ImportedAssessment { state?: State; } export interface LastImport { - actor: Actor1; - created_at: CreatedAt2; id: Id6; + actor: Actor1; + created_at: CreatedAt1; } export interface SourceReportPage { + object?: Object4; data: Data1; has_more: HasMore1; next: Next1; - object?: Object5; } export interface SourceReportSummary { - assessment?: ImportedAssessment; - canonical_finding_id: CanonicalFindingId2; - created_at: CreatedAt3; - environment_id: EnvironmentId2; id: Id7; - object?: Object4; - observation_id: ObservationId2; + object?: Object5; origin?: Origin1; - repo_connector_id: RepoConnectorId3; repo_id: RepoId1; - severity: Severity1; + repo_connector_id: RepoConnectorId3; + environment_id: EnvironmentId2; + canonical_finding_id: CanonicalFindingId2; source: ImportSource; source_finding_id: SourceFindingId2; - source_updated_at: SourceUpdatedAt1; + observation_id: ObservationId2; + version: Version2; title: Title1; + severity: Severity1; + source_updated_at: SourceUpdatedAt1; + assessment?: ImportedAssessment; + created_at: CreatedAt3; updated_at: UpdatedAt1; - version: Version2; } export type ExternalFindingEvidence = ImportedFindingEvidence; From c465086714893b2daa8efc8973258d72d4f21183 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 23:02:23 +0000 Subject: [PATCH 3/9] fix: handle native Semgrep export variants --- docs/scanner-imports.md | 5 +- .../scripts/fixtures/package-consumer.ts | 2 +- sdk/typescript/src/semgrep-findings.ts | 23 +++++-- .../tests-ts/semgrep-findings.test.ts | 69 +++++++++++++++++++ 4 files changed, 91 insertions(+), 8 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index c9eccafe0..16030aa52 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -91,7 +91,10 @@ the upload. Semgrep requires a Cloud version that accepts the `semgrep` provider - 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. -- JSON scan errors and unsuccessful SARIF invocations are rejected. Ambiguous +- 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, diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 237ea358c..8aca69bf0 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -293,7 +293,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/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index af59715f1..b89023ef1 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -150,13 +150,19 @@ export function normalizeSemgrepFindings( throw new Error( "The Semgrep report contains scan errors; supply a successful report before importing findings.", ); - const { results, ...context } = document; + 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"); - if (extra["sca_info"] != null || extra["validation_state"] != null) + const metadata = record(extra["metadata"] ?? {}, "Semgrep metadata"); + const product = metadata["product"]; + if ( + (product != null && product !== "sast") || + extra["sca_info"] != null || + extra["validation_state"] != null + ) throw new Error( "This adapter supports Semgrep SAST; Supply Chain and Secrets reports need separate qualification.", ); @@ -230,7 +236,7 @@ export function normalizeSemgrepSarif( const tool = record(run["tool"], "SARIF tool"); const driver = record(tool["driver"], "SARIF driver"); if ( - !["Semgrep", "Semgrep OSS", "Semgrep Pro"].includes( + !["Semgrep", "Semgrep OSS", "Semgrep PRO", "Semgrep Pro"].includes( requiredText(driver["name"], "SARIF producer"), ) ) @@ -241,11 +247,16 @@ export function normalizeSemgrepSarif( run["invocations"] ?? [], "Semgrep invocations", )) { + const details = record(invocation, "Semgrep invocation"); + if (details["executionSuccessful"] === false) + throw new Error("Semgrep SARIF contains an unsuccessful invocation."); if ( - record(invocation, "Semgrep invocation")["executionSuccessful"] === - false + array( + details["toolExecutionNotifications"] ?? [], + "Semgrep scan notifications", + ).length ) - throw new Error("Semgrep SARIF contains an unsuccessful invocation."); + throw new Error("Semgrep SARIF contains scan error notifications."); } const rules = array(driver["rules"] ?? [], "Semgrep rules").map((r) => record(r, "Semgrep rule"), diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index 6416ef4d3..efe1a6293 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -184,3 +184,72 @@ test("does not relabel Supply Chain or Secrets findings as SAST", () => { expect(output.excluded[0]!.reason).toContain("separate qualification"); } }); + +test("accepts the native Semgrep PRO SARIF producer name", () => { + const input = semgrepSarif(); + input.runs[0]!.tool.driver.name = "Semgrep PRO"; + const output = normalizeSemgrepSarif(input); + expect(output.findings).toHaveLength(1); + expect(output.excluded).toEqual([]); +}); + +test.each([ + ["secrets", undefined], + ["secrets", null], + ["sca", undefined], + ["sca", null], +] as const)( + "excludes explicit %s product without validator metadata (%s)", + (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); +}); From c3a0119f439cb40dec3107dcbb2fa1e61758a707 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 23:22:02 +0000 Subject: [PATCH 4/9] fix: accept native Semgrep code validation markers --- sdk/typescript/src/semgrep-findings.ts | 3 ++- .../tests-ts/semgrep-findings.test.ts | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index b89023ef1..4cc36b33f 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -158,10 +158,11 @@ export function normalizeSemgrepFindings( 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 || - extra["validation_state"] != null + (validation != null && validation !== "NO_VALIDATOR") ) throw new Error( "This adapter supports Semgrep SAST; Supply Chain and Secrets reports need separate qualification.", diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index efe1a6293..deec17cb3 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -198,8 +198,10 @@ test.each([ ["secrets", null], ["sca", undefined], ["sca", null], + ["secrets", "NO_VALIDATOR"], + ["sca", "NO_VALIDATOR"], ] as const)( - "excludes explicit %s product without validator metadata (%s)", + "excludes explicit %s product without validator metadata (%p)", (product, validation) => { const input: any = semgrepJson(); input.results[0].extra.metadata = { product }; @@ -253,3 +255,19 @@ test("large scan inventories and timing tables do not exclude a small finding", ).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" } }, + }); + }, +); From 73e36e65d83d0804ca803ef0a66a72d39433283f Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 23:32:42 +0000 Subject: [PATCH 5/9] fix: accept native Semgrep CE SARIF variants --- sdk/typescript/src/semgrep-findings.ts | 24 +++++--- .../tests-ts/semgrep-findings.test.ts | 61 ++++++++++++++++--- 2 files changed, 67 insertions(+), 18 deletions(-) diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index 4cc36b33f..bf18d5577 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -237,8 +237,8 @@ export function normalizeSemgrepSarif( const tool = record(run["tool"], "SARIF tool"); const driver = record(tool["driver"], "SARIF driver"); if ( - !["Semgrep", "Semgrep OSS", "Semgrep PRO", "Semgrep Pro"].includes( - requiredText(driver["name"], "SARIF producer"), + !["semgrep", "semgrep oss", "semgrep pro"].includes( + requiredText(driver["name"], "SARIF producer").toLowerCase(), ) ) throw new Error("Expected a Semgrep SARIF producer."); @@ -292,14 +292,15 @@ export function normalizeSemgrepSarif( ); const ruleId = requiredText(result["ruleId"], "Semgrep ruleId"); const matches = rules.filter((r) => r["id"] === ruleId); - if (matches.length !== 1) + 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 rule = matches[0]; + const ruleIndex = rule === undefined ? undefined : rules.indexOf(rule); if ( result["ruleIndex"] !== undefined && - result["ruleIndex"] !== rules.indexOf(rule) + result["ruleIndex"] !== ruleIndex ) throw new Error("Semgrep ruleIndex and ruleId disagree."); if (result["rule"] !== undefined) { @@ -307,9 +308,8 @@ export function normalizeSemgrepSarif( if ( ref["toolComponent"] !== undefined || (ref["id"] !== undefined && ref["id"] !== ruleId) || - (ref["index"] !== undefined && - ref["index"] !== rules.indexOf(rule)) || - (ref["guid"] !== undefined && ref["guid"] !== rule["guid"]) + (ref["index"] !== undefined && ref["index"] !== ruleIndex) || + (ref["guid"] !== undefined && ref["guid"] !== rule?.["guid"]) ) throw new Error("Inconsistent or external Semgrep rule reference."); } @@ -334,7 +334,7 @@ export function normalizeSemgrepSarif( throw new Error("Semgrep source range ends before it starts."); const message = record(result["message"], "Semgrep message"); const configuration = record( - rule["defaultConfiguration"] ?? {}, + rule?.["defaultConfiguration"] ?? {}, "Rule configuration", ); const level = text(result["level"]) ?? text(configuration["level"]); @@ -374,7 +374,11 @@ export function normalizeSemgrepSarif( ? "suppressed" : null, }, - source_data: structuredClone({ ...context, rule, result }), + source_data: structuredClone({ + ...context, + ...(rule ? { rule } : {}), + result, + }), }), }; }, diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index deec17cb3..d5852f962 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -157,7 +157,8 @@ test("requires source identity and rejects conflicting SARIF repository provenan }); test("gated CE fingerprint placeholders use independent location identities", () => { - const input = semgrepSarif(); + 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); @@ -185,13 +186,57 @@ test("does not relabel Supply Chain or Secrets findings as SAST", () => { } }); -test("accepts the native Semgrep PRO SARIF producer name", () => { - const input = semgrepSarif(); - input.runs[0]!.tool.driver.name = "Semgrep PRO"; - const output = normalizeSemgrepSarif(input); - expect(output.findings).toHaveLength(1); - expect(output.excluded).toEqual([]); -}); +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], From a1496d5200a040a0b0e5f5783b310db35e08786e Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Fri, 16 Oct 2026 23:45:43 +0000 Subject: [PATCH 6/9] fix: preserve Semgrep findings with empty messages --- sdk/typescript/src/semgrep-findings.ts | 11 +++++-- .../tests-ts/semgrep-findings.test.ts | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index bf18d5577..cc4775871 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -42,6 +42,12 @@ function ungatedText(value: unknown): string | null { 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", @@ -182,7 +188,7 @@ export function normalizeSemgrepFindings( source_finding_id: jsonIdentity(result), evidence: validateExternalEvidence({ title: rule, - description: requiredText(extra["message"], "Semgrep message"), + description: messageText(extra["message"]), severity: severity(extra["severity"]), locations: path ? [{ path, line }] : [], details: { @@ -353,8 +359,7 @@ export function normalizeSemgrepSarif( evidence: validateExternalEvidence({ title: ruleId, description: - text(message["markdown"]) ?? - requiredText(message["text"], "Semgrep message text"), + text(message["markdown"]) ?? messageText(message["text"]), severity: mappedSeverity, locations: path ? [{ path, line }] : [], details: { diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index d5852f962..fef72b9a3 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -316,3 +316,36 @@ test.each([undefined, "sast"])( }); }, ); + +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); + } + }, +); From a5dc4711c4e1317cab201c7b6d124883ef198418 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 16:03:21 +0900 Subject: [PATCH 7/9] fix: preserve Semgrep SARIF paths and repository identity --- .../src/external-findings-publish.ts | 8 +-- .../src/external-import-contract.ts | 6 ++ sdk/typescript/src/semgrep-findings.ts | 14 ++-- .../tests-ts/semgrep-findings.test.ts | 69 +++++++++++++++++++ 4 files changed, 84 insertions(+), 13 deletions(-) diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index 1cf12b11f..9e4bc27d8 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -33,7 +33,7 @@ import type { ExternalPublicationInput, } from "./external-findings.js"; import { - repositoryUrlKey, + cloudRepositoryUrlKey, validateImportRequest, validateExternalEvidence, validateImportReceipt, @@ -50,12 +50,6 @@ 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; 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/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index cc4775871..9df1ad5d4 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -1,5 +1,5 @@ import { - repositoryUrlKey, + cloudRepositoryUrlKey, validateExternalEvidence, } from "./external-import-contract.js"; import type { ExternalFindings } from "./external-findings.js"; @@ -132,9 +132,11 @@ function sarifIdentity(value: unknown): string { return identity( null, requiredText(result["ruleId"], "Semgrep ruleId"), - requiredText( - record(physical["artifactLocation"], "Artifact location")["uri"], - "Semgrep artifact URI", + decodeURIComponent( + requiredText( + record(physical["artifactLocation"], "Artifact location")["uri"], + "Semgrep artifact URI", + ), ), line, column, @@ -234,7 +236,7 @@ export function normalizeSemgrepSarif( ); if ( declared && - repositoryUrlKey(declared) !== repositoryUrlKey(repositoryUrl) + cloudRepositoryUrlKey(declared) !== cloudRepositoryUrlKey(repositoryUrl) ) throw new Error( "SARIF source repository does not match the declared source repository URL.", @@ -353,7 +355,7 @@ export function normalizeSemgrepSarif( : level === "note" ? "low" : "unknown"; - const path = relativePath(rawPath); + const path = relativePath(decodeURIComponent(rawPath)); return { source_finding_id: sarifIdentity(result), evidence: validateExternalEvidence({ diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index fef72b9a3..c98310d94 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -156,6 +156,75 @@ test("requires source identity and rejects conflicting SARIF repository provenan expect(() => normalizeSemgrepFindings(input)).toThrow("does not match"); }); +test.each([true, false])( + "SARIF decodes source paths once and matches 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; + sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.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([ + "%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 = semgrepSarif(); + input.runs[0]!.results[0]!.locations[0]!.physicalLocation.artifactLocation.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("gated CE fingerprint placeholders use independent location identities", () => { const input: any = semgrepSarif(); delete input.runs[0].tool.driver.rules; From 7d9a9c299d09a8dcd8f1dfb0134472d08159ffaf Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 16:47:35 +0900 Subject: [PATCH 8/9] fix: avoid mapping unresolved Semgrep SARIF locations --- docs/scanner-imports.md | 5 + sdk/typescript/src/semgrep-findings.ts | 48 ++++++-- .../external-findings-publish.test.ts | 49 ++++++++ .../tests-ts/semgrep-findings.test.ts | 114 ++++++++++++++++++ 4 files changed, 205 insertions(+), 11 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index 690d3ecd8..baedb8ed1 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -102,6 +102,11 @@ the upload. Semgrep requires a Cloud version that accepts the `semgrep` provider 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. 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. diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index 9df1ad5d4..27834ab25 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -111,7 +111,30 @@ function jsonIdentity(value: unknown): string { ); } -function sarifIdentity(value: unknown): string { +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; + return decodeURIComponent( + requiredText(artifact["uri"], "Semgrep artifact URI"), + ); +} + +function sarifIdentity( + value: unknown, + runContext: Record, +): string { const result = record(value, "Semgrep result"); const supplied = ungatedText( record(result["fingerprints"] ?? {}, "Semgrep fingerprints")[ @@ -126,18 +149,21 @@ function sarifIdentity(value: unknown): string { 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"), - decodeURIComponent( - requiredText( - record(physical["artifactLocation"], "Artifact location")["uri"], - "Semgrep artifact URI", - ), - ), + uri, line, column, position(region["endLine"] ?? line, "End line"), @@ -332,7 +358,7 @@ export function normalizeSemgrepSarif( physical["artifactLocation"], "Artifact location", ); - const rawPath = requiredText(artifact["uri"], "Semgrep artifact URI"); + 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"); @@ -355,9 +381,9 @@ export function normalizeSemgrepSarif( : level === "note" ? "low" : "unknown"; - const path = relativePath(decodeURIComponent(rawPath)); + const path = rawPath === null ? null : relativePath(rawPath); return { - source_finding_id: sarifIdentity(result), + source_finding_id: sarifIdentity(result, context.runContext), evidence: validateExternalEvidence({ title: ruleId, description: @@ -389,7 +415,7 @@ export function normalizeSemgrepSarif( }), }; }, - ({ result }) => sarifIdentity(result), + ({ result, runContext }) => sarifIdentity(result, runContext), ); } diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index d803553e2..bcb19ba9b 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -3003,6 +3003,55 @@ test("Semgrep publication preserves identity across JSON and SARIF with explicit }); }); +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()); diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index c98310d94..3614a0a08 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -225,6 +225,120 @@ test("SARIF matches custom GitHub Enterprise repository casing without merging p } }); +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; From f9771602e0b38c50da2df590c5422d64b5df3069 Mon Sep 17 00:00:00 2001 From: Michael D'Angelo Date: Sat, 17 Oct 2026 17:13:49 +0900 Subject: [PATCH 9/9] fix: preserve literal Semgrep SARIF paths --- docs/scanner-imports.md | 4 +- sdk/typescript/src/semgrep-findings.ts | 6 +- .../external-findings-publish.test.ts | 61 +++++++++++++ .../tests-ts/semgrep-findings.test.ts | 88 +++++++++++++++++-- 4 files changed, 149 insertions(+), 10 deletions(-) diff --git a/docs/scanner-imports.md b/docs/scanner-imports.md index baedb8ed1..dcf8b603a 100644 --- a/docs/scanner-imports.md +++ b/docs/scanner-imports.md @@ -103,7 +103,9 @@ the upload. Semgrep requires a Cloud version that accepts the `semgrep` provider 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. Indexed artifacts, other URI bases and redefined + 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). diff --git a/sdk/typescript/src/semgrep-findings.ts b/sdk/typescript/src/semgrep-findings.ts index 27834ab25..1c699d895 100644 --- a/sdk/typescript/src/semgrep-findings.ts +++ b/sdk/typescript/src/semgrep-findings.ts @@ -126,9 +126,9 @@ function sarifArtifactUri( ] !== undefined)) ) return null; - return decodeURIComponent( - requiredText(artifact["uri"], "Semgrep artifact URI"), - ); + 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( diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index bcb19ba9b..070f75a5a 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -3003,6 +3003,67 @@ test("Semgrep publication preserves identity across JSON and SARIF with explicit }); }); +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(); diff --git a/sdk/typescript/tests-ts/semgrep-findings.test.ts b/sdk/typescript/tests-ts/semgrep-findings.test.ts index 3614a0a08..7dd2f28c6 100644 --- a/sdk/typescript/tests-ts/semgrep-findings.test.ts +++ b/sdk/typescript/tests-ts/semgrep-findings.test.ts @@ -157,14 +157,16 @@ test("requires source identity and rejects conflicting SARIF repository provenan }); test.each([true, false])( - "SARIF decodes source paths once and matches JSON identity (fingerprint=%s)", + "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; - sarif.runs[0].results[0].locations[0].physicalLocation.artifactLocation.uri = - path.split("/").map(encodeURIComponent).join("/"); + 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; @@ -183,6 +185,78 @@ test.each([true, false])( }, ); +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", @@ -191,9 +265,11 @@ test.each([ ])( "SARIF retains encoded unsafe URI %s without a repository location", (uri) => { - const input = semgrepSarif(); - input.runs[0]!.results[0]!.locations[0]!.physicalLocation.artifactLocation.uri = - 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([]);