penthera 1.5.0 → 1.6.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 +170 -0
- package/README.md +128 -663
- package/bin/scan.js +19 -0
- package/docs/commands/README.md +23 -0
- package/docs/commands/action.md +21 -0
- package/docs/commands/agents.md +38 -0
- package/docs/commands/deps.md +115 -0
- package/docs/commands/fix.md +53 -0
- package/docs/commands/hook.md +69 -0
- package/docs/commands/install.md +53 -0
- package/docs/commands/scan.md +100 -0
- package/docs/commands/skill.md +58 -0
- package/docs/compare/README.md +25 -0
- package/docs/compare/github.md +60 -0
- package/docs/compare/semgrep.md +54 -0
- package/docs/compare/snyk.md +62 -0
- package/docs/compare/socket.md +78 -0
- package/docs/compare/zap.md +59 -0
- package/docs/dashboard.md +244 -0
- package/docs/evidence.md +8 -4
- package/docs/examples/github-actions-deps.yml +4 -4
- package/docs/examples/github-actions-scan.yml +4 -4
- package/docs/index.md +9 -0
- package/docs/internal-audit.md +83 -0
- package/docs/marketing.md +151 -0
- package/docs/plan.md +4 -3
- package/docs/privacy.md +38 -0
- package/docs/references.bib +10 -0
- package/docs/references.md +1 -0
- package/docs/research/socket-positioning-notes.md +183 -0
- package/docs/use-cases/README.md +14 -0
- package/docs/use-cases/agencies.md +71 -0
- package/docs/use-cases/ai-coding-teams.md +81 -0
- package/docs/use-cases/eu-sme-compliance.md +68 -0
- package/docs/use-cases/open-source-maintainers.md +75 -0
- package/docs/use-cases/solo-founder.md +65 -0
- package/lib/blackbox/email-dns.js +5 -0
- package/lib/compliance/questionnaire.js +4 -2
- package/lib/deps/capabilities.js +49 -12
- package/lib/deps/index.js +290 -30
- package/lib/deps/lockfile.js +25 -2
- package/lib/deps/popular-pypi.json +1 -0
- package/lib/deps/python/capabilities.js +447 -0
- package/lib/deps/python/index.js +234 -0
- package/lib/deps/python/lockfiles.js +359 -0
- package/lib/deps/python/source.js +396 -0
- package/lib/deps/python/toml.js +219 -0
- package/lib/deps/report.js +37 -6
- package/lib/deps/source.js +149 -24
- package/lib/deps/typosquat.js +24 -13
- package/lib/fix/apply.js +83 -19
- package/lib/hook/index.js +41 -8
- package/lib/install/index.js +59 -18
- package/lib/install/policy.js +17 -5
- package/lib/memory/repo-suppress.js +11 -3
- package/lib/references.json +77 -0
- package/lib/remediation/index.js +9 -0
- package/lib/retirejs.js +69 -17
- package/lib/rules/regex-guard.js +368 -0
- package/lib/templates.js +13 -6
- package/lib/tls.js +7 -0
- package/lib/whitebox/frameworks.js +8 -5
- package/lib/whitebox/secrets.js +20 -7
- package/package.json +7 -3
- package/skills/penthera/SKILL.md +23 -5
- package/src/cli/deps.js +60 -5
- package/src/cli/evidence.js +14 -4
- package/src/cli/install.js +14 -5
- package/src/cli/report-outputs.js +25 -2
- package/src/cli/ui.js +89 -0
- package/src/cli.js +19 -7
- package/src/report/audience.js +10 -10
- package/src/report/evidence.js +33 -14
- package/src/report/footer.js +11 -0
- package/src/report/markdown.js +6 -3
- package/src/report/md-escape.js +16 -0
- package/src/reporter.js +1 -0
- package/src/scan-url.js +5 -1
- package/src/ui/api/data.js +309 -0
- package/src/ui/api/demo.js +464 -0
- package/src/ui/api/routes.js +134 -0
- package/src/ui/api/runs.js +243 -0
- package/src/ui/api/summarize.js +451 -0
- package/src/ui/security.js +153 -0
- package/src/ui/server.js +186 -0
- package/src/ui/static/app.css +723 -0
- package/src/ui/static/favicon.svg +1 -0
- package/src/ui/static/index.html +57 -0
- package/src/ui/static/js/api.js +29 -0
- package/src/ui/static/js/app.js +138 -0
- package/src/ui/static/js/charts.js +207 -0
- package/src/ui/static/js/copy.js +79 -0
- package/src/ui/static/js/dom.js +120 -0
- package/src/ui/static/js/format.js +42 -0
- package/src/ui/static/js/theme.js +10 -0
- package/src/ui/static/js/typer.js +99 -0
- package/src/ui/static/js/views/ai.js +68 -0
- package/src/ui/static/js/views/deps.js +96 -0
- package/src/ui/static/js/views/evidence.js +115 -0
- package/src/ui/static/js/views/findings.js +165 -0
- package/src/ui/static/js/views/history.js +92 -0
- package/src/ui/static/js/views/overview.js +184 -0
- package/src/ui/static/js/views/run.js +349 -0
- package/src/ui/static/js/views/settings.js +67 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-line footer at the end of human-readable Markdown output: the default
|
|
3
|
+
* report, audience reports, the PR comment and the evidence bundle README.
|
|
4
|
+
* Never added to SARIF or JSON. `--no-footer` turns it off.
|
|
5
|
+
*/
|
|
6
|
+
export const FOOTER = "Checked by Penthera, open-source security for the apps and AI systems you ship · https://github.com/danoszz/penthera";
|
|
7
|
+
|
|
8
|
+
/** Lines to push at the very end of a Markdown document, or none when opted out. */
|
|
9
|
+
export function footerLines({ footer = true } = {}) {
|
|
10
|
+
return footer === false ? [] : [FOOTER, ""];
|
|
11
|
+
}
|
package/src/report/markdown.js
CHANGED
|
@@ -9,6 +9,8 @@ import { buildActionPlan, formatActionPlanMarkdown } from "../../lib/remediation
|
|
|
9
9
|
import { formatAuditLoopMarkdown } from "../../lib/audit-loop.js";
|
|
10
10
|
import { referencesFor, formatSourcesMarkdown } from "../../lib/references.js";
|
|
11
11
|
import { formatDepsMarkdown } from "../../lib/deps/report.js";
|
|
12
|
+
import { mdInline } from "./md-escape.js";
|
|
13
|
+
import { footerLines } from "./footer.js";
|
|
12
14
|
|
|
13
15
|
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
14
16
|
const SEV_TAG = {
|
|
@@ -33,9 +35,7 @@ function countBySeverity(findings) {
|
|
|
33
35
|
return counts;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
|
-
|
|
37
|
-
return String(s || "").replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
38
|
-
}
|
|
38
|
+
const mdEscape = mdInline;
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
41
|
* Generate Markdown report content from scan result.
|
|
@@ -43,6 +43,7 @@ function mdEscape(s) {
|
|
|
43
43
|
export function formatMarkdownReport(result, opts = {}) {
|
|
44
44
|
const findings = sortFindings(result.findings);
|
|
45
45
|
const counts = countBySeverity(findings);
|
|
46
|
+
// TODO(good-first-issue): the "0.2.0" fallback is stale; read the version from package.json instead.
|
|
46
47
|
const version = opts.version || "0.2.0";
|
|
47
48
|
const lines = [];
|
|
48
49
|
|
|
@@ -57,6 +58,7 @@ export function formatMarkdownReport(result, opts = {}) {
|
|
|
57
58
|
lines.push(`|---|---|`);
|
|
58
59
|
lines.push(`| **Target** | \`${result.target || "unknown"}\` |`);
|
|
59
60
|
lines.push(`| **Scan time** | ${result.timestamp?.replace("T", " ").slice(0, 19) || "-"} UTC |`);
|
|
61
|
+
// TODO(good-first-issue): a missing duration prints ", "; print "-" like the other empty cells and add a test.
|
|
60
62
|
lines.push(`| **Duration** | ${result.duration ? `${(result.duration / 1000).toFixed(1)}s` : ", "} |`);
|
|
61
63
|
lines.push(`| **Mode** | ${result.modes?.join(", ") || result.mode || "-"} |`);
|
|
62
64
|
lines.push(`| **Reachable** | ${result.reachable ? "Yes" : "No"} |`);
|
|
@@ -231,6 +233,7 @@ export function formatMarkdownReport(result, opts = {}) {
|
|
|
231
233
|
lines.push("");
|
|
232
234
|
lines.push("*Authorized security testing only. See Penthera README for ethical use guidelines.*");
|
|
233
235
|
lines.push("");
|
|
236
|
+
lines.push(...footerLines(opts));
|
|
234
237
|
|
|
235
238
|
return lines.join("\n");
|
|
236
239
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One escape for untrusted text placed inline in Markdown (table cells, list
|
|
3
|
+
* items). Finding titles, evidence and questionnaire text come from scanned
|
|
4
|
+
* targets or pasted files, so they must not break the table, inject HTML, or
|
|
5
|
+
* smuggle a backslash that un-escapes our own `\|`. Backslash goes first so the
|
|
6
|
+
* escapes added after it are not themselves doubled.
|
|
7
|
+
*/
|
|
8
|
+
export function mdInline(s) {
|
|
9
|
+
return String(s ?? "")
|
|
10
|
+
.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "")
|
|
11
|
+
.replace(/\r?\n|\r/g, " ")
|
|
12
|
+
.replace(/\\/g, "\\\\")
|
|
13
|
+
.replace(/</g, "<")
|
|
14
|
+
.replace(/>/g, ">")
|
|
15
|
+
.replace(/\|/g, "\\|");
|
|
16
|
+
}
|
package/src/reporter.js
CHANGED
|
@@ -15,6 +15,7 @@ const PKG_VERSION = JSON.parse(
|
|
|
15
15
|
|
|
16
16
|
// ── ANSI helpers ─────────────────────────────────────────────────────────
|
|
17
17
|
|
|
18
|
+
// TODO(good-first-issue): also drop colors when stdout is not a TTY (piped output gets escape codes); let FORCE_COLOR=1 override.
|
|
18
19
|
const NO_COLOR = !!process.env.NO_COLOR;
|
|
19
20
|
|
|
20
21
|
const ansi = (code) => (s) => NO_COLOR ? s : `\x1b[${code}m${s}\x1b[0m`;
|
package/src/scan-url.js
CHANGED
|
@@ -386,7 +386,11 @@ async function scanUrlInContext(target, opts) {
|
|
|
386
386
|
} catch { /* non-critical */ }
|
|
387
387
|
|
|
388
388
|
// ── Phase 9b: Email authentication posture (SPF/DMARC via DNS) ─────────
|
|
389
|
-
|
|
389
|
+
// DNS TXT/MX queries go to resolvers, not the target, so --offline skips them.
|
|
390
|
+
if (!local && !isIpHost && opts.offline) {
|
|
391
|
+
progress("Skipping email authentication (--offline: no DNS lookups for SPF, DMARC, MX)...");
|
|
392
|
+
}
|
|
393
|
+
if (!local && !isIpHost && !opts.offline) {
|
|
390
394
|
progress("Checking email authentication (SPF, DMARC)...");
|
|
391
395
|
try {
|
|
392
396
|
const emailResult = await probeEmailDns(extractDomain(target));
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's data source for a real machine: report JSON files in the
|
|
3
|
+
* chosen reports dir, evidence bundles below it, and the local learning store.
|
|
4
|
+
*
|
|
5
|
+
* File access rule: only paths inside the chosen roots (project dirs, reports
|
|
6
|
+
* dir, Penthera home) are ever read. Symlinks are skipped, every path is
|
|
7
|
+
* realpath'd and re-checked, and no API takes a file path from the browser:
|
|
8
|
+
* findings and bundles are addressed by ids this module hands out.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, lstatSync, realpathSync, existsSync, statSync, mkdirSync, openSync, writeSync, closeSync, constants as FS } from "node:fs";
|
|
11
|
+
import { join, resolve, relative, sep, isAbsolute, dirname } from "node:path";
|
|
12
|
+
import { createHash } from "node:crypto";
|
|
13
|
+
import { openMemory, buildViews, buildHistory } from "../../../lib/memory/index.js";
|
|
14
|
+
import { referencesFor } from "../../../lib/references.js";
|
|
15
|
+
import { planFixes, redactPlan } from "../../../lib/fix/index.js";
|
|
16
|
+
import { verifyEvidence, MANIFEST, SIGNATURE, SCHEMA as EVIDENCE_SCHEMA } from "../../report/evidence.js";
|
|
17
|
+
import { writeEvidence } from "../../cli/report-outputs.js";
|
|
18
|
+
import { formatAudienceReport, AUDIENCES } from "../../report/audience.js";
|
|
19
|
+
import {
|
|
20
|
+
classify, buildOverview, buildFindings, locateFinding, findingDetail, buildDeps, buildAi, memoryStats, loopSummary,
|
|
21
|
+
explainFinding, buildScanHistory, latestScan, slug,
|
|
22
|
+
} from "./summarize.js";
|
|
23
|
+
|
|
24
|
+
const MAX_REPORT_BYTES = 25 * 1024 * 1024;
|
|
25
|
+
const MAX_FILES = 400;
|
|
26
|
+
const CACHE_MS = 1500;
|
|
27
|
+
|
|
28
|
+
/** True when `p` is `root` or inside it (both absolute, already resolved). */
|
|
29
|
+
export function isInside(root, p) {
|
|
30
|
+
const rel = relative(root, p);
|
|
31
|
+
return rel === "" || (!!rel && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Resolve real paths for the allowed roots (missing roots are kept as given). */
|
|
35
|
+
export function realRoots(paths) {
|
|
36
|
+
return [...new Set(paths.filter(Boolean).map((p) => {
|
|
37
|
+
const abs = resolve(p);
|
|
38
|
+
try { return realpathSync(abs); } catch { return abs; }
|
|
39
|
+
}))];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a user-supplied directory against the allowed roots. Returns the
|
|
44
|
+
* real path, or throws with a message that names the fix.
|
|
45
|
+
*/
|
|
46
|
+
export function confineDir(input, roots) {
|
|
47
|
+
if (typeof input !== "string" || !input.trim() || input.length > 4096 || input.includes("\0")) {
|
|
48
|
+
throw new Error("Enter a project folder path.");
|
|
49
|
+
}
|
|
50
|
+
let real;
|
|
51
|
+
try {
|
|
52
|
+
real = realpathSync(resolve(roots[0] || ".", input.trim()));
|
|
53
|
+
} catch {
|
|
54
|
+
throw new Error(`Folder not found: ${input}`);
|
|
55
|
+
}
|
|
56
|
+
if (!statSync(real).isDirectory()) throw new Error(`Not a folder: ${input}`);
|
|
57
|
+
if (!roots.some((r) => isInside(r, real))) {
|
|
58
|
+
throw new Error("That folder is outside the project folders this dashboard was started with. Restart with `penthera ui --project <dir>`.");
|
|
59
|
+
}
|
|
60
|
+
return real;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Throws unless no existing component of `target` below `root` is a symlink.
|
|
65
|
+
* The same rule lib/fix/apply.js applies to fix diffs: a planted symlink
|
|
66
|
+
* (reports/evidence -> ~/.ssh) must never redirect a write out of the folder.
|
|
67
|
+
*/
|
|
68
|
+
export function assertNoSymlinks(root, target) {
|
|
69
|
+
const rel = relative(root, target);
|
|
70
|
+
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error(`Refusing to write ${target}: it is outside ${root}`);
|
|
71
|
+
let cur = root;
|
|
72
|
+
for (const seg of rel.split(sep).filter(Boolean)) {
|
|
73
|
+
cur = join(cur, seg);
|
|
74
|
+
let st;
|
|
75
|
+
try { st = lstatSync(cur); } catch (e) { if (e.code === "ENOENT") return; throw e; }
|
|
76
|
+
if (st.isSymbolicLink()) throw new Error(`Refusing to write through a symlink: ${relative(root, cur)}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Write a new file in `dir` (which must be `root` or below it): exclusive
|
|
82
|
+
* create, never through a symlink, never over an existing file. A name is a
|
|
83
|
+
* single path segment.
|
|
84
|
+
*/
|
|
85
|
+
export function writeNewFile(root, dir, name, content) {
|
|
86
|
+
if (typeof name !== "string" || !name || name.includes("/") || name.includes("\\") || name.includes("\0") || name === "." || name === "..") {
|
|
87
|
+
throw new Error(`Bad file name ${JSON.stringify(name)}`);
|
|
88
|
+
}
|
|
89
|
+
const target = join(dir, name);
|
|
90
|
+
assertNoSymlinks(root, target);
|
|
91
|
+
mkdirSync(dir, { recursive: true });
|
|
92
|
+
assertNoSymlinks(root, target); // again, after mkdir
|
|
93
|
+
const fd = openSync(target, FS.O_WRONLY | FS.O_CREAT | FS.O_EXCL | (FS.O_NOFOLLOW || 0), 0o644);
|
|
94
|
+
try {
|
|
95
|
+
const buf = Buffer.from(content, "utf-8");
|
|
96
|
+
let off = 0;
|
|
97
|
+
while (off < buf.length) off += writeSync(fd, buf, off, buf.length - off);
|
|
98
|
+
} finally {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
return target;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const idFor = (s) => createHash("sha256").update(s).digest("hex").slice(0, 16);
|
|
105
|
+
|
|
106
|
+
export function createDataSource({ projects, reportsDir, home, memoryRoot, settings, version = "dev" }) {
|
|
107
|
+
const projectRoots = realRoots(projects);
|
|
108
|
+
const reportsReal = realRoots([reportsDir])[0];
|
|
109
|
+
const roots = realRoots([...projects, reportsDir, home]);
|
|
110
|
+
let cache = null;
|
|
111
|
+
|
|
112
|
+
function safeRead(path) {
|
|
113
|
+
let real;
|
|
114
|
+
try { real = realpathSync(path); } catch { return null; }
|
|
115
|
+
if (!roots.some((r) => isInside(r, real))) return null;
|
|
116
|
+
const st = lstatSync(real);
|
|
117
|
+
if (!st.isFile() || st.size > MAX_REPORT_BYTES) return null;
|
|
118
|
+
return readFileSync(real);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Report files (top level of the reports dir) and evidence bundles (up to two levels down). */
|
|
122
|
+
function scan() {
|
|
123
|
+
if (cache && Date.now() - cache.at < CACHE_MS) return cache;
|
|
124
|
+
const out = { scans: [], deps: [], agents: [], installs: [], loops: [], bundles: [], at: Date.now() };
|
|
125
|
+
if (!existsSync(reportsReal)) { cache = out; return out; }
|
|
126
|
+
let seen = 0;
|
|
127
|
+
const walk = (dir, depth) => {
|
|
128
|
+
let entries;
|
|
129
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
130
|
+
if (entries.some((e) => e.isFile() && e.name === MANIFEST) && depth > 0) {
|
|
131
|
+
out.bundles.push(dir);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const e of entries) {
|
|
135
|
+
if (seen >= MAX_FILES) return;
|
|
136
|
+
if (e.isSymbolicLink()) continue;
|
|
137
|
+
const p = join(dir, e.name);
|
|
138
|
+
if (e.isDirectory() && depth < 2 && !e.name.startsWith(".") && e.name !== "node_modules") walk(p, depth + 1);
|
|
139
|
+
else if (e.isFile() && depth === 0 && e.name.endsWith(".json")) {
|
|
140
|
+
seen++;
|
|
141
|
+
const buf = safeRead(p);
|
|
142
|
+
if (!buf) continue;
|
|
143
|
+
let data;
|
|
144
|
+
try { data = JSON.parse(buf.toString("utf-8")); } catch { continue; }
|
|
145
|
+
const kind = classify(data);
|
|
146
|
+
if (!kind) continue;
|
|
147
|
+
const rec = { name: e.name, mtime: statSync(p).mtimeMs, data };
|
|
148
|
+
({ scan: out.scans, deps: out.deps, agents: out.agents, install: out.installs, loop: out.loops })[kind].push(rec);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
walk(reportsReal, 0);
|
|
153
|
+
const byTime = (a, b) => (Date.parse(a.data.timestamp || a.data.loopRun?.startedAt) || a.mtime) - (Date.parse(b.data.timestamp || b.data.loopRun?.startedAt) || b.mtime);
|
|
154
|
+
for (const k of ["scans", "deps", "agents", "installs", "loops"]) out[k].sort(byTime);
|
|
155
|
+
cache = out;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Every memory project under the memory root, verified; a broken chain shows as broken, never as data. */
|
|
160
|
+
function memory() {
|
|
161
|
+
if (!existsSync(memoryRoot)) return [];
|
|
162
|
+
const list = [];
|
|
163
|
+
for (const e of readdirSync(memoryRoot, { withFileTypes: true })) {
|
|
164
|
+
if (!e.isDirectory() || !/^[0-9a-f]{64}$/.test(e.name)) continue;
|
|
165
|
+
const mem = openMemory(e.name, { root: memoryRoot });
|
|
166
|
+
const chain = mem.verify();
|
|
167
|
+
const events = chain.ok ? mem.events() : [];
|
|
168
|
+
list.push(memoryStats({
|
|
169
|
+
id: e.name,
|
|
170
|
+
label: mem.meta()?.label,
|
|
171
|
+
chain,
|
|
172
|
+
views: chain.ok ? buildViews(events) : null,
|
|
173
|
+
history: chain.ok ? buildHistory(events) : null,
|
|
174
|
+
}));
|
|
175
|
+
}
|
|
176
|
+
return list;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
demo: false,
|
|
181
|
+
roots,
|
|
182
|
+
projectRoots,
|
|
183
|
+
|
|
184
|
+
meta() {
|
|
185
|
+
return { reportsDir: reportsReal, projects: projectRoots, memoryRoot };
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
overview() {
|
|
189
|
+
const s = scan();
|
|
190
|
+
return buildOverview({ scans: s.scans, memory: memory(), runs: [...s.loops].reverse() });
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
findings() {
|
|
194
|
+
return buildFindings(scan().scans);
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
async finding(id, project) {
|
|
198
|
+
const hit = locateFinding(scan().scans, id, project);
|
|
199
|
+
if (!hit) return null;
|
|
200
|
+
const detail = findingDetail(hit.finding);
|
|
201
|
+
const sources = referencesFor(hit.finding).map((r) => ({ cite: r.cite, url: r.url }));
|
|
202
|
+
let fix = null;
|
|
203
|
+
// A fix preview needs the repo the finding came from, and only inside a chosen project dir.
|
|
204
|
+
const target = typeof hit.report.data.target === "string" ? hit.report.data.target : null;
|
|
205
|
+
const repo = hit.report.data.repo || (target && !/^https?:/i.test(target) ? target : null);
|
|
206
|
+
if (repo) {
|
|
207
|
+
try {
|
|
208
|
+
const dir = confineDir(repo, projectRoots);
|
|
209
|
+
const { plans } = await planFixes(dir, [hit.finding]);
|
|
210
|
+
if (plans[0]) {
|
|
211
|
+
const p = redactPlan(plans[0]);
|
|
212
|
+
fix = { recipe: p.recipe, title: p.title, confidence: p.confidence, files: p.files, diff: p.diff.slice(0, 20000) };
|
|
213
|
+
}
|
|
214
|
+
} catch { /* no preview outside the project dirs */ }
|
|
215
|
+
}
|
|
216
|
+
return { ...detail, id, project, sources, fix, file: hit.report.name, explain: explainFinding(hit.finding, hit.report.data) };
|
|
217
|
+
},
|
|
218
|
+
|
|
219
|
+
history(project, a, b) {
|
|
220
|
+
return buildScanHistory(scan().scans, project, a, b);
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/** "Export evidence for an auditor": a bundle from the latest report of a project, into <reports>/evidence/. */
|
|
224
|
+
createEvidence(project) {
|
|
225
|
+
const latest = latestScan(scan().scans, project);
|
|
226
|
+
if (!latest) return null;
|
|
227
|
+
const dir = join(reportsReal, "evidence", `${slug(project)}-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-")}`);
|
|
228
|
+
if (dirname(dirname(dir)) !== reportsReal) return null;
|
|
229
|
+
assertNoSymlinks(reportsReal, dir); // reports/evidence must not be a planted symlink
|
|
230
|
+
writeEvidence(latest.data, { evidence: dir }, { version, mdOpts: { version }, argv: ["ui", "evidence", "create"] });
|
|
231
|
+
cache = null;
|
|
232
|
+
return { id: idFor(dir), path: relative(reportsReal, dir) };
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
/** The developer, owner or auditor report for the latest scan of a project, as Markdown. */
|
|
236
|
+
audienceReport(project, audience) {
|
|
237
|
+
if (!AUDIENCES.includes(audience)) return null;
|
|
238
|
+
const latest = latestScan(scan().scans, project);
|
|
239
|
+
return latest ? formatAudienceReport(latest.data, audience, { version }) : null;
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
deps() {
|
|
243
|
+
const s = scan();
|
|
244
|
+
return buildDeps({ deps: s.deps, installs: s.installs });
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
ai() {
|
|
248
|
+
return buildAi(scan().agents);
|
|
249
|
+
},
|
|
250
|
+
|
|
251
|
+
memory,
|
|
252
|
+
|
|
253
|
+
runs() {
|
|
254
|
+
return [...scan().loops].reverse().slice(0, 20).map((r) => loopSummary(r.data));
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
evidence() {
|
|
258
|
+
return scan().bundles.map((dir) => {
|
|
259
|
+
let manifest = null;
|
|
260
|
+
try { manifest = JSON.parse(safeRead(join(dir, MANIFEST))?.toString("utf-8") || "null"); } catch { /* shown as unreadable */ }
|
|
261
|
+
const ok = manifest?.schema === EVIDENCE_SCHEMA;
|
|
262
|
+
return {
|
|
263
|
+
id: idFor(dir),
|
|
264
|
+
path: relative(reportsReal, dir) || ".",
|
|
265
|
+
target: ok ? String(manifest.target ?? "").slice(0, 500) : null,
|
|
266
|
+
generatedAt: ok ? manifest.generated_at || null : null,
|
|
267
|
+
tool: ok ? `${manifest.tool?.name || "penthera"} ${manifest.tool?.version || ""}`.trim() : null,
|
|
268
|
+
artifacts: ok && Array.isArray(manifest.artifacts) ? manifest.artifacts.length : 0,
|
|
269
|
+
signed: existsSync(join(dir, SIGNATURE)),
|
|
270
|
+
readable: ok,
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
verifyEvidence(id) {
|
|
276
|
+
const dir = scan().bundles.find((d) => idFor(d) === id);
|
|
277
|
+
if (!dir) return null;
|
|
278
|
+
const r = verifyEvidence(dir);
|
|
279
|
+
return {
|
|
280
|
+
id,
|
|
281
|
+
ok: r.ok,
|
|
282
|
+
problems: r.problems.slice(0, 50),
|
|
283
|
+
files: r.files.slice(0, 200).map((f) => ({ path: f.path, ok: f.ok, reason: f.reason || null })),
|
|
284
|
+
signature: r.signature ? { ok: r.signature.ok, status: r.signature.status, detail: r.signature.detail } : null,
|
|
285
|
+
};
|
|
286
|
+
},
|
|
287
|
+
|
|
288
|
+
/** Persist a finished loop run next to the other reports. */
|
|
289
|
+
saveLoopRun(loopRun) {
|
|
290
|
+
try {
|
|
291
|
+
const name = `loop-${String(loopRun.startedAt || new Date().toISOString()).replace(/[:.]/g, "-")}.json`;
|
|
292
|
+
writeNewFile(reportsReal, reportsReal, name, JSON.stringify({ loopRun }, null, 2) + "\n");
|
|
293
|
+
cache = null;
|
|
294
|
+
return name;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/** A scan, deps or agents report from a run started here: a new file, never through a symlink. */
|
|
301
|
+
writeReport(name, result) {
|
|
302
|
+
writeNewFile(reportsReal, reportsReal, name, JSON.stringify(result, null, 2) + "\n");
|
|
303
|
+
cache = null;
|
|
304
|
+
return name;
|
|
305
|
+
},
|
|
306
|
+
|
|
307
|
+
settings,
|
|
308
|
+
};
|
|
309
|
+
}
|