Skip to content

g1t/crates/scan/src/validity.rs

176 lines7,603 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.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! Validity checks: asking a secret's issuer whether it still works, so an
2//! alert can say active or inactive.
3//!
4//! A check is made only where the issuer has an endpoint that answers
5//! "who is this?" without changing anything, and only over HTTPS to the
6//! issuer's own API: the secret goes nowhere it was not already meant to
7//! go. Formats with no such endpoint, or whose check needs more than the
8//! value found (an AWS access key needs its secret key to sign a request),
9//! are [`Validity::Unsupported`]; [`check_for`] is where a new one is added.
10
11use serde::{Deserialize, Serialize};
12
13use crate::secrets::SecretKind;
14
15/// What the issuer said.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "lowercase")]
18pub enum Validity {
19 /// The issuer accepted it: it works and must be rotated.
20 Active,
21 /// The issuer refused it: revoked, expired or never real.
22 Inactive,
23 /// The issuer could not say (an error, a rate limit), or it was not
24 /// checked.
25 Unknown,
26 /// There is no safe way to check this kind of secret.
27 Unsupported,
28}
29
30impl Validity {
31 pub fn as_str(self) -> &'static str {
32 match self {
33 Validity::Active => "active",
34 Validity::Inactive => "inactive",
35 Validity::Unknown => "unknown",
36 Validity::Unsupported => "unsupported",
37 }
38 }
39
40 pub fn parse(text: &str) -> Validity {
41 match text {
42 "active" => Validity::Active,
43 "inactive" => Validity::Inactive,
44 "unsupported" => Validity::Unsupported,
45 _ => Validity::Unknown,
46 }
47 }
48}
49
50/// A request that asks the issuer about a secret.
51#[derive(Clone, Debug, PartialEq, Eq)]
52pub struct Probe {
53 pub method: &'static str,
54 pub url: &'static str,
55 pub headers: Vec<(&'static str, String)>,
56 pub body: Option<String>,
57 /// How to read the answer.
58 pub reader: Reader,
59}
60
61/// How an issuer's answer says active or inactive.
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
63pub enum Reader {
64 /// 2xx is active; 401 (and 403, for these issuers a refused key) inactive.
65 Status,
66 /// Slack answers 200 either way, with `"ok": true` or an error code.
67 SlackOk,
68}
69
70/// The kinds a check exists for, as the docs list them.
71pub const SUPPORTED: [SecretKind; 8] = [
72 SecretKind::GithubToken,
73 SecretKind::GitlabToken,
74 SecretKind::StripeLiveKey,
75 SecretKind::SlackToken,
76 SecretKind::NpmToken,
77 SecretKind::OpenaiKey,
78 SecretKind::AnthropicKey,
79 SecretKind::SendgridKey,
80];
81
82/// The request that checks `value`, a secret of `kind`, or `None` when
83/// there is no safe check for it.
84pub fn check_for(kind: SecretKind, value: &str) -> Option<Probe> {
85 let bearer = || vec![("authorization", format!("Bearer {value}"))];
86 let status = |method, url, headers| Some(Probe { method, url, headers, body: None, reader: Reader::Status });
87 match kind {
88 // Who the token belongs to; any token, any scope, can ask.
89 SecretKind::GithubToken => status("GET", "https://api.github.com/user", {
90 let mut headers = bearer();
91 headers.push(("accept", "application/vnd.github+json".to_owned()));
92 headers
93 }),
94 SecretKind::GitlabToken if value.starts_with("glpat-") => {
95 status("GET", "https://gitlab.com/api/v4/personal_access_tokens/self", vec![("private-token", value.to_owned())])
96 }
97 // The account's balance: read-only, and readable by restricted keys
98 // that are allowed to (a restricted key without it answers 403,
99 // which still means the key exists).
100 SecretKind::StripeLiveKey => status("GET", "https://api.stripe.com/v1/balance", bearer()),
101 SecretKind::SlackToken => Some(Probe {
102 method: "POST",
103 url: "https://slack.com/api/auth.test",
104 headers: bearer(),
105 body: None,
106 reader: Reader::SlackOk,
107 }),
108 SecretKind::NpmToken => status("GET", "https://registry.npmjs.org/-/whoami", bearer()),
109 SecretKind::OpenaiKey => status("GET", "https://api.openai.com/v1/models", bearer()),
110 SecretKind::AnthropicKey => status("GET", "https://api.anthropic.com/v1/models", vec![
111 ("x-api-key", value.to_owned()),
112 ("anthropic-version", "2023-06-01".to_owned()),
113 ]),
114 SecretKind::SendgridKey => status("GET", "https://api.sendgrid.com/v3/scopes", bearer()),
115 // An access key alone cannot sign a request; its secret key is
116 // usually elsewhere. Webhook addresses would post a message. The
117 // rest have no read-only identity endpoint.
118 _ => None,
119 }
120}
121
122/// What the issuer's answer means.
123pub fn read(reader: Reader, kind: SecretKind, status: u16, body: &str) -> Validity {
124 match reader {
125 Reader::Status => match status {
126 200..=299 => Validity::Active,
127 // Stripe: a restricted key without balance access is real.
128 403 if kind == SecretKind::StripeLiveKey => Validity::Active,
129 401 | 403 => Validity::Inactive,
130 _ => Validity::Unknown,
131 },
132 Reader::SlackOk => {
133 let answer: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
134 match (status, answer["ok"].as_bool(), answer["error"].as_str()) {
135 (200, Some(true), _) => Validity::Active,
136 (200, Some(false), Some("invalid_auth" | "token_revoked" | "account_inactive" | "token_expired" | "not_authed")) => {
137 Validity::Inactive
138 }
139 _ => Validity::Unknown,
140 }
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn checks_go_to_the_issuers_own_api_over_https() {
151 for kind in SUPPORTED {
152 let probe = check_for(kind, "glpat-value").unwrap_or_else(|| panic!("{kind:?} has a check"));
153 assert!(probe.url.starts_with("https://"), "{}", probe.url);
154 }
155 let github = check_for(SecretKind::GithubToken, "ghp_x").unwrap();
156 assert_eq!((github.method, github.url), ("GET", "https://api.github.com/user"));
157 assert!(github.headers.contains(&("authorization", "Bearer ghp_x".to_owned())));
158 // No check that could not be made safely.
159 assert!(check_for(SecretKind::AwsAccessKey, "AKIA…").is_none());
160 assert!(check_for(SecretKind::SlackWebhook, "https://hooks.slack.com/…").is_none());
161 assert!(check_for(SecretKind::PrivateKey, "-----BEGIN").is_none());
162 assert!(check_for(SecretKind::GitlabToken, "glrt-runner").is_none());
163 }
164
165 #[test]
166 fn answers_are_read_as_active_inactive_or_unknown() {
167 assert_eq!(read(Reader::Status, SecretKind::GithubToken, 200, ""), Validity::Active);
168 assert_eq!(read(Reader::Status, SecretKind::GithubToken, 401, ""), Validity::Inactive);
169 assert_eq!(read(Reader::Status, SecretKind::StripeLiveKey, 403, ""), Validity::Active);
170 assert_eq!(read(Reader::Status, SecretKind::OpenaiKey, 429, ""), Validity::Unknown);
171 assert_eq!(read(Reader::SlackOk, SecretKind::SlackToken, 200, r#"{"ok":true,"team":"x"}"#), Validity::Active);
172 assert_eq!(read(Reader::SlackOk, SecretKind::SlackToken, 200, r#"{"ok":false,"error":"token_revoked"}"#), Validity::Inactive);
173 assert_eq!(read(Reader::SlackOk, SecretKind::SlackToken, 200, r#"{"ok":false,"error":"ratelimited"}"#), Validity::Unknown);
174 assert_eq!(Validity::parse(Validity::Inactive.as_str()), Validity::Inactive);
175 }
176}