@trusty-squire/mcp 1.1.28 → 1.1.29-rc.2

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.
Files changed (86) hide show
  1. package/dist/api-client.d.ts +6 -0
  2. package/dist/api-client.d.ts.map +1 -1
  3. package/dist/api-client.js.map +1 -1
  4. package/dist/bot/act/act.d.ts.map +1 -1
  5. package/dist/bot/act/act.js +15 -11
  6. package/dist/bot/act/act.js.map +1 -1
  7. package/dist/bot/browser.d.ts +0 -29
  8. package/dist/bot/browser.d.ts.map +1 -1
  9. package/dist/bot/browser.js +2 -747
  10. package/dist/bot/browser.js.map +1 -1
  11. package/dist/bot/capture/capture.d.ts +7 -15
  12. package/dist/bot/capture/capture.d.ts.map +1 -1
  13. package/dist/bot/capture/capture.js +71 -253
  14. package/dist/bot/capture/capture.js.map +1 -1
  15. package/dist/bot/card-release-approval.d.ts.map +1 -1
  16. package/dist/bot/card-release-approval.js +11 -2
  17. package/dist/bot/card-release-approval.js.map +1 -1
  18. package/dist/bot/credential-capture.d.ts +10 -0
  19. package/dist/bot/credential-capture.d.ts.map +1 -1
  20. package/dist/bot/credential-capture.js +5 -0
  21. package/dist/bot/credential-capture.js.map +1 -1
  22. package/dist/bot/credential-shape.d.ts +0 -5
  23. package/dist/bot/credential-shape.d.ts.map +1 -1
  24. package/dist/bot/credential-shape.js +8 -132
  25. package/dist/bot/credential-shape.js.map +1 -1
  26. package/dist/bot/drive-feedback.d.ts +2 -8
  27. package/dist/bot/drive-feedback.d.ts.map +1 -1
  28. package/dist/bot/drive-feedback.js +7 -49
  29. package/dist/bot/drive-feedback.js.map +1 -1
  30. package/dist/bot/jev-client.d.ts +3 -0
  31. package/dist/bot/jev-client.d.ts.map +1 -1
  32. package/dist/bot/jev-client.js +14 -5
  33. package/dist/bot/jev-client.js.map +1 -1
  34. package/dist/bot/operate-drive.d.ts +13 -63
  35. package/dist/bot/operate-drive.d.ts.map +1 -1
  36. package/dist/bot/operate-drive.js +136 -592
  37. package/dist/bot/operate-drive.js.map +1 -1
  38. package/dist/bot/page-driver.js +1 -1
  39. package/dist/bot/page-driver.js.map +1 -1
  40. package/dist/bot/provision-session.d.ts +3 -1
  41. package/dist/bot/provision-session.d.ts.map +1 -1
  42. package/dist/bot/provision-session.js +1 -1
  43. package/dist/bot/provision-session.js.map +1 -1
  44. package/dist/bot/session/lifecycle.d.ts.map +1 -1
  45. package/dist/bot/session/lifecycle.js +14 -13
  46. package/dist/bot/session/lifecycle.js.map +1 -1
  47. package/dist/bot/skill-hint.d.ts.map +1 -1
  48. package/dist/bot/skill-hint.js +4 -3
  49. package/dist/bot/skill-hint.js.map +1 -1
  50. package/dist/install/broker-service.d.ts.map +1 -1
  51. package/dist/install/broker-service.js +13 -15
  52. package/dist/install/broker-service.js.map +1 -1
  53. package/dist/install/cli.d.ts.map +1 -1
  54. package/dist/install/cli.js +43 -32
  55. package/dist/install/cli.js.map +1 -1
  56. package/dist/install/connect-report.d.ts +7 -7
  57. package/dist/install/connect-report.d.ts.map +1 -1
  58. package/dist/install/connect-report.js +31 -25
  59. package/dist/install/connect-report.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +42 -3
  62. package/dist/server.js.map +1 -1
  63. package/dist/tools/fetch-credential.d.ts.map +1 -1
  64. package/dist/tools/fetch-credential.js +3 -1
  65. package/dist/tools/fetch-credential.js.map +1 -1
  66. package/dist/tools/provision-drive-schema.d.ts +0 -1
  67. package/dist/tools/provision-drive-schema.d.ts.map +1 -1
  68. package/dist/tools/provision-drive-schema.js +125 -238
  69. package/dist/tools/provision-drive-schema.js.map +1 -1
  70. package/dist/tools/provision-drive.d.ts +217 -73
  71. package/dist/tools/provision-drive.d.ts.map +1 -1
  72. package/dist/tools/provision-drive.js +133 -180
  73. package/dist/tools/provision-drive.js.map +1 -1
  74. package/dist/tools/store-credential.d.ts.map +1 -1
  75. package/dist/tools/store-credential.js +5 -1
  76. package/dist/tools/store-credential.js.map +1 -1
  77. package/dist/tools/use-credential.js +2 -2
  78. package/package.json +1 -1
  79. package/dist/bot/credential-text.d.ts +0 -4
  80. package/dist/bot/credential-text.d.ts.map +0 -1
  81. package/dist/bot/credential-text.js +0 -296
  82. package/dist/bot/credential-text.js.map +0 -1
  83. package/dist/bot/extraction.d.ts +0 -18
  84. package/dist/bot/extraction.d.ts.map +0 -1
  85. package/dist/bot/extraction.js +0 -54
  86. package/dist/bot/extraction.js.map +0 -1
@@ -513,8 +513,6 @@ export class BrowserController {
513
513
  return "closed";
514
514
  }
515
515
  async initializePages(context, hardened, remoteMode) {
516
- // Speed: optionally abort heavy/irrelevant requests before any navigation.
517
- await this.installResourceBlocking();
518
516
  const contextInitScripts = contextInitScriptsFor({ hardened, remoteMode });
519
517
  // Never register context init scripts under patchright. Its injection path
520
518
  // rewrites text/html after decoding the response as UTF-8, corrupting
@@ -740,54 +738,6 @@ export class BrowserController {
740
738
  get stealthProfile() {
741
739
  return this.processOwner.stealthProfile;
742
740
  }
743
- // Resource blocking for speed (BOT_BLOCK_RESOURCES, default OFF). Aborts
744
- // image/media/font requests to cut page-load wall-clock. Exempt captcha/challenge + payment
745
- // scripts (blocking those breaks the Turnstile/hCaptcha token poll and the
746
- // signup form). CSS + first-party JS are never blocked (not in BLOCK_TYPES) —
747
- // the SPA form renders from them and the vision planner reads the styled
748
- // render. DUAL RISK, hence default-OFF + an OF#2 A/B before flipping on:
749
- // (1) a browser that loads ZERO images is itself an anti-bot fingerprint;
750
- // (2) the screenshot the vision planner reads loses detail — mitigated
751
- // because the DOM inventory is the authoritative action space, but
752
- // still a regression risk on image-only affordances.
753
- // Registered on the CONTEXT so it covers OAuth popups + iframes.
754
- async installResourceBlocking() {
755
- const ctx = this.context;
756
- if (ctx === null)
757
- return;
758
- if (!/^(1|true|on)$/i.test(process.env.BOT_BLOCK_RESOURCES ?? ""))
759
- return;
760
- const BLOCK_TYPES = new Set(["image", "media", "font"]);
761
- // NEVER block — these break signup (captcha/challenge widgets + payment SDK).
762
- const ALWAYS_ALLOW = [
763
- "challenges.cloudflare.com",
764
- "turnstile",
765
- "hcaptcha.com",
766
- "newassets.hcaptcha.com",
767
- "recaptcha",
768
- "gstatic.com/recaptcha",
769
- "js.stripe.com",
770
- ];
771
- await ctx.route("**/*", async (route) => {
772
- try {
773
- const url = route.request().url();
774
- if (ALWAYS_ALLOW.some((h) => url.includes(h))) {
775
- await route.continue();
776
- return;
777
- }
778
- const type = route.request().resourceType();
779
- if (BLOCK_TYPES.has(type)) {
780
- await route.abort();
781
- return;
782
- }
783
- await route.continue();
784
- }
785
- catch {
786
- // Routing race / already-handled — never let a decision crash nav.
787
- }
788
- });
789
- this.logOperatorDiagnostic("[operator] resource blocking ON (image/media/font aborted; captcha/CSS/JS allowed)");
790
- }
791
741
  async start() {
792
742
  // A satellite's page is already attached by attachSessionPage() — there is
793
743
  // no process for it to start.
@@ -3840,51 +3790,8 @@ export class BrowserController {
3840
3790
  await cdp.detach().catch(() => undefined);
3841
3791
  }
3842
3792
  }
3843
- async getState(page = this.page) {
3844
- if (!page)
3845
- throw new Error("Browser not started");
3846
- // page.content() / page.title() / screenshot() all throw
3847
- // "Execution context was destroyed" when the page is mid-
3848
- // navigation — common after an OAuth-button click that kicks off
3849
- // a 3-5 hop redirect chain (sentry.io → accounts.google.com →
3850
- // consent → callback → onboarding). Retry once after a short
3851
- // settle: most navigations finish in <500ms even on slow links.
3852
- try {
3853
- return await this.snapshotState(page);
3854
- }
3855
- catch {
3856
- await this.wait(0.8);
3857
- return await this.snapshotState(page);
3858
- }
3859
- }
3860
- async snapshotState(page = this.page) {
3861
- if (!page)
3862
- throw new Error("Browser not started");
3863
- return {
3864
- url: page.url(),
3865
- title: await page.title(),
3866
- html: await page.content(),
3867
- screenshot: await page
3868
- .screenshot({ fullPage: false, type: "jpeg", quality: 70, timeout: 8_000 })
3869
- .then((shot) => shot.toString("base64"))
3870
- .catch(() => ""),
3871
- };
3872
- }
3873
- async extractText() {
3874
- if (!this.page)
3875
- throw new Error("Browser not started");
3876
- return (await this.page.textContent("body")) || "";
3877
- }
3878
- // RENDERED, visibility-respecting body text. extractText() reads
3879
- // textContent("body"), which includes display:none / visibility:hidden /
3880
- // off-screen nodes — so a fully-rendered dashboard whose DOM merely
3881
- // CONTAINS a hidden skeleton / "Loading…" / "Please wait 30 seconds…"
3882
- // string (Next.js RSC inline payloads, lazy placeholders, aria-hidden
3883
- // spinners) reads as still-loading and false-trips the loading-shell gate.
3884
- // innerText is layout-aware: it omits hidden text and reflects what a user
3885
- // would actually see. Use this for the SHELL decision ONLY — credential/key
3886
- // extraction and wall-text checks deliberately read RAW text via
3887
- // extractText() and must stay byte-identical, so this is purely additive.
3793
+ // Visible page text omits hidden skeletons and placeholders that could
3794
+ // otherwise make a rendered page look like it is still loading.
3888
3795
  async extractVisibleText(page = this.page) {
3889
3796
  if (page === null)
3890
3797
  throw new Error("Browser not started");
@@ -4195,20 +4102,6 @@ export class BrowserController {
4195
4102
  return false;
4196
4103
  }
4197
4104
  }
4198
- // Discrete strings an API key might occupy — for credential
4199
- // extraction. Gathered so a key is read WHOLE and un-glued from its
4200
- // neighbours: extractText() concatenates the whole <body>, which
4201
- // fuses a key to an adjacent "Copy"/"Done" button with no separator.
4202
- //
4203
- // Two surfaces:
4204
- // 1. input/textarea VALUES — a copy-to-clipboard key field. An
4205
- // input's value is not in textContent at all. Hidden and
4206
- // password fields are excluded (captcha tokens / the signup
4207
- // password), keeping this a clean credential surface.
4208
- // 2. Each element's OWN direct text — the text nodes that are its
4209
- // immediate children, excluding descendants. A key in a
4210
- // <code>/<span>/<div> yields its clean value here even when a
4211
- // sibling button shares the same parent.
4212
4105
  // F10: read the clipboard contents (typically populated by the
4213
4106
  // user-modal's Copy button — every modern API-key reveal modal puts
4214
4107
  // the full secret here while displaying a masked stub). Requires
@@ -4247,644 +4140,6 @@ export class BrowserController {
4247
4140
  }
4248
4141
  });
4249
4142
  }
4250
- // F10 fallback: ALL <input> / <textarea> values, ignoring
4251
- // visibility and type filters. extractCredentialCandidates
4252
- // deliberately skips `type=hidden` / `type=password` / invisible
4253
- // elements (correct for general candidate scanning), but some
4254
- // API-key modals stash the full key in a hidden input the masked
4255
- // display reads from — and that needs to be reachable when the
4256
- // visible extraction comes back truncated.
4257
- async extractAllInputValues(page = this.page) {
4258
- if (!page)
4259
- throw new Error("Browser not started");
4260
- return await page.evaluate(() => {
4261
- const out = [];
4262
- document.querySelectorAll("input, textarea").forEach((el) => {
4263
- if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement))
4264
- return;
4265
- // Only text-shaped controls RENDER a value. A checkbox/radio/button's
4266
- // `value` is a markup constant (or the control's id) and must never be
4267
- // read as a credential candidate.
4268
- if (el instanceof HTMLInputElement &&
4269
- !["text", "search", "url", "tel", "number", "email", ""].includes(el.type))
4270
- return;
4271
- const value = el.value;
4272
- if (value.trim().length > 0)
4273
- out.push(value.trim());
4274
- });
4275
- return out;
4276
- });
4277
- }
4278
- // Last-resort scan: walk innerText looking for credential-shaped
4279
- // tokens (UUIDs and other long alnum+hyphen blobs) inside any DOM
4280
- // subtree that ALSO contains a "Copy" / "Copy token" / "Copy to
4281
- // clipboard" affordance. The Copy-button colocation is what tells
4282
- // us "the UI is presenting this string AS a credential" — without
4283
- // it, we'd false-positive on session IDs in URLs, cache-buster
4284
- // query params, etc. Returns every match it finds; the caller picks
4285
- // the first that survives extractApiKeyFromText.
4286
- async extractCredentialsNearCopyButtons(page = this.page) {
4287
- if (!page)
4288
- throw new Error("Browser not started");
4289
- return await page.evaluate(() => {
4290
- const out = [];
4291
- const isVisible = (el) => {
4292
- const r = el.getBoundingClientRect();
4293
- return r.width > 2 && r.height > 2;
4294
- };
4295
- // Find every Copy- OR reveal-class affordance. A secret table-cell value
4296
- // (deepinfra's keys table) lives in a row next to BOTH a copy and a
4297
- // toggle-visibility/reveal control — but those are often icon buttons
4298
- // whose accessible NAME is the row's date, not "copy". So match the
4299
- // element's id / class / data-testid too, which carry the semantic name
4300
- // ("copy-key", "toggle-token-visibility"). Reveal patterns are scoped to
4301
- // key/token/secret/visibility context so a generic "Show more" doesn't
4302
- // anchor a harvest.
4303
- const copyButtons = Array.from(document.querySelectorAll('button, [role="button"], a, [aria-label]')).filter((el) => {
4304
- if (!isVisible(el))
4305
- return false;
4306
- const name = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""}`.toLowerCase();
4307
- // el.className is an SVGAnimatedString on SVG elements — read via attr.
4308
- const attrs = `${el.id} ${el.getAttribute("class") ?? ""} ${el.getAttribute("data-testid") ?? ""} ${el.getAttribute("data-test") ?? ""}`.toLowerCase();
4309
- const hay = `${name} ${attrs}`;
4310
- return /\bcopy\b|clipboard|reveal|toggle[-_ ]?(?:token|visibility)|show[-_ ]?(?:key|token|secret|api)/.test(hay);
4311
- });
4312
- // For each, walk up a few ancestors and dump the subtree's
4313
- // innerText. The token is somewhere in there.
4314
- const seen = new Set();
4315
- const harvest = (text) => {
4316
- if (text.length === 0 || text.length > 4096)
4317
- return;
4318
- // Tokenize by whitespace — each token is a separate candidate.
4319
- text.split(/\s+/).forEach((tok) => {
4320
- if (tok.length < 16 || tok.length > 256)
4321
- return;
4322
- if (seen.has(tok))
4323
- return;
4324
- seen.add(tok);
4325
- out.push(tok);
4326
- });
4327
- };
4328
- for (const btn of copyButtons) {
4329
- // The value often lives in the copy button's OWN aria-label/title
4330
- // ("Copy to clipboard: GOCSPX-…", "Copy api key sk-…") rather than in
4331
- // any visible text node — GCP's new client-secret reveal does exactly
4332
- // this, so the innerText-only walk below would miss it entirely.
4333
- harvest(`${btn.getAttribute("aria-label") ?? ""} ${btn.getAttribute("title") ?? ""}`.trim());
4334
- // Then walk up a few ancestors and dump the subtree's innerText.
4335
- let anc = btn;
4336
- for (let i = 0; i < 6 && anc !== null; i++) {
4337
- anc = anc.parentElement;
4338
- }
4339
- if (anc === null)
4340
- continue;
4341
- harvest((anc.innerText ?? "").trim());
4342
- }
4343
- return out;
4344
- });
4345
- }
4346
- // DOM-proximity labeled credential candidates. Walks every visible
4347
- // input/code/text element looking for credential-shape strings,
4348
- // pairs each one with its nearest credential-label text in the DOM
4349
- // tree, and returns the labeled tuples for the multi-cred extractor
4350
- // to fold into the credentials Record.
4351
- //
4352
- // Complements the Phase E planner-quoted extractor — when the
4353
- // planner's prose doesn't explicitly label values (multi-cred page
4354
- // where the planner missed one), this DOM-grounded pass picks them
4355
- // up via the visible labels the page itself renders.
4356
- //
4357
- // Returns shape:
4358
- // { value: "<credential-shape string>",
4359
- // label: "<the closest matching label text>" | null,
4360
- // isMasked: true if the value looks like a redacted display
4361
- // (••••, ****, contains "•" or runs of "*") }
4362
- //
4363
- // The caller maps label
4364
- // text to canonical credential keys using the same vocabulary the
4365
- // Phase E parser uses.
4366
- async extractLabeledCredentialCandidates(page = this.page) {
4367
- if (!page)
4368
- throw new Error("Browser not started");
4369
- return await page.evaluate(() => {
4370
- const LABEL_PHRASES = [
4371
- // Generic
4372
- "api key",
4373
- "api token",
4374
- "api secret",
4375
- "secret key",
4376
- "access key",
4377
- "access token",
4378
- "auth token",
4379
- "bearer token",
4380
- "personal access token",
4381
- "client id",
4382
- "client secret",
4383
- "team id",
4384
- "project id",
4385
- "client key",
4386
- // Cloudinary
4387
- "cloud name",
4388
- "cloudname",
4389
- // Algolia
4390
- "application id",
4391
- "app id",
4392
- "admin api key",
4393
- "search api key",
4394
- "monitoring api key",
4395
- "search-only api key",
4396
- // Twilio
4397
- "account sid",
4398
- "auth token",
4399
- // Stripe
4400
- "publishable key",
4401
- "secret key",
4402
- // AWS
4403
- "access key id",
4404
- "secret access key",
4405
- // OAuth1
4406
- "consumer key",
4407
- "consumer secret",
4408
- "access token secret",
4409
- // Misc
4410
- "project api key",
4411
- "personal api key",
4412
- "organization id",
4413
- "org id",
4414
- "app key",
4415
- "app secret",
4416
- // Pusher (and other keys tables) label fields bare: key / secret /
4417
- // cluster. Without these the value inherits the nearest recognized
4418
- // label (the app_id field), mislabeling key + secret as "app id".
4419
- "cluster",
4420
- "key",
4421
- "secret",
4422
- ];
4423
- const isVisible = (el) => {
4424
- const r = el.getBoundingClientRect();
4425
- return r.width > 2 && r.height > 2;
4426
- };
4427
- const isCredentialShape = (s) => {
4428
- // Reasonable credential length range
4429
- if (s.length < 6 || s.length > 256)
4430
- return false;
4431
- // Reject pure prose (spaces inside)
4432
- if (/\s/.test(s))
4433
- return false;
4434
- // Must include some entropy markers: digit + letter combo OR
4435
- // a credential prefix like sk_/pk_/api_/ etc.
4436
- const hasDigit = /\d/.test(s);
4437
- const hasLetter = /[A-Za-z]/.test(s);
4438
- if (!hasDigit && !hasLetter)
4439
- return false;
4440
- // Reject pure URL fragments
4441
- if (/^https?:\/\//i.test(s))
4442
- return false;
4443
- // Reject simple words / capitalized phrases
4444
- if (/^[A-Za-z]+$/.test(s) && s.length < 12)
4445
- return false;
4446
- // Reject label-text masquerading as a value: a short token of only
4447
- // letters + separators with NO digit (e.g. the literal "app_id" /
4448
- // "secret" label text pusher renders next to the real value). Real
4449
- // credentials carry a digit or are long; field labels don't.
4450
- if (!hasDigit && /^[a-z][a-z_-]*$/i.test(s) && s.length < 16)
4451
- return false;
4452
- return true;
4453
- };
4454
- // Inline mirror of credential-shape.ts MASKED_DISPLAY_RE — page.evaluate
4455
- // code can't import, so keep this regex byte-identical to the canonical.
4456
- // Any mask glyph: bullet/circle, 3+ asterisks, ellipsis, or 3+ dots. (Was
4457
- // `[•●⬤]{3,}|\*{4,}`, which MISSED the ellipsis masks GCP/Zilliz/S3 use.)
4458
- const isMaskedShape = (s) => /[•●⬤]|\*{3,}|…|\.{3,}/.test(s);
4459
- // Compute element-center coords for proximity matching.
4460
- const centerOf = (el) => {
4461
- const r = el.getBoundingClientRect();
4462
- return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
4463
- };
4464
- const labelHits = [];
4465
- document.querySelectorAll("body *").forEach((el) => {
4466
- if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
4467
- return;
4468
- if (!isVisible(el))
4469
- return;
4470
- // Only consider DIRECT text content — child element text gets
4471
- // claimed by THOSE elements' own label scans.
4472
- let direct = "";
4473
- el.childNodes.forEach((n) => {
4474
- if (n.nodeType === Node.TEXT_NODE)
4475
- direct += n.textContent ?? "";
4476
- });
4477
- direct = direct.trim().toLowerCase();
4478
- if (direct.length === 0 || direct.length > 100)
4479
- return;
4480
- // Normalize separators so underscore/hyphen field labels match the
4481
- // space-form phrases: pusher renders "app_id" / "app_key" / "app_secret"
4482
- // and "app_id".includes("app id") is false, so every value used to
4483
- // inherit a far "App keys" heading. With this, each field label matches
4484
- // its own phrase and wins the proximity match.
4485
- const directNorm = direct.replace(/[_-]+/g, " ");
4486
- for (const phrase of LABEL_PHRASES) {
4487
- if (directNorm.includes(phrase)) {
4488
- const c = centerOf(el);
4489
- labelHits.push({ phrase, x: c.x, y: c.y, el });
4490
- break; // one label per element is enough
4491
- }
4492
- }
4493
- });
4494
- // Detect reveal buttons (eye / show / unmask icons) — any visible
4495
- // button or [role=button] / svg whose aria-label / title / text
4496
- // matches the reveal vocabulary. We only check WHETHER one exists
4497
- // near a candidate; the clicker (revealMaskedCredentials below)
4498
- // does the actual click pass.
4499
- const REVEAL_PATTERN = /\b(?:reveal|show|unmask|view|toggle|copy)\b/i;
4500
- const revealButtons = [];
4501
- document
4502
- .querySelectorAll('button, [role="button"], a, [aria-label], [title]')
4503
- .forEach((el) => {
4504
- if (!isVisible(el))
4505
- return;
4506
- const hay = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""}`;
4507
- if (!REVEAL_PATTERN.test(hay))
4508
- return;
4509
- const c = centerOf(el);
4510
- revealButtons.push({ x: c.x, y: c.y, el });
4511
- });
4512
- // For each candidate, find nearest label by Euclidean distance.
4513
- const findNearestLabel = (x, y) => {
4514
- let best = null;
4515
- for (const lh of labelHits) {
4516
- const dx = lh.x - x;
4517
- const dy = lh.y - y;
4518
- const d = Math.sqrt(dx * dx + dy * dy);
4519
- // Conservative cap — labels more than 400px away from the
4520
- // value aren't visually grouped with it. Roughly: a typical
4521
- // table-row width.
4522
- if (d > 400)
4523
- continue;
4524
- if (best === null || d < best.d)
4525
- best = { phrase: lh.phrase, d };
4526
- }
4527
- return best?.phrase ?? null;
4528
- };
4529
- const hasNearbyReveal = (x, y) => {
4530
- for (const rb of revealButtons) {
4531
- const dx = rb.x - x;
4532
- const dy = rb.y - y;
4533
- // Reveal/copy buttons are usually right next to the value —
4534
- // 200px is generous.
4535
- if (Math.sqrt(dx * dx + dy * dy) < 200)
4536
- return true;
4537
- }
4538
- return false;
4539
- };
4540
- const seen = new Set();
4541
- const out = [];
4542
- const pushCandidate = (value, el) => {
4543
- const trimmed = value.trim();
4544
- if (trimmed.length === 0)
4545
- return;
4546
- const masked = isMaskedShape(trimmed);
4547
- if (!masked && !isCredentialShape(trimmed)) {
4548
- // 0.8.2-rc.17 — when the whole text-node string has
4549
- // whitespace (Cloudinary's "Cloud name: dlq4xgrca" sits
4550
- // in a SINGLE <div> with the label and value glued
4551
- // together), isCredentialShape rejects the whole string.
4552
- // Try to split on the canonical label-value separator
4553
- // patterns ("Label: value", "Label = value", "Label\nvalue")
4554
- // and re-evaluate each side. The token side gets the
4555
- // candidate slot; the label side already lives on its own
4556
- // (we don't need to push it). First-wins on duplicates.
4557
- const split = /^([A-Za-z][A-Za-z _-]{1,40}?)\s*[:=]\s*([A-Za-z0-9._\-]{4,256})$/.exec(trimmed);
4558
- if (split === null)
4559
- return;
4560
- const valueToken = split[2];
4561
- if (valueToken === undefined)
4562
- return;
4563
- if (!isCredentialShape(valueToken))
4564
- return;
4565
- if (seen.has(valueToken))
4566
- return;
4567
- seen.add(valueToken);
4568
- const c = centerOf(el);
4569
- const label = findNearestLabel(c.x, c.y);
4570
- out.push({
4571
- value: valueToken,
4572
- label,
4573
- isMasked: false,
4574
- hasRevealButton: false,
4575
- });
4576
- return;
4577
- }
4578
- if (seen.has(trimmed))
4579
- return;
4580
- seen.add(trimmed);
4581
- const c = centerOf(el);
4582
- const label = findNearestLabel(c.x, c.y);
4583
- const hasReveal = masked ? hasNearbyReveal(c.x, c.y) : false;
4584
- out.push({
4585
- value: trimmed,
4586
- label,
4587
- isMasked: masked,
4588
- hasRevealButton: hasReveal,
4589
- });
4590
- };
4591
- // 0. Inline config snippets: a credential block listing multiple
4592
- // label = "value" pairs in one text run (pusher's App Keys page:
4593
- // app_id = "2164307" key = "..." secret = "..." cluster = "ap3").
4594
- // No separate label ELEMENTS exist, so the proximity matcher mislabels
4595
- // every value with whatever heading is nearest. Parse the label-value
4596
- // pairs straight from the page text — each pair's own label is
4597
- // authoritative. Runs FIRST so its correctly-labeled candidates win
4598
- // the `seen` dedup over the proximity passes. Noise pairs are harmless:
4599
- // a skill only matches the labels it asks for.
4600
- const bodyText = document.body?.innerText ?? "";
4601
- const INLINE_PAIR = /\b([A-Za-z][A-Za-z0-9_-]{1,40})\s*[:=]\s*["']?([A-Za-z0-9._-]{6,256})["']?/g;
4602
- for (const m of bodyText.matchAll(INLINE_PAIR)) {
4603
- const label = (m[1] ?? "").toLowerCase();
4604
- const value = m[2] ?? "";
4605
- if (!isCredentialShape(value))
4606
- continue;
4607
- if (seen.has(value))
4608
- continue;
4609
- seen.add(value);
4610
- out.push({ value, label, isMasked: false, hasRevealButton: false });
4611
- }
4612
- // 1. <input> / <textarea> values (visible only). Only text-shaped
4613
- // controls can RENDER a credential: a checkbox/radio/button's `value`
4614
- // is a markup constant (or the control's id), not page content — an
4615
- // offscreen `<input type="checkbox" id="secret" value="fallback">`
4616
- // must never become a stored credential.
4617
- document.querySelectorAll("input, textarea").forEach((el) => {
4618
- if (el instanceof HTMLInputElement &&
4619
- !["text", "search", "url", "tel", "number", "email", ""].includes(el.type))
4620
- return;
4621
- if (!isVisible(el))
4622
- return;
4623
- const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
4624
- if (value.length > 0)
4625
- pushCandidate(value, el);
4626
- });
4627
- // 2. Direct text content in visible leaf elements.
4628
- document.querySelectorAll("body *").forEach((el) => {
4629
- if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
4630
- return;
4631
- if (!isVisible(el))
4632
- return;
4633
- let direct = "";
4634
- el.childNodes.forEach((n) => {
4635
- if (n.nodeType === Node.TEXT_NODE)
4636
- direct += n.textContent ?? "";
4637
- });
4638
- direct = direct.trim();
4639
- if (direct.length === 0 || direct.length > 256)
4640
- return;
4641
- pushCandidate(direct, el);
4642
- });
4643
- // 3. Structural containers (code/pre/kbd) where the credential
4644
- // is interpolated through nested spans.
4645
- document.querySelectorAll('code, pre, kbd, samp, [role="textbox"]').forEach((el) => {
4646
- if (!isVisible(el))
4647
- return;
4648
- const full = (el.textContent ?? "").trim();
4649
- if (full.length === 0 || full.length > 256)
4650
- return;
4651
- pushCandidate(full, el);
4652
- });
4653
- return out;
4654
- });
4655
- }
4656
- // Click every visible "Reveal" / "Show" / "Eye" / "Copy" button on
4657
- // the page that sits next to a masked credential display. Used as a
4658
- // pre-extract pass for services like Cloudinary that hide the
4659
- // api_secret behind a click-to-reveal icon. Best-effort: failures
4660
- // don't throw; subsequent extract pass tries whatever surfaced.
4661
- // Returns the number of buttons successfully clicked.
4662
- async revealMaskedCredentials(page = this.page) {
4663
- if (page === null)
4664
- throw new Error("Browser not started");
4665
- const probe = await page.evaluate(() => {
4666
- const isVisible = (el) => {
4667
- const r = el.getBoundingClientRect();
4668
- return r.width > 2 && r.height > 2;
4669
- };
4670
- // Walk up to the nearest "row-like" ancestor — a <tr>, a <li>,
4671
- // or any container ≤ 800px wide with limited height. Cloudinary,
4672
- // Algolia, Twilio all use table rows; clicking the reveal in
4673
- // ROW X must populate the value in ROW X, not some neighbor row.
4674
- const rowAncestor = (el) => {
4675
- let cur = el;
4676
- for (let i = 0; i < 8 && cur !== null; i++) {
4677
- if (cur.tagName === "TR" || cur.tagName === "LI")
4678
- return cur;
4679
- const r = cur.getBoundingClientRect();
4680
- if (r.width > 200 && r.width < 900 && r.height < 200)
4681
- return cur;
4682
- cur = cur.parentElement;
4683
- }
4684
- return el.parentElement;
4685
- };
4686
- const masked = [];
4687
- document.querySelectorAll("body *").forEach((el) => {
4688
- if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
4689
- return;
4690
- if (!isVisible(el))
4691
- return;
4692
- let direct = "";
4693
- el.childNodes.forEach((n) => {
4694
- if (n.nodeType === Node.TEXT_NODE)
4695
- direct += n.textContent ?? "";
4696
- });
4697
- const t = direct.trim();
4698
- if (t.length < 3 || t.length > 100)
4699
- return;
4700
- // Canonical MASKED_DISPLAY_RE union (credential-shape.ts): bullet/
4701
- // circle, 3+ asterisks, ellipsis, or 3+ dots. The old
4702
- // `[•●⬤*]{3,}` missed the `…`/`...` masks a masked sibling key uses
4703
- // (`vsk_sandbox_write_20af…`), so its Reveal was never clicked.
4704
- if (!/[•●⬤]|\*{3,}|…|\.{3,}/.test(t))
4705
- return;
4706
- masked.push({ el, row: rowAncestor(el) });
4707
- });
4708
- document.querySelectorAll('input[type="password"]').forEach((el) => {
4709
- if (!isVisible(el))
4710
- return;
4711
- masked.push({ el, row: rowAncestor(el) });
4712
- });
4713
- const selectorFor = (el) => {
4714
- const parts = [];
4715
- let current = el;
4716
- while (current !== null && current !== document.body) {
4717
- const tag = current.tagName.toLowerCase();
4718
- const siblings = Array.from(current.parentElement?.children ?? []).filter((sibling) => sibling.tagName === current.tagName);
4719
- parts.unshift(`${tag}:nth-of-type(${siblings.indexOf(current) + 1})`);
4720
- current = current.parentElement;
4721
- }
4722
- return `body > ${parts.join(" > ")}`;
4723
- };
4724
- // No masked placeholder anywhere — but some consoles hide the key
4725
- // ENTIRELY behind a "View/Show Key" button with no ••• shown at all
4726
- // (Zilliz's "View My Personal Key"). The row-anchored pass below has
4727
- // nothing to anchor on, so without this the reveal pass bails and the
4728
- // extractor reports no_legit_credential on a page that DOES have a key.
4729
- // Anchor-free fallback: click a button whose label pairs a SAFE reveal
4730
- // verb with a credential noun, excluding destructive verbs (reset/
4731
- // regenerate/delete/revoke/rotate would mint or destroy a key, not
4732
- // reveal the existing one).
4733
- const DESTRUCTIVE = /\b(?:reset|regenerat\w*|delete|revoke|rotate|create|new|remove|add|download)\b/i;
4734
- if (masked.length === 0) {
4735
- const KEY_NOUN = /\b(?:api\s*key|secret|token|credential|personal\s+key|access\s+key|key)\b/i;
4736
- const SAFE_REVEAL = /\b(?:view|show|reveal|display|see)\b/i;
4737
- const out = [];
4738
- const diag = [];
4739
- document
4740
- .querySelectorAll('button, [role="button"], a[role="button"]')
4741
- .forEach((el) => {
4742
- if (!isVisible(el))
4743
- return;
4744
- const hay = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""}`
4745
- .replace(/\s+/g, " ")
4746
- .trim();
4747
- if (hay.length === 0 || hay.length > 60)
4748
- return;
4749
- if (!SAFE_REVEAL.test(hay) || !KEY_NOUN.test(hay))
4750
- return;
4751
- if (DESTRUCTIVE.test(hay))
4752
- return;
4753
- out.push(selectorFor(el));
4754
- diag.push(`anchorless_key_reveal:"${hay.slice(0, 40)}"`);
4755
- });
4756
- return {
4757
- selectors: out,
4758
- diagnostic: out.length > 0 ? diag : ["no_masked_displays"],
4759
- };
4760
- }
4761
- // 2. Classify candidate buttons. Prefer SHOW/REVEAL/EYE; fall
4762
- // back to COPY only when no show button exists in the row.
4763
- // (Copy generally puts value in clipboard, not in DOM —
4764
- // which our extractor can't read in headless.)
4765
- const SHOW_PATTERN = /\b(?:reveal|show|unmask|view|toggle|eye)\b/i;
4766
- const COPY_PATTERN = /\bcopy\b/i;
4767
- const collectButtonsInRow = (row) => {
4768
- const showBtns = [];
4769
- const copyBtns = [];
4770
- if (row === null)
4771
- return { showBtns, copyBtns };
4772
- row
4773
- .querySelectorAll('button, [role="button"], a[role="button"], [aria-label], [title]')
4774
- .forEach((el) => {
4775
- if (!isVisible(el))
4776
- return;
4777
- const hay = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""} ${el.getAttribute("title") ?? ""} ${el.className ?? ""}`;
4778
- if (DESTRUCTIVE.test(hay))
4779
- return;
4780
- if (SHOW_PATTERN.test(hay))
4781
- showBtns.push(el);
4782
- else if (COPY_PATTERN.test(hay))
4783
- copyBtns.push(el);
4784
- });
4785
- return { showBtns, copyBtns };
4786
- };
4787
- const selectors = [];
4788
- const diagnostic = [];
4789
- const usedRows = new Set();
4790
- for (const m of masked) {
4791
- if (m.row === null)
4792
- continue;
4793
- if (usedRows.has(m.row))
4794
- continue;
4795
- usedRows.add(m.row);
4796
- const { showBtns, copyBtns } = collectButtonsInRow(m.row);
4797
- if (showBtns.length > 0) {
4798
- const btn = showBtns[0];
4799
- const sel = selectorFor(btn);
4800
- selectors.push(sel);
4801
- const label = (btn.textContent ??
4802
- btn.getAttribute("aria-label") ??
4803
- btn.getAttribute("title") ??
4804
- "")
4805
- .trim()
4806
- .slice(0, 40);
4807
- diagnostic.push(`row→show:"${label}"→${sel}`);
4808
- }
4809
- else if (copyBtns.length > 0) {
4810
- diagnostic.push(`row→copy_only_no_show_button (copy='${copyBtns.length} found' — skipped, would only populate clipboard not DOM)`);
4811
- }
4812
- else {
4813
- diagnostic.push("row→no_buttons_found");
4814
- }
4815
- }
4816
- return { selectors, diagnostic };
4817
- });
4818
- let clicked = 0;
4819
- for (const sel of probe.selectors) {
4820
- try {
4821
- await page.locator(sel).first().click({ timeout: 1500 });
4822
- clicked += 1;
4823
- // Reveal click often triggers a fetch (Cloudinary returns the
4824
- // secret over an XHR before populating the DOM). Wait longer
4825
- // than the previous 150ms.
4826
- await this.sleep(800);
4827
- }
4828
- catch {
4829
- // Click failed — best-effort.
4830
- }
4831
- }
4832
- return { clicked, diagnostic: probe.diagnostic };
4833
- }
4834
- async extractCredentialCandidates() {
4835
- if (!this.page)
4836
- throw new Error("Browser not started");
4837
- return await this.page.evaluate(() => {
4838
- const out = [];
4839
- const isVisible = (el) => {
4840
- const r = el.getBoundingClientRect();
4841
- return r.width > 2 && r.height > 2;
4842
- };
4843
- document.querySelectorAll("input, textarea").forEach((el) => {
4844
- // Only text-shaped inputs can RENDER a credential. A checkbox/
4845
- // radio/button's `value` is a markup constant, not page content —
4846
- // zilliz's CookieScript banner ships `<input type="checkbox"
4847
- // value="personalization">` and those words sit earlier in DOM
4848
- // order than the real key, so the validator-shaped scan tier was
4849
- // returning them as the "credential".
4850
- if (el instanceof HTMLInputElement &&
4851
- !["text", "search", "url", "tel", "number", "email", ""].includes(el.type)) {
4852
- return;
4853
- }
4854
- const value = el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement ? el.value : "";
4855
- if (value.trim().length > 0 && isVisible(el))
4856
- out.push(value.trim());
4857
- });
4858
- document.querySelectorAll("body *").forEach((el) => {
4859
- if (el.tagName === "SCRIPT" || el.tagName === "STYLE")
4860
- return;
4861
- if (!isVisible(el))
4862
- return;
4863
- let direct = "";
4864
- el.childNodes.forEach((n) => {
4865
- if (n.nodeType === Node.TEXT_NODE)
4866
- direct += n.textContent ?? "";
4867
- });
4868
- direct = direct.trim();
4869
- // A real key is short; a long blob is a paragraph, not a key.
4870
- if (direct.length > 0 && direct.length <= 256)
4871
- out.push(direct);
4872
- });
4873
- // Structural containers (<code>, <pre>, kbd, samp, [role=textbox])
4874
- // often render a credential by interpolating it through nested
4875
- // <span>s — the loop above sees an empty direct-text and skips
4876
- // them. Push the full textContent so a UUID built as
4877
- // <code><span>7</span><span>5</span>…</code> is still scannable.
4878
- document.querySelectorAll('code, pre, kbd, samp, [role="textbox"]').forEach((el) => {
4879
- if (!isVisible(el))
4880
- return;
4881
- const full = (el.textContent ?? "").trim();
4882
- if (full.length > 0 && full.length <= 256)
4883
- out.push(full);
4884
- });
4885
- return out;
4886
- });
4887
- }
4888
4143
  // Wait for the signup form to actually render before the planner
4889
4144
  // screenshots the page (F1). SPA and two-stage signup pages render
4890
4145
  // the form after JS executes; planning against a pre-render