mirror of
https://github.com/usestrix/strix.git
synced 2026-08-18 09:49:17 +02:00
Fix .gitignore skipping some viewer source files
This commit is contained in:
+6
-4
@@ -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
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
finished: boolean;
|
||||
vulnerabilities: Vulnerability[];
|
||||
reportMarkdown: string | null;
|
||||
transcript: Transcript;
|
||||
}
|
||||
|
||||
async function getJson(path: string): Promise<unknown> {
|
||||
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<string, unknown>;
|
||||
finished: boolean;
|
||||
}> {
|
||||
const raw = (await getJson("/api/run")) as Record<string, unknown>;
|
||||
// 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<Vulnerability[]> {
|
||||
const arr = await getJson("/api/vulnerabilities");
|
||||
return parseVulnerabilitiesJson(JSON.stringify(arr), runId);
|
||||
}
|
||||
|
||||
export async function fetchReportMarkdown(): Promise<string | null> {
|
||||
const obj = (await getJson("/api/report")) as { markdown?: string };
|
||||
return obj?.markdown ?? null;
|
||||
}
|
||||
|
||||
export async function fetchTranscript(): Promise<Transcript> {
|
||||
const obj = (await getJson("/api/transcript")) as Partial<Transcript>;
|
||||
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<LoadedRun> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>/{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<string, unknown>;
|
||||
|
||||
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<string, unknown>).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<string, unknown>;
|
||||
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<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown>;
|
||||
const statuses = (record.statuses ?? {}) as Record<string, unknown>;
|
||||
const parentOf = (record.parent_of ?? {}) as Record<string, unknown>;
|
||||
const names = (record.names ?? {}) as Record<string, unknown>;
|
||||
const metadata = (record.metadata ?? {}) as Record<string, unknown>;
|
||||
|
||||
const agents = new Map<string, ParsedAgent>();
|
||||
for (const id of Object.keys(statuses)) {
|
||||
const meta = (metadata[id] ?? {}) as Record<string, unknown>;
|
||||
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<string | null, string[]>();
|
||||
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<string>();
|
||||
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<VulnerabilitySeverity, number> {
|
||||
const counts: Record<VulnerabilitySeverity, number> = {
|
||||
critical: 0,
|
||||
high: 0,
|
||||
medium: 0,
|
||||
low: 0,
|
||||
};
|
||||
for (const v of vulns) counts[v.severity] += 1;
|
||||
return counts;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
const LANGUAGE_MAP: Record<string, string> = {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user