Skip to main content

allow_report/
sarif.rs

1use allow_core::{Finding, MatchOutcome, MatchStatus, json_escape, normalize_path};
2
3use crate::evidence_repair::{
4    evidence_repair_queues_from_context, push_evidence_repair_queue_json_fields,
5};
6use crate::json::{bool_json, option_json, push_json_source_context_properties};
7use crate::{
8    ReportContext, Summary, baseline_debt_count, broken_evidence_link_count,
9    policy_missing_evidence_count, weak_evidence_reference_count,
10};
11
12pub fn render_sarif(
13    command: &str,
14    findings: &[Finding],
15    outcomes: &[MatchOutcome],
16    failed: bool,
17) -> String {
18    render_sarif_with_context(
19        command,
20        findings,
21        outcomes,
22        failed,
23        ReportContext::default(),
24    )
25}
26
27pub fn render_sarif_with_context(
28    command: &str,
29    findings: &[Finding],
30    outcomes: &[MatchOutcome],
31    failed: bool,
32    context: ReportContext<'_>,
33) -> String {
34    let summary = Summary::from_outcomes(outcomes);
35    let reportable = outcomes
36        .iter()
37        .filter(|outcome| outcome.status != MatchStatus::Matched)
38        .collect::<Vec<_>>();
39    let mut out = String::new();
40    out.push_str("{\n");
41    out.push_str("  \"$schema\": \"https://json.schemastore.org/sarif-2.1.0.json\",\n");
42    out.push_str("  \"version\": \"2.1.0\",\n");
43    out.push_str("  \"runs\": [\n");
44    out.push_str("    {\n");
45    out.push_str("      \"tool\": {\n");
46    out.push_str("        \"driver\": {\n");
47    out.push_str("          \"name\": \"cargo-allow\",\n");
48    out.push_str(
49        "          \"informationUri\": \"https://github.com/EffortlessMetrics/cargo-allow\",\n",
50    );
51    out.push_str("          \"rules\": [\n");
52    for (index, status) in SARIF_STATUSES.iter().enumerate() {
53        if index > 0 {
54            out.push_str(",\n");
55        }
56        out.push_str(&render_sarif_rule(*status));
57    }
58    out.push_str("\n          ]\n");
59    out.push_str("        }\n");
60    out.push_str("      },\n");
61    out.push_str("      \"properties\": {\n");
62    out.push_str(&format!(
63        "        \"command\": \"{}\",\n",
64        json_escape(command)
65    ));
66    out.push_str(&format!(
67        "        \"status\": \"{}\",\n",
68        if failed { "failed" } else { "passed" }
69    ));
70    out.push_str(&format!("        \"failed\": {},\n", bool_json(failed)));
71    push_json_source_context_properties(&mut out, context.into(), "        ");
72    push_policy_context_properties(&mut out, &summary, context);
73    push_evidence_repair_queues_property(&mut out, &summary, context);
74    out.push_str("      },\n");
75    out.push_str("      \"results\": [\n");
76    for (index, outcome) in reportable.iter().enumerate() {
77        if index > 0 {
78            out.push_str(",\n");
79        }
80        let finding = outcome.finding_index.and_then(|idx| findings.get(idx));
81        out.push_str(&render_sarif_result(outcome, finding));
82    }
83    out.push_str("\n      ]\n");
84    out.push_str("    }\n");
85    out.push_str("  ]\n");
86    out.push_str("}\n");
87    out
88}
89
90fn push_policy_context_properties(out: &mut String, summary: &Summary, context: ReportContext<'_>) {
91    let rows = policy_context_property_rows(summary, context);
92    if rows.is_empty() {
93        return;
94    }
95    out.push_str(",\n");
96    for (index, (name, count)) in rows.iter().enumerate() {
97        let comma = if index + 1 == rows.len() { "" } else { "," };
98        out.push_str(&format!("        \"{name}\": {count}{comma}\n"));
99    }
100}
101
102fn policy_context_property_rows(
103    summary: &Summary,
104    context: ReportContext<'_>,
105) -> Vec<(&'static str, usize)> {
106    let mut rows = Vec::new();
107    let baseline_debt = baseline_debt_count(summary, context);
108    if baseline_debt > summary.count(MatchStatus::BaselineDebt) {
109        rows.push(("policy_baseline_debt", baseline_debt));
110    }
111    let policy_missing_evidence = policy_missing_evidence_count(summary, context);
112    if policy_missing_evidence > summary.count(MatchStatus::EvidenceMissing) {
113        rows.push(("policy_missing_evidence", policy_missing_evidence));
114    }
115    let broken_evidence_links = broken_evidence_link_count(context);
116    if broken_evidence_links > 0 {
117        rows.push(("broken_evidence_links", broken_evidence_links));
118    }
119    let weak_evidence_references = weak_evidence_reference_count(context);
120    if weak_evidence_references > 0 {
121        rows.push(("weak_evidence_references", weak_evidence_references));
122    }
123    rows
124}
125
126fn push_evidence_repair_queues_property(
127    out: &mut String,
128    summary: &Summary,
129    context: ReportContext<'_>,
130) {
131    let queues = evidence_repair_queues_from_context(summary, context);
132    if queues.is_empty() {
133        return;
134    }
135
136    out.push_str(",\n");
137    out.push_str("        \"evidence_repair_queues\": [\n");
138    for (index, queue) in queues.iter().enumerate() {
139        if index > 0 {
140            out.push_str(",\n");
141        }
142        out.push_str("          {\n");
143        push_evidence_repair_queue_json_fields(out, queue, "            ");
144        out.push_str("          }");
145    }
146    out.push_str("\n        ]\n");
147}
148
149const SARIF_STATUSES: &[MatchStatus] = &[
150    MatchStatus::New,
151    MatchStatus::Expired,
152    MatchStatus::ReviewDue,
153    MatchStatus::Stale,
154    MatchStatus::Ambiguous,
155    MatchStatus::InvalidSelector,
156    MatchStatus::MissingRequiredField,
157    MatchStatus::EvidenceMissing,
158    MatchStatus::BaselineDebt,
159];
160
161fn render_sarif_rule(status: MatchStatus) -> String {
162    format!(
163        "            {{\"id\": \"{}\", \"name\": \"{}\", \"shortDescription\": {{\"text\": \"{}\"}}}}",
164        sarif_rule_id(status),
165        status.as_str(),
166        sarif_rule_description(status)
167    )
168}
169
170fn render_sarif_result(outcome: &MatchOutcome, finding: Option<&Finding>) -> String {
171    let mut out = String::new();
172    out.push_str("        {\n");
173    out.push_str(&format!(
174        "          \"ruleId\": \"{}\",\n",
175        sarif_rule_id(outcome.status)
176    ));
177    out.push_str(&format!(
178        "          \"level\": \"{}\",\n",
179        sarif_level(outcome.status)
180    ));
181    out.push_str(&format!(
182        "          \"message\": {{\"text\": \"{}\"}},\n",
183        json_escape(&outcome.message)
184    ));
185    out.push_str("          \"properties\": {\n");
186    out.push_str(&format!(
187        "            \"status\": \"{}\",\n",
188        outcome.status.as_str()
189    ));
190    out.push_str(&format!(
191        "            \"allow_id\": {},\n",
192        option_json(outcome.allow_id.as_deref())
193    ));
194    out.push_str(&format!(
195        "            \"finding_index\": {},\n",
196        outcome
197            .finding_index
198            .map(|idx| idx.to_string())
199            .unwrap_or_else(|| "null".to_string())
200    ));
201    out.push_str(&format!("            \"score\": {},\n", outcome.score));
202    out.push_str(&format!(
203        "            \"source_package\": {}\n",
204        option_json(finding.and_then(|finding| finding.identity.crate_name.as_deref()))
205    ));
206    out.push_str("          }");
207    if let Some(finding) = finding {
208        out.push_str(",\n");
209        out.push_str("          \"locations\": [\n");
210        out.push_str(&render_sarif_location(finding));
211        out.push_str("\n          ]\n");
212        out.push_str("        }");
213    } else {
214        out.push('\n');
215        out.push_str("        }");
216    }
217    out
218}
219
220fn render_sarif_location(finding: &Finding) -> String {
221    let mut out = String::new();
222    out.push_str("            {\n");
223    out.push_str("              \"physicalLocation\": {\n");
224    out.push_str(&format!(
225        "                \"artifactLocation\": {{\"uri\": \"{}\"}}",
226        json_escape(&normalize_path(&finding.path))
227    ));
228    if let Some(span) = &finding.span {
229        out.push_str(",\n");
230        out.push_str("                \"region\": {\n");
231        out.push_str(&format!(
232            "                  \"startLine\": {},\n",
233            span.line
234        ));
235        out.push_str(&format!(
236            "                  \"startColumn\": {}\n",
237            span.column
238        ));
239        out.push_str("                }\n");
240        out.push_str("              }\n");
241    } else {
242        out.push('\n');
243        out.push_str("              }\n");
244    }
245    out.push_str("            }");
246    out
247}
248
249fn sarif_rule_id(status: MatchStatus) -> String {
250    format!("cargo-allow/{}", status.as_str())
251}
252
253fn sarif_rule_description(status: MatchStatus) -> &'static str {
254    match status {
255        MatchStatus::New => "New unreceipted source-tree exception finding.",
256        MatchStatus::Expired => "Matched allow entry is expired.",
257        MatchStatus::ReviewDue => "Matched allow entry is due for review.",
258        MatchStatus::Stale => "Allow entry did not match any current finding.",
259        MatchStatus::Ambiguous => "Selector matched ambiguously and needs narrowing.",
260        MatchStatus::InvalidSelector => "Allow entry selector is invalid.",
261        MatchStatus::MissingRequiredField => "Allow entry is missing required policy metadata.",
262        MatchStatus::EvidenceMissing => "Allow entry is missing required evidence.",
263        MatchStatus::BaselineDebt => "Generated baseline debt remains in policy.",
264        MatchStatus::Matched => "Finding matched policy.",
265    }
266}
267
268fn sarif_level(status: MatchStatus) -> &'static str {
269    match status {
270        MatchStatus::New
271        | MatchStatus::Expired
272        | MatchStatus::Ambiguous
273        | MatchStatus::InvalidSelector
274        | MatchStatus::MissingRequiredField
275        | MatchStatus::EvidenceMissing => "error",
276        MatchStatus::ReviewDue | MatchStatus::BaselineDebt => "warning",
277        MatchStatus::Stale => "note",
278        MatchStatus::Matched => "none",
279    }
280}