From 1e4db0098bba90a8388e8edbf1761b76104b586f Mon Sep 17 00:00:00 2001 From: Jonathan Singer Date: Fri, 17 Jul 2026 15:45:39 -0400 Subject: [PATCH] Fix .gitignore skipping some viewer source files --- .gitignore | 10 +- strix/viewer_src/src/data/serverSource.ts | 98 ++++++ strix/viewer_src/src/lib/cta.ts | 18 + strix/viewer_src/src/lib/display-number.ts | 7 + strix/viewer_src/src/lib/hljs.ts | 14 + strix/viewer_src/src/lib/local-run-parser.ts | 325 ++++++++++++++++++ strix/viewer_src/src/lib/target-utils.ts | 51 +++ strix/viewer_src/src/lib/utils.ts | 124 +++++++ .../viewer_src/src/lib/vulnerability-utils.ts | 196 +++++++++++ 9 files changed, 839 insertions(+), 4 deletions(-) create mode 100644 strix/viewer_src/src/data/serverSource.ts create mode 100644 strix/viewer_src/src/lib/cta.ts create mode 100644 strix/viewer_src/src/lib/display-number.ts create mode 100644 strix/viewer_src/src/lib/hljs.ts create mode 100644 strix/viewer_src/src/lib/local-run-parser.ts create mode 100644 strix/viewer_src/src/lib/target-utils.ts create mode 100644 strix/viewer_src/src/lib/utils.ts create mode 100644 strix/viewer_src/src/lib/vulnerability-utils.ts diff --git a/.gitignore b/.gitignore index 0ee4176f..8dbfc126 100644 --- a/.gitignore +++ b/.gitignore @@ -10,14 +10,16 @@ __pycache__/ *$py.class *.so .Python -build/ +# Anchored to the repo root: these are Python build-artifact dir names, but +# unanchored they also match nested source dirs (e.g. the viewer's src/lib). +/build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ @@ -52,7 +54,7 @@ pip-delete-this-directory.txt .env.production.local # MongoDB -data/ +/data/ mongod.log *.mongodb *.mongorc.js diff --git a/strix/viewer_src/src/data/serverSource.ts b/strix/viewer_src/src/data/serverSource.ts new file mode 100644 index 00000000..445d16dd --- /dev/null +++ b/strix/viewer_src/src/data/serverSource.ts @@ -0,0 +1,98 @@ +import type { Vulnerability } from "@/types/issues"; +import { + parseRunJson, + parseVulnerabilitiesJson, + type ParsedRunSummary, +} from "@/lib/local-run-parser"; + +/** + * Data seam for the local viewer. Replaces strix-app's browser file-picker + * (`loadFromTexts`) with fetches against the local Python server's JSON + * endpoints (same origin, relative URLs). Produces the same in-memory + * `LoadedRun` shape the UI renders, plus a `finished` flag driving live polling. + * + * The server serves a live in-progress run and a finished one identically; the + * only signal is `run.finished`. + */ + +/** A transcript agent as emitted by GET /api/transcript (already parsed). */ +export interface TranscriptAgent { + id: string; + name: string; + parent_id: string | null; + status: string; + created_at: string; + updated_at: string; +} + +/** Chat/tool event data as emitted by GET /api/transcript. */ +export interface TranscriptEvent { + id: string; + type: "chat" | "tool"; + agent_id: string; + timestamp: string; + version: number; + data: Record; +} + +export interface Transcript { + agents: TranscriptAgent[]; + events: TranscriptEvent[]; +} + +export interface LoadedRun { + summary: ParsedRunSummary; + /** Whole raw run record (for llm_usage, targets_info details, etc.). */ + raw: Record; + finished: boolean; + vulnerabilities: Vulnerability[]; + reportMarkdown: string | null; + transcript: Transcript; +} + +async function getJson(path: string): Promise { + const res = await fetch(path, { cache: "no-store" }); + if (!res.ok) throw new Error(`${path} responded ${res.status}`); + return res.json(); +} + +export async function fetchRunSummary(): Promise<{ + summary: ParsedRunSummary; + raw: Record; + finished: boolean; +}> { + const raw = (await getJson("/api/run")) as Record; + // parseRunJson tolerates extra keys and takes raw TEXT. + const summary = parseRunJson(JSON.stringify(raw)); + const finished = raw.finished === true; + return { summary, raw, finished }; +} + +export async function fetchVulnerabilities(runId: string | null): Promise { + const arr = await getJson("/api/vulnerabilities"); + return parseVulnerabilitiesJson(JSON.stringify(arr), runId); +} + +export async function fetchReportMarkdown(): Promise { + const obj = (await getJson("/api/report")) as { markdown?: string }; + return obj?.markdown ?? null; +} + +export async function fetchTranscript(): Promise { + const obj = (await getJson("/api/transcript")) as Partial; + return { + agents: Array.isArray(obj?.agents) ? obj.agents : [], + events: Array.isArray(obj?.events) ? obj.events : [], + }; +} + +/** One-shot fetch of every endpoint (used on mount and on final settle). */ +export async function fetchAll(): Promise { + const { summary, raw, finished } = await fetchRunSummary(); + const [vulnerabilities, reportMarkdown, transcript] = await Promise.all([ + fetchVulnerabilities(summary.runId).catch(() => [] as Vulnerability[]), + fetchReportMarkdown().catch(() => null), + fetchTranscript().catch(() => ({ agents: [], events: [] }) as Transcript), + ]); + return { summary, raw, finished, vulnerabilities, reportMarkdown, transcript }; +} diff --git a/strix/viewer_src/src/lib/cta.ts b/strix/viewer_src/src/lib/cta.ts new file mode 100644 index 00000000..1532791f --- /dev/null +++ b/strix/viewer_src/src/lib/cta.ts @@ -0,0 +1,18 @@ +// All upsell / sign-up CTAs route anonymous local-viewer users to the public +// cloud sign-up. Open in a new tab so the local results stay put. +export const SIGNUP_URL = "https://app.strix.ai/api/auth/signup"; + +// Best-effort, anonymous conversion tracking. The local server forwards this to +// PostHog only if the user has telemetry enabled; it never blocks navigation. +export function trackCta(cta: string): void { + try { + const body = JSON.stringify({ event: "cta_clicked", cta }); + if (typeof navigator !== "undefined" && navigator.sendBeacon) { + navigator.sendBeacon("/api/event", body); + } else { + void fetch("/api/event", { method: "POST", body, keepalive: true }); + } + } catch { + /* analytics is best-effort */ + } +} diff --git a/strix/viewer_src/src/lib/display-number.ts b/strix/viewer_src/src/lib/display-number.ts new file mode 100644 index 00000000..745b2077 --- /dev/null +++ b/strix/viewer_src/src/lib/display-number.ts @@ -0,0 +1,7 @@ +// Slim, dependency-free extract of strix-app's display-number helper. The full +// version queries Supabase to compute org-wide finding numbers; the local viewer +// only ever needs the pure formatter, so the supabase-backed functions are +// intentionally omitted (a local run has no org context). +export function formatStrixId(num: number): string { + return `STRIX-${num}`; +} diff --git a/strix/viewer_src/src/lib/hljs.ts b/strix/viewer_src/src/lib/hljs.ts new file mode 100644 index 00000000..69e83a39 --- /dev/null +++ b/strix/viewer_src/src/lib/hljs.ts @@ -0,0 +1,14 @@ +import hljs from "highlight.js/lib/common"; +import http from "highlight.js/lib/languages/http"; +import nginx from "highlight.js/lib/languages/nginx"; +import apache from "highlight.js/lib/languages/apache"; +import dockerfile from "highlight.js/lib/languages/dockerfile"; +import properties from "highlight.js/lib/languages/properties"; + +hljs.registerLanguage("http", http); +hljs.registerLanguage("nginx", nginx); +hljs.registerLanguage("apache", apache); +hljs.registerLanguage("dockerfile", dockerfile); +hljs.registerLanguage("properties", properties); + +export default hljs; diff --git a/strix/viewer_src/src/lib/local-run-parser.ts b/strix/viewer_src/src/lib/local-run-parser.ts new file mode 100644 index 00000000..54968dda --- /dev/null +++ b/strix/viewer_src/src/lib/local-run-parser.ts @@ -0,0 +1,325 @@ +import type { + Vulnerability, + VulnerabilitySeverity, + VulnerabilityStatus, +} from "@/types/issues"; + +/** + * Pure, dependency-free parsers that turn a Strix CLI local run + * (`strix_runs//{run.json,vulnerabilities.json}`) into the app's own + * types, so the /results view can reuse the dashboard's finding components. + * + * These run entirely client-side against files the user picked from disk — + * nothing here uploads or persists anything. + */ + +export class RunParseError extends Error { + constructor(message: string) { + super(message); + this.name = "RunParseError"; + } +} + +export interface ParsedRunSummary { + runId: string | null; + runName: string | null; + targets: string[]; + scanMode: string | null; + status: string | null; + startTime: string | null; + endTime: string | null; + durationSeconds: number | null; + executiveSummary: string | null; + technicalAnalysis: string | null; + methodology: string | null; + recommendations: string | null; +} + +const KNOWN_SEVERITIES: VulnerabilitySeverity[] = ["critical", "high", "medium", "low"]; + +function coerceSeverity(raw: unknown): VulnerabilitySeverity { + const s = String(raw ?? "").toLowerCase().trim(); + if ((KNOWN_SEVERITIES as string[]).includes(s)) return s as VulnerabilitySeverity; + // The app's severity type has no "info"/"informational" bucket; fold those + // (and anything unrecognized) into "low" so the shared UI renders cleanly. + return "low"; +} + +function toIsoTimestamp(raw: unknown): string { + if (typeof raw === "string" && raw.trim()) { + // CLI writes e.g. "2025-01-02 03:04:05 UTC". + const normalized = raw.trim().replace(" UTC", "Z").replace(" ", "T"); + const d = new Date(normalized); + if (!Number.isNaN(d.getTime())) return d.toISOString(); + const direct = new Date(raw); + if (!Number.isNaN(direct.getTime())) return direct.toISOString(); + } + return new Date().toISOString(); +} + +function asStringOrNull(v: unknown): string | null { + return typeof v === "string" && v.length > 0 ? v : null; +} + +function asNumberOrNull(v: unknown): number | null { + return typeof v === "number" && Number.isFinite(v) ? v : null; +} + +function parseJson(text: string, label: string): unknown { + try { + return JSON.parse(text); + } catch { + throw new RunParseError( + `${label} isn't valid JSON. Make sure you selected a Strix run directory.` + ); + } +} + +export function parseRunJson(text: string): ParsedRunSummary { + const data = parseJson(text, "run.json"); + if (!data || typeof data !== "object" || Array.isArray(data)) { + throw new RunParseError("run.json is not an object."); + } + const record = data as Record; + + const targets: string[] = []; + const targetsInfo = record.targets_info; + if (Array.isArray(targetsInfo)) { + for (const t of targetsInfo) { + if (t && typeof t === "object") { + const original = (t as Record).original; + if (typeof original === "string" && original) targets.push(original); + } + } + } + + const startTime = asStringOrNull(record.start_time); + const endTime = asStringOrNull(record.end_time); + let durationSeconds: number | null = null; + if (startTime && endTime) { + const s = new Date(startTime).getTime(); + const e = new Date(endTime).getTime(); + if (!Number.isNaN(s) && !Number.isNaN(e) && e >= s) { + durationSeconds = Math.round((e - s) / 1000); + } + } + + let executiveSummary: string | null = null; + let technicalAnalysis: string | null = null; + let methodology: string | null = null; + let recommendations: string | null = null; + const scanResults = record.scan_results; + if (scanResults && typeof scanResults === "object") { + const sr = scanResults as Record; + executiveSummary = asStringOrNull(sr.executive_summary); + technicalAnalysis = asStringOrNull(sr.technical_analysis); + methodology = asStringOrNull(sr.methodology); + recommendations = asStringOrNull(sr.recommendations); + } + + return { + runId: asStringOrNull(record.run_id), + runName: asStringOrNull(record.run_name), + targets, + scanMode: asStringOrNull(record.scan_mode), + status: asStringOrNull(record.status), + startTime, + endTime, + durationSeconds, + executiveSummary, + technicalAnalysis, + methodology, + recommendations, + }; +} + +/** Fields on the app's Vulnerability type that a local run never provides. */ +function emptyVulnerabilityDefaults(): Omit< + Vulnerability, + | "id" + | "title" + | "description" + | "severity" + | "created_at" + | "scan_id" + | "status" +> { + return { + pr_review_id: null, + cve: null, + cvss: null, + potential_risk_saving: null, + risk_saving_description: null, + impact: null, + endpoint: null, + method: null, + target: null, + technical_analysis: null, + poc_description: null, + poc_script_code: null, + code_diff: null, + code_file: null, + code_before: null, + code_after: null, + cwe: null, + code_locations: null, + remediation_steps: null, + fix_pr_body: null, + evidence: null, + assumptions: null, + fix_effort: null, + cvss_breakdown: null, + status_changed_at: null, + status_changed_by: null, + status_note: null, + snoozed_until: null, + reopened_at: null, + reopened_by: null, + original_severity: null, + severity_changed_at: null, + severity_changed_by: null, + severity_override_reason: null, + retest_of_vulnerability_id: null, + }; +} + +function parseOneVulnerability( + raw: Record, + index: number, + runId: string | null +): Vulnerability { + const cweRaw = raw.cwe; + const cwe = + typeof cweRaw === "string" && cweRaw.trim() + ? [cweRaw.trim()] + : Array.isArray(cweRaw) + ? (cweRaw.filter((c) => typeof c === "string" && c) as string[]) + : null; + + const status: VulnerabilityStatus = "open"; + + return { + ...emptyVulnerabilityDefaults(), + id: asStringOrNull(raw.id) ?? `vuln-${index + 1}`, + scan_id: runId, + title: asStringOrNull(raw.title) ?? "Untitled finding", + description: asStringOrNull(raw.description) ?? "", + severity: coerceSeverity(raw.severity), + status, + created_at: toIsoTimestamp(raw.timestamp), + cve: asStringOrNull(raw.cve), + cvss: asNumberOrNull(raw.cvss), + impact: asStringOrNull(raw.impact), + endpoint: asStringOrNull(raw.endpoint), + method: asStringOrNull(raw.method), + target: asStringOrNull(raw.target), + technical_analysis: asStringOrNull(raw.technical_analysis), + poc_description: asStringOrNull(raw.poc_description), + poc_script_code: asStringOrNull(raw.poc_script_code), + cwe, + code_locations: Array.isArray(raw.code_locations) + ? (raw.code_locations as Vulnerability["code_locations"]) + : null, + remediation_steps: asStringOrNull(raw.remediation_steps), + fix_pr_body: asStringOrNull(raw.fix_pr_body), + evidence: asStringOrNull(raw.evidence), + assumptions: asStringOrNull(raw.assumptions), + fix_effort: (asStringOrNull(raw.fix_effort) as Vulnerability["fix_effort"]) ?? null, + cvss_breakdown: (raw.cvss_breakdown as Vulnerability["cvss_breakdown"]) ?? null, + }; +} + +export function parseVulnerabilitiesJson( + text: string, + runId: string | null = null +): Vulnerability[] { + const data = parseJson(text, "vulnerabilities.json"); + if (!Array.isArray(data)) { + throw new RunParseError("vulnerabilities.json is not a JSON array."); + } + return data.map((item, i) => { + if (!item || typeof item !== "object") { + throw new RunParseError(`vulnerabilities.json entry #${i + 1} is not an object.`); + } + return parseOneVulnerability(item as Record, i, runId); + }); +} + +export interface ParsedAgent { + id: string; + name: string; + status: string; + parentId: string | null; + task: string | null; + skills: string[]; + depth: number; +} + +/** + * Parse the agent execution trace from `.state/agents.json` into a pre-ordered + * tree (children follow their parent; `depth` drives indentation). Rendered + * 100% client-side and never uploaded — traces contain target details, so they + * must stay on the user's machine. The heavier `.state/agents.db` (SQLite) is + * intentionally ignored; `agents.json` has everything the panel needs. + */ +export function parseAgentsJson(text: string): ParsedAgent[] { + const data = parseJson(text, "agents.json"); + if (!data || typeof data !== "object" || Array.isArray(data)) return []; + const record = data as Record; + const statuses = (record.statuses ?? {}) as Record; + const parentOf = (record.parent_of ?? {}) as Record; + const names = (record.names ?? {}) as Record; + const metadata = (record.metadata ?? {}) as Record; + + const agents = new Map(); + for (const id of Object.keys(statuses)) { + const meta = (metadata[id] ?? {}) as Record; + const skillsRaw = meta.skills; + agents.set(id, { + id, + name: asStringOrNull(names[id]) ?? id, + status: asStringOrNull(statuses[id]) ?? "unknown", + parentId: asStringOrNull(parentOf[id]), + task: asStringOrNull(meta.task), + skills: Array.isArray(skillsRaw) + ? skillsRaw.filter((s): s is string => typeof s === "string") + : [], + depth: 0, + }); + } + if (agents.size === 0) return []; + + const childrenOf = new Map(); + for (const a of agents.values()) { + const key = a.parentId && agents.has(a.parentId) ? a.parentId : null; + (childrenOf.get(key) ?? childrenOf.set(key, []).get(key)!).push(a.id); + } + + const ordered: ParsedAgent[] = []; + const seen = new Set(); + const visit = (id: string, depth: number): void => { + const a = agents.get(id); + if (!a || seen.has(id)) return; + seen.add(id); + a.depth = depth; + ordered.push(a); + for (const childId of childrenOf.get(id) ?? []) visit(childId, depth + 1); + }; + for (const rootId of childrenOf.get(null) ?? []) visit(rootId, 0); + // Defensive: include any agents not reachable from a root. + for (const a of agents.values()) if (!seen.has(a.id)) ordered.push(a); + return ordered; +} + +export function severityCounts( + vulns: Vulnerability[] +): Record { + const counts: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + }; + for (const v of vulns) counts[v.severity] += 1; + return counts; +} diff --git a/strix/viewer_src/src/lib/target-utils.ts b/strix/viewer_src/src/lib/target-utils.ts new file mode 100644 index 00000000..842e97cf --- /dev/null +++ b/strix/viewer_src/src/lib/target-utils.ts @@ -0,0 +1,51 @@ +export interface ParsedTarget { + display: string; + href: string | null; + provider: "github" | "gitlab" | "bitbucket" | null; +} + +export function parseTarget(target: string): ParsedTarget { + // GitHub URL + const ghMatch = target.match( + /(?:https?:\/\/)?(?:www\.)?github\.com\/([^\s/]+\/[^\s/]+)/ + ); + if (ghMatch) { + const slug = ghMatch[1].replace(/\.git$/, ""); + return { display: slug, href: `https://github.com/${slug}`, provider: "github" }; + } + + // GitLab URL + const glMatch = target.match( + /(?:https?:\/\/)?(?:www\.)?gitlab\.com\/([^\s/]+\/[^\s/]+)/ + ); + if (glMatch) { + const slug = glMatch[1].replace(/\.git$/, ""); + return { display: slug, href: `https://gitlab.com/${slug}`, provider: "gitlab" }; + } + + // Bitbucket URL + const bbMatch = target.match( + /(?:https?:\/\/)?(?:www\.)?bitbucket\.org\/([^\s/]+\/[^\s/]+)/ + ); + if (bbMatch) { + const slug = bbMatch[1].replace(/\.git$/, ""); + return { display: slug, href: `https://bitbucket.org/${slug}`, provider: "bitbucket" }; + } + + // URL with protocol + if (/^https?:\/\//i.test(target)) { + return { + display: target.replace(/^https?:\/\/(www\.)?/, ""), + href: target, + provider: null, + }; + } + + // Bare domain (e.g. "example.com" or "example.com/path") + if (/^[a-zA-Z0-9][\w.-]*\.[a-zA-Z]{2,}/.test(target)) { + return { display: target, href: `https://${target}`, provider: null }; + } + + // Not a URL + return { display: target, href: null, provider: null }; +} diff --git a/strix/viewer_src/src/lib/utils.ts b/strix/viewer_src/src/lib/utils.ts new file mode 100644 index 00000000..121d0c28 --- /dev/null +++ b/strix/viewer_src/src/lib/utils.ts @@ -0,0 +1,124 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +export function isValidUrl(url: string): boolean { + if (!url || !url.trim()) return false; + + try { + // Add protocol if missing + let urlWithProtocol = url.trim(); + if (!urlWithProtocol.startsWith("http://") && !urlWithProtocol.startsWith("https://")) { + urlWithProtocol = `https://${urlWithProtocol}`; + } + const parsed = new URL(urlWithProtocol); + // Check if it has a valid hostname with at least one dot (domain) + return Boolean(parsed.hostname) && parsed.hostname.includes("."); + } catch { + return false; + } +} + +export function isValidDomain(domain: string | null): boolean { + if (!domain) return false; + + // Basic domain validation regex + const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + + // Check basic format + if (!domainRegex.test(domain)) { + return false; + } + + // Check length constraints + if (domain.length > 253) { + return false; + } + + // Must have at least one dot (TLD required) + if (!domain.includes(".")) { + return false; + } + + // Check each label length (max 63 chars per label) + const labels = domain.split("."); + for (const label of labels) { + if (label.length === 0 || label.length > 63) { + return false; + } + } + + // TLD should be at least 2 characters + const tld = labels[labels.length - 1]; + if (tld.length < 2) { + return false; + } + + return true; +} + +export function formatCurrency(amount: number): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +export function formatTimeAgo(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); + + if (diffInSeconds < 60) { + return "just now"; + } + if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `${minutes}m ago`; + } + if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `${hours}h ago`; + } + if (diffInSeconds < 604800) { + const days = Math.floor(diffInSeconds / 86400); + return `${days}d ago`; + } + return formatDate(dateString); +} + +export function formatTimeUntil(dateString: string): string { + const date = new Date(dateString); + const now = new Date(); + const diffInSeconds = Math.floor((date.getTime() - now.getTime()) / 1000); + + if (diffInSeconds < 0) return "now"; + if (diffInSeconds < 60) return "in <1m"; + if (diffInSeconds < 3600) { + const minutes = Math.floor(diffInSeconds / 60); + return `in ${minutes}m`; + } + if (diffInSeconds < 86400) { + const hours = Math.floor(diffInSeconds / 3600); + return `in ${hours}h`; + } + if (diffInSeconds < 604800) { + const days = Math.round(diffInSeconds / 86400); + return `in ${days || 1}d`; + } + return formatDate(dateString); +} diff --git a/strix/viewer_src/src/lib/vulnerability-utils.ts b/strix/viewer_src/src/lib/vulnerability-utils.ts new file mode 100644 index 00000000..7123d4f0 --- /dev/null +++ b/strix/viewer_src/src/lib/vulnerability-utils.ts @@ -0,0 +1,196 @@ +const LANGUAGE_MAP: Record = { + js: "javascript", ts: "typescript", tsx: "typescript", jsx: "javascript", + py: "python", rb: "ruby", go: "go", rs: "rust", java: "java", php: "php", + cs: "csharp", cpp: "cpp", c: "c", sh: "bash", bash: "bash", sql: "sql", + html: "html", css: "css", json: "json", yaml: "yaml", yml: "yaml", xml: "xml", +}; + +export function getLanguageFromFile(filename: string | null | undefined): string | null { + if (!filename) return null; + const ext = filename.split(".").pop()?.toLowerCase(); + return ext ? LANGUAGE_MAP[ext] || null : null; +} + +export function getSeverityDot(severity: string): string { + switch (severity) { + case "critical": return "bg-red-500"; + case "high": return "bg-orange-500"; + case "medium": return "bg-yellow-500"; + default: return "bg-blue-500"; + } +} + +import type { Vulnerability } from "@/types/issues"; + +export function buildMarkdown(v: Vulnerability): string { + const parts: string[] = []; + parts.push(`# ${v.title}`); + parts.push(""); + const cwePart = v.cwe && v.cwe.length > 0 ? ` · **CWE:** ${v.cwe.join(", ")}` : ""; + parts.push(`**Severity:** ${v.severity.toUpperCase()}${v.cvss ? ` · **CVSS:** ${v.cvss}` : ""}${v.cve ? ` · **CVE:** ${v.cve}` : ""}${cwePart}${v.fix_effort ? ` · **Fix Effort:** ${v.fix_effort}` : ""}`); + parts.push(`**Status:** ${v.status}`); + if (v.target) parts.push(`**Target:** ${v.target}`); + if (v.endpoint) parts.push(`**Endpoint:** ${v.method ? `${v.method} ` : ""}${v.endpoint}`); + parts.push(""); + if (v.description) { + parts.push("## Description"); + parts.push(""); + parts.push(v.description); + parts.push(""); + } + if (v.impact) { + parts.push("## Impact"); + parts.push(""); + parts.push(v.impact); + parts.push(""); + } + if (v.evidence) { + parts.push("## Evidence"); + parts.push(""); + parts.push(v.evidence); + parts.push(""); + } + if (v.assumptions) { + parts.push("## Assumptions"); + parts.push(""); + parts.push(v.assumptions); + parts.push(""); + } + if (v.technical_analysis) { + parts.push("## Technical Details"); + parts.push(""); + parts.push(v.technical_analysis); + parts.push(""); + } + if (v.remediation_steps) { + parts.push("## How to Fix"); + parts.push(""); + parts.push(v.remediation_steps); + parts.push(""); + } + if (v.poc_description) { + parts.push("## Proof of Concept"); + parts.push(""); + parts.push(v.poc_description); + if (v.poc_script_code) { + parts.push(""); + parts.push("```"); + parts.push(v.poc_script_code); + parts.push("```"); + } + parts.push(""); + } + if (v.code_locations?.length) { + parts.push("## Code Locations"); + parts.push(""); + if (v.location_meta) { + parts.push(`**Repository:** ${v.location_meta.repo_url} (${v.location_meta.branch})`); + parts.push(""); + } + for (const loc of v.code_locations) { + if (!loc.file) continue; + const lineRef = loc.end_line && loc.end_line !== loc.start_line + ? `${loc.file}:${loc.start_line}-${loc.end_line}` + : `${loc.file}:${loc.start_line}`; + parts.push(`### \`${lineRef}\``); + if (loc.label) parts.push(loc.label); + if (loc.snippet) { + parts.push(""); + parts.push("```"); + parts.push(loc.snippet); + parts.push("```"); + } + if (loc.fix_before && loc.fix_after) { + parts.push(""); + parts.push("```diff"); + for (const l of loc.fix_before.split("\n")) parts.push(`- ${l}`); + for (const l of loc.fix_after.split("\n")) parts.push(`+ ${l}`); + parts.push("```"); + } + parts.push(""); + } + } else if (v.code_file && (v.code_before || v.code_after)) { + parts.push("## Code"); + parts.push(""); + parts.push(`**File:** \`${v.code_file}\``); + if (v.code_before && v.code_after) { + parts.push(""); + parts.push("```diff"); + for (const l of v.code_before.split("\n")) parts.push(`- ${l}`); + for (const l of v.code_after.split("\n")) parts.push(`+ ${l}`); + parts.push("```"); + } else if (v.code_diff) { + parts.push(""); + parts.push("```diff"); + parts.push(v.code_diff); + parts.push("```"); + } + parts.push(""); + } + return parts.join("\n"); +} + +export function buildAIFixPrompt(v: Vulnerability): string { + const parts: string[] = []; + parts.push("This is a security vulnerability found during a code review."); + parts.push(""); + parts.push(`Vulnerability: ${v.title}`); + parts.push(`Severity: ${v.severity.toUpperCase()}`); + if (v.cwe && v.cwe.length > 0) { + parts.push(`CWE: ${v.cwe.join(", ")}`); + } + parts.push(""); + parts.push(v.description); + + if (v.evidence) { + parts.push(""); + parts.push("Evidence:"); + parts.push(v.evidence); + } + + const allLocations = v.code_locations || []; + for (const fixLoc of allLocations) { + if (!fixLoc.file) continue; + const startLine = fixLoc.start_line || 0; + const endLine = fixLoc.end_line || startLine; + parts.push(""); + parts.push(`Location: ${fixLoc.file}:${startLine}-${endLine}`); + if (fixLoc.label) { + parts.push(`Context: ${fixLoc.label}`); + } + if (fixLoc.fix_before && fixLoc.fix_after) { + parts.push("```"); + parts.push(`// Before:\n${fixLoc.fix_before}`); + parts.push(`// After:\n${fixLoc.fix_after}`); + parts.push("```"); + } else if (fixLoc.snippet) { + parts.push("```"); + parts.push(fixLoc.snippet); + parts.push("```"); + } + } + + if (v.remediation_steps) { + parts.push(""); + parts.push("How to fix:"); + parts.push(v.remediation_steps); + } + parts.push(""); + parts.push("Please fix this vulnerability. If you propose a fix, make it concise and minimal."); + return parts.join("\n"); +} + +export async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + } catch { + const ta = document.createElement("textarea"); + ta.value = text; + ta.style.position = "absolute"; + ta.style.left = "-9999px"; + document.body.appendChild(ta); + ta.select(); + document.execCommand("copy"); + document.body.removeChild(ta); + } +}