secrets.rs
Rust · 160 lines · 9.3 KB
//! Refusing secrets: entries, reviews and text sent to models are checked here.
use anyhow::{bail, Result};
use regex::Regex;
use std::sync::LazyLock;
static SECRET_RES: LazyLock<Vec<(Regex, &'static str)>> = LazyLock::new(|| {
[
(r"-----BEGIN [A-Z ]*PRIVATE KEY-----", "a PEM private key"),
(r"(?i)(private|secret|mnemonic|seed)[^\n]{0,40}\b(0x)?[0-9a-f]{64}\b", "a hex private key"),
(r"\bsk-(ant-|proj-)?[A-Za-z0-9_-]{20,}", "an API key (sk-...)"),
(r"\b(ghp|gho|ghu|ghs|github_pat)_[A-Za-z0-9_]{20,}", "a GitHub token"),
(r"\bAKIA[0-9A-Z]{16}\b", "an AWS access key"),
(r"(?i)AccountKey=[A-Za-z0-9+/=]{20,}", "an Azure storage connection string"),
(r"(?i)SharedAccessSignature\s|[?&]sig=[A-Za-z0-9%+/=]{20,}", "an Azure SAS token"), // leak-scan: allow
(r"(?i)(api[_-]?key|subscription[_-]?key|ocp-apim|azure[a-z_]*key)[^\n]{0,20}\b[0-9a-f]{32}\b", "an Azure API key"),
// Provider token formats (the site's leak scanner has the same list).
(r"\bhf_[A-Za-z0-9]{30,}", "a Hugging Face token"),
(r"\bglpat-[A-Za-z0-9_-]{20,}", "a GitLab token"),
(r"\bnpm_[A-Za-z0-9]{30,}", "an npm token"),
(r"\bAIza[0-9A-Za-z_-]{35}\b", "a Google API key"),
(r"\b[sr]k_live_[0-9A-Za-z]{20,}", "a Stripe key"),
(r"\bdop_v1_[0-9a-f]{40,}", "a DigitalOcean token"),
(r"\b(xai|gsk)-?_?[A-Za-z0-9]{40,}", "an AI provider key"),
(r"\bxox[abpr]-[A-Za-z0-9-]{10,}", "a Slack token"),
(r"\beyJ[A-Za-z0-9_-]{15,}\.eyJ[A-Za-z0-9_-]{15,}\.[A-Za-z0-9_-]{10,}", "a JWT"),
(r"(?i)\b(authorization\s*:\s*)?(bearer|basic)\s+[A-Za-z0-9._~+/=-]{20,}", "an Authorization header"),
(r"(?i)\b[a-z][a-z0-9+.-]*://[^\s/:@<>]+:[^\s/@<>$]{6,}@", "a password inside a URL"),
]
.into_iter()
.map(|(r, what)| (Regex::new(r).unwrap(), what))
.collect()
});
/// `password = x`, `"api_key": "x y z"`, `client_secret: x`, `AZURE_CLIENT_SECRET=x`, and YAML
/// `password: |` blocks. Whether the value is really a secret is decided by `secret_value`.
/// Also any name ending in `_key`, `_token`, `_secret`, `_pass` or `_pat` (`AZURE_OPENAI_KEY=…`,
/// `HF_TOKEN=…`), and a bare `token`, like the site's leak scanner.
const SECRET_KEYS: &str = r"(?i)([a-z0-9_]*?_(?:key|token|secret|pass|pat|apikey)\b|\btoken\b|password|passwd|passphrase|pwd|api[_-]?key|secret[_-]access[_-]key|secret|auth[_-]?token|access[_-]?token|refresh[_-]?token|private[_-]?key|credentials?)";
static ASSIGNMENT: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(
r#"{SECRET_KEYS}["']?[ \t]*(?::=|=>|[:=])[ \t]*(?:"([^"\n]{{1,200}})"|'([^'\n]{{1,200}})'|[|>][-+]?[ \t]*\r?\n[ \t]+([^\n]{{1,200}})|([^\s"'`<>,;]{{1,200}}))"#
))
.unwrap()
});
/// A typed binding: the literal comes after the type (`const PASSWORD: &str = "…"`, `api_key: str = '…'`).
static TYPED: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(&format!(r#"{SECRET_KEYS}[ \t]*:[ \t]*[&\w<>:\[\]' ]{{1,40}}?=[ \t]*(?:"([^"\n]{{1,200}})"|'([^'\n]{{1,200}})')"#)).unwrap()
});
/// Names of where a secret lives, not secrets: `$DEPLOY_KEY`, `$password`, `${TOKEN}`, `$(pass show x)`,
/// `%APPDATA%`, `{{ secrets.X }}`, `********`, `xxxx`, `<your key>`, `0xYOUR_KEY`, `your-api-key`.
static REFERENCE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"^(\$[A-Za-z_][A-Za-z0-9_]*|\$\{.*|\$\(.*|%[A-Za-z_][A-Za-z0-9_]*%.*|\{\{.*|\*+|[xX]{4,}|<.*>|(?i)(0x)?your[_-].*)$").unwrap()
});
/// Code, judged only for unquoted values: type names (`String`, `Option<…>`, `SecretString`,
/// `config::Secret`), field paths (`self.password`, `cfg.api_key.clone()`), calls and lookups
/// (`env::var(`, `os.environ[`). A quoted value is a literal, so these never excuse it.
static CODE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
// A fixed list of type names: PascalCase in general isn't excused (`CorrectHorseBatteryStaple`).
r"^(String|Str|Option|Secret|SecretString|SecretBox|SecretVec|Zeroizing|Vec|Bytes|Box|Arc|Rc",
r"|Credentials?|Password|PasswordHash|Token|Key|ApiKey)$|^[A-Z][A-Za-z]*(::[A-Za-z_]\w*)+$",
// Field paths start with a lowercase receiver (`self.password`, `cfg.api_key`), never digits
// (checked below): `Summer.Winter` and `welcome.home2025` aren't code.
r"|^[a-z_]+(\.[A-Za-z_][A-Za-z0-9_]*)+(\(\))?\??$",
// Calls and lookups go through a path (`env::var(`, `os.environ[`, `Math.random(`), so
// `Welcome[2024]` or `hunter(22` aren't excused.
r"|^[A-Za-z_]\w*(\.|::)[A-Za-z0-9_.:]*[(\[]",
// A plain function call whose argument (if any) starts like code: `require_env(`,
// `env_first(env_prefix`; `hunter(22x` isn't one.
r"|^[a-z_]+\(([a-z_&*]|$)",
))
.unwrap()
});
/// Also fetch()'s `credentials` options.
const PLACEHOLDER_WORDS: [&str; 15] = [
"example", "placeholder", "redacted", "required", "optional", "string", "none", "null", "true", "false", "undefined",
"unset", "same-origin", "include", "omit",
];
/// Prose, not a passphrase: `secret: "stored in the vault"`.
const PROSE_WORDS: [&str; 26] = [
"the", "a", "an", "in", "is", "are", "to", "of", "and", "or", "from", "for", "with", "see", "use", "set", "stored",
"via", "your", "this", "that", "it", "not", "on", "at", "by",
];
/// Whether an assigned value is really a secret. `quoted`: it was written as a literal ("…", '…'
/// or a YAML block), so only references, placeholders and plain prose are excused.
fn secret_value(key: &str, v: &str, quoted: bool) -> bool {
let v = v.trim().trim_end_matches([',', ';', '`']);
let lower = v.to_ascii_lowercase();
if v.chars().count() < 6 || REFERENCE.is_match(v) || PLACEHOLDER_WORDS.contains(&lower.as_str()) {
return false;
}
// Where it lives, not what it is (what the refusal message tells you to write): a path
// (`~/.config/x/secret`, `/run/secrets/db`, `C:\keys\x`) or a password manager item.
let is_path = (v.starts_with("~/") || v.starts_with("~\\") || v.starts_with('/') || v.starts_with("./") || v.starts_with("$HOME")
|| v.as_bytes().get(1..3).is_some_and(|b| b == b":\\" || b == b":/") && v.as_bytes()[0].is_ascii_alphabetic())
&& !v.contains('@');
let vault = ["1password", "op://", "vault:", "keychain", "bitwarden", "lastpass", "keyvault", "key vault"].iter().any(|p| lower.starts_with(p));
if is_path || vault {
return false;
}
// A generic `*_key`/`*_token` name (`partition_key`, `cache_key`, `primary_key`) whose value is a
// lowercase identifier with no digits is a schema or config note, not a secret.
let k = key.to_ascii_lowercase();
let generic = k.contains('_') && ["_key", "_token", "_secret", "_pass", "_pat", "_apikey"].iter().any(|s| k.ends_with(s));
let not_secret_names = ["public", "primary", "foreign", "sort", "partition", "cache", "idempotency", "routing", "keyboard", "hash", "row", "unique", "composite", "lookup", "map", "dict", "next_page", "page", "csrf_token_name"];
if generic && (not_secret_names.iter().any(|n| k.starts_with(n)) || !quoted && v.chars().all(|c| c.is_ascii_lowercase() || c == '_' || c == '.')) {
return false;
}
if quoted {
// Prose has spaces, a common little word, and no digits (`"me and the 7 dwarfs"` is a secret).
// Never for passwords: a passphrase is often ordinary words (`"winter is coming"`).
let password_key = ["password", "passwd", "passphrase", "pwd"].contains(&key.to_ascii_lowercase().as_str());
let prose = !password_key
&& v.contains(' ')
&& !v.chars().any(|c| c.is_ascii_digit())
&& lower.split_whitespace().any(|w| PROSE_WORDS.contains(&w));
return !prose;
}
let v = v.trim_end_matches([')', '.']);
let lower = v.to_ascii_lowercase();
let digits = v.chars().any(|c| c.is_ascii_digit());
// Code shapes; a field path with digits in it isn't excused (calls and lookups are).
if CODE.is_match(v) && (!digits || v.contains(['(', '['])) {
return false;
}
// A variable or field named after what it holds: the key itself (`password: password,`,
// `api_key=api_key`) or a name ending in it (`db_password`), with no digits:
// `mypassword` and `Summer2024_password` are not excused.
let key = key.to_ascii_lowercase().replace('-', "_");
let named = !digits
&& v.chars().all(|c| c.is_ascii_alphabetic() || c == '_')
&& (lower == key || lower.ends_with(&format!("_{key}")));
!named
}
/// What kind of secret the text seems to contain, if any.
pub fn secret_kind(text: &str) -> Option<&'static str> {
if let Some((_, what)) = SECRET_RES.iter().find(|(re, _)| re.is_match(text)) {
return Some(what);
}
let assigned = ASSIGNMENT
.captures_iter(text)
.any(|c| (2..=5).any(|i| c.get(i).is_some_and(|m| secret_value(&c[1], m.as_str(), i < 5))));
let typed = TYPED.captures_iter(text).any(|c| (2..=3).any(|i| c.get(i).is_some_and(|m| secret_value(&c[1], m.as_str(), true))));
(assigned || typed).then_some("a password/key assignment")
}
pub fn secret_check(text: &str) -> Result<()> {
{
if let Some(what) = secret_kind(text) {
bail!(
"this looks like it contains {what}. Never put secrets in the mind: name the env var, \
file or vault entry that holds it instead (e.g. `$DEPLOY_KEY`, `~/.config/x/secret`)."
);
}
}
Ok(())
}