| 1 | /** |
| 2 | * Security advisories and private vulnerability reports as the pages read |
| 3 | * them: what a form posts, checked, and how states and severities show. |
| 4 | * Pure, so it is tested on its own (routes/repo/security-advisor*.tsx, |
| 5 | * security-report*.tsx). |
| 6 | */ |
| 7 | import type { AdvisoryInput, AdvisorySeverity, AdvisoryState, ReportState } from "@g1t/contracts"; |
| 8 | |
| 9 | // The contracts' lists, as types only, so this file runs under `node --test`. |
| 10 | export const SEVERITIES: AdvisorySeverity[] = ["critical", "high", "medium", "low"]; |
| 11 | export const ADVISORY_STATES: AdvisoryState[] = ["draft", "published", "withdrawn"]; |
| 12 | export const REPORT_STATES: ReportState[] = ["new", "accepted", "declined"]; |
| 13 | |
| 14 | export const SEVERITY_LABEL: Record<AdvisorySeverity, string> = { critical: "Critical", high: "High", medium: "Medium", low: "Low" }; |
| 15 | |
| 16 | export const STATE_LABEL: Record<AdvisoryState, string> = { draft: "Draft", published: "Published", withdrawn: "Withdrawn" }; |
| 17 | |
| 18 | export const STATE_TONE: Record<AdvisoryState, "neutral" | "success" | "warn"> = { draft: "neutral", published: "success", withdrawn: "warn" }; |
| 19 | |
| 20 | export const REPORT_STATE_LABEL: Record<ReportState, string> = { new: "New", accepted: "Accepted", declined: "Declined" }; |
| 21 | |
| 22 | export const REPORT_STATE_TONE: Record<ReportState, "warn" | "success" | "neutral"> = { new: "warn", accepted: "success", declined: "neutral" }; |
| 23 | |
| 24 | export function parseSeverity(value: unknown): AdvisorySeverity | null { |
| 25 | return SEVERITIES.find((severity) => severity === value) ?? null; |
| 26 | } |
| 27 | |
| 28 | export function parseAdvisoryState(value: unknown): AdvisoryState | null { |
| 29 | return ADVISORY_STATES.find((state) => state === value) ?? null; |
| 30 | } |
| 31 | |
| 32 | export function parseReportState(value: unknown): ReportState | null { |
| 33 | return REPORT_STATES.find((state) => state === value) ?? null; |
| 34 | } |
| 35 | |
| 36 | const text = (form: FormData, key: string, max: number): string => |
| 37 | String(form.get(key) ?? "") |
| 38 | .trim() |
| 39 | .slice(0, max); |
| 40 | |
| 41 | const optional = (form: FormData, key: string, max: number): string | null => text(form, key, max) || null; |
| 42 | |
| 43 | /** The advisory a form posts, or what is wrong with it. */ |
| 44 | export function advisoryFromForm(form: FormData): AdvisoryInput | string { |
| 45 | const title = text(form, "title", 200); |
| 46 | if (!title) return "Give the advisory a title."; |
| 47 | const severity = parseSeverity(form.get("severity")); |
| 48 | if (!severity) return "Choose a severity."; |
| 49 | const scoreText = text(form, "cvssScore", 10); |
| 50 | const cvssScore = scoreText ? Number(scoreText) : null; |
| 51 | if (cvssScore != null && (!Number.isFinite(cvssScore) || cvssScore < 0 || cvssScore > 10)) return "A CVSS score is 0 to 10."; |
| 52 | const cveId = optional(form, "cveId", 32); |
| 53 | if (cveId && !/^CVE-\d{4}-\d{4,}$/i.test(cveId)) return "A CVE id looks like CVE-2026-12345."; |
| 54 | return { |
| 55 | title, |
| 56 | description: text(form, "description", 20_000), |
| 57 | severity, |
| 58 | cvssVector: optional(form, "cvssVector", 200), |
| 59 | cvssScore, |
| 60 | cveId: cveId?.toUpperCase() ?? null, |
| 61 | ecosystem: optional(form, "ecosystem", 50), |
| 62 | package: optional(form, "package", 200), |
| 63 | affectedVersions: optional(form, "affectedVersions", 200), |
| 64 | patchedVersions: optional(form, "patchedVersions", 200), |
| 65 | credits: text(form, "credits", 2000) |
| 66 | .split(/[\n,]+/) |
| 67 | .map((credit) => credit.trim().replace(/^@/, "")) |
| 68 | .filter(Boolean) |
| 69 | .slice(0, 20), |
| 70 | }; |
| 71 | } |
| 72 | |
| 73 | export type ReportDraft = { title: string; description: string; severity: AdvisorySeverity | null; email: string | null }; |
| 74 | |
| 75 | /** The report a form posts, or what is wrong with it. Someone signed out gives an email. */ |
| 76 | export function reportFromForm(form: FormData, signedIn: boolean): ReportDraft | string { |
| 77 | const title = text(form, "title", 200); |
| 78 | if (!title) return "Give the report a title: one line on what you found."; |
| 79 | const description = text(form, "description", 20_000); |
| 80 | if (!description) return "Describe the vulnerability: what is affected and how to reproduce it."; |
| 81 | const email = optional(form, "email", 254); |
| 82 | if (!signedIn && !(email && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))) return "Give an email address the maintainers can answer at, or sign in."; |
| 83 | return { title, description, severity: parseSeverity(form.get("severity")), email: signedIn ? null : email }; |
| 84 | } |
| 85 | |
| 86 | /** "Published 3 days ago by ana", or what a draft or withdrawn advisory says. */ |
| 87 | export function advisoryStanding(advisory: { state: AdvisoryState; createdBy: string; publishedAt?: string | null; withdrawnAt?: string | null }): { |
| 88 | verb: string; |
| 89 | at: string | null; |
| 90 | } { |
| 91 | switch (advisory.state) { |
| 92 | case "published": |
| 93 | return { verb: "Published", at: advisory.publishedAt ?? null }; |
| 94 | case "withdrawn": |
| 95 | return { verb: "Withdrawn", at: advisory.withdrawnAt ?? null }; |
| 96 | default: |
| 97 | return { verb: `Drafted by ${advisory.createdBy}`, at: null }; |
| 98 | } |
| 99 | } |