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
package/lib/injections.js
CHANGED
|
@@ -14,28 +14,26 @@
|
|
|
14
14
|
* - Nuclei, pattern-based detection
|
|
15
15
|
* - Burp Suite, time-based blind SQLi
|
|
16
16
|
*
|
|
17
|
-
*
|
|
17
|
+
* What this file is, for anyone reviewing the package:
|
|
18
|
+
* The strings below are test inputs, the same well-known probes found in
|
|
19
|
+
* OWASP testing guides and scanners like sqlmap, Burp and Nuclei. Penthera
|
|
20
|
+
* never executes them. They are sent as request parameters to the one target
|
|
21
|
+
* URL the user names, and only when the user opts in with --deep (or the
|
|
22
|
+
* deep profile). Detection happens by reading that target's responses.
|
|
23
|
+
* Nothing here runs on, or reaches out from, the machine running Penthera;
|
|
24
|
+
* the HTTP client in src/utils/http.js refuses other hosts under --offline.
|
|
25
|
+
*
|
|
18
26
|
* Only use against targets you have permission to test.
|
|
19
27
|
*/
|
|
20
28
|
|
|
21
29
|
import { redirectsToHost } from "../src/utils/url.js";
|
|
30
|
+
import { fetchText } from "../src/utils/http.js";
|
|
22
31
|
|
|
23
32
|
const INJECT_TIMEOUT = 10_000;
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const start = Date.now();
|
|
29
|
-
try {
|
|
30
|
-
const res = await fetch(url, { ...opts, signal: controller.signal, redirect: "manual" });
|
|
31
|
-
const elapsed = Date.now() - start;
|
|
32
|
-
const body = await res.text().catch(() => "");
|
|
33
|
-
return { status: res.status, body, headers: Object.fromEntries(res.headers.entries()), elapsed, ok: true };
|
|
34
|
-
} catch {
|
|
35
|
-
return { status: 0, body: "", headers: {}, elapsed: Date.now() - start, ok: false };
|
|
36
|
-
} finally {
|
|
37
|
-
clearTimeout(timeout);
|
|
38
|
-
}
|
|
34
|
+
/** A fetch bound to one probe name, so failures are attributed in result.errors. */
|
|
35
|
+
function clientFor(probe) {
|
|
36
|
+
return (url, opts = {}) => fetchText(url, { timeout: INJECT_TIMEOUT, probe, ...opts });
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
// ── Timing-based detection helpers ───────────────────────────────────────
|
|
@@ -125,6 +123,7 @@ const SQLI_PAYLOADS = [
|
|
|
125
123
|
* @returns {object[]} Findings
|
|
126
124
|
*/
|
|
127
125
|
export async function probeSqli(baseUrl, path, opts = {}) {
|
|
126
|
+
const safeFetch = clientFor("sqli");
|
|
128
127
|
const findings = [];
|
|
129
128
|
const method = opts.method || "GET";
|
|
130
129
|
|
|
@@ -253,6 +252,7 @@ export function sstiEvaluated(body, payload, expr, product) {
|
|
|
253
252
|
* Test an endpoint for SSTI.
|
|
254
253
|
*/
|
|
255
254
|
export async function probeSsti(baseUrl, path, opts = {}) {
|
|
255
|
+
const safeFetch = clientFor("ssti");
|
|
256
256
|
const findings = [];
|
|
257
257
|
const { probes, expr, product } = buildSstiProbes();
|
|
258
258
|
|
|
@@ -316,6 +316,7 @@ const SSRF_TARGETS = [
|
|
|
316
316
|
* Test an endpoint for SSRF via URL parameters.
|
|
317
317
|
*/
|
|
318
318
|
export async function probeSsrf(baseUrl, path, opts = {}) {
|
|
319
|
+
const safeFetch = clientFor("ssrf");
|
|
319
320
|
const findings = [];
|
|
320
321
|
|
|
321
322
|
// Identify URL-like parameters in the path or common parameter names
|
|
@@ -373,6 +374,7 @@ const REDIRECT_PAYLOADS = [
|
|
|
373
374
|
* Test an endpoint for open redirect.
|
|
374
375
|
*/
|
|
375
376
|
export async function probeOpenRedirect(baseUrl, path, opts = {}) {
|
|
377
|
+
const safeFetch = clientFor("open-redirect");
|
|
376
378
|
const findings = [];
|
|
377
379
|
const redirectParams = ["redirect", "redirect_uri", "return", "return_to", "next", "url", "callback", "redir", "destination", "go"];
|
|
378
380
|
|
|
@@ -421,6 +423,7 @@ const CMDI_PAYLOADS = [
|
|
|
421
423
|
* Test an endpoint for OS command injection.
|
|
422
424
|
*/
|
|
423
425
|
export async function probeCmdi(baseUrl, path, opts = {}) {
|
|
426
|
+
const safeFetch = clientFor("cmdi");
|
|
424
427
|
const findings = [];
|
|
425
428
|
const method = opts.method || "POST";
|
|
426
429
|
|
|
@@ -512,6 +515,7 @@ export function isHtmlResponse(res) {
|
|
|
512
515
|
* 5. Verify the payload is reflected unescaped
|
|
513
516
|
*/
|
|
514
517
|
export async function probeXss(baseUrl, path, opts = {}) {
|
|
518
|
+
const safeFetch = clientFor("xss");
|
|
515
519
|
const findings = [];
|
|
516
520
|
const CANARY = makeCanary();
|
|
517
521
|
const inputParams = ["q", "search", "query", "name", "input", "text", "value", "msg", "error", "redirect", "url", "id"];
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `penthera install`: an install firewall on top of the `penthera deps` engine.
|
|
3
|
+
*
|
|
4
|
+
* 1. Resolve. `npm install --package-lock-only --ignore-scripts [pkg...]` runs
|
|
5
|
+
* in a temporary copy of package.json / package-lock.json / .npmrc. Not in
|
|
6
|
+
* place: npm rewrites node_modules/.package-lock.json even in lock-only
|
|
7
|
+
* mode, and an abort must leave the project byte for byte as it was.
|
|
8
|
+
* 2. Fetch. `npm cache add` puts each new tarball in npm's cache; nothing is
|
|
9
|
+
* unpacked into node_modules and no package code runs. The deps engine
|
|
10
|
+
* reads tarballs back from the cache only after verifying them against
|
|
11
|
+
* the proposed lockfile's integrity (lib/deps/source.js).
|
|
12
|
+
* 3. Diff. lib/deps scans the proposed tree against the current lockfile:
|
|
13
|
+
* new packages, capabilities gained, install scripts, typosquats.
|
|
14
|
+
* 4. Decide. lib/install/policy.js turns that into deny / needs-approval.
|
|
15
|
+
* 5. Apply (only when allowed). The vetted package.json and lockfile are
|
|
16
|
+
* written into the project and `npm install` installs exactly that tree,
|
|
17
|
+
* with `--ignore-scripts` unless the new install scripts were approved.
|
|
18
|
+
*
|
|
19
|
+
* Penthera makes no network calls here. npm talks to its configured registry
|
|
20
|
+
* (resolve and fetch); that is the one expected egress.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from "node:child_process";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
import { existsSync, mkdtempSync, readFileSync, writeFileSync, copyFileSync, rmSync, mkdirSync } from "node:fs";
|
|
25
|
+
import { join, resolve } from "node:path";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import { scanDeps } from "../deps/index.js";
|
|
28
|
+
import { parseLockfile } from "../deps/lockfile.js";
|
|
29
|
+
import { sourceFromNpmCache, npmCacheDir } from "../deps/source.js";
|
|
30
|
+
import { evaluatePolicy, normalizePolicy } from "./policy.js";
|
|
31
|
+
|
|
32
|
+
const run = promisify(execFile);
|
|
33
|
+
const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
34
|
+
const LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json"];
|
|
35
|
+
const COPY = ["package.json", ...LOCKFILES, ".npmrc"];
|
|
36
|
+
const QUIET = ["--no-audit", "--no-fund", "--no-update-notifier"];
|
|
37
|
+
const LOCAL_SPEC = /^(file:|link:|portal:|\.{1,2}\/|\/)/;
|
|
38
|
+
|
|
39
|
+
async function npm(args, opts) {
|
|
40
|
+
try {
|
|
41
|
+
return await run(NPM, args, { maxBuffer: 64 * 1024 * 1024, shell: process.platform === "win32", ...opts });
|
|
42
|
+
} catch (e) {
|
|
43
|
+
const msg = String(e.stderr || e.message).trim().split("\n").filter((l) => !/^npm (verbose|silly|timing)/.test(l)).slice(-8).join("\n");
|
|
44
|
+
throw new Error(`npm ${args[0]} failed:\n${msg}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** npm's version and registry, as npm sees them from the project. */
|
|
49
|
+
export async function npmInfo(root, env = process.env) {
|
|
50
|
+
const [version, registry] = await Promise.all([
|
|
51
|
+
npm(["--version"], { cwd: root, env }).then((r) => r.stdout.trim()),
|
|
52
|
+
npm(["config", "get", "registry"], { cwd: root, env }).then((r) => r.stdout.trim(), () => "the configured npm registry"),
|
|
53
|
+
]);
|
|
54
|
+
const major = parseInt(version, 10) || 0;
|
|
55
|
+
// npm 12 blocks dependency install scripts unless package.json allowScripts lists them.
|
|
56
|
+
return { version, major, registry, allowScriptsPolicy: major >= 12 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const key = (p) => `${p.path}@${p.version}#${p.integrity || ""}`;
|
|
60
|
+
const scriptsOf = (s) => (s && Object.keys(s).length ? s : null);
|
|
61
|
+
|
|
62
|
+
/** The rule ids a change raises (see lib/install/policy.js). */
|
|
63
|
+
function flagsFor(caps, { newScripts, typosquat, unscanned }) {
|
|
64
|
+
const flags = [...(caps || [])].filter((id) => id !== "install-scripts");
|
|
65
|
+
if (newScripts) flags.unshift("install-scripts");
|
|
66
|
+
if (typosquat) flags.push("typosquat");
|
|
67
|
+
if (unscanned) flags.push("unscanned");
|
|
68
|
+
return flags;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Turn the deps diff into one change per new or upgraded package.
|
|
73
|
+
* `packages` is result.deps.packages, for the capabilities of an upgrade whose
|
|
74
|
+
* old version could not be read.
|
|
75
|
+
*/
|
|
76
|
+
export function changesFromDiff(diff, typosquats = [], packages = []) {
|
|
77
|
+
const typo = new Map(typosquats.map((t) => [t.name, t]));
|
|
78
|
+
const capsOf = (name, version) => {
|
|
79
|
+
const c = packages.find((p) => p.name === name && p.version === version)?.capabilities;
|
|
80
|
+
return c ? Object.keys(c) : null;
|
|
81
|
+
};
|
|
82
|
+
const out = [];
|
|
83
|
+
for (const a of diff.added) {
|
|
84
|
+
const scripts = scriptsOf(a.installScripts);
|
|
85
|
+
out.push({
|
|
86
|
+
change: "added",
|
|
87
|
+
name: a.name,
|
|
88
|
+
version: a.version,
|
|
89
|
+
capabilities: a.capabilities || [],
|
|
90
|
+
installScripts: scripts,
|
|
91
|
+
typosquatOf: typo.get(a.name)?.target || null,
|
|
92
|
+
flags: flagsFor(a.capabilities, { newScripts: !!scripts, typosquat: typo.has(a.name), unscanned: a.capabilities === null }),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
for (const u of diff.upgraded) {
|
|
96
|
+
const scripts = scriptsOf(u.installScripts);
|
|
97
|
+
const before = scriptsOf(u.previousInstallScripts);
|
|
98
|
+
// A hook that is new, or whose command changed, is new code at install time.
|
|
99
|
+
const newScripts = !!scripts && JSON.stringify(scripts) !== JSON.stringify(before);
|
|
100
|
+
// Old version unreadable: judge the new version on everything it can do.
|
|
101
|
+
const gained = u.gained ?? (u.unavailable === "old" ? capsOf(u.name, u.to) : null);
|
|
102
|
+
out.push({
|
|
103
|
+
change: "upgraded",
|
|
104
|
+
name: u.name,
|
|
105
|
+
version: u.to,
|
|
106
|
+
from: u.from,
|
|
107
|
+
capabilities: gained || [],
|
|
108
|
+
installScripts: scripts,
|
|
109
|
+
previousInstallScripts: before,
|
|
110
|
+
compared: u.unavailable == null,
|
|
111
|
+
typosquatOf: null,
|
|
112
|
+
flags: flagsFor(gained, { newScripts, typosquat: false, unscanned: u.unavailable === "new" }),
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readIfExists(p) {
|
|
119
|
+
return existsSync(p) ? readFileSync(p) : null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function checkProject(root, pkgJson) {
|
|
123
|
+
if (pkgJson.workspaces) throw new Error("npm workspaces are not supported by `penthera install` yet. Run `npm install --package-lock-only --ignore-scripts` and review with `penthera deps --base HEAD`.");
|
|
124
|
+
const local = Object.entries({ ...pkgJson.dependencies, ...pkgJson.devDependencies, ...pkgJson.optionalDependencies })
|
|
125
|
+
.filter(([, spec]) => LOCAL_SPEC.test(String(spec)));
|
|
126
|
+
if (local.length) throw new Error(`Local path dependencies (${local.map(([n]) => n).join(", ")}) cannot be resolved from a temporary copy. Run \`npm install --package-lock-only --ignore-scripts\` and review with \`penthera deps --base HEAD\`.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Work out what `npm install [specs]` would change, without running any
|
|
131
|
+
* package code and without writing to the project.
|
|
132
|
+
* @param {string} root project folder (has package.json)
|
|
133
|
+
* @param {string[]} specs packages as npm accepts them (name, name@range, ...)
|
|
134
|
+
* @param {{ policy?: object, saveDev?: boolean, saveExact?: boolean, offline?: boolean,
|
|
135
|
+
* env?: object, onPhase?: Function, info?: object }} opts
|
|
136
|
+
*/
|
|
137
|
+
export async function planInstall(root, specs, opts = {}) {
|
|
138
|
+
root = resolve(root);
|
|
139
|
+
const env = opts.env || process.env;
|
|
140
|
+
const onPhase = opts.onPhase || (() => {});
|
|
141
|
+
const pkgPath = join(root, "package.json");
|
|
142
|
+
if (!existsSync(pkgPath)) throw new Error(`No package.json in ${root}`);
|
|
143
|
+
checkProject(root, JSON.parse(readFileSync(pkgPath, "utf-8")));
|
|
144
|
+
const bad = specs.filter((s) => s.startsWith("-") || LOCAL_SPEC.test(s));
|
|
145
|
+
if (bad.length) throw new Error(`Unsupported package spec(s): ${bad.join(", ")}. Pass registry names (name, name@version, name@range).`);
|
|
146
|
+
|
|
147
|
+
const info = opts.info || await npmInfo(root, env);
|
|
148
|
+
const original = Object.fromEntries(COPY.map((f) => [f, readIfExists(join(root, f))]));
|
|
149
|
+
const lockName = LOCKFILES.find((f) => original[f]) || "package-lock.json";
|
|
150
|
+
|
|
151
|
+
const tmp = mkdtempSync(join(tmpdir(), "penthera-install-"));
|
|
152
|
+
try {
|
|
153
|
+
const work = join(tmp, "project");
|
|
154
|
+
mkdirSync(work);
|
|
155
|
+
for (const f of COPY) if (original[f]) copyFileSync(join(root, f), join(work, f));
|
|
156
|
+
|
|
157
|
+
const flags = [...(opts.saveDev ? ["--save-dev"] : []), ...(opts.saveExact ? ["--save-exact"] : []), ...(opts.offline ? ["--offline"] : [])];
|
|
158
|
+
onPhase(`Resolving in a temporary copy: npm install --package-lock-only --ignore-scripts${specs.length ? ` ${specs.join(" ")}` : ""}`);
|
|
159
|
+
await npm(["install", "--package-lock-only", "--ignore-scripts", ...QUIET, ...flags, ...specs], { cwd: work, env });
|
|
160
|
+
|
|
161
|
+
const proposedLockPath = join(work, lockName);
|
|
162
|
+
if (!existsSync(proposedLockPath)) throw new Error("npm did not write a lockfile");
|
|
163
|
+
const proposedLock = JSON.parse(readFileSync(proposedLockPath, "utf-8"));
|
|
164
|
+
|
|
165
|
+
// Base: the project's current lockfile, or an empty tree for a first install.
|
|
166
|
+
let baseLockPath = original[lockName] ? join(root, lockName) : null;
|
|
167
|
+
if (!baseLockPath) {
|
|
168
|
+
mkdirSync(join(tmp, "base"));
|
|
169
|
+
baseLockPath = join(tmp, "base", "package-lock.json");
|
|
170
|
+
writeFileSync(baseLockPath, JSON.stringify({ lockfileVersion: 3, packages: { "": {} } }));
|
|
171
|
+
}
|
|
172
|
+
const oldKeys = new Set(parseLockfile(JSON.parse(readFileSync(baseLockPath, "utf-8"))).map(key));
|
|
173
|
+
const fresh = parseLockfile(proposedLock).filter((p) => !p.link && p.version && !oldKeys.has(key(p)));
|
|
174
|
+
|
|
175
|
+
// New tarballs go into a private npm cache (npm cache add): nothing is
|
|
176
|
+
// unpacked or run. Private because npm will not reliably say where its own
|
|
177
|
+
// cache is (`npm config get cache` refuses some paths), and because the
|
|
178
|
+
// scan must not depend on what else that cache holds.
|
|
179
|
+
const userCache = npmCacheDir(env);
|
|
180
|
+
const fetchCache = join(tmp, "cache");
|
|
181
|
+
const toFetch = fresh.filter((p) => !p.integrity || !sourceFromNpmCache(p.integrity, userCache));
|
|
182
|
+
if (toFetch.length) {
|
|
183
|
+
onPhase(`Fetching ${toFetch.length} tarball(s) with npm cache add (nothing is unpacked or run)`);
|
|
184
|
+
const fetchSpecs = [...new Set(toFetch.map((p) => (p.resolved && !/^https?:/.test(p.resolved) ? p.resolved : `${p.name}@${p.version}`)))];
|
|
185
|
+
await npm(["cache", "add", "--cache", fetchCache, ...(opts.offline ? ["--offline"] : []), ...fetchSpecs], { cwd: tmp, env });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
onPhase("Comparing capabilities with the current tree (offline, integrity-verified)");
|
|
189
|
+
const result = await scanDeps(work, { diff: baseLockPath, treeRoot: root, cacheDir: [fetchCache, userCache] });
|
|
190
|
+
const changes = changesFromDiff(result.deps.diff, result.deps.typosquats, result.deps.packages);
|
|
191
|
+
const verdict = evaluatePolicy(changes, opts.policy || normalizePolicy({}));
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
root,
|
|
195
|
+
specs,
|
|
196
|
+
npm: info,
|
|
197
|
+
lockName,
|
|
198
|
+
original,
|
|
199
|
+
proposed: {
|
|
200
|
+
"package.json": readFileSync(join(work, "package.json")),
|
|
201
|
+
[lockName]: readFileSync(proposedLockPath),
|
|
202
|
+
},
|
|
203
|
+
changes,
|
|
204
|
+
removed: result.deps.diff.removed,
|
|
205
|
+
verdict,
|
|
206
|
+
findings: result.findings,
|
|
207
|
+
typosquats: result.deps.typosquats,
|
|
208
|
+
evidence: result.deps.diff.evidence,
|
|
209
|
+
};
|
|
210
|
+
} finally {
|
|
211
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Packages whose new or changed install scripts would run. */
|
|
216
|
+
export const scriptChanges = (plan) => plan.verdict.items.filter((i) => i.flags.includes("install-scripts"));
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Pin approved packages in package.json allowScripts (npm >= 12 runs a
|
|
220
|
+
* dependency's install scripts only when listed there). Returns new bytes.
|
|
221
|
+
*/
|
|
222
|
+
export function withAllowScripts(pkgJsonBuf, items) {
|
|
223
|
+
const text = pkgJsonBuf.toString("utf-8");
|
|
224
|
+
const pkg = JSON.parse(text);
|
|
225
|
+
const allow = { ...(pkg.allowScripts || {}) };
|
|
226
|
+
for (const i of items) {
|
|
227
|
+
if (allow[i.name] === false) continue; // an explicit denial in the project wins
|
|
228
|
+
allow[`${i.name}@${i.version}`] = true;
|
|
229
|
+
}
|
|
230
|
+
pkg.allowScripts = Object.fromEntries(Object.entries(allow).sort((a, b) => a[0].localeCompare(b[0])));
|
|
231
|
+
const indent = text.match(/^\{\r?\n([ \t]+)"/)?.[1] || " ";
|
|
232
|
+
return Buffer.from(JSON.stringify(pkg, null, indent) + (text.endsWith("\n") ? "\n" : ""));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function restore(root, original) {
|
|
236
|
+
for (const [f, buf] of Object.entries(original)) {
|
|
237
|
+
const p = join(root, f);
|
|
238
|
+
if (buf) writeFileSync(p, buf);
|
|
239
|
+
else rmSync(p, { force: true });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Install the vetted tree. Writes the proposed package.json and lockfile,
|
|
245
|
+
* then runs `npm install` with no package arguments, so npm installs exactly
|
|
246
|
+
* the lockfile that was reviewed. Restores both files if npm fails.
|
|
247
|
+
* @param {{ runScripts: boolean, env?: object, stdio?: string, offline?: boolean }} opts
|
|
248
|
+
*/
|
|
249
|
+
export async function applyInstall(plan, opts) {
|
|
250
|
+
const { root, original } = plan;
|
|
251
|
+
const env = opts.env || process.env;
|
|
252
|
+
let pkgJson = plan.proposed["package.json"];
|
|
253
|
+
const approved = scriptChanges(plan);
|
|
254
|
+
if (opts.runScripts && plan.npm.allowScriptsPolicy && approved.length) pkgJson = withAllowScripts(pkgJson, approved);
|
|
255
|
+
|
|
256
|
+
writeFileSync(join(root, "package.json"), pkgJson);
|
|
257
|
+
writeFileSync(join(root, plan.lockName), plan.proposed[plan.lockName]);
|
|
258
|
+
const args = ["install", ...QUIET, ...(opts.runScripts ? [] : ["--ignore-scripts"]), ...(opts.offline ? ["--offline"] : [])];
|
|
259
|
+
try {
|
|
260
|
+
await npm(args, { cwd: root, env });
|
|
261
|
+
} catch (e) {
|
|
262
|
+
restore(root, original);
|
|
263
|
+
throw new Error(`${e.message}\npackage.json and ${plan.lockName} were restored; node_modules may be partly updated (run \`npm ci\` to reset it).`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// npm should have installed the reviewed lockfile as is.
|
|
267
|
+
const want = new Set(parseLockfile(JSON.parse(plan.proposed[plan.lockName])).map(key));
|
|
268
|
+
const got = parseLockfile(JSON.parse(readFileSync(join(root, plan.lockName), "utf-8"))).map(key);
|
|
269
|
+
const drift = got.filter((k) => !want.has(k));
|
|
270
|
+
return { args, scriptsRan: opts.runScripts, allowScriptsAdded: pkgJson !== plan.proposed["package.json"] ? approved.map((i) => `${i.name}@${i.version}`) : [], drift };
|
|
271
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
* The same file has two optional sections for other commands: `licenses`
|
|
19
|
+
* (penthera deps, lib/deps/license.js) and `hook` (penthera hook run,
|
|
20
|
+
* lib/hook/policy.js). Both come back as null / defaults when absent.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
23
|
+
import { join } from "node:path";
|
|
24
|
+
import { CAPABILITY_ORDER, CAPABILITIES } from "../deps/capabilities.js";
|
|
25
|
+
import { normalizeLicensePolicy } from "../deps/license.js";
|
|
26
|
+
import { normalizeHookPolicy } from "../hook/policy.js";
|
|
27
|
+
|
|
28
|
+
export const POLICY_FILE = "penthera.policy.json";
|
|
29
|
+
|
|
30
|
+
export const RULE_IDS = [...CAPABILITY_ORDER, "typosquat", "unscanned"];
|
|
31
|
+
|
|
32
|
+
export const RULE_LABELS = {
|
|
33
|
+
...Object.fromEntries(CAPABILITY_ORDER.map((id) => [id, CAPABILITIES[id].label])),
|
|
34
|
+
typosquat: "looks like a popular package",
|
|
35
|
+
unscanned: "could not be scanned",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_POLICY = Object.freeze({
|
|
39
|
+
allow: [],
|
|
40
|
+
deny: [],
|
|
41
|
+
requireApproval: ["install-scripts", "ai-config-write", "ai-cli-invoke", "token-harvest", "obfuscated", "typosquat", "unscanned"],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Validate and complete a policy object. Throws with a readable message on
|
|
46
|
+
* unknown fields, unknown rule ids, or non-string entries.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizePolicy(raw, label = POLICY_FILE) {
|
|
49
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected a JSON object`);
|
|
50
|
+
const known = ["allow", "deny", "requireApproval", "licenses", "hook", "$schema", "description"];
|
|
51
|
+
const extra = Object.keys(raw).filter((k) => !known.includes(k));
|
|
52
|
+
if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected allow, deny, requireApproval, licenses, hook)`);
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const field of ["allow", "deny", "requireApproval"]) {
|
|
55
|
+
const v = raw[field] ?? DEFAULT_POLICY[field];
|
|
56
|
+
if (!Array.isArray(v) || v.some((x) => typeof x !== "string" || !x.trim())) {
|
|
57
|
+
throw new Error(`${label}: "${field}" must be an array of non-empty strings`);
|
|
58
|
+
}
|
|
59
|
+
out[field] = [...new Set(v.map((x) => x.trim()))];
|
|
60
|
+
}
|
|
61
|
+
for (const field of ["deny", "requireApproval"]) {
|
|
62
|
+
const bad = out[field].filter((id) => !RULE_IDS.includes(id));
|
|
63
|
+
if (bad.length) throw new Error(`${label}: unknown capability in "${field}": ${bad.join(", ")}. Known: ${RULE_IDS.join(", ")}`);
|
|
64
|
+
}
|
|
65
|
+
out.licenses = raw.licenses == null ? null : normalizeLicensePolicy(raw.licenses, `${label}: licenses`);
|
|
66
|
+
out.hook = normalizeHookPolicy(raw.hook, `${label}: hook`);
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Load the policy: an explicit path, else penthera.policy.json in the project,
|
|
72
|
+
* else the defaults. Returns { policy, path } (path null for defaults).
|
|
73
|
+
*/
|
|
74
|
+
export function loadPolicy(root, explicitPath = null) {
|
|
75
|
+
const path = explicitPath || join(root, POLICY_FILE);
|
|
76
|
+
if (!existsSync(path)) {
|
|
77
|
+
if (explicitPath) throw new Error(`Policy file not found: ${explicitPath}`);
|
|
78
|
+
return { policy: normalizePolicy({}), path: null };
|
|
79
|
+
}
|
|
80
|
+
let raw;
|
|
81
|
+
try {
|
|
82
|
+
raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
83
|
+
} catch (e) {
|
|
84
|
+
throw new Error(`${path}: ${e.message}`);
|
|
85
|
+
}
|
|
86
|
+
return { policy: normalizePolicy(raw, path), path };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const escapeRe = (s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
|
|
90
|
+
|
|
91
|
+
/** Split "name@1.2.3" / "@scope/name@1.2.3" into { name, version }. */
|
|
92
|
+
function splitEntry(entry) {
|
|
93
|
+
const at = entry.lastIndexOf("@");
|
|
94
|
+
if (at > 0) return { name: entry.slice(0, at), version: entry.slice(at + 1) };
|
|
95
|
+
return { name: entry, version: null };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The first allow entry matching name@version, or null. */
|
|
99
|
+
export function matchAllow(allow, name, version) {
|
|
100
|
+
for (const entry of allow) {
|
|
101
|
+
const e = splitEntry(entry);
|
|
102
|
+
const re = new RegExp(`^${e.name.split("*").map(escapeRe).join(".*")}$`);
|
|
103
|
+
if (!re.test(name)) continue;
|
|
104
|
+
if (e.version && e.version !== "*" && e.version !== version) continue;
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Apply the policy to the changes an install would make.
|
|
112
|
+
* @param {{ name, version, flags: string[] }[]} changes flags: the rule ids this change raises
|
|
113
|
+
* @returns {{ items: object[], denied: object[], needsApproval: object[], clean: boolean }}
|
|
114
|
+
*/
|
|
115
|
+
export function evaluatePolicy(changes, policy) {
|
|
116
|
+
const items = changes.map((c) => {
|
|
117
|
+
const allowedBy = matchAllow(policy.allow, c.name, c.version);
|
|
118
|
+
const deny = allowedBy ? [] : c.flags.filter((id) => policy.deny.includes(id));
|
|
119
|
+
const approval = allowedBy ? [] : c.flags.filter((id) => policy.requireApproval.includes(id) && !deny.includes(id));
|
|
120
|
+
return { ...c, allowedBy, deny, approval };
|
|
121
|
+
});
|
|
122
|
+
const denied = items.filter((i) => i.deny.length);
|
|
123
|
+
const needsApproval = items.filter((i) => i.approval.length);
|
|
124
|
+
return { items, denied, needsApproval, clean: denied.length === 0 && needsApproval.length === 0 };
|
|
125
|
+
}
|
|
@@ -38,6 +38,15 @@
|
|
|
38
38
|
"content": "Final report: detailed description, root cause, applied and ongoing mitigations, and any cross-border impact."
|
|
39
39
|
}
|
|
40
40
|
],
|
|
41
|
+
"cra": {
|
|
42
|
+
"law": "Cyber Resilience Act, Regulation (EU) 2024/2847, Article 14 (reporting applies from 11 September 2026)",
|
|
43
|
+
"csirtCoordinator": "NCSC-NL (Nationaal Cyber Security Centrum)",
|
|
44
|
+
"csirtContact": "https://www.ncsc.nl/contact",
|
|
45
|
+
"srp": "https://portal.cra-srp.enisa.europa.eu/",
|
|
46
|
+
"timezone": "Europe/Amsterdam",
|
|
47
|
+
"source": "https://www.enisa.europa.eu/topics/product-security/vulnerability-services/eu-incident-response-and-cyber-crisis-management/single-reporting-platform-srp/list-of-csirts-designated-as-coordinators",
|
|
48
|
+
"note": "NCSC-NL is the Dutch CSIRT designated as coordinator on ENISA's list (checked 2026-10-19). It applies if your main establishment is in the Netherlands, i.e. where cybersecurity decisions about your products are predominantly taken (Art. 14(7))."
|
|
49
|
+
},
|
|
41
50
|
"penalties": "Up to €10M or 2% of global annual turnover. Management can be held personally accountable.",
|
|
42
51
|
"pointers": [
|
|
43
52
|
"Determine whether you are an essential or important entity, and your sector's competent authority.",
|
|
@@ -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
|
+
}
|