Skip to content

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

229 lines8,700 bytesCodeBlameRaw
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 * 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.
37 */
38export function alertCapability(_id: string): "security_alerts" {
39 return "security_alerts";
40}
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 {
148 text: "found the upgrade needs code changes and opened an issue to make them",
149 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}