fix/audit-findings #5
1 changed files with 201 additions and 13 deletions
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.
commit
088ba3fb15
214
src/fold.rs
214
src/fold.rs
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue