Skip to content

g1t/apps/web/app/lib/security-alerts.ts

229 lines8,700 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1/**
2 * The Security page's alerts, sorted and described: which filter an alert
3 * falls under, which secrets are likely test values, packages grouped with
4 * their worst severity, what each security update state means, and each
5 * alert's activity as a list of entries. Pure, so it is tested on its own.
6 */
7import type {
8 AlertActivity,
9 AlertState,
10 DismissReason,
11 SecretFinding,
12 SecurityUpdate,
13 Severity,
14 UpdateState,
15 Vulnerability,
16} from "@g1t/contracts";
17
18/** Most severe first, as the contracts list them; here so the tests need no build of the contracts. */
19const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "unknown"];
20
21export const ALERT_STATES: AlertState[] = ["open", "dismissed", "fixed"];
22
23/** The `state` URL parameter as a filter; anything else is `open`. */
24export function parseAlertState(value: string | null | undefined): AlertState {
25 return value === "dismissed" || value === "fixed" ? value : "open";
26}
27
28/** Which tab an alert's id belongs on. */
29export function tabOf(id: string): "secrets" | "dependencies" {
30 return id.startsWith("vul_") ? "dependencies" : "secrets";
31}
32
33/**
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA34 * What an alert takes to dismiss or reopen: `security_alerts`, the Write
35 * role or a security manager, for a secret and a dependency alike, as on
36 * GitHub.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily37 */
Merge membership: owners, org roles, GitHub's repo roles, privileges, 2FA38export function alertCapability(_id: string): "security_alerts" {
39 return "security_alerts";
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily40}
41
42export function countByState(alerts: { state: AlertState }[]): Record<AlertState, number> {
43 const counts: Record<AlertState, number> = { open: 0, dismissed: 0, fixed: 0 };
44 for (const alert of alerts) counts[alert.state] += 1;
45 return counts;
46}
47
48/** Open secrets that look real first, then the likely test values. */
49export function splitSecrets(secrets: SecretFinding[]): { real: SecretFinding[]; tests: SecretFinding[] } {
50 return {
51 real: secrets.filter((secret) => !secret.testValue),
52 tests: secrets.filter((secret) => !!secret.testValue),
53 };
54}
55
56export type PackageGroup = { key: string; ecosystem: string; name: string; vulns: Vulnerability[] };
57
58/** Alerts grouped by package, in the order they first appear. */
59export function groupByPackage(vulnerabilities: Vulnerability[]): PackageGroup[] {
60 const map = new Map<string, PackageGroup>();
61 for (const vuln of vulnerabilities) {
62 const key = `${vuln.ecosystem}:${vuln.package}`;
63 const group = map.get(key) ?? { key, ecosystem: vuln.ecosystem, name: vuln.package, vulns: [] };
64 group.vulns.push(vuln);
65 map.set(key, group);
66 }
67 return [...map.values()];
68}
69
70export function worstSeverity(vulns: { severity: Severity }[]): Severity {
71 return SEVERITIES.find((severity) => vulns.some((vuln) => vuln.severity === severity)) ?? "unknown";
72}
73
74/** The package's newest security update, if g1t has started one. */
75export function latestUpdate(vulns: Vulnerability[]): SecurityUpdate | null {
76 let latest: SecurityUpdate | null = null;
77 for (const vuln of vulns) {
78 if (vuln.update && (!latest || vuln.update.updatedAt > latest.updatedAt)) latest = vuln.update;
79 }
80 return latest;
81}
82
83/** The highest fixed version among `vulns`, compared number by number. */
84export function highestFix(vulns: Vulnerability[]): string | null {
85 const versions = vulns.map((vuln) => vuln.fixedVersion).filter((version): version is string => !!version);
86 if (versions.length === 0) return null;
87 return versions.sort(compareVersions).at(-1)!;
88}
89
90export function compareVersions(a: string, b: string): number {
91 const pa = a.split(/[.+-]/);
92 const pb = b.split(/[.+-]/);
93 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
94 const x = pa[i] ?? "";
95 const y = pb[i] ?? "";
96 const nx = Number(x);
97 const ny = Number(y);
98 const order = x !== "" && y !== "" && !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);
99 if (order !== 0) return order;
100 }
101 return 0;
102}
103
104/** A security update's state as the page names it, and what it means. */
105export const UPDATE_STATES: Record<UpdateState, { label: string; tone: "accent" | "info" | "merged" | "neutral" | "warn" | "danger" }> = {
106 requested: { label: "Security update in progress", tone: "info" },
107 open: { label: "Security update open", tone: "accent" },
108 merged: { label: "Security update merged", tone: "merged" },
109 closed: { label: "Security update closed", tone: "neutral" },
110 superseded: { label: "Security update superseded", tone: "neutral" },
111 needs_code: { label: "Needs code changes", tone: "warn" },
112 failed: { label: "Security update failed", tone: "danger" },
113};
114
115/** One line of an alert's activity log. */
116export type ActivityEntry = {
117 key: string;
118 /** A username, `g1t`, or null when nobody in particular. */
119 actor: string | null;
120 /** What happened, after the actor's name. */
121 text: string;
122 /** The pull request or issue it concerns. */
123 ref: { kind: "pull" | "issue"; number: number } | null;
124 reason: DismissReason | null;
125 comment: string | null;
126 at: string;
127};
128
129function describe(row: AlertActivity): { text: string; ref: ActivityEntry["ref"] } {
130 const pull = row.number != null ? { kind: "pull" as const, number: row.number } : null;
131 switch (row.action) {
132 case "dismissed":
133 return { text: "dismissed it", ref: null };
134 case "reopened":
135 return { text: "reopened it", ref: null };
136 case "update_requested":
137 return { text: "started a security update", ref: null };
138 case "update_opened":
139 return { text: "opened a security update", ref: pull };
140 case "update_merged":
141 return { text: "merged the security update", ref: pull };
142 case "update_closed":
143 return { text: "closed the security update", ref: pull };
144 case "update_superseded":
145 return { text: "closed the security update as superseded", ref: pull };
146 case "update_needs_code":
147 return {
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent148 text: "found the upgrade needs code changes and opened an issue to make them",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily149 ref: row.number != null ? { kind: "issue", number: row.number } : null,
150 };
151 case "update_failed":
152 return { text: "could not make the security update", ref: null };
153 default:
154 return { text: row.action.replace(/_/g, " "), ref: pull };
155 }
156}
157
158/**
159 * Everything that happened to an alert, oldest first: the rows recorded
160 * for it, with when it was found, and older decisions made before rows
161 * were kept.
162 */
163export function alertActivity(alert: SecretFinding | Vulnerability, activity: AlertActivity[]): ActivityEntry[] {
164 const rows = activity.filter((row) => row.alertId === alert.id);
165 const entries: ActivityEntry[] = rows.map((row) => ({
166 key: row.id,
167 actor: row.actor,
168 ...describe(row),
169 reason: row.reason,
170 comment: row.comment,
171 at: row.at,
172 }));
173 const dismissedRow = rows.some((row) => row.action === "dismissed");
174 if ("kind" in alert) {
175 entries.push({
176 key: `${alert.id}:found`,
177 actor: alert.source === "push" ? alert.foundBy : null,
178 text: alert.source === "push" ? (alert.status === "blocked" ? "pushed it, and the push was refused" : "pushed it") : "Found in the history",
179 ref: null,
180 reason: null,
181 comment: null,
182 at: alert.foundAt,
183 });
184 if (alert.decidedBy && alert.decidedAt && !dismissedRow && alert.state !== "open") {
185 entries.push({
186 key: `${alert.id}:decided`,
187 actor: alert.decidedBy,
188 text: alert.state === "fixed" && !alert.dismissedReason ? "marked it resolved" : "dismissed it",
189 ref: null,
190 reason: alert.dismissedReason ?? (alert.state === "fixed" ? null : alert.status === "allowed" ? "false_positive" : null),
191 comment: alert.reason,
192 at: alert.decidedAt,
193 });
194 }
195 } else {
196 entries.push({
197 key: `${alert.id}:found`,
198 actor: null,
199 text: `Found ${alert.package} ${alert.version} in ${alert.manifest}`,
200 ref: null,
201 reason: null,
202 comment: null,
203 at: alert.foundAt,
204 });
205 if (alert.dismissedBy && alert.dismissedAt && !dismissedRow && alert.state === "dismissed") {
206 entries.push({
207 key: `${alert.id}:dismissed`,
208 actor: alert.dismissedBy,
209 text: "dismissed it",
210 ref: null,
211 reason: alert.dismissedReason ?? null,
212 comment: alert.dismissedComment ?? null,
213 at: alert.dismissedAt,
214 });
215 }
216 if (alert.state === "fixed" && alert.fixedAt && !rows.some((row) => row.action === "update_merged")) {
217 entries.push({
218 key: `${alert.id}:fixed`,
219 actor: "g1t",
220 text: "found it no longer vulnerable",
221 ref: null,
222 reason: null,
223 comment: null,
224 at: alert.fixedAt,
225 });
226 }
227 }
228 return entries.sort((a, b) => a.at.localeCompare(b.at));
229}

This file's history is long; its oldest lines are credited to the oldest commit read.