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