| 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 | */ |
| 7 | import 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. */ |
| 19 | const SEVERITIES: Severity[] = ["critical", "high", "medium", "low", "unknown"]; |
| 20 | |
| 21 | export const ALERT_STATES: AlertState[] = ["open", "dismissed", "fixed"]; |
| 22 | |
| 23 | /** The `state` URL parameter as a filter; anything else is `open`. */ |
| 24 | export 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. */ |
| 29 | export 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 | */ |
| 37 | export function alertCapability(id: string): "manage_integrations" | "push" { |
| 38 | return id.startsWith("vul_") ? "push" : "manage_integrations"; |
| 39 | } |
| 40 | |
| 41 | export 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. */ |
| 48 | export 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 | |
| 55 | export type PackageGroup = { key: string; ecosystem: string; name: string; vulns: Vulnerability[] }; |
| 56 | |
| 57 | /** Alerts grouped by package, in the order they first appear. */ |
| 58 | export 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 | |
| 69 | export 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. */ |
| 74 | export 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. */ |
| 83 | export 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 | |
| 89 | export 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. */ |
| 104 | export 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. */ |
| 115 | export 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 | |
| 128 | function 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 | */ |
| 162 | export 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 | } |