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 bar | 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 | ||
| 6 | use g1t_contracts::access::{self, Capability}; | |
| 7 | use g1t_contracts::security::{AlertActivity, NewSecret, SecretFinding, SecretStatus}; | |
| 8 | use 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 bar | 11 | }; |
| 12 | use g1t_contracts::time::rfc3339; | |
| 13 | use g1t_contracts::{FailureCode, Outcome, Role, User}; | |
| 14 | use g1t_kit::now_ms; | |
| 15 | use g1t_scan::secrets::SecretKind; | |
| 16 | use worker::Result; | |
| 17 | ||
| 18 | use crate::Security; | |
| 19 | use crate::store::{Activity, RepoRow}; | |
| 20 | use 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. | 21 | use crate::summaries::Changed; |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 22 | |
| 23 | const MAX_COMMENT_CHARS: usize = 500; | |
| 24 | /// Validity checks the sweep makes per repository, and how often a secret | |
| 25 | /// is asked about again. | |
| 26 | const CHECKS_PER_SWEEP: u32 = 10; | |
| 27 | const RECHECK_MS: u64 = 7 * 24 * 60 * 60 * 1000; | |
| 28 | ||
| 29 | fn 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. | |
| 35 | pub 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. | |
| 43 | pub 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. | |
| 52 | pub 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 tokens | 68 | /// 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 bar | 69 | fn reviews_bypasses(user: &User, repo: &RepoRow) -> bool { |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 70 | 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 bar | 72 | } |
| 73 | ||
| 74 | impl 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 bar | 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?; | |
| 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 bar | 90 | 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 bar | 93 | 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 bar | 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 | } | |
| 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 bar | 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 | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 197 | /// workspace's owners and security managers. |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 198 | 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 tokens | 202 | .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 bar | 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; | |
| 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 bar | 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 | }; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 263 | let owner = viewer.manages_security(&workspace); |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 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 | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 268 | || 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 bar | 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)] | |
| 435 | mod 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 tokens | 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)); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 475 | } |
| 476 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.