186 lines
8.4 KiB
C++
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());
|
|
}
|