Skip to main content

keyhog_core/
report.rs

1//! Reporting logic for scan results.
2
3pub(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
24/// Serialize redacted companion values deterministically for report formats
25/// that expose a scalar details field instead of the native JSON object.
26pub(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
35/// Serialize the canonical Tier-B remediation projection for scalar report
36/// formats. Keeping this beside companion serialization prevents CSV and other
37/// adapters from inventing provider-specific remediation logic.
38pub(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
44/// Common error type used by all reporters.
45pub use anyhow::Error as ReportError;
46
47/// Terminal state carried by detached scan artifacts.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
49#[serde(rename_all = "snake_case")]
50pub enum ScanCompletionStatus {
51    /// The requested input completed without coverage gaps.
52    Success,
53    /// Every requested byte completed, with one or more backend ranges replayed
54    /// after a visible recoverable runtime fault.
55    CompleteAfterRecovery,
56    /// The artifact completed, but one or more requested inputs were not fully scanned.
57    Partial,
58    /// The operator or host interrupted the scan before completion.
59    Cancelled,
60    /// The scan failed before it could produce a trustworthy complete result.
61    Failed,
62}
63
64impl Default for ScanCompletionStatus {
65    fn default() -> Self {
66        Self::Success
67    }
68}
69
70impl ScanCompletionStatus {
71    /// Derive the normal terminal state from the coverage summary.
72    #[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    /// Resolve metadata and observed coverage into one terminal state.
82    ///
83    /// A non-empty coverage summary upgrades an optimistic `success` metadata
84    /// value to `partial`, while explicit `cancelled` and `failed` states are
85    /// preserved even when no gap counter was recorded.
86    #[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/// Stable, machine-diffable description of the resolved detection mode.
102///
103/// The preset names the shipped base (`default`, `fast`, `deep`, or
104/// `precision`). `effective` contains the scalar and list-identity values the
105/// scanner actually used, while `overrides` names values that refine that
106/// preset. Maps are ordered so equivalent scans serialize byte-for-byte
107/// identically across report formats and benchmark runs.
108#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub struct ResolvedScanManifest {
110    /// Version of this manifest contract. Additive fields require a new minor
111    /// report schema, while a breaking manifest change increments this value.
112    pub schema_version: u16,
113    /// Shipped base preset selected for the scan.
114    pub preset: String,
115    /// Resolved detection settings encoded as stable string values.
116    pub effective: BTreeMap<String, String>,
117    /// Settings that differ from the selected preset base.
118    pub overrides: Vec<String>,
119}
120
121/// Bounded, non-secret summary of one completed exact recovery.
122///
123/// Exact dispatch-local ranges remain available on the daemon wire. Detached
124/// reports carry stable aggregates because scanner chunk indices restart for
125/// each batch and therefore are not durable source identities. Admission-plan
126/// recovery uses the selected backend in both backend fields and identifies
127/// the rejected identity in `reason`.
128#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
129pub struct ScanBackendRecoverySummary {
130    /// Number of recovery events represented by this summary row.
131    pub events: usize,
132    /// Authenticated backend whose selected work required exact recovery.
133    pub failed_backend: String,
134    /// Backend that completed the stable input ranges after exact recovery.
135    pub recovery_backend: String,
136    /// Number of canonical disjoint ranges recovered in this event.
137    pub recovered_ranges: usize,
138    /// Number of distinct scanner chunks containing those ranges.
139    pub recovered_chunks: usize,
140    /// Total recovered source bytes across the canonical ranges.
141    pub recovered_bytes: u64,
142    /// Non-secret recovery diagnostic.
143    pub reason: String,
144    /// Canonical operator action for the represented recovery condition.
145    pub repair_command: String,
146}
147
148/// Schema generation for exact bounded static-recovery telemetry.
149pub const STATIC_RECOVERY_METRICS_SCHEMA_VERSION: &str = "static-recovery-v1";
150
151/// Exact, non-secret bounded static-recovery telemetry for one scan.
152///
153/// `reasons` contains rejection counts only. Its unsupported reasons conserve
154/// to `unsupported`; all remaining reasons conserve to `erroneous`.
155#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
156pub struct StaticRecoveryMetrics {
157    /// Version of this nested telemetry contract.
158    pub schema_version: String,
159    /// Recognized expressions successfully evaluated within the static bounds.
160    pub supported: u64,
161    /// Recognized expressions requiring unsupported dynamic behavior.
162    pub unsupported: u64,
163    /// Recognized expressions rejected because their bounded evaluation failed.
164    pub erroneous: u64,
165    /// Exact rejection counts keyed by the stable scanner reason vocabulary.
166    pub reasons: BTreeMap<String, u64>,
167}
168
169/// Format-neutral operator-visible metadata for a scan report.
170///
171/// The metadata belongs to the report, not to one renderer. Individual output
172/// formats project the fields they can represent: HTML renders the complete
173/// object, while schema-constrained formats retain their established fields.
174/// Keeping this model in `keyhog-core` prevents the CLI and a single reporter
175/// from growing competing definitions of scan identity and timing.
176#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
177pub struct ScanReportMetadata {
178    /// Stable non-secret identifier shared by artifacts from one scan run.
179    /// Missing values deserialize as empty for reports produced before this
180    /// field was introduced; current producers always populate it.
181    #[serde(default)]
182    pub scan_id: String,
183    /// Terminal state for detached artifacts. Older reports default to success
184    /// because they predate this explicit field and have no state to recover.
185    #[serde(default)]
186    pub scan_status: ScanCompletionStatus,
187    /// Completed exact recovery events. Empty means no backend fault or
188    /// admission-plan identity mismatch required recovery.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub backend_recoveries: Vec<ScanBackendRecoverySummary>,
191    /// Exact bounded static-recovery telemetry. Absent only in legacy reports
192    /// produced before JSON report schema 1.8.
193    #[serde(default, skip_serializing_if = "Option::is_none")]
194    pub static_recovery: Option<StaticRecoveryMetrics>,
195    /// KeyHog crate/binary version that produced the report.
196    pub keyhog_version: String,
197    /// Git identity of the binary that produced the report.
198    pub git_hash: String,
199    /// Digest of the embedded detector set compiled into the binary.
200    pub detector_digest: String,
201    /// Digest of the effective scan configuration, when the orchestrator had
202    /// a resolved configuration identity available.
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub config_digest: Option<String>,
205    /// Stable resolved preset and override manifest for mode comparisons.
206    /// Absent only for reports produced by callers that do not have a CLI scan
207    /// configuration (for example a library-created report).
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub resolved_scan: Option<ResolvedScanManifest>,
210    /// UTC generation timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
211    pub generated_at: String,
212    /// UTC scan start timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
213    pub scan_started_at: String,
214    /// UTC scan finish timestamp formatted as `YYYY-MM-DDTHH:MM:SS`.
215    pub scan_finished_at: String,
216    /// Wall-clock scan duration in milliseconds.
217    pub duration_ms: u128,
218    /// Redacted operator-visible target labels for the requested scan sources.
219    pub targets: Vec<String>,
220    /// Number of source chunks the scanner consumed for this report.
221    pub source_chunks_scanned: usize,
222    /// Number of source bytes the scanner consumed for this report.
223    pub source_bytes_scanned: u64,
224    /// Number of loaded detector specs used by this scan.
225    pub detector_count: usize,
226}
227
228/// Current major version for the versioned JSON report envelope.
229pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 1;
230/// Current minor version for the versioned JSON report envelope. Minor 9 adds
231/// the optional `correlations` array and minor 10 the optional
232/// `access_targets` object, each absent unless the producer opted in.
233pub const JSON_REPORT_SCHEMA_MINOR: u16 = 10;
234/// Current minor version for the versioned JSONL stream contract.
235pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 9;
236
237/// Version marker carried by every versioned JSON report.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
239pub struct JsonReportSchemaVersion {
240    /// Incompatible schema generation.
241    pub major: u16,
242    /// Backward-compatible additive revision.
243    pub minor: u16,
244}
245
246/// Versioned machine-readable JSON report.
247///
248/// A reader must reject an unsupported `major` and may accept any `minor`
249/// under a supported major because minor revisions only add optional fields.
250#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
251pub struct JsonReportEnvelope {
252    /// Version marker used to select the reader contract.
253    pub schema_version: JsonReportSchemaVersion,
254    /// Terminal state for the detached artifact, independent of process exit status.
255    #[serde(default)]
256    pub scan_status: ScanCompletionStatus,
257    /// Optional scan-wide metadata supplied by the producer.
258    #[serde(skip_serializing_if = "Option::is_none")]
259    pub metadata: Option<ScanReportMetadata>,
260    /// Non-zero source or scanner coverage gaps observed during the scan.
261    #[serde(default)]
262    pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
263    /// Findings in the same redacted shape used by the legacy array.
264    pub findings: Vec<VerifiedFinding>,
265    /// Cross-file credential correlations, present only when the producer ran
266    /// with correlation enabled. Absent, not empty, otherwise, so a report from
267    /// a default scan is byte-identical to one from before minor 9.
268    #[serde(default, skip_serializing_if = "Vec::is_empty")]
269    pub correlations: Vec<CorrelatedCredential>,
270    /// Access targets ("doors") derived from the findings, present only when
271    /// the producer ran with access-target association enabled. Absent, not
272    /// empty, otherwise, so a report from a default scan is byte-identical to
273    /// one from before minor 10.
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub access_targets: Option<AccessTargetReport>,
276}
277
278/// One scan-wide coverage gap preserved in a versioned JSON report.
279#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
280pub struct JsonReportCoverageGap {
281    /// Stable machine-readable reason shared with SARIF/HTML projections.
282    pub reason: String,
283    /// Number of affected files, chunks, or invariant events.
284    pub count: usize,
285}
286
287impl JsonReportEnvelope {
288    /// Parse and validate a versioned JSON report.
289    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/// Header written as the first record of a versioned JSONL stream.
303#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
304pub struct JsonlStreamHeader {
305    /// Distinguishes the stream header from a finding record.
306    pub record_type: String,
307    /// Version marker used to select the JSONL reader contract.
308    pub schema_version: JsonReportSchemaVersion,
309    /// Optional scan-wide metadata supplied by the producer.
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub metadata: Option<ScanReportMetadata>,
312}
313
314impl JsonlStreamHeader {
315    /// Construct a stream header for the current schema.
316    #[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    /// Parse and validate one JSONL header record.
329    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/// One validated segment of a JSONL input. Concatenated streams produce one
349/// segment per header, so boundaries remain explicit instead of being inferred
350/// from finding content.
351#[derive(Debug, Clone)]
352pub struct JsonlStream {
353    /// Header that governed this segment.
354    pub header: JsonlStreamHeader,
355    /// Terminal summary when the producer completed normally. None means
356    /// the input ended before completion and must not be treated as complete.
357    pub summary: Option<JsonlStreamSummary>,
358    /// Findings following the header until the next header or end of input.
359    pub findings: Vec<VerifiedFinding>,
360}
361
362/// Terminal record written when a versioned JSONL stream completes.
363#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
364pub struct JsonlStreamSummary {
365    /// Distinguishes the terminal record from headers and findings.
366    pub record_type: String,
367    /// Completion state for the stream.
368    pub status: String,
369    /// Terminal scan state; `status` remains the transport completion marker.
370    #[serde(default)]
371    pub scan_status: ScanCompletionStatus,
372    /// Number of finding records written before this summary.
373    pub finding_count: usize,
374    /// Coverage gaps observed during the stream.
375    #[serde(default)]
376    pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
377}
378
379impl JsonlStreamSummary {
380    /// Construct a complete summary for a stream.
381    #[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    /// Construct a complete summary using an explicitly recorded terminal
391    /// state from the scan metadata.
392    #[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    /// Whether the stream has a validated terminal summary.
428    #[must_use]
429    pub fn is_complete(&self) -> bool {
430        self.summary.is_some()
431    }
432}
433
434/// Parse one or more concatenated, versioned JSONL streams.
435pub 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
498/// Compatibility name for callers that used the original HTML-only type.
499///
500/// New code should use [`ScanReportMetadata`]. The alias is intentionally kept
501/// so a report-format migration does not break library consumers.
502pub type HtmlScanMetadata = ScanReportMetadata;
503
504/// The format-neutral input shared by every report renderer.
505///
506/// Renderers borrow findings so constructing a report does not copy a large
507/// finding set. Metadata is optional for the legacy [`write_report`] wrapper;
508/// production scan paths should pass it through [`write_scan_report`].
509#[derive(Debug, Clone, Copy)]
510pub struct ScanReport<'a> {
511    /// Findings after all scan filtering, suppression, and verification.
512    pub findings: &'a [VerifiedFinding],
513    /// Common scan identity and timing metadata, when the caller has it.
514    pub metadata: Option<&'a ScanReportMetadata>,
515    /// Cross-file credential correlations, empty unless the caller opted in.
516    ///
517    /// Correlations are derived from `findings` and never replace them, so an
518    /// empty slice reproduces the report exactly as it rendered before
519    /// correlation existed.
520    pub correlations: &'a [CorrelatedCredential],
521    /// Access targets derived from these findings, absent unless the caller
522    /// opted in.
523    ///
524    /// Like correlations, targets are derived from `findings` and never replace
525    /// them, so `None` reproduces the report exactly as it rendered before
526    /// access-target association existed.
527    pub access_targets: Option<&'a AccessTargetReport>,
528}
529
530impl<'a> ScanReport<'a> {
531    /// Create a report without optional metadata.
532    pub fn new(findings: &'a [VerifiedFinding]) -> Self {
533        Self {
534            findings,
535            metadata: None,
536            correlations: &[],
537            access_targets: None,
538        }
539    }
540
541    /// Attach the common scan metadata used by format projections.
542    #[must_use]
543    pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
544        self.metadata = Some(metadata);
545        self
546    }
547
548    /// Attach cross-file credential correlations computed from these findings.
549    #[must_use]
550    pub fn with_correlations(mut self, correlations: &'a [CorrelatedCredential]) -> Self {
551        self.correlations = correlations;
552        self
553    }
554
555    /// Attach access targets ("doors") computed from these findings.
556    #[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
563/// Output format and formatter options for [`write_report`].
564pub enum ReportFormat {
565    /// Human-oriented terminal output.
566    Text {
567        /// Emit ANSI color escapes.
568        color: bool,
569        /// Number of example suppression hints to include.
570        example_suppressions: usize,
571        /// Include dogfood telemetry hints in the text report.
572        dogfood_active: bool,
573        /// The scan read zero source bytes. The empty-findings summary must
574        /// then say the scan covered nothing rather than that nothing was
575        /// detected: a scan that examined no bytes has detected nothing in the
576        /// same way an unopened envelope contains no bad news.
577        covered_nothing: bool,
578        /// Matches dropped by the minified/vendored path policy. A subset of
579        /// `example_suppressions`, reported separately because a credential a
580        /// build pipeline inlined into a bundle is not an example key.
581        path_policy_suppressions: usize,
582    },
583    /// JSON array output.
584    Json,
585    /// Versioned JSON envelope output and its scan-wide coverage summary.
586    JsonEnvelope {
587        /// Non-zero source or scanner coverage gaps observed during the scan.
588        coverage_gap_summary: Vec<(String, usize)>,
589    },
590    /// Newline-delimited JSON output.
591    Jsonl,
592    /// Versioned newline-delimited JSON output with a stream header.
593    JsonlEnvelope {
594        /// Non-zero source or scanner coverage gaps observed during the scan.
595        coverage_gap_summary: Vec<(String, usize)>,
596    },
597    /// SARIF output.
598    Sarif {
599        /// Operator-visible scan coverage-gap summary entries.
600        skip_summary: Vec<(String, usize)>,
601    },
602    /// CSV output.
603    Csv,
604    /// GitHub Actions workflow command annotations.
605    GithubAnnotations,
606    /// GitHub Actions annotations with a terminal scan coverage notice.
607    GithubAnnotationsCoverage {
608        /// Non-zero source or scanner coverage gaps observed during the scan.
609        skip_summary: Vec<(String, usize)>,
610    },
611    /// GitLab SAST security report JSON.
612    GitlabSast {
613        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
614        scan_started_at: String,
615        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
616        scan_finished_at: String,
617    },
618    /// GitLab SAST output with scan-wide coverage status.
619    GitlabSastCoverage {
620        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
621        scan_started_at: String,
622        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
623        scan_finished_at: String,
624        /// Non-zero source or scanner coverage gaps observed during the scan.
625        skip_summary: Vec<(String, usize)>,
626    },
627    /// Self-contained HTML output.
628    Html {
629        /// Operator-visible scan coverage-gap summary entries (same data the
630        /// SARIF report surfaces), rendered as a "coverage" panel so the report
631        /// never reads as a clean bill of health when files went unscanned.
632        skip_summary: Vec<(String, usize)>,
633        /// Scan identity, timing, target, and size metadata for the report hero.
634        metadata: Option<HtmlScanMetadata>,
635    },
636    /// JUnit XML output.
637    Junit,
638    /// JUnit XML output with deterministic scan coverage properties.
639    JunitCoverage {
640        /// Non-zero source or scanner coverage gaps observed during the scan.
641        skip_summary: Vec<(String, usize)>,
642    },
643}
644
645/// Write a complete findings report in the requested format.
646pub 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
654/// Write a complete report from the shared scan model.
655///
656/// [`write_report`] remains as a compatibility wrapper for callers that only
657/// have findings. New scan paths should use this entrypoint so every renderer
658/// receives the same report object and metadata cannot be wired only to HTML.
659pub 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() // LAW10: absent report metadata means no recovery rows; findings and coverage status are unchanged
797}
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
809/// Write a CSV scan artifact with a self-describing scan-status preamble.
810///
811/// This dedicated entrypoint keeps the legacy [`ReportFormat::Csv`] enum
812/// variant and its header-first byte contract unchanged for library callers,
813/// while CLI scan artifacts can retain coverage state even when no finding row
814/// exists.
815pub 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
870/// Common trait for all finding reporters.
871pub(crate) trait Reporter: Send {
872    /// Report a single finding.
873    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
874
875    /// Finalize the report and flush buffered bytes.
876    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
890/// Implements [`WriterBackedReporter`] for a reporter whose only state behind
891/// the trait is a single `writer: W` field. Every reporter in this module is
892/// generic over `W: Write + Send` and exposes its writer identically, so the
893/// impl is purely mechanical, the macro keeps all nine reporters from drifting
894/// to nine subtly different spellings of the same three lines. Invoked as
895/// `impl_writer_backed!(CsvReporter);` inside each reporter's module, where both
896/// `Write` and `WriterBackedReporter` are already in scope.
897macro_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// `BufferedFindingReporter` was the legacy buffer-everything trait. The
910// SARIF reporter now streams results directly to its writer (audit
911// 2026-04-26 audit), so the trait has no callers and is removed. Other
912// reporters that still buffer (text, JSON-array) keep their state inline.