| 1 | //! 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 | |
| 11 | use serde::{Deserialize, Serialize}; |
| 12 | |
| 13 | use crate::secrets::SecretKind; |
| 14 | |
| 15 | /// What the issuer said. |
| 16 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] |
| 17 | #[serde(rename_all = "lowercase")] |
| 18 | pub 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 | |
| 30 | impl 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)] |
| 52 | pub 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)] |
| 63 | pub 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. |
| 71 | pub 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. |
| 84 | pub 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. |
| 123 | pub 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)] |
| 146 | mod 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 | } |