Skip to main content

devboy_secret_patterns/
lib.rs

1//! Pattern catalogue for `devboy-tools` secrets.
2//!
3//! A *secret pattern* is everything we know about a *kind* of secret —
4//! "what does a GitHub Personal Access Token look like, where does the
5//! user obtain a fresh one, how often should it be rotated, how can we
6//! tell whether a candidate value is still alive". One pattern, many
7//! consumers:
8//!
9//! - The **secret store** (ADR-020 / ADR-023) reads `format_regex` for
10//!   validation, `metadata().retrieval_url_template` for the
11//!   `[Open URL]` button in the rotation flow, `rotation()` for the
12//!   `doctor` cadence reminder, `liveness()` for the `secrets validate`
13//!   probe.
14//! - The **OTLP sanitizer** (#240) and **`otel scan`** auditor (#242)
15//!   read `format_regex` and `severity` only — they don't care about
16//!   retrieval or rotation, just "is this string a leaked secret?".
17//!
18//! The trait is **layered** so the two consumers can ignore the parts
19//! they don't need without forcing the catalogue to fill in fields it
20//! doesn't have. Mandatory: `id`, `display_name`, `format_regex`,
21//! `severity`. Optional: `metadata`, `rotation`, `liveness`.
22//!
23//! See [ADR-023] §3.6 for the design rationale.
24//!
25//! [ADR-023]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-023-secret-store-ux-layer.md
26//!
27//! # Example
28//!
29//! Implementing a minimal pattern (mandatory fields only) for a
30//! made-up provider:
31//!
32//! ```
33//! use devboy_secret_patterns::{SecretPattern, Severity};
34//! use regex::Regex;
35//! use std::sync::OnceLock;
36//!
37//! struct ExampleProviderToken;
38//!
39//! impl SecretPattern for ExampleProviderToken {
40//!     fn id(&self) -> &str { "example-provider-token" }
41//!     fn display_name(&self) -> &str { "Example Provider API Token" }
42//!     fn severity(&self) -> Severity { Severity::High }
43//!
44//!     fn format_regex(&self) -> &Regex {
45//!         static R: OnceLock<Regex> = OnceLock::new();
46//!         R.get_or_init(|| Regex::new(r"^example_[A-Za-z0-9]{32}$").unwrap())
47//!     }
48//! }
49//!
50//! let p = ExampleProviderToken;
51//! assert_eq!(p.id(), "example-provider-token");
52//! assert_eq!(p.severity(), Severity::High);
53//! assert!(p.format_regex().is_match("example_abcdefghijklmnopqrstuvwxyz012345"));
54//! assert!(!p.format_regex().is_match("nope"));
55//! // Optional layers default to None.
56//! assert!(p.metadata().is_none());
57//! assert!(p.rotation().is_none());
58//! assert!(p.liveness().is_none());
59//! ```
60
61#![forbid(unsafe_code)]
62
63pub mod builtin;
64pub mod user;
65
66pub use builtin::{BUILTINS, Builtin, builtins, find};
67pub use user::{Catalogue, LoadError, LoadWarning, LoadWarningKind, UserPattern, UserPatternFile};
68
69use std::borrow::Cow;
70use std::fmt;
71
72use regex::Regex;
73use serde::{Deserialize, Serialize};
74
75// =============================================================================
76// Severity
77// =============================================================================
78
79/// How dangerous a leak of this kind of secret is.
80///
81/// Drives the colour/icon shown by `doctor` and the OTEL scan auditor.
82/// `serde` produces lowercase tokens (`"low"`, `"medium"`, `"high"`) —
83/// the catalogue is consumed by both internal Rust code and external
84/// JSON tooling, so a stable wire shape matters.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
86#[serde(rename_all = "lowercase")]
87pub enum Severity {
88    /// Recommendation to redact, no immediate alarm. Examples:
89    /// long-lived but read-only public-data tokens, hashed identifiers
90    /// that still decode to PII through some lookup.
91    Low,
92    /// Should not appear in transcripts or logs. Examples: PII (email,
93    /// phone), file paths revealing project structure, raw API
94    /// payloads that may contain user data.
95    Medium,
96    /// Hard credential — API tokens, OAuth tokens, private keys,
97    /// cloud-provider access keys, database connection strings with
98    /// embedded passwords. A leak here is an incident.
99    High,
100}
101
102impl fmt::Display for Severity {
103    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104        f.write_str(match self {
105            Self::Low => "low",
106            Self::Medium => "medium",
107            Self::High => "high",
108        })
109    }
110}
111
112// =============================================================================
113// Optional metadata layer
114// =============================================================================
115
116/// Optional descriptive metadata for a pattern. Consumed by the secret
117/// store's `pattern_id` inheritance (epic phase P2.4) and by the UI.
118///
119/// String fields are [`Cow<'static, str>`] so both built-in patterns
120/// (which carry `&'static str` literals via `Cow::Borrowed`) and
121/// user-loaded patterns (which carry owned `String`s deserialised from
122/// `~/.devboy/secrets/patterns.d/*.toml`) can populate them without
123/// either side leaking memory or leaning on `Box::leak`.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub struct PatternMetadata {
126    /// Stable provider identifier, lowercase ASCII (`"github"`,
127    /// `"gitlab"`, `"openai"`, …). Used as the second segment in
128    /// suggested ADR-020 paths and as the routing key for liveness
129    /// probes.
130    pub provider_id: Cow<'static, str>,
131    /// URL template the user opens to obtain a fresh value. May
132    /// contain `{var}` placeholders the UI substitutes; if it has no
133    /// placeholders, the UI opens it verbatim.
134    pub retrieval_url_template: Cow<'static, str>,
135    /// Default rotation cadence in days. Drives `doctor`'s default
136    /// reminder when the per-secret entry doesn't override it.
137    pub default_expiry_days: Option<u32>,
138    /// Hint on the API scopes this token is typically created with.
139    /// Surfaced in the metadata card; does **not** validate.
140    pub scopes_hint: Vec<Cow<'static, str>>,
141}
142
143// =============================================================================
144// Optional rotation layer
145// =============================================================================
146
147/// How a secret of this kind is rotated.
148///
149/// Mirrors the same idea as
150/// [`devboy_storage::RotationMethod`](https://docs.rs/devboy-storage)
151/// but stays in this crate to avoid a back-edge in the dep graph and
152/// because the catalogue's variant carries an extra URL template that
153/// the storage `RotationMethod` does not.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum RotationMethodSpec {
156    /// User rotates the secret themselves through the upstream UI;
157    /// `devboy-tools` records the new value and validates liveness.
158    /// The first release ships **only** this method
159    /// (ADR-023 §3.5 — provider-driven rotation is deferred).
160    Manual,
161    /// Reserved — `devboy-tools` opens the provider's UI at the
162    /// templated URL and accepts the new value through the rotation
163    /// flow. Not wired in v1.
164    ProviderUi {
165        /// URL template (may contain `{var}` placeholders).
166        url_template: &'static str,
167    },
168    /// Reserved — `devboy-tools` calls the provider's rotation API
169    /// directly. Not wired in v1.
170    ProviderApi,
171}
172
173/// Optional rotation hint for a pattern.
174#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct RotationSpec {
176    /// How rotation happens.
177    pub method: RotationMethodSpec,
178}
179
180// =============================================================================
181// Optional liveness layer
182// =============================================================================
183
184/// HTTP method for liveness probes.
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
186pub enum HttpMethod {
187    /// HTTP `GET`.
188    Get,
189    /// HTTP `POST`.
190    Post,
191    /// HTTP `HEAD`.
192    Head,
193}
194
195impl HttpMethod {
196    /// Uppercase wire form (`"GET"`, `"POST"`, `"HEAD"`).
197    pub fn as_str(self) -> &'static str {
198        match self {
199            Self::Get => "GET",
200            Self::Post => "POST",
201            Self::Head => "HEAD",
202        }
203    }
204}
205
206/// How to attach the candidate secret to the liveness HTTP request.
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
208pub enum LivenessAuth {
209    /// `Authorization: Bearer <secret>` header.
210    Bearer,
211    /// `Authorization: Basic base64(<secret>:)` header (the secret is
212    /// the username; the password is empty).
213    BasicUser,
214    /// HTTP Basic auth with empty username and `<secret>` as password.
215    BasicPassword,
216    /// Custom HTTP header name carrying the raw secret.
217    Header {
218        /// Header name (e.g. `"PRIVATE-TOKEN"`, `"X-API-Key"`).
219        name: &'static str,
220    },
221}
222
223/// Liveness probe shape — currently only HTTP, but the enum gives us
224/// room to add subprocess- or socket-based probes later without
225/// breaking the trait.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum LivenessKind {
228    /// Make an HTTP request and check the response status code.
229    Http {
230        /// Endpoint URL (typically the provider's `/me` or `/user`
231        /// endpoint).
232        url: &'static str,
233        /// HTTP method.
234        method: HttpMethod,
235        /// How the secret is attached to the request.
236        auth: LivenessAuth,
237        /// HTTP status that means "secret is valid".
238        expect_status: u16,
239    },
240}
241
242/// Optional liveness specification for a pattern.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct LivenessSpec {
245    /// Probe kind + parameters.
246    pub kind: LivenessKind,
247}
248
249// =============================================================================
250// SecretPattern trait
251// =============================================================================
252
253/// One *kind* of secret in the catalogue.
254///
255/// Implementors are usually zero-sized types (one per pattern); the
256/// catalogue (epic phase P2.2) holds them behind `&'static dyn
257/// SecretPattern` references.
258///
259/// **Thread-safety.** The trait requires `Send + Sync` because the
260/// secret store and the OTLP sanitizer both consume patterns from
261/// concurrent contexts.
262///
263/// **Layering.** The mandatory accessors (`id`, `display_name`,
264/// `format_regex`, `severity`) cover the OTLP-sanitizer / scan use
265/// case. The three optional layers (`metadata`, `rotation`,
266/// `liveness`) cover the secret-store use case; patterns may
267/// implement all, some, or none of them. The default impl returns
268/// `None` so a minimal pattern only has to write four method
269/// bodies.
270pub trait SecretPattern: Send + Sync {
271    /// Stable identifier (lowercase, kebab-case). Used as a foreign
272    /// key from the global index entry's `pattern_id` (ADR-020 §3)
273    /// and as a join key with other tools that consume the
274    /// catalogue.
275    fn id(&self) -> &str;
276
277    /// Human-readable name shown in `secrets describe` and in
278    /// scan-tool reports.
279    fn display_name(&self) -> &str;
280
281    /// Regular expression matching valid values of this kind.
282    ///
283    /// Returned by reference so implementors can lazy-compile and
284    /// cache the [`Regex`] (e.g. via `OnceLock`) without paying the
285    /// cost on every match. The catalogue is hot-path: every secret
286    /// resolution and every OTLP attribute walk hits this method.
287    fn format_regex(&self) -> &Regex;
288
289    /// Severity to attach to a leak finding for this pattern.
290    fn severity(&self) -> Severity;
291
292    /// Optional descriptive metadata (provider, retrieval URL,
293    /// expiry, scopes).
294    ///
295    /// Default returns `None`; consumers that only need format/severity
296    /// (the sanitizer and scan tools) ignore this layer.
297    fn metadata(&self) -> Option<&PatternMetadata> {
298        None
299    }
300
301    /// Optional rotation hint (manual vs provider-driven).
302    ///
303    /// Default returns `None`.
304    fn rotation(&self) -> Option<&RotationSpec> {
305        None
306    }
307
308    /// Optional liveness probe specification.
309    ///
310    /// Default returns `None`. Patterns that ship a probe let the
311    /// `secrets validate` flow check whether a candidate value is
312    /// currently accepted by the upstream.
313    fn liveness(&self) -> Option<&LivenessSpec> {
314        None
315    }
316}
317
318// =============================================================================
319// Tests
320// =============================================================================
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use std::sync::OnceLock;
326
327    /// Minimal pattern — only the four mandatory methods. Used to
328    /// exercise the trait's default impls.
329    struct Minimal;
330    impl SecretPattern for Minimal {
331        fn id(&self) -> &str {
332            "minimal"
333        }
334        fn display_name(&self) -> &str {
335            "Minimal Test Pattern"
336        }
337        fn severity(&self) -> Severity {
338            Severity::Low
339        }
340        fn format_regex(&self) -> &Regex {
341            static R: OnceLock<Regex> = OnceLock::new();
342            R.get_or_init(|| Regex::new(r"^min_[a-z0-9]{8}$").unwrap())
343        }
344    }
345
346    /// Pattern that fills in every optional layer. Used to verify the
347    /// optional accessors deliver what the impl supplies.
348    struct Full;
349    impl SecretPattern for Full {
350        fn id(&self) -> &str {
351            "full"
352        }
353        fn display_name(&self) -> &str {
354            "Full Test Pattern"
355        }
356        fn severity(&self) -> Severity {
357            Severity::High
358        }
359        fn format_regex(&self) -> &Regex {
360            static R: OnceLock<Regex> = OnceLock::new();
361            R.get_or_init(|| Regex::new(r"^full_.+$").unwrap())
362        }
363        fn metadata(&self) -> Option<&PatternMetadata> {
364            static M: OnceLock<PatternMetadata> = OnceLock::new();
365            Some(M.get_or_init(|| PatternMetadata {
366                provider_id: Cow::Borrowed("test"),
367                retrieval_url_template: Cow::Borrowed("https://test.example/tokens"),
368                default_expiry_days: Some(90),
369                scopes_hint: vec![Cow::Borrowed("read"), Cow::Borrowed("write")],
370            }))
371        }
372        fn rotation(&self) -> Option<&RotationSpec> {
373            static R: OnceLock<RotationSpec> = OnceLock::new();
374            Some(R.get_or_init(|| RotationSpec {
375                method: RotationMethodSpec::Manual,
376            }))
377        }
378        fn liveness(&self) -> Option<&LivenessSpec> {
379            static L: OnceLock<LivenessSpec> = OnceLock::new();
380            Some(L.get_or_init(|| LivenessSpec {
381                kind: LivenessKind::Http {
382                    url: "https://test.example/api/me",
383                    method: HttpMethod::Get,
384                    auth: LivenessAuth::Bearer,
385                    expect_status: 200,
386                },
387            }))
388        }
389    }
390
391    // -- Severity --------------------------------------------------------------
392
393    #[test]
394    fn severity_serializes_lowercase() {
395        // Stable wire shape — sanitizer (#240) and scan (#242) parse this.
396        assert_eq!(serde_json::to_string(&Severity::Low).unwrap(), "\"low\"");
397        assert_eq!(
398            serde_json::to_string(&Severity::Medium).unwrap(),
399            "\"medium\""
400        );
401        assert_eq!(serde_json::to_string(&Severity::High).unwrap(), "\"high\"");
402    }
403
404    #[test]
405    fn severity_deserializes_lowercase() {
406        let s: Severity = serde_json::from_str("\"high\"").unwrap();
407        assert_eq!(s, Severity::High);
408    }
409
410    #[test]
411    fn severity_display_matches_serde() {
412        for s in [Severity::Low, Severity::Medium, Severity::High] {
413            let displayed = format!("{s}");
414            let serded: String = serde_json::from_value(serde_json::to_value(s).unwrap()).unwrap();
415            assert_eq!(displayed, serded);
416        }
417    }
418
419    #[test]
420    fn severity_orders_low_below_high() {
421        assert!(Severity::Low < Severity::Medium);
422        assert!(Severity::Medium < Severity::High);
423    }
424
425    // -- HttpMethod ------------------------------------------------------------
426
427    #[test]
428    fn http_method_as_str_matches_uppercase_wire_form() {
429        assert_eq!(HttpMethod::Get.as_str(), "GET");
430        assert_eq!(HttpMethod::Post.as_str(), "POST");
431        assert_eq!(HttpMethod::Head.as_str(), "HEAD");
432    }
433
434    // -- Trait: mandatory accessors ------------------------------------------
435
436    #[test]
437    fn minimal_pattern_exposes_mandatory_accessors() {
438        let p = Minimal;
439        assert_eq!(p.id(), "minimal");
440        assert_eq!(p.display_name(), "Minimal Test Pattern");
441        assert_eq!(p.severity(), Severity::Low);
442        assert!(p.format_regex().is_match("min_abc12345"));
443        assert!(!p.format_regex().is_match("nope"));
444    }
445
446    #[test]
447    fn minimal_pattern_optional_accessors_default_to_none() {
448        let p = Minimal;
449        assert!(p.metadata().is_none());
450        assert!(p.rotation().is_none());
451        assert!(p.liveness().is_none());
452    }
453
454    // -- Trait: optional accessors -------------------------------------------
455
456    #[test]
457    fn full_pattern_exposes_metadata_layer() {
458        let p = Full;
459        let m = p.metadata().expect("Full provides metadata");
460        assert_eq!(m.provider_id, "test");
461        assert_eq!(m.retrieval_url_template, "https://test.example/tokens");
462        assert_eq!(m.default_expiry_days, Some(90));
463        assert_eq!(
464            m.scopes_hint,
465            vec![Cow::Borrowed("read"), Cow::Borrowed("write")]
466        );
467    }
468
469    #[test]
470    fn full_pattern_exposes_rotation_layer() {
471        let p = Full;
472        let r = p.rotation().expect("Full provides rotation");
473        assert_eq!(r.method, RotationMethodSpec::Manual);
474    }
475
476    #[test]
477    fn full_pattern_exposes_liveness_layer() {
478        let p = Full;
479        let l = p.liveness().expect("Full provides liveness");
480        match &l.kind {
481            LivenessKind::Http {
482                url,
483                method,
484                auth,
485                expect_status,
486            } => {
487                assert_eq!(*url, "https://test.example/api/me");
488                assert_eq!(*method, HttpMethod::Get);
489                assert_eq!(*auth, LivenessAuth::Bearer);
490                assert_eq!(*expect_status, 200);
491            }
492        }
493    }
494
495    // -- Trait object --------------------------------------------------------
496
497    #[test]
498    fn trait_is_object_safe_and_send_sync() {
499        // The trait must be usable behind `&dyn SecretPattern` for the
500        // catalogue (P2.2) and `Box<dyn SecretPattern>` for user-loaded
501        // patterns (P2.3). Send+Sync because both the secret store and
502        // the OTLP sanitizer hold patterns concurrently.
503        let patterns: Vec<&dyn SecretPattern> = vec![&Minimal, &Full];
504        assert_eq!(patterns.len(), 2);
505
506        fn assert_send_sync<T: Send + Sync + ?Sized>() {}
507        assert_send_sync::<dyn SecretPattern>();
508    }
509
510    // -- LivenessAuth coverage ------------------------------------------------
511
512    #[test]
513    fn liveness_auth_header_variant_carries_name() {
514        let a = LivenessAuth::Header {
515            name: "PRIVATE-TOKEN",
516        };
517        match a {
518            LivenessAuth::Header { name } => assert_eq!(name, "PRIVATE-TOKEN"),
519            _ => panic!("expected Header variant"),
520        }
521    }
522}