Skip to content

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

364 linesCodeBlameRaw
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 /// The work item opened for it, by number in the workspace.
137 pub item: Option<u32>,
138 pub error: Option<String>,
139 pub updated_at: String,
140}
141
142impl From<SecurityUpdate> for AlertUpdate {
143 fn from(update: SecurityUpdate) -> Self {
144 AlertUpdate {
145 state: update.state,
146 target: update.target,
147 branch: update.branch,
148 pull: update.pull,
149 item: update.item,
150 error: update.error,
151 updated_at: update.updated_at,
152 }
153 }
154}
155
156impl From<SecretFinding> for SecurityAlert {
157 fn from(secret: SecretFinding) -> Self {
158 let decided = secret.state != AlertState::Open;
159 SecurityAlert {
160 kind: AlertKind::Secret,
161 id: secret.id,
162 state: secret.state,
163 detail: AlertDetail::Secret(SecretDetail {
164 secret_type: secret.kind,
165 label: secret.label,
166 path: secret.path,
167 line: secret.line,
168 commit: secret.commit,
169 preview: secret.preview,
170 status: secret.status,
171 source: secret.source,
172 test_value: secret.test_value,
173 found_by: secret.found_by,
174 }),
175 found_at: secret.found_at,
176 dismissed_reason: secret.dismissed_reason.filter(|_| decided),
177 dismissed_comment: secret.reason.filter(|_| decided),
178 dismissed_by: secret.decided_by.filter(|_| decided),
179 dismissed_at: secret.decided_at.filter(|_| decided),
180 }
181 }
182}
183
184impl From<Vulnerability> for SecurityAlert {
185 fn from(vulnerability: Vulnerability) -> Self {
186 let dismissed = vulnerability.state == AlertState::Dismissed;
187 SecurityAlert {
188 kind: AlertKind::Dependency,
189 id: vulnerability.id,
190 state: vulnerability.state,
191 detail: AlertDetail::Dependency(DependencyDetail {
192 ecosystem: vulnerability.ecosystem,
193 package: vulnerability.package,
194 version: vulnerability.version,
195 manifest: vulnerability.manifest,
196 advisory: vulnerability.advisory,
197 osv_id: vulnerability.osv_id,
198 summary: vulnerability.summary,
199 severity: vulnerability.severity,
200 fixed_version: vulnerability.fixed_version,
201 fixed_at: vulnerability.fixed_at,
202 update: vulnerability.update.map(AlertUpdate::from),
203 }),
204 found_at: vulnerability.found_at,
205 dismissed_reason: vulnerability.dismissed_reason.filter(|_| dismissed),
206 dismissed_comment: vulnerability.dismissed_comment.filter(|_| dismissed),
207 dismissed_by: vulnerability.dismissed_by.filter(|_| dismissed),
208 dismissed_at: vulnerability.dismissed_at.filter(|_| dismissed),
209 }
210 }
211}
212
213impl SecurityAlert {
214 /// The alert `dismiss` or `reopen` changed, if it names one.
215 pub fn from_change(change: AlertChange) -> Option<SecurityAlert> {
216 change
217 .secret
218 .map(SecurityAlert::from)
219 .or_else(|| change.vulnerability.map(SecurityAlert::from))
220 }
221}
222
223/// Secrets, then dependencies, filtered by state and kind when given.
224pub fn list(
225 secrets: Vec<SecretFinding>,
226 vulnerabilities: Vec<Vulnerability>,
227 state: Option<AlertState>,
228 kind: Option<AlertKind>,
229) -> Vec<SecurityAlert> {
230 let secrets = secrets.into_iter().map(SecurityAlert::from);
231 let dependencies = vulnerabilities.into_iter().map(SecurityAlert::from);
232 secrets
233 .chain(dependencies)
234 .filter(|alert| state.is_none_or(|state| alert.state == state))
235 .filter(|alert| kind.is_none_or(|kind| alert.kind == kind))
236 .collect()
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use serde_json::{Value, json};
243
244 fn secret(state: AlertState) -> SecretFinding {
245 serde_json::from_value(json!({
246 "id": "sec_1",
247 "repoId": "rep_1",
248 "kind": "aws_access_key",
249 "label": "an AWS access key",
250 "path": "config/dev.env",
251 "line": 3,
252 "commit": "9f2c1e0",
253 "preview": "AKIA…MPLE",
254 "status": if state == AlertState::Open { "open" } else { "allowed" },
255 "source": "history",
256 "foundBy": null,
257 "foundAt": "2026-10-01T12:00:00Z",
258 "decidedBy": "ada",
259 "reason": "Only in the test fixtures.",
260 "decidedAt": "2026-10-02T09:00:00Z",
261 "dismissedReason": "used_in_tests",
262 "testValue": "a documented example key",
263 "state": state,
264 }))
265 .unwrap()
266 }
267
268 fn vulnerability() -> Vulnerability {
269 serde_json::from_value(json!({
270 "id": "vul_1",
271 "repoId": "rep_1",
272 "ecosystem": "npm",
273 "package": "lodash",
274 "version": "4.17.20",
275 "manifest": "package-lock.json",
276 "advisory": "GHSA-35jh-r3h4-6jhm",
277 "osvId": "GHSA-35jh-r3h4-6jhm",
278 "summary": "Command injection in lodash",
279 "severity": "high",
280 "fixedVersion": null,
281 "status": "open",
282 "item": null,
283 "foundAt": "2026-10-01T12:00:00Z",
284 "fixedAt": null,
285 "state": "open",
286 "update": { "state": "open", "target": "4.17.21", "branch": "g1t/security/lodash-4.17.21", "pull": 12, "item": null, "error": null, "updatedAt": "2026-10-01T12:05:00Z" },
287 }))
288 .unwrap()
289 }
290
291 fn keys(value: &Value, out: &mut Vec<String>) {
292 match value {
293 Value::Object(fields) => {
294 for (key, value) in fields {
295 out.push(key.clone());
296 keys(value, out);
297 }
298 }
299 Value::Array(items) => items.iter().for_each(|item| keys(item, out)),
300 _ => {}
301 }
302 }
303
304 #[test]
305 fn an_alert_is_snake_case_in_one_flat_shape() {
306 let alerts = list(vec![secret(AlertState::Dismissed)], vec![vulnerability()], None, None);
307 let sent = serde_json::to_value(&alerts).unwrap();
308 assert!(g1t_kit::wire::camel_case_keys(&sent).is_empty(), "{sent}");
309 let mut names = Vec::new();
310 keys(&sent, &mut names);
311 for name in names {
312 assert!(name.chars().all(|c| c.is_ascii_lowercase() || c == '_'), "{name}");
313 }
314 let (secret, dependency) = (&sent[0], &sent[1]);
315 assert_eq!(secret["kind"], "secret");
316 assert_eq!(secret["secret_type"], "aws_access_key");
317 assert_eq!(secret["dismissed_reason"], "used_in_tests");
318 assert_eq!(secret["dismissed_comment"], "Only in the test fixtures.");
319 assert_eq!(secret["dismissed_by"], "ada");
320 assert!(secret.get("package").is_none());
321 assert_eq!(dependency["kind"], "dependency");
322 // No patched version is a null, not a missing field.
323 assert!(dependency["fixed_version"].is_null() && dependency.get("fixed_version").is_some());
324 assert_eq!(dependency["update"]["updated_at"], "2026-10-01T12:05:00Z");
325 assert!(dependency["dismissed_reason"].is_null());
326 assert!(dependency.get("secret_type").is_none());
327 // And it reads back as it was.
328 let again: Vec<SecurityAlert> = serde_json::from_value(sent).unwrap();
329 assert_eq!(again, alerts);
330 }
331
332 #[test]
333 fn an_open_secret_shows_no_decision() {
334 let alert = SecurityAlert::from(secret(AlertState::Open));
335 assert_eq!(alert.dismissed_reason, None);
336 assert_eq!(alert.dismissed_by, None);
337 assert_eq!(alert.dismissed_comment, None);
338 }
339
340 #[test]
341 fn alerts_filter_by_state_and_kind() {
342 let all = || (vec![secret(AlertState::Dismissed)], vec![vulnerability()]);
343 let (s, v) = all();
344 assert_eq!(list(s, v, Some(AlertState::Open), None).len(), 1);
345 let (s, v) = all();
346 let secrets = list(s, v, None, Some(AlertKind::Secret));
347 assert_eq!(secrets.len(), 1);
348 assert_eq!(secrets[0].kind, AlertKind::Secret);
349 let (s, v) = all();
350 assert!(list(s, v, Some(AlertState::Fixed), None).is_empty());
351 }
352
353 #[test]
354 fn each_kind_takes_its_own_reasons() {
355 assert_eq!(AlertKind::Secret.reasons(), ["false_positive", "used_in_tests", "revoked", "wont_fix"]);
356 assert_eq!(
357 AlertKind::Dependency.reasons(),
358 ["fix_started", "no_bandwidth", "tolerable_risk", "inaccurate", "not_used"]
359 );
360 assert_eq!(AlertKind::of_id("sec_9"), Some(AlertKind::Secret));
361 assert_eq!(AlertKind::of_id("vul_9"), Some(AlertKind::Dependency));
362 assert_eq!(AlertKind::of_id("x"), None);
363 }
364}