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}