Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/scanner-imports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Import scanner findings

`codex-security publish findings` can import a selected Snyk Open Source SARIF
2.1.0 report into a configured Cloud repository:

```sh
codex-security publish findings selected.sarif --to cloud \
--provider snyk --repository <cloud-repository-id> \
--source-key <organization/project/branch> --dry-run
```

Remove `--dry-run` to review and confirm the upload. The existing `--yes` option
allows noninteractive publication. Defaults and the Wiz/GitHub formats are
unchanged. This command imports saved evidence; it does not run Snyk, fetch links,
or start a Cloud assessment.

Use a stable source key for one Snyk organization, project and branch. Do not
combine different projects that have the same manifest path. Identity uses the
Snyk advisory ID and the literal manifest URI set within that namespace; a moved
manifest is a new identity. Timestamped automation IDs and run indexes are retained
as provenance but never used as identity. All records with a duplicate identity
are excluded, with their positions shown in the preview.

The mapper follows the public [Snyk Open Source SARIF formatter](https://github.com/snyk/cli/blob/main/src/lib/formatters/open-source-sarif-output.ts).
It keeps the original result, rule, dependency paths and report metadata. Severity
comes from Snyk's severity label, preserving critical versus high. Unrecognized
labels remain unknown. Suggested upgrades and synthetic line numbers remain in
the original evidence; they are not presented as verified fixes or source lines.
Absolute paths and traversal paths also stay in the original evidence without
being mapped to repository locations.

Reported version-control repository URLs must match the selected Cloud repository
before a new upload is prepared. Existing immutable requests continue to use their
saved repository IDs when resuming, including after a repository rename.

Snyk Code, Container, IaC, license findings and external SARIF property files are
not supported by this profile. Indexed or base-relative artifact references are
excluded because this formatter profile uses direct manifest URIs. Gzip and UTF-8
BOM input are accepted. The existing
Cloud evidence limits still apply: oversized or invalid records are excluded, not
truncated. Review the preview's exclusions before proceeding.

Tests use synthetic vendor-shaped fixtures, including repeated imports, changed
evidence, duplicate identities, 500 findings and malformed inputs. These tests do
not establish live Snyk tenant or deployed Cloud compatibility.
2 changes: 2 additions & 0 deletions sdk/typescript/scripts/package-dist-files.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const distModules = [
"external-findings-publish",
"external-import-contract",
"external-import-models",
"scanner-import",
"snyk-findings",
"wiz-findings",
"codex-prompt",
"codex-sdk-environment",
Expand Down
9 changes: 5 additions & 4 deletions sdk/typescript/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3254,15 +3254,16 @@ export async function main(
},
});
publication.command("findings", {
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",
description:
"Preview and import selected Wiz, GitHub, or Snyk findings to Cloud.",
hint: "For Snyk, supply self-contained Open Source SARIF 2.1.0. For GitHub, supply unfiltered JSON/JSONL saved by import github or import github-advisories. For Wiz, supply a package vulnerability JSON report, named SAST/repository-secret/IaC JSON collections with repository metadata, or normalized JSONL. Gzip is supported; CSV, scan events, workload secrets, and cloud configuration exports are not. --full-output requires --format json or --format jsonl.\nExample: codex-security publish findings selected-wiz.json --to cloud --repository https://github.com/example/project --provider wiz --source-key TENANT_ID/vulnerability-finding --dry-run --format json",
destructive: true,
mcp: false,
args: z.object({
file: z
.string()
.describe(
"Saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.",
"Snyk Open Source SARIF, saved GitHub import output, Wiz finding collections, or normalized Wiz JSONL.",
),
}),
options: z.object({
Expand All @@ -3271,7 +3272,7 @@ export async function main(
"Authorized Cloud repository ID or repository URL copied from Cloud.",
),
provider: z
.enum(["wiz", "github"])
.enum(["wiz", "github", "snyk"])
.describe("Provider that produced these findings."),
sourceKey: optionValue("--source-key").describe(
"Stable vendor tenant and finding namespace; not a project selection filter.",
Expand Down
37 changes: 26 additions & 11 deletions sdk/typescript/src/external-findings-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
} from "./runtime.js";
import { readVendorFindingsForPublication } from "./wiz-findings.js";
import { readGitHubFindings } from "./github-findings.js";
import { readSnykFindings, snykRepositoryUrls } from "./snyk-findings.js";
import type {
ExternalFindings,
ExternalPublicationInput,
Expand Down Expand Up @@ -56,7 +57,7 @@ function cloudRepositoryUrlKey(value: string): string {
/** CLI and plugin callers prepare the same immutable request before approval. */
export interface ExternalPublicationOptions {
repository: string;
provider: "wiz" | "github";
provider: "wiz" | "github" | "snyk";
sourceKey: string;
}

Expand Down Expand Up @@ -287,6 +288,12 @@ export async function prepareExternalPublication(
options: ExternalPublicationOptions,
dependencies: ExternalPublicationDependencies = {},
): Promise<PreparedExternalPublication> {
if (options.provider === "snyk")
return prepareNormalizedExternalPublication(
await readSnykFindings(path),
options,
dependencies,
);
if (options.provider === "github")
return prepareNormalizedExternalPublication(
await readGitHubFindings(path),
Expand Down Expand Up @@ -354,7 +361,9 @@ async function preparePublicationInput(
throw new CodexSecurityError(
options.provider === "wiz"
? "The source key must contain 1–512 UTF-8 bytes. Use a stable Wiz tenant/finding namespace, such as TENANT_ID/vulnerability-finding, across exports."
: "The source key must contain 1–512 UTF-8 bytes. Use a stable GitHub host/repository/finding namespace across imports.",
: options.provider === "snyk"
? "The source key must contain 1–512 UTF-8 bytes. Use a stable Snyk organization/project/branch namespace across exports."
: "The source key must contain 1–512 UTF-8 bytes. Use a stable GitHub host/repository/finding namespace across imports.",
);
}
progress({ phase: "reading", completed: 0 });
Expand Down Expand Up @@ -647,15 +656,21 @@ async function preparePublicationInput(
let requests = saved?.requests;
if (!requests) {
for (const finding of parsed.findings) {
const sourceRepository = finding.evidence.details?.repository;
if (
sourceRepository &&
cloudRepositoryUrlKey(sourceRepository.url) !==
cloudRepositoryUrlKey(destination.url)
)
throw new CodexSecurityError(
`Finding ${JSON.stringify(finding.source_finding_id)} belongs to ${JSON.stringify(sourceRepository.url)}, which does not match the selected Cloud repository ${JSON.stringify(destination.url)}. Verify the source repository mapping before publishing.`,
);
const sourceRepositories = [
finding.evidence.details?.repository?.url,
...(options.provider === "snyk"
? snykRepositoryUrls(finding.evidence.source_data)
: []),
];
for (const sourceRepository of sourceRepositories)
if (
sourceRepository &&
cloudRepositoryUrlKey(sourceRepository) !==
cloudRepositoryUrlKey(destination.url)
)
throw new CodexSecurityError(
`Finding ${JSON.stringify(finding.source_finding_id)} belongs to ${JSON.stringify(sourceRepository)}, which does not match the selected Cloud repository ${JSON.stringify(destination.url)}. Verify the source repository mapping before publishing.`,
);
}
let preparedCount = 0;
progress({
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,5 @@ export type {
ValidateRecordsOptions,
ValidationReviewRequest,
} from "./validation-records.js";

export { normalizeSnykSarif, readSnykFindings } from "./snyk-findings.js";
129 changes: 129 additions & 0 deletions sdk/typescript/src/scanner-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { hash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { promisify } from "node:util";
import { gunzip } from "node:zlib";
import { CodexSecurityError, errorMessage } from "./errors.js";
import type { ExternalFinding, ExternalFindings } from "./external-findings.js";

export function record(value: unknown, label: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value))
throw new Error(`${label} must be an object.`);
return value as Record<string, unknown>;
}

export function array(value: unknown, label: string): unknown[] {
if (!Array.isArray(value)) throw new Error(`${label} must be an array.`);
return value;
}

export function text(value: unknown): string | null {
return typeof value === "string" && value.trim() ? value : null;
}

export function requiredText(value: unknown, label: string): string {
const result = text(value);
if (!result) throw new Error(`${label} must be nonempty text.`);
return result;
}

export function strings(value: unknown): string[] {
return Array.isArray(value)
? value.filter((v): v is string => text(v) !== null)
: [];
}

/** Paths that cannot be attributed to a repository stay in original evidence. */
export function relativePath(value: unknown): string | null {
const path = text(value)?.replaceAll("\\", "/");
return path &&
!path.startsWith("/") &&
!path.includes("\0") &&
!path.includes(":") &&
!path.split("/").includes("..")
? path
: null;
}

export function stableId(prefix: string, fields: unknown[]): string {
return `${prefix}:${hash("sha256", JSON.stringify(fields))}`;
}

export async function readScannerJson(path: string): Promise<unknown> {
let bytes = await readFile(path);
if (bytes[0] === 0x1f && bytes[1] === 0x8b)
bytes = await promisify(gunzip)(bytes);
let decoded: string;
try {
decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch (error) {
throw new CodexSecurityError(
`Scanner report is not valid UTF-8. ${errorMessage(error)}`,
{ cause: error },
);
}
try {
return JSON.parse(decoded.replace(/^\uFEFF/u, ""));
} catch (error) {
throw new CodexSecurityError(
`Scanner JSON could not be parsed. ${errorMessage(error)}`,
{ cause: error },
);
}
}

/** Exclude every colliding record: choosing the first could overwrite another occurrence. */
export function normalizeRecords<T>(
records: T[],
normalize: (record: T) => ExternalFinding,
identify: (record: T) => string,
): ExternalFindings {
const result: ExternalFindings = {
read: records.length,
findings: [],
excluded: [],
};
const identified: { position: number; value: T; id: string }[] = [];
const counts = new Map<string, number>();
for (const [index, value] of records.entries()) {
try {
const id = identify(value);
if (!id.trim() || id.includes("\0") || Buffer.byteLength(id) > 512)
throw new Error(
"Source finding ID must contain 1–512 UTF-8 bytes and no NUL characters.",
);
identified.push({ position: index + 1, value, id });
counts.set(id, (counts.get(id) ?? 0) + 1);
} catch (error) {
result.excluded.push({
position: index + 1,
source_finding_id: null,
reason: errorMessage(error),
});
}
}
for (const { position, value, id } of identified) {
if (counts.get(id)! > 1) {
result.excluded.push({
position,
source_finding_id: id,
reason:
"Ambiguous duplicate source identity; select one complete finding or separate source projects.",
});
continue;
}
try {
const finding = normalize(value);
if (finding.source_finding_id !== id)
throw new Error("Finding identity changed during normalization.");
result.findings.push(finding);
} catch (error) {
result.excluded.push({
position,
source_finding_id: id,
reason: errorMessage(error),
});
}
}
result.excluded.sort((a, b) => a.position - b.position);
return result;
}
Loading
Loading