penthera 1.7.0 → 1.8.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/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
|
@@ -144,24 +144,77 @@ export function bigGrade(letter) {
|
|
|
144
144
|
return h("pre", { class: `grade-big grade-${letter}`, "aria-hidden": "true" }, rows.join("\n"));
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
// The
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
147
|
+
// ── The halftone field ──────────────────────────────────────────────────
|
|
148
|
+
//
|
|
149
|
+
// The one strange element: a field of halftone dots whose density is the
|
|
150
|
+
// open risk. A clean project shows plain paper; a critical one is dark with
|
|
151
|
+
// dots. The blobs are placed by a seed (the project key), so each project
|
|
152
|
+
// keeps its own shape between visits. Drawn once per size or theme change,
|
|
153
|
+
// never animated.
|
|
154
|
+
|
|
155
|
+
function seeded(str) {
|
|
156
|
+
let a = 2166136261;
|
|
157
|
+
for (const c of String(str)) a = Math.imul(a ^ c.charCodeAt(0), 16777619);
|
|
158
|
+
return () => {
|
|
159
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
160
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
161
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
162
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Risk 0 (nothing open) to 4 (something critical), from severity counts. */
|
|
167
|
+
export function riskLevel(counts) {
|
|
168
|
+
if (!counts) return 0;
|
|
169
|
+
if (counts.critical) return 4;
|
|
170
|
+
if (counts.high) return 3;
|
|
171
|
+
if (counts.medium) return 2;
|
|
172
|
+
if (counts.low || counts.info) return 1;
|
|
173
|
+
return 0;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function halftone({ risk = 0, seed = "penthera", label = "" } = {}) {
|
|
177
|
+
const canvas = h("canvas", { class: "halftone", "aria-hidden": "true", dataset: { risk: String(risk) } });
|
|
178
|
+
const wrap = h("div", { class: "halftone-wrap", role: label ? "img" : null, "aria-label": label || null }, canvas);
|
|
179
|
+
if (!risk) return wrap;
|
|
180
|
+
const rnd = seeded(seed);
|
|
181
|
+
const blobs = Array.from({ length: 2 + risk }, () => ({ x: rnd(), y: rnd(), r: 0.12 + rnd() * 0.22 }));
|
|
182
|
+
const strength = [0, 0.35, 0.55, 0.75, 1][risk];
|
|
183
|
+
const draw = () => {
|
|
184
|
+
if (!canvas.isConnected) { ro.disconnect(); mo.disconnect(); return; }
|
|
185
|
+
const w = wrap.clientWidth;
|
|
186
|
+
const hgt = wrap.clientHeight;
|
|
187
|
+
if (!w || !hgt) return;
|
|
188
|
+
const dpr = window.devicePixelRatio || 1;
|
|
189
|
+
canvas.width = Math.round(w * dpr);
|
|
190
|
+
canvas.height = Math.round(hgt * dpr);
|
|
191
|
+
const g = canvas.getContext("2d");
|
|
192
|
+
g.scale(dpr, dpr);
|
|
193
|
+
g.clearRect(0, 0, w, hgt);
|
|
194
|
+
g.fillStyle = getComputedStyle(canvas).color;
|
|
195
|
+
const cell = 6;
|
|
196
|
+
const span = Math.max(w, hgt);
|
|
197
|
+
for (let y = cell / 2; y < hgt; y += cell) {
|
|
198
|
+
for (let x = cell / 2; x < w; x += cell) {
|
|
199
|
+
let v = 0;
|
|
200
|
+
for (const b of blobs) {
|
|
201
|
+
const dx = (x - b.x * w) / (b.r * span);
|
|
202
|
+
const dy = (y - b.y * hgt) / (b.r * span);
|
|
203
|
+
v = Math.max(v, Math.exp(-(dx * dx + dy * dy) * 2.2));
|
|
204
|
+
}
|
|
205
|
+
const r = v * strength * cell * 0.48;
|
|
206
|
+
if (r < 0.45) continue;
|
|
207
|
+
g.beginPath();
|
|
208
|
+
g.arc(x, y, r, 0, Math.PI * 2);
|
|
209
|
+
g.fill();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
const ro = new ResizeObserver(draw);
|
|
214
|
+
const mo = new MutationObserver(draw);
|
|
215
|
+
ro.observe(wrap);
|
|
216
|
+
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
|
|
217
|
+
return wrap;
|
|
165
218
|
}
|
|
166
219
|
|
|
167
220
|
/**
|
|
@@ -205,3 +258,31 @@ export function ganttChart(rows, scans, { width = 1100 } = {}) {
|
|
|
205
258
|
});
|
|
206
259
|
return svg;
|
|
207
260
|
}
|
|
261
|
+
|
|
262
|
+
const CONFIDENCE = [["confirmed", "Confirmed"], ["likely", "Likely"], ["needs-human-review", "Needs a person"]];
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Triage matrix: severity down, how sure across. Each cell is a button with
|
|
266
|
+
* its count; the cell is filled with the severity's pattern when it has
|
|
267
|
+
* findings, so the dangerous, certain corner reads first. Picking a cell
|
|
268
|
+
* filters the list; picking it again clears the filter.
|
|
269
|
+
*/
|
|
270
|
+
export function matrix(findings, current, onPick) {
|
|
271
|
+
const table = h("table", { class: "matrix" });
|
|
272
|
+
table.append(h("thead", {}, h("tr", {}, h("th", { scope: "col" }, h("span", { class: "sr" }, "Severity")), CONFIDENCE.map(([, label]) => h("th", { scope: "col" }, label)))));
|
|
273
|
+
const body = h("tbody");
|
|
274
|
+
for (const sev of SEVERITIES) {
|
|
275
|
+
const row = h("tr", {}, h("th", { scope: "row" }, h("span", { class: `sev sev-${sev}` }, sev)));
|
|
276
|
+
for (const [conf, label] of CONFIDENCE) {
|
|
277
|
+
const n = findings.filter((f) => f.severity === sev && (f.confidence || "likely") === conf).length;
|
|
278
|
+
const on = current && current.severity === sev && current.confidence === conf;
|
|
279
|
+
row.append(h("td", {}, h("button", {
|
|
280
|
+
type: "button", class: ["cell", n ? `fill-${sev}` : null], disabled: !n, "aria-pressed": String(Boolean(on)),
|
|
281
|
+
"aria-label": `${n} ${sev}, ${label.toLowerCase()}`, onclick: () => onPick(on ? null : { severity: sev, confidence: conf }),
|
|
282
|
+
}, h("span", { class: "n" }, n ? String(n) : "·"))));
|
|
283
|
+
}
|
|
284
|
+
body.append(row);
|
|
285
|
+
}
|
|
286
|
+
table.append(body);
|
|
287
|
+
return table;
|
|
288
|
+
}
|
package/src/ui/static/js/copy.js
CHANGED
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
// exclamation marks, verbs first on buttons, empty states name the action
|
|
4
4
|
// that fills them, errors give the cause then the fix.
|
|
5
5
|
|
|
6
|
-
export const TAGLINE = "
|
|
6
|
+
export const TAGLINE = "Your open-source cyber security agent"; // README headline
|
|
7
7
|
|
|
8
8
|
export const START = {
|
|
9
9
|
title: "Welcome",
|
|
10
10
|
intro: "No reports yet. Start with a scan of something you own :: your localhost, a preview URL, or this repo. Results land in the reports folder and this view fills in.",
|
|
11
|
+
introSimple: "Nothing checked yet. Set up your project and run a first check: it takes a minute or two, and everything stays on this computer.",
|
|
11
12
|
gate: "Only scan apps you own or have written permission to test.",
|
|
12
13
|
steps: [
|
|
13
14
|
{ kind: "scan", title: "Scan your app", body: "TLS, headers, cookies, CORS, auth, exposed APIs and leaked secrets." },
|
|
@@ -18,10 +19,10 @@ export const START = {
|
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
export const KINDS = [
|
|
21
|
-
{ kind: "scan", label: "Scan",
|
|
22
|
-
{ kind: "deps", label: "Deps",
|
|
23
|
-
{ kind: "agents", label: "AI tooling",
|
|
24
|
-
{ kind: "loop", label: "Fix loop",
|
|
22
|
+
{ kind: "scan", label: "Scan", title: "Scan a site or folder", button: "Scan" },
|
|
23
|
+
{ kind: "deps", label: "Deps", title: "Check dependencies", button: "Check dependencies" },
|
|
24
|
+
{ kind: "agents", label: "AI tooling", title: "Audit AI tooling", button: "Audit AI tooling" },
|
|
25
|
+
{ kind: "loop", label: "Fix loop", title: "Run the fix loop", button: "Run the fix loop" },
|
|
25
26
|
];
|
|
26
27
|
|
|
27
28
|
export const NOTES = {
|
|
@@ -36,8 +37,10 @@ export const EMPTY = {
|
|
|
36
37
|
runs: "No runs yet.",
|
|
37
38
|
findings: "No open findings in the latest scan.",
|
|
38
39
|
filtered: "Nothing matches the filter. Press Esc to clear it.",
|
|
39
|
-
deps: "No dependency report yet.
|
|
40
|
-
ai: "No AI tooling report yet.
|
|
40
|
+
deps: "No dependency report yet. Choose Scan ▸ Check dependencies in the menu bar.",
|
|
41
|
+
ai: "No AI tooling report yet. Choose Scan ▸ Audit AI tooling in the menu bar.",
|
|
42
|
+
inspector: "Pick a finding to read it here in plain words, with its evidence and the fix.",
|
|
43
|
+
history: "No runs for this project yet.",
|
|
41
44
|
evidence: "No bundles yet. Create one from the latest scan.",
|
|
42
45
|
memory: "No memory yet. The fix loop and scans with --memory write it.",
|
|
43
46
|
changes: "No fixes applied yet. Run the fix loop to apply one.",
|
|
@@ -48,6 +51,62 @@ export const ERR = {
|
|
|
48
51
|
};
|
|
49
52
|
|
|
50
53
|
export const KEYS = [
|
|
51
|
-
["/", "search findings"], ["j k", "next / previous finding"], ["
|
|
52
|
-
["y n q", "apply, skip, stop a diff"], ["Esc", "close the detail"],
|
|
54
|
+
["n", "new scan"], ["/", "search findings"], ["j k", "next / previous finding"], ["1-5", "switch tab (Advanced)"],
|
|
55
|
+
["[ ]", "toggle the side windows"], ["y n q", "apply, skip, stop a diff"], ["Esc", "close the detail or a menu"],
|
|
53
56
|
];
|
|
57
|
+
|
|
58
|
+
export const ABOUT = {
|
|
59
|
+
line: "Your open-source cyber security agent: the security review you never had time for. This window reads your reports and runs scans on this machine.",
|
|
60
|
+
license: "MIT",
|
|
61
|
+
type: "Departure Mono by Helena Zhang (OFL)",
|
|
62
|
+
local: "Bound to 127.0.0.1. Nothing on this page loads from another host.",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ── Simple mode: the same data, in words a non-programmer uses ──────────
|
|
66
|
+
|
|
67
|
+
/** Severity as a word anyone reads the same way. */
|
|
68
|
+
export const SEV_WORD = { critical: "Critical", high: "Serious", medium: "Worth fixing", low: "Minor", info: "Note" };
|
|
69
|
+
|
|
70
|
+
export const SIMPLE_KINDS = [
|
|
71
|
+
{ kind: "scan", title: "Check my app", body: "Looks at your app and code for security problems. Takes a minute or two.", button: "Check my app" },
|
|
72
|
+
{ kind: "loop", title: "Fix problems for me", body: "Proposes a fix for each problem it knows how to fix. You approve every change; nothing is published.", button: "Start fixing" },
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
export const SETUP = {
|
|
76
|
+
steps: 3,
|
|
77
|
+
welcome: {
|
|
78
|
+
title: "Welcome to Penthera",
|
|
79
|
+
body: [
|
|
80
|
+
"Penthera checks the apps you build for security problems, explains each one in plain words and can fix many of them for you.",
|
|
81
|
+
"Everything runs on this computer. Nothing is sent anywhere, and no change is made without your OK.",
|
|
82
|
+
],
|
|
83
|
+
gate: "Only check apps you own or have written permission to test.",
|
|
84
|
+
},
|
|
85
|
+
mode: {
|
|
86
|
+
title: "How much do you want to see?",
|
|
87
|
+
simple: { title: "Simple", body: "Is my app safe, and what do I do about it? Plain words, one list, one button at a time." },
|
|
88
|
+
advanced: { title: "Advanced", body: "Everything: dependencies, AI tooling, run history, evidence for auditors, confidence and the raw diffs." },
|
|
89
|
+
note: "You can switch any time in the Penthera menu.",
|
|
90
|
+
},
|
|
91
|
+
project: {
|
|
92
|
+
title: "Your project",
|
|
93
|
+
folder: "The folder with your app's code",
|
|
94
|
+
folderNote: "Penthera reads only this folder. To add another, start it with penthera ui --project <folder>.",
|
|
95
|
+
url: "Where your app runs (optional)",
|
|
96
|
+
urlNote: "For example the address you open while building it. Leave it empty to check only the code.",
|
|
97
|
+
check: "Check my app now",
|
|
98
|
+
skip: "Skip, look around first",
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** One sentence that answers "can I ship?" for a project or all of them. */
|
|
103
|
+
export function verdict({ counts, open, top, scanned, when }) {
|
|
104
|
+
if (!scanned) return { text: "Not checked yet.", sub: "Run a first check to see where you stand." };
|
|
105
|
+
const c = counts?.critical || 0;
|
|
106
|
+
const hi = counts?.high || 0;
|
|
107
|
+
const from = `From the check ${when}.`;
|
|
108
|
+
if (c) return { level: "stop", text: `Not ready to ship: ${c === 1 ? "1 critical problem" : `${c} critical problems`} to fix first.`, sub: from, top };
|
|
109
|
+
if (hi) return { level: "fix", text: `Fix ${hi === 1 ? "1 serious problem" : `${hi} serious problems`} before you ship.`, sub: from, top };
|
|
110
|
+
if (open) return { level: "ok", text: `Nothing blocks a launch. ${open === 1 ? "1 smaller thing is" : `${open} smaller things are`} worth fixing.`, sub: from, top };
|
|
111
|
+
return { level: "clear", text: "Nothing open. The last check found no problems.", sub: from };
|
|
112
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Floating windows: the New scan dialog and About. A native <dialog> opened
|
|
2
|
+
// with showModal(), so focus is trapped, Esc closes it and the page behind is
|
|
3
|
+
// inert. Focus returns to whatever opened it.
|
|
4
|
+
import { h, announce } from "./dom.js";
|
|
5
|
+
import { api } from "./api.js";
|
|
6
|
+
import { state, set, folderFor, liveRun, simple } from "./state.js";
|
|
7
|
+
import { shortProject } from "./format.js";
|
|
8
|
+
import { KINDS, NOTES, ABOUT, SIMPLE_KINDS } from "./copy.js";
|
|
9
|
+
|
|
10
|
+
const LOCAL = /^(localhost|127\.\d+\.\d+\.\d+|\[::1\]|[a-z0-9-]+\.localhost)$/i;
|
|
11
|
+
|
|
12
|
+
function hostOf(raw) {
|
|
13
|
+
const v = String(raw || "").trim();
|
|
14
|
+
if (!v) return null;
|
|
15
|
+
try { return new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(v) ? v : `http://${v}`).hostname; } catch { return null; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Open a modal window. Returns { el, close }. */
|
|
19
|
+
export function openWindow({ title, label, body, wide = false, striped = false }) {
|
|
20
|
+
const opener = document.activeElement;
|
|
21
|
+
const dlg = h("dialog", { class: ["window", wide ? "wide" : null], "aria-label": label || title });
|
|
22
|
+
const close = () => { if (dlg.open) dlg.close(); };
|
|
23
|
+
dlg.append(
|
|
24
|
+
h("div", { class: ["win-bar", striped ? "striped" : null] },
|
|
25
|
+
h("button", { type: "button", class: "icon close-box", "aria-label": "Close (Esc)", title: "Close Esc", onclick: close }),
|
|
26
|
+
h("span", { class: "title" }, title)),
|
|
27
|
+
h("div", { class: "window-body" }, body),
|
|
28
|
+
);
|
|
29
|
+
dlg.addEventListener("close", () => {
|
|
30
|
+
dlg.remove();
|
|
31
|
+
if (opener instanceof HTMLElement && opener.isConnected) opener.focus({ preventScroll: true });
|
|
32
|
+
});
|
|
33
|
+
// A click on the backdrop (the dialog element itself, outside its box) closes it.
|
|
34
|
+
dlg.addEventListener("click", (e) => {
|
|
35
|
+
if (e.target !== dlg) return;
|
|
36
|
+
const r = dlg.getBoundingClientRect();
|
|
37
|
+
if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) close();
|
|
38
|
+
});
|
|
39
|
+
document.body.append(dlg);
|
|
40
|
+
dlg.showModal();
|
|
41
|
+
return { el: dlg, close };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* New scan: what to run, on which folder, against which URL. The hostname of
|
|
46
|
+
* a URL that is not on this machine must be typed back before the run starts.
|
|
47
|
+
*/
|
|
48
|
+
export function newScan(ctx, { kind = "scan", project = null } = {}) {
|
|
49
|
+
const selected = project ?? (state.sel.kind === "project" ? state.sel.key : null);
|
|
50
|
+
const folders = state.meta?.projects || [];
|
|
51
|
+
const busy = liveRun();
|
|
52
|
+
|
|
53
|
+
const easy = simple();
|
|
54
|
+
const kinds = easy ? SIMPLE_KINDS : KINDS.map((k) => ({ ...k, body: NOTES[k.kind] }));
|
|
55
|
+
if (!kinds.some((k) => k.kind === kind)) kind = kinds[0].kind;
|
|
56
|
+
const radios = kinds.map((k) => {
|
|
57
|
+
const input = h("input", { type: "radio", name: "kind", value: k.kind, checked: k.kind === kind });
|
|
58
|
+
return h("label", { class: "choice" }, input,
|
|
59
|
+
h("span", {}, h("span", { class: "t1" }, k.title), h("span", { class: "note prose" }, k.body)));
|
|
60
|
+
});
|
|
61
|
+
const kindNow = () => radios.map((r) => r.querySelector("input")).find((i) => i.checked)?.value || "scan";
|
|
62
|
+
|
|
63
|
+
const folder = h("select", { id: "ns-folder", "aria-describedby": "ns-folder-note" },
|
|
64
|
+
folders.map((f) => h("option", { value: f, selected: f === folderFor(selected) }, shortProject(f))));
|
|
65
|
+
const url = h("input", { type: "text", id: "ns-url", autocomplete: "off", spellcheck: "false", placeholder: "http://localhost:3000" });
|
|
66
|
+
if (selected && /^https?:/.test(selected)) url.value = selected;
|
|
67
|
+
const urlRow = h("div", { class: "field" }, h("label", { for: "ns-url" }, easy ? "Where your app runs " : "Running app URL ", h("span", { class: "muted" }, "(optional)")), url);
|
|
68
|
+
|
|
69
|
+
const confirm = h("input", { type: "text", id: "ns-confirm", autocomplete: "off", spellcheck: "false" });
|
|
70
|
+
const confirmQ = h("label", { for: "ns-confirm", class: "prose" });
|
|
71
|
+
const confirmRow = h("div", { class: "field confirm", hidden: true }, confirmQ, confirm);
|
|
72
|
+
|
|
73
|
+
const offline = h("input", { type: "checkbox", id: "ns-offline", checked: state.meta?.settings?.offline !== false });
|
|
74
|
+
offline.addEventListener("change", async () => {
|
|
75
|
+
const s = await api.post("/api/settings", { offline: offline.checked }).catch(() => null);
|
|
76
|
+
if (!s) { offline.checked = !offline.checked; return; }
|
|
77
|
+
state.meta.settings = s;
|
|
78
|
+
set({ meta: state.meta });
|
|
79
|
+
announce(`Offline ${s.offline ? "on" : "off"}`);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const err = h("p", { class: "err", role: "alert", hidden: true });
|
|
83
|
+
const start = h("button", { type: "submit", class: "btn primary", disabled: Boolean(busy) });
|
|
84
|
+
const cancel = h("button", { type: "button", class: "btn" }, "Cancel");
|
|
85
|
+
|
|
86
|
+
const sync = () => {
|
|
87
|
+
const k = kindNow();
|
|
88
|
+
const def = kinds.find((x) => x.kind === k);
|
|
89
|
+
const usesUrl = k === "scan" || k === "loop";
|
|
90
|
+
urlRow.hidden = !usesUrl;
|
|
91
|
+
const host = usesUrl ? hostOf(url.value) : null;
|
|
92
|
+
const remote = Boolean(host && !LOCAL.test(host));
|
|
93
|
+
confirmRow.hidden = !remote;
|
|
94
|
+
if (remote) confirmQ.textContent = `${host} is not on this machine. Type its name to confirm you own it or may test it:`;
|
|
95
|
+
start.textContent = `${def.button} ▸`;
|
|
96
|
+
};
|
|
97
|
+
for (const r of radios) r.querySelector("input").addEventListener("change", sync);
|
|
98
|
+
url.addEventListener("input", sync);
|
|
99
|
+
|
|
100
|
+
const form = h("form", { class: "form", novalidate: true },
|
|
101
|
+
h("fieldset", {}, h("legend", {}, easy ? "What do you want to do?" : "What to run"), h("div", { class: "choices" }, radios)),
|
|
102
|
+
h("div", { class: "field" }, h("label", { for: "ns-folder" }, easy ? "Your app's folder" : "Project folder"), folders.length ? folder : h("p", { class: "muted" }, "No folders. Start penthera ui with --project <dir>."),
|
|
103
|
+
h("p", { id: "ns-folder-note", class: "muted prose" }, "Runs read and write only inside this folder.")),
|
|
104
|
+
urlRow,
|
|
105
|
+
confirmRow,
|
|
106
|
+
h("label", { class: "check" }, offline, h("span", {}, h("span", { class: "t1" }, "Offline"), h("span", { class: "note prose" }, " :: contact only the target. Applies to every run started here."))),
|
|
107
|
+
busy ? h("p", { class: "muted prose" }, "A run is in progress. Stop it or wait for it to finish first.") : null,
|
|
108
|
+
err,
|
|
109
|
+
h("div", { class: "actions" }, h("span", { class: "grow muted" }, "Only scan what you own or may test."), cancel, start),
|
|
110
|
+
);
|
|
111
|
+
const win = openWindow({ title: easy ? "Check my app" : "New scan", body: form });
|
|
112
|
+
cancel.addEventListener("click", win.close);
|
|
113
|
+
sync();
|
|
114
|
+
|
|
115
|
+
form.addEventListener("submit", async (e) => {
|
|
116
|
+
e.preventDefault();
|
|
117
|
+
err.hidden = true;
|
|
118
|
+
start.disabled = true;
|
|
119
|
+
const k = kindNow();
|
|
120
|
+
const usesUrl = k === "scan" || k === "loop";
|
|
121
|
+
try {
|
|
122
|
+
const r = await api.post("/api/runs", { kind: k, repo: folder.value || folderFor(selected), url: usesUrl ? url.value : "", confirm: confirm.value, maxIterations: 5 });
|
|
123
|
+
win.close();
|
|
124
|
+
await ctx.refresh();
|
|
125
|
+
set({ sel: { kind: "run", key: r.id, run: r } });
|
|
126
|
+
} catch (ex) {
|
|
127
|
+
err.textContent = ex.message;
|
|
128
|
+
err.hidden = false;
|
|
129
|
+
start.disabled = false;
|
|
130
|
+
if (ex.status === 428) confirm.focus();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
queueMicrotask(() => (radios.find((r) => r.querySelector("input").checked)?.querySelector("input"))?.focus());
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** About Penthera: the wordmark, the version and what this page is. */
|
|
137
|
+
export function about() {
|
|
138
|
+
const body = h("div", { class: "about" },
|
|
139
|
+
h("span", { class: "logotype xl", role: "img", "aria-label": "Penthera" }),
|
|
140
|
+
h("p", { class: "prose" }, ABOUT.line),
|
|
141
|
+
h("dl", { class: "kv" },
|
|
142
|
+
h("dt", {}, "version"), h("dd", {}, `v${state.meta?.version || ""}${state.meta?.demo ? " · demo data" : ""}`),
|
|
143
|
+
h("dt", {}, "license"), h("dd", {}, ABOUT.license),
|
|
144
|
+
h("dt", {}, "type"), h("dd", {}, ABOUT.type)),
|
|
145
|
+
h("p", { class: "muted prose" }, ABOUT.local),
|
|
146
|
+
);
|
|
147
|
+
const win = openWindow({ title: "About Penthera", body });
|
|
148
|
+
const ok = h("button", { type: "button", class: "btn primary", onclick: win.close }, "OK");
|
|
149
|
+
body.append(h("div", { class: "actions" }, ok));
|
|
150
|
+
ok.focus();
|
|
151
|
+
}
|
|
152
|
+
|
package/src/ui/static/js/left.js
CHANGED
|
@@ -1,83 +1,46 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
1
|
+
// The projects window: "New scan…", then every project with its grade, open
|
|
2
|
+
// findings and last scan, "All projects" first. Runs live with their project
|
|
3
|
+
// (History tab) and in the menu bar while one is going; a project with a run
|
|
4
|
+
// in progress shows a blinking half-tone square.
|
|
5
|
+
import { h, clear } from "./dom.js";
|
|
4
6
|
import { state, set, projectKeys, projectOf } from "./state.js";
|
|
5
|
-
import { ago, shortProject } from "./format.js";
|
|
7
|
+
import { ago, shortProject, plural } from "./format.js";
|
|
6
8
|
import { EMPTY } from "./copy.js";
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export function renderLeft(el, { onNewScan, toggleLeft, theme }) {
|
|
10
|
+
export function renderLeft(el, ctx) {
|
|
11
11
|
const projects = state.overview?.projects || [];
|
|
12
12
|
const keys = projectKeys();
|
|
13
13
|
const totalOpen = projects.reduce((n, p) => n + p.open, 0);
|
|
14
|
+
const running = new Set(state.runs.live.filter((r) => r.status === "running").flatMap((r) => [r.repo, r.url]).filter(Boolean));
|
|
15
|
+
const viewing = state.sel.kind === "all" || state.sel.kind === "project";
|
|
14
16
|
|
|
15
17
|
const item = (attrs, ...kids) => h("button", { type: "button", class: "item", ...attrs }, ...kids);
|
|
16
|
-
const current = (
|
|
18
|
+
const current = (key) => String(viewing && (key == null ? state.sel.kind === "all" : state.sel.key === key));
|
|
17
19
|
|
|
18
|
-
const
|
|
19
|
-
h("li", {}, item({ "aria-current": current(
|
|
20
|
+
const list = h("ul", { class: "side-list", "aria-label": "Projects" },
|
|
21
|
+
h("li", {}, item({ "aria-current": current(null), onclick: () => set({ sel: { kind: "all", key: null } }) },
|
|
20
22
|
h("span", { class: "grade" }, "∑"), h("span", { class: "name" }, "All projects"), h("span", { class: "count" }, String(totalOpen)),
|
|
21
|
-
h("span", { class: "sub" }, `${projects.length}
|
|
23
|
+
h("span", { class: "sub" }, `${plural(projects.length, "project")} · ${totalOpen} open`))),
|
|
22
24
|
keys.map((key) => {
|
|
23
25
|
const p = projectOf(key);
|
|
24
|
-
return h("li", {}, item({ "aria-current": current(
|
|
26
|
+
return h("li", {}, item({ "aria-current": current(key), title: key, onclick: () => set({ sel: { kind: "project", key } }) },
|
|
25
27
|
h("span", { class: `grade grade-${p?.grade || "x"}` }, p ? p.grade : "·"),
|
|
26
28
|
h("span", { class: "name" }, shortProject(key)),
|
|
27
|
-
h("span", { class: "count" }, p ? String(p.open) : ""),
|
|
29
|
+
running.has(key) ? h("span", { class: "dot live", role: "img", "aria-label": "a run is in progress" }) : h("span", { class: "count" }, p ? String(p.open) : ""),
|
|
28
30
|
h("span", { class: "sub" }, p ? `${p.mode || "scan"} · ${ago(p.lastScan)}` : "not scanned yet")));
|
|
29
31
|
}),
|
|
30
32
|
);
|
|
31
|
-
if (!keys.length)
|
|
32
|
-
|
|
33
|
-
// Runs: live ones from this session first, then past loop runs, grouped by project.
|
|
34
|
-
const runs = [
|
|
35
|
-
...state.runs.live.map((r) => ({ live: true, id: r.id, kind: r.kind, project: r.repo || r.url, status: r.status, at: r.startedAt, run: r })),
|
|
36
|
-
...state.runs.past.map((l, i) => ({ live: false, id: `past-${i}`, kind: "loop", project: l.target.repo || l.target.url, status: l.stopReason, at: l.startedAt, past: l })),
|
|
37
|
-
];
|
|
38
|
-
const groups = new Map();
|
|
39
|
-
for (const r of runs) {
|
|
40
|
-
const k = r.project || "unknown";
|
|
41
|
-
if (!groups.has(k)) groups.set(k, []);
|
|
42
|
-
groups.get(k).push(r);
|
|
43
|
-
}
|
|
44
|
-
const runList = h("ul", { class: "side-list", "aria-label": "Runs" });
|
|
45
|
-
for (const [project, list] of groups) {
|
|
46
|
-
runList.append(h("li", { class: "group-h", title: project }, shortProject(project)));
|
|
47
|
-
for (const r of list.slice(0, 6)) {
|
|
48
|
-
const kindSel = r.live ? "run" : "past";
|
|
49
|
-
const sum = r.past ? `${r.past.totals.verified} verified · ${r.past.findings.initial ?? "?"} → ${r.past.findings.final ?? "?"}` : r.status;
|
|
50
|
-
runList.append(h("li", {}, item({
|
|
51
|
-
"aria-current": current(kindSel, r.id),
|
|
52
|
-
onclick: () => set({ sel: { kind: kindSel, key: r.id, run: r.run || null, past: r.past || null } }),
|
|
53
|
-
},
|
|
54
|
-
h("span", { class: `dot ${r.live && r.status === "running" ? "live" : r.live ? "done" : ""}`, "aria-hidden": "true" }),
|
|
55
|
-
h("span", { class: "name" }, KIND_LABEL[r.kind] || r.kind),
|
|
56
|
-
h("span", { class: "count muted" }, ago(r.at)),
|
|
57
|
-
h("span", { class: "sub" }, sum || ""))));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (!runs.length) runList.append(h("li", { class: "group-h" }, EMPTY.runs));
|
|
33
|
+
if (!keys.length) list.append(h("li", { class: "group-h" }, EMPTY.projects));
|
|
61
34
|
|
|
62
35
|
clear(el).append(
|
|
63
|
-
h("div", { class: "
|
|
64
|
-
|
|
65
|
-
s("path", { class: "px", d: "M0 0h5v1h1v1h1v2H6v1H5v1H2v2H0zM2 2v2h3V2z" })),
|
|
66
|
-
h("span", { class: "grow" }, "penthera"),
|
|
67
|
-
h("button", { type: "button", class: "icon", "aria-label": "Hide the sidebar ([)", title: "Hide the sidebar [", onclick: toggleLeft }, "⇤"),
|
|
68
|
-
),
|
|
69
|
-
h("button", { type: "button", class: "btn new-scan", onclick: onNewScan }, "+ New scan"),
|
|
70
|
-
h("div", { class: "side-label" }, h("span", {}, "Projects")),
|
|
71
|
-
projectList,
|
|
72
|
-
h("div", { class: "side-label" }, h("span", {}, "Runs")),
|
|
73
|
-
runList,
|
|
74
|
-
h("div", { class: "side-foot" },
|
|
75
|
-
h("button", { type: "button", class: "icon", "aria-label": "Settings", title: "Settings", "aria-pressed": String(state.right === "settings"),
|
|
76
|
-
onclick: () => set({ right: state.right === "settings" ? "cards" : "settings", openRight: true }) }, "⚙"),
|
|
77
|
-
h("button", { type: "button", class: "icon", "aria-label": `Theme: ${theme.get()}. Switch`, title: "Switch theme", onclick: () => { theme.set(theme.get() === "light" ? "dark" : "light"); renderLeft(el, { onNewScan, toggleLeft, theme }); } }, theme.get() === "light" ? "◑" : "◐"),
|
|
36
|
+
h("div", { class: "win-bar" },
|
|
37
|
+
h("span", { class: "title" }, "Projects"),
|
|
78
38
|
h("span", { class: "grow" }),
|
|
79
|
-
h("
|
|
80
|
-
|
|
39
|
+
h("button", { type: "button", class: "icon", "aria-label": "Hide the projects window ([)", title: "Hide [", onclick: ctx.toggleLeft }, "⇤"),
|
|
40
|
+
),
|
|
41
|
+
h("div", { class: "pane-body" },
|
|
42
|
+
h("button", { type: "button", class: "btn new-scan", onclick: () => ctx.newScan() }, "New scan…", h("kbd", {}, "n")),
|
|
43
|
+
list,
|
|
81
44
|
),
|
|
82
45
|
);
|
|
83
46
|
}
|