use crate::check::Outcome;
use crate::pushrefs::PushRef;
use super::common;
const ALLOW: &str = "amont:allow-secret";
const MAX_BYTES: usize = 2 * 1024 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Kind {
PrivateKey,
AwsAccessKeyId,
GithubToken,
SlackToken,
GoogleApiKey,
StripeLiveKey,
NpmToken,
ApiKey,
}
impl Kind {
fn name(self) -> &'static str {
match self {
Kind::PrivateKey => "a private key",
Kind::AwsAccessKeyId => "an AWS access key id",
Kind::GithubToken => "a GitHub token",
Kind::SlackToken => "a Slack token",
Kind::GoogleApiKey => "a Google API key",
Kind::StripeLiveKey => "a Stripe live key",
Kind::NpmToken => "an npm token",
Kind::ApiKey => "an API key",
}
}
}
fn is_token_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
fn token_run(text: &str, at: usize, n: usize) -> bool {
text.as_bytes()[at..]
.iter()
.take_while(|b| is_token_char(**b))
.count()
>= n
}
fn boundary_before(text: &str, at: usize) -> bool {
at == 0 || !is_token_char(text.as_bytes()[at - 1])
}
fn has_prefixed_token(line: &str, prefix: &str, min: usize) -> bool {
let mut from = 0;
while let Some(i) = line[from..].find(prefix) {
let at = from + i;
if boundary_before(line, at) && token_run(line, at + prefix.len(), min) {
return true;
}
from = at + prefix.len();
}
false
}
pub(crate) fn sniff(line: &str) -> Option<Kind> {
if line.contains(ALLOW) {
return None;
}
if line.contains(concat!("-----", "BEGIN ")) && line.contains(concat!("PRIVATE", " KEY-----")) {
return Some(Kind::PrivateKey);
}
for p in [concat!("AK", "IA"), concat!("AS", "IA")] {
let mut from = 0;
while let Some(i) = line[from..].find(p) {
let at = from + i;
let rest = &line.as_bytes()[at + 4..];
if boundary_before(line, at)
&& rest.len() >= 16
&& rest[..16]
.iter()
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
{
return Some(Kind::AwsAccessKeyId);
}
from = at + 4;
}
}
for p in [
concat!("gh", "p_"),
concat!("gh", "o_"),
concat!("gh", "u_"),
concat!("gh", "s_"),
concat!("gh", "r_"),
] {
if has_prefixed_token(line, p, 36) {
return Some(Kind::GithubToken);
}
}
if has_prefixed_token(line, concat!("github_", "pat_"), 60) {
return Some(Kind::GithubToken);
}
for p in [
concat!("xox", "b-"),
concat!("xox", "p-"),
concat!("xox", "a-"),
concat!("xox", "r-"),
concat!("xox", "s-"),
] {
if has_prefixed_token(line, p, 10) {
return Some(Kind::SlackToken);
}
}
if has_prefixed_token(line, concat!("AI", "za"), 30) {
return Some(Kind::GoogleApiKey);
}
for p in [concat!("sk_", "live_"), concat!("rk_", "live_")] {
if has_prefixed_token(line, p, 20) {
return Some(Kind::StripeLiveKey);
}
}
if has_prefixed_token(line, concat!("np", "m_"), 36) {
return Some(Kind::NpmToken);
}
for p in [concat!("sk-", "proj-"), concat!("sk-", "ant-")] {
if has_prefixed_token(line, p, 20) {
return Some(Kind::ApiKey);
}
}
None
}
fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8000).any(|b| *b == 0)
}
fn scan(text: &str) -> Vec<(usize, Kind)> {
text.lines()
.enumerate()
.filter_map(|(i, line)| sniff(line).map(|k| (i + 1, k)))
.collect()
}
pub fn staged() -> Outcome {
let files = common::staged_files(&[]);
let root = common::repo_root();
let mut found = false;
for f in &files {
let path = std::path::Path::new(&root).join(f);
let Ok(bytes) = std::fs::read(&path) else {
continue; };
if looks_binary(&bytes) || bytes.len() > MAX_BYTES {
continue;
}
let text = String::from_utf8_lossy(&bytes);
for (line, kind) in scan(&text) {
found = true;
common::fail(&format!(
"secrets: {} at {}:{line} — unstage it; once pushed it is \
not history, it is an incident",
kind.name(),
crate::ui::sanitize(f),
));
}
}
if found {
return Outcome::Failed;
}
common::ok("No secrets staged");
Outcome::Passed
}
pub fn pushed(refs: &[PushRef]) -> Outcome {
let zero = crate::git::stdout(&["hash-object", "--stdin"])
.map(|h| "0".repeat(h.len()))
.unwrap_or_else(|| "0".repeat(40));
let mut found = false;
let mut checked_any_ref = false;
for r in refs {
if r.local_oid == zero {
continue; }
let commits: Vec<String> = crate::pushrefs::commits_and_files_for(r, &zero)
.into_iter()
.map(|(c, _)| c)
.collect();
if commits.is_empty() && r.remote_oid != zero {
continue;
}
checked_any_ref = true;
for commit in &commits {
let Some(diff) = crate::git::stdout(&["show", "--no-color", "--format=", commit])
else {
common::warn(
"secrets: git would not show a pushed commit — the push was \
NOT fully scanned",
);
return Outcome::Unavailable;
};
let mut file = String::from("?");
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ b/") {
file = rest.to_string();
continue;
}
let Some(added) = line.strip_prefix('+') else {
continue;
};
if let Some(kind) = sniff(added) {
found = true;
common::fail(&format!(
"secrets: {} added by commit {} in {} — this push would \
publish it; rewrite the history first (the secret may \
already need rotating)",
kind.name(),
&commit[..commit.len().min(12)],
crate::ui::sanitize(&file),
));
}
}
}
}
if found {
return Outcome::Failed;
}
let _ = checked_any_ref; common::ok("No secrets in the pushed commits");
Outcome::Passed
}
#[cfg(test)]
mod tests {
use super::*;
fn pem() -> String {
format!("{}{} RSA {}{}", "-----", "BEGIN", "PRIVATE", " KEY-----")
}
fn aws() -> String {
format!("{}{}{}", "AK", "IA", "IOSFODNN7EXAMPLE")
}
fn gh() -> String {
format!("{}{}{}", "gh", "p_", "a".repeat(36))
}
#[test]
fn the_known_shapes_are_recognised() {
assert_eq!(sniff(&pem()), Some(Kind::PrivateKey));
assert_eq!(
sniff(&format!("key = {}", aws())),
Some(Kind::AwsAccessKeyId)
);
assert_eq!(sniff(&format!("token: {}", gh())), Some(Kind::GithubToken));
assert_eq!(
sniff(&format!("SLACK={}{}", "xox", "b-1234567890-abc")),
Some(Kind::SlackToken)
);
assert_eq!(
sniff(&format!("{}{}", "AI", "za".to_owned() + &"D".repeat(35))),
Some(Kind::GoogleApiKey)
);
assert_eq!(
sniff(&format!("{}{}{}", "sk_", "live_", "a".repeat(24))),
Some(Kind::StripeLiveKey)
);
assert_eq!(
sniff(&format!(
"{}{}{}",
"sk-",
"ant-",
"api03-".to_owned() + &"x".repeat(20)
)),
Some(Kind::ApiKey)
);
}
#[test]
fn lookalikes_are_left_alone() {
assert_eq!(sniff("AKIAI is the prefix"), None); assert_eq!(sniff(&format!("X{}", aws())), None); assert_eq!(sniff("ghp_short"), None);
assert_eq!(sniff("the sk-1234 identifier"), None); assert_eq!(
sniff(&format!("{}{}{}", "sk_", "test_", "a".repeat(24))),
None
);
assert_eq!(sniff("xoxb- alone"), None);
assert_eq!(sniff(""), None);
}
#[test]
fn the_allow_pragma_skips_the_line() {
let line = format!("{} // {}", aws(), ALLOW);
assert_eq!(sniff(&line), None);
}
#[test]
fn the_scanner_does_not_flag_its_own_source() {
let own = include_str!("secrets.rs");
assert!(
scan(own).is_empty(),
"the scanner flagged its own source: {:?}",
scan(own)
);
}
#[test]
fn binary_content_is_skipped() {
assert!(looks_binary(b"\x00PNG"));
assert!(!looks_binary(b"just text"));
}
#[test]
fn scan_reports_each_line_once() {
let text = format!("clean\n{}\nclean\n{}\n", pem(), aws());
let hits = scan(&text);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0], (2, Kind::PrivateKey));
assert_eq!(hits[1], (4, Kind::AwsAccessKeyId));
}
}