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
7pub 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 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
25pub fn 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 props = serde_json::json!({
117 "decision": outcome.decision.as_str(),
118 "controlStatus": finding.status.as_str(),
119 });
120 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 #[test]
269 fn days_to_ymd_known_dates() {
270 assert_eq!(days_to_ymd(0), (1970, 1, 1));
272 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 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 assert_eq!(days_to_ymd(11016), (2000, 2, 29));
284 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 assert_eq!(days_to_ymd(84065), (2200, 3, 1));
291 assert_eq!(days_to_ymd(120589), (2300, 3, 1));
292 assert_eq!(days_to_ymd(157113), (2400, 2, 29));
294 assert_eq!(days_to_ymd(157114), (2400, 3, 1));
295 }
296
297 #[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 #[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 #[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 #[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 #[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 assert_eq!(results[0]["level"], "note");
350 assert_eq!(results[0]["properties"]["decision"], "pass");
351
352 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 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 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 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 #[test]
480 fn filter_sarif_runs_keeps_only_errors() {
481 let mut sarif = build_sarif(&sample_report(), "t", "0");
482 assert_eq!(sarif["runs"][0]["results"].as_array().unwrap().len(), 2);
484 filter_sarif_runs(&mut sarif);
485 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 #[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 #[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}