Skip to main content

devboy_secret_patterns/
builtin.rs

1//! Built-in pattern catalogue per [ADR-023] §3.6.
2//!
3//! Thirty hard-coded patterns covering the long tail of provider
4//! tokens, private keys, JWTs, and connection strings. Each pattern
5//! implements [`SecretPattern`] through the shared [`Builtin`]
6//! adapter struct so the catalogue stays declarative — adding a
7//! pattern is one entry in [`BUILTINS`].
8//!
9//! Patterns expose:
10//!
11//! - **Mandatory** — `id`, `display_name`, `format_regex`, `severity`.
12//! - **Metadata** (optional) — for patterns with a known retrieval URL
13//!   (`github-pat` → GitHub settings page, `openai-key` → OpenAI
14//!   platform). Patterns whose value shape we recognise but which
15//!   have no central retrieval URL (`jwt`, `private-key-*`,
16//!   `postgres-url`) omit the metadata layer.
17//! - **Rotation / liveness** — left to a follow-up phase (P2.4 and
18//!   P9.x respectively); each entry's slot is `None` here.
19//!
20//! [ADR-023]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md
21
22use std::borrow::Cow;
23use std::sync::{LazyLock, OnceLock};
24
25use regex::Regex;
26
27use crate::{LivenessSpec, PatternMetadata, RotationSpec, SecretPattern, Severity};
28
29/// Adapter struct that turns a static data row into a
30/// [`SecretPattern`] implementation. Each [`BUILTINS`] entry is a
31/// `Builtin`; the regex compiles lazily on first access via
32/// [`OnceLock`] so process startup pays nothing for patterns that are
33/// never consulted.
34pub struct Builtin {
35    /// Stable identifier (lowercase kebab-case).
36    pub id: &'static str,
37    /// Human-readable name.
38    pub display_name: &'static str,
39    /// Severity to attach to a leak finding.
40    pub severity: Severity,
41    /// Regex source — compiled lazily into [`Builtin::regex`].
42    pub regex_src: &'static str,
43    /// Lazily-compiled regex. Use `Builtin::compiled_regex` in
44    /// preference to touching this field directly.
45    pub regex: OnceLock<Regex>,
46    /// Optional descriptive metadata (provider id, retrieval URL,
47    /// expiry default, scope hints).
48    pub metadata: Option<PatternMetadata>,
49    /// Optional rotation hint. None for v1; populated when P2.4
50    /// wires inheritance.
51    pub rotation: Option<RotationSpec>,
52    /// Optional liveness probe. None for v1; populated when P9.2
53    /// adds provider liveness checks.
54    pub liveness: Option<LivenessSpec>,
55}
56
57impl Builtin {
58    /// Compile the regex on first access; subsequent calls reuse the
59    /// cached value. Panics only if a built-in regex source is
60    /// malformed — caught by the
61    /// [`tests::all_builtin_regex_sources_compile`] test.
62    fn compiled_regex(&self) -> &Regex {
63        self.regex.get_or_init(|| {
64            Regex::new(self.regex_src).unwrap_or_else(|e| {
65                panic!(
66                    "built-in pattern '{}' has malformed regex `{}`: {e}",
67                    self.id, self.regex_src
68                )
69            })
70        })
71    }
72}
73
74impl SecretPattern for Builtin {
75    fn id(&self) -> &str {
76        self.id
77    }
78    fn display_name(&self) -> &str {
79        self.display_name
80    }
81    fn severity(&self) -> Severity {
82        self.severity
83    }
84    fn format_regex(&self) -> &Regex {
85        self.compiled_regex()
86    }
87    fn metadata(&self) -> Option<&PatternMetadata> {
88        self.metadata.as_ref()
89    }
90    fn rotation(&self) -> Option<&RotationSpec> {
91        self.rotation.as_ref()
92    }
93    fn liveness(&self) -> Option<&LivenessSpec> {
94        self.liveness.as_ref()
95    }
96}
97
98// =============================================================================
99// Catalogue
100// =============================================================================
101
102/// Helper: create a `PatternMetadata` with empty scopes and no
103/// expiry default. Used by the entries that supply only the
104/// provider/URL fields.
105const fn meta(provider_id: &'static str, retrieval_url_template: &'static str) -> PatternMetadata {
106    PatternMetadata {
107        provider_id: Cow::Borrowed(provider_id),
108        retrieval_url_template: Cow::Borrowed(retrieval_url_template),
109        default_expiry_days: None,
110        scopes_hint: Vec::new(),
111    }
112}
113
114/// Helper: create a `PatternMetadata` with a default expiry hint.
115const fn meta_with_expiry(
116    provider_id: &'static str,
117    retrieval_url_template: &'static str,
118    default_expiry_days: u32,
119) -> PatternMetadata {
120    PatternMetadata {
121        provider_id: Cow::Borrowed(provider_id),
122        retrieval_url_template: Cow::Borrowed(retrieval_url_template),
123        default_expiry_days: Some(default_expiry_days),
124        scopes_hint: Vec::new(),
125    }
126}
127
128/// The 30-pattern catalogue. Order is purely cosmetic.
129///
130/// Entries that share a provider use the same `provider_id` so
131/// downstream tooling can group them; severity reflects how bad a
132/// leak is (high = hard credential, medium = quasi-credential, low
133/// = identifier or catch-all). Patterns that recognise a *shape* but
134/// have no central retrieval flow (`jwt`, `private-key-*`,
135/// `*-url`) omit the metadata layer.
136///
137/// Wrapped in [`LazyLock`] because each `Builtin` carries a
138/// [`OnceLock<Regex>`] for lazy regex compilation, and Rust does not
139/// allow interior mutability directly in a `static` slot. Access via
140/// [`builtins`] / [`find`] (or `BUILTINS.iter()` if you really need a
141/// direct slice).
142#[allow(clippy::too_many_lines)]
143pub static BUILTINS: LazyLock<Vec<Builtin>> = LazyLock::new(|| {
144    vec![
145        // ── GitHub ──────────────────────────────────────────────────────────
146        Builtin {
147            id: "github-pat",
148            display_name: "GitHub Classic Personal Access Token",
149            severity: Severity::High,
150            regex_src: r"^gh[pousr]_[A-Za-z0-9]{36,255}$",
151            regex: OnceLock::new(),
152            metadata: Some(meta_with_expiry(
153                "github",
154                "https://github.com/settings/tokens",
155                90,
156            )),
157            rotation: None,
158            liveness: None,
159        },
160        Builtin {
161            id: "github-fine-grained-pat",
162            display_name: "GitHub Fine-Grained Personal Access Token",
163            severity: Severity::High,
164            regex_src: r"^github_pat_[A-Za-z0-9_]{82,}$",
165            regex: OnceLock::new(),
166            metadata: Some(meta_with_expiry(
167                "github",
168                "https://github.com/settings/personal-access-tokens/new",
169                90,
170            )),
171            rotation: None,
172            liveness: None,
173        },
174        // ── GitLab ──────────────────────────────────────────────────────────
175        Builtin {
176            id: "gitlab-pat",
177            display_name: "GitLab Personal Access Token",
178            severity: Severity::High,
179            regex_src: r"^glpat-[A-Za-z0-9_-]{20,}$",
180            regex: OnceLock::new(),
181            metadata: Some(meta_with_expiry(
182                "gitlab",
183                "https://gitlab.com/-/profile/personal_access_tokens",
184                90,
185            )),
186            rotation: None,
187            liveness: None,
188        },
189        Builtin {
190            id: "gitlab-deploy-token",
191            display_name: "GitLab Deploy Token",
192            severity: Severity::High,
193            regex_src: r"^gldt-[A-Za-z0-9_-]{20,}$",
194            regex: OnceLock::new(),
195            metadata: Some(meta(
196                "gitlab",
197                "https://gitlab.com/<group-or-project>/-/settings/repository#js-deploy-tokens",
198            )),
199            rotation: None,
200            liveness: None,
201        },
202        // ── AWS ─────────────────────────────────────────────────────────────
203        Builtin {
204            id: "aws-access-key",
205            display_name: "AWS Access Key ID",
206            severity: Severity::High,
207            regex_src: r"^AKIA[0-9A-Z]{16}$",
208            regex: OnceLock::new(),
209            metadata: Some(meta_with_expiry(
210                "aws",
211                "https://console.aws.amazon.com/iam/home#/security_credentials",
212                90,
213            )),
214            rotation: None,
215            liveness: None,
216        },
217        // ── OpenAI / Anthropic ──────────────────────────────────────────────
218        Builtin {
219            id: "openai-key",
220            display_name: "OpenAI API Key",
221            severity: Severity::High,
222            regex_src: r"^sk-(proj-)?[A-Za-z0-9_-]{20,}$",
223            regex: OnceLock::new(),
224            metadata: Some(meta("openai", "https://platform.openai.com/api-keys")),
225            rotation: None,
226            liveness: None,
227        },
228        Builtin {
229            id: "anthropic-key",
230            display_name: "Anthropic API Key",
231            severity: Severity::High,
232            regex_src: r"^sk-ant-[A-Za-z0-9_-]{20,}$",
233            regex: OnceLock::new(),
234            metadata: Some(meta(
235                "anthropic",
236                "https://console.anthropic.com/settings/keys",
237            )),
238            rotation: None,
239            liveness: None,
240        },
241        Builtin {
242            id: "moonshot-api-key",
243            display_name: "Kimi (Moonshot AI) API Key",
244            severity: Severity::High,
245            regex_src: r"^sk-[A-Za-z0-9]{32,}$",
246            regex: OnceLock::new(),
247            metadata: Some(meta(
248                "moonshot",
249                "https://platform.moonshot.cn/console/api-keys",
250            )),
251            // Liveness lives in `devboy-token-catalog/data/kimi.json`
252            // (see ADR-023 §3.4) — the rust pattern catalogue stays
253            // shape-only at v1 per the `rotation_and_liveness_are_unset_in_v1`
254            // invariant. The catalog tier carries the per-variant probe.
255            rotation: None,
256            liveness: None,
257        },
258        // ── Slack ───────────────────────────────────────────────────────────
259        Builtin {
260            id: "slack-bot-token",
261            display_name: "Slack Bot User Token",
262            severity: Severity::High,
263            regex_src: r"^xoxb-[0-9A-Za-z-]{20,}$",
264            regex: OnceLock::new(),
265            metadata: Some(meta("slack", "https://api.slack.com/apps")),
266            rotation: None,
267            liveness: None,
268        },
269        Builtin {
270            id: "slack-user-token",
271            display_name: "Slack User Token",
272            severity: Severity::High,
273            regex_src: r"^xoxp-[0-9A-Za-z-]{20,}$",
274            regex: OnceLock::new(),
275            metadata: Some(meta("slack", "https://api.slack.com/apps")),
276            rotation: None,
277            liveness: None,
278        },
279        Builtin {
280            id: "slack-app-token",
281            display_name: "Slack App-Level Token",
282            severity: Severity::High,
283            regex_src: r"^xapp-[0-9A-Za-z-]{20,}$",
284            regex: OnceLock::new(),
285            metadata: Some(meta("slack", "https://api.slack.com/apps")),
286            rotation: None,
287            liveness: None,
288        },
289        Builtin {
290            id: "slack-webhook",
291            display_name: "Slack Incoming Webhook",
292            severity: Severity::High,
293            regex_src: r"^https://hooks\.slack\.com/services/T[A-Za-z0-9]{8,}/B[A-Za-z0-9]{8,}/[A-Za-z0-9]{20,}$",
294            regex: OnceLock::new(),
295            metadata: Some(meta("slack", "https://api.slack.com/messaging/webhooks")),
296            rotation: None,
297            liveness: None,
298        },
299        // ── Discord ─────────────────────────────────────────────────────────
300        Builtin {
301            id: "discord-webhook",
302            display_name: "Discord Webhook URL",
303            severity: Severity::High,
304            regex_src: r"^https://(discord(app)?\.com)/api/webhooks/[0-9]{17,20}/[A-Za-z0-9_-]{60,}$",
305            regex: OnceLock::new(),
306            metadata: Some(meta(
307                "discord",
308                "https://discord.com/developers/applications",
309            )),
310            rotation: None,
311            liveness: None,
312        },
313        // ── Stripe ──────────────────────────────────────────────────────────
314        Builtin {
315            id: "stripe-live-secret",
316            display_name: "Stripe Live Secret Key",
317            severity: Severity::High,
318            regex_src: r"^sk_live_[A-Za-z0-9]{24,}$",
319            regex: OnceLock::new(),
320            metadata: Some(meta("stripe", "https://dashboard.stripe.com/apikeys")),
321            rotation: None,
322            liveness: None,
323        },
324        Builtin {
325            id: "stripe-test-secret",
326            display_name: "Stripe Test Secret Key",
327            severity: Severity::High,
328            regex_src: r"^sk_test_[A-Za-z0-9]{24,}$",
329            regex: OnceLock::new(),
330            metadata: Some(meta("stripe", "https://dashboard.stripe.com/test/apikeys")),
331            rotation: None,
332            liveness: None,
333        },
334        Builtin {
335            id: "stripe-publishable",
336            display_name: "Stripe Publishable Key",
337            severity: Severity::Low,
338            regex_src: r"^pk_(live|test)_[A-Za-z0-9]{24,}$",
339            regex: OnceLock::new(),
340            metadata: Some(meta("stripe", "https://dashboard.stripe.com/apikeys")),
341            rotation: None,
342            liveness: None,
343        },
344        // ── npm / SendGrid / Twilio / Doppler ───────────────────────────────
345        Builtin {
346            id: "npm-token",
347            display_name: "npm Authentication Token",
348            severity: Severity::High,
349            regex_src: r"^npm_[A-Za-z0-9]{36,}$",
350            regex: OnceLock::new(),
351            metadata: Some(meta("npm", "https://www.npmjs.com/settings/<user>/tokens")),
352            rotation: None,
353            liveness: None,
354        },
355        Builtin {
356            id: "sendgrid-key",
357            display_name: "SendGrid API Key",
358            severity: Severity::High,
359            regex_src: r"^SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}$",
360            regex: OnceLock::new(),
361            metadata: Some(meta(
362                "sendgrid",
363                "https://app.sendgrid.com/settings/api_keys",
364            )),
365            rotation: None,
366            liveness: None,
367        },
368        Builtin {
369            id: "twilio-account-sid",
370            display_name: "Twilio Account SID",
371            severity: Severity::Medium,
372            regex_src: r"^AC[a-f0-9]{32}$",
373            regex: OnceLock::new(),
374            metadata: Some(meta("twilio", "https://console.twilio.com")),
375            rotation: None,
376            liveness: None,
377        },
378        Builtin {
379            id: "doppler-cli-token",
380            display_name: "Doppler CLI Token",
381            severity: Severity::High,
382            regex_src: r"^dp\.ct\.[A-Za-z0-9]{40,}$",
383            regex: OnceLock::new(),
384            metadata: Some(meta(
385                "doppler",
386                "https://dashboard.doppler.com/workplace/tokens",
387            )),
388            rotation: None,
389            liveness: None,
390        },
391        // ── Generic shapes ─────────────────────────────────────────────────
392        Builtin {
393            id: "jwt",
394            display_name: "JSON Web Token",
395            severity: Severity::High,
396            regex_src: r"^eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}$",
397            regex: OnceLock::new(),
398            metadata: None,
399            rotation: None,
400            liveness: None,
401        },
402        Builtin {
403            id: "private-key-rsa",
404            display_name: "RSA Private Key",
405            severity: Severity::High,
406            regex_src: r"-----BEGIN RSA PRIVATE KEY-----",
407            regex: OnceLock::new(),
408            metadata: None,
409            rotation: None,
410            liveness: None,
411        },
412        Builtin {
413            id: "private-key-openssh",
414            display_name: "OpenSSH Private Key",
415            severity: Severity::High,
416            regex_src: r"-----BEGIN OPENSSH PRIVATE KEY-----",
417            regex: OnceLock::new(),
418            metadata: None,
419            rotation: None,
420            liveness: None,
421        },
422        Builtin {
423            id: "private-key-ec",
424            display_name: "EC Private Key",
425            severity: Severity::High,
426            regex_src: r"-----BEGIN EC PRIVATE KEY-----",
427            regex: OnceLock::new(),
428            metadata: None,
429            rotation: None,
430            liveness: None,
431        },
432        Builtin {
433            id: "private-key-pgp",
434            display_name: "PGP Private Key Block",
435            severity: Severity::High,
436            regex_src: r"-----BEGIN PGP PRIVATE KEY BLOCK-----",
437            regex: OnceLock::new(),
438            metadata: None,
439            rotation: None,
440            liveness: None,
441        },
442        Builtin {
443            id: "private-key-generic",
444            display_name: "Private Key (catch-all)",
445            severity: Severity::High,
446            regex_src: r"-----BEGIN [A-Z ]+PRIVATE KEY( BLOCK)?-----",
447            regex: OnceLock::new(),
448            metadata: None,
449            rotation: None,
450            liveness: None,
451        },
452        // ── Connection strings ─────────────────────────────────────────────
453        Builtin {
454            id: "postgres-url",
455            display_name: "PostgreSQL Connection String with Password",
456            severity: Severity::High,
457            regex_src: r"^postgres(ql)?://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?/.+$",
458            regex: OnceLock::new(),
459            metadata: None,
460            rotation: None,
461            liveness: None,
462        },
463        Builtin {
464            id: "mongodb-url",
465            display_name: "MongoDB Connection String with Password",
466            severity: Severity::High,
467            regex_src: r"^mongodb(\+srv)?://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s]+/.*$",
468            regex: OnceLock::new(),
469            metadata: None,
470            rotation: None,
471            liveness: None,
472        },
473        Builtin {
474            id: "redis-url",
475            display_name: "Redis Connection String with Password",
476            severity: Severity::High,
477            regex_src: r"^rediss?://[^:/?#\s@]*:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?(/.*)?$",
478            regex: OnceLock::new(),
479            metadata: None,
480            rotation: None,
481            liveness: None,
482        },
483        Builtin {
484            id: "mysql-url",
485            display_name: "MySQL Connection String with Password",
486            severity: Severity::High,
487            regex_src: r"^mysql://[^:/?#\s@]+:[^@/?#\s]+@[^/?#\s:]+(:[0-9]+)?/.+$",
488            regex: OnceLock::new(),
489            metadata: None,
490            rotation: None,
491            liveness: None,
492        },
493        // ── Catch-all (low severity) ───────────────────────────────────────
494        Builtin {
495            id: "generic-bearer",
496            display_name: "Generic Long Bearer-Style Token (catch-all)",
497            severity: Severity::Low,
498            regex_src: r"^[A-Za-z0-9._-]{40,}$",
499            regex: OnceLock::new(),
500            metadata: None,
501            rotation: None,
502            liveness: None,
503        },
504    ]
505});
506
507// =============================================================================
508// Public API
509// =============================================================================
510
511/// Iterate over every built-in pattern as `&dyn SecretPattern`.
512///
513/// Useful for downstream consumers (the secret store's `pattern_id`
514/// inheritance in P2.4, the OTLP sanitizer in #240, the OTEL scan
515/// auditor in #242) that want to walk the whole catalogue without
516/// caring about the [`Builtin`] adapter type.
517pub fn builtins() -> impl Iterator<Item = &'static dyn SecretPattern> {
518    // `LazyLock<Vec<T>>` derefs to `Vec<T>`. We want `&'static dyn`
519    // references; `LazyLock` lives for the program's lifetime, so a
520    // borrow into it yields a `'static` reference.
521    let slice: &'static [Builtin] = &BUILTINS;
522    slice.iter().map(|b| b as &'static dyn SecretPattern)
523}
524
525/// Look up a built-in by its [`SecretPattern::id`].
526///
527/// Returns `None` if no built-in matches; callers that want the
528/// merged "built-in + user-supplied" view will compose this with the
529/// user-extension loader from epic phase P2.3.
530pub fn find(id: &str) -> Option<&'static dyn SecretPattern> {
531    let slice: &'static [Builtin] = &BUILTINS;
532    slice
533        .iter()
534        .find(|b| b.id == id)
535        .map(|b| b as &'static dyn SecretPattern)
536}
537
538// =============================================================================
539// Tests
540// =============================================================================
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    /// One positive sample + one negative decoy per built-in. The
547    /// samples are synthetic — the spec rules out real client/team/
548    /// infra references in committed code (CLAUDE.md).
549    const TEST_CASES: &[(&str, &str, &str)] = &[
550        // (id, positive sample, negative decoy)
551        (
552            "github-pat",
553            "ghp_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ",
554            "not-a-token",
555        ),
556        (
557            "github-fine-grained-pat",
558            // 82 'a's after the `github_pat_` prefix — minimum
559            // payload for the `[A-Za-z0-9_]{82,}` quantifier (real
560            // GitHub fine-grained PATs are 93 chars total).
561            "github_pat_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
562            "github_pat_short",
563        ),
564        ("gitlab-pat", "glpat-abcdefghij_KLMNOPQRSTU", "glpat-short"),
565        (
566            "gitlab-deploy-token",
567            "gldt-abcdefghij_KLMNOPQRSTU",
568            "gldt-short",
569        ),
570        ("aws-access-key", "AKIAIOSFODNN7EXAMPLE", "AKIASHORT"),
571        (
572            "openai-key",
573            "sk-proj-abcdefghijklmnopqrstuvwx",
574            "sk-too-short",
575        ),
576        (
577            "anthropic-key",
578            "sk-ant-api03-abcdefghijklmnopqrst",
579            "sk-not-anthropic",
580        ),
581        (
582            "slack-bot-token",
583            "xoxb-12345-67890-abcdefghijklmno",
584            "xoxb-short",
585        ),
586        (
587            "slack-user-token",
588            "xoxp-12345-67890-abcdefghijklmno",
589            "xoxp-short",
590        ),
591        (
592            "slack-app-token",
593            "xapp-1-A12345-67890-abcdefghijkl",
594            "xapp-short",
595        ),
596        (
597            "slack-webhook",
598            "https://hooks.slack.com/services/T01234567/B01234567/abcdefghijklmnopqrst",
599            "https://hooks.slack.com/services/short",
600        ),
601        (
602            "discord-webhook",
603            "https://discord.com/api/webhooks/123456789012345678/abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789ab",
604            "https://discord.com/api/webhooks/short",
605        ),
606        // Note: the synthetic samples below are split with `concat!`
607        // so the source file does not contain the well-known prefixes
608        // verbatim — GitHub push protection (and other secret
609        // scanners) match on those prefixes regardless of how
610        // obviously fake the payload is. The compiled string at
611        // runtime is identical to the un-split form; only the source
612        // representation differs.
613        (
614            "stripe-live-secret",
615            concat!("sk_li", "ve_abcdefghijklmnopqrstuvwx"),
616            concat!("sk_li", "ve_short"),
617        ),
618        (
619            "stripe-test-secret",
620            concat!("sk_te", "st_abcdefghijklmnopqrstuvwx"),
621            concat!("sk_te", "st_short"),
622        ),
623        (
624            "stripe-publishable",
625            concat!("pk_li", "ve_abcdefghijklmnopqrstuvwx"),
626            "pk_unknown_x",
627        ),
628        (
629            "npm-token",
630            concat!("npm", "_abcdefghijklmnopqrstuvwxyzABCDEFGHIJ"),
631            "npm_short",
632        ),
633        (
634            "sendgrid-key",
635            // 22 chars after `SG.`, then `.`, then exactly 43 chars to
636            // match the `[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}` shape.
637            concat!(
638                "SG",
639                ".abcdefghijklmnopqrstuv.abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"
640            ),
641            "SG.short.x",
642        ),
643        (
644            "twilio-account-sid",
645            concat!("AC", "abcdef0123456789abcdef0123456789"),
646            "ACshort",
647        ),
648        (
649            "doppler-cli-token",
650            concat!("dp.", "ct.abcdefghij0123456789abcdefghij0123456789"),
651            "dp.ct.short",
652        ),
653        (
654            "jwt",
655            "eyJhbGciOiJIUzI1NiIs.eyJzdWIiOiIxMjM0NTY3ODkw.SflKxwRJSMeKKF2QT4f",
656            "eyJonly.eyJtwo",
657        ),
658        (
659            "private-key-rsa",
660            "-----BEGIN RSA PRIVATE KEY-----\nMIIE\n-----END RSA PRIVATE KEY-----",
661            "no rsa key here",
662        ),
663        (
664            "private-key-openssh",
665            "-----BEGIN OPENSSH PRIVATE KEY-----\nb3Bl\n-----END OPENSSH PRIVATE KEY-----",
666            "ssh-rsa AAAA",
667        ),
668        (
669            "private-key-ec",
670            "-----BEGIN EC PRIVATE KEY-----\nMHc\n-----END EC PRIVATE KEY-----",
671            "no ec",
672        ),
673        (
674            "private-key-pgp",
675            "-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOY\n-----END PGP PRIVATE KEY BLOCK-----",
676            "no pgp",
677        ),
678        (
679            "private-key-generic",
680            "-----BEGIN DSA PRIVATE KEY-----",
681            "-----BEGIN PUBLIC KEY-----",
682        ),
683        (
684            "postgres-url",
685            "postgres://user:p4ssw0rd@db.example.test:5432/appdb",
686            "postgres://localhost/appdb",
687        ),
688        (
689            "mongodb-url",
690            "mongodb+srv://user:p4ssw0rd@cluster0.example.test/appdb",
691            "mongodb://localhost/appdb",
692        ),
693        (
694            "redis-url",
695            "redis://:p4ssw0rd@redis.example.test:6379/0",
696            "redis://localhost:6379",
697        ),
698        (
699            "mysql-url",
700            "mysql://user:p4ssw0rd@db.example.test:3306/appdb",
701            "mysql://localhost/appdb",
702        ),
703        (
704            "generic-bearer",
705            "abcdefghijABCDEFGHIJ0123456789_abcdefghij",
706            "tooshort",
707        ),
708        (
709            "moonshot-api-key",
710            "sk-abcdefghijklmnopqrstuvwxyz0123456789",
711            "ghp_xxx",
712        ),
713    ];
714
715    #[test]
716    fn catalogue_has_thirty_patterns() {
717        // P2.2 description: "~30 patterns". The exact count is a
718        // useful regression signal — if this fails because someone
719        // added a pattern, update the literal.
720        assert_eq!(BUILTINS.len(), 31);
721    }
722
723    #[test]
724    fn all_builtin_regex_sources_compile() {
725        for b in BUILTINS.iter() {
726            // Touch `compiled_regex` to force compilation; the
727            // `expect` in there panics with a precise message if any
728            // source is malformed.
729            let _ = b.compiled_regex();
730        }
731    }
732
733    #[test]
734    fn pattern_ids_are_unique() {
735        let mut seen = std::collections::HashSet::new();
736        for b in BUILTINS.iter() {
737            assert!(
738                seen.insert(b.id),
739                "duplicate pattern id in catalogue: {}",
740                b.id
741            );
742        }
743    }
744
745    #[test]
746    fn pattern_ids_are_lowercase_kebab() {
747        for b in BUILTINS.iter() {
748            assert!(
749                b.id.chars()
750                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'),
751                "pattern id '{}' is not lowercase-kebab-case",
752                b.id
753            );
754        }
755    }
756
757    #[test]
758    fn test_cases_cover_every_pattern() {
759        let case_ids: std::collections::HashSet<&str> =
760            TEST_CASES.iter().map(|(id, _, _)| *id).collect();
761        for b in BUILTINS.iter() {
762            assert!(
763                case_ids.contains(b.id),
764                "pattern '{}' is missing a TEST_CASES entry",
765                b.id
766            );
767        }
768        assert_eq!(
769            case_ids.len(),
770            BUILTINS.len(),
771            "duplicate ids in TEST_CASES"
772        );
773    }
774
775    #[test]
776    fn each_pattern_matches_its_positive_sample() {
777        for (id, positive, _negative) in TEST_CASES {
778            let p = find(id).unwrap_or_else(|| panic!("pattern '{id}' not in catalogue"));
779            assert!(
780                p.format_regex().is_match(positive),
781                "pattern '{id}' should match positive sample {positive:?}"
782            );
783        }
784    }
785
786    #[test]
787    fn each_pattern_rejects_its_negative_decoy() {
788        for (id, _positive, negative) in TEST_CASES {
789            let p = find(id).unwrap_or_else(|| panic!("pattern '{id}' not in catalogue"));
790            assert!(
791                !p.format_regex().is_match(negative),
792                "pattern '{id}' should NOT match negative decoy {negative:?}"
793            );
794        }
795    }
796
797    #[test]
798    fn find_returns_none_for_unknown_id() {
799        assert!(find("no-such-pattern").is_none());
800    }
801
802    #[test]
803    fn find_returns_some_for_known_id() {
804        let p = find("github-pat").expect("github-pat must exist");
805        assert_eq!(p.id(), "github-pat");
806        assert_eq!(p.severity(), Severity::High);
807    }
808
809    #[test]
810    fn builtins_iter_yields_every_pattern() {
811        let count = builtins().count();
812        assert_eq!(count, BUILTINS.len());
813    }
814
815    #[test]
816    fn metadata_present_on_provider_patterns() {
817        // Every entry that names a provider in its `id` prefix should
818        // carry a `PatternMetadata` so `secrets describe` can render
819        // a useful card. The generic shapes (jwt, private-key-*,
820        // *-url, generic-bearer) intentionally omit it.
821        for b in BUILTINS.iter() {
822            let has_provider = !matches!(
823                b.id,
824                "jwt"
825                    | "private-key-rsa"
826                    | "private-key-openssh"
827                    | "private-key-ec"
828                    | "private-key-pgp"
829                    | "private-key-generic"
830                    | "postgres-url"
831                    | "mongodb-url"
832                    | "redis-url"
833                    | "mysql-url"
834                    | "generic-bearer"
835            );
836            if has_provider {
837                assert!(
838                    b.metadata.is_some(),
839                    "pattern '{}' should carry PatternMetadata",
840                    b.id
841                );
842            } else {
843                assert!(
844                    b.metadata.is_none(),
845                    "pattern '{}' should NOT carry PatternMetadata (it is a generic shape)",
846                    b.id
847                );
848            }
849        }
850    }
851
852    #[test]
853    fn rotation_and_liveness_are_unset_in_v1() {
854        // P2.4 (#23) wires inheritance into the global index; P9.x
855        // adds liveness probes. Both layers stay None for now.
856        for b in BUILTINS.iter() {
857            assert!(b.rotation.is_none());
858            assert!(b.liveness.is_none());
859        }
860    }
861}