blastproof 0.20.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
@@ -275,11 +275,11 @@ The application under test is not trusted input: its page content reaches the mo
275
275
 
276
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.
277
277
 
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.
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
279
 
280
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)).
281
281
 
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.
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.*}}`.
283
283
 
284
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.
285
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]
@@ -339,16 +339,20 @@ function escapeRegExp(value) {
339
339
  function secretPattern(secret) {
340
340
  return new RegExp(escapeRegExp(secret).replace(/\s+/g, "\\s+"), "gi");
341
341
  }
342
- function compile(secrets) {
343
- return [...secrets].filter(Boolean).sort((a, b) => b.length - a.length).map((value) => ({ value, pattern: secretPattern(value) }));
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)) }));
344
348
  }
345
349
  function maskCompiled(text, compiled, onNearMiss) {
346
350
  let masked = text;
347
- for (const { value, pattern } of compiled) {
351
+ for (const { value, pattern, label } of compiled) {
348
352
  pattern.lastIndex = 0;
349
353
  masked = masked.replace(pattern, (found) => {
350
354
  if (found !== value) onNearMiss?.(value, found);
351
- return "***";
355
+ return label;
352
356
  });
353
357
  }
354
358
  return masked;
@@ -388,7 +392,7 @@ var SecretsMask = class {
388
392
  }
389
393
  }
390
394
  mask(text) {
391
- this.compiled ??= compile(this.secrets);
395
+ this.compiled ??= compile(this.secrets, this.names);
392
396
  return maskCompiled(text, this.compiled, (secret) => {
393
397
  const name = this.names.get(secret);
394
398
  if (name !== void 0) this.nearMissed.add(name);
@@ -677,7 +681,26 @@ var StepRecovery = class {
677
681
  * and #28 has now produced one on three applications.
678
682
  */
679
683
  refusalFor(action) {
680
- 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.`;
681
704
  }
682
705
  repeatedCommitRefusal(action) {
683
706
  if (!COMMIT_ACTIONS.has(action.action)) return void 0;
@@ -1449,7 +1472,7 @@ Rules:
1449
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.
1450
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.
1451
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.
1452
- - \`***\` 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.
1453
1476
  - Keep reasoning to one short sentence.`;
1454
1477
  }
1455
1478
  function agentUserPrompt(input) {
@@ -1485,7 +1508,7 @@ The record is not evidence that the step's outcome holds. An action reported as
1485
1508
 
1486
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.
1487
1510
 
1488
- \`***\` 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.`;
1489
1512
  }
1490
1513
  function assertUserPrompt(step, expectation, snapshot, stepHistory) {
1491
1514
  const parts = [`Step under test: ${step}`];
@@ -1520,7 +1543,7 @@ Rules:
1520
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.
1521
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.
1522
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.
1523
- - 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.
1524
1547
  - Keep the whole test to a handful of steps: one journey, not an exhaustive suite.`;
1525
1548
  }
1526
1549
  function plannerUserPrompt(input) {
@@ -1736,6 +1759,41 @@ import { mkdir as mkdir4, writeFile as writeFile3 } from "fs/promises";
1736
1759
  import path6 from "path";
1737
1760
  import { stringify } from "yaml";
1738
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
+
1739
1797
  // src/runner/testfile.ts
1740
1798
  import { readFile as readFile3, readdir } from "fs/promises";
1741
1799
  import path5 from "path";
@@ -1811,9 +1869,20 @@ var CREDENTIAL_WORD = /\b(password|passwd|api[ _-]?key|token|secret|credential)\
1811
1869
  var QUOTED_LITERAL = /["'][^"']+["']/;
1812
1870
  function findSecretLiterals(steps) {
1813
1871
  return steps.filter(
1814
- (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))
1815
1873
  );
1816
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
+ }
1817
1886
  function routeToSlug(route) {
1818
1887
  const slug = route.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
1819
1888
  return slug || "home";
@@ -1845,9 +1914,10 @@ async function generateForRoute(page, options) {
1845
1914
  `Cannot load ${url}: ${error instanceof Error ? error.message : String(error)}`
1846
1915
  );
1847
1916
  }
1917
+ const pageSnapshot = mask(await takeSnapshot(page));
1848
1918
  const generated = await brain.planTest({
1849
1919
  route,
1850
- snapshot: mask(await takeSnapshot(page)),
1920
+ snapshot: pageSnapshot,
1851
1921
  changedFiles
1852
1922
  });
1853
1923
  const leaked = findSecretLiterals(generated.steps);
@@ -1856,7 +1926,11 @@ async function generateForRoute(page, options) {
1856
1926
  `Generated steps contain literal secrets instead of {{env.VAR}} placeholders: ${leaked.join(" | ")}`
1857
1927
  );
1858
1928
  }
1859
- return { ...generated, routes: [route] };
1929
+ return {
1930
+ ...generated,
1931
+ routes: [route],
1932
+ unsourcedEmails: findUnsourcedEmails(generated.steps, pageSnapshot)
1933
+ };
1860
1934
  }
1861
1935
  async function writeDraft(cwd, draft, meta) {
1862
1936
  const dir = path6.join(cwd, TESTS_RELATIVE_DIR);
@@ -2294,36 +2368,6 @@ async function writeJUnit(file, xml) {
2294
2368
  }
2295
2369
  }
2296
2370
 
2297
- // src/runner/authoring.ts
2298
- var VALUE_VERBS = ["fill", "enter", "type", "input", "set"];
2299
- var LEADING_VALUE_VERB = new RegExp(`^\\s*(?:${VALUE_VERBS.join("|")})\\b`, "i");
2300
- var CONNECTOR_WORDS = ["with", "to", "as", "using", "into", "from", "in"];
2301
- var CONNECTOR_WORD = new RegExp(`\\b(?:${CONNECTOR_WORDS.join("|")})\\b`, "i");
2302
- var CONNECTOR_SYMBOL = /["'`:=]|\{\{env\./i;
2303
- var PHRASAL_IN = new RegExp(`^(\\s*(?:${VALUE_VERBS.join("|")}))\\s+in\\b`, "i");
2304
- function namesNoValue(step) {
2305
- if (!LEADING_VALUE_VERB.test(step)) return false;
2306
- const withoutPhrasal = step.replace(PHRASAL_IN, "$1");
2307
- return !CONNECTOR_WORD.test(withoutPhrasal) && !CONNECTOR_SYMBOL.test(withoutPhrasal);
2308
- }
2309
- function detectMissingValues(tests) {
2310
- const findings = [];
2311
- for (const test of tests) {
2312
- for (const origin of ["setup", "steps"]) {
2313
- const steps = origin === "setup" ? test.setup ?? [] : test.steps;
2314
- steps.forEach((step, position) => {
2315
- if (namesNoValue(step)) {
2316
- findings.push({ test, origin, index: position + 1, step });
2317
- }
2318
- });
2319
- }
2320
- }
2321
- return { findings };
2322
- }
2323
- function suggestValueClause(step) {
2324
- return `${step.trimEnd()} with <value>`;
2325
- }
2326
-
2327
2371
  // src/runner/pool.ts
2328
2372
  async function runWithConcurrency(items, concurrency, run) {
2329
2373
  if (concurrency < 1) throw new RangeError(`concurrency must be at least 1, got ${concurrency}`);
@@ -3074,6 +3118,12 @@ async function planCommand(options) {
3074
3118
  } finally {
3075
3119
  await context.close();
3076
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
+ }
3077
3127
  generated.push(route);
3078
3128
  if (options.write) {
3079
3129
  try {
@@ -3206,7 +3256,7 @@ function parsePositiveNumber(flag) {
3206
3256
  };
3207
3257
  }
3208
3258
  var program = new Command();
3209
- program.name("blastproof").description("Open-source AI testing agent: plain-English YAML tests executed agentically on a real browser.").version("0.20.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");
3210
3260
  program.command("init").description("Scaffold .blastproof/ (config, tests, sample tests) in the current directory").action(async () => {
3211
3261
  try {
3212
3262
  const result = await initProject(process.cwd());