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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openhacker",
3
- "version": "0.1.2",
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. 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).
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
- // Public demo: anyone can call the agent from the browser. Add real auth
5
- // (e.g. vercelOidc/localDev or your own session check) before exposing
6
- // anything sensitive.
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: React.FormEvent) {
15
+ function onSubmit(e: SyntheticEvent<HTMLFormElement>) {
13
16
  e.preventDefault();
14
- const value = repo.trim();
15
- if (!value || busy) return;
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 "${value}" for security vulnerabilities. Walk through what you check and report what you find.`,
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
- autoFocus
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
- {reply ? (
72
+ {result || busy ? (
49
73
  <section className="reply">
50
- {reply.parts.map((part, i) => {
51
- if (part.type === "reasoning") {
52
- return (
53
- <p key={i} className="reasoning">
54
- {part.text}
55
- </p>
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 nextConfig: NextConfig = {};
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
+ }