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.
9pub fn 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).
26pub fn 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 props = serde_json::json!({
117                "decision": outcome.decision.as_str(),
118                "controlStatus": finding.status.as_str(),
119            });
120            // Merge policy annotations into SARIF properties
121            for (k, v) in &outcome.annotations {
122                props[k] = serde_json::Value::String(v.clone());
123            }
124
125            let mut result = serde_json::json!({
126                "ruleId": outcome.control_id.as_str(),
127                "level": severity_to_level(outcome.severity),
128                "message": { "text": outcome.rationale },
129                "properties": props,
130            });
131
132            if !finding.subjects.is_empty() {
133                let locations: Vec<serde_json::Value> = finding
134                    .subjects
135                    .iter()
136                    .map(|s| {
137                        serde_json::json!({
138                            "logicalLocations": [{
139                                "fullyQualifiedName": s,
140                                "kind": "resource",
141                            }]
142                        })
143                    })
144                    .collect();
145                result["locations"] = serde_json::Value::Array(locations);
146            }
147
148            if !finding.evidence_gaps.is_empty() {
149                let gaps: Vec<String> = finding
150                    .evidence_gaps
151                    .iter()
152                    .map(|g| format!("{g}"))
153                    .collect();
154                result["properties"]["evidenceGaps"] = serde_json::json!(gaps);
155            }
156
157            result
158        })
159        .collect();
160
161    let end_time = utc_now_rfc3339();
162    serde_json::json!({
163        "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json",
164        "version": "2.1.0",
165        "runs": [{
166            "tool": {
167                "driver": {
168                    "name": tool_name,
169                    "version": tool_version,
170                    "rules": rules,
171                }
172            },
173            "invocations": [{
174                "endTimeUtc": end_time,
175                "executionSuccessful": true,
176            }],
177            "results": results,
178        }]
179    })
180}
181
182fn filter_sarif_runs(sarif: &mut serde_json::Value) {
183    if let Some(runs) = sarif["runs"].as_array_mut() {
184        for run in runs.iter_mut() {
185            if let Some(results) = run["results"].as_array() {
186                let filtered: Vec<serde_json::Value> = results
187                    .iter()
188                    .filter(|r| r["level"].as_str() == Some("error"))
189                    .cloned()
190                    .collect();
191                run["results"] = serde_json::Value::Array(filtered);
192            }
193        }
194    }
195}
196
197fn rule_descriptor(id: &ControlId) -> serde_json::Value {
198    let desc = builtin_rule_description(id.as_str());
199    serde_json::json!({
200        "id": id.as_str(),
201        "shortDescription": { "text": desc },
202    })
203}
204
205fn severity_to_level(severity: FindingSeverity) -> &'static str {
206    match severity {
207        FindingSeverity::Info => "note",
208        FindingSeverity::Warning => "warning",
209        FindingSeverity::Error => "error",
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use libverify_core::assessment::{
217        AssessmentReport, BatchEntry, BatchReport, VerificationResult,
218    };
219    use libverify_core::control::{ControlFinding, builtin};
220    use libverify_core::evidence::EvidenceGap;
221    use libverify_core::profile::{GateDecision, ProfileOutcome};
222    use std::collections::BTreeMap;
223
224    fn sample_report() -> AssessmentReport {
225        AssessmentReport {
226            profile_name: "test-profile".to_string(),
227            findings: vec![
228                ControlFinding::satisfied(
229                    builtin::id(builtin::REVIEW_INDEPENDENCE),
230                    "Independent reviewer approved",
231                    vec!["pr:owner/repo#1".to_string()],
232                ),
233                ControlFinding::violated(
234                    builtin::id(builtin::SOURCE_AUTHENTICITY),
235                    "1 unsigned commit",
236                    vec!["pr:owner/repo#1".to_string()],
237                ),
238            ],
239            outcomes: vec![
240                ProfileOutcome {
241                    control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
242                    severity: FindingSeverity::Info,
243                    decision: GateDecision::Pass,
244                    rationale: "Independent reviewer approved".to_string(),
245                    annotations: Default::default(),
246                },
247                ProfileOutcome {
248                    control_id: builtin::id(builtin::SOURCE_AUTHENTICITY),
249                    severity: FindingSeverity::Error,
250                    decision: GateDecision::Fail,
251                    rationale: "1 unsigned commit".to_string(),
252                    annotations: Default::default(),
253                },
254            ],
255            severity_labels: Default::default(),
256        }
257    }
258
259    fn sample_verification_result() -> VerificationResult {
260        VerificationResult {
261            report: sample_report(),
262            evidence: None,
263        }
264    }
265
266    // ── days_to_ymd: known-date regression ──────────────────────────
267
268    #[test]
269    fn days_to_ymd_known_dates() {
270        // Unix epoch
271        assert_eq!(days_to_ymd(0), (1970, 1, 1));
272        // Standard dates
273        assert_eq!(days_to_ymd(59), (1970, 3, 1));
274        assert_eq!(days_to_ymd(10957), (2000, 1, 1));
275        assert_eq!(days_to_ymd(20453), (2025, 12, 31));
276        assert_eq!(days_to_ymd(20454), (2026, 1, 1));
277        assert_eq!(days_to_ymd(20536), (2026, 3, 24));
278        // Leap years (4-year rule)
279        assert_eq!(days_to_ymd(789), (1972, 2, 29));
280        assert_eq!(days_to_ymd(19782), (2024, 2, 29));
281        assert_eq!(days_to_ymd(19783), (2024, 3, 1));
282        // 400-year leap: 2000 IS a leap year
283        assert_eq!(days_to_ymd(11016), (2000, 2, 29));
284        // Century boundaries: 2100 is NOT a leap year (100-year correction)
285        assert_eq!(days_to_ymd(46080), (2096, 2, 29));
286        assert_eq!(days_to_ymd(47540), (2100, 2, 28));
287        assert_eq!(days_to_ymd(47541), (2100, 3, 1));
288        assert_eq!(days_to_ymd(49001), (2104, 2, 29));
289        // Additional century boundaries
290        assert_eq!(days_to_ymd(84065), (2200, 3, 1));
291        assert_eq!(days_to_ymd(120589), (2300, 3, 1));
292        // 400-year leap: 2400 IS a leap year (400-year correction)
293        assert_eq!(days_to_ymd(157113), (2400, 2, 29));
294        assert_eq!(days_to_ymd(157114), (2400, 3, 1));
295    }
296
297    // ── utc_now_rfc3339 ─────────────────────────────────────────────
298
299    #[test]
300    fn utc_now_rfc3339_format() {
301        let ts = utc_now_rfc3339();
302        assert!(ts.ends_with('Z'));
303        assert_eq!(ts.len(), 20);
304        let year: u64 = ts[..4].parse().unwrap();
305        assert!(year >= 2026, "unexpected year: {year}");
306    }
307
308    // ── builtin_rule_description ────────────────────────────────────
309
310    #[test]
311    fn builtin_rule_description_returns_known_description() {
312        let desc = builtin_rule_description(builtin::REVIEW_INDEPENDENCE);
313        assert!(!desc.is_empty());
314        assert_ne!(desc, "xyzzy");
315        assert_ne!(desc, "Custom control");
316    }
317
318    // ── severity_to_level ───────────────────────────────────────────
319
320    #[test]
321    fn severity_to_level_maps_all_variants() {
322        assert_eq!(severity_to_level(FindingSeverity::Info), "note");
323        assert_eq!(severity_to_level(FindingSeverity::Warning), "warning");
324        assert_eq!(severity_to_level(FindingSeverity::Error), "error");
325    }
326
327    // ── rule_descriptor ─────────────────────────────────────────────
328
329    #[test]
330    fn rule_descriptor_contains_id_and_description() {
331        let id = builtin::id(builtin::REVIEW_INDEPENDENCE);
332        let desc = rule_descriptor(&id);
333        assert_eq!(desc["id"].as_str().unwrap(), builtin::REVIEW_INDEPENDENCE);
334        assert!(desc["shortDescription"]["text"].as_str().unwrap().len() > 0);
335    }
336
337    // ── build_sarif ─────────────────────────────────────────────────
338
339    #[test]
340    fn build_sarif_structure() {
341        let sarif = build_sarif(&sample_report(), "test-verify", "0.1.0");
342        assert_eq!(sarif["version"], "2.1.0");
343        assert_eq!(sarif["runs"][0]["tool"]["driver"]["name"], "test-verify");
344        assert_eq!(sarif["runs"][0]["tool"]["driver"]["version"], "0.1.0");
345        let results = sarif["runs"][0]["results"].as_array().unwrap();
346        assert_eq!(results.len(), 2);
347
348        // First result: pass/note
349        assert_eq!(results[0]["level"], "note");
350        assert_eq!(results[0]["properties"]["decision"], "pass");
351
352        // Second result: fail/error
353        assert_eq!(results[1]["level"], "error");
354        assert_eq!(results[1]["properties"]["decision"], "fail");
355    }
356
357    #[test]
358    fn build_sarif_includes_subjects_as_locations() {
359        let sarif = build_sarif(&sample_report(), "t", "0");
360        let results = sarif["runs"][0]["results"].as_array().unwrap();
361        // Both findings have subjects, so both should have locations
362        let locs = results[0]["locations"].as_array().unwrap();
363        assert_eq!(locs.len(), 1);
364        assert_eq!(
365            locs[0]["logicalLocations"][0]["fullyQualifiedName"],
366            "pr:owner/repo#1"
367        );
368    }
369
370    #[test]
371    fn build_sarif_omits_locations_when_no_subjects() {
372        let report = AssessmentReport {
373            profile_name: "test".to_string(),
374            findings: vec![ControlFinding::not_applicable(
375                builtin::id(builtin::REVIEW_INDEPENDENCE),
376                "N/A",
377            )],
378            outcomes: vec![ProfileOutcome {
379                control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
380                severity: FindingSeverity::Info,
381                decision: GateDecision::Pass,
382                rationale: "N/A".to_string(),
383                annotations: Default::default(),
384            }],
385            severity_labels: Default::default(),
386        };
387        let sarif = build_sarif(&report, "t", "0");
388        let result = &sarif["runs"][0]["results"][0];
389        assert!(result["locations"].is_null());
390    }
391
392    #[test]
393    fn build_sarif_includes_evidence_gaps() {
394        let finding = ControlFinding::indeterminate(
395            builtin::id(builtin::SOURCE_AUTHENTICITY),
396            "missing data",
397            vec!["pr:owner/repo#1".to_string()],
398            vec![EvidenceGap::CollectionFailed {
399                source: "api".to_string(),
400                subject: "pr:owner/repo#1".to_string(),
401                detail: "timeout".to_string(),
402            }],
403        );
404        // Ensure evidence_gaps is populated
405        assert!(!finding.evidence_gaps.is_empty());
406
407        let report = AssessmentReport {
408            profile_name: "test".to_string(),
409            findings: vec![finding],
410            outcomes: vec![ProfileOutcome {
411                control_id: builtin::id(builtin::SOURCE_AUTHENTICITY),
412                severity: FindingSeverity::Warning,
413                decision: GateDecision::Review,
414                rationale: "missing data".to_string(),
415                annotations: Default::default(),
416            }],
417            severity_labels: Default::default(),
418        };
419        let sarif = build_sarif(&report, "t", "0");
420        let result = &sarif["runs"][0]["results"][0];
421        let gaps = result["properties"]["evidenceGaps"].as_array().unwrap();
422        assert!(!gaps.is_empty());
423    }
424
425    #[test]
426    fn build_sarif_invocations_timestamp() {
427        let sarif = build_sarif(&sample_report(), "t", "0");
428        let ts = sarif["runs"][0]["invocations"][0]["endTimeUtc"]
429            .as_str()
430            .unwrap();
431        assert!(ts.ends_with('Z'));
432        assert_eq!(ts.len(), 20);
433    }
434
435    #[test]
436    fn build_sarif_dedups_rules() {
437        // Two findings for same control → one rule entry
438        let report = AssessmentReport {
439            profile_name: "test".to_string(),
440            findings: vec![
441                ControlFinding::satisfied(
442                    builtin::id(builtin::REVIEW_INDEPENDENCE),
443                    "pass1",
444                    vec![],
445                ),
446                ControlFinding::violated(
447                    builtin::id(builtin::REVIEW_INDEPENDENCE),
448                    "fail1",
449                    vec![],
450                ),
451            ],
452            outcomes: vec![
453                ProfileOutcome {
454                    control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
455                    severity: FindingSeverity::Info,
456                    decision: GateDecision::Pass,
457                    rationale: "pass1".to_string(),
458                    annotations: Default::default(),
459                },
460                ProfileOutcome {
461                    control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
462                    severity: FindingSeverity::Error,
463                    decision: GateDecision::Fail,
464                    rationale: "fail1".to_string(),
465                    annotations: Default::default(),
466                },
467            ],
468            severity_labels: Default::default(),
469        };
470        let sarif = build_sarif(&report, "t", "0");
471        let rules = sarif["runs"][0]["tool"]["driver"]["rules"]
472            .as_array()
473            .unwrap();
474        assert_eq!(rules.len(), 1);
475    }
476
477    // ── filter_sarif_runs ───────────────────────────────────────────
478
479    #[test]
480    fn filter_sarif_runs_keeps_only_errors() {
481        let mut sarif = build_sarif(&sample_report(), "t", "0");
482        // Before filter: 2 results (note + error)
483        assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 2);
484        filter_sarif_runs(&mut sarif);
485        // After filter: only error level kept
486        let results = sarif["runs"][0]["results"].as_array().unwrap();
487        assert_eq!(results.len(), 1);
488        assert_eq!(results[0]["level"], "error");
489    }
490
491    // ── render / render_batch ───────────────────────────────────────
492
493    #[test]
494    fn render_produces_valid_sarif_json() {
495        let result = sample_verification_result();
496        let output = render(&result, false, "test", "0.1").unwrap();
497        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
498        assert_eq!(parsed["version"], "2.1.0");
499        assert_eq!(parsed["runs"][0]["results"].as_array().unwrap().len(), 2);
500    }
501
502    #[test]
503    fn render_with_only_failures_filters() {
504        let result = sample_verification_result();
505        let output = render(&result, true, "test", "0.1").unwrap();
506        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
507        let results = parsed["runs"][0]["results"].as_array().unwrap();
508        assert_eq!(results.len(), 1);
509        assert_eq!(results[0]["level"], "error");
510    }
511
512    #[test]
513    fn render_batch_produces_valid_sarif_json() {
514        let batch = BatchReport {
515            reports: vec![BatchEntry {
516                subject_id: "owner/repo".to_string(),
517                result: sample_verification_result(),
518            }],
519            total_pass: 1,
520            total_review: 0,
521            total_fail: 1,
522            skipped: vec![],
523        };
524        let output = render_batch(&batch, false, "test", "0.1").unwrap();
525        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
526        assert_eq!(parsed["version"], "2.1.0");
527        let runs = parsed["runs"].as_array().unwrap();
528        assert_eq!(runs.len(), 1);
529        assert_eq!(runs[0]["properties"]["subjectId"], "owner/repo");
530    }
531
532    #[test]
533    fn render_batch_with_only_failures_filters() {
534        let batch = BatchReport {
535            reports: vec![BatchEntry {
536                subject_id: "owner/repo".to_string(),
537                result: sample_verification_result(),
538            }],
539            total_pass: 1,
540            total_review: 0,
541            total_fail: 1,
542            skipped: vec![],
543        };
544        let output = render_batch(&batch, true, "test", "0.1").unwrap();
545        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
546        let results = parsed["runs"][0]["results"].as_array().unwrap();
547        assert_eq!(results.len(), 1);
548        assert_eq!(results[0]["level"], "error");
549    }
550
551    // ── annotations in SARIF properties ─────────────────────────────
552
553    #[test]
554    fn build_sarif_merges_annotations_into_properties() {
555        let mut annotations = BTreeMap::new();
556        annotations.insert("framework_ref".to_string(), "SOC2-CC6.1".to_string());
557        let report = AssessmentReport {
558            profile_name: "test".to_string(),
559            findings: vec![ControlFinding::violated(
560                builtin::id(builtin::REVIEW_INDEPENDENCE),
561                "failed",
562                vec![],
563            )],
564            outcomes: vec![ProfileOutcome {
565                control_id: builtin::id(builtin::REVIEW_INDEPENDENCE),
566                severity: FindingSeverity::Error,
567                decision: GateDecision::Fail,
568                rationale: "failed".to_string(),
569                annotations,
570            }],
571            severity_labels: Default::default(),
572        };
573        let sarif = build_sarif(&report, "t", "0");
574        let props = &sarif["runs"][0]["results"][0]["properties"];
575        assert_eq!(props["framework_ref"], "SOC2-CC6.1");
576    }
577}