Skip to main content

openmoat_core/
token.rs

1//! Well-known token formats with a vendor prefix, and the services they belong to.
2//!
3//! The same shapes as `openmoat_audit::redact::TOKEN_SHAPES`, which masks them
4//! with regular expressions before anything is stored; core has no regex
5//! engine, so each shape is matched here by hand with the same rules (a test
6//! in `openmoat-audit` keeps the two in step). Only prefixed formats: no
7//! entropy guessing, so a commit SHA, a UUID or a base64 blob is never one.
8
9/// One token format: `\b<prefix><body>{min,max}\b` in regex terms.
10#[derive(Debug)]
11pub struct Shape {
12    /// Short id used in atoms and reasons (`github`).
13    pub id: &'static str,
14    /// What the token is, as `openmoat_audit::redact::token_shape` names it.
15    pub description: &'static str,
16    /// Host names (and their subdomains) of the service the token is for.
17    pub hosts: &'static [&'static str],
18    prefixes: &'static [&'static str],
19    /// Optional `[a-z]+-` between prefix and body (`sk-proj-…`).
20    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
48/// Every shape, in the order of `TOKEN_SHAPES`.
49pub 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    // Anthropic (`sk-ant-api03-…`) and OpenAI project, service-account and
81    // admin keys, whose body also holds `-` and `_`.
82    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/// The first shape, in [`SHAPES`] order, that occurs in `text`.
145#[must_use]
146pub fn find(text: &str) -> Option<&'static Shape> {
147    shapes_in(text).next()
148}
149
150/// Every shape that occurs in `text`.
151pub 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    /// Whether `host` is one of the service's hosts or below one.
158    #[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    /// Whether the body, after the optional lowercase label, starts at `at`
180    /// and ends at a word boundary.
181    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
206/// Regex `\b`: a word character on exactly one side of `at`.
207fn 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
212/// A word character as the `regex` crate's Unicode `\b` sees one, near enough:
213/// letters, digits and `_`.
214fn 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;