Skip to main content

keyhog_core/
evidence.rs

1//! Deterministic finding verdicts derived from scanner evidence.
2
3use serde::{de::Error as _, ser::Error as _, Deserialize, Deserializer, Serialize, Serializer};
4
5/// Operator-facing evidence tier for one finding.
6#[repr(u8)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "kebab-case")]
9pub enum EvidenceTier {
10    /// Ambiguous evidence that remains visible but does not block default CI.
11    Review,
12    /// Strong provider and source evidence that blocks default CI.
13    Likely,
14    /// Intrinsic or live proof that blocks every finding policy.
15    Confirmed,
16}
17
18impl EvidenceTier {
19    /// Return the stable output spelling.
20    pub const fn as_str(self) -> &'static str {
21        match self {
22            Self::Review => "review",
23            Self::Likely => "likely",
24            Self::Confirmed => "confirmed",
25        }
26    }
27
28    /// Whether this tier blocks the selected CI policy.
29    pub const fn blocks(self, paranoid: bool) -> bool {
30        paranoid || !matches!(self, Self::Review)
31    }
32}
33
34/// Stable reason code that determines a finding's evidence tier.
35#[repr(u8)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(rename_all = "kebab-case")]
38pub enum EvidenceReasonCode {
39    /// No scanner producer evidence was available.
40    Unattributed,
41    /// The source syntax was unsupported, malformed, or outside parser bounds.
42    UnsupportedContext,
43    /// A declared semantic evidence requirement was not proven.
44    RequiredEvidenceMissing,
45    /// The detector declared only a weak or unanchored context.
46    WeakAnchor,
47    /// A generic detector pattern produced the candidate.
48    GenericDetector,
49    /// The generic assignment lane produced the candidate.
50    GenericAssignment,
51    /// The entropy-only lane produced the candidate.
52    EntropyOnly,
53    /// The candidate is in a test or example fixture.
54    TestFixture,
55    /// The candidate is in documentation prose.
56    Documentation,
57    /// The candidate is inside a regex, scanner rule, or grammar definition.
58    RuleDefinition,
59    /// The candidate is an identifier, type, or member name.
60    Identifier,
61    /// The candidate is a command-option declaration rather than its value.
62    OptionDeclaration,
63    /// The candidate is in generated or vendored material.
64    GeneratedMaterial,
65    /// Parsed source semantics do not match the detector's declared source roles.
66    SourceRoleMismatch,
67    /// A provider-specific named pattern matched in a credential-bearing source role.
68    VendorPattern,
69    /// A detector-owned structural grammar proved the credential shape.
70    StructuralGrammar,
71    /// Required companion evidence was present.
72    RequiredCompanion,
73    /// An intrinsic credential checksum validated.
74    ChecksumValid,
75    /// Live provider verification succeeded.
76    LiveVerification,
77}
78
79impl EvidenceReasonCode {
80    /// Return the tier implied by this reason code.
81    pub const fn tier(self) -> EvidenceTier {
82        match self {
83            Self::VendorPattern => EvidenceTier::Likely,
84            Self::StructuralGrammar
85            | Self::RequiredCompanion
86            | Self::ChecksumValid
87            | Self::LiveVerification => EvidenceTier::Confirmed,
88            Self::Unattributed
89            | Self::UnsupportedContext
90            | Self::RequiredEvidenceMissing
91            | Self::WeakAnchor
92            | Self::GenericDetector
93            | Self::GenericAssignment
94            | Self::EntropyOnly
95            | Self::TestFixture
96            | Self::Documentation
97            | Self::RuleDefinition
98            | Self::Identifier
99            | Self::OptionDeclaration
100            | Self::GeneratedMaterial
101            | Self::SourceRoleMismatch => EvidenceTier::Review,
102        }
103    }
104
105    /// Return the stable output spelling.
106    pub const fn as_str(self) -> &'static str {
107        match self {
108            Self::Unattributed => "unattributed",
109            Self::UnsupportedContext => "unsupported-context",
110            Self::RequiredEvidenceMissing => "required-evidence-missing",
111            Self::WeakAnchor => "weak-anchor",
112            Self::GenericDetector => "generic-detector",
113            Self::GenericAssignment => "generic-assignment",
114            Self::EntropyOnly => "entropy-only",
115            Self::TestFixture => "test-fixture",
116            Self::Documentation => "documentation",
117            Self::RuleDefinition => "rule-definition",
118            Self::Identifier => "identifier",
119            Self::OptionDeclaration => "option-declaration",
120            Self::GeneratedMaterial => "generated-material",
121            Self::SourceRoleMismatch => "source-role-mismatch",
122            Self::VendorPattern => "vendor-pattern",
123            Self::StructuralGrammar => "structural-grammar",
124            Self::RequiredCompanion => "required-companion",
125            Self::ChecksumValid => "checksum-valid",
126            Self::LiveVerification => "live-verification",
127        }
128    }
129}
130
131/// Scanner lane that produced a finding candidate.
132#[repr(u8)]
133#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
134#[serde(rename_all = "kebab-case")]
135pub enum FindingCandidateChannel {
136    /// A detector-owned named pattern.
137    Pattern,
138    /// The generic credential assignment lane.
139    GenericAssignment,
140    /// The detector-owned entropy lane.
141    Entropy,
142    /// A caller-created finding without scanner provenance.
143    Unattributed,
144}
145
146impl FindingCandidateChannel {
147    /// Return the stable output spelling.
148    pub const fn as_str(self) -> &'static str {
149        match self {
150            Self::Pattern => "pattern",
151            Self::GenericAssignment => "generic-assignment",
152            Self::Entropy => "entropy",
153            Self::Unattributed => "unattributed",
154        }
155    }
156}
157
158/// Secret-safe candidate identity retained in public evidence.
159#[repr(C)]
160#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
161pub struct FindingProvenance {
162    detector_digest: u64,
163    pattern_index: u32,
164    candidate_channel: FindingCandidateChannel,
165    source_role: crate::SemanticSourceRole,
166    context_class: EvidenceReasonCode,
167}
168
169impl FindingProvenance {
170    /// Current persisted provenance schema.
171    pub const SCHEMA_VERSION: u8 = 1;
172
173    const fn has_scanner_context(context_class: EvidenceReasonCode) -> bool {
174        !matches!(
175            context_class,
176            EvidenceReasonCode::Unattributed | EvidenceReasonCode::LiveVerification
177        )
178    }
179
180    const fn fields_are_consistent(self) -> bool {
181        match self.candidate_channel {
182            FindingCandidateChannel::Pattern
183            | FindingCandidateChannel::GenericAssignment
184            | FindingCandidateChannel::Entropy => Self::has_scanner_context(self.context_class),
185            FindingCandidateChannel::Unattributed => {
186                self.detector_digest == 0
187                    && self.pattern_index == 0
188                    && matches!(self.source_role, crate::SemanticSourceRole::Unknown)
189                    && matches!(self.context_class, EvidenceReasonCode::Unattributed)
190            }
191        }
192    }
193
194    /// Construct provenance for a scanner-owned named pattern.
195    pub const fn pattern(
196        detector_digest: u64,
197        pattern_index: u32,
198        source_role: crate::SemanticSourceRole,
199        context_class: EvidenceReasonCode,
200    ) -> Self {
201        Self {
202            detector_digest,
203            pattern_index,
204            candidate_channel: FindingCandidateChannel::Pattern,
205            source_role,
206            context_class,
207        }
208    }
209
210    /// Construct provenance for the generic assignment lane.
211    pub const fn generic_assignment(
212        detector_digest: u64,
213        source_role: crate::SemanticSourceRole,
214        context_class: EvidenceReasonCode,
215    ) -> Self {
216        Self::lane(
217            detector_digest,
218            FindingCandidateChannel::GenericAssignment,
219            source_role,
220            context_class,
221        )
222    }
223
224    /// Construct provenance for the entropy lane.
225    pub const fn entropy(
226        detector_digest: u64,
227        source_role: crate::SemanticSourceRole,
228        context_class: EvidenceReasonCode,
229    ) -> Self {
230        Self::lane(
231            detector_digest,
232            FindingCandidateChannel::Entropy,
233            source_role,
234            context_class,
235        )
236    }
237
238    const fn lane(
239        detector_digest: u64,
240        candidate_channel: FindingCandidateChannel,
241        source_role: crate::SemanticSourceRole,
242        context_class: EvidenceReasonCode,
243    ) -> Self {
244        Self {
245            detector_digest,
246            pattern_index: 0,
247            candidate_channel,
248            source_role,
249            context_class,
250        }
251    }
252
253    /// Construct provenance for a caller-created finding.
254    pub const fn unattributed() -> Self {
255        Self {
256            detector_digest: 0,
257            pattern_index: 0,
258            candidate_channel: FindingCandidateChannel::Unattributed,
259            source_role: crate::SemanticSourceRole::Unknown,
260            context_class: EvidenceReasonCode::Unattributed,
261        }
262    }
263
264    /// Return the active detector-corpus digest.
265    pub const fn detector_digest(self) -> Option<u64> {
266        if matches!(
267            self.candidate_channel,
268            FindingCandidateChannel::Unattributed
269        ) {
270            None
271        } else {
272            Some(self.detector_digest)
273        }
274    }
275
276    /// Return the detector-local source pattern ordinal.
277    pub const fn pattern_index(self) -> Option<u32> {
278        if matches!(self.candidate_channel, FindingCandidateChannel::Pattern) {
279            Some(self.pattern_index)
280        } else {
281            None
282        }
283    }
284
285    /// Return the candidate producer lane.
286    pub const fn candidate_channel(self) -> FindingCandidateChannel {
287        self.candidate_channel
288    }
289
290    /// Return the parsed source role.
291    pub const fn source_role(self) -> crate::SemanticSourceRole {
292        self.source_role
293    }
294
295    /// Return the pre-verification evidence context.
296    pub const fn context_class(self) -> EvidenceReasonCode {
297        self.context_class
298    }
299}
300
301impl Serialize for FindingProvenance {
302    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
303    where
304        S: Serializer,
305    {
306        if !self.fields_are_consistent() {
307            return Err(S::Error::custom(
308                "finding provenance fields are inconsistent with candidate_channel",
309            ));
310        }
311        #[derive(Serialize)]
312        struct WireProvenance<'a> {
313            schema_version: u8,
314            detector_digest: Option<&'a str>,
315            pattern_index: Option<u32>,
316            candidate_channel: FindingCandidateChannel,
317            source_role: crate::SemanticSourceRole,
318            context_class: EvidenceReasonCode,
319        }
320
321        const HEX: &[u8; 16] = b"0123456789abcdef";
322        let mut digest_hex = [b'0'; 16];
323        let detector_digest = if let Some(digest) = self.detector_digest() {
324            for (index, digit) in digest_hex.iter_mut().enumerate() {
325                let shift = (15 - index) * 4;
326                *digit = HEX[((digest >> shift) & 0x0f) as usize];
327            }
328            Some(std::str::from_utf8(&digest_hex).map_err(S::Error::custom)?)
329        } else {
330            None
331        };
332        WireProvenance {
333            schema_version: Self::SCHEMA_VERSION,
334            detector_digest,
335            pattern_index: self.pattern_index(),
336            candidate_channel: self.candidate_channel,
337            source_role: self.source_role,
338            context_class: self.context_class,
339        }
340        .serialize(serializer)
341    }
342}
343
344impl<'de> Deserialize<'de> for FindingProvenance {
345    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
346    where
347        D: Deserializer<'de>,
348    {
349        fn required_nullable<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
350        where
351            D: Deserializer<'de>,
352            T: Deserialize<'de>,
353        {
354            Option::<T>::deserialize(deserializer)
355        }
356
357        #[derive(Deserialize)]
358        #[serde(deny_unknown_fields)]
359        struct WireProvenance {
360            schema_version: u8,
361            #[serde(deserialize_with = "required_nullable")]
362            detector_digest: Option<String>,
363            #[serde(deserialize_with = "required_nullable")]
364            pattern_index: Option<u32>,
365            candidate_channel: FindingCandidateChannel,
366            source_role: crate::SemanticSourceRole,
367            context_class: EvidenceReasonCode,
368        }
369
370        let wire = WireProvenance::deserialize(deserializer)?;
371        if wire.schema_version != Self::SCHEMA_VERSION {
372            return Err(D::Error::custom(format!(
373                "unsupported finding provenance schema {}; expected {}",
374                wire.schema_version,
375                Self::SCHEMA_VERSION
376            )));
377        }
378        let detector_digest = wire
379            .detector_digest
380            .map(|digest| {
381                if digest.len() != 16
382                    || !digest
383                        .bytes()
384                        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
385                {
386                    return Err(D::Error::custom(
387                        "finding provenance detector_digest must be 16 lowercase hex digits",
388                    ));
389                }
390                u64::from_str_radix(&digest, 16).map_err(D::Error::custom)
391            })
392            .transpose()?;
393        let has_scanner_context = Self::has_scanner_context(wire.context_class);
394        let fields_are_consistent = match wire.candidate_channel {
395            FindingCandidateChannel::Pattern => {
396                detector_digest.is_some() && wire.pattern_index.is_some() && has_scanner_context
397            }
398            FindingCandidateChannel::GenericAssignment | FindingCandidateChannel::Entropy => {
399                detector_digest.is_some() && wire.pattern_index.is_none() && has_scanner_context
400            }
401            FindingCandidateChannel::Unattributed => {
402                detector_digest.is_none()
403                    && wire.pattern_index.is_none()
404                    && matches!(wire.source_role, crate::SemanticSourceRole::Unknown)
405                    && matches!(wire.context_class, EvidenceReasonCode::Unattributed)
406            }
407        };
408        if !fields_are_consistent {
409            return Err(D::Error::custom(
410                "finding provenance fields are inconsistent with candidate_channel",
411            ));
412        }
413        Ok(Self {
414            detector_digest: detector_digest.unwrap_or(0),
415            pattern_index: wire.pattern_index.unwrap_or(0),
416            candidate_channel: wire.candidate_channel,
417            source_role: wire.source_role,
418            context_class: wire.context_class,
419        })
420    }
421}
422
423/// One internally consistent finding verdict.
424#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
425pub struct EvidenceVerdict {
426    reason_code: EvidenceReasonCode,
427    provenance: FindingProvenance,
428}
429
430impl EvidenceVerdict {
431    /// Construct a verdict from its canonical reason code.
432    pub const fn from_reason(reason_code: EvidenceReasonCode) -> Self {
433        Self {
434            reason_code,
435            provenance: FindingProvenance::unattributed(),
436        }
437    }
438
439    /// Compatibility verdict for a caller-created finding with no producer proof.
440    pub const fn review_unattributed() -> Self {
441        Self::from_reason(EvidenceReasonCode::Unattributed)
442    }
443
444    /// Attach exact secret-safe scanner provenance.
445    pub const fn with_provenance(mut self, provenance: FindingProvenance) -> Self {
446        self.provenance = provenance;
447        self
448    }
449
450    /// Replace the final reason while retaining candidate provenance.
451    pub const fn with_reason(mut self, reason_code: EvidenceReasonCode) -> Self {
452        self.reason_code = reason_code;
453        self
454    }
455
456    /// Return the derived evidence tier.
457    pub const fn tier(self) -> EvidenceTier {
458        self.reason_code.tier()
459    }
460
461    /// Return the canonical reason code.
462    pub const fn reason_code(self) -> EvidenceReasonCode {
463        self.reason_code
464    }
465
466    /// Return the exact secret-safe candidate provenance.
467    pub const fn provenance(self) -> FindingProvenance {
468        self.provenance
469    }
470
471    /// Select the stronger verdict, with stable reason and provenance tiebreaks.
472    pub fn stronger(self, other: Self) -> Self {
473        let self_tier = self.tier() as u8;
474        let other_tier = other.tier() as u8;
475        let same_reason = other_tier == self_tier && other.reason_code == self.reason_code;
476        let self_attributed = !matches!(
477            self.provenance.candidate_channel(),
478            FindingCandidateChannel::Unattributed
479        );
480        let other_attributed = !matches!(
481            other.provenance.candidate_channel(),
482            FindingCandidateChannel::Unattributed
483        );
484        if other_tier > self_tier
485            || (other_tier == self_tier && other.reason_code as u8 > self.reason_code as u8)
486            || (same_reason && other_attributed && !self_attributed)
487            || (same_reason
488                && other_attributed == self_attributed
489                && other.provenance > self.provenance)
490        {
491            other
492        } else {
493            self
494        }
495    }
496}
497
498impl Serialize for EvidenceVerdict {
499    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
500    where
501        S: Serializer,
502    {
503        #[derive(Serialize)]
504        struct WireVerdict {
505            tier: EvidenceTier,
506            reason_code: EvidenceReasonCode,
507            provenance: FindingProvenance,
508        }
509
510        WireVerdict {
511            tier: self.tier(),
512            reason_code: self.reason_code,
513            provenance: self.provenance,
514        }
515        .serialize(serializer)
516    }
517}
518
519impl<'de> Deserialize<'de> for EvidenceVerdict {
520    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
521    where
522        D: Deserializer<'de>,
523    {
524        #[derive(Deserialize)]
525        #[serde(deny_unknown_fields)]
526        struct WireVerdict {
527            tier: EvidenceTier,
528            reason_code: EvidenceReasonCode,
529            provenance: FindingProvenance,
530        }
531
532        let wire = WireVerdict::deserialize(deserializer)?;
533        let verdict = Self::from_reason(wire.reason_code).with_provenance(wire.provenance);
534        if verdict.tier() != wire.tier {
535            return Err(D::Error::custom(format!(
536                "evidence tier {:?} does not match reason code {:?}",
537                wire.tier, wire.reason_code
538            )));
539        }
540        Ok(verdict)
541    }
542}