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

228 lines8,720 bytesCodeBlame
1/**
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/**
34 * The role an alert takes to dismiss or reopen: Admin
35 * (`manage_integrations`) for a secret, Write (`push`) for a dependency.
36 */
37export function alertCapability(id: string): "manage_integrations" | "push" {
38 return id.startsWith("vul_") ? "push" : "manage_integrations";
39}
40
41export function countByState(alerts: { state: AlertState }[]): Record<AlertState, number> {
42 const counts: Record<AlertState, number> = { open: 0, dismissed: 0, fixed: 0 };
43 for (const alert of alerts) counts[alert.state] += 1;
44 return counts;
45}
46
47/** Open secrets that look real first, then the likely test values. */
48export function splitSecrets(secrets: SecretFinding[]): { real: SecretFinding[]; tests: SecretFinding[] } {
49 return {
50 real: secrets.filter((secret) => !secret.testValue),
51 tests: secrets.filter((secret) => !!secret.testValue),
52 };
53}
54
55export type PackageGroup = { key: string; ecosystem: string; name: string; vulns: Vulnerability[] };
56
57/** Alerts grouped by package, in the order they first appear. */
58export function groupByPackage(vulnerabilities: Vulnerability[]): PackageGroup[] {
59 const map = new Map<string, PackageGroup>();
60 for (const vuln of vulnerabilities) {
61 const key = `${vuln.ecosystem}:${vuln.package}`;
62 const group = map.get(key) ?? { key, ecosystem: vuln.ecosystem, name: vuln.package, vulns: [] };
63 group.vulns.push(vuln);
64 map.set(key, group);
65 }
66 return [...map.values()];
67}
68
69export function worstSeverity(vulns: { severity: Severity }[]): Severity {
70 return SEVERITIES.find((severity) => vulns.some((vuln) => vuln.severity === severity)) ?? "unknown";
71}
72
73/** The package's newest security update, if g1t has started one. */
74export function latestUpdate(vulns: Vulnerability[]): SecurityUpdate | null {
75 let latest: SecurityUpdate | null = null;
76 for (const vuln of vulns) {
77 if (vuln.update && (!latest || vuln.update.updatedAt > latest.updatedAt)) latest = vuln.update;
78 }
79 return latest;
80}
81
82/** The highest fixed version among `vulns`, compared number by number. */
83export function highestFix(vulns: Vulnerability[]): string | null {
84 const versions = vulns.map((vuln) => vuln.fixedVersion).filter((version): version is string => !!version);
85 if (versions.length === 0) return null;
86 return versions.sort(compareVersions).at(-1)!;
87}
88
89export function compareVersions(a: string, b: string): number {
90 const pa = a.split(/[.+-]/);
91 const pb = b.split(/[.+-]/);
92 for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
93 const x = pa[i] ?? "";
94 const y = pb[i] ?? "";
95 const nx = Number(x);
96 const ny = Number(y);
97 const order = x !== "" && y !== "" && !Number.isNaN(nx) && !Number.isNaN(ny) ? nx - ny : x.localeCompare(y);
98 if (order !== 0) return order;
99 }
100 return 0;
101}
102
103/** A security update's state as the page names it, and what it means. */
104export const UPDATE_STATES: Record<UpdateState, { label: string; tone: "accent" | "info" | "merged" | "neutral" | "warn" | "danger" }> = {
105 requested: { label: "Security update in progress", tone: "info" },
106 open: { label: "Security update open", tone: "accent" },
107 merged: { label: "Security update merged", tone: "merged" },
108 closed: { label: "Security update closed", tone: "neutral" },
109 superseded: { label: "Security update superseded", tone: "neutral" },
110 needs_code: { label: "Needs code changes", tone: "warn" },
111 failed: { label: "Security update failed", tone: "danger" },
112};
113
114/** One line of an alert's activity log. */
115export type ActivityEntry = {
116 key: string;
117 /** A username, `g1t`, or null when nobody in particular. */
118 actor: string | null;
119 /** What happened, after the actor's name. */
120 text: string;
121 /** The pull request or issue it concerns. */
122 ref: { kind: "pull" | "issue"; number: number } | null;
123 reason: DismissReason | null;
124 comment: string | null;
125 at: string;
126};
127
128function describe(row: AlertActivity): { text: string; ref: ActivityEntry["ref"] } {
129 const pull = row.number != null ? { kind: "pull" as const, number: row.number } : null;
130 switch (row.action) {
131 case "dismissed":
132 return { text: "dismissed it", ref: null };
133 case "reopened":
134 return { text: "reopened it", ref: null };
135 case "update_requested":
136 return { text: "started a security update", ref: null };
137 case "update_opened":
138 return { text: "opened a security update", ref: pull };
139 case "update_merged":
140 return { text: "merged the security update", ref: pull };
141 case "update_closed":
142 return { text: "closed the security update", ref: pull };
143 case "update_superseded":
144 return { text: "closed the security update as superseded", ref: pull };
145 case "update_needs_code":
146 return {
147 text: "found the upgrade needs code changes and opened an issue for g1t-agent",
148 ref: row.number != null ? { kind: "issue", number: row.number } : null,
149 };
150 case "update_failed":
151 return { text: "could not make the security update", ref: null };
152 default:
153 return { text: row.action.replace(/_/g, " "), ref: pull };
154 }
155}
156
157/**
158 * Everything that happened to an alert, oldest first: the rows recorded
159 * for it, with when it was found, and older decisions made before rows
160 * were kept.
161 */
162export function alertActivity(alert: SecretFinding | Vulnerability, activity: AlertActivity[]): ActivityEntry[] {
163 const rows = activity.filter((row) => row.alertId === alert.id);
164 const entries: ActivityEntry[] = rows.map((row) => ({
165 key: row.id,
166 actor: row.actor,
167 ...describe(row),
168 reason: row.reason,
169 comment: row.comment,
170 at: row.at,
171 }));
172 const dismissedRow = rows.some((row) => row.action === "dismissed");
173 if ("kind" in alert) {
174 entries.push({
175 key: `${alert.id}:found`,
176 actor: alert.source === "push" ? alert.foundBy : null,
177 text: alert.source === "push" ? (alert.status === "blocked" ? "pushed it, and the push was refused" : "pushed it") : "Found in the history",
178 ref: null,
179 reason: null,
180 comment: null,
181 at: alert.foundAt,
182 });
183 if (alert.decidedBy && alert.decidedAt && !dismissedRow && alert.state !== "open") {
184 entries.push({
185 key: `${alert.id}:decided`,
186 actor: alert.decidedBy,
187 text: alert.state === "fixed" && !alert.dismissedReason ? "marked it resolved" : "dismissed it",
188 ref: null,
189 reason: alert.dismissedReason ?? (alert.state === "fixed" ? null : alert.status === "allowed" ? "false_positive" : null),
190 comment: alert.reason,
191 at: alert.decidedAt,
192 });
193 }
194 } else {
195 entries.push({
196 key: `${alert.id}:found`,
197 actor: null,
198 text: `Found ${alert.package} ${alert.version} in ${alert.manifest}`,
199 ref: null,
200 reason: null,
201 comment: null,
202 at: alert.foundAt,
203 });
204 if (alert.dismissedBy && alert.dismissedAt && !dismissedRow && alert.state === "dismissed") {
205 entries.push({
206 key: `${alert.id}:dismissed`,
207 actor: alert.dismissedBy,
208 text: "dismissed it",
209 ref: null,
210 reason: alert.dismissedReason ?? null,
211 comment: alert.dismissedComment ?? null,
212 at: alert.dismissedAt,
213 });
214 }
215 if (alert.state === "fixed" && alert.fixedAt && !rows.some((row) => row.action === "update_merged")) {
216 entries.push({
217 key: `${alert.id}:fixed`,
218 actor: "g1t",
219 text: "found it no longer vulnerable",
220 ref: null,
221 reason: null,
222 comment: null,
223 at: alert.fixedAt,
224 });
225 }
226 }
227 return entries.sort((a, b) => a.at.localeCompare(b.at));
228}