flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/api/src/alerts.rs

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