Skip to content

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

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