Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

323 linesCodeBlameRaw
1import type { ServiceBinding } from "./clients";
2import type { User, Viewer } from "./identity";
3import type { RepoPath } from "./repos";
4import type { Result } from "./result";
5import type { BumpPackage, BumpRegistry, VersionUpdatesState } from "./updates";
6
7export * from "./updates";
8
9/**
10 * The security service: secrets found in pushes and in history, vulnerable
11 * dependencies, and the updates g1t opens for them. Members of a
12 * workspace see its findings; nobody else does. Mirrors
13 * `g1t_contracts::security`.
14 */
15
16/**
17 * Where a secret stands. `open`: in history, to be rotated. `blocked`: a
18 * push carrying it was refused, so it never landed. `allowed`: someone said
19 * it is not a real secret, and pushes carrying it go through. `resolved`:
20 * rotated or removed.
21 */
22export type SecretStatus = "open" | "blocked" | "allowed" | "resolved";
23
24/**
25 * Where an alert stands, as the Security page's filters and the API put it.
26 * `open`: needs someone. `dismissed`: someone said why it can stay.
27 * `fixed`: a secret revoked, or a dependency no longer vulnerable.
28 */
29export type AlertState = "open" | "dismissed" | "fixed";
30
31/** Why a person dismissed an alert. */
32export type DismissReason =
33 | "false_positive"
34 | "used_in_tests"
35 | "revoked"
36 | "wont_fix"
37 | "fix_started"
38 | "no_bandwidth"
39 | "tolerable_risk"
40 | "inaccurate"
41 | "not_used";
42
43/** The reasons a secret alert can be dismissed with, as people read them. */
44export const SECRET_DISMISS_REASONS: { reason: DismissReason; label: string; about: string }[] = [
45 { reason: "false_positive", label: "False positive", about: "It is not a secret." },
46 { reason: "used_in_tests", label: "Used in tests", about: "A value made for tests or examples." },
47 { reason: "revoked", label: "Revoked", about: "It was real and has been revoked or rotated." },
48 { reason: "wont_fix", label: "Won't fix", about: "It is real, and accepted as it is." },
49];
50
51/** The reasons a dependency alert can be dismissed with, as people read them. */
52export const DEPENDENCY_DISMISS_REASONS: { reason: DismissReason; label: string; about: string }[] = [
53 { reason: "fix_started", label: "A fix has already been started", about: "Someone is upgrading it." },
54 { reason: "no_bandwidth", label: "No bandwidth to fix this", about: "Nobody can get to it now." },
55 { reason: "tolerable_risk", label: "Risk is tolerable to this project", about: "It does not matter for how this project uses it." },
56 { reason: "inaccurate", label: "This alert is inaccurate or incorrect", about: "The advisory is wrong about this package or version." },
57 { reason: "not_used", label: "Vulnerable code is not actually used", about: "The vulnerable code is never called." },
58];
59
60/** A reason's label. */
61export function dismissLabel(reason: DismissReason): string {
62 return [...SECRET_DISMISS_REASONS, ...DEPENDENCY_DISMISS_REASONS].find((r) => r.reason === reason)?.label ?? reason;
63}
64
65export type SecretFinding = {
66 id: string;
67 repoId: string;
68 /** `aws_access_key`, `github_token`, … */
69 kind: string;
70 /** "an AWS access key". */
71 label: string;
72 path: string;
73 line: number;
74 commit: string;
75 /** Enough of the secret to recognise it; the secret itself is never kept. */
76 preview: string;
77 status: SecretStatus;
78 source: "push" | "history";
79 foundBy: string | null;
80 /** RFC 3339. */
81 foundAt: string;
82 /** Who dismissed it (allowed or resolved it). */
83 decidedBy: string | null;
84 /** The comment given when it was dismissed. */
85 reason: string | null;
86 decidedAt: string | null;
87 /** Why it was dismissed; absent on open alerts and older decisions. */
88 dismissedReason?: DismissReason | null;
89 /**
90 * Why the value looks made for tests or documentation, when it does. Such
91 * an alert never stops a push and is never counted as critical.
92 */
93 testValue?: string | null;
94 state: AlertState;
95 /** What its issuer said when last asked: active, inactive, unknown or unsupported. */
96 validity?: "active" | "inactive" | "unknown" | "unsupported" | null;
97 validityCheckedAt?: string | null;
98 /** How it got past push protection, when someone bypassed it. */
99 bypass?: {
100 reason: "false_positive" | "used_in_tests" | "will_fix_later";
101 comment: string | null;
102 by: string;
103 at: string;
104 approvedBy: string | null;
105 } | null;
106 /** The custom pattern that found it, for a `custom_pattern` finding. */
107 patternId?: string | null;
108 patternName?: string | null;
109 /** How many places it was found in. */
110 locations?: number;
111};
112
113export type Severity = "critical" | "high" | "medium" | "low" | "unknown";
114
115export const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "unknown"];
116
117export type Vulnerability = {
118 id: string;
119 repoId: string;
120 /** `npm`, `crates.io`, `Go`, `PyPI`. */
121 ecosystem: string;
122 package: string;
123 version: string;
124 /** The lockfile that resolves it. */
125 manifest: string;
126 /** Its GHSA id when it has one. */
127 advisory: string;
128 osvId: string;
129 summary: string;
130 severity: Severity;
131 fixedVersion: string | null;
132 status: "open" | "fixed" | "dismissed";
133 /**
134 * The work item opened for g1t, by number in the workspace, when upgrading
135 * needs code changes.
136 */
137 item: number | null;
138 foundAt: string;
139 fixedAt: string | null;
140 state: AlertState;
141 dismissedBy?: string | null;
142 dismissedReason?: DismissReason | null;
143 dismissedComment?: string | null;
144 dismissedAt?: string | null;
145 /** The security update for its package, if g1t has started one. */
146 update?: SecurityUpdate | null;
147};
148
149/**
150 * Where a security update stands. `requested`: a sandbox is making the
151 * change. `open`: its pull request is going through the required checks.
152 * `superseded`: a newer update replaced it, or the package is no longer
153 * vulnerable. `needs_code`: an earlier update g1t opened a work item for; new
154 * updates no longer reach it. `failed`: see `error`.
155 */
156export type UpdateState = "requested" | "open" | "merged" | "closed" | "superseded" | "needs_code" | "failed";
157
158/** The pull request g1t opens itself to upgrade one vulnerable package. */
159export type SecurityUpdate = {
160 state: UpdateState;
161 /** The version it upgrades to. */
162 target: string;
163 /** `g1t/security/<package>-<version>`. */
164 branch: string | null;
165 pull: number | null;
166 /** The work item opened for g1t, by number in the workspace. */
167 item: number | null;
168 error: string | null;
169 updatedAt: string;
170};
171
172/** Secret alerts by where they stand. */
173export type SecretCounts = {
174 /** In the history and looking real: rotate these. */
175 open: number;
176 /** Stopped at a push, so never landed, and looking real. */
177 blocked: number;
178 /** Open or blocked, but likely test values. */
179 testValues: number;
180 dismissed: number;
181 fixed: number;
182};
183
184/** One thing that happened to an alert. */
185export type AlertActivity = {
186 id: string;
187 alertId: string;
188 /**
189 * `dismissed`, `reopened`, `update_requested`, `update_opened`,
190 * `update_merged`, `update_closed`, `update_superseded`,
191 * `update_needs_code` or `update_failed`.
192 */
193 action: string;
194 /** A person's username, or `g1t`. */
195 actor: string | null;
196 reason: DismissReason | null;
197 comment: string | null;
198 /** The pull request or work item it concerns. */
199 number: number | null;
200 at: string;
201};
202
203export type SeverityCounts = Record<Severity, number>;
204
205export type ScanState = {
206 /** `pending`, `running`, `done`, or `stopped` at the workspace's limit. */
207 history: "pending" | "running" | "done" | "stopped";
208 commitsScanned: number;
209 historyFinishedAt: string | null;
210 dependenciesScannedAt: string | null;
211 dependenciesError: string | null;
212 lockfiles: string[];
213};
214
215export type SecurityOverview = {
216 repoId: string;
217 /**
218 * Open alerts by severity: vulnerabilities open and not dismissed, and
219 * secrets in the history that look real, as critical. Blocked secrets
220 * and likely test values are not counted.
221 */
222 counts: SeverityCounts;
223 secretCounts: SecretCounts;
224 secrets: SecretFinding[];
225 vulnerabilities: Vulnerability[];
226 /** What happened to the alerts, newest first. */
227 activity: AlertActivity[];
228 scan: ScanState;
229 /** Security updates: whether g1t opens a pull request for each vulnerable dependency with a fix. */
230 upkeep: boolean;
231 versionUpdates: VersionUpdatesState;
232};
233
234/** The alert `dismiss` or `reopen` changed, as it is now. */
235export type AlertChange = {
236 secret: SecretFinding | null;
237 vulnerability: Vulnerability | null;
238};
239
240export type RepoSecurity = {
241 repoId: string;
242 name: string;
243 counts: SeverityCounts;
244 secrets: number;
245 vulnerabilities: number;
246 upkeep: boolean;
247 dependenciesScannedAt: string | null;
248};
249
250export interface SecurityApi {
251 overview(repo: RepoPath, viewer: Viewer): Promise<Result<SecurityOverview>>;
252 /**
253 * Dismisses an alert (a secret takes Admin, a dependency Write) with a
254 * reason and an optional comment.
255 */
256 dismiss(actor: User, repo: RepoPath, id: string, reason: DismissReason, comment: string): Promise<Result<AlertChange>>;
257 /** Opens a dismissed alert again. */
258 reopen(actor: User, repo: RepoPath, id: string): Promise<Result<AlertChange>>;
259 rescan(actor: User, repo: RepoPath): Promise<Result<ScanState>>;
260 setUpkeep(actor: User, repo: RepoPath, enabled: boolean): Promise<Result<boolean>>;
261 workspace(workspace: string, viewer: Viewer): Promise<Result<RepoSecurity[]>>;
262 /** Checks one `updates` entry of the dependency update file for new versions now. Write and up. */
263 checkUpdates(actor: User, repo: RepoPath, entry: string): Promise<Result<VersionUpdatesState>>;
264}
265
266export function securityClient(service: ServiceBinding): SecurityApi {
267 const call = async <T>(method: string, args: object): Promise<T> => {
268 const response = await service.fetch(`https://service/rpc/${method}`, {
269 method: "POST",
270 headers: { "content-type": "application/json" },
271 body: JSON.stringify(args),
272 });
273 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
274 return (await response.json()) as T;
275 };
276 return {
277 overview: (repo, viewer) => call("overview", { repo, viewer }),
278 dismiss: (actor, repo, id, reason, comment) => call("dismiss", { actor, repo, id, reason, comment }),
279 reopen: (actor, repo, id) => call("reopen", { actor, repo, id }),
280 rescan: (actor, repo) => call("rescan", { actor, repo }),
281 setUpkeep: (actor, repo, enabled) => call("set_upkeep", { actor, repo, enabled }),
282 workspace: (workspace, viewer) => call("workspace", { workspace, viewer }),
283 checkUpdates: (actor, repo, entry) => call("check_updates", { actor, repo, entry }),
284 };
285}
286
287/**
288 * The runner's `bump`: makes a security update in a sandbox. It clones the
289 * default branch, raises `package` to `version` in each lockfile with the
290 * ecosystem's own tool, commits that as g1t and pushes it to `branch`
291 * (`g1t/security/…`). The push tells the security service to open the pull
292 * request. Mirrors `g1t_contracts::security::BumpArgs`.
293 */
294export type BumpArgs = {
295 repo: RepoPath;
296 /** OSV's name for the ecosystem: `npm`, `crates.io`, `Go` or `PyPI`. */
297 ecosystem: string;
298 package: string;
299 version: string;
300 /** The lockfiles that resolve a vulnerable version, from the root. */
301 lockfiles: string[];
302 branch: string;
303 /** The commit's message. */
304 message: string;
305 /** `version` for a version update, whose branch is any but the default one. */
306 kind?: "version";
307 /** Several packages raised in one commit, for a grouped update. */
308 packages?: BumpPackage[];
309 /** `increase` (default), `increase-if-necessary`, `widen` or `lockfile-only`. */
310 strategy?: string;
311 /** Replace the branch if it is there already: a rebase or a recreate. */
312 force?: boolean;
313 /** Private registries the tools may read, with their credentials. */
314 registries?: BumpRegistry[];
315 /**
316 * The branch to start from, which its pull request merges into
317 * (`target-branch`): the default branch when absent.
318 */
319 base?: string;
320};
321
322/** The prefix every security update's branch starts with. */
323export const UPDATE_BRANCH_PREFIX = "g1t/security/";