diff --git a/plugins/codex-security/skills/publish-findings/SKILL.md b/plugins/codex-security/skills/publish-findings/SKILL.md index 3ea68bf03..de2e3fb8e 100644 --- a/plugins/codex-security/skills/publish-findings/SKILL.md +++ b/plugins/codex-security/skills/publish-findings/SKILL.md @@ -1,13 +1,17 @@ --- name: publish-findings -description: Import selected Wiz package vulnerabilities, SAST, repository secrets, and IaC findings to Codex Security Cloud after previewing the destination and evidence. Supports plain or gzip-compressed JSON and normalized JSONL; does not import workload secrets or cloud configuration, run a scan, or assess findings. +description: Import selected Wiz findings, GitHub code-scanning alerts, or GHSA security advisories to Codex Security Cloud after previewing the destination and evidence. Supports saved JSON/JSONL and gzip; does not run a scan, assess applicability, or synchronize triage decisions. --- # Publish external findings Use the installed `codex-security publish findings` command for both preview and upload. It owns source mapping, Cloud authentication, source versions, and saved request retries. Do not implement another HTTP uploader or put credentials in a command argument. -Cloud destinations currently support connected GitHub and GitHub Enterprise repositories, including custom Enterprise hostnames. Resolve the user's destination to its repository URL or Cloud repository ID; the command accepts either. Confirm that the repository is connected in the intended Cloud account and has an authorized environment. Use the existing file-backed ChatGPT login; explain a missing or incompatible login rather than changing credential storage automatically. Read selected Wiz findings through the user's configured connection or supplied export. Save the actual selected JSON records locally, retaining their IDs and evidence. Finish the requested pagination; if the user selected a subset, save that explicit subset and retain its named collection. The command does not fetch from Wiz or accept CSV, scan events, workload secrets, cloud configuration, or external network exports. The publisher also accepts JSONL records with `source_finding_id` and `evidence` as described in the CLI documentation. +Cloud destinations currently support connected GitHub and GitHub Enterprise repositories, including custom Enterprise hostnames. Resolve the user's destination to its repository URL or Cloud repository ID; the command accepts either. Confirm that the repository is connected in the intended Cloud account and has an authorized environment. Use the existing file-backed ChatGPT login; explain a missing or incompatible login rather than changing credential storage automatically. + +## Wiz findings + +Read selected Wiz findings through the user's configured connection or supplied export. Save the actual selected JSON records locally, retaining their IDs and evidence. Finish the requested pagination; if the user selected a subset, save that explicit subset and retain its named collection. The command does not fetch from Wiz or accept CSV, scan events, workload secrets, cloud configuration, or external network exports. The publisher also accepts JSONL records with `source_finding_id` and `evidence` as described in the CLI documentation. Use a stable Wiz tenant/finding namespace as `--source-key`. A project filter is selection context, not a new identity. Confirm the selected assets or builds belong to the destination repository; do not infer a scanner branch or revision from the current checkout. Do not publish vendor credentials, unrelated records, or local assessments as original scanner evidence. @@ -17,6 +21,30 @@ For SAST, repository secrets, and IaC, retain the explicit `data.sastFindings.no Keep raw source records unchanged in `source_data`. New raw occurrence IDs are prefixed `sast:`, `secret:`, or `iac:`; existing package IDs are unchanged. For CSV report conversion, use a reviewed mapping of the actual header names into the documented normalized JSONL profile, set `evidence.details.kind` and the verified `details.repository.url`, and use the same prefixed occurrence ID. The command does not parse CSV directly. Select source paths and line numbers explicitly in secret reports; the standard metadata query lacks them. Preserve rule/CWE, range/snippet, detector validation, and IaC comparison fields in the typed details described in the CLI docs. Preserve empty comparison strings. Do not invent missing IDs, severity, revisions, or locations, retrieve credential values, test credentials, or apply suggested fixes. Vendor status, AI verdict, secret validation, introduced commits, and logical IaC resources are evidence, not Codex assessment or proof of runtime deployment. +## GitHub alerts and advisories + +Keep code-scanning alerts and GHSA advisories as separate source families. Read code-scanning alerts with the existing `codex-security import github OWNER/REPO --format json` command. It defaults to open alerts on the default branch; use `--github-alert NUMBER` repeatedly for exact alerts or `--github-ref REF` for a selected ref. Save the complete output without field or token filters. The mapper retains the selected instance's ref, revision, detection state, rule, scanner, location and original evidence. Typed source status honors an alert-wide dismissal; otherwise it uses the selected instance's open/fixed state. Dismissal is not a Codex assessment. Missing security severity remains `unknown`; do not turn SARIF error/warning/note into vulnerability ratings. + +Read advisories with `codex-security import github-advisories OWNER/REPO --format json`. It defaults to published repository advisories. `--github-advisory GHSA-xxxx-xxxx-xxxx` selects exact repository advisory IDs and may include private drafts only when the user selects them. To list private repository advisories, require the user's explicit state selection using `--github-state draft|triage|closed`. Do not combine a nondefault state with exact IDs. For public database records, use `--github-advisory-source global` with one or more explicit `--github-advisory` IDs; never enumerate or mirror the global database. Both readers are read-only and use the user's GitHub CLI authentication and `GH_HOST` when configured. Stop on a read failure instead of publishing an incomplete selection. + +Publish the saved reader output with `--provider github`. Use a stable namespace such as `HOST/OWNER/REPO/code-scanning` or `HOST/OWNER/REPO/security-advisories` as `--source-key`, with separate keys for the two families. Code-scanning identities include the ref; GHSA records retain their advisory identity. Code-scanning and repository advisories must match the selected Cloud repository. Global advisory evidence omits source-repository provenance; the explicit Cloud destination selects where to investigate it. Preserve affected package ranges and patched versions without claiming an installed vulnerable dependency or inventing source locations. The original GHSA/CVE IDs remain evidence, not cross-provider source identities. GitHub dismissal, resolution and advisory withdrawal are not Codex assessments. Do not write back to GitHub or include Dependabot alerts without a separately supported ingestion path. + +```sh +codex-security import github example/project --format json > /path/to/alerts.json +codex-security publish findings /path/to/alerts.json \ + --to cloud --repository https://github.com/example/project --provider github \ + --source-key github.com/example/project/code-scanning --dry-run --format json + +codex-security import github-advisories example/project \ + --github-advisory-source global --github-advisory GHSA-2345-6789-cfgh \ + --format json > /path/to/advisories.json +codex-security publish findings /path/to/advisories.json \ + --to cloud --repository https://github.com/example/project --provider github \ + --source-key github.com/example/project/security-advisories --dry-run --format json +``` + +## Review and publish + For staging or another Cloud deployment, set `CODEX_SECURITY_CLOUD_BASE_URL` to its API root before both preview and upload. If the Cloud page or user supplies its app URL, also set `CODEX_SECURITY_CLOUD_WEB_URL` to that full URL so the command can return a repository Findings link. The UI setting does not change API routing or authentication. Use that deployment's login and an isolated state directory for tests. Verify `cloudApiUrl` and any `cloudUrl` in the preview against the intended deployment; the former is the API endpoint and the latter is the UI link. Saved retries are separated by deployment. The native scan setting `CODEX_SECURITY_CLOUD_PUBLISH_URL` does not route vendor imports. Never fall back to production when staging is unavailable. ```sh diff --git a/plugins/codex-security/skills/publish-findings/agents/openai.yaml b/plugins/codex-security/skills/publish-findings/agents/openai.yaml index c6d611bc3..624f9f5a0 100644 --- a/plugins/codex-security/skills/publish-findings/agents/openai.yaml +++ b/plugins/codex-security/skills/publish-findings/agents/openai.yaml @@ -1,6 +1,6 @@ interface: display_name: "Publish Findings" short_description: "Preview and publish vendor findings to Cloud" - default_prompt: "Use $publish-findings to preview selected Wiz findings for my Cloud repository." + default_prompt: "Use $publish-findings to preview selected Wiz findings, GitHub code-scanning alerts, or GHSA advisories for my Cloud repository." policy: allow_implicit_invocation: true diff --git a/sdk/typescript/README.md b/sdk/typescript/README.md index 606c65d47..4912a74d8 100644 --- a/sdk/typescript/README.md +++ b/sdk/typescript/README.md @@ -604,6 +604,15 @@ grant Cloud access. See [Cloud publication](docs/cli.md#publish-findings-to-clou for setup and review steps, or [Linear publication](docs/cli.md#publish-completed-scans-to-linear) for issue creation. +The SDK also exposes `prepareExternalPublication(file, options)` for saved Wiz or GitHub +imports and `prepareNormalizedExternalPublication(findings, options)` for +normalized external evidence. Both resolve the Cloud repository and return a +`preview` plus a separate `publish()` operation. Inspect the preview before +calling `publish()`. Imported evidence remains not assessed by Codex, and saved +requests and receipts support retries after interruption. GitHub code-scanning +alerts and GHSA advisories have [separate readers and mappings](docs/cli.md#import-github-findings-to-cloud); +advisory imports preserve affected ranges without claiming an installed dependency. + ## Findings storage and deduplication Saved-scan deduplication uses local SQLite directly: diff --git a/sdk/typescript/docs/cli.md b/sdk/typescript/docs/cli.md index 3e50f3379..3cd2f2103 100644 --- a/sdk/typescript/docs/cli.md +++ b/sdk/typescript/docs/cli.md @@ -1733,6 +1733,106 @@ record absolute Node/CLI paths. If those change, back up and remove the verified generated hook, then reinstall with the same threshold. Restore the backup if installation fails. The installer respects `core.hooksPath`. +## Import GitHub findings to Cloud + +Save code-scanning alerts with the existing read-only importer, then inspect the +Cloud publication preview: + +```bash +codex-security import github example/project --format json > /path/outside/repository/alerts.json +codex-security publish findings /path/outside/repository/alerts.json \ + --to cloud --provider github --repository https://github.com/example/project \ + --source-key github.com/example/project/code-scanning --dry-run --format json +``` + +After reviewing the destination, source, exclusions and evidence, repeat the +publication command without `--dry-run` and confirm it, or add `--yes` for a +previously reviewed input. Authentication, batching, saved receipts, partial +results and interrupted-upload recovery follow the [Wiz publication flow](#import-wiz-findings). +JSON, JSONL and gzip are supported. Save complete import output without field or +token filters. + +Use a stable source key for the GitHub host, repository and finding family. +Code-scanning identities also include the selected ref, so an import from another +branch cannot replace an existing branch's evidence. GitHub's source state and +full original alert stay attached to the source report. An alert-wide dismissal +applies to every branch; otherwise the selected instance supplies the open/fixed +state. Dismissal or a fixed state does not create a Codex assessment. Alerts without +a security severity are reported as `unknown`; SARIF error/warning/note levels are not vulnerability ratings. + +The SDK offers the same preview and publication boundary: + +```ts +import { + importGitHubCodeScanningAlerts, + normalizeGitHubCodeScanningAlerts, + prepareNormalizedExternalPublication, +} from "@openai/codex-security"; + +const alerts = await importGitHubCodeScanningAlerts({ + repository: "example/project", +}); +const prepared = await prepareNormalizedExternalPublication( + normalizeGitHubCodeScanningAlerts(alerts), + { + provider: "github", + repository: "https://github.com/example/project", + sourceKey: "github.com/example/project/code-scanning", + }, +); +console.log(prepared.preview); +// After reviewing the preview: +const receipt = await prepared.publish(); +``` + +### Import GHSA advisories separately + +Repository advisories describe disclosures maintained by a repository. Global +advisories describe affected software in GitHub's public advisory database; they +do not establish that your repository installs that software. + +```bash +# Read only published advisories for this repository. +codex-security import github-advisories example/project --format json > /path/outside/repository/advisories.json + +# Select individual public database records without enumerating the database. +codex-security import github-advisories example/project \ + --github-advisory-source global --github-advisory GHSA-2345-6789-cfgh \ + --format json > /path/outside/repository/advisories.json + +codex-security publish findings /path/outside/repository/advisories.json \ + --to cloud --provider github --repository https://github.com/example/project \ + --source-key github.com/example/project/security-advisories --dry-run --format json +``` + +`--github-advisory-source` defaults to `repository`. Repeat `--github-advisory` +to select exact IDs from that API, regardless of advisory state. Repository +lists default to `--github-state published`; `draft`, `triage`, and `closed` +require explicit selection. A nondefault state cannot be combined with exact +IDs. Global selection requires at least one ID and never lists the advisory +database. Both readers use `GH_HOST` and GitHub CLI authentication by default; +the SDK also accepts `githubHost` and `githubToken`. + +Review the saved evidence before publication, including any selected private +advisories. Import failure returns no partial selection. Publication reports +invalid records as exclusions and retains acknowledged receipts if a later +batch or readback fails. + +Advisory evidence retains GHSA/CVE IDs, affected version ranges, available +patched versions, source status and original JSON. It does not infer an installed +version or source location. Global advisories omit a source repository; the +explicit Cloud destination binds the import for investigation. Repository +advisories retain and check their source repository. Withdrawal remains vendor +state, and all imported evidence remains not assessed by Codex. Use a separate +source key from code-scanning alerts; shared advisory IDs do not combine source +records from different providers. + +In the SDK, call `importGitHubAdvisories()` and `normalizeGitHubAdvisories()` +with `prepareNormalizedExternalPublication()` as shown above. The former defaults +to `{ source: "repository", state: "published" }`; use +`{ source: "global", advisoryIds: ["GHSA-2345-6789-cfgh"] }` for selected database +records. Both forms require `repository: "OWNER/REPO"` as source or target context. + ## Import alerts from the CLI ```bash diff --git a/sdk/typescript/schemas/external-findings.schema.json b/sdk/typescript/schemas/external-findings.schema.json index bd0f50e10..c10ad248e 100644 --- a/sdk/typescript/schemas/external-findings.schema.json +++ b/sdk/typescript/schemas/external-findings.schema.json @@ -378,7 +378,7 @@ "additionalProperties": false, "properties": { "provider": { - "enum": ["wiz", "endor", "snyk"], + "enum": ["wiz", "endor", "snyk", "github"], "title": "Provider", "type": "string" }, @@ -393,6 +393,20 @@ "title": "ImportSource", "type": "object" }, + "ImportedAdvisoryContext": { + "additionalProperties": false, + "description": "Where the advisory was read; applicability is assessed separately.", + "properties": { + "source": { + "enum": ["repository", "global"], + "title": "Source", + "type": "string" + } + }, + "required": ["source"], + "title": "ImportedAdvisoryContext", + "type": "object" + }, "ImportedAssessment": { "additionalProperties": false, "properties": { @@ -506,12 +520,29 @@ "description": "Typed repository evidence; vendor state is not a Codex assessment.", "properties": { "kind": { - "enum": ["sast", "secret", "iac"], + "enum": ["sast", "secret", "iac", "advisory"], "title": "Kind", "type": "string" }, "repository": { - "$ref": "#/$defs/ImportedRepositoryReference" + "anyOf": [ + { + "$ref": "#/$defs/ImportedRepositoryReference" + }, + { + "type": "null" + } + ] + }, + "advisory": { + "anyOf": [ + { + "$ref": "#/$defs/ImportedAdvisoryContext" + }, + { + "type": "null" + } + ] }, "source_status": { "anyOf": [ @@ -637,7 +668,7 @@ "title": "File Url" } }, - "required": ["kind", "repository"], + "required": ["kind"], "title": "ImportedFindingDetails", "type": "object" }, @@ -651,7 +682,14 @@ "type": "string" }, "severity": { - "enum": ["critical", "high", "medium", "low", "informational"], + "enum": [ + "critical", + "high", + "medium", + "low", + "informational", + "unknown" + ], "title": "Severity", "type": "string" }, @@ -910,6 +948,19 @@ "default": null, "title": "Installed Version" }, + "vulnerable_version_range": { + "anyOf": [ + { + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Vulnerable Version Range" + }, "manifest_path": { "anyOf": [ { @@ -1285,7 +1336,14 @@ "type": "string" }, "severity": { - "enum": ["critical", "high", "medium", "low", "informational"], + "enum": [ + "critical", + "high", + "medium", + "low", + "informational", + "unknown" + ], "title": "Severity", "type": "string" }, diff --git a/sdk/typescript/scripts/fixtures/package-consumer.ts b/sdk/typescript/scripts/fixtures/package-consumer.ts index 985170fc7..46fa9ff28 100644 --- a/sdk/typescript/scripts/fixtures/package-consumer.ts +++ b/sdk/typescript/scripts/fixtures/package-consumer.ts @@ -1,5 +1,14 @@ import { CodexSecurity, + importGitHubCodeScanningAlerts, + importGitHubAdvisories, + normalizeGitHubAdvisories, + normalizeGitHubCodeScanningAlerts, + prepareExternalPublication, + prepareNormalizedExternalPublication, + type ExternalFindings, + type ExternalPublicationOptions, + type ExternalPublicationResult, DiffTarget, classifySeverity, classifyScanSeverity, @@ -282,6 +291,50 @@ export async function dedupeRecords( return await deduplicateRecords(input, { reviewRunner, signal }); } +export async function externalPublication( + file: string, + input: ExternalFindings, + options: ExternalPublicationOptions, +): Promise { + const fromFile = await prepareExternalPublication(file, options); + fromFile.preview.requests[0]?.source.provider satisfies + "wiz" | "endor" | "snyk" | "github" | undefined; + const prepared = await prepareNormalizedExternalPublication(input, options); + return prepared.publish(); +} + +export async function publishGitHubAlerts(): Promise { + const imported = await importGitHubCodeScanningAlerts({ + repository: "example/project", + }); + const prepared = await prepareNormalizedExternalPublication( + normalizeGitHubCodeScanningAlerts(imported), + { + provider: "github", + sourceKey: "github.com/example/project/code-scanning", + repository: "https://github.com/example/project", + }, + ); + return prepared.publish(); +} + +export async function publishGitHubAdvisories(): Promise { + const imported = await importGitHubAdvisories({ + repository: "example/project", + source: "global", + advisoryIds: ["GHSA-2345-6789-cfgh"], + }); + const prepared = await prepareNormalizedExternalPublication( + normalizeGitHubAdvisories(imported), + { + provider: "github", + sourceKey: "github.com/example/project/security-advisories", + repository: "https://github.com/example/project", + }, + ); + return prepared.publish(); +} + export const ownerVariants: OwnerSuggestion["owner"][] = [ { name: "Alex Example", email: "alex@example.test" }, { kind: "person", provider: "github", handle: "example-user" }, diff --git a/sdk/typescript/scripts/package-dist-files.mjs b/sdk/typescript/scripts/package-dist-files.mjs index 342844187..d8b27886a 100644 --- a/sdk/typescript/scripts/package-dist-files.mjs +++ b/sdk/typescript/scripts/package-dist-files.mjs @@ -17,6 +17,7 @@ const distModules = [ "severity-store", "cloud-endpoint", "cloud-publish", + "external-findings", "external-findings-publish", "external-import-contract", "external-import-models", @@ -48,6 +49,8 @@ const distModules = [ "finding-catalogue", "findings-import", "github", + "github-advisories", + "github-findings", "index", "import-scan", "knowledge-base", diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index b6bfe53bb..f0fd6b120 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -395,7 +395,7 @@ try { "--input-type=module", "--eval", `const sdk = await import(${JSON.stringify(packageManifest.name)}); - for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "deduplicateRecords", "classifySeverity", "classifyScanSeverity", "classifyScanDirectorySeverity", "matchScanFindings", "securityPolicyDiff", "loadProjectConfig", "resolveProjectConfig"]) { + for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "deduplicateRecords", "classifySeverity", "classifyScanSeverity", "classifyScanDirectorySeverity", "matchScanFindings", "securityPolicyDiff", "loadProjectConfig", "resolveProjectConfig", "prepareExternalPublication", "prepareNormalizedExternalPublication", "importGitHubCodeScanningAlerts", "normalizeGitHubCodeScanningAlerts", "readGitHubFindings", "importGitHubAdvisories", "normalizeGitHubAdvisories"]) { if (typeof sdk[name] !== "function") { throw new Error("The installed package does not export " + name + "."); } diff --git a/sdk/typescript/src/cli.ts b/sdk/typescript/src/cli.ts index ba63bcf7b..d1b70d692 100644 --- a/sdk/typescript/src/cli.ts +++ b/sdk/typescript/src/cli.ts @@ -185,6 +185,10 @@ import { ScanCostLimitExceededError, ScanInterruptedError, } from "./errors.js"; +import { + GITHUB_ADVISORY_STATES, + importGitHubAdvisories, +} from "./github-advisories.js"; import { GITHUB_ALERT_STATES, importGitHubCodeScanningAlerts, @@ -596,7 +600,7 @@ function renderExternalPublicationSummary( ...(result.status === "interrupted" ? [] : [ - "Imported Wiz evidence remains not assessed by Codex.", + "Imported evidence remains not assessed by Codex.", result.cloudUrl ? `View findings: ${diagnosticValue(result.cloudUrl)}\nSearch indexing can lag an accepted import.` : "Open the Codex Security Cloud app's Findings view and select this repository. Search indexing can lag an accepted import.", @@ -1211,6 +1215,7 @@ interface CliDependencies { planComponents?: typeof planComponents; linearClient?: LinearClientFactory; importGitHubAlerts?: typeof importGitHubCodeScanningAlerts; + importGitHubAdvisories?: typeof importGitHubAdvisories; importScan?: typeof importScan; runWorkbench( args: readonly string[], @@ -3249,16 +3254,15 @@ export async function main( }, }); publication.command("findings", { - description: - "Preview and import selected Wiz repository findings to Cloud.", - hint: "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", + description: "Preview and import selected Wiz or GitHub findings to Cloud.", + hint: "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( - "Saved Wiz finding collections, package vulnerability JSON, or normalized JSONL.", + "Saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.", ), }), options: z.object({ @@ -3267,8 +3271,8 @@ export async function main( "Authorized Cloud repository ID or repository URL copied from Cloud.", ), provider: z - .literal("wiz") - .describe("Vendor that produced these findings."), + .enum(["wiz", "github"]) + .describe("Provider that produced these findings."), sourceKey: optionValue("--source-key").describe( "Stable vendor tenant and finding namespace; not a project selection filter.", ), @@ -3467,7 +3471,7 @@ export async function main( }, }); const imports = Cli.create("import", { - description: "Read GitHub code scanning alerts.", + description: "Read GitHub code scanning alerts and security advisories.", }).command("github", { description: "Read GitHub code scanning alerts without changing GitHub.", hint: "To save CSV or JSON findings as a local scan, use codex-security scan import --help.", @@ -3548,6 +3552,93 @@ export async function main( } }, }); + imports.command("github-advisories", { + description: "Read GitHub security advisories without changing GitHub.", + hint: "Defaults to published repository advisories. Use --github-advisory-source global with explicit --github-advisory IDs to select public database entries. Advisories do not establish that the selected repository has an installed vulnerable dependency.", + destructive: false, + mcp: false, + args: z.object({ + repository: z + .string() + .min(1) + .describe( + "Source repository, or target context for global IDs, in OWNER/REPO form.", + ), + }), + options: z.object({ + githubAdvisory: z + .array( + optionValue("--github-advisory").regex( + /^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/iu, + "GitHub advisory IDs must use GHSA-xxxx-xxxx-xxxx form.", + ), + ) + .default([]) + .describe( + "Exact GHSA ID; repeat to select a subset. Required for global advisories.", + ), + githubAdvisorySource: z + .enum(["repository", "global"]) + .default("repository") + .describe( + "Advisory API to read; global requires exact IDs and never lists the database.", + ), + githubState: z + .enum(GITHUB_ADVISORY_STATES) + .default("published") + .describe( + "Repository advisory list state; omit when selecting exact GHSA IDs.", + ), + }), + output: z + .array( + z.object({ + source: z.literal("github-security-advisory"), + selection: z.enum(["repository", "global"]), + repository: z.string(), + githubHost: z.string(), + ghsaId: z.string(), + url: z.string(), + advisory: z.record(z.string(), z.unknown()), + }), + ) + .optional(), + async run({ args, options }) { + const controller = new AbortController(); + const removeSignals = listenForAbort(dependencies, controller); + try { + return await ( + dependencies.importGitHubAdvisories ?? importGitHubAdvisories + )( + { + repository: args.repository, + advisoryIds: options.githubAdvisory, + source: options.githubAdvisorySource, + state: options.githubState, + signal: controller.signal, + }, + { + environment: dependencies.environment, + currentDirectory: dependencies.currentDirectory(), + }, + ); + } catch (error) { + const signal = controller.signal.reason; + if (signal === "SIGINT" || signal === "SIGTERM") { + exitCode = signal === "SIGINT" ? 130 : 143; + errorOutput.write( + `codex-security: GitHub advisory import ${signal === "SIGINT" ? "canceled" : "terminated"}.\n`, + ); + } else { + exitCode = 2; + errorOutput.write(`codex-security: ${diagnosticValue(error)}\n`); + } + return undefined; + } finally { + removeSignals(); + } + }, + }); const infoOutput = z.object({ sdkVersion: z.string(), bundledPluginVersion: z.string(), diff --git a/sdk/typescript/src/external-findings-publish.ts b/sdk/typescript/src/external-findings-publish.ts index f0e8f793a..6bfca461a 100644 --- a/sdk/typescript/src/external-findings-publish.ts +++ b/sdk/typescript/src/external-findings-publish.ts @@ -23,13 +23,16 @@ import { codexSecurityStateDirectory, validatePreparedOutputDir, } from "./runtime.js"; -import { - readVendorFindingsForPublication, - type VendorFindings, -} from "./wiz-findings.js"; +import { readVendorFindingsForPublication } from "./wiz-findings.js"; +import { readGitHubFindings } from "./github-findings.js"; +import type { + ExternalFindings, + ExternalPublicationInput, +} from "./external-findings.js"; import { repositoryUrlKey, validateImportRequest, + validateExternalEvidence, validateImportReceipt, validateRepositories, validateSourceReports, @@ -53,11 +56,11 @@ function cloudRepositoryUrlKey(value: string): string { /** CLI and plugin callers prepare the same immutable request before approval. */ export interface ExternalPublicationOptions { repository: string; - provider: "wiz"; + provider: "wiz" | "github"; sourceKey: string; } -/** @internal Injectable transport and account for offline integration tests. */ +/** Optional transport, account, cancellation, and progress hooks. */ export interface ExternalPublicationDependencies { environment?: NodeJS.ProcessEnv; fetch?: (url: string, options: RequestInit) => Promise; @@ -85,7 +88,7 @@ export interface ExternalPublicationResult { status: "complete" | "partial" | "interrupted"; read: number; ready: number; - excluded: VendorFindings["excluded"]; + excluded: ExternalFindings["excluded"]; receipts: FindingImportReceipt[]; counts: Required; failures: { source_finding_id: string; code: string; message: string }[]; @@ -113,7 +116,7 @@ interface SavedSubmission { inputProjection?: { inputDigest: string; savedEvidenceDigest: string }; } -export interface ExternalPublicationPreview extends VendorFindings { +export interface ExternalPublicationPreview extends ExternalFindings { cloudApiUrl: string; cloudUrl?: string; accountId: string; @@ -176,7 +179,7 @@ function sameSubmission( function submissionFindings( requests: readonly FindingImportRequest[], -): VendorFindings["findings"] { +): ExternalFindings["findings"] { return requests.flatMap((batch) => batch.items.map(({ source_finding_id, evidence }) => ({ source_finding_id, @@ -186,8 +189,8 @@ function submissionFindings( } function sameFindingSelection( - left: VendorFindings["findings"], - right: VendorFindings["findings"], + left: ExternalFindings["findings"], + right: ExternalFindings["findings"], ): boolean { return ( left.length === right.length && @@ -283,6 +286,62 @@ export async function prepareExternalPublication( path: string, options: ExternalPublicationOptions, dependencies: ExternalPublicationDependencies = {}, +): Promise { + if (options.provider === "github") + return prepareNormalizedExternalPublication( + await readGitHubFindings(path), + options, + dependencies, + ); + const input = await readVendorFindingsForPublication(path); + return preparePublicationInput(input, options, dependencies); +} + +/** Prepare normalized provider evidence without reading a vendor export. */ +export async function prepareNormalizedExternalPublication( + input: ExternalFindings, + options: ExternalPublicationOptions, + dependencies: ExternalPublicationDependencies = {}, +): Promise { + // Validation adds Cloud defaults; leave the caller's objects unchanged. + const current = structuredClone(input); + // structuredClone preserves aliases. Detach every raw record before adding + // typed defaults, including aliases shared with another finding's evidence. + for (const finding of current.findings) + if (finding.evidence.source_data !== undefined) + finding.evidence.source_data = structuredClone( + finding.evidence.source_data, + ); + const identities = new Set(); + for (const finding of current.findings) { + const id = finding.source_finding_id; + if (!id.trim() || id.includes("\0") || Buffer.byteLength(id) > 512) + throw new CodexSecurityError( + "Source finding id must contain 1–512 UTF-8 bytes and no NUL characters.", + ); + if (identities.has(id)) + throw new CodexSecurityError( + `Duplicate source finding id ${JSON.stringify(id)}.`, + ); + identities.add(id); + finding.evidence = validateExternalEvidence(finding.evidence); + } + return preparePublicationInput( + { + current, + legacy: current, + currentIdentity: current.findings, + legacyIdentity: current.findings, + }, + options, + dependencies, + ); +} + +async function preparePublicationInput( + input: ExternalPublicationInput, + options: ExternalPublicationOptions, + dependencies: ExternalPublicationDependencies, ): Promise { const progress = (event: ExternalPublicationProgress): void => { try { @@ -293,7 +352,9 @@ export async function prepareExternalPublication( }; if (!options.sourceKey.trim() || Buffer.byteLength(options.sourceKey) > 512) { throw new CodexSecurityError( - "The source key must contain 1–512 UTF-8 bytes. Use a stable Wiz tenant/finding namespace, such as TENANT_ID/vulnerability-finding, across exports.", + options.provider === "wiz" + ? "The source key must contain 1–512 UTF-8 bytes. Use a stable Wiz tenant/finding namespace, such as TENANT_ID/vulnerability-finding, across exports." + : "The source key must contain 1–512 UTF-8 bytes. Use a stable GitHub host/repository/finding namespace across imports.", ); } progress({ phase: "reading", completed: 0 }); @@ -302,7 +363,7 @@ export async function prepareExternalPublication( legacy: legacyParsed, currentIdentity, legacyIdentity, - } = await readVendorFindingsForPublication(path); + } = input; if (parsed.findings.length === 0) { throw new CodexSecurityError( `No supported findings are ready to publish. ${parsed.excluded.map((item) => `Item ${item.position}: ${item.reason}`).join(" ")}`, diff --git a/sdk/typescript/src/external-findings.ts b/sdk/typescript/src/external-findings.ts new file mode 100644 index 000000000..3c4a0436c --- /dev/null +++ b/sdk/typescript/src/external-findings.ts @@ -0,0 +1,25 @@ +import type { ExternalFindingEvidence } from "./external-import-models.js"; + +/** A provider occurrence and its evidence, before Cloud assigns a source report. */ +export interface ExternalFinding { + source_finding_id: string; + evidence: ExternalFindingEvidence; +} + +export interface ExternalFindings { + read: number; + findings: ExternalFinding[]; + excluded: { + position: number; + source_finding_id: string | null; + reason: string; + }[]; +} + +/** @internal Keep earlier provider projections only for immutable request replay. */ +export interface ExternalPublicationInput { + current: ExternalFindings; + legacy: ExternalFindings; + currentIdentity: unknown; + legacyIdentity: unknown; +} diff --git a/sdk/typescript/src/external-import-contract.ts b/sdk/typescript/src/external-import-contract.ts index 7da1c18fc..b289e7425 100644 --- a/sdk/typescript/src/external-import-contract.ts +++ b/sdk/typescript/src/external-import-contract.ts @@ -123,13 +123,29 @@ function materializeDetailDefaults( function normalizeEvidenceDetails(value: unknown): void { if (value !== null && typeof value === "object") { const fields = value as Record; - // Only this new wrapper is omitted for null; retain every legacy default. + // Cloud omits these new optional fields when null. Preserve every legacy + // default so saved Wiz requests still replay the same evidence bytes. + if (Array.isArray(fields["packages"])) + for (const value of fields["packages"]) { + if ( + value && + typeof value === "object" && + value["vulnerable_version_range"] === null + ) + delete value["vulnerable_version_range"]; + } if (fields["details"] === null) delete fields["details"]; - else + else { + const details = fields["details"]; + if (details && typeof details === "object" && !Array.isArray(details)) + for (const field of ["repository", "advisory"]) + if ((details as Record)[field] === null) + delete (details as Record)[field]; materializeDetailDefaults( fields["details"], modelSchemas["ImportedFindingEvidence"]!.properties!["details"]!, ); + } } } @@ -183,6 +199,28 @@ export function validateExternalEvidence( ) throw new Error("Locations must use repository-relative paths."); } + const details = result.details; + if (details) { + if (details.kind === "advisory") { + if (!details.advisory) + throw new Error("Advisory evidence requires its source context."); + if (details.advisory.source === "global" && details.repository) + throw new Error( + "Global advisory evidence cannot claim a source repository.", + ); + if (details.advisory.source === "repository" && !details.repository) + throw new Error( + "Repository advisory evidence requires its source repository.", + ); + } else { + if (!details.repository) + throw new Error( + "Repository finding evidence requires its source repository.", + ); + if (details.advisory) + throw new Error("Advisory source context requires advisory evidence."); + } + } const endLine = result.details?.code?.end_line; if (endLine !== null && endLine !== undefined) { const firstLine = result.locations?.[0]?.line; @@ -193,7 +231,7 @@ export function validateExternalEvidence( } const urls = [ result.url, - result.details?.repository.url, + result.details?.repository?.url, result.details?.file_url, ]; for (const value of urls) { diff --git a/sdk/typescript/src/external-import-models.ts b/sdk/typescript/src/external-import-models.ts index efc87b42f..d588d2f64 100644 --- a/sdk/typescript/src/external-import-models.ts +++ b/sdk/typescript/src/external-import-models.ts @@ -5,13 +5,14 @@ export type Id = string; export type RepoConnectorId = string; export type EnvironmentId = string; export type ResetMarker = string; -export type Provider = "wiz" | "endor" | "snyk"; +export type Provider = "wiz" | "endor" | "snyk" | "github"; export type SourceKey = 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"; +export type Severity = + "critical" | "high" | "medium" | "low" | "informational" | "unknown"; export type Description = string | null; export type Url = string | null; /** @@ -21,6 +22,7 @@ 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; /** * @maxItems 100 @@ -44,10 +46,11 @@ export type SourceScanId = string | null; */ export type ImageDigests = string[]; export type SourceUpdatedAt = number | null; -export type Kind = "sast" | "secret" | "iac"; +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; @@ -136,7 +139,7 @@ export type ObservationId2 = string; export type Version2 = number; export type Title1 = string; export type Severity1 = - "critical" | "high" | "medium" | "low" | "informational"; + "critical" | "high" | "medium" | "low" | "informational" | "unknown"; export type SourceUpdatedAt1 = number | null; export type CreatedAt3 = number; export type UpdatedAt1 = number; @@ -196,6 +199,7 @@ export interface ImportedPackage { name: Name; ecosystem?: Ecosystem; installed_version?: InstalledVersion; + vulnerable_version_range?: VulnerableVersionRange; manifest_path?: ManifestPath; fixed_versions?: FixedVersions; } @@ -211,7 +215,8 @@ export interface SourceData { */ export interface ImportedFindingDetails { kind: Kind; - repository: ImportedRepositoryReference; + repository?: ImportedRepositoryReference | null; + advisory?: ImportedAdvisoryContext | null; source_status?: SourceStatus; scanner_origin?: ScannerOrigin; scanner_verdict?: ScannerVerdict; @@ -231,6 +236,12 @@ export interface ImportedRepositoryReference { id?: Id1; name?: Name1; } +/** + * Where the advisory was read; applicability is assessed separately. + */ +export interface ImportedAdvisoryContext { + source: Source; +} export interface ImportedFindingRule { id?: Id2; short_id?: ShortId; diff --git a/sdk/typescript/src/github-advisories.ts b/sdk/typescript/src/github-advisories.ts new file mode 100644 index 000000000..403d5bd71 --- /dev/null +++ b/sdk/typescript/src/github-advisories.ts @@ -0,0 +1,218 @@ +import type { Octokit } from "@octokit/core"; +import { CodexSecurityError, errorMessage } from "./errors.js"; +import { createAuthenticatedGitHub } from "./github.js"; + +export const GITHUB_ADVISORY_STATES = [ + "published", + "draft", + "triage", + "closed", +] as const; + +export interface GitHubAdvisoryImportOptions { + /** Source repository, or the selected target for explicit global advisory IDs. */ + repository: string; + /** Exact GHSA IDs. Required for the global advisory API. */ + advisoryIds?: readonly string[]; + /** Repository advisories by default; global never enumerates the database. */ + source?: "repository" | "global"; + /** Repository list state (default: published). Exact IDs ignore state. */ + state?: (typeof GITHUB_ADVISORY_STATES)[number]; + /** Defaults to GH_HOST or github.com. */ + githubHost?: string; + /** Omit to use the authenticated GitHub CLI. */ + githubToken?: string; + signal?: AbortSignal; +} + +type RepositoryAdvisory = Awaited>; +type GlobalAdvisory = Awaited>; + +export interface ImportedGitHubAdvisory { + source: "github-security-advisory"; + selection: "repository" | "global"; + repository: string; + githubHost: string; + ghsaId: string; + url: string; + /** Upstream disclosure, not proof that the selected repository is affected. */ + advisory: RepositoryAdvisory | GlobalAdvisory; +} + +interface GitHubAdvisoryImportDependencies { + environment?: NodeJS.ProcessEnv; + currentDirectory?: string; + createGitHub?: (host: string, signal?: AbortSignal) => Promise; +} + +/** Read repository disclosures or explicitly selected public GHSA records. */ +export async function importGitHubAdvisories( + options: GitHubAdvisoryImportOptions, + dependencies: GitHubAdvisoryImportDependencies = {}, +): Promise { + options.signal?.throwIfAborted(); + const repository = options.repository.trim(); + const parts = /^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/iu.exec(repository); + if (!parts || parts.slice(1).some((part) => /^\.{1,2}$/u.test(part))) + throw new CodexSecurityError("GitHub repository must be OWNER/REPO."); + const owner = parts[1]!; + const repo = parts[2]!; + const ids = [ + ...new Set( + (options.advisoryIds ?? []).map((id) => + id + .trim() + .toLowerCase() + .replace(/^ghsa-/u, "GHSA-"), + ), + ), + ]; + if (ids.some((id) => !/^GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/u.test(id))) + throw new CodexSecurityError( + "GitHub advisory IDs must use GHSA-xxxx-xxxx-xxxx form.", + ); + const source = options.source ?? "repository"; + const state = options.state ?? "published"; + if (source !== "repository" && source !== "global") + throw new CodexSecurityError( + "GitHub advisory source must be repository or global.", + ); + if (!GITHUB_ADVISORY_STATES.includes(state)) + throw new CodexSecurityError("GitHub advisory state is invalid."); + if (source === "global" && ids.length === 0) + throw new CodexSecurityError( + "Select explicit GHSA IDs for global advisories; database-wide import is not supported.", + ); + if (ids.length > 0 && state !== "published") + throw new CodexSecurityError( + "--github-state only filters repository lists; omit it when selecting exact GHSA IDs.", + ); + const environment = dependencies.environment ?? process.env; + const host = + options.githubHost?.trim() ?? + (environment["GH_HOST"]?.trim() || "github.com"); + if (!host) throw new CodexSecurityError("GitHub host must not be empty."); + + try { + const github = dependencies.createGitHub + ? await dependencies.createGitHub(host, options.signal) + : await createAuthenticatedGitHub(host, { + token: options.githubToken, + environment, + currentDirectory: dependencies.currentDirectory, + signal: options.signal, + }); + const advisories: (RepositoryAdvisory | GlobalAdvisory)[] = []; + if (ids.length > 0) { + for (const id of ids) { + options.signal?.throwIfAborted(); + advisories.push( + source === "global" + ? await fetchGlobalAdvisory(github, id, options.signal) + : await fetchRepositoryAdvisory( + github, + owner, + repo, + id, + options.signal, + ), + ); + } + } else { + let after: string | undefined; + const cursors = new Set(); + do { + options.signal?.throwIfAborted(); + const response = await github.request( + "GET /repos/{owner}/{repo}/security-advisories", + { + owner, + repo, + state, + per_page: 100, + after, + request: { signal: options.signal, redirect: "error" }, + }, + ); + advisories.push(...response.data); + const next = response.headers.link + ?.split(",") + .find((link) => /;\s*rel="next"/u.test(link)); + const nextUrl = next?.match(/<([^>]+)>/u)?.[1]; + // Keep requests on the authenticated route; only carry over the cursor. + after = nextUrl + ? (new URL(nextUrl).searchParams.get("after") ?? undefined) + : undefined; + if (next && (!after || cursors.has(after))) + throw new CodexSecurityError( + "GitHub returned invalid advisory pagination.", + ); + if (after) cursors.add(after); + } while (after !== undefined); + } + return advisories.map((advisory) => ({ + source: "github-security-advisory", + selection: source, + repository, + githubHost: host, + ghsaId: advisory.ghsa_id, + url: advisory.html_url, + advisory, + })); + } catch (error) { + options.signal?.throwIfAborted(); + if (error instanceof CodexSecurityError) throw error; + const status = + typeof error === "object" && error !== null && "status" in error + ? error.status + : undefined; + if (status === 401) + throw new CodexSecurityError("GitHub authentication failed."); + if (status === 403) + throw new CodexSecurityError( + "GitHub denied security advisory access. Check repository advisory permissions and API rate limits.", + ); + if (status === 404) + throw new CodexSecurityError( + "GitHub repository or security advisory was not found or is not accessible.", + ); + if (status === 429) + throw new CodexSecurityError( + "GitHub request was rate limited. Wait and retry.", + ); + throw new CodexSecurityError( + `GitHub security advisory request failed${typeof status === "number" ? ` (HTTP ${status})` : ""}: ${errorMessage(error)}`, + ); + } +} + +async function fetchRepositoryAdvisory( + github: Octokit, + owner: string, + repo: string, + id: string, + signal?: AbortSignal, +) { + const response = await github.request( + "GET /repos/{owner}/{repo}/security-advisories/{ghsa_id}", + { + owner, + repo, + ghsa_id: id, + request: { signal, redirect: "error" }, + }, + ); + return response.data; +} + +async function fetchGlobalAdvisory( + github: Octokit, + id: string, + signal?: AbortSignal, +) { + const response = await github.request("GET /advisories/{ghsa_id}", { + ghsa_id: id, + request: { signal, redirect: "error" }, + }); + return response.data; +} diff --git a/sdk/typescript/src/github-findings.ts b/sdk/typescript/src/github-findings.ts new file mode 100644 index 000000000..659e20125 --- /dev/null +++ b/sdk/typescript/src/github-findings.ts @@ -0,0 +1,338 @@ +import { hash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { promisify } from "node:util"; +import { gunzip } from "node:zlib"; +import { CodexSecurityError } from "./errors.js"; +import { validateExternalEvidence } from "./external-import-contract.js"; +import type { ExternalFinding, ExternalFindings } from "./external-findings.js"; +import type { ImportedGitHubCodeScanningAlert } from "./github.js"; +import type { ImportedGitHubAdvisory } from "./github-advisories.js"; + +const decompressGzip = promisify(gunzip); + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function text(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value : null; +} + +function repositoryName(value: unknown): string { + const repository = text(value); + const parts = /^([a-z0-9_.-]+)\/([a-z0-9_.-]+)$/iu.exec(repository ?? ""); + if (!parts || parts.slice(1).some((part) => /^\.{1,2}$/u.test(part))) + throw new Error("GitHub repository must be OWNER/REPO."); + return repository!; +} + +function sourceUrl(value: string): { + parsed: URL; + host: string; + origin: string; +} { + const parsed = new URL(value); + const origin = /^(https?:\/\/([^/?#]+))/iu.exec(value); + if (!origin) throw new Error("GitHub evidence URL must be an HTTP(S) link."); + // URL.origin/host remove explicit default ports; Cloud keeps those distinct. + return { parsed, host: origin[2]!.toLowerCase(), origin: origin[1]! }; +} + +function sourceTimestamp(value: unknown): number | null { + if (value === undefined || value === null) return null; + const seconds = typeof value === "string" ? Date.parse(value) / 1000 : NaN; + if (!Number.isFinite(seconds) || seconds < 0) + throw new Error("GitHub updated_at is not a valid timestamp."); + return Math.floor(seconds); +} + +function severity(value: unknown): string { + // SARIF error/warning/note are not vulnerability severity ratings. + return typeof value === "string" && + ["critical", "high", "medium", "low", "informational"].includes(value) + ? value + : "unknown"; +} + +function codeScanningFinding(record: Record): ExternalFinding { + const repository = repositoryName(record["repository"]); + const alert = object(record["alert"]); + const number = record["number"]; + if ( + !alert || + typeof number !== "number" || + !Number.isSafeInteger(number) || + number < 1 || + alert["number"] !== number + ) + throw new Error( + "Expected a GitHub code-scanning import with matching alert numbers.", + ); + const url = text(record["url"]); + if (!url || url !== alert["html_url"]) + throw new Error("GitHub alert URL must match the original alert."); + const { parsed: parsedUrl, host, origin } = sourceUrl(url); + if ( + !["security/code-scanning", "code-scanning"].some( + (route) => + parsedUrl.pathname.toLowerCase() === + `/${repository}/${route}/${number}`.toLowerCase(), + ) + ) + throw new Error( + "GitHub alert URL does not match the selected repository and alert.", + ); + const rule = object(alert["rule"]); + const instance = object(alert["most_recent_instance"]); + const ref = text(instance?.["ref"]); + if (!ref) + throw new Error( + "GitHub code-scanning evidence requires the selected instance ref.", + ); + const location = object(instance?.["location"]); + const tool = object(alert["tool"]); + const message = text(object(instance?.["message"])?.["text"]); + const description = + text(rule?.["full_description"]) ?? text(rule?.["description"]); + const tags = Array.isArray(rule?.["tags"]) ? rule["tags"] : []; + const weaknessIds = [ + ...new Set( + tags.flatMap((tag: unknown) => { + const match = + typeof tag === "string" + ? /^external\/cwe\/cwe-(\d+)$/iu.exec(tag) + : null; + return match ? [`CWE-${Number(match[1])}`] : []; + }), + ), + ]; + const path = text(location?.["path"]); + const sourceId = `${host}/${repository.toLowerCase()}/code-scanning/${number}/${hash("sha256", ref)}`; + return { + source_finding_id: sourceId, + evidence: validateExternalEvidence({ + title: + text(rule?.["name"]) ?? + text(rule?.["description"]) ?? + `GitHub code-scanning alert #${number}`, + severity: severity(rule?.["security_severity_level"]), + description: + [...new Set([message, description].filter(Boolean))].join("\n\n") || + null, + url, + locations: path ? [{ path, line: location?.["start_line"] ?? null }] : [], + branch: ref, + code_revision: text(instance?.["commit_sha"]), + source_updated_at: sourceTimestamp(alert["updated_at"]), + source_data: record, + details: { + kind: "sast", + repository: { + url: `${origin}/${repository}`, + name: repository, + }, + // Dismissal covers every branch; detection state belongs to the selected ref. + source_status: + alert["state"] === "dismissed" + ? "dismissed" + : (text(instance?.["state"]) ?? text(alert["state"])), + scanner_origin: text(tool?.["name"]), + rule: { id: text(rule?.["id"]), name: text(rule?.["name"]) }, + weakness_ids: weaknessIds, + remediation_instructions: text(rule?.["help"]), + code: path ? { end_line: location?.["end_line"] ?? null } : null, + }, + }), + }; +} + +function advisoryFinding(record: Record): ExternalFinding { + const repository = repositoryName(record["repository"]); + const advisory = object(record["advisory"]); + const id = text(record["ghsaId"]); + const selection = record["selection"]; + if ( + !advisory || + !id || + !/^GHSA-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/iu.test(id) || + text(advisory["ghsa_id"])?.toUpperCase() !== id.toUpperCase() + ) + throw new Error( + "Expected a GitHub advisory import with matching GHSA IDs.", + ); + if (selection !== "repository" && selection !== "global") + throw new Error("GitHub advisory selection must be repository or global."); + const url = text(record["url"]); + if (!url || url !== advisory["html_url"]) + throw new Error("GitHub advisory URL must match the original advisory."); + const { parsed: parsedUrl, host: linkHost, origin } = sourceUrl(url); + const host = text(record["githubHost"])?.toLowerCase(); + // Enterprise APIs may return the public advisory database's canonical link. + const canonicalGlobalLink = + selection === "global" && parsedUrl.host === "github.com"; + const expectedPath = + selection === "repository" + ? `/${repository}/security/advisories/${id}` + : `/advisories/${id}`; + if ( + !host || + (linkHost !== host && !canonicalGlobalLink) || + parsedUrl.pathname.toLowerCase() !== expectedPath.toLowerCase() + ) + throw new Error( + "GitHub advisory URL does not match the selected source and GHSA ID.", + ); + const vulnerabilities = + advisory["vulnerabilities"] === null ? [] : advisory["vulnerabilities"]; + if (!Array.isArray(vulnerabilities)) + throw new Error("GitHub advisory vulnerabilities must be an array."); + const identifiers = Array.isArray(advisory["identifiers"]) + ? advisory["identifiers"] + : []; + const advisoryIds = [ + id, + text(advisory["cve_id"]), + ...identifiers.map((identifier) => text(object(identifier)?.["value"])), + ].filter((value): value is string => value !== null); + const packages = vulnerabilities.flatMap((value) => { + const vulnerability = object(value); + const pkg = object(vulnerability?.["package"]); + const name = text(pkg?.["name"]); + // GitHub permits incomplete package metadata in advisory disclosures. + // Preserve it in source_data without inventing a typed package identity. + if (!name) return []; + const fixedVersion = + text(vulnerability?.["first_patched_version"]) ?? + text(vulnerability?.["patched_versions"]); + return [ + { + name, + ecosystem: text(pkg?.["ecosystem"]), + ...(text(vulnerability?.["vulnerable_version_range"]) + ? { + vulnerable_version_range: + vulnerability!["vulnerable_version_range"], + } + : {}), + fixed_versions: fixedVersion ? [fixedVersion] : [], + }, + ]; + }); + const cwes = Array.isArray(advisory["cwes"]) ? advisory["cwes"] : []; + return { + source_finding_id: `${host}/${selection === "repository" ? repository.toLowerCase() : "global"}/security-advisory/${id.toUpperCase()}`, + evidence: validateExternalEvidence({ + title: text(advisory["summary"]) ?? id, + severity: severity(advisory["severity"]), + description: text(advisory["description"]), + url, + advisory_ids: [...new Set(advisoryIds)], + packages, + source_updated_at: sourceTimestamp(advisory["updated_at"]), + source_data: record, + details: { + kind: "advisory", + advisory: { source: selection }, + ...(selection === "repository" + ? { + repository: { + url: `${origin}/${repository}`, + name: repository, + }, + } + : {}), + source_status: text(advisory["withdrawn_at"]) + ? "withdrawn" + : (text(advisory["state"]) ?? + (selection === "global" ? "published" : null)), + scanner_origin: "GitHub Security Advisories", + weakness_ids: cwes + .map((value) => text(object(value)?.["cwe_id"])) + .filter((value): value is string => value !== null), + }, + }), + }; +} + +function normalizeRecords(records: readonly unknown[]): ExternalFindings { + const result: ExternalFindings = { + read: records.length, + findings: [], + excluded: [], + }; + const identities = new Set(); + for (const [index, value] of records.entries()) { + const record = object(value); + try { + if (!record) throw new Error("GitHub finding must be an object."); + let finding: ExternalFinding; + if (record["source"] === "github-code-scanning") + finding = codeScanningFinding(record); + else if (record["source"] === "github-security-advisory") + finding = advisoryFinding(record); + else + throw new Error( + "Expected saved output from import github or import github-advisories; raw or incomplete API responses need an explicit mapping.", + ); + if (identities.has(finding.source_finding_id)) + throw new CodexSecurityError( + `Duplicate source finding id ${JSON.stringify(finding.source_finding_id)}. Select each GitHub occurrence once.`, + ); + identities.add(finding.source_finding_id); + result.findings.push(finding); + } catch (error) { + if (error instanceof CodexSecurityError) throw error; + result.excluded.push({ + position: index + 1, + source_finding_id: text(record?.["url"]), + reason: error instanceof Error ? error.message : String(error), + }); + } + } + return result; +} + +/** Map selected code-scanning instances while retaining complete upstream evidence. */ +export function normalizeGitHubCodeScanningAlerts( + alerts: readonly ImportedGitHubCodeScanningAlert[], +): ExternalFindings { + return normalizeRecords(alerts); +} + +/** Map advisory disclosures without asserting an installed vulnerable dependency. */ +export function normalizeGitHubAdvisories( + advisories: readonly ImportedGitHubAdvisory[], +): ExternalFindings { + return normalizeRecords(advisories); +} + +/** Read unfiltered JSON/JSONL output saved by the read-only GitHub import commands. */ +export async function readGitHubFindings( + path: string, +): Promise { + try { + const bytes = await readFile(path); + const decoded = + bytes[0] === 0x1f && bytes[1] === 0x8b + ? await decompressGzip(bytes) + : bytes; + const contents = new TextDecoder("utf-8", { fatal: true }).decode(decoded); + let payload: unknown; + try { + payload = JSON.parse(contents); + } catch { + payload = contents + .split(/\r?\n/u) + .filter((line) => line.trim()) + .map((line) => JSON.parse(line)); + } + return normalizeRecords(Array.isArray(payload) ? payload : [payload]); + } catch (cause) { + throw new CodexSecurityError( + `Could not read GitHub findings as UTF-8 JSON or JSONL: ${cause instanceof Error ? cause.message : String(cause)}`, + { cause }, + ); + } +} diff --git a/sdk/typescript/src/index.ts b/sdk/typescript/src/index.ts index 02ed20fea..eb2e9cc43 100644 --- a/sdk/typescript/src/index.ts +++ b/sdk/typescript/src/index.ts @@ -182,6 +182,37 @@ export type { DeduplicationDiagnosticObserver, } from "./deduplication/diagnostics.js"; export type { FindingEmbedder } from "./server/embeddings.js"; +export { + ExternalPublicationError, + prepareExternalPublication, + prepareNormalizedExternalPublication, +} from "./external-findings-publish.js"; +export type { + ExternalPublicationOptions, + ExternalPublicationDependencies, + ExternalPublicationPreview, + ExternalPublicationProgress, + ExternalPublicationResult, + PreparedExternalPublication, +} from "./external-findings-publish.js"; +export type { ExternalFinding, ExternalFindings } from "./external-findings.js"; +export type { + ExternalFindingEvidence, + FindingImportRequest, + FindingImportReceipt, + ImportRepository, + SourceReport, +} from "./external-import-models.js"; +export { + normalizeGitHubCodeScanningAlerts, + normalizeGitHubAdvisories, + readGitHubFindings, +} from "./github-findings.js"; +export { importGitHubAdvisories } from "./github-advisories.js"; +export type { + GitHubAdvisoryImportOptions, + ImportedGitHubAdvisory, +} from "./github-advisories.js"; export { importGitHubCodeScanningAlerts } from "./github.js"; export type { GitHubCodeScanningImportOptions, diff --git a/sdk/typescript/src/wiz-findings.ts b/sdk/typescript/src/wiz-findings.ts index e31164f39..9190de485 100644 --- a/sdk/typescript/src/wiz-findings.ts +++ b/sdk/typescript/src/wiz-findings.ts @@ -6,24 +6,18 @@ import { repositoryUrlKey, validateExternalEvidence, } from "./external-import-contract.js"; -import type { ExternalFindingEvidence } from "./external-import-models.js"; const decompressGzip = promisify(gunzip); -export interface VendorFinding { - source_finding_id: string; - evidence: ExternalFindingEvidence; -} +import type { + ExternalFinding as VendorFinding, + ExternalFindings as VendorFindings, +} from "./external-findings.js"; -export interface VendorFindings { - read: number; - findings: VendorFinding[]; - excluded: { - position: number; - source_finding_id: string | null; - reason: string; - }[]; -} +export type { + ExternalFinding as VendorFinding, + ExternalFindings as VendorFindings, +} from "./external-findings.js"; function object(value: unknown): Record | undefined { return value !== null && typeof value === "object" && !Array.isArray(value) diff --git a/sdk/typescript/tests-ts/cli-fixtures.ts b/sdk/typescript/tests-ts/cli-fixtures.ts index 7e8dffcbc..724d8c175 100644 --- a/sdk/typescript/tests-ts/cli-fixtures.ts +++ b/sdk/typescript/tests-ts/cli-fixtures.ts @@ -211,6 +211,7 @@ export function dependencies( onCodex?: OnCodex; linearClient?: MainDependencies["linearClient"]; importGitHubAlerts?: MainDependencies["importGitHubAlerts"]; + importGitHubAdvisories?: MainDependencies["importGitHubAdvisories"]; onRepositoryCommand?: ( ...arguments_: Parameters ) => string | Promise; @@ -290,6 +291,9 @@ export function dependencies( ...(options.linearClient === undefined ? {} : { linearClient: options.linearClient }), + ...(options.importGitHubAdvisories === undefined + ? {} + : { importGitHubAdvisories: options.importGitHubAdvisories }), ...(options.importGitHubAlerts === undefined ? {} : { importGitHubAlerts: options.importGitHubAlerts }), diff --git a/sdk/typescript/tests-ts/external-findings-publish.test.ts b/sdk/typescript/tests-ts/external-findings-publish.test.ts index 922b71ef8..f98239250 100644 --- a/sdk/typescript/tests-ts/external-findings-publish.test.ts +++ b/sdk/typescript/tests-ts/external-findings-publish.test.ts @@ -18,8 +18,10 @@ import { afterEach, expect, spyOn, test } from "bun:test"; import { ExternalPublicationError, prepareExternalPublication, + prepareNormalizedExternalPublication, type ExternalPublicationProgress, } from "../src/external-findings-publish.js"; +import { readGitHubFindings } from "../src/github-findings.js"; import { readVendorFindings } from "../src/wiz-findings.js"; import { validateExternalEvidence, @@ -33,6 +35,10 @@ import type { } from "../src/external-import-models.js"; import { main } from "../src/cli.js"; import { dependencies } from "./cli-fixtures.js"; +import { + importedAlert, + importedAdvisory, +} from "./support/github-import-fixtures.js"; import { createCliTest } from "./support/cli-run.js"; const cleanups: (() => Promise)[] = []; @@ -573,7 +579,7 @@ test("Wiz network-scan findings are excluded without requiring a detection metho }); test("unsupported records are visible exclusions; duplicate identities and unfinished pages stop publication", async () => { - const f = await fixture([normalized(), normalized("unknown", "unknown")]); + const f = await fixture([normalized(), normalized("unknown", "unsupported")]); const parsed = await readVendorFindings(f.file); expect(parsed.findings).toHaveLength(1); expect(parsed.excluded).toHaveLength(1); @@ -840,7 +846,10 @@ test.each(["human", "json"])( source_data: { id: "vendor-selected", note: "Original vendor detail" }, }, }; - const f = await fixture([selected, normalized("unsupported", "unknown")]); + const f = await fixture([ + selected, + normalized("unsupported", "unsupported"), + ]); const cli = createCliTest(main); let prompted = false; expect( @@ -2016,7 +2025,7 @@ test("unsafe evidence integers are rejected while exact strings and safe integer test("mixed valid and rejected input reports partial completion with exit code 1", async () => { const f = await fixture([ - normalized("invalid", "unknown"), + normalized("invalid", "unsupported"), normalized("valid"), ]); const cli = createCliTest(main); @@ -2034,7 +2043,7 @@ test("mixed valid and rejected input reports partial completion with exit code 1 test("server item failures identify the vendor record after local exclusions", async () => { const f = await fixture([ - normalized("invalid", "unknown"), + normalized("invalid", "unsupported"), normalized("rejected-vendor"), ]); f.state.finalError = true; @@ -2385,3 +2394,327 @@ test("human previews stay compact while JSON previews retain complete evidence", ).toBe("complete-vendor-evidence-5"); expect(f.posts).toHaveLength(0); }); + +test("normalized SDK publication preserves caller evidence and exclusion accounting", async () => { + const f = await fixture(); + const input = { + read: 2, + findings: [normalized()], + excluded: [ + { + position: 2, + source_finding_id: null, + reason: "Unsupported source record.", + }, + ], + }; + const original = structuredClone(input); + const prepared = await prepareNormalizedExternalPublication( + input as Parameters[0], + options, + f.deps, + ); + expect(input).toEqual(original); + expect(prepared.preview.read).toBe(2); + const result = await prepared.publish(); + expect(result.status).toBe("partial"); + expect(result.counts.created).toBe(1); + expect(result.excluded).toEqual(input.excluded); + expect(result.verified).toBe(1); +}); + +test("GitHub publication previews, imports, re-imports, updates and resumes selected-ref evidence", async () => { + const imported = importedAlert(); + const f = await fixture([imported]); + const githubOptions = { + ...options, + provider: "github" as const, + sourceKey: "github.com/example/project/code-scanning", + }; + const command = f.command.map((arg) => + arg === "wiz" + ? "github" + : arg === options.sourceKey + ? githubOptions.sourceKey + : arg, + ); + const cli = createCliTest(main); + expect( + await cli.runCli([...command, "--dry-run", "--format", "json"], f.cliDeps), + ).toBe(0); + expect(f.posts).toHaveLength(0); + expect(JSON.parse(cli.stdout.text()).source.provider).toBe("github"); + const first = await ( + await prepareExternalPublication(f.file, githubOptions, f.deps) + ).publish(); + expect(first.counts.created).toBe(1); + expect(first.verified).toBe(1); + const repeated = await ( + await prepareExternalPublication(f.file, githubOptions, f.deps) + ).publish(); + expect(repeated.counts.unchanged).toBe(1); + imported.alert.most_recent_instance.state = "fixed"; + await writeFile(f.file, JSON.stringify([imported])); + f.state.loseResponse = true; + await expect( + (await prepareExternalPublication(f.file, githubOptions, f.deps)).publish(), + ).rejects.toBeInstanceOf(ExternalPublicationError); + const prepared = await prepareExternalPublication( + f.file, + githubOptions, + f.deps, + ); + expect(prepared.preview.resumed).toBe(true); + const resumed = await prepared.publish(); + expect(resumed.counts.updated).toBe(1); + expect(resumed.verified).toBe(1); + expect([...f.reports.values()][0]).toMatchObject({ + source: { provider: "github" }, + evidence: { details: { source_status: "fixed" }, source_data: imported }, + assessment: { state: "not_assessed" }, + }); + await writeFile(f.file, JSON.stringify([importedAlert("refs/heads/review")])); + const differentRef = await ( + await prepareExternalPublication(f.file, githubOptions, f.deps) + ).publish(); + expect(differentRef.counts.created).toBe(1); + expect(f.reports.size).toBe(2); +}); + +test("GitHub imports reject a different Cloud destination before uploading", async () => { + const imported = importedAlert("refs/heads/main", "git.example.test"); + const f = await fixture([imported]); + await expect( + prepareExternalPublication( + f.file, + { ...options, provider: "github" }, + f.deps, + ), + ).rejects.toThrow("does not match the selected Cloud repository"); + expect(f.posts).toHaveLength(0); +}); + +test("global GHSA publication retains unassessed applicability through repeat import and withdrawal", async () => { + const imported = importedAdvisory("global"); + const f = await fixture([imported]); + const selected = { + ...options, + provider: "github" as const, + sourceKey: "github.com/example/project/security-advisories", + }; + expect( + ( + await ( + await prepareExternalPublication(f.file, selected, f.deps) + ).publish() + ).counts.created, + ).toBe(1); + expect( + ( + await ( + await prepareExternalPublication(f.file, selected, f.deps) + ).publish() + ).counts.unchanged, + ).toBe(1); + const withdrawn = { + ...imported, + advisory: { ...imported.advisory, withdrawn_at: "2026-01-03T00:00:00Z" }, + }; + await writeFile(f.file, JSON.stringify([withdrawn])); + const result = await ( + await prepareExternalPublication(f.file, selected, f.deps) + ).publish(); + expect(result.counts.updated).toBe(1); + expect(result.verified).toBe(1); + const report = [...f.reports.values()][0]!; + expect(report.evidence.details).toMatchObject({ + kind: "advisory", + advisory: { source: "global" }, + source_status: "withdrawn", + }); + expect(report.evidence.details).not.toHaveProperty("repository"); + expect(report.assessment?.state).toBe("not_assessed"); + expect(report.evidence.packages![0]!.installed_version).toBeNull(); +}); + +test("saved GitHub JSONL and gzip preserve evidence while malformed UTF-8 cannot change identities", async () => { + const imported = [importedAlert(), importedAdvisory("global")]; + const f = await fixture(imported); + const jsonl = imported.map((record) => JSON.stringify(record)).join("\n"); + for (const bytes of [Buffer.from(jsonl), gzipSync(jsonl)]) { + await writeFile(f.file, bytes); + const parsed = await readGitHubFindings(f.file); + expect(parsed.read).toBe(2); + expect(parsed.excluded).toEqual([]); + expect( + parsed.findings.map((finding) => finding.evidence.source_data) as unknown, + ).toEqual(imported); + } + await writeFile(f.file, Buffer.from([0xff])); + await expect(readGitHubFindings(f.file)).rejects.toThrow("UTF-8"); +}); + +test("normalized publication omits only new null fields to match canonical Cloud readback", async () => { + const f = await fixture(); + const evidence = { + title: "Synthetic advisory without known package range", + severity: "unknown" as const, + packages: [ + { + name: "example-package", + vulnerable_version_range: null, + installed_version: null, + }, + ], + details: { + kind: "advisory" as const, + repository: null, + advisory: { source: "global" as const }, + }, + }; + const prepared = await prepareNormalizedExternalPublication( + { + read: 1, + findings: [{ source_finding_id: "synthetic-global-advisory", evidence }], + excluded: [], + }, + { ...options, provider: "github" }, + f.deps, + ); + const sent = prepared.preview.findings[0]!.evidence; + expect(sent.packages![0]).not.toHaveProperty("vulnerable_version_range"); + expect(sent.packages![0]!.installed_version).toBeNull(); + expect(sent.details).not.toHaveProperty("repository"); + expect(evidence.details.repository).toBeNull(); + expect((await prepared.publish()).verified).toBe(1); + + await writeFile( + f.file, + JSON.stringify([ + { + source_finding_id: "synthetic-sast", + evidence: { + title: "Synthetic SAST evidence", + severity: "high", + packages: [ + { name: "example-package", vulnerable_version_range: null }, + ], + details: { + kind: "sast", + repository: { url: "https://github.com/example/project" }, + advisory: null, + }, + }, + }, + ]), + ); + const wiz = await prepareExternalPublication(f.file, options, f.deps); + expect(wiz.preview.findings[0]!.evidence.details).not.toHaveProperty( + "advisory", + ); + expect(wiz.preview.findings[0]!.evidence.details!.scanner_origin).toBeNull(); + expect((await wiz.publish()).verified).toBe(1); +}); + +test("normalized SDK publication detaches raw evidence aliases before validating every finding", async () => { + const f = await fixture(); + const sharedPackage = { name: "example-package" }; + const input = { + read: 2, + findings: [ + { + source_finding_id: "shared-first", + evidence: { + title: "First report", + severity: "high" as const, + packages: [sharedPackage], + source_data: { package: sharedPackage }, + }, + }, + { + source_finding_id: "shared-second", + evidence: { + title: "Second report", + severity: "high" as const, + source_data: { earlier_package: sharedPackage }, + }, + }, + ], + excluded: [], + }; + const original = JSON.stringify(input); + const prepared = await prepareNormalizedExternalPublication( + input, + options, + f.deps, + ); + expect(JSON.stringify(input)).toBe(original); + expect(prepared.preview.findings[0]!.evidence.source_data).toEqual({ + package: { name: "example-package" }, + }); + expect(prepared.preview.findings[1]!.evidence.source_data).toEqual({ + earlier_package: { name: "example-package" }, + }); + await writeFile(f.file, JSON.stringify(input.findings)); + const fromFile = await prepareExternalPublication(f.file, options, f.deps); + expect(prepared.preview.findings).toEqual(fromFile.preview.findings); +}); + +test.each(["code-scanning", "advisory"])( + "%s imports bind an explicit default port to the matching Cloud repository", + async (family) => { + const alert = importedAlert("refs/heads/main", "github.com:443"); + const advisory = importedAdvisory(); + advisory.githubHost = "github.com:443"; + advisory.url = advisory.advisory.html_url = advisory.url.replace( + "github.com", + advisory.githubHost, + ); + const f = await fixture([family === "code-scanning" ? alert : advisory]); + const selected = { ...options, provider: "github" as const }; + await expect( + prepareExternalPublication(f.file, selected, f.deps), + ).rejects.toThrow("does not match the selected Cloud repository"); + expect(f.posts).toHaveLength(0); + const matching = { + ...f.deps, + fetch: async (url: string, init: RequestInit) => { + const response = await f.deps.fetch(url, init); + if (new URL(url).pathname.endsWith("/repositories")) { + const body = await response.json(); + body.data[0].url = "https://github.com:443/example/project"; + return Response.json(body); + } + return response; + }, + }; + const result = await ( + await prepareExternalPublication(f.file, selected, matching) + ).publish(); + expect(result.counts.created).toBe(1); + expect(result.verified).toBe(1); + }, +); + +test("normalized SDK publication rejects nonfinite raw evidence without JSON coercion", async () => { + const f = await fixture(); + const input = { + read: 1, + findings: [ + { + source_finding_id: "nonfinite-raw-evidence", + evidence: { + title: "Synthetic evidence", + severity: "high" as const, + source_data: { score: Infinity }, + }, + }, + ], + excluded: [], + }; + await expect( + prepareNormalizedExternalPublication(input, options, f.deps), + ).rejects.toThrow("finite JSON numbers"); + expect(input.findings[0]!.evidence.source_data.score).toBe(Infinity); + expect(f.posts).toHaveLength(0); +}); diff --git a/sdk/typescript/tests-ts/github-advisories.test.ts b/sdk/typescript/tests-ts/github-advisories.test.ts new file mode 100644 index 000000000..c4f5b42d9 --- /dev/null +++ b/sdk/typescript/tests-ts/github-advisories.test.ts @@ -0,0 +1,199 @@ +import { Octokit } from "@octokit/core"; +import { expect, mock, test } from "bun:test"; +import { + importGitHubAdvisories, + type GitHubAdvisoryImportOptions, +} from "../src/github-advisories.js"; +import { main } from "../src/cli.js"; +import { dependencies, FakeSignals } from "./cli-fixtures.js"; +import { captureCli, runCapturedCli } from "./support/cli-run.js"; +import { importedAdvisory } from "./support/github-import-fixtures.js"; + +function client(respond: (url: URL) => Response): Octokit { + return new Octokit({ + auth: "SYNTHETIC_ADVISORY_TOKEN", + request: { + fetch: async ( + resource: Parameters[0], + init?: Parameters[1], + ) => { + expect(init?.method).toBe("GET"); + expect(init?.redirect).toBe("error"); + return respond( + new URL(resource instanceof Request ? resource.url : resource), + ); + }, + }, + }); +} + +test("repository advisory lists default to published and paginate cursors with full evidence", async () => { + const paths: string[] = []; + const github = client((url) => { + paths.push(url.toString()); + expect(url.pathname).toBe("/repos/example/project/security-advisories"); + expect(url.searchParams.get("state")).toBe("published"); + expect(url.searchParams.get("per_page")).toBe("100"); + return Response.json( + [importedAdvisory().advisory], + url.searchParams.has("after") + ? undefined + : { + headers: { + link: '; rel="next"', + }, + }, + ); + }); + const imported = await importGitHubAdvisories( + { repository: "example/project" }, + { createGitHub: async () => github }, + ); + expect(paths).toHaveLength(2); + expect(new URL(paths[1]!).searchParams.get("after")).toBe("cursor-2"); + expect(imported[0]).toEqual(importedAdvisory()); + expect(JSON.stringify(imported)).not.toContain("SYNTHETIC_ADVISORY_TOKEN"); +}); + +test("global advisory selection uses only explicit deduplicated IDs and preserves the source family", async () => { + const selected = importedAdvisory("global"); + const paths: string[] = []; + const github = client((url) => { + paths.push(url.pathname); + return Response.json(selected.advisory); + }); + const imported = await importGitHubAdvisories( + { + repository: "example/project", + source: "global", + advisoryIds: [selected.ghsaId.toLowerCase(), selected.ghsaId], + }, + { createGitHub: async () => github }, + ); + expect(paths).toEqual([`/advisories/${selected.ghsaId}`]); + expect(imported).toEqual([selected]); +}); + +test("explicit repository IDs allow private drafts without listing other advisories", async () => { + const selected = importedAdvisory(); + const draft = { ...selected.advisory, state: "draft" as const }; + const github = client((url) => { + expect(url.pathname).toBe( + `/repos/example/project/security-advisories/${selected.ghsaId}`, + ); + expect(url.searchParams.has("state")).toBe(false); + return Response.json(draft); + }); + const imported = await importGitHubAdvisories( + { repository: "example/project", advisoryIds: [selected.ghsaId] }, + { createGitHub: async () => github }, + ); + expect(imported[0]!.advisory).toEqual(draft); +}); + +test("global crawl and misleading exact-ID state filters fail before GitHub authentication", async () => { + const createGitHub = mock(async () => client(() => Response.json([]))); + await expect( + importGitHubAdvisories( + { repository: "example/project", source: "global" }, + { createGitHub }, + ), + ).rejects.toThrow("explicit GHSA IDs"); + await expect( + importGitHubAdvisories( + { + repository: "example/project", + advisoryIds: [importedAdvisory().ghsaId], + state: "draft", + }, + { createGitHub }, + ), + ).rejects.toThrow("only filters repository lists"); + expect(createGitHub).not.toHaveBeenCalled(); +}); + +test("a later advisory page failure returns no incomplete selection", async () => { + const github = client((url) => + url.searchParams.has("after") + ? Response.json({ message: "Rate limit" }, { status: 429 }) + : Response.json([importedAdvisory().advisory], { + headers: { + link: '; rel="next"', + }, + }), + ); + await expect( + importGitHubAdvisories( + { repository: "example/project" }, + { createGitHub: async () => github }, + ), + ).rejects.toThrow("rate limited"); +}); + +test("GitHub advisory CLI exposes separate selection defaults and exact public IDs", async () => { + let options: GitHubAdvisoryImportOptions | undefined; + const output = captureCli(main, "stdout"); + expect( + await output.run( + [ + "import", + "github-advisories", + "example/project", + "--github-advisory", + importedAdvisory().ghsaId, + "--github-advisory-source", + "global", + "--format", + "json", + ], + dependencies({ + importGitHubAdvisories: async (value) => { + options = value; + return [importedAdvisory("global")]; + }, + }), + ), + ).toBe(0); + expect(options).toMatchObject({ + repository: "example/project", + source: "global", + state: "published", + advisoryIds: [importedAdvisory().ghsaId], + }); + expect(JSON.parse(output.text())).toEqual([importedAdvisory("global")]); + const schema = captureCli(main, "stdout"); + expect( + await schema.run( + ["import", "github-advisories", "--schema", "--format", "json"], + dependencies(), + ), + ).toBe(0); + expect(JSON.parse(schema.text())).toMatchObject({ + options: { + properties: { + githubAdvisorySource: { default: "repository" }, + githubState: { default: "published" }, + }, + }, + }); +}); + +test("advisory CLI cancellation removes handlers and preserves its exit code", async () => { + const signals = new FakeSignals(); + expect( + await runCapturedCli( + main, + ["import", "github-advisories", "example/project"], + dependencies({ + signals, + importGitHubAdvisories: async ({ signal }) => { + signals.emit("SIGINT"); + signal!.throwIfAborted(); + return []; + }, + }), + ), + ).toBe(130); + expect(signals.listeners.get("SIGINT")?.size).toBe(0); + expect(signals.listeners.get("SIGTERM")?.size).toBe(0); +}); diff --git a/sdk/typescript/tests-ts/github-findings.test.ts b/sdk/typescript/tests-ts/github-findings.test.ts new file mode 100644 index 000000000..7901ed477 --- /dev/null +++ b/sdk/typescript/tests-ts/github-findings.test.ts @@ -0,0 +1,344 @@ +import { expect, test } from "bun:test"; +import { Octokit } from "@octokit/core"; +import { importGitHubCodeScanningAlerts } from "../src/github.js"; +import { importGitHubAdvisories } from "../src/github-advisories.js"; +import { validateExternalEvidence } from "../src/external-import-contract.js"; +import { + normalizeGitHubCodeScanningAlerts, + normalizeGitHubAdvisories, +} from "../src/github-findings.js"; +import type { ImportedGitHubCodeScanningAlert } from "../src/github.js"; + +import { + importedAlert, + importedAdvisory, +} from "./support/github-import-fixtures.js"; + +test("code-scanning mapping preserves full evidence and selected instance status without assessing it", () => { + const imported = importedAlert("refs/heads/review"); + const result = normalizeGitHubCodeScanningAlerts([imported]); + expect(result.excluded).toEqual([]); + expect(result.findings[0]).toMatchObject({ + evidence: { + title: "Untrusted query input", + severity: "high", + branch: "refs/heads/review", + code_revision: "a".repeat(40), + source_updated_at: 1767312000, + source_data: imported, + locations: [{ path: "src/query.ts", line: 12 }], + details: { + kind: "sast", + repository: { url: "https://github.com/example/project" }, + source_status: "open", + scanner_origin: "Synthetic Scanner", + weakness_ids: ["CWE-89"], + code: { end_line: 14 }, + }, + }, + }); + expect(result.findings[0]!.evidence).not.toHaveProperty("assessment"); +}); + +test("code-scanning identities isolate hosts, repositories and selected refs but not updated evidence", () => { + const first = importedAlert(); + const changed = structuredClone(first); + changed.alert.most_recent_instance.commit_sha = "b".repeat(40); + changed.alert.most_recent_instance.state = "fixed"; + const identity = (input: ImportedGitHubCodeScanningAlert) => + normalizeGitHubCodeScanningAlerts([input]).findings[0]!.source_finding_id; + expect(identity(changed)).toBe(identity(first)); + expect(identity(importedAlert("refs/heads/review"))).not.toBe( + identity(first), + ); + expect( + identity(importedAlert("refs/heads/main", "git.example.test")), + ).not.toBe(identity(first)); + const other = JSON.parse( + JSON.stringify(first).replaceAll("example/project", "example/other"), + ) as ImportedGitHubCodeScanningAlert; + expect(identity(other)).not.toBe(identity(first)); + expect(() => normalizeGitHubCodeScanningAlerts([first, changed])).toThrow( + "Duplicate source finding id", + ); +}); + +test("SARIF levels do not fabricate security severity and malformed siblings remain explicit exclusions", () => { + const noRating = importedAlert(); + delete noRating.alert.rule.security_severity_level; + const badRepository = importedAlert("refs/heads/other"); + badRepository.repository = "example/wrong"; + const result = normalizeGitHubCodeScanningAlerts([badRepository, noRating]); + expect(result.read).toBe(2); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]!.evidence.severity).toBe("unknown"); + expect(result.excluded).toEqual([ + { + position: 1, + source_finding_id: badRepository.url, + reason: + "GitHub alert URL does not match the selected repository and alert.", + }, + ]); +}); + +test.each(["repository", "global"] as const)( + "%s advisories preserve package ranges without claiming an installed dependency", + (selection) => { + const imported = importedAdvisory(selection); + const result = normalizeGitHubAdvisories([imported]); + expect(result.excluded).toEqual([]); + expect(result.findings[0]!.evidence).toMatchObject({ + advisory_ids: [imported.ghsaId, "CVE-2026-0001"], + packages: [ + { + name: "example-package", + ecosystem: "npm", + vulnerable_version_range: ">= 1.0.0, < 1.2.3", + installed_version: null, + manifest_path: null, + fixed_versions: ["1.2.3"], + }, + ], + locations: [], + source_data: imported, + details: { + kind: "advisory", + advisory: { source: selection }, + source_status: "published", + }, + }); + if (selection === "repository") + expect(result.findings[0]!.evidence.details!.repository!.url).toBe( + "https://github.com/example/project", + ); + else + expect(result.findings[0]!.evidence.details).not.toHaveProperty( + "repository", + ); + }, +); + +test("advisory withdrawal and unknown severity remain source evidence with stable identity", () => { + const imported = importedAdvisory("global"); + const before = normalizeGitHubAdvisories([imported]).findings[0]!; + const changed = { + ...imported, + advisory: { + ...imported.advisory, + withdrawn_at: "2026-01-03T00:00:00Z", + severity: null, + }, + }; + const after = normalizeGitHubAdvisories([changed as typeof imported]) + .findings[0]!; + expect(after.source_finding_id).toBe(before.source_finding_id); + expect(after.evidence.severity).toBe("unknown"); + expect(after.evidence.details!.source_status).toBe("withdrawn"); + expect(after.evidence).not.toHaveProperty("assessment"); +}); + +test("repository/global advisory identities stay separate and source URL conflicts are explicit exclusions", () => { + const repository = importedAdvisory(); + const global = importedAdvisory("global"); + expect(normalizeGitHubAdvisories([repository, global]).findings).toHaveLength( + 2, + ); + repository.repository = "example/other"; + const result = normalizeGitHubAdvisories([repository, global]); + expect(result.findings).toHaveLength(1); + expect(result.excluded[0]!.reason).toContain( + "does not match the selected source", + ); +}); + +test.each(["repository", "global"] as const)( + "%s advisories retain disclosures with nullable package metadata", + (selection) => { + const imported = importedAdvisory(selection); + const known = imported.advisory.vulnerabilities![0]!; + for (const vulnerabilities of [ + null, + [ + { ...known, package: null }, + { ...known, package: { ecosystem: "npm", name: null } }, + known, + ], + ]) { + const input = { + ...imported, + advisory: { ...imported.advisory, vulnerabilities }, + } as unknown as typeof imported; + const result = normalizeGitHubAdvisories([input]); + expect(result.excluded).toEqual([]); + expect(result.findings).toHaveLength(1); + expect(result.findings[0]!.evidence.source_data as unknown).toEqual( + input, + ); + expect(result.findings[0]!.evidence.packages).toHaveLength( + vulnerabilities === null ? 0 : 1, + ); + } + }, +); + +test("normalized global advisory evidence rejects invented source repository provenance", () => { + const evidence = normalizeGitHubAdvisories([importedAdvisory("global")]) + .findings[0]!.evidence; + evidence.details!.repository = { url: "https://github.com/example/project" }; + expect(() => validateExternalEvidence(evidence)).toThrow( + "Global advisory evidence cannot claim a source repository", + ); +}); + +test.each([ + { kind: "sast" }, + { kind: "secret" }, + { kind: "iac" }, + { kind: "advisory" }, + { kind: "advisory", advisory: { source: "repository" } }, + { + kind: "sast", + repository: { url: "https://github.com/example/project" }, + advisory: { source: "repository" }, + }, +])( + "external evidence enforces repository and advisory source context: %j", + (details) => { + expect(() => + validateExternalEvidence({ + title: "Synthetic evidence", + severity: "unknown", + details, + }), + ).toThrow(); + }, +); + +test("global advisories accept canonical public links from an Enterprise API without changing source host identity", () => { + const imported = importedAdvisory("global"); + imported.githubHost = "git.example.test"; + const result = normalizeGitHubAdvisories([imported]); + expect(result.excluded).toEqual([]); + expect(result.findings[0]!.source_finding_id).toStartWith( + "git.example.test/global/security-advisory/", + ); + expect(result.findings[0]!.evidence.url).toBe(imported.url); + expect(result.findings[0]!.evidence.source_data as unknown).toEqual(imported); + expect(result.findings[0]!.evidence.details).not.toHaveProperty("repository"); + const wrongRepositoryHost = importedAdvisory("repository"); + wrongRepositoryHost.githubHost = "git.example.test"; + expect( + normalizeGitHubAdvisories([wrongRepositoryHost]).excluded, + ).toHaveLength(1); +}); + +test("repository-wide dismissal takes precedence over detection state while selected refs keep their own open/fixed state", () => { + const imported = importedAlert("refs/heads/review"); + imported.alert.state = "dismissed"; + imported.alert.dismissed_reason = "false positive"; + expect( + normalizeGitHubCodeScanningAlerts([imported]).findings[0]!.evidence.details! + .source_status, + ).toBe("dismissed"); + imported.alert.state = "fixed"; + imported.alert.dismissed_reason = null; + expect( + normalizeGitHubCodeScanningAlerts([imported]).findings[0]!.evidence.details! + .source_status, + ).toBe("open"); +}); + +test.each(["security/code-scanning", "code-scanning"])( + "code-scanning accepts the %s HTML route without changing identity or repository binding", + (route) => { + const original = importedAlert(); + const imported = structuredClone(original); + imported.url = `https://github.com/example/project/${route}/12`; + imported.alert.html_url = imported.url; + const result = normalizeGitHubCodeScanningAlerts([imported]); + expect(result.excluded).toEqual([]); + expect(result.findings[0]!.source_finding_id).toBe( + normalizeGitHubCodeScanningAlerts([original]).findings[0]! + .source_finding_id, + ); + expect(result.findings[0]!.evidence.source_data as unknown).toEqual( + imported, + ); + imported.repository = "example/other"; + expect(normalizeGitHubCodeScanningAlerts([imported]).excluded).toHaveLength( + 1, + ); + imported.repository = original.repository; + imported.url = `https://github.com/example/project/${route}/13`; + imported.alert.html_url = imported.url; + expect(normalizeGitHubCodeScanningAlerts([imported]).excluded).toHaveLength( + 1, + ); + }, +); + +test("real GitHub readers and mappings preserve explicit ports in repository evidence and identities", async () => { + const codeIds = new Set(); + const advisoryIds = new Set(); + for (const port of ["", ":443", ":8443"]) { + const host = `git.example.test${port}`; + const alert = importedAlert("refs/heads/main", host); + const advisory = importedAdvisory(); + advisory.githubHost = host; + advisory.url = advisory.advisory.html_url = advisory.url.replace( + "github.com", + host, + ); + const github = new Octokit({ + request: { + fetch: async (resource: Parameters[0]) => { + const url = new URL( + resource instanceof Request ? resource.url : resource, + ); + return Response.json( + url.pathname.includes("code-scanning") + ? alert.alert + : advisory.advisory, + ); + }, + }, + }); + const dependency = { + createGitHub: async (selectedHost: string) => { + expect(selectedHost).toBe(host); + return github; + }, + }; + const importedAlerts = await importGitHubCodeScanningAlerts( + { repository: alert.repository, alertNumbers: [12], githubHost: host }, + dependency, + ); + const code = normalizeGitHubCodeScanningAlerts(importedAlerts); + const importedAdvisories = await importGitHubAdvisories( + { + repository: advisory.repository, + advisoryIds: [advisory.ghsaId], + githubHost: host, + }, + dependency, + ); + const disclosed = normalizeGitHubAdvisories(importedAdvisories); + expect(code.excluded).toEqual([]); + expect(disclosed.excluded).toEqual([]); + for (const result of [code, disclosed]) + expect(result.findings[0]!.evidence.details!.repository!.url).toBe( + `https://${host}/example/project`, + ); + expect(code.findings[0]!.evidence.source_data as unknown).toEqual( + importedAlerts[0], + ); + expect(disclosed.findings[0]!.evidence.source_data as unknown).toEqual( + importedAdvisories[0], + ); + codeIds.add(code.findings[0]!.source_finding_id); + advisoryIds.add(disclosed.findings[0]!.source_finding_id); + } + expect(codeIds.size).toBe(3); + expect(advisoryIds.size).toBe(3); +}); diff --git a/sdk/typescript/tests-ts/support/github-import-fixtures.ts b/sdk/typescript/tests-ts/support/github-import-fixtures.ts new file mode 100644 index 000000000..f0be01843 --- /dev/null +++ b/sdk/typescript/tests-ts/support/github-import-fixtures.ts @@ -0,0 +1,82 @@ +import type { ImportedGitHubCodeScanningAlert } from "../../src/github.js"; + +export function importedAlert( + ref = "refs/heads/main", + host = "github.com", +): ImportedGitHubCodeScanningAlert { + const url = `https://${host}/example/project/security/code-scanning/12`; + return { + source: "github-code-scanning", + repository: "example/project", + number: 12, + url, + alert: { + number: 12, + html_url: url, + state: "open", + dismissed_reason: null, + dismissed_comment: null, + updated_at: "2026-01-02T00:00:00Z", + rule: { + id: "synthetic/sql-query", + name: "Untrusted query input", + security_severity_level: "high", + severity: "error", + description: "Query input reaches the database.", + help: "Use query parameters.", + tags: ["external/cwe/cwe-089"], + }, + tool: { name: "Synthetic Scanner", version: "1.0" }, + most_recent_instance: { + ref, + commit_sha: "a".repeat(40), + state: "open", + message: { text: "Untrusted input at the query." }, + location: { path: "src/query.ts", start_line: 12, end_line: 14 }, + }, + } as ImportedGitHubCodeScanningAlert["alert"], + }; +} + +export function importedAdvisory( + selection: "repository" | "global" = "repository", +) { + const ghsaId = "GHSA-2345-6789-cfgh"; + const url = + selection === "repository" + ? `https://github.com/example/project/security/advisories/${ghsaId}` + : `https://github.com/advisories/${ghsaId}`; + return { + source: "github-security-advisory" as const, + selection, + repository: "example/project", + githubHost: "github.com", + ghsaId, + url, + advisory: { + ghsa_id: ghsaId, + cve_id: "CVE-2026-0001", + html_url: url, + summary: "Synthetic dependency advisory", + description: "A published report for an example package.", + severity: "high", + state: "published", + withdrawn_at: null, + updated_at: "2026-01-02T00:00:00Z", + identifiers: [ + { type: "GHSA", value: ghsaId }, + { type: "CVE", value: "CVE-2026-0001" }, + ], + vulnerabilities: [ + { + package: { name: "example-package", ecosystem: "npm" }, + vulnerable_version_range: ">= 1.0.0, < 1.2.3", + ...(selection === "repository" + ? { patched_versions: "1.2.3" } + : { first_patched_version: "1.2.3" }), + }, + ], + cwes: [{ cwe_id: "CWE-79", name: "Synthetic weakness" }], + }, + } as unknown as import("../../src/github-advisories.js").ImportedGitHubAdvisory; +} diff --git a/sdk/typescript/tests-ts/wiz-findings.test.ts b/sdk/typescript/tests-ts/wiz-findings.test.ts index a5bcaa0fd..a21dbfe52 100644 --- a/sdk/typescript/tests-ts/wiz-findings.test.ts +++ b/sdk/typescript/tests-ts/wiz-findings.test.ts @@ -703,7 +703,7 @@ test("saved typed requests resume by repository ID after a repository rename", a expect((await resumed.publish()).verified).toBe(1); expect(f.posts).toHaveLength(1); expect( - f.reports.get("sast:occurrence-1")!.evidence.details!.repository.url, + f.reports.get("sast:occurrence-1")!.evidence.details!.repository!.url, ).toBe(repositoryUrl); }); @@ -768,7 +768,7 @@ test("GitHub repository metadata accepts case variants without changing source U }); expect(parsed.excluded).toEqual([]); expect(parsed.findings[0]!.evidence.source_data).toEqual(supplied); - expect(parsed.findings[0]!.evidence.details!.repository.url).toBe( + expect(parsed.findings[0]!.evidence.details!.repository!.url).toBe( supplied.repository.url, ); }); @@ -882,7 +882,7 @@ test("custom GitHub Enterprise binding preserves branch case and exact source ev expect((await prepared.publish()).verified).toBe(1); const evidence = f.posts[0]!.items[0]!.evidence; expect(evidence.branch).toBe(record.repositoryBranch.name); - expect(evidence.details!.repository.url).toBe(record.repository.url); + expect(evidence.details!.repository!.url).toBe(record.repository.url); expect(evidence.source_data).toEqual(record); }); @@ -927,7 +927,7 @@ test.each([ expect((await prepared.publish()).verified).toBe(1); const evidence = f.posts[0]!.items[0]!.evidence; expect(evidence.branch).toBe(branch.name); - expect(evidence.details!.repository.url).toBe(sourceRepository.url); + expect(evidence.details!.repository!.url).toBe(sourceRepository.url); expect(evidence.source_data).toEqual(record); }, ); @@ -2046,7 +2046,7 @@ test.each([ expect(replay.preview.resumed).toBe(true); expect(replay.preview.requests).toEqual(original.preview.requests); expect( - replay.preview.findings[0]!.evidence.details!.repository.name, + replay.preview.findings[0]!.evidence.details!.repository!.name, ).toBe("EXAMPLE/PROJECT"); } const newer = f.reports.get("sast:occurrence-1")!;