1use crate::check::Outcome;
33use crate::pushrefs::PushRef;
34
35use super::common;
36
37const ALLOW: &str = "amont:allow-secret";
39
40const MAX_BYTES: usize = 2 * 1024 * 1024;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub(crate) enum Kind {
47 PrivateKey,
48 AwsAccessKeyId,
49 GithubToken,
50 SlackToken,
51 GoogleApiKey,
52 StripeLiveKey,
53 NpmToken,
54 ApiKey,
55}
56
57impl Kind {
58 fn name(self) -> &'static str {
59 match self {
60 Kind::PrivateKey => "a private key",
61 Kind::AwsAccessKeyId => "an AWS access key id",
62 Kind::GithubToken => "a GitHub token",
63 Kind::SlackToken => "a Slack token",
64 Kind::GoogleApiKey => "a Google API key",
65 Kind::StripeLiveKey => "a Stripe live key",
66 Kind::NpmToken => "an npm token",
67 Kind::ApiKey => "an API key",
68 }
69 }
70}
71
72fn is_token_char(b: u8) -> bool {
73 b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
74}
75
76fn token_run(text: &str, at: usize, n: usize) -> bool {
78 text.as_bytes()[at..]
79 .iter()
80 .take_while(|b| is_token_char(**b))
81 .count()
82 >= n
83}
84
85fn boundary_before(text: &str, at: usize) -> bool {
88 at == 0 || !is_token_char(text.as_bytes()[at - 1])
89}
90
91fn has_prefixed_token(line: &str, prefix: &str, min: usize) -> bool {
93 let mut from = 0;
94 while let Some(i) = line[from..].find(prefix) {
95 let at = from + i;
96 if boundary_before(line, at) && token_run(line, at + prefix.len(), min) {
97 return true;
98 }
99 from = at + prefix.len();
100 }
101 false
102}
103
104pub(crate) fn sniff(line: &str) -> Option<Kind> {
107 if line.contains(ALLOW) {
108 return None;
109 }
110 if line.contains(concat!("-----", "BEGIN ")) && line.contains(concat!("PRIVATE", " KEY-----")) {
115 return Some(Kind::PrivateKey);
116 }
117 for p in [concat!("AK", "IA"), concat!("AS", "IA")] {
119 let mut from = 0;
120 while let Some(i) = line[from..].find(p) {
121 let at = from + i;
122 let rest = &line.as_bytes()[at + 4..];
123 if boundary_before(line, at)
124 && rest.len() >= 16
125 && rest[..16]
126 .iter()
127 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
128 {
129 return Some(Kind::AwsAccessKeyId);
130 }
131 from = at + 4;
132 }
133 }
134 for p in [
137 concat!("gh", "p_"),
138 concat!("gh", "o_"),
139 concat!("gh", "u_"),
140 concat!("gh", "s_"),
141 concat!("gh", "r_"),
142 ] {
143 if has_prefixed_token(line, p, 36) {
144 return Some(Kind::GithubToken);
145 }
146 }
147 if has_prefixed_token(line, concat!("github_", "pat_"), 60) {
148 return Some(Kind::GithubToken);
149 }
150 for p in [
152 concat!("xox", "b-"),
153 concat!("xox", "p-"),
154 concat!("xox", "a-"),
155 concat!("xox", "r-"),
156 concat!("xox", "s-"),
157 ] {
158 if has_prefixed_token(line, p, 10) {
159 return Some(Kind::SlackToken);
160 }
161 }
162 if has_prefixed_token(line, concat!("AI", "za"), 30) {
165 return Some(Kind::GoogleApiKey);
166 }
167 for p in [concat!("sk_", "live_"), concat!("rk_", "live_")] {
169 if has_prefixed_token(line, p, 20) {
170 return Some(Kind::StripeLiveKey);
171 }
172 }
173 if has_prefixed_token(line, concat!("np", "m_"), 36) {
174 return Some(Kind::NpmToken);
175 }
176 for p in [concat!("sk-", "proj-"), concat!("sk-", "ant-")] {
180 if has_prefixed_token(line, p, 20) {
181 return Some(Kind::ApiKey);
182 }
183 }
184 None
185}
186
187fn looks_binary(bytes: &[u8]) -> bool {
189 bytes.iter().take(8000).any(|b| *b == 0)
190}
191
192fn scan(text: &str) -> Vec<(usize, Kind)> {
194 text.lines()
195 .enumerate()
196 .filter_map(|(i, line)| sniff(line).map(|k| (i + 1, k)))
197 .collect()
198}
199
200pub fn staged() -> Outcome {
203 let files = common::staged_files(&[]);
204 let root = common::repo_root();
205 let mut found = false;
206 for f in &files {
207 let path = std::path::Path::new(&root).join(f);
208 let Ok(bytes) = std::fs::read(&path) else {
209 continue; };
211 if looks_binary(&bytes) || bytes.len() > MAX_BYTES {
212 continue;
213 }
214 let text = String::from_utf8_lossy(&bytes);
215 for (line, kind) in scan(&text) {
216 found = true;
217 common::fail(&format!(
218 "secrets: {} at {}:{line} — unstage it; once pushed it is \
219 not history, it is an incident",
220 kind.name(),
221 crate::ui::sanitize(f),
222 ));
223 }
224 }
225 if found {
226 return Outcome::Failed;
227 }
228 common::ok("No secrets staged");
229 Outcome::Passed
230}
231
232pub fn pushed(refs: &[PushRef]) -> Outcome {
236 let zero = crate::git::stdout(&["hash-object", "--stdin"])
237 .map(|h| "0".repeat(h.len()))
238 .unwrap_or_else(|| "0".repeat(40));
239 let mut found = false;
240 let mut checked_any_ref = false;
241 for r in refs {
242 if r.local_oid == zero {
243 continue; }
245 let commits: Vec<String> = crate::pushrefs::commits_and_files_for(r, &zero)
246 .into_iter()
247 .map(|(c, _)| c)
248 .collect();
249 if commits.is_empty() && r.remote_oid != zero {
250 continue;
252 }
253 checked_any_ref = true;
254 for commit in &commits {
255 let Some(diff) = crate::git::stdout(&["show", "--no-color", "--format=", commit])
256 else {
257 common::warn(
258 "secrets: git would not show a pushed commit — the push was \
259 NOT fully scanned",
260 );
261 return Outcome::Unavailable;
262 };
263 let mut file = String::from("?");
264 for line in diff.lines() {
265 if let Some(rest) = line.strip_prefix("+++ b/") {
266 file = rest.to_string();
267 continue;
268 }
269 let Some(added) = line.strip_prefix('+') else {
270 continue;
271 };
272 if let Some(kind) = sniff(added) {
273 found = true;
274 common::fail(&format!(
275 "secrets: {} added by commit {} in {} — this push would \
276 publish it; rewrite the history first (the secret may \
277 already need rotating)",
278 kind.name(),
279 &commit[..commit.len().min(12)],
280 crate::ui::sanitize(&file),
281 ));
282 }
283 }
284 }
285 }
286 if found {
287 return Outcome::Failed;
288 }
289 let _ = checked_any_ref; common::ok("No secrets in the pushed commits");
291 Outcome::Passed
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 fn pem() -> String {
301 format!("{}{} RSA {}{}", "-----", "BEGIN", "PRIVATE", " KEY-----")
302 }
303 fn aws() -> String {
304 format!("{}{}{}", "AK", "IA", "IOSFODNN7EXAMPLE")
305 }
306 fn gh() -> String {
307 format!("{}{}{}", "gh", "p_", "a".repeat(36))
308 }
309
310 #[test]
311 fn the_known_shapes_are_recognised() {
312 assert_eq!(sniff(&pem()), Some(Kind::PrivateKey));
313 assert_eq!(
314 sniff(&format!("key = {}", aws())),
315 Some(Kind::AwsAccessKeyId)
316 );
317 assert_eq!(sniff(&format!("token: {}", gh())), Some(Kind::GithubToken));
318 assert_eq!(
319 sniff(&format!("SLACK={}{}", "xox", "b-1234567890-abc")),
320 Some(Kind::SlackToken)
321 );
322 assert_eq!(
323 sniff(&format!("{}{}", "AI", "za".to_owned() + &"D".repeat(35))),
324 Some(Kind::GoogleApiKey)
325 );
326 assert_eq!(
327 sniff(&format!("{}{}{}", "sk_", "live_", "a".repeat(24))),
328 Some(Kind::StripeLiveKey)
329 );
330 assert_eq!(
331 sniff(&format!(
332 "{}{}{}",
333 "sk-",
334 "ant-",
335 "api03-".to_owned() + &"x".repeat(20)
336 )),
337 Some(Kind::ApiKey)
338 );
339 }
340
341 #[test]
344 fn lookalikes_are_left_alone() {
345 assert_eq!(sniff("AKIAI is the prefix"), None); assert_eq!(sniff(&format!("X{}", aws())), None); assert_eq!(sniff("ghp_short"), None);
348 assert_eq!(sniff("the sk-1234 identifier"), None); assert_eq!(
353 sniff(&format!("{}{}{}", "sk_", "test_", "a".repeat(24))),
354 None
355 );
356 assert_eq!(sniff("xoxb- alone"), None);
357 assert_eq!(sniff(""), None);
358 }
359
360 #[test]
362 fn the_allow_pragma_skips_the_line() {
363 let line = format!("{} // {}", aws(), ALLOW);
364 assert_eq!(sniff(&line), None);
365 }
366
367 #[test]
370 fn the_scanner_does_not_flag_its_own_source() {
371 let own = include_str!("secrets.rs");
372 assert!(
373 scan(own).is_empty(),
374 "the scanner flagged its own source: {:?}",
375 scan(own)
376 );
377 }
378
379 #[test]
381 fn binary_content_is_skipped() {
382 assert!(looks_binary(b"\x00PNG"));
383 assert!(!looks_binary(b"just text"));
384 }
385
386 #[test]
388 fn scan_reports_each_line_once() {
389 let text = format!("clean\n{}\nclean\n{}\n", pem(), aws());
390 let hits = scan(&text);
391 assert_eq!(hits.len(), 2);
392 assert_eq!(hits[0], (2, Kind::PrivateKey));
393 assert_eq!(hits[1], (4, Kind::AwsAccessKeyId));
394 }
395}