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