fold/tests/unit/test_fact_id.cpp
2026-07-28 11:57:38 -04:00

186 lines
8.4 KiB
C++

#include <catch2/catch_test_macros.hpp>
#include <sstream>
#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());
}