Skip to content
866 linesCodeBlameRaw
1//! The security service: what g1t finds wrong in a repository, and the
2//! upkeep that fixes it without a person.
3//!
4//! - Secrets. The repos service refuses pushes that add one
5//! (`push_blocked` says which were allowed and records the rest), and
6//! each repository's history is scanned once, in the background.
7//! A push too large to scan first is let through, and its new commits
8//! are scanned after they land (`history::advance_push_scan`).
9//! - Alerts are open, dismissed (with a reason, a comment and who) or
10//! fixed, and each keeps an activity log. Likely test values are listed
11//! apart and never block a push or count as critical.
12//! - Dependencies. On every push to a default branch, and daily, the
13//! lockfiles are read and every package checked against OSV. Each
14//! vulnerable package with a fix gets a security update: g1t itself
15//! (`User::system`) opens a pull request raising its version, made in a
16//! sandbox (the runner's `bump`), which lands through the branch's
17//! required checks. Only when code has to change is g1t put on an
18//! issue for it.
19//!
20//! - The security suite (`g1t_contracts::security_suite`): custom secret
21//! patterns (`patterns`), push protection bypasses, their review and
22//! validity checks (`secret_alerts`), code scanning from SARIF uploads
23//! and its pull request check (`code_scanning`), the dependency graph,
24//! its SBOM and dependency review (`supply_chain`), "Fix with g1t"
25//! (`fixes`), and settings with the workspace's overview (`overview`).
26//! On private repositories its paid parts need the workspace's Security
27//! and quality activation (`suite`); it tells people through events.
28//!
29//! Other services reach it over `POST /rpc/<method>`; see
30//! `g1t_contracts::security`.
31
32mod advisories;
33mod code_scanning;
34mod config;
35mod deps;
36mod fixes;
37mod history;
38mod manifests;
39mod overview;
40mod patterns;
41mod planning;
42mod pull_text;
43mod resolved;
44mod ranges;
45mod registries;
46mod schedule;
47mod secret_alerts;
48mod security_updates;
49mod store;
50mod suite;
51mod suite_store;
52mod summaries;
53mod supply_chain;
54mod timezones;
55mod update_store;
56mod updates;
57mod version_updates;
58mod yaml;
59
60use g1t_contracts::access::{self, Capability};
61use g1t_contracts::events::{Event, WorkspaceRenamed};
62use g1t_contracts::security::UPDATE_BRANCH_PREFIX;
63use g1t_contracts::repos::{GetArgs, PathByIdArgs, Repo, RepoPath, RepoStatus, StatusByIdArgs};
64use g1t_contracts::security::*;
65use g1t_contracts::time::rfc3339;
66use g1t_contracts::{FailureCode, Outcome, User};
67use g1t_kit::{args, now_ms, reply, rpc_method};
68use serde::Deserialize;
69use worker::{Context, Env, Fetcher, MessageBatch, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
70
71use store::{Activity, RepoRow, Store};
72
73/// Repositories whose history is continued per sweep, and pages each.
74const HISTORIES_PER_SWEEP: u32 = 5;
75const PAGES_PER_SWEEP: u32 = 4;
76/// Repositories whose dependencies are read again per sweep.
77const DEPENDENCIES_PER_SWEEP: u32 = 10;
78const DAY_MS: u64 = 24 * 60 * 60 * 1000;
79const MAX_REASON_CHARS: usize = 500;
80/// What seeing and dismissing a repository's findings takes: the Write
81/// role, as on GitHub, or a security manager of its workspace. Changing its
82/// security settings takes Admin (`ManageSecurity`).
83const SEE_FINDINGS: Capability = Capability::SecurityAlerts;
84
85pub struct Security {
86 store: Store,
87 identity: Fetcher,
88 repos: Fetcher,
89 work: Fetcher,
90 runner: Fetcher,
91 billing: Fetcher,
92 actions: Fetcher,
93 /// The events service: security events for webhooks and the inbox,
94 /// and audit entries. Optional so a deployment without the binding
95 /// still scans.
96 events: Option<Fetcher>,
97}
98
99fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
100 Outcome::fail(code, message)
101}
102
103/// `git.push`, as far as this service reads it.
104#[derive(Deserialize)]
105#[serde(rename_all = "camelCase")]
106struct Pushed {
107 repo_id: String,
108 #[serde(default)]
109 default_branch: bool,
110 #[serde(default, rename = "ref")]
111 git_ref: String,
112 #[serde(default)]
113 before: Option<String>,
114 #[serde(default)]
115 after: String,
116 /// Too large to scan before it was stored.
117 #[serde(default)]
118 unscanned: bool,
119}
120
121/// `pull.merged`, `pull.closed` and `checks.completed`, as far as this
122/// service reads them.
123#[derive(Deserialize)]
124#[serde(rename_all = "camelCase")]
125struct PullHappened {
126 repo_id: String,
127 number: u32,
128 #[serde(default)]
129 status: Option<String>,
130}
131
132/// The longest comment a dismissal keeps.
133const MAX_COMMENT_CHARS: usize = MAX_REASON_CHARS;
134/// Activity rows the Security page reads, newest first.
135const ACTIVITY_SHOWN: u32 = 500;
136
137#[derive(Deserialize)]
138#[serde(rename_all = "camelCase")]
139struct Created {
140 repo_id: String,
141 /// Absent from events older than the field.
142 #[serde(default)]
143 is_private: Option<bool>,
144}
145
146impl Security {
147 fn new(env: &Env) -> Result<Self> {
148 Ok(Security {
149 store: Store { db: env.d1("DB")? },
150 identity: env.service("IDENTITY")?,
151 repos: env.service("REPOS")?,
152 work: env.service("WORK")?,
153 runner: env.service("RUNNER")?,
154 billing: env.service("BILLING")?,
155 actions: env.service("ACTIONS")?,
156 events: env.service("EVENTS").ok(),
157 })
158 }
159
160 /// The repository at `path`, recorded here, if `viewer` may see its
161 /// findings (the Write role) and do `capability`. Findings are for those
162 /// who can change the code: to anyone else the page does not exist,
163 /// public repository or not.
164 async fn member_repo(
165 &self,
166 path: &RepoPath,
167 viewer: &Option<User>,
168 capability: Capability,
169 ) -> Result<Outcome<RepoRow>> {
170 let hidden = || fail(FailureCode::NotFound, "Repository not found.");
171 if viewer.is_none() {
172 return Ok(hidden());
173 }
174 let repo: Outcome<Repo> =
175 g1t_kit::call(&self.repos, "get", &GetArgs { path: path.clone(), viewer: viewer.clone() }).await?;
176 let repo = match repo {
177 Outcome::Ok(repo) if repo.fork_of.is_none() => repo,
178 _ => return Ok(hidden()),
179 };
180 if !access::can(viewer.as_ref(), &repo, SEE_FINDINGS) {
181 return Ok(hidden());
182 }
183 if !access::can(viewer.as_ref(), &repo, capability) {
184 return Ok(fail(
185 FailureCode::Forbidden,
186 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
187 ));
188 }
189 let row = self.store.register(&repo.id, &repo.namespace, &repo.name).await?;
190 self.store.set_private(&repo.id, repo.is_private).await?;
191 Ok(Outcome::Ok(row))
192 }
193
194 /// Where the repository stands: archived, deleted, a mirror. When repos
195 /// cannot say, it is taken as an ordinary active repository.
196 async fn status(&self, repo_id: &str) -> Result<RepoStatus> {
197 let status: Result<RepoStatus> =
198 g1t_kit::call(&self.repos, "status_by_id", &StatusByIdArgs { id: repo_id.to_owned() }).await;
199 Ok(match status {
200 Ok(status) => status,
201 Err(error) => {
202 worker::console_error!("security: status_by_id {repo_id}: {error}");
203 RepoStatus::default()
204 }
205 })
206 }
207
208 /// Whether the repository is neither archived nor deleted. Scanning
209 /// goes on in a mirror: it reads.
210 async fn active(&self, repo_id: &str) -> Result<bool> {
211 Ok(self.status(repo_id).await?.active())
212 }
213
214 /// Whether g1t may open branches and pull requests here on its own
215 /// (security and dependency updates): active, and not a mirror short
216 /// of a takeover (`g1t_contracts::mirrors`). `Err` says why not.
217 async fn takes_updates(&self, repo: &RepoRow) -> Result<std::result::Result<(), String>> {
218 let status = self.status(&repo.repo_id).await?;
219 if status.takes_updates() {
220 return Ok(Ok(()));
221 }
222 Ok(Err(match &status.mirror {
223 Some(mirror) if status.active() => g1t_contracts::mirrors::updates_wait_message(&repo.namespace, &repo.name, mirror),
224 _ => "Updates wait while the repository is archived or deleted.".to_owned(),
225 }))
226 }
227
228 /// Records a repository named in an event, by id. Forks are not
229 /// recorded: a pull request's findings belong to its repository.
230 async fn register_by_id(&self, repo_id: &str) -> Result<Option<RepoRow>> {
231 if let Some(row) = self.store.repo(repo_id).await? {
232 return Ok(Some(row));
233 }
234 let path: Option<RepoPath> = g1t_kit::call(&self.repos, "path_by_id", &PathByIdArgs { id: repo_id.to_owned() }).await?;
235 match path {
236 Some(path) => Ok(Some(self.store.register(repo_id, &path.namespace, &path.name).await?)),
237 None => Ok(None),
238 }
239 }
240
241 async fn overview(&self, a: OverviewArgs) -> Result<Outcome<SecurityOverview>> {
242 let mut repo = match self.member_repo(&a.repo, &a.viewer, SEE_FINDINGS).await? {
243 Outcome::Ok(repo) => repo,
244 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
245 };
246 // The first look at a repository reads its dependencies at once;
247 // its history is scanned in the background.
248 if repo.deps_scanned_at.is_none() {
249 self.scan_dependencies(&repo).await?;
250 repo = self.store.repo(&repo.repo_id).await?.unwrap_or(repo);
251 }
252 let (counts, _, _) = self.store.counts(&repo.repo_id).await?;
253 Ok(Outcome::Ok(SecurityOverview {
254 repo_id: repo.repo_id.clone(),
255 counts,
256 secret_counts: self.store.secret_counts(&repo.repo_id).await?,
257 secrets: self.store.secrets(&repo.repo_id).await?,
258 vulnerabilities: self.store.vulnerabilities(&repo.repo_id).await?,
259 activity: self.store.activity(&repo.repo_id, ACTIVITY_SHOWN).await?,
260 scan: repo.scan_state(),
261 upkeep: repo.upkeep != 0,
262 version_updates: self.version_updates_view(&repo).await?,
263 }))
264 }
265
266 /// What dismissing or reopening alert `id` takes: Write, for a secret
267 /// as for a vulnerable dependency, as on GitHub; a security manager may
268 /// too.
269 fn capability_for(_id: &str) -> Capability {
270 SEE_FINDINGS
271 }
272
273 async fn dismiss(&self, a: DismissArgs) -> Result<Outcome<AlertChange>> {
274 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
275 Outcome::Ok(repo) => repo,
276 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
277 };
278 if !a.actor.verified {
279 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
280 }
281 let comment: String = a.comment.trim().chars().take(MAX_COMMENT_CHARS).collect();
282 let comment = (!comment.is_empty()).then_some(comment);
283 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
284 if !a.reason.for_secrets() {
285 return Ok(fail(
286 FailureCode::Invalid,
287 "A secret is dismissed as false_positive, used_in_tests, revoked or wont_fix.",
288 ));
289 }
290 if finding.state != AlertState::Open {
291 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
292 }
293 self.store
294 .dismiss_secret(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
295 .await?;
296 self.store
297 .record(&repo.repo_id, &[Activity {
298 alert_id: &a.id,
299 action: "dismissed",
300 actor: Some(&a.actor.username),
301 reason: Some(a.reason),
302 comment: comment.as_deref(),
303 number: None,
304 }])
305 .await?;
306 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
307 if let Some(secret) = &secret {
308 // Revoked is fixed; any other reason, dismissed.
309 let action = if a.reason == DismissReason::Revoked { "fixed" } else { "dismissed" };
310 let event = g1t_contracts::security_suite::SecurityEvent {
311 reason: Some(a.reason.as_str().to_owned()),
312 ..secret_alerts::secret_event(&repo, secret)
313 };
314 self.alert_event(g1t_contracts::security_suite::AlertType::SecretScanning, action, &repo, event, Some(a.actor.id.clone()))
315 .await;
316 let changed = summaries::Changed {
317 scan: "change",
318 fixed: if action == "fixed" { vec![a.id.clone()] } else { Vec::new() },
319 dismissed: u32::from(action == "dismissed"),
320 ..summaries::Changed::default()
321 };
322 self.summarize(&repo, g1t_contracts::security_suite::AlertType::SecretScanning, changed, Some(a.actor.id.clone())).await?;
323 }
324 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
325 }
326 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
327 return Ok(fail(FailureCode::NotFound, "No such alert."));
328 };
329 if a.reason.for_secrets() {
330 return Ok(fail(
331 FailureCode::Invalid,
332 "A dependency is dismissed as fix_started, no_bandwidth, tolerable_risk, inaccurate or not_used.",
333 ));
334 }
335 if vuln.state != AlertState::Open {
336 return Ok(fail(FailureCode::Conflict, "This alert is not open. Reopen it first to dismiss it again."));
337 }
338 self.store
339 .dismiss_vulnerability(&repo.repo_id, &a.id, a.reason, &a.actor.username, comment.as_deref())
340 .await?;
341 self.store
342 .record(&repo.repo_id, &[Activity {
343 alert_id: &a.id,
344 action: "dismissed",
345 actor: Some(&a.actor.username),
346 reason: Some(a.reason),
347 comment: comment.as_deref(),
348 number: None,
349 }])
350 .await?;
351 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
352 if let Some(vuln) = &vulnerability {
353 let event = g1t_contracts::security_suite::SecurityEvent { reason: Some(a.reason.as_str().to_owned()), ..deps::vulnerability_event(&repo, vuln) };
354 self.alert_event(g1t_contracts::security_suite::AlertType::Vulnerability, "dismissed", &repo, event, Some(a.actor.id.clone())).await;
355 let changed = summaries::Changed { scan: "change", dismissed: 1, ..summaries::Changed::default() };
356 self.summarize(&repo, g1t_contracts::security_suite::AlertType::Vulnerability, changed, Some(a.actor.id.clone())).await?;
357 }
358 // A security update whose alerts are now all fixed or dismissed closes.
359 if let Err(error) = self.resolve_updates(&repo, None).await {
360 worker::console_error!("security: updates of {} not resolved after a dismissal: {error}", repo.repo_id);
361 }
362 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
363 }
364
365 async fn reopen(&self, a: ReopenArgs) -> Result<Outcome<AlertChange>> {
366 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Self::capability_for(&a.id)).await? {
367 Outcome::Ok(repo) => repo,
368 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
369 };
370 if !a.actor.verified {
371 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
372 }
373 let reopened = Activity { alert_id: &a.id, action: "reopened", actor: Some(&a.actor.username), reason: None, comment: None, number: None };
374 if let Some(finding) = self.store.secret(&repo.repo_id, &a.id).await? {
375 if finding.state == AlertState::Open {
376 return Ok(fail(FailureCode::Conflict, "This alert is already open."));
377 }
378 // A secret that never landed has nothing to reopen to but blocked.
379 let status = if finding.source == "push" && finding.test_value.is_none() { SecretStatus::Blocked } else { SecretStatus::Open };
380 self.store.reopen_secret(&repo.repo_id, &a.id, status).await?;
381 self.store.record(&repo.repo_id, &[reopened]).await?;
382 let secret = self.store.secret(&repo.repo_id, &a.id).await?;
383 if let Some(secret) = &secret {
384 self.alert_event(
385 g1t_contracts::security_suite::AlertType::SecretScanning,
386 "reopened",
387 &repo,
388 secret_alerts::secret_event(&repo, secret),
389 Some(a.actor.id.clone()),
390 )
391 .await;
392 // The thread's counts move; nobody is told anew of what they reopened.
393 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
394 self.summarize(&repo, g1t_contracts::security_suite::AlertType::SecretScanning, changed, Some(a.actor.id.clone())).await?;
395 }
396 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
397 }
398 let Some(vuln) = self.store.vulnerability(&repo.repo_id, &a.id).await? else {
399 return Ok(fail(FailureCode::NotFound, "No such alert."));
400 };
401 if vuln.state != AlertState::Dismissed {
402 return Ok(fail(FailureCode::Conflict, "Only a dismissed alert can be reopened; a fixed one reopens when it is found again."));
403 }
404 self.store.reopen_vulnerability(&repo.repo_id, &a.id).await?;
405 self.store.record(&repo.repo_id, &[reopened]).await?;
406 let vulnerability = self.store.vulnerability(&repo.repo_id, &a.id).await?;
407 if let Some(vuln) = &vulnerability {
408 self.alert_event(
409 g1t_contracts::security_suite::AlertType::Vulnerability,
410 "reopened",
411 &repo,
412 deps::vulnerability_event(&repo, vuln),
413 Some(a.actor.id.clone()),
414 )
415 .await;
416 let changed = summaries::Changed { scan: "change", ..summaries::Changed::default() };
417 self.summarize(&repo, g1t_contracts::security_suite::AlertType::Vulnerability, changed, Some(a.actor.id.clone())).await?;
418 }
419 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
420 }
421
422 async fn rescan(&self, a: RescanArgs) -> Result<Outcome<ScanState>> {
423 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), SEE_FINDINGS).await? {
424 Outcome::Ok(repo) => repo,
425 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
426 };
427 self.store.restart_history(&repo.repo_id).await?;
428 self.scan_dependencies(&repo).await?;
429 if let Some(fresh) = self.store.repo(&repo.repo_id).await? {
430 self.advance_history(&fresh, 1).await?;
431 }
432 let state = self.store.repo(&repo.repo_id).await?.map(|row| row.scan_state()).unwrap_or_default();
433 Ok(Outcome::Ok(state))
434 }
435
436 async fn set_upkeep(&self, a: SetUpkeepArgs) -> Result<Outcome<bool>> {
437 // Whether agents keep its dependencies up to date is one of its
438 // settings.
439 let repo = match self.member_repo(&a.repo, &Some(a.actor.clone()), Capability::ManageSettings).await? {
440 Outcome::Ok(repo) => repo,
441 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
442 };
443 if !a.actor.verified {
444 return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
445 }
446 self.store.set_upkeep(&repo.repo_id, a.enabled, &a.actor.username).await?;
447 Ok(Outcome::Ok(a.enabled))
448 }
449
450 async fn workspace(&self, a: WorkspaceArgs) -> Result<Outcome<Vec<RepoSecurity>>> {
451 let workspace = a.workspace.to_lowercase();
452 if !a.viewer.as_ref().is_some_and(|user| user.is_member(&workspace)) {
453 return Ok(fail(FailureCode::NotFound, "Workspace not found."));
454 }
455 // Only the repositories whose findings the viewer may see. The
456 // Write role is never had through being public, so treating each
457 // as private changes nothing.
458 let repos: Vec<RepoRow> = self
459 .store
460 .in_namespace(&workspace)
461 .await?
462 .into_iter()
463 .filter(|repo| {
464 let target = access::RepoRef { id: &repo.repo_id, namespace: &workspace, private: true };
465 access::can(a.viewer.as_ref(), target, SEE_FINDINGS)
466 })
467 .collect();
468 // Every repository's counts at once, not one after another.
469 let counts = futures_util::future::try_join_all(repos.iter().map(|repo| self.store.counts(&repo.repo_id))).await?;
470 let list = repos
471 .into_iter()
472 .zip(counts)
473 .map(|(repo, (counts, secrets, vulnerabilities))| RepoSecurity {
474 repo_id: repo.repo_id,
475 name: repo.name,
476 counts,
477 secrets,
478 vulnerabilities,
479 upkeep: repo.upkeep != 0,
480 dependencies_scanned_at: repo.deps_scanned_at,
481 })
482 .collect();
483 Ok(Outcome::Ok(list))
484 }
485
486 /// Push protection's question: which of these secrets were allowed?
487 /// The others are recorded as blocked, so someone can allow them.
488 async fn push_blocked(&self, a: PushBlockedArgs) -> Result<PushVerdict> {
489 let repo = self.store.register(&a.repo_id, &a.path.namespace, &a.path.name).await?;
490 if let Some(private) = a.private {
491 self.store.set_private(&a.repo_id, private).await?;
492 }
493 let fingerprints: Vec<String> = a.secrets.iter().map(|secret| secret.fingerprint.clone()).collect();
494 let known = self.store.known(&a.repo_id, &fingerprints).await?;
495 let mut allowed = let_through(&known, &a.secrets);
496 // Bypassed with a reason: let through, whatever the alert says now.
497 allowed.extend(self.store.bypassed(&a.repo_id, &fingerprints).await?);
498 allowed.sort();
499 allowed.dedup();
500 let all = a.secrets.clone();
501 let fresh: Vec<NewSecret> = a
502 .secrets
503 .into_iter()
504 .filter(|secret| !known.iter().any(|(fingerprint, _, _)| *fingerprint == secret.fingerprint))
505 .collect();
506 // A likely test value goes through, so it lands: open, not blocked.
507 let (tests, real): (Vec<NewSecret>, Vec<NewSecret>) = fresh.into_iter().partition(|secret| secret.test_value.is_some());
508 self.store
509 .add_secrets(&a.repo_id, &real, SecretStatus::Blocked, "push", a.pusher.as_deref())
510 .await?;
511 self.store
512 .add_secrets(&a.repo_id, &tests, SecretStatus::Open, "push", a.pusher.as_deref())
513 .await?;
514 let fresh: Vec<String> = real.iter().map(|secret| secret.fingerprint.clone()).collect();
515 self.secrets_found(&repo, &all, "push", &fresh, a.pusher.as_deref()).await?;
516 let ids = self
517 .store
518 .known(&a.repo_id, &fingerprints)
519 .await?
520 .into_iter()
521 .filter(|(fingerprint, _, _)| !allowed.contains(fingerprint))
522 .map(|(fingerprint, id, _)| (fingerprint, id))
523 .collect();
524 Ok(PushVerdict { allowed, ids })
525 }
526
527 /// What happens on the bus that concerns this service.
528 async fn on_event(&self, event: &Event) -> Result<()> {
529 match event.kind.as_str() {
530 "git.push" => {
531 let Ok(pushed) = serde_json::from_value::<Pushed>(event.data.clone()) else {
532 return Ok(());
533 };
534 // Too large to scan before it was stored: its new commits
535 // are scanned now, on whichever branch.
536 if pushed.unscanned
537 && !pushed.after.is_empty()
538 && let Some(repo) = self.register_by_id(&pushed.repo_id).await?
539 {
540 let id = self
541 .store
542 .add_push_scan(&repo.repo_id, &pushed.git_ref, &pushed.after, pushed.before.as_deref(), event.actor.as_deref())
543 .await?;
544 self.advance_push_scan(&id, history::PUSH_PAGES_AT_ONCE).await?;
545 }
546 // A version update's branch, or a grouped security update's,
547 // pushed by its sandbox: time for its pull request.
548 if !pushed.default_branch
549 && let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
550 && self.update_pull_pushed(&pushed.repo_id, branch, &pushed.after).await?
551 {
552 return Ok(());
553 }
554 // A security update's branch, pushed by its sandbox: time for
555 // its pull request.
556 if let Some(branch) = pushed.git_ref.strip_prefix("refs/heads/")
557 && branch.starts_with(UPDATE_BRANCH_PREFIX)
558 {
559 self.update_pushed(&pushed.repo_id, branch, &pushed.after).await?;
560 return Ok(());
561 }
562 if !pushed.default_branch {
563 return Ok(());
564 }
565 if let Some(repo) = self.register_by_id(&pushed.repo_id).await? {
566 self.scan_dependencies(&repo).await?;
567 if repo.history != "done" {
568 self.advance_history(&repo, 1).await?;
569 }
570 }
571 }
572 "pull.merged" | "pull.closed" | "checks.completed" => {
573 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone())
574 && !(event.kind != "checks.completed" && self.update_pull_closed(&event.kind, &happened.repo_id, happened.number).await?)
575 {
576 self.update_pull_event(
577 &event.kind,
578 &happened.repo_id,
579 happened.number,
580 happened.status.as_deref(),
581 event.actor.as_deref(),
582 )
583 .await?;
584 }
585 // One of g1t's update pull requests closed or merged, by g1t
586 // or by a person: its branch goes.
587 if event.kind != "checks.completed"
588 && let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone())
589 {
590 self.pull_finished(&happened.repo_id, happened.number).await?;
591 }
592 }
593 "comment.created" => self.update_comment(event).await?,
594 // A pull request opened or its head moved: a dependency update
595 // file it changes is checked (not on `pull.ready`, which moves
596 // nothing), and dependency review runs.
597 "pull.opened" | "pull.updated" | "pull.ready" => {
598 if event.kind != "pull.ready" {
599 self.check_dependabot_file(event).await?;
600 }
601 if let Ok(happened) = serde_json::from_value::<PullHappened>(event.data.clone()) {
602 self.review_pull(&happened.repo_id, happened.number).await?;
603 }
604 }
605 "repo.created" => {
606 if let Ok(created) = serde_json::from_value::<Created>(event.data.clone()) {
607 // Recorded with its visibility, so the overview never takes a
608 // public repository for a private one.
609 if self.register_by_id(&created.repo_id).await?.is_some()
610 && let Some(private) = created.is_private
611 {
612 self.store.set_private(&created.repo_id, private).await?;
613 }
614 }
615 }
616 "repo.visibility_changed" => {
617 if let Ok(changed) = serde_json::from_value::<g1t_contracts::events::RepoVisibilityChanged>(event.data.clone())
618 && self.store.repo(&changed.repo_id).await?.is_some()
619 {
620 self.store.set_private(&changed.repo_id, changed.is_private).await?;
621 }
622 }
623 // A repository transferred or renamed: it is recorded under its new path.
624 "repo.transferred" | "repo.renamed" => {
625 if let Some(moved) = g1t_kit::transfer::read(event) {
626 // Where it is now, so moves heard out of order end in
627 // the same place.
628 let now: Option<g1t_contracts::repos::RepoPath> = g1t_kit::call(
629 &self.repos,
630 "path_by_id",
631 &g1t_contracts::repos::PathByIdArgs { id: moved.repo_id.clone() },
632 )
633 .await?;
634 let current = now.map_or_else(
635 || moved.destination().to_owned(),
636 |path| format!("{}/{}", path.namespace, path.name),
637 );
638 if let Some((namespace, name)) = current.split_once('/') {
639 self.store.moved(&moved.repo_id, namespace, name).await?;
640 }
641 }
642 }
643 // A repository purged: everything found in it goes.
644 "repo.purged" => {
645 if let Some(g1t_kit::lifecycle::Lifecycle::Purged(purged)) = g1t_kit::lifecycle::read(event) {
646 self.store.purge_advisories(&purged.repo_id).await?;
647 self.store.purge_suite(&purged.repo_id).await?;
648 self.store.purge(&purged.repo_id).await?;
649 }
650 }
651 "workspace.renamed" => {
652 if let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) {
653 self.store.rename_namespace(&renamed.stale_slugs(&renamed.to), &renamed.to).await?;
654 }
655 }
656 _ => {}
657 }
658 Ok(())
659 }
660
661 /// The sweep: continues history scans and scans of pushes that landed
662 /// unscanned, catches security updates whose sandbox never pushed, and
663 /// reads dependencies that have not been read for a day.
664 async fn sweep(&self) -> Result<()> {
665 for scan in self.store.pending_push_scans(HISTORIES_PER_SWEEP).await? {
666 if let Err(error) = self.advance_push_scan(&scan.id, PAGES_PER_SWEEP).await {
667 worker::console_error!("security: push scan {} not continued: {error}", scan.id);
668 }
669 }
670 if let Err(error) = self.stalled_updates().await {
671 worker::console_error!("security: stalled security updates not handled: {error}");
672 }
673 if let Err(error) = self.sweep_suite().await {
674 worker::console_error!("security: daily snapshots and validity checks: {error}");
675 }
676 // Archived and deleted repositories wait; a few more are looked at
677 // so that they do not hold up the rest.
678 let mut histories = 0;
679 for repo in self.store.unfinished_histories(HISTORIES_PER_SWEEP * 4).await? {
680 if histories == HISTORIES_PER_SWEEP {
681 break;
682 }
683 if !self.active(&repo.repo_id).await? {
684 continue;
685 }
686 histories += 1;
687 if let Err(error) = self.advance_history(&repo, PAGES_PER_SWEEP).await {
688 worker::console_error!("security: history of {} not scanned: {error}", repo.repo_id);
689 }
690 }
691 let day_ago = rfc3339(now_ms().saturating_sub(DAY_MS));
692 for repo in self.store.stale_dependencies(&day_ago, DEPENDENCIES_PER_SWEEP).await? {
693 if !self.active(&repo.repo_id).await? {
694 self.store
695 .skip_dependencies(&repo.repo_id, "Dependencies are not checked while the repository is archived or deleted.")
696 .await?;
697 continue;
698 }
699 if let Err(error) = self.scan_dependencies(&repo).await {
700 worker::console_error!("security: dependencies of {} not read: {error}", repo.repo_id);
701 }
702 }
703 Ok(())
704 }
705}
706
707/// The fingerprints a push may carry: those someone dismissed (allowed),
708/// and likely test values, which are recorded but never stop a push.
709/// `known` is (fingerprint, id, status) of findings already recorded.
710fn let_through(known: &[(String, String, String)], secrets: &[NewSecret]) -> Vec<String> {
711 let mut allowed: Vec<String> = known
712 .iter()
713 .filter(|(_, _, status)| status == "allowed")
714 .map(|(fingerprint, _, _)| fingerprint.clone())
715 .chain(secrets.iter().filter(|secret| secret.test_value.is_some()).map(|secret| secret.fingerprint.clone()))
716 .collect();
717 allowed.sort();
718 allowed.dedup();
719 allowed
720}
721
722#[event(fetch)]
723async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
724 let Some(method) = rpc_method(&request) else {
725 return Response::error("Not found", 404);
726 };
727 let security = Security::new(&env)?;
728 let body: serde_json::Value = request.json().await?;
729 match method.as_str() {
730 "overview" => reply(&security.overview(args(body)?).await?),
731 "dismiss" => reply(&security.dismiss(args(body)?).await?),
732 "reopen" => reply(&security.reopen(args(body)?).await?),
733 "rescan" => reply(&security.rescan(args(body)?).await?),
734 "set_upkeep" => reply(&security.set_upkeep(args(body)?).await?),
735 "workspace" => reply(&security.workspace(args(body)?).await?),
736 "push_blocked" => reply(&security.push_blocked(args(body)?).await?),
737 "check_updates" => reply(&security.check_updates(args(body)?).await?),
738 // The security suite.
739 "patterns_for" => reply(&security.patterns_for(args(body)?).await?),
740 "custom_patterns" => reply(&security.custom_patterns(args(body)?).await?),
741 "save_custom_pattern" => reply(&security.save_custom_pattern(args(body)?).await?),
742 "delete_custom_pattern" => reply(&security.delete_custom_pattern(args(body)?).await?),
743 "dry_run_pattern" => reply(&security.dry_run_pattern(args(body)?).await?),
744 "secret_alert" => reply(&security.secret_alert(args(body)?).await?),
745 "bypass" => reply(&security.bypass(args(body)?).await?),
746 "bypass_requests" => reply(&security.bypass_requests(args(body)?).await?),
747 "review_bypass" => reply(&security.review_bypass(args(body)?).await?),
748 "check_validity" => reply(&security.check_validity(args(body)?).await?),
749 "upload_sarif" => reply(&security.upload_sarif(args(body)?).await?),
750 "sarif_status" => reply(&security.sarif_status(args(body)?).await?),
751 "code_scanning" => reply(&security.code_scanning(args(body)?).await?),
752 "code_alert" => reply(&security.code_alert(args(body)?).await?),
753 "set_code_alert_state" => reply(&security.set_code_alert_state(args(body)?).await?),
754 "pull_code_scanning" => reply(&security.pull_code_scanning(args(body)?).await?),
755 "fix_alert" => reply(&security.fix_alert(args(body)?).await?),
756 "dependency_graph" => reply(&security.dependency_graph(args(body)?).await?),
757 "sbom" => reply(&security.sbom(args(body)?).await?),
758 "dependency_review" => reply(&security.dependency_review(args(body)?).await?),
759 "security_settings" => reply(&security.security_settings(args(body)?).await?),
760 "set_security_settings" => reply(&security.set_security_settings(args(body)?).await?),
761 "workspace_security_settings" => reply(&security.workspace_security_settings(args(body)?).await?),
762 "set_workspace_security_settings" => reply(&security.set_workspace_security_settings(args(body)?).await?),
763 "security_overview" => reply(&security.security_overview(args(body)?).await?),
764 "workspace_alerts" => reply(&security.workspace_alerts(args(body)?).await?),
765 // Reporting: advisories and private vulnerability reports (advisories.rs).
766 "list_advisories" => reply(&security.list_advisories(args(body)?).await?),
767 "get_advisory" => reply(&security.get_advisory(args(body)?).await?),
768 "create_advisory" => reply(&security.create_advisory(args(body)?).await?),
769 "update_advisory" => reply(&security.update_advisory(args(body)?).await?),
770 "set_advisory_state" => reply(&security.set_advisory_state(args(body)?).await?),
771 "report_vulnerability" => reply(&security.report_vulnerability(args(body)?).await?),
772 "report_settings" => reply(&security.report_settings(args(body)?).await?),
773 "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?),
774 "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?),
775 _ => Response::error("Unknown method", 404),
776 }
777}
778
779#[event(queue)]
780async fn queue(batch: MessageBatch<Event>, env: Env, _ctx: Context) -> Result<()> {
781 let security = Security::new(&env)?;
782 for message in batch.messages()? {
783 let event = message.body();
784 if let Err(error) = security.on_event(event).await {
785 // Scans are idempotent and the sweep catches up, so one failed
786 // event is logged rather than retried.
787 worker::console_error!("security: {} {} failed: {error}", event.kind, event.id);
788 }
789 }
790 Ok(())
791}
792
793/// The cron (wrangler.jsonc) that runs version updates that are due.
794const VERSION_UPDATES_CRON: &str = "*/5 * * * *";
795
796#[event(scheduled)]
797async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
798 match Security::new(&env) {
799 // Version updates run every few minutes, so a schedule's time is kept.
800 Ok(security) if event.cron() == VERSION_UPDATES_CRON => {
801 if let Err(error) = security.version_update_sweep().await {
802 worker::console_error!("security: the version update sweep failed: {error}");
803 }
804 }
805 Ok(security) => {
806 if let Err(error) = security.sweep().await {
807 worker::console_error!("security: the sweep failed: {error}");
808 }
809 }
810 Err(error) => worker::console_error!("security: could not start: {error}"),
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use super::*;
817
818 fn secret(fingerprint: &str, test_value: Option<&str>) -> NewSecret {
819 NewSecret {
820 fingerprint: fingerprint.into(),
821 kind: "aws_access_key".into(),
822 path: "a.env".into(),
823 line: 1,
824 commit: "c".into(),
825 preview: "AKIA…".into(),
826 test_value: test_value.map(str::to_owned),
827 pattern_id: None,
828 pattern_name: None,
829 }
830 }
831
832 #[test]
833 fn dismissed_secrets_and_test_values_go_through() {
834 let known = vec![
835 ("allowed".to_owned(), "sec_1".to_owned(), "allowed".to_owned()),
836 ("resolved".to_owned(), "sec_2".to_owned(), "resolved".to_owned()),
837 ("blocked".to_owned(), "sec_3".to_owned(), "blocked".to_owned()),
838 ];
839 let secrets = [secret("allowed", None), secret("resolved", None), secret("example", Some("it says it is an example")), secret("real", None)];
840 assert_eq!(let_through(&known, &secrets), ["allowed", "example"]);
841 }
842
843 #[test]
844 fn a_push_says_when_it_landed_unscanned() {
845 let pushed: Pushed = serde_json::from_value(serde_json::json!({
846 "repoId": "rep_1", "ref": "refs/heads/import", "after": "abc", "defaultBranch": false, "unscanned": true
847 }))
848 .unwrap();
849 assert!(pushed.unscanned && pushed.before.is_none() && pushed.git_ref == "refs/heads/import");
850 let ordinary: Pushed = serde_json::from_value(serde_json::json!({ "repoId": "rep_1", "ref": "refs/heads/main", "after": "abc", "defaultBranch": true })).unwrap();
851 assert!(!ordinary.unscanned);
852 }
853
854 #[test]
855 fn owners_are_told_what_landed_and_what_to_do() {
856 let one = history::landed_secrets_intro("acme", "rocket", "import", 1);
857 assert!(one.contains("A push to import in acme/rocket") && one.contains("a secret that looks real") && one.contains("Rotate"));
858 assert!(history::landed_secrets_intro("acme", "rocket", "main", 3).contains("3 secrets that look real"));
859 }
860
861 #[test]
862 fn dismissing_a_secret_takes_admin_and_a_dependency_write() {
863 assert_eq!(Security::capability_for("sec_1"), Capability::SecurityAlerts);
864 assert_eq!(Security::capability_for("vul_1"), Capability::SecurityAlerts);
865 }
866}