Skip to content

g1t/services/security/src/secret_alerts.rs

452 lines21,451 bytesCodeBlame
1//! Secret alerts beyond the list: every place a secret was found, pushing
2//! past push protection with a reason (or asking to, when the workspace
3//! delegates bypasses to its owners and the repository's admins), and
4//! asking a secret's issuer whether it still works.
5
6use g1t_contracts::access::{self, Capability};
7use g1t_contracts::security::{AlertActivity, NewSecret, SecretFinding, SecretStatus};
8use g1t_contracts::security_suite::{
9 AlertType, BypassArgs, BypassReason, BypassRequest, BypassRequestsArgs, BypassResult, CheckSecretArgs, CheckValidityArgs,
10 PaidFeature, ReviewBypassArgs, SecretAlertArgs, SecretAlertDetail, SecretValidity, SecurityEvent,
11};
12use g1t_contracts::time::rfc3339;
13use g1t_contracts::{FailureCode, Outcome, Role, User};
14use g1t_kit::now_ms;
15use g1t_scan::secrets::SecretKind;
16use worker::Result;
17
18use crate::Security;
19use crate::store::{Activity, RepoRow};
20use crate::suite::link;
21
22const MAX_COMMENT_CHARS: usize = 500;
23/// Validity checks the sweep makes per repository, and how often a secret
24/// is asked about again.
25const CHECKS_PER_SWEEP: u32 = 10;
26const RECHECK_MS: u64 = 7 * 24 * 60 * 60 * 1000;
27
28fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
29 Outcome::fail(code, message)
30}
31
32/// How a sentence names a secret: its format's label, or its custom
33/// pattern's.
34pub fn label_of(kind: &str, pattern_name: Option<&str>) -> String {
35 if kind == g1t_scan::custom::KIND {
36 return g1t_scan::custom::label(pattern_name.unwrap_or("custom"));
37 }
38 SecretKind::parse(kind).map_or("a secret", |kind| kind.label()).to_owned()
39}
40
41/// "An AWS access key in config/prod.env": a secret alert's title.
42pub fn title_of(secret: &SecretFinding) -> String {
43 let mut label = secret.label.clone();
44 if let Some(first) = label.get(..1) {
45 label = format!("{}{}", first.to_uppercase(), &label[1..]);
46 }
47 format!("{label} in {}", secret.path)
48}
49
50/// The event that tells of a secret alert.
51pub fn secret_event(repo: &RepoRow, secret: &SecretFinding) -> SecurityEvent {
52 SecurityEvent {
53 repo_id: repo.repo_id.clone(),
54 alert_id: secret.id.clone(),
55 alert_type: AlertType::SecretScanning.as_str().to_owned(),
56 severity: if secret.test_value.is_some() { "low" } else { "critical" }.to_owned(),
57 title: title_of(secret),
58 link: link(repo, &format!("secret-scanning/{}", secret.id)),
59 path: Some(secret.path.clone()),
60 line: Some(secret.line),
61 state: secret.state.as_str().to_owned(),
62 ..SecurityEvent::default()
63 }
64}
65
66/// Whether a person reviews bypass requests in this repository: an owner
67/// of its workspace, or one of its admins.
68fn reviews_bypasses(user: &User, repo: &RepoRow) -> bool {
69 user.role_in(&repo.namespace) == Some(Role::Owner)
70 || access::can(Some(user), access::RepoRef { id: &repo.repo_id, namespace: &repo.namespace, private: true }, Capability::ManageIntegrations)
71}
72
73impl Security {
74 /// Records where new secrets were found and publishes an alert for each
75 /// one seen for the first time. `fresh` are the fingerprints not known
76 /// before; `pusher` is who pushed, for a blocked push.
77 pub(crate) async fn secrets_found(&self, repo: &RepoRow, secrets: &[NewSecret], source: &str, fresh: &[String], pusher: Option<&str>) -> Result<()> {
78 self.store.note_found(&repo.repo_id, secrets, source).await?;
79 let known = self.store.known(&repo.repo_id, fresh).await?;
80 for (_, id, _) in known.iter().take(20) {
81 if let Some(secret) = self.store.secret(&repo.repo_id, id).await? {
82 let mut event = secret_event(repo, &secret);
83 event.pusher = pusher.filter(|_| secret.status == SecretStatus::Blocked).map(str::to_owned);
84 self.alert_event(AlertType::SecretScanning, "created", repo, event, None).await;
85 }
86 }
87 Ok(())
88 }
89
90 pub(crate) async fn secret_alert(&self, a: SecretAlertArgs) -> Result<Outcome<SecretAlertDetail>> {
91 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
92 Outcome::Ok(repo) => repo,
93 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
94 };
95 let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else {
96 return Ok(fail(FailureCode::NotFound, "No such alert."));
97 };
98 let activity: Vec<AlertActivity> =
99 self.store.activity(&repo.repo_id, 500).await?.into_iter().filter(|item| item.alert_id == secret.id).collect();
100 let workspace = self.store.workspace_settings(&repo.namespace).await?;
101 let viewer = a.viewer.as_ref();
102 let blocked = secret.status == SecretStatus::Blocked && secret.bypass.is_none();
103 let reviewer = viewer.is_some_and(|user| reviews_bypasses(user, &repo));
104 let delegated = workspace.delegated_bypass && self.entitled(&repo).await?;
105 Ok(Outcome::Ok(SecretAlertDetail {
106 locations: self.store.locations(&secret.id).await?,
107 requests: self.store.requests_for_secret(&secret.id).await?.iter().map(|row| row.contract()).collect(),
108 checkable: SecretKind::parse(&secret.kind).is_some_and(|kind| g1t_scan::validity::SUPPORTED.contains(&kind)),
109 can_bypass: blocked && (!delegated || reviewer),
110 can_request_bypass: blocked && delegated && !reviewer,
111 activity,
112 secret,
113 }))
114 }
115
116 pub(crate) async fn bypass(&self, a: BypassArgs) -> Result<Outcome<BypassResult>> {
117 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? {
118 Outcome::Ok(repo) => repo,
119 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
120 };
121 if !a.actor.verified {
122 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
123 }
124 let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else {
125 return Ok(fail(FailureCode::NotFound, "No such alert."));
126 };
127 if secret.status != SecretStatus::Blocked || secret.bypass.is_some() {
128 return Ok(fail(FailureCode::Conflict, "Only a secret push protection blocked, and not bypassed yet, can be bypassed."));
129 }
130 let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect();
131 let comment = (!comment.is_empty()).then_some(comment);
132 let workspace = self.store.workspace_settings(&repo.namespace).await?;
133 let delegated = workspace.delegated_bypass && self.entitled(&repo).await?;
134 if delegated && !reviews_bypasses(&a.actor, &repo) {
135 // Asked for, not made: an owner or admin decides.
136 if let Some(pending) = self.store.pending_request(&secret.id, &a.actor.username).await? {
137 return Ok(Outcome::Ok(BypassResult { secret, request: Some(pending.contract()) }));
138 }
139 let id = self
140 .store
141 .add_request(&repo.repo_id, &repo.namespace, &secret.id, &a.actor.username, a.reason, comment.as_deref())
142 .await?;
143 self.store
144 .record(&repo.repo_id, &[Activity {
145 alert_id: &secret.id,
146 action: "bypass_requested",
147 actor: Some(&a.actor.username),
148 reason: None,
149 comment: Some(a.reason.label()),
150 number: None,
151 }])
152 .await?;
153 let request = self.store.request(&id).await?.map(|row| row.contract());
154 let reviewers = self.bypass_reviewers(&repo).await;
155 let event = SecurityEvent {
156 request_id: Some(id.clone()),
157 reason: Some(a.reason.as_str().to_owned()),
158 notify: reviewers.into_iter().filter(|name| !name.eq_ignore_ascii_case(&a.actor.username)).collect(),
159 link: format!("/{}/-/security/bypass-requests", repo.namespace),
160 ..secret_event(&repo, &secret)
161 };
162 self.publish("secret_scanning.bypass_requested", &repo, event, Some(a.actor.id.clone())).await;
163 self.audit(
164 &a.actor,
165 "secret_scanning.bypass_requested",
166 Some(&repo),
167 &repo.namespace,
168 Some(&secret.path),
169 &format!("Asked to bypass push protection for {} ({})", title_of(&secret), a.reason.label()),
170 )
171 .await;
172 return Ok(Outcome::Ok(BypassResult { secret, request }));
173 }
174 self.apply_bypass(&repo, &secret, a.reason, comment.as_deref(), &a.actor, None).await?;
175 let secret = self.store.secret(&repo.repo_id, &a.id).await?.unwrap_or(secret);
176 Ok(Outcome::Ok(BypassResult { secret, request: None }))
177 }
178
179 /// The people who review bypass requests that the inbox tells: the
180 /// workspace's owners.
181 async fn bypass_reviewers(&self, repo: &RepoRow) -> Vec<String> {
182 self.members(&repo.namespace)
183 .await
184 .into_iter()
185 .filter(|member| member.role == Role::Owner)
186 .map(|member| member.username)
187 .collect()
188 }
189
190 /// Lets a secret through: recorded on the alert, in its activity and in
191 /// the audit log.
192 async fn apply_bypass(
193 &self,
194 repo: &RepoRow,
195 secret: &SecretFinding,
196 reason: BypassReason,
197 comment: Option<&str>,
198 by: &User,
199 approved_by: Option<&str>,
200 ) -> Result<()> {
201 self.store.bypass(&repo.repo_id, &secret.id, reason, comment, &by.username, approved_by).await?;
202 self.store
203 .record(&repo.repo_id, &[Activity {
204 alert_id: &secret.id,
205 action: "bypassed",
206 actor: Some(&by.username),
207 reason: reason.dismissal(),
208 comment: Some(comment.unwrap_or(reason.label())),
209 number: None,
210 }])
211 .await?;
212 self.audit(
213 by,
214 "secret_scanning.bypass",
215 Some(repo),
216 &repo.namespace,
217 Some(&secret.path),
218 &format!(
219 "Bypassed push protection for {} ({}){}",
220 title_of(secret),
221 reason.label(),
222 approved_by.map(|who| format!(", approved by {who}")).unwrap_or_default()
223 ),
224 )
225 .await;
226 if let Some(dismissal) = reason.dismissal() {
227 let event = SecurityEvent { reason: Some(dismissal.as_str().to_owned()), state: "dismissed".to_owned(), ..secret_event(repo, secret) };
228 self.alert_event(AlertType::SecretScanning, "dismissed", repo, event, Some(by.id.clone())).await;
229 }
230 Ok(())
231 }
232
233 pub(crate) async fn bypass_requests(&self, a: BypassRequestsArgs) -> Result<Outcome<Vec<BypassRequest>>> {
234 let workspace = a.workspace.to_lowercase();
235 let Some(viewer) = a.viewer.as_ref().filter(|user| user.is_member(&workspace)) else {
236 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
237 };
238 let repo_id = match &a.repo {
239 Some(path) => match self.member_repo(path, &a.viewer, crate::SEE_FINDINGS).await? {
240 Outcome::Ok(repo) => Some(repo.repo_id),
241 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
242 },
243 None => None,
244 };
245 let owner = viewer.role_in(&workspace) == Some(Role::Owner);
246 let mut shown = Vec::new();
247 for row in self.store.requests(&workspace, repo_id.as_deref(), a.state.as_deref()).await? {
248 // Reviewers see every request; anyone else their own.
249 let reviewer = owner
250 || access::can(Some(viewer), access::RepoRef { id: &row.repo_id, namespace: &workspace, private: true }, Capability::ManageIntegrations);
251 if reviewer || row.requester.eq_ignore_ascii_case(&viewer.username) {
252 shown.push(row.contract());
253 }
254 }
255 Ok(Outcome::Ok(shown))
256 }
257
258 pub(crate) async fn review_bypass(&self, a: ReviewBypassArgs) -> Result<Outcome<BypassRequest>> {
259 let workspace = a.workspace.to_lowercase();
260 if !a.actor.is_member(&workspace) {
261 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
262 }
263 let Some(request) = self.store.request(&a.id).await?.filter(|row| row.namespace == workspace) else {
264 return Ok(fail(FailureCode::NotFound, "No such request."));
265 };
266 let Some(repo) = self.store.repo(&request.repo_id).await? else {
267 return Ok(fail(FailureCode::NotFound, "No such request."));
268 };
269 if request.state != "pending" {
270 return Ok(fail(FailureCode::Conflict, format!("This request was {} already.", request.state)));
271 }
272 let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect();
273 let comment = (!comment.is_empty()).then_some(comment);
274 let state = match a.decision.as_str() {
275 "cancel" if request.requester.eq_ignore_ascii_case(&a.actor.username) => "cancelled",
276 "cancel" => return Ok(fail(FailureCode::Forbidden, "Only whoever asked can cancel a request.")),
277 "approve" | "deny" if !reviews_bypasses(&a.actor, &repo) => {
278 return Ok(fail(FailureCode::Forbidden, "Only the workspace's owners and the repository's admins review bypass requests."));
279 }
280 "approve" | "deny" if request.requester.eq_ignore_ascii_case(&a.actor.username) => {
281 return Ok(fail(FailureCode::Forbidden, "Someone else has to review your own request."));
282 }
283 "approve" => "approved",
284 "deny" => "denied",
285 _ => return Ok(fail(FailureCode::Invalid, "The decision is approve, deny or cancel.")),
286 };
287 if !a.actor.verified {
288 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
289 }
290 if !self.store.review_request(&a.id, state, &a.actor.username, comment.as_deref()).await? {
291 return Ok(fail(FailureCode::Conflict, "This request was reviewed already."));
292 }
293 let reason = BypassReason::parse(&request.reason).unwrap_or(BypassReason::WillFixLater);
294 let secret = self.store.secret(&repo.repo_id, &request.secret_id).await?;
295 if let (Some(secret), "approved") = (&secret, state) {
296 let requester = User { username: request.requester.clone(), ..User::default() };
297 self.apply_bypass(&repo, secret, reason, request.comment.as_deref(), &requester, Some(&a.actor.username)).await?;
298 }
299 if let Some(secret) = &secret {
300 self.store
301 .record(&repo.repo_id, &[Activity {
302 alert_id: &secret.id,
303 action: match state {
304 "approved" => "bypass_approved",
305 "denied" => "bypass_denied",
306 _ => "bypass_cancelled",
307 },
308 actor: Some(&a.actor.username),
309 reason: None,
310 comment: comment.as_deref(),
311 number: None,
312 }])
313 .await?;
314 if state != "cancelled" {
315 let event = SecurityEvent {
316 request_id: Some(request.id.clone()),
317 state: state.to_owned(),
318 reason: Some(reason.as_str().to_owned()),
319 notify: vec![request.requester.clone()],
320 ..secret_event(&repo, secret)
321 };
322 self.publish("secret_scanning.bypass_reviewed", &repo, event, Some(a.actor.id.clone())).await;
323 }
324 }
325 self.audit(
326 &a.actor,
327 "secret_scanning.bypass_reviewed",
328 Some(&repo),
329 &workspace,
330 None,
331 &format!("Bypass request {} by {} {state}", request.id, request.requester),
332 )
333 .await;
334 let Some(row) = self.store.request(&a.id).await? else {
335 return Ok(fail(FailureCode::NotFound, "No such request."));
336 };
337 Ok(Outcome::Ok(row.contract()))
338 }
339
340 pub(crate) async fn check_validity(&self, a: CheckValidityArgs) -> Result<Outcome<SecretFinding>> {
341 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), crate::SEE_FINDINGS).await? {
342 Outcome::Ok(repo) => repo,
343 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
344 };
345 if let Some(refusal) = self.gate(&repo, PaidFeature::ValidityChecks).await? {
346 return Ok(refusal);
347 }
348 if !self.store.workspace_settings(&repo.namespace).await?.validity_checks {
349 return Ok(fail(
350 FailureCode::Conflict,
351 "Validity checks are off for this workspace. An owner can turn them on in its Security settings.",
352 ));
353 }
354 let Some(secret) = self.store.secret(&repo.repo_id, &a.id).await? else {
355 return Ok(fail(FailureCode::NotFound, "No such alert."));
356 };
357 let validity = self.ask_issuer(&repo, &secret).await;
358 self.store.set_validity(&repo.repo_id, &secret.id, &validity.validity).await?;
359 if let Some(detail) = &validity.detail {
360 worker::console_log!("security: validity of {} unknown: {detail}", secret.id);
361 }
362 Ok(match self.store.secret(&repo.repo_id, &a.id).await? {
363 Some(secret) => Outcome::Ok(secret),
364 None => fail(FailureCode::NotFound, "No such alert."),
365 })
366 }
367
368 /// Asks the repos service, which can read the secret where it landed,
369 /// to ask its issuer. A secret that never landed (blocked at a push)
370 /// cannot be read again, so it is unknown.
371 async fn ask_issuer(&self, repo: &RepoRow, secret: &SecretFinding) -> SecretValidity {
372 let supported = SecretKind::parse(&secret.kind).is_some_and(|kind| g1t_scan::validity::SUPPORTED.contains(&kind));
373 if !supported {
374 return SecretValidity { validity: "unsupported".to_owned(), detail: None };
375 }
376 if secret.status == SecretStatus::Blocked {
377 return SecretValidity { validity: "unknown".to_owned(), detail: Some("it never landed, so it cannot be read again".to_owned()) };
378 }
379 let fingerprint = match self.store.fingerprint_of(&repo.repo_id, &secret.id).await {
380 Ok(Some(fingerprint)) => fingerprint,
381 _ => return SecretValidity { validity: "unknown".to_owned(), detail: Some("no fingerprint".to_owned()) },
382 };
383 let asked: Result<SecretValidity> = g1t_kit::call(
384 &self.repos,
385 "check_secret",
386 &CheckSecretArgs {
387 repo_id: repo.repo_id.clone(),
388 commit: secret.commit.clone(),
389 path: secret.path.clone(),
390 line: secret.line,
391 kind: secret.kind.clone(),
392 fingerprint,
393 },
394 )
395 .await;
396 asked.unwrap_or_else(|error| SecretValidity { validity: "unknown".to_owned(), detail: Some(error.to_string()) })
397 }
398
399 /// The sweep's part: in workspaces that turned validity checks on, open
400 /// secrets not asked about for a week are asked about again.
401 pub(crate) async fn sweep_validity(&self, repo: &RepoRow) -> Result<()> {
402 if !self.store.workspace_settings(&repo.namespace).await?.validity_checks || !self.entitled(repo).await? {
403 return Ok(());
404 }
405 let before = rfc3339(now_ms().saturating_sub(RECHECK_MS));
406 for id in self.store.unchecked_secrets(&repo.repo_id, &before, CHECKS_PER_SWEEP).await? {
407 if let Some(secret) = self.store.secret(&repo.repo_id, &id).await? {
408 let validity = self.ask_issuer(repo, &secret).await;
409 self.store.set_validity(&repo.repo_id, &id, &validity.validity).await?;
410 }
411 }
412 Ok(())
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use super::*;
419
420 #[test]
421 fn titles_name_the_secret_and_its_file() {
422 assert_eq!(label_of("aws_access_key", None), "an AWS access key");
423 assert_eq!(label_of("custom_pattern", Some("Acme key")), "a match for the custom pattern \"Acme key\"");
424 assert_eq!(label_of("unheard_of", None), "a secret");
425 }
426
427 #[test]
428 fn owners_and_admins_review_bypasses() {
429 let repo = RepoRow {
430 repo_id: "rep_1".into(),
431 namespace: "acme".into(),
432 name: "rocket".into(),
433 upkeep: 1,
434 history: "done".into(),
435 history_cursor: None,
436 history_commits: 0,
437 history_finished_at: None,
438 deps_scanned_at: None,
439 deps_error: None,
440 lockfiles: "[]".into(),
441 version_updates: None,
442 };
443 let member = |role| User {
444 id: "usr_1".into(),
445 username: "ana".into(),
446 workspaces: vec![g1t_contracts::Membership { role, ..g1t_contracts::Membership::member("acme") }],
447 ..User::default()
448 };
449 assert!(reviews_bypasses(&member(Role::Owner), &repo));
450 assert!(!reviews_bypasses(&User { username: "eve".into(), ..User::default() }, &repo));
451 }
452}