Skip to content
Open
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
62 changes: 62 additions & 0 deletions sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,9 @@ def _recover_unsealed_findings(
writeup_schema = _require_dict(
finding_properties, "writeup", "findings.schema.properties.findings.items.properties"
)
code_evidence_schema = _require_dict(
finding_properties, "codeEvidence", "findings.schema.properties.findings.items.properties"
)
auxiliary_schemas = {
name: _require_dict(
finding_properties, name, "findings.schema.properties.findings.items.properties"
Expand Down Expand Up @@ -842,6 +845,65 @@ def _recover_unsealed_findings(
)
finding_id = finding["findingId"]
previous_position = finding_positions.get(finding_id)

if "codeEvidence" in finding:
try:
_validate_schema_node(
finding["codeEvidence"], code_evidence_schema, f"{context}.codeEvidence"
)
seen_evidence_ids: set[str] = set()
for evidence_index, evidence in enumerate(finding["codeEvidence"]):
evidence_id = evidence["id"]
if evidence_id in seen_evidence_ids:
raise ContractError(
f"{context}.codeEvidence[{evidence_index}].id: "
"duplicate code-evidence id"
)
seen_evidence_ids.add(evidence_id)
except ContractError as exc:
finding.pop("codeEvidence")
warnings.append(
f"Skipped malformed codeEvidence for finding {index + 1}: {exc}."
)

taxonomy = finding.get("taxonomy")
if isinstance(taxonomy, dict) and "cwe" not in taxonomy:
taxonomy["cwe"] = []
warnings.append(
f"Recovered finding {index + 1}: backfilled missing taxonomy.cwe with an empty list."
)

known_evidence_ids = {
evidence["id"]
for evidence in finding.get("codeEvidence") or []
if isinstance(evidence, dict) and isinstance(evidence.get("id"), str)
}
attack_path = finding.get("attackPath")
evidence_ref_sections = [
(finding.get("rootCause"), "rootCause"),
(finding.get("validation"), "validation"),
(attack_path, "attackPath"),
]
if isinstance(attack_path, dict):
evidence_ref_sections.append(
(attack_path.get("dataflow"), "attackPath.dataflow")
)
for section, section_name in evidence_ref_sections:
if not isinstance(section, dict):
continue
refs = section.get("evidenceRefs")
if not isinstance(refs, list):
continue
kept_refs = [
ref for ref in refs if isinstance(ref, str) and ref in known_evidence_ids
]
if len(kept_refs) != len(refs):
section["evidenceRefs"] = kept_refs
warnings.append(
f"Recovered finding {index + 1}: dropped dangling {section_name}.evidenceRefs "
"after codeEvidence recovery."
)

_validate_finding(finding, context)
if "writeup" in finding:
try:
Expand Down
227 changes: 227 additions & 0 deletions sdk/typescript/tests-ts/scan-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,233 @@ describe("malformed scan artifact recovery", () => {
}
});

test("backfills a missing taxonomy.cwe instead of discarding the finding", async () => {
const fixture = await startDraftScan();
const path = join(fixture.scanDir, "findings.json");
const document = await readJson<FindingsDocument>(path);

const missingCwe = structuredClone(document.findings[0]!);
missingCwe.identity.anchor = "missing-cwe";
const taxonomy = (missingCwe as Record<string, unknown>)[
"taxonomy"
] as Partial<{ cwe: string[] }>;
delete taxonomy.cwe;
document.findings.push(missingCwe);
await writeJson(path, document);

const completed = await completeScan(fixture);

expect(completed.progress.status).toBe("complete");
expect(completed.findingCount).toBe(2);
expect(completed.warnings).toHaveLength(1);
expect(
completed.warnings.some(
(warning) =>
warning.includes("Recovered finding") &&
warning.includes("backfilled missing taxonomy.cwe"),
),
).toBe(true);

const recovered = (await readJson<FindingsDocument>(path)).findings.find(
(finding) => finding?.identity.anchor === "missing-cwe",
);
expect(recovered).toBeDefined();
expect((recovered as Record<string, unknown>)?.["taxonomy"]).toMatchObject({
cwe: [],
});
});

test("drops schema-invalid codeEvidence and prunes the rootCause references it strands", async () => {
const fixture = await startDraftScan();
const path = join(fixture.scanDir, "findings.json");
const document = await readJson<FindingsDocument>(path);

const malformedEvidence = structuredClone(document.findings[0]!);
malformedEvidence.identity.anchor = "malformed-evidence";
(malformedEvidence as Record<string, unknown>)["codeEvidence"] = [
// Missing the required `explanation` field, so the JSON-schema pass
// rejects it while the hand-rolled validator would accept it.
{
id: "ev1",
label: "sink",
path: "src/extract.py",
startLine: 1,
code: "x = 1",
},
];
(malformedEvidence as Record<string, unknown>)["rootCause"] = {
summary: "Dangling reference regression check.",
evidenceRefs: ["ev1"],
};
document.findings.push(malformedEvidence);
await writeJson(path, document);

const completed = await completeScan(fixture);

expect(completed.progress.status).toBe("complete");
expect(completed.findingCount).toBe(2);
expect(completed.warnings).toHaveLength(2);
expect(
completed.warnings.filter((warning) =>
warning.startsWith("Skipped malformed codeEvidence for finding"),
),
).toHaveLength(1);
expect(
completed.warnings.some(
(warning) =>
warning.includes("Recovered finding") &&
warning.includes("dropped dangling rootCause.evidenceRefs"),
),
).toBe(true);

const recovered = (await readJson<FindingsDocument>(path)).findings.find(
(finding) => finding?.identity.anchor === "malformed-evidence",
);
expect(recovered).toBeDefined();
expect(recovered).not.toHaveProperty("codeEvidence");
expect((recovered as Record<string, unknown>)?.["rootCause"]).toMatchObject(
{ evidenceRefs: [] },
);
});

test("drops duplicate codeEvidence ids and prunes nested attackPath references", async () => {
const fixture = await startDraftScan();
const path = join(fixture.scanDir, "findings.json");
const document = await readJson<FindingsDocument>(path);

// Duplicate ids are caught by the hand-rolled validator rather than the
// JSON-schema pass, so this exercises the other detection path. The
// stranded references also live in the nested
// `attackPath.dataflow.evidenceRefs` location, not just the top level.
const duplicateEvidence = structuredClone(document.findings[0]!);
duplicateEvidence.identity.anchor = "duplicate-evidence";
(duplicateEvidence as Record<string, unknown>)["codeEvidence"] = [
{
id: "ev1",
label: "a",
path: "src/extract.py",
startLine: 1,
code: "x = 1",
explanation: "first",
},
{
id: "ev1",
label: "b",
path: "src/extract.py",
startLine: 2,
code: "y = 2",
explanation: "second",
},
];
(duplicateEvidence as Record<string, unknown>)["attackPath"] = {
summary: "Nested dangling reference regression check.",
dataflow: {
summary: "source -> sink",
source: "input",
sink: "output",
outcome: "impact",
evidenceRefs: ["ev1"],
},
evidenceRefs: ["ev1"],
};
document.findings.push(duplicateEvidence);
await writeJson(path, document);

const completed = await completeScan(fixture);

expect(completed.progress.status).toBe("complete");
expect(completed.findingCount).toBe(2);
expect(completed.warnings).toHaveLength(3);
expect(
completed.warnings.filter((warning) =>
warning.startsWith("Skipped malformed codeEvidence for finding"),
),
).toHaveLength(1);
expect(
completed.warnings.some(
(warning) =>
warning.includes("Recovered finding") &&
warning.includes("dropped dangling attackPath.evidenceRefs"),
),
).toBe(true);
expect(
completed.warnings.some(
(warning) =>
warning.includes("Recovered finding") &&
warning.includes("dropped dangling attackPath.dataflow.evidenceRefs"),
),
).toBe(true);

const recovered = (await readJson<FindingsDocument>(path)).findings.find(
(finding) => finding?.identity.anchor === "duplicate-evidence",
);
expect(recovered).toBeDefined();
expect(recovered).not.toHaveProperty("codeEvidence");
expect(
(recovered as Record<string, unknown>)?.["attackPath"],
).toMatchObject({
evidenceRefs: [],
dataflow: { evidenceRefs: [] },
});
});

test("prunes non-string evidenceRefs entries instead of crashing recovery", async () => {
const fixture = await startDraftScan();
const path = join(fixture.scanDir, "findings.json");
const document = await readJson<FindingsDocument>(path);
const valid = document.findings[0]!;

// codeEvidence is malformed so it gets stripped, and evidenceRefs mixes a
// valid string with unhashable (object/array) and non-string (number)
// garbage that a malfunctioning producer could emit. Recovery must prune
// these rather than crash when testing set membership.
const garbageRefs = structuredClone(valid);
garbageRefs.identity.anchor = "garbage-evidence-refs";
(garbageRefs as Record<string, unknown>)["codeEvidence"] = [
{
id: "ev1",
label: "sink",
path: "src/extract.py",
startLine: 1,
code: "x = 1",
},
];
(garbageRefs as Record<string, unknown>)["rootCause"] = {
summary: "Non-string evidenceRefs regression check.",
evidenceRefs: ["ev1", { nested: "garbage" }, ["nested", "garbage"], 42],
};

document.findings.push(garbageRefs);
await writeJson(path, document);

const completed = await completeScan(fixture);

expect(completed.progress.status).toBe("complete");
expect(completed.findingCount).toBe(2);
expect(
completed.warnings.some((warning) =>
warning.startsWith("Skipped malformed codeEvidence for finding"),
),
).toBe(true);
expect(
completed.warnings.some(
(warning) =>
warning.includes("Recovered finding") &&
warning.includes("dropped dangling rootCause.evidenceRefs"),
),
).toBe(true);

const recovered = (await readJson<FindingsDocument>(path)).findings;
const garbageRefsRecovered = recovered.find(
(finding) => finding?.identity.anchor === "garbage-evidence-refs",
);
expect(garbageRefsRecovered).toBeDefined();
expect(garbageRefsRecovered).not.toHaveProperty("codeEvidence");
expect(
(garbageRefsRecovered as Record<string, unknown>)?.["rootCause"],
).toMatchObject({ evidenceRefs: [] });
});

test("keeps verified coverage receipts and downgrades invalid coverage", async () => {
const fixture = await startDraftScan();
const path = join(fixture.scanDir, "coverage.json");
Expand Down