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 | //! What the security suite's parts share: whether a repository has the |
| 2 | //! paid features, telling people (events for webhooks and the inbox), and | |
| 3 | //! the audit log. | |
| 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. | 4 | //! |
| 5 | //! Who hears what: per-alert events (`<type>.created`, `.fixed`, …) go to | |
| 6 | //! webhooks. The inbox reads the one summary each scan publishes | |
| 7 | //! (summaries.rs), bypass requests and their answers, and vulnerability | |
| 8 | //! reports; each names the workspace's owners and security managers as | |
| 9 | //! `notify`, and its members as `members`, those who may see findings. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 10 | |
| 11 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 12 | use g1t_contracts::billing::{Feature, HasFeatureArgs}; | |
| 13 | use g1t_contracts::events::{NewEvent, Publish}; | |
| 14 | use g1t_contracts::identity::{ListMembersArgs, Member}; | |
| 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. | 15 | use g1t_contracts::advisories::AdvisoryEvent; |
| 16 | use g1t_contracts::security_suite::{AlertType, EVENT_TYPES, PaidFeature, SecurityEvent, SecuritySummary, needs_activation}; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 17 | use g1t_contracts::{FailureCode, Outcome, Role, User, new_id}; |
| 18 | use g1t_kit::now_ms; | |
| 19 | use worker::Result; | |
| 20 | ||
| 21 | use crate::Security; | |
| 22 | use crate::store::RepoRow; | |
| 23 | ||
| 24 | /// The most workspace members an event names as able to see findings. | |
| 25 | const MAX_MEMBERS_NAMED: usize = 500; | |
| 26 | ||
| 27 | /// A failure for want of the activation. | |
| 28 | pub fn payment_required<T>(feature: PaidFeature, workspace: &str) -> Outcome<T> { | |
| 29 | Outcome::fail(FailureCode::PaymentRequired, needs_activation(feature, workspace)) | |
| 30 | } | |
| 31 | ||
| 32 | /// The page of a repository's security section: `/acme/rocket/security/…`. | |
| 33 | pub fn link(repo: &RepoRow, rest: &str) -> String { | |
| 34 | let rest = rest.trim_start_matches('/'); | |
| 35 | if rest.is_empty() { | |
| 36 | format!("/{}/{}/security", repo.namespace, repo.name) | |
| 37 | } else { | |
| 38 | format!("/{}/{}/security/{rest}", repo.namespace, repo.name) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | impl Security { | |
| 43 | /// Whether the workspace has the Security and quality activation (or | |
| 44 | /// has it included). When billing cannot say, it is taken as not: a | |
| 45 | /// paid feature waits rather than running unpaid. | |
| 46 | pub(crate) async fn activated(&self, namespace: &str) -> bool { | |
| 47 | let answer: Result<Outcome<bool>> = g1t_kit::call( | |
| 48 | &self.billing, | |
| 49 | "has_feature", | |
| 50 | &HasFeatureArgs { workspace: namespace.to_owned(), feature: Feature::Security }, | |
| 51 | ) | |
| 52 | .await; | |
| 53 | match answer { | |
| 54 | Ok(Outcome::Ok(on)) => on, | |
| 55 | Ok(Outcome::Fail(_)) => false, | |
| 56 | Err(error) => { | |
| 57 | worker::console_error!("security: has_feature for {namespace}: {error}"); | |
| 58 | false | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | /// Whether a repository has the paid features: it is public, or its | |
| 64 | /// workspace has the activation. | |
| 65 | pub(crate) async fn entitled(&self, repo: &RepoRow) -> Result<bool> { | |
| 66 | let (_, private) = self.store.repo_settings(&repo.repo_id).await?; | |
| 67 | Ok(!private || self.activated(&repo.namespace).await) | |
| 68 | } | |
| 69 | ||
| 70 | /// `None` when the repository may use `feature`, or the refusal. | |
| 71 | pub(crate) async fn gate<T>(&self, repo: &RepoRow, feature: PaidFeature) -> Result<Option<Outcome<T>>> { | |
| 72 | Ok((!self.entitled(repo).await?).then(|| payment_required(feature, &repo.namespace))) | |
| 73 | } | |
| 74 | ||
| 75 | /// The workspace's members, as g1t sees them. | |
| 76 | pub(crate) async fn members(&self, namespace: &str) -> Vec<Member> { | |
| 77 | let found: Result<Outcome<Vec<Member>>> = g1t_kit::call( | |
| 78 | &self.identity, | |
| 79 | "list_members", | |
| 80 | &ListMembersArgs { slug: namespace.to_owned(), viewer: Some(User::system(namespace)) }, | |
| 81 | ) | |
| 82 | .await; | |
| 83 | match found { | |
| 84 | Ok(Outcome::Ok(members)) => members, | |
| 85 | Ok(Outcome::Fail(failure)) => { | |
| 86 | worker::console_error!("security: members of {namespace}: {}", failure.message); | |
| 87 | Vec::new() | |
| 88 | } | |
| 89 | Err(error) => { | |
| 90 | worker::console_error!("security: members of {namespace}: {error}"); | |
| 91 | Vec::new() | |
| 92 | } | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 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. | 96 | /// Publishes a security event about one alert, for webhooks (and, for |
| 97 | /// a bypass request, the inbox). What the inbox tells people of new | |
| 98 | /// alerts comes from the scan's summary (summaries.rs), not from these. | |
| 99 | /// Failing to publish never fails the change. | |
| 100 | pub(crate) async fn publish(&self, kind: &str, repo: &RepoRow, event: SecurityEvent, actor_id: Option<String>) { | |
| 101 | let data = serde_json::to_value(&event).unwrap_or_default(); | |
| 102 | let audience = kind == "secret_scanning.bypass_requested" && event.notify.is_empty(); | |
| 103 | self.publish_data(kind, repo, data, audience, actor_id).await; | |
| 104 | } | |
| 105 | ||
| 106 | /// Publishes a scan's summary (`<type>.summary`): the inbox's one item | |
| 107 | /// per repository and alert type, told to the workspace's owners and | |
| 108 | /// security managers, and to watchers who chose security and may see | |
| 109 | /// findings. | |
| 110 | pub(crate) async fn publish_summary(&self, kind: &str, repo: &RepoRow, summary: &SecuritySummary, actor_id: Option<String>) { | |
| 111 | let data = serde_json::to_value(summary).unwrap_or_default(); | |
| 112 | self.publish_data(kind, repo, data, true, actor_id).await; | |
| 113 | } | |
| 114 | ||
| 115 | /// Publishes an advisory event (`security_advisory.*`). A report tells | |
| 116 | /// the owners and security managers; a published advisory, webhooks | |
| 117 | /// and the repository's security watchers. | |
| 118 | pub(crate) async fn publish_advisory(&self, kind: &str, repo: &RepoRow, event: &AdvisoryEvent, actor_id: Option<String>) { | |
| 119 | let data = serde_json::to_value(event).unwrap_or_default(); | |
| 120 | self.publish_data(kind, repo, data, kind == "security_advisory.reported" || kind == "security_advisory.published", actor_id).await; | |
| 121 | } | |
| 122 | ||
| 123 | /// Publishes one of [`EVENT_TYPES`]. With `audience`, `notify` (when | |
| 124 | /// empty) becomes the workspace's owners and security managers, and | |
| 125 | /// `members` names its members, as those who may see findings. | |
| 126 | async fn publish_data(&self, kind: &str, repo: &RepoRow, mut data: serde_json::Value, audience: bool, actor_id: Option<String>) { | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 127 | let Some(kind) = EVENT_TYPES.iter().copied().find(|known| *known == kind) else { |
| 128 | worker::console_error!("security: no event called {kind}"); | |
| 129 | return; | |
| 130 | }; | |
| 131 | let Some(events) = &self.events else { return }; | |
| 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. | 132 | if audience { |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 133 | let members = self.members(&repo.namespace).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. | 134 | if data["notify"].as_array().is_none_or(Vec::is_empty) { |
| 135 | data["notify"] = serde_json::json!( | |
| 136 | members | |
| 137 | .iter() | |
| 138 | .filter(|member| member.role == Role::Owner || member.org_roles.contains(&g1t_contracts::OrgRole::SecurityManager)) | |
| 139 | .map(|member| member.username.clone()) | |
| 140 | .collect::<Vec<_>>() | |
| 141 | ); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 142 | } |
| 143 | data["members"] = serde_json::json!( | |
| 144 | members.iter().take(MAX_MEMBERS_NAMED).map(|member| member.username.clone()).collect::<Vec<_>>() | |
| 145 | ); | |
| 146 | } | |
| 147 | let published: Result<serde_json::Value> = g1t_kit::call( | |
| 148 | events, | |
| 149 | "publish", | |
| 150 | &Publish { | |
| 151 | events: vec![NewEvent { kind, source: "security", repo_id: Some(repo.repo_id.clone()), actor: actor_id, data }], | |
| 152 | }, | |
| 153 | ) | |
| 154 | .await; | |
| 155 | if let Err(error) = published { | |
| 156 | worker::console_error!("security: {kind} for {} not published: {error}", repo.repo_id); | |
| 157 | } | |
| 158 | } | |
| 159 | ||
| 160 | /// Publishes `<type>.<action>` for one alert. | |
| 161 | pub(crate) async fn alert_event(&self, alert_type: AlertType, action: &str, repo: &RepoRow, event: SecurityEvent, actor_id: Option<String>) { | |
| 162 | let kind = format!("{}.{action}", alert_type.event_prefix()); | |
| 163 | self.publish(&kind, repo, event, actor_id).await; | |
| 164 | } | |
| 165 | ||
| 166 | /// Records a security decision in the workspace's audit log. | |
| 167 | pub(crate) async fn audit(&self, actor: &User, action: &str, repo: Option<&RepoRow>, namespace: &str, path: Option<&str>, message: &str) { | |
| 168 | let Some(events) = &self.events else { return }; | |
| 169 | let entry = NewAuditEntry { | |
| 170 | actor: AuditActor::of(actor), | |
| 171 | action: action.to_owned(), | |
| 172 | surface: Surface::Web, | |
| 173 | target: AuditTarget { | |
| 174 | workspace: namespace.to_owned(), | |
| 175 | repo: repo.map(|repo| format!("{}/{}", repo.namespace, repo.name)), | |
| 176 | path: path.map(str::to_owned), | |
| 177 | ..AuditTarget::default() | |
| 178 | }, | |
| 179 | outcome: AuditOutcome::Allowed, | |
| 180 | rule: "security".to_owned(), | |
| 181 | result: Some("ok".to_owned()), | |
| 182 | message: Some(message.chars().take(500).collect()), | |
| 183 | request_id: new_id("req", now_ms()), | |
| 184 | }; | |
| 185 | let recorded: Result<u32> = g1t_kit::call(events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await; | |
| 186 | if let Err(error) = recorded { | |
| 187 | worker::console_error!("security: audit entry {action} not recorded: {error}"); | |
| 188 | } | |
| 189 | } | |
| 190 | } |