flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/crates/scan/src/secrets.rs

562 lines22,648 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1//! Secrets that must never reach a repository: keys and tokens of the
2//! formats their issuers made recognisable on purpose, and private keys.
3//!
4//! Each rule knows a format exactly (its prefix, its alphabet, its length),
5//! so a match is almost always a real credential, or a fake made to look
6//! like one. Fakes in tests and docs are still found; a person marks them
7//! with [`ALLOW_MARKER`] on the same line, or allows the finding once on the
8//! project's Security page. Guesswork from entropy alone is left out: it is
9//! what makes scanners noisy, and noise is what makes people bypass them.
10
11use sha2::{Digest, Sha256};
12
13/// Written anywhere on a line, in a comment, says what is on it is not a
14/// real secret: `const KEY = "AKIA…"; // g1t:allow-secret`.
15pub const ALLOW_MARKER: &str = "g1t:allow-secret";
16
17/// What a secret is.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
19pub enum SecretKind {
20 AwsAccessKey,
21 AwsSecretKey,
22 GithubToken,
23 GitlabToken,
24 StripeLiveKey,
25 SlackToken,
26 SlackWebhook,
27 GoogleApiKey,
28 PrivateKey,
29 AnthropicKey,
30 OpenaiKey,
31 ServiceJwt,
32 NpmToken,
33 G1tToken,
34 SendgridKey,
35}
36
37impl SecretKind {
38 pub const ALL: [SecretKind; 15] = [
39 SecretKind::AwsAccessKey,
40 SecretKind::AwsSecretKey,
41 SecretKind::GithubToken,
42 SecretKind::GitlabToken,
43 SecretKind::StripeLiveKey,
44 SecretKind::SlackToken,
45 SecretKind::SlackWebhook,
46 SecretKind::GoogleApiKey,
47 SecretKind::PrivateKey,
48 SecretKind::AnthropicKey,
49 SecretKind::OpenaiKey,
50 SecretKind::ServiceJwt,
51 SecretKind::NpmToken,
52 SecretKind::G1tToken,
53 SecretKind::SendgridKey,
54 ];
55
56 /// Stored and sent between services.
57 pub fn id(self) -> &'static str {
58 match self {
59 SecretKind::AwsAccessKey => "aws_access_key",
60 SecretKind::AwsSecretKey => "aws_secret_key",
61 SecretKind::GithubToken => "github_token",
62 SecretKind::GitlabToken => "gitlab_token",
63 SecretKind::StripeLiveKey => "stripe_live_key",
64 SecretKind::SlackToken => "slack_token",
65 SecretKind::SlackWebhook => "slack_webhook",
66 SecretKind::GoogleApiKey => "google_api_key",
67 SecretKind::PrivateKey => "private_key",
68 SecretKind::AnthropicKey => "anthropic_key",
69 SecretKind::OpenaiKey => "openai_key",
70 SecretKind::ServiceJwt => "service_jwt",
71 SecretKind::NpmToken => "npm_token",
72 SecretKind::G1tToken => "g1t_token",
73 SecretKind::SendgridKey => "sendgrid_key",
74 }
75 }
76
77 /// For a sentence: "contains an AWS access key".
78 pub fn label(self) -> &'static str {
79 match self {
80 SecretKind::AwsAccessKey => "an AWS access key",
81 SecretKind::AwsSecretKey => "an AWS secret access key",
82 SecretKind::GithubToken => "a GitHub token",
83 SecretKind::GitlabToken => "a GitLab token",
84 SecretKind::StripeLiveKey => "a Stripe live key",
85 SecretKind::SlackToken => "a Slack token",
86 SecretKind::SlackWebhook => "a Slack webhook address",
87 SecretKind::GoogleApiKey => "a Google API key",
88 SecretKind::PrivateKey => "a private key",
89 SecretKind::AnthropicKey => "an Anthropic API key",
90 SecretKind::OpenaiKey => "an OpenAI API key",
91 SecretKind::ServiceJwt => "a service-role JWT",
92 SecretKind::NpmToken => "an npm token",
93 SecretKind::G1tToken => "a g1t token",
94 SecretKind::SendgridKey => "a SendGrid key",
95 }
96 }
97
98 pub fn parse(id: &str) -> Option<SecretKind> {
99 SecretKind::ALL.into_iter().find(|kind| kind.id() == id)
100 }
101}
102
103/// A secret found on one line.
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub struct Hit {
106 pub kind: SecretKind,
107 /// From 1.
108 pub line: u32,
109 /// The secret itself. Never stored or shown: see [`Hit::fingerprint`]
110 /// and [`Hit::preview`].
111 pub value: String,
112}
113
114impl Hit {
115 /// Names this secret without holding it: the same secret found again,
116 /// in another commit or a rewritten one, has the same fingerprint.
117 pub fn fingerprint(&self) -> String {
118 fingerprint(self.kind, &self.value)
119 }
120
121 /// Enough of the secret for its owner to recognise it, and no more.
122 pub fn preview(&self) -> String {
123 preview(self.kind, &self.value)
124 }
125}
126
127pub fn fingerprint(kind: SecretKind, value: &str) -> String {
128 let digest = Sha256::digest(format!("{}:{value}", kind.id()).as_bytes());
129 digest[..16].iter().map(|byte| format!("{byte:02x}")).collect()
130}
131
132pub fn preview(kind: SecretKind, value: &str) -> String {
133 if kind == SecretKind::PrivateKey {
134 return value.lines().next().unwrap_or("-----BEGIN PRIVATE KEY-----").to_owned();
135 }
136 let chars: Vec<char> = value.chars().collect();
137 let shown = (chars.len() / 4).clamp(3, 8);
138 format!("{}…", chars[..shown.min(chars.len())].iter().collect::<String>())
139}
140
141#[derive(Clone, Copy)]
142enum Alphabet {
143 /// A–Z and 0–9.
144 UpperDigit,
145 /// Letters and digits.
146 Alnum,
147 /// Letters, digits and `_`.
148 Word,
149 /// Letters, digits, `_` and `-`.
150 Token,
151 /// Lowercase hex.
152 Hex,
153 /// Letters, digits, `_`, `-` and `.`.
154 Dotted,
155 /// Letters, digits and `/`.
156 Path,
157}
158
159impl Alphabet {
160 fn has(self, c: char) -> bool {
161 match self {
162 Alphabet::UpperDigit => c.is_ascii_uppercase() || c.is_ascii_digit(),
163 Alphabet::Alnum => c.is_ascii_alphanumeric(),
164 Alphabet::Word => c.is_ascii_alphanumeric() || c == '_',
165 Alphabet::Token => c.is_ascii_alphanumeric() || c == '_' || c == '-',
166 Alphabet::Hex => c.is_ascii_digit() || ('a'..='f').contains(&c),
167 Alphabet::Dotted => c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'),
168 Alphabet::Path => c.is_ascii_alphanumeric() || c == '/',
169 }
170 }
171}
172
173/// A format: what it starts with, which characters follow, and how many.
174struct Rule {
175 kind: SecretKind,
176 prefixes: &'static [&'static str],
177 body: Alphabet,
178 min: usize,
179 max: usize,
180 /// Anything else the body has to be.
181 check: Option<fn(&str) -> bool>,
182}
183
184const RULES: &[Rule] = &[
185 Rule { kind: SecretKind::AwsAccessKey, prefixes: &["AKIA", "ASIA", "ABIA", "ACCA"], body: Alphabet::UpperDigit, min: 16, max: 16, check: None },
186 Rule { kind: SecretKind::GithubToken, prefixes: &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"], body: Alphabet::Alnum, min: 36, max: 255, check: None },
187 Rule { kind: SecretKind::GithubToken, prefixes: &["github_pat_"], body: Alphabet::Word, min: 50, max: 255, check: None },
188 Rule { kind: SecretKind::GitlabToken, prefixes: &["glpat-", "gloas-", "glrt-", "glptt-", "gldt-"], body: Alphabet::Token, min: 20, max: 64, check: None },
189 Rule { kind: SecretKind::StripeLiveKey, prefixes: &["sk_live_", "rk_live_"], body: Alphabet::Alnum, min: 20, max: 255, check: None },
190 Rule { kind: SecretKind::SlackToken, prefixes: &["xoxb-", "xoxp-", "xoxa-", "xoxr-", "xoxs-", "xoxe-"], body: Alphabet::Token, min: 20, max: 255, check: Some(has_digit) },
191 Rule { kind: SecretKind::SlackWebhook, prefixes: &["https://hooks.slack.com/services/"], body: Alphabet::Path, min: 30, max: 120, check: Some(slack_webhook) },
192 Rule { kind: SecretKind::GoogleApiKey, prefixes: &["AIza"], body: Alphabet::Token, min: 35, max: 35, check: None },
193 Rule { kind: SecretKind::AnthropicKey, prefixes: &["sk-ant-"], body: Alphabet::Token, min: 40, max: 255, check: None },
194 Rule { kind: SecretKind::OpenaiKey, prefixes: &["sk-proj-", "sk-svcacct-", "sk-admin-"], body: Alphabet::Token, min: 40, max: 255, check: None },
195 // The keys OpenAI issued before project keys carry "T3BlbkFJ" in the middle.
196 Rule { kind: SecretKind::OpenaiKey, prefixes: &["sk-"], body: Alphabet::Alnum, min: 40, max: 60, check: Some(openai_legacy) },
197 Rule { kind: SecretKind::NpmToken, prefixes: &["npm_"], body: Alphabet::Alnum, min: 36, max: 36, check: None },
198 Rule { kind: SecretKind::G1tToken, prefixes: &["g1t_"], body: Alphabet::Hex, min: 40, max: 40, check: None },
199 Rule { kind: SecretKind::SendgridKey, prefixes: &["SG."], body: Alphabet::Dotted, min: 66, max: 66, check: Some(sendgrid) },
200];
201
202fn has_digit(body: &str) -> bool {
203 body.chars().any(|c| c.is_ascii_digit())
204}
205
206fn slack_webhook(body: &str) -> bool {
207 let parts: Vec<&str> = body.split('/').collect();
208 parts.len() == 3 && parts[0].starts_with('T') && parts[1].starts_with('B') && parts[2].len() >= 20
209}
210
211fn openai_legacy(body: &str) -> bool {
212 body.contains("T3BlbkFJ")
213}
214
215fn sendgrid(body: &str) -> bool {
216 let parts: Vec<&str> = body.split('.').collect();
217 parts.len() == 2 && parts[0].len() == 22 && parts[1].len() == 43
218}
219
220/// A run of one character over and over, or a handful of them, is a
221/// placeholder in documentation: `ghp_xxxxxxxx…`, `AKIA0000…`.
222fn placeholder(body: &str) -> bool {
223 let mut seen = std::collections::HashSet::new();
224 for c in body.chars() {
225 seen.insert(c.to_ascii_lowercase());
226 }
227 seen.len() < 6
228}
229
230fn boundary_before(line: &str, at: usize) -> bool {
231 line[..at]
232 .chars()
233 .next_back()
234 .is_none_or(|c| !c.is_ascii_alphanumeric())
235}
236
237/// The secrets of the formats in [`RULES`] on one line.
238fn by_rules(line: &str, found: &mut Vec<(SecretKind, String)>) {
239 for rule in RULES {
240 for prefix in rule.prefixes {
241 let mut from = 0;
242 while let Some(offset) = line[from..].find(prefix) {
243 let at = from + offset;
244 from = at + prefix.len();
245 if !boundary_before(line, at) {
246 continue;
247 }
248 let body: String = line[from..].chars().take_while(|c| rule.body.has(*c)).collect();
249 let length = body.chars().count();
250 if length < rule.min || length > rule.max || placeholder(&body) {
251 continue;
252 }
253 if rule.check.is_some_and(|check| !check(&body)) {
254 continue;
255 }
256 found.push((rule.kind, format!("{prefix}{body}")));
257 }
258 }
259 }
260}
261
262/// `aws_secret_access_key = "…"`: forty characters of base64 are only an
263/// AWS secret when the line says so.
264fn aws_secret(line: &str, found: &mut Vec<(SecretKind, String)>) {
265 let lower = line.to_ascii_lowercase();
266 if !lower.contains("aws") || !lower.contains("secret") {
267 return;
268 }
269 for (at, _) in line.match_indices(['=', ':']) {
270 let value: String = line[at + 1..]
271 .trim_start_matches([' ', '"', '\'', '\t'])
272 .chars()
273 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '+'))
274 .collect();
275 let mixed = value.chars().any(|c| c.is_ascii_uppercase())
276 && value.chars().any(|c| c.is_ascii_lowercase())
277 && value.chars().any(|c| c.is_ascii_digit());
278 if value.len() == 40 && mixed && !placeholder(&value) {
279 found.push((SecretKind::AwsSecretKey, value));
280 }
281 }
282}
283
284fn base64url_decode(input: &str) -> Option<Vec<u8>> {
285 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
286 let (mut buffer, mut bits) = (0u32, 0);
287 for byte in input.bytes().filter(|byte| *byte != b'=') {
288 let value = match byte {
289 b'A'..=b'Z' => byte - b'A',
290 b'a'..=b'z' => byte - b'a' + 26,
291 b'0'..=b'9' => byte - b'0' + 52,
292 b'-' | b'+' => 62,
293 b'_' | b'/' => 63,
294 _ => return None,
295 };
296 buffer = (buffer << 6) | u32::from(value);
297 bits += 6;
298 if bits >= 8 {
299 bits -= 8;
300 bytes.push((buffer >> bits) as u8);
301 }
302 }
303 Some(bytes)
304}
305
306/// A JWT whose claims make it a service's key rather than a user's
307/// session: one that bypasses row-level security, signed to last.
308fn service_jwt(line: &str, found: &mut Vec<(SecretKind, String)>) {
309 let mut from = 0;
310 while let Some(offset) = line[from..].find("eyJ") {
311 let at = from + offset;
312 from = at + 3;
313 if !boundary_before(line, at) {
314 continue;
315 }
316 let token: String = line[at..]
317 .chars()
318 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.'))
319 .collect();
320 let parts: Vec<&str> = token.split('.').collect();
321 if parts.len() != 3 || parts[0].len() < 10 || parts[1].len() < 10 || parts[2].len() < 20 {
322 continue;
323 }
324 let Some(claims) = base64url_decode(parts[1]) else {
325 continue;
326 };
327 let claims = String::from_utf8_lossy(&claims);
328 if claims.contains("\"service_role\"") {
329 from = at + token.len();
330 found.push((SecretKind::ServiceJwt, token));
331 }
332 }
333}
334
335const PEM_BODY: usize = 40;
336
337fn looks_like_pem_body(text: &str) -> bool {
338 let text = text.trim().trim_start_matches(['"', '\'']);
339 text.starts_with("Proc-Type:")
340 || text
341 .chars()
342 .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '='))
343 .count()
344 >= PEM_BODY
345}
346
347/// The header of a PEM private key, when a key follows it: on the next
348/// line, or after an escaped newline on the same one, as a key pasted into
349/// a string does. A header alone is code that handles keys, not a key.
350fn private_key(line: &str, next: Option<&str>, found: &mut Vec<(SecretKind, String)>) {
351 let Some(start) = line.find("-----BEGIN ") else {
352 return;
353 };
354 let rest = &line[start..];
355 let Some(end) = rest[11..].find("-----") else {
356 return;
357 };
358 let header = &rest[..11 + end + 5];
359 if !header.contains("PRIVATE KEY") {
360 return;
361 }
362 let after = rest[header.len()..].trim_start_matches("\\n").trim_start_matches("\\r\\n");
363 let body = if looks_like_pem_body(after) {
364 Some(after)
365 } else {
366 next.filter(|next| looks_like_pem_body(next))
367 };
368 if let Some(body) = body {
369 let body: String = body
370 .trim()
371 .trim_start_matches(['"', '\''])
372 .chars()
373 .take_while(|c| !c.is_whitespace() && *c != '\\' && *c != '"')
374 .collect();
375 found.push((SecretKind::PrivateKey, format!("{header}\n{body}")));
376 }
377}
378
379/// The secrets on one line. `next` is the line after it, which a private
380/// key's header needs to tell a key from code that mentions one.
381pub fn scan_line(line: &str, next: Option<&str>) -> Vec<(SecretKind, String)> {
382 let mut found = Vec::new();
383 if line.contains(ALLOW_MARKER) {
384 return found;
385 }
386 by_rules(line, &mut found);
387 aws_secret(line, &mut found);
388 service_jwt(line, &mut found);
389 private_key(line, next, &mut found);
390 // Two rules can find one secret (`sk-` and `sk-proj-`); keep the first.
391 let mut seen = std::collections::HashSet::new();
392 found.retain(|(_, value)| seen.insert(value.clone()));
393 found
394}
395
396/// Every secret in `text`, on the lines `wanted` accepts (numbered from 1).
397pub fn scan_lines(text: &str, wanted: impl Fn(u32) -> bool) -> Vec<Hit> {
398 let lines: Vec<&str> = text.lines().collect();
399 let mut hits = Vec::new();
400 for (index, line) in lines.iter().enumerate() {
401 let number = index as u32 + 1;
402 if !wanted(number) {
403 continue;
404 }
405 for (kind, value) in scan_line(line, lines.get(index + 1).copied()) {
406 hits.push(Hit { kind, line: number, value });
407 }
408 }
409 hits
410}
411
412/// Every secret in `text`.
413pub fn scan_text(text: &str) -> Vec<Hit> {
414 scan_lines(text, |_| true)
415}
416
417/// Paths whose contents are never secrets of their own: lockfiles and
418/// vendored or generated code, where a match is someone else's fixture.
419pub fn skipped_path(path: &str) -> bool {
420 let name = path.rsplit('/').next().unwrap_or(path);
421 matches!(
422 name,
423 "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" | "Cargo.lock" | "go.sum" | "poetry.lock"
424 ) || path.split('/').any(|part| part == "node_modules" || part == "vendor")
425 || name.ends_with(".min.js")
426 || name.ends_with(".map")
427}
428
429#[cfg(test)]
430mod tests {
431 use super::*;
432
433 /// Key-shaped values are put together at run time, so that no whole
434 /// key sits in this file for any scanner, this one included, to flag.
435 fn join(a: &str, b: &str) -> String {
436 format!("{a}{b}")
437 }
438
439 fn kinds(line: &str) -> Vec<SecretKind> {
440 scan_line(line, None).into_iter().map(|(kind, _)| kind).collect()
441 }
442
443 #[test]
444 fn real_formats_are_found() {
445 let cases = [
446 (join("aws_access_key_id = AK", "IAZ7Q4N2XWLM3KDTRV"), SecretKind::AwsAccessKey),
447 (join("token: gh", "p_Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lXmC3b"), SecretKind::GithubToken),
448 (join("GITLAB=glp", "at-x7Rk2PqLm9VwT4bN8cZs"), SecretKind::GitlabToken),
449 (join("STRIPE_KEY=sk_l", "ive_51HabcdEFGhijKLMnop7QRSTuv"), SecretKind::StripeLiveKey),
450 (join("SLACK=xo", "xb-2048-1029384756-Zq8wN3vR7tY2uI5oP1aS"), SecretKind::SlackToken),
451 (join("url = https://hooks.slack.com/serv", "ices/T024BE7LD/B01ABCDEFGH/Zq8wN3vR7tY2uI5oP1aS6dF4"), SecretKind::SlackWebhook),
452 (join("key: AI", "zaSyD-9tSrke72PouQMnMX-a7eZSW0jkFMBWY"), SecretKind::GoogleApiKey),
453 (join("ANTHROPIC_API_KEY=sk-an", "t-api03-Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lXmC3bVn8wQ2"), SecretKind::AnthropicKey),
454 (join("OPENAI_API_KEY=sk-pr", "oj-Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lXmC3bVn8wQ2xx"), SecretKind::OpenaiKey),
455 (join("key = \"sk-Zq8wN3vR7tY2uI5oP1a", "T3BlbkFJS6dF4gH9jK0lXmC3b\""), SecretKind::OpenaiKey),
456 (join("//registry.npmjs.org/:_authToken=np", "m_Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lXmC3b"), SecretKind::NpmToken),
457 (join("G1T_TOKEN=g1", "t_3f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f1a"), SecretKind::G1tToken),
458 (join("SENDGRID=SG", ".Zq8wN3vR7tY2uI5oP1aS6x.dF4gH9jK0lXmC3bVn8wQ2xZq8wN3vR7tY2uI5oP1aS6"), SecretKind::SendgridKey),
459 (join("aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCY", "Q2x9Lm3Kd7"), SecretKind::AwsSecretKey),
460 ];
461 for (line, kind) in cases {
462 assert_eq!(kinds(&line), [kind], "{line}");
463 }
464 }
465
466 #[test]
467 fn fakes_are_found_too_and_the_marker_allows_them() {
468 let example = join("AWS_KEY=AK", "IAIOSFODNN7EXAMPLE");
469 assert_eq!(kinds(&example), [SecretKind::AwsAccessKey]);
470 assert!(kinds(&format!("{example} # g1t:allow-secret")).is_empty());
471 assert!(kinds(&format!("{example} // {ALLOW_MARKER}")).is_empty());
472 }
473
474 #[test]
475 fn ordinary_code_is_left_alone() {
476 for line in [
477 "Commit 9f2c4e1a7b3d5f60812a4c6e8b0d2f4a6c8e0b13 introduced the flaky retry.",
478 "const prefix = \"ghp_\";",
479 "STRIPE_KEY=sk_test_51HabcdEFGhijKLMnop7QRSTuv",
480 "GITHUB_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
481 "if pem.starts_with(\"-----BEGIN PRIVATE KEY-----\") {",
482 "\"integrity\": \"sha512-Zq8wN3vR7tY2uI5oP1aS6dF4gH9jK0lXmC3bVn8wQ2xZq8wN3vR7tY2uI5oP1aS6dF==\"",
483 "password = hunter2hunter2",
484 "let task = ASIAN_MARKETS;",
485 "import { AIzaClient } from './maps';",
486 "-----BEGIN PUBLIC KEY-----",
487 "secret = process.env.AWS_SECRET_ACCESS_KEY",
488 "https://hooks.slack.com/services/T000/B000/XXXXXXXXXXXXXXXXXXXXXXXX",
489 ] {
490 assert!(kinds(line).is_empty(), "{line}");
491 }
492 }
493
494 #[test]
495 fn a_private_key_needs_its_body() {
496 let header = join("-----BEGIN RSA PRIVA", "TE KEY-----");
497 let body = "MIIEowIBAAKCAQEAu1SU1LfVLPHCozMxH2Mo4lgOEePzNm0tRgeLezV6ffAt0gun";
498 assert_eq!(kinds_with_next(&header, Some(body)), [SecretKind::PrivateKey]);
499 assert!(kinds_with_next(&header, Some("...")).is_empty());
500 let escaped = format!("key: \"{header}\\n{body}\\n\"");
501 assert_eq!(kinds(&escaped), [SecretKind::PrivateKey]);
502 let hits = scan_text(&format!("x\n{header}\n{body}\n-----END RSA PRIVATE KEY-----\n"));
503 assert_eq!(hits.len(), 1);
504 assert_eq!(hits[0].line, 2);
505 assert!(hits[0].preview().contains("BEGIN RSA"));
506 }
507
508 fn kinds_with_next(line: &str, next: Option<&str>) -> Vec<SecretKind> {
509 scan_line(line, next).into_iter().map(|(kind, _)| kind).collect()
510 }
511
512 #[test]
513 fn a_service_role_jwt_is_found_and_a_user_jwt_is_not() {
514 // {"alg":"HS256","typ":"JWT"} . {"role":"service_role","iss":"supabase"}
515 let service = join(
516 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UifQ",
517 ".dGhpc2lzbm90YXJlYWxzaWduYXR1cmVidXRsb25nZW5vdWdo",
518 );
519 assert_eq!(kinds(&format!("SUPABASE_SERVICE_KEY={service}")), [SecretKind::ServiceJwt]);
520 // {"role":"anon"}
521 let anon = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiJ9.dGhpc2lzbm90YXJlYWxzaWduYXR1cmVidXRsb25nZW5vdWdo";
522 assert!(kinds(anon).is_empty());
523 }
524
525 #[test]
526 fn fingerprints_name_the_secret_not_where_it_is() {
527 let line = join("AK", "IAZ7Q4N2XWLM3KDTRV");
528 let a = scan_lines(&format!("a\n{line}\n"), |_| true);
529 let b = scan_lines(&format!("{line}\n"), |_| true);
530 assert_eq!(a[0].line, 2);
531 assert_eq!(b[0].line, 1);
532 assert_eq!(a[0].fingerprint(), b[0].fingerprint());
533 assert_eq!(a[0].fingerprint().len(), 32);
534 assert!(!a[0].preview().contains(&a[0].value));
535 assert!(a[0].preview().starts_with("AKIA"));
536 }
537
538 #[test]
539 fn only_the_wanted_lines_are_scanned() {
540 let key = join("AK", "IAZ7Q4N2XWLM3KDTRV");
541 let text = format!("{key}\nplain\n{key}\n");
542 let hits = scan_lines(&text, |line| line == 3);
543 assert_eq!(hits.len(), 1);
544 assert_eq!(hits[0].line, 3);
545 }
546
547 #[test]
548 fn kinds_round_trip() {
549 for kind in SecretKind::ALL {
550 assert_eq!(SecretKind::parse(kind.id()), Some(kind));
551 assert!(!kind.label().is_empty());
552 }
553 }
554
555 #[test]
556 fn lockfiles_and_vendored_code_are_skipped() {
557 assert!(skipped_path("web/package-lock.json"));
558 assert!(skipped_path("vendor/github.com/x/y.go"));
559 assert!(skipped_path("node_modules/a/index.js"));
560 assert!(!skipped_path("src/config.ts"));
561 }
562}