Cybersecurity-Projects/PROJECTS/foundations/hash-identifier/test_hash_identifier.py

555 lines
24 KiB
Python

"""
©AngelaMos | 2026
test_hash_identifier.py
Tests for hash_identifier — focused on the most-relied-on cases
────────────────────────────────────────────────────────────────────
What "tests" are and why we write them
────────────────────────────────────────────────────────────────────
A test is a tiny Python function that calls our real code with a
known input and then ASSERTS that the result is what we expected.
If the assertion fails, pytest prints a red FAIL message — which
means we changed something and broke a behavior we cared about
Tests are insurance. The first time you write the code, the test
just confirms it works. But six months later when you refactor or
add a new feature, the existing tests catch any accidental breakage.
This is why every senior codebase has tests: not because the code is
hard to write, but because the code is hard to keep WORKING over time
────────────────────────────────────────────────────────────────────
The shape of a pytest test
────────────────────────────────────────────────────────────────────
def test_<what_we_are_checking>() -> None:
result = some_function(some_input)
assert result == expected
Three rules
1. The function name must start with `test_` — pytest only collects
functions that match that pattern
2. The function takes no arguments (unless using fixtures)
3. Use the `assert` keyword to declare what should be true. If the
condition is false, the test fails
We follow the "Arrange-Act-Assert" structure inside each test
- Arrange: set up inputs (the `sample = ...` line)
- Act: call the real code (`candidates = identify(sample)`)
- Assert: check the result (`assert candidates[0]...`)
────────────────────────────────────────────────────────────────────
Coverage strategy
────────────────────────────────────────────────────────────────────
We do NOT try to test every algorithm in the table — that would be
hundreds of nearly identical tests. Instead we exercise each BRANCH
of identify() at least once
- prefix matches (one bcrypt, one Argon2id, one Django, one crypt)
- special MySQL5 format
- hex length matches (MD5, SHA-1, SHA-256 lengths)
- the empty / garbage / whitespace fallbacks
- HashCandidate's immutability
Together these give us confidence that every code path runs without
explosion, and that the most-common inputs produce the expected
top-ranked candidate
"""
# Importing from `hash_identifier` (NOT `hash_identifier.py`) tells
# Python to load the module that lives in this same directory. We
# pull in three things:
# - `identify` — the function under test
# - `HashCandidate`— the return-type dataclass (used in the
# immutability test)
# - `PREFIX_RULES` — the prefix lookup table (used by the
# parametrized "every row is covered" test
# at the bottom of this file)
# `pytest` is also imported so we can use the @pytest.mark.parametrize
# decorator to expand one test function into many test cases
# Third-party: the test runner itself. We also need it imported here
# so we can use its `@pytest.mark.parametrize` decorator below.
import pytest
# Local: our own module. We pull in the public pieces under test —
# the prefix-rule table, the result dataclass, and the entry function.
from hash_identifier import PREFIX_RULES, HashCandidate, identify
# =============================================================================
# Prefix matches (high confidence)
# =============================================================================
# These tests verify Step 1 of identify(): when the input starts with a
# known prefix, we report HIGH confidence. The exact hash payload after
# the prefix does not matter to identify() — it only inspects the
# leading characters
def test_bcrypt_prefix_is_recognized() -> None:
"""
A real bcrypt hash starts with `$2b$` and should be reported as bcrypt
"""
# Sample: an actual bcrypt hash for the password "password" with cost 12.
# The interesting part for our test is just `$2b$` — we never even
# decode the rest
sample = "$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQNQy.uK4Of2T7G"
# Call the function under test. `candidates` is a list of HashCandidate
candidates = identify(sample)
# First assert the list is non-empty. `assert <thing>` fails when
# <thing> is falsy — empty lists are falsy, so this catches the
# "no candidates returned" bug
assert candidates
# Then check the FIRST candidate (highest-priority guess) is bcrypt.
# `candidates[0]` is the first item; `.algorithm` is the field we
# check. `==` compares for equality
assert candidates[0].algorithm == "bcrypt"
# And confidence must be "high" — prefix matches are definitive
assert candidates[0].confidence == "high"
def test_argon2id_prefix_is_recognized() -> None:
"""
Argon2id PHC strings begin with `$argon2id$`
"""
# PHC format for Argon2id: $argon2id$v=<version>$m=...,t=...,p=...$<salt>$<hash>
sample = "$argon2id$v=19$m=65536,t=3,p=4$c2FsdHNhbHQ$aGFzaA"
candidates = identify(sample)
# `any(...)` returns True if at least one element of the iterable
# makes the inner expression true. We check that AT LEAST ONE
# candidate is Argon2id — using any() instead of [0] keeps the test
# robust if we ever add a second guess to the same prefix
assert any(c.algorithm == "Argon2id" for c in candidates)
def test_sha512_crypt_prefix_is_recognized() -> None:
"""
`$6$` is the marker for SHA-512 crypt — what /etc/shadow uses on Linux
"""
sample = "$6$rounds=10000$salt$hashedpasswordhere"
candidates = identify(sample)
# `[0]` because we want the TOP candidate. If anything else was
# ranked first, this assertion would fail loudly
assert candidates[0].algorithm == "SHA-512 crypt"
def test_django_pbkdf2_prefix_is_recognized() -> None:
"""
Django stores passwords as `pbkdf2_sha256$<iter>$<salt>$<hash>`
"""
sample = "pbkdf2_sha256$260000$salt$hash"
candidates = identify(sample)
assert candidates[0].algorithm == "Django PBKDF2-SHA256"
def test_apr1_prefix_is_recognized() -> None:
"""
Apache `.htpasswd` MD5 hashes start with $apr1$
The htpasswd tool generates these by default with the `-m` flag.
Same MD5 family as the Unix $1$ format but with Apache's own salt
handling — and FAR more common in the wild because every Apache
basic-auth tutorial ends with one of these in a .htpasswd file
"""
# Real-looking apr1 hash. The trailing payload after the second
# `$` is the base64-flavored encoding of the MD5 digest + salt.
# identify() never decodes it — only the leading `$apr1$` matters
sample = "$apr1$rsalt$mp7TYYDvbgvNCJN3JTd6q1"
candidates = identify(sample)
assert candidates[0].algorithm == "Apache MD5-crypt"
assert candidates[0].confidence == "high"
# =============================================================================
# Special formats
# =============================================================================
# Step 2 of identify(): formats that are NOT PHC strings but still have
# unmistakable shapes. Today we recognize NetNTLMv1, NetNTLMv2, and
# MySQL5 — three structurally-distinct records that all benefit from
# the same "check the literal shape before falling back to length" path
def test_mysql5_format_is_recognized() -> None:
"""
MySQL5 = literal `*` followed by 40 uppercase hex chars
MySQL5 stores SHA-1(SHA-1(password)) printed in uppercase hex with
a leading asterisk. So the whole hash is exactly 41 characters
"""
# The * matters — without it, this would just be 40 hex chars
# and would fall through to the SHA-1 length rule
sample = "*23AE809DDACAF96AF0FD78ED04B6A265E05AA257"
candidates = identify(sample)
# MySQL5 is a definitive shape, so we expect HIGH confidence
assert candidates[0].algorithm == "MySQL5"
assert candidates[0].confidence == "high"
def test_mysql5_rejects_lowercase_body() -> None:
"""
Lowercase hex after the leading `*` is not real MySQL5 output
MySQL emits uppercase via `%02X`, so a `*` followed by lowercase
hex is almost certainly hand-edited junk rather than a real
MySQL5 hash. We would rather return nothing than return a
confident WRONG answer here
"""
# Lowercase version of the previous test's body. The leading `*`
# is the only thing it shares with real MySQL5 output, and the
# case mismatch alone should disqualify it
lowercase_body = "23ae809ddacaf96af0fd78ed04b6a265e05aa257"
candidates = identify("*" + lowercase_body)
# Either the list is empty (preferred, what we expect today) OR
# whatever did match must NOT be labeled MySQL5. The `if`
# guard makes the test robust to future rules that might catch
# this shape under a different (correct) label — what matters is
# that we do not LIE and call it MySQL5
if candidates:
assert candidates[0].algorithm != "MySQL5"
def test_netntlmv2_format_is_recognized() -> None:
"""
NetNTLMv2 records from Responder look like
`user::domain:challenge:hmac:blob`
The hmac field is exactly 32 hex chars. The leading `::` (empty
LM-hash slot) is the giveaway that this is an AD challenge-
response record, not a stored password hash
"""
# Build a realistic NetNTLMv2 record:
# alice :: CORP : <16-char challenge> : <32 hex hmac> : <64 hex blob>
# The actual hash values do not matter — only the structural
# shape (colon count + hex field lengths) is what identify() checks
sample = "alice::CORP:1122334455667788:" + "a" * 32 + ":" + "b" * 64
candidates = identify(sample)
# NetNTLMv2 is a definitive shape — HIGH confidence
assert candidates[0].algorithm == "NetNTLMv2"
assert candidates[0].confidence == "high"
def test_netntlmv1_format_is_recognized() -> None:
"""
NetNTLMv1 records have 48-hex-char lmhash AND nthash before the challenge
Layout: `user::domain:lm(48 hex):nt(48 hex):challenge`. We
recognize this by looking at field index 3 — in NetNTLMv1 it is
exactly 48 hex chars, while in NetNTLMv2 it is the (shorter)
challenge field
"""
# Build a realistic NetNTLMv1 record. The 48-char lmhash and
# nthash are the load-bearing signal; identify() never decodes
# them — only their length and charset matter
sample = "alice::CORP:" + "a" * 48 + ":" + "b" * 48 + ":1122334455667788"
candidates = identify(sample)
assert candidates[0].algorithm == "NetNTLMv1"
assert candidates[0].confidence == "high"
def test_descrypt_format_is_recognized() -> None:
"""
Traditional DES crypt has NO prefix — only length and charset identify it
Legacy /etc/passwd from pre-shadow Unix systems used this format:
13 characters drawn from the alphabet `./0-9A-Za-z`. Rare today
but still turns up in retro CTFs, and hashcat still ships a mode
(1500) for cracking it
"""
# A realistic 13-char DES crypt output. Content does not matter
# for the test — only the length and the all-valid-charset
# property are what _is_descrypt checks
sample = "kRq14pmccuMOA"
candidates = identify(sample)
assert candidates[0].algorithm == "DES crypt"
# MEDIUM (not HIGH) confidence because a 13-char `./0-9A-Za-z`
# string CAN technically be other things (session IDs, random
# tokens). An honest medium beats a confident false-positive
assert candidates[0].confidence == "medium"
# =============================================================================
# Hex length matches (medium / low confidence)
# =============================================================================
# Step 3 of identify(): when the input is pure hex, length narrows down
# the algorithm. The FIRST listed algorithm for each length gets
# medium confidence (the modern default); the rest are low
def test_mysql323_length_returns_mysql323_first() -> None:
"""
16 hex chars points at MySQL323 (legacy MySQL OLD_PASSWORD output)
MySQL versions before 4.1 stored passwords as a 16-char hex string
from a custom (now-broken) hash function. The OLD_PASSWORD() SQL
function still produces this format on modern MySQL for legacy
compatibility, so these still show up in CTFs and old MySQL
breach dumps
"""
# A 16-hex-char string. The content does not matter — only the
# length and the all-hex charset are what identify() checks
sample = "5d2e19393cc5ef67"
candidates = identify(sample)
# MySQL323 ranks ABOVE CRC-64 because in a security context (a
# CTF challenge, a breach dump, a password column) MySQL323 is
# by far the more likely source. A 64-bit CRC almost never
# arrives at a hash-identifier tool
assert candidates[0].algorithm == "MySQL323"
# MEDIUM confidence: length alone is suggestive but cannot rule
# out CRC-64 with certainty without seeing the surrounding context
assert candidates[0].confidence == "medium"
def test_md5_length_returns_md5_first() -> None:
"""
32 hex chars matches MD5, NTLM, MD4, RIPEMD-128
MD5 is BY FAR the most common 32-hex hash in the wild, so it must
be the top candidate. NTLM (a Windows hash) is also 32 hex but
far less common in pasted hash dumps, so it should appear later
"""
# The literal MD5 of the string "password". A useful sample because
# any reader can verify it: `echo -n password | md5sum`
sample = "5f4dcc3b5aa765d61d8327deb882cf99"
candidates = identify(sample)
# Top candidate is MD5
assert candidates[0].algorithm == "MD5"
# And we report MEDIUM confidence — length alone is suggestive
# but not definitive (no prefix to confirm)
assert candidates[0].confidence == "medium"
# NTLM should appear in the candidate list as a less-likely option.
# We pull just the algorithm names into a list using a comprehension,
# then check membership with `in`
algorithms = [c.algorithm for c in candidates]
assert "NTLM" in algorithms
def test_sha256_length_returns_sha256_first() -> None:
"""
64 hex chars points at SHA-256 first
SHA3-256 and BLAKE2s also produce 64 hex chars but are rarer in
real systems, so SHA-256 takes the top spot
"""
# `"a" * 64` is Python shorthand for "the character 'a' repeated 64
# times" — a quick way to make a 64-char string for length tests.
# The actual content does not matter; only the length does
sample = "a" * 64
candidates = identify(sample)
assert candidates[0].algorithm == "SHA-256"
def test_sha1_length_returns_sha1_first() -> None:
"""
40 hex chars = SHA-1 (RIPEMD-160 as a backup guess)
"""
sample = "a" * 40
candidates = identify(sample)
assert candidates[0].algorithm == "SHA-1"
# =============================================================================
# No-match / edge cases
# =============================================================================
# Always test the boring edge cases. Empty inputs, whitespace-only,
# garbage. These caused real bugs in real codebases more than once
def test_empty_input_returns_no_candidates() -> None:
"""
Empty string returns an empty list — never blows up
"""
# Two checks because both of these have caused crashes elsewhere:
# truly empty AND whitespace-only (which strips down to empty)
assert identify("") == []
assert identify(" ") == []
def test_garbage_returns_no_candidates() -> None:
"""
A string with neither a known prefix nor a hex shape returns []
"""
# Sentence with spaces and punctuation: cannot be hex, has no
# PHC prefix. identify() should return an empty list, not guess
assert identify("hello, this is not a hash") == []
def test_input_is_trimmed_of_whitespace() -> None:
"""
Trailing newlines and leading spaces should not block recognition
This matters because copy-paste from a terminal often picks up
invisible whitespace. We strip it inside identify()
"""
# Note the leading spaces and trailing \n — escaped newline character
sample = " 5f4dcc3b5aa765d61d8327deb882cf99\n"
candidates = identify(sample)
# If trim works, we still recognize MD5 despite the surrounding noise
assert candidates[0].algorithm == "MD5"
# =============================================================================
# Soft-match fallbacks (shape hints, LOW confidence)
# =============================================================================
# Steps 4 and 5 of identify(): when nothing in the PREFIX_RULES table
# or the special-formats step or the hex-length table fires, we still
# try two soft matches — generic PHC string shape, then "this looks
# like a JWT / base64 blob, not a hash." Both return LOW confidence
# because shape alone is a weaker signal than a known prefix
def test_unknown_phc_string_falls_back_to_generic() -> None:
"""
A PHC string from an algorithm we don't have a specific rule for
is still reported as a PHC string with the extracted algorithm name
This is a SOFT match (LOW confidence) but still beats the
alternative of returning nothing on an obviously-PHC-shaped input
"""
# Passlib's pbkdf2-sha512 PHC encoding. We have no specific rule
# for it in PREFIX_RULES — but the `$pbkdf2-sha512$...` shape is
# unambiguous, so the generic fallback should pick it up
sample = "$pbkdf2-sha512$25000$cnNhbHQ$aGFzaA"
candidates = identify(sample)
assert candidates
# The algorithm column should say "PHC string (pbkdf2-sha512)" —
# both the marker word "PHC" and the extracted algorithm name
# should appear so the user knows WHAT kind of thing they pasted
assert "PHC" in candidates[0].algorithm
assert "pbkdf2-sha512" in candidates[0].algorithm
# LOW confidence because we matched on shape only, not on a
# specific rule that would let us confirm the algorithm
assert candidates[0].confidence == "low"
def test_jwt_input_is_called_out_as_not_a_hash() -> None:
"""
JWTs start with `eyJ` and should be called out as not-a-hash
Beginners often paste JWTs into a hash identifier because they
look hash-like. Saying "this is a JWT, not a hash" is more
useful than silence — it teaches the user what they have
"""
# A short but real-shaped JWT (header.payload.signature). The
# signature here is intentionally not real — identify() never
# validates it, only the leading `eyJ` matters
sample = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIn0.sig"
candidates = identify(sample)
assert candidates
# The algorithm column should contain "JWT" so the user knows
# exactly what kind of thing they pasted
assert "JWT" in candidates[0].algorithm
# LOW confidence is honest: we are not 100% sure this is a JWT,
# we are 100% sure it starts with `eyJ` and JWTs start with `eyJ`
assert candidates[0].confidence == "low"
def test_base64_blob_is_called_out_as_not_a_hash() -> None:
"""
A string containing base64-only chars (`+`, `/`, `=`) is not hex
Hex hashes never contain those characters, so their presence is a
strong signal that the input is base64-encoded data of some kind
rather than a hash — even when we cannot say what it decodes to
"""
# A base64-looking blob with the `=` padding character
sample = "VGhpcyBpcyBub3QgYSBoYXNoLCBpdHMgYmFzZTY0Lg=="
candidates = identify(sample)
assert candidates
# "Base64 blob" should appear in the algorithm name so the user
# knows we did identify SOMETHING, just not a hash
assert "Base64" in candidates[0].algorithm
# =============================================================================
# HashCandidate is immutable
# =============================================================================
# We declared HashCandidate with @dataclass(frozen=True). Frozen means
# you cannot reassign fields after construction — like a sealed envelope.
# This test makes sure the seal actually holds, so future refactors do
# not accidentally drop the `frozen` flag
def test_hash_candidate_is_frozen() -> None:
"""
Attempting to mutate a HashCandidate must raise an error
"""
# First, construct a normal instance
candidate = HashCandidate(
algorithm = "MD5",
confidence = "medium",
reason = "test",
)
# try/except is Python's "guard against an error" syntax. Inside
# `try`, we attempt the operation we EXPECT to fail. The `except`
# block runs ONLY if the listed exception types fire — and our
# frozen dataclass raises one of these on assignment
try:
# `# type: ignore[misc]` tells mypy "I know this is a type error;
# I am doing it on purpose to verify it actually fails at runtime"
candidate.algorithm = "SHA-1" # type: ignore[misc]
except (AttributeError, TypeError):
# Got the expected exception — the test passes by returning
return
# If we reached this line, no exception was raised — frozen is
# broken. Fail the test loudly with a clear message
raise AssertionError(
"HashCandidate should be frozen; assignment should have raised"
)
# =============================================================================
# Comprehensive PREFIX_RULES table coverage
# =============================================================================
# The individual prefix tests above (bcrypt, Argon2id, etc.) double as
# readable EXAMPLES of how identify() handles known prefixes. This last
# test plays a different role: it is the safety-net that guarantees
# EVERY row of PREFIX_RULES is exercised, so a typo in any single row's
# algorithm name or note string fails its own test case rather than
# slipping through silently.
#
# `@pytest.mark.parametrize(name, values)` is pytest's mechanism for
# expanding ONE test function into MANY test cases. Here we hand it
# the entire PREFIX_RULES list. Pytest unpacks each `(prefix, algorithm,
# note)` tuple into the three parameters of the test function and runs
# the body once per row. The leading `_` in `_note` tells linters
# (and the next reader) that we intentionally do not assert on the
# note string — we accept whatever the table author wrote
@pytest.mark.parametrize("prefix,algorithm,_note", PREFIX_RULES)
def test_every_prefix_rule_is_recognized_with_high_confidence(
prefix: str,
algorithm: str,
_note: str,
) -> None:
"""
Every entry in PREFIX_RULES produces a HIGH-confidence candidate
with the matching algorithm when its prefix sits at the start
of the input
The body of the hash after the prefix does not matter to
identify() — it only inspects the leading characters — so we
just glue any junk onto the end to form a syntactically-plausible
input
"""
sample = prefix + "fakebodydoesntmatter"
candidates = identify(sample)
# If identify() returned nothing, the prefix-loop branch is
# broken — fail with a message that names the offending prefix
# so the failure is debuggable at a glance
assert candidates, f"no candidates returned for prefix `{prefix}`"
assert candidates[0].algorithm == algorithm
assert candidates[0].confidence == "high"