openhacker 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -6
- package/templates/agent/.env.example +0 -8
- package/templates/agent/README.md +9 -2
- package/templates/agent/agent/channels/eve.ts +3 -3
- package/templates/agent/agent/instructions.md +0 -1
- package/templates/agent/app/globals.css +52 -3
- package/templates/agent/app/page.tsx +39 -40
- package/templates/agent/lib/repository.ts +81 -0
- package/templates/agent/next.config.ts +38 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openhacker",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Scaffold a self-hosted OpenHacker security agent",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -19,14 +19,16 @@
|
|
|
19
19
|
"templates",
|
|
20
20
|
"README.md"
|
|
21
21
|
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc --noEmit && node --check ./bin/openhacker && node --check ./src/index.js && node --check ./scripts/sync-template.js && node --check ./scripts/clean-template.js",
|
|
24
|
+
"sync-template": "node ./scripts/sync-template.js",
|
|
25
|
+
"prepack": "pnpm run sync-template",
|
|
26
|
+
"postpack": "node ./scripts/clean-template.js"
|
|
27
|
+
},
|
|
22
28
|
"engines": {
|
|
23
29
|
"node": ">=20"
|
|
24
30
|
},
|
|
25
31
|
"devDependencies": {
|
|
26
32
|
"typescript": "6.0.2"
|
|
27
|
-
},
|
|
28
|
-
"scripts": {
|
|
29
|
-
"build": "tsc --noEmit && node --check ./bin/openhacker && node --check ./src/index.js && node --check ./scripts/sync-template.js && node --check ./scripts/clean-template.js",
|
|
30
|
-
"sync-template": "node ./scripts/sync-template.js"
|
|
31
33
|
}
|
|
32
|
-
}
|
|
34
|
+
}
|
|
@@ -3,11 +3,3 @@
|
|
|
3
3
|
# For LOCAL runs of the eve agent, provide a gateway key:
|
|
4
4
|
AI_GATEWAY_API_KEY=
|
|
5
5
|
|
|
6
|
-
# --- Persistent storage ---
|
|
7
|
-
# Add a Vercel KV / Upstash Redis integration. Without these, an in-memory store is
|
|
8
|
-
# used and data does NOT persist across restarts/deploys.
|
|
9
|
-
KV_REST_API_URL=
|
|
10
|
-
KV_REST_API_TOKEN=
|
|
11
|
-
|
|
12
|
-
# --- Optional: protect POST /api/scan for programmatic triggers ---
|
|
13
|
-
OPENHACKER_API_TOKEN=
|
|
@@ -15,13 +15,20 @@ Your self-hosted OpenHacker security agent — a Next.js dashboard with an embed
|
|
|
15
15
|
|
|
16
16
|
1. Push this directory to a Git repository.
|
|
17
17
|
2. Import it into Vercel (it deploys as one project — UI + agent + cron).
|
|
18
|
-
3.
|
|
19
|
-
|
|
18
|
+
3. Enable Vercel Deployment Protection for the project so only approved users
|
|
19
|
+
can reach the dashboard and eve routes.
|
|
20
20
|
4. Open the deployment URL and add a target repository.
|
|
21
21
|
|
|
22
22
|
Inference runs through the Vercel AI Gateway and authenticates automatically via Vercel
|
|
23
23
|
OIDC — no model API key required in production.
|
|
24
24
|
|
|
25
|
+
The main screen calls the eve channel directly. This is safe only when the
|
|
26
|
+
deployment is protected by Vercel Deployment Protection; without that gate,
|
|
27
|
+
`/eve/v1/*` is a public compute endpoint. The UI validates GitHub repository
|
|
28
|
+
names before it sends the agent message.
|
|
29
|
+
|
|
30
|
+
This starter intentionally does not configure external persistence yet.
|
|
31
|
+
|
|
25
32
|
## Local development
|
|
26
33
|
|
|
27
34
|
```bash
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eveChannel } from "eve/channels/eve";
|
|
2
2
|
import { none } from "eve/channels/auth";
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// This app is intended to run behind Vercel Deployment Protection. The
|
|
5
|
+
// deployment gate owns user access; the Eve channel accepts requests that reach
|
|
6
|
+
// the protected app.
|
|
7
7
|
export default eveChannel({ auth: [none()] });
|
|
@@ -4,7 +4,6 @@ You are OpenHacker, an application security agent.
|
|
|
4
4
|
|
|
5
5
|
The user gives you a GitHub repository (as `owner/name` or a github.com URL). Analyze it for security vulnerabilities and report your findings.
|
|
6
6
|
|
|
7
|
-
- Briefly narrate what you are checking as you go (dependencies, auth, input handling, secrets, etc.).
|
|
8
7
|
- Use the shell to clone and explore the repo. Use `ls`/`find` to list directories; only use `read_file` on actual file paths, never on a directory.
|
|
9
8
|
- Call out concrete risks with severity and, where possible, how to fix them.
|
|
10
9
|
- If you cannot access the repository, say so plainly.
|
|
@@ -95,13 +95,35 @@ h1 span {
|
|
|
95
95
|
margin-bottom: 0;
|
|
96
96
|
}
|
|
97
97
|
.reply .reasoning {
|
|
98
|
-
white-space: pre-wrap;
|
|
99
|
-
color: var(--muted);
|
|
100
|
-
font-size: 13px;
|
|
101
98
|
margin: 0 0 12px;
|
|
102
99
|
padding-left: 12px;
|
|
103
100
|
border-left: 2px solid var(--border);
|
|
104
101
|
}
|
|
102
|
+
.reply .reasoning > summary {
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
color: var(--muted);
|
|
105
|
+
font-size: 12px;
|
|
106
|
+
text-transform: uppercase;
|
|
107
|
+
letter-spacing: 0.5px;
|
|
108
|
+
list-style: none;
|
|
109
|
+
user-select: none;
|
|
110
|
+
}
|
|
111
|
+
.reply .reasoning > summary::-webkit-details-marker {
|
|
112
|
+
display: none;
|
|
113
|
+
}
|
|
114
|
+
.reply .reasoning > summary::before {
|
|
115
|
+
content: "› ";
|
|
116
|
+
display: inline-block;
|
|
117
|
+
}
|
|
118
|
+
.reply .reasoning[open] > summary::before {
|
|
119
|
+
content: "⌄ ";
|
|
120
|
+
}
|
|
121
|
+
.reply .reasoning > p {
|
|
122
|
+
white-space: pre-wrap;
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
font-size: 13px;
|
|
125
|
+
margin: 8px 0 0;
|
|
126
|
+
}
|
|
105
127
|
|
|
106
128
|
.tool {
|
|
107
129
|
display: flex;
|
|
@@ -123,6 +145,33 @@ h1 span {
|
|
|
123
145
|
padding: 0 8px;
|
|
124
146
|
}
|
|
125
147
|
|
|
148
|
+
.hacking {
|
|
149
|
+
color: var(--muted);
|
|
150
|
+
font-size: 13px;
|
|
151
|
+
margin: 0;
|
|
152
|
+
}
|
|
153
|
+
.reply .text + .hacking {
|
|
154
|
+
margin-top: 12px;
|
|
155
|
+
}
|
|
156
|
+
.dots::after {
|
|
157
|
+
content: "";
|
|
158
|
+
animation: dots 1.2s steps(4, end) infinite;
|
|
159
|
+
}
|
|
160
|
+
@keyframes dots {
|
|
161
|
+
0% {
|
|
162
|
+
content: "";
|
|
163
|
+
}
|
|
164
|
+
25% {
|
|
165
|
+
content: ".";
|
|
166
|
+
}
|
|
167
|
+
50% {
|
|
168
|
+
content: "..";
|
|
169
|
+
}
|
|
170
|
+
75% {
|
|
171
|
+
content: "...";
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
126
175
|
.cursor {
|
|
127
176
|
display: inline-block;
|
|
128
177
|
width: 8px;
|
|
@@ -1,21 +1,31 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState } from "react";
|
|
3
|
+
import { type SyntheticEvent, useState } from "react";
|
|
4
4
|
import { useEveAgent } from "eve/react";
|
|
5
5
|
|
|
6
|
+
import { validateGitHubRepository } from "@/lib/repository";
|
|
7
|
+
|
|
6
8
|
export default function Home() {
|
|
7
9
|
const [repo, setRepo] = useState("");
|
|
10
|
+
const [error, setError] = useState("");
|
|
8
11
|
const agent = useEveAgent();
|
|
9
12
|
|
|
10
13
|
const busy = agent.status === "submitted" || agent.status === "streaming";
|
|
11
14
|
|
|
12
|
-
function onSubmit(e:
|
|
15
|
+
function onSubmit(e: SyntheticEvent<HTMLFormElement>) {
|
|
13
16
|
e.preventDefault();
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
if (busy) return;
|
|
18
|
+
|
|
19
|
+
const validation = validateGitHubRepository(repo);
|
|
20
|
+
if (!validation.ok) {
|
|
21
|
+
setError(validation.error);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
setError("");
|
|
16
26
|
agent.reset();
|
|
17
27
|
agent.send({
|
|
18
|
-
message: `Analyze the GitHub repository
|
|
28
|
+
message: `Analyze the GitHub repository ${validation.repository} for security vulnerabilities. Reply with only the final report.`,
|
|
19
29
|
});
|
|
20
30
|
}
|
|
21
31
|
|
|
@@ -23,6 +33,20 @@ export default function Home() {
|
|
|
23
33
|
.reverse()
|
|
24
34
|
.find((m) => m.role === "assistant");
|
|
25
35
|
|
|
36
|
+
const parts = reply?.parts ?? [];
|
|
37
|
+
const lastStep = parts.reduce<number | undefined>((max, p) => {
|
|
38
|
+
const idx = "stepIndex" in p ? p.stepIndex : undefined;
|
|
39
|
+
if (typeof idx !== "number") return max;
|
|
40
|
+
return max === undefined ? idx : Math.max(max, idx);
|
|
41
|
+
}, undefined);
|
|
42
|
+
|
|
43
|
+
let result = "";
|
|
44
|
+
for (const p of parts) {
|
|
45
|
+
if (p.type !== "text") continue;
|
|
46
|
+
if (lastStep !== undefined && p.stepIndex !== lastStep) continue;
|
|
47
|
+
result += p.text;
|
|
48
|
+
}
|
|
49
|
+
|
|
26
50
|
return (
|
|
27
51
|
<main className="container">
|
|
28
52
|
<h1>
|
|
@@ -38,53 +62,28 @@ export default function Home() {
|
|
|
38
62
|
value={repo}
|
|
39
63
|
onChange={(e) => setRepo(e.target.value)}
|
|
40
64
|
placeholder="owner/name or https://github.com/owner/name"
|
|
41
|
-
|
|
65
|
+
aria-label="GitHub repository"
|
|
42
66
|
/>
|
|
43
67
|
<button type="submit" disabled={busy || !repo.trim()}>
|
|
44
68
|
{busy ? "Analyzing…" : "Analyze"}
|
|
45
69
|
</button>
|
|
46
70
|
</form>
|
|
47
71
|
|
|
48
|
-
{
|
|
72
|
+
{result || busy ? (
|
|
49
73
|
<section className="reply">
|
|
50
|
-
{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
if (part.type === "text") {
|
|
59
|
-
return (
|
|
60
|
-
<p key={i} className="text">
|
|
61
|
-
{part.text}
|
|
62
|
-
</p>
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
if (part.type === "dynamic-tool") {
|
|
66
|
-
return (
|
|
67
|
-
<div key={i} className="tool">
|
|
68
|
-
<span className="tool-name">{part.toolName}</span>
|
|
69
|
-
<span className="tool-state">{part.state}</span>
|
|
70
|
-
</div>
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
return null;
|
|
74
|
-
})}
|
|
75
|
-
{agent.status === "streaming" ? (
|
|
76
|
-
<span className="cursor" aria-hidden />
|
|
74
|
+
{result ? <p className="text">{result}</p> : null}
|
|
75
|
+
{busy && !result ? (
|
|
76
|
+
<p className="hacking">
|
|
77
|
+
hacking
|
|
78
|
+
<span className="dots" aria-hidden />
|
|
79
|
+
</p>
|
|
77
80
|
) : null}
|
|
78
81
|
</section>
|
|
79
|
-
) : busy ? (
|
|
80
|
-
<section className="reply">
|
|
81
|
-
<span className="cursor" aria-hidden />
|
|
82
|
-
</section>
|
|
83
82
|
) : null}
|
|
84
83
|
|
|
85
|
-
{agent.status === "error" ? (
|
|
84
|
+
{error || agent.status === "error" ? (
|
|
86
85
|
<div className="banner">
|
|
87
|
-
{String(agent.error ?? "Something went wrong.")}
|
|
86
|
+
{error || String(agent.error ?? "Something went wrong.")}
|
|
88
87
|
</div>
|
|
89
88
|
) : null}
|
|
90
89
|
</main>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
const OWNER_PATTERN = /^[a-z\d](?:[a-z\d-]{0,37}[a-z\d])?$/i;
|
|
2
|
+
const REPO_PATTERN = /^[a-z\d._-]{1,100}$/i;
|
|
3
|
+
|
|
4
|
+
export type RepositoryValidationResult =
|
|
5
|
+
| { ok: true; repository: string }
|
|
6
|
+
| { ok: false; error: string };
|
|
7
|
+
|
|
8
|
+
export function validateGitHubRepository(
|
|
9
|
+
input: string,
|
|
10
|
+
): RepositoryValidationResult {
|
|
11
|
+
const value = input.trim();
|
|
12
|
+
|
|
13
|
+
if (!value) {
|
|
14
|
+
return { ok: false, error: "Enter a GitHub repository." };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (value.length > 200) {
|
|
18
|
+
return { ok: false, error: "Repository input is too long." };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const path = extractRepositoryPath(value);
|
|
22
|
+
if (!path) {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
error: "Use owner/name or https://github.com/owner/name.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const normalizedPath = path.endsWith(".git") ? path.slice(0, -4) : path;
|
|
30
|
+
const parts = normalizedPath.split("/");
|
|
31
|
+
if (parts.length !== 2) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: "Use owner/name or https://github.com/owner/name.",
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const [owner, repo] = parts;
|
|
39
|
+
if (!owner || !repo || !OWNER_PATTERN.test(owner) || !REPO_PATTERN.test(repo)) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: "Repository must be a valid GitHub owner/name pair.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (repo === "." || repo === "..") {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
error: "Repository must be a valid GitHub owner/name pair.",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { ok: true, repository: `${owner}/${repo}` };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function extractRepositoryPath(value: string): string | null {
|
|
57
|
+
if (/^https?:\/\//i.test(value)) {
|
|
58
|
+
try {
|
|
59
|
+
const url = new URL(value);
|
|
60
|
+
if (
|
|
61
|
+
url.hostname.toLowerCase() !== "github.com" ||
|
|
62
|
+
url.username ||
|
|
63
|
+
url.password ||
|
|
64
|
+
url.search ||
|
|
65
|
+
url.hash
|
|
66
|
+
) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return url.pathname.replace(/^\/|\/$/g, "");
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (value.includes(":") || value.includes("\\") || value.includes("?")) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return value.replace(/^\/|\/$/g, "");
|
|
81
|
+
}
|
|
@@ -1,8 +1,45 @@
|
|
|
1
1
|
import type { NextConfig } from "next";
|
|
2
2
|
import { withEve } from "eve/next";
|
|
3
3
|
|
|
4
|
-
const
|
|
4
|
+
const securityHeaders = [
|
|
5
|
+
{ key: "Content-Security-Policy", value: csp() },
|
|
6
|
+
{ key: "Referrer-Policy", value: "no-referrer" },
|
|
7
|
+
{ key: "X-Content-Type-Options", value: "nosniff" },
|
|
8
|
+
{ key: "X-Frame-Options", value: "DENY" },
|
|
9
|
+
{
|
|
10
|
+
key: "Permissions-Policy",
|
|
11
|
+
value: "camera=(), microphone=(), geolocation=(), payment=()",
|
|
12
|
+
},
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const nextConfig: NextConfig = {
|
|
16
|
+
async headers() {
|
|
17
|
+
return [
|
|
18
|
+
{
|
|
19
|
+
headers: securityHeaders,
|
|
20
|
+
source: "/:path*",
|
|
21
|
+
},
|
|
22
|
+
];
|
|
23
|
+
},
|
|
24
|
+
};
|
|
5
25
|
|
|
6
26
|
// Mounts the eve agent (agent/) and the dashboard as a single Vercel deployment.
|
|
7
27
|
// Schedules under agent/schedules become Vercel Cron Jobs automatically.
|
|
8
28
|
export default withEve(nextConfig);
|
|
29
|
+
|
|
30
|
+
function csp() {
|
|
31
|
+
return [
|
|
32
|
+
"default-src 'self'",
|
|
33
|
+
"base-uri 'self'",
|
|
34
|
+
"connect-src 'self'",
|
|
35
|
+
"form-action 'self'",
|
|
36
|
+
"frame-ancestors 'none'",
|
|
37
|
+
"img-src 'self' data: blob:",
|
|
38
|
+
"object-src 'none'",
|
|
39
|
+
"script-src 'self' 'unsafe-inline' 'unsafe-eval'",
|
|
40
|
+
"style-src 'self' 'unsafe-inline'",
|
|
41
|
+
process.env.NODE_ENV === "production" ? "upgrade-insecure-requests" : "",
|
|
42
|
+
]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.join("; ");
|
|
45
|
+
}
|