1use anyhow::Result;
2use libverify_core::assessment::{AssessmentReport, BatchReport, VerificationResult};
3use libverify_core::control::ControlId;
4use libverify_core::profile::FindingSeverity;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7fn utc_now_rfc3339() -> String {
10 let secs = SystemTime::now()
11 .duration_since(UNIX_EPOCH)
12 .unwrap_or_default()
13 .as_secs();
14 let s = secs % 60;
15 let total_min = secs / 60;
16 let m = total_min % 60;
17 let total_hour = total_min / 60;
18 let h = total_hour % 24;
19 let total_days = total_hour / 24;
20 let (year, month, day) = days_to_ymd(total_days);
22 format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
23}
24
25fn days_to_ymd(days: u64) -> (u64, u64, u64) {
27 let z = days + 719468;
29 let era = z / 146097;
30 let doe = z % 146097;
31 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
32 let y = yoe + era * 400;
33 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
34 let mp = (5 * doy + 2) / 153;
35 let d = doy - (153 * mp + 2) / 5 + 1;
36 let m = if mp < 10 { mp + 3 } else { mp - 9 };
37 let y = if m <= 2 { y + 1 } else { y };
38 (y, m, d)
39}
40
41fn builtin_rule_description(id: &str) -> &'static str {
42 libverify_core::controls::control_description(id)
43}
44
45pub fn render(
46 result: &VerificationResult,
47 only_failures: bool,
48 tool_name: &str,
49 tool_version: &str,
50) -> Result<String> {
51 let mut sarif = build_sarif(&result.report, tool_name, tool_version);
52 if only_failures {
53 filter_sarif_runs(&mut sarif);
54 }
55 if let Some(evidence) = &result.evidence
56 && let Some(run) = sarif["runs"].as_array_mut().and_then(|a| a.first_mut())
57 {
58 run["properties"]["evidence"] = serde_json::to_value(evidence)?;
59 }
60 Ok(serde_json::to_string_pretty(&sarif)?)
61}
62
63pub fn render_batch(
64 batch: &BatchReport,
65 only_failures: bool,
66 tool_name: &str,
67 tool_version: &str,
68) -> Result<String> {
69 let mut runs = Vec::new();
70 for entry in &batch.reports {
71 let mut sarif = build_sarif(&entry.result.report, tool_name, tool_version);
72 if only_failures {
73 filter_sarif_runs(&mut sarif);
74 }
75 if let Some(run) = sarif["runs"].as_array().and_then(|a| a.first()) {
76 let mut run = run.clone();
77 let mut props = serde_json::json!({ "subjectId": entry.subject_id });
78 if let Some(evidence) = &entry.result.evidence {
79 props["evidence"] = serde_json::to_value(evidence)?;
80 }
81 run["properties"] = props;
82 runs.push(run);
83 }
84 }
85 let sarif = serde_json::json!({
86 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
87 "version": "2.1.0",
88 "runs": runs,
89 });
90 Ok(serde_json::to_string_pretty(&sarif)?)
91}
92
93fn build_sarif(
94 report: &AssessmentReport,
95 tool_name: &str,
96 tool_version: &str,
97) -> serde_json::Value {
98 let mut seen_rules: Vec<ControlId> = Vec::new();
99 let rules: Vec<serde_json::Value> = report
100 .outcomes
101 .iter()
102 .filter_map(|o| {
103 if seen_rules.contains(&o.control_id) {
104 return None;
105 }
106 seen_rules.push(o.control_id.clone());
107 Some(rule_descriptor(&o.control_id))
108 })
109 .collect();
110
111 let results: Vec<serde_json::Value> = report
112 .findings
113 .iter()
114 .zip(report.outcomes.iter())
115 .map(|(finding, outcome)| {
116 let mut result = serde_json::json!({
117 "ruleId": outcome.control_id.as_str(),
118 "level": severity_to_level(outcome.severity),
119 "message": { "text": outcome.rationale },
120 "properties": {
121 "decision": outcome.decision.as_str(),
122 "controlStatus": finding.status.as_str(),
123 },
124 });
125
126 if !finding.subjects.is_empty() {
127 let locations: Vec<serde_json::Value> = finding
128 .subjects
129 .iter()
130 .map(|s| {
131 serde_json::json!({
132 "logicalLocations": [{
133 "fullyQualifiedName": s,
134 "kind": "resource",
135 }]
136 })
137 })
138 .collect();
139 result["locations"] = serde_json::Value::Array(locations);
140 }
141
142 if !finding.evidence_gaps.is_empty() {
143 let gaps: Vec<String> = finding
144 .evidence_gaps
145 .iter()
146 .map(|g| format!("{g}"))
147 .collect();
148 result["properties"]["evidenceGaps"] = serde_json::json!(gaps);
149 }
150
151 result
152 })
153 .collect();
154
155 let end_time = utc_now_rfc3339();
156 serde_json::json!({
157 "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
158 "version": "2.1.0",
159 "runs": [{
160 "tool": {
161 "driver": {
162 "name": tool_name,
163 "version": tool_version,
164 "rules": rules,
165 }
166 },
167 "invocations": [{
168 "endTimeUtc": end_time,
169 "executionSuccessful": true,
170 }],
171 "results": results,
172 }]
173 })
174}
175
176fn filter_sarif_runs(sarif: &mut serde_json::Value) {
177 if let Some(runs) = sarif["runs"].as_array_mut() {
178 for run in runs.iter_mut() {
179 if let Some(results) = run["results"].as_array() {
180 let filtered: Vec<serde_json::Value> = results
181 .iter()
182 .filter(|r| r["level"].as_str() == Some("error"))
183 .cloned()
184 .collect();
185 run["results"] = serde_json::Value::Array(filtered);
186 }
187 }
188 }
189}
190
191fn rule_descriptor(id: &ControlId) -> serde_json::Value {
192 let desc = builtin_rule_description(id.as_str());
193 serde_json::json!({
194 "id": id.as_str(),
195 "shortDescription": { "text": desc },
196 })
197}
198
199fn severity_to_level(severity: FindingSeverity) -> &'static str {
200 match severity {
201 FindingSeverity::Info => "note",
202 FindingSeverity::Warning => "warning",
203 FindingSeverity::Error => "error",
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use libverify_core::assessment::AssessmentReport;
211 use libverify_core::control::{ControlFinding, builtin};
212 use libverify_core::profile::{GateDecision, ProfileOutcome};
213
214 fn sample_report() -> AssessmentReport {
215 AssessmentReport {
216 profile_name: "slsa-source-l1-build-l1".to_string(),
217 findings: vec![
218 ControlFinding::satisfied(
219 builtin::id(builtin::REVIEW_INDEPENDENCE),
220 "Independent reviewer approved",
221 vec!["pr:owner/repo#1".to_string()],
222 ),
223 ControlFinding::violated(
224 builtin::id(builtin::SOURCE_AUTHENTICITY),
225 "1 unsigned commit",
226 vec!["pr:owner/repo#1".to_string()],
227 ),
228 ],
229 outcomes: vec![
230 ProfileOutcome {
231 control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
232 severity: FindingSeverity::Info,
233 decision: GateDecision::Pass,
234 rationale: "Independent reviewer approved".to_string(),
235 },
236 ProfileOutcome {
237 control_id: builtin::id(builtin::SOURCE_AUTHENTICITY),
238 severity: FindingSeverity::Error,
239 decision: GateDecision::Fail,
240 rationale: "1 unsigned commit".to_string(),
241 },
242 ],
243 severity_labels: Default::default(),
244 }
245 }
246
247 #[test]
248 fn sarif_version_is_2_1_0() {
249 let sarif = build_sarif(&sample_report(), "test-verify", "0.1.0");
250 assert_eq!(sarif["version"], "2.1.0");
251 }
252
253 #[test]
254 fn sarif_results_length_matches_outcomes() {
255 let sarif = build_sarif(&sample_report(), "test-verify", "0.1.0");
256 let results = sarif["runs"][0]["results"].as_array().unwrap();
257 assert_eq!(results.len(), 2);
258 }
259
260 #[test]
261 fn sarif_tool_name_is_configurable() {
262 let sarif = build_sarif(&sample_report(), "atlassian-verify", "1.0.0");
263 assert_eq!(
264 sarif["runs"][0]["tool"]["driver"]["name"],
265 "atlassian-verify"
266 );
267 }
268
269 #[test]
270 fn sarif_invocations_present_and_successful() {
271 let sarif = build_sarif(&sample_report(), "test-verify", "0.1.0");
272 let invocations = sarif["runs"][0]["invocations"].as_array().unwrap();
273 assert_eq!(invocations.len(), 1);
274 assert_eq!(invocations[0]["executionSuccessful"], true);
275 let ts = invocations[0]["endTimeUtc"].as_str().unwrap();
276 assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
278 assert_eq!(ts.len(), 20, "unexpected timestamp length: {ts}");
279 }
280
281 #[test]
282 fn utc_now_rfc3339_format() {
283 let ts = utc_now_rfc3339();
284 assert!(ts.ends_with('Z'));
285 assert_eq!(ts.len(), 20);
286 let year: u64 = ts[..4].parse().unwrap();
288 assert!(year >= 2026, "unexpected year: {year}");
289 }
290}