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)]
125pub struct ScanBackendRecoverySummary {
126 pub events: usize,
128 pub failed_backend: String,
131 pub recovery_backend: String,
133 pub recovered_ranges: usize,
135 pub recovered_chunks: usize,
137 pub recovered_bytes: u64,
139 pub reason: String,
141 pub repair_command: String,
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
153pub struct ScanReportMetadata {
154 #[serde(default)]
158 pub scan_id: String,
159 #[serde(default)]
162 pub scan_status: ScanCompletionStatus,
163 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
167 pub keyhog_version: String,
169 pub git_hash: String,
171 pub detector_digest: String,
173 #[serde(skip_serializing_if = "Option::is_none")]
176 pub config_digest: Option<String>,
177 #[serde(skip_serializing_if = "Option::is_none")]
181 pub resolved_scan: Option<ResolvedScanManifest>,
182 pub generated_at: String,
184 pub scan_started_at: String,
186 pub scan_finished_at: String,
188 pub duration_ms: u128,
190 pub targets: Vec<String>,
192 pub source_chunks_scanned: usize,
194 pub source_bytes_scanned: u64,
196 pub detector_count: usize,
198}
199
200pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
202pub const JSON_REPORT_SCHEMA_MINOR: u16 = 7;
204pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 8;
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
209pub struct JsonReportSchemaVersion {
210 pub major: u16,
212 pub minor: u16,
214}
215
216#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct JsonReportEnvelope {
222 pub schema_version: JsonReportSchemaVersion,
224 #[serde(default)]
226 pub scan_status: ScanCompletionStatus,
227 #[serde(skip_serializing_if = "Option::is_none")]
229 pub metadata: Option<ScanReportMetadata>,
230 #[serde(default)]
232 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
233 pub findings: Vec<VerifiedFinding>,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct JsonReportCoverageGap {
240 pub reason: String,
242 pub count: usize,
244}
245
246impl JsonReportEnvelope {
247 pub fn parse(input: &str) -> Result<Self, ReportError> {
249 let report: Self = serde_json::from_str(input)?;
250 if report.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
251 anyhow::bail!(
252 "unsupported JSON report schema major {}; this reader supports major {}",
253 report.schema_version.major,
254 JSON_REPORT_SCHEMA_MAJOR
255 );
256 }
257 Ok(report)
258 }
259}
260
261#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
263pub struct JsonlStreamHeader {
264 pub record_type: String,
266 pub schema_version: JsonReportSchemaVersion,
268 #[serde(skip_serializing_if = "Option::is_none")]
270 pub metadata: Option<ScanReportMetadata>,
271}
272
273impl JsonlStreamHeader {
274 #[must_use]
276 pub fn new(metadata: Option<&ScanReportMetadata>) -> Self {
277 Self {
278 record_type: "header".to_string(),
279 schema_version: JsonReportSchemaVersion {
280 major: JSON_REPORT_SCHEMA_MAJOR,
281 minor: JSONL_REPORT_SCHEMA_MINOR,
282 },
283 metadata: metadata.cloned(),
284 }
285 }
286
287 pub fn parse(input: &str) -> Result<Self, ReportError> {
289 let header: Self = serde_json::from_str(input)?;
290 if header.record_type != "header" {
291 anyhow::bail!(
292 "invalid JSONL stream header record_type {:?}",
293 header.record_type
294 );
295 }
296 if header.schema_version.major != JSON_REPORT_SCHEMA_MAJOR {
297 anyhow::bail!(
298 "unsupported JSONL report schema major {}; this reader supports major {}",
299 header.schema_version.major,
300 JSON_REPORT_SCHEMA_MAJOR
301 );
302 }
303 Ok(header)
304 }
305}
306
307#[derive(Debug, Clone)]
311pub struct JsonlStream {
312 pub header: JsonlStreamHeader,
314 pub summary: Option<JsonlStreamSummary>,
317 pub findings: Vec<VerifiedFinding>,
319}
320
321#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
323pub struct JsonlStreamSummary {
324 pub record_type: String,
326 pub status: String,
328 #[serde(default)]
330 pub scan_status: ScanCompletionStatus,
331 pub finding_count: usize,
333 #[serde(default)]
335 pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
336}
337
338impl JsonlStreamSummary {
339 #[must_use]
341 pub fn complete(finding_count: usize, coverage_gap_summary: &[(String, usize)]) -> Self {
342 Self::complete_with_status(
343 finding_count,
344 ScanCompletionStatus::from_coverage_gaps(!coverage_gap_summary.is_empty()),
345 coverage_gap_summary,
346 )
347 }
348
349 #[must_use]
352 pub fn complete_with_status(
353 finding_count: usize,
354 scan_status: ScanCompletionStatus,
355 coverage_gap_summary: &[(String, usize)],
356 ) -> Self {
357 Self {
358 record_type: "summary".to_string(),
359 status: "complete".to_string(),
360 scan_status,
361 finding_count,
362 coverage_gap_summary: coverage_gap_summary
363 .iter()
364 .map(|(reason, count)| JsonReportCoverageGap {
365 reason: reason.clone(),
366 count: *count,
367 })
368 .collect(),
369 }
370 }
371
372 fn parse(input: &str) -> Result<Self, ReportError> {
373 let summary: Self = serde_json::from_str(input)?;
374 if summary.record_type != "summary" || summary.status != "complete" {
375 anyhow::bail!(
376 "invalid JSONL stream summary: record_type={:?}, status={:?}",
377 summary.record_type,
378 summary.status
379 );
380 }
381 Ok(summary)
382 }
383}
384
385impl JsonlStream {
386 #[must_use]
388 pub fn is_complete(&self) -> bool {
389 self.summary.is_some()
390 }
391}
392
393pub fn parse_jsonl_stream(input: &str) -> Result<Vec<JsonlStream>, ReportError> {
395 let mut streams = Vec::new();
396 let mut current: Option<JsonlStream> = None;
397
398 for (index, line) in input.lines().enumerate() {
399 let line_number = index + 1;
400 if line.trim().is_empty() {
401 anyhow::bail!("JSONL line {line_number} is empty; remove blank records");
402 }
403 let value: serde_json::Value = serde_json::from_str(line)
404 .map_err(|error| anyhow::anyhow!("invalid JSONL line {line_number}: {error}"))?;
405 let is_header =
406 value.get("record_type").and_then(serde_json::Value::as_str) == Some("header");
407 if is_header {
408 if let Some(stream) = current.take() {
409 streams.push(stream);
410 }
411 current = Some(JsonlStream {
412 header: JsonlStreamHeader::parse(line)?,
413 summary: None,
414 findings: Vec::new(),
415 });
416 continue;
417 }
418
419 let stream = current.as_mut().ok_or_else(|| {
420 anyhow::anyhow!("JSONL line {line_number} precedes its stream header")
421 })?;
422 let is_summary =
423 value.get("record_type").and_then(serde_json::Value::as_str) == Some("summary");
424 if is_summary {
425 if stream.summary.is_some() {
426 anyhow::bail!("JSONL line {line_number} repeats the terminal summary");
427 }
428 let summary = JsonlStreamSummary::parse(line)?;
429 if summary.finding_count != stream.findings.len() {
430 anyhow::bail!(
431 "JSONL summary count {} does not match {} finding records",
432 summary.finding_count,
433 stream.findings.len()
434 );
435 }
436 stream.summary = Some(summary);
437 continue;
438 }
439 if stream.summary.is_some() {
440 anyhow::bail!("JSONL line {line_number} follows the terminal summary");
441 }
442 let finding = serde_json::from_value(value).map_err(|error| {
443 anyhow::anyhow!("invalid finding on JSONL line {line_number}: {error}")
444 })?;
445 stream.findings.push(finding);
446 }
447
448 if let Some(stream) = current {
449 streams.push(stream);
450 }
451 if streams.is_empty() {
452 anyhow::bail!("JSONL stream is empty; expected a versioned header record");
453 }
454 Ok(streams)
455}
456
457pub type HtmlScanMetadata = ScanReportMetadata;
462
463#[derive(Debug, Clone, Copy)]
469pub struct ScanReport<'a> {
470 pub findings: &'a [VerifiedFinding],
472 pub metadata: Option<&'a ScanReportMetadata>,
474}
475
476impl<'a> ScanReport<'a> {
477 pub fn new(findings: &'a [VerifiedFinding]) -> Self {
479 Self {
480 findings,
481 metadata: None,
482 }
483 }
484
485 #[must_use]
487 pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
488 self.metadata = Some(metadata);
489 self
490 }
491}
492
493pub enum ReportFormat {
495 Text {
497 color: bool,
499 example_suppressions: usize,
501 dogfood_active: bool,
503 },
504 Json,
506 JsonEnvelope {
508 coverage_gap_summary: Vec<(String, usize)>,
510 },
511 Jsonl,
513 JsonlEnvelope {
515 coverage_gap_summary: Vec<(String, usize)>,
517 },
518 Sarif {
520 skip_summary: Vec<(String, usize)>,
522 },
523 Csv,
525 GithubAnnotations,
527 GithubAnnotationsCoverage {
529 skip_summary: Vec<(String, usize)>,
531 },
532 GitlabSast {
534 scan_started_at: String,
536 scan_finished_at: String,
538 },
539 GitlabSastCoverage {
541 scan_started_at: String,
543 scan_finished_at: String,
545 skip_summary: Vec<(String, usize)>,
547 },
548 Html {
550 skip_summary: Vec<(String, usize)>,
554 metadata: Option<HtmlScanMetadata>,
556 },
557 Junit,
559 JunitCoverage {
561 skip_summary: Vec<(String, usize)>,
563 },
564}
565
566pub fn write_report<W: Write + Send>(
568 writer: W,
569 format: ReportFormat,
570 findings: &[VerifiedFinding],
571) -> Result<(), ReportError> {
572 write_scan_report(writer, format, ScanReport::new(findings))
573}
574
575pub fn write_scan_report<W: Write + Send>(
581 writer: W,
582 format: ReportFormat,
583 report: ScanReport<'_>,
584) -> Result<(), ReportError> {
585 let findings = report.findings;
586 let report_metadata = report.metadata;
587 match format {
588 ReportFormat::Text {
589 color,
590 example_suppressions,
591 dogfood_active,
592 } => {
593 let mut reporter = text::TextReporter::with_color(writer, color);
594 reporter.set_example_suppressions(example_suppressions);
595 reporter.set_dogfood_active(dogfood_active);
596 finish_reporter(reporter, findings)
597 }
598 ReportFormat::Json => finish_reporter(json::JsonArrayReporter::new(writer)?, findings),
599 ReportFormat::JsonEnvelope {
600 coverage_gap_summary,
601 } => finish_reporter(
602 json::JsonEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
603 findings,
604 ),
605 ReportFormat::Jsonl => finish_reporter(json::JsonlReporter::new(writer), findings),
606 ReportFormat::JsonlEnvelope {
607 coverage_gap_summary,
608 } => finish_reporter(
609 json::JsonlEnvelopeReporter::new(writer, report_metadata, &coverage_gap_summary)?,
610 findings,
611 ),
612 ReportFormat::Sarif { skip_summary } => finish_reporter(
613 sarif::SarifReporter::new(writer)
614 .with_skip_summary(skip_summary.clone())
615 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
616 .with_backend_recoveries(report_recoveries(report_metadata)),
617 findings,
618 ),
619 ReportFormat::Csv => finish_reporter(csv::CsvReporter::new(writer)?, findings),
620 ReportFormat::GithubAnnotations => finish_reporter(
621 github_annotations::GithubAnnotationsReporter::new(writer)
622 .with_backend_recoveries(report_recoveries(report_metadata)),
623 findings,
624 ),
625 ReportFormat::GithubAnnotationsCoverage { skip_summary } => finish_reporter(
626 github_annotations::GithubAnnotationsReporter::new(writer)
627 .with_skip_summary(skip_summary.clone())
628 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
629 .with_backend_recoveries(report_recoveries(report_metadata)),
630 findings,
631 ),
632 ReportFormat::GitlabSast {
633 scan_started_at,
634 scan_finished_at,
635 } => finish_reporter(
636 gitlab_sast::GitlabSastReporter::new(
637 writer,
638 report_time(
639 report_metadata,
640 scan_started_at,
641 |metadata| &metadata.scan_started_at,
642 "scan_started_at",
643 )?,
644 report_time(
645 report_metadata,
646 scan_finished_at,
647 |metadata| &metadata.scan_finished_at,
648 "scan_finished_at",
649 )?,
650 )
651 .with_backend_recoveries(report_recoveries(report_metadata)),
652 findings,
653 ),
654 ReportFormat::GitlabSastCoverage {
655 scan_started_at,
656 scan_finished_at,
657 skip_summary,
658 } => finish_reporter(
659 gitlab_sast::GitlabSastReporter::new(
660 writer,
661 report_time(
662 report_metadata,
663 scan_started_at,
664 |metadata| &metadata.scan_started_at,
665 "scan_started_at",
666 )?,
667 report_time(
668 report_metadata,
669 scan_finished_at,
670 |metadata| &metadata.scan_finished_at,
671 "scan_finished_at",
672 )?,
673 )
674 .with_skip_summary(skip_summary.clone())
675 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
676 .with_backend_recoveries(report_recoveries(report_metadata)),
677 findings,
678 ),
679 ReportFormat::Html {
680 skip_summary,
681 metadata,
682 } => finish_reporter(
683 html::HtmlReporter::new(writer)
684 .with_skip_summary(skip_summary)
685 .with_metadata(merge_html_metadata(metadata, report_metadata)?),
686 findings,
687 ),
688 ReportFormat::Junit => finish_reporter(
689 junit::JunitReporter::new(writer)
690 .with_backend_recoveries(report_recoveries(report_metadata)),
691 findings,
692 ),
693 ReportFormat::JunitCoverage { skip_summary } => finish_reporter(
694 junit::JunitReporter::new(writer)
695 .with_skip_summary(skip_summary.clone())
696 .with_scan_status(resolve_report_status(report_metadata, &skip_summary))
697 .with_backend_recoveries(report_recoveries(report_metadata)),
698 findings,
699 ),
700 }
701}
702
703fn report_recoveries(metadata: Option<&ScanReportMetadata>) -> Vec<ScanBackendRecoverySummary> {
704 metadata
705 .map(|value| value.backend_recoveries.clone())
706 .unwrap_or_default() }
708
709fn resolve_report_status(
710 metadata: Option<&ScanReportMetadata>,
711 coverage_gap_summary: &[(String, usize)],
712) -> ScanCompletionStatus {
713 ScanCompletionStatus::resolve(
714 metadata.map(|value| value.scan_status),
715 !coverage_gap_summary.is_empty(),
716 )
717}
718
719pub fn write_csv_coverage_report<W: Write + Send>(
726 writer: W,
727 report: ScanReport<'_>,
728 coverage_gap_summary: &[(String, usize)],
729) -> Result<(), ReportError> {
730 finish_reporter(
731 csv::CsvReporter::with_scan_metadata(writer, report.metadata, coverage_gap_summary)?,
732 report.findings,
733 )
734}
735
736fn report_time(
737 metadata: Option<&ScanReportMetadata>,
738 explicit: String,
739 select: fn(&ScanReportMetadata) -> &String,
740 field: &str,
741) -> Result<String, ReportError> {
742 let Some(metadata) = metadata else {
743 return Ok(explicit);
744 };
745 let canonical = select(metadata);
746 if explicit != *canonical {
747 anyhow::bail!(
748 "report metadata conflict for {field}: format options and ScanReport disagree; pass one canonical value"
749 );
750 }
751 Ok(explicit)
752}
753
754fn merge_html_metadata(
755 explicit: Option<ScanReportMetadata>,
756 report: Option<&ScanReportMetadata>,
757) -> Result<Option<ScanReportMetadata>, ReportError> {
758 match (explicit, report) {
759 (Some(explicit), Some(report)) if explicit != *report => {
760 anyhow::bail!(
761 "report metadata conflict for HTML: format options and ScanReport disagree; pass one canonical value"
762 );
763 }
764 (Some(explicit), _) => Ok(Some(explicit)),
765 (None, report) => Ok(report.cloned()),
766 }
767}
768
769fn finish_reporter<R: Reporter>(
770 mut reporter: R,
771 findings: &[VerifiedFinding],
772) -> Result<(), ReportError> {
773 for finding in findings {
774 reporter.report(finding)?;
775 }
776 reporter.finish()?;
777 Ok(())
778}
779
780pub(crate) trait Reporter: Send {
782 fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
784
785 fn finish(&mut self) -> Result<(), ReportError>;
787}
788
789trait WriterBackedReporter {
790 type Writer: Write;
791
792 fn writer_mut(&mut self) -> &mut Self::Writer;
793
794 fn flush_writer(&mut self) -> Result<(), ReportError> {
795 self.writer_mut().flush()?;
796 Ok(())
797 }
798}
799
800macro_rules! impl_writer_backed {
808 ($reporter:ident) => {
809 impl<W: Write + Send> WriterBackedReporter for $reporter<W> {
810 type Writer = W;
811 fn writer_mut(&mut self) -> &mut Self::Writer {
812 &mut self.writer
813 }
814 }
815 };
816}
817pub(crate) use impl_writer_backed;
818
819