Skip to main content

keyhog_core/
finding.rs

1//! Scanner findings: raw matches and evidence-bearing, report-safe verdicts.
2
3// Debt bucket: 16 public items predating the crate floor raising `missing_docs`
4// to `warn`. Public output schema; remove once each carries a doc line.
5#![allow(missing_docs)]
6
7use serde::ser::SerializeStruct;
8use serde::{Deserialize, Serialize, Serializer};
9use std::borrow::Cow;
10use std::collections::{BTreeMap, HashMap};
11use std::sync::Arc;
12
13use crate::{EvidenceReasonCode, EvidenceVerdict, SensitiveString, Severity};
14
15/// SHA-256 digest of a credential.
16///
17/// This is intentionally distinct from other 32-byte digests in the system
18/// (Merkle content hashes, detector-set hashes, verifier cache internals). A
19/// credential hash can suppress findings, correlate reports, and cross detector
20/// boundaries; keeping it named prevents those contracts from blending into
21/// arbitrary `[u8; 32]` arrays.
22#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
23#[repr(transparent)]
24#[serde(transparent)]
25pub struct CredentialHash(#[serde(with = "serde_hash_hex")] [u8; 32]);
26
27impl CredentialHash {
28    /// All-zero hash used only as a compatibility sentinel for historical test
29    /// constructors and old serialized fixtures that omitted real hashes.
30    pub const ZERO: Self = Self([0; 32]);
31
32    /// Construct from raw SHA-256 bytes.
33    #[inline]
34    pub const fn from_bytes(bytes: [u8; 32]) -> Self {
35        Self(bytes)
36    }
37
38    /// Borrow the raw SHA-256 bytes.
39    #[inline]
40    pub const fn as_bytes(&self) -> &[u8; 32] {
41        &self.0
42    }
43
44    /// Return the raw SHA-256 bytes.
45    #[inline]
46    pub const fn into_bytes(self) -> [u8; 32] {
47        self.0
48    }
49
50    /// True when this value is the historical all-zero compatibility sentinel.
51    #[inline]
52    pub const fn is_zero(self) -> bool {
53        let mut idx = 0;
54        while idx < self.0.len() {
55            if self.0[idx] != 0 {
56                return false;
57            }
58            idx += 1;
59        }
60        true
61    }
62}
63
64impl From<[u8; 32]> for CredentialHash {
65    #[inline]
66    fn from(bytes: [u8; 32]) -> Self {
67        Self::from_bytes(bytes)
68    }
69}
70
71impl From<CredentialHash> for [u8; 32] {
72    #[inline]
73    fn from(hash: CredentialHash) -> Self {
74        hash.into_bytes()
75    }
76}
77
78impl AsRef<[u8; 32]> for CredentialHash {
79    #[inline]
80    fn as_ref(&self) -> &[u8; 32] {
81        self.as_bytes()
82    }
83}
84
85impl AsRef<[u8]> for CredentialHash {
86    #[inline]
87    fn as_ref(&self) -> &[u8] {
88        self.as_bytes()
89    }
90}
91
92impl std::fmt::Debug for CredentialHash {
93    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
94        f.write_str(&hex_encode(self))
95    }
96}
97
98/// Borrowed raw-match identity used before report-scope deduplication.
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub struct RawMatchDedupKey<'a> {
101    pub detector_id: &'a str,
102    pub credential: &'a str,
103}
104
105/// Companion values keyed by scanner-compiled, reference-counted names.
106///
107/// Names are immutable detector metadata. Sharing them keeps repeated findings
108/// from allocating and copying the same key while preserving the map's string
109/// keys at serialization and reporting boundaries.
110pub type CompanionMap = HashMap<Arc<str>, String>;
111
112mod serde_companion_map {
113    use super::CompanionMap;
114    use serde::ser::SerializeMap;
115    use serde::{Deserialize, Serializer};
116    use std::collections::HashMap;
117    use std::sync::Arc;
118
119    pub(super) fn serialize<S>(companions: &CompanionMap, serializer: S) -> Result<S::Ok, S::Error>
120    where
121        S: Serializer,
122    {
123        let mut sorted: Vec<(&str, &str)> = companions
124            .iter()
125            .map(|(name, value)| (name.as_ref(), value.as_str()))
126            .collect();
127        sorted.sort_by_key(|&(k, _)| k);
128        let mut map = serializer.serialize_map(Some(sorted.len()))?;
129        for (name, value) in sorted {
130            map.serialize_entry(name, value)?;
131        }
132        map.end()
133    }
134
135    pub(super) fn deserialize<'de, D>(deserializer: D) -> Result<CompanionMap, D::Error>
136    where
137        D: serde::Deserializer<'de>,
138    {
139        HashMap::<String, String>::deserialize(deserializer).map(|companions| {
140            companions
141                .into_iter()
142                .map(|(name, value)| (Arc::from(name), value))
143                .collect()
144        })
145    }
146}
147
148/// A raw pattern match before verification or deduplication.
149///
150/// `entropy` and `confidence` are stored as `f64` but are guaranteed never to
151/// be `NaN` (sanitized at construction time). This keeps the manual `Eq` impl
152/// reflexive, which downstream code relies on for `HashMap`/`BTreeMap` keys.
153///
154/// Serde is deliberately asymmetric: `Deserialize` accepts historical
155/// protected-wire input for compatibility, while implicit `Serialize` fails
156/// closed through `SensitiveString` and cannot emit plaintext. Manual `Debug`
157/// also redacts the credential. Use [`Self::to_redacted`] before any disk,
158/// network, log, or report output boundary.
159#[derive(Clone, Serialize, Deserialize)]
160pub struct RawMatch {
161    /// Stable detector identifier.
162    #[serde(with = "serde_arc_str")]
163    pub detector_id: Arc<str>,
164    /// Human-readable detector name.
165    #[serde(with = "serde_arc_str")]
166    pub detector_name: Arc<str>,
167    /// Service namespace associated with the detector.
168    #[serde(with = "serde_arc_str")]
169    pub service: Arc<str>,
170    /// Detector severity level.
171    pub severity: Severity,
172    /// Matched credential bytes before redaction.
173    pub credential: SensitiveString,
174    /// SHA-256 digest of the credential for allowlisting and deduplication.
175    ///
176    /// Stored as the raw 32 inline bytes (matching the verifier `CacheKey`),
177    /// never the 64-char hex `String`: zero heap, half the per-finding
178    /// footprint, no per-match allocation on the pre-dedup hot path. Hex
179    /// encoding happens lazily at the redacted/report boundary only.
180    pub credential_hash: CredentialHash,
181    /// Companion credential or context value extracted nearby.
182    #[serde(with = "serde_companion_map")]
183    pub companions: CompanionMap,
184    /// Source location for the match.
185    pub location: MatchLocation,
186    /// Shannon entropy of the matched credential (0.0 - 8.0). NaN-sanitized.
187    #[serde(skip_serializing_if = "Option::is_none")]
188    pub entropy: Option<f64>,
189    /// Confidence score (0.0 - 1.0). NaN-sanitized at construction.
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub confidence: Option<f64>,
192    /// Deterministic evidence verdict carried through deduplication and verification.
193    pub evidence: EvidenceVerdict,
194}
195
196impl RawMatch {
197    /// Replace NaN floats with `None` so the manual `Eq` impl stays reflexive
198    /// and `HashMap`/`BTreeMap` lookups don't trap. Call this on any externally
199    /// constructed `RawMatch` (deserialized findings, scanner outputs).
200    pub(crate) fn sanitize_floats(mut self) -> Self {
201        if self.entropy.is_some_and(f64::is_nan) {
202            self.entropy = None;
203        }
204        if self.confidence.is_some_and(f64::is_nan) {
205            self.confidence = None;
206        }
207        self
208    }
209}
210
211impl PartialEq for RawMatch {
212    fn eq(&self, other: &Self) -> bool {
213        // Compare every field; for the f64 options use `total_cmp` semantics so
214        // NaN-vs-NaN compares equal. We additionally normalize NaN→None on
215        // construction (`sanitize_floats`), but the total-ordering comparison
216        // here keeps the impl sound even if a NaN slips through.
217        self.detector_id == other.detector_id
218            && self.detector_name == other.detector_name
219            && self.service == other.service
220            && self.severity == other.severity
221            && self.credential == other.credential
222            && self.credential_hash == other.credential_hash
223            && self.companions == other.companions
224            && self.location == other.location
225            && opt_f64_total_eq(self.entropy, other.entropy)
226            && opt_f64_total_eq(self.confidence, other.confidence)
227            && self.evidence == other.evidence
228    }
229}
230
231impl Eq for RawMatch {}
232
233impl std::fmt::Debug for RawMatch {
234    /// Redacted Debug. Replaces `derive(Debug)` which would print the raw
235    /// credential plaintext. See kimi-wave1 audit finding 1.1.
236    /// `credential_hash` is preserved because it's already a one-way SHA-256.
237    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238        f.debug_struct("RawMatch")
239            .field("detector_id", &self.detector_id)
240            .field("detector_name", &self.detector_name)
241            .field("service", &self.service)
242            .field("severity", &self.severity)
243            .field(
244                "credential",
245                &format_args!("<redacted {} bytes>", self.credential.len()),
246            )
247            .field(
248                "credential_hash",
249                &format_args!("{}", hex_encode(self.credential_hash)),
250            )
251            .field(
252                "companions",
253                &format_args!("<{} redacted companions>", self.companions.len()),
254            )
255            .field("location", &self.location)
256            .field("entropy", &self.entropy)
257            .field("confidence", &self.confidence)
258            .field("evidence", &self.evidence)
259            .finish()
260    }
261}
262
263#[inline]
264fn opt_f64_total_eq(a: Option<f64>, b: Option<f64>) -> bool {
265    match (a, b) {
266        (None, None) => true,
267        (Some(x), Some(y)) => x.total_cmp(&y) == std::cmp::Ordering::Equal,
268        _ => false,
269    }
270}
271
272#[inline]
273fn opt_f64_total_cmp(a: Option<f64>, b: Option<f64>) -> std::cmp::Ordering {
274    match (a, b) {
275        (None, None) => std::cmp::Ordering::Equal,
276        (None, Some(_)) => std::cmp::Ordering::Less,
277        (Some(_), None) => std::cmp::Ordering::Greater,
278        (Some(x), Some(y)) => x.total_cmp(&y),
279    }
280}
281
282fn companion_map_cmp(a: &CompanionMap, b: &CompanionMap) -> std::cmp::Ordering {
283    if a == b {
284        return std::cmp::Ordering::Equal;
285    }
286    match a.len().cmp(&b.len()) {
287        std::cmp::Ordering::Equal => {}
288        ordering => return ordering,
289    }
290
291    // Companion sets are tiny and this path runs only after every priority key
292    // ties. Walk each map in lexical-key order without allocating comparator
293    // scratch; the O(n²) selection cost is bounded by companion count and avoids
294    // heap traffic inside BinaryHeap/sort comparators.
295    let mut a_after: Option<&str> = None;
296    let mut b_after: Option<&str> = None;
297    for _ in 0..a.len() {
298        let Some(a_entry) = a
299            .iter()
300            .filter(|(key, _)| a_after.is_none_or(|after| key.as_ref() > after))
301            .min_by(|left, right| left.0.cmp(right.0))
302        else {
303            return std::cmp::Ordering::Equal;
304        };
305        let Some(b_entry) = b
306            .iter()
307            .filter(|(key, _)| b_after.is_none_or(|after| key.as_ref() > after))
308            .min_by(|left, right| left.0.cmp(right.0))
309        else {
310            return std::cmp::Ordering::Equal;
311        };
312        match a_entry.cmp(&b_entry) {
313            std::cmp::Ordering::Equal => {
314                a_after = Some(a_entry.0.as_ref());
315                b_after = Some(b_entry.0.as_ref());
316            }
317            ordering => return ordering,
318        }
319    }
320    std::cmp::Ordering::Equal
321}
322
323impl PartialOrd for RawMatch {
324    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
325        Some(self.cmp(other))
326    }
327}
328
329impl Ord for RawMatch {
330    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
331        // Higher confidence first
332        // LAW10 (both): recall-safe, a `None` confidence sorts as 0.0 (lowest)
333        // for stable display ordering ONLY. Both findings remain in the result
334        // set; sort position never drops a finding.
335        let self_conf = self.confidence.unwrap_or(0.0); // LAW10: absent confidence => 0.0 for sort/partition ordering only; recall-safe
336        let other_conf = other.confidence.unwrap_or(0.0); // LAW10: absent confidence => 0.0 for sort/partition ordering only; recall-safe
337
338        match other_conf.total_cmp(&self_conf) {
339            std::cmp::Ordering::Equal => {}
340            ord => return ord,
341        }
342
343        // Then higher severity first (Critical > High > Medium > Low > Info)
344        match other.severity.cmp(&self.severity) {
345            std::cmp::Ordering::Equal => {}
346            ord => return ord,
347        }
348
349        // Then by detector and credential.
350        match self.detector_id.cmp(&other.detector_id) {
351            std::cmp::Ordering::Equal => {}
352            ord => return ord,
353        }
354        match self.credential.cmp(&other.credential) {
355            std::cmp::Ordering::Equal => {}
356            ord => return ord,
357        }
358
359        // Next by location (offset, then line) so the priority prefix is total
360        // with respect to the dedup identity (detector, credential, offset).
361        // Without this key, two matches of the same secret at different offsets
362        // compare Equal, so when the capped per-chunk match heap
363        // (`ScanState::push_match`) evicts among them at `max_matches_per_chunk`,
364        // the survivor is chosen by insertion order, which is HashMap-iteration
365        // and rayon-thread nondeterministic. A dense, repetitive chunk (e.g. the
366        // concat-source throughput corpus) overflows the cap with many such
367        // ties, so the finding set flickered run-to-run. Including the location
368        // makes eviction content-determined: the kept set is reproducible
369        // regardless of marking volume or thread interleaving.
370        match self.location.offset.cmp(&other.location.offset) {
371            std::cmp::Ordering::Equal => {}
372            ord => return ord,
373        }
374        match self.location.line.cmp(&other.location.line) {
375            std::cmp::Ordering::Equal => {}
376            ord => return ord,
377        }
378
379        // The priority keys above intentionally determine user-visible order.
380        // The remaining fields are deterministic identity tiebreakers only:
381        // `Ord` must compare Equal exactly when field-wise `Eq` does, otherwise
382        // BTree collections and unstable sorts can silently merge or reorder
383        // distinct findings.
384        self.detector_name
385            .cmp(&other.detector_name)
386            .then_with(|| self.service.cmp(&other.service))
387            .then_with(|| self.credential_hash.cmp(&other.credential_hash))
388            .then_with(|| companion_map_cmp(&self.companions, &other.companions))
389            .then_with(|| self.location.source.cmp(&other.location.source))
390            .then_with(|| self.location.file_path.cmp(&other.location.file_path))
391            .then_with(|| self.location.commit.cmp(&other.location.commit))
392            .then_with(|| self.location.author.cmp(&other.location.author))
393            .then_with(|| self.location.date.cmp(&other.location.date))
394            .then_with(|| opt_f64_total_cmp(self.entropy, other.entropy))
395            .then_with(|| opt_f64_total_cmp(self.confidence, other.confidence))
396            .then_with(|| {
397                (other.evidence.reason_code() as u8).cmp(&(self.evidence.reason_code() as u8))
398            })
399            .then_with(|| self.evidence.provenance().cmp(&other.evidence.provenance()))
400    }
401}
402
403/// Where a credential was found: file path, line number, commit, and author.
404#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
405pub struct MatchLocation {
406    /// Logical source backend, such as `filesystem` or `git`.
407    #[serde(with = "serde_arc_str")]
408    pub source: Arc<str>,
409    /// File path, object key, or logical path when available.
410    ///
411    /// Paths stored here must be valid UTF-8. Source implementations that see
412    /// non-UTF-8 paths should encode them into a reversible escaped string
413    /// before constructing a [`MatchLocation`].
414    #[serde(with = "serde_arc_str_opt")]
415    pub file_path: Option<Arc<str>>,
416    /// One-based line number when known.
417    pub line: Option<usize>,
418    /// Byte offset from the start of the source chunk.
419    pub offset: usize,
420    /// Commit identifier for history-derived matches.
421    #[serde(with = "serde_arc_str_opt")]
422    pub commit: Option<Arc<str>>,
423    /// Commit author when available.
424    #[serde(with = "serde_arc_str_opt")]
425    pub author: Option<Arc<str>>,
426    /// Commit timestamp when available.
427    #[serde(with = "serde_arc_str_opt")]
428    pub date: Option<Arc<str>>,
429}
430
431/// A finding after verification - the final output.
432#[derive(Debug, Clone, Deserialize)]
433pub struct VerifiedFinding {
434    /// Stable detector identifier.
435    #[serde(with = "serde_arc_str")]
436    pub detector_id: Arc<str>,
437    /// Human-readable detector name.
438    #[serde(with = "serde_arc_str")]
439    pub detector_name: Arc<str>,
440    /// Service namespace associated with the detector.
441    #[serde(with = "serde_arc_str")]
442    pub service: Arc<str>,
443    /// Detector severity level.
444    pub severity: Severity,
445    /// Redacted version of the credential for reporting.
446    pub credential_redacted: Cow<'static, str>,
447    /// SHA-256 digest of the original credential for internal correlation.
448    /// Raw 32 inline bytes; hex-encoded lazily at the serde/reporter boundary.
449    pub credential_hash: CredentialHash,
450    /// Redacted companion credentials or context values extracted nearby.
451    ///
452    /// Companion values follow the same boundary rule as the primary
453    /// credential: reports may expose a safe preview, never plaintext.
454    #[serde(default)]
455    pub companions_redacted: HashMap<String, String>,
456    /// Source location for the match.
457    pub location: MatchLocation,
458    /// Verification result.
459    pub verification: VerificationResult,
460    /// Additional provider-specific metadata (e.g. account ID, scope).
461    pub metadata: HashMap<String, String>,
462    /// Additional duplicate locations found for this credential.
463    pub additional_locations: Vec<MatchLocation>,
464    /// Shannon entropy measured by the detection path, when available.
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub entropy: Option<f64>,
467    /// Uncalibrated evidence score (0.0 - 1.0) retained for ranking and diagnostics.
468    #[serde(skip_serializing_if = "Option::is_none")]
469    pub evidence_score: Option<f64>,
470    /// Deterministic verdict and stable reason code for this finding.
471    pub evidence: EvidenceVerdict,
472}
473
474impl VerifiedFinding {
475    /// Construct the report-safe view of a deduplicated match.
476    ///
477    /// This is the single conversion boundary for verifier and skipped paths:
478    /// every new report field must be initialized here, while callers retain
479    /// ownership of policy-specific severity and verification decisions.
480    pub fn from_deduped(
481        group: crate::DedupedMatch,
482        severity: Severity,
483        verification: VerificationResult,
484        metadata: HashMap<String, String>,
485    ) -> Self {
486        let evidence = if matches!(verification, VerificationResult::Live) {
487            group
488                .evidence
489                .with_reason(EvidenceReasonCode::LiveVerification)
490        } else {
491            group.evidence
492        };
493        Self {
494            detector_id: group.detector_id,
495            detector_name: group.detector_name,
496            service: group.service,
497            severity,
498            credential_redacted: crate::redact(&group.credential),
499            credential_hash: group.credential_hash,
500            companions_redacted: redact_companions(&group.companions),
501            location: group.primary_location,
502            verification,
503            metadata,
504            additional_locations: group.additional_locations,
505            entropy: group.entropy,
506            evidence_score: group.confidence,
507            evidence,
508        }
509    }
510}
511
512impl Serialize for VerifiedFinding {
513    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
514    where
515        S: Serializer,
516    {
517        let remediation =
518            crate::auto_fix::remediation_for(&self.detector_id, &self.service, self.severity);
519        let mut field_count = 13;
520        if self.entropy.is_some() {
521            field_count += 1;
522        }
523        if self.evidence_score.is_some() {
524            field_count += 1;
525        }
526        let mut state = serializer.serialize_struct("VerifiedFinding", field_count)?;
527        state.serialize_field("detector_id", self.detector_id.as_ref())?;
528        state.serialize_field("detector_name", self.detector_name.as_ref())?;
529        state.serialize_field("service", self.service.as_ref())?;
530        state.serialize_field("severity", &self.severity)?;
531        state.serialize_field("credential_redacted", self.credential_redacted.as_ref())?;
532        state.serialize_field("credential_hash", &hex_encode(self.credential_hash))?;
533        let sorted_companions: BTreeMap<&str, &str> = self
534            .companions_redacted
535            .iter()
536            .map(|(key, value)| (key.as_str(), value.as_str()))
537            .collect();
538        state.serialize_field("companions_redacted", &sorted_companions)?;
539        state.serialize_field("location", &self.location)?;
540        state.serialize_field("verification", &self.verification)?;
541        let sorted_metadata: BTreeMap<&str, &str> = self
542            .metadata
543            .iter()
544            .map(|(key, value)| (key.as_str(), value.as_str()))
545            .collect();
546        state.serialize_field("metadata", &sorted_metadata)?;
547        state.serialize_field("additional_locations", &self.additional_locations)?;
548        state.serialize_field("evidence", &self.evidence)?;
549        if let Some(entropy) = self.entropy {
550            state.serialize_field("entropy", &entropy)?;
551        }
552        if let Some(evidence_score) = self.evidence_score {
553            state.serialize_field("evidence_score", &evidence_score)?;
554        }
555        state.serialize_field("remediation", &remediation)?;
556        state.end()
557    }
558}
559
560/// Result of live verification: whether the credential is active, revoked, or untested.
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
562#[serde(rename_all = "snake_case")]
563pub enum VerificationResult {
564    /// Credential is active and verified by the provider.
565    Live,
566    /// Credential is valid but has been explicitly revoked or disabled.
567    Revoked,
568    /// Credential was rejected by the provider (invalid password/token).
569    Dead,
570    /// Provider returned a rate-limit error (e.g. 429).
571    RateLimited,
572    /// Verification failed due to network error or timeout.
573    Error(String),
574    /// Detector does not support live verification.
575    Unverifiable,
576    /// Verification was not attempted (e.g. disabled via flag).
577    Skipped,
578}
579
580impl RawMatch {
581    /// Get the raw-match correlation key used before report scope is applied.
582    ///
583    /// This intentionally excludes location. Window/raw-span dedup uses
584    /// `(detector, credential, offset)` in the scanner, while report grouping
585    /// applies `DedupScope` in `core::dedup`.
586    pub(crate) fn deduplication_key(&self) -> RawMatchDedupKey<'_> {
587        RawMatchDedupKey {
588            detector_id: &self.detector_id,
589            credential: &self.credential,
590        }
591    }
592
593    /// Convert into a serialization-safe DTO that never carries the plaintext
594    /// credential. Use this anywhere a `RawMatch` would otherwise be written
595    /// to disk, sent over the network, or rendered into a user-visible
596    /// report. See kimi-wave1 audit finding 2.1 (`scan_system.rs` JSON exfil).
597    pub fn to_redacted(&self) -> RedactedFinding {
598        RedactedFinding {
599            detector_id: self.detector_id.clone(),
600            detector_name: self.detector_name.clone(),
601            service: self.service.clone(),
602            severity: self.severity,
603            credential_redacted: crate::redact(&self.credential),
604            credential_hash: self.credential_hash,
605            companions_redacted: redact_companions(&self.companions),
606            location: self.location.clone(),
607            entropy: self.entropy,
608            evidence_score: self.confidence,
609            evidence: self.evidence,
610        }
611    }
612}
613
614/// Redact every companion value at the process boundary.
615///
616/// Keeping this transformation centralized prevents verifier, offline scan,
617/// and serialization paths from accidentally diverging on companion safety.
618pub fn redact_companions<K>(companions: &HashMap<K, String>) -> HashMap<String, String>
619where
620    K: AsRef<str> + Eq + std::hash::Hash,
621{
622    companions
623        .iter()
624        .map(|(key, value)| (key.as_ref().to_string(), crate::redact(value).into_owned()))
625        .collect()
626}
627
628mod serde_sorted_string_map {
629    use serde::ser::SerializeMap;
630    use serde::Serializer;
631    use std::collections::HashMap;
632
633    pub fn serialize<S>(map: &HashMap<String, String>, serializer: S) -> Result<S::Ok, S::Error>
634    where
635        S: Serializer,
636    {
637        let mut sorted: Vec<(&str, &str)> =
638            map.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
639        sorted.sort_by_key(|&(k, _)| k);
640        let mut ser_map = serializer.serialize_map(Some(sorted.len()))?;
641        for (k, v) in sorted {
642            ser_map.serialize_entry(k, v)?;
643        }
644        ser_map.end()
645    }
646}
647
648/// Redacted, disk-safe view of a `RawMatch`. Carries only the SHA-256 hash
649/// and a "first4...last4" preview, never the plaintext credential. Use this
650/// before verification; [`VerifiedFinding`] is the final report-safe shape.
651#[derive(Debug, Clone, Serialize, Deserialize)]
652pub struct RedactedFinding {
653    #[serde(with = "serde_arc_str")]
654    pub detector_id: Arc<str>,
655    #[serde(with = "serde_arc_str")]
656    pub detector_name: Arc<str>,
657    #[serde(with = "serde_arc_str")]
658    pub service: Arc<str>,
659    pub severity: Severity,
660    pub credential_redacted: Cow<'static, str>,
661    /// SHA-256 digest as raw 32 inline bytes; hex-encoded at the serde boundary.
662    pub credential_hash: CredentialHash,
663    #[serde(serialize_with = "serde_sorted_string_map::serialize", default)]
664    pub companions_redacted: HashMap<String, String>,
665    pub location: MatchLocation,
666    #[serde(skip_serializing_if = "Option::is_none")]
667    pub entropy: Option<f64>,
668    /// Optional uncalibrated score retained beside the categorical verdict.
669    #[serde(skip_serializing_if = "Option::is_none")]
670    pub evidence_score: Option<f64>,
671    /// Deterministic evidence verdict for this redacted match.
672    pub evidence: EvidenceVerdict,
673}
674
675/// Lower-case hex of digest bytes. The only place the hex string is materialized
676/// for `CredentialHash` values (reporters, Debug).
677#[inline]
678pub fn hex_encode(bytes: impl AsRef<[u8]>) -> String {
679    hex::encode(bytes.as_ref())
680}
681
682/// SHA-256 of a string as the `CredentialHash` domain type. This is the single
683/// source for credential hashing across the workspace (scanner, dedup,
684/// telemetry); hex encoding is a separate step at the serde/reporter boundary
685/// via [`hex_encode`], keeping the pre-dedup hot path zero-heap.
686#[inline]
687pub fn sha256_hash(s: &str) -> CredentialHash {
688    use sha2::{Digest, Sha256};
689    let mut hasher = Sha256::new();
690    hasher.update(s.as_bytes());
691    CredentialHash::from_bytes(hasher.finalize().into())
692}
693
694/// Serde adapter keeping the on-wire shape of `credential_hash` a 64-char
695/// lower-case hex string while the in-memory field is raw `[u8; 32]`. This
696/// preserves the documented JSON/JSONL/baseline/SARIF format (`.credential_hash`
697/// consumers, `keyhogignore` `hash:` entries) with zero heap on the hot path.
698pub(crate) mod serde_hash_hex {
699    use std::borrow::Cow;
700
701    use serde::{Deserialize, Deserializer, Serializer};
702
703    pub(crate) fn serialize<S>(val: &[u8; 32], serializer: S) -> Result<S::Ok, S::Error>
704    where
705        S: Serializer,
706    {
707        serializer.serialize_str(&super::hex_encode(val))
708    }
709
710    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<[u8; 32], D::Error>
711    where
712        D: Deserializer<'de>,
713    {
714        let s = Cow::<'de, str>::deserialize(deserializer)?;
715        if s.len() != crate::git_lfs::SHA256_HEX_LEN {
716            return Err(serde::de::Error::invalid_length(
717                s.len(),
718                &"64-char hex SHA-256 digest",
719            ));
720        }
721        let mut bytes = [0_u8; 32];
722        hex::decode_to_slice(s.as_bytes(), &mut bytes).map_err(serde::de::Error::custom)?;
723        Ok(bytes)
724    }
725}
726
727/// Convert a borrowed-or-owned string into an `Arc<str>` without an extra copy
728/// when the value is already owned. Single owner shared by `serde_arc_str` and
729/// `serde_arc_str_opt` deserialization.
730#[inline]
731fn arc_from_cow(value: Cow<'_, str>) -> Arc<str> {
732    match value {
733        Cow::Borrowed(value) => Arc::from(value),
734        Cow::Owned(value) => Arc::from(value),
735    }
736}
737
738pub(crate) mod serde_arc_str {
739    use serde::{Deserialize, Deserializer, Serialize, Serializer};
740    use std::borrow::Cow;
741    use std::sync::Arc;
742
743    pub(crate) fn serialize<S>(val: &Arc<str>, serializer: S) -> Result<S::Ok, S::Error>
744    where
745        S: Serializer,
746    {
747        val.as_ref().serialize(serializer)
748    }
749
750    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Arc<str>, D::Error>
751    where
752        D: Deserializer<'de>,
753    {
754        Cow::<'de, str>::deserialize(deserializer).map(super::arc_from_cow)
755    }
756}
757
758pub(crate) mod serde_arc_str_opt {
759    use serde::{Deserialize, Deserializer, Serialize, Serializer};
760    use std::borrow::Cow;
761    use std::sync::Arc;
762
763    pub(crate) fn serialize<S>(val: &Option<Arc<str>>, serializer: S) -> Result<S::Ok, S::Error>
764    where
765        S: Serializer,
766    {
767        val.as_ref().map(|s| s.as_ref()).serialize(serializer)
768    }
769
770    pub(crate) fn deserialize<'de, D>(deserializer: D) -> Result<Option<Arc<str>>, D::Error>
771    where
772        D: Deserializer<'de>,
773    {
774        Option::<Cow<'de, str>>::deserialize(deserializer).map(|opt| opt.map(super::arc_from_cow))
775    }
776}
777
778// Tests live in `tests/unit/finding_arc_str_serde_roundtrip.rs` (KH-GAP-004: no
779// inline test modules in `src/`). The `arc_from_cow` deserialize helper is
780// exercised end-to-end through the public `RawMatch` serde round-trip (its
781// `Arc<str>` fields use `serde_arc_str`, which calls it).