Skip to main content

riff_core/
notes.rs

1//! The checks of the notes of the lead (01M59258K3TYR09HC4AWHWZPEX).
2//!
3//! The notes of the lead are text that riff-server keeps for each user and
4//! repository. The server and the client use the same checks:
5//! [`check`] refuses a text over [`NOTES_MAX`] bytes
6//! (01M59258K3TYR09HC4AWHWZPEX), and a text that looks like a token
7//! (01M59258RJ3CG498Z8AVQ5TV5Q).
8//!
9//! ```
10//! use riff_core::notes::{NOTES_MAX, check};
11//!
12//! assert!(check("Prefer thelio for builds. Commit 3f2a9c1d.").is_ok());
13//! assert!(check(&"a ".repeat(NOTES_MAX)).unwrap_err().contains("64 KiB"));
14//! assert!(check("the token is sk-ant-oat01-abcdefghijklmnop").unwrap_err().contains("secret"));
15//! ```
16
17/// The most bytes of the notes of one lead: 64 KiB.
18pub const NOTES_MAX: usize = 64 * 1024;
19
20/// The start of a word that is a token, with the least length of the
21/// word.
22const PREFIXES: [(&str, usize); 11] = [
23    ("sk-ant-", 16),
24    ("sk-", 24),
25    ("ghp_", 20),
26    ("gho_", 20),
27    ("ghu_", 20),
28    ("ghs_", 20),
29    ("ghr_", 20),
30    ("github_pat_", 24),
31    ("glpat-", 20),
32    ("xox", 20),
33    ("AKIA", 20),
34];
35
36/// The least length of a run of letters and digits, with both, that
37/// counts as a token.
38const RUN: usize = 32;
39
40/// Says if `text` can be the notes of a lead: `Err` has the reason.
41///
42/// ```
43/// use riff_core::notes::check;
44///
45/// assert!(check("").is_ok());
46/// // A branch name, a path and a requirement ID are no token.
47/// assert!(check("branch claude-token-keyring-instead-of-a-file-1166").is_ok());
48/// assert!(check("see crates/riff/src/lead.rs and 01M59258K3TYR09HC4AWHWZPEX").is_ok());
49/// // A hash is hex.
50/// assert!(check(&format!("sha {}", "ab12".repeat(16))).is_ok());
51/// // A token.
52/// assert!(check("GH_TOKEN=ghp_abcdefghijklmnopqrstuvwxyz0123456789").is_err());
53/// assert!(check("key abcdefghijklmnopqrstuvwxyz0123456789ABCD").is_err());
54/// assert!(check("-----BEGIN OPENSSH PRIVATE KEY-----").is_err());
55/// ```
56pub fn check(text: &str) -> Result<(), String> {
57    if text.len() > NOTES_MAX {
58        return Err(format!(
59            "the notes have {} bytes, and the limit is {NOTES_MAX} bytes (64 KiB). Make the text shorter.",
60            text.len()
61        ));
62    }
63    if looks_like_secret(text) {
64        return Err(
65            "the text looks like it has a secret (a token or a key). Take it out: riff never \
66             keeps a secret in the notes."
67                .to_owned(),
68        );
69    }
70    Ok(())
71}
72
73fn looks_like_secret(text: &str) -> bool {
74    if text.contains("PRIVATE KEY-----") {
75        return true;
76    }
77    let words = text.split(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'));
78    let prefixed = words.clone().any(|word| {
79        PREFIXES
80            .iter()
81            .any(|(prefix, least)| word.starts_with(prefix) && word.len() >= *least)
82    });
83    prefixed
84        || text
85            .split(|c: char| !c.is_ascii_alphanumeric())
86            .any(is_run_of_a_token)
87}
88
89fn is_run_of_a_token(run: &str) -> bool {
90    run.len() >= RUN
91        && run.bytes().any(|b| b.is_ascii_digit())
92        && run.bytes().any(|b| b.is_ascii_alphabetic())
93        && !run.bytes().all(|b| b.is_ascii_hexdigit())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn the_limit_is_in_bytes_and_a_text_at_the_limit_is_kept() {
102        assert!(check(&"a".repeat(NOTES_MAX)).is_ok());
103        let over = check(&"a".repeat(NOTES_MAX + 1)).unwrap_err();
104        assert!(over.contains("65536"), "{over}");
105    }
106
107    #[test]
108    fn a_refusal_does_not_repeat_the_secret() {
109        let secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789";
110        let refused = check(&format!("token {secret}")).unwrap_err();
111        assert!(!refused.contains("ghp_"), "{refused}");
112    }
113
114    #[test]
115    fn each_prefix_of_a_token_is_found() {
116        for token in [
117            "sk-ant-oat01-abcdefghijkl",
118            "github_pat_11ABCDEFG0abcdefghijkl",
119            "glpat-abcdefghijklmnopqrstu",
120            "xoxb-123456789012-abcdefgh",
121            "AKIAIOSFODNN7EXAMPLE",
122        ] {
123            assert!(check(&format!("x {token} y")).is_err(), "{token}");
124        }
125        assert!(check("the sk-learn skill and the ghp_ prefix").is_ok());
126    }
127}