openhacker 0.0.0 → 0.1.0
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/README.md +12 -1
- package/bin/openhacker +4 -0
- package/package.json +29 -3
- package/scripts/clean-template.js +8 -0
- package/scripts/sync-template.js +53 -0
- package/src/cli.js +153 -0
- package/src/index.js +1 -0
- package/src/index.ts +1 -0
- package/templates/agent/.env.example +20 -0
- package/templates/agent/README.md +35 -0
- package/templates/agent/agent/agent.ts +9 -0
- package/templates/agent/agent/instructions.md +49 -0
- package/templates/agent/agent/lib/auth.ts +23 -0
- package/templates/agent/agent/lib/github.ts +74 -0
- package/templates/agent/agent/lib/osv.ts +152 -0
- package/templates/agent/agent/lib/scan.ts +153 -0
- package/templates/agent/agent/lib/store.ts +151 -0
- package/templates/agent/agent/lib/types.ts +63 -0
- package/templates/agent/agent/schedules/daily_audit.ts +20 -0
- package/templates/agent/agent/tools/check_advisories.ts +27 -0
- package/templates/agent/agent/tools/list_targets.ts +21 -0
- package/templates/agent/agent/tools/read_repo_file.ts +31 -0
- package/templates/agent/agent/tools/report_finding.ts +59 -0
- package/templates/agent/agent/tools/run_dependency_scan.ts +16 -0
- package/templates/agent/app/_components/ui.tsx +29 -0
- package/templates/agent/app/actions.ts +120 -0
- package/templates/agent/app/api/scan/route.ts +34 -0
- package/templates/agent/app/globals.css +280 -0
- package/templates/agent/app/layout.tsx +35 -0
- package/templates/agent/app/login/page.tsx +40 -0
- package/templates/agent/app/page.tsx +114 -0
- package/templates/agent/app/settings/page.tsx +92 -0
- package/templates/agent/app/targets/[id]/page.tsx +127 -0
- package/templates/agent/next.config.ts +8 -0
- package/templates/agent/package.json +30 -0
- package/templates/agent/proxy.ts +21 -0
- package/templates/agent/tsconfig.json +43 -0
|
@@ -0,0 +1,153 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { Redis } from "@upstash/redis";
|
|
2
|
+
import { DEFAULT_SETTINGS, type Finding, type Settings, type Target } from "./types";
|
|
3
|
+
|
|
4
|
+
export interface Store {
|
|
5
|
+
listTargets(): Promise<Target[]>;
|
|
6
|
+
getTarget(id: string): Promise<Target | null>;
|
|
7
|
+
saveTarget(target: Target): Promise<void>;
|
|
8
|
+
deleteTarget(id: string): Promise<void>;
|
|
9
|
+
|
|
10
|
+
getTargetToken(id: string): Promise<string | null>;
|
|
11
|
+
setTargetToken(id: string, token: string | null): Promise<void>;
|
|
12
|
+
|
|
13
|
+
listFindings(targetId?: string): Promise<Finding[]>;
|
|
14
|
+
replaceTargetFindings(targetId: string, findings: Finding[]): Promise<void>;
|
|
15
|
+
upsertFinding(finding: Finding): Promise<void>;
|
|
16
|
+
|
|
17
|
+
getSettings(): Promise<Settings>;
|
|
18
|
+
saveSettings(settings: Settings): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const K = {
|
|
22
|
+
targets: "oh:targets",
|
|
23
|
+
token: (id: string) => `oh:token:${id}`,
|
|
24
|
+
findings: (id: string) => `oh:findings:${id}`,
|
|
25
|
+
settings: "oh:settings",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
class RedisStore implements Store {
|
|
29
|
+
constructor(private redis: Redis) {}
|
|
30
|
+
|
|
31
|
+
async listTargets(): Promise<Target[]> {
|
|
32
|
+
const all = (await this.redis.hgetall<Record<string, Target>>(K.targets)) ?? {};
|
|
33
|
+
return Object.values(all).sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
34
|
+
}
|
|
35
|
+
async getTarget(id: string): Promise<Target | null> {
|
|
36
|
+
return (await this.redis.hget<Target>(K.targets, id)) ?? null;
|
|
37
|
+
}
|
|
38
|
+
async saveTarget(target: Target): Promise<void> {
|
|
39
|
+
await this.redis.hset(K.targets, { [target.id]: target });
|
|
40
|
+
}
|
|
41
|
+
async deleteTarget(id: string): Promise<void> {
|
|
42
|
+
await this.redis.hdel(K.targets, id);
|
|
43
|
+
await this.redis.del(K.token(id), K.findings(id));
|
|
44
|
+
}
|
|
45
|
+
async getTargetToken(id: string): Promise<string | null> {
|
|
46
|
+
return (await this.redis.get<string>(K.token(id))) ?? null;
|
|
47
|
+
}
|
|
48
|
+
async setTargetToken(id: string, token: string | null): Promise<void> {
|
|
49
|
+
if (token) await this.redis.set(K.token(id), token);
|
|
50
|
+
else await this.redis.del(K.token(id));
|
|
51
|
+
}
|
|
52
|
+
async listFindings(targetId?: string): Promise<Finding[]> {
|
|
53
|
+
if (targetId) return (await this.redis.get<Finding[]>(K.findings(targetId))) ?? [];
|
|
54
|
+
const targets = await this.listTargets();
|
|
55
|
+
const lists = await Promise.all(targets.map((t) => this.listFindings(t.id)));
|
|
56
|
+
return lists.flat();
|
|
57
|
+
}
|
|
58
|
+
async replaceTargetFindings(targetId: string, findings: Finding[]): Promise<void> {
|
|
59
|
+
await this.redis.set(K.findings(targetId), findings);
|
|
60
|
+
}
|
|
61
|
+
async upsertFinding(finding: Finding): Promise<void> {
|
|
62
|
+
const existing = await this.listFindings(finding.targetId);
|
|
63
|
+
const next = existing.filter((f) => f.id !== finding.id);
|
|
64
|
+
next.push(finding);
|
|
65
|
+
await this.replaceTargetFindings(finding.targetId, next);
|
|
66
|
+
}
|
|
67
|
+
async getSettings(): Promise<Settings> {
|
|
68
|
+
return (await this.redis.get<Settings>(K.settings)) ?? DEFAULT_SETTINGS;
|
|
69
|
+
}
|
|
70
|
+
async saveSettings(settings: Settings): Promise<void> {
|
|
71
|
+
await this.redis.set(K.settings, settings);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Process-local fallback so the app boots and is testable without a KV store.
|
|
76
|
+
// Not durable across serverless invocations — configure Redis/KV for production.
|
|
77
|
+
const mem = {
|
|
78
|
+
targets: new Map<string, Target>(),
|
|
79
|
+
tokens: new Map<string, string>(),
|
|
80
|
+
findings: new Map<string, Finding[]>(),
|
|
81
|
+
settings: null as Settings | null,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
class MemoryStore implements Store {
|
|
85
|
+
async listTargets(): Promise<Target[]> {
|
|
86
|
+
return [...mem.targets.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
87
|
+
}
|
|
88
|
+
async getTarget(id: string): Promise<Target | null> {
|
|
89
|
+
return mem.targets.get(id) ?? null;
|
|
90
|
+
}
|
|
91
|
+
async saveTarget(target: Target): Promise<void> {
|
|
92
|
+
mem.targets.set(target.id, target);
|
|
93
|
+
}
|
|
94
|
+
async deleteTarget(id: string): Promise<void> {
|
|
95
|
+
mem.targets.delete(id);
|
|
96
|
+
mem.tokens.delete(id);
|
|
97
|
+
mem.findings.delete(id);
|
|
98
|
+
}
|
|
99
|
+
async getTargetToken(id: string): Promise<string | null> {
|
|
100
|
+
return mem.tokens.get(id) ?? null;
|
|
101
|
+
}
|
|
102
|
+
async setTargetToken(id: string, token: string | null): Promise<void> {
|
|
103
|
+
if (token) mem.tokens.set(id, token);
|
|
104
|
+
else mem.tokens.delete(id);
|
|
105
|
+
}
|
|
106
|
+
async listFindings(targetId?: string): Promise<Finding[]> {
|
|
107
|
+
if (targetId) return mem.findings.get(targetId) ?? [];
|
|
108
|
+
return [...mem.findings.values()].flat();
|
|
109
|
+
}
|
|
110
|
+
async replaceTargetFindings(targetId: string, findings: Finding[]): Promise<void> {
|
|
111
|
+
mem.findings.set(targetId, findings);
|
|
112
|
+
}
|
|
113
|
+
async upsertFinding(finding: Finding): Promise<void> {
|
|
114
|
+
const existing = mem.findings.get(finding.targetId) ?? [];
|
|
115
|
+
mem.findings.set(finding.targetId, [...existing.filter((f) => f.id !== finding.id), finding]);
|
|
116
|
+
}
|
|
117
|
+
async getSettings(): Promise<Settings> {
|
|
118
|
+
return mem.settings ?? DEFAULT_SETTINGS;
|
|
119
|
+
}
|
|
120
|
+
async saveSettings(settings: Settings): Promise<void> {
|
|
121
|
+
mem.settings = settings;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let store: Store | null = null;
|
|
126
|
+
|
|
127
|
+
export function getStore(): Store {
|
|
128
|
+
if (store) return store;
|
|
129
|
+
|
|
130
|
+
const url = process.env.KV_REST_API_URL ?? process.env.UPSTASH_REDIS_REST_URL;
|
|
131
|
+
const token = process.env.KV_REST_API_TOKEN ?? process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
132
|
+
|
|
133
|
+
if (url && token) {
|
|
134
|
+
store = new RedisStore(new Redis({ url, token }));
|
|
135
|
+
} else {
|
|
136
|
+
if (process.env.NODE_ENV === "production") {
|
|
137
|
+
console.warn(
|
|
138
|
+
"[openhacker] No KV/Redis env configured — using in-memory store. " +
|
|
139
|
+
"Data will NOT persist. Add a Vercel KV / Upstash Redis integration.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
store = new MemoryStore();
|
|
143
|
+
}
|
|
144
|
+
return store;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function isPersistent(): boolean {
|
|
148
|
+
const url = process.env.KV_REST_API_URL ?? process.env.UPSTASH_REDIS_REST_URL;
|
|
149
|
+
const token = process.env.KV_REST_API_TOKEN ?? process.env.UPSTASH_REDIS_REST_TOKEN;
|
|
150
|
+
return Boolean(url && token);
|
|
151
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export type Severity = "critical" | "high" | "medium" | "low" | "info";
|
|
2
|
+
|
|
3
|
+
export type FindingCategory =
|
|
4
|
+
| "dependency"
|
|
5
|
+
| "injection"
|
|
6
|
+
| "authz"
|
|
7
|
+
| "ssrf"
|
|
8
|
+
| "secrets"
|
|
9
|
+
| "xss"
|
|
10
|
+
| "deserialization"
|
|
11
|
+
| "other";
|
|
12
|
+
|
|
13
|
+
export type Target = {
|
|
14
|
+
id: string;
|
|
15
|
+
name: string;
|
|
16
|
+
/** "owner/name" on GitHub. */
|
|
17
|
+
repo: string;
|
|
18
|
+
branch: string;
|
|
19
|
+
provider: "github";
|
|
20
|
+
hasToken: boolean;
|
|
21
|
+
autoRemediate: boolean;
|
|
22
|
+
createdAt: string;
|
|
23
|
+
lastScanAt?: string;
|
|
24
|
+
lastScanStatus?: "ok" | "error";
|
|
25
|
+
lastScanError?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type Finding = {
|
|
29
|
+
id: string;
|
|
30
|
+
targetId: string;
|
|
31
|
+
title: string;
|
|
32
|
+
severity: Severity;
|
|
33
|
+
category: FindingCategory;
|
|
34
|
+
packageName?: string;
|
|
35
|
+
installedVersion?: string;
|
|
36
|
+
advisoryIds?: string[];
|
|
37
|
+
location?: { file: string; startLine?: number; endLine?: number; symbol?: string };
|
|
38
|
+
proof: { status: "proven" | "likely" | "unconfirmed"; evidence?: string; poc?: string };
|
|
39
|
+
remediation?: { summary: string; fixedVersion?: string; prUrl?: string };
|
|
40
|
+
status: "open" | "triaged" | "fixed" | "ignored" | "false_positive";
|
|
41
|
+
references?: string[];
|
|
42
|
+
firstSeen: string;
|
|
43
|
+
lastSeen: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type Settings = {
|
|
47
|
+
/** Gateway model id used by the eve agent for deep analysis. */
|
|
48
|
+
model: string;
|
|
49
|
+
autoRemediate: boolean;
|
|
50
|
+
integrations: {
|
|
51
|
+
github: { connected: boolean };
|
|
52
|
+
hackerone: { connected: boolean; handle?: string };
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const DEFAULT_SETTINGS: Settings = {
|
|
57
|
+
model: "anthropic/claude-sonnet-4.6",
|
|
58
|
+
autoRemediate: false,
|
|
59
|
+
integrations: {
|
|
60
|
+
github: { connected: false },
|
|
61
|
+
hackerone: { connected: false },
|
|
62
|
+
},
|
|
63
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineSchedule } from "eve/schedules";
|
|
2
|
+
import { runScan } from "../lib/scan";
|
|
3
|
+
import { getStore } from "../lib/store";
|
|
4
|
+
|
|
5
|
+
// Becomes a Vercel Cron Job on deploy. Vercel evaluates cron in UTC.
|
|
6
|
+
// Deterministically re-scans every configured target's dependencies against OSV,
|
|
7
|
+
// so newly disclosed advisories are caught even when the code has not changed.
|
|
8
|
+
export default defineSchedule({
|
|
9
|
+
cron: "0 6 * * *",
|
|
10
|
+
run({ waitUntil }) {
|
|
11
|
+
waitUntil(
|
|
12
|
+
(async () => {
|
|
13
|
+
const targets = await getStore().listTargets();
|
|
14
|
+
for (const target of targets) {
|
|
15
|
+
await runScan(target.id);
|
|
16
|
+
}
|
|
17
|
+
})(),
|
|
18
|
+
);
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { queryOsv } from "../lib/osv";
|
|
4
|
+
|
|
5
|
+
export default defineTool({
|
|
6
|
+
description:
|
|
7
|
+
"Check a package against the OSV.dev vulnerability database. Returns known " +
|
|
8
|
+
"advisories (CVE/GHSA) affecting the given version. Use this to confirm whether " +
|
|
9
|
+
"a dependency's INSTALLED version is actually vulnerable before reporting it.",
|
|
10
|
+
inputSchema: z.object({
|
|
11
|
+
name: z.string().min(1).describe("Package name, e.g. 'next' or 'lodash'."),
|
|
12
|
+
version: z.string().optional().describe("Installed version, e.g. '14.1.0'."),
|
|
13
|
+
ecosystem: z
|
|
14
|
+
.enum(["npm", "PyPI", "Go", "crates.io", "Maven", "RubyGems", "NuGet"])
|
|
15
|
+
.default("npm"),
|
|
16
|
+
}),
|
|
17
|
+
async execute({ name, version, ecosystem }) {
|
|
18
|
+
const advisories = await queryOsv(name, version, ecosystem);
|
|
19
|
+
return {
|
|
20
|
+
package: name,
|
|
21
|
+
version: version ?? null,
|
|
22
|
+
ecosystem,
|
|
23
|
+
advisoryCount: advisories.length,
|
|
24
|
+
advisories,
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getStore } from "../lib/store";
|
|
4
|
+
|
|
5
|
+
export default defineTool({
|
|
6
|
+
description: "List the repositories (targets) configured for scanning in this OpenHacker instance.",
|
|
7
|
+
inputSchema: z.object({}),
|
|
8
|
+
async execute() {
|
|
9
|
+
const targets = await getStore().listTargets();
|
|
10
|
+
return {
|
|
11
|
+
targets: targets.map((t) => ({
|
|
12
|
+
id: t.id,
|
|
13
|
+
name: t.name,
|
|
14
|
+
repo: t.repo,
|
|
15
|
+
branch: t.branch,
|
|
16
|
+
autoRemediate: t.autoRemediate,
|
|
17
|
+
lastScanAt: t.lastScanAt ?? null,
|
|
18
|
+
})),
|
|
19
|
+
};
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { getFile, listDir } from "../lib/github";
|
|
4
|
+
import { getStore } from "../lib/store";
|
|
5
|
+
|
|
6
|
+
export default defineTool({
|
|
7
|
+
description:
|
|
8
|
+
"Read a file or list a directory from a target's repository, for code-level " +
|
|
9
|
+
"vulnerability analysis. Use directory listings to find route handlers, server " +
|
|
10
|
+
"actions, and other trust boundaries, then read those files.",
|
|
11
|
+
inputSchema: z.object({
|
|
12
|
+
targetId: z.string(),
|
|
13
|
+
path: z.string().default("").describe("Repo-relative path. Empty string lists the repo root."),
|
|
14
|
+
mode: z.enum(["file", "list"]).default("file"),
|
|
15
|
+
}),
|
|
16
|
+
async execute({ targetId, path, mode }) {
|
|
17
|
+
const store = getStore();
|
|
18
|
+
const target = await store.getTarget(targetId);
|
|
19
|
+
if (!target) return { ok: false as const, error: "Target not found" };
|
|
20
|
+
const token = await store.getTargetToken(targetId);
|
|
21
|
+
|
|
22
|
+
if (mode === "list") {
|
|
23
|
+
const entries = await listDir(target.repo, path, target.branch, token);
|
|
24
|
+
return { ok: true as const, mode, path, entries };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const content = await getFile(target.repo, path, target.branch, token);
|
|
28
|
+
if (content == null) return { ok: false as const, error: `File not found: ${path}` };
|
|
29
|
+
return { ok: true as const, mode, path, content: content.slice(0, 60_000) };
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { defineTool } from "eve/tools";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { getStore } from "../lib/store";
|
|
5
|
+
import type { Finding } from "../lib/types";
|
|
6
|
+
|
|
7
|
+
export default defineTool({
|
|
8
|
+
description:
|
|
9
|
+
"Persist a confirmed code-level vulnerability for a target. Only call this when " +
|
|
10
|
+
"you have evidence the issue applies to the target's code. Be honest about proof status. " +
|
|
11
|
+
"Dependency advisories are recorded by run_dependency_scan; use this for code findings.",
|
|
12
|
+
inputSchema: z.object({
|
|
13
|
+
targetId: z.string().describe("The target this finding belongs to."),
|
|
14
|
+
title: z.string().min(1),
|
|
15
|
+
severity: z.enum(["critical", "high", "medium", "low", "info"]),
|
|
16
|
+
category: z.enum(["injection", "authz", "ssrf", "secrets", "xss", "deserialization", "other"]),
|
|
17
|
+
location: z
|
|
18
|
+
.object({
|
|
19
|
+
file: z.string(),
|
|
20
|
+
startLine: z.number().int().optional(),
|
|
21
|
+
endLine: z.number().int().optional(),
|
|
22
|
+
symbol: z.string().optional(),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
proof: z.object({
|
|
26
|
+
status: z.enum(["proven", "likely", "unconfirmed"]),
|
|
27
|
+
poc: z.string().optional(),
|
|
28
|
+
evidence: z.string().optional(),
|
|
29
|
+
}),
|
|
30
|
+
remediation: z.object({ summary: z.string(), fixedVersion: z.string().optional() }).optional(),
|
|
31
|
+
}),
|
|
32
|
+
async execute(input) {
|
|
33
|
+
const id = createHash("sha256")
|
|
34
|
+
.update(`${input.targetId}::${input.category}::${input.location?.file ?? ""}::${input.title}`.toLowerCase())
|
|
35
|
+
.digest("hex")
|
|
36
|
+
.slice(0, 16);
|
|
37
|
+
|
|
38
|
+
const now = new Date().toISOString();
|
|
39
|
+
const store = getStore();
|
|
40
|
+
const existing = (await store.listFindings(input.targetId)).find((f) => f.id === id);
|
|
41
|
+
|
|
42
|
+
const finding: Finding = {
|
|
43
|
+
id,
|
|
44
|
+
targetId: input.targetId,
|
|
45
|
+
title: input.title,
|
|
46
|
+
severity: input.severity,
|
|
47
|
+
category: input.category,
|
|
48
|
+
location: input.location,
|
|
49
|
+
proof: input.proof,
|
|
50
|
+
remediation: input.remediation,
|
|
51
|
+
status: existing?.status ?? "open",
|
|
52
|
+
firstSeen: existing?.firstSeen ?? now,
|
|
53
|
+
lastSeen: now,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
await store.upsertFinding(finding);
|
|
57
|
+
return { id, recorded: true };
|
|
58
|
+
},
|
|
59
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineTool } from "eve/tools";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { runScan } from "../lib/scan";
|
|
4
|
+
|
|
5
|
+
export default defineTool({
|
|
6
|
+
description:
|
|
7
|
+
"Run the deterministic dependency vulnerability scan for a target: fetches its " +
|
|
8
|
+
"manifest/lockfile, checks every dependency against OSV, and persists findings. " +
|
|
9
|
+
"Run this first, then reason about which findings are actually reachable.",
|
|
10
|
+
inputSchema: z.object({
|
|
11
|
+
targetId: z.string().describe("The target to scan."),
|
|
12
|
+
}),
|
|
13
|
+
async execute({ targetId }) {
|
|
14
|
+
return runScan(targetId);
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Finding, Severity } from "@/agent/lib/types";
|
|
2
|
+
|
|
3
|
+
const ORDER: Severity[] = ["critical", "high", "medium", "low", "info"];
|
|
4
|
+
|
|
5
|
+
export function SeverityBadge({ severity }: { severity: Severity }) {
|
|
6
|
+
return <span className={`badge sev-${severity}`}>{severity}</span>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function SeverityCounts({ findings }: { findings: Finding[] }) {
|
|
10
|
+
const open = findings.filter((f) => f.status === "open" || f.status === "triaged");
|
|
11
|
+
const counts = ORDER.map((sev) => ({
|
|
12
|
+
sev,
|
|
13
|
+
n: open.filter((f) => f.severity === sev).length,
|
|
14
|
+
})).filter((c) => c.n > 0);
|
|
15
|
+
|
|
16
|
+
if (counts.length === 0) {
|
|
17
|
+
return <span className="mono-sm">no open findings</span>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<span className="counts">
|
|
22
|
+
{counts.map((c) => (
|
|
23
|
+
<span key={c.sev} className={`badge sev-${c.sev}`}>
|
|
24
|
+
{c.n} {c.sev}
|
|
25
|
+
</span>
|
|
26
|
+
))}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
}
|