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/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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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] ? `
|
|
43
|
-
const val = 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("
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
: ">
|
|
147
|
+
: "> No crash, error, or warning surfaced β the app survived this pass.";
|
|
124
148
|
return `<!-- aztrx -->
|
|
125
|
-
##
|
|
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
|
|
package/dist/core/replay.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/core/report.js
CHANGED
|
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
? `${
|
|
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>${
|
|
32
|
-
const sel = 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>${
|
|
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">${
|
|
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>
|
package/dist/core/resolver.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/studio.js
CHANGED
|
@@ -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("\
|
|
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: "
|
|
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.
|
|
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": {
|