1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
//! Service-name vocabulary for the keyword-specificity ML feature
//! (`ml_features::SERVICE_CONTEXT_FEATURE_INDEX`, feature 42. DET-1).
//!
//! # What this feature separates
//!
//! The CredData/mirror analysis showed the MoE's dominant confusion is
//! UUID/opaque-token shapes: `CODECOV_TOKEN = "7b3e5d8c-…"` (a real credential <!-- keyhog:ignore detector=generic-secret -->
//! 171 contract positives across 62 detectors carry exactly this shape) versus
//! `SESSION_ID = "50bcba48-…"` / `API_KEY = "<uuid>"` (an identifier, mirror
//! labels these negative, and they are 68-76% of the CredData FP flood). The
//! shape features cannot split these: the VALUE is identical. What differs is
//! the CONTEXT: real UUID-shaped secrets ride next to a SPECIFIC service name
//! (codecov, equinix, grafana, …); identifier UUIDs ride next to GENERIC
//! credential role-words only (api_key, secret, token). Feature 17 already says
//! "context mentions a generic credential word"; this module powers feature 42,
//! "context names a specific service", so the model can learn
//! `service-context + UUID → secret` / `generic-context-only + UUID → reject`.
//!
//! # ONE-PLACE derivation (never a hand-curated list)
//!
//! The vocabulary is DERIVED from the embedded detector corpus, the single
//! definitional home of "which services keyhog knows", via
//! [`keyhog_core::embedded_detector_specs`]. Every detector TOML's prefilter
//! `keywords` feed in; three deterministic filters remove non-service noise:
//!
//! 1. **Length floor** ([`MIN_SERVICE_KEYWORD_LEN`]): 1-3 byte keywords are
//! value prefixes (`cko`, `dt0`, `sk-`) or symbols (`$`, `://`) that
//! collide with random credential bytes in the context window, not names.
//! 2. **Generic-family exclusion**: any keyword listed by a `generic-*` (or
//! future `entropy*`) detector spec is a credential ROLE word by
//! definition (api_key, secret, token, password, …), the exact vocabulary
//! feature 42 must NOT fire on. SUBSTRINGS of those words are excluded
//! too: as a `contains` needle, `api_` fires everywhere `api_key` does,
//! making it strictly more generic than the word itself.
//! 3. **Stem-spread genericity** ([`GENERIC_STEM_SPREAD_LIMIT`]): a keyword
//! used by detectors of ≥ 3 DISTINCT id stems (stem = the id's first
//! `-`-separated token) names a cross-vendor concept (`client_secret`
//! spans 14 stems, `bearer` 6, `webhook_secret` 6), not a service. A
//! keyword spread across many detectors of ONE stem (`gitlab` appears in 9
//! `gitlab-*` detectors) stays: that is one service with many token kinds.
//!
//! The result is lowercased, deduplicated (this also collapses the 562
//! defensive case-variant keyword pairs like `ADOBE`/`adobe`), and sorted, so
//! the vocabulary is a deterministic function of the detector corpus alone.
//!
//! # Train/serve parity contract
//!
//! Training features come from the Rust `dump_features` serve path, so training
//! and serving share THIS implementation. The independent Python parity oracle
//! (`ml/feature_parity.py::_service_vocabulary`) re-derives the vocabulary from
//! `detectors/*.toml` with byte-identical rules; `ml/parity_check.py` fails
//! loudly on any disagreement. Change the rules here and there together.
use ;
use LazyLock;
/// Keywords shorter than this never enter the service vocabulary: at 1-3 bytes
/// they are credential value-prefixes or separators, and as case-insensitive
/// `contains` needles they false-fire inside random base64/hex bytes that share
/// the ±5-line ML context window with the candidate.
pub const MIN_SERVICE_KEYWORD_LEN: usize = 4;
/// A keyword used by detectors of this many DISTINCT id stems (or more) is a
/// cross-vendor role word, not a service name. 2 keeps two-spelling vendors
/// (`aws-*` + `amazon-*` both carrying `amazonaws`); 3 is where genuine
/// role-words start (`x-api-key` spans 9 stems, `authorization` 10).
pub const GENERIC_STEM_SPREAD_LIMIT: usize = 3;
/// The id's first `-`-separated token: `gitlab-pipeline-trigger-token` →
/// `gitlab`. Groups sibling detectors of one service so per-service keyword
/// reuse is not mistaken for cross-vendor genericity.
/// Pure vocabulary builder over an explicit spec slice (unit-testable without
/// the embedded corpus). See the module doc for the three filter rules.
pub
/// The service vocabulary derived from the embedded corpus, built exactly once.
pub
/// One case-insensitive multi-pattern automaton over the whole vocabulary.
/// `contains_any` over ~2.4k needles per ML candidate would be O(needles ×
/// context) (Law 7); Aho-Corasick makes the probe a single pass over the ±5-line
/// context window. Build failure is a build-time-data defect (the corpus is
/// compiled in), so it fails closed like every other embedded-corpus consumer.
static SERVICE_AC: = new;
/// Feature-42 probe: does the ML context window (±5 lines + `file:` path)
/// mention any known service name? ASCII-case-insensitive `contains`, matching
/// the semantics of the sibling context probes (features 17/18/20).
pub