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