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.
- package/package.json +1 -1
- package/src/cli.js +151 -28
- package/templates/agent/.env.example +0 -7
- package/templates/agent/README.md +1 -2
- package/templates/agent/agent/agent.ts +1 -5
- package/templates/agent/agent/channels/eve.ts +7 -0
- package/templates/agent/agent/instructions.md +7 -45
- package/templates/agent/app/globals.css +65 -197
- package/templates/agent/app/layout.tsx +2 -22
- package/templates/agent/app/page.tsx +80 -102
- package/templates/agent/package.json +2 -3
- package/templates/agent/agent/lib/auth.ts +0 -23
- package/templates/agent/agent/lib/github.ts +0 -74
- package/templates/agent/agent/lib/osv.ts +0 -152
- package/templates/agent/agent/lib/scan.ts +0 -153
- package/templates/agent/agent/lib/store.ts +0 -151
- package/templates/agent/agent/lib/types.ts +0 -63
- package/templates/agent/agent/schedules/daily_audit.ts +0 -20
- package/templates/agent/agent/tools/check_advisories.ts +0 -27
- package/templates/agent/agent/tools/list_targets.ts +0 -21
- package/templates/agent/agent/tools/read_repo_file.ts +0 -31
- package/templates/agent/agent/tools/report_finding.ts +0 -59
- package/templates/agent/agent/tools/run_dependency_scan.ts +0 -16
- package/templates/agent/app/_components/ui.tsx +0 -29
- package/templates/agent/app/actions.ts +0 -120
- package/templates/agent/app/api/scan/route.ts +0 -34
- package/templates/agent/app/login/page.tsx +0 -40
- package/templates/agent/app/settings/page.tsx +0 -92
- package/templates/agent/app/targets/[id]/page.tsx +0 -127
- 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: "
|
|
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
|
-
|
|
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
|
-
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useEveAgent } from "eve/react";
|
|
7
5
|
|
|
8
|
-
export default
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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>
|
|
25
|
-
|
|
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
|
-
{
|
|
85
|
+
{agent.status === "error" ? (
|
|
28
86
|
<div className="banner">
|
|
29
|
-
|
|
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
|
-
}
|