keyhog_core/spec.rs
1//! Detector specification: TOML-based pattern definitions with regex, keywords,
2//! verification endpoints, and companion patterns.
3
4// Debt bucket: 55 public items, each landed before the crate floor raised
5// `missing_docs` to `warn`. Each is part of the public TOML schema and would
6// benefit from a doc line; remove this allow once they all carry one.
7#![allow(missing_docs)]
8
9mod load;
10mod validate;
11
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15pub use load::{
16 load_detector_cache, load_detectors, load_detectors_from_str, load_detectors_with_gate,
17 save_detector_cache,
18};
19pub use validate::{validate_detector, QualityIssue};
20
21/// Metadata field specification for verification results.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct MetadataSpec {
24 /// Field name in the finding metadata map.
25 pub name: String,
26 /// GJSON path to extract from the verification response body.
27 pub json_path: String,
28}
29
30/// A complete detector definition loaded from a TOML file.
31#[derive(Debug, Clone, Serialize, Deserialize, Default)]
32pub struct DetectorSpec {
33 /// Unique stable identifier (e.g. \`aws-access-key\`).
34 pub id: String,
35 /// Human-readable name.
36 pub name: String,
37 /// Target service (e.g. \`aws\`, \`stripe\`).
38 pub service: String,
39 /// Default severity for findings.
40 pub severity: Severity,
41 /// List of regex patterns to match.
42 pub patterns: Vec<PatternSpec>,
43 /// Secondary patterns required to confirm a match.
44 #[serde(default)]
45 pub companions: Vec<CompanionSpec>,
46 /// Live verification configuration.
47 pub verify: Option<VerifySpec>,
48 /// High-performance pre-filtering keywords.
49 #[serde(default)]
50 pub keywords: Vec<String>,
51}
52
53/// A regex pattern with optional capture group and description.
54#[derive(Debug, Clone, Serialize, Deserialize, Default)]
55pub struct PatternSpec {
56 /// Regular expression string (Rust flavor).
57 pub regex: String,
58 /// Optional context description.
59 pub description: Option<String>,
60 /// Optional capture group index containing the secret.
61 pub group: Option<usize>,
62 /// When true, a match against THIS pattern downgrades the
63 /// finding to `Severity::ClientSafe` (regardless of the detector's
64 /// nominal severity). Used by services that intentionally ship
65 /// public-facing keys in client bundles:
66 ///
67 /// - Sentry DSN (the `https://<key>@` URL is meant for the browser)
68 /// - Stripe `pk_live_` / `pk_test_` (publishable, sk_ is secret)
69 /// - Mapbox `pk.` (public, `sk.` is secret)
70 /// - Firebase Web API key, Google Maps browser key
71 /// - PostHog / Mixpanel / Algolia search / Datadog browser RUM
72 ///
73 /// Per-pattern (not per-detector) so detectors that fire on both
74 /// the public *and* the secret prefix can tag only the public one.
75 ///
76 /// Case sensitivity: keyhog compiles every regex `case_insensitive(true)`,
77 /// so to make a single pattern case-SENSITIVE (AWS `AKIA` is uppercase,
78 /// GCP/Snowflake ids are lowercase) prefix its regex with the inline flag
79 /// `(?-i)` in the TOML - no schema field needed.
80 #[serde(default)]
81 pub client_safe: bool,
82}
83
84/// Secondary pattern used to confirm a primary match or provide extra context.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct CompanionSpec {
87 /// Field name used in verification templates (e.g. \`{{companion.secret_key}}\`).
88 pub name: String,
89 /// Regex to find the companion value nearby.
90 pub regex: String,
91 /// Maximum line distance from the primary match.
92 pub within_lines: usize,
93 /// Whether this companion must be found to report the finding.
94 #[serde(default)]
95 pub required: bool,
96}
97
98/// Live verification configuration for a detector.
99#[derive(Debug, Clone, Default, Serialize, Deserialize)]
100pub struct VerifySpec {
101 /// Target service identifier (defaults to detector's service if omitted).
102 #[serde(default)]
103 pub service: String,
104 /// HTTP method (default: GET).
105 pub method: Option<HttpMethod>,
106 /// Endpoint URL with optional \`{{match}}\` or \`{{companion.<name>}}\` placeholders.
107 pub url: Option<String>,
108 /// Authentication scheme.
109 pub auth: Option<AuthSpec>,
110 /// Custom HTTP headers.
111 #[serde(default)]
112 pub headers: Vec<HeaderSpec>,
113 /// Optional request body template.
114 pub body: Option<String>,
115 /// Criteria for a successful verification.
116 pub success: Option<SuccessSpec>,
117 /// Metadata to extract from the response.
118 #[serde(default)]
119 pub metadata: Vec<MetadataSpec>,
120 /// Optional request timeout override.
121 pub timeout_ms: Option<u64>,
122 /// Multi-step verification flow.
123 #[serde(default)]
124 pub steps: Vec<StepSpec>,
125 /// Domain allowlist for the verify URL after interpolation. If non-empty,
126 /// the resolved host of the (interpolated) URL - and of every step's URL -
127 /// MUST equal one of these entries (or be a subdomain of one). When empty,
128 /// the verifier falls back to a hardcoded service allowlist if the
129 /// `service` field maps to a known provider; otherwise the verifier
130 /// REFUSES to send the request. This blocks malicious detector TOMLs
131 /// that set `url = "{{match}}"` (or interpolate an attacker-controlled
132 /// companion) from exfiltrating credentials. See kimi-wave1 audit
133 /// finding 4.1 + wave3 §1.
134 #[serde(default)]
135 pub allowed_domains: Vec<String>,
136 /// Optional out-of-band verification probe. When set, the verifier mints a
137 /// per-finding correlation URL via the configured interactsh server,
138 /// substitutes `{{interactsh}}` (and `{{interactsh.host}}` /
139 /// `{{interactsh.url}}`) into the request template, and waits for the
140 /// service to call back. OOB verification proves a leaked credential is
141 /// **exfil-capable**, not just live: a webhook URL that returns 200 OK to
142 /// every probe still has to actually fetch our collector to confirm it
143 /// will deliver attacker-controlled traffic.
144 ///
145 /// Gated behind the runtime `--verify-oob` flag - never default. When the
146 /// flag is off, `oob` is ignored and verification falls back to the
147 /// HTTP success criteria alone.
148 pub oob: Option<OobSpec>,
149}
150
151/// Out-of-band callback verification configuration.
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct OobSpec {
154 /// Callback protocol the verifier waits for. The service may also touch
155 /// other protocols on the same correlation id; only the listed ones count
156 /// toward `Verified`.
157 pub protocol: OobProtocol,
158 /// How long to wait for the callback after the HTTP request returns.
159 /// Defaults to 30 seconds when omitted; capped at the engine's
160 /// `oob_timeout_max` to bound scan time.
161 #[serde(default)]
162 pub timeout_secs: Option<u64>,
163 /// Verification policy:
164 /// - `OobAndHttp` (default): both HTTP success criteria *and* OOB
165 /// callback must hold. This is the strict mode for webhook-style
166 /// detectors where 200 OK is necessary but not sufficient.
167 /// - `OobOnly`: ignore HTTP success, trust the OOB callback. For
168 /// detectors where the API has no useful HTTP response shape but
169 /// provably triggers an outbound request (e.g., one-way push tokens).
170 /// - `OobOptional`: HTTP success alone verifies; OOB just enriches
171 /// metadata with `oob_observed=true|false` for the report.
172 #[serde(default)]
173 pub policy: OobPolicy,
174}
175
176/// Out-of-band callback protocol expected from a successful exfil.
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
178#[serde(rename_all = "lowercase")]
179pub enum OobProtocol {
180 /// Any DNS resolution against `{{interactsh}}.host`. Cheapest signal -
181 /// many services resolve a webhook URL even before fetching it.
182 Dns,
183 /// HTTP or HTTPS request to the interactsh URL. The strongest signal;
184 /// proves the service made an outbound HTTP request with the credential.
185 Http,
186 /// SMTP delivery attempt to `<random>@{{interactsh.host}}`. For mail
187 /// detectors (Mailgun, SendGrid, …) where exfil = sending mail.
188 Smtp,
189 /// Any of the above. Use sparingly - a chatty CDN doing DNS prefetch
190 /// can cause false positives.
191 Any,
192}
193
194/// How OOB observation combines with HTTP success criteria.
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
196#[serde(rename_all = "snake_case")]
197pub enum OobPolicy {
198 #[default]
199 OobAndHttp,
200 OobOnly,
201 OobOptional,
202}
203
204/// A single step in a multi-step verification flow.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct StepSpec {
207 pub name: String,
208 pub method: HttpMethod,
209 pub url: String,
210 pub auth: AuthSpec,
211 #[serde(default)]
212 pub headers: Vec<HeaderSpec>,
213 pub body: Option<String>,
214 pub success: SuccessSpec,
215 #[serde(default)]
216 pub extract: Vec<MetadataSpec>,
217}
218
219/// Custom HTTP header specification.
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct HeaderSpec {
222 pub name: String,
223 pub value: String,
224}
225
226/// Authentication scheme for verification requests.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228#[serde(tag = "type", rename_all = "snake_case")]
229pub enum AuthSpec {
230 None,
231 Bearer {
232 field: String,
233 },
234 Basic {
235 username: String,
236 password: String,
237 },
238 Header {
239 name: String,
240 template: String,
241 },
242 Query {
243 param: String,
244 field: String,
245 },
246 #[serde(rename = "aws_v4")]
247 AwsV4 {
248 access_key: String,
249 secret_key: String,
250 region: String,
251 service: String,
252 session_token: Option<String>,
253 },
254 Script {
255 engine: String,
256 code: String,
257 },
258}
259
260impl AuthSpec {
261 pub fn service_name(&self) -> Option<&str> {
262 match self {
263 AuthSpec::AwsV4 { service, .. } => Some(service),
264 _ => None,
265 }
266 }
267}
268
269/// Criteria for a successful verification response.
270#[derive(Debug, Clone, Serialize, Deserialize, Default)]
271pub struct SuccessSpec {
272 #[serde(default)]
273 /// Required HTTP status code.
274 pub status: Option<u16>,
275 #[serde(default)]
276 /// Reject if this status code is returned.
277 pub status_not: Option<u16>,
278 #[serde(default)]
279 /// Response body must contain this substring.
280 pub body_contains: Option<String>,
281 #[serde(default)]
282 /// Response body must NOT contain this substring.
283 pub body_not_contains: Option<String>,
284 #[serde(default)]
285 /// GJSON path to check in response body.
286 pub json_path: Option<String>,
287 #[serde(default)]
288 /// Expected value at \`json_path\`.
289 pub equals: Option<String>,
290}
291
292/// Severity level for a finding.
293///
294/// `ClientSafe` is the bug-bounty tier for keys that are public by
295/// design and shipped in client bundles: Sentry DSNs, Stripe `pk_*`
296/// publishable keys, Mapbox `pk.` public tokens, PostHog project keys,
297/// Firebase Web API keys, Google Maps browser keys, Algolia search
298/// keys, Datadog browser RUM tokens, Mixpanel project tokens. The
299/// detector still fires (a token grep is a token grep) but the
300/// finding is rendered below `Low` and gated by `--hide-client-safe`
301/// so a hunter running `keyhog scan --hide-client-safe target/` only
302/// sees credentials that an attacker could actually exfiltrate
303/// server-side.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
305#[serde(rename_all = "kebab-case")]
306pub enum Severity {
307 #[default]
308 Info,
309 #[serde(alias = "client_safe")]
310 ClientSafe,
311 Low,
312 Medium,
313 High,
314 Critical,
315}
316
317impl Severity {
318 pub fn to_severity(&self) -> Self {
319 *self
320 }
321
322 /// Step the severity down one tier (Critical → High, High → Medium, …).
323 /// `Info` stays at `Info` (no lower bucket).
324 ///
325 /// Used by diff-aware scoring: a credential that only appears in non-HEAD
326 /// git history is still a leak (commit history is public if the repo is)
327 /// but is meaningfully less urgent than a credential live in HEAD that an
328 /// attacker can grep right now. One tier of downgrade communicates that
329 /// without hiding the finding entirely.
330 pub fn downgrade_one(self) -> Self {
331 match self {
332 Severity::Critical => Severity::High,
333 Severity::High => Severity::Medium,
334 Severity::Medium => Severity::Low,
335 Severity::Low => Severity::ClientSafe,
336 Severity::ClientSafe => Severity::Info,
337 Severity::Info => Severity::Info,
338 }
339 }
340}
341
342/// HTTP method for verification requests.
343#[derive(Debug, Clone, Serialize, Deserialize)]
344pub enum HttpMethod {
345 #[serde(rename = "GET")]
346 Get,
347 #[serde(rename = "POST")]
348 Post,
349 #[serde(rename = "PUT")]
350 Put,
351 #[serde(rename = "DELETE")]
352 Delete,
353 #[serde(rename = "PATCH")]
354 Patch,
355 #[serde(rename = "HEAD")]
356 Head,
357}
358
359/// Wrapping struct for a detector TOML file.
360#[derive(Debug, Clone, Serialize, Deserialize)]
361pub struct DetectorFile {
362 pub detector: DetectorSpec,
363}
364
365/// Errors returned while loading or validating detector specifications.
366#[derive(Debug, Error)]
367#[allow(clippy::result_large_err)] // SpecError variants include 128-byte toml::de::Error; boxing would be a breaking API change.
368pub enum SpecError {
369 #[error(
370 "failed to read detector file {path}: {source}. Fix: check the detector path exists and that the file is readable TOML"
371 )]
372 ReadFile {
373 path: String,
374 source: std::io::Error,
375 },
376 #[error("invalid TOML in detector {path}: {source}. Fix: repair the TOML syntax in the detector file")]
377 InvalidToml {
378 path: std::path::PathBuf,
379 source: toml::de::Error,
380 },
381}