penthera 1.2.0 → 1.4.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +379 -39
  3. package/docs/METHODOLOGY.md +321 -0
  4. package/docs/evidence.md +158 -0
  5. package/docs/examples/github-actions-deps.yml +105 -0
  6. package/docs/examples/github-actions-scan.yml +1 -1
  7. package/docs/examples/pr-dependency-review.yml +56 -0
  8. package/docs/funding/due-diligence-checklist.md +79 -0
  9. package/docs/funding/nlnet-ngi-zero-draft.md +273 -0
  10. package/docs/funding/partner-one-pager.md +84 -0
  11. package/docs/funding/sponsor-tiers.md +81 -0
  12. package/docs/index.md +12 -1
  13. package/docs/local-loop.md +276 -0
  14. package/docs/references.bib +539 -0
  15. package/docs/references.md +77 -0
  16. package/docs/release-plan.md +228 -0
  17. package/lib/adaptive-probes.js +8 -5
  18. package/lib/agents/discover.js +384 -0
  19. package/lib/agents/hidden-content.js +387 -0
  20. package/lib/agents/index.js +286 -0
  21. package/lib/agents/lock.js +115 -0
  22. package/lib/agents/owasp.js +83 -0
  23. package/lib/agents/rules.js +376 -0
  24. package/lib/crawler.js +5 -15
  25. package/lib/data/retire-jsrepository.json.gz +0 -0
  26. package/lib/data/retire-jsrepository.meta.json +7 -0
  27. package/lib/deps/capabilities.js +523 -0
  28. package/lib/deps/diff.js +84 -0
  29. package/lib/deps/index.js +416 -0
  30. package/lib/deps/lockfile.js +106 -0
  31. package/lib/deps/online.js +132 -0
  32. package/lib/deps/popular-packages.json +1 -0
  33. package/lib/deps/report.js +173 -0
  34. package/lib/deps/source.js +171 -0
  35. package/lib/deps/typosquat.js +113 -0
  36. package/lib/fix/apply.js +95 -0
  37. package/lib/fix/diff.js +199 -0
  38. package/lib/fix/index.js +129 -0
  39. package/lib/fix/model.js +108 -0
  40. package/lib/fix/recipes/cookies.js +130 -0
  41. package/lib/fix/recipes/cors.js +116 -0
  42. package/lib/fix/recipes/headers.js +250 -0
  43. package/lib/fix/recipes/rate-limit.js +211 -0
  44. package/lib/fix/recipes/secrets.js +110 -0
  45. package/lib/fix/util.js +231 -0
  46. package/lib/fuzzer.js +14 -8
  47. package/lib/injections.js +19 -15
  48. package/lib/install/index.js +271 -0
  49. package/lib/install/policy.js +117 -0
  50. package/lib/memory/history.js +52 -0
  51. package/lib/memory/index.js +111 -0
  52. package/lib/memory/repo-suppress.js +64 -0
  53. package/lib/memory/store.js +325 -0
  54. package/lib/memory/views.js +198 -0
  55. package/lib/params.js +4 -12
  56. package/lib/provenance.js +2 -0
  57. package/lib/recon.js +12 -20
  58. package/lib/references.js +31 -0
  59. package/lib/references.json +374 -0
  60. package/lib/remediation/index.js +28 -0
  61. package/lib/retirejs.js +50 -22
  62. package/lib/templates.js +7 -10
  63. package/lib/whitebox/frameworks.js +1 -1
  64. package/package.json +16 -4
  65. package/skills/penthera/SKILL.md +103 -22
  66. package/src/agent/git.js +87 -0
  67. package/src/agent/loop.js +519 -0
  68. package/src/agent/tests.js +48 -0
  69. package/src/cli/agent.js +183 -0
  70. package/src/cli/deps.js +84 -0
  71. package/src/cli/evidence.js +67 -0
  72. package/src/cli/fix.js +191 -0
  73. package/src/cli/install.js +225 -0
  74. package/src/cli/memory.js +146 -0
  75. package/src/cli/merge-results.js +2 -0
  76. package/src/cli/report-outputs.js +72 -0
  77. package/src/cli/run-scan.js +2 -0
  78. package/src/cli.js +152 -6
  79. package/src/report/audience.js +270 -0
  80. package/src/report/evidence.js +253 -0
  81. package/src/report/markdown.js +65 -2
  82. package/src/report/model.js +212 -0
  83. package/src/report/template.js +175 -0
  84. package/src/report/timeline.js +145 -0
  85. package/src/reporter.js +109 -5
  86. package/src/scan-url.js +31 -7
  87. package/src/utils/http.js +252 -11
  88. package/src/utils/url.js +23 -0
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `penthera.policy.json`: what an install may bring in without a human.
3
+ *
4
+ * {
5
+ * "allow": ["@acme/*", "esbuild", "sharp@0.33.5"],
6
+ * "deny": ["ai-cli-invoke", "token-harvest"],
7
+ * "requireApproval": ["install-scripts", "obfuscated", "typosquat"]
8
+ * }
9
+ *
10
+ * `deny` and `requireApproval` list capability ids from lib/deps/capabilities.js,
11
+ * plus two of the firewall's own: `typosquat` (a new name close to a popular
12
+ * package) and `unscanned` (a new package whose files could not be read).
13
+ * `allow` lists package names or globs (`*` matches any run of characters),
14
+ * optionally pinned to one version with `@x.y.z`; an allowed package passes
15
+ * both lists. A field left out keeps its default; a field given replaces it.
16
+ * Everything here is pure: no files are read except the policy itself.
17
+ */
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { CAPABILITY_ORDER, CAPABILITIES } from "../deps/capabilities.js";
21
+
22
+ export const POLICY_FILE = "penthera.policy.json";
23
+
24
+ export const RULE_IDS = [...CAPABILITY_ORDER, "typosquat", "unscanned"];
25
+
26
+ export const RULE_LABELS = {
27
+ ...Object.fromEntries(CAPABILITY_ORDER.map((id) => [id, CAPABILITIES[id].label])),
28
+ typosquat: "looks like a popular package",
29
+ unscanned: "could not be scanned",
30
+ };
31
+
32
+ export const DEFAULT_POLICY = Object.freeze({
33
+ allow: [],
34
+ deny: [],
35
+ requireApproval: ["install-scripts", "ai-config-write", "ai-cli-invoke", "token-harvest", "obfuscated", "typosquat", "unscanned"],
36
+ });
37
+
38
+ /**
39
+ * Validate and complete a policy object. Throws with a readable message on
40
+ * unknown fields, unknown rule ids, or non-string entries.
41
+ */
42
+ export function normalizePolicy(raw, label = POLICY_FILE) {
43
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected a JSON object`);
44
+ const known = ["allow", "deny", "requireApproval", "$schema", "description"];
45
+ const extra = Object.keys(raw).filter((k) => !known.includes(k));
46
+ if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval)`);
47
+ const out = {};
48
+ for (const field of ["allow", "deny", "requireApproval"]) {
49
+ const v = raw[field] ?? DEFAULT_POLICY[field];
50
+ if (!Array.isArray(v) || v.some((x) => typeof x !== "string" || !x.trim())) {
51
+ throw new Error(`${label}: "${field}" must be an array of non-empty strings`);
52
+ }
53
+ out[field] = [...new Set(v.map((x) => x.trim()))];
54
+ }
55
+ for (const field of ["deny", "requireApproval"]) {
56
+ const bad = out[field].filter((id) => !RULE_IDS.includes(id));
57
+ if (bad.length) throw new Error(`${label}: unknown capability in "${field}": ${bad.join(", ")}. Known: ${RULE_IDS.join(", ")}`);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /**
63
+ * Load the policy: an explicit path, else penthera.policy.json in the project,
64
+ * else the defaults. Returns { policy, path } (path null for defaults).
65
+ */
66
+ export function loadPolicy(root, explicitPath = null) {
67
+ const path = explicitPath || join(root, POLICY_FILE);
68
+ if (!existsSync(path)) {
69
+ if (explicitPath) throw new Error(`Policy file not found: ${explicitPath}`);
70
+ return { policy: normalizePolicy({}), path: null };
71
+ }
72
+ let raw;
73
+ try {
74
+ raw = JSON.parse(readFileSync(path, "utf-8"));
75
+ } catch (e) {
76
+ throw new Error(`${path}: ${e.message}`);
77
+ }
78
+ return { policy: normalizePolicy(raw, path), path };
79
+ }
80
+
81
+ const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
82
+
83
+ /** Split "name@1.2.3" / "@scope/name@1.2.3" into { name, version }. */
84
+ function splitEntry(entry) {
85
+ const at = entry.lastIndexOf("@");
86
+ if (at > 0) return { name: entry.slice(0, at), version: entry.slice(at + 1) };
87
+ return { name: entry, version: null };
88
+ }
89
+
90
+ /** The first allow entry matching name@version, or null. */
91
+ export function matchAllow(allow, name, version) {
92
+ for (const entry of allow) {
93
+ const e = splitEntry(entry);
94
+ const re = new RegExp(`^${e.name.split("*").map(escapeRe).join(".*")}$`);
95
+ if (!re.test(name)) continue;
96
+ if (e.version && e.version !== "*" && e.version !== version) continue;
97
+ return entry;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Apply the policy to the changes an install would make.
104
+ * @param {{ name, version, flags: string[] }[]} changes flags: the rule ids this change raises
105
+ * @returns {{ items: object[], denied: object[], needsApproval: object[], clean: boolean }}
106
+ */
107
+ export function evaluatePolicy(changes, policy) {
108
+ const items = changes.map((c) => {
109
+ const allowedBy = matchAllow(policy.allow, c.name, c.version);
110
+ const deny = allowedBy ? [] : c.flags.filter((id) => policy.deny.includes(id));
111
+ const approval = allowedBy ? [] : c.flags.filter((id) => policy.requireApproval.includes(id) && !deny.includes(id));
112
+ return { ...c, allowedBy, deny, approval };
113
+ });
114
+ const denied = items.filter((i) => i.deny.length);
115
+ const needsApproval = items.filter((i) => i.approval.length);
116
+ return { items, denied, needsApproval, clean: denied.length === 0 && needsApproval.length === 0 };
117
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Report-side view over memory events: the scan trend (counts per scan) and
3
+ * the fix history per finding id (first seen → fix applied → verified or
4
+ * failed). Like views.js it only reads, and every field is type-checked.
5
+ */
6
+
7
+ const MAX_STR = 500;
8
+ const str = (x) => (typeof x === "string" && x.length > 0 && x.length <= MAX_STR ? x : null);
9
+ const int = (x) => (Number.isInteger(x) && x >= 0 ? x : 0);
10
+ const iso = (x) => (str(x) && !Number.isNaN(Date.parse(x)) ? x : null);
11
+
12
+ /**
13
+ * @param {object[]} events verified events from openMemory().events()
14
+ * @param {{ maxScans?: number }} opts how many recent scans the trend keeps
15
+ * @returns {{ trend: object[], fixes: object[] }}
16
+ */
17
+ export function buildHistory(events, { maxScans = 12 } = {}) {
18
+ const trend = [];
19
+ const fixes = new Map();
20
+ const fix = (id) => {
21
+ if (!fixes.has(id)) fixes.set(id, { id, firstSeen: null, applied: null, recipe: null, verified: null, failed: null });
22
+ return fixes.get(id);
23
+ };
24
+ const firstSeen = new Map();
25
+
26
+ for (const ev of events || []) {
27
+ const d = ev?.data;
28
+ const ts = iso(ev?.ts);
29
+ if (!d || typeof d !== "object" || !ts) continue;
30
+ if (ev.type === "scan") {
31
+ const c = d.counts && typeof d.counts === "object" ? d.counts : {};
32
+ trend.push({
33
+ ts, critical: int(c.critical), high: int(c.high), medium: int(c.medium), low: int(c.low),
34
+ total: int(c.total), suppressed: int(c.suppressed),
35
+ });
36
+ for (const id of Array.isArray(d.ids) ? d.ids : []) if (str(id) && !firstSeen.has(id)) firstSeen.set(id, ts);
37
+ } else if (ev.type === "fix_applied" || ev.type === "fix_verified" || ev.type === "fix_failed") {
38
+ const id = str(d.id);
39
+ if (!id) continue;
40
+ const f = fix(id);
41
+ if (ev.type === "fix_applied") {
42
+ f.applied = ts;
43
+ f.recipe = str(d.recipe) || f.recipe;
44
+ f.verified = null;
45
+ f.failed = null;
46
+ } else if (ev.type === "fix_verified") f.verified = ts;
47
+ else f.failed = ts;
48
+ }
49
+ }
50
+ for (const f of fixes.values()) f.firstSeen = firstSeen.get(f.id) || f.applied;
51
+ return { trend: trend.slice(-maxScans), fixes: [...fixes.values()] };
52
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Local learning store (v1.4 "local loop", workstream W2). Public API:
3
+ *
4
+ * openMemory(projectId) -> { append(event), verify(), events(), forget() }
5
+ * views(memory) -> recipe success, suppressions, probe ranking, time-to-fix
6
+ * rankFindings(findings, v) -> same findings, re-ordered
7
+ * isSuppressed(finding, v) -> suppression record or null
8
+ *
9
+ * See docs/local-loop.md for the threat model.
10
+ */
11
+ import { openMemory, projectIdFor, memoryRoot, forgetAll, EVENT_TYPES } from "./store.js";
12
+ import { buildViews, rankFindings, isSuppressed, withRepoSuppressions, findingId } from "./views.js";
13
+ import { loadRepoSuppressions, REPO_SUPPRESS_FILE } from "./repo-suppress.js";
14
+ import { buildHistory } from "./history.js";
15
+
16
+ export {
17
+ openMemory, projectIdFor, memoryRoot, forgetAll, EVENT_TYPES,
18
+ buildViews, rankFindings, isSuppressed, withRepoSuppressions, findingId,
19
+ loadRepoSuppressions, REPO_SUPPRESS_FILE, buildHistory,
20
+ };
21
+
22
+ /**
23
+ * Views for an opened memory. Returns null when the chain does not verify:
24
+ * a tampered log must not steer a scan, so callers fall back to no memory.
25
+ */
26
+ export function views(memory, opts = {}) {
27
+ if (!memory.verify().ok) return null;
28
+ return buildViews(memory.events(), opts);
29
+ }
30
+
31
+ /** Lower-cased framework name for the scanned target, or "unknown". */
32
+ export function detectStack(result) {
33
+ const fw = result?.fingerprint?.framework || result?.attackSurface?.find?.((r) => r?.framework)?.framework;
34
+ return typeof fw === "string" && fw ? fw.toLowerCase().slice(0, 64) : "unknown";
35
+ }
36
+
37
+ /** Findings per probe: executed probes count from 0, finding sources add to them. */
38
+ export function probeYields(result) {
39
+ const counts = new Map();
40
+ for (const p of result?.executedProbes || []) if (typeof p === "string") counts.set(p, 0);
41
+ for (const f of result?.findings || []) {
42
+ const probe = typeof f.source === "string" && f.source ? f.source : "unknown";
43
+ counts.set(probe, (counts.get(probe) || 0) + 1);
44
+ }
45
+ return [...counts].map(([probe, findings]) => ({ probe, findings }));
46
+ }
47
+
48
+ /**
49
+ * The `--memory` scan hook. Mutates `merged`:
50
+ * merged.findings -> unsuppressed findings, ranked
51
+ * merged.suppressed -> suppressed findings, each with .suppression { source, reason, actor }
52
+ * merged.memory -> { projectId, chain: "ok" | "broken", ... }
53
+ * then appends `scan` and `probe_yield` events. Returns { warnings }.
54
+ *
55
+ * Fail-closed: if the chain is broken, memory suppressions and ranking are not
56
+ * applied and nothing is appended (the repo file, which is labelled, still is).
57
+ */
58
+ export function applyMemoryToScan(merged, { repo = null, url = null, now = new Date().toISOString(), root } = {}) {
59
+ const warnings = [];
60
+ const { id: projectId, label } = projectIdFor({ repo, url });
61
+ const mem = openMemory(projectId, { root, label, now: () => now });
62
+ const check = mem.verify();
63
+ const stack = detectStack(merged);
64
+
65
+ let v = { suppressions: [], recipeSuccess: [], seen: {} };
66
+ if (check.ok) {
67
+ v = buildViews(mem.events(), { now });
68
+ } else {
69
+ warnings.push(`memory chain broken at line ${check.brokenAt} (${check.reason}); memory not applied. Run \`penthera memory verify\`.`);
70
+ }
71
+
72
+ if (repo) {
73
+ const rs = loadRepoSuppressions(repo, { now });
74
+ warnings.push(...rs.warnings);
75
+ v = withRepoSuppressions(v, rs.entries);
76
+ }
77
+
78
+ const all = merged.findings || [];
79
+ const kept = [];
80
+ const suppressed = [];
81
+ for (const f of all) {
82
+ const fid = findingId(f);
83
+ const s = isSuppressed(f, v);
84
+ if (s) suppressed.push({ ...f, fid, suppression: { source: s.source, reason: s.reason, actor: s.actor, expires: s.expires || null } });
85
+ else kept.push({ ...f, fid });
86
+ }
87
+ merged.findings = check.ok ? rankFindings(kept, v, { framework: stack }) : kept;
88
+ merged.suppressed = suppressed;
89
+ merged.memory = { projectId, stack, chain: check.ok ? "ok" : "broken", events: check.count };
90
+
91
+ if (check.ok) {
92
+ const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
93
+ for (const f of all) if (counts[f.severity] != null) counts[f.severity]++;
94
+ mem.append({
95
+ type: "scan",
96
+ ts: now,
97
+ data: {
98
+ stack,
99
+ modes: merged.modes || (merged.mode ? [merged.mode] : []),
100
+ counts: { ...counts, total: all.length, suppressed: suppressed.length },
101
+ ids: all.filter((f) => f.severity !== "info").map(findingId),
102
+ },
103
+ });
104
+ for (const p of probeYields({ executedProbes: merged.executedProbes, findings: all })) {
105
+ mem.append({ type: "probe_yield", ts: now, data: { probe: p.probe, stack, findings: p.findings } });
106
+ }
107
+ merged.memory.events = mem.verify().count;
108
+ merged.memory.history = buildHistory(mem.events());
109
+ }
110
+ return { warnings, projectId };
111
+ }
@@ -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
+ }