penthera 1.2.0 → 1.5.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 +269 -0
- package/README.md +493 -45
- package/docs/METHODOLOGY.md +321 -0
- package/docs/compliance-cra.md +141 -0
- package/docs/compliance-nis2.md +3 -1
- package/docs/evidence.md +158 -0
- package/docs/examples/github-actions-deps.yml +105 -0
- package/docs/examples/github-actions-scan.yml +1 -1
- package/docs/examples/pr-dependency-review.yml +56 -0
- package/docs/funding/due-diligence-checklist.md +79 -0
- package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
- package/docs/funding/partner-one-pager.md +84 -0
- package/docs/funding/sponsor-tiers.md +81 -0
- package/docs/index.md +12 -1
- package/docs/local-loop.md +276 -0
- package/docs/plan.md +100 -0
- package/docs/references.bib +539 -0
- package/docs/references.md +77 -0
- package/docs/release-plan.md +228 -0
- package/lib/adaptive-probes.js +8 -5
- package/lib/agents/discover.js +384 -0
- package/lib/agents/hidden-content.js +387 -0
- package/lib/agents/index.js +286 -0
- package/lib/agents/lock.js +115 -0
- package/lib/agents/owasp.js +83 -0
- package/lib/agents/rules.js +376 -0
- package/lib/compliance/cra.js +525 -0
- package/lib/crawler.js +5 -15
- package/lib/data/retire-jsrepository.json.gz +0 -0
- package/lib/data/retire-jsrepository.meta.json +7 -0
- package/lib/deps/capabilities.js +523 -0
- package/lib/deps/diff.js +84 -0
- package/lib/deps/index.js +440 -0
- package/lib/deps/license.js +329 -0
- package/lib/deps/lockfile.js +109 -0
- package/lib/deps/online.js +132 -0
- package/lib/deps/popular-packages.json +1 -0
- package/lib/deps/report.js +190 -0
- package/lib/deps/source.js +171 -0
- package/lib/deps/spdx.json +1 -0
- package/lib/deps/typosquat.js +113 -0
- package/lib/fix/apply.js +95 -0
- package/lib/fix/diff.js +199 -0
- package/lib/fix/index.js +129 -0
- package/lib/fix/model.js +108 -0
- package/lib/fix/recipes/cookies.js +130 -0
- package/lib/fix/recipes/cors.js +116 -0
- package/lib/fix/recipes/headers.js +250 -0
- package/lib/fix/recipes/rate-limit.js +211 -0
- package/lib/fix/recipes/secrets.js +110 -0
- package/lib/fix/util.js +231 -0
- package/lib/fuzzer.js +14 -8
- package/lib/hook/index.js +312 -0
- package/lib/hook/policy.js +62 -0
- package/lib/injections.js +19 -15
- package/lib/install/index.js +271 -0
- package/lib/install/policy.js +125 -0
- package/lib/jurisdictions/nl.json +9 -0
- package/lib/memory/history.js +52 -0
- package/lib/memory/index.js +111 -0
- package/lib/memory/repo-suppress.js +64 -0
- package/lib/memory/store.js +325 -0
- package/lib/memory/views.js +198 -0
- package/lib/params.js +4 -12
- package/lib/provenance.js +2 -0
- package/lib/recon.js +12 -20
- package/lib/references.js +31 -0
- package/lib/references.json +374 -0
- package/lib/remediation/index.js +28 -0
- package/lib/retirejs.js +50 -22
- package/lib/templates.js +7 -10
- package/lib/whitebox/aibom.js +384 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/sbom.js +45 -10
- package/lib/whitebox/secrets.js +11 -1
- package/package.json +17 -4
- package/skills/penthera/SKILL.md +132 -22
- package/src/agent/git.js +87 -0
- package/src/agent/loop.js +519 -0
- package/src/agent/tests.js +48 -0
- package/src/cli/agent.js +183 -0
- package/src/cli/deps.js +104 -0
- package/src/cli/evidence.js +67 -0
- package/src/cli/fix.js +191 -0
- package/src/cli/hook.js +131 -0
- package/src/cli/install.js +225 -0
- package/src/cli/memory.js +146 -0
- package/src/cli/merge-results.js +2 -0
- package/src/cli/report-outputs.js +72 -0
- package/src/cli/run-scan.js +2 -0
- package/src/cli.js +212 -8
- package/src/report/audience.js +270 -0
- package/src/report/evidence.js +253 -0
- package/src/report/markdown.js +65 -2
- package/src/report/model.js +212 -0
- package/src/report/template.js +175 -0
- package/src/report/timeline.js +145 -0
- package/src/reporter.js +109 -5
- package/src/scan-url.js +31 -7
- package/src/utils/http.js +252 -11
- package/src/utils/url.js +23 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional repo file `penthera.suppress.json`. This is the ONLY thing Penthera
|
|
3
|
+
* reads from a scanned repo that changes what a report shows, so it is held to
|
|
4
|
+
* a strict, data-only shape and every effect is labelled in the report as
|
|
5
|
+
* "suppressed by repo file" with its reason. A PR that adds an entry is visible
|
|
6
|
+
* in review, and so is the suppressed finding in the report.
|
|
7
|
+
*
|
|
8
|
+
* {
|
|
9
|
+
* "version": 1,
|
|
10
|
+
* "suppress": [
|
|
11
|
+
* { "id": "3f9a0c1d2b4e5f60", "reason": "test fixture key", "expires": "2027-01-01" },
|
|
12
|
+
* { "fingerprint": "repo-secret|secret|...|...", "reason": "..." }
|
|
13
|
+
* ]
|
|
14
|
+
* }
|
|
15
|
+
*/
|
|
16
|
+
import { readFileSync, existsSync, statSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export const REPO_SUPPRESS_FILE = "penthera.suppress.json";
|
|
20
|
+
const MAX_BYTES = 64 * 1024;
|
|
21
|
+
const MAX_ENTRIES = 500;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load and validate the repo file. Returns { entries, warnings }. Invalid
|
|
25
|
+
* entries are dropped with a warning, never guessed at.
|
|
26
|
+
*/
|
|
27
|
+
export function loadRepoSuppressions(repoPath, { now = new Date().toISOString() } = {}) {
|
|
28
|
+
const warnings = [];
|
|
29
|
+
const path = join(repoPath, REPO_SUPPRESS_FILE);
|
|
30
|
+
if (!existsSync(path)) return { entries: [], warnings, path: null };
|
|
31
|
+
const st = statSync(path);
|
|
32
|
+
if (!st.isFile()) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not a regular file, ignored`], path };
|
|
33
|
+
if (st.size > MAX_BYTES) return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is larger than 64 KB, ignored`], path };
|
|
34
|
+
|
|
35
|
+
let doc;
|
|
36
|
+
try { doc = JSON.parse(readFileSync(path, "utf-8")); } catch (e) {
|
|
37
|
+
return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} is not valid JSON (${e.message}), ignored`], path };
|
|
38
|
+
}
|
|
39
|
+
if (!doc || typeof doc !== "object" || doc.version !== 1 || !Array.isArray(doc.suppress)) {
|
|
40
|
+
return { entries: [], warnings: [`${REPO_SUPPRESS_FILE} needs { "version": 1, "suppress": [...] }, ignored`], path };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const nowMs = Date.parse(now);
|
|
44
|
+
const entries = [];
|
|
45
|
+
doc.suppress.slice(0, MAX_ENTRIES).forEach((e, i) => {
|
|
46
|
+
const where = `${REPO_SUPPRESS_FILE} entry ${i + 1}`;
|
|
47
|
+
if (!e || typeof e !== "object") { warnings.push(`${where}: not an object, ignored`); return; }
|
|
48
|
+
const id = typeof e.id === "string" && /^[0-9a-f]{16}$/.test(e.id) ? e.id : null;
|
|
49
|
+
const fingerprint = typeof e.fingerprint === "string" && e.fingerprint.length <= 2000 ? e.fingerprint : null;
|
|
50
|
+
if (!id && !fingerprint) { warnings.push(`${where}: needs "id" (16 hex chars) or "fingerprint", ignored`); return; }
|
|
51
|
+
const reason = typeof e.reason === "string" ? e.reason.trim() : "";
|
|
52
|
+
if (!reason || reason.length > 500) { warnings.push(`${where}: needs a "reason" (1-500 chars), ignored`); return; }
|
|
53
|
+
let expires = null;
|
|
54
|
+
if (e.expires != null) {
|
|
55
|
+
const t = Date.parse(e.expires);
|
|
56
|
+
if (typeof e.expires !== "string" || Number.isNaN(t)) { warnings.push(`${where}: "expires" is not a date, ignored`); return; }
|
|
57
|
+
if (t <= nowMs) { warnings.push(`${where}: expired on ${e.expires}, not applied`); return; }
|
|
58
|
+
expires = e.expires;
|
|
59
|
+
}
|
|
60
|
+
entries.push({ id, fingerprint, reason, expires, actor: "repo" });
|
|
61
|
+
});
|
|
62
|
+
if (doc.suppress.length > MAX_ENTRIES) warnings.push(`${REPO_SUPPRESS_FILE}: only the first ${MAX_ENTRIES} entries are read`);
|
|
63
|
+
return { entries, warnings, path };
|
|
64
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local learning store: an append-only, hash-chained event log per project.
|
|
3
|
+
*
|
|
4
|
+
* ~/.penthera/memory/<project-id>/events.jsonl one event per line
|
|
5
|
+
* ~/.penthera/memory/<project-id>/head.json { count, hash } of the tip
|
|
6
|
+
* ~/.penthera/memory/<project-id>/meta.json human label for `memory show`
|
|
7
|
+
*
|
|
8
|
+
* Each line is { v, ts, type, data, prev, hash } with
|
|
9
|
+
* hash = sha256(prev + canonicalJson({ v, ts, type, data }))
|
|
10
|
+
* and the first event's prev bound to the project id, so a log copied from
|
|
11
|
+
* another project does not verify either.
|
|
12
|
+
*
|
|
13
|
+
* Safety rules (see docs/local-loop.md):
|
|
14
|
+
* - Memory is data. Nothing read from it is executed, evaluated, imported, or
|
|
15
|
+
* used as a file path or shell command. Views only re-order and suppress.
|
|
16
|
+
* - Memory lives under the user's home (or PENTHERA_HOME), never in the
|
|
17
|
+
* scanned repo, so a repo cannot ship pre-poisoned memory.
|
|
18
|
+
* - Writes take an exclusive lock file, then append one line with a single
|
|
19
|
+
* O_APPEND write, so concurrent scans neither interleave nor fork the chain.
|
|
20
|
+
*/
|
|
21
|
+
import { createHash } from "node:crypto";
|
|
22
|
+
import {
|
|
23
|
+
openSync, writeSync, closeSync, readFileSync, writeFileSync, renameSync,
|
|
24
|
+
mkdirSync, existsSync, rmSync, statSync, unlinkSync, constants as FS,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join, resolve, dirname } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
|
|
29
|
+
export const EVENT_VERSION = 1;
|
|
30
|
+
export const EVENT_TYPES = Object.freeze([
|
|
31
|
+
"scan", "fix_applied", "fix_verified", "fix_failed", "fp_suppressed", "probe_yield", "loop_run",
|
|
32
|
+
]);
|
|
33
|
+
const MAX_LINE_BYTES = 256 * 1024;
|
|
34
|
+
const LOCK_STALE_MS = 30_000;
|
|
35
|
+
const LOCK_TIMEOUT_MS = 10_000;
|
|
36
|
+
|
|
37
|
+
export function sha256(s) {
|
|
38
|
+
return createHash("sha256").update(s).digest("hex");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Deterministic JSON: object keys sorted, no whitespace. Only plain JSON values
|
|
43
|
+
* are accepted (no functions, symbols, undefined, NaN, class instances), so the
|
|
44
|
+
* same event always hashes the same way.
|
|
45
|
+
*/
|
|
46
|
+
export function canonicalJson(value) {
|
|
47
|
+
if (value === null) return "null";
|
|
48
|
+
switch (typeof value) {
|
|
49
|
+
case "string": return JSON.stringify(value);
|
|
50
|
+
case "boolean": return value ? "true" : "false";
|
|
51
|
+
case "number":
|
|
52
|
+
if (!Number.isFinite(value)) throw new TypeError("canonicalJson: non-finite number");
|
|
53
|
+
return JSON.stringify(value);
|
|
54
|
+
case "object": {
|
|
55
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
56
|
+
const proto = Object.getPrototypeOf(value);
|
|
57
|
+
if (proto !== Object.prototype && proto !== null) {
|
|
58
|
+
throw new TypeError("canonicalJson: only plain objects are allowed");
|
|
59
|
+
}
|
|
60
|
+
const keys = Object.keys(value).sort();
|
|
61
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(",")}}`;
|
|
62
|
+
}
|
|
63
|
+
default:
|
|
64
|
+
throw new TypeError(`canonicalJson: unsupported type ${typeof value}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function hashEvent(prev, { v, ts, type, data }) {
|
|
69
|
+
return sha256(prev + canonicalJson({ v, ts, type, data }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The chain's genesis value: binds line 1 to this project. */
|
|
73
|
+
export function genesisHash(projectId) {
|
|
74
|
+
return sha256(`penthera-memory-v1:${projectId}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Memory root: $PENTHERA_HOME/memory, else ~/.penthera/memory. */
|
|
78
|
+
export function memoryRoot(env = process.env) {
|
|
79
|
+
const base = env.PENTHERA_HOME ? resolve(env.PENTHERA_HOME) : join(homedir(), ".penthera");
|
|
80
|
+
return join(base, "memory");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Remove credentials from a URL-ish string before it is hashed or shown. */
|
|
84
|
+
export function stripCredentials(u) {
|
|
85
|
+
const s = String(u || "").trim();
|
|
86
|
+
try {
|
|
87
|
+
const url = new URL(s);
|
|
88
|
+
url.username = "";
|
|
89
|
+
url.password = "";
|
|
90
|
+
return url.toString();
|
|
91
|
+
} catch {
|
|
92
|
+
return s.replace(/\/\/[^@/]+@/, "//"); // scp-like or odd remotes
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Read the origin remote URL from .git/config without running git. Running git
|
|
98
|
+
* inside an untrusted repo can execute its hooks or core.fsmonitor, so we parse
|
|
99
|
+
* the config text ourselves. Worktrees (.git is a file) follow gitdir/commondir.
|
|
100
|
+
*/
|
|
101
|
+
export function readGitRemote(repoPath) {
|
|
102
|
+
try {
|
|
103
|
+
let gitDir = join(repoPath, ".git");
|
|
104
|
+
if (!existsSync(gitDir)) return null;
|
|
105
|
+
if (statSync(gitDir).isFile()) {
|
|
106
|
+
const m = readFileSync(gitDir, "utf-8").match(/^gitdir:\s*(.+)$/m);
|
|
107
|
+
if (!m) return null;
|
|
108
|
+
gitDir = resolve(repoPath, m[1].trim());
|
|
109
|
+
const common = join(gitDir, "commondir");
|
|
110
|
+
if (existsSync(common)) gitDir = resolve(gitDir, readFileSync(common, "utf-8").trim());
|
|
111
|
+
}
|
|
112
|
+
const cfg = readFileSync(join(gitDir, "config"), "utf-8");
|
|
113
|
+
let section = null;
|
|
114
|
+
let first = null;
|
|
115
|
+
for (const raw of cfg.split(/\r?\n/)) {
|
|
116
|
+
const line = raw.trim();
|
|
117
|
+
const sec = line.match(/^\[\s*remote\s+"([^"]+)"\s*\]$/);
|
|
118
|
+
if (sec) { section = sec[1]; continue; }
|
|
119
|
+
if (line.startsWith("[")) { section = null; continue; }
|
|
120
|
+
const kv = section && line.match(/^url\s*=\s*(.+)$/);
|
|
121
|
+
if (kv) {
|
|
122
|
+
if (section === "origin") return stripCredentials(kv[1]);
|
|
123
|
+
first ??= stripCredentials(kv[1]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return first;
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Project id: sha256 of the repo's absolute path + git remote, or of the
|
|
134
|
+
* target origin for URL-only scans. Returns { id, label }.
|
|
135
|
+
*/
|
|
136
|
+
export function projectIdFor({ repo, url } = {}) {
|
|
137
|
+
if (repo) {
|
|
138
|
+
const abs = resolve(repo);
|
|
139
|
+
const remote = readGitRemote(abs) || "";
|
|
140
|
+
return { id: sha256(`repo\n${abs}\n${remote}`), label: remote ? `${abs} (${remote})` : abs };
|
|
141
|
+
}
|
|
142
|
+
if (url) {
|
|
143
|
+
const origin = new URL(stripCredentials(url)).origin;
|
|
144
|
+
return { id: sha256(`url\n${origin}`), label: origin };
|
|
145
|
+
}
|
|
146
|
+
throw new Error("projectIdFor: need a repo path or a URL");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function sleepMs(ms) {
|
|
150
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function withLock(dir, fn) {
|
|
154
|
+
const lock = join(dir, "events.lock");
|
|
155
|
+
const started = Date.now();
|
|
156
|
+
let fd;
|
|
157
|
+
for (;;) {
|
|
158
|
+
try {
|
|
159
|
+
fd = openSync(lock, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY, 0o600);
|
|
160
|
+
break;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
if (e.code !== "EEXIST") throw e;
|
|
163
|
+
try {
|
|
164
|
+
if (Date.now() - statSync(lock).mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; }
|
|
165
|
+
} catch { continue; } // lock vanished between calls: retry
|
|
166
|
+
if (Date.now() - started > LOCK_TIMEOUT_MS) throw new Error(`memory: lock busy (${lock})`);
|
|
167
|
+
sleepMs(5 + Math.floor(Math.random() * 20));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
writeSync(fd, String(process.pid));
|
|
172
|
+
return fn();
|
|
173
|
+
} finally {
|
|
174
|
+
closeSync(fd);
|
|
175
|
+
try { unlinkSync(lock); } catch { /* already gone */ }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readJson(path) {
|
|
180
|
+
try { return JSON.parse(readFileSync(path, "utf-8")); } catch { return null; }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function writeJsonAtomic(path, obj) {
|
|
184
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
185
|
+
writeFileSync(tmp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
|
|
186
|
+
renameSync(tmp, path);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Split the log into lines, keeping a trailing partial line visible to verify(). */
|
|
190
|
+
function readLines(file) {
|
|
191
|
+
if (!existsSync(file)) return [];
|
|
192
|
+
const text = readFileSync(file, "utf-8");
|
|
193
|
+
const lines = text.split("\n");
|
|
194
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
195
|
+
return lines;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function isPlainObject(x) {
|
|
199
|
+
return x !== null && typeof x === "object" && !Array.isArray(x)
|
|
200
|
+
&& (Object.getPrototypeOf(x) === Object.prototype || Object.getPrototypeOf(x) === null);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Walk a list of raw lines and check every link. */
|
|
204
|
+
export function verifyLines(lines, projectId) {
|
|
205
|
+
let prev = genesisHash(projectId);
|
|
206
|
+
for (let i = 0; i < lines.length; i++) {
|
|
207
|
+
const lineNo = i + 1;
|
|
208
|
+
let ev;
|
|
209
|
+
try { ev = JSON.parse(lines[i]); } catch {
|
|
210
|
+
return { ok: false, count: i, brokenAt: lineNo, reason: "line is not valid JSON" };
|
|
211
|
+
}
|
|
212
|
+
if (!isPlainObject(ev) || ev.v !== EVENT_VERSION || typeof ev.ts !== "string"
|
|
213
|
+
|| !EVENT_TYPES.includes(ev.type) || !isPlainObject(ev.data)
|
|
214
|
+
|| typeof ev.prev !== "string" || typeof ev.hash !== "string") {
|
|
215
|
+
return { ok: false, count: i, brokenAt: lineNo, reason: "malformed event" };
|
|
216
|
+
}
|
|
217
|
+
if (ev.prev !== prev) {
|
|
218
|
+
return { ok: false, count: i, brokenAt: lineNo, reason: "prev does not match the previous line's hash (line removed, reordered, or inserted)" };
|
|
219
|
+
}
|
|
220
|
+
let expect;
|
|
221
|
+
try { expect = hashEvent(prev, ev); } catch {
|
|
222
|
+
return { ok: false, count: i, brokenAt: lineNo, reason: "event is not canonical JSON" };
|
|
223
|
+
}
|
|
224
|
+
if (ev.hash !== expect) {
|
|
225
|
+
return { ok: false, count: i, brokenAt: lineNo, reason: "hash mismatch (line edited)" };
|
|
226
|
+
}
|
|
227
|
+
prev = ev.hash;
|
|
228
|
+
}
|
|
229
|
+
return { ok: true, count: lines.length, tip: prev };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Open (lazily create) the memory for a project. Nothing touches disk until the
|
|
234
|
+
* first append, so read-only commands on an unknown project leave no trace.
|
|
235
|
+
*/
|
|
236
|
+
export function openMemory(projectId, { root = memoryRoot(), label = null, now = () => new Date().toISOString() } = {}) {
|
|
237
|
+
if (!/^[0-9a-f]{64}$/.test(String(projectId))) throw new Error("openMemory: project id must be a sha256 hex string");
|
|
238
|
+
const dir = join(root, projectId);
|
|
239
|
+
const file = join(dir, "events.jsonl");
|
|
240
|
+
const headFile = join(dir, "head.json");
|
|
241
|
+
const metaFile = join(dir, "meta.json");
|
|
242
|
+
|
|
243
|
+
function ensureDir() {
|
|
244
|
+
if (!existsSync(dir)) {
|
|
245
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
246
|
+
writeJsonAtomic(metaFile, { projectId, label, createdAt: now() });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Validated, parsed events (only call after verify() says ok). */
|
|
251
|
+
function events() {
|
|
252
|
+
return readLines(file).map((l) => JSON.parse(l));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function verify() {
|
|
256
|
+
const lines = readLines(file);
|
|
257
|
+
const res = verifyLines(lines, projectId);
|
|
258
|
+
if (!res.ok) return res;
|
|
259
|
+
// The chain alone cannot see the tail being cut off; head.json can.
|
|
260
|
+
const head = readJson(headFile);
|
|
261
|
+
if (lines.length === 0) {
|
|
262
|
+
if (head && head.count > 0) return { ok: false, count: 0, brokenAt: 1, reason: `log is empty but head.json records ${head.count} events (lines deleted)` };
|
|
263
|
+
return res;
|
|
264
|
+
}
|
|
265
|
+
if (!head || !Number.isInteger(head.count) || head.count < 1 || typeof head.hash !== "string") {
|
|
266
|
+
return { ok: false, count: lines.length, brokenAt: lines.length, reason: "head.json missing or malformed" };
|
|
267
|
+
}
|
|
268
|
+
if (head.count > lines.length) {
|
|
269
|
+
return { ok: false, count: lines.length, brokenAt: lines.length + 1, reason: `head.json records ${head.count} events, log has ${lines.length} (lines deleted from the end)` };
|
|
270
|
+
}
|
|
271
|
+
const atHead = JSON.parse(lines[head.count - 1]).hash;
|
|
272
|
+
if (atHead !== head.hash) {
|
|
273
|
+
return { ok: false, count: lines.length, brokenAt: head.count, reason: "head.json hash does not match the log (log rewritten)" };
|
|
274
|
+
}
|
|
275
|
+
return { ...res, headStale: head.count < lines.length };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Append one event. `event` is { type, data, ts? }. Returns the stored line
|
|
280
|
+
* object. Refuses unknown types, non-plain data, and oversized lines.
|
|
281
|
+
*/
|
|
282
|
+
function append(event) {
|
|
283
|
+
const type = event?.type;
|
|
284
|
+
if (!EVENT_TYPES.includes(type)) throw new Error(`memory: unknown event type "${type}"`);
|
|
285
|
+
const data = event.data ?? {};
|
|
286
|
+
if (!isPlainObject(data)) throw new Error("memory: event data must be a plain object");
|
|
287
|
+
const ts = typeof event.ts === "string" ? event.ts : now();
|
|
288
|
+
canonicalJson(data); // throws on anything that is not plain JSON
|
|
289
|
+
ensureDir();
|
|
290
|
+
return withLock(dir, () => {
|
|
291
|
+
const lines = readLines(file);
|
|
292
|
+
const prev = lines.length ? JSON.parse(lines[lines.length - 1]).hash : genesisHash(projectId);
|
|
293
|
+
const body = { v: EVENT_VERSION, ts, type, data };
|
|
294
|
+
const rec = { ...body, prev, hash: hashEvent(prev, body) };
|
|
295
|
+
const line = JSON.stringify(rec) + "\n";
|
|
296
|
+
if (Buffer.byteLength(line) > MAX_LINE_BYTES) throw new Error("memory: event too large");
|
|
297
|
+
const fd = openSync(file, FS.O_CREAT | FS.O_APPEND | FS.O_WRONLY, 0o600);
|
|
298
|
+
try { writeSync(fd, line); } finally { closeSync(fd); }
|
|
299
|
+
writeJsonAtomic(headFile, { count: lines.length + 1, hash: rec.hash });
|
|
300
|
+
return rec;
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function meta() {
|
|
305
|
+
return readJson(metaFile);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Delete this project's memory. */
|
|
309
|
+
function forget() {
|
|
310
|
+
const existed = existsSync(dir);
|
|
311
|
+
rmSync(dir, { recursive: true, force: true });
|
|
312
|
+
return existed;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return { projectId, dir, file, append, verify, events, meta, forget, exists: () => existsSync(file) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Delete every project's memory under the root. */
|
|
319
|
+
export function forgetAll({ root = memoryRoot() } = {}) {
|
|
320
|
+
const existed = existsSync(root);
|
|
321
|
+
// Only ever remove the memory/ directory itself, never its parent.
|
|
322
|
+
if (dirname(root) === root) throw new Error("refusing to remove a filesystem root");
|
|
323
|
+
rmSync(root, { recursive: true, force: true });
|
|
324
|
+
return existed;
|
|
325
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only views over memory events, plus the two pure helpers scans use:
|
|
3
|
+
* rankFindings() re-orders, isSuppressed() hides. Neither can add a finding,
|
|
4
|
+
* change a severity, or run anything: memory content is only ever compared and
|
|
5
|
+
* counted. Every field read from an event is type-checked, so a malformed or
|
|
6
|
+
* hostile event degrades to "ignored", never to behaviour.
|
|
7
|
+
*/
|
|
8
|
+
import { fingerprintFinding } from "../audit-loop.js";
|
|
9
|
+
import { sha256 } from "./store.js";
|
|
10
|
+
|
|
11
|
+
const SEV_ORDER = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
12
|
+
const MAX_STR = 500;
|
|
13
|
+
|
|
14
|
+
/** Short, stable id for a finding: sha256 of the audit-loop fingerprint. */
|
|
15
|
+
export function findingId(f) {
|
|
16
|
+
return sha256(fingerprintFinding(f)).slice(0, 16);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const str = (x) => (typeof x === "string" && x.length > 0 && x.length <= MAX_STR ? x : null);
|
|
20
|
+
const int = (x) => (Number.isInteger(x) && x >= 0 ? x : null);
|
|
21
|
+
const time = (x) => { const t = Date.parse(x); return Number.isNaN(t) ? null : t; };
|
|
22
|
+
const key = (x) => (str(x) ? x.toLowerCase() : "unknown");
|
|
23
|
+
|
|
24
|
+
function median(nums) {
|
|
25
|
+
if (!nums.length) return null;
|
|
26
|
+
const s = [...nums].sort((a, b) => a - b);
|
|
27
|
+
const mid = Math.floor(s.length / 2);
|
|
28
|
+
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the views from a verified event list.
|
|
33
|
+
* @param {object[]} events parsed events from openMemory().events()
|
|
34
|
+
* @param {{ now?: string }} opts `now` decides which suppressions have expired
|
|
35
|
+
*/
|
|
36
|
+
export function buildViews(events, { now = new Date().toISOString() } = {}) {
|
|
37
|
+
const nowMs = time(now) ?? Date.now();
|
|
38
|
+
const recipes = new Map(); // `${framework}\0${recipe}` -> stats
|
|
39
|
+
const lastApplied = new Map(); // finding id -> { recipe, framework, category, ts }
|
|
40
|
+
const firstSeen = new Map(); // finding id -> ms
|
|
41
|
+
const seenCount = new Map(); // finding id -> scans it appeared in
|
|
42
|
+
const fixDurations = [];
|
|
43
|
+
const suppressions = new Map(); // finding id -> latest suppression
|
|
44
|
+
const probes = new Map(); // `${stack}\0${probe}` -> stats
|
|
45
|
+
let scans = 0;
|
|
46
|
+
|
|
47
|
+
const recipeStats = (framework, recipe) => {
|
|
48
|
+
const k = `${framework}\0${recipe}`;
|
|
49
|
+
if (!recipes.has(k)) recipes.set(k, { framework, recipe, applied: 0, verified: 0, failed: 0, categories: new Set() });
|
|
50
|
+
return recipes.get(k);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const ev of events || []) {
|
|
54
|
+
const d = ev?.data;
|
|
55
|
+
if (!d || typeof d !== "object") continue;
|
|
56
|
+
const ts = time(ev.ts);
|
|
57
|
+
switch (ev.type) {
|
|
58
|
+
case "scan": {
|
|
59
|
+
scans++;
|
|
60
|
+
const ids = Array.isArray(d.ids) ? d.ids.filter(str) : [];
|
|
61
|
+
for (const id of ids) {
|
|
62
|
+
if (!firstSeen.has(id) && ts != null) firstSeen.set(id, ts);
|
|
63
|
+
seenCount.set(id, (seenCount.get(id) || 0) + 1);
|
|
64
|
+
}
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
case "fix_applied": {
|
|
68
|
+
const id = str(d.id) || str(d.fingerprint);
|
|
69
|
+
const recipe = str(d.recipe);
|
|
70
|
+
if (!recipe) break;
|
|
71
|
+
const framework = key(d.framework);
|
|
72
|
+
const s = recipeStats(framework, recipe);
|
|
73
|
+
s.applied++;
|
|
74
|
+
if (str(d.category)) s.categories.add(d.category);
|
|
75
|
+
if (id) lastApplied.set(id, { recipe, framework, category: str(d.category), ts });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
case "fix_verified":
|
|
79
|
+
case "fix_failed": {
|
|
80
|
+
const id = str(d.id) || str(d.fingerprint);
|
|
81
|
+
const prior = id ? lastApplied.get(id) : null;
|
|
82
|
+
const recipe = str(d.recipe) || prior?.recipe;
|
|
83
|
+
if (!recipe) break;
|
|
84
|
+
const framework = str(d.framework) ? key(d.framework) : (prior?.framework || "unknown");
|
|
85
|
+
const s = recipeStats(framework, recipe);
|
|
86
|
+
if (ev.type === "fix_verified") {
|
|
87
|
+
s.verified++;
|
|
88
|
+
if (id && ts != null && firstSeen.has(id)) fixDurations.push(Math.max(0, ts - firstSeen.get(id)));
|
|
89
|
+
} else {
|
|
90
|
+
s.failed++;
|
|
91
|
+
}
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
case "fp_suppressed": {
|
|
95
|
+
const id = str(d.id) || (str(d.fingerprint) ? sha256(d.fingerprint).slice(0, 16) : null);
|
|
96
|
+
const reason = str(d.reason);
|
|
97
|
+
if (!id || !reason) break; // a suppression without a reason is ignored
|
|
98
|
+
const expires = str(d.expires);
|
|
99
|
+
if (expires && (time(expires) == null || time(expires) <= nowMs)) { suppressions.delete(id); break; }
|
|
100
|
+
suppressions.set(id, {
|
|
101
|
+
id, reason, actor: str(d.actor) || "unknown", since: ev.ts, expires: expires || null, source: "memory",
|
|
102
|
+
});
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
case "probe_yield": {
|
|
106
|
+
const probe = str(d.probe);
|
|
107
|
+
const findings = int(d.findings);
|
|
108
|
+
if (!probe || findings == null) break;
|
|
109
|
+
const stack = key(d.stack);
|
|
110
|
+
const k = `${stack}\0${probe}`;
|
|
111
|
+
if (!probes.has(k)) probes.set(k, { stack, probe, runs: 0, findings: 0 });
|
|
112
|
+
const p = probes.get(k);
|
|
113
|
+
p.runs++;
|
|
114
|
+
p.findings += findings;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
default:
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const recipeSuccess = [...recipes.values()].map((s) => {
|
|
123
|
+
const decided = s.verified + s.failed;
|
|
124
|
+
return {
|
|
125
|
+
framework: s.framework, recipe: s.recipe, applied: s.applied, verified: s.verified, failed: s.failed,
|
|
126
|
+
rate: decided ? s.verified / decided : null, categories: [...s.categories].sort(),
|
|
127
|
+
};
|
|
128
|
+
}).sort((a, b) => a.framework.localeCompare(b.framework) || (b.rate ?? -1) - (a.rate ?? -1) || a.recipe.localeCompare(b.recipe));
|
|
129
|
+
|
|
130
|
+
const probeRanking = {};
|
|
131
|
+
for (const p of probes.values()) {
|
|
132
|
+
(probeRanking[p.stack] ||= []).push({ probe: p.probe, runs: p.runs, findings: p.findings, yield: p.findings / p.runs });
|
|
133
|
+
}
|
|
134
|
+
for (const list of Object.values(probeRanking)) {
|
|
135
|
+
list.sort((a, b) => b.yield - a.yield || b.findings - a.findings || a.probe.localeCompare(b.probe));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const med = median(fixDurations);
|
|
139
|
+
return {
|
|
140
|
+
scans,
|
|
141
|
+
recipeSuccess,
|
|
142
|
+
suppressions: [...suppressions.values()],
|
|
143
|
+
probeRanking,
|
|
144
|
+
medianTimeToFixMs: med,
|
|
145
|
+
medianTimeToFixDays: med == null ? null : Math.round((med / 86_400_000) * 10) / 10,
|
|
146
|
+
seen: Object.fromEntries(seenCount),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Add repo-file suppressions to a views object. They keep their own source so
|
|
152
|
+
* reports can say "suppressed by repo file". A local-memory suppression for the
|
|
153
|
+
* same id wins (the user decided it, not the repo).
|
|
154
|
+
*/
|
|
155
|
+
export function withRepoSuppressions(views, repoEntries) {
|
|
156
|
+
const have = new Set((views?.suppressions || []).map((s) => s.id));
|
|
157
|
+
const extra = (repoEntries || []).filter((s) => !have.has(s.id)).map((s) => ({ ...s, source: "repo-file" }));
|
|
158
|
+
return { ...(views || {}), suppressions: [...(views?.suppressions || []), ...extra] };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Is this finding suppressed? Returns the suppression record or null.
|
|
163
|
+
* Matches on the finding's id (or its full fingerprint, for repo files).
|
|
164
|
+
*/
|
|
165
|
+
export function isSuppressed(finding, views) {
|
|
166
|
+
const list = views?.suppressions;
|
|
167
|
+
if (!list?.length) return null;
|
|
168
|
+
const fp = fingerprintFinding(finding);
|
|
169
|
+
const id = sha256(fp).slice(0, 16);
|
|
170
|
+
return list.find((s) => s.id === id || (s.fingerprint && s.fingerprint === fp)) || null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Re-order findings using what memory learned. Severity always comes first and
|
|
175
|
+
* is never changed. Within a severity:
|
|
176
|
+
* 1. findings whose category has a recipe with a proven fix rate (quick wins),
|
|
177
|
+
* 2. findings seen in more past scans (persistent problems),
|
|
178
|
+
* 3. then the original order (the sort is stable).
|
|
179
|
+
* Returns a new array with exactly the same findings.
|
|
180
|
+
*/
|
|
181
|
+
export function rankFindings(findings, views, { framework = null } = {}) {
|
|
182
|
+
const fw = framework ? String(framework).toLowerCase() : null;
|
|
183
|
+
const bestRate = new Map(); // category -> best verified rate
|
|
184
|
+
for (const r of views?.recipeSuccess || []) {
|
|
185
|
+
if (r.rate == null || (fw && r.framework !== fw && r.framework !== "unknown")) continue;
|
|
186
|
+
for (const c of r.categories || []) bestRate.set(c, Math.max(bestRate.get(c) ?? 0, r.rate));
|
|
187
|
+
}
|
|
188
|
+
const seen = views?.seen || {};
|
|
189
|
+
return (findings || [])
|
|
190
|
+
.map((f, i) => ({
|
|
191
|
+
f, i,
|
|
192
|
+
sev: SEV_ORDER[f.severity] ?? 5,
|
|
193
|
+
fix: bestRate.get(f.category) ?? 0,
|
|
194
|
+
seen: seen[findingId(f)] || 0,
|
|
195
|
+
}))
|
|
196
|
+
.sort((a, b) => a.sev - b.sev || b.fix - a.fix || b.seen - a.seen || a.i - b.i)
|
|
197
|
+
.map((x) => x.f);
|
|
198
|
+
}
|
package/lib/params.js
CHANGED
|
@@ -14,20 +14,12 @@
|
|
|
14
14
|
* Inspired by: Arjun (6.2k stars), ParamSpider
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { fetchText } from "../src/utils/http.js";
|
|
18
|
+
|
|
17
19
|
const PARAM_TIMEOUT = 8_000;
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const timeout = setTimeout(() => controller.abort(), opts.timeout || PARAM_TIMEOUT);
|
|
22
|
-
try {
|
|
23
|
-
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
24
|
-
const body = await res.text().catch(() => "");
|
|
25
|
-
return { status: res.status, body, size: body.length, ok: true };
|
|
26
|
-
} catch {
|
|
27
|
-
return { status: 0, body: "", size: 0, ok: false };
|
|
28
|
-
} finally {
|
|
29
|
-
clearTimeout(timeout);
|
|
30
|
-
}
|
|
21
|
+
function safeFetch(url, opts = {}) {
|
|
22
|
+
return fetchText(url, { timeout: PARAM_TIMEOUT, probe: "param-discovery", ...opts });
|
|
31
23
|
}
|
|
32
24
|
|
|
33
25
|
function simpleHash(str) {
|
package/lib/provenance.js
CHANGED
|
@@ -26,6 +26,7 @@ const SOURCE_CONFIDENCE = {
|
|
|
26
26
|
"openapi-scan": "confirmed",
|
|
27
27
|
"email-dns-probe": "confirmed",
|
|
28
28
|
"framework-check": "likely",
|
|
29
|
+
"deps-scan": "likely",
|
|
29
30
|
"static-analysis": "likely",
|
|
30
31
|
"trust-boundary": "likely",
|
|
31
32
|
"auth-probe": "likely",
|
|
@@ -59,6 +60,7 @@ const CATEGORY_CONFIDENCE = {
|
|
|
59
60
|
ssrf: "likely",
|
|
60
61
|
"open-redirect": "likely",
|
|
61
62
|
fuzzing: "potential",
|
|
63
|
+
"dependency-risk": "likely",
|
|
62
64
|
};
|
|
63
65
|
|
|
64
66
|
/** Honest-confidence label for a finding. An explicit finding.confidence wins. */
|