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

228 lines8,720 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/**
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}