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::access_target::AccessTargetReport;
21use crate::correlation::CorrelatedCredential;
22use crate::VerifiedFinding;
23
24pub(crate) fn companions_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
27 let companions: BTreeMap<&str, &str> = finding
28 .companions_redacted
29 .iter()
30 .map(|(key, value)| (key.as_str(), value.as_str()))
31 .collect();
32 Ok(serde_json::to_string(&companions)?)
33}
34
35pub(crate) fn remediation_json(finding: &VerifiedFinding) -> Result<String, ReportError> {
39 let remediation =
40 crate::auto_fix::remediation_for(&finding.detector_id, &finding.service, finding.severity);
41 Ok(serde_json::to_string(&remediation)?)
42}
43
44pub use anyhow::Error as ReportError;
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ScanCompletionStatus {
51 Success,
53 CompleteAfterRecovery,
56 Partial,
58 Cancelled,
60 Failed,
62}
63
64impl Default for ScanCompletionStatus {
65 fn default() -> Self {
66 Self::Success
67 }
68}
69
70impl ScanCompletionStatus {
71 #[must_use]
73 pub fn from_coverage_gaps(has_gaps: bool) -> Self {
74 if has_gaps {
75 Self::Partial
76 } else {
77 Self::Success
78 }
79 }
80
81 #[must_use]
87 pub fn resolve(metadata: Option<Self>, has_gaps: bool) -> Self {
88 match metadata {
89 Some(Self::Cancelled) => Self::Cancelled,
90 Some(Self::Failed) => Self::Failed,
91 Some(Self::Partial) => Self::Partial,
92 Some(Self::CompleteAfterRecovery) if has_gaps => Self::Partial,
93 Some(Self::CompleteAfterRecovery) => Self::CompleteAfterRecovery,
94 Some(Self::Success) if has_gaps => Self::Partial,
95 Some(status) => status,
96 None => Self::from_coverage_gaps(has_gaps),
97 }
98 }
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct ResolvedScanManifest {
110 pub schema_version: u16,
113 pub preset: String,
115 pub effective: BTreeMap<String, String>,
117 pub overrides: Vec<String>,
119}
120
121#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
129pub struct ScanBackendRecoverySummary {
130 pub events: usize,
132 pub failed_backend: String,
134 pub recovery_backend: String,
136 pub recovered_ranges: usize,
138 pub recovered_chunks: usize,
140 pub recovered_bytes: u64,
142 pub reason: String,
144 pub repair_command: String,
146}
147
148pub const STATIC_RECOVERY_METRICS_SCHEMA_VERSION: &str = "static-recovery-v1";
150
151#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct StaticRecoveryMetrics {
157 pub schema_version: String,
159 pub supported: u64,
161 pub unsupported: u64,
163 pub erroneous: u64,
165 pub reasons: BTreeMap<String, u64>,
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
177pub struct ScanReportMetadata {
178 #[serde(default)]
182 pub scan_id: String,
183 #[serde(default)]
186 pub scan_status: ScanCompletionStatus,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
191 #[serde(default, skip_serializing_if = "Option::is_none")]
194 pub static_recovery: Option<StaticRecoveryMetrics>,
195 pub keyhog_version: String,
197 pub git_hash: String,
199 pub detector_digest: String,
201 #[serde(skip_serializing_if = "Option::is_none")]
204 pub config_digest: Option<String>,
205 #[serde(skip_serializing_if = "Option::is_none")]
209 pub resolved_scan: Option<ResolvedScanManifest>,
210 pub generated_at: String,
212 pub scan_started_at: String,
214 pub scan_finished_at: String,
216 pub duration_ms: u128,
218 pub targets: Vec<String>,
220 pub source_chunks_scanned: usize,
222 pub source_bytes_scanned: u64,
224 pub detector_count: usize,
226}
227
228pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
230pub const JSON_REPORT_SCHEMA_MINOR: u16 = 10;
234pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 9;
236
237#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct JsonReportSchemaVersion {
240 pub major: u16,
242 pub minor: u16,
244}
245
246#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
251pub struct JsonReportEnvelope {
252 pub schema_version: JsonReportSchemaVersion,
254 #[serde(default)]
256 pub scan_status: ScanCompletionStatus,
257 #[serde(skip_serializing_if = "Option::is_none")]
259 pub metadata: Option<ScanReportMetadata>,
260 #[serde(default)]
262 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
263 pub findings: Vec<VerifiedFinding>,
265 #[serde(default, skip_serializing_if = "Vec::is_empty")]
269 pub correlations: Vec<CorrelatedCredential>,
270 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub access_targets: Option<AccessTargetReport>,
276}
277
278#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
280pub struct JsonReportCoverageGap {
281 pub reason: String,
283 pub count: usize,
285}
286
287impl JsonReportEnvelope {
288 pub fn parse(input: &str) -> Result<Self, ReportError> {
290 let report: Self = serde_json::from_str(input)?;
291 if report.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
292 anyhow::bail!(
293 "unsupported JSON report schema major {}; this reader supports major {}",
294 report.schema_version.major,
295 JSON_REPORT_SCHEMA_MAJOR
296 );
297 }
298 Ok(report)
299 }
300}
301
302#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
304pub struct JsonlStreamHeader {
305 pub record_type: String,
307 pub schema_version: JsonReportSchemaVersion,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub metadata: Option<ScanReportMetadata>,
312}
313
314impl JsonlStreamHeader {
315 #[must_use]
317 pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
318 Self {
319 record_type: "header".to_string(),
320 schema_version: JsonReportSchemaVersion {
321 major: JSON_REPORT_SCHEMA_MAJOR,
322 minor: JSONL_REPORT_SCHEMA_MINOR,
323 },
324 metadata: metadata.cloned(),
325 }
326 }
327
328 pub fn parse(input: &str) -> Result<Self, ReportError> {
330 let header: Self = serde_json::from_str(input)?;
331 if header.record_type != "header" {
332 anyhow::bail!(
333 "invalid JSONL stream header record_type {:?}",
334 header.record_type
335 );
336 }
337 if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
338 anyhow::bail!(
339 "unsupported JSONL report schema major {}; this reader supports major {}",
340 header.schema_version.major,
341 JSON_REPORT_SCHEMA_MAJOR
342 );
343 }
344 Ok(header)
345 }
346}
347
348#[derive(Debug, Clone)]
352pub struct JsonlStream {
353 pub header: JsonlStreamHeader,
355 pub summary: Option<JsonlStreamSummary>,
358 pub findings: Vec<VerifiedFinding>,
360}
361
362#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
364pub struct JsonlStreamSummary {
365 pub record_type: String,
367 pub status: String,
369 #[serde(default)]
371 pub scan_status: ScanCompletionStatus,
372 pub finding_count: usize,
374 #[serde(default)]
376 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
377}
378
379impl JsonlStreamSummary {
380 #[must_use]
382 pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
383 Self::complete_with_status(
384 finding_count,
385 ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
386 coverage_gap_summary,
387 )
388 }
389
390 #[must_use]
393 pub fn complete_with_status(
394 finding_count: usize,
395 scan_status: ScanCompletionStatus,
396 coverage_gap_summary: &[(String, usize)],
397 ) -> Self {
398 Self {
399 record_type: "summary".to_string(),
400 status: "complete".to_string(),
401 scan_status,
402 finding_count,
403 coverage_gap_summary: coverage_gap_summary
404 .iter()
405 .map(|(reason, count)| JsonReportCoverageGap {
406 reason: reason.clone(),
407 count: *count,
408 })
409 .collect(),
410 }
411 }
412
413 fn parse(input: &str) -> Result<Self, ReportError> {
414 let summary: Self = serde_json::from_str(input)?;
415 if summary.record_type != "summary" || summary.status != "complete" {
416 anyhow::bail!(
417 "invalid JSONL stream summary: record_type={:?}, status={:?}",
418 summary.record_type,
419 summary.status
420 );
421 }
422 Ok(summary)
423 }
424}
425
426impl JsonlStream {
427 #[must_use]
429 pub fn is_complete(&self) -> bool {
430 self.summary.is_some()
431 }
432}
433
434pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
436 let mut streams = Vec::new();
437 let mut current: Option<JsonlStream> = None;
438
439 for (index, line) in input.lines().enumerate() {
440 let line_number = index + 1;
441 if line.trim().is_empty() {
442 anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
443 }
444 let value: serde_json::Value = serde_json::from_str(line)
445 .map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
446 let is_header =
447 value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
448 if is_header {
449 if let Some(stream) = current.take() {
450 streams.push(stream);
451 }
452 current = Some(JsonlStream {
453 header: JsonlStreamHeader::parse(line)?,
454 summary: None,
455 findings: Vec::new(),
456 });
457 continue;
458 }
459
460 let stream = current.as_mut().ok_or_else(|| {
461 anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
462 })?;
463 let is_summary =
464 value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
465 if is_summary {
466 if stream.summary.is_some() {
467 anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
468 }
469 let summary = JsonlStreamSummary::parse(line)?;
470 if summary.finding_count != stream.findings.len() {
471 anyhow::bail!(
472 "JSONL summary count {} does not match {} finding records",
473 summary.finding_count,
474 stream.findings.len()
475 );
476 }
477 stream.summary = Some(summary);
478 continue;
479 }
480 if stream.summary.is_some() {
481 anyhow::bail!("JSONL line {line_number} follows the terminal summary");
482 }
483 let finding = serde_json::from_value(value).map_err(|error| {
484 anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
485 })?;
486 stream.findings.push(finding);
487 }
488
489 if let Some(stream) = current {
490 streams.push(stream);
491 }
492 if streams.is_empty() {
493 anyhow::bail!("JSONL stream is empty; expected a versioned header record");
494 }
495 Ok(streams)
496}
497
498pub type HtmlScanMetadata = ScanReportMetadata;
503
504#[derive(Debug, Clone, Copy)]
510pub struct ScanReport<'a> {
511 pub findings: &'a [VerifiedFinding],
513 pub metadata: Option<&'a ScanReportMetadata>,
515 pub correlations: &'a [CorrelatedCredential],
521 pub access_targets: Option<&'a AccessTargetReport>,
528}
529
530impl<'a> ScanReport<'a> {
531 pub fn new(findings: &'a [VerifiedFinding]) -> Self {
533 Self {
534 findings,
535 metadata: None,
536 correlations: &[],
537 access_targets: None,
538 }
539 }
540
541 #[must_use]
543 pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
544 self.metadata = Some(metadata);
545 self
546 }
547
548 #[must_use]
550 pub fn with_correlations(mut self, correlations: &'a [CorrelatedCredential]) -> Self {
551 self.correlations = correlations;
552 self
553 }
554
555 #[must_use]
557 pub fn with_access_targets(mut self, access_targets: &'a AccessTargetReport) -> Self {
558 self.access_targets = Some(access_targets);
559 self
560 }
561}
562
563pub enum ReportFormat {
565 Text {
567 color: bool,
569 example_suppressions: usize,
571 dogfood_active: bool,
573 covered_nothing: bool,
578 path_policy_suppressions: usize,
582 },
583 Json,
585 JsonEnvelope {
587 coverage_gap_summary: Vec<(String, usize)>,
589 },
590 Jsonl,
592 JsonlEnvelope {
594 coverage_gap_summary: Vec<(String, usize)>,
596 },
597 Sarif {
599 skip_summary: Vec<(String, usize)>,
601 },
602 Csv,
604 GithubAnnotations,
606 GithubAnnotationsCoverage {
608 skip_summary: Vec<(String, usize)>,
610 },
611 GitlabSast {
613 scan_started_at: String,
615 scan_finished_at: String,
617 },
618 GitlabSastCoverage {
620 scan_started_at: String,
622 scan_finished_at: String,
624 skip_summary: Vec<(String, usize)>,
626 },
627 Html {
629 skip_summary: Vec<(String, usize)>,
633 metadata: Option<HtmlScanMetadata>,
635 },
636 Junit,
638 JunitCoverage {
640 skip_summary: Vec<(String, usize)>,
642 },
643}
644
645pub fn write_report<W: Write + Send>(
647 writer: W,
648 format: ReportFormat,
649 findings: &[VerifiedFinding],
650) -> Result<(), ReportError> {
651 write_scan_report(writer, format, ScanReport::new(findings))
652}
653
654pub fn write_scan_report<W: Write + Send>(
660 writer: W,
661 format: ReportFormat,
662 report: ScanReport<'_>,
663) -> Result<(), ReportError> {
664 let findings = report.findings;
665 let report_metadata = report.metadata;
666 match format {
667 ReportFormat::Text {
668 color,
669 example_suppressions,
670 dogfood_active,
671 covered_nothing,
672 path_policy_suppressions,
673 } => {
674 let mut reporter = text::TextReporter::with_color(writer, color);
675 reporter.set_example_suppressions(example_suppressions);
676 reporter.set_dogfood_active(dogfood_active);
677 reporter.set_covered_nothing(covered_nothing);
678 reporter.set_path_policy_suppressions(path_policy_suppressions);
679 reporter.set_correlations(report.correlations);
680 finish_reporter(reporter, findings)
681 }
682 ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
683 ReportFormat::JsonEnvelope {
684 coverage_gap_summary,
685 } => finish_reporter(
686 json::JsonEnvelopeReporter::new(
687 writer,
688 report_metadata,
689 &coverage_gap_summary,
690 report.correlations,
691 report.access_targets,
692 )?,
693 findings,
694 ),
695 ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
696 ReportFormat::JsonlEnvelope {
697 coverage_gap_summary,
698 } => finish_reporter(
699 json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
700 findings,
701 ),
702 ReportFormat::Sarif { skip_summary } => finish_reporter(
703 sarif::SarifReporter::new(writer)
704 .with_skip_summary(skip_summary.clone())
705 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
706 .with_backend_recoveries(report_recoveries(report_metadata)),
707 findings,
708 ),
709 ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
710 ReportFormat::GithubAnnotations => finish_reporter(
711 github_annotations::GithubAnnotationsReporter::new(writer)
712 .with_backend_recoveries(report_recoveries(report_metadata)),
713 findings,
714 ),
715 ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
716 github_annotations::GithubAnnotationsReporter::new(writer)
717 .with_skip_summary(skip_summary.clone())
718 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
719 .with_backend_recoveries(report_recoveries(report_metadata)),
720 findings,
721 ),
722 ReportFormat::GitlabSast {
723 scan_started_at,
724 scan_finished_at,
725 } => finish_reporter(
726 gitlab_sast::GitlabSastReporter::new(
727 writer,
728 report_time(
729 report_metadata,
730 scan_started_at,
731 |metadata| &metadata.scan_started_at,
732 "scan_started_at",
733 )?,
734 report_time(
735 report_metadata,
736 scan_finished_at,
737 |metadata| &metadata.scan_finished_at,
738 "scan_finished_at",
739 )?,
740 )
741 .with_backend_recoveries(report_recoveries(report_metadata)),
742 findings,
743 ),
744 ReportFormat::GitlabSastCoverage {
745 scan_started_at,
746 scan_finished_at,
747 skip_summary,
748 } => finish_reporter(
749 gitlab_sast::GitlabSastReporter::new(
750 writer,
751 report_time(
752 report_metadata,
753 scan_started_at,
754 |metadata| &metadata.scan_started_at,
755 "scan_started_at",
756 )?,
757 report_time(
758 report_metadata,
759 scan_finished_at,
760 |metadata| &metadata.scan_finished_at,
761 "scan_finished_at",
762 )?,
763 )
764 .with_skip_summary(skip_summary.clone())
765 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
766 .with_backend_recoveries(report_recoveries(report_metadata)),
767 findings,
768 ),
769 ReportFormat::Html {
770 skip_summary,
771 metadata,
772 } => finish_reporter(
773 html::HtmlReporter::new(writer)
774 .with_skip_summary(skip_summary)
775 .with_metadata(merge_html_metadata(metadata, report_metadata)?),
776 findings,
777 ),
778 ReportFormat::Junit => finish_reporter(
779 junit::JunitReporter::new(writer)
780 .with_backend_recoveries(report_recoveries(report_metadata)),
781 findings,
782 ),
783 ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
784 junit::JunitReporter::new(writer)
785 .with_skip_summary(skip_summary.clone())
786 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
787 .with_backend_recoveries(report_recoveries(report_metadata)),
788 findings,
789 ),
790 }
791}
792
793fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
794 metadata
795 .map(|value| value.backend_recoveries.clone())
796 .unwrap_or_default() }
798
799fn resolve_report_status(
800 metadata: Option<&ScanReportMetadata>,
801 coverage_gap_summary: &[(String, usize)],
802) -> ScanCompletionStatus {
803 ScanCompletionStatus::resolve(
804 metadata.map(|value| value.scan_status),
805 !coverage_gap_summary.is_empty(),
806 )
807}
808
809pub fn write_csv_coverage_report<W: Write + Send>(
816 writer: W,
817 report: ScanReport<'_>,
818 coverage_gap_summary: &[(String, usize)],
819) -> Result<(), ReportError> {
820 finish_reporter(
821 csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
822 report.findings,
823 )
824}
825
826fn report_time(
827 metadata: Option<&ScanReportMetadata>,
828 explicit: String,
829 select: fn(&ScanReportMetadata) -> &String,
830 field: &str,
831) -> Result<String, ReportError> {
832 let Some(metadata) = metadata else {
833 return Ok(explicit);
834 };
835 let canonical = select(metadata);
836 if explicit != *canonical {
837 anyhow::bail!(
838 "report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
839 );
840 }
841 Ok(explicit)
842}
843
844fn merge_html_metadata(
845 explicit: Option<ScanReportMetadata>,
846 report: Option<&ScanReportMetadata>,
847) -> Result<Option<ScanReportMetadata>, ReportError> {
848 match (explicit, report) {
849 (Some(explicit), Some(report)) if explicit != *report => {
850 anyhow::bail!(
851 "report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
852 );
853 }
854 (Some(explicit), _) => Ok(Some(explicit)),
855 (None, report) => Ok(report.cloned()),
856 }
857}
858
859fn finish_reporter<R: Reporter>(
860 mut reporter: R,
861 findings: &[VerifiedFinding],
862) -> Result<(), ReportError> {
863 for finding in findings {
864 reporter.report(finding)?;
865 }
866 reporter.finish()?;
867 Ok(())
868}
869
870pub(crate) trait Reporter: Send {
872 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
874
875 fn finish(&mut self) -> Result<(), ReportError>;
877}
878
879trait WriterBackedReporter {
880 type Writer: Write;
881
882 fn writer_mut(&mut self) -> &mut Self::Writer;
883
884 fn flush_writer(&mut self) -> Result<(), ReportError> {
885 self.writer_mut().flush()?;
886 Ok(())
887 }
888}
889
890macro_rules! impl_writer_backed {
898 ($reporter:ident) => {
899 impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
900 type Writer = W;
901 fn writer_mut(&mut self) -> &mut Self::Writer {
902 &mut self.writer
903 }
904 }
905 };
906}
907pub(crate) use impl_writer_backed;
908
909