blastproof 0.19.0 → 0.20.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 +6 -2
- package/dist/cli.js +77 -16
- package/dist/cli.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,6 +86,8 @@ The name is matched **exactly first**, then by substring if nothing matches exac
|
|
|
86
86
|
| `<button aria-label="Delete note">` | an icon-only button with no name |
|
|
87
87
|
| `<select>` with `<option>`s | an ARIA-less custom dropdown |
|
|
88
88
|
|
|
89
|
+
**An editable data grid is the shape most likely to defeat this, and every row above understates it.** Two adversarial runs against a personal-finance application failed on the journey such an application exists for — entering a transaction. The row's cells carry no accessible name, but the Payee *column header* does, so the tree does not merely lack the target: it offers a control with exactly the right name that is the wrong control. The agent clicks the header, the row editor never opens, and the next `type` lands in whatever input does exist — there, the page's global search box. The step fails describing a textbox nobody mentioned, roughly 70 model calls in. Nobody has yet tried driving such a grid by keyboard (`Tab` into the row, type, `Tab` on), so the honest claim is that **the obvious authoring fails, and fails confusingly** — not that it is impossible. If your core journey is a row editor, spend one test on it before adopting ([#67](https://github.com/hamc/blastproof/issues/67)).
|
|
90
|
+
|
|
89
91
|
**Run an accessibility checker on your app before installing anything.** The result predicts how well this will work better than anything else you could measure — and the fixes it suggests are worth making regardless of whether you adopt this tool.
|
|
90
92
|
|
|
91
93
|
### 2. Does your journey need anything on this list?
|
|
@@ -273,9 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
|
|
|
273
275
|
|
|
274
276
|
If your application legitimately spans hosts (an identity provider, a hosted payment step), declare them. A suite that was quietly walking onto a foreign page will now fail and name the origin to add.
|
|
275
277
|
|
|
276
|
-
**Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches known
|
|
278
|
+
**Your secrets stay out of prompts.** `{{env.*}}` placeholders survive intact and are substituted at the moment of typing. Every value your tests or auth recipe reference is redacted from everything else crossing into a prompt — page snapshots included — in literal and percent-encoded form. Redaction matches a known value case-insensitively and tolerantly of whitespace, so a page that uppercases what you typed is still covered. It does not chase a value your application re-encodes, hashes or truncates — no list of transforms can be complete, and one that pretends to be would make you careless. When a redaction is found only by that wider comparison, the run prints one warning naming the variable, because the same page could just as easily have returned a form nothing here can recognise. Treat all of it as a strong default rather than a guarantee against a hostile app.
|
|
279
|
+
|
|
280
|
+
**Redaction covers text, not pixels.** A failure screenshot shows whatever was on screen, including a value typed from `{{env.*}}` or echoed back by the page. So when anything in a run references `{{env.*}}`, the HTML report does not embed screenshots: each failure links to its PNG under `.blastproof/reports/` instead. That keeps `report.html` safe to attach to a pull request. The PNGs themselves are not masked, so do not upload `.blastproof/reports/` anywhere you would not put the secret ([#110](https://github.com/hamc/blastproof/issues/110)).
|
|
277
281
|
|
|
278
|
-
**A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)).
|
|
282
|
+
**A redacted value cannot be asserted on.** The masking is thorough by design, and page snapshots are not exempt — so a step that verifies text which happens to equal an `{{env.*}}` value can never pass, because the judge is shown `***` where the page shows the thing. The failure is the most misleading shape available: the test is right, the application is right, and the report blames the application. Put a value in `{{env.*}}` because it is a secret or because it varies by environment, but do not then write a step that asserts on it ([#87](https://github.com/hamc/blastproof/issues/87)). Matching is case-insensitive, so this covers a little more of the page than the value's exact spelling — one more reason to keep assertions off it.
|
|
279
283
|
|
|
280
284
|
The system prompt also tells the model that page content is data, never instruction. That raises the cost of casual injection and is **not** a boundary — the origin constraint is. Do not point blastproof at an application you would not run locally.
|
|
281
285
|
|
package/dist/cli.js
CHANGED
|
@@ -336,16 +336,30 @@ function referencedEnvVars(text) {
|
|
|
336
336
|
function escapeRegExp(value) {
|
|
337
337
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
338
338
|
}
|
|
339
|
-
function
|
|
340
|
-
|
|
339
|
+
function secretPattern(secret) {
|
|
340
|
+
return new RegExp(escapeRegExp(secret).replace(/\s+/g, "\\s+"), "gi");
|
|
341
|
+
}
|
|
342
|
+
function compile(secrets) {
|
|
343
|
+
return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value) }));
|
|
344
|
+
}
|
|
345
|
+
function maskCompiled(text, compiled, onNearMiss) {
|
|
341
346
|
let masked = text;
|
|
342
|
-
for (const
|
|
343
|
-
|
|
347
|
+
for (const { value, pattern } of compiled) {
|
|
348
|
+
pattern.lastIndex = 0;
|
|
349
|
+
masked = masked.replace(pattern, (found) => {
|
|
350
|
+
if (found !== value) onNearMiss?.(value, found);
|
|
351
|
+
return "***";
|
|
352
|
+
});
|
|
344
353
|
}
|
|
345
354
|
return masked;
|
|
346
355
|
}
|
|
347
356
|
var SecretsMask = class {
|
|
348
357
|
secrets = /* @__PURE__ */ new Set();
|
|
358
|
+
/** Value -> the `{{env.*}}` variable it came from, so a warning can name it. */
|
|
359
|
+
names = /* @__PURE__ */ new Map();
|
|
360
|
+
nearMissed = /* @__PURE__ */ new Set();
|
|
361
|
+
/** Built on first use after a registration, not on every masked string. */
|
|
362
|
+
compiled;
|
|
349
363
|
/** Registers the current values of the env vars referenced in `text`. Throws MissingEnvError on unset vars. */
|
|
350
364
|
registerFrom(text, env = process.env) {
|
|
351
365
|
for (const name of referencedEnvVars(text)) {
|
|
@@ -353,7 +367,7 @@ var SecretsMask = class {
|
|
|
353
367
|
if (value === void 0) {
|
|
354
368
|
throw new MissingEnvError(name);
|
|
355
369
|
}
|
|
356
|
-
this.add(value);
|
|
370
|
+
this.add(value, name);
|
|
357
371
|
}
|
|
358
372
|
}
|
|
359
373
|
/**
|
|
@@ -362,14 +376,39 @@ var SecretsMask = class {
|
|
|
362
376
|
* space stopped matching a literal search and passed through unmasked.
|
|
363
377
|
* This cannot cover every transform a page might apply; see the README.
|
|
364
378
|
*/
|
|
365
|
-
add(value) {
|
|
379
|
+
add(value, name) {
|
|
366
380
|
if (!value) return;
|
|
367
381
|
this.secrets.add(value);
|
|
382
|
+
this.compiled = void 0;
|
|
383
|
+
if (name !== void 0 && !this.names.has(value)) this.names.set(value, name);
|
|
368
384
|
const encoded = encodeURIComponent(value);
|
|
369
|
-
if (encoded !== value)
|
|
385
|
+
if (encoded !== value) {
|
|
386
|
+
this.secrets.add(encoded);
|
|
387
|
+
if (name !== void 0 && !this.names.has(encoded)) this.names.set(encoded, name);
|
|
388
|
+
}
|
|
370
389
|
}
|
|
371
390
|
mask(text) {
|
|
372
|
-
|
|
391
|
+
this.compiled ??= compile(this.secrets);
|
|
392
|
+
return maskCompiled(text, this.compiled, (secret) => {
|
|
393
|
+
const name = this.names.get(secret);
|
|
394
|
+
if (name !== void 0) this.nearMissed.add(name);
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* The `{{env.*}}` variables whose value was redacted in a form differing from
|
|
399
|
+
* the one supplied — the case a literal comparison would have missed
|
|
400
|
+
* (design D2). Names only: a caller reporting this must never hold the value.
|
|
401
|
+
*/
|
|
402
|
+
nearMissedVariables() {
|
|
403
|
+
return [...this.nearMissed];
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Whether no value is registered — i.e. nothing in the run referenced
|
|
407
|
+
* `{{env.*}}`. Says nothing about which values, so a caller deciding what a
|
|
408
|
+
* report may carry never holds a secret to decide it.
|
|
409
|
+
*/
|
|
410
|
+
isEmpty() {
|
|
411
|
+
return this.secrets.size === 0;
|
|
373
412
|
}
|
|
374
413
|
};
|
|
375
414
|
|
|
@@ -2071,7 +2110,11 @@ function renderSteps(steps) {
|
|
|
2071
2110
|
${items}
|
|
2072
2111
|
</ol>`;
|
|
2073
2112
|
}
|
|
2074
|
-
|
|
2113
|
+
function withheldScreenshot(file, reportDir) {
|
|
2114
|
+
const href = path7.relative(reportDir, file).split(path7.sep).join("/");
|
|
2115
|
+
return ` <p class="note">Screenshot withheld: this run handled secrets ({{env.*}}), and a screenshot cannot be masked. <a href="${escapeHtml(href)}">${escapeHtml(href)}</a></p>`;
|
|
2116
|
+
}
|
|
2117
|
+
async function renderTest(result, screenshots, cwd) {
|
|
2075
2118
|
const failed = result.status === "failed";
|
|
2076
2119
|
const notRun = result.status === "not-run";
|
|
2077
2120
|
const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
|
|
@@ -2093,7 +2136,9 @@ async function renderTest(result, cwd) {
|
|
|
2093
2136
|
parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
|
|
2094
2137
|
}
|
|
2095
2138
|
parts.push(` ${renderSteps(result.steps)}`);
|
|
2096
|
-
if (failed && result.screenshot) {
|
|
2139
|
+
if (failed && result.screenshot && screenshots !== "embed") {
|
|
2140
|
+
parts.push(withheldScreenshot(result.screenshot, screenshots.withheldRelativeTo));
|
|
2141
|
+
} else if (failed && result.screenshot) {
|
|
2097
2142
|
const embedded = await embedScreenshot(result.screenshot);
|
|
2098
2143
|
parts.push(
|
|
2099
2144
|
embedded ? ` <img class="shot" alt="Screenshot at failure" src="${embedded}">` : ' <p class="note">Screenshot unavailable.</p>'
|
|
@@ -2108,7 +2153,7 @@ async function renderHtml(results, skipped, meta) {
|
|
|
2108
2153
|
const notRun = results.filter((r) => r.status === "not-run");
|
|
2109
2154
|
const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
|
|
2110
2155
|
const ordered = [...failures, ...notRun, ...results.filter((r) => r.status === "passed")];
|
|
2111
|
-
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.cwd)))).join("\n");
|
|
2156
|
+
const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.screenshots, meta.cwd)))).join("\n");
|
|
2112
2157
|
const verdict = meta.minScore === void 0 ? "" : meta.score >= meta.minScore ? `<span class="verdict pass">min-score ${meta.minScore}: pass</span>` : `<span class="verdict fail">min-score ${meta.minScore}: FAIL</span>`;
|
|
2113
2158
|
const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
|
|
2114
2159
|
|
|
@@ -2152,7 +2197,7 @@ ${rows}${rows && skippedRows ? "\n" : ""}${skippedRows}
|
|
|
2152
2197
|
|
|
2153
2198
|
${detail}
|
|
2154
2199
|
|
|
2155
|
-
<footer>Generated by blastproof. Screenshots are embedded, so this file works offline
|
|
2200
|
+
<footer>Generated by blastproof. ${meta.screenshots === "embed" ? "Screenshots are embedded, so this file works offline." : "Screenshots were withheld because this run handled secrets ({{env.*}}); failures link to the file instead."}</footer>
|
|
2156
2201
|
</main>
|
|
2157
2202
|
</body>
|
|
2158
2203
|
</html>
|
|
@@ -2522,8 +2567,16 @@ function printImpactReport(impact, selection, cwd) {
|
|
|
2522
2567
|
}
|
|
2523
2568
|
console.log("---------------------------------------------------------------");
|
|
2524
2569
|
}
|
|
2525
|
-
|
|
2570
|
+
function reportNearMissedSecrets(variables) {
|
|
2571
|
+
for (const variable of variables) {
|
|
2572
|
+
console.error(
|
|
2573
|
+
`warning: the application returned ${variable} in a different form than the value supplied (case or spacing). It was redacted, but a form this tool cannot recognise \u2014 an encoding, a hash \u2014 would not have been. Check what that page does with the value.`
|
|
2574
|
+
);
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
async function finalize(results, skipped, options, sessionDir, secretsHeld, nearMissedSecrets, durationMs, impact, incomplete, spend) {
|
|
2526
2578
|
if (results.length > 0) printSummary(results);
|
|
2579
|
+
reportNearMissedSecrets(nearMissedSecrets);
|
|
2527
2580
|
if (spend) console.log(formatSpendLine(spend));
|
|
2528
2581
|
const score = computeScore(results);
|
|
2529
2582
|
console.log(
|
|
@@ -2549,7 +2602,13 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
|
|
|
2549
2602
|
minScore: options.minScore,
|
|
2550
2603
|
cwd: options.cwd,
|
|
2551
2604
|
incomplete: incomplete?.message,
|
|
2552
|
-
spend
|
|
2605
|
+
spend,
|
|
2606
|
+
// A screenshot cannot be masked, so a run that held any `{{env.*}}` value
|
|
2607
|
+
// links to it instead of carrying it (design
|
|
2608
|
+
// withhold-a-screenshot-that-saw-a-secret, D1): the run's mask, not the
|
|
2609
|
+
// failed test's own placeholders, because a login credential is echoed by
|
|
2610
|
+
// the pages of every test that comes after it.
|
|
2611
|
+
screenshots: secretsHeld ? { withheldRelativeTo: path9.dirname(target) } : "embed"
|
|
2553
2612
|
});
|
|
2554
2613
|
await writeHtml(target, html);
|
|
2555
2614
|
console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
|
|
@@ -2720,7 +2779,7 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2720
2779
|
console.log(
|
|
2721
2780
|
impact ? "No impacted tests to run." : "No tests matched the given filters."
|
|
2722
2781
|
);
|
|
2723
|
-
return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
|
|
2782
|
+
return finalize(results, selection.unroutedSkipped, options, sessionDir, false, [], Date.now() - startedAt, impact);
|
|
2724
2783
|
}
|
|
2725
2784
|
try {
|
|
2726
2785
|
createModel(config.llm);
|
|
@@ -2818,6 +2877,8 @@ ${results.length} test file(s) could not be parsed:`);
|
|
|
2818
2877
|
selection.unroutedSkipped,
|
|
2819
2878
|
options,
|
|
2820
2879
|
sessionDir,
|
|
2880
|
+
!runMask.isEmpty(),
|
|
2881
|
+
runMask.nearMissedVariables(),
|
|
2821
2882
|
Date.now() - startedAt,
|
|
2822
2883
|
impact,
|
|
2823
2884
|
incomplete,
|
|
@@ -3145,7 +3206,7 @@ function parsePositiveNumber(flag) {
|
|
|
3145
3206
|
};
|
|
3146
3207
|
}
|
|
3147
3208
|
var program = new Command();
|
|
3148
|
-
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.
|
|
3209
|
+
program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.20.0");
|
|
3149
3210
|
program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
|
|
3150
3211
|
try {
|
|
3151
3212
|
const result = await initProject(process.cwd());
|