Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand All @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 +
Expand Down
11 changes: 8 additions & 3 deletions src/websec_validator/extractors/crypto_usage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 "
Expand All @@ -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 "
Expand Down
25 changes: 25 additions & 0 deletions tests/test_recon.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down