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.
| Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily | 1 | //! Security alerts as the API gives them: a secret found in a repository, |
| 2 | //! or a dependency with a known vulnerability, in one flat `snake_case` | |
| 3 | //! shape with `kind` saying which. | |
| 4 | //! | |
| 5 | //! The security service keeps them as `SecretFinding` and `Vulnerability` | |
| 6 | //! (see `g1t_contracts::security`); this is the public form of both. | |
| 7 | ||
| 8 | use g1t_contracts::security::{ | |
| 9 | AlertChange, AlertState, DismissReason, SecretFinding, SecretStatus, SecurityUpdate, UpdateState, | |
| 10 | Vulnerability, | |
| 11 | }; | |
| 12 | use serde::{Deserialize, Serialize}; | |
| 13 | ||
| 14 | /// Which kind of alert. | |
| 15 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 16 | #[serde(rename_all = "snake_case")] | |
| 17 | pub enum AlertKind { | |
| 18 | Secret, | |
| 19 | Dependency, | |
| 20 | } | |
| 21 | ||
| 22 | impl AlertKind { | |
| 23 | pub const ALL: [AlertKind; 2] = [AlertKind::Secret, AlertKind::Dependency]; | |
| 24 | ||
| 25 | pub fn as_str(self) -> &'static str { | |
| 26 | match self { | |
| 27 | AlertKind::Secret => "secret", | |
| 28 | AlertKind::Dependency => "dependency", | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | pub fn parse(text: &str) -> Option<AlertKind> { | |
| 33 | AlertKind::ALL.into_iter().find(|kind| kind.as_str() == text) | |
| 34 | } | |
| 35 | ||
| 36 | /// The kind an alert's id names: `sec_…` or `vul_…`. | |
| 37 | pub fn of_id(id: &str) -> Option<AlertKind> { | |
| 38 | if id.starts_with("sec_") { | |
| 39 | Some(AlertKind::Secret) | |
| 40 | } else if id.starts_with("vul_") { | |
| 41 | Some(AlertKind::Dependency) | |
| 42 | } else { | |
| 43 | None | |
| 44 | } | |
| 45 | } | |
| 46 | ||
| 47 | /// Whether `reason` can dismiss an alert of this kind. | |
| 48 | pub fn takes(self, reason: DismissReason) -> bool { | |
| 49 | reason.for_secrets() == (self == AlertKind::Secret) | |
| 50 | } | |
| 51 | ||
| 52 | /// The reasons that dismiss an alert of this kind, as words. | |
| 53 | pub fn reasons(self) -> Vec<&'static str> { | |
| 54 | DismissReason::ALL | |
| 55 | .into_iter() | |
| 56 | .filter(|reason| self.takes(*reason)) | |
| 57 | .map(DismissReason::as_str) | |
| 58 | .collect() | |
| 59 | } | |
| 60 | } | |
| 61 | ||
| 62 | /// One alert. | |
| 63 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 64 | pub struct SecurityAlert { | |
| 65 | pub kind: AlertKind, | |
| 66 | /// `sec_…` for a secret, `vul_…` for a dependency. | |
| 67 | pub id: String, | |
| 68 | pub state: AlertState, | |
| 69 | #[serde(flatten)] | |
| 70 | pub detail: AlertDetail, | |
| 71 | /// RFC 3339. | |
| 72 | pub found_at: String, | |
| 73 | /// Why it was dismissed (or, for a secret, revoked); null while open. | |
| 74 | pub dismissed_reason: Option<DismissReason>, | |
| 75 | pub dismissed_comment: Option<String>, | |
| 76 | /// Who dismissed it. | |
| 77 | pub dismissed_by: Option<String>, | |
| 78 | /// RFC 3339. | |
| 79 | pub dismissed_at: Option<String>, | |
| 80 | } | |
| 81 | ||
| 82 | /// What only one kind of alert has. | |
| 83 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 84 | #[serde(untagged)] | |
| 85 | pub enum AlertDetail { | |
| 86 | Secret(SecretDetail), | |
| 87 | Dependency(DependencyDetail), | |
| 88 | } | |
| 89 | ||
| 90 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 91 | pub struct SecretDetail { | |
| 92 | /// `aws_access_key`, `github_token`, … | |
| 93 | pub secret_type: String, | |
| 94 | /// "an AWS access key". | |
| 95 | pub label: String, | |
| 96 | pub path: String, | |
| 97 | pub line: u32, | |
| 98 | pub commit: String, | |
| 99 | /// Enough of it to recognise; the secret itself is never kept. | |
| 100 | pub preview: String, | |
| 101 | /// open, blocked, allowed or resolved. | |
| 102 | pub status: SecretStatus, | |
| 103 | /// `push` or `history`. | |
| 104 | pub source: String, | |
| 105 | /// Why the value looks made for tests or documentation, when it does. | |
| 106 | pub test_value: Option<String>, | |
| 107 | /// Who pushed it, for a push. | |
| 108 | pub found_by: Option<String>, | |
| 109 | } | |
| 110 | ||
| 111 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 112 | pub struct DependencyDetail { | |
| 113 | pub ecosystem: String, | |
| 114 | pub package: String, | |
| 115 | pub version: String, | |
| 116 | /// The lockfile that resolves it. | |
| 117 | pub manifest: String, | |
| 118 | pub advisory: String, | |
| 119 | pub osv_id: String, | |
| 120 | pub summary: String, | |
| 121 | pub severity: String, | |
| 122 | /// Null when no patched version is available. | |
| 123 | pub fixed_version: Option<String>, | |
| 124 | pub fixed_at: Option<String>, | |
| 125 | /// The pull request g1t opens to upgrade it, once started. | |
| 126 | pub update: Option<AlertUpdate>, | |
| 127 | } | |
| 128 | ||
| 129 | /// The security update for a dependency's package. | |
| 130 | #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] | |
| 131 | pub struct AlertUpdate { | |
| 132 | pub state: UpdateState, | |
| 133 | pub target: String, | |
| 134 | pub branch: Option<String>, | |
| 135 | pub pull: Option<u32>, | |
| 136 | pub issue: Option<u32>, | |
| 137 | pub error: Option<String>, | |
| 138 | pub updated_at: String, | |
| 139 | } | |
| 140 | ||
| 141 | impl From<SecurityUpdate> for AlertUpdate { | |
| 142 | fn from(update: SecurityUpdate) -> Self { | |
| 143 | AlertUpdate { | |
| 144 | state: update.state, | |
| 145 | target: update.target, | |
| 146 | branch: update.branch, | |
| 147 | pull: update.pull, | |
| 148 | issue: update.issue, | |
| 149 | error: update.error, | |
| 150 | updated_at: update.updated_at, | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | impl From<SecretFinding> for SecurityAlert { | |
| 156 | fn from(secret: SecretFinding) -> Self { | |
| 157 | let decided = secret.state != AlertState::Open; | |
| 158 | SecurityAlert { | |
| 159 | kind: AlertKind::Secret, | |
| 160 | id: secret.id, | |
| 161 | state: secret.state, | |
| 162 | detail: AlertDetail::Secret(SecretDetail { | |
| 163 | secret_type: secret.kind, | |
| 164 | label: secret.label, | |
| 165 | path: secret.path, | |
| 166 | line: secret.line, | |
| 167 | commit: secret.commit, | |
| 168 | preview: secret.preview, | |
| 169 | status: secret.status, | |
| 170 | source: secret.source, | |
| 171 | test_value: secret.test_value, | |
| 172 | found_by: secret.found_by, | |
| 173 | }), | |
| 174 | found_at: secret.found_at, | |
| 175 | dismissed_reason: secret.dismissed_reason.filter(|_| decided), | |
| 176 | dismissed_comment: secret.reason.filter(|_| decided), | |
| 177 | dismissed_by: secret.decided_by.filter(|_| decided), | |
| 178 | dismissed_at: secret.decided_at.filter(|_| decided), | |
| 179 | } | |
| 180 | } | |
| 181 | } | |
| 182 | ||
| 183 | impl From<Vulnerability> for SecurityAlert { | |
| 184 | fn from(vulnerability: Vulnerability) -> Self { | |
| 185 | let dismissed = vulnerability.state == AlertState::Dismissed; | |
| 186 | SecurityAlert { | |
| 187 | kind: AlertKind::Dependency, | |
| 188 | id: vulnerability.id, | |
| 189 | state: vulnerability.state, | |
| 190 | detail: AlertDetail::Dependency(DependencyDetail { | |
| 191 | ecosystem: vulnerability.ecosystem, | |
| 192 | package: vulnerability.package, | |
| 193 | version: vulnerability.version, | |
| 194 | manifest: vulnerability.manifest, | |
| 195 | advisory: vulnerability.advisory, | |
| 196 | osv_id: vulnerability.osv_id, | |
| 197 | summary: vulnerability.summary, | |
| 198 | severity: vulnerability.severity, | |
| 199 | fixed_version: vulnerability.fixed_version, | |
| 200 | fixed_at: vulnerability.fixed_at, | |
| 201 | update: vulnerability.update.map(AlertUpdate::from), | |
| 202 | }), | |
| 203 | found_at: vulnerability.found_at, | |
| 204 | dismissed_reason: vulnerability.dismissed_reason.filter(|_| dismissed), | |
| 205 | dismissed_comment: vulnerability.dismissed_comment.filter(|_| dismissed), | |
| 206 | dismissed_by: vulnerability.dismissed_by.filter(|_| dismissed), | |
| 207 | dismissed_at: vulnerability.dismissed_at.filter(|_| dismissed), | |
| 208 | } | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | impl SecurityAlert { | |
| 213 | /// The alert `dismiss` or `reopen` changed, if it names one. | |
| 214 | pub fn from_change(change: AlertChange) -> Option<SecurityAlert> { | |
| 215 | change | |
| 216 | .secret | |
| 217 | .map(SecurityAlert::from) | |
| 218 | .or_else(|| change.vulnerability.map(SecurityAlert::from)) | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | /// Secrets, then dependencies, filtered by state and kind when given. | |
| 223 | pub fn list( | |
| 224 | secrets: Vec<SecretFinding>, | |
| 225 | vulnerabilities: Vec<Vulnerability>, | |
| 226 | state: Option<AlertState>, | |
| 227 | kind: Option<AlertKind>, | |
| 228 | ) -> Vec<SecurityAlert> { | |
| 229 | let secrets = secrets.into_iter().map(SecurityAlert::from); | |
| 230 | let dependencies = vulnerabilities.into_iter().map(SecurityAlert::from); | |
| 231 | secrets | |
| 232 | .chain(dependencies) | |
| 233 | .filter(|alert| state.is_none_or(|state| alert.state == state)) | |
| 234 | .filter(|alert| kind.is_none_or(|kind| alert.kind == kind)) | |
| 235 | .collect() | |
| 236 | } | |
| 237 | ||
| 238 | #[cfg(test)] | |
| 239 | mod tests { | |
| 240 | use super::*; | |
| 241 | use serde_json::{Value, json}; | |
| 242 | ||
| 243 | fn secret(state: AlertState) -> SecretFinding { | |
| 244 | serde_json::from_value(json!({ | |
| 245 | "id": "sec_1", | |
| 246 | "repoId": "rep_1", | |
| 247 | "kind": "aws_access_key", | |
| 248 | "label": "an AWS access key", | |
| 249 | "path": "config/dev.env", | |
| 250 | "line": 3, | |
| 251 | "commit": "9f2c1e0", | |
| 252 | "preview": "AKIA…MPLE", | |
| 253 | "status": if state == AlertState::Open { "open" } else { "allowed" }, | |
| 254 | "source": "history", | |
| 255 | "foundBy": null, | |
| 256 | "foundAt": "2026-10-01T12:00:00Z", | |
| 257 | "decidedBy": "ada", | |
| 258 | "reason": "Only in the test fixtures.", | |
| 259 | "decidedAt": "2026-10-02T09:00:00Z", | |
| 260 | "dismissedReason": "used_in_tests", | |
| 261 | "testValue": "a documented example key", | |
| 262 | "state": state, | |
| 263 | })) | |
| 264 | .unwrap() | |
| 265 | } | |
| 266 | ||
| 267 | fn vulnerability() -> Vulnerability { | |
| 268 | serde_json::from_value(json!({ | |
| 269 | "id": "vul_1", | |
| 270 | "repoId": "rep_1", | |
| 271 | "ecosystem": "npm", | |
| 272 | "package": "lodash", | |
| 273 | "version": "4.17.20", | |
| 274 | "manifest": "package-lock.json", | |
| 275 | "advisory": "GHSA-35jh-r3h4-6jhm", | |
| 276 | "osvId": "GHSA-35jh-r3h4-6jhm", | |
| 277 | "summary": "Command injection in lodash", | |
| 278 | "severity": "high", | |
| 279 | "fixedVersion": null, | |
| 280 | "status": "open", | |
| 281 | "issue": null, | |
| 282 | "foundAt": "2026-10-01T12:00:00Z", | |
| 283 | "fixedAt": null, | |
| 284 | "state": "open", | |
| 285 | "update": { "state": "open", "target": "4.17.21", "branch": "g1t/security/lodash-4.17.21", "pull": 12, "issue": null, "error": null, "updatedAt": "2026-10-01T12:05:00Z" }, | |
| 286 | })) | |
| 287 | .unwrap() | |
| 288 | } | |
| 289 | ||
| 290 | fn keys(value: &Value, out: &mut Vec<String>) { | |
| 291 | match value { | |
| 292 | Value::Object(fields) => { | |
| 293 | for (key, value) in fields { | |
| 294 | out.push(key.clone()); | |
| 295 | keys(value, out); | |
| 296 | } | |
| 297 | } | |
| 298 | Value::Array(items) => items.iter().for_each(|item| keys(item, out)), | |
| 299 | _ => {} | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | #[test] | |
| 304 | fn an_alert_is_snake_case_in_one_flat_shape() { | |
| 305 | let alerts = list(vec![secret(AlertState::Dismissed)], vec![vulnerability()], None, None); | |
| 306 | let sent = serde_json::to_value(&alerts).unwrap(); | |
| 307 | assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty(), "{sent}"); | |
| 308 | let mut names = Vec::new(); | |
| 309 | keys(&sent, &mut names); | |
| 310 | for name in names { | |
| 311 | assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}"); | |
| 312 | } | |
| 313 | let (secret, dependency) = (&sent[0], &sent[1]); | |
| 314 | assert_eq!(secret["kind"], "secret"); | |
| 315 | assert_eq!(secret["secret_type"], "aws_access_key"); | |
| 316 | assert_eq!(secret["dismissed_reason"], "used_in_tests"); | |
| 317 | assert_eq!(secret["dismissed_comment"], "Only in the test fixtures."); | |
| 318 | assert_eq!(secret["dismissed_by"], "ada"); | |
| 319 | assert!(secret.get("package").is_none()); | |
| 320 | assert_eq!(dependency["kind"], "dependency"); | |
| 321 | // No patched version is a null, not a missing field. | |
| 322 | assert!(dependency["fixed_version"].is_null() && dependency.get("fixed_version").is_some()); | |
| 323 | assert_eq!(dependency["update"]["updated_at"], "2026-10-01T12:05:00Z"); | |
| 324 | assert!(dependency["dismissed_reason"].is_null()); | |
| 325 | assert!(dependency.get("secret_type").is_none()); | |
| 326 | // And it reads back as it was. | |
| 327 | let again: Vec<SecurityAlert> = serde_json::from_value(sent).unwrap(); | |
| 328 | assert_eq!(again, alerts); | |
| 329 | } | |
| 330 | ||
| 331 | #[test] | |
| 332 | fn an_open_secret_shows_no_decision() { | |
| 333 | let alert = SecurityAlert::from(secret(AlertState::Open)); | |
| 334 | assert_eq!(alert.dismissed_reason, None); | |
| 335 | assert_eq!(alert.dismissed_by, None); | |
| 336 | assert_eq!(alert.dismissed_comment, None); | |
| 337 | } | |
| 338 | ||
| 339 | #[test] | |
| 340 | fn alerts_filter_by_state_and_kind() { | |
| 341 | let all = || (vec![secret(AlertState::Dismissed)], vec![vulnerability()]); | |
| 342 | let (s, v) = all(); | |
| 343 | assert_eq!(list(s, v, Some(AlertState::Open), None).len(), 1); | |
| 344 | let (s, v) = all(); | |
| 345 | let secrets = list(s, v, None, Some(AlertKind::Secret)); | |
| 346 | assert_eq!(secrets.len(), 1); | |
| 347 | assert_eq!(secrets[0].kind, AlertKind::Secret); | |
| 348 | let (s, v) = all(); | |
| 349 | assert!(list(s, v, Some(AlertState::Fixed), None).is_empty()); | |
| 350 | } | |
| 351 | ||
| 352 | #[test] | |
| 353 | fn each_kind_takes_its_own_reasons() { | |
| 354 | assert_eq!(AlertKind::Secret.reasons(), ["false_positive", "used_in_tests", "revoked", "wont_fix"]); | |
| 355 | assert_eq!( | |
| 356 | AlertKind::Dependency.reasons(), | |
| 357 | ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"] | |
| 358 | ); | |
| 359 | assert_eq!(AlertKind::of_id("sec_9"), Some(AlertKind::Secret)); | |
| 360 | assert_eq!(AlertKind::of_id("vul_9"), Some(AlertKind::Dependency)); | |
| 361 | assert_eq!(AlertKind::of_id("x"), None); | |
| 362 | } | |
| 363 | } |