Skip to main content

libverify_output/
sarif.rs

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
7/// Format a `SystemTime` as an RFC 3339 / ISO 8601 UTC timestamp
8/// (e.g. `"2026-03-24T12:34:56Z"`). Uses only `std` — no external crates.
9fn 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    // Gregorian calendar reconstruction from epoch days
21    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
25/// Convert days since 1970-01-01 (UTC) to (year, month, day).
26fn days_to_ymd(days: u64) -> (u64, u64, u64) {
27    // Algorithm: Civil date from days — Hatcher/Richards (no external deps)
28    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        // Basic ISO 8601 UTC format check: YYYY-MM-DDTHH:MM:SSZ
277        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        // Year sanity: must be >= 2026
287        let year: u64 = ts[..4].parse().unwrap();
288        assert!(year >= 2026, "unexpected year: {year}");
289    }
290}