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. Version 2
229/// replaces the ambiguous finding `confidence` field with a required evidence
230/// verdict and optional `evidence_score`.
231pub const JSON_REPORT_SCHEMA_MAJOR: u16 = 2;
232/// Current minor version for the versioned JSON report envelope.
233pub const JSON_REPORT_SCHEMA_MINOR: u16 = 0;
234/// Current minor version for the versioned JSONL stream contract.
235pub const JSONL_REPORT_SCHEMA_MINOR: u16 = 0;
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 bare JSON array.
264    pub findings: Vec<VerifiedFinding>,
265    /// Cross-file credential correlations, present only when the producer ran
266    /// with correlation enabled. Default scans omit the field.
267    #[serde(default, skip_serializing_if = "Vec::is_empty")]
268    pub correlations: Vec<CorrelatedCredential>,
269    /// Access targets ("doors") derived from the findings, present only when
270    /// the producer ran with access-target association enabled. Default scans omit
271    /// the field.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub access_targets: Option<AccessTargetReport>,
274}
275
276/// One scan-wide coverage gap preserved in a versioned JSON report.
277#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
278pub struct JsonReportCoverageGap {
279    /// Stable machine-readable reason shared with SARIF/HTML projections.
280    pub reason: String,
281    /// Number of affected files, chunks, or invariant events.
282    pub count: usize,
283}
284
285impl JsonReportEnvelope {
286    /// Parse and validate a versioned JSON report.
287    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/// Header written as the first record of a versioned JSONL stream.
306#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
307pub struct JsonlStreamHeader {
308    /// Distinguishes the stream header from a finding record.
309    pub record_type: String,
310    /// Version marker used to select the JSONL reader contract.
311    pub schema_version: JsonReportSchemaVersion,
312    /// Optional scan-wide metadata supplied by the producer.
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub metadata: Option<ScanReportMetadata>,
315}
316
317impl JsonlStreamHeader {
318    /// Construct a stream header for the current schema.
319    #[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    /// Parse and validate one JSONL header record.
332    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/// One validated segment of a JSONL input. Concatenated streams produce one
352/// segment per header, so boundaries remain explicit instead of being inferred
353/// from finding content.
354#[derive(Debug, Clone)]
355pub struct JsonlStream {
356    /// Header that governed this segment.
357    pub header: JsonlStreamHeader,
358    /// Terminal summary when the producer completed normally. None means
359    /// the input ended before completion and must not be treated as complete.
360    pub summary: Option<JsonlStreamSummary>,
361    /// Findings following the header until the next header or end of input.
362    pub findings: Vec<VerifiedFinding>,
363}
364
365/// Terminal record written when a versioned JSONL stream completes.
366#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
367pub struct JsonlStreamSummary {
368    /// Distinguishes the terminal record from headers and findings.
369    pub record_type: String,
370    /// Completion state for the stream.
371    pub status: String,
372    /// Terminal scan state; `status` remains the transport completion marker.
373    #[serde(default)]
374    pub scan_status: ScanCompletionStatus,
375    /// Number of finding records written before this summary.
376    pub finding_count: usize,
377    /// Coverage gaps observed during the stream.
378    #[serde(default)]
379    pub coverage_gap_summary: Vec<JsonReportCoverageGap>,
380}
381
382impl JsonlStreamSummary {
383    /// Construct a complete summary for a stream.
384    #[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    /// Construct a complete summary using an explicitly recorded terminal
394    /// state from the scan metadata.
395    #[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    /// Whether the stream has a validated terminal summary.
431    #[must_use]
432    pub fn is_complete(&self) -> bool {
433        self.summary.is_some()
434    }
435}
436
437/// Parse one or more concatenated, versioned JSONL streams.
438pub 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
501/// Compatibility name for callers that used the original HTML-only type.
502///
503/// New code should use [`ScanReportMetadata`]. The alias is intentionally kept
504/// so a report-format migration does not break library consumers.
505pub type HtmlScanMetadata = ScanReportMetadata;
506
507/// The format-neutral input shared by every report renderer.
508///
509/// Renderers borrow findings so constructing a report does not copy a large
510/// finding set. Metadata is optional for the legacy [`write_report`] wrapper;
511/// production scan paths should pass it through [`write_scan_report`].
512#[derive(Debug, Clone, Copy)]
513pub struct ScanReport<'a> {
514    /// Findings after all scan filtering, suppression, and verification.
515    pub findings: &'a [VerifiedFinding],
516    /// Common scan identity and timing metadata, when the caller has it.
517    pub metadata: Option<&'a ScanReportMetadata>,
518    /// Cross-file credential correlations, empty unless the caller opted in.
519    ///
520    /// Correlations are derived from `findings` and never replace them, so an
521    /// empty slice reproduces the report exactly as it rendered before
522    /// correlation existed.
523    pub correlations: &'a [CorrelatedCredential],
524    /// Access targets derived from these findings, absent unless the caller
525    /// opted in.
526    ///
527    /// Like correlations, targets are derived from `findings` and never replace
528    /// them, so `None` reproduces the report exactly as it rendered before
529    /// access-target association existed.
530    pub access_targets: Option<&'a AccessTargetReport>,
531}
532
533impl<'a> ScanReport<'a> {
534    /// Create a report without optional metadata.
535    pub fn new(findings: &'a [VerifiedFinding]) -> Self {
536        Self {
537            findings,
538            metadata: None,
539            correlations: &[],
540            access_targets: None,
541        }
542    }
543
544    /// Attach the common scan metadata used by format projections.
545    #[must_use]
546    pub fn with_metadata(mut self, metadata: &'a ScanReportMetadata) -> Self {
547        self.metadata = Some(metadata);
548        self
549    }
550
551    /// Attach cross-file credential correlations computed from these findings.
552    #[must_use]
553    pub fn with_correlations(mut self, correlations: &'a [CorrelatedCredential]) -> Self {
554        self.correlations = correlations;
555        self
556    }
557
558    /// Attach access targets ("doors") computed from these findings.
559    #[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
566/// Output format and formatter options for [`write_report`].
567pub enum ReportFormat {
568    /// Human-oriented terminal output.
569    Text {
570        /// Emit ANSI color escapes.
571        color: bool,
572        /// Number of example suppression hints to include.
573        example_suppressions: usize,
574        /// Include dogfood telemetry hints in the text report.
575        dogfood_active: bool,
576        /// The scan read zero source bytes. The empty-findings summary must
577        /// then say the scan covered nothing rather than that nothing was
578        /// detected: a scan that examined no bytes has detected nothing in the
579        /// same way an unopened envelope contains no bad news.
580        covered_nothing: bool,
581        /// Matches dropped by the minified/vendored path policy. A subset of
582        /// `example_suppressions`, reported separately because a credential a
583        /// build pipeline inlined into a bundle is not an example key.
584        path_policy_suppressions: usize,
585    },
586    /// JSON array output.
587    Json,
588    /// Versioned JSON envelope output and its scan-wide coverage summary.
589    JsonEnvelope {
590        /// Non-zero source or scanner coverage gaps observed during the scan.
591        coverage_gap_summary: Vec<(String, usize)>,
592    },
593    /// Newline-delimited JSON output.
594    Jsonl,
595    /// Versioned newline-delimited JSON output with a stream header.
596    JsonlEnvelope {
597        /// Non-zero source or scanner coverage gaps observed during the scan.
598        coverage_gap_summary: Vec<(String, usize)>,
599    },
600    /// SARIF output.
601    Sarif {
602        /// Operator-visible scan coverage-gap summary entries.
603        skip_summary: Vec<(String, usize)>,
604    },
605    /// CSV output.
606    Csv,
607    /// GitHub Actions workflow command annotations.
608    GithubAnnotations,
609    /// GitHub Actions annotations with a terminal scan coverage notice.
610    GithubAnnotationsCoverage {
611        /// Non-zero source or scanner coverage gaps observed during the scan.
612        skip_summary: Vec<(String, usize)>,
613    },
614    /// GitLab SAST security report JSON.
615    GitlabSast {
616        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
617        scan_started_at: String,
618        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
619        scan_finished_at: String,
620    },
621    /// GitLab SAST output with scan-wide coverage status.
622    GitlabSastCoverage {
623        /// UTC scan start time formatted as `YYYY-MM-DDTHH:MM:SS`.
624        scan_started_at: String,
625        /// UTC scan end time formatted as `YYYY-MM-DDTHH:MM:SS`.
626        scan_finished_at: String,
627        /// Non-zero source or scanner coverage gaps observed during the scan.
628        skip_summary: Vec<(String, usize)>,
629    },
630    /// Self-contained HTML output.
631    Html {
632        /// Operator-visible scan coverage-gap summary entries (same data the
633        /// SARIF report surfaces), rendered as a "coverage" panel so the report
634        /// never reads as a clean bill of health when files went unscanned.
635        skip_summary: Vec<(String, usize)>,
636        /// Scan identity, timing, target, and size metadata for the report hero.
637        metadata: Option<HtmlScanMetadata>,
638    },
639    /// JUnit XML output.
640    Junit,
641    /// JUnit XML output with deterministic scan coverage properties.
642    JunitCoverage {
643        /// Non-zero source or scanner coverage gaps observed during the scan.
644        skip_summary: Vec<(String, usize)>,
645    },
646}
647
648/// Write a complete findings report in the requested format.
649pub 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
657/// Write a complete report from the shared scan model.
658///
659/// [`write_report`] remains as a compatibility wrapper for callers that only
660/// have findings. New scan paths should use this entrypoint so every renderer
661/// receives the same report object and metadata cannot be wired only to HTML.
662pub 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() // LAW10: absent report metadata means no recovery rows; findings and coverage status are unchanged
800}
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
812/// Write a CSV scan artifact with a self-describing scan-status preamble.
813///
814/// This dedicated entrypoint keeps the legacy [`ReportFormat::Csv`] enum
815/// variant and its header-first byte contract unchanged for library callers,
816/// while CLI scan artifacts can retain coverage state even when no finding row
817/// exists.
818pub 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
873/// Common trait for all finding reporters.
874pub(crate) trait Reporter: Send {
875    /// Report a single finding.
876    fn report(&mut self, finding: &VerifiedFinding) -> Result<(), ReportError>;
877
878    /// Finalize the report and flush buffered bytes.
879    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
893/// Implements [`WriterBackedReporter`] for a reporter whose only state behind
894/// the trait is a single `writer: W` field. Every reporter in this module is
895/// generic over `W: Write + Send` and exposes its writer identically, so the
896/// impl is purely mechanical, the macro keeps all nine reporters from drifting
897/// to nine subtly different spellings of the same three lines. Invoked as
898/// `impl_writer_backed!(CsvReporter);` inside each reporter's module, where both
899/// `Write` and `WriterBackedReporter` are already in scope.
900macro_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// `BufferedFindingReporter` was the legacy buffer-everything trait. The
921// SARIF reporter now streams results directly to its writer (audit
922// 2026-04-26 audit), so the trait has no callers and is removed. Other
923// reporters that still buffer (text, JSON-array) keep their state inline.