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 = 2;
232pub const JSON_REPORT_SCHEMA_MINOR: u16 = 0;
234pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 0;
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")]
268 pub correlations: Vec<CorrelatedCredential>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub access_targets: Option<AccessTargetReport>,
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278pub struct JsonReportCoverageGap {
279 pub reason: String,
281 pub count: usize,
283}
284
285impl JsonReportEnvelope {
286 pub fn parse(input: &str) -> Result<Self, ReportError> {
288 #[derive(serde::Deserialize)]
289 struct EnvelopeVersion {
290 schema_version: JsonReportSchemaVersion,
291 }
292
293 let version: EnvelopeVersion = serde_json::from_str(input)?;
294 if version.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
295 anyhow::bail!(
296 "unsupported JSON report schema major {}; this reader supports major {}",
297 version.schema_version.major,
298 JSON_REPORT_SCHEMA_MAJOR
299 );
300 }
301 Ok(serde_json::from_str(input)?)
302 }
303}
304
305#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
307pub struct JsonlStreamHeader {
308 pub record_type: String,
310 pub schema_version: JsonReportSchemaVersion,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub metadata: Option<ScanReportMetadata>,
315}
316
317impl JsonlStreamHeader {
318 #[must_use]
320 pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
321 Self {
322 record_type: "header".to_string(),
323 schema_version: JsonReportSchemaVersion {
324 major: JSON_REPORT_SCHEMA_MAJOR,
325 minor: JSONL_REPORT_SCHEMA_MINOR,
326 },
327 metadata: metadata.cloned(),
328 }
329 }
330
331 pub fn parse(input: &str) -> Result<Self, ReportError> {
333 let header: Self = serde_json::from_str(input)?;
334 if header.record_type != "header" {
335 anyhow::bail!(
336 "invalid JSONL stream header record_type {:?}",
337 header.record_type
338 );
339 }
340 if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
341 anyhow::bail!(
342 "unsupported JSONL report schema major {}; this reader supports major {}",
343 header.schema_version.major,
344 JSON_REPORT_SCHEMA_MAJOR
345 );
346 }
347 Ok(header)
348 }
349}
350
351#[derive(Debug, Clone)]
355pub struct JsonlStream {
356 pub header: JsonlStreamHeader,
358 pub summary: Option<JsonlStreamSummary>,
361 pub findings: Vec<VerifiedFinding>,
363}
364
365#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
367pub struct JsonlStreamSummary {
368 pub record_type: String,
370 pub status: String,
372 #[serde(default)]
374 pub scan_status: ScanCompletionStatus,
375 pub finding_count: usize,
377 #[serde(default)]
379 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
380}
381
382impl JsonlStreamSummary {
383 #[must_use]
385 pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
386 Self::complete_with_status(
387 finding_count,
388 ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
389 coverage_gap_summary,
390 )
391 }
392
393 #[must_use]
396 pub fn complete_with_status(
397 finding_count: usize,
398 scan_status: ScanCompletionStatus,
399 coverage_gap_summary: &[(String, usize)],
400 ) -> Self {
401 Self {
402 record_type: "summary".to_string(),
403 status: "complete".to_string(),
404 scan_status,
405 finding_count,
406 coverage_gap_summary: coverage_gap_summary
407 .iter()
408 .map(|(reason, count)| JsonReportCoverageGap {
409 reason: reason.clone(),
410 count: *count,
411 })
412 .collect(),
413 }
414 }
415
416 fn parse(input: &str) -> Result<Self, ReportError> {
417 let summary: Self = serde_json::from_str(input)?;
418 if summary.record_type != "summary" || summary.status != "complete" {
419 anyhow::bail!(
420 "invalid JSONL stream summary: record_type={:?}, status={:?}",
421 summary.record_type,
422 summary.status
423 );
424 }
425 Ok(summary)
426 }
427}
428
429impl JsonlStream {
430 #[must_use]
432 pub fn is_complete(&self) -> bool {
433 self.summary.is_some()
434 }
435}
436
437pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
439 let mut streams = Vec::new();
440 let mut current: Option<JsonlStream> = None;
441
442 for (index, line) in input.lines().enumerate() {
443 let line_number = index + 1;
444 if line.trim().is_empty() {
445 anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
446 }
447 let value: serde_json::Value = serde_json::from_str(line)
448 .map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
449 let is_header =
450 value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
451 if is_header {
452 if let Some(stream) = current.take() {
453 streams.push(stream);
454 }
455 current = Some(JsonlStream {
456 header: JsonlStreamHeader::parse(line)?,
457 summary: None,
458 findings: Vec::new(),
459 });
460 continue;
461 }
462
463 let stream = current.as_mut().ok_or_else(|| {
464 anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
465 })?;
466 let is_summary =
467 value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
468 if is_summary {
469 if stream.summary.is_some() {
470 anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
471 }
472 let summary = JsonlStreamSummary::parse(line)?;
473 if summary.finding_count != stream.findings.len() {
474 anyhow::bail!(
475 "JSONL summary count {} does not match {} finding records",
476 summary.finding_count,
477 stream.findings.len()
478 );
479 }
480 stream.summary = Some(summary);
481 continue;
482 }
483 if stream.summary.is_some() {
484 anyhow::bail!("JSONL line {line_number} follows the terminal summary");
485 }
486 let finding = serde_json::from_value(value).map_err(|error| {
487 anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
488 })?;
489 stream.findings.push(finding);
490 }
491
492 if let Some(stream) = current {
493 streams.push(stream);
494 }
495 if streams.is_empty() {
496 anyhow::bail!("JSONL stream is empty; expected a versioned header record");
497 }
498 Ok(streams)
499}
500
501pub type HtmlScanMetadata = ScanReportMetadata;
506
507#[derive(Debug, Clone, Copy)]
513pub struct ScanReport<'a> {
514 pub findings: &'a [VerifiedFinding],
516 pub metadata: Option<&'a ScanReportMetadata>,
518 pub correlations: &'a [CorrelatedCredential],
524 pub access_targets: Option<&'a AccessTargetReport>,
531}
532
533impl<'a> ScanReport<'a> {
534 pub fn new(findings: &'a [VerifiedFinding]) -> Self {
536 Self {
537 findings,
538 metadata: None,
539 correlations: &[],
540 access_targets: None,
541 }
542 }
543
544 #[must_use]
546 pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
547 self.metadata = Some(metadata);
548 self
549 }
550
551 #[must_use]
553 pub fn with_correlations(mut self, correlations: &'a [CorrelatedCredential]) -> Self {
554 self.correlations = correlations;
555 self
556 }
557
558 #[must_use]
560 pub fn with_access_targets(mut self, access_targets: &'a AccessTargetReport) -> Self {
561 self.access_targets = Some(access_targets);
562 self
563 }
564}
565
566pub enum ReportFormat {
568 Text {
570 color: bool,
572 example_suppressions: usize,
574 dogfood_active: bool,
576 covered_nothing: bool,
581 path_policy_suppressions: usize,
585 },
586 Json,
588 JsonEnvelope {
590 coverage_gap_summary: Vec<(String, usize)>,
592 },
593 Jsonl,
595 JsonlEnvelope {
597 coverage_gap_summary: Vec<(String, usize)>,
599 },
600 Sarif {
602 skip_summary: Vec<(String, usize)>,
604 },
605 Csv,
607 GithubAnnotations,
609 GithubAnnotationsCoverage {
611 skip_summary: Vec<(String, usize)>,
613 },
614 GitlabSast {
616 scan_started_at: String,
618 scan_finished_at: String,
620 },
621 GitlabSastCoverage {
623 scan_started_at: String,
625 scan_finished_at: String,
627 skip_summary: Vec<(String, usize)>,
629 },
630 Html {
632 skip_summary: Vec<(String, usize)>,
636 metadata: Option<HtmlScanMetadata>,
638 },
639 Junit,
641 JunitCoverage {
643 skip_summary: Vec<(String, usize)>,
645 },
646}
647
648pub fn write_report<W: Write + Send>(
650 writer: W,
651 format: ReportFormat,
652 findings: &[VerifiedFinding],
653) -> Result<(), ReportError> {
654 write_scan_report(writer, format, ScanReport::new(findings))
655}
656
657pub fn write_scan_report<W: Write + Send>(
663 writer: W,
664 format: ReportFormat,
665 report: ScanReport<'_>,
666) -> Result<(), ReportError> {
667 let findings = report.findings;
668 let report_metadata = report.metadata;
669 match format {
670 ReportFormat::Text {
671 color,
672 example_suppressions,
673 dogfood_active,
674 covered_nothing,
675 path_policy_suppressions,
676 } => {
677 let mut reporter = text::TextReporter::with_color(writer, color);
678 reporter.set_example_suppressions(example_suppressions);
679 reporter.set_dogfood_active(dogfood_active);
680 reporter.set_covered_nothing(covered_nothing);
681 reporter.set_path_policy_suppressions(path_policy_suppressions);
682 reporter.set_correlations(report.correlations);
683 finish_reporter(reporter, findings)
684 }
685 ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
686 ReportFormat::JsonEnvelope {
687 coverage_gap_summary,
688 } => finish_reporter(
689 json::JsonEnvelopeReporter::new(
690 writer,
691 report_metadata,
692 &coverage_gap_summary,
693 report.correlations,
694 report.access_targets,
695 )?,
696 findings,
697 ),
698 ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
699 ReportFormat::JsonlEnvelope {
700 coverage_gap_summary,
701 } => finish_reporter(
702 json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
703 findings,
704 ),
705 ReportFormat::Sarif { skip_summary } => finish_reporter(
706 sarif::SarifReporter::new(writer)
707 .with_skip_summary(skip_summary.clone())
708 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
709 .with_backend_recoveries(report_recoveries(report_metadata)),
710 findings,
711 ),
712 ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
713 ReportFormat::GithubAnnotations => finish_reporter(
714 github_annotations::GithubAnnotationsReporter::new(writer)
715 .with_backend_recoveries(report_recoveries(report_metadata)),
716 findings,
717 ),
718 ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
719 github_annotations::GithubAnnotationsReporter::new(writer)
720 .with_skip_summary(skip_summary.clone())
721 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
722 .with_backend_recoveries(report_recoveries(report_metadata)),
723 findings,
724 ),
725 ReportFormat::GitlabSast {
726 scan_started_at,
727 scan_finished_at,
728 } => finish_reporter(
729 gitlab_sast::GitlabSastReporter::new(
730 writer,
731 report_time(
732 report_metadata,
733 scan_started_at,
734 |metadata| &metadata.scan_started_at,
735 "scan_started_at",
736 )?,
737 report_time(
738 report_metadata,
739 scan_finished_at,
740 |metadata| &metadata.scan_finished_at,
741 "scan_finished_at",
742 )?,
743 )
744 .with_backend_recoveries(report_recoveries(report_metadata)),
745 findings,
746 ),
747 ReportFormat::GitlabSastCoverage {
748 scan_started_at,
749 scan_finished_at,
750 skip_summary,
751 } => finish_reporter(
752 gitlab_sast::GitlabSastReporter::new(
753 writer,
754 report_time(
755 report_metadata,
756 scan_started_at,
757 |metadata| &metadata.scan_started_at,
758 "scan_started_at",
759 )?,
760 report_time(
761 report_metadata,
762 scan_finished_at,
763 |metadata| &metadata.scan_finished_at,
764 "scan_finished_at",
765 )?,
766 )
767 .with_skip_summary(skip_summary.clone())
768 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
769 .with_backend_recoveries(report_recoveries(report_metadata)),
770 findings,
771 ),
772 ReportFormat::Html {
773 skip_summary,
774 metadata,
775 } => finish_reporter(
776 html::HtmlReporter::new(writer)
777 .with_skip_summary(skip_summary)
778 .with_metadata(merge_html_metadata(metadata, report_metadata)?),
779 findings,
780 ),
781 ReportFormat::Junit => finish_reporter(
782 junit::JunitReporter::new(writer)
783 .with_backend_recoveries(report_recoveries(report_metadata)),
784 findings,
785 ),
786 ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
787 junit::JunitReporter::new(writer)
788 .with_skip_summary(skip_summary.clone())
789 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
790 .with_backend_recoveries(report_recoveries(report_metadata)),
791 findings,
792 ),
793 }
794}
795
796fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
797 metadata
798 .map(|value| value.backend_recoveries.clone())
799 .unwrap_or_default() }
801
802fn resolve_report_status(
803 metadata: Option<&ScanReportMetadata>,
804 coverage_gap_summary: &[(String, usize)],
805) -> ScanCompletionStatus {
806 ScanCompletionStatus::resolve(
807 metadata.map(|value| value.scan_status),
808 !coverage_gap_summary.is_empty(),
809 )
810}
811
812pub fn write_csv_coverage_report<W: Write + Send>(
819 writer: W,
820 report: ScanReport<'_>,
821 coverage_gap_summary: &[(String, usize)],
822) -> Result<(), ReportError> {
823 finish_reporter(
824 csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
825 report.findings,
826 )
827}
828
829fn report_time(
830 metadata: Option<&ScanReportMetadata>,
831 explicit: String,
832 select: fn(&ScanReportMetadata) -> &String,
833 field: &str,
834) -> Result<String, ReportError> {
835 let Some(metadata) = metadata else {
836 return Ok(explicit);
837 };
838 let canonical = select(metadata);
839 if explicit != *canonical {
840 anyhow::bail!(
841 "report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
842 );
843 }
844 Ok(explicit)
845}
846
847fn merge_html_metadata(
848 explicit: Option<ScanReportMetadata>,
849 report: Option<&ScanReportMetadata>,
850) -> Result<Option<ScanReportMetadata>, ReportError> {
851 match (explicit, report) {
852 (Some(explicit), Some(report)) if explicit != *report => {
853 anyhow::bail!(
854 "report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
855 );
856 }
857 (Some(explicit), _) => Ok(Some(explicit)),
858 (None, report) => Ok(report.cloned()),
859 }
860}
861
862fn finish_reporter<R: Reporter>(
863 mut reporter: R,
864 findings: &[VerifiedFinding],
865) -> Result<(), ReportError> {
866 for finding in findings {
867 reporter.report(finding)?;
868 }
869 reporter.finish()?;
870 Ok(())
871}
872
873pub(crate) trait Reporter: Send {
875 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
877
878 fn finish(&mut self) -> Result<(), ReportError>;
880}
881
882trait WriterBackedReporter {
883 type Writer: Write;
884
885 fn writer_mut(&mut self) -> &mut Self::Writer;
886
887 fn flush_writer(&mut self) -> Result<(), ReportError> {
888 self.writer_mut().flush()?;
889 Ok(())
890 }
891}
892
893macro_rules! impl_writer_backed {
901 ($reporter:ident) => {
902 impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
903 type Writer = W;
904 fn writer_mut(&mut self) -> &mut Self::Writer {
905 &mut self.writer
906 }
907 }
908 };
909 ($reporter:ident <'_>) => {
910 impl<W: Write + Send> WriterBackedReporter for $reporter<'_, W> {
911 type Writer = W;
912 fn writer_mut(&mut self) -> &mut Self::Writer {
913 &mut self.writer
914 }
915 }
916 };
917}
918pub(crate) use impl_writer_backed;
919
920