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.
Files changed (37) hide show
  1. package/README.md +12 -1
  2. package/bin/openhacker +4 -0
  3. package/package.json +29 -3
  4. package/scripts/clean-template.js +8 -0
  5. package/scripts/sync-template.js +53 -0
  6. package/src/cli.js +153 -0
  7. package/src/index.js +1 -0
  8. package/src/index.ts +1 -0
  9. package/templates/agent/.env.example +20 -0
  10. package/templates/agent/README.md +35 -0
  11. package/templates/agent/agent/agent.ts +9 -0
  12. package/templates/agent/agent/instructions.md +49 -0
  13. package/templates/agent/agent/lib/auth.ts +23 -0
  14. package/templates/agent/agent/lib/github.ts +74 -0
  15. package/templates/agent/agent/lib/osv.ts +152 -0
  16. package/templates/agent/agent/lib/scan.ts +153 -0
  17. package/templates/agent/agent/lib/store.ts +151 -0
  18. package/templates/agent/agent/lib/types.ts +63 -0
  19. package/templates/agent/agent/schedules/daily_audit.ts +20 -0
  20. package/templates/agent/agent/tools/check_advisories.ts +27 -0
  21. package/templates/agent/agent/tools/list_targets.ts +21 -0
  22. package/templates/agent/agent/tools/read_repo_file.ts +31 -0
  23. package/templates/agent/agent/tools/report_finding.ts +59 -0
  24. package/templates/agent/agent/tools/run_dependency_scan.ts +16 -0
  25. package/templates/agent/app/_components/ui.tsx +29 -0
  26. package/templates/agent/app/actions.ts +120 -0
  27. package/templates/agent/app/api/scan/route.ts +34 -0
  28. package/templates/agent/app/globals.css +280 -0
  29. package/templates/agent/app/layout.tsx +35 -0
  30. package/templates/agent/app/login/page.tsx +40 -0
  31. package/templates/agent/app/page.tsx +114 -0
  32. package/templates/agent/app/settings/page.tsx +92 -0
  33. package/templates/agent/app/targets/[id]/page.tsx +127 -0
  34. package/templates/agent/next.config.ts +8 -0
  35. package/templates/agent/package.json +30 -0
  36. package/templates/agent/proxy.ts +21 -0
  37. package/templates/agent/tsconfig.json +43 -0
@@ -0,0 +1,120 @@
1
+ "use server";
2
+
3
+ import { cookies } from "next/headers";
4
+ import { redirect } from "next/navigation";
5
+ import { revalidatePath } from "next/cache";
6
+ import { SESSION_COOKIE, adminPassword, sessionToken } from "@/agent/lib/auth";
7
+ import { checkRepoAccess } from "@/agent/lib/github";
8
+ import { runScan } from "@/agent/lib/scan";
9
+ import { getStore } from "@/agent/lib/store";
10
+ import { DEFAULT_SETTINGS, type Finding, type Target } from "@/agent/lib/types";
11
+
12
+ function parseRepo(input: string): string | null {
13
+ const trimmed = input.trim().replace(/\.git$/, "");
14
+ const url = trimmed.match(/github\.com[/:]([^/]+\/[^/]+)/);
15
+ const candidate = url ? url[1] : trimmed;
16
+ return /^[^/\s]+\/[^/\s]+$/.test(candidate) ? candidate : null;
17
+ }
18
+
19
+ export async function login(formData: FormData): Promise<void> {
20
+ const password = String(formData.get("password") ?? "");
21
+ const next = String(formData.get("next") ?? "/") || "/";
22
+ const expected = adminPassword();
23
+
24
+ if (!expected || password !== expected) {
25
+ redirect(`/login?error=1&next=${encodeURIComponent(next)}`);
26
+ }
27
+
28
+ const jar = await cookies();
29
+ jar.set(SESSION_COOKIE, await sessionToken(password), {
30
+ httpOnly: true,
31
+ sameSite: "lax",
32
+ secure: process.env.NODE_ENV === "production",
33
+ path: "/",
34
+ maxAge: 60 * 60 * 24 * 30,
35
+ });
36
+ redirect(next);
37
+ }
38
+
39
+ export async function logout(): Promise<void> {
40
+ const jar = await cookies();
41
+ jar.delete(SESSION_COOKIE);
42
+ redirect("/login");
43
+ }
44
+
45
+ export async function addTarget(formData: FormData): Promise<void> {
46
+ const repo = parseRepo(String(formData.get("repo") ?? ""));
47
+ if (!repo) redirect("/?error=invalid-repo");
48
+
49
+ const name = String(formData.get("name") ?? "").trim() || repo.split("/")[1];
50
+ const branchInput = String(formData.get("branch") ?? "").trim();
51
+ const token = String(formData.get("token") ?? "").trim();
52
+ const autoRemediate = formData.get("autoRemediate") === "on";
53
+
54
+ const access = await checkRepoAccess(repo, token || null);
55
+ const branch = branchInput || access.defaultBranch || "main";
56
+
57
+ const target: Target = {
58
+ id: crypto.randomUUID(),
59
+ name,
60
+ repo,
61
+ branch,
62
+ provider: "github",
63
+ hasToken: Boolean(token),
64
+ autoRemediate,
65
+ createdAt: new Date().toISOString(),
66
+ };
67
+
68
+ const store = getStore();
69
+ await store.saveTarget(target);
70
+ if (token) await store.setTargetToken(target.id, token);
71
+
72
+ revalidatePath("/");
73
+ redirect(`/targets/${target.id}`);
74
+ }
75
+
76
+ export async function deleteTarget(formData: FormData): Promise<void> {
77
+ const id = String(formData.get("id") ?? "");
78
+ if (id) await getStore().deleteTarget(id);
79
+ revalidatePath("/");
80
+ redirect("/");
81
+ }
82
+
83
+ export async function scanTarget(formData: FormData): Promise<void> {
84
+ const id = String(formData.get("id") ?? "");
85
+ if (id) await runScan(id);
86
+ revalidatePath("/");
87
+ revalidatePath(`/targets/${id}`);
88
+ }
89
+
90
+ export async function setFindingStatus(formData: FormData): Promise<void> {
91
+ const targetId = String(formData.get("targetId") ?? "");
92
+ const findingId = String(formData.get("findingId") ?? "");
93
+ const status = String(formData.get("status") ?? "open") as Finding["status"];
94
+
95
+ const store = getStore();
96
+ const findings = await store.listFindings(targetId);
97
+ const match = findings.find((f) => f.id === findingId);
98
+ if (match) await store.upsertFinding({ ...match, status });
99
+
100
+ revalidatePath(`/targets/${targetId}`);
101
+ }
102
+
103
+ export async function saveSettings(formData: FormData): Promise<void> {
104
+ const store = getStore();
105
+ const current = await store.getSettings();
106
+ await store.saveSettings({
107
+ ...DEFAULT_SETTINGS,
108
+ ...current,
109
+ model: String(formData.get("model") ?? current.model) || DEFAULT_SETTINGS.model,
110
+ autoRemediate: formData.get("autoRemediate") === "on",
111
+ integrations: {
112
+ github: { connected: formData.get("githubConnected") === "on" },
113
+ hackerone: {
114
+ connected: formData.get("hackeroneConnected") === "on",
115
+ handle: String(formData.get("hackeroneHandle") ?? "").trim() || undefined,
116
+ },
117
+ },
118
+ });
119
+ revalidatePath("/settings");
120
+ }
@@ -0,0 +1,34 @@
1
+ import { runScan } from "@/agent/lib/scan";
2
+ import { getStore } from "@/agent/lib/store";
3
+
4
+ export const runtime = "nodejs";
5
+
6
+ function authorized(req: Request): boolean {
7
+ const token = process.env.OPENHACKER_API_TOKEN;
8
+ if (!token) return true; // no API token configured — allow (dev)
9
+ const header = req.headers.get("authorization") ?? "";
10
+ return header === `Bearer ${token}`;
11
+ }
12
+
13
+ /** Trigger a scan programmatically. Body: { targetId?: string } — omit to scan all. */
14
+ export async function POST(req: Request): Promise<Response> {
15
+ if (!authorized(req)) {
16
+ return Response.json({ error: "unauthorized" }, { status: 401 });
17
+ }
18
+
19
+ let targetId: string | undefined;
20
+ try {
21
+ const body = (await req.json()) as { targetId?: string };
22
+ targetId = body.targetId;
23
+ } catch {
24
+ // no body — scan all
25
+ }
26
+
27
+ const store = getStore();
28
+ const targets = targetId ? [targetId] : (await store.listTargets()).map((t) => t.id);
29
+ const results = await Promise.all(
30
+ targets.map(async (id) => ({ targetId: id, ...(await runScan(id)) })),
31
+ );
32
+
33
+ return Response.json({ scanned: results.length, results });
34
+ }
@@ -0,0 +1,280 @@
1
+ :root {
2
+ --bg: #000000;
3
+ --panel: #0d0d0d;
4
+ --panel-2: #161616;
5
+ --border: #2a2a2a;
6
+ --text: #f5f5f5;
7
+ --muted: #8f8f8f;
8
+ --accent: #ffffff;
9
+ --accent-dim: #333333;
10
+ --crit: #ffffff;
11
+ --high: #cfcfcf;
12
+ --med: #9a9a9a;
13
+ --low: #6f6f6f;
14
+ --info: #555555;
15
+ --ok: #f5f5f5;
16
+ }
17
+
18
+ * {
19
+ box-sizing: border-box;
20
+ }
21
+
22
+ html,
23
+ body {
24
+ margin: 0;
25
+ padding: 0;
26
+ background: var(--bg);
27
+ color: var(--text);
28
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
+ font-size: 14px;
30
+ line-height: 1.5;
31
+ }
32
+
33
+ a {
34
+ color: var(--accent);
35
+ text-decoration: none;
36
+ }
37
+ a:hover {
38
+ text-decoration: underline;
39
+ }
40
+
41
+ .nav {
42
+ display: flex;
43
+ align-items: center;
44
+ gap: 20px;
45
+ padding: 14px 24px;
46
+ border-bottom: 1px solid var(--border);
47
+ background: var(--panel);
48
+ }
49
+ .nav .brand {
50
+ font-weight: 700;
51
+ letter-spacing: 0.5px;
52
+ color: var(--text);
53
+ }
54
+ .nav .brand span {
55
+ color: var(--accent);
56
+ }
57
+ .nav .spacer {
58
+ flex: 1;
59
+ }
60
+ .nav a {
61
+ color: var(--muted);
62
+ }
63
+ .nav a:hover {
64
+ color: var(--text);
65
+ text-decoration: none;
66
+ }
67
+
68
+ .container {
69
+ max-width: 960px;
70
+ margin: 0 auto;
71
+ padding: 28px 24px 80px;
72
+ }
73
+
74
+ h1 {
75
+ font-size: 20px;
76
+ margin: 0 0 4px;
77
+ }
78
+ h2 {
79
+ font-size: 15px;
80
+ margin: 28px 0 12px;
81
+ color: var(--muted);
82
+ text-transform: uppercase;
83
+ letter-spacing: 1px;
84
+ }
85
+ .sub {
86
+ color: var(--muted);
87
+ margin: 0 0 24px;
88
+ }
89
+
90
+ .panel {
91
+ background: var(--panel);
92
+ border: 1px solid var(--border);
93
+ border-radius: 10px;
94
+ padding: 18px;
95
+ margin-bottom: 16px;
96
+ }
97
+
98
+ .card {
99
+ background: var(--panel);
100
+ border: 1px solid var(--border);
101
+ border-radius: 10px;
102
+ padding: 16px 18px;
103
+ margin-bottom: 12px;
104
+ display: flex;
105
+ align-items: center;
106
+ gap: 16px;
107
+ }
108
+ .card .grow {
109
+ flex: 1;
110
+ min-width: 0;
111
+ }
112
+ .card .repo {
113
+ font-weight: 600;
114
+ }
115
+ .card .meta {
116
+ color: var(--muted);
117
+ font-size: 12px;
118
+ margin-top: 2px;
119
+ }
120
+
121
+ label {
122
+ display: block;
123
+ font-size: 12px;
124
+ color: var(--muted);
125
+ margin-bottom: 6px;
126
+ }
127
+ input[type="text"],
128
+ input[type="password"],
129
+ select {
130
+ width: 100%;
131
+ background: var(--panel-2);
132
+ border: 1px solid var(--border);
133
+ color: var(--text);
134
+ border-radius: 8px;
135
+ padding: 9px 11px;
136
+ font: inherit;
137
+ }
138
+ input:focus,
139
+ select:focus {
140
+ outline: none;
141
+ border-color: var(--accent);
142
+ }
143
+ .row {
144
+ display: flex;
145
+ gap: 12px;
146
+ flex-wrap: wrap;
147
+ margin-bottom: 12px;
148
+ }
149
+ .row > div {
150
+ flex: 1;
151
+ min-width: 160px;
152
+ }
153
+ .check {
154
+ display: flex;
155
+ align-items: center;
156
+ gap: 8px;
157
+ }
158
+ .check label {
159
+ margin: 0;
160
+ }
161
+
162
+ button,
163
+ .btn {
164
+ background: var(--accent);
165
+ color: #000000;
166
+ border: none;
167
+ border-radius: 8px;
168
+ padding: 9px 14px;
169
+ font: inherit;
170
+ font-weight: 600;
171
+ cursor: pointer;
172
+ }
173
+ button:hover {
174
+ filter: brightness(1.08);
175
+ }
176
+ .btn-ghost {
177
+ background: transparent;
178
+ color: var(--muted);
179
+ border: 1px solid var(--border);
180
+ }
181
+ .btn-ghost:hover {
182
+ color: var(--text);
183
+ }
184
+ .btn-danger {
185
+ background: transparent;
186
+ color: var(--crit);
187
+ border: 1px solid var(--accent-dim);
188
+ }
189
+
190
+ .badge {
191
+ display: inline-block;
192
+ padding: 1px 8px;
193
+ border-radius: 20px;
194
+ font-size: 11px;
195
+ font-weight: 700;
196
+ text-transform: uppercase;
197
+ letter-spacing: 0.5px;
198
+ border: 1px solid var(--border);
199
+ background: transparent;
200
+ color: var(--text);
201
+ }
202
+ .sev-critical {
203
+ background: #ffffff;
204
+ color: #000000;
205
+ border-color: #ffffff;
206
+ }
207
+ .sev-high {
208
+ color: #ffffff;
209
+ border-color: #cfcfcf;
210
+ background: rgba(255, 255, 255, 0.1);
211
+ }
212
+ .sev-medium {
213
+ color: var(--high);
214
+ border-color: #5a5a5a;
215
+ }
216
+ .sev-low {
217
+ color: var(--med);
218
+ border-color: #3a3a3a;
219
+ }
220
+ .sev-info {
221
+ color: var(--info);
222
+ border-color: #2a2a2a;
223
+ }
224
+
225
+ .counts {
226
+ display: flex;
227
+ gap: 6px;
228
+ }
229
+
230
+ table {
231
+ width: 100%;
232
+ border-collapse: collapse;
233
+ }
234
+ th,
235
+ td {
236
+ text-align: left;
237
+ padding: 10px 12px;
238
+ border-bottom: 1px solid var(--border);
239
+ vertical-align: top;
240
+ font-size: 13px;
241
+ }
242
+ th {
243
+ color: var(--muted);
244
+ font-size: 11px;
245
+ text-transform: uppercase;
246
+ letter-spacing: 0.5px;
247
+ }
248
+
249
+ .banner {
250
+ border: 1px solid var(--border);
251
+ background: rgba(255, 255, 255, 0.04);
252
+ color: var(--text);
253
+ border-radius: 8px;
254
+ padding: 10px 14px;
255
+ margin-bottom: 18px;
256
+ font-size: 13px;
257
+ }
258
+ .empty {
259
+ color: var(--muted);
260
+ padding: 24px;
261
+ text-align: center;
262
+ border: 1px dashed var(--border);
263
+ border-radius: 10px;
264
+ }
265
+ .inline {
266
+ display: inline;
267
+ }
268
+ .actions {
269
+ display: flex;
270
+ gap: 8px;
271
+ align-items: center;
272
+ }
273
+ .mono-sm {
274
+ font-size: 12px;
275
+ color: var(--muted);
276
+ }
277
+ .login-wrap {
278
+ max-width: 360px;
279
+ margin: 12vh auto 0;
280
+ }
@@ -0,0 +1,35 @@
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
+ import "./globals.css";
6
+
7
+ export const metadata: Metadata = {
8
+ title: "OpenHacker",
9
+ description: "Autonomous application security agent",
10
+ };
11
+
12
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
13
+ return (
14
+ <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>
33
+ </html>
34
+ );
35
+ }
@@ -0,0 +1,40 @@
1
+ import { authEnabled } from "@/agent/lib/auth";
2
+ import { login } from "../actions";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export default async function LoginPage({
7
+ searchParams,
8
+ }: {
9
+ searchParams: Promise<{ error?: string; next?: string }>;
10
+ }) {
11
+ const { error, next } = await searchParams;
12
+
13
+ return (
14
+ <main className="container">
15
+ <div className="login-wrap">
16
+ <h1>
17
+ open<span style={{ color: "var(--accent)" }}>hacker</span>
18
+ </h1>
19
+ <p className="sub">Sign in to your instance.</p>
20
+
21
+ {!authEnabled() ? (
22
+ <div className="banner">
23
+ No admin password is set. The dashboard is currently open — set{" "}
24
+ <code>OPENHACKER_ADMIN_PASSWORD</code> to require sign-in.
25
+ </div>
26
+ ) : null}
27
+ {error ? <div className="banner">Incorrect password.</div> : null}
28
+
29
+ <form action={login} className="panel">
30
+ <input type="hidden" name="next" value={next ?? "/"} />
31
+ <label htmlFor="password">Password</label>
32
+ <input id="password" name="password" type="password" autoFocus required />
33
+ <button type="submit" style={{ marginTop: 14 }}>
34
+ Sign in
35
+ </button>
36
+ </form>
37
+ </div>
38
+ </main>
39
+ );
40
+ }
@@ -0,0 +1,114 @@
1
+ import Link from "next/link";
2
+ import { getStore, isPersistent } from "@/agent/lib/store";
3
+ import { SeverityCounts } from "./_components/ui";
4
+ import { addTarget, deleteTarget, scanTarget } from "./actions";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ export default async function Dashboard({
9
+ searchParams,
10
+ }: {
11
+ searchParams: Promise<{ error?: string }>;
12
+ }) {
13
+ const { error } = await searchParams;
14
+ const store = getStore();
15
+ const targets = await store.listTargets();
16
+ const findingsByTarget = new Map(
17
+ await Promise.all(
18
+ targets.map(async (t) => [t.id, await store.listFindings(t.id)] as const),
19
+ ),
20
+ );
21
+
22
+ return (
23
+ <main className="container">
24
+ <h1>Targets</h1>
25
+ <p className="sub">Repositories OpenHacker continuously scans for vulnerabilities.</p>
26
+
27
+ {!isPersistent() ? (
28
+ <div className="banner">
29
+ Using an in-memory store — data will not persist across restarts/deploys. Add a Vercel KV
30
+ or Upstash Redis integration and set <code>KV_REST_API_URL</code> /{" "}
31
+ <code>KV_REST_API_TOKEN</code> to persist.
32
+ </div>
33
+ ) : 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
+ </main>
113
+ );
114
+ }