openpitstop 1.0.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/LICENSE +21 -0
- package/README.md +512 -0
- package/demo-repo/README.md +24 -0
- package/demo-repo/package-lock.json +4483 -0
- package/demo-repo/package.json +17 -0
- package/demo-repo/src/config.js +5 -0
- package/demo-repo/src/db.js +31 -0
- package/demo-repo/src/server.js +13 -0
- package/demo-repo/src/userController.js +22 -0
- package/demo-repo/src/userRepo.js +18 -0
- package/demo-repo/src/userService.js +59 -0
- package/demo-repo/test/user.test.js +12 -0
- package/demo-repo-fintech/README.md +27 -0
- package/demo-repo-fintech/package-lock.json +1023 -0
- package/demo-repo-fintech/package.json +14 -0
- package/demo-repo-fintech/pitstop-repro-ledger-order-ord-conc-1-charged-2.test.mjs +175 -0
- package/demo-repo-fintech/pitstop-repro-ledger-order-ord-retry-2-charged-2.test.mjs +175 -0
- package/demo-repo-fintech/pitstop-repro-ledger-order-ord-webhook-3-charged-2.test.mjs +175 -0
- package/demo-repo-fintech/src/gateway.js +26 -0
- package/demo-repo-fintech/src/server.js +61 -0
- package/demo-repo-generators/package-lock.json +1508 -0
- package/demo-repo-generators/package.json +17 -0
- package/demo-repo-generators/public/index.html +10 -0
- package/demo-repo-generators/scripts/build.js +17 -0
- package/demo-repo-generators/src/counter.js +16 -0
- package/demo-repo-integrity/README.md +49 -0
- package/demo-repo-integrity/package.json +12 -0
- package/demo-repo-integrity/src/money.js +19 -0
- package/demo-repo-integrity/test/money.test.js +17 -0
- package/dist/analyzers/accessibility.d.ts +18 -0
- package/dist/analyzers/accessibility.js +248 -0
- package/dist/analyzers/accessibility.js.map +1 -0
- package/dist/analyzers/dependencyGraph.d.ts +14 -0
- package/dist/analyzers/dependencyGraph.js +206 -0
- package/dist/analyzers/dependencyGraph.js.map +1 -0
- package/dist/analyzers/devex.d.ts +19 -0
- package/dist/analyzers/devex.js +301 -0
- package/dist/analyzers/devex.js.map +1 -0
- package/dist/analyzers/duplication.d.ts +2 -0
- package/dist/analyzers/duplication.js +47 -0
- package/dist/analyzers/duplication.js.map +1 -0
- package/dist/analyzers/index.d.ts +12 -0
- package/dist/analyzers/index.js +38 -0
- package/dist/analyzers/index.js.map +1 -0
- package/dist/analyzers/integrity/assertionLiteralTamper.d.ts +2 -0
- package/dist/analyzers/integrity/assertionLiteralTamper.js +164 -0
- package/dist/analyzers/integrity/assertionLiteralTamper.js.map +1 -0
- package/dist/analyzers/integrity/exceptionSwallow.d.ts +2 -0
- package/dist/analyzers/integrity/exceptionSwallow.js +198 -0
- package/dist/analyzers/integrity/exceptionSwallow.js.map +1 -0
- package/dist/analyzers/integrity/exitCheat.d.ts +2 -0
- package/dist/analyzers/integrity/exitCheat.js +31 -0
- package/dist/analyzers/integrity/exitCheat.js.map +1 -0
- package/dist/analyzers/integrity/git.d.ts +10 -0
- package/dist/analyzers/integrity/git.js +181 -0
- package/dist/analyzers/integrity/git.js.map +1 -0
- package/dist/analyzers/integrity/hardcodedMatch.d.ts +2 -0
- package/dist/analyzers/integrity/hardcodedMatch.js +93 -0
- package/dist/analyzers/integrity/hardcodedMatch.js.map +1 -0
- package/dist/analyzers/integrity/helpers.d.ts +21 -0
- package/dist/analyzers/integrity/helpers.js +66 -0
- package/dist/analyzers/integrity/helpers.js.map +1 -0
- package/dist/analyzers/integrity/index.d.ts +10 -0
- package/dist/analyzers/integrity/index.js +27 -0
- package/dist/analyzers/integrity/index.js.map +1 -0
- package/dist/analyzers/integrity/mockOverreach.d.ts +2 -0
- package/dist/analyzers/integrity/mockOverreach.js +72 -0
- package/dist/analyzers/integrity/mockOverreach.js.map +1 -0
- package/dist/analyzers/integrity/suppressionCreep.d.ts +2 -0
- package/dist/analyzers/integrity/suppressionCreep.js +27 -0
- package/dist/analyzers/integrity/suppressionCreep.js.map +1 -0
- package/dist/analyzers/integrity/testTamper.d.ts +2 -0
- package/dist/analyzers/integrity/testTamper.js +153 -0
- package/dist/analyzers/integrity/testTamper.js.map +1 -0
- package/dist/analyzers/integrity/types.d.ts +63 -0
- package/dist/analyzers/integrity/types.js +13 -0
- package/dist/analyzers/integrity/types.js.map +1 -0
- package/dist/analyzers/ledger/attacks.d.ts +17 -0
- package/dist/analyzers/ledger/attacks.js +135 -0
- package/dist/analyzers/ledger/attacks.js.map +1 -0
- package/dist/analyzers/ledger/discover.d.ts +15 -0
- package/dist/analyzers/ledger/discover.js +122 -0
- package/dist/analyzers/ledger/discover.js.map +1 -0
- package/dist/analyzers/ledger/evidence.d.ts +22 -0
- package/dist/analyzers/ledger/evidence.js +127 -0
- package/dist/analyzers/ledger/evidence.js.map +1 -0
- package/dist/analyzers/ledger/harness.d.ts +4 -0
- package/dist/analyzers/ledger/harness.js +270 -0
- package/dist/analyzers/ledger/harness.js.map +1 -0
- package/dist/analyzers/ledger/index.d.ts +14 -0
- package/dist/analyzers/ledger/index.js +116 -0
- package/dist/analyzers/ledger/index.js.map +1 -0
- package/dist/analyzers/ledger/types.d.ts +31 -0
- package/dist/analyzers/ledger/types.js +2 -0
- package/dist/analyzers/ledger/types.js.map +1 -0
- package/dist/analyzers/perf.d.ts +2 -0
- package/dist/analyzers/perf.js +106 -0
- package/dist/analyzers/perf.js.map +1 -0
- package/dist/analyzers/reliability.d.ts +22 -0
- package/dist/analyzers/reliability.js +339 -0
- package/dist/analyzers/reliability.js.map +1 -0
- package/dist/analyzers/routes.d.ts +17 -0
- package/dist/analyzers/routes.js +115 -0
- package/dist/analyzers/routes.js.map +1 -0
- package/dist/analyzers/security.d.ts +2 -0
- package/dist/analyzers/security.js +302 -0
- package/dist/analyzers/security.js.map +1 -0
- package/dist/analyzers/suiteRunner.d.ts +64 -0
- package/dist/analyzers/suiteRunner.js +381 -0
- package/dist/analyzers/suiteRunner.js.map +1 -0
- package/dist/analyzers/tests.d.ts +2 -0
- package/dist/analyzers/tests.js +215 -0
- package/dist/analyzers/tests.js.map +1 -0
- package/dist/analyzers/types.d.ts +203 -0
- package/dist/analyzers/types.js +2 -0
- package/dist/analyzers/types.js.map +1 -0
- package/dist/analyzers/util.d.ts +35 -0
- package/dist/analyzers/util.js +209 -0
- package/dist/analyzers/util.js.map +1 -0
- package/dist/ciCheckA.d.ts +2 -0
- package/dist/ciCheckA.js +5 -0
- package/dist/ciCheckA.js.map +1 -0
- package/dist/ciCheckB.d.ts +2 -0
- package/dist/ciCheckB.js +6 -0
- package/dist/ciCheckB.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +65 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/budget.d.ts +33 -0
- package/dist/commands/budget.js +108 -0
- package/dist/commands/budget.js.map +1 -0
- package/dist/commands/ci.d.ts +14 -0
- package/dist/commands/ci.js +157 -0
- package/dist/commands/ci.js.map +1 -0
- package/dist/commands/demo.d.ts +2 -0
- package/dist/commands/demo.js +163 -0
- package/dist/commands/demo.js.map +1 -0
- package/dist/commands/digest.d.ts +8 -0
- package/dist/commands/digest.js +189 -0
- package/dist/commands/digest.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +98 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/drive.d.ts +3 -0
- package/dist/commands/drive.js +170 -0
- package/dist/commands/drive.js.map +1 -0
- package/dist/commands/gate.d.ts +2 -0
- package/dist/commands/gate.js +136 -0
- package/dist/commands/gate.js.map +1 -0
- package/dist/commands/honesty.d.ts +2 -0
- package/dist/commands/honesty.js +182 -0
- package/dist/commands/honesty.js.map +1 -0
- package/dist/commands/inspect.d.ts +2 -0
- package/dist/commands/inspect.js +244 -0
- package/dist/commands/inspect.js.map +1 -0
- package/dist/commands/install.d.ts +2 -0
- package/dist/commands/install.js +81 -0
- package/dist/commands/install.js.map +1 -0
- package/dist/commands/integrity.d.ts +2 -0
- package/dist/commands/integrity.js +74 -0
- package/dist/commands/integrity.js.map +1 -0
- package/dist/commands/memory.d.ts +6 -0
- package/dist/commands/memory.js +90 -0
- package/dist/commands/memory.js.map +1 -0
- package/dist/commands/pen.d.ts +17 -0
- package/dist/commands/pen.js +158 -0
- package/dist/commands/pen.js.map +1 -0
- package/dist/commands/prompt.d.ts +2 -0
- package/dist/commands/prompt.js +37 -0
- package/dist/commands/prompt.js.map +1 -0
- package/dist/commands/readyCheck.d.ts +21 -0
- package/dist/commands/readyCheck.js +68 -0
- package/dist/commands/readyCheck.js.map +1 -0
- package/dist/commands/report.d.ts +2 -0
- package/dist/commands/report.js +48 -0
- package/dist/commands/report.js.map +1 -0
- package/dist/commands/repro.d.ts +3 -0
- package/dist/commands/repro.js +61 -0
- package/dist/commands/repro.js.map +1 -0
- package/dist/commands/scan.d.ts +27 -0
- package/dist/commands/scan.js +348 -0
- package/dist/commands/scan.js.map +1 -0
- package/dist/commands/share.d.ts +2 -0
- package/dist/commands/share.js +163 -0
- package/dist/commands/share.js.map +1 -0
- package/dist/commands/trends.d.ts +2 -0
- package/dist/commands/trends.js +103 -0
- package/dist/commands/trends.js.map +1 -0
- package/dist/commands/try.d.ts +12 -0
- package/dist/commands/try.js +208 -0
- package/dist/commands/try.js.map +1 -0
- package/dist/commands/verify.d.ts +44 -0
- package/dist/commands/verify.js +381 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/commands/watch.d.ts +10 -0
- package/dist/commands/watch.js +70 -0
- package/dist/commands/watch.js.map +1 -0
- package/dist/evidence.d.ts +37 -0
- package/dist/evidence.js +61 -0
- package/dist/evidence.js.map +1 -0
- package/dist/graph/correlate.d.ts +7 -0
- package/dist/graph/correlate.js +246 -0
- package/dist/graph/correlate.js.map +1 -0
- package/dist/graph/integrity.d.ts +22 -0
- package/dist/graph/integrity.js +39 -0
- package/dist/graph/integrity.js.map +1 -0
- package/dist/installer/targets.d.ts +23 -0
- package/dist/installer/targets.js +162 -0
- package/dist/installer/targets.js.map +1 -0
- package/dist/memory/store.d.ts +33 -0
- package/dist/memory/store.js +131 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/pen/dynamic.d.ts +36 -0
- package/dist/pen/dynamic.js +622 -0
- package/dist/pen/dynamic.js.map +1 -0
- package/dist/pen/fix.d.ts +35 -0
- package/dist/pen/fix.js +239 -0
- package/dist/pen/fix.js.map +1 -0
- package/dist/pen/proof.d.ts +46 -0
- package/dist/pen/proof.js +112 -0
- package/dist/pen/proof.js.map +1 -0
- package/dist/pen/report.d.ts +11 -0
- package/dist/pen/report.js +258 -0
- package/dist/pen/report.js.map +1 -0
- package/dist/pen/static.d.ts +17 -0
- package/dist/pen/static.js +565 -0
- package/dist/pen/static.js.map +1 -0
- package/dist/pen/store.d.ts +11 -0
- package/dist/pen/store.js +36 -0
- package/dist/pen/store.js.map +1 -0
- package/dist/pen/types.d.ts +112 -0
- package/dist/pen/types.js +53 -0
- package/dist/pen/types.js.map +1 -0
- package/dist/report/format.d.ts +126 -0
- package/dist/report/format.js +706 -0
- package/dist/report/format.js.map +1 -0
- package/dist/report/score.d.ts +47 -0
- package/dist/report/score.js +164 -0
- package/dist/report/score.js.map +1 -0
- package/dist/repro/framework.d.ts +26 -0
- package/dist/repro/framework.js +224 -0
- package/dist/repro/framework.js.map +1 -0
- package/dist/repro/generate.d.ts +14 -0
- package/dist/repro/generate.js +669 -0
- package/dist/repro/generate.js.map +1 -0
- package/dist/repro/ids.d.ts +32 -0
- package/dist/repro/ids.js +175 -0
- package/dist/repro/ids.js.map +1 -0
- package/dist/repro/index.d.ts +24 -0
- package/dist/repro/index.js +69 -0
- package/dist/repro/index.js.map +1 -0
- package/dist/repro/pen.d.ts +26 -0
- package/dist/repro/pen.js +321 -0
- package/dist/repro/pen.js.map +1 -0
- package/dist/repro/run.d.ts +18 -0
- package/dist/repro/run.js +36 -0
- package/dist/repro/run.js.map +1 -0
- package/dist/sandbox/proxy.d.ts +41 -0
- package/dist/sandbox/proxy.js +231 -0
- package/dist/sandbox/proxy.js.map +1 -0
- package/dist/sandbox/startCmd.d.ts +24 -0
- package/dist/sandbox/startCmd.js +71 -0
- package/dist/sandbox/startCmd.js.map +1 -0
- package/dist/ui/spinner.d.ts +18 -0
- package/dist/ui/spinner.js +26 -0
- package/dist/ui/spinner.js.map +1 -0
- package/dist/verify/metrics.d.ts +13 -0
- package/dist/verify/metrics.js +72 -0
- package/dist/verify/metrics.js.map +1 -0
- package/package.json +68 -0
- package/scripts/cheat-demo.cjs +102 -0
- package/templates/ledger/preload.cjs +331 -0
- package/templates/pen/preload.cjs +196 -0
- package/templates/pitstop.prompt.md +371 -0
|
@@ -0,0 +1,622 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pen/dynamic.ts — the dynamic phase of `pitstop pen`.
|
|
3
|
+
*
|
|
4
|
+
* Boots the app-under-test under the pen sandbox (`templates/pen/preload.cjs`
|
|
5
|
+
* for Node/JS) and fires a battery of live attacks at the discovered routes.
|
|
6
|
+
* Every attack is recorded (method, path, payload, status, response snippet)
|
|
7
|
+
* and its outcome is checked against BOTH the response and the sandbox
|
|
8
|
+
* evidence stream.
|
|
9
|
+
*
|
|
10
|
+
* Non-Node stacks (Go/Rust/Python/.NET/Java/Dart) are sandboxed with an
|
|
11
|
+
* HTTP(S)_PROXY recording proxy instead of the nock preload: outbound calls
|
|
12
|
+
* from apps honoring the proxy are recorded and never forwarded anywhere real.
|
|
13
|
+
* That proxy cannot observe subprocess spawns, so for those stacks
|
|
14
|
+
* command-injection is capped at "indicated" (response-echo), never "proven".
|
|
15
|
+
*
|
|
16
|
+
* Honesty rules:
|
|
17
|
+
* - Attack requests may legitimately crash bits of the app (a 500 is a
|
|
18
|
+
* finding too: `crash-on-input`). The app is this user's own repo, started
|
|
19
|
+
* by the user, on a local port, with all external network traffic
|
|
20
|
+
* intercepted and logged.
|
|
21
|
+
* - "proven" is reserved for findings where the sandbox recorded a canary —
|
|
22
|
+
* e.g. a spawn containing our marker (command injection) or an outbound
|
|
23
|
+
* HTTP call to our canary host (SSRF). Everything else is "indicated".
|
|
24
|
+
*/
|
|
25
|
+
import { execa } from "execa";
|
|
26
|
+
import fs from "node:fs";
|
|
27
|
+
import net from "node:net";
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import { fileURLToPath } from "node:url";
|
|
30
|
+
import { detectLanguage } from "../analyzers/util.js";
|
|
31
|
+
import { startRecordingProxy } from "../sandbox/proxy.js";
|
|
32
|
+
import { resolveNativeStart, resolveNodeStart, } from "../sandbox/startCmd.js";
|
|
33
|
+
import { findingIdFor } from "../repro/ids.js";
|
|
34
|
+
const STARTUP_TIMEOUT_MS = 25_000;
|
|
35
|
+
const REQUEST_TIMEOUT_MS = 6_000;
|
|
36
|
+
const MAX_ROUTES_PROBED = 40;
|
|
37
|
+
const RATE_LIMIT_HAMMER = 30;
|
|
38
|
+
function preloadPath() {
|
|
39
|
+
return fileURLToPath(new URL("../../templates/pen/preload.cjs", import.meta.url));
|
|
40
|
+
}
|
|
41
|
+
function freePort() {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const s = net.createServer();
|
|
44
|
+
s.unref();
|
|
45
|
+
s.on("error", reject);
|
|
46
|
+
s.listen(0, () => {
|
|
47
|
+
const p = s.address().port;
|
|
48
|
+
s.close(() => resolve(p));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
function controlHas(controlPath, needle) {
|
|
53
|
+
if (!fs.existsSync(controlPath))
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
return fs
|
|
57
|
+
.readFileSync(controlPath, "utf8")
|
|
58
|
+
.split(/\r?\n/)
|
|
59
|
+
.some((l) => l.includes(needle));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function waitForServer(baseUrl, exited, controlPath, timeoutMs) {
|
|
66
|
+
const deadline = Date.now() + timeoutMs;
|
|
67
|
+
return (async () => {
|
|
68
|
+
while (Date.now() < deadline) {
|
|
69
|
+
if (controlHas(controlPath, '"event":"abort"'))
|
|
70
|
+
return { up: false, aborted: true };
|
|
71
|
+
if (exited())
|
|
72
|
+
return { up: false, aborted: controlHas(controlPath, '"event":"abort"') };
|
|
73
|
+
if (controlHas(controlPath, '"event":"armed"')) {
|
|
74
|
+
try {
|
|
75
|
+
const res = await fetch(`${baseUrl}/__pitstop_pen_probe__`, { signal: AbortSignal.timeout(2500) });
|
|
76
|
+
if (res)
|
|
77
|
+
return { up: true, aborted: false };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
/* not up yet */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
await new Promise((r) => setTimeout(r, 150));
|
|
84
|
+
}
|
|
85
|
+
return { up: false, aborted: controlHas(controlPath, '"event":"abort"') };
|
|
86
|
+
})();
|
|
87
|
+
}
|
|
88
|
+
/* ------------------------------------------------------------------ */
|
|
89
|
+
/* HTTP helpers */
|
|
90
|
+
/* ------------------------------------------------------------------ */
|
|
91
|
+
function sanitize(s) {
|
|
92
|
+
return s
|
|
93
|
+
.replace(/\u001b\[[0-9;]*m/g, "")
|
|
94
|
+
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/g, " ")
|
|
95
|
+
.replace(/\s+/g, " ")
|
|
96
|
+
.trim()
|
|
97
|
+
.slice(0, 300);
|
|
98
|
+
}
|
|
99
|
+
async function hit(baseUrl, method, p, body) {
|
|
100
|
+
const effort = {
|
|
101
|
+
method,
|
|
102
|
+
headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
|
103
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
104
|
+
redirect: "manual",
|
|
105
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
106
|
+
};
|
|
107
|
+
try {
|
|
108
|
+
const res = await fetch(baseUrl + p, effort);
|
|
109
|
+
const text = await res.text().catch(() => "");
|
|
110
|
+
const headers = {};
|
|
111
|
+
res.headers.forEach((v, k) => {
|
|
112
|
+
headers[k] = v;
|
|
113
|
+
});
|
|
114
|
+
return { status: res.status, snippet: sanitize(text), headers, text };
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return { status: 0, snippet: "", headers: {}, text: "" };
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
const idish = (name) => /id|key|slug|code|order/i.test(name);
|
|
121
|
+
const pathTraversalFix = "Resolve the path and verify it stays inside a ROOT directory (path.resolve(root, name)), " +
|
|
122
|
+
"then reject any result that escapes via startsWith(root + path.sep); use a safelist of allowed filenames.";
|
|
123
|
+
function substitute(rawPath) {
|
|
124
|
+
return rawPath
|
|
125
|
+
.replace(/:([A-Za-z_][\w-]*)/g, (_, name) => (idish(name) ? "1" : "abc"))
|
|
126
|
+
.replace(/\{([A-Za-z_][\w-]*)\}/g, (_, name) => (idish(name) ? "1" : "abc"));
|
|
127
|
+
}
|
|
128
|
+
function buildAttacks(marker, canary) {
|
|
129
|
+
const xss = `<script>${marker}</script>`;
|
|
130
|
+
const sql = `' OR '1'='1' -- ${marker}`;
|
|
131
|
+
const nosql = { $gt: "" };
|
|
132
|
+
const trav = "../../../../etc/passwd";
|
|
133
|
+
const cmd = `;echo ${marker};`;
|
|
134
|
+
const q = (v) => [
|
|
135
|
+
["q", v],
|
|
136
|
+
["name", v],
|
|
137
|
+
["search", v],
|
|
138
|
+
["input", v],
|
|
139
|
+
];
|
|
140
|
+
return [
|
|
141
|
+
{ tag: "xss", method: "GET", origin: "query arguments", qs: q(xss) },
|
|
142
|
+
{ tag: "sql", method: "GET", origin: "query arguments", qs: q(sql) },
|
|
143
|
+
{
|
|
144
|
+
tag: "traversal",
|
|
145
|
+
method: "GET",
|
|
146
|
+
origin: "query arguments",
|
|
147
|
+
qs: [["file", trav], ["path", trav], ["filename", trav]],
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
tag: "cmd",
|
|
151
|
+
method: "GET",
|
|
152
|
+
origin: "query arguments",
|
|
153
|
+
qs: [["cmd", cmd], ["command", cmd], ["execute", cmd]],
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
tag: "ssrf",
|
|
157
|
+
method: "GET",
|
|
158
|
+
origin: "query arguments",
|
|
159
|
+
qs: [["url", canary], ["uri", canary], ["link", canary], ["target", canary], ["remote", canary], ["redirect", canary], ["callback", canary], ["webhook", canary], ["image", canary], ["src", canary], ["host", canary], ["api", canary]],
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
tag: "xss",
|
|
163
|
+
method: "POST",
|
|
164
|
+
origin: "request body",
|
|
165
|
+
qs: [],
|
|
166
|
+
body: (m) => ({ q: `<script>${m}</script>`, name: `<script>${m}</script>`, search: `<script>${m}</script>`, input: `<script>${m}</script>`, message: `<script>${m}</script>` }),
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
tag: "sql",
|
|
170
|
+
method: "POST",
|
|
171
|
+
origin: "request body",
|
|
172
|
+
qs: [],
|
|
173
|
+
body: (m) => ({ q: `' OR '1'='1' -- ${m}`, name: `' OR '1'='1' -- ${m}`, search: `' OR '1'='1' -- ${m}`, input: `' OR '1'='1' -- ${m}` }),
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
tag: "nosql",
|
|
177
|
+
method: "POST",
|
|
178
|
+
origin: "request body",
|
|
179
|
+
qs: [],
|
|
180
|
+
body: () => ({ q: { $gt: "" }, name: { $gt: "" }, id: { $gt: "" } }),
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
tag: "traversal",
|
|
184
|
+
method: "POST",
|
|
185
|
+
origin: "request body",
|
|
186
|
+
qs: [],
|
|
187
|
+
body: () => ({ file: "../../../../etc/passwd", path: "../../../../etc/passwd", filename: "..\\..\\..\\..\\windows\\win.ini" }),
|
|
188
|
+
},
|
|
189
|
+
{
|
|
190
|
+
tag: "cmd",
|
|
191
|
+
method: "POST",
|
|
192
|
+
origin: "request body",
|
|
193
|
+
qs: [],
|
|
194
|
+
body: (m) => ({ cmd: `;echo ${m};`, command: `;echo ${m};`, execute: `;echo ${m};` }),
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
tag: "ssrf",
|
|
198
|
+
method: "POST",
|
|
199
|
+
origin: "request body",
|
|
200
|
+
qs: [],
|
|
201
|
+
body: (_m, c) => ({ url: c, target: c, remote: c, image: c, src: c, webhook: c, callback: c, redirect: c, uri: c, link: c, host: c, api: c }),
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
}
|
|
205
|
+
function readOutbound(outboundPath) {
|
|
206
|
+
if (!fs.existsSync(outboundPath))
|
|
207
|
+
return [];
|
|
208
|
+
try {
|
|
209
|
+
return fs
|
|
210
|
+
.readFileSync(outboundPath, "utf8")
|
|
211
|
+
.split(/\r?\n/)
|
|
212
|
+
.filter(Boolean)
|
|
213
|
+
.map((l) => {
|
|
214
|
+
try {
|
|
215
|
+
return JSON.parse(l);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
.filter((e) => !!e);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
return [];
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
export async function runDynamic(repo, routes) {
|
|
228
|
+
const t0 = Date.now();
|
|
229
|
+
const abortWith = (note) => ({
|
|
230
|
+
status: "aborted",
|
|
231
|
+
note,
|
|
232
|
+
routesProbed: 0,
|
|
233
|
+
attacks: 0,
|
|
234
|
+
bootMs: 0,
|
|
235
|
+
durationMs: Date.now() - t0,
|
|
236
|
+
outboundEvents: 0,
|
|
237
|
+
findings: [],
|
|
238
|
+
});
|
|
239
|
+
const lang = detectLanguage(repo);
|
|
240
|
+
const nodeMode = lang === "js";
|
|
241
|
+
if (lang === "unknown") {
|
|
242
|
+
return abortWith("could not identify the repo language — dynamic pen cannot choose a sandbox; " +
|
|
243
|
+
"static pen still ran — see its findings below.");
|
|
244
|
+
}
|
|
245
|
+
const workDir = path.join(repo, ".pitstop", "pen");
|
|
246
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
247
|
+
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
248
|
+
const runDir = path.join(workDir, `run-${ts}`);
|
|
249
|
+
fs.mkdirSync(runDir, { recursive: true });
|
|
250
|
+
const controlPath = path.join(runDir, "control.jsonl");
|
|
251
|
+
const outboundPath = path.join(runDir, "outbound.jsonl");
|
|
252
|
+
const appOut = path.join(runDir, "app.out.log");
|
|
253
|
+
const port = await freePort();
|
|
254
|
+
const baseUrl = `http://127.0.0.1:${port}`;
|
|
255
|
+
let start = null;
|
|
256
|
+
try {
|
|
257
|
+
start = nodeMode ? resolveNodeStart(repo) : resolveNativeStart(repo, lang, port);
|
|
258
|
+
}
|
|
259
|
+
catch (e) {
|
|
260
|
+
return abortWith(e.message);
|
|
261
|
+
}
|
|
262
|
+
if (!start) {
|
|
263
|
+
return abortWith(`could not determine a start command for a ${lang} repo — set PITSTOP_START ` +
|
|
264
|
+
`(e.g. "PITSTOP_START=uvicorn app.main:app") and re-run. Static pen still ran — see its findings below.`);
|
|
265
|
+
}
|
|
266
|
+
// Non-Node sandbox: HTTP(S)_PROXY recording proxy. Java/Dart clients do not
|
|
267
|
+
// honor the proxy by default — outbound evidence there simply never fires,
|
|
268
|
+
// while response-side findings stay valid.
|
|
269
|
+
let proxy = null;
|
|
270
|
+
if (!nodeMode) {
|
|
271
|
+
try {
|
|
272
|
+
proxy = await startRecordingProxy({
|
|
273
|
+
logPath: outboundPath,
|
|
274
|
+
controlPath,
|
|
275
|
+
gatewayHosts: [],
|
|
276
|
+
canarySuffix: "pitstop.invalid",
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
catch (e) {
|
|
280
|
+
return abortWith(`could not start the recording proxy: ${e.message}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const bootStart = Date.now();
|
|
284
|
+
const baseEnv = {
|
|
285
|
+
...process.env,
|
|
286
|
+
PORT: String(port),
|
|
287
|
+
PITSTOP_PEN_CONTROL: controlPath,
|
|
288
|
+
PITSTOP_PEN_OUTBOUND: outboundPath,
|
|
289
|
+
RAZORPAY_KEY_ID: process.env.RAZORPAY_KEY_ID || "rzp_test_pitstop000000",
|
|
290
|
+
RAZORPAY_KEY_SECRET: process.env.RAZORPAY_KEY_SECRET || "pitstop_fake_secret",
|
|
291
|
+
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY || "sk_test_pitstop_fake",
|
|
292
|
+
NODE_ENV: process.env.NODE_ENV || "test",
|
|
293
|
+
};
|
|
294
|
+
const env = nodeMode
|
|
295
|
+
? {
|
|
296
|
+
...baseEnv,
|
|
297
|
+
NODE_OPTIONS: [process.env.NODE_OPTIONS, `--require=${preloadPath()}`]
|
|
298
|
+
.filter(Boolean)
|
|
299
|
+
.join(" "),
|
|
300
|
+
}
|
|
301
|
+
: {
|
|
302
|
+
...baseEnv,
|
|
303
|
+
HTTP_PROXY: `http://127.0.0.1:${proxy.port}`,
|
|
304
|
+
HTTPS_PROXY: `http://127.0.0.1:${proxy.port}`,
|
|
305
|
+
http_proxy: `http://127.0.0.1:${proxy.port}`,
|
|
306
|
+
https_proxy: `http://127.0.0.1:${proxy.port}`,
|
|
307
|
+
NO_PROXY: "localhost,127.0.0.1,::1",
|
|
308
|
+
no_proxy: "localhost,127.0.0.1,::1",
|
|
309
|
+
};
|
|
310
|
+
const child = execa(start.cmd, start.args, {
|
|
311
|
+
cwd: repo,
|
|
312
|
+
env,
|
|
313
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
314
|
+
windowsHide: true,
|
|
315
|
+
reject: false,
|
|
316
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
317
|
+
});
|
|
318
|
+
let exited = false;
|
|
319
|
+
child
|
|
320
|
+
.then(() => {
|
|
321
|
+
exited = true;
|
|
322
|
+
})
|
|
323
|
+
.catch(() => {
|
|
324
|
+
exited = true;
|
|
325
|
+
});
|
|
326
|
+
const outFd = fs.openSync(appOut, "a");
|
|
327
|
+
const pump = (chunk) => fs.writeSync(outFd, typeof chunk === "string" ? chunk : chunk.toString());
|
|
328
|
+
child.stdout?.on("data", pump);
|
|
329
|
+
child.stderr?.on("data", pump);
|
|
330
|
+
const { up, aborted } = await waitForServer(baseUrl, () => exited, controlPath, STARTUP_TIMEOUT_MS);
|
|
331
|
+
const close = () => {
|
|
332
|
+
try {
|
|
333
|
+
child.kill();
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
/* ignore */
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
fs.closeSync(outFd);
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
/* ignore */
|
|
343
|
+
}
|
|
344
|
+
if (proxy) {
|
|
345
|
+
proxy.close().catch(() => {
|
|
346
|
+
/* ignore */
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
if (!up) {
|
|
351
|
+
close();
|
|
352
|
+
const note = aborted
|
|
353
|
+
? "the sandbox aborted during startup (a raw socket / uninterceptable channel fired before we were ready) — see the run dir for control.jsonl"
|
|
354
|
+
: `app did not come up within ${STARTUP_TIMEOUT_MS / 1000}s (start: "${start.cmd} ${start.args.join(" ")}")`;
|
|
355
|
+
return {
|
|
356
|
+
status: "aborted",
|
|
357
|
+
note,
|
|
358
|
+
routesProbed: 0,
|
|
359
|
+
attacks: 0,
|
|
360
|
+
bootMs: Date.now() - bootStart,
|
|
361
|
+
durationMs: Date.now() - t0,
|
|
362
|
+
outboundEvents: readOutbound(outboundPath).length,
|
|
363
|
+
findings: [],
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
const bootMs = Date.now() - bootStart;
|
|
367
|
+
const findings = [];
|
|
368
|
+
let attacks = 0;
|
|
369
|
+
let routesProbed = 0;
|
|
370
|
+
try {
|
|
371
|
+
const probed = routes.slice(0, MAX_ROUTES_PROBED);
|
|
372
|
+
routesProbed = probed.length;
|
|
373
|
+
const marker = `gpn${Math.floor(Math.random() * 1e8)}`;
|
|
374
|
+
const canaryHost = `ssrf-canary-${marker}.pitstop.invalid`;
|
|
375
|
+
const canaryUrl = `http://${canaryHost}/pen`;
|
|
376
|
+
const seen = new Set();
|
|
377
|
+
const pushFinding = (f) => {
|
|
378
|
+
const key = `${f.type}|${f.route ?? f.file ?? ""}`;
|
|
379
|
+
if (seen.has(key))
|
|
380
|
+
return;
|
|
381
|
+
seen.add(key);
|
|
382
|
+
findings.push({
|
|
383
|
+
id: findingIdFor("pen", f.type, f.route ?? f.file, f.title),
|
|
384
|
+
source: "pen-dynamic",
|
|
385
|
+
...f,
|
|
386
|
+
});
|
|
387
|
+
};
|
|
388
|
+
for (const route of probed) {
|
|
389
|
+
const urlPath = substitute(route.path);
|
|
390
|
+
const base = await hit(baseUrl, route.method, urlPath, route.method === "GET" ? undefined : {});
|
|
391
|
+
attacks++;
|
|
392
|
+
const headers = base.headers;
|
|
393
|
+
const routeLabel = `${route.method} ${route.path}`;
|
|
394
|
+
/* ---- headers: framework disclosure + missing security headers ---- */
|
|
395
|
+
if (headers["x-powered-by"]) {
|
|
396
|
+
pushFinding({
|
|
397
|
+
type: "info-leak-header",
|
|
398
|
+
severity: "low",
|
|
399
|
+
confidence: "indicated",
|
|
400
|
+
title: "framework version leaked via X-Powered-By",
|
|
401
|
+
description: `${routeLabel} responds with X-Powered-By: ${headers["x-powered-by"]} — fingerprints framework/version for attackers.`,
|
|
402
|
+
route: route.path,
|
|
403
|
+
method: route.method,
|
|
404
|
+
response: { status: base.status, headers },
|
|
405
|
+
repro: `curl -sI /${urlPath} | grep -i x-powered-by`,
|
|
406
|
+
fix: 'Disable it: `app.disable("x-powered-by");` (deterministic patch via `pitstop pen --fix`).',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
const secHeaders = ["x-frame-options", "content-security-policy", "x-content-type-options", "strict-transport-security"];
|
|
410
|
+
const missingSec = secHeaders.filter((h) => !headers[h]);
|
|
411
|
+
if (missingSec.length >= 2) {
|
|
412
|
+
pushFinding({
|
|
413
|
+
type: "missing-security-headers",
|
|
414
|
+
severity: "medium",
|
|
415
|
+
confidence: "indicated",
|
|
416
|
+
title: `security headers absent on ${routeLabel}`,
|
|
417
|
+
description: `Missing: ${missingSec.join(", ")}. Responses are clickjackable and MIME-sniffable.`,
|
|
418
|
+
route: route.path,
|
|
419
|
+
method: route.method,
|
|
420
|
+
response: { status: base.status, headers },
|
|
421
|
+
fix: 'Ship `helmet()` (or manually set X-Frame-Options: DENY, X-Content-Type-Options: nosniff, a CSP, and HSTS).',
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
/* ---- auth check on sensitive routes ---- */
|
|
425
|
+
if (route.sensitive && base.status >= 200 && base.status < 300) {
|
|
426
|
+
pushFinding({
|
|
427
|
+
type: "missing-auth",
|
|
428
|
+
severity: "high",
|
|
429
|
+
confidence: "indicated",
|
|
430
|
+
title: "sensitive route answers unauthenticated",
|
|
431
|
+
description: `${routeLabel} returns ${base.status} with NO Authorization header / session cookie. Response: ${base.snippet}`,
|
|
432
|
+
route: route.path,
|
|
433
|
+
method: route.method,
|
|
434
|
+
attack: { method: route.method, path: urlPath },
|
|
435
|
+
response: { status: base.status, snippet: base.snippet },
|
|
436
|
+
repro: `curl -s -o /dev/null -w '%{http_code}' /${urlPath}`,
|
|
437
|
+
fix: "Require authentication (session/JWT) BEFORE the handler; 401 on missing credentials.",
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/* ---- injection battery ---- */
|
|
441
|
+
for (const atk of buildAttacks(marker, canaryUrl)) {
|
|
442
|
+
const qs = atk.qs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
|
|
443
|
+
const p = qs ? `${urlPath}${urlPath.includes("?") ? "&" : "?"}${qs}` : urlPath;
|
|
444
|
+
const pre = readOutbound(outboundPath);
|
|
445
|
+
const nextN = pre.length ? Math.max(...pre.map((o) => (o.n ?? 0))) : 0;
|
|
446
|
+
const res = await hit(baseUrl, atk.method, p, atk.body ? atk.body(marker, canaryUrl) : undefined);
|
|
447
|
+
attacks++;
|
|
448
|
+
// canary-linked proof: only evidence recorded DURING this attack (n > nextN).
|
|
449
|
+
const inWindow = readOutbound(outboundPath).filter((o) => (o.n ?? 0) > nextN);
|
|
450
|
+
const cmdProof = inWindow.filter((o) => o.kind === "spawn" && o.cmd && o.cmd.includes(marker));
|
|
451
|
+
const ssrfProof = inWindow.filter((o) => o.kind === "http" && o.host && o.host.includes("ssrf-canary"));
|
|
452
|
+
if (ssrfProof.length > 0) {
|
|
453
|
+
pushFinding({
|
|
454
|
+
type: "ssrf",
|
|
455
|
+
severity: "high",
|
|
456
|
+
confidence: "proven",
|
|
457
|
+
title: `server-side request forgery via ${routeLabel}`,
|
|
458
|
+
description: `A ${atk.origin} "url/webhook/callback"-style field made the SERVER open a connection to ${canaryHost} (recorded by ${nodeMode ? "the sandbox" : "the recording proxy"} — no real byte left the machine). Response: HTTP ${res.status}.`,
|
|
459
|
+
route: route.path,
|
|
460
|
+
method: route.method,
|
|
461
|
+
attack: { method: atk.method, path: p, payload: atk.body ? atk.body(marker, canaryUrl) : undefined },
|
|
462
|
+
response: { status: res.status, snippet: res.snippet },
|
|
463
|
+
outbound: ssrfProof.slice(0, 3).map((e) => `${e.kind} → ${e.host}${e.method ? " " + e.method : ""}`),
|
|
464
|
+
repro: `POST ${urlPath} with a "url"/"webhook"/"callback" field set to ${canaryUrl} — the app opens a connection to it.`,
|
|
465
|
+
fix: "Only allow an explicit allowlist of hosts; block private/metadata ranges (169.254.169.254 etc.) after DNS resolution; never accept attacker-controlled URLs for server-side fetches.",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
if (cmdProof.length > 0) {
|
|
469
|
+
pushFinding({
|
|
470
|
+
type: "command-injection",
|
|
471
|
+
severity: "critical",
|
|
472
|
+
confidence: "proven",
|
|
473
|
+
title: `command injection on ${routeLabel}`,
|
|
474
|
+
description: `The sandbox recorded a child_process spawn containing the marker after sending a "cmd"-style payload (executed cmd: ${cmdProof[0].cmd}). This is remote code execution with the server's privileges.`,
|
|
475
|
+
route: route.path,
|
|
476
|
+
method: route.method,
|
|
477
|
+
attack: { method: atk.method, path: p, payload: atk.body ? atk.body(marker, canaryUrl) : undefined },
|
|
478
|
+
response: { status: res.status, snippet: res.snippet },
|
|
479
|
+
outbound: cmdProof.slice(0, 2).map((e) => `spawn: ${e.cmd}`),
|
|
480
|
+
repro: `GET/POST with e.g. ?cmd=;echo ${marker}; — the server spawns the command.`,
|
|
481
|
+
fix: "Never shell out with request data. Use execFile with a fixed command and no shell; validate input against a strict allowlist.",
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
else if (!nodeMode &&
|
|
485
|
+
atk.tag === "cmd" &&
|
|
486
|
+
res.status < 300 &&
|
|
487
|
+
res.text.includes(marker)) {
|
|
488
|
+
// The recording proxy cannot observe subprocess spawns, so an echoed
|
|
489
|
+
// marker is the strongest evidence it can give: indicated, never proven.
|
|
490
|
+
pushFinding({
|
|
491
|
+
type: "command-injection",
|
|
492
|
+
severity: "critical",
|
|
493
|
+
confidence: "indicated",
|
|
494
|
+
title: `command payload echoed on ${routeLabel}`,
|
|
495
|
+
description: `A "cmd"-style payload's marker (${marker}) came back in the HTTP ${res.status} response. The value reached a shell/exec path — but the HTTP proxy cannot observe spawns, so this is indicated, not proven (a pure echo endpoint could also reflect it).`,
|
|
496
|
+
route: route.path,
|
|
497
|
+
method: route.method,
|
|
498
|
+
attack: { method: atk.method, path: p, payload: atk.body ? atk.body(marker, canaryUrl) : undefined },
|
|
499
|
+
response: { status: res.status, snippet: res.snippet },
|
|
500
|
+
repro: `GET/POST with e.g. ?cmd=;echo ${marker}; — the marker appears in the response body.`,
|
|
501
|
+
fix: "Never shell out with request data. Use execFile with a fixed command and no shell; validate input against a strict allowlist.",
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
// Injection-style observations (indicated): response reflects or crashes.
|
|
505
|
+
if (res.status >= 500) {
|
|
506
|
+
pushFinding({
|
|
507
|
+
type: "crash-on-input",
|
|
508
|
+
severity: "medium",
|
|
509
|
+
confidence: "indicated",
|
|
510
|
+
title: `server error on crafted input (${routeLabel})`,
|
|
511
|
+
description: `${atk.tag} payload (${atk.origin}) produced HTTP ${res.status}. Unhandled exceptions are a DoS vector and usually a red flag for injection.`,
|
|
512
|
+
route: route.path,
|
|
513
|
+
method: route.method,
|
|
514
|
+
attack: { method: atk.method, path: p, payload: atk.body ? atk.body(marker, canaryUrl) : undefined },
|
|
515
|
+
response: { status: res.status, snippet: res.snippet },
|
|
516
|
+
fix: "Validate/normalize input at the boundary; never let malformed request data reach a raw handler; add a global error handler that returns 400.",
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
if (atk.tag === "xss" && res.status < 300 && res.text.includes(marker)) {
|
|
520
|
+
pushFinding({
|
|
521
|
+
type: "reflected-xss",
|
|
522
|
+
severity: "high",
|
|
523
|
+
confidence: "proven",
|
|
524
|
+
title: `reflected XSS on ${routeLabel}`,
|
|
525
|
+
description: `The exact payload ${marker} appeared UNESCAPED in the HTTP ${res.status} response to ${atk.method} ${p.slice(0, 120)}. A browser visiting the URL executes attacker script.`,
|
|
526
|
+
route: route.path,
|
|
527
|
+
method: route.method,
|
|
528
|
+
attack: { method: atk.method, path: p, payload: atk.body && atk.body(marker, canaryUrl) },
|
|
529
|
+
response: { status: res.status, snippet: res.snippet },
|
|
530
|
+
repro: `GET "${baseUrl}${p}" and grep for ${marker} in the body.`,
|
|
531
|
+
fix: "Never interpolate request data into HTML. Escape at the output (or use text nodes); apply a strict CSP and set X-Content-Type-Options: nosniff.",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
if (atk.tag === "traversal" && res.status < 300 && (res.text.includes("root:x:0:0") || res.text.includes("[extensions]") || res.text.includes("; for 16-bit app support"))) {
|
|
535
|
+
pushFinding({
|
|
536
|
+
type: "path-traversal",
|
|
537
|
+
severity: "critical",
|
|
538
|
+
confidence: "proven",
|
|
539
|
+
title: `path traversal reads files on ${routeLabel}`,
|
|
540
|
+
description: `A ../../.. path payload made the server return the host's ${res.text.includes("root:x:0:0") ? "/etc/passwd" : "windows config"} inside HTTP ${res.status}. That is arbitrary file read.`,
|
|
541
|
+
route: route.path,
|
|
542
|
+
method: route.method,
|
|
543
|
+
attack: { method: atk.method, path: p, payload: atk.body && atk.body(marker, canaryUrl) },
|
|
544
|
+
response: { status: res.status, snippet: res.snippet },
|
|
545
|
+
repro: "Hit the route with `..%2f..%2f..%2fetc%2fpasswd` in a file/path parameter.",
|
|
546
|
+
fix: pathTraversalFix,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if ((atk.tag === "sql" || atk.tag === "nosql") && res.status < 300 && res.text.includes("error") && /syntax|unterminated|parse|invalid|mongo|sqlite|sql|sequelize|knex|cannot read/i.test(res.snippet)) {
|
|
550
|
+
pushFinding({
|
|
551
|
+
type: `${atk.tag === "sql" ? "sql-injection" : "nosql-injection"}`,
|
|
552
|
+
severity: "critical",
|
|
553
|
+
confidence: "indicated",
|
|
554
|
+
title: `injection probing surfaced parse errors on ${routeLabel}`,
|
|
555
|
+
description: `${atk.tag} payload produced a DB-ish error surface in the ${res.status} response (${res.snippet}). Strong evidence the value lands in a query.`,
|
|
556
|
+
route: route.path,
|
|
557
|
+
method: route.method,
|
|
558
|
+
attack: { method: atk.method, path: p, payload: atk.body && atk.body(marker, canaryUrl) },
|
|
559
|
+
response: { status: res.status, snippet: res.snippet },
|
|
560
|
+
fix: "Parameterize all queries; disable verbose error output in production.",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
/* ---- rate-limit battery on login-like routes ---- */
|
|
565
|
+
if (route.loginLike) {
|
|
566
|
+
let seen429 = 0;
|
|
567
|
+
let after429Status = "";
|
|
568
|
+
for (let i = 0; i < RATE_LIMIT_HAMMER; i++) {
|
|
569
|
+
const r = await hit(baseUrl, "POST", urlPath, { username: "admin", password: `x-${i}-${Math.random()}` });
|
|
570
|
+
attacks++;
|
|
571
|
+
if (r.status === 429) {
|
|
572
|
+
seen429++;
|
|
573
|
+
if (!after429Status)
|
|
574
|
+
after429Status = `${r.status} (${r.snippet})`;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
if (seen429 === 0) {
|
|
578
|
+
pushFinding({
|
|
579
|
+
type: "no-rate-limit",
|
|
580
|
+
severity: "medium",
|
|
581
|
+
confidence: "indicated",
|
|
582
|
+
title: `no rate limiting on ${routeLabel}`,
|
|
583
|
+
description: `${RATE_LIMIT_HAMMER} rapid guesses were all accepted (hit ${base.status} after the first request) — no 429 anywhere. Credential stuffing / OTP brute force is unthrottled.`,
|
|
584
|
+
route: route.path,
|
|
585
|
+
method: route.method,
|
|
586
|
+
attack: { method: "POST", path: urlPath, payload: `{ username, password } × ${RATE_LIMIT_HAMMER} rapid requests` },
|
|
587
|
+
repro: `for i in $(seq 1 ${RATE_LIMIT_HAMMER}); do curl -s -o /dev/null -w '%{http_code}\\n' -X POST /${urlPath} -d '{"username":"admin","password":"x"}'; done | sort | uniq -c`,
|
|
588
|
+
fix: "Add express-rate-limit on this route (e.g. 5 / 15min per IP) and consider lockout / captcha after failures.",
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
close();
|
|
594
|
+
return {
|
|
595
|
+
status: "ok",
|
|
596
|
+
note: nodeMode
|
|
597
|
+
? undefined
|
|
598
|
+
: `HTTP(S)_PROXY recording sandbox (${lang}) — only apps whose HTTP clients honor the proxy are intercepted; ` +
|
|
599
|
+
"native binaries/raw sockets are not; command-injection is capped at 'indicated'; https outbound is blocked (502).",
|
|
600
|
+
routesProbed,
|
|
601
|
+
attacks,
|
|
602
|
+
bootMs,
|
|
603
|
+
durationMs: Date.now() - t0,
|
|
604
|
+
outboundEvents: readOutbound(outboundPath).length,
|
|
605
|
+
findings,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
catch (e) {
|
|
609
|
+
close();
|
|
610
|
+
return {
|
|
611
|
+
status: "aborted",
|
|
612
|
+
note: `dynamic phase crashed: ${e.message}`,
|
|
613
|
+
routesProbed,
|
|
614
|
+
attacks,
|
|
615
|
+
bootMs,
|
|
616
|
+
durationMs: Date.now() - t0,
|
|
617
|
+
outboundEvents: readOutbound(outboundPath).length,
|
|
618
|
+
findings,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
//# sourceMappingURL=dynamic.js.map
|