1pub(crate) mod csv;
4pub(crate) mod escape;
5pub(crate) mod github_annotations;
6pub(crate) mod gitlab_sast;
7pub(crate) mod html;
8pub(crate) mod json;
9pub(crate) mod junit;
10pub(crate) mod sarif;
11mod style;
12pub(crate) mod text;
13
14#[path = "report/sarif_uri.rs"]
15pub(crate) mod sarif_uri;
16
17use std::collections::BTreeMap;
18use std::io::Write;
19
20use crate::VerifiedFinding;
21
22pub(crate) fn companions_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
25 let companions: BTreeMap<&str, &str> = finding
26 .companions_redacted
27 .iter()
28 .map(|(key, value)| (key.as_str(), value.as_str()))
29 .collect();
30 Ok(serde_json::to_string(&companions)?)
31}
32
33pub(crate) fn remediation_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
37 let remediation =
38 crate::auto_fix::remediation_for(&finding.detector_id, &finding.service, finding.severity);
39 Ok(serde_json::to_string(&remediation)?)
40}
41
42pub use anyhow::Error as ReportError;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum ScanCompletionStatus {
49 Success,
51 CompleteAfterRecovery,
54 Partial,
56 Cancelled,
58 Failed,
60}
61
62impl Default for ScanCompletionStatus {
63 fn default() -> Self {
64 Self::Success
65 }
66}
67
68impl ScanCompletionStatus {
69 #[must_use]
71 pub fn from_coverage_gaps(has_gaps: bool) -> Self {
72 if has_gaps {
73 Self::Partial
74 } else {
75 Self::Success
76 }
77 }
78
79 #[must_use]
85 pub fn resolve(metadata: Option<Self>, has_gaps: bool) -> Self {
86 match metadata {
87 Some(Self::Cancelled) => Self::Cancelled,
88 Some(Self::Failed) => Self::Failed,
89 Some(Self::Partial) => Self::Partial,
90 Some(Self::CompleteAfterRecovery) if has_gaps => Self::Partial,
91 Some(Self::CompleteAfterRecovery) => Self::CompleteAfterRecovery,
92 Some(Self::Success) if has_gaps => Self::Partial,
93 Some(status) => status,
94 None => Self::from_coverage_gaps(has_gaps),
95 }
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
107pub struct ResolvedScanManifest {
108 pub schema_version: u16,
111 pub preset: String,
113 pub effective: BTreeMap<String, String>,
115 pub overrides: Vec<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
127pub struct ScanBackendRecoverySummary {
128 pub events: usize,
130 pub failed_backend: String,
133 pub recovery_backend: String,
135 pub recovered_ranges: usize,
137 pub recovered_chunks: usize,
139 pub recovered_bytes: u64,
141 pub reason: String,
143 pub repair_command: String,
145}
146
147pub const STATIC_RECOVERY_METRICS_SCHEMA_VERSION: &str = "static-recovery-v1";
149
150#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
155pub struct StaticRecoveryMetrics {
156 pub schema_version: String,
158 pub supported: u64,
160 pub unsupported: u64,
162 pub erroneous: u64,
164 pub reasons: BTreeMap<String, u64>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct ScanReportMetadata {
177 #[serde(default)]
181 pub scan_id: String,
182 #[serde(default)]
185 pub scan_status: ScanCompletionStatus,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
189 pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
190 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub static_recovery: Option<StaticRecoveryMetrics>,
194 pub keyhog_version: String,
196 pub git_hash: String,
198 pub detector_digest: String,
200 #[serde(skip_serializing_if = "Option::is_none")]
203 pub config_digest: Option<String>,
204 #[serde(skip_serializing_if = "Option::is_none")]
208 pub resolved_scan: Option<ResolvedScanManifest>,
209 pub generated_at: String,
211 pub scan_started_at: String,
213 pub scan_finished_at: String,
215 pub duration_ms: u128,
217 pub targets: Vec<String>,
219 pub source_chunks_scanned: usize,
221 pub source_bytes_scanned: u64,
223 pub detector_count: usize,
225}
226
227pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
229pub const JSON_REPORT_SCHEMA_MINOR: u16 = 8;
231pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 9;
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
236pub struct JsonReportSchemaVersion {
237 pub major: u16,
239 pub minor: u16,
241}
242
243#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
248pub struct JsonReportEnvelope {
249 pub schema_version: JsonReportSchemaVersion,
251 #[serde(default)]
253 pub scan_status: ScanCompletionStatus,
254 #[serde(skip_serializing_if = "Option::is_none")]
256 pub metadata: Option<ScanReportMetadata>,
257 #[serde(default)]
259 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
260 pub findings: Vec<VerifiedFinding>,
262}
263
264#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
266pub struct JsonReportCoverageGap {
267 pub reason: String,
269 pub count: usize,
271}
272
273impl JsonReportEnvelope {
274 pub fn parse(input: &str) -> Result<Self, ReportError> {
276 let report: Self = serde_json::from_str(input)?;
277 if report.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
278 anyhow::bail!(
279 "unsupported JSON report schema major {}; this reader supports major {}",
280 report.schema_version.major,
281 JSON_REPORT_SCHEMA_MAJOR
282 );
283 }
284 Ok(report)
285 }
286}
287
288#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
290pub struct JsonlStreamHeader {
291 pub record_type: String,
293 pub schema_version: JsonReportSchemaVersion,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub metadata: Option<ScanReportMetadata>,
298}
299
300impl JsonlStreamHeader {
301 #[must_use]
303 pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
304 Self {
305 record_type: "header".to_string(),
306 schema_version: JsonReportSchemaVersion {
307 major: JSON_REPORT_SCHEMA_MAJOR,
308 minor: JSONL_REPORT_SCHEMA_MINOR,
309 },
310 metadata: metadata.cloned(),
311 }
312 }
313
314 pub fn parse(input: &str) -> Result<Self, ReportError> {
316 let header: Self = serde_json::from_str(input)?;
317 if header.record_type != "header" {
318 anyhow::bail!(
319 "invalid JSONL stream header record_type {:?}",
320 header.record_type
321 );
322 }
323 if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
324 anyhow::bail!(
325 "unsupported JSONL report schema major {}; this reader supports major {}",
326 header.schema_version.major,
327 JSON_REPORT_SCHEMA_MAJOR
328 );
329 }
330 Ok(header)
331 }
332}
333
334#[derive(Debug, Clone)]
338pub struct JsonlStream {
339 pub header: JsonlStreamHeader,
341 pub summary: Option<JsonlStreamSummary>,
344 pub findings: Vec<VerifiedFinding>,
346}
347
348#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
350pub struct JsonlStreamSummary {
351 pub record_type: String,
353 pub status: String,
355 #[serde(default)]
357 pub scan_status: ScanCompletionStatus,
358 pub finding_count: usize,
360 #[serde(default)]
362 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
363}
364
365impl JsonlStreamSummary {
366 #[must_use]
368 pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
369 Self::complete_with_status(
370 finding_count,
371 ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
372 coverage_gap_summary,
373 )
374 }
375
376 #[must_use]
379 pub fn complete_with_status(
380 finding_count: usize,
381 scan_status: ScanCompletionStatus,
382 coverage_gap_summary: &[(String, usize)],
383 ) -> Self {
384 Self {
385 record_type: "summary".to_string(),
386 status: "complete".to_string(),
387 scan_status,
388 finding_count,
389 coverage_gap_summary: coverage_gap_summary
390 .iter()
391 .map(|(reason, count)| JsonReportCoverageGap {
392 reason: reason.clone(),
393 count: *count,
394 })
395 .collect(),
396 }
397 }
398
399 fn parse(input: &str) -> Result<Self, ReportError> {
400 let summary: Self = serde_json::from_str(input)?;
401 if summary.record_type != "summary" || summary.status != "complete" {
402 anyhow::bail!(
403 "invalid JSONL stream summary: record_type={:?}, status={:?}",
404 summary.record_type,
405 summary.status
406 );
407 }
408 Ok(summary)
409 }
410}
411
412impl JsonlStream {
413 #[must_use]
415 pub fn is_complete(&self) -> bool {
416 self.summary.is_some()
417 }
418}
419
420pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
422 let mut streams = Vec::new();
423 let mut current: Option<JsonlStream> = None;
424
425 for (index, line) in input.lines().enumerate() {
426 let line_number = index + 1;
427 if line.trim().is_empty() {
428 anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
429 }
430 let value: serde_json::Value = serde_json::from_str(line)
431 .map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
432 let is_header =
433 value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
434 if is_header {
435 if let Some(stream) = current.take() {
436 streams.push(stream);
437 }
438 current = Some(JsonlStream {
439 header: JsonlStreamHeader::parse(line)?,
440 summary: None,
441 findings: Vec::new(),
442 });
443 continue;
444 }
445
446 let stream = current.as_mut().ok_or_else(|| {
447 anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
448 })?;
449 let is_summary =
450 value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
451 if is_summary {
452 if stream.summary.is_some() {
453 anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
454 }
455 let summary = JsonlStreamSummary::parse(line)?;
456 if summary.finding_count != stream.findings.len() {
457 anyhow::bail!(
458 "JSONL summary count {} does not match {} finding records",
459 summary.finding_count,
460 stream.findings.len()
461 );
462 }
463 stream.summary = Some(summary);
464 continue;
465 }
466 if stream.summary.is_some() {
467 anyhow::bail!("JSONL line {line_number} follows the terminal summary");
468 }
469 let finding = serde_json::from_value(value).map_err(|error| {
470 anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
471 })?;
472 stream.findings.push(finding);
473 }
474
475 if let Some(stream) = current {
476 streams.push(stream);
477 }
478 if streams.is_empty() {
479 anyhow::bail!("JSONL stream is empty; expected a versioned header record");
480 }
481 Ok(streams)
482}
483
484pub type HtmlScanMetadata = ScanReportMetadata;
489
490#[derive(Debug, Clone, Copy)]
496pub struct ScanReport<'a> {
497 pub findings: &'a [VerifiedFinding],
499 pub metadata: Option<&'a ScanReportMetadata>,
501}
502
503impl<'a> ScanReport<'a> {
504 pub fn new(findings: &'a [VerifiedFinding]) -> Self {
506 Self {
507 findings,
508 metadata: None,
509 }
510 }
511
512 #[must_use]
514 pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
515 self.metadata = Some(metadata);
516 self
517 }
518}
519
520pub enum ReportFormat {
522 Text {
524 color: bool,
526 example_suppressions: usize,
528 dogfood_active: bool,
530 },
531 Json,
533 JsonEnvelope {
535 coverage_gap_summary: Vec<(String, usize)>,
537 },
538 Jsonl,
540 JsonlEnvelope {
542 coverage_gap_summary: Vec<(String, usize)>,
544 },
545 Sarif {
547 skip_summary: Vec<(String, usize)>,
549 },
550 Csv,
552 GithubAnnotations,
554 GithubAnnotationsCoverage {
556 skip_summary: Vec<(String, usize)>,
558 },
559 GitlabSast {
561 scan_started_at: String,
563 scan_finished_at: String,
565 },
566 GitlabSastCoverage {
568 scan_started_at: String,
570 scan_finished_at: String,
572 skip_summary: Vec<(String, usize)>,
574 },
575 Html {
577 skip_summary: Vec<(String, usize)>,
581 metadata: Option<HtmlScanMetadata>,
583 },
584 Junit,
586 JunitCoverage {
588 skip_summary: Vec<(String, usize)>,
590 },
591}
592
593pub fn write_report<W: Write + Send>(
595 writer: W,
596 format: ReportFormat,
597 findings: &[VerifiedFinding],
598) -> Result<(), ReportError> {
599 write_scan_report(writer, format, ScanReport::new(findings))
600}
601
602pub fn write_scan_report<W: Write + Send>(
608 writer: W,
609 format: ReportFormat,
610 report: ScanReport<'_>,
611) -> Result<(), ReportError> {
612 let findings = report.findings;
613 let report_metadata = report.metadata;
614 match format {
615 ReportFormat::Text {
616 color,
617 example_suppressions,
618 dogfood_active,
619 } => {
620 let mut reporter = text::TextReporter::with_color(writer, color);
621 reporter.set_example_suppressions(example_suppressions);
622 reporter.set_dogfood_active(dogfood_active);
623 finish_reporter(reporter, findings)
624 }
625 ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
626 ReportFormat::JsonEnvelope {
627 coverage_gap_summary,
628 } => finish_reporter(
629 json::JsonEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
630 findings,
631 ),
632 ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
633 ReportFormat::JsonlEnvelope {
634 coverage_gap_summary,
635 } => finish_reporter(
636 json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
637 findings,
638 ),
639 ReportFormat::Sarif { skip_summary } => finish_reporter(
640 sarif::SarifReporter::new(writer)
641 .with_skip_summary(skip_summary.clone())
642 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
643 .with_backend_recoveries(report_recoveries(report_metadata)),
644 findings,
645 ),
646 ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
647 ReportFormat::GithubAnnotations => finish_reporter(
648 github_annotations::GithubAnnotationsReporter::new(writer)
649 .with_backend_recoveries(report_recoveries(report_metadata)),
650 findings,
651 ),
652 ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
653 github_annotations::GithubAnnotationsReporter::new(writer)
654 .with_skip_summary(skip_summary.clone())
655 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
656 .with_backend_recoveries(report_recoveries(report_metadata)),
657 findings,
658 ),
659 ReportFormat::GitlabSast {
660 scan_started_at,
661 scan_finished_at,
662 } => finish_reporter(
663 gitlab_sast::GitlabSastReporter::new(
664 writer,
665 report_time(
666 report_metadata,
667 scan_started_at,
668 |metadata| &metadata.scan_started_at,
669 "scan_started_at",
670 )?,
671 report_time(
672 report_metadata,
673 scan_finished_at,
674 |metadata| &metadata.scan_finished_at,
675 "scan_finished_at",
676 )?,
677 )
678 .with_backend_recoveries(report_recoveries(report_metadata)),
679 findings,
680 ),
681 ReportFormat::GitlabSastCoverage {
682 scan_started_at,
683 scan_finished_at,
684 skip_summary,
685 } => finish_reporter(
686 gitlab_sast::GitlabSastReporter::new(
687 writer,
688 report_time(
689 report_metadata,
690 scan_started_at,
691 |metadata| &metadata.scan_started_at,
692 "scan_started_at",
693 )?,
694 report_time(
695 report_metadata,
696 scan_finished_at,
697 |metadata| &metadata.scan_finished_at,
698 "scan_finished_at",
699 )?,
700 )
701 .with_skip_summary(skip_summary.clone())
702 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
703 .with_backend_recoveries(report_recoveries(report_metadata)),
704 findings,
705 ),
706 ReportFormat::Html {
707 skip_summary,
708 metadata,
709 } => finish_reporter(
710 html::HtmlReporter::new(writer)
711 .with_skip_summary(skip_summary)
712 .with_metadata(merge_html_metadata(metadata, report_metadata)?),
713 findings,
714 ),
715 ReportFormat::Junit => finish_reporter(
716 junit::JunitReporter::new(writer)
717 .with_backend_recoveries(report_recoveries(report_metadata)),
718 findings,
719 ),
720 ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
721 junit::JunitReporter::new(writer)
722 .with_skip_summary(skip_summary.clone())
723 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
724 .with_backend_recoveries(report_recoveries(report_metadata)),
725 findings,
726 ),
727 }
728}
729
730fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
731 metadata
732 .map(|value| value.backend_recoveries.clone())
733 .unwrap_or_default() }
735
736fn resolve_report_status(
737 metadata: Option<&ScanReportMetadata>,
738 coverage_gap_summary: &[(String, usize)],
739) -> ScanCompletionStatus {
740 ScanCompletionStatus::resolve(
741 metadata.map(|value| value.scan_status),
742 !coverage_gap_summary.is_empty(),
743 )
744}
745
746pub fn write_csv_coverage_report<W: Write + Send>(
753 writer: W,
754 report: ScanReport<'_>,
755 coverage_gap_summary: &[(String, usize)],
756) -> Result<(), ReportError> {
757 finish_reporter(
758 csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
759 report.findings,
760 )
761}
762
763fn report_time(
764 metadata: Option<&ScanReportMetadata>,
765 explicit: String,
766 select: fn(&ScanReportMetadata) -> &String,
767 field: &str,
768) -> Result<String, ReportError> {
769 let Some(metadata) = metadata else {
770 return Ok(explicit);
771 };
772 let canonical = select(metadata);
773 if explicit != *canonical {
774 anyhow::bail!(
775 "report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
776 );
777 }
778 Ok(explicit)
779}
780
781fn merge_html_metadata(
782 explicit: Option<ScanReportMetadata>,
783 report: Option<&ScanReportMetadata>,
784) -> Result<Option<ScanReportMetadata>, ReportError> {
785 match (explicit, report) {
786 (Some(explicit), Some(report)) if explicit != *report => {
787 anyhow::bail!(
788 "report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
789 );
790 }
791 (Some(explicit), _) => Ok(Some(explicit)),
792 (None, report) => Ok(report.cloned()),
793 }
794}
795
796fn finish_reporter<R: Reporter>(
797 mut reporter: R,
798 findings: &[VerifiedFinding],
799) -> Result<(), ReportError> {
800 for finding in findings {
801 reporter.report(finding)?;
802 }
803 reporter.finish()?;
804 Ok(())
805}
806
807pub(crate) trait Reporter: Send {
809 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
811
812 fn finish(&mut self) -> Result<(), ReportError>;
814}
815
816trait WriterBackedReporter {
817 type Writer: Write;
818
819 fn writer_mut(&mut self) -> &mut Self::Writer;
820
821 fn flush_writer(&mut self) -> Result<(), ReportError> {
822 self.writer_mut().flush()?;
823 Ok(())
824 }
825}
826
827macro_rules! impl_writer_backed {
835 ($reporter:ident) => {
836 impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
837 type Writer = W;
838 fn writer_mut(&mut self) -> &mut Self::Writer {
839 &mut self.writer
840 }
841 }
842 };
843}
844pub(crate) use impl_writer_backed;
845
846