diff --git a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py index 3119005..67f8e8b 100644 --- a/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py +++ b/sdk/typescript/_bundled_plugin/scripts/finalize_scan_contract.py @@ -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" @@ -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: diff --git a/sdk/typescript/tests-ts/scan-recovery.test.ts b/sdk/typescript/tests-ts/scan-recovery.test.ts index 2da5fb9..49d0dcd 100644 --- a/sdk/typescript/tests-ts/scan-recovery.test.ts +++ b/sdk/typescript/tests-ts/scan-recovery.test.ts @@ -749,6 +749,201 @@ describe("malformed scan artifact recovery", () => { } }); + test("keeps findings while recovering missing taxonomy CWE and malformed code evidence", async () => { + const fixture = await startDraftScan(); + const path = join(fixture.scanDir, "findings.json"); + const document = await readJson(path); + const valid = document.findings[0]!; + + const missingCwe = structuredClone(valid); + missingCwe.identity.anchor = "missing-cwe"; + const missingCweTaxonomy = (missingCwe as Record)[ + "taxonomy" + ] as Partial<{ cwe: string[] }>; + delete missingCweTaxonomy.cwe; + + const malformedEvidence = structuredClone(valid); + malformedEvidence.identity.anchor = "malformed-evidence"; + (malformedEvidence as Record)["codeEvidence"] = [ + // Missing the required `explanation` field. + { + id: "ev1", + label: "sink", + path: "src/extract.py", + startLine: 1, + code: "x = 1", + }, + ]; + (malformedEvidence as Record)["rootCause"] = { + summary: "Dangling reference regression check.", + evidenceRefs: ["ev1"], + }; + + // A codeEvidence malformation caught by the hand-rolled validator itself + // (duplicate ids), not only by the JSON-schema pass, plus a dangling + // reference in the nested `attackPath.dataflow.evidenceRefs` location. + const duplicateEvidence = structuredClone(valid); + duplicateEvidence.identity.anchor = "duplicate-evidence"; + (duplicateEvidence as Record)["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)["attackPath"] = { + summary: "Nested dangling reference regression check.", + dataflow: { + summary: "source -> sink", + source: "input", + sink: "output", + outcome: "impact", + evidenceRefs: ["ev1"], + }, + evidenceRefs: ["ev1"], + }; + + document.findings.push(missingCwe, malformedEvidence, duplicateEvidence); + await writeJson(path, document); + + const completed = await completeScan(fixture); + + expect(completed.progress.status).toBe("complete"); + expect(completed.findingCount).toBe(4); + expect(completed.warnings).toHaveLength(6); + expect( + completed.warnings.some( + (warning) => + warning.includes("Recovered finding") && + warning.includes("backfilled missing taxonomy.cwe"), + ), + ).toBe(true); + expect( + completed.warnings.filter((warning) => + warning.startsWith("Skipped malformed codeEvidence for finding"), + ), + ).toHaveLength(2); + expect( + completed.warnings.some( + (warning) => + warning.includes("Recovered finding") && + warning.includes("dropped dangling rootCause.evidenceRefs"), + ), + ).toBe(true); + 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(path)).findings; + const missingCweRecovered = recovered.find( + (finding) => finding?.identity.anchor === "missing-cwe", + ); + expect(missingCweRecovered).toBeDefined(); + expect( + (missingCweRecovered as Record)?.["taxonomy"], + ).toMatchObject({ cwe: [] }); + + const malformedEvidenceRecovered = recovered.find( + (finding) => finding?.identity.anchor === "malformed-evidence", + ); + expect(malformedEvidenceRecovered).toBeDefined(); + expect(malformedEvidenceRecovered).not.toHaveProperty("codeEvidence"); + expect( + (malformedEvidenceRecovered as Record)?.["rootCause"], + ).toMatchObject({ evidenceRefs: [] }); + + const duplicateEvidenceRecovered = recovered.find( + (finding) => finding?.identity.anchor === "duplicate-evidence", + ); + expect(duplicateEvidenceRecovered).toBeDefined(); + expect(duplicateEvidenceRecovered).not.toHaveProperty("codeEvidence"); + expect( + (duplicateEvidenceRecovered as Record)?.["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(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)["codeEvidence"] = [ + { + id: "ev1", + label: "sink", + path: "src/extract.py", + startLine: 1, + code: "x = 1", + }, + ]; + (garbageRefs as Record)["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(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)?.["rootCause"], + ).toMatchObject({ evidenceRefs: [] }); + }); + test("keeps verified coverage receipts and downgrades invalid coverage", async () => { const fixture = await startDraftScan(); const path = join(fixture.scanDir, "coverage.json");