Skip to content
Merged
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
4 changes: 3 additions & 1 deletion sdk/typescript/_bundled_plugin/references/sarif-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@ The adapter:
- stores SARIF separately from the canonical seal
- emits SARIF 2.1.0
- uses stable `ruleId` values
- keeps rule descriptors stable across scans
- derives stable, readable rule names from `ruleId`
- includes categories, CWE tags, and canonical remediation in rule help and result messages
- emits repository-relative POSIX paths
- keeps the root-control location first for GitHub annotation when available and emits every distinct affected or code-evidence location in `locations`, so vulnerable sinks remain matchable
- preserves the semantic fingerprint under `codexSecurity/v1`
- emits GitHub's source-line `primaryLocationLineHash` when it can safely hash a bounded regular non-symlink source file inside the available source root
- maps categorical severity to SARIF `level`
- sets GitHub's rule-level `security-severity` to the highest finding score. Unscored critical, high, medium, low, and informational findings use 9.5, 8.0, 5.0, 2.0, and 0.0. A rule with no positive score omits this field. These defaults are display values, not calculated CVSS scores.
- preserves a deep scan's canonical `candidateId` under each child result's properties so consumers can group results without changing the original SARIF result presentation

Lifecycle, rich validation evidence, attack-path context, and coverage are lossy or omitted in SARIF. Preserve them in semantic JSON.
Expand Down
73 changes: 67 additions & 6 deletions sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@
"low": "note",
"informational": "note",
}
SARIF_SECURITY_SCORES = {
"critical": 9.5,
"high": 8.0,
"medium": 5.0,
"low": 2.0,
"informational": 0.0,
}
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._/-]*$")
RFC3339_RE = re.compile(
r"^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})$"
Expand Down Expand Up @@ -1701,15 +1708,67 @@ def _validate_contract_refs(scan: dict[str, Any]) -> None:
raise ContractError(f"manifest.scan.{field}: expected {expected!r}")


def _sarif_rule(rule_id: str) -> dict[str, Any]:
def _sarif_label(value: str) -> str:
words = re.sub(r"[-_./]+", " ", value).split()
acronyms = {"api", "csrf", "html", "http", "id", "rce", "sql", "ssrf", "url", "xml", "xss"}
label = " ".join(word.upper() if word.lower() in acronyms else word for word in words)
return label[:1].upper() + label[1:]


def _sarif_rule(rule_id: str, findings: list[dict[str, Any]]) -> dict[str, Any]:
name = ": ".join(_sarif_label(part) for part in rule_id.split("."))
categories = sorted({finding["taxonomy"]["category"] for finding in findings})
cwes = sorted({cwe for finding in findings for cwe in finding["taxonomy"]["cwe"]})
tags = {"security", *categories}
for cwe in cwes:
match = re.fullmatch(r"CWE-([0-9]+)", cwe, re.IGNORECASE)
if match and int(match[1]) > 0:
tags.add(f"external/cwe/cwe-{int(match[1]):03d}")
description = f"{name}. Categories: {', '.join(map(_sarif_label, categories))}."
if cwes:
description += f" Weaknesses: {', '.join(cwes)}."
remediation = "\n\n".join(sorted({finding["remediation"] for finding in findings}))
properties: dict[str, Any] = {"tags": sorted(tags)}
# GitHub assigns security severity to the shared rule, not each result.
score = max(
finding["severity"].get("score", SARIF_SECURITY_SCORES[finding["severity"]["level"]])
for finding in findings
)
if score > 0:
properties["security-severity"] = str(score)
return {
"id": rule_id,
"name": rule_id,
"shortDescription": {"text": rule_id},
"properties": {"tags": ["security"]},
"name": name,
"shortDescription": {"text": name},
"fullDescription": {"text": description},
"help": {
"text": f"{description}\n\nRemediation:\n\n{remediation}",
"markdown": f"{description}\n\n## Remediation\n\n{remediation}",
},
"properties": properties,
}


def _sarif_finding_message(finding: dict[str, Any]) -> str:
taxonomy = finding["taxonomy"]
details = [
finding["title"],
finding["summary"],
f"Severity: {finding['severity']['level']}",
f"Category: {_sarif_label(taxonomy['category'])}",
]
if taxonomy["cwe"]:
details.append(f"Weaknesses: {', '.join(taxonomy['cwe'])}")
details.append(f"Remediation:\n{finding['remediation']}")
for key, label in (
("remediationTests", "Remediation tests"),
("preventiveControls", "Preventive controls"),
):
if finding.get(key):
details.append(f"{label}:\n" + "\n".join(f"- {item}" for item in finding[key]))
return "\n\n".join(details)


def _utf16_code_units(value: str) -> Iterator[int]:
encoded = value.encode("utf-16-le")
for index in range(0, len(encoded), 2):
Expand Down Expand Up @@ -2013,7 +2072,7 @@ def _sarif_result(
"ruleId": finding["ruleId"],
"ruleIndex": rule_index,
"level": SARIF_LEVELS[finding["severity"]["level"]],
"message": {"text": finding["summary"]},
"message": {"text": _sarif_finding_message(finding)},
"locations": [_sarif_location(location) for location in _sarif_locations(finding)],
"partialFingerprints": partial_fingerprints,
"properties": properties,
Expand All @@ -2038,7 +2097,9 @@ def build_sarif(
"driver": {
"name": "Codex Security",
"version": scan["producer"]["version"],
"rules": [_sarif_rule(rule_id) for rule_id in ordered_rule_ids],
"rules": [
_sarif_rule(rule_id, findings_by_rule[rule_id]) for rule_id in ordered_rule_ids
],
}
},
"automationDetails": {"id": scan["id"]},
Expand Down
32 changes: 31 additions & 1 deletion sdk/typescript/tests-ts/cli-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,13 @@ describe("CLI", () => {
const directory = await mkdtemp(join(tmpdir(), "codex-security-export-"));
try {
const scan = await copyCompletedScan(directory);
const paths = [
"scan-manifest.json",
"findings.json",
"coverage.json",
].map((name) => join(scan, name));
const before = await Promise.all(paths.map((path) => readFile(path)));
const source = JSON.parse(before[1]!.toString()).findings[0];
for (const [format, filename] of [
["csv", "findings.csv"],
["json", "findings.json"],
Expand All @@ -365,13 +372,36 @@ describe("CLI", () => {
documentType: "codex-security.findings",
});
} else {
expect(JSON.parse(contents)).toMatchObject({ version: "2.1.0" });
const sarif = JSON.parse(contents);
expect(sarif.version).toBe("2.1.0");
const run = sarif.runs[0];
expect(run.tool.driver.rules[0]).toMatchObject({
id: source.ruleId,
help: { markdown: expect.stringContaining(source.remediation) },
properties: {
"security-severity": "8.1",
tags: expect.arrayContaining([
"security",
"external/cwe/cwe-022",
]),
},
});
expect(run.results[0]).toMatchObject({
ruleId: source.ruleId,
message: { text: expect.stringContaining(source.remediation) },
partialFingerprints: {
"codexSecurity/v1": source.fingerprints.primary,
},
});
}
if (process.platform !== "win32")
expect((await stat(output)).mode & 0o777).toBe(0o600);
expect(stdout.text()).toBe("");
expect(stderr.text()).toBe(`${format.toUpperCase()}: ${output}\n`);
}
expect(await Promise.all(paths.map((path) => readFile(path)))).toEqual(
before,
);
} finally {
await rm(directory, { recursive: true, force: true });
}
Expand Down
158 changes: 158 additions & 0 deletions sdk/typescript/tests-ts/sarif.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { spawnSync } from "node:child_process";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import type { Finding, FindingsDocument } from "../src/models.js";
import { PLUGIN_ROOT } from "./plugin-root.js";

const example = join(PLUGIN_ROOT, "examples", "completed-scan");
const manifest = JSON.parse(
await readFile(join(example, "scan-manifest.json"), "utf8"),
);
const document = JSON.parse(
await readFile(join(example, "findings.json"), "utf8"),
) as FindingsDocument;

function finding(overrides: Partial<Finding> = {}): Finding {
return { ...document.findings[0]!, ...overrides };
}

function buildSarif(findings: Finding[]) {
const python = Bun.which("python3") ?? Bun.which("python");
expect(python).not.toBeNull();
const result = spawnSync(
python!,
[
"-I",
"-B",
"-c",
[
"import json, sys",
"sys.path.insert(0, sys.argv[1])",
"from finalize_scan_contract import build_sarif",
"payload = json.load(sys.stdin)",
"print(json.dumps(build_sarif(payload['manifest'], payload['findings'])['runs'][0]))",
].join("\n"),
join(PLUGIN_ROOT, "scripts"),
],
{
encoding: "utf8",
input: JSON.stringify({ manifest, findings: { ...document, findings } }),
},
);
expect(result.status, result.stderr).toBe(0);
return JSON.parse(result.stdout);
}

describe("SARIF presentation", () => {
test("keeps rule names and alert identity independent of finding text", () => {
const source = finding({
extensions: { candidateId: "candidate-example" },
});
const run = buildSarif([source]);
const rule = run.tool.driver.rules[0];
const result = run.results[0];
expect(rule.name).not.toBe(source.ruleId);
expect(rule.shortDescription.text).toBe(rule.name);
expect(rule.help.text).toContain(source.remediation);
expect(result.properties).toMatchObject({
findingId: source.findingId,
occurrenceId: source.occurrenceId,
candidateId: "candidate-example",
});
for (const text of [
source.title,
source.summary,
...source.remediationTests!,
...source.preventiveControls!,
]) {
expect(result.message.text).toContain(text);
}

const changed = buildSarif([
{
...source,
title: "A new title",
summary: "A changed summary",
remediation: "A different fix.",
},
]);
expect(changed.tool.driver.rules[0].name).toBe(rule.name);
for (const key of [
"ruleId",
"ruleIndex",
"locations",
"partialFingerprints",
"properties",
]) {
expect(changed.results[0][key]).toEqual(result[key]);
}
});

test("merges shared rule metadata without mixing result remediation", () => {
const first = finding({
occurrenceId: `occ_${"1".repeat(24)}`,
severity: { level: "low" },
remediation: "Apply the first control.",
});
const second = finding({
occurrenceId: `occ_${"2".repeat(24)}`,
severity: { level: "critical", score: 9.7, scoringSystem: "CVSS:3.1" },
taxonomy: {
category: "archive-extraction",
cwe: ["cwe-022", "CWE-23", "unknown"],
},
remediation: "Apply the second control.",
});
const run = buildSarif([first, second]);
expect(buildSarif([second, first])).toEqual(run);
expect(run.tool.driver.rules).toHaveLength(1);
const rule = run.tool.driver.rules[0];
expect(rule.properties).toEqual({
"security-severity": "9.7",
tags: [
"archive-extraction",
"external/cwe/cwe-022",
"external/cwe/cwe-023",
"path-traversal",
"security",
],
});
expect(rule.help.markdown).toContain(first.remediation);
expect(rule.help.markdown).toContain(second.remediation);
for (const [index, own, other] of [
[0, first, second],
[1, second, first],
] as const) {
const result = run.results[index];
expect(result.ruleIndex).toBe(0);
expect(result.message.text).toContain(own.remediation);
expect(result.message.text).not.toContain(other.remediation);
expect(result.properties.severity).toBe(own.severity.level);
}
expect(run.results[0].level).toBe("note");
});

test.each([
["critical", undefined, "9.5"],
["high", undefined, "8.0"],
["medium", undefined, "5.0"],
["low", undefined, "2.0"],
["informational", undefined, undefined],
["high", 0, undefined],
["high", 6.25, "6.25"],
["critical", 10, "10"],
] as const)("maps %s severity with score %s", (level, score, expected) => {
const severity =
score === undefined
? { level }
: { level, score, scoringSystem: "CVSS:3.1" };
const run = buildSarif([
finding({ severity, taxonomy: { category: "sql-injection", cwe: [] } }),
]);
expect(run.tool.driver.rules[0].properties).toEqual({
tags: ["security", "sql-injection"],
...(expected === undefined ? {} : { "security-severity": expected }),
});
});
});
Loading