Skip to content

Detection Rules

Sensitive Canary scans text against the following rules. Patterns are sourced from gitleaks and TruffleHog detector definitions.

Secrets

Cloud

Rule IDDescription
aws-access-keyAWS Access Key ID (AKIA, ASIA, AGPA, AIDA, AROA, … + 16 uppercase alphanumeric chars)
gcp-api-keyGoogle Cloud API Key (AIza + 35 alphanumeric/dash/underscore chars)
digitalocean-patDigitalOcean Personal Access Token (dop_v1_ prefix)
supabase-keySupabase Personal Access Token (sbp_ prefix)

Source Control

Rule IDDescription
github-patGitHub Personal Access Token (ghp_, gho_, ghu_, ghs_, ghr_ prefix)
github-fine-grainedGitHub Fine-Grained Token (github_pat_ prefix)
gitlab-patGitLab Personal Access Token (glpat- prefix)

Package Registries

Rule IDDescription
npm-tokennpm Access Token (npm_ prefix + 36 alphanumeric chars)

AI Services

Rule IDDescription
openai-keyOpenAI API Key — legacy format (sk- + 48 chars)
openai-project-keyOpenAI Project API Key (sk-proj- prefix, entropy-filtered)
anthropic-keyAnthropic API Key (sk-ant- prefix)
replicate-tokenReplicate API Token (r8_ prefix)
huggingface-tokenHugging Face Access Token (hf_ prefix)
groq-keyGroq API Key (gsk_ prefix)
openrouter-keyOpenRouter API Key (sk-or-v1- prefix)
xai-keyxAI (Grok) API Key (xai- prefix)
perplexity-keyPerplexity API Key (pplx- prefix)

Communication

Rule IDDescription
slack-tokenSlack Token (xoxb-, xoxa-, xoxp-, xoxr-, xoxs- prefix)
slack-webhookSlack Incoming Webhook URL
discord-webhookDiscord Webhook URL
telegram-bot-tokenTelegram Bot Token
twilio-sidTwilio Account SID

Payment

Rule IDDescription
stripe-secret-keyStripe Secret Key (sk_live_ / sk_test_ prefix)
stripe-restricted-keyStripe Restricted Key (rk_live_ / rk_test_ prefix)
square-access-tokenSquare Access Token (EAAA prefix)

Email Services

Rule IDDescription
sendgrid-keySendGrid API Key (SG. prefix)
mailgun-keyMailgun API Key (key- prefix)
mailchimp-keyMailchimp API Key (32-char hex + -usN suffix)

Auth

Rule IDDescription
jwtJSON Web Token (three Base64URL segments separated by .)
private-keyPEM Private Key header (-----BEGIN … PRIVATE KEY-----)
private-key-base64PEM private key that has been base64-encoded — how one appears in a kubeconfig, a Kubernetes Secret or a Terraform state, where the -----BEGIN header never shows in the text
url-basic-authCredentials in the userinfo field of an http(s) URL — a git remote, a .netrc, a private registry, a curl invocation. RFC 3986 deprecates the form for this reason
connection-stringDatabase connection string with embedded credentials; each half of the credentials is bounded at 1024 characters — see the note below the tables

SaaS / Developer Tools

Rule IDDescription
mapbox-tokenMapbox Token (pk. / sk. JWT prefix)
sentry-user-tokenSentry User Auth Token (sntryu_ prefix)
sentry-org-tokenSentry Organization Auth Token (sntrys_ JWT prefix)
atlassian-tokenAtlassian (Jira/Confluence) API Token (ATATT3 prefix)
linear-keyLinear API Key (lin_api_ prefix)
postman-keyPostman API Key (PMAK- prefix)

Platforms and Infrastructure

Rule IDDescription
openai-service-keyOpenAI Service Account / Admin Key (sk-svcacct-, sk-admin-, sk-proj- prefix)
azure-storage-keyAzure Storage Account Key (AccountKey= + 88-char base64)
azure-sas-keyAzure Shared Access Key for Service Bus, Event Hubs and IoT Hub (SharedAccessKey= + 22–86-char base64 with optional = padding). Separate from the storage account key, which is 88 characters
google-oauth-secretGoogle OAuth Client Secret (GOCSPX- prefix)
flyio-tokenFly.io API Token (FlyV1 fm2_ prefix)
databricks-tokenDatabricks Personal Access Token (dapi + 32 hex)
vault-tokenHashiCorp Vault Token (hvs. / hvb. prefix)
shopify-tokenShopify Access Token (shpat_, shpss_, shpca_, shppa_ prefix)
doppler-tokenDoppler Token (dp.pt., dp.st., … prefix)
grafana-tokenGrafana Cloud / Service Account Token (glc_, glsa_ prefix)
notion-tokenNotion Integration Token (ntn_ prefix)

Generic / Env-based

Rule IDDescriptionEntropy threshold
generic-secretapi_key, secret_key, access_token, api_secret assignments3.5
env-assignmentAssignments whose name carries SECRET, PASSWORD, PASSWD, PASS, TOKEN, API_KEY, ACCESS_KEY or PRIVATE_KEY, written with = anywhere or with : at the start of a line. PASS must stand on its own — COMPASS and BYPASS are not passwords. The value must be a value: eight characters or more, no brackets or separators in it, and not a $VAR reference3.0

The entropy threshold filters out low-entropy values that are unlikely to be real secrets — API_KEY=aaaaaaaa scores 0 and is dropped. It is a weak filter, not a classifier: placeholder scores 3.096 and clears the 3.0 threshold, so entropy alone would report API_KEY=placeholder. What drops that one is the placeholder test, not the threshold. Entropy is the Shannon entropy of the value.

Why private-key-base64 carries three prefixes

Base64 encodes three bytes at a time, so what -----BEGIN looks like once encoded depends on where it sits relative to that boundary. A key encoded whole — client-key-data in a kubeconfig, tls.key in a Kubernetes Secret, a base64 -w0 < key.pem — starts at offset 0 and produces LS0tLS1CRUdJTi. A key that begins one or two bytes into what was encoded produces 0tLS0tQkVHSU4g or tLS0tLUJFR0lOI instead, and the rule lists all three: matching only the first would find one PEM in three. Offsets beyond two repeat the cycle.

The plaintext private-key rule is unaffected either way — it reads the header itself.

PII

Rule IDDescriptionNotes
pii-emailEmail AddressLocal part up to 64 characters per unbroken run (RFC 5321's limit); domain matched as dot-separated labels, so a domain with no dot (user@localhost) is not one. Both bounds are there to keep the pattern from backtracking — see the note below the table
pii-credit-cardCredit Card NumberVisa, Mastercard, Amex, Discover; validated with Luhn algorithm
pii-ssnUS Social Security NumberExcludes invalid area (000, 666, 9xx), group (00), and serial (0000) numbers
pii-mynumber-jpJapanese Individual Number (My Number)12 digits, validated with weighted checksum (mod 11)
pii-nir-frFrench NIR / Social Security Number15 digits, validated with check key (mod 97); Corsica 2A/2B supported
pii-codice-fiscale-itItalian Codice Fiscale16 alphanumeric chars, validated with control character (mod 26)
pii-steuer-id-deGerman Steuer-Identifikationsnummer11 digits, validated with MOD 11,10 (ISO/IEC 7064)
pii-dni-nie-esSpanish DNI / NIE8 digits + letter (DNI) or X/Y/Z + 7 digits + letter (NIE); validated with mod 23
pii-phone-usUS Phone NumberWith or without country code
pii-phone-jpJapanese Phone NumberArea code + subscriber number format
pii-phone-frFrench Phone NumberContext-gated (requires nearby phone label)
pii-phone-itItalian Phone NumberContext-gated
pii-phone-deGerman Phone NumberContext-gated
pii-phone-esSpanish Phone NumberContext-gated
pii-postal-jpJapanese Postal CodeRequires prefix to avoid false positives
pii-postal-codePostal Code (US ZIP / EU / KR)Context-gated (requires nearby postal label)
pii-rrn-krKorean Resident Registration Number13 digits, validated with weighted checksum (mod 11); context-gated, since thirteen digits that satisfy the checksum turn up in timestamps
pii-brn-krKorean Business Registration Number10 digits, validated with NTS standard checksum; context-gated, for the same reason
pii-resident-id-cnChinese Resident Identity Card18 chars (17 digits + check), validated with GB 11643 MOD 11-2
pii-phone-krKorean Phone NumberContext-gated
pii-phone-cnChinese Phone NumberContext-gated
pii-postal-cnChinese Postal Code (6-digit)Context-gated
pii-ipv4-publicPublic IPv4 AddressContext-gated; reserved/private ranges excluded
pii-ipv6IPv6 AddressContext-gated; loopback, link-local, ULA, multicast excluded

Why three patterns carry length bounds

pii-email, env-assignment and connection-string each bound a repeated character class, and the bounds are not cosmetic. Each put an unbounded quantifier on a class that also matches the separator around it, so on a long run with no match in it — a log full of IP addresses, a file of capitals with no =, a line of mongodb:// with no @ — every position started a greedy consume of the rest of the text and then backtracked a character at a time. That is quadratic: measured on env-assignment alone, 59 KB took 381 ms, 234 KB took 6.9 s and 1 MiB took 125 s; connection-string took 2.3 s on 188 KB and 98 s on 1 MiB through the hook.

This matters more than a slow scan. A hook that does not return is killed by Claude Code's PreToolUse timeout, and a killed hook does not block the tool call — so a slow pattern is a way through, not an inconvenience. src/lib/__tests__/rules.test.ts runs every rule in this file against a list of adversarial shapes and fails any that takes more than two seconds. That list is not a proof: connection-string was quadratic and every shape then in the list walked past it, so one written for its own syntax had to be added before it failed. Adding a rule means asking what input makes its own quantifiers run, and adding that shape there if the list has nothing like it.

A rule that needs an unbounded repeat should say why in its description, and should come with a case in that file.

What the bounds cost, stated rather than implied:

  • pii-email: an address with 65 or more [A-Za-z0-9_] characters in an unbroken run before the @. Every other character the local part allows — ., %, +, - — is a non-word character, so the word boundary restarts at it and a long address containing one is still found (measured: 65 letters is missed, 65 with a % in the middle is not); 64 is RFC 5321's limit for the whole local part, so no deliverable address is lost.
  • env-assignment: a name with 65 or more capitals in a run on either side of the keyword — AAA…SECRET= and SECRET…AAA= alike. _ is a word character, so A×65_SECRET= is lost too, since the boundary does not restart at the underscore.
  • connection-string: a user or a password longer than 1024 characters.

None of these is a spelling anyone writes. All of them are a way to write one this tool will not see, which is the honest way to hold both facts.

National ID Validation

National ID numbers (JP My Number, FR NIR, IT Codice Fiscale, DE Steuer-IdNr., ES DNI/NIE) are matched by pattern and validated against their official checksum algorithm. A digit sequence that looks right but fails the checksum is not flagged. The algorithms follow each issuing authority's published spec:

  • My Number: 地方公共団体情報システム機構 (J-LIS)
  • NIR: INSEE / décret n°82-103 (97 − N mod 97)
  • Codice Fiscale: Agenzia delle Entrate, DM 12 giugno 2007 (mod 26)
  • Steuer-IdNr.: Bundeszentralamt für Steuern (ISO/IEC 7064 MOD 11,10)
  • DNI/NIE: Ministerio del Interior, Orden INT/2058/2008 (mod 23)
  • Korean RRN: 주민등록 사무편람, Ministry of the Interior and Safety (weighted mod 11)
  • Chinese Resident ID: GB 11643-1999 (ISO 7064 MOD 11-2)
  • Korean BRN: NTS (Hometax) standard algorithm

Context Gating

Phone numbers (IT, DE, FR, ES, KR, CN) and bare postal codes (5/9-digit and Chinese 6-digit) produce too many false positives on digit-only patterns. These rules carry a list of nearby context words (phone, tel, ZIP, PLZ, CAP, postal, … in each relevant language) and only fire when one of those words appears within a small window of the match. Only words that directly indicate the PII type are included; generic words such as contact, host, server, or code are excluded because they cause false positives. If no decisive context word is nearby, the match is dropped.

National ID numbers rely on their checksums instead and do not require context. Japanese postal codes keep their prefix requirement, which is a stricter form of the same idea.

Public IPv4 and IPv6 addresses are also context-gated, and additionally exclude reserved ranges (private, loopback, link-local, TEST-NET, multicast, documentation, etc.), so an address like 8.8.8.8 fires only when a label such as ip, ipv4 or ipv6 is nearby. RFC 1918 private addresses are not matched by any rule: they are non-routable and identify nothing outside their own network, and treating them as personal data blocked ordinary infrastructure work.

Credit Card Validation

Credit card numbers are matched by pattern and validated using the Luhn algorithm. This means valid-looking but invalid card numbers (e.g. 4111111111111112) are not flagged.

Allow Tags

All blocks can be bypassed by including an allow tag in your prompt. Allow tags are read only from the current user message — they do not carry over between turns.

TagBypasses
[allow-secret]All findings with category: secret
[allow-pii]All findings with category: pii
[allow-all]All findings regardless of category

Tags are case-insensitive: [ALLOW-SECRET] and [Allow-Secret] work the same as [allow-secret].

The name-based block on .env/.env.* files is a secret guard, so [allow-secret] and [allow-all] lift it and [allow-pii] does not.

Mask Tags

[mask-secret], [mask-pii], and [mask-all] are recognised but not supported. Claude Code hooks cannot rewrite prompt content before it is sent to the API.

If you include a mask tag in your prompt, sensitive-canary shows an explanation and suggests the equivalent allow tag instead. The prompt is not sent until you resend with an allow tag or redact the value manually.

Mask tagSuggested allow tag
[mask-secret][allow-secret]
[mask-pii][allow-pii]
[mask-all][allow-all]

Tag Priority

When more than one tag appears, the last one wins. It replaces the earlier ones entirely rather than combining with them, so changing your mind mid-message works the way it reads.

Examplesecretpii
[allow-all] … [allow-secret]allowblocked
[allow-secret] … [allow-all]allowallow
[allow-secret] … [mask-secret]mask (unsupported)blocked
[mask-secret] … [allow-secret]allowblocked
[allow-secret] … [allow-pii]blockedallow

The last line is the one to watch: two tags do not add up. Narrowing from [allow-all] to [allow-secret] really does put PII back under guard, which is the point — but so does writing [allow-secret] [allow-pii] and expecting both. [allow-all] is how you ask for both.

A tag counts wherever it appears in the message, including mid-sentence. What does not count is a tag inside a fenced code block, inside one of the elements Claude Code writes around command output, or in a message the runtime wrote rather than you — a compaction summary or a skill body. Those are quoting, not asking.

Category Filtering

Set the SENSITIVE_CANARY_CATEGORIES environment variable (e.g. in the env block of Claude Code settings.json) to limit which rule categories are active:

ValueEffect
secretScan for secrets only — PII rules are disabled
piiScan for PII only — secret rules and the name-based .env/.env.* block are disabled
secret,pii / allScan everything (default)

Values are comma-separated and case-insensitive. Unset, empty, or containing no valid token means all categories are enabled. This persistent filter is applied before allow tags.

.env File Blocking

.env and .env.* files (e.g. .env.local, .env.production) are blocked by filename when Claude attempts to read them, regardless of content. This name-based block is a secret guard: it only applies while the secret category is enabled via SENSITIVE_CANARY_CATEGORIES.

Files that end in .env but don't start with a dot (e.g. production.env) are handled by content scanning rather than name-based blocking.

[allow-secret] and [allow-all] lift the name-based block; [allow-pii] does not, since the block is a secret-category finding. Lifting the name block is not a pass: the contents are still scanned, and a tag only removes findings of the category it names. A file exempted by [allow-secret] is still blocked for an email address in it.

Bash Command Scanning

When Claude uses the Bash tool, sensitive-canary checks three things:

  1. Environment variables — any $VAR or ${VAR} references in the command are looked up in the current environment; if their values contain secrets or PII, the command is blocked.
  2. Command string — the raw command is scanned, which catches a secret written inline (echo ghp_…, curl -H "Authorization: Bearer …").
  3. File-reading commands — the target files are read and scanned before the command runs. The set is the one in src/lib/bash-commands.ts: around forty commands that print their operands (cat, head, xxd, zcat, iconv, comm, …), a second class whose first argument is a pattern and whose rest are files (grep, sed, awk, jq, zgrep, …), the git subcommands that print contents, dd if=, and inline program text. Compound commands using |, ;, &&, || are split and each segment is checked independently. README's "How it works" has the full picture.

Ways a rule goes quiet without saying so

A rule that matches nothing looks the same as a rule that finds nothing. These are the settings that produce one, and none of them warns:

  • secretGroup: 0 is not the same as omitting the field. A rule that names a capture group is treated as capturing a free-form value, which brings in the placeholder and shape tests that the built-in assignment rules rely on — so a value that is all digits, or a path, or a URL, is skipped. Omit the field when the whole match is the secret.
  • entropyThreshold above 8. Shannon entropy is at most 8 bits per character, so anything higher rejects every match. 1e999 parses as Infinity and is accepted.
  • secretGroup pointing at a group the pattern does not have. The capture is undefined and the match is dropped.
  • flags containing y. A sticky pattern only matches at position 0, so the rule finds a secret at the very start of a file and nothing anywhere else.
  • A large contextWindow. It widens excludeContext as well as contextWords, so a single buffer anywhere in a large file can suppress every postal-code match in it.

After writing or overriding a rule, check it against a file you expect it to catch. An override that fails to compile leaves the built-in in place and says so on stderr; one that compiles and matches nothing replaces the built-in silently.

Custom Rules

All built-in rules are defined in src/lib/default-config.json as JSON data. You can add your own rules or override built-in ones without modifying the plugin source.

Config file

Create ~/.config/sensitive-canary/config.json, or set the SENSITIVE_CANARY_CONFIG environment variable to a custom path (e.g. in the env block of your Claude Code settings.json).

Rule fields

FieldTypeRequiredDescription
idstringyesUnique identifier. Matching a built-in id overrides it.
descriptionstringyesHuman-readable label shown in block messages.
regexstringyesRegex source (not a /literal/).
category"secret" | "pii"yesWhich category the rule belongs to.
flagsstringnoRegex flags. g is added if you leave it out, since the scan needs every match; y makes a rule match only at the start of the text, which is almost never what a detection rule wants
secretGroupnumbernoCapture group containing the secret. Omit it for the full match — writing 0 is not the same as omitting it, see Ways a rule goes quiet without saying so
entropyThresholdnumbernoSkip matches below this Shannon entropy (bits/char).
requireContextbooleannoOnly fire when a context word is nearby.
contextWordsstring[]noWords that satisfy requireContext.
contextWindownumbernoPer-rule override for context scan width (tokens).
excludeContextstring[]noWords that suppress a match when one is near it — the inverse of contextWords. The postal-code rule uses buffer, bytes, byte, memory and cache, so 65536 bytes is a size rather than a place
validatestringnoName of a built-in checksum validator.

Available validators

NameAlgorithm
luhnLuhn checksum, and not a card number the payment gateways publish as test data
aws-keyNot a key ending in EXAMPLE, which is how AWS writes every key in its documentation
phone-jpTen or eleven digits beginning with 0, excluding the 0120 and 0800 freephone prefixes, which belong to a business
mynumber-jpJapanese Individual Number (My Number)
nir-frFrench NIR / Social Security Number
codice-fiscale-itItalian Codice Fiscale
steuer-id-deGerman Steuer-Identifikationsnummer
dni-nie-esSpanish DNI / NIE
rrn-krKorean Resident Registration Number
brn-krKorean Business Registration Number
resident-id-cnChinese Resident Identity Card
public-ipv4Rejects reserved IPv4 ranges
public-ipv6Rejects reserved IPv6 ranges

Global context window

Set contextWindow at the top level to override the default (3 tokens ≈ 24 characters):

json
{
  "contextWindow": 5,
  "rules": []
}

Examples

Add a custom secret pattern:

json
{
  "rules": [
    {
      "id": "custom-api-key",
      "description": "My Service API Key",
      "regex": "MYSVC-[A-Za-z0-9]{32}",
      "category": "secret",
      "entropyThreshold": 3.5
    }
  ]
}

Add a context-gated PII rule:

json
{
  "rules": [
    {
      "id": "employee-id",
      "description": "Employee ID",
      "regex": "EMP\\d{6}",
      "category": "pii",
      "requireContext": true,
      "contextWords": ["employee", "staff", "社員"]
    }
  ]
}

Override a built-in rule (same id replaces the original):

json
{
  "rules": [
    {
      "id": "pii-email",
      "description": "Internal Email Only",
      "regex": "[A-Za-z0-9]+@internal\\.corp\\.(com|org)",
      "category": "pii"
    }
  ]
}

Invalid rules (bad regex, wrong types, missing required fields) are skipped with a warning on stderr. The rest of the config loads normally. Each rule is validated against a strict schema before compilation:

  • Required: id, description, regex (non-empty strings), category ("secret" or "pii")
  • Type-checked: flags (string), secretGroup (non-negative integer), entropyThreshold (non-negative number), validate (string), contextWords (array of non-empty strings), requireContext (boolean), contextWindow (positive integer)
  • Cross-field: requireContext: true without contextWords is rejected (empty contextWords would disable context gating, making the rule fire on every match)

Released under the MIT License.