From fdaf607c59aa966256ee2ec215f12d03750a3280 Mon Sep 17 00:00:00 2001 From: franchb Date: Thu, 24 Sep 2026 09:48:48 -0500 Subject: [PATCH 1/3] Gate name-based hardcoded-secret rules on the literal's value shape 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) --- frame/sil/scanner.py | 125 ++++++++++++++++- tests/test_secret_value_shape.py | 232 +++++++++++++++++++++++++++++++ 2 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 tests/test_secret_value_shape.py diff --git a/frame/sil/scanner.py b/frame/sil/scanner.py index 873e4d82..ca5961c5 100644 --- a/frame/sil/scanner.py +++ b/frame/sil/scanner.py @@ -28,7 +28,9 @@ from frame.sil.llm_client import LLMUnavailableError +import math import re +from collections import Counter from typing import Tuple from frame.sil.procedure import Program @@ -1497,6 +1499,117 @@ def _verify_check(self, check: VulnerabilityCheck) -> BugReport: 'xxx', 'xxxx', 'todo', 'none', 'null', 'example', 'test', 'placeholder', } + # Value-shape gate for the name-based rules (Rule A). The target name only + # says the variable is *about* a credential; constants such as + # `SSH_AUTH_PRIVATE_KEY = "ssh-privatekey"` (a secret's field name), + # `SA_PRIVATE_KEY_NAME = "sa.key"` (a filename) or + # `EC_PRIVATE_KEY_BLOCK_TYPE = "EC PRIVATE KEY"` (a PEM label) hold no + # secret. A literal is suppressed only when, in order: + # 1. it is NOT key material and carries no known credential prefix + # (veto: such values are always reported); + # 2. it has a strict non-secret shape: PEM block-type label, bare PEM + # armor line, key/cert filename, filesystem path, or placeholder; 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`, `...Name`, `...Field`, `...Path`, ...), + # or every value token echoes a name token / a concatenation of + # adjacent name tokens / a structural word (id, name, field, ...). + # Everything else is reported as before -- human passwords are low + # entropy by nature ("hunter2pass", "super-secret-key"), so the gate never + # requires positive evidence of a secret. + # Literal values arrive with escapes unprocessed (a "\n" in source is the + # two characters backslash + n), so newline matching accepts both forms. + _PEM_WITH_BODY = re.compile( + r'-----BEGIN [A-Z0-9 ]+-----(?:(?!-----END).){0,512}?[A-Za-z0-9+/]{16,}', re.DOTALL) + _KNOWN_CREDENTIAL = re.compile( + r'\b(?:AKIA|ASIA|AGPA|AIDA|AROA|ANPA|ANVA|AIPA)[0-9A-Z]{16}\b' # AWS access key id + r'|\bgh[pousr]_[A-Za-z0-9]{36,}' # GitHub token + r'|\bgithub_pat_[A-Za-z0-9_]{22,}' # GitHub fine-grained PAT + r'|\bglpat-[A-Za-z0-9_-]{20,}' # GitLab PAT + r'|\bxox[baprs]-[A-Za-z0-9-]{10,}' # Slack token + r'|\bAIza[0-9A-Za-z_-]{35}' # Google API key + r'|\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}' # Stripe secret key + r'|\beyJ[A-Za-z0-9_-]{5,}\.eyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{10,}' # JWT + ) + # Credential prefixes at any length: a value carrying one is never + # suppressed, even when too short to be a full-format token. + _CREDENTIAL_PREFIX = re.compile( + r'\b(?:[srp]k_(?:live|test)_|gh[pousr]_|github_pat_|glpat-|xox[baprs]-|SG\.)' + r'|\b(?:AKIA|ASIA|AIza|eyJ)') + _HEX_BLOB = re.compile(r'[0-9a-f]{32,}|[0-9A-F]{32,}') + _TOKEN_CHARS = re.compile(r'[A-Za-z0-9+/=_\-.~]+') + _STRICT_NONSECRET_SHAPES = [ + # PEM block type label ("EC PRIVATE KEY", "CERTIFICATE REQUEST"). + re.compile(r'(?:[A-Z][A-Z0-9]* )+(?:KEY|CERTIFICATE|REQUEST|CRL|PARAMETERS|PKCS7|CMS|SIGNATURE)'), + # PEM armor line with no body ("-----BEGIN RSA PRIVATE KEY-----"). + re.compile(r'-----(?:BEGIN|END) [A-Z0-9 ]+-----(?:\s|\\[nr])*'), + # Key / certificate filename ("sa.key", "tls.crt"). + re.compile(r'(?i)[\w.-]+\.(?:key|pem|crt|cer|csr|der|p8|p12|pfx|jks|keystore|pub|' + r'kdbx|asc|gpg)'), + # Filesystem path ("/etc/kubernetes/pki/sa.key", "./certs", "~/.ssh/id_rsa"). + re.compile(r'(?:~|\.{1,2})?/[\w.\-/]*'), + # Placeholders ("", "********", "xxxx", "$SECRET", + # "${SECRET}", "$(cat key)"). + re.compile(r'<[^<>]+>|\*+|(?i:x+)|\$[A-Z_][A-Z0-9_]*|\$\{[^}]*\}|\$\(.*\)'), + ] + # Identifier-shaped value: lowercase words joined by - _ . , or UPPER_SNAKE. + _IDENTIFIER_VALUE = re.compile(r'[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)+|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+') + # Target-name tokens saying the constant describes/locates a secret. + _NAME_DESCRIPTORS = frozenset({ + 'env', 'var', 'name', 'field', 'header', 'label', 'type', 'file', 'path', + 'dir', 'prefix', 'suffix', 'param', 'attr', 'column', 'prop'}) + # Structural words allowed in a value that otherwise echoes the name. + _STRUCTURAL_WORDS = frozenset({'id', 'name', 'field', 'ref', 'data'}) + + @staticmethod + def _name_tokens(target: str) -> List[str]: + """Split an identifier on camelCase and separators, lowercased: + 'SSHAuthPrivateKey' -> ['ssh', 'auth', 'private', 'key'].""" + t = re.sub(r'([A-Z]+)([A-Z][a-z])', r'\1_\2', target) + t = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', t) + return [x for x in re.split(r'[^A-Za-z0-9]+', t.lower()) if x] + + @staticmethod + def _shannon_entropy(value: str) -> float: + n = len(value) + return -sum(c / n * math.log2(c / n) for c in Counter(value).values()) + + @classmethod + def _is_key_material(cls, value: str) -> bool: + """Unambiguous secret: a PEM block with a base64 body, a known + credential format, or a high-entropy token (16+ non-whitespace token + chars; 32+ hex, or upper + lower + digit with Shannon entropy >= 3.5 + bits/char).""" + if cls._PEM_WITH_BODY.search(value) or cls._KNOWN_CREDENTIAL.search(value): + return True + if len(value) < 16 or not cls._TOKEN_CHARS.fullmatch(value): + return False + if cls._HEX_BLOB.fullmatch(value): + return True + classes = (any(c.islower() for c in value) + any(c.isupper() for c in value) + + any(c.isdigit() for c in value)) + return classes == 3 and cls._shannon_entropy(value) >= 3.5 + + @classmethod + def _is_nonsecret_value(cls, target: str, value: str) -> bool: + """True when the literal bound to credential-named `target` names, + labels or locates a secret rather than being one (see the rule + comment above). Key material and credential prefixes always veto.""" + if cls._is_key_material(value) or cls._CREDENTIAL_PREFIX.search(value): + return False + v = value.strip() + if any(p.fullmatch(v) for p in cls._STRICT_NONSECRET_SHAPES): + return True + if not cls._IDENTIFIER_VALUE.fullmatch(v): + return False + name = cls._name_tokens(target) + if any(tok.rstrip('s') in cls._NAME_DESCRIPTORS for tok in name): + return True + grams = {''.join(name[i:j]) for i in range(len(name)) for j in range(i + 1, len(name) + 1)} + vals = [x for x in re.split(r'[-_.]', v.lower()) if x] + return (any(x in grams for x in vals) + and all(x in grams or x in cls._STRUCTURAL_WORDS for x in vals)) + def _scan_literals(self, program: Program, filename: str) -> List[Vulnerability]: """Tier-2 structural scan over the SIL for hardcoded secrets (CWE-798/ 259/321): a credential-named assignment target bound to a literal string. @@ -1522,11 +1635,13 @@ def _scan_literals(self, program: Program, filename: str) -> List[Vulnerability] continue cwe = label = None - # Rule A: credential-named target bound to a literal. - for pattern, c, lbl in self._SECRET_NAME_RULES: - if pattern.search(target): - cwe, label = c, lbl - break + # Rule A: credential-named target bound to a literal whose + # value is not merely a name/label/filename for a secret. + if not self._is_nonsecret_value(target, value): + for pattern, c, lbl in self._SECRET_NAME_RULES: + if pattern.search(target): + cwe, label = c, lbl + break # Rule B: value embeds a credential (e.g. connection string # "...;Password=secret;..."), regardless of the target name. if cwe is None and self._CONNSTR_SECRET.search(value): diff --git a/tests/test_secret_value_shape.py b/tests/test_secret_value_shape.py new file mode 100644 index 00000000..b4d3baef --- /dev/null +++ b/tests/test_secret_value_shape.py @@ -0,0 +1,232 @@ +"""Value-shape gate for the Tier-2 hardcoded-secret literal scan. + +The name-based secret rules (CWE-259/321/798) match on the *target* name, e.g. +``(crypt|secret|private|signing)[._]?key``. A constant whose name mentions a +key but whose value is a field name, filename, PEM block label or env-var name +is not a hardcoded secret -- e.g. Kubernetes' ``SSHAuthPrivateKey = +"ssh-privatekey"`` or ``ECPrivateKeyBlockType = "EC PRIVATE KEY"``. These +tests pin that such identifier-shaped values stay silent while real key +material still fires. + +Every silent case is paired with a firing twin at the same name/position so a +frontend that stops emitting the assignment makes the test fail loudly instead +of passing vacuously. +""" + +import pytest + +from frame.sil.scanner import FrameScanner, VulnType + + +PEM_BODY = ( + "-----BEGIN EC PRIVATE" " KEY-----\\n" + "MHcCAQEEIBkg4LVWM9nuwNSk3yByxZpYRTBnVJk5oZjQRE3Uu1aroAoGCCqGSM49\\n" + "AwEHoUQDQgAEoBUyo8CQAFPeYPvv78ylh5MwFZjTCLQeb042TjiMJxG+9DLFmRSM\\n" + "-----END EC PRIVATE KEY-----\\n" +) +# Token-shaped fixtures are split with adjacent-literal concatenation so the +# committed source never contains a whole credential-format string (keeps +# secret scanners / push protection quiet); runtime values are unchanged. +AWS_KEY = "AKIA" "IOSFODNN7EXAMPLE" + +# (language, extension, template with {name} and {value}) +_TEMPLATES = [ + ("python", "py", 'def f():\n {name} = "{value}"\n'), + ("python", "py", '{name} = "{value}"\n'), + ("java", "java", 'class C {{\n void f() {{\n String {name} = "{value}";\n }}\n}}\n'), + ("javascript", "js", 'function f() {{\n const {name} = "{value}";\n}}\n'), + ("csharp", "cs", 'class C {{\n void F() {{\n string {name} = "{value}";\n }}\n}}\n'), +] + +# Kubernetes-shaped constants that the name rule matches but whose values are +# identifiers / filenames / PEM labels, not secrets. +_K8S_NONSECRETS = [ + # camelCase (Go spelling), UPPER_SNAKE and snake_case variants. + ("ServiceAccountPrivateKeyName", "sa.key"), + ("SSHAuthPrivateKey", "ssh-privatekey"), + ("ECPrivateKeyBlockType", "EC PRIVATE KEY"), + ("BootstrapTokenSecretKey", "token-secret"), + ("SERVICE_ACCOUNT_PRIVATE_KEY_NAME", "sa.key"), + ("SSH_AUTH_PRIVATE_KEY", "ssh-privatekey"), + ("EC_PRIVATE_KEY_BLOCK_TYPE", "EC PRIVATE KEY"), + ("BOOTSTRAP_TOKEN_SECRET_KEY", "token-secret"), + ("ssh_auth_private_key", "ssh-privatekey"), + ("bootstrap_token_secret_key", "token-secret"), +] + + +def _secrets(lang, ext, code): + result = FrameScanner(language=lang, verify=False).scan(code, "t." + ext) + return [v for v in result.vulnerabilities if v.type == VulnType.HARDCODED_SECRET] + + +def _ids(params): + return [f"{p[0]}-{i}" for i, p in enumerate(params)] + + +_SCAN_CASES = [(lang, ext, tpl, name, value) + for (lang, ext, tpl) in _TEMPLATES + for (name, value) in _K8S_NONSECRETS] + + +@pytest.mark.parametrize("lang,ext,tpl,name,value", _SCAN_CASES, ids=_ids(_SCAN_CASES)) +def test_identifier_shaped_value_is_silent(lang, ext, tpl, name, value): + code = tpl.format(name=name, value=value) + hits = _secrets(lang, ext, code) + assert not hits, f"false positive on {name} = {value!r} ({lang}): {hits}" + + +@pytest.mark.parametrize("lang,ext,tpl,name,value", _SCAN_CASES, ids=_ids(_SCAN_CASES)) +def test_key_material_twin_fires(lang, ext, tpl, name, value): + # Same name and position as the silent case, real key material as value. + for secret in (PEM_BODY, AWS_KEY): + code = tpl.format(name=name, value=secret) + hits = _secrets(lang, ext, code) + assert hits, f"missed key material in {name} ({lang}): {secret[:30]!r}" + + +@pytest.mark.parametrize("code", [ + # PEM armor line alone (no body) is a label, not a key. + 'def f():\n private_key_header = "-----BEGIN RSA PRIVATE KEY-----"\n', + # Filenames / paths. + 'def f():\n private_key_file = "/etc/kubernetes/pki/sa.key"\n', + 'def f():\n signing_key_path = "./certs/signing.pem"\n', + 'def f():\n private_key = "tls.key"\n', + 'def f():\n password_file = "secret.txt"\n', + # Env-var name holding where the secret lives. + 'def f():\n SECRET_KEY_ENV = "APP_SECRET_KEY"\n', + 'def f():\n password_env = "DB_PASSWORD"\n', + # Field / key names in a map or secret object. + 'def f():\n api_key_field = "api-key"\n', + 'def f():\n token_key = "token_id"\n', + # Placeholders. + 'def f():\n secret_key = ""\n', + 'def f():\n secret_key = "$APP_SECRET_KEY"\n', + 'def f():\n secret_key = "$(cat /run/secrets/key)"\n', +]) +def test_nonsecret_shapes_python(code): + assert not _secrets("python", "py", code), f"false positive on {code!r}" + + +@pytest.mark.parametrize("code,cwe", [ + # PEM with a base64 body -- the real Kubernetes hit shape. + (f'def f():\n ecdsa_private_key = "{PEM_BODY}"\n', "CWE-321"), + # Python triple-quoted PEM (real newlines rather than escapes). + ('ECDSA_PRIVATE_KEY = """-----BEGIN EC PRIVATE' ' KEY-----\n' + 'MHcCAQEEIBkg4LVWM9nuwNSk3yByxZpYRTBnVJk5oZjQRE3Uu1aroAoGCCqGSM49\n' + '-----END EC PRIVATE KEY-----\n"""\n', "CWE-321"), + # High-entropy token. + ('def f():\n secret_key = "q8Vz3Lr0XkP2mT9wYb7NcD4hJ6sF1aGe"\n', "CWE-321"), + # High-entropy token containing '/', must not be mistaken for a path. + ('def f():\n aws_secret_access_key = "wJalrXUtnFEMI/' 'K7MDENG/bPxRfiCYEXAMPLEKEY"\n', None), + # Known credential formats. + (f'def f():\n access_key = "{AWS_KEY}"\n', "CWE-798"), + ('def f():\n token = "gh' 'p_1A2b3C4d5E6f7G8h9I0jK1l2M3n4O5p6Q7r8"\n', "CWE-798"), + ('def f():\n token = "xox' 'b-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx"\n', "CWE-798"), + ('def f():\n auth_token = "ey' 'JhbGciOiJIUzI1NiJ9.ey' 'JzdWIiOiIxMjM0In0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk"\n', + "CWE-798"), + # Short values carrying a credential prefix still fire. + ('def f():\n api_key = "sk_live_short1"\n', "CWE-798"), + ('def f():\n token = "ghp_short"\n', "CWE-798"), + ('def f():\n token = "xoxp-abc"\n', "CWE-798"), + # Ambiguous human passwords keep firing (consistent with the existing + # `password = "hunter2pass"` test): passwords are low-entropy by nature. + ('def f():\n DB_PASSWORD = "S3cr3t!Passw0rd"\n', "CWE-259"), + ('def f():\n password = "admin123"\n', "CWE-259"), + ('def f():\n password = "hunter2pass"\n', "CWE-259"), + ('def f():\n signing_key = "mysupersecretkey"\n', "CWE-321"), + ('def f():\n api_key = "sk_live_abcd1234"\n', "CWE-798"), + # Identifier-shaped values NOT tied to the target name are real secrets + # (regression rows: a value-only classifier silenced all of these). + ('SECRET_KEY = "super-secret-key"\n', "CWE-321"), + ('def f():\n SECRET_KEY = "super-secret-key"\n', "CWE-321"), + ('def f():\n SECRET_KEY = "secret-key-change-in-production"\n', "CWE-321"), + ('def f():\n SECRET_KEY = "django-insecure-abcdefghijklmnopqrstuvwxyz"\n', "CWE-321"), + ('def f():\n secret = "my-app-secret"\n', "CWE-798"), + ('def f():\n client_secret = "dev-client-secret"\n', "CWE-798"), + ('def f():\n jwt_secret = "jwt_secret_key"\n', "CWE-798"), + ('def f():\n password = "correct-horse-battery-staple"\n', "CWE-259"), + ('def f():\n password = "hunter2_pass"\n', "CWE-259"), + ('def f():\n password = "PROD_DB_PASS_2024"\n', "CWE-259"), + ('def f():\n password = "ADMIN_PASS"\n', "CWE-259"), + ('def f():\n password = "pass.word"\n', "CWE-259"), + ('def f():\n token = "abc.def.ghi"\n', "CWE-798"), + # Non-key file extension is only a filename when the name says so. + ('def f():\n password = "secret.txt"\n', "CWE-259"), + # '$' inside a password is not an env-var placeholder. + ('def f():\n password = "$ecret"\n', "CWE-259"), + ('def f():\n password = "$uperman1"\n', "CWE-259"), + # ALL-CAPS words not ending in a PEM block type are not a PEM label. + ('def f():\n secret_key = "TOP SECRET"\n', "CWE-321"), +]) +def test_secret_shapes_fire_python(code, cwe): + hits = _secrets("python", "py", code) + assert hits, f"missed secret in {code!r}" + if cwe is not None: + assert hits[0].cwe_id == cwe + + +@pytest.mark.parametrize("lang,ext,code", [ + ("javascript", "js", 'function f() {\n const password = "PROD_DB_PASS_2024";\n}\n'), + ("javascript", "js", 'function f() {\n const client_secret = "dev-client-secret";\n}\n'), + ("java", "java", 'class C {\n void f() {\n String secret_key = "super-secret-key";\n }\n}\n'), +]) +def test_unrelated_identifier_value_fires_other_languages(lang, ext, code): + assert _secrets(lang, ext, code), f"missed secret in {code!r} ({lang})" + + +# --- unit tests of the classifier itself ----------------------------------- + +@pytest.mark.parametrize("target,value", [ + ("ServiceAccountPrivateKeyName", "sa.key"), + ("SSHAuthPrivateKey", "ssh-privatekey"), + ("ECPrivateKeyBlockType", "EC PRIVATE KEY"), + ("BootstrapTokenSecretKey", "token-secret"), + ("private_key", "-----BEGIN RSA PRIVATE KEY-----"), + ("private_key", "-----END CERTIFICATE-----"), + ("private_key", "/etc/kubernetes/pki/sa.key"), + ("signing_key", "./certs/signing.pem"), + ("private_key", "~/.ssh/id_rsa"), + ("private_key", "tls.crt"), + ("SSH_PRIVATE_KEY_FILE", "id_rsa"), + ("SECRET_KEY_ENV", "APP_SECRET_KEY"), + ("secret_key", ""), + ("secret_key", "${SECRET_KEY}"), + ("CLIENT_SECRET_NAME", "kube-apiserver-client-kubelet"), + ("privateKeyRef", "private-key-ref"), + ("token_key", "token_id"), + # Accepted cost: a value that only echoes the name stays silent. + ("secret_key", "secret-key"), +]) +def test_classifier_nonsecret(target, value): + assert FrameScanner._is_nonsecret_value(target, value) + + +@pytest.mark.parametrize("target,value", [ + ("private_key", PEM_BODY), ("access_key", AWS_KEY), + ("secret_key", "q8Vz3Lr0XkP2mT9wYb7NcD4hJ6sF1aGe"), + ("aws_secret_access_key", "wJalrXUtnFEMI/" "K7MDENG/bPxRfiCYEXAMPLEKEY"), + ("token", "xox" "b-123456789012-1234567890123-AbCdEfGhIjKlMnOpQrStUvWx"), + # Credential prefixes veto suppression at any length, even under a + # descriptor-named target. + ("api_key_name", "sk_live_short1"), ("token_field", "ghp_short"), + ("token_var", "xoxp-abc"), + ("password", "hunter2pass"), ("password", "S3cr3t!Passw0rd"), + ("api_key", "sk_live_abcd1234"), ("signing_key", "mysupersecretkey"), + ("SECRET_KEY", "super-secret-key"), ("SECRET_KEY", "secret-key-change-in-production"), + ("SECRET_KEY", "django-insecure-abcdefghijklmnopqrstuvwxyz"), + ("secret", "my-app-secret"), ("client_secret", "dev-client-secret"), + ("jwt_secret", "jwt_secret_key"), ("password", "correct-horse-battery-staple"), + ("password", "hunter2_pass"), ("password", "PROD_DB_PASS_2024"), + ("password", "ADMIN_PASS"), ("password", "pass.word"), ("token", "abc.def.ghi"), + ("password", "secret.txt"), ("password", "$ecret"), ("password", "$uperman1"), + ("secret_key", "TOP SECRET"), +]) +def test_classifier_not_nonsecret(target, value): + assert not FrameScanner._is_nonsecret_value(target, value) + + +def test_name_tokens(): + assert FrameScanner._name_tokens("SSHAuthPrivateKey") == ["ssh", "auth", "private", "key"] + assert FrameScanner._name_tokens("BOOTSTRAP_TOKEN_SECRET_KEY") == ["bootstrap", "token", "secret", "key"] + assert FrameScanner._name_tokens("ecPrivateKey2Name") == ["ec", "private", "key2", "name"] From fc10b55f4d6e594d0634ed856d29cdd0b723f3a1 Mon Sep 17 00:00:00 2001 From: franchb Date: Thu, 24 Sep 2026 10:40:29 -0500 Subject: [PATCH 2/3] Silence action names, HTTP auth header names and k8s qualified keys 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 `/` 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) --- frame/sil/scanner.py | 33 ++++++++++++++++-- tests/test_secret_value_shape.py | 58 +++++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/frame/sil/scanner.py b/frame/sil/scanner.py index ca5961c5..22d36a72 100644 --- a/frame/sil/scanner.py +++ b/frame/sil/scanner.py @@ -1514,6 +1514,13 @@ def _verify_check(self, check: VulnerabilityCheck) -> BugReport: # descriptor token (`..._ENV`, `...Name`, `...Field`, `...Path`, ...), # or every value token echoes a name token / a concatenation of # adjacent name tokens / a structural word (id, name, field, ...). + # A Kubernetes qualified key (`/`, e.g. + # "csi.storage.k8s.io/node-expand-secret-name") is judged by its + # name part under the same tie-to-name test; or + # 4. it exactly matches a standard HTTP auth header / scheme name + # ("Authorization", "X-Api-Key", "Bearer", ...; case-insensitive, + # exact match only) and the target is not password-named -- "cookie" + # or "basic" is a plausible weak password. # Everything else is reported as before -- human passwords are low # entropy by nature ("hunter2pass", "super-secret-key"), so the gate never # requires positive evidence of a secret. @@ -1555,9 +1562,26 @@ def _verify_check(self, check: VulnerabilityCheck) -> BugReport: # Identifier-shaped value: lowercase words joined by - _ . , or UPPER_SNAKE. _IDENTIFIER_VALUE = re.compile(r'[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)+|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+') # Target-name tokens saying the constant describes/locates a secret. + # `action` / `permission` name an authorization object ("ABAC action + # export-certificate-private-key"); `annotation` is the sibling of `label`. + # Deliberately NOT descriptors: role (DB_ROLE_PASSWORD), event + # (WEBHOOK_EVENT_SECRET), metric (METRICS_AUTH_TOKEN) -- each commonly + # names a real credential; scope/route values are not identifier-shaped + # or are already paths; kind is too generic. _NAME_DESCRIPTORS = frozenset({ 'env', 'var', 'name', 'field', 'header', 'label', 'type', 'file', 'path', - 'dir', 'prefix', 'suffix', 'param', 'attr', 'column', 'prop'}) + 'dir', 'prefix', 'suffix', 'param', 'attr', 'column', 'prop', + 'action', 'permission', 'annotation'}) + # Kubernetes qualified key: DNS subdomain (2+ lowercase labels) + '/' + + # name, e.g. "csi.storage.k8s.io/node-expand-secret-name". + _QUALIFIED_KEY = re.compile( + r'[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)+/(?P[^/]+)') + # Standard HTTP auth header / auth-scheme names (lowercased; exact match). + _HTTP_AUTH_NAMES = frozenset({ + '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', 'basic'}) + _PASSWORD_WORDS = frozenset({'pass', 'password', 'passwd', 'pwd'}) # Structural words allowed in a value that otherwise echoes the name. _STRUCTURAL_WORDS = frozenset({'id', 'name', 'field', 'ref', 'data'}) @@ -1600,9 +1624,14 @@ def _is_nonsecret_value(cls, target: str, value: str) -> bool: v = value.strip() if any(p.fullmatch(v) for p in cls._STRICT_NONSECRET_SHAPES): return True + name = cls._name_tokens(target) + if v.lower() in cls._HTTP_AUTH_NAMES: + return not any(tok in cls._PASSWORD_WORDS for tok in name) + qualified = cls._QUALIFIED_KEY.fullmatch(v) + if qualified: + v = qualified.group('name') if not cls._IDENTIFIER_VALUE.fullmatch(v): return False - name = cls._name_tokens(target) if any(tok.rstrip('s') in cls._NAME_DESCRIPTORS for tok in name): return True grams = {''.join(name[i:j]) for i in range(len(name)) for j in range(i + 1, len(name) + 1)} diff --git a/tests/test_secret_value_shape.py b/tests/test_secret_value_shape.py index b4d3baef..54ab8ce7 100644 --- a/tests/test_secret_value_shape.py +++ b/tests/test_secret_value_shape.py @@ -54,6 +54,28 @@ ("bootstrap_token_secret_key", "token-secret"), ] +# Further false positives from a real Go codebase and Kubernetes, in Go +# (camelCase / exported) and other spellings: +# - an ABAC action name (the `action` descriptor); +# - a standard HTTP auth header / scheme name held by a token-named constant; +# - a Kubernetes qualified parameter/annotation key (`/`) +# whose name part echoes the target. +_REAL_CODEBASE_NONSECRETS = [ + ("actionExportPrivateKey", "export-certificate-private-key"), + ("ActionExportPrivateKey", "export-certificate-private-key"), + ("ACTION_EXPORT_PRIVATE_KEY", "export-certificate-private-key"), + ("authToken", "Authorization"), + ("AuthToken", "Authorization"), + ("AUTH_TOKEN", "Authorization"), + ("auth_token", "Proxy-Authorization"), + ("apiKey", "X-Api-Key"), + ("auth_token", "X-Auth-Token"), + ("secretKey", "Cookie"), + ("token", "Bearer"), + ("csiNodeExpandSecretKey", "csi.storage.k8s.io/node-expand-secret-name"), + ("CSI_NODE_EXPAND_SECRET_KEY", "csi.storage.k8s.io/node-expand-secret-name"), +] + def _secrets(lang, ext, code): result = FrameScanner(language=lang, verify=False).scan(code, "t." + ext) @@ -66,7 +88,7 @@ def _ids(params): _SCAN_CASES = [(lang, ext, tpl, name, value) for (lang, ext, tpl) in _TEMPLATES - for (name, value) in _K8S_NONSECRETS] + for (name, value) in _K8S_NONSECRETS + _REAL_CODEBASE_NONSECRETS] @pytest.mark.parametrize("lang,ext,tpl,name,value", _SCAN_CASES, ids=_ids(_SCAN_CASES)) @@ -158,6 +180,18 @@ def test_nonsecret_shapes_python(code): ('def f():\n password = "$uperman1"\n', "CWE-259"), # ALL-CAPS words not ending in a PEM block type are not a PEM label. ('def f():\n secret_key = "TOP SECRET"\n', "CWE-321"), + # Header / auth-scheme names are an exact-match list, not a pattern: + # anything longer or different is still a candidate secret. + ('def f():\n password = "Authorization1"\n', "CWE-259"), + ('def f():\n token = "authorization-secret-value"\n', "CWE-798"), + ('def f():\n api_key = "X-Api-Key-123abc"\n', "CWE-798"), + # A header / scheme word held by a password-named target is a plausible + # weak password, so the header list does not apply there. + ('def f():\n password = "Cookie"\n', "CWE-259"), + ('def f():\n DB_PASSWORD = "basic"\n', "CWE-259"), + # A qualified key whose name part does not echo the target still fires. + ('def f():\n secret_key = "example.com/my-app-secret"\n', "CWE-321"), + ('def f():\n password = "corp.example.io/hunter-two"\n', "CWE-259"), ]) def test_secret_shapes_fire_python(code, cwe): hits = _secrets("python", "py", code) @@ -170,6 +204,9 @@ def test_secret_shapes_fire_python(code, cwe): ("javascript", "js", 'function f() {\n const password = "PROD_DB_PASS_2024";\n}\n'), ("javascript", "js", 'function f() {\n const client_secret = "dev-client-secret";\n}\n'), ("java", "java", 'class C {\n void f() {\n String secret_key = "super-secret-key";\n }\n}\n'), + ("javascript", "js", 'function f() {\n const password = "Authorization1";\n}\n'), + ("java", "java", 'class C {\n void f() {\n String password = "Cookie";\n }\n}\n'), + ("javascript", "js", 'function f() {\n const api_key = "X-Api-Key-123abc";\n}\n'), ]) def test_unrelated_identifier_value_fires_other_languages(lang, ext, code): assert _secrets(lang, ext, code), f"missed secret in {code!r} ({lang})" @@ -197,6 +234,16 @@ def test_unrelated_identifier_value_fires_other_languages(lang, ext, code): ("token_key", "token_id"), # Accepted cost: a value that only echoes the name stays silent. ("secret_key", "secret-key"), + ("actionExportPrivateKey", "export-certificate-private-key"), + ("PermissionReadSecretKey", "read-secret-key-material"), + ("secretKeyAnnotation", "example-operator-rotated-at"), + ("authToken", "Authorization"), + ("authToken", "authorization"), + ("AUTH_TOKEN_HEADER", "WWW-Authenticate"), + ("token", "Set-Cookie"), + ("token", " Bearer "), + ("csiNodeExpandSecretKey", "csi.storage.k8s.io/node-expand-secret-name"), + ("ServiceAccountTokenSecretKey", "kubernetes.io/service-account-token"), ]) def test_classifier_nonsecret(target, value): assert FrameScanner._is_nonsecret_value(target, value) @@ -221,6 +268,15 @@ def test_classifier_nonsecret(target, value): ("password", "ADMIN_PASS"), ("password", "pass.word"), ("token", "abc.def.ghi"), ("password", "secret.txt"), ("password", "$ecret"), ("password", "$uperman1"), ("secret_key", "TOP SECRET"), + # Header list: exact match only, and never for password-named targets. + ("password", "Authorization1"), ("token", "authorization-secret-value"), + ("api_key", "X-Api-Key-123abc"), ("password", "Cookie"), ("dbPassword", "basic"), + ("token", "Bearer abc123"), + # Descriptor words (plural-stripped) never outweigh a credential prefix. + ("GITHUB_ACTIONS_TOKEN", "ghs_short"), + # Qualified keys: the name part must still echo the target. + ("secret_key", "example.com/my-app-secret"), ("password", "corp.example.io/hunter-two"), + ("token", "k8s.io/abc"), ("csiNodeExpandSecretKey", "csi.storage.k8s.io/Node-Secret"), ]) def test_classifier_not_nonsecret(target, value): assert not FrameScanner._is_nonsecret_value(target, value) From 06e491f93ea6d08148b2bbfc1aadf0a330794984 Mon Sep 17 00:00:00 2001 From: franchb Date: Thu, 24 Sep 2026 10:46:50 -0500 Subject: [PATCH 3/3] Count action/permission as descriptors only as the leading name token 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) --- frame/sil/scanner.py | 10 ++++++++-- tests/test_secret_value_shape.py | 26 ++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/frame/sil/scanner.py b/frame/sil/scanner.py index 22d36a72..08fa7a3a 100644 --- a/frame/sil/scanner.py +++ b/frame/sil/scanner.py @@ -1563,7 +1563,10 @@ def _verify_check(self, check: VulnerabilityCheck) -> BugReport: _IDENTIFIER_VALUE = re.compile(r'[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)+|[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+') # Target-name tokens saying the constant describes/locates a secret. # `action` / `permission` name an authorization object ("ABAC action - # export-certificate-private-key"); `annotation` is the sibling of `label`. + # export-certificate-private-key") but only as the LEADING name token, + # exact and singular (Go `ActionX` / `PermissionX`); elsewhere they say + # where a credential is used (GITHUB_ACTIONS_TOKEN, DEPLOY_ACTION_TOKEN). + # `annotation` is the sibling of `label`. # Deliberately NOT descriptors: role (DB_ROLE_PASSWORD), event # (WEBHOOK_EVENT_SECRET), metric (METRICS_AUTH_TOKEN) -- each commonly # names a real credential; scope/route values are not identifier-shaped @@ -1571,7 +1574,8 @@ def _verify_check(self, check: VulnerabilityCheck) -> BugReport: _NAME_DESCRIPTORS = frozenset({ 'env', 'var', 'name', 'field', 'header', 'label', 'type', 'file', 'path', 'dir', 'prefix', 'suffix', 'param', 'attr', 'column', 'prop', - 'action', 'permission', 'annotation'}) + 'annotation'}) + _LEADING_DESCRIPTORS = frozenset({'action', 'permission'}) # Kubernetes qualified key: DNS subdomain (2+ lowercase labels) + '/' + # name, e.g. "csi.storage.k8s.io/node-expand-secret-name". _QUALIFIED_KEY = re.compile( @@ -1632,6 +1636,8 @@ def _is_nonsecret_value(cls, target: str, value: str) -> bool: v = qualified.group('name') if not cls._IDENTIFIER_VALUE.fullmatch(v): return False + if name and name[0] in cls._LEADING_DESCRIPTORS: + return True if any(tok.rstrip('s') in cls._NAME_DESCRIPTORS for tok in name): return True grams = {''.join(name[i:j]) for i in range(len(name)) for j in range(i + 1, len(name) + 1)} diff --git a/tests/test_secret_value_shape.py b/tests/test_secret_value_shape.py index 54ab8ce7..0c56fc56 100644 --- a/tests/test_secret_value_shape.py +++ b/tests/test_secret_value_shape.py @@ -212,6 +212,32 @@ def test_unrelated_identifier_value_fires_other_languages(lang, ext, code): assert _secrets(lang, ext, code), f"missed secret in {code!r} ({lang})" +# `action` / `permission` are descriptors only as the LEADING name token (the +# Go `ActionX` / `PermissionX` idiom). Anywhere else -- or plural, as in a CI +# `ACTIONS_*` secret -- they name where a real credential is used. +_ACTION_NAMED_SECRETS = [ + ("ACTIONS_SECRET", "hunter2_pass"), + ("ACTIONS_SECRET", "prod-deploy-secret"), + ("GH_ACTIONS_SECRET", "super-secret-value"), + ("GITHUB_ACTIONS_TOKEN", "prod-deploy-token"), + ("GITHUB_ACTIONS_TOKEN", "hunter2_pass"), + ("GITHUB_ACTIONS_TOKEN", "PROD_DEPLOY_TOKEN_2024"), + ("actions_token", "my-actions-token"), + ("DEPLOY_ACTION_TOKEN", "prod-release-token"), + ("PERMISSIONS_TOKEN", "ci-bot-token"), +] +_ACTION_CASES = [(lang, ext, tpl, name, value) + for (lang, ext, tpl) in (_TEMPLATES[0], _TEMPLATES[3]) + for (name, value) in _ACTION_NAMED_SECRETS] + + +@pytest.mark.parametrize("lang,ext,tpl,name,value", _ACTION_CASES, ids=_ids(_ACTION_CASES)) +def test_non_leading_action_permission_is_not_descriptor(lang, ext, tpl, name, value): + code = tpl.format(name=name, value=value) + assert _secrets(lang, ext, code), f"missed secret in {name} = {value!r} ({lang})" + assert not FrameScanner._is_nonsecret_value(name, value) + + # --- unit tests of the classifier itself ----------------------------------- @pytest.mark.parametrize("target,value", [