use std::{
fmt,
fs::{File, OpenOptions},
io::{BufWriter, Write},
path::PathBuf,
sync::{Arc, Mutex},
time::Duration,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::{error, info};
use crate::{
error::CapturedError,
runner::{RunResult, RuntimeMetrics},
};
#[derive(Debug, Serialize)]
struct SarifReport {
version: String,
#[serde(rename = "$schema")]
schema: String,
runs: Vec<SarifRun>,
}
#[derive(Debug, Serialize)]
struct SarifRun {
tool: SarifTool,
results: Vec<SarifResult>,
}
#[derive(Debug, Serialize)]
struct SarifTool {
driver: SarifDriver,
}
#[derive(Debug, Serialize)]
struct SarifDriver {
name: String,
version: String,
rules: Vec<SarifRule>,
}
#[derive(Debug, Serialize)]
struct SarifRule {
id: String,
name: String,
#[serde(rename = "shortDescription")]
short_description: SarifText,
#[serde(rename = "fullDescription")]
full_description: SarifText,
#[serde(skip_serializing_if = "Option::is_none")]
help: Option<SarifText>,
}
#[derive(Debug, Serialize)]
struct SarifResult {
#[serde(rename = "ruleId")]
rule_id: String,
level: String,
message: SarifText,
locations: Vec<SarifLocation>,
}
#[derive(Debug, Serialize)]
struct SarifLocation {
#[serde(rename = "physicalLocation")]
physical_location: SarifPhysicalLocation,
}
#[derive(Debug, Serialize)]
struct SarifPhysicalLocation {
#[serde(rename = "artifactLocation")]
artifact_location: SarifArtifactLocation,
}
#[derive(Debug, Serialize)]
struct SarifArtifactLocation {
uri: String,
}
#[derive(Debug, Serialize)]
struct SarifText {
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "UPPERCASE")]
pub enum Severity {
Critical,
High,
Medium,
Low,
#[default]
Info,
}
impl PartialOrd for Severity {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Severity {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.rank().cmp(&other.rank())
}
}
impl Severity {
#[inline]
pub fn rank(&self) -> u8 {
match self {
Severity::Critical => 4,
Severity::High => 3,
Severity::Medium => 2,
Severity::Low => 1,
Severity::Info => 0,
}
}
pub fn label(&self) -> &'static str {
match self {
Severity::Critical => "CRITICAL",
Severity::High => "HIGH ",
Severity::Medium => "MEDIUM ",
Severity::Low => "LOW ",
Severity::Info => "INFO ",
}
}
}
impl fmt::Display for Severity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label().trim())
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Finding {
pub url: String,
pub check: String,
pub title: String,
pub severity: Severity,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub evidence: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub remediation: Option<String>,
pub scanner: String,
pub timestamp: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<serde_json::Value>,
}
impl Finding {
pub fn new(
url: impl Into<String>,
check: impl Into<String>,
title: impl Into<String>,
severity: Severity,
detail: impl Into<String>,
scanner: impl Into<String>,
) -> Self {
Self {
url: url.into(),
check: check.into(),
title: title.into(),
severity,
detail: detail.into(),
scanner: scanner.into(),
timestamp: Utc::now(),
..Default::default()
}
}
#[must_use]
pub fn with_evidence(mut self, evidence: impl Into<String>) -> Self {
self.evidence = Some(evidence.into());
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_remediation(mut self, rem: impl Into<String>) -> Self {
self.remediation = Some(rem.into());
self
}
#[must_use]
#[allow(dead_code)]
pub fn with_metadata(mut self, meta: serde_json::Value) -> Self {
self.metadata = Some(meta);
self
}
}
#[derive(Debug, Clone)]
pub struct ReportConfig {
pub format: ReportFormat,
pub output_path: Option<PathBuf>,
pub print_summary: bool,
pub quiet: bool,
pub stream: bool,
}
impl Default for ReportConfig {
fn default() -> Self {
Self {
format: ReportFormat::Pretty,
output_path: None,
print_summary: true,
quiet: false,
stream: false,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ReportFormat {
#[default]
Pretty,
Ndjson,
Sarif,
}
#[derive(Debug, Serialize)]
pub struct ReportDocument {
pub meta: ReportMeta,
pub summary: ReportSummary,
pub findings: Vec<Finding>,
pub errors: Vec<CapturedErrorRecord>,
}
#[derive(Debug, Serialize)]
pub struct ReportMeta {
pub generated_at: DateTime<Utc>,
pub elapsed_ms: u128,
pub scanned: usize,
pub skipped: usize,
pub scanner_ver: &'static str,
pub runtime_metrics: RuntimeMetrics,
}
#[derive(Debug, Serialize, Default)]
pub struct ReportSummary {
pub total: usize,
pub critical: usize,
pub high: usize,
pub medium: usize,
pub low: usize,
pub info: usize,
pub errors: usize,
}
#[derive(Debug, Serialize)]
pub struct CapturedErrorRecord {
pub url: Option<String>,
pub kind: String,
pub message: String,
}
impl From<&CapturedError> for CapturedErrorRecord {
fn from(e: &CapturedError) -> Self {
Self {
url: e.url.clone(),
kind: e.error_type.clone(),
message: e.message.clone(),
}
}
}
pub struct Reporter {
cfg: ReportConfig,
file_writer: Option<Arc<Mutex<BufWriter<File>>>>,
}
impl Reporter {
pub fn new(cfg: ReportConfig) -> std::io::Result<Self> {
let file_writer = if let Some(ref path) = cfg.output_path {
let file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)?;
Some(Arc::new(Mutex::new(BufWriter::new(file))))
} else {
None
};
Ok(Self { cfg, file_writer })
}
pub fn stream_enabled(&self) -> bool {
self.cfg.stream && self.cfg.format == ReportFormat::Ndjson
}
pub fn write_run_result(&self, result: &RunResult) {
let doc = build_document(result);
if self.cfg.print_summary {
print_summary_table(&doc.summary, result.elapsed);
}
match self.cfg.format {
ReportFormat::Pretty => self.write_pretty(&doc),
ReportFormat::Ndjson => {
if self.cfg.stream {
self.write_ndjson_stream_final(&doc);
} else {
self.write_ndjson(&doc);
}
}
ReportFormat::Sarif => self.write_sarif(&doc),
}
}
#[allow(dead_code)]
pub fn flush_finding(&self, finding: &Finding) {
if self.cfg.format != ReportFormat::Ndjson || !self.cfg.stream {
return;
}
match serde_json::to_string(finding) {
Ok(line) => {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
Err(e) => {
error!("Failed to serialise finding for streaming flush: {e}");
}
}
}
pub fn start_stream(&self, meta: &ReportMeta) {
if self.cfg.format != ReportFormat::Ndjson || !self.cfg.stream {
return;
}
let header = serde_json::json!({
"type": "meta",
"meta": meta,
"stream": true,
});
if let Ok(line) = serde_json::to_string(&header) {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
}
fn write_pretty(&self, doc: &ReportDocument) {
match serde_json::to_string_pretty(doc) {
Ok(json) => {
self.write_line_to_file(&json);
if !self.cfg.quiet {
println!("{json}");
}
}
Err(e) => error!("Failed to serialise report: {e}"),
}
}
fn write_sarif(&self, doc: &ReportDocument) {
let mut rules_map: std::collections::BTreeMap<String, SarifRule> =
std::collections::BTreeMap::new();
let mut results = Vec::new();
for f in &doc.findings {
rules_map
.entry(f.check.clone())
.or_insert_with(|| SarifRule {
id: f.check.clone(),
name: f.title.clone(),
short_description: SarifText {
text: f.title.clone(),
},
full_description: SarifText {
text: f.detail.clone(),
},
help: f
.remediation
.as_ref()
.map(|r| SarifText { text: r.clone() }),
});
let level = match f.severity {
Severity::Critical | Severity::High => "error",
Severity::Medium => "warning",
Severity::Low | Severity::Info => "note",
};
let message = if let Some(evidence) = &f.evidence {
format!("{} — {}", f.detail, evidence)
} else {
f.detail.clone()
};
results.push(SarifResult {
rule_id: f.check.clone(),
level: level.to_string(),
message: SarifText { text: message },
locations: vec![SarifLocation {
physical_location: SarifPhysicalLocation {
artifact_location: SarifArtifactLocation { uri: f.url.clone() },
},
}],
});
}
let report = SarifReport {
version: "2.1.0".to_string(),
schema: "https://json.schemastore.org/sarif-2.1.0.json".to_string(),
runs: vec![SarifRun {
tool: SarifTool {
driver: SarifDriver {
name: env!("CARGO_PKG_NAME").to_string(),
version: doc.meta.scanner_ver.to_string(),
rules: rules_map.into_values().collect(),
},
},
results,
}],
};
match serde_json::to_string_pretty(&report) {
Ok(json) => {
self.write_line_to_file(&json);
if !self.cfg.quiet {
println!("{json}");
}
}
Err(e) => error!("Failed to serialise SARIF report: {e}"),
}
}
fn write_ndjson(&self, doc: &ReportDocument) {
let header = serde_json::json!({
"type": "meta",
"meta": &doc.meta,
"summary": &doc.summary,
});
if let Ok(line) = serde_json::to_string(&header) {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
for finding in &doc.findings {
match serde_json::to_string(finding) {
Ok(line) => {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
Err(e) => error!("Failed to serialise finding: {e}"),
}
}
for err in &doc.errors {
match serde_json::to_string(err) {
Ok(line) => {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
Err(e) => error!("Failed to serialise error record: {e}"),
}
}
}
fn write_ndjson_stream_final(&self, doc: &ReportDocument) {
let summary = serde_json::json!({
"type": "summary",
"summary": &doc.summary,
"meta": &doc.meta,
});
if let Ok(line) = serde_json::to_string(&summary) {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
for err in &doc.errors {
match serde_json::to_string(err) {
Ok(line) => {
self.write_line_to_file(&line);
if !self.cfg.quiet {
println!("{line}");
}
}
Err(e) => error!("Failed to serialise error record: {e}"),
}
}
}
fn write_line_to_file(&self, content: &str) {
let Some(ref writer) = self.file_writer else {
return;
};
match writer.lock() {
Ok(mut w) => {
if let Err(e) = writeln!(w, "{content}") {
error!("Failed to write to report file: {e}");
}
}
Err(e) => error!("Report file writer lock poisoned: {e}"),
}
}
pub fn finalize(&self) {
let Some(ref writer) = self.file_writer else {
return;
};
match writer.lock() {
Ok(mut w) => {
if let Err(e) = w.flush() {
error!("Failed to flush report file: {e}");
} else if let Some(ref path) = self.cfg.output_path {
info!(path = %path.display(), "Report written");
}
}
Err(e) => error!("Report file writer lock poisoned on finalize: {e}"),
}
}
}
pub fn build_document(result: &RunResult) -> ReportDocument {
let summary = build_summary(result);
let errors: Vec<CapturedErrorRecord> = result
.errors
.iter()
.map(CapturedErrorRecord::from)
.collect();
ReportDocument {
meta: ReportMeta {
generated_at: Utc::now(),
elapsed_ms: result.elapsed.as_millis(),
scanned: result.scanned,
skipped: result.skipped,
scanner_ver: env!("CARGO_PKG_VERSION"),
runtime_metrics: result.metrics.clone(),
},
summary,
findings: result.findings.clone(),
errors,
}
}
pub fn build_summary(result: &RunResult) -> ReportSummary {
let mut s = ReportSummary {
total: result.findings.len(),
errors: result.errors.len(),
..Default::default()
};
for f in &result.findings {
match f.severity {
Severity::Critical => s.critical += 1,
Severity::High => s.high += 1,
Severity::Medium => s.medium += 1,
Severity::Low => s.low += 1,
Severity::Info => s.info += 1,
}
}
s
}
fn print_summary_table(summary: &ReportSummary, elapsed: Duration) {
println!();
println!("╔═══════════════════════════════╗");
println!("║ SCAN SUMMARY ║");
println!("╠═══════════════════════════════╣");
println!("║ Findings {:>5} ║", summary.total);
println!("║ ├─ Critical {:>5} ║", summary.critical);
println!("║ ├─ High {:>5} ║", summary.high);
println!("║ ├─ Medium {:>5} ║", summary.medium);
println!("║ ├─ Low {:>5} ║", summary.low);
println!("║ └─ Info {:>5} ║", summary.info);
println!("╠═══════════════════════════════╣");
println!("║ Errors {:>5} ║", summary.errors);
println!("╠═══════════════════════════════╣");
println!("║ Elapsed {:>8}ms ║", elapsed.as_millis());
println!("╚═══════════════════════════════╝");
println!();
}
pub fn exit_code(summary: &ReportSummary, threshold: &Severity) -> i32 {
let mut code = 0i32;
let has_findings = match *threshold {
Severity::Critical => summary.critical > 0,
Severity::High => summary.critical + summary.high > 0,
Severity::Medium => summary.critical + summary.high + summary.medium > 0,
Severity::Low => summary.critical + summary.high + summary.medium + summary.low > 0,
Severity::Info => summary.total > 0,
};
if has_findings {
code |= 1;
}
if summary.errors > 0 {
code |= 2;
}
code
}
pub fn filter_findings<'a>(findings: &'a [Finding], min_severity: &Severity) -> Vec<&'a Finding> {
findings
.iter()
.filter(|f| f.severity.rank() >= min_severity.rank())
.collect()
}
pub fn load_baseline_keys(
path: &std::path::Path,
) -> Result<std::collections::HashSet<(String, String)>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read baseline file: {}", path.display()))?;
let mut keys = std::collections::HashSet::new();
for (idx, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: serde_json::Value = serde_json::from_str(trimmed)
.with_context(|| format!("Invalid JSON on baseline line {}", idx + 1))?;
let url = value.get("url").and_then(|v| v.as_str());
let check = value.get("check").and_then(|v| v.as_str());
if let (Some(u), Some(c)) = (url, check) {
keys.insert((u.to_string(), c.to_string()));
}
}
Ok(keys)
}
pub fn filter_new_findings(
findings: Vec<Finding>,
baseline: &std::collections::HashSet<(String, String)>,
) -> Vec<Finding> {
findings
.into_iter()
.filter(|f| !baseline.contains(&(f.url.clone(), f.check.clone())))
.collect()
}
pub fn dedup_findings(mut findings: Vec<Finding>) -> Vec<Finding> {
findings.sort_by(|a, b| b.severity.rank().cmp(&a.severity.rank()));
let mut seen = std::collections::HashSet::new();
findings.retain(|f| {
seen.insert((
f.url.clone(),
f.check.clone(),
f.evidence.clone().unwrap_or_default(),
))
});
findings
}