Skip to main content

keyhog_core/
spec.rs

1//! Detector specification: TOML-based pattern definitions with regex, keywords,
2//! verification endpoints, and companion patterns.
3
4mod evidence;
5pub(crate) mod load;
6mod semantic;
7mod validate;
8
9use std::fmt;
10
11use serde::ser::Error as _;
12use serde::{Deserialize, Serialize};
13
14pub use evidence::{ProviderEvidenceRole, ProviderEvidenceSensitivity};
15pub use load::{
16    load_detector_corpus, load_detectors, load_detectors_with_gate, read_detector_toml_file,
17    LoadedDetectorCorpus, SpecError, DETECTOR_TOML_FILE_BYTES,
18};
19pub use semantic::{
20    AnchorSemanticRole, CaptureSemanticRole, DetectorHardNegativeClass, DetectorSemanticPolicySpec,
21    RequiredSemanticEvidence, SemanticSourceRole,
22};
23pub use validate::{validate_detector, validate_detector_for_corpus_schema, QualityIssue};
24
25/// Metadata field specification for verification results.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28pub struct MetadataSpec {
29    /// Field name in the finding metadata map. Top-level verification metadata
30    /// must name a supported [`ProviderEvidenceRole`]. Multi-step extraction
31    /// may use a flow-local template name instead.
32    pub name: String,
33    /// `$`-rooted response selector, such as `$.account.email` or `$.orgs[0].name`.
34    pub json_path: String,
35    /// How a selected provider value may cross the reporting boundary.
36    #[serde(default)]
37    pub sensitivity: ProviderEvidenceSensitivity,
38}
39
40/// A complete detector definition loaded from a TOML file.
41#[derive(Debug, Clone, Serialize, Deserialize, Default)]
42#[serde(deny_unknown_fields)]
43pub struct DetectorSpec {
44    /// Unique stable identifier (e.g. \`aws-access-key\`).
45    pub id: String,
46    /// Human-readable name.
47    pub name: String,
48    /// Target service (e.g. \`aws\`, \`stripe\`).
49    pub service: String,
50    /// Default severity for findings.
51    pub severity: Severity,
52    /// What scan phase produces this detector's findings, and thus what the
53    /// loader requires of it. Defaults to [`DetectorKind::Regex`]. A `regex`
54    /// detector carries >=1 regex pattern and fires in phase 1. A
55    /// `phase2-generic` detector is a shapeless-secret bridge: bare passwords
56    /// and high-entropy blobs fire in phase 2 from `keywords` plus
57    /// `entropy_floor`. It may also declare structured regex envelopes while
58    /// keeping both paths under one detector owner. Modeled here so those
59    /// detectors are first-class TOML specs, one home for every knob, instead
60    /// of engine constants scattered across `detector_ids.rs` and policy files.
61    #[serde(default)]
62    pub kind: DetectorKind,
63    /// Detector-owned policy for model scoring. The model implementation is a
64    /// shared engine, but whether this detector invokes it, how model and
65    /// structural evidence combine, and how much source context is extracted
66    /// are properties of the secret type and therefore live in its TOML.
67    /// TOML detectors must declare it. Programmatic `DetectorSpec::default()`
68    /// disables both model paths until the caller chooses a policy.
69    pub ml: DetectorMlPolicySpec,
70    /// Detector-owned scoring policy for regex matches. Every signal weight,
71    /// entropy tier, penalty, structural floor, and low-promise result is
72    /// compiled into the detector execution plan.
73    #[serde(default)]
74    pub match_confidence: Option<DetectorMatchConfidenceSpec>,
75    /// Detector-owned offline validation policy. Each entry declares both the
76    /// validator primitive and every detector-specific parameter it needs.
77    /// Shared scanner code supplies the primitive implementations, but it must
78    /// not own token prefixes, field widths, confidence floors, or shape rules.
79    #[serde(default)]
80    pub validators: Vec<DetectorValidatorSpec>,
81    /// Detector-owned admission policy for asymmetric evasion transforms.
82    /// Shared decoders implement reverse and Caesar recovery, while each
83    /// detector declares the literal prefixes that make those transforms
84    /// eligible. Empty lists disable the corresponding transform for this
85    /// detector. The compiled scanner unions only the declarations in its
86    /// active corpus, so a custom corpus never inherits unrelated prefixes.
87    #[serde(default)]
88    pub decode_transforms: DetectorDecodeTransformSpec,
89    /// Typed classification of the captured credential bytes. Current scan
90    /// admission does not consume this declaration; `unknown` preserves
91    /// current findings.
92    #[serde(default, skip_serializing_if = "CaptureSemanticRole::is_unknown")]
93    pub capture_role: CaptureSemanticRole,
94    /// Typed classification of the detector anchor. Current scan admission
95    /// does not consume this declaration; `unknown` preserves current findings.
96    #[serde(default, skip_serializing_if = "AnchorSemanticRole::is_unknown")]
97    pub anchor_role: AnchorSemanticRole,
98    /// Detector-owned source-role declaration included in execution identity.
99    /// Current scan admission does not consume this list. Empty preserves
100    /// current findings.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub allowed_source_roles: Vec<SemanticSourceRole>,
103    /// Detector-owned evidence declaration included in execution identity.
104    /// Current scan admission does not consume this list. Empty preserves
105    /// current findings.
106    #[serde(default, skip_serializing_if = "Vec::is_empty")]
107    pub required_evidence: Vec<RequiredSemanticEvidence>,
108    /// List of regex patterns to match. Defaults to empty so a
109    /// `kind = "phase2-generic"` detector can omit it when it has no structured
110    /// envelope; a `kind = "regex"` detector with no patterns is rejected by
111    /// the quality gate (`validate_patterns_present`), so this default never
112    /// silently ships a dead regex detector.
113    #[serde(default)]
114    pub patterns: Vec<PatternSpec>,
115    /// Secondary patterns required to confirm a match.
116    #[serde(default)]
117    pub companions: Vec<CompanionSpec>,
118    /// Relationships to findings emitted by other detectors in the same source,
119    /// file, and revision.
120    #[serde(default)]
121    pub detector_relations: Vec<DetectorRelationSpec>,
122    /// Positive source selectors. Every non-empty selector family must admit a
123    /// finding before detector-specific matching can report it.
124    #[serde(default)]
125    pub source_admission: SourceAdmissionSpec,
126    /// Live verification configuration.
127    pub verify: Option<VerifySpec>,
128    /// High-performance pre-filtering keywords.
129    #[serde(default)]
130    pub keywords: Vec<String>,
131    /// Literal prefixes eligible for the optional `simdsieve` first-pass
132    /// accelerator. Each prefix must be non-empty ASCII, unique in the loaded
133    /// corpus, and an actual literal prefix of one of this detector's patterns.
134    /// Empty means this detector does not participate in that accelerator.
135    #[serde(default)]
136    pub simdsieve_prefixes: Vec<String>,
137    /// Self-declared per-detector confidence floor, in `[0.0, 1.0]`.
138    ///
139    /// When set, findings from THIS detector use this floor instead of the
140    /// global `--min-confidence` / `[scan].min_confidence`. A detector with a
141    /// distinctive vendor prefix (e.g. sourcegraph `sgp_<40hex>`, cursor
142    /// `key_<64hex>`) is high-confidence by virtue of the prefix even when the
143    /// body is low-entropy hex that the generic confidence model scores below
144    /// the global floor; the detector author declares that here so the
145    /// detector ships working out of the box. Costs nothing at scan time
146    /// it is a single O(1) map lookup at the post-scan floor gate, on an
147    /// already-compiled corpus. An operator `.keyhog.toml`
148    /// `[detector.<id>] min_confidence` still overrides this self-declared
149    /// default. `None` (the default) means "use the global floor".
150    #[serde(default)]
151    pub min_confidence: Option<f64>,
152    /// Per-detector low-entropy suppression floor, owned HERE in the detector's
153    /// own TOML, the single source of truth for generic and weak-anchor entropy
154    /// gates (there is no separate `rules/entropy-floors.toml`, no code table,
155    /// no cross-detector policy borrowing). Length-bucketed: the FIRST bucket whose
156    /// `max_len >= L` sets the floor for a candidate of length `L`; the last
157    /// bucket omits `max_len` and is the catch-all. `max_len` must strictly
158    /// increase. A generic-detector candidate whose Shannon entropy is BELOW the
159    /// applicable floor is suppressed. Active entropy owners must declare at
160    /// least one bucket.
161    #[serde(default)]
162    pub entropy_floor: Vec<EntropyFloorBucket>,
163    // ── PER-DETECTOR RECALL/PRECISION KNOBS (migration 2026-07-07) ────────────
164    // ARCHITECTURE LAW: there is NO global/overall entropy or recall/precision
165    // gate applied uniformly to every candidate. EVERY threshold that affects
166    // whether a candidate survives is a PER-DETECTOR field, OWNED HERE in the
167    // detector's own TOML spec, exactly like `min_confidence`/`entropy_floor`
168    // above. Optional representation preserves schema compatibility for
169    // non-owning programmatic detectors, but active entropy owners must declare
170    // every field and are compiled into concrete runtime policy. Reading two
171    // places to understand one active detector's behavior is banned.
172    /// Per-detector HIGH-entropy threshold (bits/byte), the keyword-independent
173    /// bar. Active entropy owners must declare it.
174    #[serde(default)]
175    pub entropy_high: Option<f64>,
176    /// Per-detector keyword-context (LOW) entropy threshold (bits/byte).
177    /// Active entropy owners must declare it.
178    #[serde(default)]
179    pub entropy_low: Option<f64>,
180    /// Per-detector VERY-high entropy threshold for keyword-free/isolated tokens.
181    /// Active entropy owners must declare it.
182    #[serde(default)]
183    pub entropy_very_high: Option<f64>,
184    /// Metadata used when this detector owns a synthetic entropy finding.
185    /// Keeping the semantic class, emitted id, display name, and service beside
186    /// the owning detector prevents scanner-side identity tables from drifting
187    /// away from detector policy. Active entropy owners must declare it; a
188    /// missing block is a compile-time configuration error, never a guessed
189    /// scanner identity.
190    #[serde(default)]
191    pub entropy_fallback: Option<EntropyFallbackMetadata>,
192    /// Detector-owned confidence tiers for synthetic entropy findings. The
193    /// entropy engine supplies shared Shannon scoring, while each owning
194    /// detector declares how that evidence maps to report confidence.
195    #[serde(default)]
196    pub entropy_fallback_confidence: Option<EntropyFallbackConfidenceSpec>,
197    /// Detector-owned confidence policy for phase-two generic assignments.
198    /// Context bases and evidence lifts compile with the detector that owns
199    /// the assignment keyword.
200    #[serde(default)]
201    pub generic_assignment_confidence: Option<GenericAssignmentConfidenceSpec>,
202    /// Corpus-level entropy roles owned by this detector. Roles make the
203    /// shared entropy engine data-driven: it resolves keyword-free,
204    /// isolated-bare, and unclaimed-keyword candidates from detector TOML
205    /// instead of naming built-in detector IDs in scanner code. Each role may
206    /// be claimed by at most one detector in a compiled corpus.
207    #[serde(default)]
208    pub entropy_roles: Vec<EntropyDetectionRole>,
209    /// Per-detector keyword-free entropy threshold used for clearly sensitive
210    /// paths. Active entropy owners must declare it; setting it lower than
211    /// `entropy_very_high` is an explicit recall policy for files such as `.env`
212    /// and secrets manifests, not a scanner-wide hidden discount.
213    #[serde(default)]
214    pub sensitive_path_entropy_very_high: Option<f64>,
215    /// Detector-owned isolated entropy shapes. These are explicit structural
216    /// exceptions to the broad keyword-free floor, such as a four-group
217    /// lower-dash app password. Active entropy owners must declare the list.
218    #[serde(default)]
219    pub entropy_shapes: Vec<EntropyShapeSpec>,
220    /// Complete detector-owned strict plausibility policy. Active entropy
221    /// owners must declare the block; absence is valid only for detector paths
222    /// that never invoke phase-2 plausibility scoring.
223    #[serde(default)]
224    pub plausibility: Option<DetectorPlausibilityPolicySpec>,
225    /// Precedence when this detector owns entropy-fallback policy for one of
226    /// its declared keywords. Active entropy owners must declare the value;
227    /// regex detectors opt in by doing so. Higher values win overlapping
228    /// keyword claims, so the policy decision is declared in detector TOML
229    /// instead of depending on detector IDs or load order.
230    #[serde(default)]
231    pub entropy_policy_priority: Option<u16>,
232    /// Per-detector BPE token-efficiency ceiling in UTF-8 bytes per
233    /// `cl100k_base` token. Candidates above the ceiling are word-like and are
234    /// suppressed after the cheaper entropy/shape gates. BPE-enabled entropy
235    /// owners must declare it; an explicit scan override still has precedence.
236    #[serde(default)]
237    pub bpe_max_bytes_per_token: Option<f64>,
238    /// Whether the BPE token-efficiency precision gate applies to this
239    /// detector. Active entropy owners must declare the choice. `Some(false)`
240    /// disables tokenization for detector families such as human-chosen
241    /// passwords where word-like values are legitimate. A disabled detector
242    /// must not also set a BPE ceiling.
243    #[serde(default)]
244    pub bpe_enabled: Option<bool>,
245    /// Exact printable-hex character counts this phase-2 detector may retain
246    /// after transport decoding. Keeping the lengths in detector TOML avoids a
247    /// scanner-wide hardcoded key-width list. An empty list retains no decoded
248    /// digest-shaped values.
249    #[serde(default)]
250    pub decoded_hex_key_material_lengths: Vec<usize>,
251    /// Detector-owned canonical pure-hex key material. Phase-2 generic
252    /// detectors scope lengths to exact assignment `keywords` or vendor
253    /// `suffixes`. Regex detectors use length-only entries because their own
254    /// matched pattern is already the anchor. This keeps digest-shaped recall
255    /// exceptions in detector TOML instead of a scanner-wide length table.
256    #[serde(default)]
257    pub canonical_hex_key_material: Vec<CanonicalHexKeyMaterialSpec>,
258    /// Per-detector minimum length for an anchor-free (keyword-free/isolated)
259    /// candidate. Active entropy owners must declare it.
260    #[serde(default)]
261    pub keyword_free_min_len: Option<usize>,
262    /// Per-detector minimum candidate length in UTF-8 bytes (any candidate this
263    /// detector emits). Active entropy owners must declare it.
264    #[serde(default)]
265    pub min_len: Option<usize>,
266    /// Per-detector maximum byte length for generic assignment values owned by
267    /// this entropy policy, including regex detectors that also claim generic
268    /// keywords through `entropy_policy_priority`.
269    /// Values above this ceiling are rejected whole; they are never truncated
270    /// into an apparently valid credential. Active phase-2 entropy owners must
271    /// declare it.
272    #[serde(default)]
273    pub max_len: Option<usize>,
274    /// Structural assignment suffixes for `<vendor>_<suffix>` names not claimed
275    /// by an exact keyword. At most one phase-2 generic detector may declare a
276    /// non-empty list; omission disables unlisted vendor suffixes.
277    #[serde(default)]
278    pub generic_vendor_suffixes: Vec<String>,
279    /// Optional suffix segments accepted after an exact assignment keyword.
280    /// At most one phase-2 generic detector may declare a non-empty list.
281    #[serde(default)]
282    pub generic_assignment_tail_suffixes: Vec<String>,
283    /// Per-detector path-exclusion regexes (betterleaks-style allowlist): a match
284    /// whose FILE PATH matches any of these is suppressed. Owned per detector.
285    #[serde(default)]
286    pub allowlist_paths: Vec<String>,
287    /// Per-detector value-exclusion regexes: a matched SECRET VALUE matching any
288    /// of these is suppressed (per-detector test/example/placeholder demotion).
289    #[serde(default)]
290    pub allowlist_values: Vec<String>,
291    /// Per-detector literal stopwords: a matched value equal to / containing any
292    /// of these (case-insensitive) is suppressed. Owned per detector.
293    #[serde(default)]
294    pub stopwords: Vec<String>,
295    /// Uppercase assignment-key fragments that identify public IDs rather than
296    /// credentials for this detector's entropy path. Runtime matching is ASCII
297    /// case-insensitive without allocating a normalized source line, so boundary
298    /// bytes such as `=`, `:`, space, and quote are semantic. An empty list
299    /// disables this detector-local suppression. TOML stores canonical uppercase.
300    #[serde(default)]
301    pub public_identifier_assignment_markers: Vec<String>,
302    /// Per-detector "structural password slot" classification, OWNED HERE per the
303    /// architecture law above (was a hardcoded detector-id list in scanner
304    /// code, so a detector's family lived outside its TOML).
305    ///
306    /// `true` marks a STRONG-anchor detector whose regex proves a syntactic
307    /// credential SLOT (`scheme://user:<x>@host`, `IDENTIFIED BY '<x>'`,
308    /// `--password <x>`, `Bearer <x>`) but captures a FREE-FORM value the way a
309    /// real password is written. Such detectors apply the password-slot
310    /// placeholder gate (drop a captured literal dictionary word like `password`
311    /// / `secret`, or a low-letter-diversity mask like `xxxxxxxx`) that a
312    /// service-anchored detector's structured capture never needs. A new
313    /// structural-password-slot detector now declares this in its own TOML, no
314    /// code edit (and the whole story lives in the detector file).
315    #[serde(default)]
316    pub structural_password_slot: bool,
317    /// Per-detector weak-anchor classification, owned by this detector definition.
318    ///
319    /// `true` marks a SERVICE-anchored detector whose regex capture nonetheless
320    /// structurally collides with a generic value (a bare hex/base64 run the
321    /// vendor prefix does not tightly bound: `alchemy-api-key`, `carbon-black-api-key`,
322    /// `flickr-api-key`, …), so scanner suppression keeps the Tier-B shape gates
323    /// ENGAGED for it (`WeakAnchorBase::Always`) instead of trusting the anchor.
324    /// Without this the collision-prone captures would bypass the generic
325    /// shape/entropy floors and flood FP. The structural-password-slot family is
326    /// deliberately NOT weak_anchor (its slot is syntactic, not a vendor prefix).
327    /// A new weak-anchor detector now declares this in its own TOML, no code
328    /// edit (and the whole story lives in the detector file).
329    #[serde(default)]
330    pub weak_anchor: bool,
331    /// Per-detector private-key-block classification, owned by this detector
332    /// definition.
333    ///
334    /// `true` marks a detector whose match SPAN is an enclosing private-key block
335    /// (`private-key`, `ssh-private-key`, `github-app-private-key`), a multi-line
336    /// PEM/OpenSSH body. Resolution (`resolution::suppress_matches_nested_in_private_key_blocks`)
337    /// fully suppresses any lower-specificity child finding nested inside such a
338    /// span (an entropy/base64 hit on a line INSIDE the key body is not a second
339    /// secret). A new private-key-block detector now declares this in its own TOML
340    /// no code edit (and the whole story lives in the detector file).
341    #[serde(default)]
342    pub private_key_block: bool,
343    /// Explicit detector specificity used when overlapping findings compete.
344    ///
345    /// Higher values win. The default `0` preserves equal standing; detectors
346    /// that intentionally wrap a broader detector declare their precedence here
347    /// instead of relying on detector ID spelling or input order.
348    #[serde(default)]
349    pub resolution_priority: i16,
350    /// Per-detector credential shape constraint (see [`CredentialShape`]), OWNED
351    /// HERE per the architecture law (was `rules/detector-credential-shapes.toml`).
352    /// `None` (the default) means the detector declares no shape constraint.
353    #[serde(default)]
354    pub credential_shape: Option<CredentialShape>,
355    /// Inline self-test fixtures (`[[detector.tests]]`, Tier-B data): each entry
356    /// carries a positive example the detector MUST fire on and/or a negative
357    /// example it MUST NOT. Consumed by the contract/self-validate harness;
358    /// ignored at scan time. Modeled here (rather than silently dropped) so the
359    /// schema's `deny_unknown_fields` typo-guard covers the whole detector file.
360    #[serde(default)]
361    pub tests: Vec<DetectorTestSpec>,
362}
363
364/// Which scan phase produces a detector's findings (see [`DetectorSpec::kind`]).
365#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
366#[serde(rename_all = "kebab-case")]
367pub enum DetectorKind {
368    /// Phase-1 regex detector: carries >=1 regex pattern, has a distinctive
369    /// anchor. The default and the vast majority of the corpus.
370    #[default]
371    Regex,
372    /// Phase-2 generic bridge: fires on `keywords` + `entropy_floor`. It may
373    /// additionally carry explicit regex patterns for strongly structured
374    /// envelopes (for example a JSON `"secret"` field); those anchors compile
375    /// through the same detector while phase-2 remains the shapeless fallback.
376    Phase2Generic,
377}
378
379/// Literal admission prefixes for detector-owned evasion recovery.
380#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
381#[serde(deny_unknown_fields)]
382pub struct DetectorDecodeTransformSpec {
383    /// Plaintext prefixes whose character-reversed spelling may admit reverse
384    /// recovery. Prefixes shorter than three bytes are rejected because they
385    /// create excessive accidental matches in encoded data.
386    #[serde(default)]
387    pub reverse_prefixes: Vec<String>,
388    /// Plaintext prefixes whose rotated spellings may admit Caesar/ROT-N
389    /// recovery. A prefix must contain at least one ASCII letter so rotation
390    /// can change it.
391    #[serde(default)]
392    pub caesar_prefixes: Vec<String>,
393}
394
395impl DetectorDecodeTransformSpec {
396    /// Validate transform prefix shape and per-list uniqueness.
397    pub fn validate(&self) -> Vec<String> {
398        let mut issues = Vec::new();
399        validate_decode_transform_prefixes(
400            "reverse_prefixes",
401            &self.reverse_prefixes,
402            true,
403            &mut issues,
404        );
405        validate_decode_transform_prefixes(
406            "caesar_prefixes",
407            &self.caesar_prefixes,
408            false,
409            &mut issues,
410        );
411        issues
412    }
413}
414
415fn validate_decode_transform_prefixes(
416    field: &str,
417    prefixes: &[String],
418    reverse: bool,
419    issues: &mut Vec<String>,
420) {
421    let mut seen = std::collections::HashSet::with_capacity(prefixes.len());
422    for prefix in prefixes {
423        if prefix.is_empty() || !prefix.is_ascii() {
424            issues.push(format!("{field} entry {prefix:?} must be non-empty ASCII"));
425            continue;
426        }
427        if reverse && prefix.len() < 3 {
428            issues.push(format!(
429                "{field} entry {prefix:?} must contain at least three bytes"
430            ));
431        }
432        if !reverse && !prefix.bytes().any(|byte| byte.is_ascii_alphabetic()) {
433            issues.push(format!(
434                "{field} entry {prefix:?} must contain an ASCII letter"
435            ));
436        }
437        if !seen.insert(prefix.as_str()) {
438            issues.push(format!("{field} contains duplicate prefix {prefix:?}"));
439        }
440    }
441}
442
443/// How the shared ML model participates in one detector path.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
445#[serde(rename_all = "kebab-case")]
446pub enum DetectorMlMode {
447    /// Do not run model inference for this path.
448    #[default]
449    Disabled,
450    /// Let the model raise structurally derived confidence but never veto a
451    /// detector match. The detector-owned weight controls the fraction of the
452    /// positive model delta that is applied.
453    Lift,
454    /// Combine model confidence with the detector's structural evidence.
455    Blend,
456    /// Let the model score replace the heuristic score for weakly anchored
457    /// candidates where entropy magnitude is not positive evidence.
458    Authoritative,
459}
460
461impl DetectorMlMode {
462    /// Stable TOML spelling used by diagnostics and semantic hashes.
463    pub const fn as_str(self) -> &'static str {
464        match self {
465            Self::Disabled => "disabled",
466            Self::Lift => "lift",
467            Self::Blend => "blend",
468            Self::Authoritative => "authoritative",
469        }
470    }
471}
472
473/// Complete detector-local configuration for the shared ML scoring engine.
474#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
475#[serde(deny_unknown_fields)]
476pub struct DetectorMlPolicySpec {
477    /// Policy for regex and generic-assignment matches owned by this detector.
478    pub match_mode: DetectorMlMode,
479    /// Policy for entropy-fallback candidates owned by this detector.
480    pub entropy_mode: DetectorMlMode,
481    /// Model contribution for `lift` and `blend`, in the closed interval `[0, 1]`.
482    pub weight: f64,
483    /// Number of source lines on each side of the candidate supplied to feature
484    /// extraction. Zero intentionally restricts inference to the candidate line.
485    pub context_radius_lines: usize,
486}
487
488/// Complete detector-local confidence policy for regex candidates.
489#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
490#[serde(deny_unknown_fields)]
491pub struct DetectorMatchConfidenceSpec {
492    /// Weight earned by a detector-specific literal prefix.
493    pub literal_prefix_weight: f64,
494    /// Weight earned by a required contextual capture.
495    pub context_anchor_weight: f64,
496    /// Full weight earned at the very-high entropy tier.
497    pub entropy_weight: f64,
498    /// Partial weight earned at the resolved high entropy tier.
499    pub high_entropy_partial_weight: f64,
500    /// Shannon threshold for moderate entropy evidence.
501    pub moderate_entropy_threshold: f64,
502    /// Weight earned at the moderate entropy tier.
503    pub moderate_entropy_weight: f64,
504    /// Shannon floor below which a long match receives a penalty.
505    pub low_entropy_penalty_floor: f64,
506    /// Minimum byte length above which the low-entropy penalty applies.
507    pub low_entropy_min_match_length: usize,
508    /// Multiplier applied when the low-entropy penalty fires.
509    pub low_entropy_penalty_multiplier: f64,
510    /// Weight earned when detector-owned context is nearby.
511    pub keyword_nearby_weight: f64,
512    /// Weight earned in a sensitive file.
513    pub sensitive_file_weight: f64,
514    /// Weight earned when a companion capture is present.
515    pub companion_weight: f64,
516    /// Margin above the resolved high tier for full entropy evidence.
517    pub very_high_entropy_margin: f64,
518    /// Structural floor for a non-generic match carrying a compiled anchor.
519    pub named_anchor_floor: Option<f64>,
520    /// Final score for an unaccompanied generic match rejected by the cheap
521    /// promise gate before model inference.
522    pub low_promise_confidence: Option<f64>,
523    /// Confidence multiplier for an assignment context.
524    pub assignment_context_multiplier: f64,
525    /// Confidence multiplier for a string-literal context.
526    pub string_literal_context_multiplier: f64,
527    /// Confidence multiplier when source context is unknown.
528    pub unknown_context_multiplier: f64,
529    /// Confidence multiplier for documentation context.
530    pub documentation_context_multiplier: f64,
531    /// Confidence multiplier for comment context.
532    pub comment_context_multiplier: f64,
533    /// Confidence multiplier for test-code context.
534    pub test_context_multiplier: f64,
535    /// Confidence multiplier for encrypted or sealed context.
536    pub encrypted_context_multiplier: f64,
537    /// Hard-suppression threshold for comment, test, and documentation context.
538    pub soft_context_suppression_threshold: f64,
539    /// Hard-suppression threshold for encrypted or sealed context.
540    pub encrypted_context_suppression_threshold: f64,
541    /// Detector-owned policy applied after optional model scoring.
542    pub post_match: DetectorPostMatchConfidenceSpec,
543}
544
545/// Detector-local confidence penalties applied after optional model scoring.
546#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
547#[serde(deny_unknown_fields)]
548pub struct DetectorPostMatchConfidenceSpec {
549    /// Multiplier for a surface or decoded placeholder word.
550    pub placeholder_multiplier: f64,
551    /// Minimum unique-byte ratio before the value receives a diversity penalty.
552    pub minimum_byte_diversity: f64,
553    /// Multiplier for values below `minimum_byte_diversity`.
554    pub low_diversity_multiplier: f64,
555    /// Maximum repeated-byte run ratio before the value receives a penalty.
556    pub maximum_repeat_ratio: f64,
557    /// Absolute repeated-byte run length that receives a penalty regardless of ratio.
558    pub degenerate_run_min_length: usize,
559    /// Multiplier for values exceeding either repeated-byte limit.
560    pub degenerate_repeat_multiplier: f64,
561    /// Multiplier for decoded data envelopes. Omit it when the detector's
562    /// anchored credential shape makes this evidence inapplicable.
563    pub data_envelope_multiplier: Option<f64>,
564    /// Multiplier for findings under fixture or example path components.
565    pub fixture_path_multiplier: f64,
566    /// Reapply the source-context multiplier to model scores below this floor.
567    pub ml_context_reapply_below: f64,
568}
569
570impl DetectorPostMatchConfidenceSpec {
571    /// Validate ratios, multipliers, and the repeated-run boundary.
572    pub fn validate(self) -> Result<(), &'static str> {
573        if [
574            self.placeholder_multiplier,
575            self.minimum_byte_diversity,
576            self.low_diversity_multiplier,
577            self.maximum_repeat_ratio,
578            self.degenerate_repeat_multiplier,
579            self.fixture_path_multiplier,
580            self.ml_context_reapply_below,
581        ]
582        .into_iter()
583        .chain(self.data_envelope_multiplier)
584        .any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
585        {
586            return Err("post-match ratios and multipliers must be finite values in [0.0, 1.0]");
587        }
588        if self.degenerate_run_min_length == 0 {
589            return Err("post-match degenerate_run_min_length must be greater than zero");
590        }
591        Ok(())
592    }
593}
594
595impl DetectorMatchConfidenceSpec {
596    /// Validate probabilities, entropy domains, and evidence ordering.
597    pub fn validate(self) -> Result<(), &'static str> {
598        self.post_match.validate()?;
599        let probabilities = [
600            self.literal_prefix_weight,
601            self.context_anchor_weight,
602            self.entropy_weight,
603            self.high_entropy_partial_weight,
604            self.moderate_entropy_weight,
605            self.low_entropy_penalty_multiplier,
606            self.keyword_nearby_weight,
607            self.sensitive_file_weight,
608            self.companion_weight,
609            self.assignment_context_multiplier,
610            self.string_literal_context_multiplier,
611            self.unknown_context_multiplier,
612            self.documentation_context_multiplier,
613            self.comment_context_multiplier,
614            self.test_context_multiplier,
615            self.encrypted_context_multiplier,
616            self.soft_context_suppression_threshold,
617            self.encrypted_context_suppression_threshold,
618        ];
619        if probabilities
620            .into_iter()
621            .chain(self.named_anchor_floor)
622            .chain(self.low_promise_confidence)
623            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
624        {
625            return Err("weights, multipliers, floors, and final scores must be finite values in [0.0, 1.0]");
626        }
627        if [
628            self.moderate_entropy_threshold,
629            self.low_entropy_penalty_floor,
630            self.very_high_entropy_margin,
631        ]
632        .into_iter()
633        .any(|value| !value.is_finite() || !(0.0..=u8::BITS as f64).contains(&value))
634        {
635            return Err("entropy thresholds and margins must be finite values in [0.0, 8.0]");
636        }
637        if self.low_entropy_penalty_floor > self.moderate_entropy_threshold {
638            return Err("low_entropy_penalty_floor must not exceed moderate_entropy_threshold");
639        }
640        if self.moderate_entropy_weight > self.high_entropy_partial_weight
641            || self.high_entropy_partial_weight > self.entropy_weight
642        {
643            return Err("entropy weights must satisfy moderate <= high partial <= full");
644        }
645        if self.literal_prefix_weight
646            + self.context_anchor_weight
647            + self.entropy_weight
648            + self.keyword_nearby_weight
649            + self.sensitive_file_weight
650            + self.companion_weight
651            <= 0.0
652        {
653            return Err("at least one maximum signal weight must be positive");
654        }
655        Ok(())
656    }
657}
658
659/// Exact base64 dialect accepted by a detector-owned offline validator.
660#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(rename_all = "kebab-case")]
662pub enum DetectorBase64Alphabet {
663    /// Canonical RFC 4648 base64 with `+`/`/` and `=` padding.
664    Standard,
665    /// RFC 4648 base64 with `+`/`/` and no `=` padding.
666    StandardNoPad,
667    /// URL-safe base64 with `-`/`_` and `=` padding.
668    UrlSafe,
669    /// URL-safe base64 with `-`/`_` and no `=` padding.
670    UrlSafeNoPad,
671}
672
673/// An offline validator declared by one detector.
674///
675/// The enum is deliberately closed and typed: malformed combinations are
676/// rejected while loading detector TOML instead of becoming runtime defaults.
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
678#[serde(tag = "type", rename_all = "kebab-case", deny_unknown_fields)]
679pub enum DetectorValidatorSpec {
680    /// CRC32 over a fixed entropy body, encoded as a fixed-width base62 suffix.
681    Crc32Base62 {
682        /// Literal token prefixes this validator claims.
683        #[serde(default)]
684        prefixes: Vec<String>,
685        /// Exact length of the entropy body between the prefix and the checksum.
686        entropy_len: usize,
687        /// Exact length of the base62-encoded CRC32 suffix.
688        checksum_len: usize,
689        /// Reject a candidate that carries extra token bytes past the declared length.
690        reject_overlong: bool,
691        /// Confidence assigned when the checksum verifies.
692        confidence_floor: f64,
693    },
694    /// GitHub fine-grained PAT CRC32 layout with two underscore-separated
695    /// segments and compatibility for the two formats GitHub has emitted.
696    GithubFineGrainedCrc32 {
697        /// Literal token prefixes this validator claims.
698        #[serde(default)]
699        prefixes: Vec<String>,
700        /// Exact length of the segment before the underscore separator.
701        left_len: usize,
702        /// Exact length of the segment after the underscore separator.
703        right_len: usize,
704        /// Exact length of the base62-encoded CRC32 suffix.
705        checksum_len: usize,
706        /// Confidence assigned when the checksum verifies.
707        confidence_floor: f64,
708    },
709    /// A base64 payload whose successful decode is offline authenticity
710    /// evidence, such as the macaroon carried by a PyPI API token.
711    Base64Payload {
712        /// Literal token prefixes this validator claims.
713        #[serde(default)]
714        prefixes: Vec<String>,
715        /// Base64 dialect the payload must decode under.
716        alphabet: DetectorBase64Alphabet,
717        /// Smallest accepted encoded payload length.
718        min_encoded_len: usize,
719        /// Largest accepted encoded payload length.
720        max_encoded_len: usize,
721        /// Smallest accepted decoded payload length.
722        min_decoded_len: usize,
723        /// Confidence assigned when the payload decodes.
724        confidence_floor: f64,
725    },
726    /// The detector's own patterns are the complete structural contract. The
727    /// scanner compiles anchored copies once for generic/public validation;
728    /// named matches reuse the already-proven pattern result without rerunning
729    /// a second regex.
730    PatternShape {
731        /// Literal token prefixes this validator claims.
732        #[serde(default)]
733        prefixes: Vec<String>,
734        /// Whether a candidate that begins with a complete declared pattern but
735        /// continues with provider-token bytes is an unknown future shape
736        /// (`true`) or malformed (`false`).
737        allow_overlong: bool,
738    },
739    /// JSON Web Token (JWT) structural validation (RFC 7519).
740    /// Validates 3 dot-separated base64url segments, parseable header JSON,
741    /// and valid signature algorithm.
742    Jwt {
743        /// Literal token prefixes this validator claims.
744        #[serde(default)]
745        prefixes: Vec<String>,
746        /// Reject unsigned `alg = "none"` tokens.
747        #[serde(default)]
748        reject_alg_none: bool,
749        /// Confidence assigned when the JWT structure is valid.
750        confidence_floor: f64,
751    },
752    /// Canonical 8-4-4-4-12 hyphenated hexadecimal UUID validation (RFC 4122 / RFC 9562).
753    Uuid {
754        /// Literal token prefixes this validator claims.
755        #[serde(default)]
756        prefixes: Vec<String>,
757        /// Confidence assigned when the UUID is structurally valid.
758        confidence_floor: f64,
759    },
760    /// Fixed-length hexadecimal digest / hash validation.
761    HexHash {
762        /// Literal token prefixes this validator claims.
763        #[serde(default)]
764        prefixes: Vec<String>,
765        /// Expected hexadecimal payload length.
766        expected_len: usize,
767        /// Whether uppercase hex characters are rejected (lowercase only).
768        #[serde(default)]
769        lowercase_only: bool,
770        /// Confidence assigned when the hex hash is structurally valid.
771        confidence_floor: f64,
772    },
773    /// Luhn (mod 10) checksum validation algorithm (ISO/IEC 7812).
774    LuhnChecksum {
775        /// Literal token prefixes this validator claims.
776        #[serde(default)]
777        prefixes: Vec<String>,
778        /// Minimum accepted digit length.
779        min_len: usize,
780        /// Maximum accepted digit length.
781        max_len: usize,
782        /// Confidence assigned when the Luhn checksum verifies.
783        confidence_floor: f64,
784    },
785}
786
787impl DetectorValidatorSpec {
788    /// Literal prefixes claimed by this validator.
789    pub fn prefixes(&self) -> &[String] {
790        match self {
791            Self::Crc32Base62 { prefixes, .. }
792            | Self::GithubFineGrainedCrc32 { prefixes, .. }
793            | Self::Base64Payload { prefixes, .. }
794            | Self::PatternShape { prefixes, .. }
795            | Self::Jwt { prefixes, .. }
796            | Self::Uuid { prefixes, .. }
797            | Self::HexHash { prefixes, .. }
798            | Self::LuhnChecksum { prefixes, .. } => prefixes,
799        }
800    }
801
802    /// Confidence floor earned by positive offline proof, when applicable.
803    pub fn confidence_floor(&self) -> Option<f64> {
804        match self {
805            Self::Crc32Base62 {
806                confidence_floor, ..
807            }
808            | Self::GithubFineGrainedCrc32 {
809                confidence_floor, ..
810            }
811            | Self::Base64Payload {
812                confidence_floor, ..
813            }
814            | Self::Jwt {
815                confidence_floor, ..
816            }
817            | Self::Uuid {
818                confidence_floor, ..
819            }
820            | Self::HexHash {
821                confidence_floor, ..
822            }
823            | Self::LuhnChecksum {
824                confidence_floor, ..
825            } => Some(*confidence_floor),
826            Self::PatternShape { .. } => None,
827        }
828    }
829}
830
831/// Strict candidate-plausibility policy owned by one detector TOML.
832#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
833#[serde(deny_unknown_fields)]
834pub struct DetectorPlausibilityPolicySpec {
835    /// Entropy floor for mixed alphabetic/numeric values.
836    pub mixed_alnum_floor: f64,
837    /// Entropy floor for symbolic values carrying a credential anchor.
838    pub symbolic_entropy_floor: f64,
839    /// Minimum entropy in the second half of a long value.
840    pub second_half_entropy_floor: f64,
841    /// Minimum byte length at which second-half entropy is required.
842    pub second_half_min_len: usize,
843    /// Minimum byte length at which distinct-character diversity is required.
844    pub unique_chars_min_len: usize,
845    /// Minimum distinct characters required at and above `unique_chars_min_len`.
846    pub min_unique_chars: usize,
847    /// Longest unanchored all-hex value that is not rejected as key material.
848    pub unanchored_hex_max_len: usize,
849    /// Longest single-character repetition that is not rejected.
850    pub identical_char_max_len: usize,
851    /// Minimum byte length for an isolated structured dotted token.
852    pub structured_dotted_min_len: usize,
853    /// Minimum byte length for the mixed alphabetic/numeric carve-out.
854    pub mixed_alnum_min_len: usize,
855    /// Shannon floor for isolated mixed-case alphanumeric tokens, with or
856    /// without an underscore separator.
857    pub isolated_mixed_entropy_floor: f64,
858    /// Minimum byte length for isolated symbolic opaque-token shapes.
859    pub isolated_symbolic_min_len: usize,
860    /// Minimum number of symbol bytes in an isolated symbolic shape.
861    pub isolated_symbolic_min_symbols: usize,
862    /// Require at least one symbolic byte other than underscore.
863    pub isolated_symbolic_requires_non_underscore: bool,
864    /// Minimum number of symbol bytes in an isolated alpha-only symbolic shape.
865    pub isolated_alpha_only_min_symbols: usize,
866    /// Minimum fraction of bytes that must be alphabetic in an isolated
867    /// alpha-only symbolic shape.
868    pub isolated_alpha_only_min_alpha_ratio: f64,
869    /// Minimum fraction of characters that must be alphanumeric.
870    pub min_alnum_ratio: f64,
871    /// Maximum byte length for a source type-name shape.
872    pub source_type_name_max_len: usize,
873    /// Minimum uppercase byte count for a source type-name shape.
874    pub source_type_name_min_uppercase: usize,
875    /// Minimum byte length at which a high-entropy punctuation payload may
876    /// bypass URL and path-shape suppression.
877    pub url_path_high_entropy_min_len: usize,
878    /// Minimum byte length for the left component of `opaque:opaque` tokens.
879    pub isolated_colon_left_min_len: usize,
880    /// Minimum byte length for the right component of `opaque:opaque` tokens.
881    pub isolated_colon_right_min_len: usize,
882    /// Shannon floor for an unanchored leading-slash base64 token.
883    pub leading_slash_base64_entropy_floor: f64,
884    /// Minimum byte length for an unanchored leading-slash base64 token.
885    pub leading_slash_base64_min_len: usize,
886    /// Margin added to the Tier-A entropy threshold before comparing an
887    /// unanchored keyword-free candidate. Required only for the detector that
888    /// claims the `keyword-free` entropy role.
889    #[serde(default)]
890    pub keyword_free_operator_margin: Option<f64>,
891    /// Reject periodic values, including a truncated final repetition.
892    pub reject_repeated_blocks: bool,
893    /// Admit an anchored alphabetic value after the other shape gates pass.
894    pub allow_alphabetic_credential: bool,
895    /// Reject source-language identifier shapes.
896    pub reject_program_identifiers: bool,
897    /// Reject mixed alphanumeric source-symbol shapes that include digits.
898    pub reject_source_symbol_identifiers: bool,
899    /// Reject dash-segmented product serial and identifier shapes.
900    pub reject_dash_segmented_alnum: bool,
901}
902
903impl Default for DetectorMlPolicySpec {
904    fn default() -> Self {
905        Self {
906            match_mode: DetectorMlMode::Disabled,
907            entropy_mode: DetectorMlMode::Disabled,
908            weight: 0.0,
909            context_radius_lines: 0,
910        }
911    }
912}
913
914/// One length bucket of a detector's [`DetectorSpec::entropy_floor`]. Owned in the
915/// detector's TOML (`entropy_floor = [{ max_len = 24, floor = 3.0 }, { floor = 3.5 }]`).
916#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
917#[serde(deny_unknown_fields)]
918pub struct EntropyFloorBucket {
919    /// Inclusive maximum candidate length this bucket applies to. Omit on the
920    /// final catch-all bucket (applies to any longer candidate).
921    #[serde(default)]
922    pub max_len: Option<usize>,
923    /// Shannon-entropy floor (bits/byte). A candidate scoring below this is
924    /// suppressed by the low-entropy gate.
925    pub floor: f64,
926}
927
928/// Detector-owned identity for a finding emitted by the entropy fallback path.
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
930#[serde(deny_unknown_fields)]
931pub struct EntropyFallbackMetadata {
932    /// Semantic class used by the entropy fallback. This is detector data,
933    /// not a scanner-side identity bucket; it keeps the emitted role visible
934    /// when several custom detectors share the same generic family.
935    pub class: EntropyFallbackClass,
936    /// Stable emitted detector id. Must use the `entropy-` namespace.
937    pub id: String,
938    /// Human-readable finding name.
939    pub name: String,
940    /// Service family attached to the synthetic finding.
941    pub service: String,
942}
943
944/// Confidence mapping for one detector's synthetic entropy findings.
945#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
946#[serde(deny_unknown_fields)]
947pub struct EntropyFallbackConfidenceSpec {
948    /// Maximum base confidence below the detector's high-entropy tier.
949    pub low_entropy_max: f64,
950    /// Base confidence at or above `entropy_high`.
951    pub high_entropy: f64,
952    /// Base confidence at or above `entropy_very_high`.
953    pub very_high_entropy: f64,
954    /// Confidence added when a detector keyword owns the candidate.
955    pub keyword_lift: f64,
956    /// Maximum confidence this fallback path may emit.
957    pub max_confidence: f64,
958}
959
960impl EntropyFallbackConfidenceSpec {
961    /// Validate probability bounds and monotonic evidence tiers.
962    pub fn validate(self) -> Result<(), &'static str> {
963        let values = [
964            self.low_entropy_max,
965            self.high_entropy,
966            self.very_high_entropy,
967            self.keyword_lift,
968            self.max_confidence,
969        ];
970        if values
971            .into_iter()
972            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
973        {
974            return Err("all values must be finite probabilities in [0.0, 1.0]");
975        }
976        if self.low_entropy_max > self.high_entropy
977            || self.high_entropy > self.very_high_entropy
978            || self.very_high_entropy > self.max_confidence
979        {
980            return Err(
981                "confidence tiers must satisfy low_entropy_max <= high_entropy <= very_high_entropy <= max_confidence",
982            );
983        }
984        Ok(())
985    }
986}
987
988/// Confidence mapping for a detector-owned generic assignment candidate.
989#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
990#[serde(deny_unknown_fields)]
991pub struct GenericAssignmentConfidenceSpec {
992    /// Base confidence for ordinary source and configuration content.
993    pub ordinary_base: f64,
994    /// Base confidence for test content when test-path penalties are enabled.
995    pub test_base: f64,
996    /// Base confidence for documentation when test-path penalties are enabled.
997    pub documentation_base: f64,
998    /// Base confidence for comments under the default comment policy.
999    pub comment_base: f64,
1000    /// Base confidence for comments when the operator enables comment scanning.
1001    pub scanned_comment_base: f64,
1002    /// Shannon entropy at which the entropy lift starts.
1003    pub entropy_reference: f64,
1004    /// Confidence gained per entropy bit above `entropy_reference`.
1005    pub entropy_gain_per_bit: f64,
1006    /// Maximum confidence contributed by entropy.
1007    pub entropy_lift_max: f64,
1008    /// Byte length at which the length lift starts.
1009    pub length_reference: usize,
1010    /// Confidence gained per byte above `length_reference`.
1011    pub length_gain_per_byte: f64,
1012    /// Maximum confidence contributed by length.
1013    pub length_lift_max: f64,
1014    /// Maximum confidence this generic assignment path may emit.
1015    pub max_confidence: f64,
1016}
1017
1018impl GenericAssignmentConfidenceSpec {
1019    /// Validate probability bounds and the byte-entropy reference domain.
1020    pub fn validate(self) -> Result<(), &'static str> {
1021        let probabilities = [
1022            self.ordinary_base,
1023            self.test_base,
1024            self.documentation_base,
1025            self.comment_base,
1026            self.scanned_comment_base,
1027            self.entropy_gain_per_bit,
1028            self.entropy_lift_max,
1029            self.length_gain_per_byte,
1030            self.length_lift_max,
1031            self.max_confidence,
1032        ];
1033        if probabilities
1034            .into_iter()
1035            .any(|value| !value.is_finite() || !(0.0..=1.0).contains(&value))
1036        {
1037            return Err(
1038                "bases, gains, lift caps, and max_confidence must be finite values in [0.0, 1.0]",
1039            );
1040        }
1041        if !self.entropy_reference.is_finite()
1042            || !(0.0..=u8::BITS as f64).contains(&self.entropy_reference)
1043        {
1044            return Err("entropy_reference must be finite and in [0.0, 8.0]");
1045        }
1046        if [
1047            self.ordinary_base,
1048            self.test_base,
1049            self.documentation_base,
1050            self.comment_base,
1051            self.scanned_comment_base,
1052        ]
1053        .into_iter()
1054        .any(|base| base > self.max_confidence)
1055        {
1056            return Err("every context base must be less than or equal to max_confidence");
1057        }
1058        Ok(())
1059    }
1060}
1061
1062impl EntropyFallbackMetadata {
1063    /// Whether this metadata can safely identify a synthetic entropy finding.
1064    /// IDs stay lowercase and delimiter-stable so reports, hashes, and
1065    /// suppression receipts cannot acquire ambiguous namespace variants.
1066    pub fn has_valid_identity(&self) -> bool {
1067        let Some(suffix) = self.id.strip_prefix("entropy-") else {
1068            return false;
1069        };
1070        !suffix.is_empty()
1071            && suffix
1072                .bytes()
1073                .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
1074            && !self.name.trim().is_empty()
1075            && !self.service.trim().is_empty()
1076    }
1077}
1078
1079/// Semantic role of a detector-owned synthetic entropy finding.
1080///
1081/// The role is deliberately separate from the emitted id and display text:
1082/// operators may rename a detector's finding without changing which evidence
1083/// family owns the candidate. Runtime emission resolves the active detector's
1084/// complete metadata, so this enum never acts as a scanner-wide identity
1085/// fallback.
1086#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
1087#[serde(rename_all = "kebab-case")]
1088pub enum EntropyFallbackClass {
1089    /// Anchor-free high-entropy material owned by the generic-secret policy.
1090    #[default]
1091    Generic,
1092    /// Password-family assignment or isolated password evidence.
1093    Password,
1094    /// Token/secret keyword-family evidence.
1095    Token,
1096    /// API/access-key and cryptographic-key evidence.
1097    ApiKey,
1098}
1099
1100/// Detector-owned entry roles for the shared entropy engine.
1101#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1102#[serde(rename_all = "kebab-case")]
1103pub enum EntropyDetectionRole {
1104    /// Own anchor-free high-entropy candidates.
1105    KeywordFree,
1106    /// Own isolated bare candidates admitted by a detector shape policy.
1107    IsolatedBare,
1108    /// Own credential keywords not explicitly claimed by another detector.
1109    UnclaimedKeyword,
1110}
1111
1112impl EntropyDetectionRole {
1113    /// Stable serialized spelling used by diagnostics and policy hashes.
1114    pub const fn as_str(self) -> &'static str {
1115        match self {
1116            Self::KeywordFree => "keyword-free",
1117            Self::IsolatedBare => "isolated-bare",
1118            Self::UnclaimedKeyword => "unclaimed-keyword",
1119        }
1120    }
1121}
1122
1123impl EntropyFallbackClass {
1124    /// Stable serialized spelling used by explain output and policy hashes.
1125    pub const fn as_str(self) -> &'static str {
1126        match self {
1127            Self::Generic => "generic",
1128            Self::Password => "password",
1129            Self::Token => "token",
1130            Self::ApiKey => "api-key",
1131        }
1132    }
1133}
1134
1135/// Character class an isolated entropy shape admits. Replaces the former
1136/// per-shape enum variant so a new shape family is TOML data, not scanner code.
1137#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1138#[serde(rename_all = "kebab-case")]
1139pub enum ShapeCharset {
1140    /// Lowercase letters and digits only.
1141    LowerAlnum,
1142    /// Hexadecimal digits only.
1143    Hex,
1144    /// Standard base64 alphabet (`A-Za-z0-9`, `+`, `/`, optional `=` padding).
1145    Base64Standard,
1146    /// URL-safe base64 alphabet (`A-Za-z0-9`, `-`, `_`).
1147    Base64Url,
1148}
1149
1150fn default_shape_separator() -> char {
1151    '-'
1152}
1153
1154/// Fixed-width separator grouping, e.g. a Bluesky app password's four
1155/// dash-separated groups of four. Absent means an ungrouped run.
1156#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1157#[serde(deny_unknown_fields)]
1158pub struct ShapeGrouping {
1159    /// Number of separator-delimited groups.
1160    pub group_count: usize,
1161    /// Exact byte length of every group.
1162    pub group_length: usize,
1163    /// Character that separates the groups.
1164    #[serde(default = "default_shape_separator")]
1165    pub separator: char,
1166}
1167
1168/// A declarative structural shape that may cross a detector's broad isolated
1169/// entropy floor. Every field is detector TOML data and the scanner keeps one
1170/// general matcher, so a new shape family (base64, hex block) is added by a
1171/// detector TOML rather than a scanner enum variant.
1172#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
1173#[serde(deny_unknown_fields)]
1174pub struct EntropyShapeSpec {
1175    /// Character class the candidate body must consist of.
1176    pub charset: ShapeCharset,
1177    /// Minimum Shannon entropy in bits/byte.
1178    pub entropy_floor: f64,
1179    /// Minimum candidate length used by the isolated-shape revisit. This may be
1180    /// below the detector's broad keyword-free minimum.
1181    pub special_min_length: usize,
1182    /// Optional separator grouping. Absent means an ungrouped run.
1183    #[serde(default)]
1184    pub grouping: Option<ShapeGrouping>,
1185    /// Require both a lowercase and an uppercase letter.
1186    #[serde(default)]
1187    pub require_mixed_case: bool,
1188    /// Require at least one digit.
1189    #[serde(default)]
1190    pub require_digit: bool,
1191    /// Minimum count of symbol (non-alphanumeric) bytes.
1192    #[serde(default)]
1193    pub min_symbols: usize,
1194    /// Require at least one non-hex alphabetic byte, distinguishing an app
1195    /// password from a pure-hex digest of the same layout.
1196    #[serde(default)]
1197    pub require_non_hex_alpha: bool,
1198    /// When grouped, require every group to contain at least one letter and one
1199    /// digit (the app-password per-group rule).
1200    #[serde(default)]
1201    pub require_group_alpha_digit: bool,
1202}
1203
1204/// One detector-local pure-hex key-material policy.
1205///
1206/// A candidate is eligible only when its captured assignment key matches one of
1207/// `keywords`, ends with one of `suffixes`, and is not in `excluded_keywords`
1208/// after normal assignment-key case/separator normalization. Its exact
1209/// character count must appear in `lengths`. The scanner still applies entropy,
1210/// placeholder, context, and reporting gates.
1211#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1212#[serde(deny_unknown_fields)]
1213pub struct CanonicalHexKeyMaterialSpec {
1214    /// Exact pure-hex character counts admitted by this policy.
1215    #[serde(default)]
1216    pub lengths: Vec<usize>,
1217    /// Assignment keys owned by a phase-2 generic policy. Each must also appear
1218    /// in the detector's top-level `keywords` list. Regex policies leave this
1219    /// empty because their matched pattern supplies the scope.
1220    #[serde(default)]
1221    pub keywords: Vec<String>,
1222    /// Normalized phase-2 assignment-key suffixes that may own this policy. This
1223    /// expresses vendor-prefixed names such as `stripe_secret_key` without a
1224    /// scanner-global suffix heuristic.
1225    #[serde(default)]
1226    pub suffixes: Vec<String>,
1227    /// Normalized phase-2 assignment keys excluded from suffix ownership, such
1228    /// as the ambiguous `license_key` shape.
1229    #[serde(default)]
1230    pub excluded_keywords: Vec<String>,
1231}
1232
1233impl DetectorSpec {
1234    /// Whether this detector supplies policy to the generic entropy engine.
1235    pub fn owns_entropy_policy(&self) -> bool {
1236        self.kind == DetectorKind::Phase2Generic || self.entropy_policy_priority.is_some()
1237    }
1238
1239    /// Canonical semantic policy copied into compiled and persisted plans.
1240    pub fn semantic_policy(&self) -> DetectorSemanticPolicySpec {
1241        DetectorSemanticPolicySpec {
1242            capture_role: self.capture_role,
1243            anchor_role: self.anchor_role,
1244            allowed_source_roles: self.allowed_source_roles.clone(),
1245            required_evidence: self.required_evidence.clone(),
1246        }
1247    }
1248
1249    /// Return the stable, redaction-safe declaration used by detector
1250    /// introspection surfaces.
1251    ///
1252    /// The projection starts from `DetectorSpec`'s own serializer. Fields that
1253    /// describe identity and matching stay at the top level; every other
1254    /// declared field moves into `policy`. This means a newly added detector
1255    /// field is included automatically instead of requiring a second manual
1256    /// field list in each CLI surface. Inline fixture bytes are replaced with
1257    /// positive/negative coverage booleans.
1258    pub fn introspection(&self) -> DetectorIntrospection<'_> {
1259        DetectorIntrospection { detector: self }
1260    }
1261
1262    /// Whether this detector admits transport-decoded pure-hex key material at
1263    /// the exact declared character count.
1264    pub fn allows_decoded_hex_key_material(&self, value: &str) -> bool {
1265        value.bytes().all(|byte| byte.is_ascii_hexdigit())
1266            && self.decoded_hex_key_material_lengths.contains(&value.len())
1267    }
1268
1269    /// Whether this detector admits a transport wrapper whose decoded payload
1270    /// is pure hex at the exact declared character count.
1271    pub fn allows_decoded_hex_key_material_len(&self, decoded_len: Option<usize>) -> bool {
1272        decoded_len.is_some_and(|length| self.decoded_hex_key_material_lengths.contains(&length))
1273    }
1274
1275    /// Whether this detector's canonical-hex policy admits an exact assignment
1276    /// key and pure-hex value pair.
1277    pub fn allows_canonical_hex_key_material(&self, keyword: &str, value: &str) -> bool {
1278        if !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
1279            return false;
1280        }
1281        self.canonical_hex_key_material.iter().any(|policy| {
1282            if !policy.lengths.contains(&value.len()) {
1283                return false;
1284            }
1285            if policy
1286                .excluded_keywords
1287                .iter()
1288                .any(|excluded| compact_assignment_keywords_equal(keyword, excluded))
1289            {
1290                return false;
1291            }
1292            policy
1293                .keywords
1294                .iter()
1295                .any(|owned_keyword| compact_assignment_keywords_equal(keyword, owned_keyword))
1296                || policy
1297                    .suffixes
1298                    .iter()
1299                    .any(|suffix| compact_assignment_keyword_ends_with(keyword, suffix))
1300        })
1301    }
1302}
1303
1304/// Redaction-safe serialized view of one detector declaration.
1305pub struct DetectorIntrospection<'a> {
1306    detector: &'a DetectorSpec,
1307}
1308
1309impl Serialize for DetectorIntrospection<'_> {
1310    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
1311    where
1312        S: serde::Serializer,
1313    {
1314        let serialized = serde_json::to_value(self.detector).map_err(S::Error::custom)?;
1315        let serde_json::Value::Object(mut declared) = serialized else {
1316            return Err(S::Error::custom(
1317                "DetectorSpec serialization must produce a JSON object",
1318            ));
1319        };
1320
1321        let tests = declared
1322            .remove("tests")
1323            .ok_or_else(|| S::Error::custom("DetectorSpec serialization omitted tests"))?
1324            .as_array()
1325            .cloned()
1326            .ok_or_else(|| S::Error::custom("DetectorSpec tests must serialize as an array"))?;
1327        let test_contracts = tests
1328            .into_iter()
1329            .map(|test| {
1330                let positive = test
1331                    .get("test_positive")
1332                    .is_some_and(|value| !value.is_null());
1333                let negative = test
1334                    .get("test_negative")
1335                    .is_some_and(|value| !value.is_null());
1336                serde_json::json!({
1337                    "positive": positive,
1338                    "negative": negative,
1339                })
1340            })
1341            .collect();
1342
1343        let verification = declared
1344            .remove("verify")
1345            .ok_or_else(|| S::Error::custom("DetectorSpec serialization omitted verify"))?;
1346        let has_verification = !verification.is_null();
1347
1348        let mut output = serde_json::Map::new();
1349        for field in [
1350            "id",
1351            "name",
1352            "service",
1353            "severity",
1354            "keywords",
1355            "simdsieve_prefixes",
1356            "patterns",
1357            "companions",
1358        ] {
1359            let Some(value) = declared.remove(field) else {
1360                return Err(S::Error::custom(format!(
1361                    "DetectorSpec serialization omitted required field {field:?}"
1362                )));
1363            };
1364            output.insert(field.to_string(), value);
1365        }
1366        output.insert(
1367            "verify".to_string(),
1368            serde_json::Value::Bool(has_verification),
1369        );
1370        output.insert("verification".to_string(), verification);
1371        output.insert(
1372            "test_contracts".to_string(),
1373            serde_json::Value::Array(test_contracts),
1374        );
1375        output.insert("policy".to_string(), serde_json::Value::Object(declared));
1376
1377        serde_json::Value::Object(output).serialize(serializer)
1378    }
1379}
1380
1381fn compact_assignment_keywords_equal(left: &str, right: &str) -> bool {
1382    compact_assignment_keyword_bytes(left).eq(compact_assignment_keyword_bytes(right))
1383}
1384
1385fn compact_assignment_keyword_ends_with(value: &str, suffix: &str) -> bool {
1386    let value_len = compact_assignment_keyword_bytes(value).count();
1387    let suffix_len = compact_assignment_keyword_bytes(suffix).count();
1388    // A suffix policy describes a vendor-prefixed assignment (`stripe_key`),
1389    // not the bare suffix itself (`key`). Exact names belong in `keywords`,
1390    // which keeps the policy explicit and preserves the bare-key digest gate.
1391    suffix_len > 0
1392        && value_len > suffix_len
1393        && compact_assignment_keyword_bytes(value)
1394            .skip(value_len - suffix_len)
1395            .eq(compact_assignment_keyword_bytes(suffix))
1396}
1397
1398fn compact_assignment_keyword_bytes(value: &str) -> impl Iterator<Item = u8> + '_ {
1399    value
1400        .bytes()
1401        .filter(|byte| !matches!(byte, b'_' | b'-' | b'.'))
1402        .map(|byte| byte.to_ascii_lowercase())
1403}
1404
1405/// Per-detector credential SHAPE constraint (`[detector.credential_shape]`),
1406/// OWNED HERE per the architecture law (was a centralized
1407/// `rules/detector-credential-shapes.toml` `[[shape]]` list keyed by detector
1408/// id, a per-detector property in a second file). A candidate whose byte length
1409/// / prefix / post-prefix body length does not fit the declared shape is
1410/// suppressed by the scanner's shape gate (`CredentialShapeRule::allows`). Only a
1411/// couple of fixed-format vendor detectors declare it: `aws-access-key` is
1412/// exactly 20 bytes; `anthropic-api-key` is `sk-ant-api03-` + an 80..=120 body.
1413#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1414#[serde(deny_unknown_fields)]
1415pub struct CredentialShape {
1416    /// Exact total credential byte length, for a fixed-length format.
1417    #[serde(default)]
1418    pub exact_length: Option<usize>,
1419    /// Literal prefix. The body-length bounds below apply ONLY to a candidate
1420    /// that starts with this prefix (a differently-shaped credential is not
1421    /// owned by this rule and passes untouched).
1422    #[serde(default)]
1423    pub prefix: Option<String>,
1424    /// Minimum body byte length AFTER `prefix` (requires `prefix`).
1425    #[serde(default)]
1426    pub body_min_length: Option<usize>,
1427    /// Maximum body byte length AFTER `prefix` (requires `prefix`).
1428    #[serde(default)]
1429    pub body_max_length: Option<usize>,
1430}
1431
1432impl CredentialShape {
1433    /// Validate the internal consistency of a declared shape (the single owner of
1434    /// these rules, was `credential_shapes::validate_shape_entries`). `detector_id`
1435    /// is only used to build a precise error message. Fails closed so a malformed
1436    /// per-detector shape is caught at load/build, never silently ignored.
1437    pub fn validate(&self, detector_id: &str) -> Result<(), String> {
1438        let has_constraint = self.exact_length.is_some()
1439            || self.prefix.is_some()
1440            || self.body_min_length.is_some()
1441            || self.body_max_length.is_some();
1442        if !has_constraint {
1443            return Err(format!(
1444                "credential shape for '{detector_id}' has no shape constraints"
1445            ));
1446        }
1447        if self.prefix.is_some()
1448            && self.exact_length.is_none()
1449            && self.body_min_length.is_none()
1450            && self.body_max_length.is_none()
1451        {
1452            return Err(format!(
1453                "credential shape for '{detector_id}' has a prefix but no length constraint"
1454            ));
1455        }
1456        if self.exact_length == Some(0) {
1457            return Err(format!(
1458                "credential shape for '{detector_id}' has exact_length=0"
1459            ));
1460        }
1461        if self.prefix.as_deref() == Some("") {
1462            return Err(format!(
1463                "credential shape for '{detector_id}' has an empty prefix"
1464            ));
1465        }
1466        if let (Some(minimum), Some(maximum)) = (self.body_min_length, self.body_max_length) {
1467            if minimum > maximum {
1468                return Err(format!(
1469                    "credential shape for '{detector_id}' has body_min_length greater than body_max_length"
1470                ));
1471            }
1472        }
1473        if (self.body_min_length.is_some() || self.body_max_length.is_some())
1474            && self.prefix.is_none()
1475        {
1476            return Err(format!(
1477                "credential shape for '{detector_id}' sets body length without a prefix"
1478            ));
1479        }
1480        if let (Some(exact_length), Some(prefix)) = (self.exact_length, self.prefix.as_deref()) {
1481            if let Some(minimum) = self.body_min_length {
1482                let minimum_total = prefix.len().checked_add(minimum).ok_or_else(|| {
1483                    format!("credential shape for '{detector_id}' overflows prefix plus body_min_length")
1484                })?;
1485                if exact_length < minimum_total {
1486                    return Err(format!(
1487                        "credential shape for '{detector_id}' has exact_length below prefix plus body_min_length"
1488                    ));
1489                }
1490            }
1491            if let Some(maximum) = self.body_max_length {
1492                let maximum_total = prefix.len().checked_add(maximum).ok_or_else(|| {
1493                    format!("credential shape for '{detector_id}' overflows prefix plus body_max_length")
1494                })?;
1495                if exact_length > maximum_total {
1496                    return Err(format!(
1497                        "credential shape for '{detector_id}' has exact_length above prefix plus body_max_length"
1498                    ));
1499                }
1500            }
1501        }
1502        Ok(())
1503    }
1504}
1505
1506/// One inline detector self-test fixture (`[[detector.tests]]`).
1507#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1508#[serde(deny_unknown_fields)]
1509pub struct DetectorTestSpec {
1510    /// Text this detector MUST fire on.
1511    #[serde(default)]
1512    pub test_positive: Option<String>,
1513    /// Text this detector MUST NOT fire on.
1514    #[serde(default)]
1515    pub test_negative: Option<String>,
1516    /// Canonical zero-based pattern ordinal this evidence directly covers.
1517    #[serde(default, skip_serializing_if = "Option::is_none")]
1518    pub pattern_index: Option<u32>,
1519    /// Named false-positive class for `test_negative`.
1520    #[serde(default, skip_serializing_if = "Option::is_none")]
1521    pub negative_class: Option<DetectorHardNegativeClass>,
1522    /// Optional source path used when executing this fixture through the scanner.
1523    #[serde(default)]
1524    pub test_path: Option<String>,
1525}
1526
1527/// A regex pattern with optional capture group and description.
1528#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
1529#[serde(deny_unknown_fields)]
1530pub struct PatternSpec {
1531    /// Regular expression string (Rust flavor). The owning detector TOML
1532    /// defines its exact separator and quantifier semantics; loading never
1533    /// rewrites the expression.
1534    pub regex: String,
1535    /// Optional context description.
1536    pub description: Option<String>,
1537    /// Optional capture group index containing the secret.
1538    pub group: Option<usize>,
1539    /// ASCII literals used only for candidate routing. Every full regex match
1540    /// must contain at least one declared literal; detector validation proves
1541    /// that OR-condition from the regex AST before the corpus can load.
1542    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1543    pub required_literals: Vec<String>,
1544    /// When true, a match against THIS pattern downgrades the
1545    /// finding to `Severity::ClientSafe` (regardless of the detector's
1546    /// nominal severity). Used by services that intentionally ship
1547    /// public-facing keys in client bundles:
1548    ///
1549    ///   - Sentry DSN (the `https://<key>@` URL is meant for the browser)
1550    ///   - Stripe `pk_live_` / `pk_test_` (publishable, sk_ is secret)
1551    ///   - Mapbox `pk.` (public, `sk.` is secret)
1552    ///   - Firebase Web API key, Google Maps browser key
1553    ///   - PostHog / Mixpanel / Algolia search / Datadog browser RUM
1554    ///
1555    /// Per-pattern (not per-detector) so detectors that fire on both
1556    /// the public *and* the secret prefix can tag only the public one.
1557    ///
1558    /// Case sensitivity: keyhog compiles every regex `case_insensitive(true)`,
1559    /// so to make a single pattern case-SENSITIVE (AWS `AKIA` is uppercase,
1560    /// GCP/Snowflake ids are lowercase) prefix its regex with the inline flag
1561    /// `(?-i)` in the TOML - no schema field needed.
1562    #[serde(default)]
1563    pub client_safe: bool,
1564    /// Keep generic shape and entropy gates active for matches from this
1565    /// pattern. Declare this beside the regex so policy cannot drift when a
1566    /// detector's patterns are reordered. Detector-level `weak_anchor = true`
1567    /// applies the same policy to every pattern.
1568    #[serde(default)]
1569    pub weak_anchor: bool,
1570    /// Treat matches from this exact regex as syntactically proven password
1571    /// slots. Unlike the detector-level flag, this does not exempt generic
1572    /// keyword-bridge candidates or sibling patterns from Tier-B shape gates.
1573    #[serde(default)]
1574    pub structural_password_slot: bool,
1575}
1576
1577impl PatternSpec {
1578    /// Validate that the declared routing literals are a necessary OR-condition
1579    /// of every regex match. The proof is conservative: declarations that span
1580    /// optional or structurally ambiguous AST nodes are rejected.
1581    pub fn validate_required_literals(&self) -> Result<(), String> {
1582        use regex_syntax::ast::{parse::Parser, Ast, RepetitionKind, RepetitionRange};
1583
1584        if self.required_literals.is_empty() {
1585            return Ok(());
1586        }
1587        if self
1588            .required_literals
1589            .iter()
1590            .any(|literal| literal.is_empty() || !literal.is_ascii())
1591        {
1592            return Err("must contain only non-empty ASCII strings".into());
1593        }
1594        let mut literals = self
1595            .required_literals
1596            .iter()
1597            .map(|literal| literal.to_ascii_lowercase())
1598            .collect::<Vec<_>>();
1599        literals.sort_unstable();
1600        if literals.windows(2).any(|pair| pair[0] == pair[1]) {
1601            return Err("contains a duplicate ASCII-insensitive literal".into());
1602        }
1603
1604        fn repetition_min(kind: &RepetitionKind) -> u32 {
1605            match kind {
1606                RepetitionKind::ZeroOrOne | RepetitionKind::ZeroOrMore => 0,
1607                RepetitionKind::OneOrMore => 1,
1608                RepetitionKind::Range(RepetitionRange::Exactly(min))
1609                | RepetitionKind::Range(RepetitionRange::AtLeast(min))
1610                | RepetitionKind::Range(RepetitionRange::Bounded(min, _)) => *min,
1611            }
1612        }
1613
1614        fn guarantees(ast: &Ast, literals: &[String]) -> bool {
1615            fn run_contains(run: &str, literals: &[String]) -> bool {
1616                let folded = run.to_ascii_lowercase();
1617                literals.iter().any(|literal| folded.contains(literal))
1618            }
1619
1620            match ast {
1621                Ast::Literal(literal) => run_contains(&literal.c.to_string(), literals),
1622                Ast::Group(group) => guarantees(&group.ast, literals),
1623                Ast::Alternation(alternation) => {
1624                    !alternation.asts.is_empty()
1625                        && alternation
1626                            .asts
1627                            .iter()
1628                            .all(|branch| guarantees(branch, literals))
1629                }
1630                Ast::Repetition(repetition) => {
1631                    repetition_min(&repetition.op.kind) > 0 && guarantees(&repetition.ast, literals)
1632                }
1633                Ast::Concat(concat) => {
1634                    let mut run = String::new();
1635                    for node in &concat.asts {
1636                        if let Ast::Literal(literal) = node {
1637                            run.push(literal.c);
1638                            continue;
1639                        }
1640                        if run_contains(&run, literals) || guarantees(node, literals) {
1641                            return true;
1642                        }
1643                        run.clear();
1644                    }
1645                    run_contains(&run, literals)
1646                }
1647                Ast::Empty(_)
1648                | Ast::Dot(_)
1649                | Ast::Assertion(_)
1650                | Ast::ClassUnicode(_)
1651                | Ast::ClassPerl(_)
1652                | Ast::ClassBracketed(_)
1653                | Ast::Flags(_) => false,
1654            }
1655        }
1656
1657        let ast = Parser::new()
1658            .parse(&self.regex)
1659            .map_err(|error| format!("cannot prove literals against invalid regex: {error}"))?;
1660        if guarantees(&ast, &literals) {
1661            Ok(())
1662        } else {
1663            Err("is not a proven necessary OR-condition of every regex match".into())
1664        }
1665    }
1666}
1667
1668/// How a contextual evidence match must be positioned relative to the primary credential.
1669#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1670#[serde(rename_all = "kebab-case")]
1671pub enum EvidenceDirection {
1672    /// Accept evidence on either side of the primary credential.
1673    #[default]
1674    Either,
1675    /// Accept only evidence ending before the primary credential begins.
1676    Before,
1677    /// Accept only evidence beginning after the primary credential ends.
1678    After,
1679}
1680
1681/// The structural boundary in which contextual evidence may satisfy a relation.
1682#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1683#[serde(rename_all = "kebab-case")]
1684pub enum EvidenceScope {
1685    /// Search the configured line and byte window.
1686    #[default]
1687    Window,
1688    /// Search only the primary credential's physical line.
1689    SameLine,
1690    /// Search only the blank-line-delimited record containing the credential.
1691    SameRecord,
1692    /// Search only the smallest balanced JSON-like object or array containing the credential.
1693    SameObject,
1694}
1695
1696/// Whether contextual evidence admits, strengthens, or rejects a detector match.
1697#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1698#[serde(rename_all = "kebab-case")]
1699pub enum EvidenceRequirement {
1700    /// Preserve the match without the evidence, but include a found value in scoring and verification.
1701    #[default]
1702    Reinforcing,
1703    /// Suppress the primary match when the evidence is absent.
1704    Required,
1705    /// Suppress the primary match when the evidence is present.
1706    Forbidden,
1707}
1708
1709/// The value relationship between a selected evidence capture and the primary credential.
1710#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1711#[serde(rename_all = "kebab-case")]
1712pub enum EvidenceValueRelation {
1713    /// Presence inside the declared structural and distance bounds is sufficient.
1714    #[default]
1715    Present,
1716    /// The selected evidence bytes must exactly equal the primary credential.
1717    EqualsPrimary,
1718    /// The selected evidence bytes must differ from the primary credential.
1719    DiffersFromPrimary,
1720}
1721
1722impl EvidenceDirection {
1723    /// Stable detector-schema spelling used by diagnostics and corpus identities.
1724    pub const fn as_str(self) -> &'static str {
1725        match self {
1726            Self::Either => "either",
1727            Self::Before => "before",
1728            Self::After => "after",
1729        }
1730    }
1731}
1732
1733impl EvidenceScope {
1734    /// Stable detector-schema spelling used by diagnostics and corpus identities.
1735    pub const fn as_str(self) -> &'static str {
1736        match self {
1737            Self::Window => "window",
1738            Self::SameLine => "same-line",
1739            Self::SameRecord => "same-record",
1740            Self::SameObject => "same-object",
1741        }
1742    }
1743}
1744
1745impl EvidenceRequirement {
1746    /// Stable detector-schema spelling used by diagnostics and corpus identities.
1747    pub const fn as_str(self) -> &'static str {
1748        match self {
1749            Self::Reinforcing => "reinforcing",
1750            Self::Required => "required",
1751            Self::Forbidden => "forbidden",
1752        }
1753    }
1754}
1755
1756impl EvidenceValueRelation {
1757    /// Stable detector-schema spelling used by diagnostics and corpus identities.
1758    pub const fn as_str(self) -> &'static str {
1759        match self {
1760            Self::Present => "present",
1761            Self::EqualsPrimary => "equals-primary",
1762            Self::DiffersFromPrimary => "differs-from-primary",
1763        }
1764    }
1765}
1766
1767/// Cross-detector evidence operation applied during deterministic match resolution.
1768#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1769#[serde(rename_all = "kebab-case")]
1770pub enum DetectorRelationKind {
1771    /// Keep this detector's finding only when the target detector also matches nearby.
1772    Requires,
1773    /// Suppress this detector's finding when the target detector matches nearby.
1774    Conflicts,
1775    /// Suppress nearby target-detector findings when this detector matches.
1776    Subsumes,
1777}
1778
1779impl DetectorRelationKind {
1780    /// Stable detector-schema spelling used by diagnostics and corpus identities.
1781    pub const fn as_str(self) -> &'static str {
1782        match self {
1783            Self::Requires => "requires",
1784            Self::Conflicts => "conflicts",
1785            Self::Subsumes => "subsumes",
1786        }
1787    }
1788}
1789
1790/// A bounded relationship between findings from two detector owners.
1791#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1792#[serde(deny_unknown_fields)]
1793pub struct DetectorRelationSpec {
1794    /// Stable target detector ID.
1795    pub detector_id: String,
1796    /// Resolution operation performed when target evidence is present or absent.
1797    pub kind: DetectorRelationKind,
1798    /// Maximum absolute line distance between source and target findings.
1799    pub within_lines: usize,
1800    /// Optional maximum byte gap between source and target finding spans.
1801    #[serde(default)]
1802    pub within_bytes: Option<usize>,
1803    /// Direction of the target finding relative to this detector's finding.
1804    #[serde(default)]
1805    pub direction: EvidenceDirection,
1806}
1807
1808/// Positive source selectors for detector families valid only in known locations.
1809#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1810#[serde(deny_unknown_fields)]
1811pub struct SourceAdmissionSpec {
1812    /// File-path regexes. At least one must match when this list is non-empty.
1813    #[serde(default)]
1814    pub path_patterns: Vec<String>,
1815    /// Exact `ChunkMetadata::source_type` values admitted by this detector.
1816    #[serde(default)]
1817    pub source_types: Vec<String>,
1818    /// Case-insensitive filename extensions without a leading dot.
1819    #[serde(default)]
1820    pub file_extensions: Vec<String>,
1821}
1822
1823/// Secondary pattern used to confirm a primary match or provide extra context.
1824#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1825#[serde(deny_unknown_fields)]
1826pub struct CompanionSpec {
1827    /// Field name used in verification templates (e.g. \`{{companion.secret_key}}\`).
1828    pub name: String,
1829    /// Regex to find the companion value nearby. The owning detector TOML
1830    /// defines its exact semantics; loading never rewrites the expression.
1831    pub regex: String,
1832    /// Maximum line distance from the primary match.
1833    pub within_lines: usize,
1834    /// Optional maximum byte gap between the evidence and primary spans.
1835    #[serde(default)]
1836    pub within_bytes: Option<usize>,
1837    /// Direction in which the evidence must occur.
1838    #[serde(default)]
1839    pub direction: EvidenceDirection,
1840    /// Structural boundary that both evidence and primary spans must share.
1841    #[serde(default)]
1842    pub scope: EvidenceScope,
1843    /// Admission effect of a present or absent evidence match.
1844    #[serde(default)]
1845    pub requirement: EvidenceRequirement,
1846    /// Explicit regex capture group whose bytes become the evidence value.
1847    ///
1848    /// `None` preserves the existing rule: select group 1 when the regex has a
1849    /// capture, otherwise select the whole match. Group 0 explicitly selects
1850    /// the whole match even when captures exist.
1851    #[serde(default)]
1852    pub capture_group: Option<usize>,
1853    /// Exact relationship required between the selected evidence and primary values.
1854    #[serde(default)]
1855    pub value_relation: EvidenceValueRelation,
1856    /// Schema-v2 compatibility input. New detector corpora use
1857    /// `requirement = "required"` instead.
1858    #[serde(default)]
1859    pub required: bool,
1860}
1861
1862impl CompanionSpec {
1863    /// Resolve the schema-v2 boolean into the typed admission requirement.
1864    pub fn effective_requirement(&self) -> EvidenceRequirement {
1865        if self.required {
1866            EvidenceRequirement::Required
1867        } else {
1868            self.requirement
1869        }
1870    }
1871}
1872
1873/// Live verification configuration for a detector.
1874#[derive(Debug, Clone, Default, Serialize, Deserialize)]
1875#[serde(deny_unknown_fields)]
1876pub struct VerifySpec {
1877    /// Target service identifier (defaults to detector's service if omitted).
1878    #[serde(default)]
1879    pub service: String,
1880    /// HTTP method (default: GET).
1881    pub method: Option<HttpMethod>,
1882    /// Endpoint URL with optional \`{{match}}\` or \`{{companion.<name>}}\` placeholders.
1883    pub url: Option<String>,
1884    /// Authentication scheme.
1885    pub auth: Option<AuthSpec>,
1886    /// Custom HTTP headers.
1887    #[serde(default)]
1888    pub headers: Vec<HeaderSpec>,
1889    /// Optional request body template.
1890    pub body: Option<String>,
1891    /// Criteria for a successful verification.
1892    pub success: Option<SuccessSpec>,
1893    /// Metadata to extract from the response.
1894    #[serde(default)]
1895    pub metadata: Vec<MetadataSpec>,
1896    /// Optional request timeout override.
1897    pub timeout_ms: Option<u64>,
1898    /// Multi-step verification flow.
1899    #[serde(default)]
1900    pub steps: Vec<StepSpec>,
1901    /// Domain allowlist for the verify URL after interpolation. If non-empty,
1902    /// the resolved host of the (interpolated) URL - and of every step's URL -
1903    /// MUST equal one of these entries (or be a subdomain of one). When empty,
1904    /// the verifier falls back to a hardcoded service allowlist if the
1905    /// `service` field maps to a known provider; otherwise the verifier
1906    /// REFUSES to send the request. This blocks malicious detector TOMLs
1907    /// that set `url = "{{match}}"` (or interpolate an attacker-controlled
1908    /// companion) from exfiltrating credentials. See kimi-wave1 audit
1909    /// finding 4.1 + wave3 §1.
1910    #[serde(default)]
1911    pub allowed_domains: Vec<String>,
1912    /// Optional out-of-band verification probe. When set, the verifier mints a
1913    /// per-finding correlation URL via the configured interactsh server,
1914    /// substitutes `{{interactsh}}` (and `{{interactsh.host}}` /
1915    /// `{{interactsh.url}}`) into the request template, and waits for the
1916    /// service to call back. OOB verification proves a leaked credential is
1917    /// **exfil-capable**, not just live: a webhook URL that returns 200 OK to
1918    /// every probe still has to actually fetch our collector to confirm it
1919    /// will deliver attacker-controlled traffic.
1920    ///
1921    /// Gated behind the runtime `--verify-oob` flag - never default. When a
1922    /// detector sets `oob`, verification requires an active OOB session and
1923    /// fails closed if the session is unavailable, rather than sending a
1924    /// malformed HTTP-only probe with empty interactsh substitutions.
1925    pub oob: Option<OobSpec>,
1926}
1927
1928/// Out-of-band callback verification configuration.
1929#[derive(Debug, Clone, Serialize, Deserialize)]
1930#[serde(deny_unknown_fields)]
1931pub struct OobSpec {
1932    /// Callback protocol the verifier waits for. The service may also touch
1933    /// other protocols on the same correlation id; only the listed ones count
1934    /// toward `Verified`.
1935    pub protocol: OobProtocol,
1936    /// How long to wait for the callback after the HTTP request returns.
1937    /// Defaults to 30 seconds when omitted; capped at the engine's
1938    /// `oob_timeout_max` to bound scan time.
1939    #[serde(default)]
1940    pub timeout_secs: Option<u64>,
1941    /// Verification policy (TOML wire values shown; serde is `snake_case`):
1942    /// - `oob_and_http` (default): both HTTP success criteria *and* OOB
1943    ///   callback must hold. This is the strict mode for webhook-style
1944    ///   detectors where 200 OK is necessary but not sufficient.
1945    /// - `oob_only`: ignore HTTP success, trust the OOB callback. For
1946    ///   detectors where the API has no useful HTTP response shape but
1947    ///   provably triggers an outbound request (e.g., one-way push tokens).
1948    /// - `oob_optional`: HTTP success alone verifies; OOB just enriches
1949    ///   metadata with `oob_observed=true|false` for the report.
1950    #[serde(default)]
1951    pub policy: OobPolicy,
1952}
1953
1954/// Out-of-band callback protocol expected from a successful exfil.
1955#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1956#[serde(rename_all = "lowercase")]
1957pub enum OobProtocol {
1958    /// Any DNS resolution against `{{interactsh}}.host`. Cheapest signal -
1959    /// many services resolve a webhook URL even before fetching it.
1960    Dns,
1961    /// HTTP or HTTPS request to the interactsh URL. The strongest signal;
1962    /// proves the service made an outbound HTTP request with the credential.
1963    Http,
1964    /// SMTP delivery attempt to `<random>@{{interactsh.host}}`. For mail
1965    /// detectors (Mailgun, SendGrid, …) where exfil = sending mail.
1966    Smtp,
1967    /// Any of the above. Use sparingly - a chatty CDN doing DNS prefetch
1968    /// can cause false positives.
1969    Any,
1970}
1971
1972/// How OOB observation combines with HTTP success criteria.
1973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1974#[serde(rename_all = "snake_case")]
1975pub enum OobPolicy {
1976    #[default]
1977    /// The credential is live only when the HTTP success criteria pass AND the OOB callback is observed.
1978    OobAndHttp,
1979    /// The credential is live purely on an observed OOB callback; the HTTP response is ignored.
1980    OobOnly,
1981    /// The HTTP success criteria decide the verdict; an observed OOB callback is added as extra evidence.
1982    OobOptional,
1983}
1984
1985/// A single step in a multi-step verification flow.
1986#[derive(Debug, Clone, Serialize, Deserialize)]
1987#[serde(deny_unknown_fields)]
1988pub struct StepSpec {
1989    /// Step name referenced by later steps and by extracted-metadata keys.
1990    pub name: String,
1991    /// HTTP method used for this step's request.
1992    pub method: HttpMethod,
1993    /// Request URL template, interpolated with credential and prior-step values.
1994    pub url: String,
1995    /// Authentication scheme applied to this step's request.
1996    pub auth: AuthSpec,
1997    #[serde(default)]
1998    /// Extra request headers, each interpolated like the URL.
1999    pub headers: Vec<HeaderSpec>,
2000    /// Optional request body template.
2001    pub body: Option<String>,
2002    /// Criteria that decide whether this step succeeded.
2003    pub success: SuccessSpec,
2004    #[serde(default)]
2005    /// Values pulled out of this step's response for later steps and the finding.
2006    pub extract: Vec<MetadataSpec>,
2007}
2008
2009/// Custom HTTP header specification.
2010#[derive(Debug, Clone, Serialize, Deserialize)]
2011#[serde(deny_unknown_fields)]
2012pub struct HeaderSpec {
2013    /// Header field name sent verbatim.
2014    pub name: String,
2015    /// Header value template, interpolated before the request is sent.
2016    pub value: String,
2017}
2018
2019/// Authentication scheme for verification requests.
2020#[derive(Debug, Clone, Serialize, Deserialize)]
2021#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
2022pub enum AuthSpec {
2023    /// Unauthenticated request.
2024    None {},
2025    /// `Authorization: Bearer <value>`.
2026    Bearer {
2027        /// Companion or credential field supplying the bearer value.
2028        field: String,
2029    },
2030    /// HTTP basic authentication.
2031    Basic {
2032        /// Username template.
2033        username: String,
2034        /// Password template.
2035        password: String,
2036    },
2037    /// Credential carried in a custom header.
2038    Header {
2039        /// Header field name.
2040        name: String,
2041        /// Header value template.
2042        template: String,
2043    },
2044    /// Credential carried in a query-string parameter.
2045    Query {
2046        /// Query parameter name.
2047        param: String,
2048        /// Companion or credential field supplying the parameter value.
2049        field: String,
2050    },
2051    #[serde(rename = "aws_v4")]
2052    /// AWS Signature Version 4 request signing.
2053    AwsV4 {
2054        /// Field supplying the access key id.
2055        access_key: String,
2056        /// Field supplying the secret access key.
2057        secret_key: String,
2058        /// AWS region the request is signed for.
2059        region: String,
2060        /// AWS service name the request is signed for.
2061        service: String,
2062        /// Optional field supplying a session token.
2063        session_token: Option<String>,
2064    },
2065    /// Credential prepared by an external interpreter script.
2066    Script {
2067        /// Interpreter that runs `code`.
2068        engine: ScriptEngine,
2069        /// Script source executed to produce the request credential.
2070        code: String,
2071    },
2072}
2073
2074/// Script interpreter names accepted by the detector TOML schema.
2075#[derive(Debug, Clone, PartialEq, Eq)]
2076pub enum ScriptEngine {
2077    /// CPython 3 (`python3`).
2078    Python3,
2079    /// Ambient CPython (`python`).
2080    Python,
2081    /// Node.js (`node`).
2082    Node,
2083    /// Any other engine name; parsed but refused for verification.
2084    Other(String),
2085}
2086
2087impl ScriptEngine {
2088    /// Engine names a detector may use for live verification.
2089    pub const ALLOWED_FOR_VERIFY: &'static [&'static str] = &["python3", "python", "node"];
2090
2091    /// Wire name of this engine, as written in detector TOML.
2092    pub fn as_str(&self) -> &str {
2093        match self {
2094            Self::Python3 => "python3",
2095            Self::Python => "python",
2096            Self::Node => "node",
2097            Self::Other(engine) => engine,
2098        }
2099    }
2100
2101    /// True when this engine is allowed to run during live verification.
2102    pub fn is_allowed_for_verify(&self) -> bool {
2103        matches!(self, Self::Python3 | Self::Python | Self::Node)
2104    }
2105}
2106
2107impl From<String> for ScriptEngine {
2108    fn from(engine: String) -> Self {
2109        match engine.as_str() {
2110            "python3" => Self::Python3,
2111            "python" => Self::Python,
2112            "node" => Self::Node,
2113            _ => Self::Other(engine),
2114        }
2115    }
2116}
2117
2118impl From<&str> for ScriptEngine {
2119    fn from(engine: &str) -> Self {
2120        Self::from(engine.to_owned())
2121    }
2122}
2123
2124impl fmt::Display for ScriptEngine {
2125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2126        f.write_str(self.as_str())
2127    }
2128}
2129
2130impl Serialize for ScriptEngine {
2131    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
2132    where
2133        S: serde::Serializer,
2134    {
2135        serializer.serialize_str(self.as_str())
2136    }
2137}
2138
2139impl<'de> Deserialize<'de> for ScriptEngine {
2140    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2141    where
2142        D: serde::Deserializer<'de>,
2143    {
2144        Ok(String::deserialize(deserializer)?.into())
2145    }
2146}
2147
2148/// How a verifier response establishes a successful credential check.
2149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2150#[serde(rename_all = "snake_case")]
2151pub enum SuccessPolicy {
2152    /// Stable response-body evidence must match.
2153    BodyPositive,
2154    /// The accepted status is necessary, then the generic provider-error body
2155    /// backstop must still reject populated error signals.
2156    StatusWithErrorBackstop,
2157    /// The provider protocol makes the accepted status authoritative even when
2158    /// the response body is empty or unstable.
2159    StatusAuthoritative,
2160}
2161
2162/// Criteria for a successful verification response.
2163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
2164#[serde(deny_unknown_fields)]
2165pub struct SuccessSpec {
2166    #[serde(default)]
2167    /// Required HTTP status code.
2168    pub status: Option<u16>,
2169    #[serde(default)]
2170    /// Reject if this status code is returned.
2171    pub status_not: Option<u16>,
2172    #[serde(default, skip_serializing_if = "Option::is_none")]
2173    /// Explicit response-success policy. Shipped detector contracts must
2174    /// classify this; `None` exists only for backwards-compatible decoding and
2175    /// is rejected by detector quality validation.
2176    pub policy: Option<SuccessPolicy>,
2177    #[serde(default)]
2178    /// Response body must contain this substring.
2179    pub body_contains: Option<String>,
2180    #[serde(default)]
2181    /// Response body must NOT contain this substring.
2182    pub body_not_contains: Option<String>,
2183    #[serde(default)]
2184    /// `$`-rooted response selector to check in the JSON response body.
2185    pub json_path: Option<String>,
2186    #[serde(default)]
2187    /// Expected value at \`json_path\`.
2188    pub equals: Option<String>,
2189}
2190
2191/// Migrate unambiguous schema-v1 status-only success contracts to the
2192/// conservative explicit policy.
2193///
2194/// Returns the number of contracts migrated so the loader can surface legacy
2195/// normalization. Body-constrained, no-status, and already-classified contracts
2196/// remain untouched and will pass or fail normal validation without guessing.
2197pub(crate) fn migrate_legacy_success_policies(detector: &mut DetectorSpec) -> usize {
2198    let Some(verify) = detector.verify.as_mut() else {
2199        return 0;
2200    };
2201
2202    let mut migrated = verify
2203        .success
2204        .as_mut()
2205        .is_some_and(migrate_legacy_success_policy) as usize;
2206    for step in &mut verify.steps {
2207        migrated += migrate_legacy_success_policy(&mut step.success) as usize;
2208    }
2209    migrated
2210}
2211
2212fn migrate_legacy_success_policy(success: &mut SuccessSpec) -> bool {
2213    let is_unambiguous_status_only = success.policy.is_none()
2214        && success.status.is_some()
2215        && success.body_contains.is_none()
2216        && success.body_not_contains.is_none()
2217        && success.json_path.is_none()
2218        && success.equals.is_none();
2219    if !is_unambiguous_status_only {
2220        return false;
2221    }
2222
2223    success.policy = Some(SuccessPolicy::StatusWithErrorBackstop);
2224    true
2225}
2226
2227/// Severity level for a finding.
2228///
2229/// `ClientSafe` is the bug-bounty tier for keys that are public by
2230/// design and shipped in client bundles: Sentry DSNs, Stripe `pk_*`
2231/// publishable keys, Mapbox `pk.` public tokens, PostHog project keys,
2232/// Firebase Web API keys, Google Maps browser keys, Algolia search
2233/// keys, Datadog browser RUM tokens, Mixpanel project tokens. The
2234/// detector still fires (a token grep is a token grep) but the
2235/// finding is rendered below `Low` and gated by `--hide-client-safe`
2236/// so a hunter running `keyhog scan --hide-client-safe target/` only
2237/// sees credentials that an attacker could actually exfiltrate
2238/// server-side.
2239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Default)]
2240#[serde(rename_all = "kebab-case")]
2241pub enum Severity {
2242    #[default]
2243    /// Informational only; not treated as a credential exposure.
2244    Info,
2245    /// Safe to ship to a client; an attacker gains nothing server-side.
2246    ClientSafe,
2247    /// Limited blast radius.
2248    Low,
2249    /// Meaningful blast radius.
2250    Medium,
2251    /// Serious blast radius.
2252    High,
2253    /// Full compromise of the referenced account or service.
2254    Critical,
2255}
2256
2257/// Canonical `kebab-case` severity wire forms in `ORDERED` order, the set an
2258/// unknown-token deserialize error advertises. DERIVED (const-evaluated) from the
2259/// single [`Severity::ORDERED`] + [`Severity::as_str`] table so it can never drift
2260/// from what the enum actually renders and accepts: a variant added to `ORDERED`
2261/// appears here, and in the deserialize accept-list and the unknown-variant
2262/// diagnostic, automatically, with no second hand-maintained string list. Lists
2263/// only the canonical spellings and deliberately omits the private `client_safe`
2264/// back-compat alias (still *accepted* on input by the visitor below, never
2265/// advertised).
2266const SEVERITY_CANONICAL_WIRE_FORMS: [&str; Severity::ORDERED.len()] = {
2267    let mut out = [""; Severity::ORDERED.len()];
2268    let mut i = 0;
2269    while i < Severity::ORDERED.len() {
2270        out[i] = Severity::ORDERED[i].as_str();
2271        i += 1;
2272    }
2273    out
2274};
2275
2276// Hand-written `Deserialize` (Serialize stays derived; `rename_all` makes it
2277// re-emit the canonical kebab form). Two reasons the derive is not enough:
2278//   * a non-string input (number/bool/null) must fail with an `invalid type`
2279//     error, the categorically-correct diagnostic, not the derive's
2280//     variant-identifier path; and
2281//   * an unknown token must advertise ONLY the canonical kebab forms while the
2282//     visitor still accepts the `client_safe` snake alias on input.
2283// Match is exact: case-sensitive and non-trimming (` critical `, `Critical`,
2284// `CLIENT-SAFE` all fail closed). No binary/non-self-describing serde path
2285// exists for `Severity` (every load is `serde_json`/`toml`, both self-describing
2286// with string values), so `deserialize_str` is safe here.
2287impl<'de> serde::Deserialize<'de> for Severity {
2288    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2289    where
2290        D: serde::Deserializer<'de>,
2291    {
2292        struct SeverityVisitor;
2293
2294        impl serde::de::Visitor<'_> for SeverityVisitor {
2295            type Value = Severity;
2296
2297            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2298                f.write_str(
2299                    "a severity string (one of info, client-safe, low, medium, high, critical)",
2300                )
2301            }
2302
2303            fn visit_str<E>(self, value: &str) -> Result<Severity, E>
2304            where
2305                E: serde::de::Error,
2306            {
2307                // Private back-compat alias, deliberately NOT a canonical wire
2308                // form (kept out of `as_str`/the advertised set).
2309                if value == "client_safe" {
2310                    return Ok(Severity::ClientSafe);
2311                }
2312                // Canonical match is EXACT (case-sensitive, non-trimming): compare
2313                // the input against each variant's single-source-of-truth
2314                // `as_str`, so `Critical`/` critical `/`CLIENT-SAFE`/`` all fall
2315                // through to the fail-closed unknown-variant path below.
2316                Severity::ORDERED
2317                    .iter()
2318                    .find(|variant| variant.as_str() == value)
2319                    .copied()
2320                    .ok_or_else(|| E::unknown_variant(value, &SEVERITY_CANONICAL_WIRE_FORMS))
2321            }
2322        }
2323
2324        deserializer.deserialize_str(SeverityVisitor)
2325    }
2326}
2327
2328impl Severity {
2329    pub(crate) const FILTER_EXPECTED_LABELS: &'static str =
2330        "info|client-safe|low|medium|high|critical";
2331    pub(crate) const ORDERED: [Severity; 6] = [
2332        Severity::Info,
2333        Severity::ClientSafe,
2334        Severity::Low,
2335        Severity::Medium,
2336        Severity::High,
2337        Severity::Critical,
2338    ];
2339
2340    /// Step the severity down one tier (Critical → High, High → Medium, …).
2341    /// `Info` stays at `Info` (no lower bucket).
2342    ///
2343    /// Used by diff-aware scoring: a credential that only appears in non-HEAD
2344    /// git history is still a leak (commit history is public if the repo is)
2345    /// but is meaningfully less urgent than a credential live in HEAD that an
2346    /// attacker can grep right now. One tier of downgrade communicates that
2347    /// without hiding the finding entirely.
2348    pub fn downgrade_one(self) -> Self {
2349        match self {
2350            Severity::Critical => Severity::High,
2351            Severity::High => Severity::Medium,
2352            Severity::Medium => Severity::Low,
2353            Severity::Low => Severity::ClientSafe,
2354            Severity::ClientSafe => Severity::Info,
2355            Severity::Info => Severity::Info,
2356        }
2357    }
2358
2359    /// Canonical lowercase string for this severity, matching the serde
2360    /// `kebab-case` wire form (`client-safe`, not `clientsafe`). This is the
2361    /// single source of truth for rendering a severity as text; reporters and
2362    /// any other surface should go through `Display`/`as_str` rather than
2363    /// reaching for `format!("{:?}")`, which diverges for `ClientSafe`.
2364    ///
2365    /// Public so downstream crates (the CLI completion/severity summary,
2366    /// stream previews) render severity text from this one table instead of
2367    /// keeping their own `match` copies that can drift.
2368    pub const fn as_str(&self) -> &'static str {
2369        // THE single source of truth for every severity wire form. `const` so the
2370        // canonical-wire-form set, the deserialize accept-list, and the filter
2371        // parser all DERIVE from this one table at compile time instead of
2372        // re-listing the six (variant, string) pairs and risking drift.
2373        match self {
2374            Severity::Info => "info",
2375            Severity::ClientSafe => "client-safe",
2376            Severity::Low => "low",
2377            Severity::Medium => "medium",
2378            Severity::High => "high",
2379            Severity::Critical => "critical",
2380        }
2381    }
2382
2383    pub(crate) fn from_filter_label(label: &str) -> Option<Self> {
2384        // Filter labels are lenient (trim + lowercase), unlike the exact
2385        // deserializer path above, but both resolve against the SAME single
2386        // `as_str` table so a new/renamed wire form is honoured everywhere at
2387        // once. `client_safe` snake alias is accepted here too.
2388        let normalized = label.trim().to_ascii_lowercase();
2389        if normalized == "client_safe" {
2390            return Some(Severity::ClientSafe);
2391        }
2392        Severity::ORDERED
2393            .iter()
2394            .find(|variant| variant.as_str() == normalized)
2395            .copied()
2396    }
2397
2398    pub(crate) fn rank(self) -> usize {
2399        match Self::ORDERED
2400            .iter()
2401            .position(|candidate| *candidate == self)
2402        {
2403            Some(rank) => rank,
2404            None => Self::ORDERED.len() - 1, // LAW10: fail-closed/security: impossible enum/table drift clamps to highest severity so severity_lte cannot over-suppress.
2405        }
2406    }
2407
2408    pub(crate) fn label_for_rank(rank: usize) -> &'static str {
2409        match Self::ORDERED.get(rank) {
2410            Some(severity) => severity.as_str(),
2411            None => Severity::Critical.as_str(), // LAW10: fail-closed/security: invalid rank maps to highest severity label so severity_lte cannot over-suppress.
2412        }
2413    }
2414}
2415
2416impl std::fmt::Display for Severity {
2417    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2418        f.write_str(self.as_str())
2419    }
2420}
2421
2422/// HTTP method for verification requests.
2423#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2424pub enum HttpMethod {
2425    #[serde(rename = "GET")]
2426    /// HTTP `GET`.
2427    Get,
2428    #[serde(rename = "POST")]
2429    /// HTTP `POST`.
2430    Post,
2431    #[serde(rename = "PUT")]
2432    /// HTTP `PUT`.
2433    Put,
2434    #[serde(rename = "DELETE")]
2435    /// HTTP `DELETE`.
2436    Delete,
2437    #[serde(rename = "PATCH")]
2438    /// HTTP `PATCH`.
2439    Patch,
2440    #[serde(rename = "HEAD")]
2441    /// HTTP `HEAD`.
2442    Head,
2443}
2444
2445/// Canonical file name for the directory-scoped detector corpus manifest.
2446pub const DETECTOR_CORPUS_MANIFEST_FILE: &str = "corpus.toml";
2447
2448/// Oldest legacy detector schema this binary can migrate deterministically.
2449pub const DETECTOR_CORPUS_MIN_SCHEMA_VERSION: u32 = 1;
2450
2451/// Detector schema authored and enforced by this binary.
2452pub const DETECTOR_CORPUS_SCHEMA_VERSION: u32 = 5;
2453pub(crate) const HARD_NEGATIVE_TEST_EVIDENCE_SCHEMA_VERSION: u32 = 5;
2454
2455/// Highest newer detector schema this binary may inspect additively.
2456///
2457/// A corpus beyond this one-version compatibility window is rejected before
2458/// any detector is loaded.
2459pub const DETECTOR_CORPUS_MAX_FORWARD_SCHEMA_VERSION: u32 = DETECTOR_CORPUS_SCHEMA_VERSION + 1;
2460
2461/// Directory-scoped compatibility contract for detector TOML files.
2462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2463#[serde(deny_unknown_fields)]
2464pub struct DetectorCorpusManifest {
2465    /// Schema version shared by every detector file in the directory.
2466    pub schema_version: u32,
2467}
2468
2469/// Wrapping struct for a detector TOML file.
2470#[derive(Debug, Clone, Serialize, Deserialize)]
2471#[serde(deny_unknown_fields)]
2472pub struct DetectorFile {
2473    /// The single detector this file defines.
2474    pub detector: DetectorSpec,
2475}