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
package/lib/hook/index.js
CHANGED
|
@@ -14,11 +14,11 @@
|
|
|
14
14
|
* Offline, no telemetry. Findings use the standard shape, so ids match a full
|
|
15
15
|
* scan and penthera.suppress.json works here too.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
19
|
-
import { tmpdir } from "node:os";
|
|
17
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
20
18
|
import { join, posix, resolve } from "node:path";
|
|
21
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { git, gitSpawn, gitToplevel } from "../git.js";
|
|
21
|
+
import { withTempDir } from "../tmp.js";
|
|
22
22
|
import { findSecretsInText, isSecretScanTarget, maskMatch } from "../whitebox/secrets.js";
|
|
23
23
|
import { loadPolicy, normalizePolicy, POLICY_FILE } from "../install/policy.js";
|
|
24
24
|
import { parseRepoSuppressions, REPO_SUPPRESS_FILE } from "../memory/repo-suppress.js";
|
|
@@ -28,73 +28,101 @@ import { globToRegExp, isAtLeast, SEVERITIES } from "./policy.js";
|
|
|
28
28
|
|
|
29
29
|
export const HOOK_MARKER = "# penthera-hook";
|
|
30
30
|
export const BACKUP_NAME = "pre-commit.pre-penthera";
|
|
31
|
-
|
|
31
|
+
// Staged files larger than this are not read. They never pass silently: by
|
|
32
|
+
// default they block the commit (policy "largeFiles": "warn" only warns).
|
|
33
|
+
// Large enough for the package-lock.json of a big monorepo.
|
|
34
|
+
export const MAX_BLOB_BYTES = 16 * 1024 * 1024;
|
|
35
|
+
const REGULAR = new Set(["100644", "100755"]);
|
|
32
36
|
|
|
33
37
|
// ── git plumbing ──────────────────────────────────────────────────────────
|
|
34
38
|
|
|
35
|
-
function
|
|
36
|
-
|
|
39
|
+
export function repoRoot(cwd) {
|
|
40
|
+
const root = gitToplevel(cwd);
|
|
41
|
+
if (!root) throw new Error(`Not inside a git repository: ${cwd}`);
|
|
42
|
+
return root;
|
|
37
43
|
}
|
|
38
44
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Index entries this commit adds: added, copied, modified, renamed or
|
|
47
|
+
* type-changed (a symlink that became a file). `{ path, mode, oid }`, read
|
|
48
|
+
* NUL-separated so any byte in a name survives, with the object id of the
|
|
49
|
+
* staged blob, so content is always read by id and never by a path git
|
|
50
|
+
* might parse as revision syntax.
|
|
51
|
+
*/
|
|
52
|
+
export function stagedEntries(root) {
|
|
53
|
+
const parts = git(root, ["diff", "--cached", "--raw", "-z", "--no-abbrev", "--no-renames", "--diff-filter=ACMRT"]).split("\0");
|
|
54
|
+
const out = [];
|
|
55
|
+
for (let i = 0; i < parts.length - 1; i += 2) {
|
|
56
|
+
const m = /^:\d{6} (\d{6}) [0-9a-f]+ ([0-9a-f]+) [A-Z]\d*$/.exec(parts[i]);
|
|
57
|
+
if (!m) throw new Error(`unexpected git diff --raw output: ${JSON.stringify(parts[i].slice(0, 200))}`);
|
|
58
|
+
out.push({ path: parts[i + 1], mode: m[1], oid: m[2] });
|
|
44
59
|
}
|
|
60
|
+
return out;
|
|
45
61
|
}
|
|
46
62
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
63
|
+
function catFile(root, mode, oids) {
|
|
64
|
+
const r = gitSpawn(root, ["cat-file", mode], { input: oids.map((o) => `${o}\n`).join("") });
|
|
65
|
+
if (r.status !== 0) throw new Error(`git cat-file failed: ${String(r.stderr).trim()}`);
|
|
66
|
+
return r.stdout;
|
|
50
67
|
}
|
|
51
68
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
69
|
+
/**
|
|
70
|
+
* Read staged blobs by object id. Only regular files (mode 100644/100755):
|
|
71
|
+
* a staged symlink or submodule has no content of its own to check.
|
|
72
|
+
* @param {{ path: string, mode: string, oid: string }[]} entries
|
|
73
|
+
* @returns {{ blobs: Map<string, Buffer>, oversized: { path: string, size: number }[] }}
|
|
74
|
+
*/
|
|
75
|
+
export function readStaged(root, entries, { maxBytes = MAX_BLOB_BYTES } = {}) {
|
|
76
|
+
const blobs = new Map();
|
|
77
|
+
const oversized = [];
|
|
78
|
+
const files = entries.filter((e) => REGULAR.has(e.mode));
|
|
79
|
+
if (!files.length) return { blobs, oversized };
|
|
80
|
+
const sizes = catFile(root, "--batch-check", files.map((e) => e.oid)).toString().split("\n");
|
|
81
|
+
const wanted = [];
|
|
82
|
+
files.forEach((e, i) => {
|
|
83
|
+
const m = /^([0-9a-f]+) blob (\d+)$/.exec(sizes[i] || "");
|
|
84
|
+
if (!m || m[1] !== e.oid) throw new Error(`git cat-file: staged blob for ${JSON.stringify(e.path)} not found`);
|
|
85
|
+
const size = Number(m[2]);
|
|
86
|
+
if (size > maxBytes) oversized.push({ path: e.path, size });
|
|
87
|
+
else wanted.push(e);
|
|
88
|
+
});
|
|
89
|
+
if (!wanted.length) return { blobs, oversized };
|
|
90
|
+
const buf = catFile(root, "--batch", wanted.map((e) => e.oid));
|
|
60
91
|
let pos = 0;
|
|
61
|
-
for (const
|
|
92
|
+
for (const e of wanted) {
|
|
62
93
|
const nl = buf.indexOf(10, pos);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
pos = nl + 1;
|
|
66
|
-
const m = /^\S+ (\w+) (\d+)$/.exec(header);
|
|
67
|
-
if (!m) continue; // "<name> missing"
|
|
94
|
+
const m = nl === -1 ? null : /^([0-9a-f]+) blob (\d+)$/.exec(buf.subarray(pos, nl).toString());
|
|
95
|
+
if (!m || m[1] !== e.oid) throw new Error(`git cat-file: unexpected output for ${JSON.stringify(e.path)}`);
|
|
68
96
|
const size = Number(m[2]);
|
|
69
|
-
|
|
70
|
-
pos
|
|
97
|
+
blobs.set(e.path, buf.subarray(nl + 1, nl + 1 + size));
|
|
98
|
+
pos = nl + 1 + size + 1;
|
|
71
99
|
}
|
|
72
|
-
return
|
|
100
|
+
return { blobs, oversized };
|
|
73
101
|
}
|
|
74
102
|
|
|
75
103
|
// ── Checks ────────────────────────────────────────────────────────────────
|
|
76
104
|
|
|
77
|
-
function lineOf(text, index) {
|
|
78
|
-
let n = 1;
|
|
79
|
-
for (let i = 0; i < index; i++) if (text.charCodeAt(i) === 10) n++;
|
|
80
|
-
return n;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
105
|
function checkSecrets(files, blobs) {
|
|
84
106
|
const findings = [];
|
|
85
107
|
for (const path of files) {
|
|
86
108
|
if (!isSecretScanTarget(path)) continue;
|
|
87
109
|
const buf = blobs.get(path);
|
|
88
|
-
if (!buf
|
|
110
|
+
if (!buf) continue;
|
|
111
|
+
// Read as text even when it holds NUL bytes: a NUL does not stop a key
|
|
112
|
+
// from being a key, and the extension already says this is a text file.
|
|
89
113
|
const text = buf.toString("utf-8");
|
|
90
|
-
|
|
114
|
+
const hits = findSecretsInText(text).sort((a, b) => a.index - b.index);
|
|
115
|
+
let line = 1;
|
|
116
|
+
let at = 0;
|
|
117
|
+
for (const hit of hits) {
|
|
118
|
+
for (; at < hit.index; at++) if (text.charCodeAt(at) === 10) line++;
|
|
91
119
|
// Same title/url/category/source as the repo scan, so finding ids match.
|
|
92
120
|
findings.push({
|
|
93
121
|
severity: hit.severity,
|
|
94
122
|
title: `${hit.name} in source`,
|
|
95
123
|
description: `Possible secret in \`${path}\`: \`${maskMatch(hit.match)}\``,
|
|
96
124
|
url: path,
|
|
97
|
-
line
|
|
125
|
+
line,
|
|
98
126
|
evidence: maskMatch(hit.match),
|
|
99
127
|
category: "secrets",
|
|
100
128
|
source: "secret-scan",
|
|
@@ -126,9 +154,8 @@ async function checkAgents(root, staged) {
|
|
|
126
154
|
.map((l) => /^(\d{6}) [0-9a-f]+ \d\t(.*)$/s.exec(l))
|
|
127
155
|
.filter((m) => m && (m[1] === "100644" || m[1] === "100755") && isAgentConfig(m[2]))
|
|
128
156
|
.map((m) => m[2]);
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const r = spawnSync("git", ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { cwd: root, input: all.join("\0") });
|
|
157
|
+
return withTempDir("penthera-hook-", async (tmp) => {
|
|
158
|
+
const r = gitSpawn(root, ["checkout-index", "-z", "--stdin", `--prefix=${tmp}/`], { input: all.join("\0") });
|
|
132
159
|
if (r.status !== 0) throw new Error(`git checkout-index failed: ${String(r.stderr).trim()}`);
|
|
133
160
|
const { scanAgents } = await import("../agents/index.js");
|
|
134
161
|
const result = await scanAgents(tmp, { lockPath: join(tmp, ".penthera-hook-no-lock") });
|
|
@@ -140,9 +167,7 @@ async function checkAgents(root, staged) {
|
|
|
140
167
|
return m ? { ...f, line: Number(m[1]) } : f;
|
|
141
168
|
});
|
|
142
169
|
return { findings, files: touched.length };
|
|
143
|
-
}
|
|
144
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
145
|
-
}
|
|
170
|
+
});
|
|
146
171
|
}
|
|
147
172
|
|
|
148
173
|
const EMPTY_LOCK = { lockfileVersion: 3, packages: {} };
|
|
@@ -182,15 +207,15 @@ function suppressedBy(entries, f) {
|
|
|
182
207
|
|
|
183
208
|
const bySeverity = (a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity) || String(a.url).localeCompare(String(b.url)) || (a.line || 0) - (b.line || 0);
|
|
184
209
|
|
|
185
|
-
/**
|
|
186
|
-
* Check what is staged. Never writes to the repo.
|
|
187
|
-
* @param {string} cwd anywhere inside the repo
|
|
188
|
-
* @param {{ policyPath?: string, now?: string }} opts
|
|
189
|
-
* @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
|
|
190
|
-
*/
|
|
191
|
-
/** One file as staged, or null when it is not in the index. */
|
|
210
|
+
/** One regular file as staged (stage 0), or null when it is not in the index. */
|
|
192
211
|
function stagedBlob(root, path) {
|
|
193
|
-
|
|
212
|
+
const line = git(root, ["--literal-pathspecs", "ls-files", "-s", "-z", "--", path]).split("\0")
|
|
213
|
+
.map((l) => /^(\d{6}) ([0-9a-f]+) 0\t(.*)$/s.exec(l))
|
|
214
|
+
.find((m) => m && m[3] === path);
|
|
215
|
+
if (!line) return null;
|
|
216
|
+
const { blobs, oversized } = readStaged(root, [{ path, mode: line[1], oid: line[2] }]);
|
|
217
|
+
if (oversized.length) throw new Error(`${path} (staged) is too large to read`);
|
|
218
|
+
return blobs.get(path) || null;
|
|
194
219
|
}
|
|
195
220
|
|
|
196
221
|
/** The policy: an explicit --policy file, else penthera.policy.json as staged, else defaults. */
|
|
@@ -207,18 +232,42 @@ function stagedPolicy(root, explicitPath) {
|
|
|
207
232
|
return { policy: normalizePolicy(raw, `${POLICY_FILE} (staged)`), path: join(root, POLICY_FILE) };
|
|
208
233
|
}
|
|
209
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Check what is staged. Never writes to the repo.
|
|
237
|
+
* @param {string} cwd anywhere inside the repo
|
|
238
|
+
* @param {{ policyPath?: string, now?: string, maxBlobBytes?: number }} opts
|
|
239
|
+
* @returns {Promise<{ root, staged, checked, blocking, below, suppressed, blockOn, policyPath, warnings, duration }>}
|
|
240
|
+
*/
|
|
210
241
|
export async function runHook(cwd, opts = {}) {
|
|
211
242
|
const started = Date.now();
|
|
212
243
|
const root = repoRoot(cwd);
|
|
213
244
|
const { policy, path: policyPath } = stagedPolicy(root, opts.policyPath || null);
|
|
214
|
-
const { blockOn, checks, ignore } = policy.hook;
|
|
245
|
+
const { blockOn, checks, ignore, largeFiles } = policy.hook;
|
|
215
246
|
const ignored = ignore.map(globToRegExp);
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
247
|
+
const entries = stagedEntries(root).filter((e) => !ignored.some((re) => re.test(e.path)));
|
|
248
|
+
const staged = entries.map((e) => e.path);
|
|
249
|
+
|
|
250
|
+
const isLock = (p) => /(^|\/)(package-lock|npm-shrinkwrap)\.json$/.test(p);
|
|
251
|
+
const wantBlobs = entries.filter((e) => (checks.secrets && isSecretScanTarget(e.path)) || (checks.deps && isLock(e.path)));
|
|
252
|
+
const { blobs, oversized } = readStaged(root, wantBlobs, { maxBytes: opts.maxBlobBytes ?? MAX_BLOB_BYTES });
|
|
253
|
+
const warnings = [];
|
|
254
|
+
const unchecked = [];
|
|
255
|
+
for (const o of oversized) {
|
|
256
|
+
const limit = `${Math.round((opts.maxBlobBytes ?? MAX_BLOB_BYTES) / 1024 / 1024 * 10) / 10} MB`;
|
|
257
|
+
warnings.push(`${o.path} is ${o.size} bytes, over the ${limit} limit, so it was not checked${largeFiles === "block" ? " (this blocks the commit)" : ""}`);
|
|
258
|
+
if (largeFiles === "block") {
|
|
259
|
+
unchecked.push({
|
|
260
|
+
severity: "critical",
|
|
261
|
+
title: "Staged file too large to check",
|
|
262
|
+
description: `\`${o.path}\` is ${o.size} bytes, over the ${limit} the hook reads, so it could hold a secret nobody checked.`,
|
|
263
|
+
url: o.path,
|
|
264
|
+
category: "unchecked",
|
|
265
|
+
source: "hook",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
220
269
|
|
|
221
|
-
const secrets = checks.secrets ? checkSecrets(staged, blobs) :
|
|
270
|
+
const secrets = checks.secrets ? [...checkSecrets(staged, blobs), ...unchecked] : unchecked;
|
|
222
271
|
const agents = checks.agents ? await checkAgents(root, staged) : { findings: [], files: 0 };
|
|
223
272
|
const deps = checks.deps ? await checkLockfiles(root, staged, blobs, { licensePolicy: checks.licenses ? policy.licenses : null }) : { findings: [], files: 0 };
|
|
224
273
|
|
|
@@ -248,7 +297,7 @@ export async function runHook(cwd, opts = {}) {
|
|
|
248
297
|
blocking: kept.filter((f) => isAtLeast(f.severity, blockOn)),
|
|
249
298
|
below: kept.filter((f) => !isAtLeast(f.severity, blockOn)),
|
|
250
299
|
suppressed,
|
|
251
|
-
warnings: sup.warnings,
|
|
300
|
+
warnings: [...warnings, ...sup.warnings],
|
|
252
301
|
duration: Date.now() - started,
|
|
253
302
|
};
|
|
254
303
|
}
|
|
@@ -258,8 +307,27 @@ export function fixHint(f) {
|
|
|
258
307
|
if (f.category === "secrets") return "Load it from an env var or secret store and unstage the file; rotate it if it was ever pushed or shared.";
|
|
259
308
|
if (f.category === "license") return `Replace ${f.package}, or add it to "allowPackages" in the licenses section of penthera.policy.json.`;
|
|
260
309
|
if (f.category === "dependency-risk") return `Review it before committing the lockfile: \`penthera deps --base HEAD -v\` shows the evidence.`;
|
|
261
|
-
|
|
262
|
-
return
|
|
310
|
+
if (f.category === "unchecked") return `If it holds no secrets, add it to "ignore" (or set "largeFiles": "warn") in the hook section of penthera.policy.json.`;
|
|
311
|
+
return firstSentence(f.description) || "See `penthera agents` for details.";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The description's first sentence, without a trailing `[...]` tag or a
|
|
316
|
+
* `(line N)` note. Only the first line, capped, is looked at, and nothing
|
|
317
|
+
* here backtracks: descriptions quote agent config, which anyone can write.
|
|
318
|
+
*/
|
|
319
|
+
function firstSentence(description) {
|
|
320
|
+
let s = String(description || "");
|
|
321
|
+
const nl = s.indexOf("\n");
|
|
322
|
+
s = (nl === -1 ? s : s.slice(0, nl)).slice(0, 2000);
|
|
323
|
+
if (s.endsWith("]")) {
|
|
324
|
+
const open = s.lastIndexOf("[");
|
|
325
|
+
if (open !== -1 && !s.slice(open, -1).includes("]")) s = s.slice(0, open).trimEnd();
|
|
326
|
+
}
|
|
327
|
+
const line = /\(line \d+\)/.exec(s);
|
|
328
|
+
if (line) s = s.slice(0, line.index).trimEnd() + s.slice(line.index + line[0].length);
|
|
329
|
+
const stop = /\.\s/.exec(s);
|
|
330
|
+
return stop ? s.slice(0, stop.index + 1) : s;
|
|
263
331
|
}
|
|
264
332
|
|
|
265
333
|
// ── Install / uninstall ───────────────────────────────────────────────────
|
|
@@ -288,14 +356,22 @@ exit 0
|
|
|
288
356
|
|
|
289
357
|
/** Where git looks for hooks (honours core.hooksPath and worktrees). */
|
|
290
358
|
export function hooksDir(root) {
|
|
291
|
-
|
|
359
|
+
// raw: the repo's real core.hooksPath, which the safe config overrides.
|
|
360
|
+
return resolve(root, git(root, ["rev-parse", "--git-path", "hooks"], { raw: true }).trim());
|
|
292
361
|
}
|
|
293
362
|
|
|
294
363
|
function hooksPathSetting(root) {
|
|
295
|
-
try { return git(root, ["config", "--get", "core.hooksPath"]).trim() || null; } catch { return null; }
|
|
364
|
+
try { return git(root, ["config", "--get", "core.hooksPath"], { raw: true }).trim() || null; } catch { return null; }
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** lstat, or null when nothing is there (a dangling symlink is something). */
|
|
368
|
+
function lstatOrNull(path) {
|
|
369
|
+
try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") return null; throw e; }
|
|
296
370
|
}
|
|
297
371
|
|
|
298
|
-
|
|
372
|
+
// Ours only when it is a regular file with our marker: a symlink is never
|
|
373
|
+
// ours, even when it points at a file that carries the marker.
|
|
374
|
+
const isOurs = (path) => !!lstatOrNull(path)?.isFile() && readFileSync(path, "utf-8").includes(HOOK_MARKER);
|
|
299
375
|
|
|
300
376
|
/**
|
|
301
377
|
* Write .git/hooks/pre-commit. An existing hook that is not ours is kept as
|
|
@@ -314,15 +390,23 @@ export function installHook(cwd, { force = false, script = hookScript() } = {})
|
|
|
314
390
|
const path = join(dir, "pre-commit");
|
|
315
391
|
const backup = join(dir, BACKUP_NAME);
|
|
316
392
|
let updated = false;
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
393
|
+
const st = lstatOrNull(path);
|
|
394
|
+
if (st) {
|
|
395
|
+
// Anything that is not our own regular file (another hook, a symlink to a
|
|
396
|
+
// shared or not-yet-created hook) is moved aside as it is, never written through.
|
|
397
|
+
if (isOurs(path)) {
|
|
398
|
+
updated = true;
|
|
399
|
+
unlinkSync(path);
|
|
400
|
+
} else if (st.isDirectory()) throw new Error(`${path} is a directory. Move it away, then run \`penthera hook install\` again.`);
|
|
401
|
+
else if (lstatOrNull(backup)) throw new Error(`Both ${path} and ${backup} exist. Merge them by hand, then run \`penthera hook install\` again.`);
|
|
320
402
|
else renameSync(path, backup);
|
|
321
403
|
}
|
|
322
404
|
mkdirSync(dir, { recursive: true });
|
|
323
|
-
|
|
405
|
+
// "wx": create a new file, and fail rather than follow anything that
|
|
406
|
+
// appeared at this path in the meantime.
|
|
407
|
+
writeFileSync(path, script, { flag: "wx", mode: 0o755 });
|
|
324
408
|
chmodSync(path, 0o755);
|
|
325
|
-
return { path, chained:
|
|
409
|
+
return { path, chained: lstatOrNull(backup) ? backup : null, updated };
|
|
326
410
|
}
|
|
327
411
|
|
|
328
412
|
/**
|
|
@@ -334,10 +418,10 @@ export function uninstallHook(cwd) {
|
|
|
334
418
|
const dir = hooksDir(root);
|
|
335
419
|
const path = join(dir, "pre-commit");
|
|
336
420
|
const backup = join(dir, BACKUP_NAME);
|
|
337
|
-
if (!
|
|
421
|
+
if (!lstatOrNull(path)) return { path, removed: false, restored: null };
|
|
338
422
|
if (!isOurs(path)) throw new Error(`${path} was not installed by penthera; left untouched.`);
|
|
339
423
|
unlinkSync(path);
|
|
340
|
-
if (
|
|
424
|
+
if (lstatOrNull(backup)) {
|
|
341
425
|
renameSync(backup, path);
|
|
342
426
|
return { path, removed: true, restored: path };
|
|
343
427
|
}
|
package/lib/hook/policy.js
CHANGED
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* "hook": {
|
|
6
6
|
* "blockOn": "high", // lowest severity that blocks: critical | high | medium | low
|
|
7
7
|
* "checks": { "secrets": true, "agents": true, "deps": true, "licenses": true },
|
|
8
|
-
* "ignore": ["tests/fixtures/**"]
|
|
8
|
+
* "ignore": ["tests/fixtures/**"], // staged paths the hook skips (globs)
|
|
9
|
+
* "largeFiles": "block" // a staged file too large to check: block (default) | warn
|
|
9
10
|
* }
|
|
10
11
|
*/
|
|
11
12
|
export const SEVERITIES = ["critical", "high", "medium", "low", "info"];
|
|
@@ -15,13 +16,16 @@ export const DEFAULT_HOOK_POLICY = Object.freeze({
|
|
|
15
16
|
blockOn: "high",
|
|
16
17
|
checks: Object.freeze(Object.fromEntries(CHECKS.map((c) => [c, true]))),
|
|
17
18
|
ignore: Object.freeze([]),
|
|
19
|
+
largeFiles: "block",
|
|
18
20
|
});
|
|
21
|
+
const LARGE_FILES = ["block", "warn"];
|
|
19
22
|
|
|
20
23
|
export function normalizeHookPolicy(raw, label = "hook") {
|
|
21
24
|
if (raw == null) return { ...DEFAULT_HOOK_POLICY, checks: { ...DEFAULT_HOOK_POLICY.checks }, ignore: [] };
|
|
25
|
+
const FIELDS = ["blockOn", "checks", "ignore", "largeFiles"];
|
|
22
26
|
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`${label}: expected an object`);
|
|
23
|
-
const extra = Object.keys(raw).filter((k) => !
|
|
24
|
-
if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected
|
|
27
|
+
const extra = Object.keys(raw).filter((k) => !FIELDS.includes(k));
|
|
28
|
+
if (extra.length) throw new Error(`${label}: unknown field(s) ${extra.join(", ")} (expected ${FIELDS.join(", ")})`);
|
|
25
29
|
const blockOn = raw.blockOn ?? "high";
|
|
26
30
|
if (!SEVERITIES.slice(0, 4).includes(blockOn)) throw new Error(`${label}: "blockOn" must be one of critical, high, medium, low`);
|
|
27
31
|
const checks = { ...DEFAULT_HOOK_POLICY.checks };
|
|
@@ -35,7 +39,9 @@ export function normalizeHookPolicy(raw, label = "hook") {
|
|
|
35
39
|
}
|
|
36
40
|
const ignore = raw.ignore ?? [];
|
|
37
41
|
if (!Array.isArray(ignore) || ignore.some((x) => typeof x !== "string" || !x.trim())) throw new Error(`${label}: "ignore" must be an array of path globs`);
|
|
38
|
-
|
|
42
|
+
const largeFiles = raw.largeFiles ?? "block";
|
|
43
|
+
if (!LARGE_FILES.includes(largeFiles)) throw new Error(`${label}: "largeFiles" must be "block" or "warn"`);
|
|
44
|
+
return { blockOn, checks, ignore: ignore.map((x) => x.trim()), largeFiles };
|
|
39
45
|
}
|
|
40
46
|
|
|
41
47
|
/** Git-style path glob: `**` crosses folders, `*` and `?` stay inside one; a bare name matches at any depth. */
|
package/lib/install/index.js
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { execFile } from "node:child_process";
|
|
26
26
|
import { promisify } from "node:util";
|
|
27
|
-
import { existsSync,
|
|
27
|
+
import { existsSync, readFileSync, writeFileSync, copyFileSync, rmSync, mkdirSync } from "node:fs";
|
|
28
28
|
import { join, resolve } from "node:path";
|
|
29
|
-
import {
|
|
29
|
+
import { withTempDir } from "../tmp.js";
|
|
30
30
|
import { scanDeps } from "../deps/index.js";
|
|
31
31
|
import { parseLockfile } from "../deps/lockfile.js";
|
|
32
32
|
import { sourceFromNpmCache, npmCacheDir } from "../deps/source.js";
|
|
@@ -183,8 +183,7 @@ export async function planInstall(root, specs, opts = {}) {
|
|
|
183
183
|
const original = Object.fromEntries(COPY.map((f) => [f, readIfExists(join(root, f))]));
|
|
184
184
|
const lockName = LOCKFILES.find((f) => original[f]) || "package-lock.json";
|
|
185
185
|
|
|
186
|
-
|
|
187
|
-
try {
|
|
186
|
+
return withTempDir("penthera-install-", async (tmp) => {
|
|
188
187
|
const work = join(tmp, "project");
|
|
189
188
|
mkdirSync(work);
|
|
190
189
|
for (const f of COPY) if (original[f]) copyFileSync(join(root, f), join(work, f));
|
|
@@ -246,9 +245,7 @@ export async function planInstall(root, specs, opts = {}) {
|
|
|
246
245
|
typosquats: result.deps.typosquats,
|
|
247
246
|
evidence: result.deps.diff.evidence,
|
|
248
247
|
};
|
|
249
|
-
}
|
|
250
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
251
|
-
}
|
|
248
|
+
});
|
|
252
249
|
}
|
|
253
250
|
|
|
254
251
|
/** Packages whose new or changed install scripts would run. */
|
package/lib/machine.js
CHANGED
|
@@ -56,19 +56,6 @@ function verifySignature(program) {
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function runFile(file, args = [], opts = {}) {
|
|
60
|
-
try {
|
|
61
|
-
return execFileSync(file, args, {
|
|
62
|
-
encoding: "utf-8",
|
|
63
|
-
timeout: opts.timeout || 30_000,
|
|
64
|
-
maxBuffer: 10 * 1024 * 1024,
|
|
65
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
66
|
-
}).trim();
|
|
67
|
-
} catch (e) {
|
|
68
|
-
return e.stdout?.trim?.() || "";
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
59
|
function which(bin) {
|
|
73
60
|
try {
|
|
74
61
|
const p = execSync(`which ${bin}`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
|
package/lib/memory/store.js
CHANGED
|
@@ -28,7 +28,7 @@ import { homedir } from "node:os";
|
|
|
28
28
|
|
|
29
29
|
export const EVENT_VERSION = 1;
|
|
30
30
|
export const EVENT_TYPES = Object.freeze([
|
|
31
|
-
"scan", "fix_applied", "fix_verified", "fix_failed", "fp_suppressed", "probe_yield", "loop_run",
|
|
31
|
+
"scan", "fix_applied", "fix_verified", "fix_failed", "fp_suppressed", "probe_yield", "loop_run", "gave_up",
|
|
32
32
|
]);
|
|
33
33
|
const MAX_LINE_BYTES = 256 * 1024;
|
|
34
34
|
const LOCK_STALE_MS = 30_000;
|
|
@@ -152,7 +152,11 @@ function sleepMs(ms) {
|
|
|
152
152
|
|
|
153
153
|
function withLock(dir, fn) {
|
|
154
154
|
const lock = join(dir, "events.lock");
|
|
155
|
-
|
|
155
|
+
// The timeout is per holder: while other writers keep taking and releasing
|
|
156
|
+
// the lock we are queued behind progress, not stuck, however long that takes
|
|
157
|
+
// on a loaded machine. Only one holder keeping it for LOCK_TIMEOUT_MS is busy.
|
|
158
|
+
let holder = null;
|
|
159
|
+
let waitingSince = Date.now();
|
|
156
160
|
let fd;
|
|
157
161
|
for (;;) {
|
|
158
162
|
try {
|
|
@@ -161,9 +165,12 @@ function withLock(dir, fn) {
|
|
|
161
165
|
} catch (e) {
|
|
162
166
|
if (e.code !== "EEXIST") throw e;
|
|
163
167
|
try {
|
|
164
|
-
|
|
168
|
+
const st = statSync(lock);
|
|
169
|
+
if (Date.now() - st.mtimeMs > LOCK_STALE_MS) { unlinkSync(lock); continue; }
|
|
170
|
+
const id = `${st.ino}:${st.mtimeMs}`;
|
|
171
|
+
if (id !== holder) { holder = id; waitingSince = Date.now(); }
|
|
165
172
|
} catch { continue; } // lock vanished between calls: retry
|
|
166
|
-
if (Date.now() -
|
|
173
|
+
if (Date.now() - waitingSince > LOCK_TIMEOUT_MS) throw new Error(`memory: lock busy (${lock})`);
|
|
167
174
|
sleepMs(5 + Math.floor(Math.random() * 20));
|
|
168
175
|
}
|
|
169
176
|
}
|
package/lib/nuclei-loader.js
CHANGED
|
@@ -307,28 +307,3 @@ export async function executeTemplates(baseUrl, templates, opts = {}) {
|
|
|
307
307
|
return findings;
|
|
308
308
|
}
|
|
309
309
|
|
|
310
|
-
/**
|
|
311
|
-
* Get stats about a template directory.
|
|
312
|
-
*/
|
|
313
|
-
export async function getTemplateStats(dir) {
|
|
314
|
-
const templates = await loadTemplatesFromDir(dir);
|
|
315
|
-
const bySeverity = {};
|
|
316
|
-
const byTag = {};
|
|
317
|
-
|
|
318
|
-
for (const t of templates) {
|
|
319
|
-
const sev = t.info.severity || "unknown";
|
|
320
|
-
bySeverity[sev] = (bySeverity[sev] || 0) + 1;
|
|
321
|
-
for (const tag of t.info.tags) {
|
|
322
|
-
byTag[tag] = (byTag[tag] || 0) + 1;
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return {
|
|
327
|
-
total: templates.length,
|
|
328
|
-
bySeverity,
|
|
329
|
-
topTags: Object.entries(byTag)
|
|
330
|
-
.sort(([, a], [, b]) => b - a)
|
|
331
|
-
.slice(0, 20)
|
|
332
|
-
.map(([tag, count]) => ({ tag, count })),
|
|
333
|
-
};
|
|
334
|
-
}
|
package/lib/owasp-wstg.js
CHANGED
|
@@ -34,6 +34,7 @@ export const PROBE_WSTG = [
|
|
|
34
34
|
{ id: "secret-scanning", probe: "Secret scanning", wstg: ["WSTG-CONF-02", "WSTG-CONF-05"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
|
|
35
35
|
{ id: "trust-boundary", probe: "Route / trust-boundary analysis", wstg: ["WSTG-ATHZ-02", "WSTG-CONF-02"], profiles: ["quick", "standard", "deep"], modes: ["repo"] },
|
|
36
36
|
{ id: "risky-patterns", probe: "Risky code patterns", wstg: ["WSTG-INPV-01", "WSTG-INPV-12"], profiles: ["standard", "deep"], modes: ["repo"] },
|
|
37
|
+
{ id: "runtime-advisories", probe: "Runtime & server advisories (PHP, nginx, Apache, Node.js, Python, OpenSSL)", wstg: ["WSTG-INFO-02", "WSTG-INFO-08", "WSTG-CONF-01"], profiles: ["quick", "standard", "deep"], modes: ["url", "repo"] },
|
|
37
38
|
];
|
|
38
39
|
|
|
39
40
|
/** Finding category/source → WSTG tags for individual findings. */
|
|
@@ -54,6 +55,7 @@ export const FINDING_WSTG = {
|
|
|
54
55
|
fuzzing: ["WSTG-INPV-11"],
|
|
55
56
|
recon: ["WSTG-INFO-01"],
|
|
56
57
|
cve: ["WSTG-CONF-04"],
|
|
58
|
+
"runtime-advisory": ["WSTG-INFO-02", "WSTG-CONF-01"],
|
|
57
59
|
"rate-limiting": ["WSTG-ATHN-10"],
|
|
58
60
|
"code-pattern": ["WSTG-INPV-01", "WSTG-INPV-12"],
|
|
59
61
|
config: ["WSTG-CONF-02"],
|
|
@@ -79,7 +81,9 @@ const WSTG_NAMES = {
|
|
|
79
81
|
"WSTG-INFO-04": "Enumerate Applications on Webserver",
|
|
80
82
|
"WSTG-INFO-06": "Identify Application Entry Points",
|
|
81
83
|
"WSTG-INFO-07": "Map Execution Paths Through Application",
|
|
84
|
+
"WSTG-INFO-08": "Fingerprint Web Application Framework",
|
|
82
85
|
"WSTG-INFO-09": "Fingerprint Web Application",
|
|
86
|
+
"WSTG-CONF-01": "Test Network Infrastructure Configuration",
|
|
83
87
|
"WSTG-CONF-02": "Test Application Platform Configuration",
|
|
84
88
|
"WSTG-CONF-04": "Review Old Backup and Unreferenced Files",
|
|
85
89
|
"WSTG-CONF-05": "Enumerate Infrastructure and Application Admin Interfaces",
|
package/lib/plugins.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* const findings = await runTemplateScan("https://app.example", templates);
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, statSync } from "node:fs";
|
|
10
|
-
import {
|
|
10
|
+
import { resolve } from "node:path";
|
|
11
11
|
import {
|
|
12
12
|
loadTemplatesFromDir,
|
|
13
13
|
executeTemplates,
|
package/lib/provenance.js
CHANGED
package/lib/recon.js
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
* Inspired by: subfinder, gau, waybackurls, ParamSpider
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { fetchJson } from "../src/utils/http.js";
|
|
16
16
|
import { registrableDomain } from "../src/utils/url.js";
|
|
17
17
|
|
|
18
18
|
const RECON_TIMEOUT = 15_000;
|
|
19
|
+
// Certificate-transparency answers for large domains run to megabytes.
|
|
20
|
+
const RECON_MAX_BODY = 20 * 1024 * 1024;
|
|
19
21
|
|
|
20
22
|
/**
|
|
21
23
|
* Third-party hosts this module queries. Under --offline the scan skips recon
|
|
@@ -33,18 +35,11 @@ export const RECON_HOSTS = ["crt.sh", "web.archive.org", "otx.alienvault.com"];
|
|
|
33
35
|
* @returns {string[]} Discovered subdomains
|
|
34
36
|
*/
|
|
35
37
|
export async function discoverSubdomains(domain) {
|
|
36
|
-
const
|
|
38
|
+
const data = await fetchJson(
|
|
37
39
|
`https://crt.sh/?q=%25.${encodeURIComponent(domain)}&output=json`,
|
|
38
|
-
{ timeout: 20_000, probe: "recon", redirect: "follow" },
|
|
40
|
+
{ timeout: 20_000, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
|
|
39
41
|
);
|
|
40
|
-
if (!
|
|
41
|
-
|
|
42
|
-
let data;
|
|
43
|
-
try {
|
|
44
|
-
data = await res.json();
|
|
45
|
-
} catch {
|
|
46
|
-
return [];
|
|
47
|
-
}
|
|
42
|
+
if (!data) return [];
|
|
48
43
|
|
|
49
44
|
const subdomains = new Set();
|
|
50
45
|
for (const entry of data) {
|
|
@@ -72,18 +67,11 @@ export async function discoverSubdomains(domain) {
|
|
|
72
67
|
*/
|
|
73
68
|
export async function fetchWaybackUrls(domain, opts = {}) {
|
|
74
69
|
const limit = opts.limit || 1000;
|
|
75
|
-
const
|
|
70
|
+
const rows = await fetchJson(
|
|
76
71
|
`https://web.archive.org/cdx/search/cdx?url=*.${encodeURIComponent(domain)}/*&output=json&fl=original&collapse=urlkey&limit=${limit}`,
|
|
77
|
-
{ timeout: 20_000, probe: "recon", redirect: "follow" },
|
|
72
|
+
{ timeout: 20_000, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
|
|
78
73
|
);
|
|
79
|
-
if (!
|
|
80
|
-
|
|
81
|
-
let rows;
|
|
82
|
-
try {
|
|
83
|
-
rows = await res.json();
|
|
84
|
-
} catch {
|
|
85
|
-
return [];
|
|
86
|
-
}
|
|
74
|
+
if (!rows) return [];
|
|
87
75
|
|
|
88
76
|
// First row is the header ["original"]
|
|
89
77
|
return rows.slice(1).map((r) => r[0]).filter(Boolean);
|
|
@@ -98,18 +86,11 @@ export async function fetchWaybackUrls(domain, opts = {}) {
|
|
|
98
86
|
*/
|
|
99
87
|
export async function fetchOtxUrls(domain, opts = {}) {
|
|
100
88
|
const limit = opts.limit || 200;
|
|
101
|
-
const
|
|
89
|
+
const data = await fetchJson(
|
|
102
90
|
`https://otx.alienvault.com/api/v1/indicators/domain/${encodeURIComponent(domain)}/url_list?limit=${limit}`,
|
|
103
|
-
{ timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow" },
|
|
91
|
+
{ timeout: RECON_TIMEOUT, probe: "recon", redirect: "follow", maxBytes: RECON_MAX_BODY },
|
|
104
92
|
);
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
let data;
|
|
108
|
-
try {
|
|
109
|
-
data = await res.json();
|
|
110
|
-
} catch {
|
|
111
|
-
return [];
|
|
112
|
-
}
|
|
93
|
+
if (!data) return [];
|
|
113
94
|
|
|
114
95
|
return (data.url_list || []).map((e) => e.url).filter(Boolean);
|
|
115
96
|
}
|