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/dist/core/pr.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
+ import { sanitizeSecrets } from "./heal/redact.js";
3
4
  /**
4
5
  * F-report β€” the PR bot's markdown comment. Same findings as the HTML report,
5
6
  * reshaped for a GitHub PR: a status badge, one `<details>` per finding, the
@@ -14,12 +15,6 @@ const SEV_BADGE = {
14
15
  warning: "f5a623",
15
16
  noise: "5b6573",
16
17
  };
17
- const SEV_ICON = {
18
- crash: "πŸ’₯",
19
- error: "🚨",
20
- warning: "⚠️",
21
- noise: "Β·",
22
- };
23
18
  /** Shields.io badge-path escaping: literal `-` β†’ `--`, `/` β†’ `%2F`, space β†’ `_`. */
24
19
  function shield(s) {
25
20
  return s.replace(/-/g, "--").replace(/\//g, "%2F").replace(/ /g, "_");
@@ -30,6 +25,28 @@ function badge(label, value, color) {
30
25
  function escapeHtml(s) {
31
26
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
32
27
  }
28
+ /** Wrap `code` in a backtick fence one longer than any run inside it, so the
29
+ * content can never close the fence (blocks markdown breakout from a hostile
30
+ * sourcemap snippet or patch). */
31
+ function fence(code, lang = "") {
32
+ const runs = code.match(/`+/g) ?? [];
33
+ const maxRun = runs.reduce((m, r) => Math.max(m, r.length), 0);
34
+ const delim = "`".repeat(Math.max(3, maxRun + 1));
35
+ return `${delim}${lang}\n${code.trimEnd()}\n${delim}`;
36
+ }
37
+ /** Inline code that can't be broken out of β€” backticks/newlines are stripped. */
38
+ function inlineCode(s) {
39
+ return "`" + s.replace(/`/g, "").replace(/[\r\n]/g, " ") + "`";
40
+ }
41
+ /** Scrub secrets and strip markdown-breakout chars for anything inlined into a
42
+ * heading or inline-code span. The PR comment renders untrusted stack/snippet
43
+ * text β€” a secret in it (or a stray backtick) must never leak or break out. */
44
+ function cleanInline(s) {
45
+ return sanitizeSecrets(s).replace(/`/g, "").replace(/[\r\n]/g, " ");
46
+ }
47
+ /** Hard cap on the server body inlined into a PR comment β€” a 500 page can be
48
+ * huge, and only the first line or two are ever diagnostic. */
49
+ const SERVER_BODY_CAP = 2000;
33
50
  function readIfExists(p) {
34
51
  try {
35
52
  return fs.readFileSync(p, "utf-8");
@@ -39,8 +56,8 @@ function readIfExists(p) {
39
56
  }
40
57
  }
41
58
  function describeAction(a) {
42
- const sel = a.selectors[0] ? ` \`${a.selectors[0]}\`` : "";
43
- const val = a.value ? ` \`${a.value}\`` : "";
59
+ const sel = a.selectors[0] ? ` ${inlineCode(sanitizeSecrets(a.selectors[0]))}` : "";
60
+ const val = a.value ? ` ${inlineCode(sanitizeSecrets(a.value))}` : "";
44
61
  return `**${a.type}**${val}${sel}`;
45
62
  }
46
63
  function healBlock(f) {
@@ -52,6 +69,7 @@ function healBlock(f) {
52
69
  unfixed: { text: "unfixed", color: "f5a623" },
53
70
  rejected: { text: "rejected", color: "ff5a5f" },
54
71
  "compile-failed": { text: "compile-failed", color: "ff5a5f" },
72
+ "test-failed": { text: "test-failed", color: "ff5a5f" },
55
73
  "apply-failed": { text: "apply-failed", color: "ff5a5f" },
56
74
  skipped: { text: "skipped", color: "5b6573" },
57
75
  "no-llm": { text: "no-llm", color: "5b6573" },
@@ -63,13 +81,14 @@ function healBlock(f) {
63
81
  // The saved unified diff is only produced for a patch that reached verify.
64
82
  const diff = h.patchPath ? readIfExists(path.resolve(h.patchPath)) : null;
65
83
  if (diff) {
66
- body.push("```diff");
67
- body.push(diff.trimEnd());
68
- body.push("```");
84
+ body.push(fence(diff, "diff"));
69
85
  }
70
86
  else if (h.explanation) {
71
87
  body.push(`> ${h.explanation}`);
72
88
  }
89
+ if (h.test?.ran) {
90
+ body.push(`**tests** ${inlineCode(h.test.command)} β€” ${h.test.ok ? "passed" : "failed"}`);
91
+ }
73
92
  if (h.error)
74
93
  body.push(`\n_${escapeHtml(h.error)}_`);
75
94
  return `\n<details>\n<summary>${badge("heal", meta.text, meta.color)} proposed patch${via}${tiers}</summary>\n\n${body.join("\n")}\n</details>`;
@@ -83,20 +102,25 @@ function reproBlock(f) {
83
102
  ? `\n${r.actions.map((a, i) => `${i + 1}. ${describeAction(a)}`).join("\n")}`
84
103
  : "";
85
104
  const spec = readIfExists(path.resolve(r.specPath));
86
- const specBlock = spec ? `\n\`\`\`ts\n${spec.trimEnd()}\n\`\`\`` : "";
105
+ const specBlock = spec ? `\n${fence(spec, "ts")}` : "";
87
106
  return `\n<details>\n<summary>β–Ά ${verdict} Β· ${r.actions.length} step(s)</summary>\n\n**Reproduce**${steps}${specBlock}\n</details>`;
88
107
  }
89
108
  function findingBlock(f) {
90
109
  const sev = f.severity;
91
- const icon = SEV_ICON[sev] ?? "Β·";
92
- const first = f.rawMessage.split("\n")[0];
110
+ const first = sanitizeSecrets(f.rawMessage.split("\n")[0]);
93
111
  const loc = f.mappedLocation
94
- ? `\n**Location** \`${f.mappedLocation.filePath}:${f.mappedLocation.line}:${f.mappedLocation.column}\``
112
+ ? `\n**Location** \`${cleanInline(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}\``
95
113
  : "";
96
114
  const snippet = f.mappedLocation?.codeContext
97
- ? `\n\n\`\`\`${path.extname(f.mappedLocation.filePath).replace(".", "") || "ts"}\n${f.mappedLocation.codeContext.trimEnd()}\n\`\`\``
115
+ ? `\n\n${fence(sanitizeSecrets(f.mappedLocation.codeContext), path.extname(f.mappedLocation.filePath).replace(".", "") || "ts")}`
116
+ : "";
117
+ const serverErr = f.serverError
118
+ ? `\n**Server** ${escapeHtml(sanitizeSecrets(f.serverError.message))}` +
119
+ (f.serverError.body
120
+ ? `\n\n${fence(sanitizeSecrets(f.serverError.body.slice(0, SERVER_BODY_CAP)), "text")}`
121
+ : "")
98
122
  : "";
99
- return `<details open>\n<summary>${icon} <code>${escapeHtml(sev)}</code> β€” ${escapeHtml(first)}</summary>\n${loc}${snippet}${reproBlock(f)}${healBlock(f)}\n</details>`;
123
+ return `<details open>\n<summary><code>${escapeHtml(sev)}</code> β€” ${escapeHtml(first)}</summary>\n${loc}${snippet}${serverErr}${reproBlock(f)}${healBlock(f)}\n</details>`;
100
124
  }
101
125
  export function renderPrComment(targetUrl, findings, opts = {}) {
102
126
  void opts;
@@ -120,13 +144,13 @@ export function renderPrComment(targetUrl, findings, opts = {}) {
120
144
  .join(" ");
121
145
  const body = sorted.length
122
146
  ? sorted.map(findingBlock).join("\n\n")
123
- : "> βœ… No crash, error, or warning surfaced β€” the app survived this pass.";
147
+ : "> No crash, error, or warning surfaced β€” the app survived this pass.";
124
148
  return `<!-- aztrx -->
125
- ## ⚑ Aztrx β€” runtime stress-test
149
+ ## Aztrx AI β€” runtime stress-test
126
150
 
127
151
  ${summaryBadges}
128
152
 
129
- **Target** \`${targetUrl}\` Β· **${counts.crash ?? 0} crash** Β· **${counts.error ?? 0} error** Β· **${counts.warning ?? 0} warning**
153
+ **Target** \`${cleanInline(targetUrl)}\` Β· **${counts.crash ?? 0} crash** Β· **${counts.error ?? 0} error** Β· **${counts.warning ?? 0} warning**
130
154
 
131
155
  ---
132
156
 
@@ -26,6 +26,22 @@ export async function replayActions(page, actions) {
26
26
  await page.waitForTimeout(30);
27
27
  continue;
28
28
  }
29
+ if (a.type === "request" && a.request) {
30
+ // Issue the request in-page so the attached interceptor sees the response
31
+ // and emits `network_5xx` β€” that is how a server finding reproduces here.
32
+ await page
33
+ .evaluate(async (r) => {
34
+ try {
35
+ await fetch(r.url, { method: r.method, headers: r.headers, body: r.body });
36
+ }
37
+ catch {
38
+ // ignore β€” the 5xx (or its absence) is observed by the interceptor
39
+ }
40
+ }, a.request)
41
+ .catch(() => { });
42
+ await page.waitForTimeout(50);
43
+ continue;
44
+ }
29
45
  for (const sel of a.selectors) {
30
46
  const loc = page.locator(sel).first();
31
47
  const n = await loc.count().catch(() => 0);
@@ -66,7 +82,7 @@ export class ReplayEngine {
66
82
  this.browser = await chromium.launch({ headless: true });
67
83
  return this.browser;
68
84
  }
69
- async run(url, actions, targetFingerprint) {
85
+ async run(url, actions, targetFingerprint, opts) {
70
86
  // The browser is reused across replays for speed, but after enough page
71
87
  // loads a renderer can crash. Relaunch once and retry so a single crash
72
88
  // doesn't take down the whole repro pipeline.
@@ -80,7 +96,18 @@ export class ReplayEngine {
80
96
  page = await context.newPage();
81
97
  const bus = new EventBus();
82
98
  const fingerprints = new Set();
83
- bus.on("telemetry", (p) => fingerprints.add(fingerprintOf(p)));
99
+ const types = new Set();
100
+ // For type-based verification (server findings), ignore telemetry from the
101
+ // initial load + settle window β€” only the replayed requests count. Client
102
+ // verification stays fingerprint-exact and keeps collecting from page
103
+ // attach, so a mount-time client bug still verifies.
104
+ let collecting = !opts?.targetType;
105
+ bus.on("telemetry", (p) => {
106
+ if (!collecting)
107
+ return;
108
+ fingerprints.add(fingerprintOf(p));
109
+ types.add(p.type);
110
+ });
84
111
  attachInterceptor(page, bus);
85
112
  if (this.opts.attachGuard)
86
113
  await this.opts.attachGuard(page);
@@ -89,9 +116,14 @@ export class ReplayEngine {
89
116
  // `load` event plus a settle window, and a replay that clicks before React
90
117
  // attaches its handlers won't reproduce the crash (false "unreliable").
91
118
  await page.waitForTimeout(2000);
119
+ if (opts?.targetType)
120
+ collecting = true;
92
121
  await replayActions(page, actions);
93
122
  await page.waitForTimeout(300);
94
- return { reproduced: fingerprints.has(targetFingerprint) };
123
+ const reproduced = opts?.targetType
124
+ ? types.has(opts.targetType)
125
+ : fingerprints.has(targetFingerprint);
126
+ return { reproduced };
95
127
  }
96
128
  catch (e) {
97
129
  lastError = e;
@@ -1,9 +1,15 @@
1
1
  import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { BASE_CSS, SEVERITY_COLOR, seismograph } from "./ui.js";
4
+ import { sanitizeSecrets } from "./heal/redact.js";
4
5
  function escapeHtml(s) {
5
6
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6
7
  }
8
+ /** HTML-escape AND scrub secrets β€” the report renders untrusted stack/snippet
9
+ * text, so a secret that survives into it must not reach the file. */
10
+ function clean(s) {
11
+ return escapeHtml(sanitizeSecrets(s));
12
+ }
7
13
  const SEV_ORDER = ["crash", "error", "warning", "noise"];
8
14
  /**
9
15
  * F-report β€” standalone offline HTML report. Self-contained (inline CSS, no
@@ -19,17 +25,23 @@ export function renderReport(targetUrl, findings) {
19
25
  const cards = sorted
20
26
  .map((f) => {
21
27
  const loc = f.mappedLocation
22
- ? `${escapeHtml(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
28
+ ? `${clean(f.mappedLocation.filePath)}:${f.mappedLocation.line}:${f.mappedLocation.column}`
29
+ : "";
30
+ const snippet = f.mappedLocation ? clean(f.mappedLocation.codeContext) : "";
31
+ const serverErr = f.serverError
32
+ ? `<div class="server">server: ${clean(f.serverError.message)}</div>` +
33
+ (f.serverError.body
34
+ ? `<pre class="server-body">${clean(f.serverError.body)}</pre>`
35
+ : "")
23
36
  : "";
24
- const snippet = f.mappedLocation ? escapeHtml(f.mappedLocation.codeContext) : "";
25
37
  const repro = f.repro
26
38
  ? `<div class="repro ${f.repro.verdict}">${f.repro.verdict} Β· ${f.repro.reproductions}/${f.repro.runs} runs Β· ${f.repro.actions.length} step(s) Β· <code>${escapeHtml(path.basename(f.repro.specPath))}</code></div>`
27
39
  : "";
28
40
  const steps = f.actionHistory.length
29
41
  ? `<details><summary>action history (${f.actionHistory.length})</summary><ol>${f.actionHistory
30
42
  .map((a) => {
31
- const detail = a.value ? ` <span>${escapeHtml(a.value)}</span>` : "";
32
- const sel = a.selectors[0] ? ` ${escapeHtml(a.selectors[0])}` : "";
43
+ const detail = a.value ? ` <span>${clean(a.value)}</span>` : "";
44
+ const sel = a.selectors[0] ? ` ${clean(a.selectors[0])}` : "";
33
45
  return `<li><code>${escapeHtml(a.type)}${detail}</code>${sel}</li>`;
34
46
  })
35
47
  .join("")}</ol></details>`
@@ -38,10 +50,11 @@ export function renderReport(targetUrl, findings) {
38
50
  <article class="finding">
39
51
  <header>
40
52
  <span class="sev" style="--sev:${SEVERITY_COLOR[f.severity]}">${escapeHtml(f.severity)}</span>
41
- <h2>${escapeHtml(f.rawMessage.split("\n")[0])}</h2>
53
+ <h2>${clean(f.rawMessage.split("\n")[0])}</h2>
42
54
  </header>
43
55
  ${loc ? `<div class="loc">${loc}</div>` : ""}
44
56
  ${snippet ? `<pre class="snippet">${snippet}</pre>` : ""}
57
+ ${serverErr}
45
58
  ${f.occurrences > 1 ? `<div class="occ">seen Γ—${f.occurrences}</div>` : ""}
46
59
  ${repro}
47
60
  ${steps}
@@ -63,7 +76,7 @@ export function renderReport(targetUrl, findings) {
63
76
  <div class="brand-row">
64
77
  <h1><span class="brand">aztrx</span> <span class="brand-sub">report</span></h1>
65
78
  </div>
66
- <div class="target">${escapeHtml(targetUrl)}</div>
79
+ <div class="target">${clean(targetUrl)}</div>
67
80
  </div>
68
81
  <div class="bar">
69
82
  <span class="count">crash <b class="crash">${counts.crash ?? 0}</b></span>
@@ -16,6 +16,35 @@ export function extractFrame(text) {
16
16
  message: text.split("\n")[0].trim().slice(0, 200),
17
17
  };
18
18
  }
19
+ /** True if `p` looks like an absolute source path β€” a `file://` URL, a POSIX
20
+ * absolute path, or a Windows drive path. Rejects bare relative tokens like
21
+ * `route.ts` so a stray `foo:12:34` in a stack body is never mistaken for a file. */
22
+ function isServerPath(p) {
23
+ return p.startsWith("file://") || p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p);
24
+ }
25
+ /**
26
+ * Pull the first server-side source frame out of a raw server stack (a 500 body,
27
+ * a Next.js dev error page, etc.). V8 emits one frame per line as
28
+ * `at <fn> (<path>:<line>:<col>)` or `at <path>:<line>:<col>`; we take the first
29
+ * frame whose path is not inside node_modules. Best-effort β€” returns null when
30
+ * the body carries no stack trace (e.g. an explicit
31
+ * `NextResponse.json(..., { status: 500 })`).
32
+ */
33
+ export function extractServerFrame(stack) {
34
+ for (const raw of stack.split("\n")) {
35
+ const line = raw.trim();
36
+ const m = line.match(/\(?([^\s()"']+):(\d+):(\d+)\)?$/);
37
+ if (!m)
38
+ continue;
39
+ const filePath = m[1];
40
+ if (!isServerPath(filePath))
41
+ continue;
42
+ if (filePath.includes("node_modules"))
43
+ continue;
44
+ return { filePath, line: parseInt(m[2], 10), column: parseInt(m[3], 10) };
45
+ }
46
+ return null;
47
+ }
19
48
  function stripQuery(url) {
20
49
  return url.split("?")[0];
21
50
  }
@@ -29,6 +58,55 @@ function isFile(p) {
29
58
  return false;
30
59
  }
31
60
  }
61
+ /** Secret-bearing filenames that must never be read, even inside the repo β€” a
62
+ * hostile sourcemap could otherwise point `source` at `.env`, an npmrc, or a
63
+ * private key and exfiltrate it into the report / PR comment. */
64
+ function isSensitive(p) {
65
+ const name = path.basename(p).toLowerCase();
66
+ // Dotfiles that hold secrets.
67
+ if (name === ".env" ||
68
+ name.startsWith(".env.") ||
69
+ name === ".npmrc" ||
70
+ name === ".yarnrc" ||
71
+ name === ".netrc" ||
72
+ name === ".htpasswd" ||
73
+ name === ".git-credentials") {
74
+ return true;
75
+ }
76
+ // SSH / private keys.
77
+ if (/^id_(rsa|ed25519|ecdsa|dsa)(\..*)?$/.test(name))
78
+ return true;
79
+ // Certificate and keystore material.
80
+ if (/\.(pem|key|p12|pfx|jks|keystore|p8)$/.test(name))
81
+ return true;
82
+ // Names that advertise secrets.
83
+ if (/(credential|secret|service[-_]?account|private[-_]?key)/.test(name))
84
+ return true;
85
+ return false;
86
+ }
87
+ /** Resolve `segments` under `root`, returning null if the result escapes the
88
+ * root β€” via `..` traversal or a symlink pointing outside it. This is the
89
+ * boundary that keeps sourcemap- and URL-derived paths from reading (or,
90
+ * downstream, healing) arbitrary files outside the repo. */
91
+ function resolveWithin(root, ...segments) {
92
+ const candidate = path.resolve(root, ...segments);
93
+ const rel = path.relative(root, candidate);
94
+ if (rel.startsWith("..") || path.isAbsolute(rel))
95
+ return null;
96
+ // Symlink escape: when the file exists, its real path must also stay inside.
97
+ try {
98
+ const realRoot = fs.realpathSync(root);
99
+ const realTarget = fs.realpathSync(candidate);
100
+ const realRel = path.relative(realRoot, realTarget);
101
+ if (realRel.startsWith("..") || path.isAbsolute(realRel))
102
+ return null;
103
+ }
104
+ catch {
105
+ // realpath fails for not-yet-existing candidates β€” the lexical check above
106
+ // already ran and is sufficient for those.
107
+ }
108
+ return candidate;
109
+ }
32
110
  /** Turns a sourcemap `source` value into candidate absolute paths to probe. */
33
111
  function sourceCandidates(source, repoRoot) {
34
112
  const cleaned = source
@@ -38,11 +116,13 @@ function sourceCandidates(source, repoRoot) {
38
116
  .replace(/^\//, "")
39
117
  .split("?")[0];
40
118
  const prefixes = ["", "apps/web/", "src/", "app/"];
41
- return prefixes.map((p) => path.resolve(repoRoot, p, cleaned));
119
+ return prefixes
120
+ .map((p) => resolveWithin(repoRoot, p, cleaned))
121
+ .filter((c) => c !== null);
42
122
  }
43
123
  function locateFile(candidates) {
44
124
  for (const c of candidates) {
45
- if (isFile(c))
125
+ if (isFile(c) && !isSensitive(c))
46
126
  return c;
47
127
  }
48
128
  return null;
@@ -56,18 +136,91 @@ export async function resolveFrame(frame, repoRoot) {
56
136
  const relative = stripQuery(frame.url)
57
137
  .replace(/^https?:\/\/[^/]+\//, "")
58
138
  .replace(/^\//, "");
59
- const directPath = path.resolve(repoRoot, relative);
139
+ const directPath = resolveWithin(repoRoot, relative);
140
+ if (!directPath) {
141
+ return {
142
+ message: frame.message,
143
+ sourceFile: relative,
144
+ line: frame.line,
145
+ column: frame.column,
146
+ codeSnippet: `<file not accessible locally: ${relative}>`,
147
+ resolvedFrom: "unresolved",
148
+ };
149
+ }
60
150
  return {
61
151
  message: frame.message,
62
152
  sourceFile: path.relative(repoRoot, directPath),
63
153
  line: frame.line,
64
154
  column: frame.column,
65
155
  codeSnippet: extractSnippet(directPath, frame.line),
66
- resolvedFrom: isFile(directPath) ? "direct" : "unresolved",
156
+ resolvedFrom: isFile(directPath) && !isSensitive(directPath) ? "direct" : "unresolved",
67
157
  };
68
158
  }
159
+ /**
160
+ * Resolve a server-side source frame (a filesystem path) to a repo-relative
161
+ * source location + snippet. Mirrors `resolveFrame`'s containment rules: a path
162
+ * outside the repo β€” via `..` traversal or a symlink pointing out β€” is never
163
+ * read. Server frames carry no sourcemap; a directly-readable file maps
164
+ * `resolvedFrom: "direct"`.
165
+ */
166
+ export function resolveServerFrame(frame, repoRoot) {
167
+ let p = frame.filePath.replace(/^file:\/\//, "");
168
+ if (/^\/[A-Za-z]:[\\/]/.test(p))
169
+ p = p.slice(1); // /C:/x β†’ C:/x (Windows-on-POSIX)
170
+ const abs = path.resolve(p);
171
+ const rel = path.relative(repoRoot, abs);
172
+ let contained = !(rel.startsWith("..") || path.isAbsolute(rel));
173
+ if (contained) {
174
+ try {
175
+ const realRoot = fs.realpathSync(repoRoot);
176
+ const realTarget = fs.realpathSync(abs);
177
+ const realRel = path.relative(realRoot, realTarget);
178
+ if (realRel.startsWith("..") || path.isAbsolute(realRel))
179
+ contained = false;
180
+ }
181
+ catch {
182
+ // realpath fails for a not-yet-existing candidate β€” the lexical check above suffices.
183
+ }
184
+ }
185
+ if (!contained) {
186
+ return {
187
+ message: frame.filePath,
188
+ sourceFile: abs,
189
+ line: frame.line,
190
+ column: frame.column,
191
+ codeSnippet: `<file not accessible locally: ${abs}>`,
192
+ resolvedFrom: "unresolved",
193
+ };
194
+ }
195
+ const resolvedFrom = isFile(abs) && !isSensitive(abs) ? "direct" : "unresolved";
196
+ return {
197
+ message: frame.filePath,
198
+ sourceFile: rel,
199
+ line: frame.line,
200
+ column: frame.column,
201
+ codeSnippet: extractSnippet(abs, frame.line),
202
+ resolvedFrom,
203
+ };
204
+ }
205
+ /** True for a loopback hostname β€” the only place a sourcemap URL may point. */
206
+ function isLoopback(host) {
207
+ const h = host.replace(/^\[|\]$/g, "").toLowerCase();
208
+ return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "0.0.0.0";
209
+ }
69
210
  async function trySourceMap(frame, repoRoot) {
70
211
  const mapUrl = stripQuery(frame.url) + ".map";
212
+ // SSRF guard: the sourcemap URL is derived from an untrusted stack frame, so
213
+ // refuse to fetch anything that isn't the local machine (this tool inspects
214
+ // local dev servers) before a single byte leaves the process.
215
+ let host;
216
+ try {
217
+ host = new URL(mapUrl).hostname;
218
+ }
219
+ catch {
220
+ return null;
221
+ }
222
+ if (!isLoopback(host))
223
+ return null;
71
224
  let rawMap;
72
225
  try {
73
226
  const res = await fetch(mapUrl);
@@ -108,7 +261,7 @@ async function trySourceMap(frame, repoRoot) {
108
261
  }
109
262
  }
110
263
  export function extractSnippet(filePath, targetLine, window = 4) {
111
- if (!isFile(filePath))
264
+ if (!isFile(filePath) || isSensitive(filePath))
112
265
  return `<file not accessible locally: ${filePath}>`;
113
266
  const lines = fs.readFileSync(filePath, "utf-8").split("\n");
114
267
  const start = Math.max(0, targetLine - window - 1);
@@ -10,10 +10,11 @@ const js = (s) => JSON.stringify(s);
10
10
  export function compileSpec(finding, actions, url) {
11
11
  const title = finding.rawMessage.split("\n")[0].slice(0, 80) || "unknown error";
12
12
  const needle = finding.rawMessage.split("\n")[0].slice(0, 120);
13
+ const hasRequest = actions.some((a) => a.type === "request");
13
14
  const out = [];
14
15
  out.push(`import { test, expect } from "@playwright/test";`);
15
16
  out.push(``);
16
- out.push(`// Aztrx repro β€” ${finding.id}`);
17
+ out.push(`// Aztrx AI repro β€” ${finding.id}`);
17
18
  out.push(`// severity: ${finding.severity} type: ${finding.type}`);
18
19
  if (finding.mappedLocation) {
19
20
  out.push(`// source: ${finding.mappedLocation.filePath}:${finding.mappedLocation.line}:${finding.mappedLocation.column}`);
@@ -32,6 +33,16 @@ export function compileSpec(finding, actions, url) {
32
33
  out.push(` await page.mouse.wheel(0, ${a.value === "up" ? -600 : 600});`);
33
34
  continue;
34
35
  }
36
+ if (a.type === "request" && a.request) {
37
+ out.push(` {`);
38
+ out.push(` const status = await page.evaluate(async (r) => {`);
39
+ out.push(` try { const resp = await fetch(r.url, { method: r.method, headers: r.headers, body: r.body }); return resp.status; }`);
40
+ out.push(` catch { return 0; }`);
41
+ out.push(` }, ${JSON.stringify(a.request)});`);
42
+ out.push(` expect(status).toBeGreaterThanOrEqual(500);`);
43
+ out.push(` }`);
44
+ continue;
45
+ }
35
46
  const sel = a.selectors[0];
36
47
  if (!sel) {
37
48
  out.push(` // (skipped β€” no reliable selector for this step)`);
@@ -56,7 +67,11 @@ export function compileSpec(finding, actions, url) {
56
67
  break;
57
68
  }
58
69
  }
59
- out.push(` await expect.poll(() => errors.join("\\n"), { timeout: 5000 }).toContain(${js(needle)});`);
70
+ if (!hasRequest) {
71
+ // A server-side 5xx produces no `pageerror`/`console.error` β€” the per-request
72
+ // status assertion above is the proof instead, so skip the error poll.
73
+ out.push(` await expect.poll(() => errors.join("\\n"), { timeout: 5000 }).toContain(${js(needle)});`);
74
+ }
60
75
  out.push(`});`);
61
76
  return out.join("\n") + "\n";
62
77
  }
@@ -29,7 +29,6 @@ function sseHandler(req, res, eventsFile) {
29
29
  "Content-Type": "text/event-stream; charset=utf-8",
30
30
  "Cache-Control": "no-cache",
31
31
  Connection: "keep-alive",
32
- "Access-Control-Allow-Origin": "*",
33
32
  });
34
33
  res.write(`data: ${JSON.stringify({ type: "hello" })}\n\n`);
35
34
  let offset = 0;
@@ -166,7 +165,7 @@ function dashboardHtml() {
166
165
  <head>
167
166
  <meta charset="utf-8">
168
167
  <meta name="viewport" content="width=device-width, initial-scale=1">
169
- <title>Aztrx Studio</title>
168
+ <title>Aztrx AI Studio</title>
170
169
  <style>${BASE_CSS}</style>
171
170
  </head>
172
171
  <body>
@@ -209,8 +208,8 @@ export function startStudio(opts) {
209
208
  return sendFile(res, path.join(aztrxDir, "repro", path.basename(url)), "text/plain; charset=utf-8");
210
209
  return send(res, 404, "text/plain; charset=utf-8", "not found");
211
210
  });
212
- server.listen(port, () => {
213
- console.log(pc.cyan("\n⚑ Aztrx Studio"));
211
+ server.listen(port, "127.0.0.1", () => {
212
+ console.log(pc.cyan("\nAztrx AI Studio"));
214
213
  console.log(pc.dim(` β†’ http://localhost:${port}`));
215
214
  console.log(pc.dim(` watching ${path.relative(process.cwd(), eventsFile)}`));
216
215
  console.log(pc.dim(" Ctrl+C to stop\n"));
package/dist/core/ui.js CHANGED
@@ -53,6 +53,8 @@ h1 .brand-sub{color:var(--dim)}
53
53
  h2{font-size:15px;margin:0;font-weight:600;word-break:break-word}
54
54
  .loc{color:var(--dim);font-size:12.5px;margin-top:8px}
55
55
  .snippet{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:12px 0 0;white-space:pre}
56
+ .server{color:var(--amber);font-size:12.5px;margin-top:8px}
57
+ .server-body{background:var(--surface-2);border:1px solid var(--border);border-radius:8px;padding:12px 14px;overflow-x:auto;font:12px/1.6 ui-monospace,monospace;color:var(--muted);margin:8px 0 0;white-space:pre;max-height:240px;overflow-y:auto}
56
58
  .occ{color:var(--dim);font-size:12px;margin-top:8px}
57
59
  .repro{display:inline-flex;align-items:center;gap:8px;font-size:12px;margin-top:12px;padding:4px 10px;border-radius:6px;border:1px solid}
58
60
  .repro.deterministic{color:var(--green);border-color:rgba(67,229,138,.35);background:rgba(67,229,138,.07)}
package/dist/ui/app.js CHANGED
@@ -17,12 +17,13 @@ const PHASE_LABEL = {
17
17
  launch: { text: "β—‰ launching browser…", color: C.azure },
18
18
  walk: { text: "β—‰ walking the DOM…", color: C.azure },
19
19
  fuzz: { text: "β—‰ fuzzing (chaos)…", color: C.azure },
20
+ "http-fuzz": { text: "β—‰ fuzzing (HTTP mutations)…", color: C.azure },
20
21
  repro: { text: "β—‰ minimize β†’ compile β†’ validate…", color: C.azure },
21
22
  heal: { text: "β—‰ healing (redact β†’ generate β†’ gate β†’ sandbox β†’ verify)…", color: C.azure },
22
23
  done: { text: "βœ“ done", color: C.green },
23
24
  };
24
25
  // Actions that mutate app state β€” the ones that "count" toward ops/sec.
25
- const EFFECTIVE = new Set(["click", "input", "select", "keypress"]);
26
+ const EFFECTIVE = new Set(["click", "input", "select", "keypress", "request"]);
26
27
  function reducer(state, msg) {
27
28
  switch (msg.type) {
28
29
  case "phase":
@@ -122,7 +123,7 @@ function AztrxApp({ bus, done, targetUrl, repoRoot, mode }) {
122
123
  }, [done, exit]);
123
124
  const phase = PHASE_LABEL[state.phase];
124
125
  const currentRoute = state.routes[state.routes.length - 1] ?? targetUrl;
125
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.azure, bold: true, children: "\u26A1 Aztrx" }), _jsx(Text, { color: C.dim, children: " \u2014 Runtime Detector" }), _jsx(Text, { color: C.dim, children: " v0.1.0" })] }), _jsxs(Text, { color: C.dim, children: [" target ", targetUrl, " repo ", repoRoot] }), _jsxs(Text, { color: C.dim, children: [" mode ", mode] }), _jsx(Text, { color: C.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: phase.color, children: phase.text }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.azureBright, bold: true, children: rate.toFixed(1) }), _jsx(Text, { color: C.dim, children: " ops/s \u00B7 " }), _jsx(Text, { color: C.fg, children: state.actions }), _jsx(Text, { color: C.dim, children: " actions \u00B7 " }), _jsx(Text, { color: C.fg, children: state.clicks }), _jsx(Text, { color: C.dim, children: " clicks" })] }), _jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " route " }), _jsx(Text, { color: C.muted, children: currentRoute }), _jsxs(Text, { color: C.dim, children: [" \u00B7 ", state.routes.length, " route(s)"] })] }), state.findings.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.muted, bold: true, children: ["findings (", state.findings.length, ")"] }), state.findings.map((f) => (_jsx(FindingRow, { finding: f, repro: state.repros[f.fingerprint] }, f.fingerprint)))] })) : null, state.noise > 0 ? (_jsxs(Text, { color: C.dim, children: [" \u25B8 ", state.noise, " noise event(s) suppressed"] })) : null] }));
126
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: C.azure, bold: true, children: "Aztrx AI" }), _jsx(Text, { color: C.dim, children: " \u2014 Runtime Detector" }), _jsx(Text, { color: C.dim, children: " v0.1.1" })] }), _jsxs(Text, { color: C.dim, children: [" target ", targetUrl, " repo ", repoRoot] }), _jsxs(Text, { color: C.dim, children: [" mode ", mode] }), _jsx(Text, { color: C.dim, children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }), _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: phase.color, children: phase.text }), _jsx(Text, { color: C.dim, children: " " }), _jsx(Text, { color: C.azureBright, bold: true, children: rate.toFixed(1) }), _jsx(Text, { color: C.dim, children: " ops/s \u00B7 " }), _jsx(Text, { color: C.fg, children: state.actions }), _jsx(Text, { color: C.dim, children: " actions \u00B7 " }), _jsx(Text, { color: C.fg, children: state.clicks }), _jsx(Text, { color: C.dim, children: " clicks" })] }), _jsxs(Box, { children: [_jsx(Text, { color: C.dim, children: " route " }), _jsx(Text, { color: C.muted, children: currentRoute }), _jsxs(Text, { color: C.dim, children: [" \u00B7 ", state.routes.length, " route(s)"] })] }), state.findings.length > 0 ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: C.muted, bold: true, children: ["findings (", state.findings.length, ")"] }), state.findings.map((f) => (_jsx(FindingRow, { finding: f, repro: state.repros[f.fingerprint] }, f.fingerprint)))] })) : null, state.noise > 0 ? (_jsxs(Text, { color: C.dim, children: [" \u25B8 ", state.noise, " noise event(s) suppressed"] })) : null] }));
126
127
  }
127
128
  /** Mount the live terminal panel and resolve once the run (or a failure) ends. */
128
129
  export function renderTui(props) {
package/media/demo.gif ADDED
Binary file
package/media/logo.svg ADDED
@@ -0,0 +1,9 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 36">
2
+ <rect width="32" height="32" rx="7" fill="#000000"/>
3
+ <path d="M4 6 H16" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
4
+ <path d="M7 11 H19" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
5
+ <path d="M15 16 H27" fill="none" stroke="#ffffff" stroke-width="3.5" stroke-linecap="round"/>
6
+ <circle cx="28.5" cy="16" r="1.8" fill="#ffffff"/>
7
+ <path d="M13 21 H25" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
8
+ <path d="M16 26 H28" fill="none" stroke="#ffffff" stroke-width="3" stroke-linecap="round"/>
9
+ </svg>
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "aztrx-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
- "description": "Aztrx β€” runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
5
+ "description": "Aztrx AI β€” runtime stress-tester for web apps. Detect bugs, then prove them with an executable repro.",
6
6
  "license": "Apache-2.0",
7
7
  "author": "Danis Chaparov <d.chaparov@gmail.com>",
8
8
  "type": "module",
9
9
  "bin": { "aztrx-cli": "dist/cli.js" },
10
10
  "main": "dist/core/orchestrator.js",
11
- "files": ["dist", "README.md", "LICENSE"],
11
+ "files": ["dist", "README.md", "LICENSE", "media"],
12
12
  "engines": { "node": ">=18" },
13
13
  "keywords": ["testing", "playwright", "stress-test", "fuzzing", "web-app", "debugging", "repro", "qa", "e2e"],
14
14
  "repository": {