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