flagon-io/g1t

public

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

g1t/packages/contracts/src/security.ts

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