| 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 | } |