Skip to main content

secret_mask/
lib.rs

1//! # secret-mask
2//!
3//! Mask known secret patterns in log lines before they reach a sink.
4//!
5//! Detects:
6//!
7//! - `sk-…`, `sk_live_…`, `sk_test_…`, `rk_live_…` — Stripe-style /
8//!   Anthropic-style API keys
9//! - `ghp_…`, `github_pat_…` — GitHub PATs
10//! - `xoxb-…`, `xoxp-…` — Slack tokens
11//! - `AKIA…` + `[A-Z0-9]{16}` — AWS access key IDs
12//! - JWTs (`eyJ…<base64.base64.base64>`) — generic
13//!
14//! Each secret is replaced with the literal `[REDACTED]`. Bring the
15//! input bytes; get the masked string back.
16//!
17//! ## Example
18//!
19//! ```
20//! use secret_mask::mask;
21//! let masked = mask("authorization: Bearer sk-live-AAAABBBBCCCCDDDD1234");
22//! assert!(!masked.contains("sk-live-AAAABBBBCCCCDDDD1234"));
23//! assert!(masked.contains("[REDACTED]"));
24//! ```
25
26#![deny(missing_docs)]
27
28/// Token written into the masked positions.
29pub const REPLACEMENT: &str = "[REDACTED]";
30
31/// Mask any secrets found in `s`.
32pub fn mask(s: &str) -> String {
33    let mut out = String::with_capacity(s.len());
34    let bytes = s.as_bytes();
35    let mut i = 0;
36    while i < bytes.len() {
37        if let Some(end) = match_secret(s, i) {
38            out.push_str(REPLACEMENT);
39            i = end;
40        } else {
41            let c = s[i..].chars().next().unwrap();
42            out.push(c);
43            i += c.len_utf8();
44        }
45    }
46    out
47}
48
49/// True when `s` contains any secret pattern.
50pub fn has_secret(s: &str) -> bool {
51    let bytes = s.as_bytes();
52    let mut i = 0;
53    while i < bytes.len() {
54        if match_secret(s, i).is_some() {
55            return true;
56        }
57        i += 1;
58    }
59    false
60}
61
62fn match_secret(s: &str, i: usize) -> Option<usize> {
63    let bytes = s.as_bytes();
64    let rest = &s[i..];
65
66    // Prefixed API keys (sk-, sk_live_, etc.) — greedy alnum/_/-
67    let prefixes: &[&str] = &[
68        "sk-", "sk_live_", "sk_test_", "rk_live_", "ghp_", "github_pat_", "xoxb-", "xoxp-",
69    ];
70    for p in prefixes {
71        if rest.starts_with(p) {
72            let mut end = i + p.len();
73            while end < bytes.len()
74                && (bytes[end].is_ascii_alphanumeric() || matches!(bytes[end], b'_' | b'-'))
75            {
76                end += 1;
77            }
78            if end - (i + p.len()) >= 16 {
79                return Some(end);
80            }
81        }
82    }
83
84    // AWS access key: "AKIA" + 16 uppercase alnum
85    if rest.starts_with("AKIA") {
86        let after = i + 4;
87        if after + 16 <= bytes.len() {
88            let tail = &bytes[after..after + 16];
89            if tail.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
90                return Some(after + 16);
91            }
92        }
93    }
94
95    // JWT: "eyJ" + base64ish.base64ish.base64ish
96    if rest.starts_with("eyJ") {
97        let mut end = i;
98        let mut dots = 0;
99        while end < bytes.len() {
100            let c = bytes[end];
101            if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-') {
102                if c == b'.' {
103                    dots += 1;
104                }
105                end += 1;
106            } else {
107                break;
108            }
109        }
110        if dots >= 2 && end - i >= 20 {
111            return Some(end);
112        }
113    }
114
115    None
116}