openhacker 0.1.0 → 0.1.2

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 (35) hide show
  1. package/README.md +2 -3
  2. package/bin/openhacker +1 -1
  3. package/package.json +3 -3
  4. package/src/index.d.ts +1 -0
  5. package/src/index.js +305 -1
  6. package/templates/agent/.env.example +0 -7
  7. package/templates/agent/README.md +1 -2
  8. package/templates/agent/agent/agent.ts +1 -5
  9. package/templates/agent/agent/channels/eve.ts +7 -0
  10. package/templates/agent/agent/instructions.md +7 -45
  11. package/templates/agent/app/globals.css +65 -197
  12. package/templates/agent/app/layout.tsx +2 -22
  13. package/templates/agent/app/page.tsx +80 -102
  14. package/templates/agent/package.json +2 -3
  15. package/src/cli.js +0 -153
  16. package/src/index.ts +0 -1
  17. package/templates/agent/agent/lib/auth.ts +0 -23
  18. package/templates/agent/agent/lib/github.ts +0 -74
  19. package/templates/agent/agent/lib/osv.ts +0 -152
  20. package/templates/agent/agent/lib/scan.ts +0 -153
  21. package/templates/agent/agent/lib/store.ts +0 -151
  22. package/templates/agent/agent/lib/types.ts +0 -63
  23. package/templates/agent/agent/schedules/daily_audit.ts +0 -20
  24. package/templates/agent/agent/tools/check_advisories.ts +0 -27
  25. package/templates/agent/agent/tools/list_targets.ts +0 -21
  26. package/templates/agent/agent/tools/read_repo_file.ts +0 -31
  27. package/templates/agent/agent/tools/report_finding.ts +0 -59
  28. package/templates/agent/agent/tools/run_dependency_scan.ts +0 -16
  29. package/templates/agent/app/_components/ui.tsx +0 -29
  30. package/templates/agent/app/actions.ts +0 -120
  31. package/templates/agent/app/api/scan/route.ts +0 -34
  32. package/templates/agent/app/login/page.tsx +0 -40
  33. package/templates/agent/app/settings/page.tsx +0 -92
  34. package/templates/agent/app/targets/[id]/page.tsx +0 -127
  35. package/templates/agent/proxy.ts +0 -21
package/src/cli.js DELETED
@@ -1,153 +0,0 @@
1
- import { cp, readFile, stat, writeFile } from "node:fs/promises";
2
- import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
-
5
- const ORANGE = "\x1b[38;5;214m";
6
- const MUTED = "\x1b[0;2m";
7
- const RED = "\x1b[0;31m";
8
- const NC = "\x1b[0m";
9
-
10
- const EXCLUDE = new Set([
11
- ".env",
12
- ".env.local",
13
- "node_modules",
14
- ".eve",
15
- ".next",
16
- ".output",
17
- ".git",
18
- ".vercel",
19
- ".turbo",
20
- ]);
21
-
22
- const here = path.dirname(fileURLToPath(import.meta.url));
23
-
24
- async function exists(p) {
25
- try {
26
- await stat(p);
27
- return true;
28
- } catch {
29
- return false;
30
- }
31
- }
32
-
33
- async function resolveTemplateDir() {
34
- const candidates = [];
35
-
36
- if (process.env.OPENHACKER_TEMPLATE_DIR) {
37
- candidates.push(path.resolve(process.env.OPENHACKER_TEMPLATE_DIR));
38
- }
39
-
40
- candidates.push(
41
- // npm package layout: packages/openhacker/src -> packages/openhacker/templates/agent
42
- path.resolve(here, "../templates/agent"),
43
- // monorepo layout: packages/openhacker/src -> repo root -> apps/agent
44
- path.resolve(here, "../../../apps/agent"),
45
- );
46
-
47
- for (const candidate of candidates) {
48
- if (await exists(path.join(candidate, "agent", "agent.ts"))) {
49
- return candidate;
50
- }
51
- }
52
-
53
- return candidates[0];
54
- }
55
-
56
- function packageNameFor(dest) {
57
- return path.basename(dest).replace(/[^a-z0-9-]+/gi, "-").toLowerCase() || "openhacker";
58
- }
59
-
60
- function shouldCopyTemplatePath(src, root) {
61
- const relative = path.relative(root, src);
62
- const segments = relative.split(path.sep);
63
-
64
- return (
65
- !segments.some((seg) => EXCLUDE.has(seg)) &&
66
- !relative.endsWith("next-env.d.ts") &&
67
- !relative.endsWith("tsconfig.tsbuildinfo")
68
- );
69
- }
70
-
71
- async function init(targetArg) {
72
- const template = await resolveTemplateDir();
73
- if (!(await exists(path.join(template, "agent", "agent.ts")))) {
74
- console.error(`${RED}Could not find the instance template at ${template}.${NC}`);
75
- console.error(`${MUTED}Set OPENHACKER_TEMPLATE_DIR to the template directory.${NC}`);
76
- process.exit(1);
77
- }
78
-
79
- const dest = path.resolve(process.cwd(), targetArg ?? "openhacker");
80
- if (await exists(dest)) {
81
- console.error(`${RED}Destination already exists: ${dest}${NC}`);
82
- process.exit(1);
83
- }
84
-
85
- console.log(`\n${MUTED}Creating OpenHacker instance in ${NC}${dest}`);
86
- await cp(template, dest, {
87
- recursive: true,
88
- filter: (src) => shouldCopyTemplatePath(src, template),
89
- });
90
-
91
- const pkgPath = path.join(dest, "package.json");
92
- const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
93
- pkg.name = packageNameFor(dest);
94
- pkg.private = true;
95
- await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
96
-
97
- console.log(`${ORANGE}\u2713${NC} OpenHacker instance ready.\n`);
98
- console.log("Next steps:\n");
99
- console.log(` cd ${path.relative(process.cwd(), dest) || "."}`);
100
- console.log(" pnpm install");
101
- console.log(" pnpm dev # run locally\n");
102
- console.log(`${MUTED}Deploy: push to a git repo and import it into Vercel (deploys as one project).`);
103
- console.log("Add a Vercel KV / Upstash Redis integration to persist findings, and set");
104
- console.log(`OPENHACKER_ADMIN_PASSWORD to protect the dashboard. See README.md.${NC}\n`);
105
- }
106
-
107
- function usage() {
108
- console.log("OpenHacker\n");
109
- console.log("Usage:");
110
- console.log(" openhacker [dir] Scaffold a deployable OpenHacker instance");
111
- console.log(" openhacker init [dir] Same as above");
112
- console.log(" openhacker --help Show help");
113
- console.log(" openhacker --version Show version\n");
114
- }
115
-
116
- async function version() {
117
- const pkg = JSON.parse(await readFile(path.resolve(here, "../package.json"), "utf8"));
118
- console.log(pkg.version);
119
- }
120
-
121
- export async function run(args = process.argv.slice(2)) {
122
- const [command, target, ...rest] = args;
123
-
124
- if (rest.length > 0) {
125
- console.error(`${RED}Too many arguments.${NC}\n`);
126
- usage();
127
- process.exit(1);
128
- }
129
-
130
- switch (command) {
131
- case undefined:
132
- await init();
133
- break;
134
- case "init":
135
- await init(target);
136
- break;
137
- case "-h":
138
- case "--help":
139
- usage();
140
- break;
141
- case "-v":
142
- case "--version":
143
- await version();
144
- break;
145
- default:
146
- if (command.startsWith("-")) {
147
- console.error(`${RED}Unknown option: ${command}${NC}\n`);
148
- usage();
149
- process.exit(1);
150
- }
151
- await init(command);
152
- }
153
- }
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export const MESSAGE = "OpenHacker";
@@ -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
- }