aztrx-cli 0.1.0 → 0.1.1
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 +130 -24
- package/dist/cli.js +26 -5
- package/dist/core/badge.js +50 -0
- package/dist/core/fuzzer.js +1 -1
- 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/orchestrator.js +41 -3
- package/dist/core/pr.js +44 -20
- 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/ui.js +2 -0
- package/dist/ui/app.js +3 -2
- package/media/demo.gif +0 -0
- package/media/logo.svg +9 -0
- package/package.json +3 -3
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
|
}
|
|
@@ -7,8 +7,9 @@ import { SignalClassifier, loadBaseline } from "./classifier.js";
|
|
|
7
7
|
import { ActionRecorder } from "./recorder.js";
|
|
8
8
|
import { walkDom } from "./domWalker.js";
|
|
9
9
|
import { fuzz } from "./fuzzer.js";
|
|
10
|
+
import { httpFuzz } from "./httpFuzzer.js";
|
|
10
11
|
import { attachNetworkGuard, allowHostsFrom } from "./networkGuard.js";
|
|
11
|
-
import { resolveFrame } from "./resolver.js";
|
|
12
|
+
import { resolveFrame, resolveServerFrame } from "./resolver.js";
|
|
12
13
|
import { ReplayEngine } from "./replay.js";
|
|
13
14
|
import { minimize } from "./minimizer.js";
|
|
14
15
|
import { writeSpec } from "./specCompiler.js";
|
|
@@ -30,6 +31,9 @@ function printFinding(f, write) {
|
|
|
30
31
|
write(pc.dim(` ${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}`));
|
|
31
32
|
write(pc.dim(f.mappedLocation.codeContext));
|
|
32
33
|
}
|
|
34
|
+
if (f.serverError) {
|
|
35
|
+
write(pc.dim(` server: ${f.serverError.message}`));
|
|
36
|
+
}
|
|
33
37
|
if (f.occurrences > 1)
|
|
34
38
|
write(pc.dim(` (×${f.occurrences})`));
|
|
35
39
|
write("");
|
|
@@ -38,6 +42,8 @@ function printFinding(f, write) {
|
|
|
38
42
|
function runMode(o) {
|
|
39
43
|
if (o.fuzz)
|
|
40
44
|
return `fuzz (seed ${o.seed ?? 42})`;
|
|
45
|
+
if (o.httpFuzz)
|
|
46
|
+
return "http fuzz";
|
|
41
47
|
if (o.heal)
|
|
42
48
|
return "repro → heal";
|
|
43
49
|
if (o.repro)
|
|
@@ -64,7 +70,7 @@ export async function run(options) {
|
|
|
64
70
|
console.log(parts.join(" "));
|
|
65
71
|
};
|
|
66
72
|
const emitPhase = (phase, detail) => bus.emit("phase", { phase, detail, ts: Date.now() });
|
|
67
|
-
say(pc.cyan("\
|
|
73
|
+
say(pc.cyan("\nAztrx AI v0.1.1 — Runtime Detector"));
|
|
68
74
|
say(pc.dim(`Target: ${url}`));
|
|
69
75
|
say(pc.dim(`Repo: ${repoRoot}`));
|
|
70
76
|
if (options.fuzz)
|
|
@@ -93,6 +99,9 @@ export async function run(options) {
|
|
|
93
99
|
return;
|
|
94
100
|
}
|
|
95
101
|
runLog.append({ type: "finding", finding });
|
|
102
|
+
if (payload.serverError) {
|
|
103
|
+
finding.serverError = { message: payload.serverError.message, body: payload.serverError.body };
|
|
104
|
+
}
|
|
96
105
|
if (payload.url && payload.line) {
|
|
97
106
|
const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, repoRoot);
|
|
98
107
|
finding.mappedLocation = {
|
|
@@ -103,6 +112,16 @@ export async function run(options) {
|
|
|
103
112
|
isOwnCode: resolved.resolvedFrom !== "unresolved",
|
|
104
113
|
};
|
|
105
114
|
}
|
|
115
|
+
else if (payload.serverError?.frame) {
|
|
116
|
+
const resolved = resolveServerFrame(payload.serverError.frame, repoRoot);
|
|
117
|
+
finding.mappedLocation = {
|
|
118
|
+
filePath: resolved.sourceFile,
|
|
119
|
+
line: resolved.line,
|
|
120
|
+
column: resolved.column,
|
|
121
|
+
codeContext: resolved.codeSnippet,
|
|
122
|
+
isOwnCode: resolved.resolvedFrom !== "unresolved",
|
|
123
|
+
};
|
|
124
|
+
}
|
|
106
125
|
bus.emit("finding", finding);
|
|
107
126
|
printFinding(finding, say);
|
|
108
127
|
});
|
|
@@ -148,6 +167,16 @@ export async function run(options) {
|
|
|
148
167
|
: await walkDom(page, bus, { maxActions, dryRun: options.dryRun });
|
|
149
168
|
say(pc.dim(`\n${options.fuzz ? "Fuzzed" : "Walked"} ${acted} action(s).\n`));
|
|
150
169
|
}
|
|
170
|
+
if (loaded && options.httpFuzz) {
|
|
171
|
+
emitPhase("http-fuzz");
|
|
172
|
+
const sent = await httpFuzz(page, url, bus, {
|
|
173
|
+
maxRequests: maxActions,
|
|
174
|
+
dryRun: options.dryRun,
|
|
175
|
+
allowHosts,
|
|
176
|
+
mutations: options.httpFuzzMutations,
|
|
177
|
+
});
|
|
178
|
+
say(pc.dim(`\nHTTP-fuzzed ${sent} request(s).\n`));
|
|
179
|
+
}
|
|
151
180
|
await page.waitForTimeout(500);
|
|
152
181
|
await browser.close();
|
|
153
182
|
const findings = classifier.findings();
|
|
@@ -240,11 +269,16 @@ export async function run(options) {
|
|
|
240
269
|
// the patch is only ever handed to a human for review, never committed.
|
|
241
270
|
if (options.heal) {
|
|
242
271
|
const healTargets = findings.filter((f) => (f.severity === "crash" || f.severity === "error") &&
|
|
272
|
+
// A `network_5xx` finding heals only when its 500 body leaked a server
|
|
273
|
+
// stack (→ an own-code source location); heal then boots the patched app
|
|
274
|
+
// to verify. `network_timeout` stays excluded — it needs a time-based
|
|
275
|
+
// repro first.
|
|
276
|
+
f.type !== "network_timeout" &&
|
|
243
277
|
f.mappedLocation?.isOwnCode &&
|
|
244
278
|
f.repro &&
|
|
245
279
|
f.repro.verdict !== "unreliable");
|
|
246
280
|
if (healTargets.length) {
|
|
247
|
-
say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → verify) —"));
|
|
281
|
+
say(pc.cyan("— Closed-loop healing (redact → generate → gate → sandbox → test → verify) —"));
|
|
248
282
|
emitPhase("heal");
|
|
249
283
|
for (const f of healTargets) {
|
|
250
284
|
say(pc.dim(` healing: ${f.rawMessage.split("\n")[0].slice(0, 60)}`));
|
|
@@ -257,6 +291,10 @@ export async function run(options) {
|
|
|
257
291
|
allowHosts: [...allowHosts],
|
|
258
292
|
model: options.healModel,
|
|
259
293
|
fastModel: options.healFastModel,
|
|
294
|
+
testCommand: options.testCommand,
|
|
295
|
+
testTimeoutMs: options.testTimeoutMs,
|
|
296
|
+
skipTest: options.skipTest,
|
|
297
|
+
startCommand: options.startCommand,
|
|
260
298
|
});
|
|
261
299
|
f.heal = result;
|
|
262
300
|
bus.emit("heal", {
|