/* GENERATED FILE -- do not edit.
 *
 * Source: the detector table inside https://levain.bmac.io/redact.html, the free
 * browser tool. Extracted by extract-core.mjs, copied here by build-redactkit.mjs.
 * source sha256: d35a73d25c440209
 *
 * The free tool and this CLI run the SAME detectors, by construction.
 */

var DETECTORS = [
  {g:"Credentials", id:"privkey", label:"Private key blocks", tag:"PRIVATE_KEY", on:true,
   re:/-----BEGIN(?: [A-Z0-9]+)* PRIVATE KEY-----[\s\S]*?-----END(?: [A-Z0-9]+)* PRIVATE KEY-----/g},
  /* ---- Public by design ------------------------------------------------
     Values shaped like credentials, named like credentials, and published on
     purpose: they ship inside the JavaScript bundle of every page that uses
     them, readable with view-source. They are still replaced -- a publishable
     key names your account, and you asked for redaction -- but filing them
     under Credentials INFLATES the tool. It makes a scan of a clean log report
     a leak, and the only person misled is the one reading the output. A miss is
     visible the moment it costs you something; this class never is.
     These sit ABOVE the credential rules on purpose: ties on start position are
     broken by detector order, so a public token gets its true label instead of
     being swallowed by the generic assignment or JWT rule.
     What this can decide is a question about the SHAPE, not about the vendor.
     `pk_` and `pk.` are the published convention for "publishable" and are
     readable with no vendor name in the string. Google's AIza keys are not: a
     browser Maps key and a server key are byte-for-byte the same shape, so they
     stay in Credentials, which is the safe direction to be wrong in. Same for a
     Supabase anon JWT, whose public role is inside the encoded payload. */
  {g:"Public by design", id:"pubkey", label:"Publishable keys in the pk_ convention (Stripe, Clerk, and anyone who copied it)", tag:"PUBLISHABLE_KEY", on:true,
   re:/\bpk_(?:live|test)_[A-Za-z0-9]{10,}\b/g},
  {g:"Public by design", id:"mapboxpub", label:"Mapbox public access tokens", tag:"PUBLISHABLE_KEY", on:true,
   /* Mapbox uses the same letter convention one level out: pk. is the public
      token that goes in the map on your marketing site, sk. is the secret one
      that can rewrite your styles. Both are JWT-shaped, so without this rule the
      public one was tagged JWT and the two were indistinguishable. */
   re:/\bpk\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g},
  {g:"Public by design", id:"phc", label:"PostHog project API keys", tag:"PUBLISHABLE_KEY", on:true,
   /* Named "API key" by the vendor and pasted into a script tag by everyone
      who installs it. Nothing else caught this shape at all. */
   re:/\bphc_[A-Za-z0-9]{30,}\b/g},
  {g:"Credentials", id:"jwt", label:"JWTs", tag:"JWT", on:true,
   re:/\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}/g},
  {g:"Credentials", id:"aws", label:"AWS access key IDs", tag:"AWS_KEY", on:true,
   re:/\b(?:AKIA|ASIA|ABIA|ACCA|AIDA|AGPA|AROA|ANPA|ANVA|A3T[A-Z0-9])[A-Z0-9]{16}\b/g},
  {g:"Credentials", id:"gh", label:"GitHub tokens", tag:"GITHUB_TOKEN", on:true,
   re:/\bgh[pousr]_[A-Za-z0-9]{30,255}\b/g},
  {g:"Credentials", id:"slack", label:"Slack tokens and webhooks", tag:"SLACK_TOKEN", on:true,
   re:/\bxox[abprse]-[A-Za-z0-9-]{10,}|https:\/\/hooks\.slack\.com\/services\/[A-Za-z0-9\/+_-]{20,}/g},
  {g:"Credentials", id:"stripe", label:"Stripe secret and restricted keys", tag:"STRIPE_KEY", on:true,
   /* sk_ and rk_ only. pk_ is the PUBLISHABLE key: Stripe documents it as safe
      to ship in a browser bundle, and it used to land here under the identical
      STRIPE_KEY tag, which made a log containing nothing but a public value
      read as a leak. See the "Public by design" group above. */
   re:/\b[sr]k_(?:live|test)_[A-Za-z0-9]{10,}\b/g},
  {g:"Credentials", id:"llm", label:"OpenAI / Anthropic keys", tag:"LLM_API_KEY", on:true,
   re:/\bsk-(?:ant-|proj-|or-v1-)?[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])/g},
  {g:"Credentials", id:"google", label:"Google API keys", tag:"GOOGLE_API_KEY", on:true,
   re:/\bAIza[A-Za-z0-9_-]{35}(?![A-Za-z0-9_-])/g},
  {g:"Credentials", id:"pkgtok", label:"npm / PyPI / SendGrid tokens", tag:"PACKAGE_TOKEN", on:true,
   re:/\bnpm_[A-Za-z0-9]{36}\b|\bpypi-[A-Za-z0-9_-]{16,}|\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}/g},
  {g:"API keys", id:"vendorpfx", label:"Vendor-prefixed tokens (GitLab, Shopify, Hugging Face, Groq, xAI, Supabase, ...)", tag:"VENDOR_TOKEN", on:true,
   /* Tokens whose vendor stamps a distinctive prefix on them, so they are
      identifiable with no key name anywhere nearby. Each alternative is kept
      tight to the real published shape; loose ones (Mailgun's bare "key-",
      Cloudflare's unprefixed 40-char tokens) are deliberately left out
      because they cannot be told from ordinary text. See "What it misses". */
   re:/\bgithub_pat_[A-Za-z0-9_]{50,}\b|\bgl(?:pat|ptt|rt|soat|cbt|dt|ft|imt|oat|agent)-[A-Za-z0-9_-]{20,}|\bdo[oprt]_v1_[A-Za-z0-9]{32,}\b|\bshp(?:at|ss|ca|pa)_[A-Fa-f0-9]{32}\b|\bsq0[a-z]{3}-[A-Za-z0-9_-]{20,}|\bATATT[A-Za-z0-9_=-]{20,}|\bfigd_[A-Za-z0-9_-]{20,}|\blin_api_[A-Za-z0-9]{20,}|\bhf_[A-Za-z0-9]{30,}\b|\bvercel_blob_rw_[A-Za-z0-9_]{24,}|\bfm2_[A-Za-z0-9+\/=_-]{20,}|\bgsk_[A-Za-z0-9]{40,}\b|\bxai-[A-Za-z0-9]{40,}\b|\bpplx-[A-Za-z0-9]{32,}\b|\bfw_[A-Za-z0-9]{24,}\b|\br8_[A-Za-z0-9]{30,}\b|\bpcsk_[A-Za-z0-9]{4,}_[A-Za-z0-9]{30,}\b|\bre_[A-Za-z0-9]{8}_[A-Za-z0-9]{22,}\b|\bdp\.(?:pt|st|ct|sa|scim)\.[A-Za-z0-9]{40,}\b|\bdapi[a-f0-9]{32}\b|\bglsa_[A-Za-z0-9]{32}_[a-f0-9]{8}\b|\bpscale_(?:tkn|pw|oauth)_[A-Za-z0-9_-]{32,}|\bsbp_[a-f0-9]{40}\b|\bPMAK-[a-f0-9]{24}-[a-f0-9]{30,}\b|\bpat[A-Za-z0-9]{14}\.[a-f0-9]{64}\b|\btskey-(?:auth|api|client)-[A-Za-z0-9]{10,}-[A-Za-z0-9]{16,}|\bNRAK-[A-Z0-9]{20,}\b|\bsk_[a-f0-9]{48}\b|\bxapp-\d-[A-Z0-9]{9,}-\d{10,}-[a-f0-9]{40,}|\bGOCSPX-[A-Za-z0-9_-]{20,}\b|\bsntry[su]_[A-Za-z0-9+\/=_.-]{40,}|\bsq[apu]_[a-f0-9]{40}\b|\bdt0[cs]01\.[A-Z0-9]{24}\.[a-f0-9]{64}\b|\brubygems_[a-f0-9]{48}\b|\boy2[a-z0-9]{40,}\b|\bnfp_[A-Za-z0-9]{30,}\b|\batlasv1\.[A-Za-z0-9]{40,}\b|\bsk\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g},
  {g:"API keys", id:"envpfx", label:"Vendor tokens in the <slug>_live_ / _test_ convention (any vendor, including ones I have never heard of)", tag:"VENDOR_TOKEN", on:true,
   /* The convention, not the roster. The detector above it is a hand-kept list of
      named vendors, and a list can only ever know vendors that already shipped.
      Hundreds of APIs copied Stripe's sk_live_ / pk_test_ shape -- one lowercase
      slug, an environment word, then the entropy -- and that shape is identifiable
      with no vendor name in it at all, which is the whole point token-design.html
      argues for. This rule reads the shape, so a key from a vendor that launched
      last week is caught the same as Stripe's.
      Three deliberate narrowings, each measured against the 81 credential-free
      formats: the slug is exactly ONE snake segment (no _ inside it), which is what
      keeps compound identifiers like aws_instance_prod_id out, since there is no word
      boundary to start a match on; the environment word is the closed set below, with
      "dev" excluded as too common in ordinary names; and the tail must pass
      looksRandom, so app_test_configuration does not qualify. Named vendors sit
      EARLIER in this list, so a real Stripe key keeps its STRIPE_KEY label and only
      an unrecognised vendor falls through to this one. */
   re:/\b[a-z][a-z0-9]{1,15}_(?:live|test|prod|sandbox|staging)_[A-Za-z0-9]{20,}\b/g,
   validate:function(v){ return looksRandom(v.replace(/^[a-z0-9]+_(?:live|test|prod|sandbox|staging)_/,"")); }},
  {g:"Credentials", id:"bearer", label:"Bearer / Basic auth headers", tag:"AUTH_TOKEN", on:true,
   re:/\b(?:Bearer|Basic|Token)\s+([A-Za-z0-9._~+\/=-]{12,})/gi, groups:[1]},
  {g:"Credentials", id:"urlcred", label:"Passwords inside URLs", tag:"PASSWORD", on:true,
   /* The user half may itself contain an @: Cloud SQL IAM, Snowflake, Azure SQL and
      Databricks all take a service-account EMAIL as the database username, so the
      userinfo holds an @ before the one that ends it, and percent-encoding pushes it
      past 64 characters. A class forbidding @ there does not merely mislabel that
      DSN, it cannot see the password at all (wake 065). The class still forbids : and
      /, so a match cannot leave the authority section, and the PASSWORD half still
      forbids @, so the capture ends at the FIRST @ after the colon -- which is the
      one that ends userinfo. */
   re:/\b[a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s:\/]{0,192}:([^\s@\/]{1,128})@/g, groups:[1],
   /* A connection string in a README or a .env.example carries a placeholder, not
      a password. Same structural set the assignment detector skips, so the two
      cannot drift apart, plus the __UPPER__ form that .env.example files use. */
   skip:/^(?:\*+|x+|\.{3,}|<[^>]*>|\[[A-Z_]+\d*\]|\$\{?[A-Za-z_]\w*\}?|__[A-Za-z][A-Za-z0-9_]*__|your[_-]?[a-z]*[_-]?here|changeme|placeholder|insert[_-]?[a-z]+[_-]?here)$/i},
  {g:"Credentials", id:"assign", label:"password= / token= / secret= assignments", tag:"SECRET", on:true,
   re:/\b(?<!\$)[A-Za-z0-9_.-]*(?:password|passwd|pwd|secret(?![_-]?(?:name|uri|url|endpoint)\b)|token(?![_-]?(?:name|uri|url|endpoint)\b)|api[_-]?key|apikey|access[_-]?key|(?<=[A-Za-z0-9][_-])(?<!(?:cache|partition|sort|primary|foreign|row|shard|index|idempotency|object|map|route|locale|translation|dedup|group|hash|lookup|search|licen[sc]e|host|column|field|record|sql|query|order|by|meta|schema|hot|bucket|etag|cursor|stream|topic|event|dedupe|composite|join|natural|surrogate|private|public)[_-])key|auth(?!ors?\b)|credential(?![_-]?(?:name|uri|url|endpoint)\b)|client[_-]?secret|session[_-]?id|cookie)[A-Za-z0-9_.-]*(?:\\?")?(?:\s*[:=]>?\s*(?:\\"([^"\\\n]{3,})\\"|"([^"\n]{3,})"|'([^'\n]{3,})')|[ \t]*[:=]>?[ \t]*(?![^\s\d!-\/:-@\[-`{-~]{3,19}\s+\S)(<[^>\n]{1,160}>|[^\s,;&"'\]}]{3,}))/gi,
   groups:[1,2,3,4],
   /* The bare-`key` alternative (wake 049) only fires on a PREFIXED name --
      `confluent_key`, `stripe_key`, `datadog_key` -- never on `key:` alone,
      which is every second line of YAML. The lookbehind list is the structural
      vocabulary that also ends in _key and never holds a credential: cache,
      partition, sort, primary, row, hot and the rest. Found by the wake-049
      alphabet-edge corpus tier, which planted a Confluent Cloud key in the
      `confluent_key: "..."` form its own docs use; verified against fp-corpus
      (zero new hits) and 25 must-not-catch probes. `private` and `public` are in
      the list because those names have their OWN detectors (the GCP
      service-account fields, the PEM block) and this one outranks them: a
      broader rule that wins a race relabels a finding that was already correct.
      claims-check caught exactly that, which is why the label is asserted and
      not just the coverage.
      Skip non-secret values: booleans, obvious placeholders, auth *scheme*
      words, so "Authorization: Bearer <jwt>" keeps the word Bearer and lets the
      dedicated header detector take just the token -- wake 050 added the
      hyphenated schemes (Api-Token, Api-Key, SSWS, NTLM), which my own
      self-score caught me redacting as if they were the credential -- and, from wake 034, the
      log-status vocabulary. "auth", "token" and "secret" are subsystem tags in
      half the loggers in the world, so "INFO token: refreshed" looked exactly
      like an assignment and the tool replaced the word "refreshed" with
      [SECRET_1]. The lookahead above kills the multi-word form ("auth: user
      jsmith logged in" is prose, not a value); this list kills the one-word
      form. Neither costs recall: a value with a digit, mixed case or 20+
      characters still matches, so letmein, hunter2 and
      correcthorsebatterystaple are all still caught. */
   /* The `<[^>\n]{1,160}>` alternative in the value class above exists so the
      `<[^>]*>` entry in this skip list can actually reach a MULTI-WORD marker.
      The unquoted value class ends at the first space, so kubectl's
      `DB_PASSWORD:  <set to the key 'password' in secret 'db-creds'>` -- printed
      for every secretKeyRef by `kubectl describe pod` -- was captured as the
      four characters `<set` and came back as [SECRET_1], destroying kubectl's
      own explanation of where the value came from and claiming a placeholder
      over a string that is not a secret and never was. Matching the marker
      whole hands it to the skip list, which already knew that anything inside
      angle brackets is an annotation rather than a value (`<redacted>`,
      `<none>`, `<not set>`). It costs no recall: the discriminator is the
      brackets, not the word "secret", so a literal `password: hunter2...` on
      the same shape of line is still a finding. Both edges in redact-spec. */
   skip:/^(?:true|false|null|none|nil|undefined|yes|no|\*+|x+|\.{3,}|<[^>]*>|\[[A-Z_]+\d*\]?|redacted|masked|hidden|scrubbed|refreshed|rotated|enabled|disabled|failed|failure|success|succeeded|expired|accepted|denied|granted|revoked|valid|invalid|missing|required|created|updated|deleted|started|stopped|skipped|cached|error|warning|unknown|pending|active|inactive|complete|completed|ok|bearer|basic|token|digest|negotiate|oauth|api[_-]?key|api[_-]?token|ssws|ntlm|\$\{?[A-Za-z_]\w*\}?|your[_-]?[a-z]*[_-]?here|changeme|placeholder|insert[_-]?[a-z]+[_-]?here)$/i,
    /* A CDN cache-buster is not a credential. `?token=1756631642` on a public
       asset URL is a publish timestamp and `?token=20260831` is a date; both used
       to come back tagged SECRET, which inflates a scan of an ordinary access log
       with credential findings it has to be wrong about. Found by the wake-060
       terminal-captured corpus tier.
       The rule reads the NAME, not the value, and that is the whole point: an
       all-digit value is harmless under `token=`, `api_key=` and `access_key=`,
       because a numeric API token does not exist, and it is a REAL secret under
       `password=` (12345678 is a bad password, not a non-password), under
       `secret=`, and under `session_id=` -- a numeric session id is exactly what
       a stolen cookie is. So this rules out only the identifier-shaped names and
       leaves every credential-shaped one alone. Recall is pinned by tp-check. */
    /* Second alternative, wake 069: `<name>:<architecture>` is a PACKAGE
       QUALIFIER, not an assignment. dpkg's multiarch syntax writes every line of
       /var/log/dpkg.log as `<verb> <pkg>:<arch> <version>`, and the prefix this
       rule allows before its keyword means `xauth:amd64` reads as `auth: amd64`
       and `libsecret-1-0:amd64` reads as `secret: amd64`. Both came back tagged
       SECRET on real bytes -- 58 findings over ~93 KB of ordinary dpkg and apt
       history output, every one of them wrong.
       The discriminator is the VALUE and the qualifier SHAPE, never the keyword
       spelling, and that choice is the whole fix. The obvious alternative --
       forbid the keyword from being glued to a preceding alphanumeric,
       `(?<![A-Za-z0-9])secret` -- was tested and is DISPROVED: this rule is /gi,
       so it also stops matching clientSecret, apiSecret and jwtSecret, which is
       how the field is spelled in half the JSON loggers in the world. A
       precision fix that costs recall is not a fix (wake 059); redact-spec pins
       those three camelCase spellings so it can never be reintroduced.
       Two conditions, and it needs both. The shape: the ENTIRE match is
       `name:value` with no whitespace, no quotes and nothing else around the
       colon -- a real assignment in a log or a config almost always has a space
       or a quote somewhere, and this rule still fires on `secret:swordfish99`
       because of the second condition. The value: a machine architecture, which
       is a closed set of about twenty words maintained by dpkg itself
       (`dpkg-architecture -L`) and shared with rpm, Go, Docker and the kernel.
       That is the same kind of rule as the status vocabulary in `skip` above --
       a small closed list of words that are never anybody's credential -- so it
       generalises past dpkg to any tool that stamps an arch after a colon, and
       it costs nothing, because no password is the string "amd64".
       `all` and `noarch` are listed even though the prose lookahead happens to
       decline `python3-oauthlib:all` today; that was luck, not coverage. */
    skipMatch:/^(?:(?![A-Za-z0-9_.-]*(?:password|passwd|pwd|secret|credential|session))[A-Za-z0-9_.-]*(?:token|api[_-]?key|apikey|access[_-]?key)[A-Za-z0-9_.-]*[ \t]*[:=]>?[ \t]*\d{4,}|[A-Za-z0-9][A-Za-z0-9_+.-]*:(?:amd64|i386|i686|x86_64|arm64|aarch64|armhf|armel|arm|ppc64el|ppc64le|ppc64|powerpc|s390x|riscv64|loong64|mips64el|mipsel|mips|sparc64|alpha|hppa|ia64|m68k|sh4|x32|all|any|noarch|native|source))$/i},

  {g:"Credentials", id:"gcpsa", label:"Google service-account key fields", tag:"GCP_SA_KEY", on:true,
   re:/"private_key_id"\s*:\s*"([A-Za-z0-9]{16,64})"/g, groups:[1]},
  {g:"Credentials", id:"azure", label:"Azure storage keys and SAS signatures", tag:"AZURE_KEY", on:true,
   re:/\bAccountKey=([A-Za-z0-9+\/=]{40,})|[?&]sig=([A-Za-z0-9%+\/=]{24,})|\b(?<!sha\d{3}-)([A-Za-z0-9+\/]{86}==)/gi, groups:[1,2,3]},
  {g:"Public by design", id:"sentry", label:"Sentry DSNs (they name your org and project, so they are still replaced)", tag:"SENTRY_DSN", on:true,
   /* Re-grouped and re-tagged on wake 053, and the corpus is what forced it. A modern
      Sentry DSN's key half is PUBLIC -- it ships inside the browser bundle of every
      site that uses Sentry -- so filing it under Credentials as a SENTRY_KEY named a
      public value a secret, which is the same dishonesty as a missed one wearing the
      opposite mask. It still redacts by default, for the reason an IP address does:
      it identifies your organisation and project, not because it unlocks anything.
      The legacy DSN form that really did carry a secret half,
      https://public:secret@sentry.io, is caught by the passwords-in-URLs rule above
      and always was. Found by the wake-053 modern-runtime corpus tier. */
   re:/https?:\/\/([A-Za-z0-9]{16,})@[A-Za-z0-9.-]*sentry\.io\b/gi, groups:[1]},
  {g:"Credentials", id:"bottok", label:"Telegram / Discord / Twilio tokens and webhooks", tag:"BOT_TOKEN", on:true,
   re:/\b\d{8,10}:AA[A-Za-z0-9_-]{32,35}\b|bot\d{8,10}:AA[A-Za-z0-9_-]{32,35}\b|\bMT[A-Za-z0-9_-]{22,}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{25,}\b|\b(?:AC|SK)[a-f0-9]{32}\b|https:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/(?:v\d+\/)?webhooks\/\d{17,20}\/[A-Za-z0-9_-]{40,}/g},
  {g:"Credentials", id:"k8ssec", label:"Kubernetes secret / docker config blobs", tag:"K8S_SECRET", on:true,
   re:/(?:tls\.key|dockerconfigjson|dockercfg)"?\s*:\s*"?([A-Za-z0-9+\/=]{20,})"?/g, groups:[1]},
  {g:"Credentials", id:"yamlblock", label:"YAML block-scalar secrets (key: | on its own line)", tag:"SECRET", on:true,
   /* key: |  or  key: >  puts the value on the following indented lines, where
      the ordinary assignment detector cannot see it. Captures the whole
      indented block. */
   re:/^[ \t]*[A-Za-z0-9_.-]*(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|credential|client[_-]?secret|private[_-]?key)[A-Za-z0-9_.-]*[ \t]*:[ \t]*[|>][+-]?[ \t]*\n((?:[ \t]+\S.*(?:\n(?=[ \t]+\S))?)+)/gim,
   groups:[1]},
  {g:"Credentials", id:"clicred", label:"Credentials on command lines", tag:"SECRET", on:true,
   re:/(?:^|\s)-u\s+[^\s:"']{1,64}:([^\s"']{1,128})|--(?:password|passwd|token|api-key|apikey|secret|access-key)(?:=|\s+)([^\s"']{3,})/gi,
   groups:[1,2],
   skip:/^(?:true|false|null|none|nil|undefined|yes|no|\*+|x+|\.{3,}|<[^>]*>|\[[A-Z_]+\d*\]|\$\{?[A-Za-z_]\w*\}?|\d{1,6})$/i},
  {g:"Personal data", id:"email", label:"Email addresses", tag:"EMAIL", on:true,
   /* The password half of "scheme://user:pass@host.tld" has the shape of an
      address and is not one. The password detector covers that position; when it
      declines (a placeholder in a README) nothing else should take it instead.
      The lookbehind spans the WHOLE userinfo tail, not just the character before
      the colon, because a first redactor leaves residue there: mask
      "postgres://app:pB5BwgNJ@db-primary.internal" to its last four and what is
      left, "****wgNJ@db-primary.internal", is a perfect email address that never
      existed. A password detector that correctly declines a masked value hands
      the position to the next detector, and the residue of one redactor reads as
      a different data type to the next (wake 064). */
   /* Unicode, not ASCII: an address may be internationalised on either side
      (RFC 6531 local parts, IDN domains). \b is defined on [A-Za-z0-9_], so it
      cannot assert a boundary next to a non-Latin letter -- it silently fails
      to match rather than matching wrongly. Explicit lookarounds instead. */
   /* This lookbehind must stay in lockstep with urlcred's userinfo class above: a
      guard that repeats the limits of the rule it guards fails in lockstep with it
      (wake 065). Same class, same cap, on purpose. */
   re:/(?<!:\/\/[^\s:\/]{0,192}:[^\s@\/]{0,128})(?<![\p{L}\p{N}._%+-])[\p{L}\p{N}._%+-]+@[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?(?:\.[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?)*\.[\p{L}]{2,24}(?![\p{L}\p{N}-])/gu},
  {g:"Personal data", id:"card", label:"Payment cards (Luhn-checked)", tag:"CARD", on:true,
   re:/\b(?:\d[ -]?){12,18}\d\b/g, validate:cardNumber},
  {g:"Personal data", id:"ssn", label:"US social security numbers", tag:"SSN", on:true,
   re:/\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b/g},
  {g:"Personal data", id:"homedir", label:"Usernames in file paths", tag:"USER", on:true,
   /* \p{L}, not A-Za-z: a person whose name is not spelled in Latin letters
      still has a home directory, and an ASCII-only class leaves their name in
      the log. The separator that ends the capture is / or \, never a letter. */
   re:/(?:\/(?:Users|home)\/|[A-Za-z]:\\Users\\)([\p{L}\p{N}._-]{2,32})/gu, groups:[1],
   skip:/^(?:root|admin|ubuntu|runner|user|shared|public|all|default|node|www-data)$/i},
  {g:"Personal data", id:"phone", label:"Phone numbers (noisy)", tag:"PHONE", on:false,
   re:/(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,4}\)[ .-]?|\d{2,4}[ .-])\d{3,4}[ .-]\d{3,4}\b/g},

  {g:"Network and machine", id:"ipv4", label:"Public IPv4 addresses", tag:"IP", on:true,
   /* Wake 068: a four-component PACKAGE VERSION is not an address. Found by scanning real
      /var/log/dpkg.log bytes, not a fixture -- `1.2.15.3-1ubuntu1.1` was being redacted to
      [IP_1], and only when all four components happen to be <=255, so it was sporadic.
      Three edges, all general rather than dpkg-specific: `(?<![\w.])` so a quad that is the
      TAIL of a longer dotted number (9.1.2.15.3) is not an address; `(?!\.\d+\.\d)` so one
      followed by TWO more dotted components is not either; and `(?!-\d[...]*[A-Za-z])` to reject a Debian/RPM
      revision suffix (-1ubuntu1.1, -1.el9). That last one requires a DIGIT immediately after
      the hyphen and a LETTER before the next space, which is what bounds the recall cost:
      `10.1.2.3-prod` still matches, and so does a range like `8.8.8.8-8.8.4.4`. Residual,
      accepted and asserted: `1.2.15.3-1` with a bare numeric revision and no letter still
      reads as an address. Narrowing that further would cost real IPs.
      Wake 070: the fifth-component rule was `(?!\.\d)`, which silently stopped catching
      every address tcpdump, netstat and ss print, because their `addr.port` form IS a fifth
      dotted component -- the fp/tp corpus caught it before release. One trailing component is
      now allowed and only a run of two more is rejected. The cost is the head of a
      five-component version (`9.1.2.15` out of `9.1.2.15.3`), which is the smaller loss: a
      missed IP in tcpdump output is a common real format, a five-component version is not. */
   re:/(?<![\w.])(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\b(?!\.\d+\.\d)(?!-\d[0-9A-Za-z.~+]*[A-Za-z])/g,
   skip:/^(?:127\.|255\.255|10\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.|169\.254\.|\d{1,3}\.0\.0\.0$)/},
  {g:"Network and machine", id:"ipv6", label:"IPv6 addresses", tag:"IPV6", on:true,
   /* Shape first, then a real validator. A loose colon-group regex alone matches
      every syslog timestamp ("09:14:02") and still misses the compressed form
      that real addresses actually use, so the shape is deliberately permissive
      and ipv6ok() decides.
      The trailing "-" in the lookahead is not cosmetic. GitHub Actions writes its
      workflow commands as "::add-mask::", and "add" is three hex digits, so the
      shape matched "::add" and ipv6ok() correctly agreed it is a valid compressed
      address (::0add). The detector was reading a DELIMITER as an address, on the
      one line in a CI log that exists because someone is hiding a secret. A real
      address is never immediately followed by a word character, a colon or a
      hyphen; a delimiter usually is.
      Wake 071, from /etc/gai.conf. An IPv4-mapped address is written
      `::ffff:169.254.0.0`, and the colon-group shape matched its HEAD -- `::ffff:169` --
      leaving `[IPV6_1].254.0.0` in the output, an address cut in half. Two parts, and the
      first alone was not enough: `(?:(?:\.\d{1,3}){3})?` lets the dotted tail join the
      match so the whole address is one finding, and `(?!\.\d)` refuses a head that is
      followed by a dotted decimal the tail did not take. The lookahead tests `\.\d` and
      not `\.` so an address ending a sentence still matches. Fixing only the head made
      the ipv4 rule take the embedded v4 and RELABEL a correct IPV6 finding as IP, which
      masked-values-check caught -- a narrower rule that stops matching hands its finding
      to a broader one, and the label is part of the answer. */
   re:/(?<![\w:.])(?=[0-9A-Fa-f:]*:[0-9A-Fa-f:]*:)[0-9A-Fa-f]{0,4}(?::[0-9A-Fa-f]{0,4}){2,7}(?:(?:\.\d{1,3}){3})?(?![\w:-])(?!\.\d)/g,
   /* Wake 071, from real bytes: /etc/gai.conf ships on every Debian and Ubuntu box
      and it is nothing but IPv6 special-purpose prefixes. Ten of ten lines in it were
      redacted. The ipv4 rule has always skipped the ranges that cannot identify a
      machine; this one skipped only ::1 and fe80:, and the asymmetry is the bug.
      Added, and each can never be a routable unicast host: ff00::/8 is multicast, so
      ff02::1 and ff02::fb are group constants that every NDP and mDNS log line carries;
      ::ffff:0:0 (anchored whole, not the prefix) is the bare IPv4-mapped
      base, and a real mapped address carries a v4 host the ipv4 rule judges instead.
      Redacting a constant the reader could have looked up INFLATES: it tells them a
      secret was there when nothing was. Recall cost is nil by construction -- none of
      these ranges can name a host -- and 2a03:...::2 style global unicast is asserted
      still caught. 2001:db8::/32 was in this list for one build and is deliberately NOT:
      it is reserved for documentation and by the same argument should be skipped, but it
      is also the address every example on earth uses, including six of my own fixtures.
      A stranger evaluating the tool pastes the canonical demo address; a detector that
      goes quiet on it reads as broken, and that costs more than the inflation it saves.
      It also never appeared in the real bytes -- I reached for it from principle, which
      is the move this whole tier exists to catch. */
   skip:/^(?:::1|::ffff:0:0$|fe80:|ff[0-9A-Fa-f]{2}:)/i, validate:ipv6ok},
  {g:"Network and machine", id:"mac", label:"MAC addresses", tag:"MAC", on:true,
   /* Six colon-separated hex pairs is also the first six bytes of every
      certificate fingerprint, GPG key id and TLS debug dump -- a run that is
      longer than a MAC and gets chopped into several fake ones. So the match
      must not be a slice of a longer colon-hex run in either direction. */
   re:/(?<![0-9A-Fa-f]:)\b(?:[0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}\b(?!:[0-9A-Fa-f])/g},
  {g:"Network and machine", id:"uuid", label:"UUIDs", tag:"UUID", on:false,
   re:/\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g},
  {g:"Network and machine", id:"hexblob", label:"Long high-entropy strings", tag:"HIGH_ENTROPY", on:false,
   re:/\b[A-Za-z0-9+\/_-]{32,}={0,2}/g, validate:looksRandom}
];

function luhn(s){
  var d = s.replace(/[ -]/g,"");
  if(!/^\d{13,19}$/.test(d)) return false;
  var sum=0, alt=false;
  for(var i=d.length-1;i>=0;i--){
    var n=+d[i];
    if(alt){ n*=2; if(n>9) n-=9; }
    sum+=n; alt=!alt;
  }
  return sum%10===0;
}

/* Luhn alone is not enough, and the gap is not small: a Luhn check passes one in
   ten of EVERY long integer, and computing is full of them -- nanosecond epochs,
   Snowflake ids, Kafka offsets, database bigints. It also passes a run of zeros,
   which is exactly OpenTelemetry's "no parent span" sentinel, so bare Luhn flags
   a span id in every distributed trace anyone pastes. So a Luhn-valid digit run
   is only a card if some network actually issues that prefix at that length. */
function cardNumber(s){
  var d = s.replace(/[ -]/g,"");
  if(!luhn(d)) return false;
  var n = d.length;
  return (/^4/.test(d)                                  && (n===13||n===16||n===19))  /* Visa */
      || (/^(5[1-5]|2[2-7])/.test(d)                    && n===16)                    /* Mastercard */
      || (/^3[47]/.test(d)                              && n===15)                    /* Amex */
      || (/^3(0[0-5]|[689])/.test(d)                    && n>=14 && n<=19)            /* Diners Club */
      || (/^35/.test(d)                                 && n>=16 && n<=19)            /* JCB */
      || (/^(6011|64[4-9]|65)/.test(d)                  && n>=16 && n<=19)            /* Discover */
      || (/^62/.test(d)                                 && n>=16 && n<=19)            /* UnionPay */
      || (/^(5018|5020|5038|6304|6759|676[1-3])/.test(d) && n>=12 && n<=19);          /* Maestro */
}

/* Shannon entropy per character. Only used by the opt-in high-entropy detector,
   because on its own it is far too eager. */
function looksRandom(s){
  if(/^\d+$/.test(s)) return false;
  if(!/\d/.test(s) || !/[A-Za-z]/.test(s)) return false;
  var f={}, i;
  for(i=0;i<s.length;i++) f[s[i]]=(f[s[i]]||0)+1;
  var h=0;
  for(var k in f){ var p=f[k]/s.length; h -= p*Math.log(p)/Math.LN2; }
  return h>3.6;
}

/* Is this value ALREADY redacted by somebody else?
   The case that motivated it: a CI job scans the artifact it is about to
   publish, and that artifact has already been through gitleaks, GitHub's
   ::add-mask::, or a hand-rolled sed. Every masked value still has the SHAPE of
   the secret it replaced, so every one of them comes back as a finding. The
   report then reads "5 secrets found" for a file that is already safe, and the
   one line that matters is buried in four that do not.

   Four shapes, and every one is deliberately narrow, because the failure mode on
   the other side is missing a real secret:
     (a) the body is nothing but mask characters, after any intact prefix --
         AKIA****************, sk_live_************, xoxb-****-****-****.
         A real credential alphabet is base64, base62 or hex; none of them
         contains "*" or the bullet a UI mask uses.
     (b) mask characters are at least four of them AND at least half the value,
         with whatever survives at either end -- ****************OY2G,
         AKIA************OY2G. Rule (a) only ever matched a mask that keeps the
         HEAD, which is the rarer convention: AWS, Stripe, every payment form
         and most loggers keep the LAST FOUR. Wake 064 measured it, and 44 of
         the 53 findings my own scanner returned on an already-masked corpus
         came from this one asymmetry.
     (c) the whole value, stripped of wrapping punctuation, is one of a closed
         list of words a redactor writes -- ***REMOVED***, <sensitive>,
         [REDACTED BY CI].
     (d) any of the above behind a scheme or label word -- "Bearer [REDACTED]",
         "sha256:REDACTED", "api:<sensitive>". A detector's span is often wider
         than the value (an auth header carries its scheme), so the predicate was
         being asked about a string nobody ever masks. Strip up to two leading
         label tokens and ask again; the remainder still has to pass on its own,
         so "user:realpassword" and "sha256:9f86d081..." are untouched.
   A password may legitimately contain an asterisk ("P@ssw*rd"), so no rule here
   fires on a value that still has real characters mixed in. */
var MASKWORDS = /^(?:redacted|removed|masked|hidden|sensitive|censored|secret|elided|scrubbed|filtered|omitted|notshown|setinci)$/;
var MASKCH = /[*\u2022\u2027\u00b7]/g;
function alreadyMasked(s, depth){
  if(!s) return false;
  /* No credential alphabet is empty of letters and digits. A value that is
     nothing but punctuation is banner padding ("secret =====") or a mask. */
  if(/^[\s!-\/:-@\[-`{-~]+$/.test(s)) return true;
  var at = s.search(/[*\u2022\u2027\u00b7]/);               /* AKIA**** / sk_live_**** / xoxb-****-**** */
  if(at >= 0 && !/[^*\u2022\u2027\u00b7\-_.: ]/.test(s.slice(at))) return true;
  /* (b) position-independent, and BOTH thresholds are load-bearing: the count
     of four keeps a two-character value like "a*" a finding, and the majority
     keeps "P@ssw*rd" and "Tr0ub4d*r*P*ss*word" findings. Each has its own
     must-flag case and its own mutant in twopass-check.mjs. */
  var mc = s.match(MASKCH);
  if(mc && mc.length >= 4 && mc.length * 2 >= s.length) return true;
  /* A run of x is the one LETTER used as a mask character. Whole-value only,
     and at least four long: "xxxxxxxx" is a mask, "xxl" is a size and
     "0xDEADBEEF" keeps its digits. The detectors' own skip lists already
     accepted this shape; it was only invisible from in here, which is where a
     scheme-wrapped "Bearer xxxxxxxx" has to be asked about. */
  if(/^[xX]{4,}$/.test(s)) return true;
  var word = s.replace(/[^A-Za-z]+/g, "").toLowerCase();
  if(word && MASKWORDS.test(word) && !/[A-Za-z]{3,}[0-9]{3,}/.test(s)) return true;
  /* (d) the same question, asked about the value rather than the span. */
  var lbl = /^[A-Za-z][A-Za-z0-9_-]{0,15}[:=\s]\s*(\S.*)$/.exec(s);
  if(lbl && (depth||0) < 2 && alreadyMasked(lbl[1], (depth||0) + 1)) return true;
  /* (c) a template placeholder. Every repository ships a .env.example, and
     "sk_live_your_key_here" has a real prefix and a body that is nothing but
     filler words. The list is CLOSED and every word in the body must be on it,
     so "sk_live_51H8xQ2AbCdEf" and even "AKIAIOSFODNN7EXAMPLE" are untouched:
     one unrecognised token is enough to make it a candidate again. */
  var parts = s.toLowerCase().split(/[^a-z]+/).filter(Boolean), run = 0;
  while(run < parts.length && parts[parts.length-1-run].length >= 3
        && FILLER.test(parts[parts.length - 1 - run])) run++;
  if(run >= 2 && parts.length >= 2) return true;
  return false;
}
/* Words that only ever appear in a placeholder, never inside a generated
   credential. Two or more of them, and nothing else, is a template. */
var FILLER = /^(?:your|our|the|this|that|here|goes|value|key|keys|token|secret|password|pass|pwd|api|apikey|changeme|change|replace|insert|example|sample|placeholder|dummy|fake|todo|fixme|xxx|yyy|zzz|none|null|empty|redacted|removed|masked|hidden)$/;

/* A real IPv6 check, run over whatever the shape regex admitted. Either the
   address is compressed with exactly one "::" and has at most 7 explicit groups,
   or it is the full form with exactly 8. Everything else -- clock times, MAC
   addresses, durations -- fails here. */
function ipv6ok(s){
  if(s.indexOf(":::")>=0) return false;
  var halves=s.split("::"), i;
  if(halves.length>2) return false;
  if(halves.length===2){
    var a=halves[0]?halves[0].split(":"):[], b=halves[1]?halves[1].split(":"):[];
    for(i=0;i<a.length;i++) if(!a[i]) return false;
    for(i=0;i<b.length;i++) if(!b[i]) return false;
    return a.length+b.length<=7;
  }
  var p=s.split(":");
  if(p.length!==8) return false;
  for(i=0;i<8;i++) if(!p[i]) return false;
  return true;
}

/* Encodings this scanner cannot see through, and why saying so matters more
   than any detector below. Every pattern here matches a contiguous ASCII-ish
   run. A log saved as UTF-16 -- which is what Windows PowerShell 5.1 writes
   from > and Out-File by default -- stores a GitHub token as g\0h\0p\0_\0,
   so every detector misses it and the scan reports zero findings. That is
   worse than an error: it is a clean bill of health for a file full of live
   credentials. Compressed and binary payloads fail the same way. Detect the
   shape of the input and refuse to call a blind scan a pass. */
function encodingHazard(text){
  if(!text || text.length<32) return null;
  var n=Math.min(text.length,8192), even=0, odd=0, ctrl=0, bad=0, i, c;
  for(i=0;i<n;i++){
    c=text.charCodeAt(i);
    if(c===0){ if(i%2) odd++; else even++; }
    /* \t \n \r and ESC are ordinary in logs; ESC especially, because ANSI
       colour codes are everywhere and must never trip this. */
    else if((c<9 || (c>13 && c<32) || c===127) && c!==27) ctrl++;
    /* U+FFFD is what a UTF-8 decoder leaves behind when it is handed bytes
       that are not UTF-8. By the time we see the string the damage is done. */
    else if(c===0xFFFD) bad++;
  }
  var nuls=even+odd, half=n/2;
  if(nuls>n*0.2){
    if(odd>half*0.7 && even<half*0.1) return {kind:"utf16le", label:"UTF-16 LE",
      note:"This looks like UTF-16 text, which is what Windows PowerShell writes from > and Out-File by default. Secrets in it are stored with a zero byte between every character, so nothing here can match them and a clean result would mean nothing. Re-save it as UTF-8, then scan again."};
    if(even>half*0.7 && odd<half*0.1) return {kind:"utf16be", label:"UTF-16 BE",
      note:"This looks like big-endian UTF-16 text. Secrets in it are stored with a zero byte between every character, so nothing here can match them and a clean result would mean nothing. Re-save it as UTF-8, then scan again."};
  }
  /* Not UTF-8 at all: a CP1251, Shift_JIS, EUC-KR or Big5 log read with the
     default utf8 decoder. The ASCII skeleton survives, so a secret is still
     found and the result looks fine -- but every non-ASCII byte has already
     been replaced, and writing the output back destroys the log. */
  if(bad>3 && bad>n*0.02) return {kind:"undecodable", label:"not UTF-8",
    note:"This is not UTF-8. It looks like a legacy single- or double-byte encoding (CP1251, Shift_JIS, EUC-KR, Big5) that your UTF-8 decoder has already mangled: every non-ASCII character has become a replacement character before this tool ever saw it. Secrets written in ASCII are still found, but the surrounding text is gone and saving this output over the original will destroy it. Convert first -- iconv -f CP1251 -t UTF-8 -- then scan."};
  if(nuls+ctrl>n*0.08) return {kind:"binary", label:"binary or compressed",
    note:"This looks like binary or compressed data, not text. Anything readable inside it is unreadable to a pattern scanner, so a clean result would mean nothing. Decompress or decode it first, then scan the text."};
  return null;
}


/* ANSI colour codes are everywhere in real logs -- GitHub Actions, docker
   compose, npm, cargo, pytest all emit them by default -- and every one of
   them ENDS IN A LETTER. ESC[33m puts an `m` immediately before the value it
   colours, so every detector guarded by (?<![A-Za-z0-9]) refuses to fire, and
   a bare AKIA... key wrapped in colour comes back as NOTHING AT ALL: a clean
   bill of health for a coloured log full of live credentials. The same
   sequences also carry digits -- ESC[38;5;208m -- which the IPv4 and card
   rules are happy to read as data. The generic assignment rules do still
   catch a coloured `key=value`, which is why this hid for so long: the shapes
   that break are the bare ones, and they break silently.
   Both halves are fixed by matching against a copy with the escape sequences
   REMOVED, so the scanner sees exactly what the terminal renders, and writing
   every span back through ansiMap so it still addresses the original bytes.
   An OSC sequence has its introducer and terminator stripped but NOT its
   payload: a key printed into a window title is still a key sitting in the
   file somebody is about to share. */
var ANSI_RE=/\u001b\[[0-9;:?]*[ -\/]*[@-~]|\u001b\][0-9]*;|\u001b[@-Z\\^_]|\u0007/g;
function stripAnsi(text){
  if(!text || text.indexOf("\u001b")<0) return text;
  return text.replace(ANSI_RE, "");
}
/* The index map that makes removal safe. ansiMap(text)[i] is the offset in the
   ORIGINAL text of character i of stripAnsi(text), so a span found in the
   stripped copy can be written back to the exact bytes the reader pasted.
   null when there is nothing to strip, which is the common case and costs
   nothing. */
function ansiMap(text){
  if(!text || text.indexOf("\u001b")<0) return null;
  var map=new Array(text.length), n=0, i=0, re=new RegExp(ANSI_RE.source, "g"), m, cut=[];
  while((m=re.exec(text))!==null){ cut.push([m.index, m.index+m[0].length]); if(m[0]==="") re.lastIndex++; }
  var c=0;
  for(i=0;i<text.length;i++){
    while(c<cut.length && i>=cut[c][1]) c++;
    if(c<cut.length && i>=cut[c][0]) continue;
    map[n++]=i;
  }
  map.length=n;
  return map;
}
/* Length-preserving variant, for the one caller that must not move: the second
   look is handed spans already measured against the original text, so its copy
   has to keep every offset where it was. A colour change inside an encoded run
   is also a real break in that run, so blanking is the honest reading there,
   where inside a URL it is not. */
function blankAnsi(text){
  if(!text || text.indexOf("\u001b")<0) return text;
  return text.replace(ANSI_RE, function(m){ return new Array(m.length+1).join(" "); });
}
function countAnsi(text){
  if(!text || text.indexOf("\u001b")<0) return 0;
  var m=text.match(ANSI_RE);
  return m ? m.length : 0;
}

/* ---- the rest of what renders as nothing -------------------------------
   Colour was one instance of a general problem, and wake 062 measured the
   rest of it. Every rule here is written against what a person SEES, so any
   character that occupies no space on screen and yet sits between two bytes
   of a credential breaks the rule while changing nothing the reader can
   notice. An escape sequence was the visible-provenance case; these are the
   invisible-provenance ones, and they arrive the same ordinary ways: text
   pasted out of a browser log viewer, a document, a chat client or a
   password manager (ZWSP, word joiner, soft hyphen), a UTF-8 BOM left in the
   middle of a file by concatenating two exports, and bidi controls, which
   are also the deliberate way to hide a string from a scanner.
   Measured against the true-positive corpus: one zero-width character placed
   inside a credential lost 37 of 231 secret-and-position cases outright, and
   a BOM lost 76 -- because JavaScript's \s matches U+FEFF but not U+200B, so
   a BOM additionally TERMINATES every value pattern that reads to whitespace.
   The set is exactly the characters with no advance width. U+00A0 is NOT
   here: a non-breaking space renders as a space, so a scanner treating it as
   nothing would disagree with the reader in the other direction. Neither is
   \r: a carriage return is a real break in what the terminal renders (the
   next text overwrites the line), so joining across it would be inventing a
   string nobody ever saw. CRLF endings need no help -- \r is already
   whitespace to every value rule. */
var INVIS_RE=/[\u00ad\u061c\u180e\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufe00-\ufe0f\ufeff\ufff9-\ufffb]/g;
var NOISE_SRC=ANSI_RE.source+"|"+INVIS_RE.source;
function hasNoise(text){ return !!text && (text.indexOf("\u001b")>=0 || new RegExp(INVIS_RE.source).test(text)); }
/* The same two functions colour needed, over the wider set: a copy with the
   noise REMOVED for the rules to read, and the index map that writes every
   span found in that copy back onto the bytes the reader actually pasted. */
function stripNoise(text){
  if(!hasNoise(text)) return text;
  return text.replace(new RegExp(NOISE_SRC,"g"), "");
}
function noiseMap(text){
  if(!hasNoise(text)) return null;
  var map=new Array(text.length), n=0, i=0, re=new RegExp(NOISE_SRC,"g"), m, cut=[];
  while((m=re.exec(text))!==null){ cut.push([m.index, m.index+m[0].length]); if(m[0]==="") re.lastIndex++; }
  var c=0;
  for(i=0;i<text.length;i++){
    while(c<cut.length && i>=cut[c][1]) c++;
    if(c<cut.length && i>=cut[c][0]) continue;
    map[n++]=i;
  }
  map.length=n;
  return map;
}
function countInvisible(text){
  if(!text) return 0;
  var m=text.match(new RegExp(INVIS_RE.source,"g"));
  return m ? m.length : 0;
}

/* ---- what renders as something ELSE -------------------------------------
   Wake 062 closed the half of this class that renders as NOTHING. This is the
   mirror image: characters that occupy their space and draw a shape a reader
   reads as an ASCII character, while being a different code point. A Cyrillic
   a in aws_secret_access_key, a Greek capital omicron inside AKIA..., a
   fullwidth colon after "password", an en dash where a hyphen was. Every one
   of them is what a reader sees as the ordinary character, and none of them
   is that character to a regular expression.
   They arrive two ordinary ways and one hostile one. Ordinary: text that has
   been through a word processor, a chat client or a PDF (hyphen becomes en
   dash, quote becomes curly quote), and text typed in a CJK input mode, which
   produces fullwidth Latin letters, digits and punctuation. Hostile: swapping
   one letter of a key name for its Cyrillic twin is the cheapest way there is
   to walk a secret past a name-anchored scanner, and the diff looks identical.
   Measured against the true-positive corpus before this pass existed: one
   single-character substitution lost the secret at 143 of 488 sites where a
   corpus secret or the name in front of it had a confusable twin -- 88 of 285
   fullwidth, 28 of 90 Cyrillic, 19 of 55 Greek, 8 of 58 typographic
   punctuation. After it: 0 of 488, with no new finding in any of the 109
   sections of the clean corpus.
   THE MAP IS 1:1 BY CONSTRUCTION and that is the whole reason this is cheap:
   folding is length-preserving, so every offset found in the folded copy still
   addresses the byte the reader pasted and the noiseMap from the invisible
   pass keeps working unchanged. Two edges follow from that constraint, and
   both are deliberate:
     - The mathematical alphanumerics (U+1D400 and up, bold and script Latin
       from social media) are OUT. They are astral, so folding one would move
       every later offset, and a redactor that reports the wrong span is worse
       than one that reports nothing.
     - U+00A0 and U+3000 are not folded either, for the reason 062 gave for the
       non-breaking space: they render as a space, and JavaScript's \s already
       matches both, so every value rule already ends at them. */
var CONFUSABLES = (function(){
  var m = {
    /* typographic: a document, a chat client or a PDF did this */
    "–":"-", "—":"-", "‑":"-", "−":"-", "‐":"-",
    "‘":"'", "’":"'", "“":'"', "”":'"', "ʼ":"'",
    /* Cyrillic twins of Latin letters */
    "а":"a", "е":"e", "о":"o", "р":"p", "с":"c",
    "х":"x", "у":"y", "і":"i", "ј":"j", "һ":"h",
    "ѕ":"s", "А":"A", "В":"B", "Е":"E", "К":"K",
    "М":"M", "Н":"H", "О":"O", "Р":"P", "С":"C",
    "Т":"T", "Х":"X", "І":"I", "Ѕ":"S", "Ј":"J",
    /* Greek twins */
    "Α":"A", "Β":"B", "Ε":"E", "Ζ":"Z", "Η":"H",
    "Ι":"I", "Κ":"K", "Μ":"M", "Ν":"N", "Ο":"O",
    "Ρ":"P", "Τ":"T", "Υ":"Y", "Χ":"X", "ο":"o",
    "ν":"v", "α":"a", "Β":"B"
  };
  /* Fullwidth forms, built rather than typed: U+FF01-FF5E is ASCII 0x21-0x7E
     shifted by 0xFEE0, so the whole block folds by arithmetic and there is no
     chance of a hand-typed row being one character off. That covers fullwidth
     letters, digits AND punctuation -- the fullwidth colon after "password"
     and the fullwidth equals sign in a config line come from here. */
  for(var c=0x21; c<=0x7e; c++) m[String.fromCharCode(c+0xfee0)] = String.fromCharCode(c);
  return m;
})();
function hasConfusable(text){
  if(!text) return false;
  for(var i=0;i<text.length;i++) if(CONFUSABLES[text.charAt(i)]) return true;
  return false;
}
/* Length-preserving by construction: one character in, one character out. */
function foldConfusables(text){
  if(!hasConfusable(text)) return text;
  var out="";
  for(var i=0;i<text.length;i++){ var c=text.charAt(i); out += (CONFUSABLES[c] || c); }
  return out;
}
function countConfusables(text){
  if(!text) return 0;
  var n=0;
  for(var i=0;i<text.length;i++) if(CONFUSABLES[text.charAt(i)]) n++;
  return n;
}


function escapeRe(s){ return s.replace(/[.*+?^${}()|[\]\\]/g,"\\$&"); }
function customDetectors(){return CUSTOM;}
var CUSTOM=[];
function collect(text){
  /* Match against the colour-stripped copy, never the raw text, and write the
     offsets back through ansiMap so every span still addresses the original
     bytes. Removing the escapes rather than blanking them is deliberate: it
     makes the scanner see exactly what the terminal renders. Blanking them
     traded one class of miss for another -- postgres://app:ESC[31mpw ESC[0m@db
     becomes app: pw @db, and the URL-userinfo rule needs :password@ to be
     contiguous, so a coloured DSN password went from caught to missed.
     Wake 062 widened the same pass from escape sequences to everything else
     that renders as nothing -- zero-width spaces, joiners, soft hyphens, a
     stray BOM, bidi controls -- for the same reason and through the same map. */
  /* Two passes, in this order, and both are length-honest about the result:
     stripNoise REMOVES what renders as nothing and hands back a map so every
     span still addresses the original bytes; foldConfusables rewrites what
     renders as something ELSE, 1:1, so it needs no map at all. */
  var scan=foldConfusables(stripNoise(text)), amap=noiseMap(text);
  var spans=[], dets=DETECTORS.concat(customDetectors());
  /* Distinct values a rule matched and then declined because another tool had
     already masked them. A negative assertion needs a witness (wake 034), and
     "0 findings" is the answer both for a file nobody ever scrubbed and for one
     that has already been through gitleaks. Counting the residue is the only way
     the second reader can tell that this tool SAW their file. Carried as a
     property on the returned array so no consumer has to change. */
  var maskedSeen=Object.create(null);
  dets.forEach(function(d, prio){
    if(!d.on) return;
    var flags = d.re.flags.indexOf("d")<0 ? d.re.flags+"d" : d.re.flags;
    var re;
    try { re=new RegExp(d.re.source, flags); } catch(e){ return; }
    var m, guard=0;
    while((m=re.exec(scan))!==null){
      if(++guard>20000) break;
      if(m[0]===""){ re.lastIndex++; continue; }
      var start=m.index, end=m.index+m[0].length, value=m[0];
      if(d.groups){
        var span=null;
        for(var gi=0; gi<d.groups.length; gi++){
          var idx = m.indices && m.indices[d.groups[gi]];
          if(idx){ span=idx; value=m[d.groups[gi]]; break; }
        }
        if(!span) continue;
        start=span[0]; end=span[1];
      }
      /* A detector's own skip list and the mask predicate are two different
         questions, and a value can be declined by either. Ask the mask question
         on both paths, or the count under-reports exactly the shapes a skip list
         already knew about ("xxxxxxxx"), while "true" and "error" -- which are
         skipped and are not masks -- still stay out of it. */
      if(d.skip && d.skip.test(value)){ if(alreadyMasked(value)) maskedSeen[value]=1; continue; }
      /* skip tests the captured VALUE; skipMatch tests the whole match, so a rule
         can rule itself out on the NAME as well. Needed where the same value is a
         secret under one name and an ordinary number under another. */
      if(d.skipMatch && d.skipMatch.test(m[0])) continue;
      if(d.validate && !d.validate(value)) continue;
      if(alreadyMasked(value)){ maskedSeen[value]=1; continue; }
      if(amap){
        /* end-1 is the last character kept; +1 puts the boundary just past it,
           so an escape sitting immediately after the value stays outside. */
        var oend = end>start ? amap[end-1]+1 : amap[start];
        start = amap[start]; end = oend;
      }
      spans.push({start:start, end:end, value:value, tag:d.tag, det:d.id, prio:prio});
    }
  });
  /* Resolve overlaps: leftmost wins, then the higher-priority detector,
     then the longer match. */
  spans.sort(function(a,b){
    return a.start-b.start || a.prio-b.prio || (b.end-b.start)-(a.end-a.start);
  });
  var kept=[], lastEnd=-1;
  kept.maskedValues=Object.keys(maskedSeen);
  kept.masked=kept.maskedValues.length;
  spans.forEach(function(s){
    if(s.start>=lastEnd){ kept.push(s); lastEnd=s.end; }
  });
  return kept;
}


export {collect, DETECTORS, luhn, cardNumber, looksRandom, encodingHazard, stripAnsi, blankAnsi, ansiMap, countAnsi, stripNoise, noiseMap, countInvisible, CONFUSABLES, foldConfusables, countConfusables, hasConfusable};