g1t/apps/api/src/alerts.rs

363 lines12,991 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.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1//! 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
8use g1t_contracts::security::{
9 AlertChange, AlertState, DismissReason, SecretFinding, SecretStatus, SecurityUpdate, UpdateState,
10 Vulnerability,
11};
12use serde::{Deserialize, Serialize};
13
14/// Which kind of alert.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum AlertKind {
18 Secret,
19 Dependency,
20}
21
22impl 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)]
64pub 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)]
85pub enum AlertDetail {
86 Secret(SecretDetail),
87 Dependency(DependencyDetail),
88}
89
90#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
91pub 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)]
112pub 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)]
131pub 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
141impl 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
155impl 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
183impl 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
212impl 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.
223pub 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)]
239mod 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}