penthera 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +315 -0
- package/README.md +139 -76
- package/SECURITY.md +8 -6
- package/docs/examples/pr-dependency-review.yml +1 -1
- package/lib/adaptive-probes.js +3 -3
- package/lib/advisories/evidence.js +248 -0
- package/lib/advisories/index.js +228 -0
- package/lib/advisories/products.js +83 -0
- package/lib/advisories/versions.js +65 -0
- package/lib/agents/hidden-content.js +122 -11
- package/lib/agents/index.js +26 -6
- package/lib/agents/lock.js +33 -5
- package/lib/agents/rules.js +33 -4
- package/lib/compliance/index.js +4 -4
- package/lib/crawler.js +12 -15
- package/lib/deps/capabilities.js +21 -5
- package/lib/deps/findings.js +309 -0
- package/lib/deps/index.js +13 -272
- package/lib/deps/iocs.js +74 -0
- package/lib/deps/lockfile.js +3 -8
- package/lib/deps/online.js +8 -3
- package/lib/deps/popular-packages.json.gz +0 -0
- package/lib/deps/popular-pypi.json.gz +0 -0
- package/lib/deps/python/capabilities.js +16 -3
- package/lib/deps/python/index.js +5 -1
- package/lib/deps/python/lockfiles.js +11 -5
- package/lib/deps/python/source.js +75 -26
- package/lib/deps/python/toml.js +15 -3
- package/lib/deps/source.js +28 -9
- package/lib/deps/typosquat.js +30 -5
- package/lib/fix/apply.js +253 -34
- package/lib/fix/index.js +7 -4
- package/lib/fix/model.js +25 -5
- package/lib/fix/util.js +6 -2
- package/lib/fuzzer.js +3 -3
- package/lib/git.js +65 -0
- package/lib/hook/index.js +157 -73
- package/lib/hook/policy.js +10 -4
- package/lib/install/index.js +4 -7
- package/lib/machine.js +0 -13
- package/lib/memory/store.js +11 -4
- package/lib/nuclei-loader.js +0 -25
- package/lib/owasp-wstg.js +4 -0
- package/lib/plugins.js +1 -1
- package/lib/provenance.js +1 -0
- package/lib/recon.js +12 -31
- package/lib/references.json +224 -0
- package/lib/retirejs.js +4 -4
- package/lib/rules/advisories/advisories.json +186 -0
- package/lib/rules/advisories/eol.json +413 -0
- package/lib/rules/advisories/pack.json +20 -0
- package/lib/rules/advisories/pack.json.sig +6 -0
- package/lib/rules/allowed_signers +13 -0
- package/lib/rules/core/agent-exec.json +29 -0
- package/lib/rules/core/agent-instructions.json +17 -0
- package/lib/rules/core/pack.json +30 -0
- package/lib/rules/core/pack.json.sig +6 -0
- package/lib/rules/core/secrets.json +161 -0
- package/lib/rules/core/templates.json +52 -0
- package/lib/rules/index.js +107 -0
- package/lib/rules/ioc-2025-2026/files.json +43 -0
- package/lib/rules/ioc-2025-2026/hosts.json +35 -0
- package/lib/rules/ioc-2025-2026/pack.json +25 -0
- package/lib/rules/ioc-2025-2026/pack.json.sig +6 -0
- package/lib/rules/ioc-2025-2026/packages.json +1048 -0
- package/lib/rules/pack.js +452 -0
- package/lib/rules/regex-guard.js +81 -9
- package/lib/rules/sshsig.js +205 -0
- package/lib/templates.js +24 -20
- package/lib/tmp.js +21 -0
- package/lib/whitebox/frameworks.js +1 -1
- package/lib/whitebox/secrets.js +117 -41
- package/package.json +12 -4
- package/scripts/rules/build-pack.mjs +80 -0
- package/scripts/rules/check-bundled.mjs +30 -0
- package/scripts/rules/sign-pack.sh +32 -0
- package/skills/penthera/SKILL.md +16 -15
- package/src/agent/git.js +147 -14
- package/src/agent/loop.js +165 -46
- package/src/cli/agent.js +11 -5
- package/src/cli/deps.js +0 -2
- package/src/cli/detect.js +14 -7
- package/src/cli/fix.js +26 -8
- package/src/cli/hook.js +2 -1
- package/src/cli/onboarding.js +14 -6
- package/src/cli/report-outputs.js +1 -0
- package/src/cli/rules.js +144 -0
- package/src/cli/ui.js +43 -10
- package/src/cli.js +24 -1
- package/src/report/evidence.js +105 -21
- package/src/report/footer.js +1 -1
- package/src/report/md-escape.js +8 -0
- package/src/scan-repo.js +6 -0
- package/src/scan-url.js +11 -3
- package/src/ui/api/data.js +39 -6
- package/src/ui/api/routes.js +44 -11
- package/src/ui/api/runs.js +32 -7
- package/src/ui/security.js +20 -23
- package/src/ui/server.js +52 -27
- package/src/ui/static/app.css +583 -213
- package/src/ui/static/favicon.svg +1 -1
- package/src/ui/static/fonts/DepartureMono-LICENSE.txt +93 -0
- package/src/ui/static/fonts/DepartureMono-Regular.woff2 +0 -0
- package/src/ui/static/index.html +5 -4
- package/src/ui/static/js/api.js +114 -6
- package/src/ui/static/js/app.js +81 -42
- package/src/ui/static/js/center.js +246 -133
- package/src/ui/static/js/charts.js +99 -18
- package/src/ui/static/js/copy.js +68 -9
- package/src/ui/static/js/dialog.js +152 -0
- package/src/ui/static/js/left.js +23 -60
- package/src/ui/static/js/menubar.js +170 -0
- package/src/ui/static/js/onboarding.js +104 -0
- package/src/ui/static/js/panels.js +186 -0
- package/src/ui/static/js/right.js +69 -188
- package/src/ui/static/js/state.js +36 -5
- package/src/ui/static/logo.svg +10 -0
- package/src/ui/static/mark.svg +3 -0
- package/src/utils/http.js +162 -13
- package/docs/METHODOLOGY.md +0 -375
- package/docs/commands/README.md +0 -24
- package/docs/commands/action.md +0 -21
- package/docs/commands/agents.md +0 -38
- package/docs/commands/deps.md +0 -115
- package/docs/commands/fix.md +0 -53
- package/docs/commands/hook.md +0 -69
- package/docs/commands/init.md +0 -114
- package/docs/commands/install.md +0 -53
- package/docs/commands/scan.md +0 -100
- package/docs/commands/skill.md +0 -58
- package/docs/compare/README.md +0 -25
- package/docs/compare/github.md +0 -60
- package/docs/compare/semgrep.md +0 -54
- package/docs/compare/snyk.md +0 -62
- package/docs/compare/socket.md +0 -78
- package/docs/compare/zap.md +0 -59
- package/docs/compliance-cra.md +0 -141
- package/docs/compliance-nis2.md +0 -168
- package/docs/dashboard.md +0 -187
- package/docs/evidence.md +0 -162
- package/docs/funding/due-diligence-checklist.md +0 -79
- package/docs/funding/nlnet-ngi-zero-draft.md +0 -273
- package/docs/funding/partner-one-pager.md +0 -84
- package/docs/funding/sponsor-tiers.md +0 -81
- package/docs/index.md +0 -81
- package/docs/internal-audit.md +0 -83
- package/docs/local-loop.md +0 -276
- package/docs/marketing.md +0 -151
- package/docs/owasp-wstg-coverage.md +0 -79
- package/docs/plan.md +0 -102
- package/docs/privacy.md +0 -38
- package/docs/references.bib +0 -559
- package/docs/references.md +0 -119
- package/docs/release-plan.md +0 -228
- package/docs/research/socket-positioning-notes.md +0 -183
- package/docs/use-cases/README.md +0 -14
- package/docs/use-cases/agencies.md +0 -71
- package/docs/use-cases/ai-coding-teams.md +0 -81
- package/docs/use-cases/eu-sme-compliance.md +0 -68
- package/docs/use-cases/open-source-maintainers.md +0 -75
- package/docs/use-cases/solo-founder.md +0 -65
- package/lib/deps/popular-packages.json +0 -1
- package/lib/deps/popular-pypi.json +0 -1
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Version strings as runtimes and servers print them, and how they compare.
|
|
3
|
+
*
|
|
4
|
+
* An upstream version is `major.minor[.patch][letters]`: `8.3.12`, `2.4.62`,
|
|
5
|
+
* `1.1.1w` (OpenSSL 1.x used letters for patch releases). Anything after it
|
|
6
|
+
* (`-1ubuntu2.19`, `+deb12u1`, `-fpm-alpine`) is a suffix. On a banner a suffix
|
|
7
|
+
* means a distribution build, whose security fixes are backported without the
|
|
8
|
+
* upstream number changing, so the number alone cannot say whether the fix is
|
|
9
|
+
* in. That case is never reported as affected; see lib/advisories/index.js.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const VERSION = /^(\d{1,4})(?:\.(\d{1,4})(?:\.(\d{1,6}))?)?([a-z]{1,2})?(.*)$/;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Parse a version string.
|
|
16
|
+
* @param {string} s
|
|
17
|
+
* `20`, `v20` and `8.3` parse too, with the missing parts null.
|
|
18
|
+
* @returns {{ major: number, minor: number|null, patch: number|null, letters: string,
|
|
19
|
+
* suffix: string, upstream: string, raw: string } | null}
|
|
20
|
+
*/
|
|
21
|
+
export function parseVersion(s) {
|
|
22
|
+
if (typeof s !== "string") return null;
|
|
23
|
+
const m = VERSION.exec(s.trim().replace(/^v(?=\d)/, ""));
|
|
24
|
+
if (!m) return null;
|
|
25
|
+
const [, major, minor, patch, letters = "", suffix = ""] = m;
|
|
26
|
+
// "8.3.12abc" is not a version; letters only follow a full x.y.z.
|
|
27
|
+
if (letters && patch === undefined) return null;
|
|
28
|
+
// A suffix starts with a separator: "20-alpine", "8.1.2-1ubuntu2", not "8x".
|
|
29
|
+
if (suffix && !/^[-+~_ ]/.test(suffix)) return null;
|
|
30
|
+
const parts = [major, minor, patch].filter((x) => x !== undefined).map(Number);
|
|
31
|
+
return {
|
|
32
|
+
major: Number(major),
|
|
33
|
+
minor: minor === undefined ? null : Number(minor),
|
|
34
|
+
patch: patch === undefined ? null : Number(patch),
|
|
35
|
+
letters,
|
|
36
|
+
suffix,
|
|
37
|
+
upstream: parts.join(".") + letters,
|
|
38
|
+
raw: s.trim(),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Compare two parsed versions (or strings). A missing patch sorts as 0. */
|
|
43
|
+
export function compareVersions(a, b) {
|
|
44
|
+
const x = typeof a === "string" ? parseVersion(a) : a;
|
|
45
|
+
const y = typeof b === "string" ? parseVersion(b) : b;
|
|
46
|
+
if (!x || !y) throw new Error(`not a version: ${!x ? a : b}`);
|
|
47
|
+
if (x.major !== y.major) return x.major < y.major ? -1 : 1;
|
|
48
|
+
const mx = x.minor ?? 0;
|
|
49
|
+
const my = y.minor ?? 0;
|
|
50
|
+
if (mx !== my) return mx < my ? -1 : 1;
|
|
51
|
+
const px = x.patch ?? 0;
|
|
52
|
+
const py = y.patch ?? 0;
|
|
53
|
+
if (px !== py) return px < py ? -1 : 1;
|
|
54
|
+
if (x.letters !== y.letters) {
|
|
55
|
+
// OpenSSL: 1.1.1 < 1.1.1a < 1.1.1z < 1.1.1za
|
|
56
|
+
if (x.letters.length !== y.letters.length) return x.letters.length < y.letters.length ? -1 : 1;
|
|
57
|
+
return x.letters < y.letters ? -1 : 1;
|
|
58
|
+
}
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Compare branches like "8.3" and "8.10", "20" and "22", "1.1.1" and "3.0". */
|
|
63
|
+
export function compareBranches(a, b) {
|
|
64
|
+
return compareVersions(a, b);
|
|
65
|
+
}
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
* Each detector returns hits `{ rule, severity, title, detail, line, count }`.
|
|
23
23
|
* Callers turn hits into findings.
|
|
24
24
|
*/
|
|
25
|
+
import { rulesOf, rulesGeneration, packTest, packMatch } from "../rules/index.js";
|
|
25
26
|
|
|
26
27
|
// ── Invisible characters ──────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -200,7 +201,7 @@ function detectWhitespaceInflation(text, opts) {
|
|
|
200
201
|
|
|
201
202
|
// ── Instructions in hidden places ─────────────────────────────────────────
|
|
202
203
|
|
|
203
|
-
const INSTRUCTION_PATTERNS = [
|
|
204
|
+
export const INSTRUCTION_PATTERNS = [
|
|
204
205
|
/\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any|other)\b[^.\n]{0,20}\b(?:instructions?|rules?|prompts?|guidelines?|directions?)\b/i,
|
|
205
206
|
/\b(?:you|assistant|claude|agent|model|ai|llm)\b[^.\n]{0,30}\b(?:must|should|shall|will|are to|need to|have to)\b/i,
|
|
206
207
|
/\bdo(?:n'?t| not) (?:tell|inform|mention|reveal|show|alert|ask|notify)\b/i,
|
|
@@ -208,15 +209,17 @@ const INSTRUCTION_PATTERNS = [
|
|
|
208
209
|
/\b(?:system prompt|developer message|new instructions?)\b/i,
|
|
209
210
|
/\b(?:run|execute|exec|invoke)\b[^.\n]{0,40}\b(?:command|script|bash|shell|curl|wget|powershell)\b/i,
|
|
210
211
|
/\b(?:send|upload|post|exfiltrat\w*|transmit|forward|copy)\b[^\n]{0,60}(?:https?:\/\/|\btokens?\b|\bsecrets?\b|\bapi keys?\b|\bcredentials?\b|\.env\b|\bssh\b|\bpasswords?\b)/i,
|
|
211
|
-
/
|
|
212
|
+
// Credential paths: lib/rules/core/agent-instructions.json.
|
|
213
|
+
...rulesOf("agent-instruction", { pack: "core" }).map((r) => r.pattern),
|
|
212
214
|
];
|
|
215
|
+
const packInstructions = () => rulesOf("agent-instruction", { exceptPack: "core" });
|
|
213
216
|
|
|
214
217
|
// Text addressed to an automated reviewer. Legitimate skills have no reason to
|
|
215
218
|
// talk to a security scanner.
|
|
216
219
|
const SCANNER_DIRECTED = /\b(?:security |safety |skill |code )?(?:scanners?|reviewers?|auditors?|judges?|classifiers?|analy[sz]ers?|moderators?)\b[^.\n]{0,60}\b(?:is|as|are|mark|classify|report|treat|consider|rate|label)\b[^.\n]{0,30}\b(?:safe|benign|harmless|clean|trusted|approved?|pass(?:ed)?|low[- ]risk|false positive)\b/i;
|
|
217
220
|
|
|
218
221
|
export function looksLikeInstruction(s) {
|
|
219
|
-
return INSTRUCTION_PATTERNS.some((re) => re.test(s));
|
|
222
|
+
return INSTRUCTION_PATTERNS.some((re) => re.test(s)) || packInstructions().some((r) => packTest(r, s));
|
|
220
223
|
}
|
|
221
224
|
|
|
222
225
|
function detectHiddenInstructions(text) {
|
|
@@ -250,28 +253,136 @@ function detectHiddenInstructions(text) {
|
|
|
250
253
|
// ── Code execution patterns ───────────────────────────────────────────────
|
|
251
254
|
|
|
252
255
|
const SHELL = String.raw`(?:sudo\s+)?(?:env\s+\S*\s*)?(?:ba|z|da|k|fi|c|tc)?sh\b`;
|
|
256
|
+
const INTERPRETER = String.raw`(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php|deno|bun)\b`;
|
|
257
|
+
const PS_DOWNLOAD = String.raw`\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b`;
|
|
258
|
+
|
|
259
|
+
// The patterns of the form `<source> ... | <sink>` are matched by the helpers
|
|
260
|
+
// below rather than by `re` itself (kept as the definition). As one regex
|
|
261
|
+
// they restart the scan at every <source> on a line, which is quadratic on a
|
|
262
|
+
// line full of them; the helpers return the same match in linear time.
|
|
263
|
+
|
|
264
|
+
function matchAt(text, start, end) {
|
|
265
|
+
const m = [text.slice(start, end)];
|
|
266
|
+
m.index = start;
|
|
267
|
+
return m;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** `source[^\n|]*\|\s*sink`: the source sits in the pipeline stage just before the pipe. */
|
|
271
|
+
function pipedFrom(source, sink) {
|
|
272
|
+
const pipe = new RegExp(String.raw`\|\s*${sink}`, "gi");
|
|
273
|
+
return (text) => {
|
|
274
|
+
for (const m of text.matchAll(pipe)) {
|
|
275
|
+
const start = m.index === 0 ? 0 : Math.max(text.lastIndexOf("|", m.index - 1), text.lastIndexOf("\n", m.index - 1)) + 1;
|
|
276
|
+
const s = source.exec(text.slice(start, m.index));
|
|
277
|
+
if (s) return matchAt(text, start + s.index, m.index + m[0].length);
|
|
278
|
+
}
|
|
279
|
+
return null;
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* `first[^\n]*second`: leftmost `first`, extended to the last `second` that
|
|
285
|
+
* starts on its line. A `second` that begins with `\|` may run past the line
|
|
286
|
+
* end (`\|\s*sh` spans a newline), so it is tried at each pipe on the line.
|
|
287
|
+
*/
|
|
288
|
+
function thenOnLine(first, second) {
|
|
289
|
+
const firstG = new RegExp(first, "gi");
|
|
290
|
+
const secondG = new RegExp(second, "gi");
|
|
291
|
+
const secondY = new RegExp(second, "iy");
|
|
292
|
+
const atPipe = second.startsWith("\\|");
|
|
293
|
+
const lastOnLine = (text, from, eol) => {
|
|
294
|
+
let last = null;
|
|
295
|
+
if (atPipe) {
|
|
296
|
+
for (let p = text.indexOf("|", from); p >= 0 && p < eol; p = text.indexOf("|", p + 1)) {
|
|
297
|
+
secondY.lastIndex = p;
|
|
298
|
+
const b = secondY.exec(text);
|
|
299
|
+
if (b) last = b;
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
for (const b of text.slice(from, eol).matchAll(secondG)) last = { 0: b[0], index: from + b.index };
|
|
303
|
+
}
|
|
304
|
+
return last;
|
|
305
|
+
};
|
|
306
|
+
return (text) => {
|
|
307
|
+
let skipTo = 0;
|
|
308
|
+
for (const a of text.matchAll(firstG)) {
|
|
309
|
+
if (a.index < skipTo) continue;
|
|
310
|
+
const aEnd = a.index + a[0].length;
|
|
311
|
+
let eol = text.indexOf("\n", aEnd);
|
|
312
|
+
if (eol < 0) eol = text.length;
|
|
313
|
+
// Only the first `first` on a line needs checking: any `second` after a
|
|
314
|
+
// later one is also after this one.
|
|
315
|
+
skipTo = eol;
|
|
316
|
+
const last = lastOnLine(text, aEnd, eol);
|
|
317
|
+
if (last) return matchAt(text, a.index, last.index + last[0].length);
|
|
318
|
+
}
|
|
319
|
+
return null;
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function earliest(...finders) {
|
|
324
|
+
return (text) => {
|
|
325
|
+
let best = null;
|
|
326
|
+
for (const f of finders) {
|
|
327
|
+
const m = f(text);
|
|
328
|
+
if (m && (!best || m.index < best.index)) best = m;
|
|
329
|
+
}
|
|
330
|
+
return best;
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
253
334
|
export const EXEC_PATTERNS = [
|
|
254
|
-
{
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
335
|
+
{
|
|
336
|
+
rule: "curl-pipe-shell", label: "download piped to a shell",
|
|
337
|
+
re: new RegExp(String.raw`\b(?:curl|wget|fetch|iwr)\b[^\n|]*\|\s*${SHELL}`, "i"),
|
|
338
|
+
match: pipedFrom(/\b(?:curl|wget|fetch|iwr)\b/i, SHELL),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
rule: "curl-pipe-interpreter", label: "download piped to an interpreter",
|
|
342
|
+
re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:python[0-9.]*|node|perl|ruby|php|deno|bun)\b/i,
|
|
343
|
+
match: pipedFrom(/\b(?:curl|wget)\b/i, INTERPRETER),
|
|
344
|
+
},
|
|
345
|
+
// Process and command substitution: lib/rules/core/agent-exec.json.
|
|
346
|
+
...rulesOf("agent-exec", { pack: "core" }).map((r) => ({ rule: r.id, label: r.label, re: r.pattern })),
|
|
347
|
+
{
|
|
348
|
+
rule: "base64-pipe-shell", label: "decoded base64 piped to a shell",
|
|
349
|
+
re: new RegExp(String.raw`base64\s+(?:-d|--decode|-D)\b[^\n]*\|\s*${SHELL}`, "i"),
|
|
350
|
+
match: thenOnLine(String.raw`base64\s+(?:-d|--decode|-D)\b`, String.raw`\|\s*${SHELL}`),
|
|
351
|
+
},
|
|
259
352
|
{ rule: "shell-eval", label: "eval of command output", re: /\beval\s+["']?\$\(/ },
|
|
260
353
|
{ rule: "js-eval-remote", label: "eval of fetched or decoded content", re: /\b(?:eval|Function)\s*\(\s*(?:await\s+)?(?:atob|Buffer\.from|fetch|require\(\s*["']child_process|\(await fetch)/ },
|
|
261
354
|
{ rule: "py-exec-remote", label: "exec of fetched or decoded content", re: /\bexec\s*\(\s*(?:base64\.b64decode|__import__|compile|urllib|requests\.get|zlib\.decompress|marshal\.loads|bytes\.fromhex)/ },
|
|
262
|
-
{
|
|
355
|
+
{
|
|
356
|
+
rule: "powershell-iex", label: "PowerShell Invoke-Expression of a download",
|
|
357
|
+
re: /\b(?:iex|Invoke-Expression)\b[^\n]*\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b|\b(?:iwr|irm|Invoke-WebRequest|Invoke-RestMethod|DownloadString)\b[^\n]*\|\s*(?:iex|Invoke-Expression)\b/i,
|
|
358
|
+
match: earliest(
|
|
359
|
+
thenOnLine(String.raw`\b(?:iex|Invoke-Expression)\b`, PS_DOWNLOAD),
|
|
360
|
+
thenOnLine(PS_DOWNLOAD, String.raw`\|\s*(?:iex|Invoke-Expression)\b`),
|
|
361
|
+
),
|
|
362
|
+
},
|
|
263
363
|
];
|
|
264
364
|
|
|
265
365
|
/** Find remote-code-execution patterns in a string (a file, a hook command). */
|
|
266
366
|
export function findExecPatterns(text) {
|
|
267
367
|
const out = [];
|
|
268
|
-
for (const p of
|
|
269
|
-
const m = text.match(p.re);
|
|
368
|
+
for (const p of execPatterns()) {
|
|
369
|
+
const m = p.pack ? packMatch(p.pack, text) : p.match ? p.match(text) : text.match(p.re);
|
|
270
370
|
if (m) out.push({ rule: p.rule, label: p.label, match: m[0], index: m.index });
|
|
271
371
|
}
|
|
272
372
|
return out;
|
|
273
373
|
}
|
|
274
374
|
|
|
375
|
+
// Built-ins, then rules from other packs (bundled or --rules).
|
|
376
|
+
let _exec = null;
|
|
377
|
+
let _execGen = -1;
|
|
378
|
+
function execPatterns() {
|
|
379
|
+
if (_execGen !== rulesGeneration()) {
|
|
380
|
+
_exec = [...EXEC_PATTERNS, ...rulesOf("agent-exec", { exceptPack: "core" }).map((r) => ({ rule: r.id, label: r.label, re: r.pattern, pack: r }))];
|
|
381
|
+
_execGen = rulesGeneration();
|
|
382
|
+
}
|
|
383
|
+
return _exec;
|
|
384
|
+
}
|
|
385
|
+
|
|
275
386
|
function detectExec(text) {
|
|
276
387
|
return findExecPatterns(text).map((m) => ({
|
|
277
388
|
rule: m.rule,
|
package/lib/agents/index.js
CHANGED
|
@@ -157,6 +157,26 @@ export function gradeFindings(findings) {
|
|
|
157
157
|
|
|
158
158
|
// ── Scan ──────────────────────────────────────────────────────────────────
|
|
159
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Run `check` on each item; one file that throws (hostile nesting, a parser
|
|
162
|
+
* edge case) is reported and skipped instead of aborting the whole audit.
|
|
163
|
+
*/
|
|
164
|
+
function eachChecked(items, hits, fileOf, check) {
|
|
165
|
+
for (const item of items) {
|
|
166
|
+
try {
|
|
167
|
+
check(item);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
hits.push({
|
|
170
|
+
rule: "config-unparsed",
|
|
171
|
+
severity: "medium",
|
|
172
|
+
title: "File could not be checked",
|
|
173
|
+
detail: `${e?.message || e}. Review it by hand.`,
|
|
174
|
+
url: fileOf(item),
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
160
180
|
/**
|
|
161
181
|
* Audit agent configuration.
|
|
162
182
|
* @param {string} root
|
|
@@ -171,24 +191,24 @@ export async function scanAgents(root, opts = {}) {
|
|
|
171
191
|
const hits = [];
|
|
172
192
|
|
|
173
193
|
progress(`Scanning ${inv.skills.length} skill(s) for hidden content...`);
|
|
174
|
-
|
|
194
|
+
eachChecked(inv.skills, hits, (s) => inv.display(s.skillFile), (s) => {
|
|
175
195
|
scanSkillFolder(s, inv, hits);
|
|
176
196
|
hits.push(...checkAllowedTools(inv.display(s.skillFile), s.frontmatter, `Skill "${s.name}"`));
|
|
177
|
-
}
|
|
197
|
+
});
|
|
178
198
|
|
|
179
199
|
progress("Scanning instruction and rule files...");
|
|
180
|
-
|
|
200
|
+
eachChecked(inv.instructionFiles, hits, (f) => f.display, (f) => {
|
|
181
201
|
scanTextFile(f.path, f.display, hits);
|
|
182
202
|
if (/\.mdc?$/i.test(f.path)) {
|
|
183
203
|
let text = "";
|
|
184
204
|
try { text = readFileSync(f.path, "utf-8"); } catch { /* unreadable */ }
|
|
185
205
|
hits.push(...checkAllowedTools(f.display, parseFrontmatter(text), "Command"));
|
|
186
206
|
}
|
|
187
|
-
}
|
|
207
|
+
});
|
|
188
208
|
|
|
189
209
|
progress(`Checking ${inv.mcpServers.length} MCP server(s), ${inv.hooks.length} hook(s), permissions and plugins...`);
|
|
190
|
-
|
|
191
|
-
|
|
210
|
+
eachChecked(inv.mcpServers, hits, (m) => m.file, (m) => hits.push(...checkMcpServer(m)));
|
|
211
|
+
eachChecked(inv.hooks, hits, (h) => h.file, (h) => hits.push(...checkHook(h)));
|
|
192
212
|
hits.push(...checkPermissions(inv));
|
|
193
213
|
hits.push(...checkMarketplaces(inv));
|
|
194
214
|
for (const e of inv.errors) {
|
package/lib/agents/lock.js
CHANGED
|
@@ -18,13 +18,41 @@ function sha256(data) {
|
|
|
18
18
|
return createHash("sha256").update(data).digest("hex");
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/**
|
|
22
|
+
* Stable JSON: object keys sorted at every level.
|
|
23
|
+
*
|
|
24
|
+
* Iterative (an explicit stack, not recursion) so a config nested hundreds of
|
|
25
|
+
* thousands of levels deep hashes instead of overflowing the call stack.
|
|
26
|
+
*/
|
|
22
27
|
export function canonicalJson(value) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
const out = [];
|
|
29
|
+
// Pending work, popped from the end: a string is emitted as is, a
|
|
30
|
+
// { v } is a value still to serialise.
|
|
31
|
+
const stack = [{ v: value }];
|
|
32
|
+
while (stack.length) {
|
|
33
|
+
const item = stack.pop();
|
|
34
|
+
if (typeof item === "string") { out.push(item); continue; }
|
|
35
|
+
const v = item.v;
|
|
36
|
+
if (Array.isArray(v)) {
|
|
37
|
+
out.push("[");
|
|
38
|
+
stack.push("]");
|
|
39
|
+
for (let i = v.length - 1; i >= 0; i--) {
|
|
40
|
+
stack.push({ v: v[i] });
|
|
41
|
+
if (i > 0) stack.push(",");
|
|
42
|
+
}
|
|
43
|
+
} else if (v && typeof v === "object") {
|
|
44
|
+
const keys = Object.keys(v).sort();
|
|
45
|
+
out.push("{");
|
|
46
|
+
stack.push("}");
|
|
47
|
+
for (let i = keys.length - 1; i >= 0; i--) {
|
|
48
|
+
stack.push({ v: v[keys[i]] });
|
|
49
|
+
stack.push(`${i > 0 ? "," : ""}${JSON.stringify(keys[i])}:`);
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
out.push(JSON.stringify(v ?? null));
|
|
53
|
+
}
|
|
26
54
|
}
|
|
27
|
-
return
|
|
55
|
+
return out.join("");
|
|
28
56
|
}
|
|
29
57
|
|
|
30
58
|
/** Hash a folder: every file's path and content hash, plus symlink targets. */
|
package/lib/agents/rules.js
CHANGED
|
@@ -52,8 +52,24 @@ export function npmSpecPinned(spec) {
|
|
|
52
52
|
/** True if a Python spec (`pkg==1.2.3`, `pkg@1.2.3`) pins an exact version. */
|
|
53
53
|
export function pySpecPinned(spec) {
|
|
54
54
|
if (/^(?:\.{0,2}\/|file:|git\+|https?:)/.test(spec)) return true;
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const version = pinnedVersion(spec);
|
|
56
|
+
// Same language as /^\d+(?:\.\d+)*(?:[-.+]?[A-Za-z0-9.]+)?$/, without its quadratic overlap.
|
|
57
|
+
return version != null && /^(?:\d[A-Za-z0-9.]*|\d+(?:\.\d+)*[-+][A-Za-z0-9.]+)$/.test(version) && !/[<>~^*]/.test(spec);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The text after the first `==` or `@` in the spec's last run of
|
|
62
|
+
* non-separator characters: what `/(?:==|@)([^,;\s]+)$/` captures, found in
|
|
63
|
+
* linear time (that regex rescans the tail from every `@`).
|
|
64
|
+
*/
|
|
65
|
+
function pinnedVersion(spec) {
|
|
66
|
+
let start = spec.length;
|
|
67
|
+
while (start > 0 && !/[,;\s]/.test(spec[start - 1])) start--;
|
|
68
|
+
const tail = spec.slice(start);
|
|
69
|
+
const at = tail.search(/==|@/);
|
|
70
|
+
if (at < 0) return null;
|
|
71
|
+
const version = tail.slice(at + (tail[at] === "@" ? 1 : 2));
|
|
72
|
+
return version || null;
|
|
57
73
|
}
|
|
58
74
|
|
|
59
75
|
const RUNNERS = [
|
|
@@ -260,9 +276,22 @@ export function checkAllowedTools(display, frontmatter, label) {
|
|
|
260
276
|
|
|
261
277
|
// ── Hooks ─────────────────────────────────────────────────────────────────
|
|
262
278
|
|
|
263
|
-
|
|
279
|
+
// Whitespace, an optional (escaped) quote, whitespace. Written so the two
|
|
280
|
+
// whitespace runs cannot split one run of spaces between them, which made the
|
|
281
|
+
// former `\s*\\?["']?\s*` quadratic on long whitespace.
|
|
282
|
+
const QSEP = String.raw`\s*(?:\\?["']\s*|\\\s*)?`;
|
|
283
|
+
const AUTO_APPROVE = new RegExp(String.raw`["']?(?:permissionDecision|behavior)["']?${QSEP}:${QSEP}allow\b|["']?decision["']?${QSEP}:${QSEP}approve\b`, "i");
|
|
264
284
|
const PIPE_TO_SHELL = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b(?:\s|$|;|&|\))/;
|
|
265
285
|
|
|
286
|
+
/** The line holding the first pipe into a shell, for a finding's detail. */
|
|
287
|
+
function pipeToShellLine(text) {
|
|
288
|
+
const m = /\|\s*(?:sudo\s+)?(?:ba|z|da|k)?sh\b/.exec(text);
|
|
289
|
+
if (!m) return null;
|
|
290
|
+
const start = text.lastIndexOf("\n", m.index) + 1;
|
|
291
|
+
const end = text.indexOf("\n", m.index + m[0].length);
|
|
292
|
+
return text.slice(start, end < 0 ? text.length : end);
|
|
293
|
+
}
|
|
294
|
+
|
|
266
295
|
function hookScript(hook, command) {
|
|
267
296
|
const env = {
|
|
268
297
|
CLAUDE_PROJECT_DIR: hook.baseDir,
|
|
@@ -306,7 +335,7 @@ export function checkHook(h) {
|
|
|
306
335
|
rule: "hook-pipe-shell",
|
|
307
336
|
severity: "high",
|
|
308
337
|
title: `${where} hook pipes into a shell`,
|
|
309
|
-
detail: `Hook ${s.label} pipes data into \`sh\`/\`bash\`: \`${preview(s.text
|
|
338
|
+
detail: `Hook ${s.label} pipes data into \`sh\`/\`bash\`: \`${preview(pipeToShellLine(s.text) || s.text, 120)}\`. Whatever produces that input controls the commands.`,
|
|
310
339
|
url: h.file,
|
|
311
340
|
});
|
|
312
341
|
break;
|
package/lib/compliance/index.js
CHANGED
|
@@ -32,13 +32,13 @@ const NIS2_MEASURES = [
|
|
|
32
32
|
probes: [], categories: [],
|
|
33
33
|
note: "Organisational, outside a scanner's reach." },
|
|
34
34
|
{ id: "d", title: "Supply-chain security", level: "partial",
|
|
35
|
-
probes: ["retirejs", "secret-scanning"],
|
|
36
|
-
categories: ["cve", "secrets", "js-vulnerability"],
|
|
37
|
-
note: "JS dependency CVEs (Retire.js), leaked credentials (secret scan), a CycloneDX SBOM (--sbom), and passive supplier rating (--suppliers)." },
|
|
35
|
+
probes: ["retirejs", "secret-scanning", "runtime-advisories"],
|
|
36
|
+
categories: ["cve", "secrets", "js-vulnerability", "runtime-advisory"],
|
|
37
|
+
note: "JS dependency CVEs (Retire.js), runtime and server advisories (PHP, nginx, Apache, Node.js, Python, OpenSSL), leaked credentials (secret scan), a CycloneDX SBOM (--sbom), and passive supplier rating (--suppliers)." },
|
|
38
38
|
{ id: "e", title: "Secure acquisition, development & maintenance; vulnerability handling", level: "strong",
|
|
39
39
|
probes: ["reachability", "endpoint-discovery", "openapi", "sensitive-files", "cors",
|
|
40
40
|
"security-headers", "email-dns", "sqli", "ssti", "ssrf", "xss", "cmdi", "open-redirect",
|
|
41
|
-
"api-fuzzing", "risky-patterns"],
|
|
41
|
+
"api-fuzzing", "risky-patterns", "runtime-advisories"],
|
|
42
42
|
categories: ["*"],
|
|
43
43
|
note: "The core scan: black- and white-box testing mapped to OWASP WSTG, with SARIF and baseline regression." },
|
|
44
44
|
{ id: "f", title: "Assessing the effectiveness of measures", level: "evidence",
|
package/lib/crawler.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* - ffuf, wordlist-based endpoint brute-forcing
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { safeFetch } from "../src/utils/http.js";
|
|
13
|
+
import { safeFetch, readBody } from "../src/utils/http.js";
|
|
14
14
|
|
|
15
15
|
const DEFAULT_TIMEOUT = 8_000;
|
|
16
16
|
|
|
@@ -101,7 +101,7 @@ export async function discoverEndpoints(baseUrl, opts = {}) {
|
|
|
101
101
|
// which is often absent on chunked responses. This matches the unit the
|
|
102
102
|
// calibration baseline uses (text.length) so the noise filter in
|
|
103
103
|
// autoCalibrate compares like with like instead of header-vs-body.
|
|
104
|
-
const body = await res
|
|
104
|
+
const body = await readBody(res).catch(() => "");
|
|
105
105
|
return {
|
|
106
106
|
path,
|
|
107
107
|
status: res.status,
|
|
@@ -145,6 +145,11 @@ export async function fingerprint(baseUrl) {
|
|
|
145
145
|
if (!res) return tech;
|
|
146
146
|
|
|
147
147
|
tech.headers = Object.fromEntries(res.headers.entries());
|
|
148
|
+
// Cookie names only (never values): PHPSESSID says PHP even when the version is hidden.
|
|
149
|
+
tech.cookieNames = (res.headers.getSetCookie?.() || [])
|
|
150
|
+
.map((c) => c.split("=")[0].trim())
|
|
151
|
+
.filter((n) => /^[\w.-]{1,64}$/.test(n))
|
|
152
|
+
.slice(0, 20);
|
|
148
153
|
|
|
149
154
|
// Server detection
|
|
150
155
|
const server = res.headers.get("server") || "";
|
|
@@ -167,13 +172,16 @@ export async function fingerprint(baseUrl) {
|
|
|
167
172
|
|
|
168
173
|
// Next.js detection from HTML
|
|
169
174
|
try {
|
|
170
|
-
const html = await res
|
|
175
|
+
const html = await readBody(res);
|
|
171
176
|
if (html.includes("/_next/static") || html.includes("__NEXT_DATA__")) {
|
|
172
177
|
tech.framework = "Next.js";
|
|
173
178
|
}
|
|
174
179
|
if (html.includes("__nuxt")) tech.framework = "Nuxt";
|
|
175
180
|
if (html.includes("ng-version")) tech.framework = "Angular";
|
|
176
181
|
if (html.includes("data-reactroot") || html.includes("__react")) tech.framework = "React";
|
|
182
|
+
// First same-site link to a .php page, as evidence that PHP serves the site.
|
|
183
|
+
const php = /\shref=["']((?:\/|\.\/|(?![a-z][a-z0-9+.-]*:))[^"'\s>]{0,200}?\.php(?:[?#][^"'\s>]{0,100})?)["']/i.exec(html);
|
|
184
|
+
tech.phpLink = php ? php[1] : null;
|
|
177
185
|
} catch { /* ignore */ }
|
|
178
186
|
|
|
179
187
|
// Security header audit
|
|
@@ -208,7 +216,7 @@ export async function autoCalibrate(baseUrl) {
|
|
|
208
216
|
]) {
|
|
209
217
|
const res = await safeFetch(`${baseUrl}${garbage}`, { timeout: DEFAULT_TIMEOUT, probe: "endpoint-discovery" });
|
|
210
218
|
if (res) {
|
|
211
|
-
const text = await res
|
|
219
|
+
const text = await readBody(res).catch(() => "");
|
|
212
220
|
baselines.push({
|
|
213
221
|
status: res.status,
|
|
214
222
|
size: text.length,
|
|
@@ -234,14 +242,3 @@ export async function autoCalibrate(baseUrl) {
|
|
|
234
242
|
};
|
|
235
243
|
}
|
|
236
244
|
|
|
237
|
-
/**
|
|
238
|
-
* Compare crawled endpoints with filesystem-discovered routes.
|
|
239
|
-
* Returns "shadow" endpoints, routes that exist at runtime but aren't in source.
|
|
240
|
-
*/
|
|
241
|
-
export function findShadowEndpoints(crawledPaths, filesystemRoutes) {
|
|
242
|
-
const knownPaths = new Set(filesystemRoutes.map(r => r.url));
|
|
243
|
-
return crawledPaths.filter(ep => {
|
|
244
|
-
const normalized = ep.path.replace(/\/$/, "");
|
|
245
|
-
return !knownPaths.has(normalized) && ep.status !== 404;
|
|
246
|
-
});
|
|
247
|
-
}
|
package/lib/deps/capabilities.js
CHANGED
|
@@ -79,7 +79,10 @@ const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
|
79
79
|
export const maxSeverity = (sevs) => sevs.reduce((m, s) => (SEV_RANK[s] > SEV_RANK[m] ? s : m), "info");
|
|
80
80
|
|
|
81
81
|
const JS_EXT = /\.(c|m)?jsx?$/i;
|
|
82
|
-
|
|
82
|
+
// Large enough for real bundles (typescript.js and editor workers are 9-13 MB),
|
|
83
|
+
// and the same as the tarball reader's per-file cap (source.js MAX_FILE_BYTES).
|
|
84
|
+
// A file over it is not read, and the package is then unscanned, never clean.
|
|
85
|
+
const MAX_JS_BYTES = 64 * 1024 * 1024;
|
|
83
86
|
const MAX_EVIDENCE = 5;
|
|
84
87
|
const EXFIL_WINDOW = 40; // lines
|
|
85
88
|
const NEAR_WINDOW = 30; // lines
|
|
@@ -451,11 +454,13 @@ export function nodeScriptTargets(cmd) {
|
|
|
451
454
|
|
|
452
455
|
/**
|
|
453
456
|
* Scan a package through a source (see source.js).
|
|
454
|
-
* @param {{ files(): string[], read(rel): Buffer, size(rel): number }} source
|
|
455
|
-
* @param {{ prefix?: string }} opts prefix is
|
|
456
|
-
*
|
|
457
|
+
* @param {{ files(): string[], read(rel): Buffer, size(rel): number, anomaly?: string, truncated?: boolean }} source
|
|
458
|
+
* @param {{ prefix?: string, iocs?: ReturnType<import("./iocs.js").loadIocs> }} opts prefix is
|
|
459
|
+
* prepended to evidence paths (e.g. node_modules/foo/); iocs are rule-pack indicators to look for
|
|
460
|
+
* @returns {{ name, version, capabilities, installScripts, exfil, filesScanned, skipped, unscanned, iocHits }}
|
|
457
461
|
* unscanned: why the package could not be fully read (archive anomaly, no
|
|
458
|
-
* package.json
|
|
462
|
+
* package.json, a code file too large to scan, more files than the source
|
|
463
|
+
* lists), else null. Callers must not treat such a package as clean.
|
|
459
464
|
*/
|
|
460
465
|
export function scanPackage(source, opts = {}) {
|
|
461
466
|
const prefix = opts.prefix || "";
|
|
@@ -474,6 +479,8 @@ export function scanPackage(source, opts = {}) {
|
|
|
474
479
|
pkg = {};
|
|
475
480
|
unscanned = unscanned || "package.json is missing or not valid JSON, so its install scripts are unknown";
|
|
476
481
|
}
|
|
482
|
+
const iocs = opts.iocs || null;
|
|
483
|
+
const iocHits = iocs ? [...iocs.scanFiles(files, source, prefix), ...iocs.scanText(pkgText, `${prefix}package.json`)] : [];
|
|
477
484
|
|
|
478
485
|
const installScripts = {};
|
|
479
486
|
for (const hook of INSTALL_HOOKS) {
|
|
@@ -507,12 +514,20 @@ export function scanPackage(source, opts = {}) {
|
|
|
507
514
|
let src;
|
|
508
515
|
try { src = source.read(f).toString("utf-8"); } catch { continue; }
|
|
509
516
|
filesScanned++;
|
|
517
|
+
if (iocs) iocHits.push(...iocs.scanText(src, `${prefix}${f}`));
|
|
510
518
|
const r = scanSource(src, `${prefix}${f}`);
|
|
511
519
|
fileCaps[f] = Object.keys(r.caps);
|
|
512
520
|
if (r.aiConfigProject) aiConfigProject[f] = r.aiConfigProject;
|
|
513
521
|
mergeCaps(caps, r.caps);
|
|
514
522
|
if (r.exfil) exfil.push(r.exfil);
|
|
515
523
|
}
|
|
524
|
+
// Code that was never read is not clean code: fail closed.
|
|
525
|
+
if (source.truncated) {
|
|
526
|
+
unscanned = unscanned || `the package has more than ${source.maxFiles} files, so some were never read`;
|
|
527
|
+
}
|
|
528
|
+
if (skipped.length) {
|
|
529
|
+
unscanned = unscanned || `${skipped.length} code file(s) over ${MAX_JS_BYTES / 1024 / 1024} MB were not scanned (${skipped.slice(0, 3).join(", ")}${skipped.length > 3 ? ", ..." : ""})`;
|
|
530
|
+
}
|
|
516
531
|
|
|
517
532
|
// Install scripts that are themselves suspicious: shell download pipes, or a
|
|
518
533
|
// `node file.js` hook whose file reaches the network, decodes payloads, etc.
|
|
@@ -556,5 +571,6 @@ export function scanPackage(source, opts = {}) {
|
|
|
556
571
|
filesScanned,
|
|
557
572
|
skipped,
|
|
558
573
|
unscanned,
|
|
574
|
+
iocHits,
|
|
559
575
|
};
|
|
560
576
|
}
|