From 91eda0b3c6cf99c1236f89796b054033d901e488 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 4 Oct 2026 12:17:46 +0200 Subject: [PATCH 1/2] fix(secrets): stop reporting password hashes as hardcoded credentials A value in PHC or modular crypt format (argon2, bcrypt, SHA-crypt, PBKDF2, scrypt) is what an application stores instead of the password and does not reveal it. It is recognised by its algorithm identifier, so an incomplete hash is not reported either; plaintext values are still reported. Closes #211 --- src/coretrace_python/plugins/secrets.py | 18 ++++++++- tests/test_password_hashes.py | 52 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 tests/test_password_hashes.py diff --git a/src/coretrace_python/plugins/secrets.py b/src/coretrace_python/plugins/secrets.py index 3559466..cce3c8f 100644 --- a/src/coretrace_python/plugins/secrets.py +++ b/src/coretrace_python/plugins/secrets.py @@ -47,6 +47,17 @@ # Subresource integrity, as in lock files: ``sha512-``. _INTEGRITY = re.compile(r"^(md5|sha1|sha256|sha384|sha512)-[A-Za-z0-9+/=]+$", re.IGNORECASE) _ALPHABET_WORDS = ("alphabet", "charset", "characters", "letters", "digits") +# PHC strings and modular crypt strings open with their algorithm: argon2, bcrypt, +# SHA-crypt, PBKDF2 and scrypt. +_PASSWORD_HASH = re.compile(r"^\$(argon2(i|d|id)|2[abxy]|5|6|pbkdf2(-sha(1|256|512))?|scrypt)\$") + + +def is_password_hash(value: str) -> bool: + """A password hash in PHC or modular crypt format, recognised by its algorithm + identifier: what an application stores instead of the password, which it does not + reveal, even when the rest of the hash is incomplete.""" + + return _PASSWORD_HASH.match(value) is not None def looks_like_digest(value: str, name: str | None) -> bool: @@ -341,7 +352,12 @@ def judge(self, value: str, name: str | None, span: SourceSpan, function: str | function, {"provider": pattern.provider, "name": name or "", "length": str(len(value))}, ) - if name is not None and self.is_credential_name(name) and not is_placeholder(value): + if ( + name is not None + and self.is_credential_name(name) + and not is_placeholder(value) + and not is_password_hash(value) + ): # A password in a test fixture or a template file is rarely a leak; a name # is a hint, so the finding stays, at low confidence. context = credential_context(str(span.source_id)) diff --git a/tests/test_password_hashes.py b/tests/test_password_hashes.py new file mode 100644 index 0000000..e468d40 --- /dev/null +++ b/tests/test_password_hashes.py @@ -0,0 +1,52 @@ +"""Acceptance tests for issue #211: password hashes are not hardcoded passwords. + +A value in a password-hash format (a PHC string such as ``$argon2id$...`` or a modular +crypt string such as ``$2b$...`` or ``$6$...``) is what an application stores instead of +the password; it does not reveal the password, so a credential-like name bound to it is +not a hardcoded credential. The algorithm identifier is what tells a hash: an incomplete +hash such as ``$argon2id$`` reveals nothing either. A plaintext value bound to the same +name is still reported, dollar signs included. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from coretrace_python import engine +from coretrace_python.findings import Finding +from coretrace_python.source import SourceManager + +PLUGINS = Path(__file__).resolve().parent.parent / "src" / "coretrace_python" / "bundled" + + +def check(text: str) -> tuple[Finding, ...]: + return engine.check(SourceManager().add_source("app/settings.py", text), [PLUGINS]) + + +@pytest.mark.parametrize( + "value", + [ + "$argon2id$v=19$m=8,t=1,p=1$c2FsdHNhbHQ$ieVgG5ysTJFx4k/KvmC9aQ", + "$argon2i$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$wWKIMhR9lyDFvRz9YTZweHKfbftvj+qf+YFY4NeBbtA", + "$argon2d$v=19$m=65536,t=2,p=1$c29tZXNhbHQ$Hl6uH5u2Bx9QA6LiWdqzkzCZD0dw6Y9V6nhuB1z0zgs", + "$argon2id$", + "$2b$12$KIXQJ1rKqvYV8m1b2u3Ewe0eY8c9Jx5mQ6v1Zp7S3y2Lh4Qn0aBcW", + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "$5$rounds=5000$saltsalt$Gbbw8bXq5ckEEnEJfRZFm6S6ki13QdkqoZ0gYJx0rU/", + "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/", + "$pbkdf2-sha256$29000$N2YMIWQsBWBMae09x1jrPQ$1t8iyB2A.WF/Z5JZv.lfCIhXXN33N23OSgQYThBYRfk", + "$scrypt$ln=16,r=8,p=1$aM15713r3Xsvxbi31lqr1Q$nFNh2CVHVjNldFVKDHDlm4CbdRSCdEBsjjJxD+iCs5E", + ], +) +def test_a_password_hash_is_not_a_hardcoded_credential(value: str) -> None: + assert check(f'web_password = "{value}"\n') == () + + +@pytest.mark.parametrize("value", ["hunter2-not-a-placeholder", "pa$$w0rd-really", "$ecret$value"]) +def test_a_plaintext_password_is_still_reported(value: str) -> None: + (finding,) = check(f'web_password = "{value}"\n') + + assert finding.rule_id == "hardcoded-credential" From 32024d3dee08753c84f95a4c4e6f32ea67abb114 Mon Sep 17 00:00:00 2001 From: Hugo Date: Sun, 4 Oct 2026 13:20:19 +0200 Subject: [PATCH 2/2] fix(secrets): recognise password hashes by their format's shape Review of the hash check: an algorithm identifier followed by plaintext, such as $2y$Summer2024!, was skipped, while Werkzeug, Django, md5-crypt, apr1 and yescrypt hashes were still reported. A value is now a hash when the identifier is followed by fields of its format's shape, or stands alone as an incomplete hash. Refs #211 --- src/coretrace_python/plugins/secrets.py | 28 +++++++++++++++----- tests/test_password_hashes.py | 35 ++++++++++++++++++++----- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/coretrace_python/plugins/secrets.py b/src/coretrace_python/plugins/secrets.py index cce3c8f..7a4101d 100644 --- a/src/coretrace_python/plugins/secrets.py +++ b/src/coretrace_python/plugins/secrets.py @@ -47,17 +47,31 @@ # Subresource integrity, as in lock files: ``sha512-``. _INTEGRITY = re.compile(r"^(md5|sha1|sha256|sha384|sha512)-[A-Za-z0-9+/=]+$", re.IGNORECASE) _ALPHABET_WORDS = ("alphabet", "charset", "characters", "letters", "digits") -# PHC strings and modular crypt strings open with their algorithm: argon2, bcrypt, -# SHA-crypt, PBKDF2 and scrypt. -_PASSWORD_HASH = re.compile(r"^\$(argon2(i|d|id)|2[abxy]|5|6|pbkdf2(-sha(1|256|512))?|scrypt)\$") +# A password hash names its algorithm, then its parameters, salt and digest, each in a +# hash alphabet: modular crypt and PHC strings (``$6$salt$digest``, ``$argon2id$v=19$...``; +# bcrypt has a fixed shape), Werkzeug's ``method:parameters$salt$hex digest`` and +# Django's ``algorithm$...$digest``. An algorithm identifier alone is an incomplete hash. +_HASH_FIELD = r"[A-Za-z0-9./+=,-]" +_CRYPT_ALGORITHMS = r"1|5|6|7|y|apr1|argon2(?:i|d|id)|pbkdf2(?:-sha(?:1|256|512))?|scrypt" +_PASSWORD_HASHES = tuple( + re.compile(regex) + for regex in ( + r"^\$2[abxy]\$\d\d\$[./A-Za-z0-9]{53}$", + rf"^\$(?:{_CRYPT_ALGORITHMS}|2[abxy])\$$", + rf"^\$(?:{_CRYPT_ALGORITHMS})(?:\${_HASH_FIELD}+){{2,}}$", + r"^(?:scrypt|pbkdf2):[a-z0-9:]+\$[^$\s]+\$[0-9a-f]+$", + rf"^(?:pbkdf2_sha(?:1|256)|argon2|bcrypt(?:_sha256)?|scrypt|md5|sha1)(?:\${_HASH_FIELD}*){{2,}}$", + ) +) def is_password_hash(value: str) -> bool: - """A password hash in PHC or modular crypt format, recognised by its algorithm - identifier: what an application stores instead of the password, which it does not - reveal, even when the rest of the hash is incomplete.""" + """A password hash of a known format, which an application stores instead of the + password and which does not reveal it: the algorithm identifier followed by fields + of that format's shape, or the identifier alone. An identifier followed by anything + else (``$2y$Summer2024!``) is a plaintext password.""" - return _PASSWORD_HASH.match(value) is not None + return any(pattern.match(value) is not None for pattern in _PASSWORD_HASHES) def looks_like_digest(value: str, name: str | None) -> bool: diff --git a/tests/test_password_hashes.py b/tests/test_password_hashes.py index e468d40..8adb7cd 100644 --- a/tests/test_password_hashes.py +++ b/tests/test_password_hashes.py @@ -1,11 +1,13 @@ """Acceptance tests for issue #211: password hashes are not hardcoded passwords. -A value in a password-hash format (a PHC string such as ``$argon2id$...`` or a modular -crypt string such as ``$2b$...`` or ``$6$...``) is what an application stores instead of -the password; it does not reveal the password, so a credential-like name bound to it is -not a hardcoded credential. The algorithm identifier is what tells a hash: an incomplete -hash such as ``$argon2id$`` reveals nothing either. A plaintext value bound to the same -name is still reported, dollar signs included. +A value in a password-hash format is what an application stores instead of the +password; it does not reveal the password, so a credential-like name bound to it is not +a hardcoded credential. The formats are PHC and modular crypt strings (``$argon2id$...``, +``$2b$...``, ``$6$...``), and the encodings of Werkzeug (``scrypt:32768:8:1$...``) and +Django (``pbkdf2_sha256$...``). A hash has the shape of its format: an algorithm +identifier followed by text that is not a hash (``$2y$Summer2024!``) is a plaintext +password, while the identifier alone (``$argon2id$``, an incomplete hash) reveals +nothing. A plaintext value bound to the same name is still reported. """ from __future__ import annotations @@ -39,13 +41,32 @@ def check(text: str) -> tuple[Finding, ...]: "$6$saltsalt$qFmFH.bQmmtXzyBY0s9v7Oicd2z4XSIecDzlB5KiA2/jctKu9YterLp8wwnSq.qc.eoxqOmSuNp2xS0ktL3nh/", "$pbkdf2-sha256$29000$N2YMIWQsBWBMae09x1jrPQ$1t8iyB2A.WF/Z5JZv.lfCIhXXN33N23OSgQYThBYRfk", "$scrypt$ln=16,r=8,p=1$aM15713r3Xsvxbi31lqr1Q$nFNh2CVHVjNldFVKDHDlm4CbdRSCdEBsjjJxD+iCs5E", + "$1$O3JMY.Tw$AdLnLjQ/5jXF9.MTp3gHv/", + "$apr1$71850310$gh9m4xcAn3MGxogwX/ztb.", + "$y$j9T$F5Jx5fExrKuPp53xLKQ..1$X3DX6M94c7o.9agCG9G317fhZg9SqC.5i5rd.RhAtQ7", + "scrypt:32768:8:1$KbI3sQ1x9cBnGz7a$d624ca2ead7454931117ad5ab4342ed9d93ae0407f730264d97f655506d36fb308d1ddb7f4c5f9046884ba4516df4b6e492ba26a31786ff43bec1f2d8999bc47", + "pbkdf2:sha256:600000$KbI3sQ1x9cBnGz7a$57fab0cc5de31d8fb5518804d402a6dd807e483ba7c6e3968a10cd466d28c46e", + "pbkdf2_sha256$870000$Qx7mR2vT9kLp4sWz$q8bVU34ZbIqSroAvvFgZhHZ6jNeCNMPqYZOg52ta260=", + "argon2$argon2id$v=19$m=102400,t=2,p=8$c29tZXNhbHQ$ieVgG5ysTJFx4k/KvmC9aQ", + "bcrypt_sha256$$2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", + "scrypt$16384$c29tZXNhbHQ$8$1$nFNh2CVHVjNldFVKDHDlm4CbdRSCdEBsjjJxD+iCs5E=", ], ) def test_a_password_hash_is_not_a_hardcoded_credential(value: str) -> None: assert check(f'web_password = "{value}"\n') == () -@pytest.mark.parametrize("value", ["hunter2-not-a-placeholder", "pa$$w0rd-really", "$ecret$value"]) +@pytest.mark.parametrize( + "value", + [ + "hunter2-not-a-placeholder", + "pa$$w0rd-really", + "$ecret$value", + "$2y$Summer2024!", + "$scrypt$MyRealPassw0rd", + "$5$hunter2-plain", + ], +) def test_a_plaintext_password_is_still_reported(value: str) -> None: (finding,) = check(f'web_password = "{value}"\n')