Skip to content

Commit

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.

syntaqxcommitted Parentd0fd07eBrowse files
32 files+2616−1170/32 viewed
+3−2
1−import { AtSign, BellRing, CornerDownLeft, GitPullRequest, Hourglass, Bell, MessageCircle, MessagesSquare, X } from "lucide-react";
1+import { AtSign, BellRing, CornerDownLeft, GitPullRequest, Hourglass, Bell, MessageCircle, MessagesSquare, ShieldAlert, X } from "lucide-react";
22 import { type FormEvent, type ReactNode, useLayoutEffect, useRef, useState } from "react";
33
44 import type { FeedNotification, NotificationKind } from "@g1t/contracts";
1818 inbox: { label: "Notifications", icon: <Bell /> },
1919 agent_waiting: { label: "Waiting on you", icon: <Hourglass /> },
2020 approval: { label: "Needs your review", icon: <GitPullRequest /> },
21+ security: { label: "Security alert", icon: <ShieldAlert /> },
2122 };
2223
2324 /** Room kept clear of the stack: the top bar and the edge on a computer; the top inset and the tab bar on a phone. */
2526 const PHONE_MARGIN = 112;
2627
2728 /** Said to you, so drawn in the accent. */
28−const LOUD: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "agent_waiting", "approval"]);
29+const LOUD: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "agent_waiting", "approval", "security"]);
2930
3031 // The toast's entrance (up from the corner on a computer, down from the top
3132 // on a phone) and its timer, a line that empties over TOAST_MS. Paused while
+1−0
8181 return "direct";
8282 case "mention":
8383 case "approval":
84+ case "security":
8485 return "mention";
8586 case "thread_reply":
8687 return "message";
+315−0
1+//! Repository security advisories and private vulnerability reporting,
2+//! the Reporting half of a repository's Security page.
3+//!
4+//! An **advisory** is a repository's own account of a vulnerability in
5+//! what it ships: what is affected, what fixes it, how bad it is and who
6+//! found it. Maintainers write it as a draft, then publish it; a published
7+//! advisory is visible to anyone who can see the repository, and may be
8+//! withdrawn. Each has an id of the form `G1TSA-2026-abcd-efgh`.
9+//!
10+//! A **vulnerability report** is how someone outside tells the maintainers
11+//! privately, when the repository has private vulnerability reporting on:
12+//! a signed-in person who can see the repository, or anyone with an email
13+//! address on a public one. The maintainers accept it, which makes a draft
14+//! advisory from it, or decline it. Reporters never see the repository's
15+//! other reports.
16+//!
17+//! Mirrors `packages/contracts/src/advisories.ts`.
18+
19+use serde::{Deserialize, Serialize};
20+
21+use crate::User;
22+use crate::repos::RepoPath;
23+
24+/// The severities an advisory or report takes.
25+pub const SEVERITIES: [&str; 4] = ["critical", "high", "medium", "low"];
26+
27+/// An advisory's states.
28+pub const ADVISORY_STATES: [&str; 3] = ["draft", "published", "withdrawn"];
29+
30+/// A report's states.
31+pub const REPORT_STATES: [&str; 3] = ["new", "accepted", "declined"];
32+
33+/// The longest title, description and version range kept.
34+pub const MAX_TITLE_CHARS: usize = 200;
35+pub const MAX_DESCRIPTION_CHARS: usize = 20_000;
36+pub const MAX_RANGE_CHARS: usize = 200;
37+/// The most people credited on one advisory.
38+pub const MAX_CREDITS: usize = 20;
39+
40+/// What a repository says about a vulnerability in what it ships.
41+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
42+#[serde(rename_all = "camelCase")]
43+pub struct Advisory {
44+ /// `adv_…`, for the API and pages.
45+ pub id: String,
46+ /// `G1TSA-2026-abcd-efgh`: what people quote.
47+ pub g1tsa_id: String,
48+ pub repo_id: String,
49+ /// `owner/name`.
50+ pub repo: String,
51+ pub title: String,
52+ /// Markdown: what it is, how it is exploited, what to do.
53+ pub description: String,
54+ /// `critical`, `high`, `medium` or `low`.
55+ pub severity: String,
56+ /// A CVSS vector string, when the maintainers scored it, and its score.
57+ #[serde(default, skip_serializing_if = "Option::is_none")]
58+ pub cvss_vector: Option<String>,
59+ #[serde(default, skip_serializing_if = "Option::is_none")]
60+ pub cvss_score: Option<f64>,
61+ /// `CVE-2026-12345`, when one was issued elsewhere.
62+ #[serde(default, skip_serializing_if = "Option::is_none")]
63+ pub cve_id: Option<String>,
64+ /// The package affected: its ecosystem (`npm`, `crates.io`, `Go`,
65+ /// `PyPI`, or how the project is distributed), its name, the versions
66+ /// affected and the versions that fix it, as people write ranges
67+ /// (`>= 1.2.0, < 1.4.1`).
68+ #[serde(default, skip_serializing_if = "Option::is_none")]
69+ pub ecosystem: Option<String>,
70+ #[serde(default, skip_serializing_if = "Option::is_none")]
71+ pub package: Option<String>,
72+ #[serde(default, skip_serializing_if = "Option::is_none")]
73+ pub affected_versions: Option<String>,
74+ #[serde(default, skip_serializing_if = "Option::is_none")]
75+ pub patched_versions: Option<String>,
76+ /// `draft`, `published` or `withdrawn`.
77+ pub state: String,
78+ /// Who found or fixed it: usernames or names, as the maintainers wrote them.
79+ #[serde(default)]
80+ pub credits: Vec<String>,
81+ /// The report it was made from, if any.
82+ #[serde(default, skip_serializing_if = "Option::is_none")]
83+ pub report_id: Option<String>,
84+ pub created_by: String,
85+ pub created_at: String,
86+ pub updated_at: String,
87+ #[serde(default, skip_serializing_if = "Option::is_none")]
88+ pub published_at: Option<String>,
89+ #[serde(default, skip_serializing_if = "Option::is_none")]
90+ pub withdrawn_at: Option<String>,
91+}
92+
93+/// What a maintainer writes when creating or changing an advisory.
94+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95+#[serde(rename_all = "camelCase")]
96+pub struct AdvisoryInput {
97+ pub title: String,
98+ #[serde(default)]
99+ pub description: String,
100+ pub severity: String,
101+ #[serde(default)]
102+ pub cvss_vector: Option<String>,
103+ #[serde(default)]
104+ pub cvss_score: Option<f64>,
105+ #[serde(default)]
106+ pub cve_id: Option<String>,
107+ #[serde(default)]
108+ pub ecosystem: Option<String>,
109+ #[serde(default)]
110+ pub package: Option<String>,
111+ #[serde(default)]
112+ pub affected_versions: Option<String>,
113+ #[serde(default)]
114+ pub patched_versions: Option<String>,
115+ #[serde(default)]
116+ pub credits: Vec<String>,
117+}
118+
119+/// `list_advisories`: a repository's advisories, newest first. Anyone who
120+/// can see the repository sees the published ones; those who see its
121+/// findings (Write, or a security manager) see drafts and withdrawn ones
122+/// too. Returns `Outcome<Vec<Advisory>>`.
123+#[derive(Debug, Serialize, Deserialize)]
124+pub struct ListAdvisoriesArgs {
125+ pub viewer: Option<User>,
126+ pub repo: RepoPath,
127+ /// `draft`, `published` or `withdrawn`; all the viewer may see when absent.
128+ #[serde(default)]
129+ pub state: Option<String>,
130+}
131+
132+/// `get_advisory`: one advisory by `adv_…` id or `G1TSA-…` id, with the
133+/// same visibility. Returns `Outcome<Advisory>`.
134+#[derive(Debug, Serialize, Deserialize)]
135+pub struct GetAdvisoryArgs {
136+ pub viewer: Option<User>,
137+ pub repo: RepoPath,
138+ pub id: String,
139+}
140+
141+/// `create_advisory`: a draft. Admin on the repository, or a security
142+/// manager of its workspace (`ManageSecurity`). Returns `Outcome<Advisory>`.
143+#[derive(Debug, Serialize, Deserialize)]
144+pub struct CreateAdvisoryArgs {
145+ pub actor: User,
146+ pub repo: RepoPath,
147+ pub advisory: AdvisoryInput,
148+}
149+
150+/// `update_advisory`: changes an advisory's text and fields in any state;
151+/// a published one's change is visible at once. Returns `Outcome<Advisory>`.
152+#[derive(Debug, Serialize, Deserialize)]
153+pub struct UpdateAdvisoryArgs {
154+ pub actor: User,
155+ pub repo: RepoPath,
156+ pub id: String,
157+ pub advisory: AdvisoryInput,
158+}
159+
160+/// `set_advisory_state`: publishes a draft (`published`), withdraws a
161+/// published one (`withdrawn`), or puts a withdrawn one back
162+/// (`published`). Publishing tells webhooks and the repository's watchers.
163+/// Returns `Outcome<Advisory>`.
164+#[derive(Debug, Serialize, Deserialize)]
165+pub struct SetAdvisoryStateArgs {
166+ pub actor: User,
167+ pub repo: RepoPath,
168+ pub id: String,
169+ pub state: String,
170+}
171+
172+/// Something someone told the maintainers privately.
173+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
174+#[serde(rename_all = "camelCase")]
175+pub struct VulnerabilityReport {
176+ /// `vrp_…`.
177+ pub id: String,
178+ pub repo_id: String,
179+ pub repo: String,
180+ /// The reporter: a username when signed in, else the address they gave.
181+ #[serde(default, skip_serializing_if = "Option::is_none")]
182+ pub reporter: Option<String>,
183+ #[serde(default, skip_serializing_if = "Option::is_none")]
184+ pub reporter_email: Option<String>,
185+ pub title: String,
186+ pub description: String,
187+ /// What the reporter thinks: `critical`, `high`, `medium` or `low`, or none.
188+ #[serde(default, skip_serializing_if = "Option::is_none")]
189+ pub severity: Option<String>,
190+ /// `new`, `accepted` or `declined`.
191+ pub state: String,
192+ /// The draft advisory an accepted report became.
193+ #[serde(default, skip_serializing_if = "Option::is_none")]
194+ pub advisory_id: Option<String>,
195+ pub created_at: String,
196+ #[serde(default, skip_serializing_if = "Option::is_none")]
197+ pub reviewed_by: Option<String>,
198+ #[serde(default, skip_serializing_if = "Option::is_none")]
199+ pub reviewed_at: Option<String>,
200+ #[serde(default, skip_serializing_if = "Option::is_none")]
201+ pub review_note: Option<String>,
202+}
203+
204+/// `report_vulnerability`: tells the maintainers privately. Needs private
205+/// vulnerability reporting on for the repository; anyone who can see the
206+/// repository may report, and on a public one someone signed out may with
207+/// an `email`. Returns `Outcome<VulnerabilityReport>`; the reporter sees
208+/// only their own report.
209+#[derive(Debug, Serialize, Deserialize)]
210+pub struct ReportVulnerabilityArgs {
211+ pub viewer: Option<User>,
212+ pub repo: RepoPath,
213+ pub title: String,
214+ pub description: String,
215+ #[serde(default)]
216+ pub severity: Option<String>,
217+ /// For someone signed out: where the maintainers can answer.
218+ #[serde(default)]
219+ pub email: Option<String>,
220+}
221+
222+/// `list_vulnerability_reports`: a repository's reports, newest first.
223+/// Those who see findings. Returns `Outcome<Vec<VulnerabilityReport>>`.
224+#[derive(Debug, Serialize, Deserialize)]
225+pub struct ListReportsArgs {
226+ pub viewer: Option<User>,
227+ pub repo: RepoPath,
228+ /// `new`, `accepted` or `declined`; all when absent.
229+ #[serde(default)]
230+ pub state: Option<String>,
231+}
232+
233+/// `review_vulnerability_report`: `accept`, which makes a draft advisory
234+/// from it, or `decline`. `ManageSecurity`. Returns `Outcome<ReviewedReport>`.
235+#[derive(Debug, Serialize, Deserialize)]
236+pub struct ReviewReportArgs {
237+ pub actor: User,
238+ pub repo: RepoPath,
239+ pub id: String,
240+ /// `accept` or `decline`.
241+ pub decision: String,
242+ #[serde(default)]
243+ pub note: String,
244+}
245+
246+#[derive(Clone, Debug, Serialize, Deserialize)]
247+#[serde(rename_all = "camelCase")]
248+pub struct ReviewedReport {
249+ pub report: VulnerabilityReport,
250+ /// The draft made, on accept.
251+ #[serde(default, skip_serializing_if = "Option::is_none")]
252+ pub advisory: Option<Advisory>,
253+}
254+
255+/// What `security_advisory.published`, `.withdrawn` and `.reported` carry,
256+/// for webhooks and the inbox. A report's event names no reporter and
257+/// carries no description: it tells the maintainers to look.
258+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
259+#[serde(rename_all = "camelCase")]
260+pub struct AdvisoryEvent {
261+ pub repo_id: String,
262+ /// `adv_…`, or for a report `vrp_…`.
263+ pub id: String,
264+ #[serde(default, skip_serializing_if = "Option::is_none")]
265+ pub g1tsa_id: Option<String>,
266+ pub title: String,
267+ #[serde(default, skip_serializing_if = "Option::is_none")]
268+ pub severity: Option<String>,
269+ pub state: String,
270+ /// Its page, from the site's root.
271+ pub link: String,
272+ /// Who hears of it: the workspace's owners and security managers, for
273+ /// a report. Usernames.
274+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
275+ pub notify: Vec<String>,
276+}
277+
278+/// The id people quote, from a time and some randomness: `G1TSA-2026-abcd-efgh`.
279+pub fn g1tsa_id(year: u32, random: &[u8]) -> String {
280+ const ALPHABET: &[u8; 32] = b"abcdefghjkmnpqrstuvwxyz23456789-";
281+ let mut letters: Vec<char> = random.iter().take(8).map(|byte| ALPHABET[(*byte as usize) % 31] as char).collect();
282+ while letters.len() < 8 {
283+ letters.push('a');
284+ }
285+ let first: String = letters[..4].iter().collect();
286+ let second: String = letters[4..].iter().collect();
287+ format!("G1TSA-{year}-{first}-{second}")
288+}
289+
290+/// Whether text is a `G1TSA-…` id.
291+pub fn is_g1tsa_id(text: &str) -> bool {
292+ text.starts_with("G1TSA-")
293+}
294+
295+#[cfg(test)]
296+mod tests {
297+ use super::*;
298+
299+ #[test]
300+ fn ids_people_quote_have_a_year_and_eight_letters() {
301+ let id = g1tsa_id(2026, &[0, 1, 2, 3, 4, 5, 6, 7]);
302+ assert_eq!(id, "G1TSA-2026-abcd-efgh");
303+ assert!(is_g1tsa_id(&id) && !is_g1tsa_id("adv_1"));
304+ // Short randomness is padded, never a panic.
305+ assert_eq!(g1tsa_id(2026, &[]).len(), "G1TSA-2026-aaaa-aaaa".len());
306+ }
307+
308+ #[test]
309+ fn events_leave_out_what_is_empty() {
310+ let event = AdvisoryEvent { repo_id: "rep_1".into(), id: "vrp_1".into(), title: "XSS in the search box".into(), state: "new".into(), ..Default::default() };
311+ let json = serde_json::to_value(&event).unwrap();
312+ assert!(json.get("g1tsaId").is_none() && json.get("notify").is_none());
313+ assert_eq!(json["repoId"], "rep_1");
314+ }
315+}
+3−0
307307 "get_security_settings",
308308 "get_workspace_security_settings",
309309 "get_security_overview",
310+ "list_security_advisories",
311+ "get_security_advisory",
312+ "list_vulnerability_reports",
310313 "list_notifications",
311314 "get_notification_thread",
312315 "get_thread_subscription",
+54−2
562562 pub events: Vec<String>,
563563 }
564564
565+/// When security alerts that are not urgent (medium, low, and what is only
566+/// to know) reach a person: in a digest once a day or once a week, at once
567+/// like the urgent ones, or not at all. Critical and high alerts, a refused
568+/// push and a vulnerability report never wait for the digest.
569+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
570+#[serde(rename_all = "snake_case")]
571+pub enum DigestCadence {
572+ #[default]
573+ Daily,
574+ Weekly,
575+ AtOnce,
576+ Off,
577+}
578+
579+impl DigestCadence {
580+ pub const ALL: [DigestCadence; 4] = [DigestCadence::Daily, DigestCadence::Weekly, DigestCadence::AtOnce, DigestCadence::Off];
581+
582+ pub fn as_str(self) -> &'static str {
583+ match self {
584+ DigestCadence::Daily => "daily",
585+ DigestCadence::Weekly => "weekly",
586+ DigestCadence::AtOnce => "at_once",
587+ DigestCadence::Off => "off",
588+ }
589+ }
590+
591+ pub fn parse(value: &str) -> Option<DigestCadence> {
592+ DigestCadence::ALL.into_iter().find(|cadence| cadence.as_str() == value)
593+ }
594+}
595+
565596 /// A person's choices about being told.
566597 #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
567598 #[serde(rename_all = "camelCase")]
570601 pub email: Vec<Reason>,
571602 /// How they watch a repository they create.
572603 pub default_watch: WatchLevel,
604+ /// Whether critical and high security alerts on repositories they watch
605+ /// for security reach their inbox at once. A workspace's owners and
606+ /// security managers are told whatever this says: those alerts are
607+ /// theirs to act on.
608+ #[serde(default = "yes")]
609+ pub security_alerts: bool,
610+ /// When the rest of the security alerts reach them.
611+ #[serde(default)]
612+ pub security_digest: DigestCadence,
613+}
614+
615+fn yes() -> bool {
616+ true
573617 }
574618
575619 impl Default for InboxSettings {
577621 InboxSettings {
578622 email: DEFAULT_EMAIL.to_vec(),
579623 default_watch: WatchLevel::All,
624+ security_alerts: true,
625+ security_digest: DigestCadence::Daily,
580626 }
581627 }
582628 }
583629
584−/// What a person is emailed for until they choose: what is waiting on them.
585−pub const DEFAULT_EMAIL: [Reason; 3] = [Reason::Agent, Reason::ReviewRequested, Reason::Mention];
630+/// What a person is emailed for until they choose: what is waiting on
631+/// them, and security alerts, which reach their inbox only when urgent or
632+/// as their digest.
633+pub const DEFAULT_EMAIL: [Reason; 4] = [Reason::Agent, Reason::ReviewRequested, Reason::Mention, Reason::SecurityAlert];
586634
587635 /// `inbox_update_settings`. What is left out is unchanged.
588636 #[derive(Clone, Debug, Serialize, Deserialize)]
593641 pub email: Option<Vec<Reason>>,
594642 #[serde(default)]
595643 pub default_watch: Option<WatchLevel>,
644+ #[serde(default)]
645+ pub security_alerts: Option<bool>,
646+ #[serde(default)]
647+ pub security_digest: Option<DigestCadence>,
596648 }
597649
598650 /// `inbox_subject` on the work service: what an event names, for the
+1−0
66
77 pub mod about;
88 pub mod access;
9+pub mod advisories;
910 pub mod account_deletion;
1011 pub mod accounts;
1112 pub mod actions;
+8−0
10671067 ("get_security_settings", Scope::SecurityRead),
10681068 ("get_workspace_security_settings", Scope::SecurityRead),
10691069 ("get_security_overview", Scope::SecurityRead),
1070+ ("list_security_advisories", Scope::SecurityRead),
1071+ ("get_security_advisory", Scope::SecurityRead),
1072+ ("list_vulnerability_reports", Scope::SecurityRead),
10701073 ("update_secret_scanning_alert", Scope::SecurityWrite),
10711074 ("bypass_push_protection", Scope::SecurityWrite),
10721075 ("check_secret_validity", Scope::SecurityWrite),
10811084 ("fix_security_alert", Scope::SecurityWrite),
10821085 ("update_security_settings", Scope::SecurityWrite),
10831086 ("update_workspace_security_settings", Scope::SecurityWrite),
1087+ ("create_security_advisory", Scope::SecurityWrite),
1088+ ("update_security_advisory", Scope::SecurityWrite),
1089+ ("set_security_advisory_state", Scope::SecurityWrite),
1090+ ("report_vulnerability", Scope::SecurityWrite),
1091+ ("review_vulnerability_report", Scope::SecurityWrite),
10841092 // Issues and plans.
10851093 ("list_issues", Scope::IssuesRead),
10861094 ("get_issue", Scope::IssuesRead),
+1−1
519519 /// Open alerts by severity: vulnerabilities open and not dismissed, and
520520 /// secrets in the history that look real, as critical. A blocked secret
521521 /// never landed and a likely test value is no danger, so neither counts.
522−#[derive(Clone, Debug, Default, Serialize, Deserialize)]
522+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
523523 pub struct SeverityCounts {
524524 pub critical: u32,
525525 pub high: u32,
+126−1
138138 pub review_deny_licenses: Vec<String>,
139139 /// Whether the review also comments its summary on the pull request.
140140 pub review_comment: bool,
141+ /// Whether secrets found in the history (and in pushes too large to
142+ /// scan first) open alerts and tell people. Push protection is never
143+ /// off: a push that adds a secret is refused whatever this says.
144+ #[serde(default = "yes")]
145+ pub history_alerts: bool,
146+ /// Whether anyone who can see the repository may report a
147+ /// vulnerability privately to its maintainers (`report_vulnerability`).
148+ #[serde(default)]
149+ pub private_vulnerability_reporting: bool,
150+}
151+
152+fn yes() -> bool {
153+ true
141154 }
142155
143156 impl Default for RepoSecuritySettings {
148161 review_fail_on: "high".to_owned(),
149162 review_deny_licenses: Vec::new(),
150163 review_comment: true,
164+ history_alerts: true,
165+ private_vulnerability_reporting: false,
151166 }
152167 }
153168 }
10721087 pub request_id: Option<String>,
10731088 }
10741089
1090+/// What one scan did to a repository's alerts of one type
1091+/// (`secret_scanning_alert.summary`, `code_scanning_alert.summary`,
1092+/// `vulnerability_alert.summary`): published once per scan, beside the
1093+/// per-alert `.created` and `.fixed` events webhooks get. The inbox reads
1094+/// these alone, so a scan that finds forty vulnerabilities is one item,
1095+/// not forty, and a later scan brings the same item back up to date
1096+/// (docs.g1t.sh/guides/notifications/#security).
1097+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
1098+#[serde(rename_all = "camelCase")]
1099+pub struct SecuritySummary {
1100+ pub repo_id: String,
1101+ /// `secret_scanning`, `code_scanning` or `vulnerability`.
1102+ pub alert_type: String,
1103+ /// What ran: `push`, `history`, `dependencies`, `analysis`, or
1104+ /// `change` for a dismissal, a reopening or a bypass.
1105+ pub scan: String,
1106+ /// Alerts the scan opened, newest first, at most [`MAX_SUMMARY_IDS`];
1107+ /// `opened_count` says how many there were.
1108+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
1109+ pub opened: Vec<String>,
1110+ pub opened_count: u32,
1111+ /// Those it closed as fixed (and how many), and how many were dismissed.
1112+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
1113+ pub fixed: Vec<String>,
1114+ pub fixed_count: u32,
1115+ pub dismissed_count: u32,
1116+ /// The alerts opened, by severity.
1117+ pub new_by_severity: SeverityCounts,
1118+ /// What is open of this type after the scan, by severity.
1119+ pub open: SeverityCounts,
1120+ /// The worst severity among those opened: `critical`, `high`, `medium`
1121+ /// or `low`; absent when nothing opened.
1122+ #[serde(default, skip_serializing_if = "Option::is_none")]
1123+ pub worst: Option<String>,
1124+ /// One line for the newest alert opened: "An AWS access key in
1125+ /// config/prod.env", "lodash 4.17.20 in package-lock.json: GHSA-…".
1126+ #[serde(default, skip_serializing_if = "Option::is_none")]
1127+ pub latest: Option<String>,
1128+ /// The findings page, from the site's root: `/acme/rocket/security/…`.
1129+ pub link: String,
1130+ /// For a push that was refused: who pushed, and how many secrets
1131+ /// stopped it. They are told at once, whatever the severity.
1132+ #[serde(default, skip_serializing_if = "Option::is_none")]
1133+ pub pusher: Option<String>,
1134+ #[serde(default)]
1135+ pub blocked: u32,
1136+ /// Who hears of it besides the repository's security watchers: its
1137+ /// workspace's owners and security managers, by username.
1138+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
1139+ pub notify: Vec<String>,
1140+}
1141+
1142+/// The most alert ids a summary names; counts say the rest.
1143+pub const MAX_SUMMARY_IDS: usize = 200;
1144+
1145+/// Whether `severity` goes to the inbox at once; the rest wait for the
1146+/// person's security digest.
1147+pub fn at_once(severity: &str) -> bool {
1148+ matches!(severity, "critical" | "high")
1149+}
1150+
1151+impl SecuritySummary {
1152+ /// How many alerts are open of this type after the scan.
1153+ pub fn open_total(&self) -> u32 {
1154+ self.open.critical + self.open.high + self.open.medium + self.open.low + self.open.unknown
1155+ }
1156+
1157+ /// Whether what it tells of is told at once: anything critical or high,
1158+ /// or a push that was refused.
1159+ pub fn urgent(&self) -> bool {
1160+ self.pusher.is_some() || self.worst.as_deref().is_some_and(at_once)
1161+ }
1162+}
1163+
1164+impl AlertType {
1165+ /// The summary event: `vulnerability_alert.summary`.
1166+ pub fn summary_event(self) -> &'static str {
1167+ match self {
1168+ AlertType::SecretScanning => "secret_scanning_alert.summary",
1169+ AlertType::CodeScanning => "code_scanning_alert.summary",
1170+ AlertType::Vulnerability => "vulnerability_alert.summary",
1171+ }
1172+ }
1173+
1174+ /// What people call the alerts: "secrets", "code scanning alerts",
1175+ /// "vulnerabilities".
1176+ pub fn plural(self) -> &'static str {
1177+ match self {
1178+ AlertType::SecretScanning => "secrets",
1179+ AlertType::CodeScanning => "code scanning alerts",
1180+ AlertType::Vulnerability => "vulnerabilities",
1181+ }
1182+ }
1183+
1184+ /// Where the alerts are listed under a repository's Security page.
1185+ pub fn page(self) -> &'static str {
1186+ match self {
1187+ AlertType::SecretScanning => "secret-scanning",
1188+ AlertType::CodeScanning => "code-scanning",
1189+ AlertType::Vulnerability => "vulnerabilities",
1190+ }
1191+ }
1192+}
1193+
10751194 /// The security event types, as webhooks list them.
1076−pub const EVENT_TYPES: [&str; 14] = [
1195+pub const EVENT_TYPES: [&str; 20] = [
10771196 "secret_scanning_alert.created",
10781197 "secret_scanning_alert.fixed",
10791198 "secret_scanning_alert.dismissed",
10801199 "secret_scanning_alert.reopened",
1200+ "secret_scanning_alert.summary",
10811201 "secret_scanning.bypass_requested",
10821202 "secret_scanning.bypass_reviewed",
10831203 "code_scanning_alert.created",
10841204 "code_scanning_alert.fixed",
10851205 "code_scanning_alert.dismissed",
10861206 "code_scanning_alert.reopened",
1207+ "code_scanning_alert.summary",
10871208 "vulnerability_alert.created",
10881209 "vulnerability_alert.fixed",
10891210 "vulnerability_alert.dismissed",
10901211 "vulnerability_alert.reopened",
1212+ "vulnerability_alert.summary",
1213+ "security_advisory.published",
1214+ "security_advisory.withdrawn",
1215+ "security_advisory.reported",
10911216 ];
10921217
10931218 /// The checks the suite reports on pull requests, as commit statuses:
+7−1
1515 use crate::{User, Viewer};
1616
1717 /// Every event a webhook can be sent, in the order people are shown them.
18−pub const EVENT_TYPES: [&str; 106] = [
18+pub const EVENT_TYPES: [&str; 112] = [
1919 "git.push",
2020 "branch.renamed",
2121 "repo.created",
117117 "vulnerability_alert.created",
118118 "vulnerability_alert.fixed",
119119 "vulnerability_alert.dismissed",
120+ "secret_scanning_alert.summary",
121+ "code_scanning_alert.summary",
120122 "vulnerability_alert.reopened",
123+ "vulnerability_alert.summary",
124+ "security_advisory.published",
125+ "security_advisory.withdrawn",
126+ "security_advisory.reported",
121127 "mirror.unreachable",
122128 "mirror.reachable",
123129 "mirror.state_changed",
+155−0
1+import type { ServiceBinding } from "./clients";
2+import type { User, Viewer } from "./identity";
3+import type { RepoPath } from "./repos";
4+import type { Result } from "./result";
5+
6+/**
7+ * Repository security advisories and private vulnerability reporting, the
8+ * Reporting half of a repository's Security page. Mirrors
9+ * `crates/contracts/src/advisories.rs`.
10+ *
11+ * An advisory is a repository's own account of a vulnerability in what it
12+ * ships: a draft until its maintainers publish it, visible to anyone who can
13+ * see the repository once they do, and withdrawable. A vulnerability report
14+ * is how someone outside tells the maintainers privately, when the
15+ * repository has private vulnerability reporting on; accepting one makes a
16+ * draft advisory from it.
17+ */
18+
19+export type AdvisorySeverity = "critical" | "high" | "medium" | "low";
20+
21+export const ADVISORY_SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"];
22+
23+export type AdvisoryState = "draft" | "published" | "withdrawn";
24+
25+export const ADVISORY_STATES: AdvisoryState[] = ["draft", "published", "withdrawn"];
26+
27+export type ReportState = "new" | "accepted" | "declined";
28+
29+export const REPORT_STATES: ReportState[] = ["new", "accepted", "declined"];
30+
31+/** The longest title, description and version range kept. */
32+export const MAX_ADVISORY_TITLE = 200;
33+export const MAX_ADVISORY_DESCRIPTION = 20_000;
34+export const MAX_ADVISORY_RANGE = 200;
35+export const MAX_ADVISORY_CREDITS = 20;
36+
37+export type Advisory = {
38+ /** `adv_…`. */
39+ id: string;
40+ /** `G1TSA-2026-abcd-efgh`: what people quote. */
41+ g1tsaId: string;
42+ repoId: string;
43+ /** `owner/name`. */
44+ repo: string;
45+ title: string;
46+ /** Markdown. */
47+ description: string;
48+ severity: AdvisorySeverity;
49+ cvssVector?: string | null;
50+ cvssScore?: number | null;
51+ cveId?: string | null;
52+ ecosystem?: string | null;
53+ package?: string | null;
54+ affectedVersions?: string | null;
55+ patchedVersions?: string | null;
56+ state: AdvisoryState;
57+ credits: string[];
58+ /** The report it was made from, if any. */
59+ reportId?: string | null;
60+ createdBy: string;
61+ createdAt: string;
62+ updatedAt: string;
63+ publishedAt?: string | null;
64+ withdrawnAt?: string | null;
65+};
66+
67+/** What a maintainer writes when creating or changing an advisory. */
68+export type AdvisoryInput = {
69+ title: string;
70+ description: string;
71+ severity: AdvisorySeverity;
72+ cvssVector?: string | null;
73+ cvssScore?: number | null;
74+ cveId?: string | null;
75+ ecosystem?: string | null;
76+ package?: string | null;
77+ affectedVersions?: string | null;
78+ patchedVersions?: string | null;
79+ credits?: string[];
80+};
81+
82+export type VulnerabilityReport = {
83+ /** `vrp_…`. */
84+ id: string;
85+ repoId: string;
86+ repo: string;
87+ /** A username when signed in, else the address given. */
88+ reporter?: string | null;
89+ reporterEmail?: string | null;
90+ title: string;
91+ description: string;
92+ severity?: AdvisorySeverity | null;
93+ state: ReportState;
94+ /** The draft advisory an accepted report became. */
95+ advisoryId?: string | null;
96+ createdAt: string;
97+ reviewedBy?: string | null;
98+ reviewedAt?: string | null;
99+ reviewNote?: string | null;
100+};
101+
102+export type ReviewedReport = { report: VulnerabilityReport; advisory?: Advisory | null };
103+
104+export interface AdvisoriesApi {
105+ /** Published ones for anyone who can see the repository; every state for those who see findings. */
106+ list(repo: RepoPath, viewer: Viewer, state?: AdvisoryState | null): Promise<Result<Advisory[]>>;
107+ /** By `adv_…` or `G1TSA-…` id. */
108+ get(repo: RepoPath, id: string, viewer: Viewer): Promise<Result<Advisory>>;
109+ /** A draft. Admin, or a security manager of the workspace. */
110+ create(actor: User, repo: RepoPath, advisory: AdvisoryInput): Promise<Result<Advisory>>;
111+ update(actor: User, repo: RepoPath, id: string, advisory: AdvisoryInput): Promise<Result<Advisory>>;
112+ /** `published` publishes a draft or brings a withdrawn one back; `withdrawn` withdraws. */
113+ setState(actor: User, repo: RepoPath, id: string, state: "published" | "withdrawn"): Promise<Result<Advisory>>;
114+ /** Tells the maintainers privately. Someone signed out gives an `email`. */
115+ report(
116+ viewer: Viewer,
117+ repo: RepoPath,
118+ report: { title: string; description: string; severity?: AdvisorySeverity | null; email?: string | null },
119+ ): Promise<Result<VulnerabilityReport>>;
120+ /** The repository's reports, newest first. Those who see findings. */
121+ reports(repo: RepoPath, viewer: Viewer, state?: ReportState | null): Promise<Result<VulnerabilityReport[]>>;
122+ /** `accept` makes a draft advisory from the report; `decline` closes it. */
123+ review(actor: User, repo: RepoPath, id: string, decision: "accept" | "decline", note: string): Promise<Result<ReviewedReport>>;
124+}
125+
126+/** Advisories and reports: methods of the security service. */
127+export function advisoriesClient(service: ServiceBinding): AdvisoriesApi {
128+ const call = async <T>(method: string, args: object): Promise<T> => {
129+ const response = await service.fetch(`https://service/rpc/${method}`, {
130+ method: "POST",
131+ headers: { "content-type": "application/json" },
132+ body: JSON.stringify(args),
133+ });
134+ if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
135+ return (await response.json()) as T;
136+ };
137+ return {
138+ list: (repo, viewer, state = null) => call("list_advisories", { repo, viewer, state }),
139+ get: (repo, id, viewer) => call("get_advisory", { repo, id, viewer }),
140+ create: (actor, repo, advisory) => call("create_advisory", { actor, repo, advisory }),
141+ update: (actor, repo, id, advisory) => call("update_advisory", { actor, repo, id, advisory }),
142+ setState: (actor, repo, id, state) => call("set_advisory_state", { actor, repo, id, state }),
143+ report: (viewer, repo, report) =>
144+ call("report_vulnerability", {
145+ viewer,
146+ repo,
147+ title: report.title,
148+ description: report.description,
149+ severity: report.severity ?? null,
150+ email: report.email ?? null,
151+ }),
152+ reports: (repo, viewer, state = null) => call("list_vulnerability_reports", { repo, viewer, state }),
153+ review: (actor, repo, id, decision, note) => call("review_vulnerability_report", { actor, repo, id, decision, note }),
154+ };
155+}
+18−0
137137 updatedAt: string | null;
138138 };
139139
140+/**
141+ * When security alerts that are not urgent (medium, low, and what is only to
142+ * know) reach a person: in a digest once a day or once a week, at once like
143+ * the urgent ones, or not at all. Critical and high alerts, a refused push
144+ * and a vulnerability report never wait for the digest.
145+ */
146+export type DigestCadence = "daily" | "weekly" | "at_once" | "off";
147+
148+export const DIGEST_CADENCES: DigestCadence[] = ["daily", "weekly", "at_once", "off"];
149+
140150 /** A person's choices about being told. */
141151 export type InboxSettings = {
142152 /** The reasons they are also emailed for. */
143153 email: InboxReason[];
144154 /** How they watch a repository they create. */
145155 defaultWatch: WatchLevel;
156+ /**
157+ * Whether critical and high security alerts on repositories they watch for
158+ * security reach their inbox at once. A workspace's owners and security
159+ * managers are told whatever this says.
160+ */
161+ securityAlerts: boolean;
162+ /** When the rest of the security alerts reach them. */
163+ securityDigest: DigestCadence;
146164 };
147165
148166 /** The inbox itself (not done, not snoozed), what was saved, or what is done. */
+1−0
3232 export * from "./guardrails";
3333 export * from "./identity";
3434 export * from "./sounds";
35+export * from "./advisories";
3536 export * from "./inbox";
3637 export * from "./instances";
3738 export * from "./marketplace";
+8−3
1313 import type { ServiceBinding } from "./clients";
1414 import type { User } from "./identity";
1515
16−/** What a notification is about; the preferences decide which ones toast and push. */
17−export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval";
16+/**
17+ * What a notification is about; the preferences decide which ones toast and
18+ * push. `security` is an urgent security alert (critical or high, a refused
19+ * push, a vulnerability report), shown like a mention; the rest of the
20+ * security items are ordinary `inbox` ones.
21+ */
22+export type NotificationKind = "dm" | "mention" | "thread_reply" | "inbox" | "agent_waiting" | "approval" | "security";
1823
19−export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
24+export const NOTIFICATION_KINDS: readonly NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval", "security"];
2025
2126 /** Who it is from: a person, an agent, or g1t itself. */
2227 export type NotificationActor = {
+8−0
443443 ["get_security_settings", "security:read"],
444444 ["get_workspace_security_settings", "security:read"],
445445 ["get_security_overview", "security:read"],
446+ ["list_security_advisories", "security:read"],
447+ ["get_security_advisory", "security:read"],
448+ ["list_vulnerability_reports", "security:read"],
446449 ["update_secret_scanning_alert", "security:write"],
447450 ["bypass_push_protection", "security:write"],
448451 ["check_secret_validity", "security:write"],
457460 ["fix_security_alert", "security:write"],
458461 ["update_security_settings", "security:write"],
459462 ["update_workspace_security_settings", "security:write"],
463+ ["create_security_advisory", "security:write"],
464+ ["update_security_advisory", "security:write"],
465+ ["set_security_advisory_state", "security:write"],
466+ ["report_vulnerability", "security:write"],
467+ ["review_vulnerability_report", "security:write"],
460468 ["list_issues", "issues:read"],
461469 ["get_issue", "issues:read"],
462470 ["get_plan", "issues:read"],
+7−0
5353 reviewFailOn: ReviewFailOn;
5454 reviewDenyLicenses: string[];
5555 reviewComment: boolean;
56+ /**
57+ * Whether secrets found in the history (and in pushes too large to scan
58+ * first) open alerts and tell people. Push protection is never off.
59+ */
60+ historyAlerts: boolean;
61+ /** Whether anyone who can see the repository may report a vulnerability privately. */
62+ privateVulnerabilityReporting: boolean;
5663 };
5764
5865 export type WorkspaceSecuritySettings = {
+6−0
110110 "vulnerability_alert.created",
111111 "vulnerability_alert.fixed",
112112 "vulnerability_alert.dismissed",
113+ "secret_scanning_alert.summary",
114+ "code_scanning_alert.summary",
113115 "vulnerability_alert.reopened",
116+ "vulnerability_alert.summary",
117+ "security_advisory.published",
118+ "security_advisory.withdrawn",
119+ "security_advisory.reported",
114120 "mirror.unreachable",
115121 "mirror.reachable",
116122 "mirror.state_changed",
+31−0
1+-- Security alerts the way people can live with (src/inbox.rs, src/security_digest.rs):
2+-- what each person chose about them, and what waits for their next digest.
3+
4+-- Whether critical and high alerts on repositories they watch for security
5+-- reach them at once (1, the default when null), and when the rest do:
6+-- daily (the default when null), weekly, at_once or off.
7+ALTER TABLE inbox_settings ADD COLUMN security_alerts INTEGER;
8+ALTER TABLE inbox_settings ADD COLUMN security_digest TEXT;
9+
10+-- One row per person per scan summary that waits for their digest: what
11+-- the scan opened, by severity (JSON SeverityCounts), its headline and
12+-- where the alerts are. The digest reads a person's rows, writes one inbox
13+-- item per workspace, emails it, and deletes them.
14+CREATE TABLE inbox_security_digest (
15+ id TEXT PRIMARY KEY,
16+ username TEXT NOT NULL,
17+ workspace TEXT NOT NULL,
18+ repo_id TEXT NOT NULL,
19+ repo TEXT NOT NULL,
20+ -- secret_scanning | code_scanning | vulnerability
21+ alert_type TEXT NOT NULL,
22+ event_id TEXT NOT NULL,
23+ opened INTEGER NOT NULL,
24+ counts TEXT NOT NULL,
25+ worst TEXT,
26+ title TEXT NOT NULL,
27+ link TEXT NOT NULL,
28+ created_at TEXT NOT NULL,
29+ UNIQUE (event_id, username)
30+);
31+CREATE INDEX inbox_security_digest_user ON inbox_security_digest (username, created_at);
+360−65
2222 //! | `comment.created` | everyone mentioned, the people of teams mentioned, then everyone subscribed | mention, team_mention, or why they are subscribed | info (success for an approval) |
2323 //! | `comment.created` by a workspace's agent | the same, shown as "Margo (agent)"; a review also tells the owner, "(advisory)" | the same, or author | the same |
2424 //! | `issue.opened`, `pull.opened` | whoever was assigned, asked to review or mentioned in its description (people and teams); watchers | assign, review_requested, mention, team_mention, subscribed | info |
25+//! | `secret_scanning_alert.summary`, `code_scanning_alert.summary`, `vulnerability_alert.summary` | the workspace's owners and security managers, whoever pushed a refused push, and watchers who chose security and may see findings; one thread per repository and alert type | security_alert | error for critical or high (told at once), info for the rest (a digest, unless the person chose at once) |
26+//! | `security_advisory.reported` | the workspace's owners and security managers | security_alert | warning |
27+//! | `secret_scanning.bypass_requested`, `.bypass_reviewed` | the reviewers named; the requester | security_alert | warning; info |
2528 //!
2629 //! Watchers of a repository at `all` (or `custom`, for the kinds they
2730 //! chose) hear of every issue and pull request opened, commented on,
4245 use g1t_contracts::identity::{AGENT_ID, UsernamesArgs};
4346 use g1t_contracts::inbox::*;
4447 use g1t_contracts::repos::{PathByIdArgs, ReadableArgs, Repo, RepoPath};
48+use g1t_contracts::security::SeverityCounts;
49+use g1t_contracts::security_suite::{AlertType, SecuritySummary};
4550 use g1t_contracts::time::rfc3339;
4651 use g1t_contracts::{new_id, system};
4752 use g1t_kit::now_ms;
155160 }
156161 }
157162
158−/// The security service's events the inbox tells people of: new alerts,
159−/// and push protection bypasses asked for and decided. Fixes and
160−/// dismissals are on the Security page and in webhooks.
161−pub const SECURITY_EVENTS: [&str; 5] = [
162− "secret_scanning_alert.created",
163− "code_scanning_alert.created",
164− "vulnerability_alert.created",
163+/// The security service's events the inbox tells people of: each scan's
164+/// summary of what it opened and fixed (one thread per repository and
165+/// alert type, brought up to date by the next scan; never one item per
166+/// alert), push protection bypasses asked for and decided, vulnerability
167+/// reports, and advisories published. The per-alert `.created`, `.fixed`
168+/// and `.dismissed` events are for webhooks.
169+pub const SECURITY_EVENTS: [&str; 7] = [
170+ "secret_scanning_alert.summary",
171+ "code_scanning_alert.summary",
172+ "vulnerability_alert.summary",
165173 "secret_scanning.bypass_requested",
166174 "secret_scanning.bypass_reviewed",
175+ "security_advisory.reported",
176+ "security_advisory.published",
167177 ];
168178
169179 /// The integrations service's mirroring events (see
237247 run_id: None,
238248 link: text("link"),
239249 },
240− // One alert, or one bypass request: its page is the link.
250+ // A repository's alerts of one type (`rep_1/security/vulnerability`),
251+ // its reports, one advisory, or one bypass request: the page is the link.
241252 kind if SECURITY_EVENTS.contains(&kind) => {
242− let which = text("requestId").or_else(|| text("alertId")).unwrap_or_else(|| event.id.clone());
253+ let which = match kind {
254+ "security_advisory.reported" => "reports".to_owned(),
255+ "security_advisory.published" => format!("advisories/{}", text("id").unwrap_or_default()),
256+ _ if kind.ends_with(".summary") => text("alertType").unwrap_or_else(|| "alerts".to_owned()),
257+ _ => text("requestId").or_else(|| text("alertId")).unwrap_or_else(|| event.id.clone()),
258+ };
243259 Thread {
244260 key: format!("{}/security/{which}", wanted.repo_id),
245261 kind: None,
605621 }
606622 }
607623 (kind, None) if SECURITY_EVENTS.contains(&kind) => {
608− let severity = match data["severity"].as_str() {
609− _ if kind == "secret_scanning.bypass_requested" => Severity::Warning,
610− _ if kind == "secret_scanning.bypass_reviewed" => Severity::Info,
611− Some("critical" | "high") => Severity::Error,
612− _ => Severity::Warning,
613− };
614624 let what = data["title"].as_str().unwrap_or("A security alert");
615− let title = match kind {
616− "secret_scanning.bypass_requested" => format!("{who} asked to bypass push protection in {repo}"),
625+ match kind {
626+ "secret_scanning.bypass_requested" => {
627+ let title = format!("{who} asked to bypass push protection in {repo}");
628+ for name in names(data, "notify") {
629+ told.tell(&name, Reason::SecurityAlert, Severity::Warning, &title, what, true);
630+ }
631+ }
617632 "secret_scanning.bypass_reviewed" => {
618633 let verdict = data["state"].as_str().unwrap_or("reviewed");
619− format!("Your request to bypass push protection in {repo} was {verdict}")
634+ let title = format!("Your request to bypass push protection in {repo} was {verdict}");
635+ for name in names(data, "notify") {
636+ told.tell(&name, Reason::SecurityAlert, Severity::Info, &title, what, true);
637+ }
620638 }
621− _ if data["pusher"].is_string() => format!("A push to {repo} was blocked: it adds a secret"),
622− _ => format!("New security alert in {repo}"),
623− };
624− // Whoever pushed a blocked secret, and those the service names
625− // (the workspace's owners, or whoever asked for a bypass).
626− if let Some(pusher) = data["pusher"].as_str() {
627− told.tell(pusher, Reason::SecurityAlert, Severity::Error, &title, what, true);
628− }
629− for name in names(data, "notify") {
630− told.tell(&name, Reason::SecurityAlert, severity, &title, what, true);
631− }
632− // Watchers who chose security alerts, if they may see findings:
633− // the service lists who may (`members`), as findings are never
634− // shown to someone who cannot change the code.
635− if !kind.starts_with("secret_scanning.bypass") {
636− let members = names(data, "members");
637− let watching: Vec<String> = told.audience.watching("security").collect();
638− for name in watching.iter().filter(|name| members.iter().any(|member| member.eq_ignore_ascii_case(name))) {
639− told.tell(name, Reason::SecurityAlert, severity, &title, what, false);
639+ // Something to act on: the owners and security managers, at once.
640+ "security_advisory.reported" => {
641+ let title = format!("A vulnerability was reported privately to {repo}");
642+ for name in names(data, "notify") {
643+ told.tell(&name, Reason::SecurityAlert, Severity::Warning, &title, what, true);
644+ }
640645 }
646+ // Published for everyone: watchers who chose security.
647+ "security_advisory.published" => {
648+ let title = format!("{repo} published a security advisory");
649+ told.tell_watchers("security", Severity::Info, &title, what);
650+ }
651+ // A scan's summary. One that opened nothing is no news: it
652+ // brings the thread up to date instead (deliver_one). Whether
653+ // what is not urgent is told now or in a digest is each
654+ // person's choice, applied there too.
655+ _ => {
656+ let Some(summary) = security_summary(event) else {
657+ return told.notices;
658+ };
659+ if summary.opened_count == 0 {
660+ return told.notices;
661+ }
662+ let severity = if summary.urgent() { Severity::Error } else { Severity::Info };
663+ let (title, pushed) = summary_titles(repo, &summary);
664+ let body = summary.latest.clone().unwrap_or_default();
665+ // Whoever pushed a refused push must act: told at once, whatever was found.
666+ if let Some(pusher) = &summary.pusher {
667+ told.tell(pusher, Reason::SecurityAlert, Severity::Error, &pushed, &body, true);
668+ }
669+ for name in &summary.notify {
670+ told.tell(name, Reason::SecurityAlert, severity, &title, &body, true);
671+ }
672+ // Watchers who chose security alerts, if they may see findings:
673+ // the service lists who may (`members`), as findings are never
674+ // shown to someone who cannot change the code.
675+ let members = names(data, "members");
676+ let watching: Vec<String> = told.audience.watching("security").collect();
677+ for name in watching.iter().filter(|name| members.iter().any(|member| member.eq_ignore_ascii_case(name))) {
678+ told.tell(name, Reason::SecurityAlert, severity, &title, &body, false);
679+ }
680+ }
641681 }
642682 }
643683 ("comment.created", Some(on)) => {
758798 sha.get(..7).unwrap_or(sha)
759799 }
760800
801+// --- Security summaries ------------------------------------------------------
802+
803+/// The scan summary a `<type>_alert.summary` event carries.
804+pub fn security_summary(event: &Event) -> Option<SecuritySummary> {
805+ if !event.kind.ends_with("_alert.summary") {
806+ return None;
807+ }
808+ serde_json::from_value(event.data.clone()).ok()
809+}
810+
811+/// "1 critical, 2 high": the counts that are not zero, worst first.
812+pub fn breakdown(counts: &SeverityCounts) -> String {
813+ [("critical", counts.critical), ("high", counts.high), ("medium", counts.medium), ("low", counts.low), ("unknown", counts.unknown)]
814+ .into_iter()
815+ .filter(|(_, n)| *n > 0)
816+ .map(|(name, n)| format!("{n} {name}"))
817+ .collect::<Vec<_>>()
818+ .join(", ")
819+}
820+
821+/// "vulnerability" or "vulnerabilities", as a count wants.
822+fn alerts_called(alert_type: &str, n: u32) -> &'static str {
823+ match (AlertType::parse(alert_type), n) {
824+ (Some(AlertType::SecretScanning), 1) => "secret",
825+ (Some(AlertType::SecretScanning), _) => "secrets",
826+ (Some(AlertType::CodeScanning), 1) => "code scanning alert",
827+ (Some(AlertType::CodeScanning), _) => "code scanning alerts",
828+ (Some(AlertType::Vulnerability), 1) => "vulnerability",
829+ (Some(AlertType::Vulnerability), _) => "vulnerabilities",
830+ (None, 1) => "security alert",
831+ (None, _) => "security alerts",
832+ }
833+}
834+
835+/// What a scan's item says to those who look after the repository ("3 new
836+/// vulnerabilities in acme/rocket: 1 critical, 2 high"), and to whoever
837+/// pushed a push that was refused.
838+pub fn summary_titles(repo: &str, summary: &SecuritySummary) -> (String, String) {
839+ let n = summary.opened_count;
840+ let counts = breakdown(&summary.new_by_severity);
841+ let title = if counts.is_empty() {
842+ format!("{n} new {} in {repo}", alerts_called(&summary.alert_type, n))
843+ } else {
844+ format!("{n} new {} in {repo}: {counts}", alerts_called(&summary.alert_type, n))
845+ };
846+ let pushed = match summary.blocked {
847+ 0 | 1 => format!("A push to {repo} was blocked: it adds a secret"),
848+ n => format!("A push to {repo} was blocked: it adds {n} secrets"),
849+ };
850+ (title, pushed)
851+}
852+
853+/// What a scan that opened nothing makes the thread say: how many alerts
854+/// are open now ("2 open vulnerabilities in acme/rocket: 2 high"), or that
855+/// none is, with what changed as the line of history.
856+pub fn refreshed_text(repo: &str, summary: &SecuritySummary) -> (String, String) {
857+ let open = summary.open_total();
858+ let mut changes = Vec::new();
859+ if summary.fixed_count > 0 {
860+ changes.push(format!("{} fixed", summary.fixed_count));
861+ }
862+ if summary.dismissed_count > 0 {
863+ changes.push(format!("{} dismissed", summary.dismissed_count));
864+ }
865+ let title = if open == 0 {
866+ format!("No open {} in {repo}", alerts_called(&summary.alert_type, 2))
867+ } else {
868+ format!("{open} open {} in {repo}: {}", alerts_called(&summary.alert_type, open), breakdown(&summary.open))
869+ };
870+ (title, changes.join(", "))
871+}
872+
873+/// Where a scan summary's notice goes for one person.
874+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
875+pub enum Delivery {
876+ /// Their inbox, now.
877+ Now,
878+ /// Their next security digest.
879+ Digest,
880+ /// Nowhere.
881+ Never,
882+}
883+
884+/// The policy: critical and high alerts, and a refused push, reach the
885+/// people the service names (the workspace's owners and security managers,
886+/// the pusher) at once whatever they chose, and watchers at once unless
887+/// they turned security alerts off. Everything else waits for the person's
888+/// digest, or comes at once or never, as they chose.
889+pub fn security_delivery(summary: &SecuritySummary, username: &str, settings: &InboxSettings) -> Delivery {
890+ if summary.urgent() {
891+ let named = summary.notify.iter().any(|name| name.eq_ignore_ascii_case(username))
892+ || summary.pusher.as_deref().is_some_and(|pusher| pusher.eq_ignore_ascii_case(username));
893+ return if named || settings.security_alerts { Delivery::Now } else { Delivery::Never };
894+ }
895+ match settings.security_digest {
896+ DigestCadence::AtOnce => Delivery::Now,
897+ DigestCadence::Daily | DigestCadence::Weekly => Delivery::Digest,
898+ DigestCadence::Off => Delivery::Never,
899+ }
900+}
901+
902+/// Brings every person's thread about a repository's alerts of one type up
903+/// to date after a scan that opened nothing: the title says what is open
904+/// now, a line of history says what changed, and a thread with nothing
905+/// left open is done. Nobody is woken: what was read stays read, and the
906+/// thread keeps its place.
907+async fn refresh_security_thread(db: &D1Database, thread: &str, repo: &str, summary: &SecuritySummary, event: &Event) -> Result<()> {
908+ let (title, body) = refreshed_text(repo, summary);
909+ let now = now_ms();
910+ let at = rfc3339(now);
911+ let settled = i32::from(summary.open_total() == 0);
912+ db.batch(vec![
913+ db.prepare(
914+ "UPDATE inbox_items SET event_id = ?1, event = ?2, title = ?3, body = ?4, updated_at = ?5, activity = activity + 1,
915+ done_at = CASE WHEN ?6 = 1 THEN COALESCE(done_at, ?5) ELSE done_at END,
916+ read_at = CASE WHEN ?6 = 1 THEN COALESCE(read_at, ?5) ELSE read_at END
917+ WHERE thread = ?7
918+ AND NOT EXISTS (SELECT 1 FROM inbox_activity a WHERE a.event_id = ?1 AND a.username = inbox_items.username)",
919+ )
920+ .bind(&[
921+ event.id.as_str().into(),
922+ event.kind.as_str().into(),
923+ title.as_str().into(),
924+ body.as_str().into(),
925+ at.as_str().into(),
926+ settled.into(),
927+ thread.into(),
928+ ])?,
929+ db.prepare(
930+ "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
931+ SELECT ?1 || ':' || username, id, username, ?2, ?3, 'security_alert', 'info', ?4, ?5, NULL, ?6 FROM inbox_items WHERE thread = ?7",
932+ )
933+ .bind(&[
934+ new_id("ntf", now).into(),
935+ event.id.as_str().into(),
936+ event.kind.as_str().into(),
937+ title.as_str().into(),
938+ body.as_str().into(),
939+ event.time.as_str().into(),
940+ thread.into(),
941+ ])?,
942+ ])
943+ .await?;
944+ Ok(())
945+}
946+
761947 /// Where an item is on g1t.sh.
762948 pub fn url(repo: Option<&str>, subject: Option<SubjectKind>, number: Option<u32>, run_id: Option<&str>, link: Option<&str>) -> String {
763949 if let Some(link) = link.filter(|link| link.starts_with('/')) {
11001286 watchers: watchers.get(&wanted.repo_id).cloned().unwrap_or_default(),
11011287 };
11021288 let repo = format!("{}/{}", path.namespace, path.name).to_lowercase();
1103− let told = notices(event, &repo, &actor, subject.as_ref(), &audience);
1289+ let mut told = notices(event, &repo, &actor, subject.as_ref(), &audience);
11041290 let mut statements = subscriptions::auto_subscribe(db, &thread.key, &wanted.repo_id, &subscribes(event, subject.as_ref()), &event.time)?;
1291+ // A scan's summary: one that opened nothing brings the thread up to date
1292+ // without waking anyone, and what is not urgent waits for each person's
1293+ // digest, or comes at once or never, as they chose (security_delivery).
1294+ if let Some(summary) = security_summary(event) {
1295+ if summary.opened_count == 0 {
1296+ refresh_security_thread(db, &thread.key, &repo, &summary, event).await?;
1297+ return Ok(Vec::new());
1298+ }
1299+ let people: Vec<String> = told.iter().map(|notice| notice.username.clone()).collect();
1300+ let settings = subscriptions::settings_of(db, &people).await?;
1301+ let now = now_ms();
1302+ let mut kept = Vec::with_capacity(told.len());
1303+ for notice in told {
1304+ let setting = settings.get(&notice.username).cloned().unwrap_or_default();
1305+ match security_delivery(&summary, &notice.username, &setting) {
1306+ Delivery::Now => kept.push(notice),
1307+ Delivery::Digest => statements.push(crate::security_digest::queue(db, &notice.username, &path, &summary, &event.id, now)?),
1308+ Delivery::Never => {}
1309+ }
1310+ }
1311+ told = kept;
1312+ }
11051313 if told.is_empty() {
11061314 if !statements.is_empty() {
11071315 db.batch(statements).await?;
12051413 /// happens when the person was already told of this event. While the
12061414 /// thread is unread its most urgent severity is kept; done or not, it
12071415 /// comes back to the inbox. A snooze stands.
1208−const BUMP: &str = "INSERT INTO inbox_items (id, username, thread, event_id, event, reason, severity, title, body,
1416+pub(crate) const BUMP: &str = "INSERT INTO inbox_items (id, username, thread, event_id, event, reason, severity, title, body,
12091417 workspace, repo_id, repo, subject, number, run_id, link, actor, created_at, updated_at, bumped, activity)
12101418 SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?18, ?19, 1
12111419 WHERE NOT EXISTS (SELECT 1 FROM inbox_activity WHERE event_id = ?4 AND username = ?2)
12401448 Reason::Agent => "agent_waiting",
12411449 Reason::ReviewRequested => "approval",
12421450 Reason::Mention | Reason::TeamMention => "mention",
1451+ // A critical or high alert, a refused push, a report or a bypass to
1452+ // review: toasted like a mention. A digest, or what came at once by
1453+ // choice, is an ordinary inbox item.
1454+ Reason::SecurityAlert if notice.severity != Severity::Info => "security",
12431455 _ => "inbox",
12441456 };
12451457 let path = url.split(['?', '#']).next().unwrap_or_default();
12821494
12831495 /// Hands items to the notify service, one call each. Logged and left when it
12841496 /// cannot be reached: the inbox and email do not wait on it.
1285−async fn tell_live(notify: &Fetcher, items: Vec<(String, serde_json::Value)>) {
1497+pub(crate) async fn tell_live(notify: &Fetcher, items: Vec<(String, serde_json::Value)>) {
12861498 #[derive(serde::Serialize)]
12871499 struct NotifyArgs {
12881500 username: String,
22252437 assert_eq!(told(&notices(&back, "acme/rocket", &Actor::default(), None, &audience)), vec![("ana", Reason::StateChange, Severity::Success)]);
22262438 }
22272439
2228− #[test]
2229− fn security_alerts_tell_the_pusher_the_owners_and_member_watchers() {
2230− let blocked = event(
2231− "secret_scanning_alert.created",
2440+ /// A scan's summary, as the security service publishes it.
2441+ fn summary(alert_type: &str, opened: &[&str], new: (u32, u32, u32, u32), open: (u32, u32, u32, u32), pusher: Option<&str>) -> Event {
2442+ let counts = |(critical, high, medium, low): (u32, u32, u32, u32)| json!({ "critical": critical, "high": high, "medium": medium, "low": low, "unknown": 0 });
2443+ let worst = [(new.0, "critical"), (new.1, "high"), (new.2, "medium"), (new.3, "low")].into_iter().find(|(n, _)| *n > 0).map(|(_, name)| name);
2444+ let page = match alert_type {
2445+ "secret_scanning" => "secret-scanning",
2446+ "code_scanning" => "code-scanning",
2447+ _ => "vulnerabilities",
2448+ };
2449+ event(
2450+ &format!("{alert_type}_alert.summary"),
22322451 None,
22332452 json!({
2234− "repoId": "rep_1", "alertId": "sec_1", "alertType": "secret_scanning", "severity": "critical",
2235− "title": "An AWS access key in config/prod.env", "link": "/acme/rocket/security/secret-scanning/sec_1",
2236− "state": "open", "pusher": "bo", "notify": ["ana"], "members": ["ana", "bo", "cy"]
2453+ "repoId": "rep_1", "alertType": alert_type, "scan": if pusher.is_some() { "push" } else { "dependencies" },
2454+ "opened": opened, "openedCount": opened.len(), "fixedCount": 0, "dismissedCount": 0,
2455+ "newBySeverity": counts(new), "open": counts(open), "worst": worst,
2456+ "latest": if opened.is_empty() { serde_json::Value::Null } else { json!("lodash 4.17.20 in package-lock.json: GHSA-1") },
2457+ "link": format!("/acme/rocket/security/{page}"),
2458+ "pusher": pusher, "blocked": if pusher.is_some() { opened.len() } else { 0 },
2459+ "notify": ["ana"], "members": ["ana", "bo", "cy"]
22372460 }),
2238− );
2239− assert_eq!(wants(&blocked), Some(Wanted { repo_id: "rep_1".into(), number: None, comment_id: None }));
2461+ )
2462+ }
2463+
2464+ #[test]
2465+ fn a_scan_is_one_item_per_repository_and_alert_type() {
2466+ let found = summary("vulnerability", &["vul_3", "vul_2", "vul_1"], (1, 2, 0, 0), (1, 2, 0, 0), None);
2467+ assert_eq!(wants(&found), Some(Wanted { repo_id: "rep_1".into(), number: None, comment_id: None }));
22402468 // cy watches for security alerts and is a member; eve watches
22412469 // everything but cannot see findings; dee watches issues only.
22422470 let audience = watched(&[
22442472 ("dee", WatchLevel::Custom, &["issues"]),
22452473 ("eve", WatchLevel::All, &[]),
22462474 ]);
2247− let notices_ = notices(&blocked, "acme/rocket", &Actor::default(), None, &audience);
2248− assert_eq!(
2249− told(&notices_),
2250− vec![
2251− ("bo", Reason::SecurityAlert, Severity::Error),
2252− ("ana", Reason::SecurityAlert, Severity::Error),
2253− ("cy", Reason::SecurityAlert, Severity::Error),
2254− ]
2255− );
2256− assert_eq!(notices_[0].title, "A push to acme/rocket was blocked: it adds a secret");
2257− assert_eq!(notices_[0].body, "An AWS access key in config/prod.env");
2258− let thread = thread_of(&blocked, &wants(&blocked).unwrap(), None);
2259− assert_eq!(thread.key, "rep_1/security/sec_1");
2260− assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/security/secret-scanning/sec_1");
2475+ let notices_ = notices(&found, "acme/rocket", &Actor::default(), None, &audience);
2476+ assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Error), ("cy", Reason::SecurityAlert, Severity::Error)]);
2477+ assert_eq!(notices_[0].title, "3 new vulnerabilities in acme/rocket: 1 critical, 2 high");
2478+ assert_eq!(notices_[0].body, "lodash 4.17.20 in package-lock.json: GHSA-1");
2479+ // The same thread for every scan of this type, however many alerts.
2480+ let thread = thread_of(&found, &wants(&found).unwrap(), None);
2481+ assert_eq!(thread.key, "rep_1/security/vulnerability");
2482+ assert_eq!(url(Some("acme/rocket"), thread.kind, None, None, thread.link.as_deref()), "/acme/rocket/security/vulnerabilities");
2483+ // Medium and low are news, but not urgent: an info item, or the digest.
2484+ let quiet = summary("code_scanning", &["cod_1"], (0, 0, 1, 0), (0, 0, 1, 0), None);
2485+ let notices_ = notices(&quiet, "acme/rocket", &Actor::default(), None, &audience);
2486+ assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Info), ("cy", Reason::SecurityAlert, Severity::Info)]);
2487+ assert_eq!(notices_[0].title, "1 new code scanning alert in acme/rocket: 1 medium");
2488+ // A scan that opened nothing tells nobody: the thread is brought up to date instead.
2489+ let fixed = summary("vulnerability", &[], (0, 0, 0, 0), (0, 1, 0, 0), None);
2490+ assert!(notices(&fixed, "acme/rocket", &Actor::default(), None, &audience).is_empty());
2491+ let (title, body) = refreshed_text("acme/rocket", &SecuritySummary { fixed_count: 2, ..security_summary(&fixed).unwrap() });
2492+ assert_eq!(title, "1 open vulnerability in acme/rocket: 1 high");
2493+ assert_eq!(body, "2 fixed");
2494+ let settled = SecuritySummary { fixed_count: 1, dismissed_count: 1, open: SeverityCounts::default(), ..security_summary(&fixed).unwrap() };
2495+ assert_eq!(refreshed_text("acme/rocket", &settled), ("No open vulnerabilities in acme/rocket".to_owned(), "1 fixed, 1 dismissed".to_owned()));
2496+ // Per-alert events are for webhooks.
2497+ assert_eq!(wants(&event("vulnerability_alert.created", None, json!({ "repoId": "rep_1" }))), None);
2498+ assert_eq!(wants(&event("code_scanning_alert.fixed", None, json!({ "repoId": "rep_1" }))), None);
2499+ }
2500+
2501+ #[test]
2502+ fn a_refused_push_tells_the_pusher_at_once_and_the_owners_once() {
2503+ let blocked = summary("secret_scanning", &["sec_1", "sec_2"], (2, 0, 0, 0), (2, 0, 0, 0), Some("bo"));
2504+ let notices_ = notices(&blocked, "acme/rocket", &Actor::default(), None, &nobody());
2505+ assert_eq!(told(&notices_), vec![("bo", Reason::SecurityAlert, Severity::Error), ("ana", Reason::SecurityAlert, Severity::Error)]);
2506+ assert_eq!(notices_[0].title, "A push to acme/rocket was blocked: it adds 2 secrets");
2507+ assert_eq!(notices_[1].title, "2 new secrets in acme/rocket: 2 critical");
2508+ assert_eq!(thread_of(&blocked, &wants(&blocked).unwrap(), None).key, "rep_1/security/secret_scanning");
2509+ // Whatever bo chose, the push is theirs to fix; ana, an owner, is told whatever she chose.
2510+ let summary_ = security_summary(&blocked).unwrap();
2511+ let off = InboxSettings { security_alerts: false, security_digest: DigestCadence::Off, ..InboxSettings::default() };
2512+ assert_eq!(security_delivery(&summary_, "bo", &off), Delivery::Now);
2513+ assert_eq!(security_delivery(&summary_, "ana", &off), Delivery::Now);
2514+ // A watcher who turned security alerts off hears of nothing urgent.
2515+ assert_eq!(security_delivery(&summary_, "cy", &off), Delivery::Never);
2516+ assert_eq!(security_delivery(&summary_, "cy", &InboxSettings::default()), Delivery::Now);
2517+ }
2518+
2519+ #[test]
2520+ fn what_is_not_urgent_waits_for_the_digest_unless_the_person_chose_otherwise() {
2521+ let medium = security_summary(&summary("vulnerability", &["vul_1"], (0, 0, 1, 0), (0, 0, 1, 0), None)).unwrap();
2522+ assert!(!medium.urgent());
2523+ let choose = |cadence| InboxSettings { security_digest: cadence, ..InboxSettings::default() };
2524+ // Owners too: a medium alert is for the digest, as they chose.
2525+ assert_eq!(security_delivery(&medium, "ana", &choose(DigestCadence::Daily)), Delivery::Digest);
2526+ assert_eq!(security_delivery(&medium, "cy", &choose(DigestCadence::Weekly)), Delivery::Digest);
2527+ assert_eq!(security_delivery(&medium, "cy", &choose(DigestCadence::AtOnce)), Delivery::Now);
2528+ assert_eq!(security_delivery(&medium, "ana", &choose(DigestCadence::Off)), Delivery::Never);
2529+ let high = security_summary(&summary("vulnerability", &["vul_1"], (0, 1, 0, 0), (0, 1, 0, 0), None)).unwrap();
2530+ assert_eq!(security_delivery(&high, "ana", &choose(DigestCadence::Off)), Delivery::Now);
2531+ assert_eq!(breakdown(&SeverityCounts { critical: 1, low: 3, ..SeverityCounts::default() }), "1 critical, 3 low");
2532+ assert_eq!(breakdown(&SeverityCounts::default()), "");
2533+ }
2534+
2535+ #[test]
2536+ fn bypass_requests_reports_and_advisories_tell_who_they_name() {
2537+ let audience = watched(&[("cy", WatchLevel::Custom, &["security"]), ("eve", WatchLevel::All, &[])]);
22612538 // A bypass request goes to the reviewers named, never to watchers.
22622539 let requested = event(
22632540 "secret_scanning.bypass_requested",
22672544 let notices_ = notices(&requested, "acme/rocket", &actor("usr_bo", "bo"), None, &audience);
22682545 assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Warning)]);
22692546 assert_eq!(notices_[0].title, "bo asked to bypass push protection in acme/rocket");
2270− // Fixes and dismissals are not news to the inbox.
2271− assert_eq!(wants(&event("code_scanning_alert.fixed", None, json!({ "repoId": "rep_1" }))), None);
2547+ assert_eq!(thread_of(&requested, &wants(&requested).unwrap(), None).key, "rep_1/security/byp_1");
2548+ // A private report is for the owners and security managers, at once, with no reporter named.
2549+ let reported = event(
2550+ "security_advisory.reported",
2551+ None,
2552+ json!({ "repoId": "rep_1", "id": "vrp_1", "title": "XSS in the search box", "state": "new", "link": "/acme/rocket/security/reports", "notify": ["ana"], "members": ["ana", "cy"] }),
2553+ );
2554+ let notices_ = notices(&reported, "acme/rocket", &Actor::default(), None, &audience);
2555+ assert_eq!(told(&notices_), vec![("ana", Reason::SecurityAlert, Severity::Warning)]);
2556+ assert_eq!(notices_[0].title, "A vulnerability was reported privately to acme/rocket");
2557+ assert_eq!(thread_of(&reported, &wants(&reported).unwrap(), None).key, "rep_1/security/reports");
2558+ // A published advisory is for watchers who chose security; it is public, so no members list is needed.
2559+ let published = event(
2560+ "security_advisory.published",
2561+ Some("usr_ana"),
2562+ json!({ "repoId": "rep_1", "id": "adv_1", "g1tsaId": "G1TSA-2026-abcd-efgh", "title": "XSS in the search box", "severity": "high", "state": "published", "link": "/acme/rocket/security/advisories/G1TSA-2026-abcd-efgh" }),
2563+ );
2564+ let notices_ = notices(&published, "acme/rocket", &actor("usr_ana", "ana"), None, &audience);
2565+ assert_eq!(told(&notices_), vec![("cy", Reason::Subscribed, Severity::Info), ("eve", Reason::Subscribed, Severity::Info)]);
2566+ assert_eq!(thread_of(&published, &wants(&published).unwrap(), None).key, "rep_1/security/advisories/adv_1");
22722567 }
22732568
22742569 #[test]
+15−2
1414 //! Other services reach it over `POST /rpc/<method>`; see
1515 //! `g1t_contracts::events`, `audit` and `inbox` for the methods and their
1616 //! arguments. `activity_digest` (digest.rs) counts a span of the log for
17−//! Home.
17+//! Home. Security alerts that are not urgent wait for each person's
18+//! security digest (security_digest.rs), sent by a cron of its own.
1819
1920 mod audit;
2021 mod digest;
2122 mod fanout;
2223 mod inbox;
24+mod security_digest;
2325 mod subscriptions;
2426
2527 use g1t_contracts::events::{Event, ListArgs, PublishArgs};
372374 /// on the plan, or what staff set). Workspaces with nothing older than the
373375 /// shortest (`AUDIT_MIN_DAYS`, 7) are left alone.
374376 #[event(scheduled)]
375−async fn scheduled(_event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) {
377+async fn scheduled(event: worker::ScheduledEvent, env: Env, _ctx: worker::ScheduleContext) {
378+ // The morning cron sends the security digests that are due, and nothing else.
379+ if event.cron() == security_digest::DIGEST_CRON {
380+ let (Ok(db), Ok(identity)) = (env.d1("DB"), env.service("IDENTITY")) else { return };
381+ let notify = env.service("NOTIFY").ok();
382+ match security_digest::send_due(&db, &identity, notify.as_ref(), now_ms()).await {
383+ Ok(sent) if sent > 0 => worker::console_log!("sent {sent} security digests"),
384+ Ok(_) => {}
385+ Err(error) => worker::console_error!("security digests not sent: {error}"),
386+ }
387+ return;
388+ }
376389 let days = |name: &str, default: u32| {
377390 env.var(name)
378391 .ok()
+320−0
1+//! The security digest: security alerts that are not urgent (medium and
2+//! low) wait here for each person's next digest, once a day or once a week
3+//! as they chose (`InboxSettings::security_digest`), and go out as one
4+//! inbox item and one email per workspace: "Security digest for acme: 4
5+//! new medium vulnerabilities in 2 repositories, 1 new low secret in 1
6+//! repository". Critical and high alerts, refused pushes and vulnerability
7+//! reports never wait here (inbox.rs decides, `security_delivery`).
8+//!
9+//! The cron (wrangler.jsonc, `DIGEST_CRON`) runs once a day in the morning,
10+//! UTC; daily digests go out on every run, weekly ones on Monday's. Rows
11+//! are deleted as their digest goes out, so a retried run sends nothing
12+//! twice; a person whose cadence became "at once" or "off" after rows were
13+//! queued gets what waited once, in a digest, rather than losing it.
14+
15+use std::collections::BTreeMap;
16+
17+use g1t_contracts::inbox::{DigestCadence, Reason, Severity};
18+use g1t_contracts::new_id;
19+use g1t_contracts::repos::RepoPath;
20+use g1t_contracts::security::SeverityCounts;
21+use g1t_contracts::security_suite::{AlertType, SecuritySummary};
22+use g1t_contracts::time::rfc3339;
23+use serde::Deserialize;
24+use worker::wasm_bindgen::JsValue;
25+use worker::{D1Database, D1PreparedStatement, Fetcher, Result};
26+
27+use crate::inbox::{self, Notice};
28+use crate::subscriptions;
29+
30+/// The cron that sends digests: 08:00 UTC daily.
31+pub const DIGEST_CRON: &str = "0 8 * * *";
32+/// Rows read per run, at most.
33+const BATCH: u32 = 5000;
34+/// What the digest's inbox item records as its event type.
35+const EVENT: &str = "security.digest";
36+
37+/// One scan summary waiting for a person's digest.
38+#[derive(Clone, Debug, Deserialize, PartialEq)]
39+pub struct Queued {
40+ pub id: String,
41+ pub username: String,
42+ pub workspace: String,
43+ pub repo_id: String,
44+ pub repo: String,
45+ pub alert_type: String,
46+ pub opened: f64,
47+ /// JSON `SeverityCounts`.
48+ pub counts: String,
49+ pub worst: Option<String>,
50+ pub title: String,
51+ pub link: String,
52+}
53+
54+/// The statement that queues a summary for one person's digest.
55+pub fn queue(db: &D1Database, username: &str, path: &RepoPath, summary: &SecuritySummary, event_id: &str, now_ms: u64) -> Result<D1PreparedStatement> {
56+ db.prepare(
57+ "INSERT OR IGNORE INTO inbox_security_digest
58+ (id, username, workspace, repo_id, repo, alert_type, event_id, opened, counts, worst, title, link, created_at)
59+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
60+ )
61+ .bind(&[
62+ new_id("dig", now_ms).into(),
63+ username.to_lowercase().into(),
64+ path.namespace.to_lowercase().into(),
65+ summary.repo_id.as_str().into(),
66+ format!("{}/{}", path.namespace, path.name).to_lowercase().into(),
67+ summary.alert_type.as_str().into(),
68+ event_id.into(),
69+ summary.opened_count.into(),
70+ serde_json::to_string(&summary.new_by_severity)?.into(),
71+ summary.worst.as_deref().map_or(JsValue::NULL, JsValue::from),
72+ summary.latest.as_deref().unwrap_or_default().into(),
73+ summary.link.as_str().into(),
74+ rfc3339(now_ms).into(),
75+ ])
76+}
77+
78+/// The day of the week at `now_ms`: 0 is Sunday. 1970-01-01 was a Thursday.
79+pub fn weekday(now_ms: u64) -> u8 {
80+ ((now_ms / 86_400_000 + 4) % 7) as u8
81+}
82+
83+/// Whether a person's digest goes out on this run: daily ones every day,
84+/// weekly ones on Monday; what waited for someone who since chose "at
85+/// once" or "off" goes out too, once, rather than being lost.
86+pub fn due(cadence: DigestCadence, weekday: u8) -> bool {
87+ match cadence {
88+ DigestCadence::Daily | DigestCadence::AtOnce | DigestCadence::Off => true,
89+ DigestCadence::Weekly => weekday == 1,
90+ }
91+}
92+
93+/// "vulnerability" or "vulnerabilities".
94+fn what(alert_type: &str, n: u32) -> &'static str {
95+ match (AlertType::parse(alert_type), n) {
96+ (Some(AlertType::SecretScanning), 1) => "secret",
97+ (Some(AlertType::SecretScanning), _) => "secrets",
98+ (Some(AlertType::CodeScanning), 1) => "code scanning alert",
99+ (Some(AlertType::CodeScanning), _) => "code scanning alerts",
100+ (_, 1) => "vulnerability",
101+ _ => "vulnerabilities",
102+ }
103+}
104+
105+/// "in 2 repositories".
106+fn in_repos(n: usize) -> String {
107+ if n == 1 { "in 1 repository".to_owned() } else { format!("in {n} repositories") }
108+}
109+
110+/// The digest's title and body for one workspace's rows: the title counts
111+/// what is new per severity and alert type, worst first; the body names up
112+/// to five of the alerts, newest first, each with its repository.
113+pub fn digest_text(workspace: &str, rows: &[Queued]) -> (String, String) {
114+ // (severity rank, alert type) -> (count, repositories)
115+ let mut groups: BTreeMap<(u8, &str), (u32, std::collections::BTreeSet<&str>)> = BTreeMap::new();
116+ for row in rows {
117+ let counts: SeverityCounts = serde_json::from_str(&row.counts).unwrap_or_default();
118+ for (rank, severity, n) in [(0u8, "critical", counts.critical), (1, "high", counts.high), (2, "medium", counts.medium), (3, "low", counts.low), (4, "", counts.unknown)] {
119+ if n == 0 {
120+ continue;
121+ }
122+ let _ = severity;
123+ let group = groups.entry((rank, row.alert_type.as_str())).or_default();
124+ group.0 += n;
125+ group.1.insert(row.repo.as_str());
126+ }
127+ }
128+ let parts: Vec<String> = groups
129+ .iter()
130+ .map(|((rank, alert_type), (n, repos))| {
131+ let severity = match rank {
132+ 0 => "critical ",
133+ 1 => "high ",
134+ 2 => "medium ",
135+ 3 => "low ",
136+ _ => "",
137+ };
138+ format!("{n} new {severity}{} {}", what(alert_type, *n), in_repos(repos.len()))
139+ })
140+ .collect();
141+ let total: u32 = rows.iter().map(|row| row.opened as u32).sum();
142+ let title = if parts.is_empty() {
143+ format!("Security digest for {workspace}: {total} new security alerts")
144+ } else {
145+ format!("Security digest for {workspace}: {}", parts.join(", "))
146+ };
147+ let mut named: Vec<String> = rows.iter().rev().filter(|row| !row.title.is_empty()).take(5).map(|row| format!("{}: {}", row.repo, row.title)).collect();
148+ if rows.len() > named.len() && !named.is_empty() {
149+ named.push(format!("and {} more", rows.iter().filter(|row| !row.title.is_empty()).count().saturating_sub(named.len())));
150+ }
151+ (title, named.join(" · "))
152+}
153+
154+/// Sends every digest that is due, and returns how many went out.
155+pub async fn send_due(db: &D1Database, identity: &Fetcher, notify: Option<&Fetcher>, now_ms: u64) -> Result<u32> {
156+ let rows = db
157+ .prepare(
158+ "SELECT id, username, workspace, repo_id, repo, alert_type, opened, counts, worst, title, link
159+ FROM inbox_security_digest ORDER BY username, workspace, created_at LIMIT ?",
160+ )
161+ .bind(&[BATCH.into()])?
162+ .all()
163+ .await?
164+ .results::<Queued>()?;
165+ if rows.is_empty() {
166+ return Ok(0);
167+ }
168+ let mut by_person: BTreeMap<String, Vec<Queued>> = BTreeMap::new();
169+ for row in rows {
170+ by_person.entry(row.username.clone()).or_default().push(row);
171+ }
172+ let people: Vec<String> = by_person.keys().cloned().collect();
173+ let settings = subscriptions::settings_of(db, &people).await?;
174+ let today = weekday(now_ms);
175+ let now = rfc3339(now_ms);
176+ let mut sent = 0;
177+ for (username, rows) in by_person {
178+ let setting = settings.get(&username).cloned().unwrap_or_default();
179+ if !due(setting.security_digest, today) {
180+ continue;
181+ }
182+ let mut by_workspace: BTreeMap<String, Vec<Queued>> = BTreeMap::new();
183+ for row in rows {
184+ by_workspace.entry(row.workspace.clone()).or_default().push(row);
185+ }
186+ for (workspace, rows) in by_workspace {
187+ let (title, body) = digest_text(&workspace, &rows);
188+ let notice = Notice { username: username.clone(), reason: Reason::SecurityAlert, severity: Severity::Info, title, body };
189+ let thread = format!("workspace:{workspace}/security/digest");
190+ let link = format!("/{workspace}/-/security");
191+ let event_id = new_id("dig", now_ms);
192+ let mut statements = vec![
193+ db.prepare(inbox::BUMP).bind(&[
194+ new_id("ntf", now_ms).into(),
195+ username.as_str().into(),
196+ thread.as_str().into(),
197+ event_id.as_str().into(),
198+ EVENT.into(),
199+ notice.reason.as_str().into(),
200+ notice.severity.as_str().into(),
201+ notice.title.as_str().into(),
202+ notice.body.as_str().into(),
203+ workspace.as_str().into(),
204+ JsValue::NULL,
205+ JsValue::NULL,
206+ JsValue::NULL,
207+ JsValue::NULL,
208+ JsValue::NULL,
209+ link.as_str().into(),
210+ JsValue::NULL,
211+ now.as_str().into(),
212+ new_id("ntf", now_ms).into(),
213+ ])?,
214+ db.prepare(
215+ "INSERT OR IGNORE INTO inbox_activity (id, item_id, username, event_id, event, reason, severity, title, body, actor, created_at)
216+ SELECT ?1, id, ?2, ?4, ?5, ?6, ?7, ?8, ?9, NULL, ?10 FROM inbox_items WHERE username = ?2 AND thread = ?3",
217+ )
218+ .bind(&[
219+ new_id("ntf", now_ms).into(),
220+ username.as_str().into(),
221+ thread.as_str().into(),
222+ event_id.as_str().into(),
223+ EVENT.into(),
224+ notice.reason.as_str().into(),
225+ notice.severity.as_str().into(),
226+ notice.title.as_str().into(),
227+ notice.body.as_str().into(),
228+ now.as_str().into(),
229+ ])?,
230+ ];
231+ let ids: Vec<&str> = rows.iter().map(|row| row.id.as_str()).collect();
232+ statements.push(
233+ db.prepare("DELETE FROM inbox_security_digest WHERE id IN (SELECT value FROM json_each(?))")
234+ .bind(&[serde_json::to_string(&ids)?.into()])?,
235+ );
236+ db.batch(statements).await?;
237+ sent += 1;
238+ if let Some(notify) = notify {
239+ inbox::tell_live(notify, vec![(username.clone(), inbox::live_notification(&event_id, &notice, &link, &now))]).await;
240+ inbox::tell_inbox_count(db, notify, &username).await;
241+ }
242+ // Emailed as a security alert, to someone who can still read the
243+ // first repository named: identity checks that, as for any item.
244+ if setting.email.contains(&Reason::SecurityAlert)
245+ && let Some(first) = rows.first()
246+ {
247+ let sent_mail: Result<bool> = g1t_kit::call(
248+ identity,
249+ "notify_by_email",
250+ &g1t_contracts::inbox::NotifyByEmailArgs {
251+ username: username.clone(),
252+ repo_id: first.repo_id.clone(),
253+ subject: notice.title.clone(),
254+ intro: notice.body.clone(),
255+ quote: None,
256+ path: link.clone(),
257+ reason: Reason::SecurityAlert,
258+ },
259+ )
260+ .await;
261+ if let Err(error) = sent_mail {
262+ worker::console_error!("security digest: email to {username} not sent: {error}");
263+ }
264+ }
265+ }
266+ }
267+ Ok(sent)
268+}
269+
270+#[cfg(test)]
271+mod tests {
272+ use super::*;
273+
274+ fn row(repo: &str, alert_type: &str, counts: &str, title: &str) -> Queued {
275+ Queued {
276+ id: "dig_1".into(),
277+ username: "ana".into(),
278+ workspace: "acme".into(),
279+ repo_id: "rep_1".into(),
280+ repo: repo.into(),
281+ alert_type: alert_type.into(),
282+ opened: 1.0,
283+ counts: counts.into(),
284+ worst: None,
285+ title: title.into(),
286+ link: "/acme/rocket/security/vulnerabilities".into(),
287+ }
288+ }
289+
290+ #[test]
291+ fn the_digest_counts_what_is_new_worst_first_across_repositories() {
292+ let rows = vec![
293+ row("acme/rocket", "vulnerability", r#"{"critical":0,"high":0,"medium":3,"low":0,"unknown":0}"#, "lodash 4.17.20 in package-lock.json: GHSA-1"),
294+ row("acme/probe", "vulnerability", r#"{"critical":0,"high":0,"medium":1,"low":0,"unknown":0}"#, "ms 2.0.0 in package-lock.json: GHSA-2"),
295+ row("acme/rocket", "secret_scanning", r#"{"critical":0,"high":0,"medium":0,"low":1,"unknown":0}"#, "An example key in README.md"),
296+ ];
297+ let (title, body) = digest_text("acme", &rows);
298+ assert_eq!(title, "Security digest for acme: 4 new medium vulnerabilities in 2 repositories, 1 new low secret in 1 repository");
299+ assert!(body.starts_with("acme/rocket: An example key in README.md · acme/probe: ms 2.0.0"));
300+ assert!(!body.contains("and "));
301+ }
302+
303+ #[test]
304+ fn weekly_digests_go_out_on_mondays_and_nothing_queued_is_lost() {
305+ // 2026-10-12 is a Monday; 2026-10-11 a Sunday.
306+ let monday = 1_791_763_200_000;
307+ assert_eq!(weekday(monday), 1);
308+ assert_eq!(weekday(monday - 86_400_000), 0);
309+ assert!(due(DigestCadence::Weekly, 1) && !due(DigestCadence::Weekly, 0));
310+ assert!(due(DigestCadence::Daily, 0) && due(DigestCadence::Off, 3) && due(DigestCadence::AtOnce, 5));
311+ }
312+
313+ #[test]
314+ fn a_digest_of_many_names_five_and_counts_the_rest() {
315+ let rows: Vec<Queued> = (0..8).map(|n| row(&format!("acme/r{n}"), "code_scanning", r#"{"critical":0,"high":0,"medium":1,"low":0,"unknown":0}"#, &format!("Rule {n}"))).collect();
316+ let (title, body) = digest_text("acme", &rows);
317+ assert_eq!(title, "Security digest for acme: 8 new medium code scanning alerts in 8 repositories");
318+ assert!(body.ends_with("and 3 more"));
319+ }
320+}
+21−4
546546 username: String,
547547 email: Option<String>,
548548 default_watch: Option<String>,
549+ #[serde(default)]
550+ security_alerts: Option<f64>,
551+ #[serde(default)]
552+ security_digest: Option<String>,
549553 }
550554
551555 impl SettingsRow {
557561 None => defaults.email,
558562 },
559563 default_watch: self.default_watch.as_deref().and_then(WatchLevel::parse).unwrap_or(defaults.default_watch),
564+ security_alerts: self.security_alerts.map_or(defaults.security_alerts, |on| on != 0.0),
565+ security_digest: self.security_digest.as_deref().and_then(DigestCadence::parse).unwrap_or(defaults.security_digest),
560566 }
561567 }
562568 }
577583 let marks = vec!["?"; usernames.len()].join(", ");
578584 let values: Vec<JsValue> = usernames.iter().map(|name| JsValue::from(name.as_str())).collect();
579585 Ok(db
580− .prepare(format!("SELECT username, email, default_watch FROM inbox_settings WHERE username IN ({marks})"))
586+ .prepare(format!("SELECT username, email, default_watch, security_alerts, security_digest FROM inbox_settings WHERE username IN ({marks})"))
581587 .bind(&values)?
582588 .all()
583589 .await?
605611 })
606612 .transpose()?;
607613 db.prepare(
608− "INSERT INTO inbox_settings (username, email, default_watch, updated_at) VALUES (?1, ?2, ?3, ?4)
614+ "INSERT INTO inbox_settings (username, email, default_watch, security_alerts, security_digest, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
609615 ON CONFLICT (username) DO UPDATE SET
610616 email = COALESCE(excluded.email, inbox_settings.email),
611617 default_watch = COALESCE(excluded.default_watch, inbox_settings.default_watch),
618+ security_alerts = COALESCE(excluded.security_alerts, inbox_settings.security_alerts),
619+ security_digest = COALESCE(excluded.security_digest, inbox_settings.security_digest),
612620 updated_at = excluded.updated_at",
613621 )
614622 .bind(&[
615623 username.as_str().into(),
616624 email.map_or(JsValue::NULL, JsValue::from),
617625 a.default_watch.map_or(JsValue::NULL, |level| level.as_str().into()),
626+ a.security_alerts.map_or(JsValue::NULL, |on| i32::from(on).into()),
627+ a.security_digest.map_or(JsValue::NULL, |cadence| cadence.as_str().into()),
618628 rfc3339(now_ms()).into(),
619629 ])?
620630 .run()
690700 fn choices_keep_only_what_is_known() {
691701 assert_eq!(custom_events(&["Pulls".into(), "releases".into(), "issues".into(), "pulls".into()]), vec!["issues", "pulls"]);
692702 assert_eq!(email_reasons(&["mention".into(), "nope".into(), "agent".into()]), vec![Reason::Agent, Reason::Mention]);
693− let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()) }.into_settings();
703+ let settings = SettingsRow { username: "ana".into(), email: None, default_watch: Some("participating".into()), security_alerts: None, security_digest: None }.into_settings();
694704 assert_eq!(settings.email, DEFAULT_EMAIL.to_vec());
695705 assert_eq!(settings.default_watch, WatchLevel::Participating);
696− let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None }.into_settings();
706+ // Until chosen: urgent alerts at once, the rest daily.
707+ assert!(settings.security_alerts);
708+ assert_eq!(settings.security_digest, DigestCadence::Daily);
709+ let settings = SettingsRow { username: "ana".into(), email: Some("[]".into()), default_watch: None, security_alerts: Some(0.0), security_digest: Some("weekly".into()) }.into_settings();
697710 assert!(settings.email.is_empty());
698711 assert_eq!(settings.default_watch, WatchLevel::All);
712+ assert!(!settings.security_alerts);
713+ assert_eq!(settings.security_digest, DigestCadence::Weekly);
714+ let settings = SettingsRow { username: "ana".into(), email: None, default_watch: None, security_alerts: None, security_digest: Some("hourly".into()) }.into_settings();
715+ assert_eq!(settings.security_digest, DigestCadence::Daily);
699716 }
700717 }
+3−1
6262 // everyone (keep it at least billing's AUDIT_MAX_DAYS), then each
6363 // workspace's older than its own days. Only workspaces with entries
6464 // older than AUDIT_MIN_DAYS, the shortest any plan keeps, are asked about.
65− "triggers": { "crons": ["41 3 * * *"] },
65+ // At 08:00 UTC the security digests that are due go out (src/security_digest.rs):
66+ // daily ones every day, weekly ones on Monday.
67+ "triggers": { "crons": ["41 3 * * *", "0 8 * * *"] },
6668 "vars": { "AUDIT_MAX_DAYS": "400", "AUDIT_MIN_DAYS": "7" },
6769 // Logs of a tenth of requests: every change in g1t is an event.
6870 "observability": { "enabled": true, "head_sampling_rate": 0.1 }
+3−3
1919 const NOTIFY_LEVELS: readonly NotifyLevel[] = ["all", "dms_mentions", "none"];
2020 const DEFAULT_NOTIFY_PREFERENCES: NotifyPreferences = { level: "dms_mentions", workspaces: {} };
2121
22−/** What `dms_mentions`, the default, lets through: what was said to you, or waits on you. */
23−const DIRECT: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "thread_reply", "agent_waiting", "approval"]);
22+/** What `dms_mentions`, the default, lets through: what was said to you, waits on you, or is an urgent security alert. */
23+const DIRECT: ReadonlySet<NotificationKind> = new Set(["dm", "mention", "thread_reply", "agent_waiting", "approval", "security"]);
2424
2525 /** A tab whose last word is older than this is not in front of anyone. Pings come every 25 s. */
2626 export const STALE_MS = 70_000;
111111 if (!value || typeof value !== "object") return null;
112112 const n = value as Record<string, unknown>;
113113 const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
114− const kinds: NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval"];
114+ const kinds: NotificationKind[] = ["dm", "mention", "thread_reply", "inbox", "agent_waiting", "approval", "security"];
115115 const kind = kinds.find((k) => k === n.kind);
116116 const id = text(n.id, 200);
117117 const workspace = text(n.workspace, 100).toLowerCase();
+57−0
1+-- Reporting (src/advisories.rs): a repository's security advisories, and
2+-- the vulnerability reports people send its maintainers privately.
3+
4+-- An advisory: what the repository says about a vulnerability in what it
5+-- ships. Drafts are the maintainers'; published ones anyone who can see
6+-- the repository reads.
7+-- state: draft | published | withdrawn
8+-- severity: critical | high | medium | low
9+-- credits: JSON array of strings
10+CREATE TABLE advisories (
11+ id TEXT PRIMARY KEY,
12+ g1tsa_id TEXT NOT NULL UNIQUE,
13+ repo_id TEXT NOT NULL,
14+ title TEXT NOT NULL,
15+ description TEXT NOT NULL DEFAULT '',
16+ severity TEXT NOT NULL,
17+ cvss_vector TEXT,
18+ cvss_score REAL,
19+ cve_id TEXT,
20+ ecosystem TEXT,
21+ package TEXT,
22+ affected_versions TEXT,
23+ patched_versions TEXT,
24+ state TEXT NOT NULL DEFAULT 'draft',
25+ credits TEXT NOT NULL DEFAULT '[]',
26+ report_id TEXT,
27+ created_by TEXT NOT NULL,
28+ created_at TEXT NOT NULL,
29+ updated_at TEXT NOT NULL,
30+ published_at TEXT,
31+ withdrawn_at TEXT
32+);
33+CREATE INDEX advisories_repo ON advisories (repo_id, state, created_at);
34+
35+-- A report: what someone told the maintainers privately, when the
36+-- repository takes private vulnerability reports. The reporter is a
37+-- username when signed in, else the address they gave; reporter_key is
38+-- either, lowercased, for the limit on how many one person files a day.
39+-- state: new | accepted | declined
40+CREATE TABLE vulnerability_reports (
41+ id TEXT PRIMARY KEY,
42+ repo_id TEXT NOT NULL,
43+ reporter TEXT,
44+ reporter_email TEXT,
45+ reporter_key TEXT NOT NULL,
46+ title TEXT NOT NULL,
47+ description TEXT NOT NULL,
48+ severity TEXT,
49+ state TEXT NOT NULL DEFAULT 'new',
50+ advisory_id TEXT,
51+ created_at TEXT NOT NULL,
52+ reviewed_by TEXT,
53+ reviewed_at TEXT,
54+ review_note TEXT
55+);
56+CREATE INDEX vulnerability_reports_repo ON vulnerability_reports (repo_id, state, created_at);
57+CREATE INDEX vulnerability_reports_reporter ON vulnerability_reports (repo_id, reporter_key, created_at);
+759−0
1+//! Reporting: a repository's security advisories, and the vulnerability
2+//! reports people send its maintainers privately. See
3+//! `g1t_contracts::advisories`.
4+//!
5+//! Who sees and does what: anyone who can see the repository reads its
6+//! published advisories and, when the repository takes private reports,
7+//! files one (signed out, on a public repository, with an email address).
8+//! Those who see findings (Write, or a security manager) see drafts,
9+//! withdrawn advisories and the reports. Writing, publishing and
10+//! withdrawing an advisory, and reviewing a report, take Admin or a
11+//! security manager (`ManageSecurity`). A new report tells the workspace's
12+//! owners and security managers at once; a published advisory tells
13+//! webhooks and the repository's security watchers.
14+
15+use g1t_contracts::access::{self, Capability};
16+use g1t_contracts::advisories::*;
17+use g1t_contracts::repos::{GetArgs, Repo};
18+use g1t_contracts::time::rfc3339;
19+use g1t_contracts::{FailureCode, Outcome, User, new_id};
20+use g1t_kit::now_ms;
21+use serde::Deserialize;
22+use worker::Result;
23+use worker::wasm_bindgen::JsValue;
24+
25+use crate::Security;
26+use crate::store::{RepoRow, Store, now, optional};
27+use crate::suite::link;
28+
29+/// The most advisories and reports listed.
30+const LISTED: u32 = 200;
31+/// One person files at most this many reports on one repository a day,
32+/// and a repository holds at most this many new reports waiting: the form
33+/// takes no account, so it must not take a flood either.
34+const REPORTS_PER_DAY: u32 = 5;
35+const MAX_NEW_REPORTS: u32 = 50;
36+const DAY_MS: u64 = 24 * 60 * 60 * 1000;
37+
38+fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
39+ Outcome::fail(code, message)
40+}
41+
42+/// An advisory's page: `/acme/rocket/security/advisories/G1TSA-…`.
43+pub fn advisory_link(repo: &RepoRow, advisory: &Advisory) -> String {
44+ link(repo, &format!("advisories/{}", advisory.g1tsa_id))
45+}
46+
47+/// Text trimmed and cut to `max` characters; none when empty.
48+fn clean(text: Option<&str>, max: usize) -> Option<String> {
49+ let text: String = text.unwrap_or_default().trim().chars().take(max).collect();
50+ (!text.is_empty()).then_some(text)
51+}
52+
53+/// Whether text reads as an email address: something, an @, something with a dot.
54+pub fn looks_like_email(text: &str) -> bool {
55+ let text = text.trim();
56+ text.len() <= 254
57+ && !text.contains(char::is_whitespace)
58+ && text.split_once('@').is_some_and(|(user, host)| !user.is_empty() && host.contains('.') && !host.starts_with('.') && !host.ends_with('.'))
59+}
60+
61+/// An advisory's input, checked and cleaned, or what is wrong with it.
62+pub fn validate(input: &AdvisoryInput) -> std::result::Result<AdvisoryInput, String> {
63+ let title = clean(Some(&input.title), MAX_TITLE_CHARS).ok_or("Give the advisory a title.")?;
64+ if !SEVERITIES.contains(&input.severity.as_str()) {
65+ return Err("severity is critical, high, medium or low.".to_owned());
66+ }
67+ if let Some(cve) = clean(input.cve_id.as_deref(), 32)
68+ && !is_cve(&cve)
69+ {
70+ return Err("cve_id looks like CVE-2026-12345.".to_owned());
71+ }
72+ if input.cvss_score.is_some_and(|score| !(0.0..=10.0).contains(&score)) {
73+ return Err("cvss_score is 0 to 10.".to_owned());
74+ }
75+ let credits: Vec<String> = input
76+ .credits
77+ .iter()
78+ .filter_map(|credit| clean(Some(credit), 100))
79+ .take(MAX_CREDITS)
80+ .collect();
81+ Ok(AdvisoryInput {
82+ title,
83+ description: input.description.trim().chars().take(MAX_DESCRIPTION_CHARS).collect(),
84+ severity: input.severity.clone(),
85+ cvss_vector: clean(input.cvss_vector.as_deref(), 200),
86+ cvss_score: input.cvss_score,
87+ cve_id: clean(input.cve_id.as_deref(), 32).map(|cve| cve.to_uppercase()),
88+ ecosystem: clean(input.ecosystem.as_deref(), 50),
89+ package: clean(input.package.as_deref(), 200),
90+ affected_versions: clean(input.affected_versions.as_deref(), MAX_RANGE_CHARS),
91+ patched_versions: clean(input.patched_versions.as_deref(), MAX_RANGE_CHARS),
92+ credits,
93+ })
94+}
95+
96+/// `CVE-2026-12345`: a year and at least four digits.
97+fn is_cve(text: &str) -> bool {
98+ let upper = text.to_uppercase();
99+ let Some(rest) = upper.strip_prefix("CVE-") else { return false };
100+ let Some((year, number)) = rest.split_once('-') else { return false };
101+ year.len() == 4 && year.chars().all(|c| c.is_ascii_digit()) && number.len() >= 4 && number.chars().all(|c| c.is_ascii_digit())
102+}
103+
104+/// The draft an accepted report becomes: its words, the reporter credited.
105+pub fn draft_from(report: &VulnerabilityReport) -> AdvisoryInput {
106+ AdvisoryInput {
107+ title: report.title.clone(),
108+ description: report.description.clone(),
109+ severity: report.severity.clone().filter(|severity| SEVERITIES.contains(&severity.as_str())).unwrap_or_else(|| "medium".to_owned()),
110+ credits: report.reporter.iter().chain(report.reporter_email.iter()).take(1).cloned().collect(),
111+ ..AdvisoryInput::default()
112+ }
113+}
114+
115+/// The year of a time, for the id people quote.
116+fn year_of(ms: u64) -> u32 {
117+ rfc3339(ms).get(..4).and_then(|year| year.parse().ok()).unwrap_or(2026)
118+}
119+
120+// --- Rows -------------------------------------------------------------------
121+
122+#[derive(Deserialize)]
123+struct AdvisoryRow {
124+ id: String,
125+ g1tsa_id: String,
126+ repo_id: String,
127+ title: String,
128+ description: String,
129+ severity: String,
130+ cvss_vector: Option<String>,
131+ cvss_score: Option<f64>,
132+ cve_id: Option<String>,
133+ ecosystem: Option<String>,
134+ package: Option<String>,
135+ affected_versions: Option<String>,
136+ patched_versions: Option<String>,
137+ state: String,
138+ credits: String,
139+ report_id: Option<String>,
140+ created_by: String,
141+ created_at: String,
142+ updated_at: String,
143+ published_at: Option<String>,
144+ withdrawn_at: Option<String>,
145+}
146+
147+impl AdvisoryRow {
148+ fn into_advisory(self, repo: &RepoRow) -> Advisory {
149+ Advisory {
150+ id: self.id,
151+ g1tsa_id: self.g1tsa_id,
152+ repo_id: self.repo_id,
153+ repo: format!("{}/{}", repo.namespace, repo.name),
154+ title: self.title,
155+ description: self.description,
156+ severity: self.severity,
157+ cvss_vector: self.cvss_vector,
158+ cvss_score: self.cvss_score,
159+ cve_id: self.cve_id,
160+ ecosystem: self.ecosystem,
161+ package: self.package,
162+ affected_versions: self.affected_versions,
163+ patched_versions: self.patched_versions,
164+ state: self.state,
165+ credits: serde_json::from_str(&self.credits).unwrap_or_default(),
166+ report_id: self.report_id,
167+ created_by: self.created_by,
168+ created_at: self.created_at,
169+ updated_at: self.updated_at,
170+ published_at: self.published_at,
171+ withdrawn_at: self.withdrawn_at,
172+ }
173+ }
174+}
175+
176+#[derive(Deserialize)]
177+struct ReportRow {
178+ id: String,
179+ repo_id: String,
180+ reporter: Option<String>,
181+ reporter_email: Option<String>,
182+ title: String,
183+ description: String,
184+ severity: Option<String>,
185+ state: String,
186+ advisory_id: Option<String>,
187+ created_at: String,
188+ reviewed_by: Option<String>,
189+ reviewed_at: Option<String>,
190+ review_note: Option<String>,
191+}
192+
193+impl ReportRow {
194+ fn into_report(self, repo: &RepoRow) -> VulnerabilityReport {
195+ VulnerabilityReport {
196+ id: self.id,
197+ repo_id: self.repo_id,
198+ repo: format!("{}/{}", repo.namespace, repo.name),
199+ reporter: self.reporter,
200+ reporter_email: self.reporter_email,
201+ title: self.title,
202+ description: self.description,
203+ severity: self.severity,
204+ state: self.state,
205+ advisory_id: self.advisory_id,
206+ created_at: self.created_at,
207+ reviewed_by: self.reviewed_by,
208+ reviewed_at: self.reviewed_at,
209+ review_note: self.review_note,
210+ }
211+ }
212+}
213+
214+#[derive(Deserialize)]
215+struct CountRow {
216+ n: f64,
217+}
218+
219+const ADVISORY_COLUMNS: &str = "id, g1tsa_id, repo_id, title, description, severity, cvss_vector, cvss_score, cve_id, ecosystem, package,
220+ affected_versions, patched_versions, state, credits, report_id, created_by, created_at, updated_at, published_at, withdrawn_at";
221+const REPORT_COLUMNS: &str =
222+ "id, repo_id, reporter, reporter_email, title, description, severity, state, advisory_id, created_at, reviewed_by, reviewed_at, review_note";
223+
224+impl Store {
225+ /// A repository's advisories in `states`, newest first.
226+ pub async fn security_advisories(&self, repo: &RepoRow, states: &[&str]) -> Result<Vec<Advisory>> {
227+ if states.is_empty() {
228+ return Ok(Vec::new());
229+ }
230+ let marks = vec!["?"; states.len()].join(", ");
231+ let mut values: Vec<JsValue> = vec![repo.repo_id.as_str().into()];
232+ values.extend(states.iter().map(|state| JsValue::from(*state)));
233+ values.push(LISTED.into());
234+ let rows = self
235+ .db
236+ .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM advisories WHERE repo_id = ? AND state IN ({marks}) ORDER BY created_at DESC LIMIT ?"))
237+ .bind(&values)?
238+ .all()
239+ .await?
240+ .results::<AdvisoryRow>()?;
241+ Ok(rows.into_iter().map(|row| row.into_advisory(repo)).collect())
242+ }
243+
244+ /// One advisory by `adv_…` or `G1TSA-…` id.
245+ pub async fn security_advisory(&self, repo: &RepoRow, id: &str) -> Result<Option<Advisory>> {
246+ let column = if is_g1tsa_id(id) { "g1tsa_id" } else { "id" };
247+ Ok(self
248+ .db
249+ .prepare(format!("SELECT {ADVISORY_COLUMNS} FROM advisories WHERE repo_id = ? AND {column} = ?"))
250+ .bind(&[repo.repo_id.as_str().into(), id.into()])?
251+ .first::<AdvisoryRow>(None)
252+ .await?
253+ .map(|row| row.into_advisory(repo)))
254+ }
255+
256+ pub async fn add_advisory(&self, repo: &RepoRow, input: &AdvisoryInput, by: &str, report_id: Option<&str>) -> Result<String> {
257+ let now_ms = now_ms();
258+ let id = new_id("adv", now_ms);
259+ let mut random = [0u8; 8];
260+ if let Err(error) = getrandom::getrandom(&mut random) {
261+ worker::console_error!("security: no randomness for an advisory id: {error}");
262+ }
263+ let at = rfc3339(now_ms);
264+ self.db
265+ .prepare(
266+ "INSERT INTO advisories (id, g1tsa_id, repo_id, title, description, severity, cvss_vector, cvss_score, cve_id, ecosystem,
267+ package, affected_versions, patched_versions, state, credits, report_id, created_by, created_at, updated_at)
268+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)",
269+ )
270+ .bind(&[
271+ id.as_str().into(),
272+ g1tsa_id(year_of(now_ms), &random).into(),
273+ repo.repo_id.as_str().into(),
274+ input.title.as_str().into(),
275+ input.description.as_str().into(),
276+ input.severity.as_str().into(),
277+ optional(input.cvss_vector.as_deref()),
278+ input.cvss_score.map_or(JsValue::NULL, JsValue::from),
279+ optional(input.cve_id.as_deref()),
280+ optional(input.ecosystem.as_deref()),
281+ optional(input.package.as_deref()),
282+ optional(input.affected_versions.as_deref()),
283+ optional(input.patched_versions.as_deref()),
284+ serde_json::to_string(&input.credits)?.into(),
285+ optional(report_id),
286+ by.into(),
287+ at.as_str().into(),
288+ at.as_str().into(),
289+ ])?
290+ .run()
291+ .await?;
292+ Ok(id)
293+ }
294+
295+ pub async fn update_advisory(&self, repo: &RepoRow, id: &str, input: &AdvisoryInput) -> Result<()> {
296+ self.db
297+ .prepare(
298+ "UPDATE advisories SET title = ?, description = ?, severity = ?, cvss_vector = ?, cvss_score = ?, cve_id = ?, ecosystem = ?,
299+ package = ?, affected_versions = ?, patched_versions = ?, credits = ?, updated_at = ?
300+ WHERE repo_id = ? AND id = ?",
301+ )
302+ .bind(&[
303+ input.title.as_str().into(),
304+ input.description.as_str().into(),
305+ input.severity.as_str().into(),
306+ optional(input.cvss_vector.as_deref()),
307+ input.cvss_score.map_or(JsValue::NULL, JsValue::from),
308+ optional(input.cve_id.as_deref()),
309+ optional(input.ecosystem.as_deref()),
310+ optional(input.package.as_deref()),
311+ optional(input.affected_versions.as_deref()),
312+ optional(input.patched_versions.as_deref()),
313+ serde_json::to_string(&input.credits)?.into(),
314+ now().into(),
315+ repo.repo_id.as_str().into(),
316+ id.into(),
317+ ])?
318+ .run()
319+ .await?;
320+ Ok(())
321+ }
322+
323+ pub async fn set_advisory_state(&self, repo: &RepoRow, id: &str, state: &str) -> Result<()> {
324+ let at = now();
325+ let stamp = match state {
326+ "published" => "published_at = COALESCE(published_at, ?1), withdrawn_at = NULL",
327+ "withdrawn" => "withdrawn_at = ?1",
328+ _ => "published_at = published_at",
329+ };
330+ self.db
331+ .prepare(format!("UPDATE advisories SET state = ?2, updated_at = ?1, {stamp} WHERE repo_id = ?3 AND id = ?4"))
332+ .bind(&[at.as_str().into(), state.into(), repo.repo_id.as_str().into(), id.into()])?
333+ .run()
334+ .await?;
335+ Ok(())
336+ }
337+
338+ pub async fn reports(&self, repo: &RepoRow, state: Option<&str>) -> Result<Vec<VulnerabilityReport>> {
339+ let rows = match state {
340+ Some(state) => {
341+ self.db
342+ .prepare(format!(
343+ "SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ? AND state = ? ORDER BY created_at DESC LIMIT ?"
344+ ))
345+ .bind(&[repo.repo_id.as_str().into(), state.into(), LISTED.into()])?
346+ }
347+ None => self
348+ .db
349+ .prepare(format!(
350+ "SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ?
351+ ORDER BY CASE state WHEN 'new' THEN 0 ELSE 1 END, created_at DESC LIMIT ?"
352+ ))
353+ .bind(&[repo.repo_id.as_str().into(), LISTED.into()])?,
354+ }
355+ .all()
356+ .await?
357+ .results::<ReportRow>()?;
358+ Ok(rows.into_iter().map(|row| row.into_report(repo)).collect())
359+ }
360+
361+ pub async fn report(&self, repo: &RepoRow, id: &str) -> Result<Option<VulnerabilityReport>> {
362+ Ok(self
363+ .db
364+ .prepare(format!("SELECT {REPORT_COLUMNS} FROM vulnerability_reports WHERE repo_id = ? AND id = ?"))
365+ .bind(&[repo.repo_id.as_str().into(), id.into()])?
366+ .first::<ReportRow>(None)
367+ .await?
368+ .map(|row| row.into_report(repo)))
369+ }
370+
371+ /// How many reports `reporter_key` filed on the repository since `since`,
372+ /// and how many new ones the repository holds.
373+ pub async fn report_counts(&self, repo: &RepoRow, reporter_key: &str, since: &str) -> Result<(u32, u32)> {
374+ let results = self
375+ .db
376+ .batch(vec![
377+ self.db
378+ .prepare("SELECT count(*) AS n FROM vulnerability_reports WHERE repo_id = ? AND reporter_key = ? AND created_at >= ?")
379+ .bind(&[repo.repo_id.as_str().into(), reporter_key.into(), since.into()])?,
380+ self.db
381+ .prepare("SELECT count(*) AS n FROM vulnerability_reports WHERE repo_id = ? AND state = 'new'")
382+ .bind(&[repo.repo_id.as_str().into()])?,
383+ ])
384+ .await?;
385+ let count = |at: usize| results.get(at).and_then(|result| result.results::<CountRow>().ok()).and_then(|rows| rows.first().map(|row| row.n as u32)).unwrap_or(0);
386+ Ok((count(0), count(1)))
387+ }
388+
389+ pub async fn add_report(
390+ &self,
391+ repo: &RepoRow,
392+ reporter: Option<&str>,
393+ email: Option<&str>,
394+ reporter_key: &str,
395+ title: &str,
396+ description: &str,
397+ severity: Option<&str>,
398+ ) -> Result<String> {
399+ let now_ms = now_ms();
400+ let id = new_id("vrp", now_ms);
401+ self.db
402+ .prepare(
403+ "INSERT INTO vulnerability_reports (id, repo_id, reporter, reporter_email, reporter_key, title, description, severity, state, created_at)
404+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'new', ?)",
405+ )
406+ .bind(&[
407+ id.as_str().into(),
408+ repo.repo_id.as_str().into(),
409+ optional(reporter),
410+ optional(email),
411+ reporter_key.into(),
412+ title.into(),
413+ description.into(),
414+ optional(severity),
415+ rfc3339(now_ms).into(),
416+ ])?
417+ .run()
418+ .await?;
419+ Ok(id)
420+ }
421+
422+ /// Settles a new report. Returns whether it was still new.
423+ pub async fn review_report(&self, repo: &RepoRow, id: &str, state: &str, by: &str, note: Option<&str>, advisory_id: Option<&str>) -> Result<bool> {
424+ #[derive(Deserialize)]
425+ struct Changed {
426+ #[allow(dead_code)]
427+ id: String,
428+ }
429+ let changed = self
430+ .db
431+ .prepare(
432+ "UPDATE vulnerability_reports SET state = ?, reviewed_by = ?, reviewed_at = ?, review_note = ?, advisory_id = ?
433+ WHERE repo_id = ? AND id = ? AND state = 'new' RETURNING id",
434+ )
435+ .bind(&[state.into(), by.into(), now().into(), optional(note), optional(advisory_id), repo.repo_id.as_str().into(), id.into()])?
436+ .all()
437+ .await?
438+ .results::<Changed>()?;
439+ Ok(!changed.is_empty())
440+ }
441+
442+ /// A repository purged: its advisories and reports go with it.
443+ pub async fn purge_advisories(&self, repo_id: &str) -> Result<()> {
444+ self.db
445+ .batch(vec![
446+ self.db.prepare("DELETE FROM advisories WHERE repo_id = ?").bind(&[repo_id.into()])?,
447+ self.db.prepare("DELETE FROM vulnerability_reports WHERE repo_id = ?").bind(&[repo_id.into()])?,
448+ ])
449+ .await?;
450+ Ok(())
451+ }
452+}
453+
454+// --- The service --------------------------------------------------------------
455+
456+impl Security {
457+ /// The repository, for someone who can see it (signed out, a public
458+ /// one), recorded here, with what the viewer may do in it.
459+ async fn visible_repo(&self, path: &g1t_contracts::repos::RepoPath, viewer: &Option<User>) -> Result<Outcome<(RepoRow, Repo)>> {
460+ let found: Outcome<Repo> = g1t_kit::call(&self.repos, "get", &GetArgs { path: path.clone(), viewer: viewer.clone() }).await?;
461+ match found {
462+ Outcome::Ok(repo) => {
463+ let row = self.store.register(&repo.id, &repo.namespace, &repo.name).await?;
464+ self.store.set_private(&repo.id, repo.is_private).await?;
465+ Ok(Outcome::Ok((row, repo)))
466+ }
467+ Outcome::Fail(_) => Ok(fail(FailureCode::NotFound, "Repository not found.")),
468+ }
469+ }
470+
471+ /// The states of advisories the viewer may see: every one for those who
472+ /// see findings, else published ones alone.
473+ fn visible_states<'a>(viewer: Option<&User>, repo: &Repo, asked: Option<&'a str>) -> Vec<&'a str> {
474+ let all = access::can(viewer, repo, Capability::SecurityAlerts);
475+ match asked {
476+ Some(state) if ADVISORY_STATES.contains(&state) => {
477+ if all || state == "published" {
478+ vec![state]
479+ } else {
480+ Vec::new()
481+ }
482+ }
483+ Some(_) => Vec::new(),
484+ None if all => ADVISORY_STATES.to_vec(),
485+ None => vec!["published"],
486+ }
487+ }
488+
489+ pub(crate) async fn list_advisories(&self, a: ListAdvisoriesArgs) -> Result<Outcome<Vec<Advisory>>> {
490+ let (row, repo) = match self.visible_repo(&a.repo, &a.viewer).await? {
491+ Outcome::Ok(found) => found,
492+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
493+ };
494+ let states = Self::visible_states(a.viewer.as_ref(), &repo, a.state.as_deref());
495+ Ok(Outcome::Ok(self.store.security_advisories(&row, &states).await?))
496+ }
497+
498+ pub(crate) async fn get_advisory(&self, a: GetAdvisoryArgs) -> Result<Outcome<Advisory>> {
499+ let (row, repo) = match self.visible_repo(&a.repo, &a.viewer).await? {
500+ Outcome::Ok(found) => found,
501+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
502+ };
503+ let states = Self::visible_states(a.viewer.as_ref(), &repo, None);
504+ match self.store.security_advisory(&row, &a.id).await? {
505+ Some(advisory) if states.contains(&advisory.state.as_str()) => Ok(Outcome::Ok(advisory)),
506+ _ => Ok(fail(FailureCode::NotFound, "No such advisory.")),
507+ }
508+ }
509+
510+ /// The repository, for an actor who manages its security.
511+ async fn managed_repo(&self, path: &g1t_contracts::repos::RepoPath, actor: &User) -> Result<Outcome<RepoRow>> {
512+ if !actor.verified {
513+ return Ok(fail(FailureCode::Forbidden, "Confirm your email address first."));
514+ }
515+ self.member_repo(path, &Some(actor.clone()), Capability::ManageSecurity).await
516+ }
517+
518+ pub(crate) async fn create_advisory(&self, a: CreateAdvisoryArgs) -> Result<Outcome<Advisory>> {
519+ let repo = match self.managed_repo(&a.repo, &a.actor).await? {
520+ Outcome::Ok(repo) => repo,
521+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
522+ };
523+ let input = match validate(&a.advisory) {
524+ Ok(input) => input,
525+ Err(message) => return Ok(fail(FailureCode::Invalid, message)),
526+ };
527+ let id = self.store.add_advisory(&repo, &input, &a.actor.username, None).await?;
528+ let Some(advisory) = self.store.security_advisory(&repo, &id).await? else {
529+ return Ok(fail(FailureCode::NotFound, "No such advisory."));
530+ };
531+ self.audit(&a.actor, "security_advisory.created", Some(&repo), &repo.namespace, None, &format!("Drafted advisory {}: {}", advisory.g1tsa_id, advisory.title))
532+ .await;
533+ Ok(Outcome::Ok(advisory))
534+ }
535+
536+ pub(crate) async fn update_advisory(&self, a: UpdateAdvisoryArgs) -> Result<Outcome<Advisory>> {
537+ let repo = match self.managed_repo(&a.repo, &a.actor).await? {
538+ Outcome::Ok(repo) => repo,
539+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
540+ };
541+ let Some(advisory) = self.store.security_advisory(&repo, &a.id).await? else {
542+ return Ok(fail(FailureCode::NotFound, "No such advisory."));
543+ };
544+ let input = match validate(&a.advisory) {
545+ Ok(input) => input,
546+ Err(message) => return Ok(fail(FailureCode::Invalid, message)),
547+ };
548+ self.store.update_advisory(&repo, &advisory.id, &input).await?;
549+ let Some(advisory) = self.store.security_advisory(&repo, &advisory.id).await? else {
550+ return Ok(fail(FailureCode::NotFound, "No such advisory."));
551+ };
552+ self.audit(&a.actor, "security_advisory.updated", Some(&repo), &repo.namespace, None, &format!("Changed advisory {}", advisory.g1tsa_id)).await;
553+ Ok(Outcome::Ok(advisory))
554+ }
555+
556+ pub(crate) async fn set_advisory_state(&self, a: SetAdvisoryStateArgs) -> Result<Outcome<Advisory>> {
557+ let repo = match self.managed_repo(&a.repo, &a.actor).await? {
558+ Outcome::Ok(repo) => repo,
559+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
560+ };
561+ let Some(advisory) = self.store.security_advisory(&repo, &a.id).await? else {
562+ return Ok(fail(FailureCode::NotFound, "No such advisory."));
563+ };
564+ let kind = match (advisory.state.as_str(), a.state.as_str()) {
565+ ("draft" | "withdrawn", "published") => "security_advisory.published",
566+ ("published", "withdrawn") => "security_advisory.withdrawn",
567+ (same, asked) if same == asked => return Ok(fail(FailureCode::Conflict, format!("This advisory is {same} already."))),
568+ (_, "published" | "withdrawn") => return Ok(fail(FailureCode::Conflict, "Only a published advisory can be withdrawn.")),
569+ _ => return Ok(fail(FailureCode::Invalid, "state is published or withdrawn.")),
570+ };
571+ if kind == "security_advisory.published" && advisory.description.trim().is_empty() {
572+ return Ok(fail(FailureCode::Invalid, "Describe the vulnerability before publishing the advisory."));
573+ }
574+ self.store.set_advisory_state(&repo, &advisory.id, &a.state).await?;
575+ let Some(advisory) = self.store.security_advisory(&repo, &advisory.id).await? else {
576+ return Ok(fail(FailureCode::NotFound, "No such advisory."));
577+ };
578+ let event = AdvisoryEvent {
579+ repo_id: repo.repo_id.clone(),
580+ id: advisory.id.clone(),
581+ g1tsa_id: Some(advisory.g1tsa_id.clone()),
582+ title: advisory.title.clone(),
583+ severity: Some(advisory.severity.clone()),
584+ state: advisory.state.clone(),
585+ link: advisory_link(&repo, &advisory),
586+ notify: Vec::new(),
587+ };
588+ self.publish_advisory(kind, &repo, &event, Some(a.actor.id.clone())).await;
589+ self.audit(&a.actor, kind, Some(&repo), &repo.namespace, None, &format!("Advisory {} {}", advisory.g1tsa_id, advisory.state)).await;
590+ Ok(Outcome::Ok(advisory))
591+ }
592+
593+ pub(crate) async fn report_vulnerability(&self, a: ReportVulnerabilityArgs) -> Result<Outcome<VulnerabilityReport>> {
594+ let (repo, record) = match self.visible_repo(&a.repo, &a.viewer).await? {
595+ Outcome::Ok(found) => found,
596+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
597+ };
598+ if !self.store.repo_settings(&repo.repo_id).await?.0.private_vulnerability_reporting {
599+ return Ok(fail(FailureCode::Conflict, "This repository does not take private vulnerability reports."));
600+ }
601+ let email = clean(a.email.as_deref(), 254).map(|email| email.to_lowercase());
602+ let (reporter, key) = match a.viewer.as_ref().filter(|user| !user.username.is_empty()) {
603+ Some(user) => (Some(user.username.to_lowercase()), format!("user:{}", user.username.to_lowercase())),
604+ None => {
605+ if record.is_private {
606+ return Ok(fail(FailureCode::NotFound, "Repository not found."));
607+ }
608+ let Some(email) = email.as_deref().filter(|email| looks_like_email(email)) else {
609+ return Ok(fail(FailureCode::Invalid, "Give an email address the maintainers can answer at, or sign in."));
610+ };
611+ (None, format!("email:{email}"))
612+ }
613+ };
614+ let Some(title) = clean(Some(&a.title), MAX_TITLE_CHARS) else {
615+ return Ok(fail(FailureCode::Invalid, "Give the report a title."));
616+ };
617+ let description: String = a.description.trim().chars().take(MAX_DESCRIPTION_CHARS).collect();
618+ if description.is_empty() {
619+ return Ok(fail(FailureCode::Invalid, "Describe the vulnerability: what is affected and how to reproduce it."));
620+ }
621+ let severity = a.severity.as_deref().map(str::trim).filter(|severity| !severity.is_empty());
622+ if severity.is_some_and(|severity| !SEVERITIES.contains(&severity)) {
623+ return Ok(fail(FailureCode::Invalid, "severity is critical, high, medium or low, or left out."));
624+ }
625+ let since = rfc3339(now_ms().saturating_sub(DAY_MS));
626+ let (by_reporter, waiting) = self.store.report_counts(&repo, &key, &since).await?;
627+ if by_reporter >= REPORTS_PER_DAY {
628+ return Ok(fail(FailureCode::Limit, format!("You have sent {REPORTS_PER_DAY} reports to this repository today. Try again tomorrow.")));
629+ }
630+ if waiting >= MAX_NEW_REPORTS {
631+ return Ok(fail(FailureCode::Conflict, "This repository has as many reports waiting as it takes. Try again once its maintainers have reviewed them."));
632+ }
633+ let id = self.store.add_report(&repo, reporter.as_deref(), email.as_deref(), &key, &title, &description, severity).await?;
634+ let Some(report) = self.store.report(&repo, &id).await? else {
635+ return Ok(fail(FailureCode::NotFound, "No such report."));
636+ };
637+ let event = AdvisoryEvent {
638+ repo_id: repo.repo_id.clone(),
639+ id: report.id.clone(),
640+ g1tsa_id: None,
641+ title: report.title.clone(),
642+ severity: report.severity.clone(),
643+ state: report.state.clone(),
644+ link: link(&repo, "reports"),
645+ notify: Vec::new(),
646+ };
647+ self.publish_advisory("security_advisory.reported", &repo, &event, a.viewer.as_ref().map(|user| user.id.clone())).await;
648+ Ok(Outcome::Ok(report))
649+ }
650+
651+ pub(crate) async fn list_vulnerability_reports(&self, a: ListReportsArgs) -> Result<Outcome<Vec<VulnerabilityReport>>> {
652+ let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
653+ Outcome::Ok(repo) => repo,
654+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
655+ };
656+ let state = a.state.as_deref().filter(|state| REPORT_STATES.contains(state));
657+ if a.state.is_some() && state.is_none() {
658+ return Ok(fail(FailureCode::Invalid, "state is new, accepted or declined."));
659+ }
660+ Ok(Outcome::Ok(self.store.reports(&repo, state).await?))
661+ }
662+
663+ pub(crate) async fn review_vulnerability_report(&self, a: ReviewReportArgs) -> Result<Outcome<ReviewedReport>> {
664+ let repo = match self.managed_repo(&a.repo, &a.actor).await? {
665+ Outcome::Ok(repo) => repo,
666+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
667+ };
668+ let Some(report) = self.store.report(&repo, &a.id).await? else {
669+ return Ok(fail(FailureCode::NotFound, "No such report."));
670+ };
671+ if report.state != "new" {
672+ return Ok(fail(FailureCode::Conflict, format!("This report was {} already.", report.state)));
673+ }
674+ let note = clean(Some(&a.note), 1000);
675+ let (state, advisory_id) = match a.decision.as_str() {
676+ "accept" => {
677+ let draft = draft_from(&report);
678+ let id = self.store.add_advisory(&repo, &draft, &a.actor.username, Some(&report.id)).await?;
679+ ("accepted", Some(id))
680+ }
681+ "decline" => ("declined", None),
682+ _ => return Ok(fail(FailureCode::Invalid, "decision is accept or decline.")),
683+ };
684+ if !self.store.review_report(&repo, &report.id, state, &a.actor.username, note.as_deref(), advisory_id.as_deref()).await? {
685+ return Ok(fail(FailureCode::Conflict, "This report was reviewed already."));
686+ }
687+ let advisory = match &advisory_id {
688+ Some(id) => self.store.security_advisory(&repo, id).await?,
689+ None => None,
690+ };
691+ let Some(report) = self.store.report(&repo, &report.id).await? else {
692+ return Ok(fail(FailureCode::NotFound, "No such report."));
693+ };
694+ self.audit(
695+ &a.actor,
696+ "security_advisory.report_reviewed",
697+ Some(&repo),
698+ &repo.namespace,
699+ None,
700+ &format!("Report {} {state}{}", report.id, advisory.as_ref().map(|advisory| format!(", drafted as {}", advisory.g1tsa_id)).unwrap_or_default()),
701+ )
702+ .await;
703+ Ok(Outcome::Ok(ReviewedReport { report, advisory }))
704+ }
705+}
706+
707+#[cfg(test)]
708+mod tests {
709+ use super::*;
710+
711+ #[test]
712+ fn an_advisory_is_checked_and_cleaned() {
713+ let input = AdvisoryInput {
714+ title: " Path traversal in the archive reader ".into(),
715+ description: "Details…".into(),
716+ severity: "high".into(),
717+ cve_id: Some("cve-2026-12345".into()),
718+ cvss_score: Some(7.5),
719+ credits: vec!["ana".into(), " ".into(), "bo".into()],
720+ ..AdvisoryInput::default()
721+ };
722+ let clean = validate(&input).unwrap();
723+ assert_eq!(clean.title, "Path traversal in the archive reader");
724+ assert_eq!(clean.cve_id.as_deref(), Some("CVE-2026-12345"));
725+ assert_eq!(clean.credits, vec!["ana", "bo"]);
726+ assert!(validate(&AdvisoryInput { title: " ".into(), severity: "high".into(), ..AdvisoryInput::default() }).is_err());
727+ assert!(validate(&AdvisoryInput { title: "x".into(), severity: "severe".into(), ..AdvisoryInput::default() }).is_err());
728+ assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cve_id: Some("CVE-1".into()), ..AdvisoryInput::default() }).is_err());
729+ assert!(validate(&AdvisoryInput { title: "x".into(), severity: "low".into(), cvss_score: Some(11.0), ..AdvisoryInput::default() }).is_err());
730+ }
731+
732+ #[test]
733+ fn an_accepted_report_becomes_a_draft_crediting_its_reporter() {
734+ let report = VulnerabilityReport {
735+ title: "XSS in the search box".into(),
736+ description: "Type <script> into…".into(),
737+ severity: Some("medium".into()),
738+ reporter: None,
739+ reporter_email: Some("finder@example.com".into()),
740+ ..VulnerabilityReport::default()
741+ };
742+ let draft = draft_from(&report);
743+ assert_eq!(draft.title, "XSS in the search box");
744+ assert_eq!(draft.credits, vec!["finder@example.com"]);
745+ let signed_in = VulnerabilityReport { reporter: Some("ana".into()), severity: None, ..report };
746+ let draft = draft_from(&signed_in);
747+ assert_eq!(draft.credits, vec!["ana"]);
748+ assert_eq!(draft.severity, "medium");
749+ }
750+
751+ #[test]
752+ fn addresses_and_cves_are_checked_loosely() {
753+ assert!(looks_like_email("finder@example.com"));
754+ assert!(!looks_like_email("finder") && !looks_like_email("@example.com") && !looks_like_email("a b@example.com"));
755+ assert!(is_cve("CVE-2026-12345") && is_cve("cve-2026-0001"));
756+ assert!(!is_cve("CVE-26-1") && !is_cve("GHSA-xxxx"));
757+ assert!(year_of(0) == 1970);
758+ }
759+}
+8−2
2121 SetCodeAlertStateArgs, UploadSarifArgs,
2222 };
2323 use g1t_contracts::work::{AddCommentArgs, Pull, PullDetail, SetCommitStatusArgs, ViewArgs};
24+use g1t_contracts::security_suite::MAX_SUMMARY_IDS;
2425 use g1t_contracts::{FailureCode, Outcome, User};
2526 use g1t_scan::sarif::{self, Finding, Gate};
2627 use serde_json::{Value, json};
2728 use worker::Result;
2829
2930 use crate::Security;
31+use crate::summaries::Changed;
3032 use crate::store::{Activity, RepoRow};
3133 use crate::suite::link;
3234 use crate::suite_store::NewCodeAlert;
3537 /// Review comments one analysis of a pull request leaves, at most.
3638 const MAX_COMMENTS: usize = 10;
3739 /// Alerts an upload announces one by one; past it, the page says the rest.
38−const MAX_EVENTS: usize = 20;
3940
4041 fn fail<T>(code: FailureCode, message: impl Into<String>) -> Outcome<T> {
4142 Outcome::fail(code, message)
226227 .add_analysis(&repo.repo_id, &id, run, &a.commit_sha, &a.git_ref, None, opened.len() as u32, fixed.len() as u32)
227228 .await?,
228229 );
230+ // An event each for webhooks, and one summary of the analysis for the inbox.
229231 for (ids, action) in [(&opened, "created"), (&fixed, "fixed")] {
230− for alert_id in ids.iter().take(MAX_EVENTS) {
232+ for alert_id in ids.iter().take(MAX_SUMMARY_IDS) {
231233 if let Some(alert) = self.store.code_alert_by_id(&repo.repo_id, alert_id).await? {
232234 self.alert_event(AlertType::CodeScanning, action, &repo, code_event(&repo, &alert), Some(a.actor.id.clone())).await;
233235 }
234236 }
235237 }
238+ let changed = Changed { scan: "analysis", opened: opened.clone(), fixed: fixed.clone(), ..Changed::default() };
239+ self.summarize(&repo, AlertType::CodeScanning, changed, Some(a.actor.id.clone())).await?;
236240 } else {
237241 analyses.push(self.store.add_analysis(&repo.repo_id, &id, run, &a.commit_sha, &a.git_ref, pull, 0, 0).await?);
238242 }
374378 };
375379 let event = SecurityEvent { reason: a.reason.map(|reason| reason.as_str().to_owned()), ..code_event(&repo, &alert) };
376380 self.alert_event(AlertType::CodeScanning, action, &repo, event, Some(a.actor.id.clone())).await;
381+ let changed = Changed { scan: "change", dismissed: u32::from(action == "dismissed"), ..Changed::default() };
382+ self.summarize(&repo, AlertType::CodeScanning, changed, Some(a.actor.id.clone())).await?;
377383 Ok(Outcome::Ok(alert))
378384 }
379385
+19−10
324324
325325 impl Security {
326326 /// Tells of vulnerability alerts a dependency read opened or fixed,
327− /// given the ids that were open before it.
327+ /// given the ids that were open before it: an event each for webhooks
328+ /// (the first `MAX_SUMMARY_IDS` of each), and one summary of the read
329+ /// for the inbox, which carries the counts whatever the number.
328330 async fn vulnerabilities_changed(&self, repo: &RepoRow, before: &BTreeSet<String>) -> Result<()> {
329− use g1t_contracts::security_suite::AlertType;
331+ use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS};
330332 let after: Vec<VulnRow> = self.store.open_vulnerabilities(&repo.repo_id).await?;
331333 let now: BTreeSet<&str> = after.iter().map(|row| row.id.as_str()).collect();
332− let opened: Vec<&str> = now.iter().copied().filter(|id| !before.contains(*id)).take(20).collect();
333− let fixed: Vec<&String> = before.iter().filter(|id| !now.contains(id.as_str())).take(20).collect();
334− for id in opened {
334+ // Worst first, as the rows come: the summary's headline is the worst new one.
335+ let opened: Vec<String> = after.iter().map(|row| row.id.clone()).filter(|id| !before.contains(id)).collect();
336+ let mut fixed: Vec<String> = Vec::new();
337+ for id in before.iter().filter(|id| !now.contains(id.as_str())) {
338+ if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await?
339+ && vuln.status == VulnStatus::Fixed
340+ {
341+ fixed.push(id.clone());
342+ }
343+ }
344+ for id in opened.iter().take(MAX_SUMMARY_IDS) {
335345 if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? {
336346 self.alert_event(AlertType::Vulnerability, "created", repo, vulnerability_event(repo, &vuln), None).await;
337347 }
338348 }
339− for id in fixed {
340− if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await?
341− && vuln.status == VulnStatus::Fixed
342− {
349+ for id in fixed.iter().take(MAX_SUMMARY_IDS) {
350+ if let Some(vuln) = self.store.vulnerability(&repo.repo_id, id).await? {
343351 self.alert_event(AlertType::Vulnerability, "fixed", repo, vulnerability_event(repo, &vuln), None).await;
344352 }
345353 }
346− Ok(())
354+ let changed = crate::summaries::Changed { scan: "dependencies", opened, fixed, ..crate::summaries::Changed::default() };
355+ self.summarize(repo, AlertType::Vulnerability, changed, None).await
347356 }
348357 }
349358
+26−0
2929 //! Other services reach it over `POST /rpc/<method>`; see
3030 //! `g1t_contracts::security`.
3131
32+mod advisories;
3233 mod code_scanning;
3334 mod config;
3435 mod deps;
4849 mod store;
4950 mod suite;
5051 mod suite_store;
52+mod summaries;
5153 mod supply_chain;
5254 mod timezones;
5355 mod update_store;
311313 };
312314 self.alert_event(g1t_contracts::security_suite::AlertType::SecretScanning, action, &repo, event, Some(a.actor.id.clone()))
313315 .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?;
314323 }
315324 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
316325 }
343352 if let Some(vuln) = &vulnerability {
344353 let event = g1t_contracts::security_suite::SecurityEvent { reason: Some(a.reason.as_str().to_owned()), ..deps::vulnerability_event(&repo, vuln) };
345354 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?;
346357 }
347358 // A security update whose alerts are now all fixed or dismissed closes.
348359 if let Err(error) = self.resolve_updates(&repo, None).await {
378389 Some(a.actor.id.clone()),
379390 )
380391 .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?;
381395 }
382396 return Ok(Outcome::Ok(AlertChange { secret, vulnerability: None }));
383397 }
399413 Some(a.actor.id.clone()),
400414 )
401415 .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?;
402418 }
403419 Ok(Outcome::Ok(AlertChange { secret: None, vulnerability }))
404420 }
627643 // A repository purged: everything found in it goes.
628644 "repo.purged" => {
629645 if let Some(g1t_kit::lifecycle::Lifecycle::Purged(purged)) = g1t_kit::lifecycle::read(event) {
646+ self.store.purge_advisories(&purged.repo_id).await?;
630647 self.store.purge_suite(&purged.repo_id).await?;
631648 self.store.purge(&purged.repo_id).await?;
632649 }
745762 "set_workspace_security_settings" => reply(&security.set_workspace_security_settings(args(body)?).await?),
746763 "security_overview" => reply(&security.security_overview(args(body)?).await?),
747764 "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+ "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?),
773+ "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?),
748774 _ => Response::error("Unknown method", 404),
749775 }
750776 }
+24−6
77 use g1t_contracts::security::{AlertActivity, NewSecret, SecretFinding, SecretStatus};
88 use g1t_contracts::security_suite::{
99 AlertType, BypassArgs, BypassReason, BypassRequest, BypassRequestsArgs, BypassResult, CheckSecretArgs, CheckValidityArgs,
10− PaidFeature, ReviewBypassArgs, SecretAlertArgs, SecretAlertDetail, SecretValidity, SecurityEvent,
10+ MAX_SUMMARY_IDS, PaidFeature, ReviewBypassArgs, SecretAlertArgs, SecretAlertDetail, SecretValidity, SecurityEvent,
1111 };
1212 use g1t_contracts::time::rfc3339;
1313 use g1t_contracts::{FailureCode, Outcome, Role, User};
1818 use crate::Security;
1919 use crate::store::{Activity, RepoRow};
2020 use crate::suite::link;
21+use crate::summaries::Changed;
2122
2223 const MAX_COMMENT_CHARS: usize = 500;
2324 /// Validity checks the sweep makes per repository, and how often a secret
7172 }
7273
7374 impl Security {
74− /// Records where new secrets were found and publishes an alert for each
75− /// one seen for the first time. `fresh` are the fingerprints not known
76− /// before; `pusher` is who pushed, for a blocked push.
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.
7782 pub(crate) async fn secrets_found(&self, repo: &RepoRow, secrets: &[NewSecret], source: &str, fresh: &[String], pusher: Option<&str>) -> Result<()> {
7883 self.store.note_found(&repo.repo_id, secrets, source).await?;
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+ }
7990 let known = self.store.known(&repo.repo_id, fresh).await?;
80− for (_, id, _) in known.iter().take(20) {
91+ let mut blocked = 0;
92+ for (_, id, _) in known.iter().take(MAX_SUMMARY_IDS) {
8193 if let Some(secret) = self.store.secret(&repo.repo_id, id).await? {
94+ if secret.status == SecretStatus::Blocked {
95+ blocked += 1;
96+ }
8297 let mut event = secret_event(repo, &secret);
8398 event.pusher = pusher.filter(|_| secret.status == SecretStatus::Blocked).map(str::to_owned);
8499 self.alert_event(AlertType::SecretScanning, "created", repo, event, None).await;
85100 }
86101 }
87− Ok(())
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
88105 }
89106
90107 pub(crate) async fn secret_alert(&self, a: SecretAlertArgs) -> Result<Outcome<SecretAlertDetail>> {
226243 if let Some(dismissal) = reason.dismissal() {
227244 let event = SecurityEvent { reason: Some(dismissal.as_str().to_owned()), state: "dismissed".to_owned(), ..secret_event(repo, secret) };
228245 self.alert_event(AlertType::SecretScanning, "dismissed", repo, event, Some(by.id.clone())).await;
246+ self.summarize(repo, AlertType::SecretScanning, Changed { scan: "change", dismissed: 1, ..Changed::default() }, Some(by.id.clone())).await?;
229247 }
230248 Ok(())
231249 }
+48−14
11 //! What the security suite's parts share: whether a repository has the
22 //! paid features, telling people (events for webhooks and the inbox), and
33 //! 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.
410
511 use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
612 use g1t_contracts::billing::{Feature, HasFeatureArgs};
713 use g1t_contracts::events::{NewEvent, Publish};
814 use g1t_contracts::identity::{ListMembersArgs, Member};
9−use g1t_contracts::security_suite::{AlertType, EVENT_TYPES, PaidFeature, SecurityEvent, needs_activation};
15+use g1t_contracts::advisories::AdvisoryEvent;
16+use g1t_contracts::security_suite::{AlertType, EVENT_TYPES, PaidFeature, SecurityEvent, SecuritySummary, needs_activation};
1017 use g1t_contracts::{FailureCode, Outcome, Role, User, new_id};
1118 use g1t_kit::now_ms;
1219 use worker::Result;
8693 }
8794 }
8895
89− /// Publishes a security event, for webhooks and the inbox. New alerts
90− /// name the workspace's owners to tell, and its members as those who
91− /// may see findings. Failing to publish never fails the change.
92− pub(crate) async fn publish(&self, kind: &str, repo: &RepoRow, mut event: SecurityEvent, actor_id: Option<String>) {
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>) {
93127 let Some(kind) = EVENT_TYPES.iter().copied().find(|known| *known == kind) else {
94128 worker::console_error!("security: no event called {kind}");
95129 return;
96130 };
97131 let Some(events) = &self.events else { return };
98− let mut data = serde_json::to_value(&event).unwrap_or_default();
99− if kind.ends_with(".created") || kind == "secret_scanning.bypass_requested" {
132+ if audience {
100133 let members = self.members(&repo.namespace).await;
101− if event.notify.is_empty() {
102− event.notify = members
103− .iter()
104− .filter(|member| member.role == Role::Owner || member.org_roles.contains(&g1t_contracts::OrgRole::SecurityManager))
105− .map(|member| member.username.clone())
106− .collect();
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+ );
107142 }
108− data = serde_json::to_value(&event).unwrap_or_default();
109143 data["members"] = serde_json::json!(
110144 members.iter().take(MAX_MEMBERS_NAMED).map(|member| member.username.clone()).collect::<Vec<_>>()
111145 );
+200−0
1+//! One event per scan per alert type, for the inbox: what the scan opened,
2+//! what it fixed, and what is left open, with the worst severity among the
3+//! new alerts and the newest one's title. The inbox keeps one thread per
4+//! repository and type from these (`<repo>/security/vulnerabilities`), so a
5+//! scan that finds forty vulnerabilities is one item and the next scan
6+//! brings the same item up to date; the per-alert `.created` and `.fixed`
7+//! events stay for webhooks (`g1t_contracts::security_suite::SecuritySummary`).
8+
9+use g1t_contracts::security::SeverityCounts;
10+use g1t_contracts::security_suite::{AlertType, MAX_SUMMARY_IDS, SecuritySummary};
11+use worker::Result;
12+
13+use crate::Security;
14+use crate::store::RepoRow;
15+use crate::suite::link;
16+
17+/// What one scan changed, as the modules report it.
18+#[derive(Debug, Default)]
19+pub struct Changed<'a> {
20+ /// `push`, `history`, `dependencies`, `analysis` or `change`.
21+ pub scan: &'a str,
22+ /// Every alert it opened, newest first.
23+ pub opened: Vec<String>,
24+ /// Every alert it closed as fixed.
25+ pub fixed: Vec<String>,
26+ pub dismissed: u32,
27+ /// Who pushed, when push protection refused the push.
28+ pub pusher: Option<&'a str>,
29+ /// How many secrets stopped that push.
30+ pub blocked: u32,
31+}
32+
33+/// Lower is worse.
34+fn rank(severity: &str) -> u8 {
35+ match severity {
36+ "critical" => 0,
37+ "high" => 1,
38+ "medium" => 2,
39+ "low" => 3,
40+ _ => 4,
41+ }
42+}
43+
44+/// The worst of some severities, or none.
45+pub fn worst<'a>(severities: impl Iterator<Item = &'a str>) -> Option<String> {
46+ severities.min_by_key(|severity| rank(severity)).map(str::to_owned)
47+}
48+
49+/// Severities counted.
50+pub fn count<'a>(severities: impl Iterator<Item = &'a str>) -> SeverityCounts {
51+ let mut counts = SeverityCounts::default();
52+ for severity in severities {
53+ match severity {
54+ "critical" => counts.critical += 1,
55+ "high" => counts.high += 1,
56+ "medium" => counts.medium += 1,
57+ "low" => counts.low += 1,
58+ _ => counts.unknown += 1,
59+ }
60+ }
61+ counts
62+}
63+
64+/// The summary of a scan, from what it changed, the new alerts (severity
65+/// and title, newest first) and what is open after it. Pure.
66+pub fn shape(repo: &RepoRow, alert_type: AlertType, changed: &Changed<'_>, news: &[(String, String)], open: SeverityCounts) -> SecuritySummary {
67+ SecuritySummary {
68+ repo_id: repo.repo_id.clone(),
69+ alert_type: alert_type.as_str().to_owned(),
70+ scan: changed.scan.to_owned(),
71+ opened: changed.opened.iter().take(MAX_SUMMARY_IDS).cloned().collect(),
72+ opened_count: changed.opened.len() as u32,
73+ fixed: changed.fixed.iter().take(MAX_SUMMARY_IDS).cloned().collect(),
74+ fixed_count: changed.fixed.len() as u32,
75+ dismissed_count: changed.dismissed,
76+ new_by_severity: count(news.iter().map(|(severity, _)| severity.as_str())),
77+ open,
78+ worst: worst(news.iter().map(|(severity, _)| severity.as_str())),
79+ latest: news.first().map(|(_, title)| title.clone()),
80+ link: link(repo, alert_type.page()),
81+ pusher: changed.pusher.map(str::to_owned),
82+ blocked: changed.blocked,
83+ notify: Vec::new(),
84+ }
85+}
86+
87+impl Security {
88+ /// Publishes what a scan changed, once, unless it changed nothing.
89+ pub(crate) async fn summarize(&self, repo: &RepoRow, alert_type: AlertType, changed: Changed<'_>, actor_id: Option<String>) -> Result<()> {
90+ if changed.opened.is_empty() && changed.fixed.is_empty() && changed.dismissed == 0 && changed.scan != "change" {
91+ return Ok(());
92+ }
93+ let mut news = Vec::with_capacity(changed.opened.len().min(MAX_SUMMARY_IDS));
94+ for id in changed.opened.iter().take(MAX_SUMMARY_IDS) {
95+ let found = match alert_type {
96+ AlertType::SecretScanning => self.store.secret(&repo.repo_id, id).await?.map(|secret| {
97+ let event = crate::secret_alerts::secret_event(repo, &secret);
98+ (event.severity, event.title)
99+ }),
100+ AlertType::CodeScanning => self.store.code_alert_by_id(&repo.repo_id, id).await?.map(|alert| {
101+ let event = crate::code_scanning::code_event(repo, &alert);
102+ (event.severity, event.title)
103+ }),
104+ AlertType::Vulnerability => self.store.vulnerability(&repo.repo_id, id).await?.map(|vuln| {
105+ let event = crate::deps::vulnerability_event(repo, &vuln);
106+ (event.severity, event.title)
107+ }),
108+ };
109+ if let Some(news_item) = found {
110+ news.push(news_item);
111+ }
112+ }
113+ // The worst new alert is the headline, whatever order the scan found them in.
114+ news.sort_by_key(|(severity, _)| rank(severity));
115+ let open = self.open_counts(repo, alert_type).await?;
116+ let summary = shape(repo, alert_type, &changed, &news, open);
117+ self.publish_summary(alert_type.summary_event(), repo, &summary, actor_id).await;
118+ Ok(())
119+ }
120+
121+ /// What is open of one type, by severity. A secret in the history that
122+ /// looks real, or stopped at a push and not yet decided, is critical; a
123+ /// likely test value is low.
124+ async fn open_counts(&self, repo: &RepoRow, alert_type: AlertType) -> Result<SeverityCounts> {
125+ Ok(match alert_type {
126+ AlertType::SecretScanning => {
127+ let secrets = self.store.secret_counts(&repo.repo_id).await?;
128+ SeverityCounts { critical: secrets.open + secrets.blocked, low: secrets.test_values, ..SeverityCounts::default() }
129+ }
130+ AlertType::CodeScanning => self.store.code_counts(&repo.repo_id).await?,
131+ AlertType::Vulnerability => {
132+ let rows = self.store.open_vulnerabilities(&repo.repo_id).await?;
133+ count(rows.iter().map(|row| row.severity.as_str()))
134+ }
135+ })
136+ }
137+}
138+
139+#[cfg(test)]
140+mod tests {
141+ use super::*;
142+
143+ fn repo() -> RepoRow {
144+ RepoRow {
145+ repo_id: "rep_1".into(),
146+ namespace: "acme".into(),
147+ name: "rocket".into(),
148+ upkeep: 1,
149+ history: "done".into(),
150+ history_cursor: None,
151+ history_commits: 0,
152+ history_finished_at: None,
153+ deps_scanned_at: None,
154+ deps_error: None,
155+ lockfiles: "[]".into(),
156+ version_updates: None,
157+ }
158+ }
159+
160+ #[test]
161+ fn a_scan_is_one_summary_with_counts_the_worst_and_the_newest() {
162+ let changed = Changed { scan: "dependencies", opened: vec!["vul_3".into(), "vul_2".into(), "vul_1".into()], fixed: vec!["vul_0".into()], ..Changed::default() };
163+ let news = vec![
164+ ("high".to_owned(), "lodash 4.17.20 in package-lock.json: GHSA-1".to_owned()),
165+ ("critical".to_owned(), "minimist 1.2.5 in package-lock.json: GHSA-2".to_owned()),
166+ ("low".to_owned(), "ms 2.0.0 in package-lock.json: GHSA-3".to_owned()),
167+ ];
168+ let open = SeverityCounts { critical: 1, high: 2, ..SeverityCounts::default() };
169+ let summary = shape(&repo(), AlertType::Vulnerability, &changed, &news, open);
170+ assert_eq!(summary.opened_count, 3);
171+ assert_eq!(summary.fixed, vec!["vul_0"]);
172+ assert_eq!(summary.worst.as_deref(), Some("critical"));
173+ assert_eq!(summary.latest.as_deref(), Some("lodash 4.17.20 in package-lock.json: GHSA-1"));
174+ assert_eq!((summary.new_by_severity.critical, summary.new_by_severity.high, summary.new_by_severity.low), (1, 1, 1));
175+ assert_eq!(summary.link, "/acme/rocket/security/vulnerabilities");
176+ assert_eq!(summary.open_total(), 3);
177+ assert!(summary.urgent());
178+ }
179+
180+ #[test]
181+ fn the_id_lists_are_capped_but_the_counts_are_not() {
182+ let opened: Vec<String> = (0..500).map(|n| format!("cod_{n}")).collect();
183+ let changed = Changed { scan: "analysis", opened, ..Changed::default() };
184+ let summary = shape(&repo(), AlertType::CodeScanning, &changed, &[], SeverityCounts::default());
185+ assert_eq!(summary.opened.len(), MAX_SUMMARY_IDS);
186+ assert_eq!(summary.opened_count, 500);
187+ assert!(summary.worst.is_none() && summary.latest.is_none());
188+ assert!(!summary.urgent());
189+ }
190+
191+ #[test]
192+ fn a_refused_push_is_urgent_whatever_was_found() {
193+ let changed = Changed { scan: "push", opened: vec!["sec_1".into()], pusher: Some("bo"), blocked: 1, ..Changed::default() };
194+ let summary = shape(&repo(), AlertType::SecretScanning, &changed, &[("low".into(), "An example key in README.md".into())], SeverityCounts::default());
195+ assert_eq!(summary.pusher.as_deref(), Some("bo"));
196+ assert!(summary.urgent());
197+ assert_eq!(worst(["low", "medium", "high"].into_iter()).as_deref(), Some("high"));
198+ assert_eq!(worst(std::iter::empty()), None);
199+ }
200+}