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