pub(crate) mod csv;
pub(crate) mod escape;
pub(crate) mod github_annotations;
pub(crate) mod gitlab_sast;
pub(crate) mod html;
pub(crate) mod json;
pub(crate) mod junit;
pub(crate) mod sarif;
mod style;
pub(crate) mod text;
#[path = "report/sarif_uri.rs"]
pub(crate) mod sarif_uri;
use std::collections::BTreeMap;
use std::io::Write;
use crate::VerifiedFinding;
pub(crate) fn companions_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
let companions: BTreeMap<&str, &str> = finding
.companions_redacted
.iter()
.map(|(key, value)| (key.as_str(), value.as_str()))
.collect();
Ok(serde_json::to_string(&companions)?)
}
pub(crate) fn remediation_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
let remediation =
crate::auto_fix::remediation_for(&finding.detector_id, &finding.service, finding.severity);
Ok(serde_json::to_string(&remediation)?)
}
pub use anyhow::Error as ReportError;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScanCompletionStatus {
Success,
CompleteAfterRecovery,
Partial,
Cancelled,
Failed,
}
impl Default for ScanCompletionStatus {
fn default() -> Self {
Self::Success
}
}
impl ScanCompletionStatus {
#[must_use]
pub fn from_coverage_gaps(has_gaps: bool) -> Self {
if has_gaps {
Self::Partial
} else {
Self::Success
}
}
#[must_use]
pub fn resolve(metadata: Option<Self>, has_gaps: bool) -> Self {
match metadata {
Some(Self::Cancelled) => Self::Cancelled,
Some(Self::Failed) => Self::Failed,
Some(Self::Partial) => Self::Partial,
Some(Self::CompleteAfterRecovery) if has_gaps => Self::Partial,
Some(Self::CompleteAfterRecovery) => Self::CompleteAfterRecovery,
Some(Self::Success) if has_gaps => Self::Partial,
Some(status) => status,
None => Self::from_coverage_gaps(has_gaps),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ResolvedScanManifest {
pub schema_version: u16,
pub preset: String,
pub effective: BTreeMap<String, String>,
pub overrides: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScanBackendRecoverySummary {
pub events: usize,
pub failed_backend: String,
pub recovery_backend: String,
pub recovered_ranges: usize,
pub recovered_chunks: usize,
pub recovered_bytes: u64,
pub reason: String,
pub repair_command: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ScanReportMetadata {
#[serde(default)]
pub scan_id: String,
#[serde(default)]
pub scan_status: ScanCompletionStatus,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
pub keyhog_version: String,
pub git_hash: String,
pub detector_digest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub config_digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resolved_scan: Option<ResolvedScanManifest>,
pub generated_at: String,
pub scan_started_at: String,
pub scan_finished_at: String,
pub duration_ms: u128,
pub targets: Vec<String>,
pub source_chunks_scanned: usize,
pub source_bytes_scanned: u64,
pub detector_count: usize,
}
pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
pub const JSON_REPORT_SCHEMA_MINOR: u16 = 7;
pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct JsonReportSchemaVersion {
pub major: u16,
pub minor: u16,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JsonReportEnvelope {
pub schema_version: JsonReportSchemaVersion,
#[serde(default)]
pub scan_status: ScanCompletionStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ScanReportMetadata>,
#[serde(default)]
pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
pub findings: Vec<VerifiedFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct JsonReportCoverageGap {
pub reason: String,
pub count: usize,
}
impl JsonReportEnvelope {
pub fn parse(input: &str) -> Result<Self, ReportError> {
let report: Self = serde_json::from_str(input)?;
if report.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
anyhow::bail!(
"unsupported JSON report schema major {}; this reader supports major {}",
report.schema_version.major,
JSON_REPORT_SCHEMA_MAJOR
);
}
Ok(report)
}
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct JsonlStreamHeader {
pub record_type: String,
pub schema_version: JsonReportSchemaVersion,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ScanReportMetadata>,
}
impl JsonlStreamHeader {
#[must_use]
pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
Self {
record_type: "header".to_string(),
schema_version: JsonReportSchemaVersion {
major: JSON_REPORT_SCHEMA_MAJOR,
minor: JSONL_REPORT_SCHEMA_MINOR,
},
metadata: metadata.cloned(),
}
}
pub fn parse(input: &str) -> Result<Self, ReportError> {
let header: Self = serde_json::from_str(input)?;
if header.record_type != "header" {
anyhow::bail!(
"invalid JSONL stream header record_type {:?}",
header.record_type
);
}
if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
anyhow::bail!(
"unsupported JSONL report schema major {}; this reader supports major {}",
header.schema_version.major,
JSON_REPORT_SCHEMA_MAJOR
);
}
Ok(header)
}
}
#[derive(Debug, Clone)]
pub struct JsonlStream {
pub header: JsonlStreamHeader,
pub summary: Option<JsonlStreamSummary>,
pub findings: Vec<VerifiedFinding>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct JsonlStreamSummary {
pub record_type: String,
pub status: String,
#[serde(default)]
pub scan_status: ScanCompletionStatus,
pub finding_count: usize,
#[serde(default)]
pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
}
impl JsonlStreamSummary {
#[must_use]
pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
Self::complete_with_status(
finding_count,
ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
coverage_gap_summary,
)
}
#[must_use]
pub fn complete_with_status(
finding_count: usize,
scan_status: ScanCompletionStatus,
coverage_gap_summary: &[(String, usize)],
) -> Self {
Self {
record_type: "summary".to_string(),
status: "complete".to_string(),
scan_status,
finding_count,
coverage_gap_summary: coverage_gap_summary
.iter()
.map(|(reason, count)| JsonReportCoverageGap {
reason: reason.clone(),
count: *count,
})
.collect(),
}
}
fn parse(input: &str) -> Result<Self, ReportError> {
let summary: Self = serde_json::from_str(input)?;
if summary.record_type != "summary" || summary.status != "complete" {
anyhow::bail!(
"invalid JSONL stream summary: record_type={:?}, status={:?}",
summary.record_type,
summary.status
);
}
Ok(summary)
}
}
impl JsonlStream {
#[must_use]
pub fn is_complete(&self) -> bool {
self.summary.is_some()
}
}
pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
let mut streams = Vec::new();
let mut current: Option<JsonlStream> = None;
for (index, line) in input.lines().enumerate() {
let line_number = index + 1;
if line.trim().is_empty() {
anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
}
let value: serde_json::Value = serde_json::from_str(line)
.map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
let is_header =
value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
if is_header {
if let Some(stream) = current.take() {
streams.push(stream);
}
current = Some(JsonlStream {
header: JsonlStreamHeader::parse(line)?,
summary: None,
findings: Vec::new(),
});
continue;
}
let stream = current.as_mut().ok_or_else(|| {
anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
})?;
let is_summary =
value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
if is_summary {
if stream.summary.is_some() {
anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
}
let summary = JsonlStreamSummary::parse(line)?;
if summary.finding_count != stream.findings.len() {
anyhow::bail!(
"JSONL summary count {} does not match {} finding records",
summary.finding_count,
stream.findings.len()
);
}
stream.summary = Some(summary);
continue;
}
if stream.summary.is_some() {
anyhow::bail!("JSONL line {line_number} follows the terminal summary");
}
let finding = serde_json::from_value(value).map_err(|error| {
anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
})?;
stream.findings.push(finding);
}
if let Some(stream) = current {
streams.push(stream);
}
if streams.is_empty() {
anyhow::bail!("JSONL stream is empty; expected a versioned header record");
}
Ok(streams)
}
pub type HtmlScanMetadata = ScanReportMetadata;
#[derive(Debug, Clone, Copy)]
pub struct ScanReport<'a> {
pub findings: &'a [VerifiedFinding],
pub metadata: Option<&'a ScanReportMetadata>,
}
impl<'a> ScanReport<'a> {
pub fn new(findings: &'a [VerifiedFinding]) -> Self {
Self {
findings,
metadata: None,
}
}
#[must_use]
pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
self.metadata = Some(metadata);
self
}
}
pub enum ReportFormat {
Text {
color: bool,
example_suppressions: usize,
dogfood_active: bool,
},
Json,
JsonEnvelope {
coverage_gap_summary: Vec<(String, usize)>,
},
Jsonl,
JsonlEnvelope {
coverage_gap_summary: Vec<(String, usize)>,
},
Sarif {
skip_summary: Vec<(String, usize)>,
},
Csv,
GithubAnnotations,
GithubAnnotationsCoverage {
skip_summary: Vec<(String, usize)>,
},
GitlabSast {
scan_started_at: String,
scan_finished_at: String,
},
GitlabSastCoverage {
scan_started_at: String,
scan_finished_at: String,
skip_summary: Vec<(String, usize)>,
},
Html {
skip_summary: Vec<(String, usize)>,
metadata: Option<HtmlScanMetadata>,
},
Junit,
JunitCoverage {
skip_summary: Vec<(String, usize)>,
},
}
pub fn write_report<W: Write + Send>(
writer: W,
format: ReportFormat,
findings: &[VerifiedFinding],
) -> Result<(), ReportError> {
write_scan_report(writer, format, ScanReport::new(findings))
}
pub fn write_scan_report<W: Write + Send>(
writer: W,
format: ReportFormat,
report: ScanReport<'_>,
) -> Result<(), ReportError> {
let findings = report.findings;
let report_metadata = report.metadata;
match format {
ReportFormat::Text {
color,
example_suppressions,
dogfood_active,
} => {
let mut reporter = text::TextReporter::with_color(writer, color);
reporter.set_example_suppressions(example_suppressions);
reporter.set_dogfood_active(dogfood_active);
finish_reporter(reporter, findings)
}
ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
ReportFormat::JsonEnvelope {
coverage_gap_summary,
} => finish_reporter(
json::JsonEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
findings,
),
ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
ReportFormat::JsonlEnvelope {
coverage_gap_summary,
} => finish_reporter(
json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
findings,
),
ReportFormat::Sarif { skip_summary } => finish_reporter(
sarif::SarifReporter::new(writer)
.with_skip_summary(skip_summary.clone())
.with_scan_status(resolve_report_status(report_metadata, &skip_summary))
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
ReportFormat::GithubAnnotations => finish_reporter(
github_annotations::GithubAnnotationsReporter::new(writer)
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
github_annotations::GithubAnnotationsReporter::new(writer)
.with_skip_summary(skip_summary.clone())
.with_scan_status(resolve_report_status(report_metadata, &skip_summary))
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::GitlabSast {
scan_started_at,
scan_finished_at,
} => finish_reporter(
gitlab_sast::GitlabSastReporter::new(
writer,
report_time(
report_metadata,
scan_started_at,
|metadata| &metadata.scan_started_at,
"scan_started_at",
)?,
report_time(
report_metadata,
scan_finished_at,
|metadata| &metadata.scan_finished_at,
"scan_finished_at",
)?,
)
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::GitlabSastCoverage {
scan_started_at,
scan_finished_at,
skip_summary,
} => finish_reporter(
gitlab_sast::GitlabSastReporter::new(
writer,
report_time(
report_metadata,
scan_started_at,
|metadata| &metadata.scan_started_at,
"scan_started_at",
)?,
report_time(
report_metadata,
scan_finished_at,
|metadata| &metadata.scan_finished_at,
"scan_finished_at",
)?,
)
.with_skip_summary(skip_summary.clone())
.with_scan_status(resolve_report_status(report_metadata, &skip_summary))
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::Html {
skip_summary,
metadata,
} => finish_reporter(
html::HtmlReporter::new(writer)
.with_skip_summary(skip_summary)
.with_metadata(merge_html_metadata(metadata, report_metadata)?),
findings,
),
ReportFormat::Junit => finish_reporter(
junit::JunitReporter::new(writer)
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
junit::JunitReporter::new(writer)
.with_skip_summary(skip_summary.clone())
.with_scan_status(resolve_report_status(report_metadata, &skip_summary))
.with_backend_recoveries(report_recoveries(report_metadata)),
findings,
),
}
}
fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
metadata
.map(|value| value.backend_recoveries.clone())
.unwrap_or_default() }
fn resolve_report_status(
metadata: Option<&ScanReportMetadata>,
coverage_gap_summary: &[(String, usize)],
) -> ScanCompletionStatus {
ScanCompletionStatus::resolve(
metadata.map(|value| value.scan_status),
!coverage_gap_summary.is_empty(),
)
}
pub fn write_csv_coverage_report<W: Write + Send>(
writer: W,
report: ScanReport<'_>,
coverage_gap_summary: &[(String, usize)],
) -> Result<(), ReportError> {
finish_reporter(
csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
report.findings,
)
}
fn report_time(
metadata: Option<&ScanReportMetadata>,
explicit: String,
select: fn(&ScanReportMetadata) -> &String,
field: &str,
) -> Result<String, ReportError> {
let Some(metadata) = metadata else {
return Ok(explicit);
};
let canonical = select(metadata);
if explicit != *canonical {
anyhow::bail!(
"report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
);
}
Ok(explicit)
}
fn merge_html_metadata(
explicit: Option<ScanReportMetadata>,
report: Option<&ScanReportMetadata>,
) -> Result<Option<ScanReportMetadata>, ReportError> {
match (explicit, report) {
(Some(explicit), Some(report)) if explicit != *report => {
anyhow::bail!(
"report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
);
}
(Some(explicit), _) => Ok(Some(explicit)),
(None, report) => Ok(report.cloned()),
}
}
fn finish_reporter<R: Reporter>(
mut reporter: R,
findings: &[VerifiedFinding],
) -> Result<(), ReportError> {
for finding in findings {
reporter.report(finding)?;
}
reporter.finish()?;
Ok(())
}
pub(crate) trait Reporter: Send {
fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
fn finish(&mut self) -> Result<(), ReportError>;
}
trait WriterBackedReporter {
type Writer: Write;
fn writer_mut(&mut self) -> &mut Self::Writer;
fn flush_writer(&mut self) -> Result<(), ReportError> {
self.writer_mut().flush()?;
Ok(())
}
}
macro_rules! impl_writer_backed {
($reporter:ident) => {
impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
type Writer = W;
fn writer_mut(&mut self) -> &mut Self::Writer {
&mut self.writer
}
}
};
}
pub(crate) use impl_writer_backed;