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}