Skip to content

Commit

A repository's Security overview is one row per feature in three groups, each with a status word (Enabled, Disabled, Needs setup, Coming), a line on what it does and the one thing to do about it: the security policy (View policy, or Set up a security policy, which commits a starting SECURITY.md on a branch and opens it as a pull request), dependency alerts, security updates (the switch, kept on Vulnerabilities and now in Settings too), code scanning (Enabled once an analysis was uploaded), secret scanning (push protection always on) and code quality as Coming; and under Reporting, advisories and the private vulnerability reporting switch. Anyone who can see a public repository but has no role on it sees the public face at the same address: the policy, the published advisories and, when it is on, Report a vulnerability. Advisories get their pages (the list with its state filter, one advisory with Publish, Withdraw and Edit, a form for a new draft or changes) and reports theirs (new ones first, each accepted into a draft advisory or declined), with a Reports page beside Advisories and an Advisories tab; the public report form takes an email when signed out and counts against the front door's heavy limit. Security settings gain Alerts for secrets in the history and Private vulnerability reporting. Settings → Notifications gains a Security section: critical and high alerts at once (always, for the workspaces one owns or manages security for) and the rest in a daily or weekly digest, at once, or not at all, with security alerts now among the email reasons. The notifications guide says how security alerts arrive and what the digest is, the security guide the overview's rows and the public face, secret protection the history alerts switch, webhooks the summary and advisory events, and a new guide covers advisories and private reporting; the report_settings method lets the public page know whether a repository takes reports.

syntaqxcommitted Parentf88b27aBrowse files
31 files+1928−1120/31 viewed
+1−0
131131 { label: 'Secret protection', slug: 'guides/security/secret-protection' },
132132 { label: 'Code scanning', slug: 'guides/security/code-scanning' },
133133 { label: 'Supply chain', slug: 'guides/security/supply-chain' },
134+ { label: 'Advisories and reporting', slug: 'guides/security/advisories' },
134135 { label: 'Security overview', slug: 'guides/security/security-overview' },
135136 { label: "What's free and what's paid", slug: 'guides/security/pricing' },
136137 ],
+2−1
312312 ## Security updates follow the same file
313313
314314 [Security updates](/guides/security/#security-updates) are still turned on
315−and off with the **Security updates** switch. When the file has an entry
315+and off with the **Security updates** switch, on the Security overview,
316+on **Vulnerabilities** and in the repository's Security settings. When the file has an entry
316317 for a vulnerable package's ecosystem, in a directory that holds the
317318 package's lockfile, that entry shapes the security update:
318319
+44−5
2424 - a workflow on one branch
2525 - a deployment: a project's production, or one pull request's preview
2626 - a workflow run waiting for your review to deploy to an environment
27+- a repository's security alerts of one kind: its secrets, its code
28+ scanning alerts, or its vulnerable dependencies (see [Security](#security))
29+- your security digest for a workspace
2730
2831 When something new happens on a thread, it comes back to the top of your
2932 notifications, unread, even if you had marked it done. It is not added a second
5154 | `mention` | mentioned | Someone mentioned you with `@username`, or you were mentioned on it before. |
5255 | `team_mention` | team mentioned | Someone mentioned a [team](/guides/teams/#mentions) you are in with `@workspace/team`, or a team you are in was mentioned on it before. |
5356 | `ci_activity` | CI activity | A check, workflow or deployment on your work finished badly, or recovered. |
54−| `security_alert` | security alert | A new secret, code scanning or vulnerability alert on a repository you look after, a push of yours that push protection blocked, or a [bypass request](/guides/security/secret-protection/#delegated-bypass) to review or its answer. The workspace's owners hear of new alerts; watchers who chose **Security alerts** do too, if they can see findings. |
57+| `security_alert` | security alert | A scan found new secrets, code scanning alerts or vulnerable dependencies on a repository you look after (one thread per repository and kind; see [Security](#security)), a push of yours that push protection refused, a [vulnerability report](/guides/security/advisories/#private-vulnerability-reporting) sent to a repository you look after, a [bypass request](/guides/security/secret-protection/#delegated-bypass) to review or its answer, or your security digest. The workspace's owners and security managers hear of these; watchers who chose **Security alerts** do too, if they can see findings. |
5558 | `state_change` | state changed | It was closed, reopened or merged. |
5659 | `author` | your work | You opened it, or you asked g1t for it. Also an owner's answer to your [token made for a workspace](/guides/authentication/#create-a-token) waiting for approval, or its revocation. |
5760 | `comment` | commented | You commented on it. |
164167 A repository you create is watched the way you choose in
165168 [your settings](#settings): **All activity** unless you change it.
166169
170+## Security
171+
172+Security alerts are told the way they can be lived with. A scan that finds
173+forty vulnerabilities is one notification, not forty: each repository has
174+one thread per kind of alert (secrets, code scanning alerts, vulnerable
175+dependencies), titled by what the latest scan found, "3 new vulnerabilities
176+in acme/rocket: 1 critical, 2 high", with the worst new alert as its line.
177+The next scan brings the same thread back up to date rather than adding
178+another; a scan that only fixed or dismissed alerts refreshes it quietly,
179+"2 open vulnerabilities in acme/rocket: 2 high" with "1 fixed" in its
180+history, without waking you, and marks it done when nothing is left open.
181+
182+What reaches you, and when, follows the alert's severity:
183+
184+| Alert | Who | When |
185+| --- | --- | --- |
186+| Critical or high | The workspace's owners and security managers, and watchers who chose **Security alerts**. | At once, as an error, with a pop-up like a mention's. |
187+| A push of yours that push protection refused | You. | At once, whatever you chose. |
188+| A vulnerability report, a bypass request | The owners and security managers; the reviewers. | At once, as something waiting on you. |
189+| Medium or low | The same people. | In your **security digest**, once a day or once a week as you choose, or at once, or never. |
190+| A published advisory | Watchers who chose **Security alerts**. | At once, as information. |
191+
192+The **security digest** is one notification and one email per workspace,
193+"Security digest for acme: 4 new medium vulnerabilities in 2 repositories,
194+1 new low secret in 1 repository", naming the newest alerts, sent in the
195+morning (UTC): every day, or on Mondays. Owners and security managers get
196+the urgent alerts whatever their settings say; those alerts are theirs to
197+act on. Everyone else can turn the urgent ones off too, and anyone can
198+choose how the rest arrive, in [Settings](#settings).
199+
200+Fixes and dismissals never make a notification of their own. The findings
201+themselves are always on each repository's [Security page](/guides/security/).
202+
167203 ## Email
168204
169205 You can also be emailed when you are told of something. By default, g1t
170−emails you for three reasons: **agent waiting**, **review requested** and
171−**mentioned**. Each time you are told of something for a reason you chose,
172−g1t sends one email with what happened and a link to it.
206+emails you for four reasons: **agent waiting**, **review requested**,
207+**mentioned** and **security alert**. Each time you are told of something
208+for a reason you chose, g1t sends one email with what happened and a link
209+to it; a security digest is one email.
173210
174211 An email is sent only when:
175212
211248 | **Do not disturb** | Silences sounds, pop-ups, and browser and phone notifications until a time you choose. | Off |
212249 | **Desktop notifications** | A system notification from an open tab for a message to you while the window is behind another. In [the desktop app](/guides/desktop/) it is on, and the app shows them itself. | Off |
213250 | **Notify me about**, **Browser notifications** | What pops up and is pushed; see [chat notifications](/guides/chat/#notifications). | DMs and mentions; off |
214−| **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned |
251+| **Email** | One checkbox per reason: you are also emailed when you are told of something for it. | agent waiting, review requested, mentioned, security alert |
252+| **Security: Critical and high alerts at once** | Whether urgent alerts on repositories you watch for security reach you at once. The workspaces you own or manage security for tell you whatever this says. | On |
253+| **Security: Medium and low alerts** | In a daily digest, in a weekly digest, at once, or not at all. | Daily digest |
215254 | **Repositories you create** | How you watch a new repository you create: **Participating and @mentions** or **All activity**. | All activity |
216255 | **Watched repositories** | Every repository you watch other than the default way, with how. | |
217256
+35−5
2121 SBOM, and dependency review on pull requests.
2222 - **Code.** Static analysis results uploaded as SARIF become alerts, and
2323 checks on pull requests. See [code scanning](/guides/security/code-scanning/).
24+- **Reporting.** What the repository tells people about vulnerabilities in
25+ what it ships, and how people tell its maintainers about one privately.
26+ See [advisories and private reporting](/guides/security/advisories/).
2427
2528 Its pages are **Overview**, **Secret scanning**, **Code scanning**,
26−**Vulnerabilities**, **Dependency graph**, **Dependency updates** and
27−**Settings**. Secret scanning,
29+**Vulnerabilities**, **Dependency graph**, **Dependency updates**,
30+**Advisories** and **Settings**. Secret scanning,
2831 push protection, vulnerability alerts, security updates and the dependency
2932 graph are free everywhere; the rest is free on public repositories and part
3033 of the g1t plan, as Security and quality, on private ones. See
4144 | **Dismiss** or **Reopen** an alert: a secret, a dependency or code scanning | Write |
4245 | **Bypass** push protection for a blocked secret | Write (or a request, with [delegated bypass](/guides/security/secret-protection/#delegated-bypass)) |
4346 | **Fix with g1t** | Write, and agents allowed to run |
44−| **Security updates** on or off, **Set up code scanning** | Maintain |
45−| **Security settings**, **Custom patterns**, reviewing bypass requests | Admin |
47+| **Security updates** on or off, **Set up code scanning**, **Set up a security policy** | Maintain |
48+| **Security settings**, **Custom patterns**, reviewing bypass requests, writing and publishing advisories, reviewing reports | Admin |
4649
4750 A workspace's [security managers](/guides/workspaces/#roles-that-add-to-a-member)
4851 can do all of this on every repository in it, and change the workspace's
5255 own page, `g1t.sh/<owner>/-/security`, lists the open alerts of every
5356 project the member can see them on, most severe first.
5457
58+Anyone else who can see a public repository sees its public face at the
59+same address: the security policy, the published advisories, and, when the
60+repository takes them, **Report a vulnerability**.
61+
5562 ## The overview
5663
57−The **Overview** counts open alerts by severity: critical, high,
64+The **Overview** is one row per feature, in three groups, each with a
65+status word (**Enabled**, **Disabled**, **Needs setup** or **Coming**), a
66+line on what it does, and the one thing to do about it:
67+
68+| Group | Row | Status | To the right |
69+| --- | --- | --- | --- |
70+| Policy | **Security policy** | Enabled when a `SECURITY.md` (or `.github/SECURITY.md`, `docs/SECURITY.md`) is on the default branch; Needs setup otherwise. | **View policy**, or **Set up a security policy**, which opens a pull request with a starting one. |
71+| Findings | **Dependency alerts** | Enabled. | **View alerts**. |
72+| Findings | **Security updates** | Enabled or Disabled. | The switch, for Maintain and up. The same switch is on **Vulnerabilities** and in **Settings**. |
73+| Findings | **Code scanning** | Enabled once an analysis was uploaded; Needs setup before. | **View alerts**, or **Set up code scanning**. |
74+| Findings | **Secret scanning** | Enabled; push protection is always on. | **View alerts**. |
75+| Findings | **Code quality** | Coming: not built yet. | |
76+| Reporting | **Security advisories** | Enabled. | **Manage advisories** or **View advisories**. |
77+| Reporting | **Private vulnerability reporting** | Enabled or Disabled. | The switch, for Admins and security managers. |
78+
79+Below the rows, the open alerts are counted by severity: critical, high,
5880 medium, low and unrated. The counts are:
5981
6082 - each open vulnerability, by its severity,
213235 who could clone it may have it. Rotate it, then dismiss it as **Revoked**.
214236 Removing it from the code is not enough, since it stays in history.
215237
238+Each page of the scan that finds secrets is one
239+[notification](/guides/notifications/#security) to the workspace's owners
240+and security managers, with how many it found; a repository whose history
241+holds old test fixtures or rotated keys can turn **Alerts for secrets in
242+the history** off in its Security settings, and the secrets are still
243+listed here but tell nobody. Push protection is never off: a push that
244+adds a secret is refused either way.
245+
216246 ### Dismissing a secret alert
217247
218248 Choose **Dismiss**, pick a reason, and add a comment if you like (up to 500
+126−0
1+---
2+title: Advisories and private reporting
3+description: What a repository tells people about vulnerabilities in what it ships, and how people tell its maintainers about one privately. Security advisories with G1TSA ids, drafted, published and withdrawn; private vulnerability reports, accepted into draft advisories.
4+---
5+
6+The Reporting half of a repository's Security page is for vulnerabilities in
7+what the repository itself ships, as opposed to the
8+[findings](/guides/security/) g1t makes in its code and dependencies:
9+
10+- A **security advisory** is the maintainers' own account of a vulnerability:
11+ what is affected, what fixes it, how bad it is and who found it. It is a
12+ draft until they publish it, visible to anyone who can see the repository
13+ once they do, and can be withdrawn.
14+- A **vulnerability report** is how someone outside tells the maintainers
15+ privately, when the repository takes such reports: from its Security tab,
16+ without an issue anyone can read. Accepting a report makes a draft advisory
17+ from it.
18+
19+Both live under `g1t.sh/<owner>/<project>/security/advisories`; the Security
20+overview's **Reporting** rows say where each stands.
21+
22+## The security policy
23+
24+The first row of the overview is the repository's **security policy**: a
25+`SECURITY.md` on the default branch (or `.github/SECURITY.md`, or
26+`docs/SECURITY.md`) that says how to report a vulnerability and which
27+versions get fixes. When there is none, **Set up a security policy** commits
28+a starting one on a new branch and opens it as your pull request, as **Set
29+up code scanning** does with its workflow. The template says how to report,
30+names the private reporting form when that is on, and leaves the supported
31+versions for you to fill in. Anyone who can see a public repository sees the
32+policy from its Security tab.
33+
34+## Advisories
35+
36+### Writing one
37+
38+Admins of the repository, and the workspace's
39+[security managers](/guides/access-and-roles/), write advisories: **New
40+advisory** under Advisories. An advisory has:
41+
42+| Field | What it is |
43+| --- | --- |
44+| Title and description | One line, and Markdown: what the vulnerability is, how it is exploited, what to do. The description is needed before publishing. |
45+| Severity | Critical, high, medium or low. |
46+| Affected package | How it is distributed (`npm`, `crates.io`, `Go`, `PyPI`, or how the project ships), the package, the versions affected and the versions that fix it, as ranges people write (`>= 1.2.0, < 1.4.1`). |
47+| CVSS | A vector string and its score, when the maintainers scored it. |
48+| CVE id | `CVE-2026-12345`, when one was issued elsewhere. |
49+| Credits | Who found or fixed it: usernames or names, up to twenty. |
50+
51+Every advisory has an id of the form `G1TSA-2026-h7kd-3mqw`, given when it is
52+drafted; pages and the API take it, or the `adv_…` id behind it.
53+
54+### Draft, published, withdrawn
55+
56+A **draft** is seen by those who see the repository's findings (Write, or a
57+security manager). Nobody is told of it.
58+
59+**Publish** makes it visible to anyone who can see the repository, at
60+`…/security/advisories/G1TSA-…`, sends `security_advisory.published` to
61+[webhooks](/guides/webhooks/) and tells the repository's watchers who chose
62+**Security alerts**. A published advisory may be edited; the change shows at
63+once.
64+
65+**Withdraw** takes it off the list for everyone but those who see findings,
66+and sends `security_advisory.withdrawn`. A withdrawn advisory can be published
67+again.
68+
69+## Private vulnerability reporting
70+
71+A repository takes private reports when **Private vulnerability reporting**
72+is on: a switch on the Security overview's Reporting rows and in
73+[Security settings](/guides/security/#settings), for Admins and security
74+managers. Off by default.
75+
76+With it on, **Report a vulnerability** is on the repository's Security tab
77+for anyone who can see it. Someone signed in reports under their name; on a
78+public repository someone signed out may report too, with an email address
79+the maintainers can answer at. A report has a title, a description (Markdown)
80+and, if the reporter has a view, a severity. Only the maintainers see it: the
81+reporter sees their own report sent, and nothing else of the repository's
82+reports.
83+
84+The workspace's owners and security managers are told at once, in
85+[Notifications](/guides/notifications/#security) ("A vulnerability was
86+reported privately to acme/rocket") and by email, whatever their digest
87+settings say: a report is something to act on.
88+
89+To keep the form from being flooded, one reporter sends at most five reports
90+a day to one repository, a repository holds at most fifty new reports
91+waiting, and the site limits how fast one address can post to it.
92+
93+### Reviewing reports
94+
95+Under **Advisories → Reports**, those who see findings read the reports,
96+new ones first. An Admin or security manager settles each:
97+
98+- **Accept** makes a draft advisory from it, in the reporter's words and
99+ crediting the reporter, for the maintainers to finish and publish. The
100+ report links to the advisory.
101+- **Decline** closes it, with an optional note for the record.
102+
103+Each is written to the workspace's [audit log](/guides/audit-log/), as
104+advisories drafted, changed, published and withdrawn are.
105+
106+## API and MCP
107+
108+| Route | What it does | Scope |
109+| --- | --- | --- |
110+| `GET /repos/{owner}/{name}/security/advisories` | Lists advisories; filter with `state`. Published ones to anyone who can see the repository; every state to those who see findings. | `security:read` |
111+| `POST /repos/{owner}/{name}/security/advisories` | Drafts an advisory: `title`, `severity`, and the fields above in `snake_case`. Admin, or a security manager. | `security:write` |
112+| `GET /repos/{owner}/{name}/security/advisories/{id}` | One advisory, by `adv_…` or `G1TSA-…` id. | `security:read` |
113+| `PATCH /repos/{owner}/{name}/security/advisories/{id}` | Changes its fields; what is left out stays. | `security:write` |
114+| `POST /repos/{owner}/{name}/security/advisories/{id}/state` | `state` `published` or `withdrawn`. | `security:write` |
115+| `GET /repos/{owner}/{name}/security/reports` | The reports, new ones first; filter with `state`. Those who see findings. | `security:read` |
116+| `POST /repos/{owner}/{name}/security/reports` | Reports a vulnerability: `title`, `description`, optional `severity`, and `email` without a token on a public repository. | `security:write`, or none |
117+| `PATCH /repos/{owner}/{name}/security/reports/{id}` | `decision` `accept` or `decline`, with an optional `note`. | `security:write` |
118+
119+The MCP server's `security` tool has the same as actions: `advisories`,
120+`advisory`, `create_advisory`, `update_advisory`, `set_advisory_state`,
121+`reports`, `report_vulnerability` and `review_report`. The
122+[API reference](/reference/api/) documents each route.
123+
124+Webhooks get `security_advisory.published`, `security_advisory.withdrawn` and
125+`security_advisory.reported` (which names no reporter and carries no
126+description: it says to look).
+18−0
2020
2121 See [What's free and what's paid](/guides/security/pricing/).
2222
23+## Alerts, and who hears of them
24+
25+A push that adds a secret is refused, and whoever pushed is told at once,
26+in their [notifications](/guides/notifications/#security) and by email:
27+that is theirs to act on. A scan of the history that finds secrets is one
28+notification per page of the scan to the workspace's owners and security
29+managers, "2 new secrets in acme/rocket: 2 critical", and to watchers who
30+chose **Security alerts** and can see findings; a later page brings the
31+same notification up to date rather than adding another. A secret that
32+looks like a test value is low, and waits for the person's security
33+digest.
34+
35+**Alerts for secrets in the history**, in the repository's Security
36+settings (Admin, or a security manager), turns the history's alerts off
37+for a repository whose history is known to hold old fixtures or rotated
38+keys: what the scan finds is still listed on this page, but tells nobody.
39+Push protection stays on whatever this says.
40+
2341 ## The Secret scanning page
2442
2543 `g1t.sh/<owner>/<project>/security/secret-scanning` lists the
+4−1
9393 | `pull.assigned` | People were assigned to a pull request. `data.assignees` is everyone assigned now, `data.added` those newly assigned. |
9494 | `pull.review_requested`, `pull.review_request_removed` | Reviewers were asked for a pull request, or no longer are. `data.reviewers` names the people. `data.teams` lists the [teams](/guides/teams/#review-requests) asked, or no longer asked, each with `team` (`workspace/team`), `notified` (who in it was told) and `assigned` (who review assignment picked). `data.code_owners` is `true` when its [CODEOWNERS file](/guides/codeowners/) asked them. |
9595 | `pull.stalled`, `pull.resumed` | g1t stopped seeing a pull request through until a person steps in, with why in `data.detail`; or it picked back up. |
96−| `secret_scanning_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [secret alert](/guides/security/secret-protection/) opened (found in history, or blocked at a push: `data.pusher`), was revoked, dismissed or reopened. `data.alert_id`, `data.title`, `data.path`, `data.line`, `data.severity`, `data.state`, `data.reason`, `data.link`. Never the secret. |
96+| `secret_scanning_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [secret alert](/guides/security/secret-protection/) opened (found in history, or blocked at a push: `data.pusher`), was revoked, dismissed or reopened. `data.alert_id`, `data.title`, `data.path`, `data.line`, `data.severity`, `data.state`, `data.reason`, `data.link`. Never the secret. The first 200 of a scan's alerts get one each; the scan's `.summary` carries the counts. |
97+| `secret_scanning_alert.summary`, `code_scanning_alert.summary`, `vulnerability_alert.summary` | What one scan did to a repository's alerts of one type, published once per scan: `data.scan` (`push`, `history`, `dependencies`, `analysis`, or `change` for a dismissal or reopening), `data.opened` (up to 200 ids) and `data.opened_count`, `data.fixed` and `data.fixed_count`, `data.dismissed_count`, `data.new_by_severity` and `data.open` (counts by severity), `data.worst`, `data.latest` (the worst new alert's title), `data.link`, and for a refused push `data.pusher` and `data.blocked`. What [notifications](/guides/notifications/#security) are built from. |
9798 | `secret_scanning.bypass_requested`, `secret_scanning.bypass_reviewed` | Someone asked to bypass push protection, or the request was approved or denied (`data.state`). `data.request_id`, `data.alert_id`, `data.reason`. |
9899 | `code_scanning_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [code scanning alert](/guides/security/code-scanning/) changed. `data.alert_id`, `data.alert_number`, `data.title`, `data.severity`, `data.path`, `data.line`, `data.state`, `data.link`. |
99100 | `vulnerability_alert.created`, `.fixed`, `.dismissed`, `.reopened` | A [vulnerability alert](/guides/security/supply-chain/) changed. `data.alert_id`, `data.title` (package, version, lockfile and advisory), `data.severity`, `data.state`, `data.link`. |
101+| `security_advisory.published`, `security_advisory.withdrawn` | The repository [published or withdrew a security advisory](/guides/security/advisories/). `data.id`, `data.g1tsa_id`, `data.title`, `data.severity`, `data.state`, `data.link`. |
102+| `security_advisory.reported` | Someone [reported a vulnerability privately](/guides/security/advisories/#private-vulnerability-reporting) to the repository's maintainers. `data.id`, `data.title`, `data.severity`, `data.link`. Never the reporter or the description: it says to look. |
100103 | `checks.completed` | A pull request's checks finished: every status on its head has reported and none is still pending, or the merge queue took it out. `data.number`, `data.commit`, and `data.status`, `passed` or `failed`. |
101104 | `status.created` | A status was set on a commit through the API. `data.sha`, `data.context`, `data.state`, `data.description`, `data.target_url`. See [Checks](/guides/checks/). |
102105 | `check_run.created`, `check_run.completed` | A check run was reported on a commit, or completed. `data.check_run`: `id`, `name`, `head_sha`, `status`, `conclusion`, `details_url`, `external_id`, `html_url`, `output`, `actions`, `check_suite` and `app`. |
+61−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { advisoryFromForm, advisoryStanding, parseAdvisoryState, parseReportState, parseSeverity, reportFromForm } from "./advisories.ts";
5+
6+const form = (fields: Record<string, string>): FormData => {
7+ const data = new FormData();
8+ for (const [key, value] of Object.entries(fields)) data.set(key, value);
9+ return data;
10+};
11+
12+test("an advisory form is checked and cleaned", () => {
13+ const input = advisoryFromForm(
14+ form({
15+ title: " Path traversal in the archive reader ",
16+ description: "Details",
17+ severity: "high",
18+ cveId: "cve-2026-12345",
19+ cvssScore: "8.1",
20+ credits: "@ana, bo\n\ncy",
21+ package: "@flagon/hello",
22+ affectedVersions: ">= 1.2.0, < 1.4.1",
23+ }),
24+ );
25+ assert.ok(typeof input !== "string");
26+ assert.equal(input.title, "Path traversal in the archive reader");
27+ assert.equal(input.cveId, "CVE-2026-12345");
28+ assert.equal(input.cvssScore, 8.1);
29+ assert.deepEqual(input.credits, ["ana", "bo", "cy"]);
30+ assert.equal(input.ecosystem, null);
31+ assert.equal(advisoryFromForm(form({ title: " ", severity: "high" })), "Give the advisory a title.");
32+ assert.equal(advisoryFromForm(form({ title: "x", severity: "severe" })), "Choose a severity.");
33+ assert.equal(advisoryFromForm(form({ title: "x", severity: "low", cvssScore: "11" })), "A CVSS score is 0 to 10.");
34+ assert.equal(advisoryFromForm(form({ title: "x", severity: "low", cveId: "CVE-1" })), "A CVE id looks like CVE-2026-12345.");
35+});
36+
37+test("a report needs a title and a description, and an email when signed out", () => {
38+ const signedIn = reportFromForm(form({ title: "XSS in the search box", description: "Type <script> into…", severity: "medium" }), true);
39+ assert.ok(typeof signedIn !== "string");
40+ assert.equal(signedIn.severity, "medium");
41+ assert.equal(signedIn.email, null);
42+ assert.equal(reportFromForm(form({ title: "XSS", description: "…" }), false), "Give an email address the maintainers can answer at, or sign in.");
43+ assert.equal(reportFromForm(form({ title: "XSS", description: "…", email: "not an address" }), false), "Give an email address the maintainers can answer at, or sign in.");
44+ const signedOut = reportFromForm(form({ title: "XSS", description: "…", email: "finder@example.com", severity: "nope" }), false);
45+ assert.ok(typeof signedOut !== "string");
46+ assert.equal(signedOut.email, "finder@example.com");
47+ assert.equal(signedOut.severity, null);
48+ assert.equal(reportFromForm(form({ title: "", description: "…" }), true), "Give the report a title: one line on what you found.");
49+ assert.equal(reportFromForm(form({ title: "x", description: " " }), true), "Describe the vulnerability: what is affected and how to reproduce it.");
50+});
51+
52+test("states and severities parse only to what is known", () => {
53+ assert.equal(parseSeverity("critical"), "critical");
54+ assert.equal(parseSeverity("severe"), null);
55+ assert.equal(parseAdvisoryState("withdrawn"), "withdrawn");
56+ assert.equal(parseAdvisoryState("gone"), null);
57+ assert.equal(parseReportState("new"), "new");
58+ assert.deepEqual(advisoryStanding({ state: "draft", createdBy: "ana" }), { verb: "Drafted by ana", at: null });
59+ assert.deepEqual(advisoryStanding({ state: "published", createdBy: "ana", publishedAt: "2026-10-07T15:02:40.000Z" }), { verb: "Published", at: "2026-10-07T15:02:40.000Z" });
60+ assert.deepEqual(advisoryStanding({ state: "withdrawn", createdBy: "ana", withdrawnAt: "2026-10-08T00:00:00.000Z" }), { verb: "Withdrawn", at: "2026-10-08T00:00:00.000Z" });
61+});
+99−0
1+/**
2+ * Security advisories and private vulnerability reports as the pages read
3+ * them: what a form posts, checked, and how states and severities show.
4+ * Pure, so it is tested on its own (routes/repo/security-advisor*.tsx,
5+ * security-report*.tsx).
6+ */
7+import type { AdvisoryInput, AdvisorySeverity, AdvisoryState, ReportState } from "@g1t/contracts";
8+
9+// The contracts' lists, as types only, so this file runs under `node --test`.
10+export const SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"];
11+export const ADVISORY_STATES: AdvisoryState[] = ["draft", "published", "withdrawn"];
12+export const REPORT_STATES: ReportState[] = ["new", "accepted", "declined"];
13+
14+export const SEVERITY_LABEL: Record<AdvisorySeverity, string> = { critical: "Critical", high: "High", medium: "Medium", low: "Low" };
15+
16+export const STATE_LABEL: Record<AdvisoryState, string> = { draft: "Draft", published: "Published", withdrawn: "Withdrawn" };
17+
18+export const STATE_TONE: Record<AdvisoryState, "neutral" | "success" | "warn"> = { draft: "neutral", published: "success", withdrawn: "warn" };
19+
20+export const REPORT_STATE_LABEL: Record<ReportState, string> = { new: "New", accepted: "Accepted", declined: "Declined" };
21+
22+export const REPORT_STATE_TONE: Record<ReportState, "warn" | "success" | "neutral"> = { new: "warn", accepted: "success", declined: "neutral" };
23+
24+export function parseSeverity(value: unknown): AdvisorySeverity | null {
25+ return SEVERITIES.find((severity) => severity === value) ?? null;
26+}
27+
28+export function parseAdvisoryState(value: unknown): AdvisoryState | null {
29+ return ADVISORY_STATES.find((state) => state === value) ?? null;
30+}
31+
32+export function parseReportState(value: unknown): ReportState | null {
33+ return REPORT_STATES.find((state) => state === value) ?? null;
34+}
35+
36+const text = (form: FormData, key: string, max: number): string =>
37+ String(form.get(key) ?? "")
38+ .trim()
39+ .slice(0, max);
40+
41+const optional = (form: FormData, key: string, max: number): string | null => text(form, key, max) || null;
42+
43+/** The advisory a form posts, or what is wrong with it. */
44+export function advisoryFromForm(form: FormData): AdvisoryInput | string {
45+ const title = text(form, "title", 200);
46+ if (!title) return "Give the advisory a title.";
47+ const severity = parseSeverity(form.get("severity"));
48+ if (!severity) return "Choose a severity.";
49+ const scoreText = text(form, "cvssScore", 10);
50+ const cvssScore = scoreText ? Number(scoreText) : null;
51+ if (cvssScore != null && (!Number.isFinite(cvssScore) || cvssScore < 0 || cvssScore > 10)) return "A CVSS score is 0 to 10.";
52+ const cveId = optional(form, "cveId", 32);
53+ if (cveId && !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) return "A CVE id looks like CVE-2026-12345.";
54+ return {
55+ title,
56+ description: text(form, "description", 20_000),
57+ severity,
58+ cvssVector: optional(form, "cvssVector", 200),
59+ cvssScore,
60+ cveId: cveId?.toUpperCase() ?? null,
61+ ecosystem: optional(form, "ecosystem", 50),
62+ package: optional(form, "package", 200),
63+ affectedVersions: optional(form, "affectedVersions", 200),
64+ patchedVersions: optional(form, "patchedVersions", 200),
65+ credits: text(form, "credits", 2000)
66+ .split(/[\n,]+/)
67+ .map((credit) => credit.trim().replace(/^@/, ""))
68+ .filter(Boolean)
69+ .slice(0, 20),
70+ };
71+}
72+
73+export type ReportDraft = { title: string; description: string; severity: AdvisorySeverity | null; email: string | null };
74+
75+/** The report a form posts, or what is wrong with it. Someone signed out gives an email. */
76+export function reportFromForm(form: FormData, signedIn: boolean): ReportDraft | string {
77+ const title = text(form, "title", 200);
78+ if (!title) return "Give the report a title: one line on what you found.";
79+ const description = text(form, "description", 20_000);
80+ if (!description) return "Describe the vulnerability: what is affected and how to reproduce it.";
81+ const email = optional(form, "email", 254);
82+ if (!signedIn && !(email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))) return "Give an email address the maintainers can answer at, or sign in.";
83+ return { title, description, severity: parseSeverity(form.get("severity")), email: signedIn ? null : email };
84+}
85+
86+/** "Published 3 days ago by ana", or what a draft or withdrawn advisory says. */
87+export function advisoryStanding(advisory: { state: AdvisoryState; createdBy: string; publishedAt?: string | null; withdrawnAt?: string | null }): {
88+ verb: string;
89+ at: string | null;
90+} {
91+ switch (advisory.state) {
92+ case "published":
93+ return { verb: "Published", at: advisory.publishedAt ?? null };
94+ case "withdrawn":
95+ return { verb: "Withdrawn", at: advisory.withdrawnAt ?? null };
96+ default:
97+ return { verb: `Drafted by ${advisory.createdBy}`, at: null };
98+ }
99+}
+3−1
144144 "/acme/rocket/actions/runs/run_1/logs.zip",
145145 "/acme/rocket/actions/runs/run_1/artifacts/dist",
146146 "/acme/rocket/actions/jobs/job_1/log.txt",
147+ "/acme/rocket/security/report",
148+ "/acme/rocket/security/report.data",
147149 ]) {
148150 assert.ok(heavy(path), path);
149151 }
150− for (const path of ["/", "/acme/rocket", "/acme/rocket/actions", "/acme/rocket/issues/1", "/acme/search"]) {
152+ for (const path of ["/", "/acme/rocket", "/acme/rocket/actions", "/acme/rocket/issues/1", "/acme/search", "/acme/rocket/security", "/acme/rocket/security/reports"]) {
151153 assert.ok(!heavy(path), path);
152154 }
153155 });
+7−2
4141 /** Files the Worker serves that are never limited: build output, fonts, and top-level files such as robots.txt. */
4242 const UNLIMITED = /^\/(?:assets\/|fonts\/|favicon|[^/]+\.(?:ico|png|svg|txt|xml|webmanifest)$)/;
4343
44−/** What is costly to answer for a signed-out visitor: a repository's archives, a run's page, logs and artifacts, and search. */
44+/**
45+ * What is costly to answer for a signed-out visitor, or must not be flooded:
46+ * a repository's archives, a run's page, logs and artifacts, search, and
47+ * the form that reports a vulnerability to a repository's maintainers
48+ * privately, which takes no account.
49+ */
4550 const HEAVY =
46− /^\/(?:search(?:\.data)?$|[^/]+\/[^/]+\/(?:archive\/|actions\/runs\/[^/]+(?:\.data|\/logs\.zip|\/artifacts\/[^/]+)?$|actions\/jobs\/[^/]+\/log))/;
51+ /^\/(?:search(?:\.data)?$|[^/]+\/[^/]+\/(?:archive\/|actions\/runs\/[^/]+(?:\.data|\/logs\.zip|\/artifacts\/[^/]+)?$|actions\/jobs\/[^/]+\/log|security\/report(?:\.data)?$))/;
4752
4853 export function unlimited(pathname: string): boolean {
4954 return UNLIMITED.test(pathname);
+18−1
66 import {
77 EMAIL_REASONS,
88 REASON_FILTERS,
9+ SECURITY_DIGEST_CHOICES,
910 REASON_LABEL,
1011 bellCount,
1112 emailReasonsFromForm,
1213 emptyFor,
14+ securityDigestFromForm,
1315 inboxReason,
1416 inboxTab,
1517 inboxView,
142144 // alert, which nothing sends yet.
143145 assert.deepEqual(
144146 EMAIL_REASONS.map((entry) => entry.reason).sort(),
145− reasons.filter((reason) => reason !== "security_alert").sort(),
147+ reasons.sort(),
146148 );
147149 assert.equal(inboxReason("mention"), "mention");
148150 assert.equal(inboxReason("gossip"), null);
198200 assert.deepEqual(emailReasonsFromForm(data), ["agent", "mention"]);
199201 assert.deepEqual(emailReasonsFromForm(new FormData()), []);
200202 });
203+
204+test("security alerts that are not urgent reach people as they choose, daily until they do", () => {
205+ assert.deepEqual(
206+ SECURITY_DIGEST_CHOICES.map((choice) => choice.cadence),
207+ ["daily", "weekly", "at_once", "off"],
208+ );
209+ const data = new FormData();
210+ data.set("security_digest", "weekly");
211+ assert.equal(securityDigestFromForm(data), "weekly");
212+ data.set("security_digest", "hourly");
213+ assert.equal(securityDigestFromForm(data), null);
214+ assert.equal(securityDigestFromForm(new FormData()), null);
215+ // Security alerts are among the reasons one can be emailed for.
216+ assert.ok(EMAIL_REASONS.some((entry) => entry.reason === "security_alert"));
217+});
+24−0
66 * decides who is told of what.
77 */
88 import type {
9+ DigestCadence,
910 InboxCounts,
1011 InboxItem,
1112 InboxMarkArgs,
299300 { reason: "team_mention", label: "Your team is mentioned", detail: "Someone wrote @workspace/team for a team you're in." },
300301 { reason: "assign", label: "You're assigned", detail: "Someone assigned you an issue or a pull request." },
301302 { reason: "ci_activity", label: "Your work's checks and deployments", detail: "Checks, a workflow or a deployment failed on your work." },
303+ {
304+ reason: "security_alert",
305+ label: "Security alerts",
306+ detail: "Critical and high alerts, a push of yours that push protection refused, a vulnerability report, and your security digest.",
307+ },
302308 { reason: "state_change", label: "What you follow closes or merges", detail: "An issue or pull request you're subscribed to was closed, reopened or merged." },
303309 { reason: "author", label: "News on your work", detail: "An approval, changes asked for, or g1t finishing what you asked for." },
304310 { reason: "comment", label: "Conversations you're in", detail: "Comments on issues and pull requests you commented on." },
311317 const checked = form.getAll("email").map(String);
312318 return INBOX_REASONS.filter((reason) => checked.includes(reason));
313319 }
320+
321+/**
322+ * When security alerts that are not urgent (medium and low) reach a person,
323+ * in the settings' order. Critical and high alerts, a refused push and a
324+ * vulnerability report come at once whatever is chosen here.
325+ */
326+export const SECURITY_DIGEST_CHOICES: { cadence: DigestCadence; label: string; detail: string }[] = [
327+ { cadence: "daily", label: "In a daily digest", detail: "One item and email a day per workspace, in the morning (UTC), with what was found." },
328+ { cadence: "weekly", label: "In a weekly digest", detail: "The same, on Monday mornings." },
329+ { cadence: "at_once", label: "At once", detail: "Each scan that finds something is an item of its own, as the urgent ones are." },
330+ { cadence: "off", label: "Not at all", detail: "Only critical and high alerts reach you. The findings are still on each repository's Security page." },
331+];
332+
333+/** The cadence a settings form posts, or null for anything else. */
334+export function securityDigestFromForm(form: FormData): DigestCadence | null {
335+ const given = String(form.get("security_digest") ?? "");
336+ return SECURITY_DIGEST_CHOICES.find((choice) => choice.cadence === given)?.cadence ?? null;
337+}
+1−0
8686 { label: "Vulnerabilities", path: "security/vulnerabilities", members: true, needs: "security_alerts" },
8787 { label: "Dependency graph", path: "security/dependency-graph", members: true, needs: "security_alerts" },
8888 { label: "Dependency updates", path: "security/dependency-updates", members: true, needs: "security_alerts" },
89+ { label: "Advisories", path: "security/advisories", members: true, needs: "security_alerts", also: ["security/reports"] },
8990 { label: "Settings", path: "security/settings", members: true, needs: "security_alerts" },
9091 ...soon("Security"),
9192 ],
+89−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ type OverviewFacts,
6+ POLICY_PATHS,
7+ overviewRows,
8+ rowsIn,
9+ securityPolicyBranch,
10+ securityPolicyPullBody,
11+ securityPolicyTemplate,
12+} from "./security-overview.ts";
13+
14+const facts = (over: Partial<OverviewFacts> = {}): OverviewFacts => ({
15+ base: "/acme/rocket",
16+ policyPath: null,
17+ reporting: false,
18+ advisories: 0,
19+ upkeep: true,
20+ codeScanning: { configured: false, analyses: 0, entitled: true },
21+ historyAlerts: true,
22+ canManage: true,
23+ canPush: true,
24+ ...over,
25+});
26+
27+test("every feature is one row with a status word and one thing to do", () => {
28+ const rows = overviewRows(facts());
29+ assert.deepEqual(
30+ rows.map((row) => [row.key, row.status]),
31+ [
32+ ["policy", "needs_setup"],
33+ ["advisories", "enabled"],
34+ ["reporting", "disabled"],
35+ ["dependency_alerts", "enabled"],
36+ ["security_updates", "enabled"],
37+ ["code_scanning", "needs_setup"],
38+ ["secret_scanning", "enabled"],
39+ ["code_quality", "coming"],
40+ ],
41+ );
42+ // No policy: a pull request adds one, for someone who can push.
43+ assert.deepEqual(rows[0].action, { kind: "post", label: "Set up a security policy", action: "/acme/rocket/security/policy/setup", pending: "Opening a pull request…" });
44+ // Code scanning not set up: the starter workflow, for an admin on an entitled repository.
45+ assert.equal(rows[5].action?.kind, "post");
46+ // Switches are for admins; the words say what they switch.
47+ assert.deepEqual(rows[2].action, { kind: "switch", label: "Private vulnerability reporting", intent: "reporting", on: false });
48+ assert.deepEqual(rows[4].action, { kind: "switch", label: "Security updates", intent: "upkeep", on: true });
49+ // Code quality is honest about not being there.
50+ assert.equal(rows[7].action, null);
51+ assert.equal(rowsIn(rows, "findings").length, 5);
52+ assert.equal(rowsIn(rows, "reporting").length, 2);
53+});
54+
55+test("what is on reads as enabled, with a link to it", () => {
56+ const rows = overviewRows(facts({ policyPath: "SECURITY.md", reporting: true, advisories: 2, codeScanning: { configured: true, analyses: 3, entitled: true } }));
57+ assert.equal(rows[0].status, "enabled");
58+ assert.deepEqual(rows[0].action, { kind: "link", label: "View policy", to: "/acme/rocket/blob/HEAD/SECURITY.md" });
59+ assert.equal(rows[1].about, "2 published advisories tell people who depend on this repository what to do.");
60+ assert.equal(rows[2].status, "enabled");
61+ assert.equal(rows[5].status, "enabled");
62+ assert.deepEqual(rows[5].action, { kind: "link", label: "View alerts", to: "/acme/rocket/security/code-scanning" });
63+});
64+
65+test("someone who cannot change settings sees the rows without the switches", () => {
66+ const rows = overviewRows(facts({ canManage: false, canPush: false, codeScanning: { configured: false, analyses: 0, entitled: false } }));
67+ assert.equal(rows[0].action, null);
68+ assert.equal(rows[2].action, null);
69+ assert.equal(rows[4].action, null);
70+ // Code scanning on a private repository without the plan: a page that says so, not a form.
71+ assert.deepEqual(rows[5].action, { kind: "link", label: "Learn more", to: "/acme/rocket/security/code-scanning" });
72+ assert.match(rows[5].hint ?? "", /g1t plan/);
73+ assert.equal(rows[6].hint, "Push protection is always on.");
74+ assert.match(overviewRows(facts({ historyAlerts: false }))[6].about, /history are off/);
75+});
76+
77+test("the policy template says how to report and names the private form when it is on", () => {
78+ const withForm = securityPolicyTemplate("acme/rocket", true, "/acme/rocket");
79+ assert.match(withForm, /^# Security policy/);
80+ assert.match(withForm, /https:\/\/g1t\.sh\/acme\/rocket\/security\/report/);
81+ assert.match(withForm, /## Supported versions/);
82+ const without = securityPolicyTemplate("acme/rocket", false, "/acme/rocket");
83+ assert.doesNotMatch(without, /security\/report/);
84+ assert.match(without, /Email the maintainers/);
85+ assert.match(securityPolicyPullBody("SECURITY.md"), /`SECURITY\.md`/);
86+ assert.equal(securityPolicyBranch([]), "add-security-policy");
87+ assert.equal(securityPolicyBranch(["add-security-policy", "add-security-policy-2"]), "add-security-policy-3");
88+ assert.equal(POLICY_PATHS[0], "SECURITY.md");
89+});
+203−0
1+/**
2+ * A repository's Security overview: one row per feature, each with a
3+ * status word, a line of what it does, and the one thing to do about it.
4+ * Pure, so the rows are tested apart from the page (routes/repo/security-overview.tsx).
5+ */
6+
7+export type FeatureStatus = "enabled" | "disabled" | "needs_setup" | "coming";
8+
9+export const STATUS_LABEL: Record<FeatureStatus, string> = {
10+ enabled: "Enabled",
11+ disabled: "Disabled",
12+ needs_setup: "Needs setup",
13+ coming: "Coming",
14+};
15+
16+/** The Badge tone each status word is shown in. */
17+export const STATUS_TONE: Record<FeatureStatus, "success" | "neutral" | "warn" | "info"> = {
18+ enabled: "success",
19+ disabled: "neutral",
20+ needs_setup: "warn",
21+ coming: "info",
22+};
23+
24+export type RowGroup = "policy" | "findings" | "reporting";
25+
26+/** The one thing to do about a feature: a page to open, or a form to post. */
27+export type RowAction =
28+ | { kind: "link"; label: string; to: string }
29+ | { kind: "post"; label: string; action: string; intent?: string; pending: string }
30+ | { kind: "switch"; label: string; intent: string; on: boolean };
31+
32+export type OverviewRow = {
33+ key: string;
34+ group: RowGroup;
35+ title: string;
36+ status: FeatureStatus;
37+ about: string;
38+ /** A short hint for the status, shown on hover, when the word alone is not enough. */
39+ hint?: string;
40+ action: RowAction | null;
41+};
42+
43+export type OverviewFacts = {
44+ /** Under the project: `/acme/rocket`. */
45+ base: string;
46+ /** Whether a SECURITY.md is on the default branch, and where. */
47+ policyPath: string | null;
48+ /** Private vulnerability reporting. */
49+ reporting: boolean;
50+ /** Published advisories. */
51+ advisories: number;
52+ /** Security updates. */
53+ upkeep: boolean;
54+ codeScanning: { configured: boolean; analyses: number; entitled: boolean };
55+ /** Alerts for secrets found in the history. Push protection is always on. */
56+ historyAlerts: boolean;
57+ /** Whether the viewer may change settings (Admin), and push (for the policy's pull request). */
58+ canManage: boolean;
59+ canPush: boolean;
60+};
61+
62+/** Where a security policy may be: the first found is the one. */
63+export const POLICY_PATHS = ["SECURITY.md", ".github/SECURITY.md", "docs/SECURITY.md"];
64+
65+/** The rows, in the order the page shows them. */
66+export function overviewRows(facts: OverviewFacts): OverviewRow[] {
67+ const { base } = facts;
68+ const code = facts.codeScanning;
69+ return [
70+ {
71+ key: "policy",
72+ group: "policy",
73+ title: "Security policy",
74+ status: facts.policyPath ? "enabled" : "needs_setup",
75+ about: facts.policyPath
76+ ? `${facts.policyPath} on the default branch tells people how to report a vulnerability and which versions are supported.`
77+ : "A SECURITY.md on the default branch tells people how to report a vulnerability and which versions are supported.",
78+ action: facts.policyPath
79+ ? { kind: "link", label: "View policy", to: `${base}/blob/HEAD/${facts.policyPath}` }
80+ : facts.canPush
81+ ? { kind: "post", label: "Set up a security policy", action: `${base}/security/policy/setup`, pending: "Opening a pull request…" }
82+ : null,
83+ },
84+ {
85+ key: "advisories",
86+ group: "reporting",
87+ title: "Security advisories",
88+ status: "enabled",
89+ about:
90+ facts.advisories === 0
91+ ? "Publish what this repository knows about vulnerabilities in what it ships, so people who depend on it can act."
92+ : `${facts.advisories} published ${facts.advisories === 1 ? "advisory tells" : "advisories tell"} people who depend on this repository what to do.`,
93+ action: { kind: "link", label: facts.canManage ? "Manage advisories" : "View advisories", to: `${base}/security/advisories` },
94+ },
95+ {
96+ key: "reporting",
97+ group: "reporting",
98+ title: "Private vulnerability reporting",
99+ status: facts.reporting ? "enabled" : "disabled",
100+ about: facts.reporting
101+ ? "Anyone who can see this repository can report a vulnerability to its maintainers privately, from its Security tab."
102+ : "Let anyone who can see this repository report a vulnerability to its maintainers privately, instead of in an issue.",
103+ action: facts.canManage ? { kind: "switch", label: "Private vulnerability reporting", intent: "reporting", on: facts.reporting } : null,
104+ },
105+ {
106+ key: "dependency_alerts",
107+ group: "findings",
108+ title: "Dependency alerts",
109+ status: "enabled",
110+ about: "Every package the lockfiles resolve is checked for known vulnerabilities, on every push to the default branch and daily.",
111+ action: { kind: "link", label: "View alerts", to: `${base}/security/vulnerabilities` },
112+ },
113+ {
114+ key: "security_updates",
115+ group: "findings",
116+ title: "Security updates",
117+ status: facts.upkeep ? "enabled" : "disabled",
118+ about: "A pull request upgrades each vulnerable dependency that has a fix; it lands through the branch's required checks.",
119+ action: facts.canManage ? { kind: "switch", label: "Security updates", intent: "upkeep", on: facts.upkeep } : null,
120+ },
121+ {
122+ key: "code_scanning",
123+ group: "findings",
124+ title: "Code scanning",
125+ status: code.analyses > 0 ? "enabled" : "needs_setup",
126+ about:
127+ code.analyses > 0
128+ ? "Your static analysis tools' results, uploaded as SARIF: alerts on the default branch and a check on pull requests."
129+ : "Upload your static analysis tools' SARIF results from a workflow: alerts on the default branch and a check on pull requests.",
130+ hint: !code.entitled ? "On a private repository, code scanning comes with the g1t plan." : undefined,
131+ action:
132+ code.analyses > 0 || code.configured
133+ ? { kind: "link", label: "View alerts", to: `${base}/security/code-scanning` }
134+ : facts.canManage && code.entitled
135+ ? { kind: "post", label: "Set up code scanning", action: `${base}/security/code-scanning/setup`, pending: "Opening a pull request…" }
136+ : { kind: "link", label: "Learn more", to: `${base}/security/code-scanning` },
137+ },
138+ {
139+ key: "secret_scanning",
140+ group: "findings",
141+ title: "Secret scanning",
142+ status: "enabled",
143+ about: facts.historyAlerts
144+ ? "Push protection refuses every push that adds a secret, and secrets already in the history open alerts."
145+ : "Push protection refuses every push that adds a secret. Alerts for secrets already in the history are off.",
146+ hint: "Push protection is always on.",
147+ action: { kind: "link", label: "View alerts", to: `${base}/security/secret-scanning` },
148+ },
149+ {
150+ key: "code_quality",
151+ group: "findings",
152+ title: "Code quality",
153+ status: "coming",
154+ about: "Maintainability and reliability findings beside the security ones. Not built yet.",
155+ action: null,
156+ },
157+ ];
158+}
159+
160+/** The rows of one group, in order. */
161+export function rowsIn(rows: OverviewRow[], group: RowGroup): OverviewRow[] {
162+ return rows.filter((row) => row.group === group);
163+}
164+
165+/**
166+ * The SECURITY.md "Set up a security policy" proposes: how to report, and
167+ * which versions are supported, for the maintainers to finish.
168+ */
169+export function securityPolicyTemplate(repo: string, reporting: boolean, base: string): string {
170+ const report = reporting
171+ ? `Report it privately at https://g1t.sh${base}/security/report. You can report without an account; leave an email address so the maintainers can answer you.`
172+ : `Email the maintainers (fill in the address here) rather than opening an issue, so the problem is not public before a fix is.`;
173+ return `# Security policy
174+
175+## Reporting a vulnerability
176+
177+If you find a vulnerability in ${repo}, do not open an issue about it.
178+
179+${report}
180+
181+Say what is affected, how to reproduce it and, if you can, how bad you think it is. We aim to answer within a few days and will keep you informed as we work on a fix. If you want to be credited in the advisory, say so.
182+
183+## Supported versions
184+
185+| Version | Supported |
186+| ------- | --------- |
187+| latest | Yes |
188+
189+Fill in which releases get security fixes.
190+`;
191+}
192+
193+/** The pull request's body for the policy. */
194+export function securityPolicyPullBody(path: string): string {
195+ return `Adds \`${path}\`, which tells people how to report a vulnerability and which versions get security fixes. Edit the address and the versions before merging.`;
196+}
197+
198+/** A branch name for the policy that is not taken. */
199+export function securityPolicyBranch(taken: string[]): string {
200+ let name = "add-security-policy";
201+ for (let n = 2; taken.includes(name); n++) name = `add-security-policy-${n}`;
202+ return name;
203+}
+3−0
33 import {
44 accountsClient,
55 actionsClient,
6+ advisoriesClient,
67 agentsClient,
78 billingClient,
89 chatClient,
7273 export const security = securityClient(SECURITY);
7374 /** The security suite: patterns, bypasses, code scanning, the dependency graph, settings. */
7475 export const securitySuite = securitySuiteClient(SECURITY);
76+/** Security advisories and private vulnerability reports: methods of the security service. */
77+export const advisories = advisoriesClient(SECURITY);
7578 /** Agent runs, sessions and memory: methods of the work service. */
7679 export const agents = agentsClient(WORK);
7780 /** What agents may do in a sandbox: methods of the work service. */
+8−0
348348 route("security/dependency-updates", "routes/repo/security-updates.tsx"),
349349 route("security/pulls/:number", "routes/repo/security-pull.tsx"),
350350 route("security/settings", "routes/repo/security-settings.tsx"),
351+ // Reporting: the policy's pull request, advisories, and private vulnerability reports.
352+ route("security/policy/setup", "routes/repo/security-policy-setup.ts"),
353+ route("security/advisories", "routes/repo/security-advisories.tsx"),
354+ route("security/advisories/new", "routes/repo/security-advisory-edit.tsx", { id: "routes/repo/security-advisory-new" }),
355+ route("security/advisories/:id", "routes/repo/security-advisory.tsx"),
356+ route("security/advisories/:id/edit", "routes/repo/security-advisory-edit.tsx"),
357+ route("security/reports", "routes/repo/security-reports.tsx"),
358+ route("security/report", "routes/repo/security-report.tsx"),
351359 route("plans", "routes/repo/plans.tsx"),
352360 route("plans/:id", "routes/repo/plan.tsx"),
353361 route("settings", "routes/repo/settings.tsx"),
+141−0
1+/**
2+ * A repository's security advisories: what its maintainers say about
3+ * vulnerabilities in what it ships. Anyone who can see the repository sees
4+ * the published ones; those who see its findings see drafts and withdrawn
5+ * ones too, and Admins (and security managers) write new ones. The reports
6+ * people sent privately are a page beside this one, for those who see findings.
7+ */
8+import { Link, useSearchParams } from "react-router";
9+
10+import type { Advisory, AdvisoryState } from "@g1t/contracts";
11+
12+import type { Route } from "./+types/security-advisories";
13+import { page } from "../../lib/meta";
14+import { SeverityBadge } from "../../components/security";
15+import { FilterSelect, SectionHeader } from "../../components/security-suite";
16+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
17+import { Badge } from "../../components/ui/badge";
18+import { Card } from "../../components/ui/card";
19+import { advisories } from "../../lib/services.server";
20+import { getViewer, unwrap } from "../../lib/session.server";
21+import { requireRepo } from "../../lib/access.server";
22+import { ADVISORY_STATES, STATE_LABEL, STATE_TONE, advisoryStanding, parseAdvisoryState } from "../../lib/advisories";
23+
24+export function meta({ params, ...args }: Route.MetaArgs) {
25+ return page(args, { title: `Security advisories · ${params.owner}/${params.repo} · g1t` });
26+}
27+
28+export async function loader({ params, context }: Route.LoaderArgs) {
29+ const viewer = getViewer(context);
30+ const { access } = await requireRepo(context, params, "read");
31+ const path = { namespace: params.owner, name: params.repo };
32+ const insider = Boolean(access.insider && access.can.security_alerts);
33+ const [list, reports] = await Promise.all([
34+ advisories.list(path, viewer),
35+ insider ? advisories.reports(path, viewer, "new") : Promise.resolve(null),
36+ ]);
37+ return {
38+ advisories: unwrap(list),
39+ insider,
40+ canManage: Boolean(access.can.manage_settings),
41+ newReports: reports?.ok ? reports.value.length : 0,
42+ };
43+}
44+
45+function AdvisoryRow({ advisory, base, insider }: { advisory: Advisory; base: string; insider: boolean }) {
46+ const standing = advisoryStanding(advisory);
47+ return (
48+ <li className="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:gap-4">
49+ <div className="min-w-0 grow">
50+ <div className="flex flex-wrap items-center gap-2">
51+ <SeverityBadge severity={advisory.severity} />
52+ <Link to={`${base}/security/advisories/${advisory.g1tsaId}`} className="min-w-0 truncate text-sm font-medium hover:underline">
53+ {advisory.title}
54+ </Link>
55+ {insider && advisory.state !== "published" && <Badge tone={STATE_TONE[advisory.state]}>{STATE_LABEL[advisory.state]}</Badge>}
56+ </div>
57+ <p className="mt-1 flex flex-wrap items-center gap-x-2 text-xs text-muted">
58+ <span className="font-mono">{advisory.g1tsaId}</span>
59+ {advisory.cveId && <span className="font-mono">{advisory.cveId}</span>}
60+ {advisory.package && (
61+ <span>
62+ {advisory.package}
63+ {advisory.affectedVersions ? ` ${advisory.affectedVersions}` : ""}
64+ </span>
65+ )}
66+ <span>
67+ {standing.verb}
68+ {standing.at && (
69+ <>
70+ {" "}
71+ <TimeAgo at={standing.at} />
72+ </>
73+ )}
74+ </span>
75+ </p>
76+ </div>
77+ </li>
78+ );
79+}
80+
81+export default function SecurityAdvisories({ loaderData, params }: Route.ComponentProps) {
82+ const { advisories: list, insider, canManage, newReports } = loaderData;
83+ const base = `/${params.owner}/${params.repo}`;
84+ const [search, setSearch] = useSearchParams();
85+ const state: AdvisoryState | null = insider ? parseAdvisoryState(search.get("state")) : null;
86+ const shown = state ? list.filter((advisory) => advisory.state === state) : list;
87+ return (
88+ <div className="max-w-5xl space-y-6">
89+ <SectionHeader
90+ title="Security advisories"
91+ about={
92+ insider
93+ ? "What this repository tells people who depend on it about vulnerabilities in what it ships. A draft is yours until you publish it; a published advisory is visible to anyone who can see the repository, and can be withdrawn."
94+ : "What this repository's maintainers have published about vulnerabilities in what it ships, and what to do about each."
95+ }
96+ actions={
97+ <>
98+ {insider && (
99+ <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent">
100+ {newReports > 0 ? `Reports (${newReports} new)` : "Reports"}
101+ </ButtonLink>
102+ )}
103+ {canManage && (
104+ <ButtonLink to={`${base}/security/advisories/new`} size="sm" prefetch="intent">
105+ New advisory
106+ </ButtonLink>
107+ )}
108+ </>
109+ }
110+ />
111+ {insider && (
112+ <div className="flex flex-wrap items-center gap-2">
113+ <FilterSelect
114+ label="State"
115+ value={state ?? "all"}
116+ options={[["all", "Any state"], ...ADVISORY_STATES.map((each): [string, string] => [each, STATE_LABEL[each]])]}
117+ onChange={(value) => {
118+ const next = new URLSearchParams(search);
119+ if (value !== "all") next.set("state", value);
120+ else next.delete("state");
121+ setSearch(next, { replace: true, preventScrollReset: true });
122+ }}
123+ />
124+ </div>
125+ )}
126+ {shown.length === 0 ? (
127+ <EmptyState title={state ? `No ${STATE_LABEL[state].toLowerCase()} advisories` : "No advisories"}>
128+ {canManage ? "Draft one when this repository has a vulnerability to tell people about." : "The maintainers have not published any security advisories."}
129+ </EmptyState>
130+ ) : (
131+ <Card asChild divided>
132+ <ul>
133+ {shown.map((advisory) => (
134+ <AdvisoryRow key={advisory.id} advisory={advisory} base={base} insider={insider} />
135+ ))}
136+ </ul>
137+ </Card>
138+ )}
139+ </div>
140+ );
141+}
+124−0
1+/**
2+ * Writing a security advisory: a new draft, or changes to one in any
3+ * state. Admin on the repository, or a security manager of its workspace.
4+ * The same route serves `advisories/new` and `advisories/:id/edit`.
5+ */
6+import { Form, data, redirect, useNavigation } from "react-router";
7+
8+import type { Advisory, AdvisorySeverity } from "@g1t/contracts";
9+
10+import type { Route } from "./+types/security-advisory-edit";
11+import { page } from "../../lib/meta";
12+import { SectionHeader } from "../../components/security-suite";
13+import { ButtonLink, ErrorText, Field, Input, SubmitButton, Textarea, notACredential } from "../../components/ui";
14+import { Card } from "../../components/ui/card";
15+import { SelectField } from "../../components/ui/select";
16+import { advisories } from "../../lib/services.server";
17+import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
18+import { refusal, requireInsider } from "../../lib/access.server";
19+import { SEVERITIES, SEVERITY_LABEL, advisoryFromForm } from "../../lib/advisories";
20+
21+export function meta({ params, ...args }: Route.MetaArgs) {
22+ return page(args, { title: `${params.id ? "Edit advisory" : "New advisory"} · ${params.owner}/${params.repo} · g1t` });
23+}
24+
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = getViewer(context) ?? requireUser(context, request);
27+ await requireInsider(context, params, "manage_security");
28+ const path = { namespace: params.owner, name: params.repo };
29+ const advisory: Advisory | null = params.id ? unwrap(await advisories.get(path, params.id, viewer)) : null;
30+ return { advisory };
31+}
32+
33+export async function action({ params, context, request }: Route.ActionArgs) {
34+ assertSameOrigin(request);
35+ const user = requireUser(context, request);
36+ const refused = await refusal(context, params, "manage_security");
37+ if (refused) return data({ error: refused }, { status: 403 });
38+ const input = advisoryFromForm(await request.formData());
39+ if (typeof input === "string") return data({ error: input }, { status: 400 });
40+ const path = { namespace: params.owner, name: params.repo };
41+ const saved = params.id ? await advisories.update(user, path, params.id, input) : await advisories.create(user, path, input);
42+ if (!saved.ok) return data({ error: saved.error.message }, { status: 400 });
43+ throw redirect(`/${params.owner}/${params.repo}/security/advisories/${saved.value.g1tsaId}`);
44+}
45+
46+export default function SecurityAdvisoryEdit({ loaderData, actionData, params }: Route.ComponentProps) {
47+ const { advisory } = loaderData;
48+ const base = `/${params.owner}/${params.repo}`;
49+ const navigation = useNavigation();
50+ const back = advisory ? `${base}/security/advisories/${advisory.g1tsaId}` : `${base}/security/advisories`;
51+ return (
52+ <div className="max-w-3xl space-y-6">
53+ <SectionHeader
54+ title={advisory ? `Edit ${advisory.g1tsaId}` : "New security advisory"}
55+ about={
56+ advisory
57+ ? "Changes to a published advisory show at once; a draft stays yours until you publish it."
58+ : "Say what the vulnerability is, what it affects and what fixes it. It starts as a draft only the maintainers see."
59+ }
60+ />
61+ <Form method="post" className="space-y-4">
62+ <Card className="space-y-4 p-4">
63+ <Field label="Title">
64+ <Input name="title" defaultValue={advisory?.title ?? ""} required maxLength={200} placeholder="Path traversal in the archive reader" {...notACredential()} />
65+ </Field>
66+ <div className="grid gap-4 sm:grid-cols-2">
67+ <Field label="Severity">
68+ <SelectField
69+ name="severity"
70+ defaultValue={advisory?.severity ?? "high"}
71+ options={SEVERITIES.map((severity: AdvisorySeverity) => ({ value: severity, label: SEVERITY_LABEL[severity] }))}
72+ aria-label="Severity"
73+ />
74+ </Field>
75+ <Field label="CVE id" hint="When one was issued elsewhere.">
76+ <Input name="cveId" defaultValue={advisory?.cveId ?? ""} placeholder="CVE-2026-12345" maxLength={32} className="font-mono" {...notACredential()} />
77+ </Field>
78+ </div>
79+ <Field label="Description" hint="Markdown: what it is, how it is exploited, what to do. Needed before publishing.">
80+ <Textarea name="description" defaultValue={advisory?.description ?? ""} rows={10} maxLength={20_000} />
81+ </Field>
82+ </Card>
83+ <Card className="space-y-4 p-4">
84+ <p className="text-sm font-medium">What is affected</p>
85+ <div className="grid gap-4 sm:grid-cols-2">
86+ <Field label="Ecosystem" hint="npm, crates.io, Go, PyPI, or how the project ships.">
87+ <Input name="ecosystem" defaultValue={advisory?.ecosystem ?? ""} maxLength={50} {...notACredential()} />
88+ </Field>
89+ <Field label="Package">
90+ <Input name="package" defaultValue={advisory?.package ?? ""} maxLength={200} className="font-mono" {...notACredential()} />
91+ </Field>
92+ <Field label="Affected versions">
93+ <Input name="affectedVersions" defaultValue={advisory?.affectedVersions ?? ""} placeholder=">= 1.2.0, < 1.4.1" maxLength={200} className="font-mono" {...notACredential()} />
94+ </Field>
95+ <Field label="Patched versions">
96+ <Input name="patchedVersions" defaultValue={advisory?.patchedVersions ?? ""} placeholder=">= 1.4.1" maxLength={200} className="font-mono" {...notACredential()} />
97+ </Field>
98+ </div>
99+ </Card>
100+ <Card className="space-y-4 p-4">
101+ <p className="text-sm font-medium">Scoring and credits</p>
102+ <div className="grid gap-4 sm:grid-cols-2">
103+ <Field label="CVSS vector">
104+ <Input name="cvssVector" defaultValue={advisory?.cvssVector ?? ""} placeholder="CVSS:3.1/AV:N/AC:L/…" maxLength={200} className="font-mono" {...notACredential()} />
105+ </Field>
106+ <Field label="CVSS score" hint="0 to 10.">
107+ <Input name="cvssScore" type="number" step="0.1" min="0" max="10" defaultValue={advisory?.cvssScore ?? ""} className="font-mono" />
108+ </Field>
109+ </div>
110+ <Field label="Credits" hint="Who found or fixed it: usernames or names, one per line or separated by commas.">
111+ <Textarea name="credits" defaultValue={(advisory?.credits ?? []).join("\n")} rows={2} />
112+ </Field>
113+ </Card>
114+ <div className="flex flex-wrap items-center gap-3">
115+ <SubmitButton pending="Saving…">{advisory ? "Save" : "Create draft"}</SubmitButton>
116+ <ButtonLink to={back} variant="outline">
117+ Cancel
118+ </ButtonLink>
119+ {navigation.state === "idle" && <ErrorText>{actionData?.error}</ErrorText>}
120+ </div>
121+ </Form>
122+ </div>
123+ );
124+}
+153−0
1+/**
2+ * One security advisory: what it says, what is affected and what fixes it,
3+ * who found it. Admins (and security managers) publish a draft, withdraw a
4+ * published one, or edit it; anyone who can see the repository reads a
5+ * published one.
6+ */
7+import { useFetcher } from "react-router";
8+
9+import type { Route } from "./+types/security-advisory";
10+import { page } from "../../lib/meta";
11+import { Markdown } from "../../components/markdown";
12+import { SeverityBadge } from "../../components/security";
13+import { SectionHeader } from "../../components/security-suite";
14+import { ButtonLink, TimeAgo } from "../../components/ui";
15+import { Badge } from "../../components/ui/badge";
16+import { Button } from "../../components/ui/button";
17+import { Card } from "../../components/ui/card";
18+import { advisories } from "../../lib/services.server";
19+import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
20+import { refusal, requireRepo } from "../../lib/access.server";
21+import { STATE_LABEL, STATE_TONE, advisoryStanding } from "../../lib/advisories";
22+
23+export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
24+ return page(args, { title: `${loaderData?.advisory.title ?? "Advisory"} · ${params.owner}/${params.repo} · g1t` });
25+}
26+
27+export async function loader({ params, context }: Route.LoaderArgs) {
28+ const viewer = getViewer(context);
29+ const { access } = await requireRepo(context, params, "read");
30+ const path = { namespace: params.owner, name: params.repo };
31+ const advisory = unwrap(await advisories.get(path, params.id, viewer));
32+ return { advisory, canManage: Boolean(access.can.manage_settings) };
33+}
34+
35+/** Publishing and withdrawing: Admin, or a security manager. */
36+export async function action({ params, context, request }: Route.ActionArgs) {
37+ assertSameOrigin(request);
38+ const user = requireUser(context, request);
39+ const refused = await refusal(context, params, "manage_security");
40+ if (refused) return { ok: false, error: refused };
41+ const form = await request.formData();
42+ const state = String(form.get("state") ?? "");
43+ if (state !== "published" && state !== "withdrawn") return { ok: false, error: "Nothing to do." };
44+ const changed = await advisories.setState(user, { namespace: params.owner, name: params.repo }, params.id, state);
45+ return changed.ok ? { ok: true } : { ok: false, error: changed.error.message };
46+}
47+
48+function Fact({ label, children }: { label: string; children: React.ReactNode }) {
49+ return (
50+ <div className="flex flex-col gap-0.5 px-4 py-3 sm:flex-row sm:gap-4">
51+ <dt className="shrink-0 text-xs font-medium text-muted sm:w-40 sm:pt-0.5">{label}</dt>
52+ <dd className="min-w-0 text-sm wrap-break-word">{children}</dd>
53+ </div>
54+ );
55+}
56+
57+export default function SecurityAdvisory({ loaderData, params }: Route.ComponentProps) {
58+ const { advisory, canManage } = loaderData;
59+ const base = `/${params.owner}/${params.repo}`;
60+ const fetcher = useFetcher<{ ok: boolean; error?: string }>();
61+ const standing = advisoryStanding(advisory);
62+ const working = fetcher.state !== "idle";
63+ return (
64+ <div className="max-w-5xl space-y-6">
65+ <SectionHeader
66+ title={advisory.title}
67+ about={
68+ <span className="flex flex-wrap items-center gap-2">
69+ <SeverityBadge severity={advisory.severity} />
70+ <Badge tone={STATE_TONE[advisory.state]}>{STATE_LABEL[advisory.state]}</Badge>
71+ <span className="font-mono text-xs">{advisory.g1tsaId}</span>
72+ {advisory.cveId && <span className="font-mono text-xs">{advisory.cveId}</span>}
73+ <span>
74+ {standing.verb}
75+ {standing.at && (
76+ <>
77+ {" "}
78+ <TimeAgo at={standing.at} />
79+ </>
80+ )}
81+ </span>
82+ </span>
83+ }
84+ actions={
85+ canManage ? (
86+ <fetcher.Form method="post" className="flex flex-wrap items-center gap-2">
87+ <ButtonLink to={`${base}/security/advisories/${advisory.g1tsaId}/edit`} variant="outline" size="sm">
88+ Edit
89+ </ButtonLink>
90+ {advisory.state === "published" ? (
91+ <Button type="submit" name="state" value="withdrawn" variant="outline" size="sm" disabled={working}>
92+ {working ? "Withdrawing…" : "Withdraw"}
93+ </Button>
94+ ) : (
95+ <Button type="submit" name="state" value="published" size="sm" disabled={working || !advisory.description.trim()}>
96+ {working ? "Publishing…" : advisory.state === "withdrawn" ? "Publish again" : "Publish"}
97+ </Button>
98+ )}
99+ </fetcher.Form>
100+ ) : null
101+ }
102+ />
103+ {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>}
104+ {canManage && advisory.state === "draft" && (
105+ <p className="text-sm text-muted">
106+ A draft is seen by this repository's maintainers only. {advisory.description.trim() ? "Publish it when it is ready." : "Describe the vulnerability before publishing it."}
107+ </p>
108+ )}
109+ <Card asChild divided>
110+ <dl>
111+ {(advisory.package || advisory.ecosystem) && (
112+ <Fact label="Affected package">
113+ <span className="font-mono">{advisory.package ?? "—"}</span>
114+ {advisory.ecosystem && <span className="text-muted"> ({advisory.ecosystem})</span>}
115+ </Fact>
116+ )}
117+ {advisory.affectedVersions && (
118+ <Fact label="Affected versions">
119+ <span className="font-mono">{advisory.affectedVersions}</span>
120+ </Fact>
121+ )}
122+ {advisory.patchedVersions && (
123+ <Fact label="Patched versions">
124+ <span className="font-mono">{advisory.patchedVersions}</span>
125+ </Fact>
126+ )}
127+ {(advisory.cvssScore != null || advisory.cvssVector) && (
128+ <Fact label="CVSS">
129+ {advisory.cvssScore != null && <span className="font-medium">{advisory.cvssScore.toFixed(1)}</span>}
130+ {advisory.cvssVector && <span className="ml-2 font-mono text-xs text-muted">{advisory.cvssVector}</span>}
131+ </Fact>
132+ )}
133+ {advisory.credits.length > 0 && <Fact label="Credits">{advisory.credits.join(", ")}</Fact>}
134+ <Fact label="Written by">
135+ {advisory.createdBy}, <TimeAgo at={advisory.createdAt} />
136+ </Fact>
137+ </dl>
138+ </Card>
139+ <section aria-labelledby="advisory-description">
140+ <h3 id="advisory-description" className="sr-only">
141+ Description
142+ </h3>
143+ {advisory.description.trim() ? (
144+ <Card className="p-4">
145+ <Markdown source={advisory.description} repo={{ namespace: params.owner, name: params.repo }} />
146+ </Card>
147+ ) : (
148+ <p className="text-sm text-muted">No description yet.</p>
149+ )}
150+ </section>
151+ </div>
152+ );
153+}
+283−84
1−import { ChevronRight, FileSearch, KeyRound, Network, PackageSearch, ShieldCheck } from "lucide-react";
2−import type { ReactNode } from "react";
3−import { Link, redirect } from "react-router";
1+/**
2+ * A repository's Security overview: one row per feature, each with its
3+ * status, a line on what it does and the one thing to do about it, in the
4+ * groups of the Security page (policy, findings, reporting); the open
5+ * alerts by severity and the scan's state below. Anyone who can see a
6+ * public repository but has no role on it sees the public face instead:
7+ * the policy, the published advisories and, when it is on, the way to
8+ * report a vulnerability privately.
9+ */
10+import { ShieldCheck } from "lucide-react";
11+import { Form, Link, redirect, useFetcher, useNavigation } from "react-router";
12+
13+import type { Advisory } from "@g1t/contracts";
414
515 import type { Route } from "./+types/security-overview";
616 import { page } from "../../lib/meta";
717 import { ScanSummary, SeverityBadge, SeverityCountsGrid } from "../../components/security";
8−import { CARD, SectionHeader } from "../../components/security-suite";
9−import { security, securitySuite } from "../../lib/services.server";
10−import { getViewer, requireUser, unwrap } from "../../lib/session.server";
11−import { requireInsider } from "../../lib/access.server";
12−import { legacySecurityTarget, severityCounts, total, worstVulnerabilities } from "../../lib/security-suite";
18+import { SectionHeader } from "../../components/security-suite";
19+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
20+import { Badge } from "../../components/ui/badge";
21+import { Button } from "../../components/ui/button";
22+import { Card } from "../../components/ui/card";
23+import { Hint } from "../../components/ui/hint";
24+import { Switch } from "../../components/ui/switch";
25+import { advisories, repos, security, securitySuite } from "../../lib/services.server";
26+import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
27+import { refusal, requireRepo } from "../../lib/access.server";
28+import { whyNot } from "../../lib/access";
29+import { legacySecurityTarget, severityCounts } from "../../lib/security-suite";
30+import { type OverviewRow, POLICY_PATHS, STATUS_LABEL, STATUS_TONE, overviewRows, rowsIn } from "../../lib/security-overview";
31+import { SEVERITY_LABEL } from "../../lib/advisories";
1332
1433 export function meta({ params, ...args }: Route.MetaArgs) {
1534 return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` });
1635 }
1736
37+/** The security policy on the default branch, if any: the first of the places it may be. */
38+async function policyPath(path: { namespace: string; name: string }, viewer: ReturnType<typeof getViewer>, defaultBranch: string): Promise<string | null> {
39+ const found = await Promise.all(POLICY_PATHS.map((file) => repos.blob(path, viewer, defaultBranch, file).catch(() => null)));
40+ return POLICY_PATHS.find((_, at) => found[at]?.ok) ?? null;
41+}
42+
1843 export async function loader({ params, context, request }: Route.LoaderArgs) {
1944 const base = `/${params.owner}/${params.repo}`;
2045 // Links from before the sections (git's push refusals among them).
2146 const moved = legacySecurityTarget(base, new URL(request.url).searchParams);
2247 if (moved) throw redirect(moved);
23− const viewer = getViewer(context) ?? requireUser(context, request);
24− await requireInsider(context, params, "security_alerts");
25− const repo = { namespace: params.owner, name: params.repo };
26− const [overview, code] = await Promise.all([security.overview(repo, viewer), securitySuite.codeScanning(repo, viewer)]);
27− return { overview: unwrap(overview), code: code.ok ? code.value : null };
48+ const viewer = getViewer(context);
49+ const { repo, access } = await requireRepo(context, params, "read");
50+ const path = { namespace: params.owner, name: params.repo };
51+ if (!access.insider || !access.can.security_alerts) {
52+ // The public face: for anyone who can see the repository.
53+ const [policy, published, reporting] = await Promise.all([
54+ policyPath(path, viewer, repo.defaultBranch),
55+ advisories.list(path, viewer, "published"),
56+ advisories.reportSettings(path, viewer),
57+ ]);
58+ return {
59+ kind: "public" as const,
60+ policy,
61+ advisories: published.ok ? published.value : [],
62+ reporting: reporting.ok && reporting.value.privateVulnerabilityReporting,
63+ signedIn: Boolean(viewer),
64+ };
65+ }
66+ const [overview, code, view, policy, published, reports] = await Promise.all([
67+ security.overview(path, viewer),
68+ securitySuite.codeScanning(path, viewer),
69+ securitySuite.settings(path, viewer),
70+ policyPath(path, viewer, repo.defaultBranch),
71+ advisories.list(path, viewer, "published"),
72+ advisories.reports(path, viewer, "new"),
73+ ]);
74+ const settings = unwrap(view);
75+ return {
76+ kind: "insider" as const,
77+ overview: unwrap(overview),
78+ code: code.ok ? code.value : null,
79+ rows: overviewRows({
80+ base,
81+ policyPath: policy,
82+ reporting: settings.settings.privateVulnerabilityReporting,
83+ advisories: published.ok ? published.value.length : 0,
84+ upkeep: settings.upkeep,
85+ codeScanning: { configured: code.ok ? code.value.configured : false, analyses: code.ok ? code.value.analyses.length : 0, entitled: settings.entitled },
86+ historyAlerts: settings.settings.historyAlerts,
87+ canManage: Boolean(access.can.manage_settings),
88+ canPush: Boolean(access.can.push),
89+ }),
90+ newReports: reports.ok ? reports.value.length : 0,
91+ can: access.can,
92+ };
93+}
94+
95+/** The switches on the overview: security updates, and private vulnerability reporting. */
96+export async function action({ params, context, request }: Route.ActionArgs) {
97+ assertSameOrigin(request);
98+ const user = requireUser(context, request);
99+ const refused = await refusal(context, params, "manage_settings");
100+ if (refused) return { ok: false, error: refused };
101+ const path = { namespace: params.owner, name: params.repo };
102+ const form = await request.formData();
103+ const on = form.get("enabled") === "true";
104+ switch (String(form.get("intent") ?? "")) {
105+ case "upkeep": {
106+ const set = await security.setUpkeep(user, path, on);
107+ return set.ok ? { ok: true } : { ok: false, error: set.error.message };
108+ }
109+ case "reporting": {
110+ const current = await securitySuite.settings(path, user);
111+ if (!current.ok) return { ok: false, error: current.error.message };
112+ const saved = await securitySuite.setSettings(user, path, { ...current.value.settings, privateVulnerabilityReporting: on });
113+ return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
114+ }
115+ default:
116+ return { ok: false, error: "Unknown action." };
117+ }
118+}
119+
120+function RowAction({ row, action, canManage }: { row: OverviewRow; action: string; canManage: boolean }) {
121+ const fetcher = useFetcher<{ ok: boolean; error?: string }>();
122+ const navigation = useNavigation();
123+ const what = row.action;
124+ if (!what) return null;
125+ if (what.kind === "link") {
126+ return (
127+ <ButtonLink to={what.to} variant="outline" size="sm" prefetch="intent">
128+ {what.label}
129+ </ButtonLink>
130+ );
131+ }
132+ if (what.kind === "post") {
133+ const working = navigation.state !== "idle" && navigation.formAction === what.action;
134+ return (
135+ <Form method="post" action={what.action}>
136+ <Button type="submit" size="sm" disabled={working}>
137+ {working ? what.pending : what.label}
138+ </Button>
139+ </Form>
140+ );
141+ }
142+ const on = fetcher.formData ? fetcher.formData.get("enabled") === "true" : what.on;
143+ return (
144+ <div className="flex flex-col items-end gap-1">
145+ <Hint label={canManage ? undefined : whyNot(null, "manage_settings")} disabled={!canManage}>
146+ <Switch
147+ aria-label={what.label}
148+ checked={on}
149+ disabled={!canManage || fetcher.state !== "idle"}
150+ onCheckedChange={(checked) => fetcher.submit({ intent: what.intent, enabled: String(checked) }, { method: "post", action })}
151+ />
152+ </Hint>
153+ {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
154+ </div>
155+ );
28156 }
29157
30−function Card({ to, icon, title, children, footer }: { to: string; icon: ReactNode; title: string; children: ReactNode; footer?: ReactNode }) {
158+function FeatureRows({ title, rows, action, canManage }: { title: string; rows: OverviewRow[]; action: string; canManage: boolean }) {
31159 return (
32− <Link to={to} className={`${CARD} group flex flex-col p-4 transition-colors hover:border-line-strong`}>
33− <span className="flex items-center gap-2 text-sm font-medium">
34− <span className="text-accent">{icon}</span>
160+ <section aria-labelledby={`rows-${title}`} className="space-y-2">
161+ <h3 id={`rows-${title}`} className="text-sm font-semibold">
35162 {title}
36− <ChevronRight size={14} className="ml-auto text-faint group-hover:text-fg" />
37− </span>
38− <div className="mt-2 grow text-sm text-muted">{children}</div>
39− {footer && <div className="mt-3 text-xs text-faint">{footer}</div>}
40− </Link>
163+ </h3>
164+ <Card asChild divided>
165+ <ul>
166+ {rows.map((row) => (
167+ <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
168+ <div className="min-w-0 grow">
169+ <div className="flex flex-wrap items-center gap-2">
170+ <span className="text-sm font-medium">{row.title}</span>
171+ <Hint label={row.hint}>
172+ <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge>
173+ </Hint>
174+ </div>
175+ <p className="mt-1 text-sm text-muted">{row.about}</p>
176+ </div>
177+ <div className="shrink-0 sm:text-right">
178+ <RowAction row={row} action={action} canManage={canManage} />
179+ </div>
180+ </li>
181+ ))}
182+ </ul>
183+ </Card>
184+ </section>
41185 );
42186 }
43187
188+function AdvisoryLine({ advisory, base }: { advisory: Advisory; base: string }) {
189+ return (
190+ <li className="flex items-center gap-3 px-4 py-3">
191+ <SeverityBadge severity={advisory.severity} />
192+ <Link to={`${base}/security/advisories/${advisory.g1tsaId}`} className="min-w-0 grow truncate text-sm font-medium hover:underline">
193+ {advisory.title}
194+ </Link>
195+ <span className="shrink-0 font-mono text-xs text-faint">{advisory.g1tsaId}</span>
196+ {advisory.publishedAt && (
197+ <span className="hidden shrink-0 text-xs text-muted sm:inline">
198+ <TimeAgo at={advisory.publishedAt} />
199+ </span>
200+ )}
201+ </li>
202+ );
203+}
204+
44205 export default function SecurityOverview({ loaderData, params }: Route.ComponentProps) {
45− const { overview, code } = loaderData;
46206 const base = `/${params.owner}/${params.repo}`;
47− const secrets = overview.secretCounts;
207+ if (loaderData.kind === "public") {
208+ const { policy, advisories: published, reporting, signedIn } = loaderData;
209+ return (
210+ <div className="max-w-5xl space-y-8">
211+ <SectionHeader
212+ title="Security"
213+ about="How to report a vulnerability in this repository, and what its maintainers have published about vulnerabilities in what it ships."
214+ actions={
215+ reporting ? (
216+ <ButtonLink to={`${base}/security/report`} prefetch="intent">
217+ Report a vulnerability
218+ </ButtonLink>
219+ ) : null
220+ }
221+ />
222+ <Card asChild divided>
223+ <ul>
224+ <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
225+ <div className="min-w-0 grow">
226+ <div className="flex flex-wrap items-center gap-2">
227+ <span className="text-sm font-medium">Security policy</span>
228+ <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge>
229+ </div>
230+ <p className="mt-1 text-sm text-muted">
231+ {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."}
232+ </p>
233+ </div>
234+ {policy && (
235+ <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm">
236+ View policy
237+ </ButtonLink>
238+ )}
239+ </li>
240+ <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6">
241+ <div className="min-w-0 grow">
242+ <div className="flex flex-wrap items-center gap-2">
243+ <span className="text-sm font-medium">Private vulnerability reporting</span>
244+ <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge>
245+ </div>
246+ <p className="mt-1 text-sm text-muted">
247+ {reporting
248+ ? signedIn
249+ ? "Tell the maintainers privately, under your name. Only they see the report."
250+ : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you."
251+ : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."}
252+ </p>
253+ </div>
254+ </li>
255+ </ul>
256+ </Card>
257+ <section aria-labelledby="public-advisories" className="space-y-2">
258+ <div className="flex items-center justify-between gap-4">
259+ <h3 id="public-advisories" className="text-sm font-semibold">
260+ Security advisories
261+ </h3>
262+ {published.length > 0 && (
263+ <Link to={`${base}/security/advisories`} className="text-sm text-muted underline underline-offset-2 hover:text-fg">
264+ All advisories
265+ </Link>
266+ )}
267+ </div>
268+ {published.length === 0 ? (
269+ <EmptyState title="No published advisories">The maintainers have not published any security advisories for this repository.</EmptyState>
270+ ) : (
271+ <Card asChild divided>
272+ <ul>
273+ {published.slice(0, 5).map((advisory) => (
274+ <AdvisoryLine key={advisory.id} advisory={advisory} base={base} />
275+ ))}
276+ </ul>
277+ </Card>
278+ )}
279+ </section>
280+ </div>
281+ );
282+ }
283+ const { overview, code, rows, newReports, can } = loaderData;
48284 const codeOpen = code ? severityCounts(code.alerts) : null;
49285 // Open alerts of every kind, by severity.
50286 const counts = { ...overview.counts };
51287 if (codeOpen) for (const key of Object.keys(counts) as (keyof typeof counts)[]) counts[key] += codeOpen[key];
52− const worst = worstVulnerabilities(overview.vulnerabilities, 3);
53− const openVulns = overview.vulnerabilities.filter((vuln) => vuln.state === "open").length;
288+ const action = `${base}/security`;
289+ const canManage = Boolean(can.manage_settings);
54290 return (
55291 <div className="max-w-5xl space-y-8">
56292 <SectionHeader
57293 title="Security"
58− about="Pushes that add a secret are refused before they land, and the history is scanned. Code scanning reads your tools' SARIF results, every package the lockfiles resolve is checked for known vulnerabilities, and g1t can fix what it finds."
294+ about="What keeps this repository safe, and where each part stands. Pushes that add a secret are refused before they land, every package the lockfiles resolve is checked for known vulnerabilities, code scanning reads your tools' results, and the maintainers publish advisories and take private reports."
295+ actions={
296+ newReports > 0 ? (
297+ <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent">
298+ {newReports === 1 ? "1 new vulnerability report" : `${newReports} new vulnerability reports`}
299+ </ButtonLink>
300+ ) : null
301+ }
59302 />
60− <div>
303+ <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} />
304+ <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} />
305+ <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} />
306+ <section aria-labelledby="open-alerts" className="space-y-3">
307+ <h3 id="open-alerts" className="flex items-center gap-2 text-sm font-semibold">
308+ <ShieldCheck size={15} className="text-accent" />
309+ Open alerts
310+ </h3>
61311 <SeverityCountsGrid counts={counts} />
62− <p className="mt-2 text-xs text-faint">
63− Open alerts of every kind by severity. A secret in the history that looks real counts as critical; blocked pushes and
64− likely test values do not.
312+ <p className="text-xs text-faint">
313+ Open alerts of every kind by severity ({SEVERITY_LABEL.critical.toLowerCase()} to {SEVERITY_LABEL.low.toLowerCase()}). A secret in the history
314+ that looks real counts as critical; blocked pushes and likely test values do not.
65315 </p>
66− </div>
67− <div className="grid gap-3 sm:grid-cols-2">
68− <Card to={`${base}/security/secret-scanning`} icon={<KeyRound size={15} />} title="Secret scanning" footer="Push protection is on for every push.">
69− {secrets.open + secrets.blocked === 0 ? (
70− <span className="inline-flex items-center gap-1.5 text-success">
71− <ShieldCheck size={14} /> No open secrets
72− </span>
73− ) : (
74− <>
75− <span className="font-medium text-fg">{secrets.open}</span> in the history to rotate,{" "}
76− <span className="font-medium text-fg">{secrets.blocked}</span> stopped at a push
77− </>
78− )}
79− </Card>
80− <Card to={`${base}/security/code-scanning`} icon={<FileSearch size={15} />} title="Code scanning" footer={code?.analyses[0] ? `Last analysis by ${code.analyses[0].tool}` : undefined}>
81− {!code || (code.analyses.length === 0 && code.alerts.length === 0) ? (
82− "Not set up. Add a workflow that uploads SARIF, and results show here and on pull requests."
83− ) : total(codeOpen!) === 0 ? (
84− <span className="inline-flex items-center gap-1.5 text-success">
85− <ShieldCheck size={14} /> No open alerts
86− </span>
87− ) : (
88− <>
89− <span className="font-medium text-fg">{total(codeOpen!)}</span> open {total(codeOpen!) === 1 ? "alert" : "alerts"}
90− </>
91− )}
92− </Card>
93− <Card to={`${base}/security/vulnerabilities`} icon={<PackageSearch size={15} />} title="Vulnerabilities">
94− {openVulns === 0 ? (
95− <span className="inline-flex items-center gap-1.5 text-success">
96− <ShieldCheck size={14} /> No vulnerable dependencies
97− </span>
98− ) : (
99− <ul className="space-y-1">
100− {worst.map((vuln) => (
101− <li key={vuln.id} className="flex items-center gap-2">
102− <SeverityBadge severity={vuln.severity} />
103− <span className="truncate font-mono text-xs text-fg-soft">
104− {vuln.package}@{vuln.version}
105− </span>
106− </li>
107− ))}
108− {openVulns > worst.length && <li className="text-xs">and {openVulns - worst.length} more</li>}
109− </ul>
110− )}
111− </Card>
112− <Card to={`${base}/security/dependency-graph`} icon={<Network size={15} />} title="Dependency graph">
113− {overview.scan.lockfiles.length === 0
114− ? "No lockfiles found on the default branch."
115− : `${overview.scan.lockfiles.length} ${overview.scan.lockfiles.length === 1 ? "lockfile" : "lockfiles"}, with an SPDX SBOM to download.`}
116− </Card>
117− </div>
118− <ScanSummary scan={overview.scan} />
316+ <ScanSummary scan={overview.scan} />
317+ </section>
119318 </div>
120319 );
121320 }
+45−0
1+/**
2+ * "Set up a security policy": commits a SECURITY.md on a new branch as the
3+ * person asking and opens it as their pull request, then goes to it, as
4+ * "Set up code scanning" does with its workflow. It adds a file to the
5+ * repository: Push.
6+ */
7+import { redirect } from "react-router";
8+
9+import type { Route } from "./+types/security-policy-setup";
10+import { refusal, requireWritableRepo } from "../../lib/access.server";
11+import { securityPolicyBranch, securityPolicyPullBody, securityPolicyTemplate } from "../../lib/security-overview";
12+import { advisories, repos, work } from "../../lib/services.server";
13+import { assertSameOrigin, requireUser } from "../../lib/session.server";
14+
15+export async function action({ request, params, context }: Route.ActionArgs) {
16+ assertSameOrigin(request);
17+ const user = requireUser(context, request);
18+ await requireWritableRepo(context, params);
19+ const refused = await refusal(context, params, "push");
20+ if (refused) return { error: refused };
21+ const path = { namespace: params.owner, name: params.repo };
22+ const base = `/${params.owner}/${params.repo}`;
23+ const [branches, reporting] = await Promise.all([repos.branches(path, user), advisories.reportSettings(path, user)]);
24+ const branch = securityPolicyBranch(branches.ok ? branches.value.map((known) => known.name) : []);
25+ const committed = await repos.commitFile(path, user, {
26+ branch,
27+ path: "SECURITY.md",
28+ content: securityPolicyTemplate(`${params.owner}/${params.repo}`, reporting.ok && reporting.value.privateVulnerabilityReporting, base),
29+ message: "Add a security policy",
30+ });
31+ if (!committed.ok) return { error: committed.error.message };
32+ const opened = await work.openPull(user, path, {
33+ branch,
34+ title: "Add a security policy",
35+ body: securityPolicyPullBody("SECURITY.md"),
36+ agent: user.username,
37+ runtime: "external",
38+ });
39+ if (!opened.ok) return { error: opened.error.message };
40+ throw redirect(`${base}/pull/${opened.value.number}`);
41+}
42+
43+export async function loader({ params }: Route.LoaderArgs) {
44+ throw redirect(`/${params.owner}/${params.repo}/security`);
45+}
+131−0
1+/**
2+ * "Report a vulnerability": tells a repository's maintainers privately,
3+ * when the repository takes such reports. Anyone who can see the
4+ * repository may; on a public one someone signed out may too, with an
5+ * email address the maintainers can answer at. The front door limits how
6+ * fast one address can post here (lib/front-door-limits.ts), and the
7+ * service takes five reports a day from one reporter.
8+ */
9+import { Form, Link, data, useNavigation } from "react-router";
10+
11+import type { AdvisorySeverity } from "@g1t/contracts";
12+
13+import type { Route } from "./+types/security-report";
14+import { page } from "../../lib/meta";
15+import { SectionHeader } from "../../components/security-suite";
16+import { ButtonLink, ErrorText, Field, Input, SubmitButton, Textarea, notACredential } from "../../components/ui";
17+import { Alert, AlertDescription, AlertTitle } from "../../components/ui/alert";
18+import { Card } from "../../components/ui/card";
19+import { SelectField } from "../../components/ui/select";
20+import { advisories } from "../../lib/services.server";
21+import { assertSameOrigin, getViewer } from "../../lib/session.server";
22+import { requireRepo } from "../../lib/access.server";
23+import { SEVERITIES, SEVERITY_LABEL, reportFromForm } from "../../lib/advisories";
24+
25+export function meta({ params, ...args }: Route.MetaArgs) {
26+ return page(args, { title: `Report a vulnerability · ${params.owner}/${params.repo} · g1t` });
27+}
28+
29+export async function loader({ params, context }: Route.LoaderArgs) {
30+ const viewer = getViewer(context);
31+ await requireRepo(context, params, "read");
32+ const settings = await advisories.reportSettings({ namespace: params.owner, name: params.repo }, viewer);
33+ return { reporting: settings.ok && settings.value.privateVulnerabilityReporting, signedIn: Boolean(viewer), username: viewer?.username ?? null };
34+}
35+
36+export async function action({ params, context, request }: Route.ActionArgs) {
37+ assertSameOrigin(request);
38+ const viewer = getViewer(context);
39+ await requireRepo(context, params, "read");
40+ const draft = reportFromForm(await request.formData(), Boolean(viewer));
41+ if (typeof draft === "string") return data({ error: draft, sent: null }, { status: 400 });
42+ const sent = await advisories.report(viewer, { namespace: params.owner, name: params.repo }, draft);
43+ if (!sent.ok) return data({ error: sent.error.message, sent: null }, { status: sent.error.code === "limit" ? 429 : 400 });
44+ return { error: null, sent: sent.value.id };
45+}
46+
47+export default function SecurityReport({ loaderData, actionData, params }: Route.ComponentProps) {
48+ const { reporting, signedIn, username } = loaderData;
49+ const base = `/${params.owner}/${params.repo}`;
50+ const navigation = useNavigation();
51+ if (actionData?.sent) {
52+ return (
53+ <div className="max-w-3xl space-y-6">
54+ <SectionHeader title="Thank you" about="Your report reached the maintainers, and only them. They will look into it and answer you when there is news." />
55+ <ButtonLink to={`${base}/security`} variant="outline">
56+ Back to Security
57+ </ButtonLink>
58+ </div>
59+ );
60+ }
61+ if (!reporting) {
62+ return (
63+ <div className="max-w-3xl space-y-6">
64+ <SectionHeader title="Report a vulnerability" about="This repository does not take private vulnerability reports here." />
65+ <Alert tone="info">
66+ <AlertTitle>Reach the maintainers another way</AlertTitle>
67+ <AlertDescription>
68+ Its{" "}
69+ <Link to={`${base}/security`} className="underline underline-offset-2">
70+ Security page
71+ </Link>{" "}
72+ shows its security policy, if it has one, which says how to report. Please do not open a public issue about a vulnerability.
73+ </AlertDescription>
74+ </Alert>
75+ </div>
76+ );
77+ }
78+ return (
79+ <div className="max-w-3xl space-y-6">
80+ <SectionHeader
81+ title="Report a vulnerability"
82+ about={`Tell the maintainers of ${params.owner}/${params.repo} privately. Only they see what you write here; nothing is published until they publish an advisory.`}
83+ />
84+ <Form method="post" className="space-y-4">
85+ <Card className="space-y-4 p-4">
86+ <Field label="Title" hint="One line: what you found.">
87+ <Input name="title" required maxLength={200} placeholder="Path traversal in the archive reader" {...notACredential()} />
88+ </Field>
89+ <Field label="Description" hint="What is affected, how to reproduce it, and what you think it allows. Markdown is fine.">
90+ <Textarea name="description" required rows={10} maxLength={20_000} />
91+ </Field>
92+ <div className="grid gap-4 sm:grid-cols-2">
93+ <Field label="Severity" hint="How bad you think it is, if you have a view.">
94+ <SelectField
95+ name="severity"
96+ defaultValue="medium"
97+ options={SEVERITIES.map((severity: AdvisorySeverity) => ({ value: severity, label: SEVERITY_LABEL[severity] }))}
98+ aria-label="Severity"
99+ />
100+ </Field>
101+ {signedIn ? (
102+ <Field label="Reported as" hint="The maintainers answer you on g1t.">
103+ <Input value={username ?? ""} readOnly className="font-mono" />
104+ </Field>
105+ ) : (
106+ <Field label="Your email" hint="Where the maintainers can answer you. Never published.">
107+ <Input name="email" type="email" required maxLength={254} autoComplete="email" placeholder="you@example.com" />
108+ </Field>
109+ )}
110+ </div>
111+ </Card>
112+ <div className="flex flex-wrap items-center gap-3">
113+ <SubmitButton pending="Sending…">Send report</SubmitButton>
114+ <ButtonLink to={`${base}/security`} variant="outline">
115+ Cancel
116+ </ButtonLink>
117+ {navigation.state === "idle" && <ErrorText>{actionData?.error}</ErrorText>}
118+ </div>
119+ {!signedIn && (
120+ <p className="text-xs text-faint">
121+ Have an account?{" "}
122+ <Link to={`/login?next=${encodeURIComponent(`${base}/security/report`)}`} className="underline underline-offset-2 hover:text-fg">
123+ Sign in
124+ </Link>{" "}
125+ to report under your name.
126+ </p>
127+ )}
128+ </Form>
129+ </div>
130+ );
131+}
+150−0
1+/**
2+ * The vulnerability reports people sent this repository's maintainers
3+ * privately: new ones first, each accepted (which drafts an advisory from
4+ * it) or declined by an Admin or a security manager. Those who see
5+ * findings read them; a reporter never sees another's.
6+ */
7+import { useFetcher, useSearchParams } from "react-router";
8+
9+import type { ReportState, VulnerabilityReport } from "@g1t/contracts";
10+
11+import type { Route } from "./+types/security-reports";
12+import { page } from "../../lib/meta";
13+import { Markdown } from "../../components/markdown";
14+import { SeverityBadge } from "../../components/security";
15+import { FilterSelect, SectionHeader } from "../../components/security-suite";
16+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
17+import { Badge } from "../../components/ui/badge";
18+import { Button } from "../../components/ui/button";
19+import { Card } from "../../components/ui/card";
20+import { Hint } from "../../components/ui/hint";
21+import { Input } from "../../components/ui/input";
22+import { advisories } from "../../lib/services.server";
23+import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
24+import { refusal, requireInsider } from "../../lib/access.server";
25+import { whyNot } from "../../lib/access";
26+import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories";
27+
28+export function meta({ params, ...args }: Route.MetaArgs) {
29+ return page(args, { title: `Vulnerability reports · ${params.owner}/${params.repo} · g1t` });
30+}
31+
32+export async function loader({ params, context, request }: Route.LoaderArgs) {
33+ const viewer = getViewer(context) ?? requireUser(context, request);
34+ const { access } = await requireInsider(context, params, "security_alerts");
35+ const path = { namespace: params.owner, name: params.repo };
36+ const [reports, settings] = await Promise.all([advisories.reports(path, viewer), advisories.reportSettings(path, viewer)]);
37+ return { reports: unwrap(reports), reporting: settings.ok && settings.value.privateVulnerabilityReporting, can: access.can };
38+}
39+
40+export async function action({ params, context, request }: Route.ActionArgs) {
41+ assertSameOrigin(request);
42+ const user = requireUser(context, request);
43+ const refused = await refusal(context, params, "manage_security");
44+ if (refused) return { ok: false, error: refused };
45+ const form = await request.formData();
46+ const decision = String(form.get("decision") ?? "");
47+ if (decision !== "accept" && decision !== "decline") return { ok: false, error: "Nothing to do." };
48+ const reviewed = await advisories.review(user, { namespace: params.owner, name: params.repo }, String(form.get("id") ?? ""), decision, String(form.get("note") ?? "").slice(0, 1000));
49+ if (!reviewed.ok) return { ok: false, error: reviewed.error.message };
50+ return { ok: true, advisory: reviewed.value.advisory?.g1tsaId ?? null };
51+}
52+
53+function ReportCard({ report, base, canReview, action }: { report: VulnerabilityReport; base: string; canReview: boolean; action: string }) {
54+ const fetcher = useFetcher<{ ok: boolean; error?: string; advisory?: string | null }>();
55+ const working = fetcher.state !== "idle";
56+ return (
57+ <Card asChild>
58+ <article className="space-y-3 p-4">
59+ <div className="flex flex-wrap items-center gap-2">
60+ {report.severity && <SeverityBadge severity={report.severity} />}
61+ <h3 className="min-w-0 text-sm font-medium">{report.title}</h3>
62+ <Badge tone={REPORT_STATE_TONE[report.state]}>{REPORT_STATE_LABEL[report.state]}</Badge>
63+ </div>
64+ <p className="text-xs text-muted">
65+ From {report.reporter ?? report.reporterEmail ?? "someone"}, <TimeAgo at={report.createdAt} />
66+ {report.reviewedBy && report.reviewedAt && (
67+ <>
68+ {" · "}
69+ {report.state} by {report.reviewedBy} <TimeAgo at={report.reviewedAt} />
70+ </>
71+ )}
72+ {report.reviewNote && <> · {report.reviewNote}</>}
73+ </p>
74+ <Markdown source={report.description} />
75+ {report.state === "new" && canReview ? (
76+ <fetcher.Form method="post" action={action} className="flex flex-wrap items-center gap-2 pt-1">
77+ <input type="hidden" name="id" value={report.id} />
78+ <Input name="note" placeholder="A note for the record (optional)" maxLength={1000} className="h-8 w-full sm:w-72" />
79+ <Button type="submit" name="decision" value="accept" size="sm" disabled={working}>
80+ {working ? "…" : "Accept as a draft advisory"}
81+ </Button>
82+ <Button type="submit" name="decision" value="decline" size="sm" variant="outline" disabled={working}>
83+ Decline
84+ </Button>
85+ {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
86+ </fetcher.Form>
87+ ) : report.state === "new" ? (
88+ <Hint label={whyNot(null, "manage_security")} disabled>
89+ <Button size="sm" disabled>
90+ Accept as a draft advisory
91+ </Button>
92+ </Hint>
93+ ) : report.advisoryId ? (
94+ <ButtonLink to={`${base}/security/advisories/${report.advisoryId}`} variant="outline" size="sm">
95+ Open the advisory
96+ </ButtonLink>
97+ ) : null}
98+ </article>
99+ </Card>
100+ );
101+}
102+
103+export default function SecurityReports({ loaderData, params }: Route.ComponentProps) {
104+ const { reports, reporting, can } = loaderData;
105+ const base = `/${params.owner}/${params.repo}`;
106+ const [search, setSearch] = useSearchParams();
107+ const state: ReportState | null = parseReportState(search.get("state"));
108+ const shown = state ? reports.filter((report) => report.state === state) : reports;
109+ return (
110+ <div className="max-w-4xl space-y-6">
111+ <SectionHeader
112+ title="Vulnerability reports"
113+ about={
114+ reporting
115+ ? "What people told this repository's maintainers privately. Accept a report to draft an advisory from it, in the reporter's words and crediting them; decline what is not a vulnerability. Reporters never see this list."
116+ : "Private vulnerability reporting is off for this repository: nobody can send a new report. Turn it on from the Security overview. Reports already sent are here."
117+ }
118+ actions={
119+ <ButtonLink to={`${base}/security/advisories`} variant="outline" size="sm" prefetch="intent">
120+ Advisories
121+ </ButtonLink>
122+ }
123+ />
124+ <div className="flex flex-wrap items-center gap-2">
125+ <FilterSelect
126+ label="State"
127+ value={state ?? "all"}
128+ options={[["all", "Any state"], ...REPORT_STATES.map((each): [string, string] => [each, REPORT_STATE_LABEL[each]])]}
129+ onChange={(value) => {
130+ const next = new URLSearchParams(search);
131+ if (value !== "all") next.set("state", value);
132+ else next.delete("state");
133+ setSearch(next, { replace: true, preventScrollReset: true });
134+ }}
135+ />
136+ </div>
137+ {shown.length === 0 ? (
138+ <EmptyState title={state ? `No ${REPORT_STATE_LABEL[state].toLowerCase()} reports` : "No reports"}>
139+ {reporting ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."}
140+ </EmptyState>
141+ ) : (
142+ <div className="space-y-3">
143+ {shown.map((report) => (
144+ <ReportCard key={report.id} report={report} base={base} canReview={Boolean(can.manage_security)} action={`${base}/security/reports`} />
145+ ))}
146+ </div>
147+ )}
148+ </div>
149+ );
150+}
+79−10
11 import { Link, useFetcher } from "react-router";
2+import type { ReactNode } from "react";
23
34 import { CODE_SCANNING_GATES, type CodeScanningGate, type RepoSecuritySettings, type ReviewFailOn } from "@g1t/contracts";
45
67 import { page } from "../../lib/meta";
78 import { ActivationPrompt, CARD, SectionHeader } from "../../components/security-suite";
89 import { Button } from "../../components/ui/button";
10+import { Card } from "../../components/ui/card";
911 import { Hint } from "../../components/ui/hint";
1012 import { SelectField } from "../../components/ui/select";
1113 import { Switch } from "../../components/ui/switch";
12−import { securitySuite } from "../../lib/services.server";
14+import { security, securitySuite } from "../../lib/services.server";
1315 import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server";
1416 import { refusal, requireInsider } from "../../lib/access.server";
1517 import { whyNot } from "../../lib/access";
4648 .filter(Boolean)
4749 .slice(0, 50),
4850 reviewComment: form.get("reviewComment") === "on",
51+ historyAlerts: form.get("historyAlerts") === "on",
52+ privateVulnerabilityReporting: form.get("privateVulnerabilityReporting") === "on",
4953 };
5054 }
5155
5458 const user = requireUser(context, request);
5559 const refused = await refusal(context, params, "manage_settings");
5660 if (refused) return { ok: false, error: refused };
57− const settings = settingsFrom(await request.formData());
61+ const form = await request.formData();
62+ const path = { namespace: params.owner, name: params.repo };
63+ // Security updates are a setting of their own, saved as the switch moves.
64+ if (form.get("intent") === "upkeep") {
65+ const set = await security.setUpkeep(user, path, form.get("enabled") === "true");
66+ return set.ok ? { ok: true } : { ok: false, error: set.error.message };
67+ }
68+ const settings = settingsFrom(form);
5869 if (typeof settings === "string") return { ok: false, error: settings };
59− const saved = await securitySuite.setSettings(user, { namespace: params.owner, name: params.repo }, settings);
70+ const saved = await securitySuite.setSettings(user, path, settings);
6071 return saved.ok ? { ok: true } : { ok: false, error: saved.error.message };
6172 }
6273
6374 const SELECT = "w-full sm:w-72";
6475
76+/** A setting that is on or off, with the words on what it does, as a card. */
77+function SettingSwitch({ name, title, children, defaultChecked, disabled }: { name: string; title: string; children: ReactNode; defaultChecked: boolean; disabled: boolean }) {
78+ return (
79+ <label className={`${CARD} flex items-start justify-between gap-4 p-4 ${disabled ? "opacity-60" : ""}`}>
80+ <span className="min-w-0">
81+ <span className="block text-sm font-medium">{title}</span>
82+ <span className="mt-1 block text-sm text-muted">{children}</span>
83+ </span>
84+ <Switch name={name} defaultChecked={defaultChecked} disabled={disabled} className="mt-0.5" />
85+ </label>
86+ );
87+}
88+
6589 export default function SecuritySettings({ loaderData, params }: Route.ComponentProps) {
6690 const { view, price, can, owner } = loaderData;
6791 const base = `/${params.owner}/${params.repo}`;
6892 const fetcher = useFetcher<{ ok: boolean; error?: string }>();
6993 const { settings } = view;
7094 const disabled = !can.manage_settings || !view.entitled;
95+ // Free everywhere: switches that need no activation.
96+ const locked = !can.manage_settings;
97+ const upkeep = useFetcher<{ ok: boolean; error?: string }>();
98+ const upkeepOn = upkeep.formData ? upkeep.formData.get("enabled") === "true" : view.upkeep;
7199 return (
72100 <div className="max-w-3xl space-y-6">
73101 <SectionHeader
74102 title="Security settings"
75− about="When the pull request checks this repository's security suite reports fail. Require them in branch protection to block merges on them, for people and agents alike."
103+ about="What this repository's security does on its own, and when the pull request checks its security suite reports fail. Require those checks in branch protection to block merges on them, for people and agents alike."
76104 />
105+ <section aria-labelledby="alerts-and-updates" className="space-y-3">
106+ <h3 id="alerts-and-updates" className="text-sm font-semibold">
107+ Alerts, updates and reporting
108+ </h3>
109+ <Card asChild className="flex items-start justify-between gap-4 p-4">
110+ <label>
111+ <span className="min-w-0">
112+ <span className="block text-sm font-medium">Security updates</span>
113+ <span className="mt-1 block text-sm text-muted">
114+ Open a pull request to upgrade each vulnerable dependency that has a fix. It lands through your branch's required checks. The same switch is on
115+ the Vulnerabilities page.
116+ </span>
117+ {upkeep.data?.error && <span className="mt-1 block text-xs text-danger">{upkeep.data.error}</span>}
118+ </span>
119+ <Hint label={whyNot(can, "manage_settings")} disabled={locked}>
120+ <Switch
121+ className="mt-0.5"
122+ checked={upkeepOn}
123+ disabled={upkeep.state !== "idle" || locked}
124+ onCheckedChange={(checked) => upkeep.submit({ intent: "upkeep", enabled: String(checked) }, { method: "post" })}
125+ />
126+ </Hint>
127+ </label>
128+ </Card>
129+ </section>
77130 {!view.entitled && <ActivationPrompt workspace={params.owner} feature="Code scanning and dependency review" monthlyCents={price} isOwner={owner} />}
78131 <fetcher.Form method="post" className="space-y-4">
132+ <fieldset disabled={locked} className="space-y-3">
133+ <legend className="sr-only">Alerts and reporting</legend>
134+ <SettingSwitch name="historyAlerts" title="Alerts for secrets in the history" defaultChecked={settings.historyAlerts} disabled={locked}>
135+ Secrets found in the history, and in pushes too large to scan first, open alerts and tell the workspace's owners and security managers. Off, they
136+ are still listed under Secret scanning but tell nobody. Push protection is always on: a push that adds a secret is refused either way.
137+ </SettingSwitch>
138+ <SettingSwitch
139+ name="privateVulnerabilityReporting"
140+ title="Private vulnerability reporting"
141+ defaultChecked={settings.privateVulnerabilityReporting}
142+ disabled={locked}
143+ >
144+ Anyone who can see this repository can report a vulnerability to its maintainers privately from its Security tab; on a public repository, without
145+ an account, with an email address. The owners and security managers are told at once.
146+ </SettingSwitch>
147+ </fieldset>
79148 <fieldset disabled={disabled} className={`${CARD} space-y-3 p-4 disabled:opacity-60`}>
80149 <legend className="sr-only">Code scanning</legend>
81150 <p className="text-sm font-medium">Code scanning results</p>
135204 <Hint label={whyNot(can, "manage_settings")} disabled={!can.manage_settings}>
136205 <Button
137206 type="submit"
138− disabled={disabled || fetcher.state !== "idle"}>
207+ disabled={locked || fetcher.state !== "idle"}>
139208 {fetcher.state !== "idle" ? "Saving…" : "Save"}
140209 </Button>
141210 </Hint>
147216 </div>
148217 </fetcher.Form>
149218 <p className="text-sm text-muted">
150− Security updates are on the{" "}
151− <Link to={`${base}/security/vulnerabilities`} className="underline underline-offset-2 hover:text-fg">
152− Vulnerabilities
153− </Link>{" "}
154− page, and version updates on{" "}
219+ Advisories and private reports are under{" "}
220+ <Link to={`${base}/security/advisories`} className="underline underline-offset-2 hover:text-fg">
221+ Advisories
222+ </Link>
223+ , and version updates on{" "}
155224 <Link to={`${base}/security/dependency-updates`} className="underline underline-offset-2 hover:text-fg">
156225 Dependency updates
157226 </Link>
+42−2
99 import { Eye, EyeOff } from "lucide-react";
1010 import { Form, Link, data, useNavigation } from "react-router";
1111
12−import type { WatchLevel } from "@g1t/contracts";
12+import type { DigestCadence, WatchLevel } from "@g1t/contracts";
1313
1414 import type { Route } from "./+types/notifications";
1515 import { ErrorText, SubmitButton } from "../../components/ui";
1616 import { Card } from "../../components/ui/card";
1717 import { CheckboxOption } from "../../components/ui/checkbox";
18+import { SwitchCard } from "../../components/ui/switch";
1819 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
19−import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox";
20+import { EMAIL_REASONS, SECURITY_DIGEST_CHOICES, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm, securityDigestFromForm } from "../../lib/inbox";
2021 import { page } from "../../lib/meta";
2122 import { identity, inbox } from "../../lib/services.server";
2223 import { assertSameOrigin, requireUser } from "../../lib/session.server";
5152 case "email":
5253 await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) });
5354 return answer("Saved. You'll be emailed for what is checked.");
55+ case "security": {
56+ const cadence: DigestCadence | null = securityDigestFromForm(form);
57+ if (!cadence) return data(answer(null, "Choose when the rest of the security alerts reach you."), { status: 400 });
58+ await inbox.updateSettings(user.username, { securityAlerts: form.get("security_alerts") === "on", securityDigest: cadence });
59+ return answer("Saved. Critical and high alerts on repositories you look after still reach you at once.");
60+ }
5461 case "default_watch": {
5562 const level = String(form.get("default_watch") ?? "");
5663 if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 });
117124 <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p>
118125 </section>
119126
127+ <section aria-labelledby="security-heading">
128+ <h2 id="security-heading" className="text-sm font-semibold">
129+ Security
130+ </h2>
131+ <p className="mt-1 text-sm text-muted">
132+ Each scan of a repository is one item, brought up to date by the next scan. Critical and high alerts, a push of yours that push protection refused
133+ and a vulnerability report reach you at once; the rest, as you choose here. The workspaces you own, or manage security for, tell you of their
134+ urgent alerts whatever you choose.
135+ </p>
136+ <Form method="post" className="mt-4 space-y-4">
137+ <input type="hidden" name="intent" value="security" />
138+ <SwitchCard name="security_alerts" title="Critical and high alerts at once" defaultChecked={settings.securityAlerts}>
139+ On repositories you watch for security alerts. Off, you hear of them only in the repositories you own or manage security for.
140+ </SwitchCard>
141+ <div>
142+ <p className="mb-2 text-sm font-medium">Medium and low alerts</p>
143+ <RadioGroup name="security_digest" defaultValue={settings.securityDigest}>
144+ {SECURITY_DIGEST_CHOICES.map((choice) => (
145+ <RadioOption key={choice.cadence} value={choice.cadence} label={choice.label} description={choice.detail} />
146+ ))}
147+ </RadioGroup>
148+ </div>
149+ <div className="flex items-center gap-3">
150+ <SubmitButton variant="outline" match={{ intent: "security" }} pending="Saving…">
151+ Save security settings
152+ </SubmitButton>
153+ {said("security")?.saved && <p className="text-xs text-success">{said("security")?.saved}</p>}
154+ </div>
155+ <ErrorText>{said("security")?.error}</ErrorText>
156+ </Form>
157+ <p className="mt-3 text-xs text-faint">Email for security alerts follows the Email section above: on by default.</p>
158+ </section>
159+
120160 <section aria-labelledby="watching-heading">
121161 <h2 id="watching-heading" className="text-sm font-semibold">
122162 Repositories you create
+17−0
219219 pub email: Option<String>,
220220 }
221221
222+/// `report_settings`: whether a repository takes private vulnerability
223+/// reports, for anyone who can see it (its public Security page says so).
224+/// Returns `Outcome<ReportSettings>`.
225+#[derive(Debug, Serialize, Deserialize)]
226+pub struct ReportSettingsArgs {
227+ pub viewer: Option<User>,
228+ pub repo: RepoPath,
229+}
230+
231+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
232+#[serde(rename_all = "camelCase")]
233+pub struct ReportSettings {
234+ pub private_vulnerability_reporting: bool,
235+ /// Published advisories.
236+ pub published: u32,
237+}
238+
222239 /// `list_vulnerability_reports`: a repository's reports, newest first.
223240 /// Those who see findings. Returns `Outcome<Vec<VulnerabilityReport>>`.
224241 #[derive(Debug, Serialize, Deserialize)]
+6−0
101101
102102 export type ReviewedReport = { report: VulnerabilityReport; advisory?: Advisory | null };
103103
104+/** Whether a repository takes private reports, and how many advisories it published: for anyone who can see it. */
105+export type ReportSettings = { privateVulnerabilityReporting: boolean; published: number };
106+
104107 export interface AdvisoriesApi {
105108 /** Published ones for anyone who can see the repository; every state for those who see findings. */
106109 list(repo: RepoPath, viewer: Viewer, state?: AdvisoryState | null): Promise<Result<Advisory[]>>;
117120 repo: RepoPath,
118121 report: { title: string; description: string; severity?: AdvisorySeverity | null; email?: string | null },
119122 ): Promise<Result<VulnerabilityReport>>;
123+ /** Whether the repository takes private reports, and how many advisories it published. Anyone who can see it. */
124+ reportSettings(repo: RepoPath, viewer: Viewer): Promise<Result<ReportSettings>>;
120125 /** The repository's reports, newest first. Those who see findings. */
121126 reports(repo: RepoPath, viewer: Viewer, state?: ReportState | null): Promise<Result<VulnerabilityReport[]>>;
122127 /** `accept` makes a draft advisory from the report; `decline` closes it. */
149154 severity: report.severity ?? null,
150155 email: report.email ?? null,
151156 }),
157+ reportSettings: (repo, viewer) => call("report_settings", { repo, viewer }),
152158 reports: (repo, viewer, state = null) => call("list_vulnerability_reports", { repo, viewer, state }),
153159 review: (actor, repo, id, decision, note) => call("review_vulnerability_report", { actor, repo, id, decision, note }),
154160 };
+10−0
648648 Ok(Outcome::Ok(report))
649649 }
650650
651+ pub(crate) async fn report_settings(&self, a: ReportSettingsArgs) -> Result<Outcome<ReportSettings>> {
652+ let (repo, _) = match self.visible_repo(&a.repo, &a.viewer).await? {
653+ Outcome::Ok(found) => found,
654+ Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
655+ };
656+ let (settings, _) = self.store.repo_settings(&repo.repo_id).await?;
657+ let published = self.store.security_advisories(&repo, &["published"]).await?.len() as u32;
658+ Ok(Outcome::Ok(ReportSettings { private_vulnerability_reporting: settings.private_vulnerability_reporting, published }))
659+ }
660+
651661 pub(crate) async fn list_vulnerability_reports(&self, a: ListReportsArgs) -> Result<Outcome<Vec<VulnerabilityReport>>> {
652662 let repo = match self.member_repo(&a.repo, &a.viewer, crate::SEE_FINDINGS).await? {
653663 Outcome::Ok(repo) => repo,
+1−0
769769 "update_advisory" => reply(&security.update_advisory(args(body)?).await?),
770770 "set_advisory_state" => reply(&security.set_advisory_state(args(body)?).await?),
771771 "report_vulnerability" => reply(&security.report_vulnerability(args(body)?).await?),
772+ "report_settings" => reply(&security.report_settings(args(body)?).await?),
772773 "list_vulnerability_reports" => reply(&security.list_vulnerability_reports(args(body)?).await?),
773774 "review_vulnerability_report" => reply(&security.review_vulnerability_report(args(body)?).await?),
774775 _ => Response::error("Unknown method", 404),