Skip to content

Gate name-based hardcoded-secret rules on the literal's value shape - #7

Merged
codelion merged 3 commits into
lambdasec:mainfrom
franchb:fix/secret-literal-value-shape
Sep 30, 2026
Merged

codelion merged 3 commits into
lambdasec:mainfrom
franchb:fix/secret-literal-value-shape

Conversation

@franchb

@franchb franchb commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The Tier-2 literal scan (FrameScanner._scan_literals) reports CWE-259/321/798 whenever an assignment target's name looks like a credential, for example (crypt|secret|private|signing)[._]?key. It never looks at the value. As a result, constants that only name, label or locate a secret are reported as hardcoded keys. These examples come from Kubernetes:

SSH_AUTH_PRIVATE_KEY = "ssh-privatekey"             # a Secret's data field name
SERVICE_ACCOUNT_PRIVATE_KEY_NAME = "sa.key"          # a filename
EC_PRIVATE_KEY_BLOCK_TYPE = "EC PRIVATE KEY"         # a PEM block-type label
BOOTSTRAP_TOKEN_SECRET_KEY = "token-secret"          # a field name

Each one is reported as CWE-321 in Python (module and function scope), Java locals, JS function-scope const and C# locals. Real key material, such as a PEM block with a base64 body, is reported correctly, but it gets lost among these false positives.

Fix

The name-based rules now share _is_nonsecret_value(target, value). A literal is suppressed only when the steps below say so, in this order:

  1. Veto: always reported. Key material or any credential prefix. Key material means a PEM block with a base64 body, a known format (AWS AKIA…, GitHub ghp_/github_pat_, GitLab glpat-, Slack xox?-, Google AIza…, Stripe sk_live_, JWT), or a high-entropy token (16+ non-whitespace chars; 32+ hex, or upper+lower+digit with entropy ≥ 3.5). A credential prefix vetoes at any length, so sk_live_x is reported too.

  2. Strict non-secret shapes: suppressed. A PEM block-type label (… KEY, … CERTIFICATE, …), a bare PEM armor line, a key or certificate filename, a filesystem path, or a placeholder (<…>, ***, $UPPER_VAR, ${…}, $(…)).

  3. Identifier-shaped values tied to the target name: suppressed. This covers lowercase words joined by -/_/., or UPPER_SNAKE, but only in one of two cases:

    • The target name carries a descriptor token (env, var, name, field, header, label, type, file, path, dir, prefix, suffix, param, attr, column, prop, annotation). action and permission count only when they are the leading name token (see the update below).
    • Every value token echoes the name: it equals a name token, a concatenation of adjacent name tokens, or one of id/name/field/ref/data.

    Names are split on camelCase and separators. SSHAuthPrivateKey = "ssh-privatekey" is silent, while SECRET_KEY = "super-secret-key" and password = "correct-horse-battery-staple" still fire.

  4. Header / auth-scheme names and Kubernetes qualified keys: suppressed (added in the update below).

  5. Everything else is reported, as before. Human passwords have low entropy, so the check never requires positive proof that a value is a secret.

Literal values reach the SIL with escapes unprocessed, so the PEM-body match accepts both real newlines and a literal \n. The connection-string rule already inspects the value and is unchanged.

Tests

  • New file tests/test_secret_value_shape.py with 374 tests.
    • Silent cases: the four Kubernetes constants in camelCase, UPPER_SNAKE and snake_case spellings, across Python (module and function scope), Java, JavaScript and C#. Each has a same-position twin holding real key material that must fire.
    • Must-fire rows: realistic low-entropy secrets (super-secret-key, django-insecure-…, dev-client-secret, PROD_DB_PASS_2024, $ecret, TOP SECRET, …) and short credential-prefixed values.
    • Direct classifier tests.
  • Every must-fire row also fires on main; the only recall given up is the intentionally suppressed shapes listed under Known tradeoffs (plus paths, placeholders and key/cert filenames).
  • Full suite: 1926 passed before, 2300 passed after. No existing test changed outcome.
  • On the 8 CWE-259/798-labeled files in benchmarks/endor_corpus/ground_truth.pooled.json, fetched at the pinned SHAs, the hit set is identical before and after.

Update: action names, HTTP header names, Kubernetes qualified keys

These false positives were still showing up on a real Go codebase and in Kubernetes. They are now silent. The veto from step 1 still runs first.

  • Descriptors. annotation is now a descriptor. action/permission count only as the leading name token, exact and singular (the ActionX/PermissionX idiom), which silences actionExportPrivateKey = "export-certificate-private-key" (an ABAC action name). GITHUB_ACTIONS_TOKEN, DEPLOY_ACTION_TOKEN, ACTIONS_SECRET and PERMISSIONS_TOKEN still fire. role, event and metric are deliberately left out because they often name a real credential, as in DB_ROLE_PASSWORD, WEBHOOK_EVENT_SECRET and METRICS_AUTH_TOKEN.
  • Header and scheme names. A value that exactly matches a standard HTTP auth header or scheme name is silent. The list is Authorization, Proxy-Authorization, WWW-Authenticate, Proxy-Authenticate, Cookie, Set-Cookie, X-Api-Key, X-Auth-Token, X-Access-Token, X-CSRF-Token, X-XSRF-Token, Bearer and Basic. Matching is case-insensitive, exact-match only, and never used for password-named targets. So authToken = "Authorization" is silent, while password = "Cookie", password = "Authorization1", token = "authorization-secret-value" and api_key = "X-Api-Key-123abc" still fire.
  • Kubernetes qualified keys. A value of the form <dns-subdomain>/<name> is judged by its name part under the same tie-to-name test as step 3. So csiNodeExpandSecretKey = "csi.storage.k8s.io/node-expand-secret-name" is silent, while secret_key = "example.com/my-app-secret" still fires.
  • Tests. 179 new tests: silent rows in Go (camelCase and exported) and other spellings, each with a firing twin in all five positions, plus must-fire and classifier rows. Nine action- and permission-named CI secrets are must-fire rows in Python and JS. Every new silent row fired before this change. Every existing must-fire row still fires.

Known tradeoffs

  • A value that only echoes the target name (for example secret_key = "secret-key") is treated as a name and stays silent.
  • A descriptor-named target (…_NAME, …_ENV, …_FIELD, …) holding an identifier-shaped value stays silent. The prefix and entropy vetoes still apply.
  • Exact header and scheme names are silent (token = "Bearer", secret = "cookie", api_key = "X-Api-Key"), except under password-named targets.
  • A qualified key whose name part echoes the target is silent (secret_key = "vault.corp.io/secret-key").
  • camelCase targets such as dbPassword or clientSecret are not matched by the password and secret name rules. That is also true on main, and this PR does not change the name rules.

🤖 Generated with Claude Code

The Tier-2 literal scan (FrameScanner._scan_literals) reported CWE-259/
321/798 whenever a credential-named target (e.g. matching
`(crypt|secret|private|signing)[._]?key`) was bound to any string
literal, without looking at the value. Constants that merely name,
label or locate a secret were flagged as hardcoded keys, e.g. (from
Kubernetes) `SSHAuthPrivateKey = "ssh-privatekey"`,
`ServiceAccountPrivateKeyName = "sa.key"`,
`ECPrivateKeyBlockType = "EC PRIVATE KEY"` and
`BootstrapTokenSecretKey = "token-secret"`. Reproduced in Python
(module and function level), Java locals, JS function-scope consts and
C# locals.

Rule A now asks `_is_nonsecret_value(target, value)` first, shared by
all name-based rules. A literal is suppressed only if, in order:
  1. it is not key material and carries no credential prefix (veto):
     a PEM block with a base64 body, known formats (AWS AKIA..., GitHub
     ghp_/github_pat_, GitLab glpat-, Slack xox?-, Google AIza, Stripe
     sk_live_, JWT), a high-entropy token (16+ non-whitespace token
     chars; 32+ hex, or upper+lower+digit with Shannon entropy >= 3.5),
     or any credential prefix at any length is always reported;
  2. it has a strict non-secret shape: a PEM block-type label ending in
     KEY/CERTIFICATE/REQUEST/..., a bare PEM armor line, a key/cert
     filename, a filesystem path, or a placeholder (<...>, ***, xxx,
     $UPPER_VAR, ${...}, $(...)); or
  3. it is identifier-shaped (lowercase words joined by - _ . or
     UPPER_SNAKE) AND tied to the target name: the name carries a
     descriptor token (env, var, name, field, header, label, type, file,
     path, dir, prefix, suffix, param, attr, column, prop), or every
     value token equals a name token, a concatenation of adjacent name
     tokens, or a structural word (id, name, field, ref, data), with at
     least one matching the name. Names are tokenised on camelCase and
     separators; there is no substring matching.
Everything else is reported as before, so realistic low-entropy
secrets such as `password = "hunter2pass"`,
`SECRET_KEY = "super-secret-key"` or
`password = "correct-horse-battery-staple"` still fire.

Literal values reach the SIL with escapes unprocessed, so the PEM-body
match accepts both real newlines and a literal backslash-n. The
connection-string rule (Rule B) already inspects the value and is
unchanged. No existing test changes outcome.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
franchb and others added 2 commits September 24, 2026 10:40
More false positives from the name-based hardcoded-secret rules, seen on
a real Go codebase and in Kubernetes:

- `actionExportPrivateKey = "export-certificate-private-key"`, an ABAC
  action name. `action`, `permission` and `annotation` are now name
  descriptors. `role`, `event` and `metric` are deliberately left out,
  because they often name a real credential (DB_ROLE_PASSWORD,
  WEBHOOK_EVENT_SECRET, METRICS_AUTH_TOKEN).
- `authToken = "Authorization"`, a standard header name. A value that
  exactly matches a small list of HTTP auth header and scheme names
  (case-insensitive, exact match only) is silent, except under a
  password-named target, where "cookie" or "basic" is a plausible weak
  password.
- `csiNodeExpandSecretKey = "csi.storage.k8s.io/node-expand-secret-name"`.
  A `<dns-subdomain>/<name>` key is judged by its name part under the
  existing tie-to-name test, so an unrelated name part still fires.

The key-material and credential-prefix veto still runs first. Every
existing must-fire row still fires, along with new ones such as
`password = "Authorization1"`, `token = "authorization-secret-value"`,
`api_key = "X-Api-Key-123abc"` and `password = "Cookie"`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A descriptor anywhere in the name, with its trailing "s" stripped, also
silenced CI secrets named after where they are used. These fired on
main but were silent after fc10b55:

    ACTIONS_SECRET, GH_ACTIONS_SECRET, GITHUB_ACTIONS_TOKEN,
    actions_token, DEPLOY_ACTION_TOKEN, PERMISSIONS_TOKEN

`action` and `permission` now count as descriptors only when they are
the first name token, exact and singular. That is the Go
`ActionX`/`PermissionX` idiom, and it still covers
`actionExportPrivateKey`, `ActionExportPrivateKey`,
`ACTION_EXPORT_PRIVATE_KEY` and `PermissionReadSecretKey`. Nine
must-fire rows are added in Python and JS positions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codelion
codelion merged commit 2029f83 into lambdasec:main Sep 30, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants