blastproof 0.19.0 → 0.21.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, and replaced with a label naming its variable: `[redacted TEST_PASSWORD]`. The label carries the name and nothing derived from the value, so it tells a reader, and the model, *which* secret was there without telling anyone what it was. 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 is asserted on by identity.** A step that verifies text equal to an `{{env.*}}` value reaches the judge through the mask, like the page does, so both sides read `[redacted NAME]`. The same label on both sides is a match; different labels are not. Until 0.21.0 every secret was redacted to the same `***`, and a step verifying one secret passed against a page showing another — measured, 3 times out of 3 ([#87](https://github.com/hamc/blastproof/issues/87)). What the judge still cannot check is anything *about* the value beyond which variable it is: its length, its format, a prefix. Assert on those with a value that is not in `{{env.*}}`.
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
@@ -118,7 +118,7 @@ var SAMPLE_LOGIN_TEMPLATE = `# TEMPLATE \u2014 rename to login.yaml once these s
118
118
  #
119
119
  # Credentials come from the environment. Export them before running:
120
120
  # export TEST_EMAIL=... TEST_PASSWORD=...
121
- # Substituted values are masked as *** everywhere and never reach the model.
121
+ # Substituted values are masked as [redacted NAME] everywhere and never reach the model.
122
122
  summary: Login with valid credentials succeeds
123
123
  priority: P0
124
124
  tags: [smoke, auth]
@@ -336,16 +336,34 @@ 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
+ var REDACTION_PREFIX = "[redacted";
343
+ function redactionLabel(name) {
344
+ return name === void 0 ? `${REDACTION_PREFIX}]` : `${REDACTION_PREFIX} ${name}]`;
345
+ }
346
+ function compile(secrets, names) {
347
+ return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value), label: redactionLabel(names?.get(value)) }));
348
+ }
349
+ function maskCompiled(text, compiled, onNearMiss) {
341
350
  let masked = text;
342
- for (const secret of ordered) {
343
- masked = masked.replace(new RegExp(escapeRegExp(secret), "g"), "***");
351
+ for (const { value, pattern, label } of compiled) {
352
+ pattern.lastIndex = 0;
353
+ masked = masked.replace(pattern, (found) => {
354
+ if (found !== value) onNearMiss?.(value, found);
355
+ return label;
356
+ });
344
357
  }
345
358
  return masked;
346
359
  }
347
360
  var SecretsMask = class {
348
361
  secrets = /* @__PURE__ */ new Set();
362
+ /** Value -> the `{{env.*}}` variable it came from, so a warning can name it. */
363
+ names = /* @__PURE__ */ new Map();
364
+ nearMissed = /* @__PURE__ */ new Set();
365
+ /** Built on first use after a registration, not on every masked string. */
366
+ compiled;
349
367
  /** Registers the current values of the env vars referenced in `text`. Throws MissingEnvError on unset vars. */
350
368
  registerFrom(text, env = process.env) {
351
369
  for (const name of referencedEnvVars(text)) {
@@ -353,7 +371,7 @@ var SecretsMask = class {
353
371
  if (value === void 0) {
354
372
  throw new MissingEnvError(name);
355
373
  }
356
- this.add(value);
374
+ this.add(value, name);
357
375
  }
358
376
  }
359
377
  /**
@@ -362,14 +380,39 @@ var SecretsMask = class {
362
380
  * space stopped matching a literal search and passed through unmasked.
363
381
  * This cannot cover every transform a page might apply; see the README.
364
382
  */
365
- add(value) {
383
+ add(value, name) {
366
384
  if (!value) return;
367
385
  this.secrets.add(value);
386
+ this.compiled = void 0;
387
+ if (name !== void 0 && !this.names.has(value)) this.names.set(value, name);
368
388
  const encoded = encodeURIComponent(value);
369
- if (encoded !== value) this.secrets.add(encoded);
389
+ if (encoded !== value) {
390
+ this.secrets.add(encoded);
391
+ if (name !== void 0 && !this.names.has(encoded)) this.names.set(encoded, name);
392
+ }
370
393
  }
371
394
  mask(text) {
372
- return maskSecrets(text, this.secrets);
395
+ this.compiled ??= compile(this.secrets, this.names);
396
+ return maskCompiled(text, this.compiled, (secret) => {
397
+ const name = this.names.get(secret);
398
+ if (name !== void 0) this.nearMissed.add(name);
399
+ });
400
+ }
401
+ /**
402
+ * The `{{env.*}}` variables whose value was redacted in a form differing from
403
+ * the one supplied — the case a literal comparison would have missed
404
+ * (design D2). Names only: a caller reporting this must never hold the value.
405
+ */
406
+ nearMissedVariables() {
407
+ return [...this.nearMissed];
408
+ }
409
+ /**
410
+ * Whether no value is registered — i.e. nothing in the run referenced
411
+ * `{{env.*}}`. Says nothing about which values, so a caller deciding what a
412
+ * report may carry never holds a secret to decide it.
413
+ */
414
+ isEmpty() {
415
+ return this.secrets.size === 0;
373
416
  }
374
417
  };
375
418
 
@@ -638,7 +681,26 @@ var StepRecovery = class {
638
681
  * and #28 has now produced one on three applications.
639
682
  */
640
683
  refusalFor(action) {
641
- return this.repeatedCommitRefusal(action) ?? this.unsourcedValueRefusal(action);
684
+ return this.repeatedCommitRefusal(action) ?? this.redactionLabelRefusal(action) ?? this.unsourcedValueRefusal(action);
685
+ }
686
+ /**
687
+ * Refuses a typed value containing a redaction label (design
688
+ * label-a-redaction-with-its-variable, D3).
689
+ *
690
+ * Checked before the source check because the source check would admit it:
691
+ * `observe` credits the model with the masked snapshot, and the label is in
692
+ * the masked snapshot. It is the mask's own writing, not the application's, so
693
+ * typing it would put the words "[redacted TEST_EMAIL]" into a real field.
694
+ * A prefix match rather than the exact label, because a model that copies
695
+ * part of one, or rebuilds one around another name, is equally wrong.
696
+ *
697
+ * Translating it back into its placeholder was the alternative, and it is the
698
+ * exemption #66 closed: the model would be choosing a secret to type.
699
+ */
700
+ redactionLabelRefusal(action) {
701
+ if (!SOURCED_VALUE_ACTIONS.has(action.action)) return void 0;
702
+ if (!action.value?.includes(REDACTION_PREFIX)) return void 0;
703
+ return `refused: the value was NOT typed, because it contains a redaction label. ${REDACTION_PREFIX} NAME] stands for the value of {{env.NAME}}, withheld from you; it is never a value the application should receive. To enter an environment value, use the {{env.*}} placeholder this step names. If the step names none, use a value it supplies, or fail the step.`;
642
704
  }
643
705
  repeatedCommitRefusal(action) {
644
706
  if (!COMMIT_ACTIONS.has(action.action)) return void 0;
@@ -1410,7 +1472,7 @@ Rules:
1410
1472
  - An action reported as "blocked" is the exception to that rule: it means another element is on top of your target, not that you picked the wrong target. Re-targeting cannot fix it. Whatever is covering the page is in the snapshot \u2014 a dialog, a cookie banner, an onboarding overlay \u2014 so dismiss that first, with its own close or accept control, or by pressing Escape with no target, and then act on your original target again. Overlays can be stacked: clearing one may reveal another, and that is progress, not failure.
1411
1473
  - Never invent a value. A value you type must come from the step, from the page, or from an {{env.*}} placeholder. This one is enforced, not merely asked: a fill or select whose value is in none of those is refused and not performed. If a step needs a value it does not give you, that is a failing step, not a gap for you to fill in.
1412
1474
  - A record of the actions you already performed in this step may be shown to you. It is the ground truth about what happened, even when the page no longer shows it: a form that submitted successfully and came back empty looks exactly like one you never submitted. Do not redo work that record says you already did.
1413
- - \`***\` in a snapshot is a redacted secret \u2014 a password, token or key deliberately withheld from you. Seeing it is expected and is not a problem. A field showing \`***\` after you filled it from an {{env.VAR}} placeholder means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, and never report failure because a value was withheld.
1475
+ - \`[redacted NAME]\` in a snapshot is the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected and is not a problem. The same label is the same value; two different labels are two different values. A field showing \`[redacted NAME]\` after you filled it from {{env.NAME}} means the fill worked; treat that as success and move on. Never retry a fill because its value is redacted, never report failure because a value was withheld, and never type a label as a value: to enter an environment value, use the {{env.*}} placeholder the step names.
1414
1476
  - Keep reasoning to one short sentence.`;
1415
1477
  }
1416
1478
  function agentUserPrompt(input) {
@@ -1446,7 +1508,7 @@ The record is not evidence that the step's outcome holds. An action reported as
1446
1508
 
1447
1509
  Be strict about what the step asks, not about withholding a pass you can plainly see is earned. Answer with pass=true/false and a one-sentence reason.
1448
1510
 
1449
- \`***\` marks a secret deliberately withheld from you \u2014 a password, token or key. Seeing it is expected. A field holding \`***\` is filled, not empty, so do not fail a step on the grounds that a value was redacted. This applies only to the redaction itself: everything else the step asks for must still be visibly satisfied by the snapshot, and a step you genuinely cannot check against what you were shown still fails.`;
1511
+ \`[redacted NAME]\` marks the value of {{env.NAME}}, deliberately withheld from you. Seeing one is expected. A field holding a label is filled, not empty, so do not fail a step on the grounds that a value was redacted. The same label is the same value, and two different labels are two different values: a step expecting \`[redacted A]\` is not satisfied by a page showing \`[redacted B]\`. This applies only to the redaction itself: everything else the step asks for must still be visibly satisfied by the snapshot, and a step you genuinely cannot check against what you were shown still fails.`;
1450
1512
  }
1451
1513
  function assertUserPrompt(step, expectation, snapshot, stepHistory) {
1452
1514
  const parts = [`Step under test: ${step}`];
@@ -1481,7 +1543,7 @@ Rules:
1481
1543
  - **The test starts at the application's base URL, not at this route.** Begin with a step that navigates to the route and says what should be visible once it loads \u2014 "navigate to /support and verify the heading "Contact support" is shown". Without it the run opens the home page and every later step looks for controls that are not there.
1482
1544
  - **Every step says what it should produce.** Name what must be true once the step has been carried out, not the action alone: "submit the support form and verify the confirmation page shows the ticket number", never "submit the support form". A step that names an action without an outcome asks the runner to judge whether something happened while looking at the page that succeeding produces \u2014 a submitted form comes back empty, a redirect moves the URL \u2014 and that is the shape behind several real failures.
1483
1545
  - **A step that enters a value writes the value.** "fill the subject field with Order not received", never "enter a subject". The runner is forbidden from inventing values, and enforces it: a fill whose value is in neither the step nor the page is refused, so a step that supplies none cannot be relied on to run.
1484
- - If a step needs a credential or any secret, write it as a placeholder like {{env.TEST_PASSWORD}}. Never write a real or invented password, token or key.
1546
+ - If a step needs a credential, or any value that identifies an account or a person \u2014 an email address, a username, an account or customer number \u2014 write it as a placeholder like {{env.TEST_EMAIL}} or {{env.TEST_PASSWORD}}. Never write a real or invented one.
1485
1547
  - Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
1486
1548
  }
1487
1549
  function plannerUserPrompt(input) {
@@ -1697,6 +1759,41 @@ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1697
1759
  import path6 from "path";
1698
1760
  import { stringify } from "yaml";
1699
1761
 
1762
+ // src/runner/authoring.ts
1763
+ var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
1764
+ var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
1765
+ var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
1766
+ var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
1767
+ var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
1768
+ var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
1769
+ function namesAValue(step) {
1770
+ const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
1771
+ return CONNECTOR_WORD.test(withoutPhrasal) || CONNECTOR_SYMBOL.test(withoutPhrasal);
1772
+ }
1773
+ function namesNoValue(step) {
1774
+ return LEADING_VALUE_VERB.test(step) && !namesAValue(step);
1775
+ }
1776
+ function entersNamedValue(step) {
1777
+ return LEADING_VALUE_VERB.test(step) && namesAValue(step);
1778
+ }
1779
+ function detectMissingValues(tests) {
1780
+ const findings = [];
1781
+ for (const test of tests) {
1782
+ for (const origin of ["setup", "steps"]) {
1783
+ const steps = origin === "setup" ? test.setup ?? [] : test.steps;
1784
+ steps.forEach((step, position) => {
1785
+ if (namesNoValue(step)) {
1786
+ findings.push({ test, origin, index: position + 1, step });
1787
+ }
1788
+ });
1789
+ }
1790
+ }
1791
+ return { findings };
1792
+ }
1793
+ function suggestValueClause(step) {
1794
+ return `${step.trimEnd()} with <value>`;
1795
+ }
1796
+
1700
1797
  // src/runner/testfile.ts
1701
1798
  import { readFile as readFile3, readdir } from "fs/promises";
1702
1799
  import path5 from "path";
@@ -1772,9 +1869,20 @@ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\
1772
1869
  var QUOTED_LITERAL = /["'][^"']+["']/;
1773
1870
  function findSecretLiterals(steps) {
1774
1871
  return steps.filter(
1775
- (step) => CREDENTIAL_WORD.test(step) && QUOTED_LITERAL.test(step) && !step.includes("{{env.")
1872
+ (step) => CREDENTIAL_WORD.test(step) && !step.includes("{{env.") && (QUOTED_LITERAL.test(step) || entersNamedValue(step))
1776
1873
  );
1777
1874
  }
1875
+ var EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/g;
1876
+ function findUnsourcedEmails(steps, snapshot) {
1877
+ const page = snapshot.toLowerCase();
1878
+ const found = [];
1879
+ steps.forEach((text, step) => {
1880
+ for (const [address] of text.matchAll(EMAIL_ADDRESS)) {
1881
+ if (!page.includes(address.toLowerCase())) found.push({ step, address });
1882
+ }
1883
+ });
1884
+ return found;
1885
+ }
1778
1886
  function routeToSlug(route) {
1779
1887
  const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1780
1888
  return slug || "home";
@@ -1806,9 +1914,10 @@ async function generateForRoute(page, options) {
1806
1914
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1807
1915
  );
1808
1916
  }
1917
+ const pageSnapshot = mask(await takeSnapshot(page));
1809
1918
  const generated = await brain.planTest({
1810
1919
  route,
1811
- snapshot: mask(await takeSnapshot(page)),
1920
+ snapshot: pageSnapshot,
1812
1921
  changedFiles
1813
1922
  });
1814
1923
  const leaked = findSecretLiterals(generated.steps);
@@ -1817,7 +1926,11 @@ async function generateForRoute(page, options) {
1817
1926
  `Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
1818
1927
  );
1819
1928
  }
1820
- return { ...generated, routes: [route] };
1929
+ return {
1930
+ ...generated,
1931
+ routes: [route],
1932
+ unsourcedEmails: findUnsourcedEmails(generated.steps, pageSnapshot)
1933
+ };
1821
1934
  }
1822
1935
  async function writeDraft(cwd, draft, meta) {
1823
1936
  const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
@@ -2071,7 +2184,11 @@ function renderSteps(steps) {
2071
2184
  ${items}
2072
2185
  </ol>`;
2073
2186
  }
2074
- async function renderTest(result, cwd) {
2187
+ function withheldScreenshot(file, reportDir) {
2188
+ const href = path7.relative(reportDir, file).split(path7.sep).join("/");
2189
+ 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>`;
2190
+ }
2191
+ async function renderTest(result, screenshots, cwd) {
2075
2192
  const failed = result.status === "failed";
2076
2193
  const notRun = result.status === "not-run";
2077
2194
  const tagClass = failed ? "fail" : notRun ? "skip" : "pass";
@@ -2093,7 +2210,9 @@ async function renderTest(result, cwd) {
2093
2210
  parts.push(` <p class="note">${escapeHtml(result.reason)}</p>`);
2094
2211
  }
2095
2212
  parts.push(` ${renderSteps(result.steps)}`);
2096
- if (failed && result.screenshot) {
2213
+ if (failed && result.screenshot && screenshots !== "embed") {
2214
+ parts.push(withheldScreenshot(result.screenshot, screenshots.withheldRelativeTo));
2215
+ } else if (failed && result.screenshot) {
2097
2216
  const embedded = await embedScreenshot(result.screenshot);
2098
2217
  parts.push(
2099
2218
  embedded ? ` <img class="shot" alt="Screenshot at failure" src="${embedded}">` : ' <p class="note">Screenshot unavailable.</p>'
@@ -2108,7 +2227,7 @@ async function renderHtml(results, skipped, meta) {
2108
2227
  const notRun = results.filter((r) => r.status === "not-run");
2109
2228
  const generatedAt = (meta.generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
2110
2229
  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");
2230
+ const detail = (await Promise.all(ordered.map((r) => renderTest(r, meta.screenshots, meta.cwd)))).join("\n");
2112
2231
  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
2232
  const banner = meta.incomplete === void 0 ? "" : ` <section class="incomplete">Run stopped: ${escapeHtml(meta.incomplete)}</section>
2114
2233
 
@@ -2152,7 +2271,7 @@ ${rows}${rows && skippedRows ? "\n" : ""}${skippedRows}
2152
2271
 
2153
2272
  ${detail}
2154
2273
 
2155
- <footer>Generated by blastproof. Screenshots are embedded, so this file works offline.</footer>
2274
+ <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
2275
  </main>
2157
2276
  </body>
2158
2277
  </html>
@@ -2249,36 +2368,6 @@ async function writeJUnit(file, xml) {
2249
2368
  }
2250
2369
  }
2251
2370
 
2252
- // src/runner/authoring.ts
2253
- var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
2254
- var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
2255
- var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
2256
- var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
2257
- var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
2258
- var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
2259
- function namesNoValue(step) {
2260
- if (!LEADING_VALUE_VERB.test(step)) return false;
2261
- const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
2262
- return !CONNECTOR_WORD.test(withoutPhrasal) && !CONNECTOR_SYMBOL.test(withoutPhrasal);
2263
- }
2264
- function detectMissingValues(tests) {
2265
- const findings = [];
2266
- for (const test of tests) {
2267
- for (const origin of ["setup", "steps"]) {
2268
- const steps = origin === "setup" ? test.setup ?? [] : test.steps;
2269
- steps.forEach((step, position) => {
2270
- if (namesNoValue(step)) {
2271
- findings.push({ test, origin, index: position + 1, step });
2272
- }
2273
- });
2274
- }
2275
- }
2276
- return { findings };
2277
- }
2278
- function suggestValueClause(step) {
2279
- return `${step.trimEnd()} with <value>`;
2280
- }
2281
-
2282
2371
  // src/runner/pool.ts
2283
2372
  async function runWithConcurrency(items, concurrency, run) {
2284
2373
  if (concurrency < 1) throw new RangeError(`concurrency must be at least 1, got ${concurrency}`);
@@ -2522,8 +2611,16 @@ function printImpactReport(impact, selection, cwd) {
2522
2611
  }
2523
2612
  console.log("---------------------------------------------------------------");
2524
2613
  }
2525
- async function finalize(results, skipped, options, sessionDir, durationMs, impact, incomplete, spend) {
2614
+ function reportNearMissedSecrets(variables) {
2615
+ for (const variable of variables) {
2616
+ console.error(
2617
+ `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.`
2618
+ );
2619
+ }
2620
+ }
2621
+ async function finalize(results, skipped, options, sessionDir, secretsHeld, nearMissedSecrets, durationMs, impact, incomplete, spend) {
2526
2622
  if (results.length > 0) printSummary(results);
2623
+ reportNearMissedSecrets(nearMissedSecrets);
2527
2624
  if (spend) console.log(formatSpendLine(spend));
2528
2625
  const score = computeScore(results);
2529
2626
  console.log(
@@ -2549,7 +2646,13 @@ async function finalize(results, skipped, options, sessionDir, durationMs, impac
2549
2646
  minScore: options.minScore,
2550
2647
  cwd: options.cwd,
2551
2648
  incomplete: incomplete?.message,
2552
- spend
2649
+ spend,
2650
+ // A screenshot cannot be masked, so a run that held any `{{env.*}}` value
2651
+ // links to it instead of carrying it (design
2652
+ // withhold-a-screenshot-that-saw-a-secret, D1): the run's mask, not the
2653
+ // failed test's own placeholders, because a login credential is echoed by
2654
+ // the pages of every test that comes after it.
2655
+ screenshots: secretsHeld ? { withheldRelativeTo: path9.dirname(target) } : "embed"
2553
2656
  });
2554
2657
  await writeHtml(target, html);
2555
2658
  console.log(`HTML report: ${path9.relative(options.cwd, target)}`);
@@ -2720,7 +2823,7 @@ ${results.length} test file(s) could not be parsed:`);
2720
2823
  console.log(
2721
2824
  impact ? "No impacted tests to run." : "No tests matched the given filters."
2722
2825
  );
2723
- return finalize(results, selection.unroutedSkipped, options, sessionDir, Date.now() - startedAt, impact);
2826
+ return finalize(results, selection.unroutedSkipped, options, sessionDir, false, [], Date.now() - startedAt, impact);
2724
2827
  }
2725
2828
  try {
2726
2829
  createModel(config.llm);
@@ -2818,6 +2921,8 @@ ${results.length} test file(s) could not be parsed:`);
2818
2921
  selection.unroutedSkipped,
2819
2922
  options,
2820
2923
  sessionDir,
2924
+ !runMask.isEmpty(),
2925
+ runMask.nearMissedVariables(),
2821
2926
  Date.now() - startedAt,
2822
2927
  impact,
2823
2928
  incomplete,
@@ -3013,6 +3118,12 @@ async function planCommand(options) {
3013
3118
  } finally {
3014
3119
  await context.close();
3015
3120
  }
3121
+ for (const { step, address } of draft.unsourcedEmails) {
3122
+ console.error(
3123
+ ` warning: step ${step + 1} writes an email address the page does not show (${address}).
3124
+ If it is the account the test signs in as, use a placeholder like {{env.TEST_EMAIL}}.`
3125
+ );
3126
+ }
3016
3127
  generated.push(route);
3017
3128
  if (options.write) {
3018
3129
  try {
@@ -3145,7 +3256,7 @@ function parsePositiveNumber(flag) {
3145
3256
  };
3146
3257
  }
3147
3258
  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");
3259
+ program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.21.0");
3149
3260
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3150
3261
  try {
3151
3262
  const result = await initProject(process.cwd());