1#[derive(Debug)]
11pub struct Shape {
12 pub id: &'static str,
14 pub description: &'static str,
16 pub hosts: &'static [&'static str],
18 prefixes: &'static [&'static str],
19 lower_label: bool,
21 body: fn(char) -> bool,
22 min: usize,
23 max: usize,
24}
25
26fn alnum(c: char) -> bool {
27 c.is_ascii_alphanumeric()
28}
29
30fn alnum_underscore(c: char) -> bool {
31 c.is_ascii_alphanumeric() || c == '_'
32}
33
34fn alnum_dash(c: char) -> bool {
35 c.is_ascii_alphanumeric() || c == '-'
36}
37
38fn alnum_underscore_dash(c: char) -> bool {
39 c.is_ascii_alphanumeric() || c == '_' || c == '-'
40}
41
42fn upper_digit(c: char) -> bool {
43 c.is_ascii_uppercase() || c.is_ascii_digit()
44}
45
46const GITHUB_HOSTS: &[&str] = &["github.com", "githubusercontent.com", "ghcr.io"];
47
48pub const SHAPES: &[Shape] = &[
50 Shape {
51 id: "github",
52 description: "a GitHub token",
53 hosts: GITHUB_HOSTS,
54 prefixes: &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"],
55 lower_label: false,
56 body: alnum,
57 min: 20,
58 max: usize::MAX,
59 },
60 Shape {
61 id: "github",
62 description: "a GitHub token",
63 hosts: GITHUB_HOSTS,
64 prefixes: &["github_pat_"],
65 lower_label: false,
66 body: alnum_underscore,
67 min: 20,
68 max: usize::MAX,
69 },
70 Shape {
71 id: "openai-or-anthropic",
72 description: "an OpenAI or Anthropic key",
73 hosts: &["openai.com", "anthropic.com"],
74 prefixes: &["sk-"],
75 lower_label: true,
76 body: alnum,
77 min: 16,
78 max: usize::MAX,
79 },
80 Shape {
83 id: "openai-or-anthropic",
84 description: "an OpenAI or Anthropic key",
85 hosts: &["openai.com", "anthropic.com"],
86 prefixes: &["sk-ant-", "sk-proj-", "sk-svcacct-", "sk-admin-"],
87 lower_label: false,
88 body: alnum_underscore_dash,
89 min: 16,
90 max: usize::MAX,
91 },
92 Shape {
93 id: "aws",
94 description: "an AWS access key id",
95 hosts: &["amazonaws.com", "amazonaws.com.cn", "aws.amazon.com"],
96 prefixes: &["AKIA"],
97 lower_label: false,
98 body: upper_digit,
99 min: 16,
100 max: 16,
101 },
102 Shape {
103 id: "slack",
104 description: "a Slack token",
105 hosts: &["slack.com"],
106 prefixes: &["xoxa-", "xoxb-", "xoxp-", "xoxr-", "xoxs-"],
107 lower_label: false,
108 body: alnum_dash,
109 min: 10,
110 max: usize::MAX,
111 },
112 Shape {
113 id: "google",
114 description: "a Google API key",
115 hosts: &["googleapis.com", "google.com"],
116 prefixes: &["AIza"],
117 lower_label: false,
118 body: alnum_underscore_dash,
119 min: 30,
120 max: usize::MAX,
121 },
122 Shape {
123 id: "npm",
124 description: "an npm token",
125 hosts: &["npmjs.org", "npmjs.com"],
126 prefixes: &["npm_"],
127 lower_label: false,
128 body: alnum,
129 min: 30,
130 max: usize::MAX,
131 },
132 Shape {
133 id: "gitlab",
134 description: "a GitLab token",
135 hosts: &["gitlab.com"],
136 prefixes: &["glpat-"],
137 lower_label: false,
138 body: alnum_underscore_dash,
139 min: 20,
140 max: usize::MAX,
141 },
142];
143
144#[must_use]
146pub fn find(text: &str) -> Option<&'static Shape> {
147 shapes_in(text).next()
148}
149
150pub fn shapes_in(text: &str) -> impl Iterator<Item = &'static Shape> {
152 let chars: Vec<char> = text.chars().collect();
153 SHAPES.iter().filter(move |shape| shape.occurs_in(&chars))
154}
155
156impl Shape {
157 #[must_use]
159 pub fn owns(&self, host: &str) -> bool {
160 let host = host.trim_end_matches('.').to_ascii_lowercase();
161 self.hosts.iter().any(|own| {
162 host == *own
163 || host
164 .strip_suffix(own)
165 .is_some_and(|rest| rest.ends_with('.'))
166 })
167 }
168
169 fn occurs_in(&self, chars: &[char]) -> bool {
170 (0..chars.len()).any(|start| {
171 (start == 0 || !is_word(chars[start - 1]))
172 && self.prefixes.iter().any(|prefix| {
173 starts_with(&chars[start..], prefix)
174 && self.body_at(chars, start + prefix.chars().count())
175 })
176 })
177 }
178
179 fn body_at(&self, chars: &[char], at: usize) -> bool {
182 let label_end = chars[at..]
183 .iter()
184 .take_while(|c| c.is_ascii_lowercase())
185 .count()
186 + at;
187 let labelled = (self.lower_label && label_end > at && chars.get(label_end) == Some(&'-'))
188 .then_some(label_end + 1);
189 labelled
190 .into_iter()
191 .chain(std::iter::once(at))
192 .any(|start| self.run_at(chars, start))
193 }
194
195 fn run_at(&self, chars: &[char], start: usize) -> bool {
196 let run = chars
197 .get(start..)
198 .unwrap_or_default()
199 .iter()
200 .take_while(|c| (self.body)(**c))
201 .count();
202 (self.min..=run.min(self.max)).any(|len| boundary(chars, start + len))
203 }
204}
205
206fn boundary(chars: &[char], at: usize) -> bool {
208 let before = at.checked_sub(1).and_then(|i| chars.get(i)).copied();
209 before.is_some_and(is_word) != chars.get(at).copied().is_some_and(is_word)
210}
211
212fn is_word(c: char) -> bool {
215 c.is_alphanumeric() || c == '_'
216}
217
218fn starts_with(chars: &[char], prefix: &str) -> bool {
219 let mut rest = chars.iter();
220 prefix.chars().all(|p| rest.next() == Some(&p))
221}
222
223#[cfg(test)]
224mod tests;