Skip to content
190 linesCodeBlameRaw
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.
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.
10
11use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
12use g1t_contracts::billing::{Feature, HasFeatureArgs};
13use g1t_contracts::events::{NewEvent, Publish};
14use g1t_contracts::identity::{ListMembersArgs, Member};
15use g1t_contracts::advisories::AdvisoryEvent;
16use g1t_contracts::security_suite::{AlertType, EVENT_TYPES, PaidFeature, SecurityEvent, SecuritySummary, needs_activation};
17use g1t_contracts::{FailureCode, Outcome, Role, User, new_id};
18use g1t_kit::now_ms;
19use worker::Result;
20
21use crate::Security;
22use crate::store::RepoRow;
23
24/// The most workspace members an event names as able to see findings.
25const MAX_MEMBERS_NAMED: usize = 500;
26
27/// A failure for want of the activation.
28pub 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/…`.
33pub 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
42impl 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
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>) {
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 };
132 if audience {
133 let members = self.members(&repo.namespace).await;
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 );
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}