Skip to main content

keyhog_core/
spec.rs

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