openhacker 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +151 -28
  3. package/templates/agent/.env.example +0 -7
  4. package/templates/agent/README.md +1 -2
  5. package/templates/agent/agent/agent.ts +1 -5
  6. package/templates/agent/agent/channels/eve.ts +7 -0
  7. package/templates/agent/agent/instructions.md +7 -45
  8. package/templates/agent/app/globals.css +65 -197
  9. package/templates/agent/app/layout.tsx +2 -22
  10. package/templates/agent/app/page.tsx +80 -102
  11. package/templates/agent/package.json +2 -3
  12. package/templates/agent/agent/lib/auth.ts +0 -23
  13. package/templates/agent/agent/lib/github.ts +0 -74
  14. package/templates/agent/agent/lib/osv.ts +0 -152
  15. package/templates/agent/agent/lib/scan.ts +0 -153
  16. package/templates/agent/agent/lib/store.ts +0 -151
  17. package/templates/agent/agent/lib/types.ts +0 -63
  18. package/templates/agent/agent/schedules/daily_audit.ts +0 -20
  19. package/templates/agent/agent/tools/check_advisories.ts +0 -27
  20. package/templates/agent/agent/tools/list_targets.ts +0 -21
  21. package/templates/agent/agent/tools/read_repo_file.ts +0 -31
  22. package/templates/agent/agent/tools/report_finding.ts +0 -59
  23. package/templates/agent/agent/tools/run_dependency_scan.ts +0 -16
  24. package/templates/agent/app/_components/ui.tsx +0 -29
  25. package/templates/agent/app/actions.ts +0 -120
  26. package/templates/agent/app/api/scan/route.ts +0 -34
  27. package/templates/agent/app/login/page.tsx +0 -40
  28. package/templates/agent/app/settings/page.tsx +0 -92
  29. package/templates/agent/app/targets/[id]/page.tsx +0 -127
  30. package/templates/agent/proxy.ts +0 -21
@@ -1,35 +1,15 @@
1
1
  import type { Metadata } from "next";
2
- import Link from "next/link";
3
- import { authEnabled } from "@/agent/lib/auth";
4
- import { logout } from "./actions";
5
2
  import "./globals.css";
6
3
 
7
4
  export const metadata: Metadata = {
8
5
  title: "OpenHacker",
9
- description: "Autonomous application security agent",
6
+ description: "Analyze a GitHub repo for vulnerabilities",
10
7
  };
11
8
 
12
9
  export default function RootLayout({ children }: { children: React.ReactNode }) {
13
10
  return (
14
11
  <html lang="en">
15
- <body>
16
- <nav className="nav">
17
- <Link href="/" className="brand">
18
- open<span>hacker</span>
19
- </Link>
20
- <div className="spacer" />
21
- <Link href="/">Dashboard</Link>
22
- <Link href="/settings">Settings</Link>
23
- {authEnabled() ? (
24
- <form action={logout} className="inline">
25
- <button className="btn-ghost" type="submit">
26
- Sign out
27
- </button>
28
- </form>
29
- ) : null}
30
- </nav>
31
- {children}
32
- </body>
12
+ <body>{children}</body>
33
13
  </html>
34
14
  );
35
15
  }
@@ -1,114 +1,92 @@
1
- import Link from "next/link";
2
- import { getStore, isPersistent } from "@/agent/lib/store";
3
- import { SeverityCounts } from "./_components/ui";
4
- import { addTarget, deleteTarget, scanTarget } from "./actions";
1
+ "use client";
5
2
 
6
- export const dynamic = "force-dynamic";
3
+ import { useState } from "react";
4
+ import { useEveAgent } from "eve/react";
7
5
 
8
- export default async function Dashboard({
9
- searchParams,
10
- }: {
11
- searchParams: Promise<{ error?: string }>;
12
- }) {
13
- const { error } = await searchParams;
14
- const store = getStore();
15
- const targets = await store.listTargets();
16
- const findingsByTarget = new Map(
17
- await Promise.all(
18
- targets.map(async (t) => [t.id, await store.listFindings(t.id)] as const),
19
- ),
20
- );
6
+ export default function Home() {
7
+ const [repo, setRepo] = useState("");
8
+ const agent = useEveAgent();
9
+
10
+ const busy = agent.status === "submitted" || agent.status === "streaming";
11
+
12
+ function onSubmit(e: React.FormEvent) {
13
+ e.preventDefault();
14
+ const value = repo.trim();
15
+ if (!value || busy) return;
16
+ agent.reset();
17
+ agent.send({
18
+ message: `Analyze the GitHub repository "${value}" for security vulnerabilities. Walk through what you check and report what you find.`,
19
+ });
20
+ }
21
+
22
+ const reply = [...agent.data.messages]
23
+ .reverse()
24
+ .find((m) => m.role === "assistant");
21
25
 
22
26
  return (
23
27
  <main className="container">
24
- <h1>Targets</h1>
25
- <p className="sub">Repositories OpenHacker continuously scans for vulnerabilities.</p>
28
+ <h1>
29
+ open<span>hacker</span>
30
+ </h1>
31
+ <p className="sub">
32
+ Paste a GitHub repo and the agent will analyze it for vulnerabilities.
33
+ </p>
34
+
35
+ <form className="ask" onSubmit={onSubmit}>
36
+ <input
37
+ type="text"
38
+ value={repo}
39
+ onChange={(e) => setRepo(e.target.value)}
40
+ placeholder="owner/name or https://github.com/owner/name"
41
+ autoFocus
42
+ />
43
+ <button type="submit" disabled={busy || !repo.trim()}>
44
+ {busy ? "Analyzing…" : "Analyze"}
45
+ </button>
46
+ </form>
47
+
48
+ {reply ? (
49
+ <section className="reply">
50
+ {reply.parts.map((part, i) => {
51
+ if (part.type === "reasoning") {
52
+ return (
53
+ <p key={i} className="reasoning">
54
+ {part.text}
55
+ </p>
56
+ );
57
+ }
58
+ if (part.type === "text") {
59
+ return (
60
+ <p key={i} className="text">
61
+ {part.text}
62
+ </p>
63
+ );
64
+ }
65
+ if (part.type === "dynamic-tool") {
66
+ return (
67
+ <div key={i} className="tool">
68
+ <span className="tool-name">{part.toolName}</span>
69
+ <span className="tool-state">{part.state}</span>
70
+ </div>
71
+ );
72
+ }
73
+ return null;
74
+ })}
75
+ {agent.status === "streaming" ? (
76
+ <span className="cursor" aria-hidden />
77
+ ) : null}
78
+ </section>
79
+ ) : busy ? (
80
+ <section className="reply">
81
+ <span className="cursor" aria-hidden />
82
+ </section>
83
+ ) : null}
26
84
 
27
- {!isPersistent() ? (
85
+ {agent.status === "error" ? (
28
86
  <div className="banner">
29
- Using an in-memory store — data will not persist across restarts/deploys. Add a Vercel KV
30
- or Upstash Redis integration and set <code>KV_REST_API_URL</code> /{" "}
31
- <code>KV_REST_API_TOKEN</code> to persist.
87
+ {String(agent.error ?? "Something went wrong.")}
32
88
  </div>
33
89
  ) : null}
34
- {error === "invalid-repo" ? (
35
- <div className="banner">Enter a valid GitHub repository (owner/name or a github.com URL).</div>
36
- ) : null}
37
-
38
- <div className="panel">
39
- <h2 style={{ marginTop: 0 }}>Add a target</h2>
40
- <form action={addTarget}>
41
- <div className="row">
42
- <div>
43
- <label htmlFor="repo">GitHub repository</label>
44
- <input id="repo" name="repo" type="text" placeholder="owner/name or URL" required />
45
- </div>
46
- <div>
47
- <label htmlFor="branch">Branch (optional)</label>
48
- <input id="branch" name="branch" type="text" placeholder="default branch" />
49
- </div>
50
- </div>
51
- <div className="row">
52
- <div>
53
- <label htmlFor="name">Display name (optional)</label>
54
- <input id="name" name="name" type="text" placeholder="My app" />
55
- </div>
56
- <div>
57
- <label htmlFor="token">Access token (optional, for private repos)</label>
58
- <input id="token" name="token" type="password" placeholder="ghp_..." />
59
- </div>
60
- </div>
61
- <div className="check" style={{ marginBottom: 14 }}>
62
- <input id="autoRemediate" name="autoRemediate" type="checkbox" />
63
- <label htmlFor="autoRemediate">Open remediation PRs automatically</label>
64
- </div>
65
- <button type="submit">Add target</button>
66
- </form>
67
- </div>
68
-
69
- <h2>Configured targets</h2>
70
- {targets.length === 0 ? (
71
- <div className="empty">No targets yet. Add a repository above to start scanning.</div>
72
- ) : (
73
- targets.map((t) => {
74
- const findings = findingsByTarget.get(t.id) ?? [];
75
- return (
76
- <div className="card" key={t.id}>
77
- <div className="grow">
78
- <div className="repo">
79
- <Link href={`/targets/${t.id}`}>{t.name}</Link>{" "}
80
- <span className="mono-sm">{t.repo}@{t.branch}</span>
81
- </div>
82
- <div className="meta">
83
- {t.lastScanAt
84
- ? `last scan ${new Date(t.lastScanAt).toLocaleString()}${
85
- t.lastScanStatus === "error" ? ` — error: ${t.lastScanError}` : ""
86
- }`
87
- : "never scanned"}
88
- </div>
89
- <div style={{ marginTop: 8 }}>
90
- <SeverityCounts findings={findings} />
91
- </div>
92
- </div>
93
- <div className="actions">
94
- <form action={scanTarget} className="inline">
95
- <input type="hidden" name="id" value={t.id} />
96
- <button type="submit">Scan now</button>
97
- </form>
98
- <Link className="btn btn-ghost" href={`/targets/${t.id}`}>
99
- View
100
- </Link>
101
- <form action={deleteTarget} className="inline">
102
- <input type="hidden" name="id" value={t.id} />
103
- <button type="submit" className="btn-danger">
104
- Delete
105
- </button>
106
- </form>
107
- </div>
108
- </div>
109
- );
110
- })
111
- )}
112
90
  </main>
113
91
  );
114
92
  }
@@ -13,18 +13,17 @@
13
13
  "eve:info": "eve info"
14
14
  },
15
15
  "dependencies": {
16
- "@upstash/redis": "^1.38.0",
17
16
  "ai": "^7.0.3",
18
17
  "eve": "^0.16.2",
19
18
  "next": "^16.2.9",
20
19
  "react": "^19.2.7",
21
- "react-dom": "^19.2.7",
22
- "zod": "^4.4.3"
20
+ "react-dom": "^19.2.7"
23
21
  },
24
22
  "devDependencies": {
25
23
  "@types/node": "25.5.2",
26
24
  "@types/react": "19.2.14",
27
25
  "@types/react-dom": "^19.2.3",
26
+ "microsandbox": "^0.6.0",
28
27
  "typescript": "6.0.2"
29
28
  }
30
29
  }
@@ -1,23 +0,0 @@
1
- // Web-Crypto only so this module works in both the Edge middleware and Node.
2
- export const SESSION_COOKIE = "oh_session";
3
-
4
- export function adminPassword(): string | null {
5
- return process.env.OPENHACKER_ADMIN_PASSWORD || null;
6
- }
7
-
8
- export function authEnabled(): boolean {
9
- return Boolean(adminPassword());
10
- }
11
-
12
- export async function sessionToken(password: string): Promise<string> {
13
- const data = new TextEncoder().encode(`openhacker:${password}`);
14
- const digest = await globalThis.crypto.subtle.digest("SHA-256", data);
15
- return Array.from(new Uint8Array(digest))
16
- .map((b) => b.toString(16).padStart(2, "0"))
17
- .join("");
18
- }
19
-
20
- export async function expectedToken(): Promise<string | null> {
21
- const password = adminPassword();
22
- return password ? sessionToken(password) : null;
23
- }
@@ -1,74 +0,0 @@
1
- const API = "https://api.github.com";
2
-
3
- function headers(token?: string | null): Record<string, string> {
4
- const h: Record<string, string> = {
5
- accept: "application/vnd.github+json",
6
- "user-agent": "openhacker-agent",
7
- "x-github-api-version": "2022-11-28",
8
- };
9
- if (token) h.authorization = `Bearer ${token}`;
10
- return h;
11
- }
12
-
13
- /** Read a single file's text content. Returns null when the file is absent. */
14
- export async function getFile(
15
- repo: string,
16
- path: string,
17
- ref: string,
18
- token?: string | null,
19
- ): Promise<string | null> {
20
- const url = `${API}/repos/${repo}/contents/${encodeURIComponent(path).replace(/%2F/g, "/")}?ref=${encodeURIComponent(ref)}`;
21
- const res = await fetch(url, { headers: headers(token) });
22
-
23
- if (res.status === 404) return null;
24
- if (!res.ok) {
25
- throw new Error(`GitHub getFile ${repo}/${path}: ${res.status} ${res.statusText}`);
26
- }
27
-
28
- const data = (await res.json()) as { content?: string; encoding?: string };
29
- if (!data.content) return null;
30
- if (data.encoding === "base64") {
31
- return Buffer.from(data.content, "base64").toString("utf8");
32
- }
33
- return data.content;
34
- }
35
-
36
- export type RepoEntry = { path: string; type: "file" | "dir"; size?: number };
37
-
38
- /** List the entries directly under a directory path ("" for the repo root). */
39
- export async function listDir(
40
- repo: string,
41
- path: string,
42
- ref: string,
43
- token?: string | null,
44
- ): Promise<RepoEntry[]> {
45
- const clean = path.replace(/^\/+|\/+$/g, "");
46
- const url = `${API}/repos/${repo}/contents/${clean ? `${clean}` : ""}?ref=${encodeURIComponent(ref)}`;
47
- const res = await fetch(url, { headers: headers(token) });
48
-
49
- if (res.status === 404) return [];
50
- if (!res.ok) {
51
- throw new Error(`GitHub listDir ${repo}/${path}: ${res.status} ${res.statusText}`);
52
- }
53
-
54
- const data = (await res.json()) as Array<{ path: string; type: string; size?: number }>;
55
- if (!Array.isArray(data)) return [];
56
- return data.map((e) => ({
57
- path: e.path,
58
- type: e.type === "dir" ? "dir" : "file",
59
- size: e.size,
60
- }));
61
- }
62
-
63
- /** Confirm the repo is reachable with the given (optional) token. */
64
- export async function checkRepoAccess(
65
- repo: string,
66
- token?: string | null,
67
- ): Promise<{ ok: boolean; private?: boolean; defaultBranch?: string; error?: string }> {
68
- const res = await fetch(`${API}/repos/${repo}`, { headers: headers(token) });
69
- if (!res.ok) {
70
- return { ok: false, error: `${res.status} ${res.statusText}` };
71
- }
72
- const data = (await res.json()) as { private?: boolean; default_branch?: string };
73
- return { ok: true, private: data.private, defaultBranch: data.default_branch };
74
- }
@@ -1,152 +0,0 @@
1
- export type OsvEcosystem =
2
- | "npm"
3
- | "PyPI"
4
- | "Go"
5
- | "crates.io"
6
- | "Maven"
7
- | "RubyGems"
8
- | "NuGet";
9
-
10
- export type Severity = "critical" | "high" | "medium" | "low";
11
-
12
- export type OsvAdvisory = {
13
- id: string;
14
- aliases: string[];
15
- summary: string | null;
16
- cvssVector: string | null;
17
- qualitative: string | null;
18
- fixedIn: string[];
19
- references: string[];
20
- };
21
-
22
- type OsvRawVuln = {
23
- id: string;
24
- aliases?: string[];
25
- summary?: string;
26
- severity?: Array<{ type: string; score: string }>;
27
- database_specific?: { severity?: string };
28
- affected?: Array<{ ranges?: Array<{ events?: Array<Record<string, string>> }> }>;
29
- references?: Array<{ type: string; url: string }>;
30
- };
31
-
32
- export async function queryOsv(
33
- name: string,
34
- version: string | undefined,
35
- ecosystem: OsvEcosystem = "npm",
36
- ): Promise<OsvAdvisory[]> {
37
- const body: Record<string, unknown> = { package: { name, ecosystem } };
38
- if (version) body.version = version;
39
-
40
- const res = await fetch("https://api.osv.dev/v1/query", {
41
- method: "POST",
42
- headers: { "content-type": "application/json" },
43
- body: JSON.stringify(body),
44
- });
45
-
46
- if (!res.ok) {
47
- throw new Error(`OSV query failed for ${name}: ${res.status} ${res.statusText}`);
48
- }
49
-
50
- const data = (await res.json()) as { vulns?: OsvRawVuln[] };
51
-
52
- return (data.vulns ?? []).map((v) => {
53
- const fixedIn = [
54
- ...new Set(
55
- (v.affected ?? [])
56
- .flatMap((a) => a.ranges ?? [])
57
- .flatMap((r) => r.events ?? [])
58
- .map((e) => e.fixed)
59
- .filter((x): x is string => Boolean(x)),
60
- ),
61
- ];
62
-
63
- const cvss = (v.severity ?? []).find((s) => /^CVSS_V3/.test(s.type)) ?? v.severity?.[0];
64
-
65
- return {
66
- id: v.id,
67
- aliases: v.aliases ?? [],
68
- summary: v.summary ?? null,
69
- cvssVector: cvss?.score ?? null,
70
- qualitative: v.database_specific?.severity ?? null,
71
- fixedIn,
72
- references: (v.references ?? []).map((r) => r.url).slice(0, 5),
73
- };
74
- });
75
- }
76
-
77
- function bucket(score: number): Severity {
78
- if (score >= 9) return "critical";
79
- if (score >= 7) return "high";
80
- if (score >= 4) return "medium";
81
- return "low";
82
- }
83
-
84
- const QUALITATIVE: Record<string, Severity> = {
85
- LOW: "low",
86
- MODERATE: "medium",
87
- MEDIUM: "medium",
88
- HIGH: "high",
89
- CRITICAL: "critical",
90
- };
91
-
92
- /** Severity from OSV: prefer the computed CVSS v3 base score, then the GHSA rating. */
93
- export function severityFromOsv(advisory: OsvAdvisory): Severity {
94
- if (advisory.cvssVector) {
95
- const score = cvss3BaseScore(advisory.cvssVector);
96
- if (score != null) return bucket(score);
97
- }
98
- if (advisory.qualitative) {
99
- const mapped = QUALITATIVE[advisory.qualitative.toUpperCase()];
100
- if (mapped) return mapped;
101
- }
102
- return "medium";
103
- }
104
-
105
- // --- Minimal CVSS v3.x base score calculator -------------------------------
106
-
107
- const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 } as const;
108
- const AC = { L: 0.77, H: 0.44 } as const;
109
- const UI = { N: 0.85, R: 0.62 } as const;
110
- const IMPACT = { H: 0.56, L: 0.22, N: 0 } as const;
111
-
112
- function roundUp(n: number): number {
113
- return Math.ceil(n * 10) / 10;
114
- }
115
-
116
- /** Returns the CVSS v3.x base score for a vector string, or null if unparsable. */
117
- export function cvss3BaseScore(vector: string): number | null {
118
- if (!/^CVSS:3/.test(vector)) return null;
119
- const parts = Object.fromEntries(
120
- vector
121
- .split("/")
122
- .slice(1)
123
- .map((p) => p.split(":") as [string, string]),
124
- );
125
-
126
- const scopeChanged = parts.S === "C";
127
- const prMap = scopeChanged
128
- ? { N: 0.85, L: 0.68, H: 0.5 }
129
- : { N: 0.85, L: 0.62, H: 0.27 };
130
-
131
- const av = AV[parts.AV as keyof typeof AV];
132
- const ac = AC[parts.AC as keyof typeof AC];
133
- const pr = prMap[parts.PR as keyof typeof prMap];
134
- const ui = UI[parts.UI as keyof typeof UI];
135
- const c = IMPACT[parts.C as keyof typeof IMPACT];
136
- const i = IMPACT[parts.I as keyof typeof IMPACT];
137
- const a = IMPACT[parts.A as keyof typeof IMPACT];
138
-
139
- if ([av, ac, pr, ui, c, i, a].some((x) => x == null)) return null;
140
-
141
- const iscBase = 1 - (1 - c) * (1 - i) * (1 - a);
142
- const impact = scopeChanged
143
- ? 7.52 * (iscBase - 0.029) - 3.25 * (iscBase - 0.02) ** 15
144
- : 6.42 * iscBase;
145
- const exploitability = 8.22 * av * ac * pr * ui;
146
-
147
- if (impact <= 0) return 0;
148
- const raw = scopeChanged
149
- ? 1.08 * (impact + exploitability)
150
- : impact + exploitability;
151
- return roundUp(Math.min(raw, 10));
152
- }
@@ -1,153 +0,0 @@
1
- import { createHash } from "node:crypto";
2
- import { getFile } from "./github";
3
- import { queryOsv, severityFromOsv } from "./osv";
4
- import { getStore } from "./store";
5
- import type { Finding } from "./types";
6
-
7
- export type Dependency = { name: string; version: string };
8
-
9
- /** Best-effort extraction of resolved dependencies from common manifests/lockfiles. */
10
- export function extractDependencies(files: {
11
- packageJson?: string | null;
12
- packageLock?: string | null;
13
- pnpmLock?: string | null;
14
- }): Dependency[] {
15
- const found = new Map<string, string>();
16
-
17
- // 1. npm lockfile (exact resolved versions).
18
- if (files.packageLock) {
19
- try {
20
- const lock = JSON.parse(files.packageLock) as {
21
- packages?: Record<string, { version?: string }>;
22
- dependencies?: Record<string, { version?: string }>;
23
- };
24
- for (const [path, meta] of Object.entries(lock.packages ?? {})) {
25
- if (!path.startsWith("node_modules/") || !meta.version) continue;
26
- const name = path.slice(path.lastIndexOf("node_modules/") + "node_modules/".length);
27
- found.set(name, meta.version);
28
- }
29
- for (const [name, meta] of Object.entries(lock.dependencies ?? {})) {
30
- if (meta.version && !found.has(name)) found.set(name, meta.version);
31
- }
32
- } catch {
33
- // ignore malformed lockfile
34
- }
35
- }
36
-
37
- // 2. pnpm lockfile (regex extraction of resolved package keys).
38
- if (files.pnpmLock) {
39
- const re = /^\s{2,}\/?((?:@[^/@\s]+\/)?[^@/\s]+)@(\d+\.\d+\.\d+[^\s:('"]*)/gm;
40
- let m: RegExpExecArray | null;
41
- while ((m = re.exec(files.pnpmLock))) {
42
- const [, name, version] = m;
43
- if (!found.has(name)) found.set(name, version);
44
- }
45
- }
46
-
47
- // 3. Fall back to package.json ranges (approximate exact version).
48
- if (files.packageJson) {
49
- try {
50
- const pkg = JSON.parse(files.packageJson) as {
51
- dependencies?: Record<string, string>;
52
- devDependencies?: Record<string, string>;
53
- };
54
- const ranges = { ...pkg.devDependencies, ...pkg.dependencies };
55
- for (const [name, range] of Object.entries(ranges)) {
56
- if (found.has(name)) continue;
57
- const cleaned = range.match(/\d+\.\d+\.\d+[^\s]*/)?.[0];
58
- if (cleaned) found.set(name, cleaned);
59
- }
60
- } catch {
61
- // ignore malformed manifest
62
- }
63
- }
64
-
65
- return [...found.entries()].map(([name, version]) => ({ name, version }));
66
- }
67
-
68
- function fingerprint(targetId: string, pkg: string, advisoryId: string): string {
69
- return createHash("sha256").update(`${targetId}::${pkg}::${advisoryId}`).digest("hex").slice(0, 16);
70
- }
71
-
72
- export type ScanResult = {
73
- ok: boolean;
74
- dependenciesChecked: number;
75
- findings: number;
76
- error?: string;
77
- };
78
-
79
- /**
80
- * Run a dependency vulnerability scan of a target against OSV and persist findings.
81
- * Deterministic and model-free; the eve agent layers reachability reasoning on top.
82
- */
83
- export async function runScan(targetId: string): Promise<ScanResult> {
84
- const store = getStore();
85
- const target = await store.getTarget(targetId);
86
- if (!target) return { ok: false, dependenciesChecked: 0, findings: 0, error: "Target not found" };
87
-
88
- const token = await store.getTargetToken(targetId);
89
- const now = new Date().toISOString();
90
-
91
- try {
92
- const [packageJson, packageLock, pnpmLock] = await Promise.all([
93
- getFile(target.repo, "package.json", target.branch, token),
94
- getFile(target.repo, "package-lock.json", target.branch, token),
95
- getFile(target.repo, "pnpm-lock.yaml", target.branch, token),
96
- ]);
97
-
98
- if (!packageJson && !packageLock && !pnpmLock) {
99
- throw new Error("No package.json or lockfile found at the repo root");
100
- }
101
-
102
- const deps = extractDependencies({ packageJson, packageLock, pnpmLock });
103
- const existing = await store.listFindings(targetId);
104
- const existingById = new Map(existing.map((f) => [f.id, f]));
105
- const findings: Finding[] = [];
106
-
107
- const results = await Promise.allSettled(
108
- deps.map(async (dep) => ({ dep, advisories: await queryOsv(dep.name, dep.version) })),
109
- );
110
-
111
- for (const r of results) {
112
- if (r.status !== "fulfilled") continue;
113
- const { dep, advisories } = r.value;
114
- for (const adv of advisories) {
115
- const id = fingerprint(targetId, dep.name, adv.id);
116
- const prior = existingById.get(id);
117
- const aliases = adv.aliases.filter((a) => a.startsWith("CVE-"));
118
- findings.push({
119
- id,
120
- targetId,
121
- title: `${dep.name}@${dep.version}: ${adv.aliases[0] ?? adv.id}`,
122
- severity: severityFromOsv(adv),
123
- category: "dependency",
124
- packageName: dep.name,
125
- installedVersion: dep.version,
126
- advisoryIds: [adv.id, ...aliases],
127
- proof: {
128
- status: "likely",
129
- evidence:
130
- `Installed version ${dep.version} of ${dep.name} is in the affected range of ` +
131
- `${adv.id}${adv.summary ? `: ${adv.summary}` : ""}.`,
132
- },
133
- remediation: adv.fixedIn.length
134
- ? { summary: `Upgrade ${dep.name} to ${adv.fixedIn[0]} or later.`, fixedVersion: adv.fixedIn[0] }
135
- : { summary: `Review advisory ${adv.id} and upgrade ${dep.name}.` },
136
- status: prior?.status ?? "open",
137
- references: adv.references,
138
- firstSeen: prior?.firstSeen ?? now,
139
- lastSeen: now,
140
- });
141
- }
142
- }
143
-
144
- await store.replaceTargetFindings(targetId, findings);
145
- await store.saveTarget({ ...target, lastScanAt: now, lastScanStatus: "ok", lastScanError: undefined });
146
-
147
- return { ok: true, dependenciesChecked: deps.length, findings: findings.length };
148
- } catch (err) {
149
- const message = err instanceof Error ? err.message : String(err);
150
- await store.saveTarget({ ...target, lastScanAt: now, lastScanStatus: "error", lastScanError: message });
151
- return { ok: false, dependenciesChecked: 0, findings: 0, error: message };
152
- }
153
- }