use crate::deps::DependencyInventory;
use crate::finding::{Finding, TaintFlowRef};
use crate::matcher::RuntimeDisabledRule;
use bonsai_common::canonicalize_path_or_existing_parent;
use serde::Serialize;
use std::path::PathBuf;
const CWE_TAXONOMY_GUID: &str = "25F72D7E-8A92-459D-AD67-64853F788765";
#[derive(Clone, Debug, Serialize)]
pub struct SecurityReport {
pub schema_version: String,
pub language_coverage: Vec<String>,
pub findings: Vec<Finding>,
pub analysis_complete: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub analysis_incomplete_reasons: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub runtime_disabled_rules: Vec<RuntimeDisabledRule>,
}
impl SecurityReport {
#[must_use]
pub fn new(findings: Vec<Finding>) -> Self {
Self::with_runtime_disabled_rules(findings, Vec::new())
}
#[must_use]
pub fn with_runtime_disabled_rules(
findings: Vec<Finding>,
runtime_disabled_rules: Vec<RuntimeDisabledRule>,
) -> Self {
let mut languages: Vec<String> = findings.iter().map(|finding| finding.language.clone()).collect();
languages.sort();
languages.dedup();
Self {
schema_version: "1.0".to_string(),
language_coverage: languages,
findings,
analysis_complete: false,
analysis_incomplete_reasons: vec!["analysis-completeness-not-provided".to_string()],
runtime_disabled_rules,
}
}
#[must_use]
pub fn with_analysis_completeness(mut self, complete: bool, mut reasons: Vec<String>) -> Self {
if !complete && reasons.is_empty() {
reasons.push("analysis-incomplete:unknown-reason".to_string());
}
reasons.sort();
reasons.dedup();
self.analysis_complete = complete && reasons.is_empty();
self.analysis_incomplete_reasons = reasons;
self
}
#[must_use]
pub fn sarif_json(&self) -> String {
render_sarif_json(self)
}
#[must_use]
pub fn sarif_json_with_workspace_root(&self, workspace_root: &str) -> String {
render_sarif_with_provenance(self, Some(workspace_root), None)
}
}
pub fn render_graph_json(report: &SecurityReport) -> String {
render_json_with_failure_metadata(report, "security-graph")
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct TrainExample {
pub example_id: String,
pub finding_id: String,
pub language: String,
pub source_id: String,
pub sink_id: String,
pub sanitizer_ids: Vec<String>,
pub taint_transform_ids: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub flow_id: Option<String>,
pub payload_types: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tag: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<String>,
pub precision: String,
}
#[derive(Clone, Debug, Serialize)]
pub(crate) struct TrainReport {
pub schema_version: String,
pub language_coverage: Vec<String>,
pub analysis_complete: bool,
pub analysis_incomplete_reasons: Vec<String>,
pub runtime_disabled_rules: Vec<RuntimeDisabledRule>,
pub examples: Vec<TrainExample>,
}
pub fn render_train_json(report: &SecurityReport) -> String {
let examples: Vec<TrainExample> = report
.findings
.iter()
.enumerate()
.map(|(finding_index, finding)| TrainExample {
example_id: format!("ex-{finding_index:06}"),
finding_id: finding.finding_id.clone(),
language: finding.language.clone(),
source_id: finding.source.rule_id.clone(),
sink_id: finding.sink.rule_id.clone(),
sanitizer_ids: finding
.sanitizers_seen
.iter()
.map(|sanitizer| sanitizer.rule_id.clone())
.collect(),
taint_transform_ids: finding
.taint_transforms_seen
.iter()
.map(|transform| transform.rule_id.clone())
.collect(),
group_id: finding.group_id.clone(),
flow_id: finding.representative_flow_id.clone(),
payload_types: finding.source.payload_types.clone(),
tag: finding.tag.clone(),
severity: finding.severity.map(|severity| severity.as_str().to_string()),
precision: finding.precision.clone(),
})
.collect();
let train = TrainReport {
schema_version: "1.0".to_string(),
language_coverage: report.language_coverage.clone(),
analysis_complete: report.analysis_complete,
analysis_incomplete_reasons: report.analysis_incomplete_reasons.clone(),
runtime_disabled_rules: report.runtime_disabled_rules.clone(),
examples,
};
render_json_with_failure_metadata(&train, "security-train")
}
fn render_json_with_failure_metadata<T: Serialize>(value: &T, surface: &str) -> String {
serde_json::to_string_pretty(value).unwrap_or_else(|error| {
serde_json::json!({
"analysis_complete": false,
"analysis_incomplete_reasons": [format!(
"{surface} serialization failed: {error}"
)],
})
.to_string()
})
}
pub fn render_grouped_text(report: &SecurityReport) -> String {
let mut output = String::new();
output.push_str(&format!(
"security — {} finding(s) across languages: {}\n",
report.findings.len(),
if report.language_coverage.is_empty() {
"<none>".to_string()
} else {
report.language_coverage.join(", ")
},
));
if report.analysis_complete {
output.push_str("analysis: complete\n");
} else {
let reasons = if report.analysis_incomplete_reasons.is_empty() {
"unknown semantic coverage gap".to_string()
} else {
report.analysis_incomplete_reasons.join("; ")
};
output.push_str(&format!("analysis: incomplete — {reasons}\n"));
}
for disabled in &report.runtime_disabled_rules {
output.push_str(&format!(
"runtime-disabled rule: {} — {}\n",
disabled.rule_id, disabled.reason
));
}
output.push('\n');
for finding in &report.findings {
output.push_str(&format!(
"{} [{}] {} -> {}\n at {}:{}:{}\n status: {}\n precision: {}\n",
finding.finding_id,
finding.severity.map_or("info", |severity| severity.as_str()),
finding.source.rule_id,
finding.sink.rule_id,
finding.sink.file,
finding.sink.line,
finding.sink.column,
finding.status.as_str(),
finding.precision,
));
if !finding.sanitizers_seen.is_empty() {
output.push_str(" sanitizers: ");
for sanitizer in &finding.sanitizers_seen {
output.push_str(&sanitizer.rule_id);
output.push(' ');
}
output.push('\n');
}
if !finding.taint_transforms_seen.is_empty() {
output.push_str(" taint transforms: ");
for transform in &finding.taint_transforms_seen {
output.push_str(&transform.rule_id);
output.push(' ');
}
output.push('\n');
}
output.push('\n');
}
output
}
pub fn render_sarif_json(report: &SecurityReport) -> String {
render_sarif_with_provenance(report, None, None)
}
pub(crate) fn render_sarif_with_provenance(
report: &SecurityReport,
workspace_root: Option<&str>,
version_control: Option<(&str, &str, &str)>,
) -> String {
let mut rule_index: std::collections::BTreeMap<String, usize> = std::collections::BTreeMap::new();
let mut rules_in_order: Vec<String> = Vec::new();
for finding in &report.findings {
let rule_id = finding.sink.rule_id.clone();
if !rule_index.contains_key(&rule_id) {
rule_index.insert(rule_id.clone(), rules_in_order.len());
rules_in_order.push(rule_id);
}
}
let mut representatives: ahash::AHashMap<&str, &crate::Finding> = ahash::AHashMap::new();
for finding in &report.findings {
representatives
.entry(finding.sink.rule_id.as_str())
.or_insert(finding);
}
let rules_json: Vec<serde_json::Value> = rules_in_order
.iter()
.map(|rule_id| {
let representative = representatives.get(rule_id.as_str()).copied();
let default_level = representative
.map(|finding| sarif_level_for_severity(finding.severity))
.unwrap_or("warning");
let cwes: Vec<String> = representative
.map(|finding| finding.cwe.clone())
.unwrap_or_default();
let cwes = dedup_strings(cwes);
let mut tags: Vec<String> = cwes
.iter()
.map(|cwe| format!("external/cwe/{}", cwe.to_lowercase()))
.collect();
if !tags.is_empty() {
tags.insert(0, "security".to_string());
}
let tags = dedup_strings(tags);
let relationships: Vec<serde_json::Value> = cwes
.iter()
.map(|cwe| cwe_relationship(cwe))
.collect();
let security_severity = representative
.and_then(|finding| finding.severity)
.map(security_severity_for_severity)
.unwrap_or("5.0");
let precision = representative
.map(|finding| sarif_precision_label(&finding.precision).to_string())
.unwrap_or_else(|| "medium".to_string());
let rank = representative
.map(|finding| sarif_rank_for_severity(finding.severity))
.unwrap_or(50.0);
let source_rule = representative
.map(|finding| finding.source.rule_id.as_str())
.unwrap_or("unknown-source");
let message_text = format!("Tainted value reaches {rule_id} from {{0}}.");
let source_summary = representative
.map(|finding| {
format!(
"{} -> {} ({})",
finding.source.rule_id,
finding.sink.rule_id,
finding.tag.as_deref().unwrap_or("untagged")
)
})
.unwrap_or_else(|| format!("{source_rule} -> {rule_id}"));
let tag = representative
.and_then(|finding| finding.tag.clone())
.unwrap_or_else(|| "untagged".to_string());
serde_json::json!({
"id": rule_id,
"name": rule_id,
"shortDescription": { "text": rule_id },
"fullDescription": { "text": format!("bonsai rule {} (tag: {}; {})", rule_id, tag, source_summary) },
"messageStrings": {
"default": { "text": message_text },
},
"defaultConfiguration": {
"level": default_level,
"rank": rank,
"enabled": true,
},
"properties": {
"cwe": cwes,
"tags": tags,
"tag": tag,
"security-severity": security_severity,
"precision": precision,
},
"relationships": relationships,
})
})
.collect();
let mut cwe_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for finding in &report.findings {
for cwe in &finding.cwe {
cwe_set.insert(cwe.clone());
}
}
let cwe_taxa: Vec<serde_json::Value> = cwe_set
.iter()
.map(|cwe| {
serde_json::json!({
"id": cwe,
"name": cwe,
"shortDescription": { "text": format!("Common Weakness Enumeration {}", cwe) },
"helpUri": cwe_help_uri(cwe),
})
})
.collect();
let taxonomies_json = if cwe_set.is_empty() {
serde_json::json!([])
} else {
serde_json::json!([{
"name": "CWE",
"guid": CWE_TAXONOMY_GUID,
"version": "4.14",
"informationUri": "https://cwe.mitre.org/",
"downloadUri": "https://cwe.mitre.org/data/xml/cwec_v4.14.xml.zip",
"shortDescription": { "text": "Common Weakness Enumeration" },
"isComprehensive": false,
"organization": "MITRE",
"taxa": cwe_taxa,
}])
};
let workspace_root_uri = workspace_root.map(uri_for_path);
let results_json: Vec<serde_json::Value> = report
.findings
.iter()
.map(|finding| finding_to_sarif_result(finding, &rule_index, &cwe_set, workspace_root))
.collect();
let automation_details = serde_json::json!({
"id": "bonsai-ninja/security/taint-analysis",
"description": { "text": "bonsai-ninja security taint-analysis run" },
});
let version_control_provenance: Vec<serde_json::Value> = match version_control {
Some((repo, branch, rev)) if !repo.is_empty() || !branch.is_empty() || !rev.is_empty() => {
let mut entry = serde_json::Map::new();
if !repo.is_empty() {
entry.insert(
"repositoryUri".to_string(),
serde_json::Value::String(repo.to_string()),
);
}
if !branch.is_empty() {
entry.insert(
"branch".to_string(),
serde_json::Value::String(branch.to_string()),
);
}
if !rev.is_empty() {
entry.insert(
"revisionId".to_string(),
serde_json::Value::String(rev.to_string()),
);
}
vec![serde_json::Value::Object(entry)]
}
_ => Vec::new(),
};
let mut run = serde_json::json!({
"tool": {
"driver": {
"name": "bonsai-ninja",
"informationUri": "https://github.com/gromhacks/bonsai-ninja",
"version": env!("CARGO_PKG_VERSION"),
"semanticVersion": env!("CARGO_PKG_VERSION"),
"rules": rules_json,
"supportedTaxonomies": [{ "name": "CWE", "guid": CWE_TAXONOMY_GUID }],
},
},
"results": results_json,
"taxonomies": taxonomies_json,
"automationDetails": automation_details,
"columnKind": "utf16CodeUnits",
"invocations": [{
"executionSuccessful": report.analysis_complete,
"toolExecutionNotifications": report.analysis_incomplete_reasons.iter().map(|reason| {
serde_json::json!({
"level": "warning",
"message": { "text": format!("Incomplete semantic coverage: {reason}") },
})
}).collect::<Vec<_>>(),
"properties": {
"bonsai": {
"analysis_complete": report.analysis_complete,
"analysis_incomplete_reasons": report.analysis_incomplete_reasons,
}
}
}],
});
if let Some(uri) = workspace_root_uri {
run["originalUriBaseIds"] = serde_json::json!({
"%SRCROOT%": {
"uri": uri,
"description": { "text": "Workspace root" },
},
});
}
if !version_control_provenance.is_empty() {
run["versionControlProvenance"] = serde_json::json!(version_control_provenance);
}
let sarif = serde_json::json!({
"$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/schemas/sarif-schema-2.1.0.json",
"version": "2.1.0",
"runs": [run],
});
render_json_with_failure_metadata(&sarif, "security-sarif")
}
fn sarif_level_for_severity(severity: Option<crate::rule::Severity>) -> &'static str {
match severity {
Some(crate::rule::Severity::Critical | crate::rule::Severity::High) => "error",
Some(crate::rule::Severity::Medium) => "warning",
Some(crate::rule::Severity::Low | crate::rule::Severity::Info) => "note",
None => "warning",
}
}
fn sarif_rank_for_severity(severity: Option<crate::rule::Severity>) -> f64 {
match severity {
Some(crate::rule::Severity::Critical) => 95.0,
Some(crate::rule::Severity::High) => 80.0,
Some(crate::rule::Severity::Medium) => 55.0,
Some(crate::rule::Severity::Low) => 25.0,
Some(crate::rule::Severity::Info) => 10.0,
None => 50.0,
}
}
fn security_severity_for_severity(severity: crate::rule::Severity) -> &'static str {
match severity {
crate::rule::Severity::Critical => "9.5",
crate::rule::Severity::High => "8.0",
crate::rule::Severity::Medium => "5.5",
crate::rule::Severity::Low => "2.5",
crate::rule::Severity::Info => "1.0",
}
}
fn sarif_precision_label(precision: &str) -> &'static str {
match precision {
"exact" | "narrowed" => "high",
"over-approximate" => "medium",
"unknown" => "low",
_ => "medium",
}
}
fn cwe_relationship(cwe: &str) -> serde_json::Value {
serde_json::json!({
"target": {
"id": cwe,
"toolComponent": {
"name": "CWE",
"guid": CWE_TAXONOMY_GUID,
},
},
"kinds": ["superset"],
})
}
fn cwe_help_uri(cwe: &str) -> String {
let id = cwe.strip_prefix("CWE-").unwrap_or(cwe);
format!("https://cwe.mitre.org/data/definitions/{id}.html")
}
fn artifact_location_relative(path: &str, workspace_root: Option<&str>) -> serde_json::Value {
if let Some(root) = workspace_root {
if let Some(relative) = relative_artifact_uri(path, root) {
return serde_json::json!({
"uri": relative,
"uriBaseId": "%SRCROOT%",
});
}
}
serde_json::json!({
"uri": path,
})
}
fn relative_artifact_uri(path: &str, workspace_root: &str) -> Option<String> {
let paths = path_candidates(path);
let roots = path_candidates(workspace_root);
for path in &paths {
for root in &roots {
if path == root {
return Some(String::new());
}
if let Ok(relative) = path.strip_prefix(root) {
return Some(normalize_artifact_uri(&relative.to_string_lossy()));
}
}
}
None
}
fn path_candidates(path: &str) -> Vec<PathBuf> {
let raw = PathBuf::from(path);
let mut candidates = vec![raw.clone()];
if let Some(canonical) = canonicalize_path_or_existing_parent(&raw) {
if canonical != raw {
candidates.push(canonical);
}
}
candidates
}
fn normalize_artifact_uri(value: &str) -> String {
value.replace('\\', "/")
}
fn uri_for_path(path: &str) -> String {
let normalized = path.trim_end_matches('/');
let mut uri = if normalized.starts_with('/') {
format!("file://{normalized}")
} else {
format!("file:///{normalized}")
};
if !uri.ends_with('/') {
uri.push('/');
}
uri
}
fn dedup_strings(values: Vec<String>) -> Vec<String> {
let mut deduped = Vec::with_capacity(values.len());
for value in values {
if !deduped.iter().any(|existing| existing == &value) {
deduped.push(value);
}
}
deduped
}
fn finding_to_sarif_result(
finding: &Finding,
rule_index: &std::collections::BTreeMap<String, usize>,
cwe_set: &std::collections::BTreeSet<String>,
workspace_root: Option<&str>,
) -> serde_json::Value {
let level = sarif_level_for_severity(finding.severity);
let kind = match finding.status {
crate::finding::FindingStatus::Sanitized | crate::finding::FindingStatus::WrongContext => "review",
crate::finding::FindingStatus::Unsanitized => "fail",
};
let rank = sarif_rank_for_severity(finding.severity);
let message = format!(
"{} -> {} ({}). {}",
finding.source.rule_id,
finding.sink.rule_id,
finding.tag.as_deref().unwrap_or("untagged"),
finding.sink.text,
);
let sink_location = match_to_sarif_location(&finding.sink, "sink", &finding.hops, workspace_root);
let is_pattern_finding = finding.source.origin == crate::rule::MatchOrigin::Pattern;
let code_flows = if is_pattern_finding {
None
} else {
Some(build_sarif_code_flows(finding, workspace_root))
};
let finding_cwes = dedup_strings(finding.cwe.clone());
let cwe_taxa: Vec<serde_json::Value> = finding_cwes
.iter()
.filter(|cwe| cwe_set.contains(*cwe))
.map(|cwe| {
serde_json::json!({
"id": cwe,
"toolComponent": {
"name": "CWE",
"guid": CWE_TAXONOMY_GUID,
},
})
})
.collect();
let mut result_tags: Vec<String> = vec!["security".to_string()];
for cwe in &finding_cwes {
result_tags.push(cwe.clone());
result_tags.push(format!("external/cwe/{}", cwe.to_lowercase()));
}
let result_tags = dedup_strings(result_tags);
let rule_idx = rule_index.get(&finding.sink.rule_id).copied().unwrap_or(0);
let primary_fingerprint = finding.finding_id.clone();
let partial_fingerprint_token = format!(
"{}:{}:{}:{}",
finding.source.rule_id,
finding.sink.rule_id,
finding.sink.enclosing_fn.as_deref().unwrap_or(""),
finding.tag.as_deref().unwrap_or(""),
);
let primary_location_line_hash = format!(
"{:016x}:1",
bonsai_hash::fnv1a_names64(&[
finding.sink.file.clone(),
finding.sink.line.to_string(),
finding.sink.rule_id.clone(),
])
);
let mut result = serde_json::json!({
"ruleId": finding.sink.rule_id,
"ruleIndex": rule_idx,
"level": level,
"kind": kind,
"rank": rank,
"message": {
"id": "default",
"arguments": [finding.source.rule_id],
"text": message,
},
"locations": [sink_location],
"fingerprints": {
"bonsai/finding/v1": primary_fingerprint,
},
"partialFingerprints": {
"bonsai/source-sink-host/v1": partial_fingerprint_token,
"primaryLocationLineHash": primary_location_line_hash,
"primaryLocationStartColumnFingerprint": finding.sink.column.max(1).to_string(),
},
"properties": {
"cwe": finding.cwe,
"tags": result_tags,
"bonsai": {
"finding_id": finding.finding_id,
"flow_id": finding.representative_flow_id,
"flow_ids": finding_flow_ids(finding),
"group_id": finding.group_id,
"tag": finding.tag,
"cwe": finding.cwe,
"owasp": finding.owasp,
"precision": finding.precision,
"analysis_complete": finding.analysis_complete,
"analysis_incomplete_reasons": finding.analysis_incomplete_reasons,
"status": finding.status.as_str(),
"language": finding.language,
"source_rule_id": finding.source.rule_id,
"sink_rule_id": finding.sink.rule_id,
"sanitizer_rule_ids": finding_sanitizer_rule_ids(finding),
"taint_transform_rule_ids": finding_taint_transform_rule_ids(finding),
"tainted_args": finding_tainted_args(finding),
"taint_path": finding.taint_path,
"chain_display": finding.chain_display,
"alternate_flows": finding.alternate_flows,
}
}
});
if let Some(code_flows) = code_flows {
result["codeFlows"] = code_flows;
}
if !cwe_taxa.is_empty() {
result["taxa"] = serde_json::json!(cwe_taxa);
}
result
}
fn build_sarif_code_flows(finding: &Finding, workspace_root: Option<&str>) -> serde_json::Value {
let flows = finding
.flows()
.enumerate()
.map(|(index, flow)| {
let hops = if index == 0 { finding.hops.as_slice() } else { &[] };
build_sarif_code_flow(flow, &finding.sink, hops, workspace_root)
})
.collect();
serde_json::Value::Array(flows)
}
fn finding_flow_ids(finding: &Finding) -> Vec<String> {
let mut ids = finding.flow_ids().map(str::to_owned).collect::<Vec<_>>();
ids.sort();
ids.dedup();
ids
}
fn finding_sanitizer_rule_ids(finding: &Finding) -> Vec<String> {
let mut ids = Vec::new();
for flow in finding.flows() {
for sanitizer in flow.sanitizers_seen {
if !ids.iter().any(|existing| existing == &sanitizer.rule_id) {
ids.push(sanitizer.rule_id.clone());
}
}
}
ids
}
fn finding_taint_transform_rule_ids(finding: &Finding) -> Vec<String> {
let mut ids = Vec::new();
for flow in finding.flows() {
for transform in flow.taint_transforms_seen {
if !ids.iter().any(|existing| existing == &transform.rule_id) {
ids.push(transform.rule_id.clone());
}
}
}
ids
}
fn finding_tainted_args(finding: &Finding) -> Vec<crate::finding::TaintedArgInfo> {
let mut args = Vec::new();
for flow in finding.flows() {
for item in flow.sink_tainted_args {
if !args.iter().any(|existing: &crate::finding::TaintedArgInfo| {
existing.index == item.index
&& existing.value_text == item.value_text
&& existing.place == item.place
&& existing.source_names == item.source_names
}) {
args.push(item.clone());
}
}
}
args.sort_by(|a, b| {
a.index
.cmp(&b.index)
.then_with(|| a.value_text.cmp(&b.value_text))
.then_with(|| a.place.cmp(&b.place))
.then_with(|| a.source_names.cmp(&b.source_names))
});
args
}
fn build_sarif_code_flow(
flow: TaintFlowRef<'_>,
sink: &crate::finding::FindingMatch,
hops: &[crate::flow_evidence::FlowFunctionBody],
workspace_root: Option<&str>,
) -> serde_json::Value {
let mut thread_flow_locations: Vec<serde_json::Value> = Vec::new();
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(flow.source, "source", hops, workspace_root),
&["source", "taint"],
"essential",
),
);
let mut sanitizers_by_step: Vec<Vec<&crate::finding::FindingMatch>> =
vec![Vec::new(); flow.taint_path.len()];
let mut unmatched_sanitizers = Vec::new();
for sanitizer in flow.sanitizers_seen {
if let Some(idx) = sanitizer_taint_step_index(sanitizer, flow.taint_path) {
sanitizers_by_step[idx].push(sanitizer);
} else {
unmatched_sanitizers.push(sanitizer);
}
}
let mut transforms_by_step: Vec<Vec<&crate::finding::FindingMatch>> =
vec![Vec::new(); flow.taint_path.len()];
let mut unmatched_transforms = Vec::new();
for transform in flow.taint_transforms_seen {
if let Some(idx) = sanitizer_taint_step_index(transform, flow.taint_path) {
transforms_by_step[idx].push(transform);
} else {
unmatched_transforms.push(transform);
}
}
for (idx, step) in flow.taint_path.iter().enumerate() {
if !taint_step_matches_match(step, sink) {
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
taint_step_to_sarif_location(step, hops, workspace_root),
&["taint", "call"],
"important",
),
);
}
for sanitizer in &sanitizers_by_step[idx] {
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(sanitizer, "sanitizer", hops, workspace_root),
&["sanitizer"],
"important",
),
);
}
for transform in &transforms_by_step[idx] {
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(transform, "taint transform", hops, workspace_root),
&["taint", "taint-transform"],
"important",
),
);
}
}
unmatched_sanitizers.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.column.cmp(&b.column))
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
for sanitizer in unmatched_sanitizers {
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(sanitizer, "sanitizer", hops, workspace_root),
&["sanitizer"],
"important",
),
);
}
unmatched_transforms.sort_by(|a, b| {
a.file
.cmp(&b.file)
.then_with(|| a.line.cmp(&b.line))
.then_with(|| a.column.cmp(&b.column))
.then_with(|| a.rule_id.cmp(&b.rule_id))
});
for transform in unmatched_transforms {
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(transform, "taint transform", hops, workspace_root),
&["taint", "taint-transform"],
"important",
),
);
}
push_thread_flow_location(
&mut thread_flow_locations,
thread_flow_location(
match_to_sarif_location(sink, "sink", hops, workspace_root),
&["sink"],
"essential",
),
);
let flow_summary = if flow.taint_path.is_empty() {
format!("{} -> {}", flow.source.rule_id, sink.rule_id)
} else {
let chain: Vec<String> = flow
.taint_path
.iter()
.map(|step| format!("{} -> {}", step.caller, step.callee))
.collect();
chain.join("; ")
};
serde_json::json!({
"message": { "text": flow_summary },
"threadFlows": [{
"id": flow.flow_id.unwrap_or("primary"),
"locations": thread_flow_locations,
}],
})
}
fn sanitizer_taint_step_index(
sanitizer: &crate::finding::FindingMatch,
taint_path: &[crate::finding::TaintPropagationStep],
) -> Option<usize> {
taint_path
.iter()
.position(|step| {
step.file == sanitizer.file && step.line == sanitizer.line && step.column == sanitizer.column
})
.or_else(|| {
taint_path
.iter()
.position(|step| step.file == sanitizer.file && step.line == sanitizer.line)
})
}
fn thread_flow_location(location: serde_json::Value, kinds: &[&str], importance: &str) -> serde_json::Value {
serde_json::json!({
"location": location,
"kinds": kinds,
"importance": importance,
})
}
fn push_thread_flow_location(locations: &mut Vec<serde_json::Value>, next: serde_json::Value) {
if let Some(previous) = locations.last_mut() {
if same_thread_flow_site(previous, &next) {
merge_thread_flow_location(previous, next);
return;
}
}
locations.push(next);
}
fn same_thread_flow_site(left: &serde_json::Value, right: &serde_json::Value) -> bool {
if thread_flow_has_kind(left, "sanitizer") || thread_flow_has_kind(right, "sanitizer") {
return false;
}
match (
thread_flow_uri(left),
thread_flow_start_line(left),
thread_flow_uri(right),
thread_flow_start_line(right),
) {
(Some(left_uri), Some(left_line), Some(right_uri), Some(right_line)) => {
left_uri == right_uri && left_line == right_line
}
_ => false,
}
}
fn thread_flow_has_kind(location: &serde_json::Value, kind: &str) -> bool {
location
.get("kinds")
.and_then(serde_json::Value::as_array)
.is_some_and(|kinds| kinds.iter().any(|value| value.as_str() == Some(kind)))
}
fn thread_flow_uri(location: &serde_json::Value) -> Option<&str> {
location
.get("location")?
.get("physicalLocation")?
.get("artifactLocation")?
.get("uri")?
.as_str()
}
fn thread_flow_start_line(location: &serde_json::Value) -> Option<u64> {
location
.get("location")?
.get("physicalLocation")?
.get("region")?
.get("startLine")?
.as_u64()
}
fn merge_thread_flow_location(previous: &mut serde_json::Value, next: serde_json::Value) {
let mut kinds: Vec<serde_json::Value> = previous
.get("kinds")
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default();
if let Some(next_kinds) = next.get("kinds").and_then(serde_json::Value::as_array) {
for kind in next_kinds {
if !kinds.iter().any(|existing| existing == kind) {
kinds.push(kind.clone());
}
}
}
previous["kinds"] = serde_json::Value::Array(kinds);
if next.get("importance").and_then(serde_json::Value::as_str) == Some("essential") {
previous["importance"] = serde_json::Value::String("essential".to_string());
}
}
fn taint_step_matches_match(
step: &crate::finding::TaintPropagationStep,
finding_match: &crate::finding::FindingMatch,
) -> bool {
step.file == finding_match.file && step.line == finding_match.line
}
fn taint_step_to_sarif_location(
step: &crate::finding::TaintPropagationStep,
hops: &[crate::flow_evidence::FlowFunctionBody],
workspace_root: Option<&str>,
) -> serde_json::Value {
let artifact = artifact_location_relative(&step.file, workspace_root);
let display = format!("{} -> {}", step.caller, step.callee);
let start_column = step.column.max(1);
let end_column = start_column.saturating_add(u32::try_from(display.chars().count()).unwrap_or(0));
let mut physical = serde_json::json!({
"artifactLocation": artifact,
"region": {
"startLine": step.line.max(1),
"startColumn": start_column,
"endLine": step.line.max(1),
"endColumn": end_column.max(start_column + 1),
"snippet": { "text": display },
},
});
if let Some(context) = body_context_region(hops, Some(step.caller.as_str()), step.line) {
physical["contextRegion"] = context;
}
serde_json::json!({
"physicalLocation": physical,
"logicalLocations": logical_locations_for(Some(step.caller.as_str()), Some(step.file.as_str())),
"message": { "text": format!("taint propagates through {display}") },
"properties": {
"kind": "taint",
"caller": step.caller,
"callee": step.callee,
"tainted_args": step.tainted_args,
}
})
}
fn body_context_region(
hops: &[crate::flow_evidence::FlowFunctionBody],
func: Option<&str>,
line: u32,
) -> Option<serde_json::Value> {
let func = func?;
let body = hops.iter().find(|b| {
b.function == func
&& b.lines.first().is_some_and(|first| first.n <= line)
&& b.lines.last().is_some_and(|last| last.n >= line)
})?;
let start = body.lines.first()?.n;
let end = body.lines.last()?.n;
let text = body
.lines
.iter()
.map(|l| l.text.as_str())
.collect::<Vec<_>>()
.join("\n");
Some(serde_json::json!({
"startLine": start,
"endLine": end,
"snippet": { "text": text },
}))
}
fn match_to_sarif_location(
finding_match: &crate::finding::FindingMatch,
role: &str,
hops: &[crate::flow_evidence::FlowFunctionBody],
workspace_root: Option<&str>,
) -> serde_json::Value {
let artifact = artifact_location_relative(&finding_match.file, workspace_root);
let start_column = finding_match.column.max(1);
let end_column =
start_column.saturating_add(u32::try_from(finding_match.text.chars().count()).unwrap_or(0));
let mut physical = serde_json::json!({
"artifactLocation": artifact,
"region": {
"startLine": finding_match.line,
"startColumn": start_column,
"endLine": finding_match.line,
"endColumn": end_column.max(start_column + 1),
"snippet": { "text": finding_match.text },
},
});
if let Some(context) =
body_context_region(hops, finding_match.enclosing_fn.as_deref(), finding_match.line)
{
physical["contextRegion"] = context;
}
serde_json::json!({
"physicalLocation": physical,
"logicalLocations": logical_locations_for(finding_match.enclosing_fn.as_deref(), Some(finding_match.file.as_str())),
"message": { "text": format!("[{role}] {}", finding_match.text) },
"properties": {
"kind": role,
"rule_id": finding_match.rule_id,
}
})
}
fn logical_locations_for(enclosing: Option<&str>, file: Option<&str>) -> serde_json::Value {
match enclosing {
Some(name) if !name.is_empty() => {
let mut frame = serde_json::json!({
"name": name,
"kind": "function",
});
if let Some(file) = file.filter(|file| !file.is_empty()) {
frame["fullyQualifiedName"] = serde_json::json!(format!("{file}::{name}"));
}
serde_json::json!([frame])
}
_ => serde_json::json!([]),
}
}
pub fn render_deps_text(inv: &DependencyInventory) -> String {
let mut output = String::new();
output.push_str(&format!("deps — {} entries\n", inv.rows.len()));
for row in &inv.rows {
output.push_str(&format!(
" [{}] {} — rules={} signals={} severity={} tags={}\n",
row.language,
row.key,
row.rule_ids.len(),
row.signals.join(","),
row.severity.map_or("-", |severity| severity.as_str()),
row.tags.join(","),
));
}
output
}
#[cfg(test)]
#[path = "report_sarif_tests.rs"]
mod sarif_tests;