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.
@@ -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. verify replay the repro against the patched app; the bug must be gone
11
- * 6. hand off write a unified-diff `.patch` for a human to review and commit
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
- // 4. Verify — the bug must stop reproducing.
210
- const serve = opts.serve ?? ((dir, fp) => staticServe(dir, fp));
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();
@@ -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
- re: /(["']?(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?token|client[_-]?secret|private[_-]?key|auth[_-]?token)["']?\s*[:=]\s*)(["']?[^"'\s;,&}{=]{8,}["']?)/gi,
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
+ }
@@ -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 = await engine.run(serveUrl, opts.actions, opts.fingerprint);
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);
@@ -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: `HTTP ${res.status()} ${res.url()}`,
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("\n⚡ Aztrx v0.1.0 — Runtime Detector"));
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", {