#include #include #include "fold/ir/fact_id.hpp" #include "fold/report/finding_json.hpp" #include "fold/report/sarif.hpp" using namespace fold; TEST_CASE("fact provenance JSON round-trips optional IDs", "[fact-id]") { ir::FactProvenance provenance; provenance.backend = "source"; provenance.lane = "clang"; provenance.artifact = "/repo/main.cpp"; provenance.artifact_hash = "abc"; provenance.function_id = ir::makeFunctionId("clang", "/repo/main.cpp", "f:int(int):1"); provenance.op_id = ir::makeOpId("clang", provenance.function_id, "3:20:null-deref:0"); const auto parsed = ir::factProvenanceFromJson(ir::factProvenanceToJson(provenance)); REQUIRE(parsed.backend == "source"); REQUIRE(parsed.lane == "clang"); REQUIRE(parsed.function_id.rfind("fold:function:v1:clang:", 0) == 0); REQUIRE(parsed.op_id.rfind("fold:op:v1:clang:", 0) == 0); REQUIRE(parsed.block_id.empty()); } TEST_CASE("finding JSON round-trips source and binary fact provenance", "[fact-id]") { Finding source; source.id = "null-deref:/repo/main.cpp:3:20"; source.kind = FindingKind::NullDeref; source.confidence = Confidence::Proved; source.evidence.provenance = ir::FactProvenance{"source", "clang", "/repo/main.cpp", "src-hash", ir::makeFunctionId("clang", "/repo/main.cpp", "f:int():1"), "", ir::makeOpId("clang", "f", "3:20:null-deref:0"), ""}; Finding binary; binary.id = "binary-oob:/tmp/app:0x401000"; binary.kind = FindingKind::OOBAccess; binary.confidence = Confidence::Heuristic; binary.evidence.provenance = ir::FactProvenance{"binary", "ghidra-pcode", "/tmp/app", "bin-hash", ir::makeFunctionId("ghidra-pcode", "bin-hash", "0x401000:sym.vuln"), "", ir::makeOpId("ghidra-pcode", "0x401000", "0x401020:3:INT_ADD"), ""}; binary.evidence.related_facts.push_back(ir::FactProvenance{ "binary", "r2-heuristic", "/tmp/app", "bin-hash", ir::makeFunctionId("r2-heuristic", "bin-hash", "0x401000:sym.vuln"), "", "", ""}); const auto source_roundtrip = findingFromJson(findingToJson(source)); REQUIRE(source_roundtrip.evidence.provenance.has_value()); REQUIRE(source_roundtrip.evidence.provenance->lane == "clang"); REQUIRE(source_roundtrip.evidence.provenance->op_id.rfind("fold:op:v1:clang:", 0) == 0); const auto binary_roundtrip = findingFromJson(findingToJson(binary)); REQUIRE(binary_roundtrip.evidence.provenance.has_value()); REQUIRE(binary_roundtrip.evidence.provenance->lane == "ghidra-pcode"); REQUIRE(binary_roundtrip.evidence.related_facts.size() == 1); REQUIRE(binary_roundtrip.evidence.related_facts.front().lane == "r2-heuristic"); } TEST_CASE("finding JSON round-trips latent precondition evidence", "[fact-id][latent]") { Finding finding; finding.id = "div-by-zero:/repo/main.cpp:9:12"; finding.kind = FindingKind::DivByZero; finding.confidence = Confidence::Heuristic; finding.message = "divisor may be zero under caller precondition"; finding.evidence.detector_name = "div-by-zero"; finding.evidence.backend = "source"; finding.evidence.precondition_required = true; finding.evidence.latent = true; finding.evidence.required_preconditions = {"input vector may be empty", "caller permits n==0"}; finding.evidence.latent_reason = "sink is reachable only when external input violates contract"; const auto json = findingToJson(finding); REQUIRE(json["evidence"]["precondition_required"] == true); REQUIRE(json["evidence"]["latent"] == true); REQUIRE(json["evidence"]["required_preconditions"].size() == 2); REQUIRE(json["evidence"]["latent_reason"] == finding.evidence.latent_reason); const auto roundtrip = findingFromJson(json); REQUIRE(roundtrip.evidence.precondition_required); REQUIRE(roundtrip.evidence.latent); REQUIRE(roundtrip.evidence.required_preconditions == finding.evidence.required_preconditions); REQUIRE(roundtrip.evidence.latent_reason == finding.evidence.latent_reason); } TEST_CASE("SARIF exposes latent precondition evidence", "[fact-id][latent][sarif]") { Finding finding; finding.id = "null-deref:/repo/main.cpp:9:12"; finding.kind = FindingKind::NullDeref; finding.confidence = Confidence::Heuristic; finding.message = "pointer may be null if caller omits precondition"; finding.locations.push_back(Location{"/repo/main.cpp", 9, 12, "f"}); finding.evidence.detector_name = "null-deref"; finding.evidence.precondition_required = true; finding.evidence.latent = true; finding.evidence.required_preconditions = {"p must be non-null"}; finding.evidence.latent_reason = "callee dereferences p after trusting caller"; std::ostringstream out; writeSarif(out, {finding}); const auto sarif = nlohmann::json::parse(out.str()); const auto& props = sarif["runs"][0]["results"][0]["properties"]; REQUIRE(props["preconditionRequired"] == true); REQUIRE(props["latent"] == true); REQUIRE(props["requiredPreconditions"].size() == 1); REQUIRE(props["latentReason"] == finding.evidence.latent_reason); } TEST_CASE("SARIF exposes summary and cross-TU proof provenance", "[fact-id][sarif]") { Finding finding; finding.id = "equiv:a==b"; finding.kind = FindingKind::Equiv; finding.confidence = Confidence::Proved; finding.message = "equivalent across translation units"; finding.locations.push_back(Location{"/repo/a.cpp", 3, 1, "a"}); finding.locations.push_back(Location{"/repo/b.cpp", 7, 1, "b"}); finding.evidence.detector_name = "equiv"; finding.evidence.is_summary_backed = true; finding.evidence.has_cross_tu_corroboration = true; std::ostringstream out; writeSarif(out, {finding}); const auto sarif = nlohmann::json::parse(out.str()); const auto& props = sarif["runs"][0]["results"][0]["properties"]; REQUIRE(props["summaryBacked"] == true); REQUIRE(props["crossTuCorroboration"] == true); } TEST_CASE("SARIF preserves fact provenance and related facts", "[fact-id][sarif]") { Finding finding; finding.id = "binary-oob:/tmp/app:0x401000"; finding.kind = FindingKind::OOBAccess; finding.confidence = Confidence::Heuristic; finding.message = "binary finding with correlated provenance"; finding.locations.push_back(Location{"/tmp/app", 1, 1, "sym.vuln"}); finding.evidence.detector_name = "binary-security"; finding.evidence.provenance = ir::FactProvenance{"binary", "ghidra-pcode", "/tmp/app", "bin-hash", ir::makeFunctionId("ghidra-pcode", "bin-hash", "0x401000:sym.vuln"), "", ir::makeOpId("ghidra-pcode", "0x401000", "0x401020:3:INT_ADD"), ""}; finding.evidence.related_facts.push_back(ir::FactProvenance{ "binary", "r2-heuristic", "/tmp/app", "bin-hash", ir::makeFunctionId("r2-heuristic", "bin-hash", "0x401000:sym.vuln"), "", "", ""}); std::ostringstream out; writeSarif(out, {finding}); const auto sarif = nlohmann::json::parse(out.str()); const auto& props = sarif["runs"][0]["results"][0]["properties"]; REQUIRE(props.contains("provenance")); REQUIRE(props["provenance"]["lane"] == "ghidra-pcode"); REQUIRE(props.contains("related_facts")); REQUIRE(props["related_facts"].is_array()); REQUIRE(props["related_facts"].size() == 1); REQUIRE(props["related_facts"][0]["lane"] == "r2-heuristic"); } TEST_CASE("old finding JSON without fact IDs still parses", "[fact-id]") { nlohmann::json old = { {"id", "null-deref:/repo/main.cpp:3:20"}, {"kind", "null-deref"}, {"confidence", "proved"}, {"locations", nlohmann::json::array()}, {"message", "old report"}, {"evidence", {{"detector_name", "null-deref"}}}, {"score", 0.0}, }; auto finding = findingFromJson(old); REQUIRE(!finding.evidence.provenance.has_value()); REQUIRE(finding.evidence.related_facts.empty()); }