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