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 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 values, so treat it as a strong default rather than a guarantee against a hostile app.
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 maskSecrets(text, secrets) {
340
- const ordered = [...secrets].filter(Boolean).sort((a, b) => b.length - a.length);
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 secret of ordered) {
343
- masked = masked.replace(new RegExp(escapeRegExp(secret), "g"), "***");
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) this.secrets.add(encoded);
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
- return maskSecrets(text, this.secrets);
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
- async function renderTest(result, cwd) {
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.</footer>
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
- async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete, spend) {
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.19.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());