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
package/README.md
CHANGED
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
# openhacker
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold a self-hosted OpenHacker security agent.
|
|
4
|
+
|
|
5
|
+
## Create an instance
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx openhacker my-instance
|
|
9
|
+
cd my-instance
|
|
10
|
+
pnpm install
|
|
11
|
+
pnpm dev
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Running `npx openhacker` with no arguments creates `./openhacker`.
|
package/bin/openhacker
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openhacker",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"license": "UNLICENSED"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scaffold a self-hosted OpenHacker security agent",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"openhacker": "./bin/openhacker"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js"
|
|
12
|
+
},
|
|
13
|
+
"main": "./src/index.js",
|
|
14
|
+
"types": "./src/index.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"bin",
|
|
17
|
+
"scripts",
|
|
18
|
+
"src",
|
|
19
|
+
"templates",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "6.0.2"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build": "tsc --noEmit && node --check ./bin/openhacker && node --check ./src/cli.js && node --check ./scripts/sync-template.js && node --check ./scripts/clean-template.js",
|
|
30
|
+
"sync-template": "node ./scripts/sync-template.js"
|
|
31
|
+
}
|
|
6
32
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const templateDir = path.resolve(here, "../templates");
|
|
7
|
+
|
|
8
|
+
await rm(templateDir, { recursive: true, force: true });
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { cp, mkdir, rm, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const EXCLUDE = new Set([
|
|
6
|
+
".env",
|
|
7
|
+
".env.local",
|
|
8
|
+
"node_modules",
|
|
9
|
+
".eve",
|
|
10
|
+
".next",
|
|
11
|
+
".output",
|
|
12
|
+
".git",
|
|
13
|
+
".vercel",
|
|
14
|
+
".turbo",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const packageRoot = path.resolve(here, "..");
|
|
19
|
+
const source = path.resolve(process.env.OPENHACKER_TEMPLATE_DIR ?? path.join(packageRoot, "../../apps/agent"));
|
|
20
|
+
const dest = path.join(packageRoot, "templates/agent");
|
|
21
|
+
|
|
22
|
+
async function exists(p) {
|
|
23
|
+
try {
|
|
24
|
+
await stat(p);
|
|
25
|
+
return true;
|
|
26
|
+
} catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function shouldCopyTemplatePath(src) {
|
|
32
|
+
const relative = path.relative(source, src);
|
|
33
|
+
const segments = relative.split(path.sep);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
!segments.some((seg) => EXCLUDE.has(seg)) &&
|
|
37
|
+
!relative.endsWith("next-env.d.ts") &&
|
|
38
|
+
!relative.endsWith("tsconfig.tsbuildinfo")
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (!(await exists(path.join(source, "agent", "agent.ts")))) {
|
|
43
|
+
throw new Error(`Could not find the OpenHacker app template at ${source}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await rm(dest, { recursive: true, force: true });
|
|
47
|
+
await mkdir(path.dirname(dest), { recursive: true });
|
|
48
|
+
await cp(source, dest, {
|
|
49
|
+
recursive: true,
|
|
50
|
+
filter: shouldCopyTemplatePath,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
console.log(`Synced OpenHacker template from ${source}`);
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
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.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MESSAGE = "OpenHacker";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const MESSAGE = "OpenHacker";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# --- Model (Vercel AI Gateway) ---
|
|
2
|
+
# On Vercel, inference authenticates automatically via VERCEL_OIDC_TOKEN — no key needed.
|
|
3
|
+
# For LOCAL runs of the eve agent, provide a gateway key:
|
|
4
|
+
AI_GATEWAY_API_KEY=
|
|
5
|
+
|
|
6
|
+
# Optional: override the agent's deep-analysis model (defaults to anthropic/claude-sonnet-4.6)
|
|
7
|
+
OPENHACKER_MODEL=
|
|
8
|
+
|
|
9
|
+
# --- Dashboard auth (recommended) ---
|
|
10
|
+
# When set, the dashboard requires this password to sign in. Unset = open dashboard.
|
|
11
|
+
OPENHACKER_ADMIN_PASSWORD=
|
|
12
|
+
|
|
13
|
+
# --- Persistent storage ---
|
|
14
|
+
# Add a Vercel KV / Upstash Redis integration. Without these, an in-memory store is
|
|
15
|
+
# used and data does NOT persist across restarts/deploys.
|
|
16
|
+
KV_REST_API_URL=
|
|
17
|
+
KV_REST_API_TOKEN=
|
|
18
|
+
|
|
19
|
+
# --- Optional: protect POST /api/scan for programmatic triggers ---
|
|
20
|
+
OPENHACKER_API_TOKEN=
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# OpenHacker instance
|
|
2
|
+
|
|
3
|
+
Your self-hosted OpenHacker security agent — a Next.js dashboard with an embedded
|
|
4
|
+
[eve](https://eve.dev) agent, deployable to Vercel as a single project.
|
|
5
|
+
|
|
6
|
+
## What it does
|
|
7
|
+
|
|
8
|
+
- Continuously scans the repositories you connect for vulnerable dependencies (via OSV).
|
|
9
|
+
- Records findings with computed CVSS severity and fix versions.
|
|
10
|
+
- A daily schedule (Vercel Cron) re-scans every target so newly disclosed advisories are
|
|
11
|
+
caught even when your code hasn't changed.
|
|
12
|
+
- The eve agent layers code-level analysis (reachability, injection, authz) on top.
|
|
13
|
+
|
|
14
|
+
## Deploy to Vercel
|
|
15
|
+
|
|
16
|
+
1. Push this directory to a Git repository.
|
|
17
|
+
2. Import it into Vercel (it deploys as one project — UI + agent + cron).
|
|
18
|
+
3. Add a **KV / Upstash Redis** integration from the Vercel Marketplace so findings persist
|
|
19
|
+
(`KV_REST_API_URL` / `KV_REST_API_TOKEN` are injected automatically).
|
|
20
|
+
4. Set `OPENHACKER_ADMIN_PASSWORD` to protect the dashboard.
|
|
21
|
+
5. Open the deployment URL, sign in, and add a target repository.
|
|
22
|
+
|
|
23
|
+
Inference runs through the Vercel AI Gateway and authenticates automatically via Vercel
|
|
24
|
+
OIDC — no model API key required in production.
|
|
25
|
+
|
|
26
|
+
## Local development
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pnpm install
|
|
30
|
+
cp .env.example .env.local # set AI_GATEWAY_API_KEY for local agent runs
|
|
31
|
+
pnpm dev
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`pnpm dev` runs the Next.js dashboard and the eve agent together. Open the printed URL,
|
|
35
|
+
add a target, and click **Scan now**.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineAgent } from "eve";
|
|
2
|
+
|
|
3
|
+
export default defineAgent({
|
|
4
|
+
// Routes through the Vercel AI Gateway. On Vercel this authenticates with the
|
|
5
|
+
// injected VERCEL_OIDC_TOKEN automatically; locally it uses AI_GATEWAY_API_KEY.
|
|
6
|
+
// The model picker in the dashboard writes OPENHACKER_MODEL.
|
|
7
|
+
model: process.env.OPENHACKER_MODEL ?? "anthropic/claude-sonnet-4.6",
|
|
8
|
+
reasoning: "medium",
|
|
9
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# OpenHacker
|
|
2
|
+
|
|
3
|
+
You are OpenHacker, an autonomous application security agent. Your job is to find
|
|
4
|
+
real, exploitable vulnerabilities in a codebase and its dependencies, prove them,
|
|
5
|
+
and propose fixes. You operate continuously and on demand.
|
|
6
|
+
|
|
7
|
+
## Operating principles
|
|
8
|
+
|
|
9
|
+
- **Signal over noise.** Security engineers ignore noisy tools. Only report an issue
|
|
10
|
+
when you have concrete evidence it applies to this project. When you cannot
|
|
11
|
+
substantiate a vulnerability, say so instead of reporting it.
|
|
12
|
+
- **Prove it.** For each candidate vulnerability, trace it from an attacker-controlled
|
|
13
|
+
entry point (route handler, server action, request body, query/header, env-derived
|
|
14
|
+
input) to the dangerous sink. Capture that data flow as your evidence. Set the
|
|
15
|
+
finding's `proof.status` honestly: `proven`, `likely`, or `unconfirmed`.
|
|
16
|
+
- **Be conservative with dependency CVEs.** A package having a CVE does not mean this
|
|
17
|
+
project is exploitable. Use `check_advisories` to confirm the *installed version* is
|
|
18
|
+
in the affected range, then assess whether the vulnerable code path is plausibly
|
|
19
|
+
reachable before reporting.
|
|
20
|
+
|
|
21
|
+
## Tools
|
|
22
|
+
|
|
23
|
+
- `list_targets` — see the repositories configured in this instance.
|
|
24
|
+
- `run_dependency_scan` — deterministically check a target's dependencies against OSV
|
|
25
|
+
and persist findings. Run this first for dependency coverage.
|
|
26
|
+
- `read_repo_file` — read files / list directories in a target's repo for code review.
|
|
27
|
+
- `check_advisories` — look up a single package in OSV.
|
|
28
|
+
- `report_finding` — persist a confirmed code-level vulnerability.
|
|
29
|
+
|
|
30
|
+
## Workflow
|
|
31
|
+
|
|
32
|
+
Given a target id:
|
|
33
|
+
|
|
34
|
+
1. Call `run_dependency_scan` to record known-vulnerable dependencies.
|
|
35
|
+
2. Use `read_repo_file` (list then read) to inspect the source layout, framework, and
|
|
36
|
+
trust boundaries (route handlers, server actions, request/query/header/env inputs).
|
|
37
|
+
3. Hunt for high-impact issue classes: broken authorization, injection (SQL/command/
|
|
38
|
+
template), SSRF, secrets in code, unsafe deserialization, and XSS (including
|
|
39
|
+
`dangerouslySetInnerHTML`).
|
|
40
|
+
4. For each confirmed code issue, call `report_finding` (with `targetId`) including an
|
|
41
|
+
accurate severity, location, data-flow evidence, and a concrete remediation.
|
|
42
|
+
5. Summarize what you checked and what you found.
|
|
43
|
+
|
|
44
|
+
## Severity
|
|
45
|
+
|
|
46
|
+
Use CVSS-style judgment: `critical` for unauthenticated RCE / auth bypass / data
|
|
47
|
+
exfiltration; `high` for authenticated high-impact issues; `medium` for issues
|
|
48
|
+
needing preconditions; `low`/`info` for hardening. Prefer under-reporting to
|
|
49
|
+
crying wolf.
|
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
}
|