Skip to content
476 linesCodeBlameRaw

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.

Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1//! 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,
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.10 MAX_SUMMARY_IDS, PaidFeature, ReviewBypassArgs, SecretAlertArgs, SecretAlertDetail, SecretValidity, SecurityEvent,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar11};
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;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.21use crate::summaries::Changed;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar22
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
Merge main (membership, two-factor, GitHub repo roles) into tokens68/// or a security manager of its workspace, or one of its admins.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar69fn reviews_bypasses(user: &User, repo: &RepoRow) -> bool {
Merge main (membership, two-factor, GitHub repo roles) into tokens70 user.manages_security(&repo.namespace)
71 || access::can(Some(user), access::RepoRef { id: &repo.repo_id, namespace: &repo.namespace, private: true }, Capability::ManageSecurity)
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar72}
73
74impl Security {
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.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.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar82 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?;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.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 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar90 let known = self.store.known(&repo.repo_id, fresh).await?;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.91 let mut blocked = 0;
92 for (_, id, _) in known.iter().take(MAX_SUMMARY_IDS) {
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar93 if let Some(secret) = self.store.secret(&repo.repo_id, id).await? {
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.94 if secret.status == SecretStatus::Blocked {
95 blocked += 1;
96 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar97 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 }
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.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
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar105 }
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
Merge main (membership, two-factor, GitHub repo roles) into tokens197 /// workspace's owners and security managers.
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar198 async fn bypass_reviewers(&self, repo: &RepoRow) -> Vec<String> {
199 self.members(&repo.namespace)
200 .await
201 .into_iter()
Merge main (membership, two-factor, GitHub repo roles) into tokens202 .filter(|member| member.role == Role::Owner || member.org_roles.contains(&g1t_contracts::OrgRole::SecurityManager))
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar203 .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;
Security tells people the way they can live with. Each scan publishes one summary event per alert type (secret_scanning_alert.summary, code_scanning_alert.summary, vulnerability_alert.summary: what it opened, by severity, what it fixed, what is open now, the worst new alert and the newest one's title, with the owners and security managers to tell) beside the per-alert events webhooks keep, so the inbox holds one thread per repository and alert type that the next scan brings up to date instead of one item per finding, and the ids are capped at 200 while the counts are not. Critical and high alerts and a refused push reach the inbox at once (and toast like a mention, as the new security notification kind); medium and low wait for each person's security digest, one inbox item and email per workspace, daily or weekly as they choose in their notification settings, at once, or never; a scan that opened nothing refreshes the thread quietly ("2 of 3 fixed") and marks it done when nothing is left open; and security alerts are emailed by default. A repository can turn alerts for secrets found in its history off while push protection stays on. Reporting arrives: security advisories (G1TSA ids, drafts published and withdrawn by Admins and security managers, visible to anyone who can see the repository once published) and private vulnerability reporting (a repository setting; anyone who can see it may report, signed out with an email on a public one, five a day each, and the owners and security managers are told at once), each with its RPC methods, REST routes under /repos/{owner}/{name}/security/advisories and /security/reports, MCP actions, scopes and reference examples.246 self.summarize(repo, AlertType::SecretScanning, Changed { scan: "change", dismissed: 1, ..Changed::default() }, Some(by.id.clone())).await?;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar247 }
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 };
Merge main (membership, two-factor, GitHub repo roles) into tokens263 let owner = viewer.manages_security(&workspace);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar264 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
Merge main (membership, two-factor, GitHub repo roles) into tokens268 || access::can(Some(viewer), access::RepoRef { id: &row.repo_id, namespace: &workspace, private: true }, Capability::ManageSecurity);
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar269 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));
Merge main (membership, two-factor, GitHub repo roles) into tokens469 // 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));
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar475 }
476}

This file's history is long; its oldest lines are credited to the oldest commit read.