aztrx-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +234 -90
- package/dist/cli/help.js +85 -0
- package/dist/cli.js +167 -29
- package/dist/core/auth.js +76 -0
- package/dist/core/badge.js +50 -0
- package/dist/core/fuzzer.js +1 -1
- package/dist/core/heal/apply.js +52 -0
- package/dist/core/heal/boot.js +136 -0
- package/dist/core/heal/childEnv.js +60 -0
- package/dist/core/heal/index.js +47 -6
- package/dist/core/heal/redact.js +31 -1
- package/dist/core/heal/sandbox.js +53 -1
- package/dist/core/heal/verify.js +30 -1
- package/dist/core/httpFuzzer.js +258 -0
- package/dist/core/init.js +2 -2
- package/dist/core/interceptor.js +10 -1
- package/dist/core/modernize.js +144 -0
- package/dist/core/orchestrator.js +66 -77
- package/dist/core/pr.js +44 -20
- package/dist/core/prompt.js +22 -0
- package/dist/core/replay.js +35 -3
- package/dist/core/report.js +19 -6
- package/dist/core/resolver.js +158 -5
- package/dist/core/specCompiler.js +17 -2
- package/dist/core/studio.js +3 -4
- package/dist/core/summarize.js +173 -0
- package/dist/core/swarm.js +235 -0
- package/dist/core/ui.js +2 -0
- package/dist/ui/app.js +4 -2
- package/media/demo.gif +0 -0
- package/media/logo.svg +9 -0
- package/package.json +25 -6
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F-swarm — parallel detection. Runs N workers at once, each with its own
|
|
3
|
+
* browser context, event bus, action recorder, and classifier, so the action
|
|
4
|
+
* history attached to a finding belongs to the worker that saw it (never
|
|
5
|
+
* interleaved). Workers attack different sides: a deterministic walk, several
|
|
6
|
+
* chaos-fuzz seeds, and — optionally — the server-side HTTP fuzzer.
|
|
7
|
+
*
|
|
8
|
+
* Findings are merged by fingerprint at the end (occurrences summed, the richest
|
|
9
|
+
* action history / source mapping kept); the caller then runs repro/heal on the
|
|
10
|
+
* merged set as usual.
|
|
11
|
+
*/
|
|
12
|
+
import * as fs from "fs";
|
|
13
|
+
import * as path from "path";
|
|
14
|
+
import { chromium } from "playwright";
|
|
15
|
+
import { EventBus } from "./eventBus.js";
|
|
16
|
+
import { attachInterceptor } from "./interceptor.js";
|
|
17
|
+
import { establishLogin } from "./auth.js";
|
|
18
|
+
import { SignalClassifier } from "./classifier.js";
|
|
19
|
+
import { ActionRecorder } from "./recorder.js";
|
|
20
|
+
import { walkDom } from "./domWalker.js";
|
|
21
|
+
import { fuzz } from "./fuzzer.js";
|
|
22
|
+
import { httpFuzz } from "./httpFuzzer.js";
|
|
23
|
+
import { attachNetworkGuard } from "./networkGuard.js";
|
|
24
|
+
import { resolveFrame, resolveServerFrame } from "./resolver.js";
|
|
25
|
+
/**
|
|
26
|
+
* Run one worker's detection pass and return its findings. All internal events
|
|
27
|
+
* flow through a local bus (isolation); only `action`/`route`/`noise` are
|
|
28
|
+
* forwarded to `forwardBus` so a live panel can aggregate, never per-worker
|
|
29
|
+
* findings (those are merged by the caller first).
|
|
30
|
+
*/
|
|
31
|
+
export async function detectWorker(browser, opts, strategy, forwardBus) {
|
|
32
|
+
const workerBus = new EventBus();
|
|
33
|
+
const recorder = new ActionRecorder();
|
|
34
|
+
const classifier = new SignalClassifier(opts.baseline);
|
|
35
|
+
workerBus.on("action", (a) => {
|
|
36
|
+
recorder.record(a);
|
|
37
|
+
forwardBus?.emit("action", a);
|
|
38
|
+
});
|
|
39
|
+
workerBus.on("route", (r) => forwardBus?.emit("route", r));
|
|
40
|
+
workerBus.on("noise", (n) => forwardBus?.emit("noise", n));
|
|
41
|
+
// Classify telemetry with THIS worker's recorder, so action history is correct.
|
|
42
|
+
workerBus.on("telemetry", async (payload) => {
|
|
43
|
+
const finding = classifier.classify(payload);
|
|
44
|
+
if (!finding)
|
|
45
|
+
return;
|
|
46
|
+
finding.actionHistory = recorder.snapshot();
|
|
47
|
+
if (finding.severity === "noise") {
|
|
48
|
+
workerBus.emit("noise", { ts: Date.now() });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (payload.serverError) {
|
|
52
|
+
finding.serverError = { message: payload.serverError.message, body: payload.serverError.body };
|
|
53
|
+
}
|
|
54
|
+
if (payload.url && payload.line) {
|
|
55
|
+
const resolved = await resolveFrame({ url: payload.url, line: payload.line, column: payload.column ?? 0, message: payload.rawMessage }, opts.repoRoot);
|
|
56
|
+
finding.mappedLocation = {
|
|
57
|
+
filePath: resolved.sourceFile,
|
|
58
|
+
line: resolved.line,
|
|
59
|
+
column: resolved.column,
|
|
60
|
+
codeContext: resolved.codeSnippet,
|
|
61
|
+
isOwnCode: resolved.resolvedFrom !== "unresolved",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
else if (payload.serverError?.frame) {
|
|
65
|
+
const resolved = resolveServerFrame(payload.serverError.frame, opts.repoRoot);
|
|
66
|
+
finding.mappedLocation = {
|
|
67
|
+
filePath: resolved.sourceFile,
|
|
68
|
+
line: resolved.line,
|
|
69
|
+
column: resolved.column,
|
|
70
|
+
codeContext: resolved.codeSnippet,
|
|
71
|
+
isOwnCode: resolved.resolvedFrom !== "unresolved",
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
const context = await browser.newContext(opts.storageState ? { storageState: opts.storageState } : {});
|
|
76
|
+
const page = await context.newPage();
|
|
77
|
+
attachInterceptor(page, workerBus);
|
|
78
|
+
if (opts.guardOn) {
|
|
79
|
+
await attachNetworkGuard(page, {
|
|
80
|
+
allowHosts: opts.allowHosts,
|
|
81
|
+
onBlock: (u) => opts.log(`[guard] blocked ${u}`),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
page.on("framenavigated", (frame) => {
|
|
85
|
+
if (frame === page.mainFrame())
|
|
86
|
+
workerBus.emit("route", { url: frame.url(), ts: Date.now() });
|
|
87
|
+
});
|
|
88
|
+
let loaded = true;
|
|
89
|
+
await page.goto(opts.url, { waitUntil: "load", timeout: 30000 }).catch((e) => {
|
|
90
|
+
loaded = false;
|
|
91
|
+
opts.log(`Failed to load target: ${e.message}`);
|
|
92
|
+
});
|
|
93
|
+
if (loaded) {
|
|
94
|
+
// Settle for hydration and mount-time effects before acting.
|
|
95
|
+
await page.waitForTimeout(2000);
|
|
96
|
+
}
|
|
97
|
+
// Auto-login (best-effort). The server-side HTTP fuzzer uses Node-side fetch,
|
|
98
|
+
// so it doesn't benefit from a browser session — skip it there.
|
|
99
|
+
let replayStorageState;
|
|
100
|
+
if (loaded && strategy.kind !== "http-fuzz" && opts.login && opts.loginEmail && opts.loginPassword) {
|
|
101
|
+
const res = await establishLogin(page, {
|
|
102
|
+
email: opts.loginEmail,
|
|
103
|
+
password: opts.loginPassword,
|
|
104
|
+
loginUrl: opts.loginUrl,
|
|
105
|
+
});
|
|
106
|
+
if (res.ok) {
|
|
107
|
+
if (opts.saveAuthState) {
|
|
108
|
+
const state = await context.storageState();
|
|
109
|
+
const authStatePath = path.join(opts.repoRoot, ".aztrx", "auth-state.json");
|
|
110
|
+
fs.mkdirSync(path.dirname(authStatePath), { recursive: true });
|
|
111
|
+
fs.writeFileSync(authStatePath, JSON.stringify(state, null, 2), "utf-8");
|
|
112
|
+
replayStorageState = authStatePath;
|
|
113
|
+
opts.log(`[auth] logged in → ${path.relative(opts.repoRoot, authStatePath)}`);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
opts.log("[auth] logged in");
|
|
117
|
+
}
|
|
118
|
+
await page.goto(opts.url, { waitUntil: "load", timeout: 30000 }).catch(() => { });
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
opts.log(`[auth] skipped: ${res.reason}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (loaded && opts.crashTest) {
|
|
125
|
+
await page.evaluate(() => {
|
|
126
|
+
setTimeout(() => {
|
|
127
|
+
throw new Error("Aztrx test: Cannot read properties of undefined (reading 'token')");
|
|
128
|
+
}, 300);
|
|
129
|
+
});
|
|
130
|
+
await page.waitForTimeout(800);
|
|
131
|
+
}
|
|
132
|
+
let actions = 0;
|
|
133
|
+
if (loaded) {
|
|
134
|
+
if (strategy.kind === "walk") {
|
|
135
|
+
actions = await walkDom(page, workerBus, { maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
136
|
+
}
|
|
137
|
+
else if (strategy.kind === "fuzz") {
|
|
138
|
+
actions = await fuzz(page, workerBus, { seed: strategy.seed, maxActions: opts.maxActions, dryRun: opts.dryRun });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
actions = await httpFuzz(page, opts.url, workerBus, {
|
|
142
|
+
maxRequests: opts.maxActions,
|
|
143
|
+
dryRun: opts.dryRun,
|
|
144
|
+
allowHosts: opts.allowHosts,
|
|
145
|
+
mutations: opts.httpFuzzMutations,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
await page.waitForTimeout(500);
|
|
150
|
+
await context.close();
|
|
151
|
+
return { findings: classifier.findings(), actions, replayStorageState };
|
|
152
|
+
}
|
|
153
|
+
/** Dedup findings across workers by fingerprint: sum occurrences, keep the richest. */
|
|
154
|
+
export function mergeFindings(arrays) {
|
|
155
|
+
const byFingerprint = new Map();
|
|
156
|
+
for (const arr of arrays) {
|
|
157
|
+
for (const f of arr) {
|
|
158
|
+
const existing = byFingerprint.get(f.fingerprint);
|
|
159
|
+
if (!existing) {
|
|
160
|
+
byFingerprint.set(f.fingerprint, { ...f, actionHistory: [...f.actionHistory] });
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
existing.occurrences += f.occurrences;
|
|
164
|
+
if (!existing.mappedLocation && f.mappedLocation)
|
|
165
|
+
existing.mappedLocation = f.mappedLocation;
|
|
166
|
+
if (existing.actionHistory.length < f.actionHistory.length)
|
|
167
|
+
existing.actionHistory = f.actionHistory;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return [...byFingerprint.values()];
|
|
171
|
+
}
|
|
172
|
+
/** Build the worker roster for a run. `workers = 1` with no http-fuzz is the
|
|
173
|
+
* legacy single pass; `--http-fuzz` and/or `workers > 1` fan out. */
|
|
174
|
+
function buildStrategies(opts) {
|
|
175
|
+
const strategies = [];
|
|
176
|
+
if (opts.httpFuzz)
|
|
177
|
+
strategies.push({ kind: "http-fuzz" });
|
|
178
|
+
const w = Math.max(1, opts.workers);
|
|
179
|
+
if (w === 1) {
|
|
180
|
+
strategies.push(opts.fuzz ? { kind: "fuzz", seed: opts.seed } : { kind: "walk" });
|
|
181
|
+
return strategies;
|
|
182
|
+
}
|
|
183
|
+
if (opts.fuzz) {
|
|
184
|
+
for (let i = 0; i < w; i++)
|
|
185
|
+
strategies.push({ kind: "fuzz", seed: opts.seed + i });
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
strategies.push({ kind: "walk" });
|
|
189
|
+
for (let i = 1; i < w; i++)
|
|
190
|
+
strategies.push({ kind: "fuzz", seed: opts.seed + i });
|
|
191
|
+
}
|
|
192
|
+
return strategies;
|
|
193
|
+
}
|
|
194
|
+
/** Launch one browser, run the worker roster concurrently, merge findings. */
|
|
195
|
+
export async function swarmDetect(opts) {
|
|
196
|
+
const strategies = buildStrategies(opts);
|
|
197
|
+
const browser = await chromium.launch({ headless: true });
|
|
198
|
+
try {
|
|
199
|
+
const settled = await Promise.allSettled(strategies.map((strategy, i) => detectWorker(browser, {
|
|
200
|
+
url: opts.url,
|
|
201
|
+
repoRoot: opts.repoRoot,
|
|
202
|
+
allowHosts: opts.allowHosts,
|
|
203
|
+
maxActions: opts.maxActions,
|
|
204
|
+
dryRun: opts.dryRun,
|
|
205
|
+
guardOn: opts.guardOn,
|
|
206
|
+
storageState: opts.storageState,
|
|
207
|
+
login: opts.login,
|
|
208
|
+
loginEmail: opts.loginEmail,
|
|
209
|
+
loginPassword: opts.loginPassword,
|
|
210
|
+
loginUrl: opts.loginUrl,
|
|
211
|
+
crashTest: i === 0 ? opts.crashTest : false,
|
|
212
|
+
saveAuthState: i === 0,
|
|
213
|
+
httpFuzzMutations: opts.httpFuzzMutations,
|
|
214
|
+
baseline: opts.baseline,
|
|
215
|
+
log: (m) => opts.log(strategies.length > 1 ? `[w${i}] ${m}` : m),
|
|
216
|
+
}, strategy)));
|
|
217
|
+
const results = [];
|
|
218
|
+
settled.forEach((r, i) => {
|
|
219
|
+
if (r.status === "fulfilled")
|
|
220
|
+
results.push(r.value);
|
|
221
|
+
else
|
|
222
|
+
opts.log(`worker ${i} failed: ${r.reason?.message ?? String(r.reason)}`);
|
|
223
|
+
});
|
|
224
|
+
let replayStorageState;
|
|
225
|
+
for (const r of results)
|
|
226
|
+
if (r.replayStorageState)
|
|
227
|
+
replayStorageState = r.replayStorageState;
|
|
228
|
+
const findings = mergeFindings(results.map((r) => r.findings));
|
|
229
|
+
const totalActions = results.reduce((sum, r) => sum + r.actions, 0);
|
|
230
|
+
return { findings, replayStorageState, totalActions, workerCount: strategies.length };
|
|
231
|
+
}
|
|
232
|
+
finally {
|
|
233
|
+
await browser.close();
|
|
234
|
+
}
|
|
235
|
+
}
|
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
|
@@ -15,14 +15,16 @@ const C = {
|
|
|
15
15
|
};
|
|
16
16
|
const PHASE_LABEL = {
|
|
17
17
|
launch: { text: "◉ launching browser…", color: C.azure },
|
|
18
|
+
swarm: { text: "◉ swarm (parallel workers)…", color: C.azure },
|
|
18
19
|
walk: { text: "◉ walking the DOM…", color: C.azure },
|
|
19
20
|
fuzz: { text: "◉ fuzzing (chaos)…", color: C.azure },
|
|
21
|
+
"http-fuzz": { text: "◉ fuzzing (HTTP mutations)…", color: C.azure },
|
|
20
22
|
repro: { text: "◉ minimize → compile → validate…", color: C.azure },
|
|
21
23
|
heal: { text: "◉ healing (redact → generate → gate → sandbox → verify)…", color: C.azure },
|
|
22
24
|
done: { text: "✓ done", color: C.green },
|
|
23
25
|
};
|
|
24
26
|
// Actions that mutate app state — the ones that "count" toward ops/sec.
|
|
25
|
-
const EFFECTIVE = new Set(["click", "input", "select", "keypress"]);
|
|
27
|
+
const EFFECTIVE = new Set(["click", "input", "select", "keypress", "request"]);
|
|
26
28
|
function reducer(state, msg) {
|
|
27
29
|
switch (msg.type) {
|
|
28
30
|
case "phase":
|
|
@@ -122,7 +124,7 @@ function AztrxApp({ bus, done, targetUrl, repoRoot, mode }) {
|
|
|
122
124
|
}, [done, exit]);
|
|
123
125
|
const phase = PHASE_LABEL[state.phase];
|
|
124
126
|
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: "
|
|
127
|
+
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
128
|
}
|
|
127
129
|
/** Mount the live terminal panel and resolve once the run (or a failure) ends. */
|
|
128
130
|
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,16 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aztrx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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
|
-
"bin": {
|
|
9
|
+
"bin": {
|
|
10
|
+
"aztrx-cli": "dist/cli.js"
|
|
11
|
+
},
|
|
10
12
|
"main": "dist/core/orchestrator.js",
|
|
11
|
-
"files": [
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
"files": [
|
|
14
|
+
"dist",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"media"
|
|
18
|
+
],
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"testing",
|
|
24
|
+
"playwright",
|
|
25
|
+
"stress-test",
|
|
26
|
+
"fuzzing",
|
|
27
|
+
"web-app",
|
|
28
|
+
"debugging",
|
|
29
|
+
"repro",
|
|
30
|
+
"qa",
|
|
31
|
+
"e2e"
|
|
32
|
+
],
|
|
14
33
|
"repository": {
|
|
15
34
|
"type": "git",
|
|
16
35
|
"url": "https://github.com/DanisChaparov/aztrx.git"
|