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