aztrx-cli 0.1.0 → 0.2.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/README.md +234 -90
- package/dist/cli/help.js +85 -0
- package/dist/cli.js +167 -29
- package/dist/core/auth.js +76 -0
- package/dist/core/badge.js +50 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.js +52 -0
- package/dist/core/heal/boot.js +136 -0
- package/dist/core/heal/childEnv.js +60 -0
- package/dist/core/heal/index.js +47 -6
- package/dist/core/heal/redact.js +31 -1
- package/dist/core/heal/sandbox.js +53 -1
- package/dist/core/heal/verify.js +30 -1
- package/dist/core/httpFuzzer.js +258 -0
- package/dist/core/init.js +2 -2
- package/dist/core/interceptor.js +10 -1
- package/dist/core/modernize.js +144 -0
- package/dist/core/orchestrator.js +66 -77
- package/dist/core/pr.js +44 -20
- package/dist/core/prompt.js +22 -0
- package/dist/core/replay.js +35 -3
- package/dist/core/report.js +19 -6
- package/dist/core/resolver.js +158 -5
- package/dist/core/specCompiler.js +17 -2
- package/dist/core/studio.js +3 -4
- package/dist/core/summarize.js +173 -0
- package/dist/core/swarm.js +235 -0
- package/dist/core/ui.js +2 -0
- package/dist/ui/app.js +4 -2
- package/media/demo.gif +0 -0
- package/media/logo.svg +9 -0
- package/package.json +25 -6
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child-process environment isolation. The heal sandbox runs the project's own
|
|
3
|
+
* test suite, `tsc`, and dev server — which means it executes untrusted PR code.
|
|
4
|
+
* Handing that code the full `process.env` would leak the caller's secrets
|
|
5
|
+
* (ANTHROPIC_API_KEY, GH_TOKEN, NPM_TOKEN, AWS_*, …) into a process the PR author
|
|
6
|
+
* controls. Children therefore get only a minimal allow-list of harmless,
|
|
7
|
+
* platform-essential variables.
|
|
8
|
+
*
|
|
9
|
+
* Escape hatch: a caller whose dev server legitimately needs app config (e.g.
|
|
10
|
+
* `DATABASE_URL`) can pass it through by naming it in `AZTRX_ENV_ALLOW`
|
|
11
|
+
* (comma-separated) — without re-opening the leak for every secret in the env.
|
|
12
|
+
*/
|
|
13
|
+
const ALLOWED = new Set([
|
|
14
|
+
"PATH",
|
|
15
|
+
"HOME",
|
|
16
|
+
"USER",
|
|
17
|
+
"USERNAME",
|
|
18
|
+
"LOGNAME",
|
|
19
|
+
"SHELL",
|
|
20
|
+
"TMP",
|
|
21
|
+
"TEMP",
|
|
22
|
+
"TMPDIR",
|
|
23
|
+
"SystemRoot",
|
|
24
|
+
"SystemDrive",
|
|
25
|
+
"windir",
|
|
26
|
+
"COMSPEC",
|
|
27
|
+
"PATHEXT",
|
|
28
|
+
"LANG",
|
|
29
|
+
"LC_ALL",
|
|
30
|
+
"LC_CTYPE",
|
|
31
|
+
"TERM",
|
|
32
|
+
"NODE_ENV",
|
|
33
|
+
"CI",
|
|
34
|
+
"PORT",
|
|
35
|
+
]);
|
|
36
|
+
/** Build a minimal child environment: harmless platform vars, anything named in
|
|
37
|
+
* `AZTRX_ENV_ALLOW`, and any caller-supplied `extra` (e.g. `{ PORT: "4317" }`). */
|
|
38
|
+
export function buildChildEnv(extra) {
|
|
39
|
+
const env = {};
|
|
40
|
+
for (const key of ALLOWED) {
|
|
41
|
+
const v = process.env[key];
|
|
42
|
+
if (v !== undefined)
|
|
43
|
+
env[key] = v;
|
|
44
|
+
}
|
|
45
|
+
const allow = process.env.AZTRX_ENV_ALLOW;
|
|
46
|
+
if (allow) {
|
|
47
|
+
for (const raw of allow.split(",")) {
|
|
48
|
+
const name = raw.trim();
|
|
49
|
+
if (!name)
|
|
50
|
+
continue;
|
|
51
|
+
const v = process.env[name];
|
|
52
|
+
if (v !== undefined)
|
|
53
|
+
env[name] = v;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (extra)
|
|
57
|
+
for (const [k, v] of Object.entries(extra))
|
|
58
|
+
env[k] = v;
|
|
59
|
+
return env;
|
|
60
|
+
}
|
package/dist/core/heal/index.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* 3. gate — re-parse the patched file; reject new imports / eval /
|
|
8
8
|
* child_process / empty catch
|
|
9
9
|
* 4. sandbox — apply the patch in a detached git worktree, never the tree
|
|
10
|
-
* 5.
|
|
11
|
-
* 6.
|
|
10
|
+
* 5. test — run the repo's own test suite; reject the patch if it goes red
|
|
11
|
+
* 6. verify — replay the repro against the patched app; the bug must be gone
|
|
12
|
+
* 7. hand off — write a unified-diff `.patch` for a human to review and commit
|
|
12
13
|
*
|
|
13
14
|
* Aztrx never commits. A patch that fails any gate, does not apply exactly, or
|
|
14
15
|
* still reproduces the bug is rejected and reported, not silently kept.
|
|
@@ -19,7 +20,8 @@ import * as path from "path";
|
|
|
19
20
|
import { redact, unredact } from "./redact.js";
|
|
20
21
|
import { auditPatch } from "./gates.js";
|
|
21
22
|
import { generatePatch, modelTiers } from "./llm.js";
|
|
22
|
-
import { applyHunks, createWorktree, diffWorktree, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
|
|
23
|
+
import { applyHunks, createWorktree, diffWorktree, runTests, typecheckWorktree, writeWorktreeFile } from "./sandbox.js";
|
|
24
|
+
import { bootServer, detectStartCommand } from "./boot.js";
|
|
23
25
|
import { verifyFix } from "./verify.js";
|
|
24
26
|
const MIME = {
|
|
25
27
|
".html": "text/html; charset=utf-8",
|
|
@@ -94,6 +96,17 @@ export async function heal(finding, opts) {
|
|
|
94
96
|
if (!finding.repro || finding.repro.verdict === "unreliable") {
|
|
95
97
|
return { ...base, error: "no deterministic repro to verify against" };
|
|
96
98
|
}
|
|
99
|
+
// Server findings (network_5xx) verify by *booting* the patched app, not static
|
|
100
|
+
// serving. That needs a start command — resolve it before paying the LLM so a
|
|
101
|
+
// missing one skips cleanly rather than after an expensive generation.
|
|
102
|
+
const isNetwork = finding.type === "network_5xx";
|
|
103
|
+
const startCommand = opts.startCommand ?? detectStartCommand(opts.repoRoot);
|
|
104
|
+
if (isNetwork && !startCommand) {
|
|
105
|
+
return {
|
|
106
|
+
...base,
|
|
107
|
+
error: "no start command for server heal (set --start-command, or add scripts.dev / scripts.start)",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
97
110
|
const filePath = loc.filePath;
|
|
98
111
|
const absPath = path.resolve(opts.repoRoot, filePath);
|
|
99
112
|
let original;
|
|
@@ -124,6 +137,7 @@ export async function heal(finding, opts) {
|
|
|
124
137
|
// The winning (or last) patch + verification, held back for the final save.
|
|
125
138
|
let savedPatch = null;
|
|
126
139
|
let savedVerification = null;
|
|
140
|
+
let savedTest = null;
|
|
127
141
|
let savedGateOk = false;
|
|
128
142
|
let last = base;
|
|
129
143
|
try {
|
|
@@ -206,14 +220,41 @@ export async function heal(finding, opts) {
|
|
|
206
220
|
};
|
|
207
221
|
continue;
|
|
208
222
|
}
|
|
209
|
-
//
|
|
210
|
-
|
|
223
|
+
// 3c. Test gate — run the repo's own test suite against the patched
|
|
224
|
+
// worktree. A patch that breaks tests is rejected before we pay for the
|
|
225
|
+
// Playwright verification, so "autonomous fixing" never regresses checks.
|
|
226
|
+
const test = opts.skipTest
|
|
227
|
+
? { ran: false, ok: true, command: "", output: "" }
|
|
228
|
+
: await runTests(wt.dir, opts.repoRoot, {
|
|
229
|
+
command: opts.testCommand,
|
|
230
|
+
timeoutMs: opts.testTimeoutMs,
|
|
231
|
+
});
|
|
232
|
+
if (test.ran)
|
|
233
|
+
savedTest = test;
|
|
234
|
+
if (test.ran && !test.ok) {
|
|
235
|
+
last = {
|
|
236
|
+
...base,
|
|
237
|
+
status: "test-failed",
|
|
238
|
+
hunks,
|
|
239
|
+
explanation: patch.explanation,
|
|
240
|
+
error: test.output.slice(0, 400) || `${test.command} failed`,
|
|
241
|
+
model: tier.model,
|
|
242
|
+
};
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
// 4. Verify — the bug must stop reproducing. Server findings boot the
|
|
246
|
+
// patched app in the worktree (static serving can't run a server); client
|
|
247
|
+
// findings keep the static server.
|
|
248
|
+
const serve = isNetwork
|
|
249
|
+
? (dir) => bootServer({ worktreeDir: dir, repoRoot: opts.repoRoot, startCommand: startCommand })
|
|
250
|
+
: opts.serve ?? ((dir, fp) => staticServe(dir, fp));
|
|
211
251
|
const v = await verifyFix({
|
|
212
252
|
url: opts.url,
|
|
213
253
|
actions: opts.actions,
|
|
214
254
|
fingerprint: opts.fingerprint,
|
|
215
255
|
runs: opts.verifyRuns ?? 3,
|
|
216
256
|
serve: () => serve(wt.dir, filePath),
|
|
257
|
+
targetType: isNetwork ? finding.type : undefined,
|
|
217
258
|
});
|
|
218
259
|
savedPatch = patch;
|
|
219
260
|
savedVerification = v;
|
|
@@ -234,7 +275,7 @@ export async function heal(finding, opts) {
|
|
|
234
275
|
if (savedPatch && savedVerification) {
|
|
235
276
|
last.patchPath = await saveArtifact(opts.repoRoot, finding, savedPatch, savedGateOk, savedVerification, wt.dir, filePath);
|
|
236
277
|
}
|
|
237
|
-
return { ...last, tiers: tiers.map((t) => t.model) };
|
|
278
|
+
return { ...last, test: savedTest ?? undefined, tiers: tiers.map((t) => t.model) };
|
|
238
279
|
}
|
|
239
280
|
finally {
|
|
240
281
|
await wt.cleanup();
|
package/dist/core/heal/redact.js
CHANGED
|
@@ -16,13 +16,29 @@ const WHOLE_MATCH = [
|
|
|
16
16
|
{ re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, label: "slack_token" },
|
|
17
17
|
{ re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, label: "jwt" },
|
|
18
18
|
{ re: /\bBearer [A-Za-z0-9._-]{20,}/g, label: "bearer_token" },
|
|
19
|
+
{ re: /\bBasic [A-Za-z0-9+/=]{10,}\b/g, label: "basic_auth" },
|
|
20
|
+
{ re: /\bAIza[0-9A-Za-z_-]{30,}\b/g, label: "google_api_key" },
|
|
21
|
+
{ re: /\b(?:sk|rk|pk)_(?:live|test)_[0-9A-Za-z]{16,}\b/g, label: "stripe_key" },
|
|
19
22
|
];
|
|
20
23
|
// Prefix-preserving: group 1 stays in place (so the code structure — the key
|
|
21
24
|
// name, the scheme+user of a URL — remains visible to the model), group 2 is
|
|
22
25
|
// the secret value that is replaced.
|
|
23
26
|
const VALUE_MATCH = [
|
|
24
27
|
{
|
|
25
|
-
|
|
28
|
+
// `key = value` where the key is (or embeds) a secret-shaped word. Boundary
|
|
29
|
+
// is "not a letter/digit" — so `_`/`-`/`.` join compound names like
|
|
30
|
+
// `aws_secret_access_key` / `FOO_ACCESS_KEY`, while a plain `secret = x`
|
|
31
|
+
// still matches. (JS `\b` treats `_` as a word char, which would miss the
|
|
32
|
+
// compound forms — hence the explicit lookarounds.)
|
|
33
|
+
re: /(["']?[A-Za-z0-9._-]*(?<![A-Za-z0-9])(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|access[_-]?token|client[_-]?secret|private[_-]?key|auth[_-]?token|credential|authorization)(?![A-Za-z0-9])[A-Za-z0-9._-]*["']?\s*[:=]\s*)(["']?(?!__AZTRX_REDACTED_)[^"'\s;,&}{=]{8,}["']?)/gi,
|
|
34
|
+
label: "secret_value",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
// CamelCase / bare form (`apiKey`, `accessToken`, `secretKey`) — the
|
|
38
|
+
// lookaround form above misses these because the keyword is glued to a
|
|
39
|
+
// letter. No boundary here, so a keyword directly before `:`/`=` matches
|
|
40
|
+
// even mid-identifier.
|
|
41
|
+
re: /(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|client[_-]?secret|private[_-]?key|auth[_-]?token|credential|authorization)["']?\s*[:=]\s*)(["']?(?!__AZTRX_REDACTED_)[^"'\s;,&}{=]{8,}["']?)/gi,
|
|
26
42
|
label: "secret_value",
|
|
27
43
|
},
|
|
28
44
|
{
|
|
@@ -61,3 +77,17 @@ export function unredact(input, map) {
|
|
|
61
77
|
out = out.split(ph).join(secret);
|
|
62
78
|
return out;
|
|
63
79
|
}
|
|
80
|
+
const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
81
|
+
/**
|
|
82
|
+
* Irreversible secret scrub for anything rendered to a human or written to a
|
|
83
|
+
* report / PR comment. Runs the same patterns as `redact()` but discards the
|
|
84
|
+
* map — every secret collapses to a fixed `[REDACTED]` token — then scrubs
|
|
85
|
+
* emails, which `redact()` deliberately leaves alone (they aren't secrets and
|
|
86
|
+
* the reversible layer must not mangle non-secret code it may need to patch).
|
|
87
|
+
*/
|
|
88
|
+
export function sanitizeSecrets(input) {
|
|
89
|
+
const { text } = redact(input);
|
|
90
|
+
return text
|
|
91
|
+
.replace(/__AZTRX_REDACTED_\d+__/g, "[REDACTED]")
|
|
92
|
+
.replace(EMAIL_RE, "[REDACTED]");
|
|
93
|
+
}
|
|
@@ -9,6 +9,7 @@ import { promisify } from "util";
|
|
|
9
9
|
import * as fs from "fs";
|
|
10
10
|
import * as os from "os";
|
|
11
11
|
import * as path from "path";
|
|
12
|
+
import { buildChildEnv } from "./childEnv.js";
|
|
12
13
|
const execFileP = promisify(execFile);
|
|
13
14
|
const preview = (s) => JSON.stringify(s.length > 60 ? s.slice(0, 57) + "…" : s);
|
|
14
15
|
/** Create a detached worktree at HEAD in a temp dir (outside the repo). */
|
|
@@ -89,7 +90,7 @@ export async function typecheckWorktree(worktreeDir, repoRoot) {
|
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
92
|
try {
|
|
92
|
-
const { stdout } = await execFileP(process.execPath, [tscBin, "--noEmit", "-p", worktreeDir], { cwd: worktreeDir, maxBuffer: 10 * 1024 * 1024 });
|
|
93
|
+
const { stdout } = await execFileP(process.execPath, [tscBin, "--noEmit", "-p", worktreeDir], { cwd: worktreeDir, maxBuffer: 10 * 1024 * 1024, env: buildChildEnv() });
|
|
93
94
|
return { ok: true, ran: true, output: stdout.trim() };
|
|
94
95
|
}
|
|
95
96
|
catch (e) {
|
|
@@ -97,3 +98,54 @@ export async function typecheckWorktree(worktreeDir, repoRoot) {
|
|
|
97
98
|
return { ok: false, ran: true, output: ((err.stdout ?? "") + (err.stderr ?? "")).trim() };
|
|
98
99
|
}
|
|
99
100
|
}
|
|
101
|
+
/** Run the repo's own test suite inside the patched worktree. Best-effort: skips
|
|
102
|
+
* (passes by omission) when there is no `test` script to run, so untested or
|
|
103
|
+
* non-JS projects are never blocked. `CI=true` is set so watch-mode runners exit
|
|
104
|
+
* instead of hanging until the timeout. */
|
|
105
|
+
export async function runTests(worktreeDir, repoRoot, opts = {}) {
|
|
106
|
+
const command = opts.command ?? "npm test";
|
|
107
|
+
// Auto-detect: without an explicit command, only run when package.json declares
|
|
108
|
+
// a `test` script — `npm test` otherwise errors "Missing script".
|
|
109
|
+
if (!opts.command) {
|
|
110
|
+
let hasTest = false;
|
|
111
|
+
try {
|
|
112
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(worktreeDir, "package.json"), "utf-8"));
|
|
113
|
+
hasTest = typeof pkg.scripts?.test === "string";
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
hasTest = false;
|
|
117
|
+
}
|
|
118
|
+
if (!hasTest)
|
|
119
|
+
return { ran: false, ok: true, command: "", output: "" };
|
|
120
|
+
}
|
|
121
|
+
// A fresh worktree has no node_modules — symlink the root's so the runner
|
|
122
|
+
// resolves (the same trick typecheckWorktree uses).
|
|
123
|
+
const rootNodeModules = path.join(repoRoot, "node_modules");
|
|
124
|
+
const wtNodeModules = path.join(worktreeDir, "node_modules");
|
|
125
|
+
if (!fs.existsSync(wtNodeModules) && fs.existsSync(rootNodeModules)) {
|
|
126
|
+
try {
|
|
127
|
+
fs.symlinkSync(rootNodeModules, wtNodeModules, process.platform === "win32" ? "junction" : "dir");
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* resolution errors surface in the run below */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const timeoutMs = opts.timeoutMs ?? 300000;
|
|
134
|
+
try {
|
|
135
|
+
const { stdout } = await execFileP(command, [], {
|
|
136
|
+
cwd: worktreeDir,
|
|
137
|
+
shell: true,
|
|
138
|
+
timeout: timeoutMs,
|
|
139
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
140
|
+
// Minimal allow-list — never hand the full `process.env` (and its
|
|
141
|
+
// ANTHROPIC_API_KEY / GH_TOKEN / AWS_* secrets) to untrusted PR test code.
|
|
142
|
+
env: buildChildEnv({ CI: "true" }),
|
|
143
|
+
});
|
|
144
|
+
return { ran: true, ok: true, command, output: stdout.trim().slice(0, 2000) };
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
const err = e;
|
|
148
|
+
const output = `${err.stdout ?? ""}\n${err.stderr ?? ""}`.trim().slice(0, 2000);
|
|
149
|
+
return { ran: true, ok: false, command, output };
|
|
150
|
+
}
|
|
151
|
+
}
|
package/dist/core/heal/verify.js
CHANGED
|
@@ -5,14 +5,43 @@
|
|
|
5
5
|
* than handing the human a lie.
|
|
6
6
|
*/
|
|
7
7
|
import { ReplayEngine } from "../replay.js";
|
|
8
|
+
/** Rewrite an absolute URL's origin to `serveUrl`'s origin, keeping path + query.
|
|
9
|
+
* The booted server runs on a fresh port (and possibly host), so a replayed
|
|
10
|
+
* request addressed to the original origin would hit the *unpatched* app. */
|
|
11
|
+
function rewriteOrigin(raw, serveUrl) {
|
|
12
|
+
try {
|
|
13
|
+
const u = new URL(raw);
|
|
14
|
+
const s = new URL(serveUrl);
|
|
15
|
+
u.protocol = s.protocol;
|
|
16
|
+
u.hostname = s.hostname;
|
|
17
|
+
u.port = s.port;
|
|
18
|
+
return u.toString();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return raw;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
8
24
|
export async function verifyFix(opts) {
|
|
9
25
|
const { url: serveUrl, close } = await opts.serve();
|
|
10
26
|
const engine = new ReplayEngine();
|
|
11
27
|
try {
|
|
12
28
|
const runs = opts.runs ?? 3;
|
|
13
29
|
let reproductions = 0;
|
|
30
|
+
const actions = opts.targetType
|
|
31
|
+
? opts.actions.map((a) => {
|
|
32
|
+
if (a.type === "request" && a.request) {
|
|
33
|
+
return { ...a, request: { ...a.request, url: rewriteOrigin(a.request.url, serveUrl) } };
|
|
34
|
+
}
|
|
35
|
+
if (a.type === "navigate" && a.value) {
|
|
36
|
+
return { ...a, value: rewriteOrigin(a.value, serveUrl) };
|
|
37
|
+
}
|
|
38
|
+
return a;
|
|
39
|
+
})
|
|
40
|
+
: opts.actions;
|
|
14
41
|
for (let i = 0; i < runs; i++) {
|
|
15
|
-
const res =
|
|
42
|
+
const res = opts.targetType
|
|
43
|
+
? await engine.run(serveUrl, actions, opts.fingerprint, { targetType: opts.targetType })
|
|
44
|
+
: await engine.run(serveUrl, actions, opts.fingerprint);
|
|
16
45
|
if (res.reproduced)
|
|
17
46
|
reproductions += 1;
|
|
18
47
|
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { network5xxMessage } from "./interceptor.js";
|
|
2
|
+
import { extractServerFrame } from "./resolver.js";
|
|
3
|
+
// Static assets carry no server-side logic worth mutating — skip them so we
|
|
4
|
+
// spend the request budget on data/API routes.
|
|
5
|
+
const STATIC_EXT = /\.(js|mjs|cjs|css|map|png|jpe?g|gif|svg|ico|webp|woff2?|ttf|otf|eot|mp4|webm)(\?|#|$)/i;
|
|
6
|
+
// Mirror the DOM deny-list (domWalker.ts) at the path level: never throw
|
|
7
|
+
// hostile requests at endpoints that mutate real state — delete, pay, logout, etc.
|
|
8
|
+
const DESTRUCTIVE_PATH = /(delete|remove|logout|sign\s?out|log\s?out|pay|checkout|purchase|buy|unsubscribe|purge|drop|truncate|confirm|update|orders|users|settings|admin|invite|grant)/i;
|
|
9
|
+
const HOSTILE_QUERY = [
|
|
10
|
+
["id", "-1"],
|
|
11
|
+
["id", "0"],
|
|
12
|
+
["id", "99999999999999999999"],
|
|
13
|
+
["q", "%00"],
|
|
14
|
+
["q", "<script>alert(1)</script>"],
|
|
15
|
+
["q", "'; DROP TABLE users;--"],
|
|
16
|
+
["limit", "-1"],
|
|
17
|
+
["page", "0"],
|
|
18
|
+
["ids", "[1,2,3]"],
|
|
19
|
+
];
|
|
20
|
+
// JSON bodies that trip naive server handlers: type confusion, malformed
|
|
21
|
+
// payloads, prototype-pollution probes, and overflow numerics.
|
|
22
|
+
const JSON_BODIES = [
|
|
23
|
+
"{}",
|
|
24
|
+
"[]",
|
|
25
|
+
"null",
|
|
26
|
+
"0",
|
|
27
|
+
"99999999999999999999",
|
|
28
|
+
'{"id":[]}',
|
|
29
|
+
'{"__proto__":{"polluted":true}}',
|
|
30
|
+
'{"a":',
|
|
31
|
+
];
|
|
32
|
+
// Hostile but valid (undici-sendable) headers: oversized values and
|
|
33
|
+
// content-type confusion are the two most likely to trip a server handler.
|
|
34
|
+
const HOSTILE_HEADERS = [
|
|
35
|
+
["x-forwarded-for", "x".repeat(8192)],
|
|
36
|
+
["content-type", "application/json"],
|
|
37
|
+
["content-type", "text/html"],
|
|
38
|
+
["accept", "application/x-www-form-urlencoded"],
|
|
39
|
+
["cookie", "a".repeat(4096)],
|
|
40
|
+
];
|
|
41
|
+
/** Read a response body as text, bounded so a huge (or gzip-bomb) 500 page can't
|
|
42
|
+
* balloon a finding. */
|
|
43
|
+
async function readBodyText(res) {
|
|
44
|
+
// Bounded so a huge (or gzip-bomb) 500 page can't balloon a finding. The first
|
|
45
|
+
// few KB is all a server stack trace / error JSON ever needs — the rest is
|
|
46
|
+
// noise that only widens the secret-leak surface if it lands in a report.
|
|
47
|
+
const MAX = 8 * 1024;
|
|
48
|
+
try {
|
|
49
|
+
const text = await res.text();
|
|
50
|
+
return text.length > MAX ? text.slice(0, MAX) + "\n…(truncated)" : text;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return "";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Best-effort server error message from a 500 body: a JSON `error`/`message`
|
|
57
|
+
* field, else the first non-empty line (HTML tags stripped). */
|
|
58
|
+
function extractServerMessage(body) {
|
|
59
|
+
const trimmed = body.trim();
|
|
60
|
+
if (!trimmed)
|
|
61
|
+
return "(empty body)";
|
|
62
|
+
try {
|
|
63
|
+
const j = JSON.parse(trimmed);
|
|
64
|
+
const msg = j?.error?.message ?? j?.message ?? j?.error ?? j?.detail;
|
|
65
|
+
if (typeof msg === "string" && msg.trim())
|
|
66
|
+
return msg.trim().slice(0, 200);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// not JSON — fall through to plain-text extraction
|
|
70
|
+
}
|
|
71
|
+
const first = trimmed.split("\n").find((l) => l.trim()) ?? trimmed;
|
|
72
|
+
const text = first.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
|
|
73
|
+
return text.slice(0, 200) || "(empty body)";
|
|
74
|
+
}
|
|
75
|
+
/** Is this host allowed under the deny-by-default policy (loopback always is)? */
|
|
76
|
+
function hostAllowed(url, allowHosts) {
|
|
77
|
+
let host;
|
|
78
|
+
try {
|
|
79
|
+
host = new URL(url).hostname.toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "::1")
|
|
85
|
+
return true;
|
|
86
|
+
for (const h of allowHosts) {
|
|
87
|
+
if (host === h || host.endsWith("." + h))
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
/** Harvest candidate endpoints the app actually uses — not blind probing. */
|
|
93
|
+
async function collectEndpoints(page, origin) {
|
|
94
|
+
const seen = new Map();
|
|
95
|
+
const push = (raw) => {
|
|
96
|
+
let u;
|
|
97
|
+
try {
|
|
98
|
+
u = new URL(raw, origin);
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (u.origin !== origin)
|
|
104
|
+
return;
|
|
105
|
+
if (STATIC_EXT.test(u.pathname))
|
|
106
|
+
return;
|
|
107
|
+
if (DESTRUCTIVE_PATH.test(u.pathname))
|
|
108
|
+
return;
|
|
109
|
+
if (!seen.has(u.pathname))
|
|
110
|
+
seen.set(u.pathname, u);
|
|
111
|
+
};
|
|
112
|
+
// URLs the page already fetched (API calls, RSC/data endpoints).
|
|
113
|
+
const resources = await page
|
|
114
|
+
.evaluate(() => performance.getEntriesByType("resource").map((e) => e.name))
|
|
115
|
+
.catch(() => []);
|
|
116
|
+
for (const r of resources)
|
|
117
|
+
push(r);
|
|
118
|
+
// Links and form actions in the DOM.
|
|
119
|
+
const domUrls = await page
|
|
120
|
+
.evaluate(() => {
|
|
121
|
+
const out = [];
|
|
122
|
+
document.querySelectorAll("a[href], form[action]").forEach((el) => {
|
|
123
|
+
const v = el.getAttribute("href") ?? el.getAttribute("action");
|
|
124
|
+
if (v)
|
|
125
|
+
out.push(v);
|
|
126
|
+
});
|
|
127
|
+
return out;
|
|
128
|
+
})
|
|
129
|
+
.catch(() => []);
|
|
130
|
+
for (const u of domUrls)
|
|
131
|
+
push(u);
|
|
132
|
+
push(origin + "/");
|
|
133
|
+
return [...seen.values()];
|
|
134
|
+
}
|
|
135
|
+
/** Expand one endpoint into a bounded set of hostile requests. Deterministic. */
|
|
136
|
+
function buildRequests(endpoint, opts = {}) {
|
|
137
|
+
const reqs = [];
|
|
138
|
+
const base = endpoint.toString();
|
|
139
|
+
// Query mutations — rewrite existing params, or append hostile ones.
|
|
140
|
+
if (endpoint.search) {
|
|
141
|
+
const keys = [...endpoint.searchParams.keys()].slice(0, 2);
|
|
142
|
+
for (const key of keys) {
|
|
143
|
+
for (const val of ["-1", "0", "99999999999999999999", "%00"]) {
|
|
144
|
+
const u = new URL(base);
|
|
145
|
+
u.searchParams.set(key, val);
|
|
146
|
+
reqs.push({ method: "GET", url: u.toString() });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
for (const [key, val] of HOSTILE_QUERY) {
|
|
152
|
+
const u = new URL(base);
|
|
153
|
+
u.searchParams.set(key, val);
|
|
154
|
+
reqs.push({ method: "GET", url: u.toString() });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Body mutations via method confusion (POST/PUT, never DELETE). Opt-in: these
|
|
158
|
+
// mutate server state, so they're off by default (GET-only).
|
|
159
|
+
if (opts.mutations) {
|
|
160
|
+
for (const body of JSON_BODIES) {
|
|
161
|
+
reqs.push({
|
|
162
|
+
method: "POST",
|
|
163
|
+
url: base,
|
|
164
|
+
headers: { "content-type": "application/json" },
|
|
165
|
+
body,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
reqs.push({
|
|
169
|
+
method: "PUT",
|
|
170
|
+
url: base,
|
|
171
|
+
headers: { "content-type": "application/json" },
|
|
172
|
+
body: "{}",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
// Header injection on a plain GET.
|
|
176
|
+
for (const [name, value] of HOSTILE_HEADERS) {
|
|
177
|
+
reqs.push({ method: "GET", url: base, headers: { [name]: value } });
|
|
178
|
+
}
|
|
179
|
+
return reqs;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* F5-http — server-side mutation fuzzer. Harvests the endpoints the app really
|
|
183
|
+
* uses, then throws seeded hostile requests at them (query overflow, JSON type
|
|
184
|
+
* confusion, header injection, method confusion). A response `status >= 500`
|
|
185
|
+
* becomes a `network_5xx` finding — same type and message format the interceptor
|
|
186
|
+
* emits for in-browser requests — so it flows through the existing
|
|
187
|
+
* ddmin → spec → validate → heal pipeline unchanged.
|
|
188
|
+
*/
|
|
189
|
+
export async function httpFuzz(page, targetUrl, bus, opts = {}) {
|
|
190
|
+
const max = opts.maxRequests ?? 100;
|
|
191
|
+
const allowHosts = opts.allowHosts ?? new Set();
|
|
192
|
+
if (!hostAllowed(targetUrl, allowHosts))
|
|
193
|
+
return 0;
|
|
194
|
+
if (!opts.dryRun) {
|
|
195
|
+
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 15000 }).catch(() => { });
|
|
196
|
+
await page.waitForTimeout(500);
|
|
197
|
+
}
|
|
198
|
+
const endpoints = await collectEndpoints(page, new URL(targetUrl).origin);
|
|
199
|
+
endpoints.sort((a, b) => a.pathname.localeCompare(b.pathname));
|
|
200
|
+
let sent = 0;
|
|
201
|
+
outer: for (const endpoint of endpoints) {
|
|
202
|
+
for (const req of buildRequests(endpoint, { mutations: opts.mutations })) {
|
|
203
|
+
if (sent >= max)
|
|
204
|
+
break outer;
|
|
205
|
+
const { pathname, search } = new URL(req.url);
|
|
206
|
+
const label = `${req.method} ${pathname}${search}`;
|
|
207
|
+
bus.emit("action", {
|
|
208
|
+
type: "request",
|
|
209
|
+
selectors: [],
|
|
210
|
+
value: label,
|
|
211
|
+
request: req,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
if (opts.dryRun) {
|
|
215
|
+
sent++;
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let status = 0;
|
|
219
|
+
let bodyText = "";
|
|
220
|
+
const ctrl = new AbortController();
|
|
221
|
+
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
222
|
+
try {
|
|
223
|
+
const res = await fetch(req.url, {
|
|
224
|
+
method: req.method,
|
|
225
|
+
headers: req.headers,
|
|
226
|
+
body: req.body,
|
|
227
|
+
signal: ctrl.signal,
|
|
228
|
+
});
|
|
229
|
+
status = res.status;
|
|
230
|
+
if (status >= 500)
|
|
231
|
+
bodyText = await readBodyText(res);
|
|
232
|
+
else
|
|
233
|
+
await res.arrayBuffer().catch(() => { });
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
// network error or timeout — hang detection is a follow-up slice
|
|
237
|
+
status = 0;
|
|
238
|
+
}
|
|
239
|
+
finally {
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
}
|
|
242
|
+
if (status >= 500) {
|
|
243
|
+
bus.emit("telemetry", {
|
|
244
|
+
type: "network_5xx",
|
|
245
|
+
rawMessage: network5xxMessage(status, req.url),
|
|
246
|
+
rawStack: "",
|
|
247
|
+
serverError: {
|
|
248
|
+
message: extractServerMessage(bodyText),
|
|
249
|
+
body: bodyText,
|
|
250
|
+
frame: extractServerFrame(bodyText) ?? undefined,
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
sent++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return sent;
|
|
258
|
+
}
|
package/dist/core/init.js
CHANGED
|
@@ -37,7 +37,7 @@ function defaultPort(framework) {
|
|
|
37
37
|
return 3000;
|
|
38
38
|
}
|
|
39
39
|
function configTemplate(framework, url) {
|
|
40
|
-
return `// aztrx.config.ts — generated by \`aztrx init\` (framework: ${framework})
|
|
40
|
+
return `// aztrx.config.ts — generated by \`aztrx-cli init\` (framework: ${framework})
|
|
41
41
|
// Docs: https://aztrx.app/docs/config
|
|
42
42
|
|
|
43
43
|
export default {
|
|
@@ -55,7 +55,7 @@ export default {
|
|
|
55
55
|
};
|
|
56
56
|
`;
|
|
57
57
|
}
|
|
58
|
-
/** `aztrx init` — detect framework + port, scaffold aztrx.config.ts, seed .gitignore. */
|
|
58
|
+
/** `aztrx-cli init` — detect framework + port, scaffold aztrx.config.ts, seed .gitignore. */
|
|
59
59
|
export async function initProject(opts) {
|
|
60
60
|
const repoRoot = path.resolve(opts.repoRoot);
|
|
61
61
|
const framework = opts.framework ?? detectFramework(repoRoot);
|
package/dist/core/interceptor.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import { extractFrame } from "./resolver.js";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical 5xx finding message. Shared by the interceptor (passive in-browser
|
|
4
|
+
* capture) and the HTTP mutation fuzzer (active Node-side capture) so the two
|
|
5
|
+
* produce byte-identical `rawMessage`s — otherwise a finding's fingerprint and
|
|
6
|
+
* its replay fingerprint would diverge and repro would never match.
|
|
7
|
+
*/
|
|
8
|
+
export function network5xxMessage(status, url) {
|
|
9
|
+
return `HTTP ${status} ${url}`;
|
|
10
|
+
}
|
|
2
11
|
// Route unhandled rejections through console.error so a single capture path
|
|
3
12
|
// handles them alongside React Error Boundary logs (both land in console.error).
|
|
4
13
|
const INIT_SCRIPT = `
|
|
@@ -84,7 +93,7 @@ export function attachInterceptor(page, bus) {
|
|
|
84
93
|
if (res.status() >= 500) {
|
|
85
94
|
bus.emit("telemetry", {
|
|
86
95
|
type: "network_5xx",
|
|
87
|
-
rawMessage:
|
|
96
|
+
rawMessage: network5xxMessage(res.status(), res.url()),
|
|
88
97
|
rawStack: "",
|
|
89
98
|
});
|
|
90
99
|
}
|