diff --git a/AGENTS.md b/AGENTS.md index 310f91b..1591bc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,7 +32,7 @@ pass. Pure-Python, **stdlib only, zero runtime dependencies**; it shells out to | Command | Purpose | |---------|---------| | `pipx install --editable .` | Install the CLI from source (or `pip install -e .` in a 3.11+ venv) | -| `python3 -m unittest discover -s tests` | Run the suite (285 tests, stdlib only, no network) | +| `python3 -m unittest discover -s tests` | Run the suite (290 tests, stdlib only, no network) | | `websec run ./target` | Full pipeline → `FACTS.json` + `AGENT-BRIEFING.md` + `probes/` | | `websec doctor ./target` | Show which optional scanners are installed | | `websec proof` | Score recon coverage vs the vuln-app corpus (needs network on first clone) | @@ -54,7 +54,7 @@ docguard diagnose # guard → emit AI fix prompts 1. **Before any work**: read `docs-canonical/` and run `docguard guard` to see the compliance state. 2. **After changing code or docs**: re-run `docguard guard`; keep the numbers (20 extractors, 16 sink - classes, 285 tests, 10/10 proof) consistent across every doc — DocGuard's metrics-consistency + classes, 290 tests, 10/10 proof) consistent across every doc — DocGuard's metrics-consistency validator cross-checks them. 3. **Update `CHANGELOG.md`** for any user-visible change. 4. **Document drift**: if code must deviate from a canonical doc, add a `// DRIFT: reason` (or diff --git a/CHANGELOG.md b/CHANGELOG.md index db53af9..c99396c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Fixed +- Fixed multiple false positives in `crypto_usage` extractor (predictable principal caching/gravatar, JWT options variable, weak password hashes for reset tokens/HIBP check). Added 5 permanent regression tests. + ### Added - **No-Row-Level-Security detection** (`missing-rls` class, in `schemas.py` + the ledger) — committed diff --git a/README.md b/README.md index 9c0a343..7c09804 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ algorithms / predictable principal), **docker-compose host-takeover** + **`.gitl secret-suppression** audits, and a **reverse-proxy prefix-escape** detector), cross-tool de-dup + **bundled Semgrep rules**, **router-mount-auth modeling** (cuts the dominant Express-monorepo missing-auth false positive), tailored probe staging, agent briefing, traceable findings ledger with -**calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (285), **Docker bundle** (all +**calibrated confidence (CJE — Wilson CIs)**, proof harness, test suite (290), **Docker bundle** (all scanners + Noir, arch-aware), **dynamic phase v1** (authenticated read-only cross-tenant BOLA — validated live, reproduced a hand-pentest's 14/14). Validated against the **REF-PENTEST pen test + retest** and re-validated on a large real-world LLM-agent monorepo (HIGH-finding noise 178 → 15, AI + diff --git a/src/websec_validator/extractors/crypto_usage.py b/src/websec_validator/extractors/crypto_usage.py index e661301..2ce1ea3 100644 --- a/src/websec_validator/extractors/crypto_usage.py +++ b/src/websec_validator/extractors/crypto_usage.py @@ -40,13 +40,17 @@ # createHash('sha256') sitting in an auth file, but its input is a VERIFIER, not a password — flagging it # weak-password-hash is a false positive (real repos: a real Next.js app, a real app OAuth adapters). PKCE_CONTEXT = re.compile(r"code_challenge|code_verifier|codeVerifier|codeChallenge|\bS256\b|\bPKCE\b", re.I) +# Benign password-related hashes: HIBP (checking sha1 prefix), password reset tokens (hashing the token). +PW_EXCLUSION = re.compile(r"pwned|hibp|reset_?token|password_?reset", re.I) JWT_VERIFY = re.compile(r"\bjwtVerify\s*\(|\bjwt\.verify\s*\(|\bjwtv2\.verify\s*\(|verifyJwt\s*\(", re.I) -JWT_ALGS = re.compile(r"algorithms?\s*[:=]|['\"]alg['\"]\s*:", re.I) +JWT_ALGS = re.compile(r"algorithms?\s*[:=]|['\"]alg['\"]\s*:|[a-zA-Z0-9_]*[Oo]ptions\b", re.I) # a public hash of an identity field, used as a security principal / tenant key PRINCIPAL_HASH = re.compile( r"createHash\s*\(\s*['\"]sha256['\"]\s*\)[\s\S]{0,100}?\.update\s*\([^)]*\b(?:email|userId|user_id|username|userEmail|sub)\b" r"|hashlib\.sha256\s*\([^)]*\b(?:email|user_id|username)\b", re.I) PRINCIPAL_USE = re.compile(r"\b(?:tenant_?Id|user_?Id|set_config\s*\(\s*['\"]app\.|app\.user_id|principal|formatUuid|asUuid|toUuid)\b", re.I) +# Exclude hashing emails for gravatar or cache keys +PRINCIPAL_EXCLUSION = re.compile(r"gravatar|avatar|\bcache\b|redis", re.I) # a request-supplied secret/token/signature compared with ===/!== (non-constant-time) instead of a # timing-safe equal — a credential/HMAC timing side-channel (CWE-208). TIMING_UNSAFE = re.compile( @@ -76,7 +80,8 @@ def add(sev, kind, attack, rel, detail): continue if ((WEAK_PW_HASH.search(text) or (PW_CONTEXT.search(text) and FAST_HASH.search(text) and not STRONG_KDF.search(text))) - and not PKCE_CONTEXT.search(text)): # PKCE S256 over the verifier ≠ password hash + and not PKCE_CONTEXT.search(text) # PKCE S256 over the verifier ≠ password hash + and not PW_EXCLUSION.search(text)): # Exclude HIBP and reset tokens add("HIGH", "weak-password-hash", "weak-password-hash", rel, "A password appears to be hashed/verified with a FAST, unsalted digest " "(SHA-256/SHA-1/MD5). These are GPU-crackable at billions/sec and rainbow-tableable " @@ -94,7 +99,7 @@ def add(sev, kind, attack, rel, detail): "(not constant-time). This leaks a byte-by-byte timing side-channel on the credential " "(CWE-208). Compare with `crypto.timingSafeEqual` on equal-length buffers (or hash both " "sides first). Low impact when fronted by another auth layer, but cheap to fix.") - if PRINCIPAL_HASH.search(text) and PRINCIPAL_USE.search(text): + if PRINCIPAL_HASH.search(text) and PRINCIPAL_USE.search(text) and not PRINCIPAL_EXCLUSION.search(text): add("LOW", "predictable-principal", "predictable-principal", rel, "A security principal (tenant/user id) appears to be derived as a public, keyless hash of " "an identity field (e.g. `sha256(email)`), so anyone who knows the email can recompute the " diff --git a/tests/test_recon.py b/tests/test_recon.py index a22c976..9a681be 100644 --- a/tests/test_recon.py +++ b/tests/test_recon.py @@ -1211,6 +1211,31 @@ def test_timing_safe_compare_not_flagged(self): "Buffer.from(expected))) return deny();"}) self.assertNotIn("timing-unsafe-compare", self._kinds(out)) + def test_hibp_sha1_not_flagged(self): + code = "def check_pwned_password(password: str):\n sha1_hash = hashlib.sha1(password.encode('utf-8')).hexdigest().upper()\n return fetch(f'https://api.pwnedpasswords.com/range/{sha1_hash[:5]}')" + out = self._crypto({"auth.py": code}) + self.assertNotIn("weak-password-hash", self._kinds(out)) + + def test_password_reset_token_not_flagged(self): + code = "def verify_password_reset_token(token: str, db_token_hash: str):\n token_hash = hashlib.sha256(token.encode('utf-8')).hexdigest()\n return crypto.timingSafeEqual(token_hash, db_token_hash)" + out = self._crypto({"auth.py": code}) + self.assertNotIn("weak-password-hash", self._kinds(out)) + + def test_avatar_hash_not_flagged(self): + code = "def get_user_avatar(email: str):\n user_id = hashlib.sha256(email.strip().lower().encode('utf-8')).hexdigest()\n return f'https://www.gravatar.com/avatar/{user_id}?d=identicon'" + out = self._crypto({"avatar.py": code}) + self.assertNotIn("predictable-principal", self._kinds(out)) + + def test_jwt_verify_options_variable_not_flagged(self): + code = "import { jwtVerifyOptions } from './config';\nexport async function verifyMyToken(token, key) {\n const { payload } = await jwt.verify(token, key, jwtVerifyOptions);\n return payload;\n}" + out = self._crypto({"mw.ts": code}) + self.assertNotIn("jwt-verify-no-algorithms", self._kinds(out)) + + def test_predictable_principal_cache_key_not_flagged(self): + code = "const crypto = require('crypto');\nfunction getUserData(userId) {\n const cacheKey = crypto.createHash('sha256').update(userId).digest('hex');\n return redis.get(`cache:user:${cacheKey}`);\n}" + out = self._crypto({"cache.js": code}) + self.assertNotIn("predictable-principal", self._kinds(out)) + class Wave3DeferredDetectorTests(unittest.TestCase): """0.8.0 deferred-backlog detectors: CORS, Next-config headers, SRI, host-redirect,