fix/audit-findings #5

Merged
trevon merged 14 commits from fix/audit-findings into main 2026-09-25 02:39:30 +00:00
Showing only changes of commit 088ba3fb15 - Show all commits

fix(fold): validate the report envelope and gate the Symbolic lane on Fold

The Fold report importer was schema-less and trusted self-description:
- `{"hello":1}` imported 0 findings with no diagnostic, like a clean run;
- findings without a file location were dropped silently;
- any JSON declaring backend "source" and confidence "proved"/"latent"
  got the Symbolic lane, whoever produced it;
- SARIF `file://` URIs (and %-escapes) never matched a scanned file.

Now a report must be a SARIF log (`runs`) or a Fold findings report
(`findings`), otherwise the import fails with "not a Fold report". The
Symbolic lane requires the producer to be Fold (`tool.name`, or every
SARIF run's `tool.driver.name`, equal to "fold", which is what Fold
writes); other producers' findings are imported in the Imported lane
with a diagnostic. An unsupported `output_version` and findings dropped
for lack of a location are diagnosed. `file://[localhost]` URIs lose
their scheme and are percent-decoded before path matching. Existing
tests that model Fold reports now carry Fold's real envelope.

Tests (fold::tests) failing before this change:
rejects_a_file_that_is_not_a_fold_report,
symbolic_lane_requires_a_fold_producer_not_a_self_declared_label,
sarif_file_uris_match_and_unlocated_findings_are_diagnosed.
trvon 2026-09-24 20:11:14 -06:00
Signed by: trevon
GPG key ID: 38686EA797D7A8A7

View file

@ -2,7 +2,7 @@ use crate::metrics;
use crate::model::{
Building, Confidence, Edge, EdgeKind, Evidence, EvidenceLane, Node, NodeKind, stable_id,
};
use anyhow::{Context, Result};
use anyhow::{Context, Result, bail};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fs;
@ -13,11 +13,67 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.with_context(|| format!("cannot read Fold report {}", report.display()))?;
let value: Value = serde_json::from_str(&raw)
.with_context(|| format!("invalid JSON in Fold report {}", report.display()))?;
let findings = if value.get("runs").is_some() {
let sarif_runs = value.get("runs").and_then(Value::as_array);
let is_native = value.get("findings").and_then(Value::as_array).is_some();
if sarif_runs.is_none() && !is_native {
// Anything else imported zero findings silently, like a clean run.
bail!(
"{} is not a Fold report: expected a SARIF log (`runs`) or a Fold findings report (`findings`)",
report.display()
);
}
// Fold's proved/latent tiers mean something only when Fold produced them:
// a report from another tool never earns the Symbolic lane by labelling itself.
let producers: Vec<Option<&str>> = match sarif_runs {
Some(runs) => runs
.iter()
.map(|run| run.pointer("/tool/driver/name").and_then(Value::as_str))
.collect(),
None => vec![value.pointer("/tool/name").and_then(Value::as_str)],
};
let from_fold = !producers.is_empty() && producers.iter().all(|name| *name == Some("fold"));
if !from_fold {
let named: Vec<&str> = producers
.iter()
.map(|name| name.unwrap_or("unnamed"))
.collect();
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"report {} was not produced by Fold (producer: {}); its findings are imported, never symbolic",
report.display(),
named.join(", ")
),
path: None,
});
}
if let Some(version) = value.get("output_version")
&& version.as_u64() != Some(1)
{
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"Fold report {} has unsupported output_version {version}; read as version 1",
report.display()
),
path: None,
});
}
let (findings, unlocated) = if sarif_runs.is_some() {
sarif_findings(&value)
} else {
native_findings(&value)
};
if unlocated > 0 {
building.diagnostics.push(crate::model::Diagnostic {
level: "warning".into(),
message: format!(
"{unlocated} Fold finding(s) in {} had no file location and were not imported",
report.display()
),
path: None,
});
}
let mut imported = 0;
for finding in findings {
@ -83,7 +139,7 @@ pub fn import_findings(building: &mut Building, report: &Path) -> Result<usize>
.unwrap_or_else(|| format!("{}:{}:{}", finding.rule, relative_path, finding.line));
let node_id = stable_id("finding", &identity);
let evidence = Evidence {
lane: fold_lane(&backend, finding.confidence.as_deref()),
lane: fold_lane(&backend, finding.confidence.as_deref(), from_fold),
backend: format!("fold:discover:{backend}"),
confidence: finding.confidence.clone().map(Confidence::Label),
provenance: format!("{}:{}", report.display(), finding.line),
@ -141,12 +197,17 @@ struct Finding {
backend: Option<String>,
}
fn native_findings(value: &Value) -> Vec<Finding> {
value
/// Findings plus the number dropped for lack of a file location.
fn native_findings(value: &Value) -> (Vec<Finding>, usize) {
let items: Vec<&Value> = value
.get("findings")
.and_then(Value::as_array)
.into_iter()
.flatten()
.collect();
let total = items.len();
let findings: Vec<Finding> = items
.into_iter()
.filter_map(|item| {
Some(Finding {
stable_id: string(item, ["id", "finding_id"]),
@ -159,11 +220,14 @@ fn native_findings(value: &Value) -> Vec<Finding> {
backend: native_backend(item),
})
})
.collect()
.collect();
let dropped = total - findings.len();
(findings, dropped)
}
fn sarif_findings(value: &Value) -> Vec<Finding> {
value
/// Findings plus the number dropped for lack of a file location.
fn sarif_findings(value: &Value) -> (Vec<Finding>, usize) {
let items: Vec<&Value> = value
.get("runs")
.and_then(Value::as_array)
.into_iter()
@ -174,6 +238,10 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.into_iter()
.flatten()
})
.collect();
let total = items.len();
let findings: Vec<Finding> = items
.into_iter()
.filter_map(|item| {
let location = item.pointer("/locations/0/physicalLocation")?;
Some(Finding {
@ -194,7 +262,7 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.and_then(Value::as_str)
.unwrap_or_default()
.into(),
path: location.pointer("/artifactLocation/uri")?.as_str()?.into(),
path: sarif_uri_path(location.pointer("/artifactLocation/uri")?.as_str()?),
line: location
.pointer("/region/startLine")
.and_then(Value::as_u64)
@ -209,7 +277,34 @@ fn sarif_findings(value: &Value) -> Vec<Finding> {
.map(str::to_owned),
})
})
.collect()
.collect();
let dropped = total - findings.len();
(findings, dropped)
}
/// SARIF `artifactLocation.uri` as a path: `file://[localhost]/p` loses its scheme and
/// percent-escapes (`%20`) are decoded; relative URIs pass through unchanged.
fn sarif_uri_path(uri: &str) -> String {
let Some(rest) = uri.strip_prefix("file://") else {
return uri.to_owned();
};
let rest = rest.strip_prefix("localhost").unwrap_or(rest);
let bytes = rest.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%'
&& i + 2 < bytes.len()
&& let Ok(byte) = u8::from_str_radix(&rest[i + 1..i + 3], 16)
{
decoded.push(byte);
i += 3;
continue;
}
decoded.push(bytes[i]);
i += 1;
}
String::from_utf8(decoded).unwrap_or_else(|_| rest.to_owned())
}
fn string<const N: usize>(value: &Value, keys: [&str; N]) -> Option<String> {
@ -327,9 +422,10 @@ fn normalize_lexical_path(path: &str) -> String {
}
}
fn fold_lane(backend: &str, confidence: Option<&str>) -> EvidenceLane {
fn fold_lane(backend: &str, confidence: Option<&str>, from_fold: bool) -> EvidenceLane {
const SYMBOLIC_CONFIDENCE: [&str; 3] = ["concrete-witness", "proved", "latent"];
if backend == "source"
if from_fold
&& backend == "source"
&& confidence.is_some_and(|confidence| SYMBOLIC_CONFIDENCE.contains(&confidence))
{
EvidenceLane::Symbolic
@ -362,6 +458,8 @@ mod tests {
fs::write(dir.path().join("main.cpp"), "int main() { return 0; }\n").unwrap();
let report = dir.path().join("fold.json");
let value = serde_json::json!({
"output_version": 1,
"tool": {"name": "fold", "version": "0.1.0"},
"findings": [{
"id": "f1",
"kind": "null-deref",
@ -414,7 +512,7 @@ mod tests {
})).collect();
fs::write(
&report,
serde_json::to_vec(&serde_json::json!({"findings": findings})).unwrap(),
serde_json::to_vec(&serde_json::json!({"tool": {"name": "fold"}, "findings": findings})).unwrap(),
)
.unwrap();
let mut model = scan(&root).unwrap();
@ -439,6 +537,7 @@ mod tests {
&report,
r#"{
"runs": [{
"tool": {"driver": {"name": "fold"}},
"results": [
{
"ruleId": "fold.valid",
@ -485,6 +584,7 @@ mod tests {
let outside = dir.path().parent().unwrap().join("other/main.cpp");
let report = dir.path().join("fold.json");
let value = serde_json::json!({
"tool": {"name": "fold"},
"findings": [
{
"id": "absolute-outside",
@ -634,4 +734,92 @@ mod tests {
.any(|evidence| evidence.provenance.contains("second.json"))
);
}
#[test]
fn rejects_a_file_that_is_not_a_fold_report() {
// `{"hello":1}` imported 0 findings with no diagnostic, indistinguishable
// from a clean Fold run.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let report = dir.path().join("fold.json");
fs::write(&report, r#"{"hello": 1}"#).unwrap();
let mut building = scan(dir.path()).unwrap();
let error = import_findings(&mut building, &report).unwrap_err();
assert!(
format!("{error:#}").contains("not a Fold report"),
"{error:#}"
);
}
#[test]
fn symbolic_lane_requires_a_fold_producer_not_a_self_declared_label() {
// Any JSON claiming backend "source" and confidence "proved" got the Symbolic
// lane. Only a report produced by Fold may carry Fold's proved tier.
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("main.cpp"), "int main() { return 0; }\n").unwrap();
let report = dir.path().join("other.json");
let value = serde_json::json!({
"tool": {"name": "someone-else"},
"findings": [{
"id": "x", "kind": "null-deref",
"locations": [{"file": "main.cpp", "line": 1}],
"confidence": "proved", "evidence": {"backend": "source"}
}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
let mut building = scan(dir.path()).unwrap();
assert_eq!(import_findings(&mut building, &report).unwrap(), 1);
let flags = building
.edges
.iter()
.find(|edge| edge.kind == EdgeKind::Flags)
.unwrap();
assert_eq!(flags.evidence[0].lane, crate::model::EvidenceLane::Imported);
assert!(
building
.diagnostics
.iter()
.any(|d| d.message.contains("not produced by Fold"))
);
}
#[test]
fn sarif_file_uris_match_and_unlocated_findings_are_diagnosed() {
let dir = tempfile::tempdir().unwrap();
fs::write(dir.path().join("my file.cpp"), "int main() { return 0; }\n").unwrap();
let mut building = scan(dir.path()).unwrap();
let uri = format!(
"file://{}",
dir.path()
.join("my file.cpp")
.to_str()
.unwrap()
.replace(' ', "%20")
);
let report = dir.path().join("fold.sarif");
let value = serde_json::json!({
"version": "2.1.0",
"runs": [{
"tool": {"driver": {"name": "fold"}},
"results": [
{"ruleId": "div-by-zero", "message": {"text": "x"},
"locations": [{"physicalLocation": {"artifactLocation": {"uri": uri},
"region": {"startLine": 1}}}],
"properties": {"confidence": "proved", "backend": "source"}},
{"ruleId": "no-location", "message": {"text": "y"},
"properties": {"confidence": "heuristic", "backend": "source"}}
]
}]
});
fs::write(&report, serde_json::to_vec(&value).unwrap()).unwrap();
assert_eq!(import_findings(&mut building, &report).unwrap(), 1);
assert!(
building
.diagnostics
.iter()
.any(|d| d.message.contains("no file location")),
"{:?}",
building.diagnostics
);
}
}