Skill Vetter
一个面向 Security 场景的 Agent 技能。原始说明:Security-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope, and suspicious patterns.
name: Crypto Audit
description: Weak cryptography detector (OWASP A02:2021 — Cryptographic Failures). Scans Python, JavaScript/TypeScript, Go, Java, and PHP source code for 10 classes of insecure cryptography — MD5/SHA1 password hashing, PRNG for security-sensitive values, hardcoded IVs, ECB mode, DES/RC4/Blowfish, RSA key size under 2048 bits, weak JWT secrets, bcrypt rounds too low, TLS 1.0/1.1 acceptance, and hardcoded cryptographic keys in source. Zero external dependencies. CI fail-gate included.
license: Apache-2.0
homepage: https://canlah.ai
metadata:
author: Canlah AI
version: "1.0.3"
tags:
Scans source code for 10 classes of cryptographic failures that lead to data breaches, credential compromise, and compliance violations. Maps to OWASP A02:2021 — Cryptographic Failures (formerly "Sensitive Data Exposure").
# Scan a directory
python crypto_audit.py ./src
# Single file
python crypto_audit.py src/utils/auth.py
# CI mode — exit 1 on CRITICAL or HIGH
python crypto_audit.py ./src --ci
# Only CRITICAL findings
python crypto_audit.py ./src --only-severity CRITICAL
| ID | Severity | Check | CWE |
|----|----------|-------|-----|
| CR001 | CRITICAL | MD5/SHA1/SHA256 used for password hashing | CWE-328 |
| CR002 | CRITICAL | PRNG (random/Math.random) for security-sensitive values | CWE-338 |
| CR003 | CRITICAL | Hardcoded cryptographic key or IV in source | CWE-321 |
| CR004 | HIGH | ECB mode encryption (deterministic, pattern-leaking) | CWE-327 |
| CR005 | HIGH | DES / 3DES / RC4 / Blowfish / MD4 algorithm | CWE-327 |
| CR006 | HIGH | RSA key size under 2048 bits | CWE-326 |
| CR007 | HIGH | bcrypt/argon2 work factor too low | CWE-916 |
| CR008 | HIGH | JWT signed with weak/hardcoded secret (HS256 only) | CWE-347 |
| CR009 | MEDIUM | TLS 1.0 or 1.1 accepted (deprecated, POODLE/BEAST) | CWE-326 |
| CR010 | MEDIUM | Hardcoded IV (same IV reuse in CBC mode) | CWE-329 |
MD5 and SHA1 can be brute-forced at hundreds of billions of hashes per second on consumer hardware. Detects hashlib.md5, hashlib.sha1, hashlib.sha256, crypto.createHash('md5'), MessageDigest.getInstance("MD5") when they appear near password-related context (user, password, passwd, hash, credential).
SHA256 is flagged at MEDIUM — it's too fast for passwords (needs bcrypt/argon2/scrypt).
Detects random.random(), random.randint(), Math.random(), rand() (PHP), math/rand (Go) used for generating tokens, API keys, session IDs, CSRF tokens, OTP codes, or password reset links. These are predictable — use secrets.token_hex(), crypto.randomBytes(), crypto/rand.
Finds AES keys, HMAC secrets, encryption passwords, and JWT secrets hardcoded as string literals. A key in source code = a key in every git clone, every container image, and every developer's laptop.
ECB (Electronic Codebook) mode encrypts identical plaintext blocks to identical ciphertext — it leaks data patterns. The "ECB penguin" attack makes even encrypted images readable. Detects AES.MODE_ECB, Cipher.getInstance("AES") without mode (defaults to ECB in Java), AES-ECB.
DES (56-bit key, broken in 22 hours in 1999), RC4 (biased keystream, broken in WEP/SSL), Blowfish (64-bit block, Sweet32 birthday attack), MD4 (completely broken). None of these should appear in new code.
RSA-1024 is factorizable by nation-state adversaries and was deprecated by NIST in 2015. Detects RSA.generate(1024), rsa.KeySize = 1024, KeyPairGenerator.getInstance("RSA") with key size < 2048.
Bcrypt with rounds < 10 is too fast for security. Argon2 with memory < 64MB or time < 2 is insufficient. Detects bcrypt.hashpw(password, bcrypt.gensalt(rounds=4)) or bcrypt.hash(pass, 4).
Detects:
jwt.sign(payload, 'hardcoded-secret') — key in sourcealgorithm: 'none' — unsigned JWTs (CVE-2015-9235)TLS 1.0 (POODLE, BEAST attacks) and TLS 1.1 are deprecated by RFC 8996. Detects ssl.TLSVersion.TLSv1, TLSv1_METHOD, MinVersion: tls.VersionTLS10, sslProtocol: "TLSv1", SSLContext.getInstance("TLSv1").
A fixed IV in CBC mode means identical plaintexts produce identical ciphertexts (partial ECB vulnerability). Detects IV = b"1234567890123456" or iv = bytes([0] * 16) near CBC mode usage.
============================================================
Crypto Audit — src/
Files scanned: 38 | Files flagged: 6
============================================================
── CRITICAL (3) ────────────────────────────────────────────
🔴 CR001 [CRITICAL] src/auth/users.py:45
hashlib.sha256 used for password hashing. SHA256 runs at ~10B hash/sec — trivially brute-forceable.
CWE: CWE-328: Use of Weak Hash
Fix: Use bcrypt.hashpw(password, bcrypt.gensalt(rounds=12)) or argon2-cffi instead.
🔴 CR002 [CRITICAL] src/utils/tokens.py:23
random.token = random.randint(100000, 999999) — PRNG used for OTP/token generation.
CWE: CWE-338: Use of Cryptographically Weak PRNG
Fix: import secrets; token = secrets.token_hex(16)
🔴 CR003 [CRITICAL] src/config/crypto.py:8
Hardcoded AES key: SECRET_KEY = "my-super-secret-key-1234"
CWE: CWE-321: Use of Hard-coded Cryptographic Key
Fix: Load from environment: SECRET_KEY = os.environ["ENCRYPTION_KEY"] (min 32 bytes for AES-256)
── HIGH (2) ────────────────────────────────────────────────
🟠 CR004 [HIGH] src/services/data_enc.py:67
AES.new(key, AES.MODE_ECB) — ECB mode leaks data patterns (identical plaintext → identical ciphertext).
CWE: CWE-327: Use of Broken Algorithm
Fix: Use AES.new(key, AES.MODE_GCM) — GCM provides both encryption and authentication.
🟠 CR007 [HIGH] src/auth/password.py:31
bcrypt.gensalt(rounds=4) — work factor too low (min recommended: 12).
CWE: CWE-916: Use of Password Hash With Insufficient Computational Effort
Fix: bcrypt.gensalt(rounds=12) — doubles attacker cost for each +1 round.
── MEDIUM (1) ──────────────────────────────────────────────
🟡 CR010 [MEDIUM] src/services/enc.py:15
Hardcoded IV: iv = b"0000000000000000" near CBC mode usage. Fixed IV = partial ECB vulnerability.
CWE: CWE-329: Not Using a Random IV with CBC Mode
Fix: iv = os.urandom(16) # generate fresh random IV for each encryption
────────────────────────────────────────────────────────────
Total: 6 findings
Critical: 3 | High: 2 | Medium: 1
❌ CI GATE FAILED — resolve CRITICAL/HIGH findings before merging.
#!/usr/bin/env python3
"""
phy-crypto-audit — Weak Cryptography Detector
OWASP A02:2021 — Cryptographic Failures
Scans Python/JS/TS/Go/Java/PHP for 10 classes of insecure cryptography.
Zero external dependencies.
"""
import sys
import re
from dataclasses import dataclass, field
from pathlib import Path
# ─── Data Structures ─────────────────────────────────────────────────────────
@dataclass
class Finding:
check_id: str
severity: str # CRITICAL / HIGH / MEDIUM
location: str
message: str
cwe: str = ""
fix: str = ""
def __str__(self) -> str:
icon = {"CRITICAL": "🔴", "HIGH": "🟠", "MEDIUM": "🟡"}.get(self.severity, "⚪")
parts = [f"{icon} {self.check_id} [{self.severity}] {self.location}"]
parts.append(f" {self.message}")
if self.cwe:
parts.append(f" CWE: {self.cwe}")
if self.fix:
parts.append(f" Fix: {self.fix}")
return "\n".join(parts)
@dataclass
class AuditResult:
scan_root: str
files_scanned: int = 0
files_flagged: int = 0
findings: list = field(default_factory=list)
@property
def critical_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "CRITICAL")
@property
def high_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "HIGH")
@property
def medium_count(self) -> int:
return sum(1 for f in self.findings if f.severity == "MEDIUM")
# ─── Constants ────────────────────────────────────────────────────────────────
# CR001 — weak hash for passwords
WEAK_HASH_RE = re.compile(
r"hashlib\.(md5|sha1|sha256|sha224)\s*\(|"
r"crypto\.createHash\s*\(\s*['\"](?:md5|sha1|sha256|sha224)['\"]\)|"
r"MessageDigest\.getInstance\s*\(\s*['\"](?:MD5|SHA-?1|SHA-?256)['\"]\)|"
r"hash\s*\(\s*['\"](?:md5|sha1|sha256)['\"]|"
r"md5\s*\(|sha1\s*\(|openssl_digest\s*\([^,]+,\s*['\"](?:md5|sha1)['\"]",
re.IGNORECASE,
)
PASSWORD_CONTEXT_RE = re.compile(
r"(password|passwd|pwd|credential|secret|hash|digest|pin|otp)",
re.IGNORECASE,
)
# CR002 — PRNG for security values
PRNG_RE = re.compile(
r"random\.random\s*\(\)|"
r"random\.randint\s*\(|"
r"random\.choice\s*\(|"
r"random\.shuffle\s*\(|"
r"Math\.random\s*\(\)|"
r"\brand\s*\(\)|" # PHP rand()
r"math/rand\b|" # Go math/rand
r"new Random\s*\(\)|" # Java java.util.Random
r"Random\.nextInt\s*\(",
re.IGNORECASE,
)
SECURITY_CONTEXT_RE = re.compile(
r"(token|session|csrf|nonce|otp|api.?key|secret|salt|password|"
r"reset|verification|activation|challenge|random.?id)",
re.IGNORECASE,
)
# CR003 — hardcoded crypto key
HARDCODED_KEY_RE = re.compile(
r"""(SECRET_KEY|ENCRYPTION_KEY|AES_KEY|HMAC_KEY|JWT_SECRET|SIGNING_KEY)\s*=\s*['"][^'"]{8,}['"]|"""
r"""(key|secret|password)\s*=\s*b?['"]((?:[A-Za-z0-9+/=]{16,}|[0-9a-fA-F]{16,}))['"]""",
re.IGNORECASE,
)
# Exclude obvious env var patterns
ENV_VAR_RE = re.compile(
r"(os\.environ|os\.getenv|process\.env|env\[|getenv\(|config\[)",
re.IGNORECASE,
)
# CR004 — ECB mode
ECB_MODE_RE = re.compile(
r"(MODE_ECB|AES\.ECB|AES-ECB|AES/ECB|"
r"Cipher\.getInstance\s*\(\s*['\"]AES['\"]|" # Java default = ECB
r"createCipheriv\s*\(\s*['\"]AES['\"])", # Node.js AES without mode
re.IGNORECASE,
)
# CR005 — weak algorithms
WEAK_ALGO_RE = re.compile(
r"(DES\.|3DES\.|TripleDES|RC4\.|ARC4\.|Blowfish\.|MD4\.|"
r"Cipher\.getInstance\s*\(\s*['\"](?:DES|DESede|RC4|ARCFOUR|Blowfish)['\"]|"
r"createCipheriv\s*\(\s*['\"](?:des|des-ede3|rc4|bf)['\"]|"
r"openssl_encrypt\s*\([^,]+,\s*['\"](?:DES|3DES|RC4|BF)['\"])",
re.IGNORECASE,
)
# CR006 — weak RSA key size
RSA_KEYGEN_RE = re.compile(
r"(RSA\.generate\s*\(\s*(\d+)|"
r"rsa\.GenerateKey\s*\([^,]+,\s*(\d+)|"
r"KeyPairGenerator\.getInstance\s*\([^)]+\)[^;]*\.initialize\s*\(\s*(\d+)|"
r"genrsa\s+-out\s+[^\s]+\s+(\d+))",
re.IGNORECASE,
)
# CR007 — bcrypt/argon2 low work factor
BCRYPT_LOW_RE = re.compile(
r"bcrypt\.(gensalt|hashpw|hash)\s*\([^,]+,\s*(\d+)\)|"
r"bcrypt\.(gensalt|hash)\s*\(\s*(\d+)\s*\)",
re.IGNORECASE,
)
ARGON2_LOW_RE = re.compile(
r"argon2\.(hash|using)\s*\([^)]*time_cost\s*=\s*(\d+)|"
r"argon2\.(hash|using)\s*\([^)]*memory_cost\s*=\s*(\d+)",
re.IGNORECASE,
)
# CR008 — JWT weak signing
JWT_HARDCODED_RE = re.compile(
r"""(jwt\.sign\s*\([^,]+,\s*['"][^'"]{1,40}['"]|"""
r"""jwt\.encode\s*\([^,]+,\s*['"][^'"]{1,40}['"]|"""
r"""sign\s*\([^,]+,\s*['"][^'"]{1,40}['"]\s*[,)])|"""
r"""algorithm\s*[=:]\s*['"]none['"]""",
re.IGNORECASE,
)
# CR009 — weak TLS versions
WEAK_TLS_RE = re.compile(
r"(ssl\.TLSVersion\.TLSv1\b(?!_2)|"
r"TLSv1_METHOD|TLSv1_1_METHOD|"
r"MinVersion\s*:\s*tls\.VersionTLS1[01]\b|"
r"SSLContext\.getInstance\s*\(\s*['\"]TLSv1['\"]|"
r"sslProtocol\s*[=:]\s*['\"]TLSv1['\"]|"
r"ssl\.PROTOCOL_TLSv1\b(?!_2))",
re.IGNORECASE,
)
# CR010 — hardcoded IV
HARDCODED_IV_RE = re.compile(
r"""(iv\s*=\s*b?['"][0\x00\\x]{8,}['"]|"""
r"""IV\s*=\s*bytes\s*\(\s*\[\s*0\s*\]|"""
r"""iv\s*=\s*b?['"]\d{16,}['"]|"""
r"""iv\s*=\s*['"]\x00{16}['"]|"""
r"""nonce\s*=\s*b?['"][0\x00\\x]{8,}['"]\s*#\s*(?!random))""",
re.IGNORECASE,
)
CBC_MODE_RE = re.compile(r"(MODE_CBC|AES\.CBC|AES-CBC|AES/CBC)", re.IGNORECASE)
SUPPORTED_EXTENSIONS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".java", ".kt", ".php", ".rb"}
SKIP_DIRS = {"node_modules", ".git", "venv", ".venv", "__pycache__", "dist", "build", ".next", "vendor", "test", "tests", "__tests__", "migrations"}
# ─── Helpers ─────────────────────────────────────────────────────────────────
def get_context(lines: list, idx: int, window: int = 15) -> str:
start = max(0, idx - window)
end = min(len(lines), idx + window)
return "\n".join(lines[start:end])
def collect_files(path: str) -> list:
p = Path(path)
if p.is_file():
return [p] if p.suffix in SUPPORTED_EXTENSIONS else []
files = []
for f in p.rglob("*"):
if any(skip in f.parts for skip in SKIP_DIRS):
continue
if f.is_file() and f.suffix in SUPPORTED_EXTENSIONS:
files.append(f)
return files
# ─── Checks ──────────────────────────────────────────────────────────────────
def check_cr001_weak_password_hash(filepath: str, lines: list) -> list:
"""CR001 — MD5/SHA1/SHA256 for password hashing."""
findings = []
for i, line in enumerate(lines):
if not WEAK_HASH_RE.search(line):
continue
ctx = get_context(lines, i, 10)
if not PASSWORD_CONTEXT_RE.search(ctx):
continue # Skip if not near password context
# Classify severity
algo_match = re.search(r"(md5|sha1|sha256|sha224)", line, re.IGNORECASE)
algo = algo_match.group(1).upper() if algo_match else "weak hash"
severity = "CRITICAL" if algo in ("MD5", "SHA1") else "HIGH" # SHA256 is HIGH
findings.append(Finding(
check_id="CR001",
severity=severity,
location=f"{filepath}:{i + 1}",
message=f"{algo} used for password hashing. {algo} runs at ~{'100B' if algo in ('MD5','SHA1') else '10B'} hash/sec — trivially brute-forceable.",
cwe="CWE-328: Use of Weak Hash",
fix="Use bcrypt.hashpw(password, bcrypt.gensalt(rounds=12)) or argon2-cffi. Never use raw hash functions for passwords.",
))
return findings
def check_cr002_prng_for_security(filepath: str, lines: list) -> list:
"""CR002 — PRNG for security-sensitive values."""
findings = []
for i, line in enumerate(lines):
if not PRNG_RE.search(line):
continue
ctx = get_context(lines, i, 10)
if not SECURITY_CONTEXT_RE.search(ctx):
continue
findings.append(Finding(
check_id="CR002",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message=f"Predictable PRNG used for security-sensitive value: '{line.strip()[:70]}'",
cwe="CWE-338: Use of Cryptographically Weak Pseudo-Random Number Generator",
fix=(
"Python: secrets.token_hex(32) or secrets.token_urlsafe(32)\n"
" JS: crypto.randomBytes(32).toString('hex')\n"
" Go: crypto/rand.Read()\n"
" Java: SecureRandom.generateSeed(32)"
),
))
return findings
def check_cr003_hardcoded_key(filepath: str, lines: list) -> list:
"""CR003 — Hardcoded cryptographic key in source."""
findings = []
for i, line in enumerate(lines):
m = HARDCODED_KEY_RE.search(line)
if not m:
continue
# Skip if loaded from env
ctx = get_context(lines, i, 3)
if ENV_VAR_RE.search(ctx):
continue
# Skip test/example files by name
fname = Path(filepath).stem.lower()
if any(x in fname for x in ("test", "example", "sample", "demo", "mock", "fake")):
continue
findings.append(Finding(
check_id="CR003",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message=f"Hardcoded cryptographic key: '{line.strip()[:70]}'",
cwe="CWE-321: Use of Hard-coded Cryptographic Key",
fix="Load from environment: os.environ['ENCRYPTION_KEY'] — never commit keys to source control.",
))
return findings
def check_cr004_ecb_mode(filepath: str, lines: list) -> list:
"""CR004 — ECB mode encryption."""
findings = []
for i, line in enumerate(lines):
if ECB_MODE_RE.search(line):
findings.append(Finding(
check_id="CR004",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"ECB mode: '{line.strip()[:70]}'. ECB encrypts identical blocks identically — leaks data patterns.",
cwe="CWE-327: Use of Broken or Risky Cryptographic Algorithm",
fix="Use AES-GCM (authenticated encryption): AES.new(key, AES.MODE_GCM) — provides both confidentiality and integrity.",
))
return findings
def check_cr005_weak_algorithms(filepath: str, lines: list) -> list:
"""CR005 — DES/3DES/RC4/Blowfish/MD4."""
findings = []
for i, line in enumerate(lines):
m = WEAK_ALGO_RE.search(line)
if m:
algo = m.group(0)[:20]
findings.append(Finding(
check_id="CR005",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"Broken algorithm '{algo}': DES=56-bit (cracked 1999), RC4=biased keystream, Blowfish=Sweet32 birthday attack.",
cwe="CWE-327: Use of Broken or Risky Cryptographic Algorithm",
fix="Use AES-256-GCM for symmetric encryption, ChaCha20-Poly1305 as alternative.",
))
return findings
def check_cr006_weak_rsa_key(filepath: str, lines: list) -> list:
"""CR006 — RSA key size under 2048 bits."""
findings = []
for i, line in enumerate(lines):
m = RSA_KEYGEN_RE.search(line)
if not m:
continue
# Extract the key size from the match groups
size_str = next((g for g in m.groups() if g and g.isdigit()), None)
if size_str is None:
continue
size = int(size_str)
if size < 2048:
findings.append(Finding(
check_id="CR006",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"RSA key size {size} bits is below NIST minimum of 2048 bits (deprecated since 2015).",
cwe="CWE-326: Inadequate Encryption Strength",
fix=f"Use RSA 3072 or 4096 bits, or switch to ECDSA P-256 (equivalent to RSA-3072 at 1/4 the size).",
))
return findings
def check_cr007_bcrypt_low_rounds(filepath: str, lines: list) -> list:
"""CR007 — bcrypt/argon2 work factor too low."""
findings = []
for i, line in enumerate(lines):
m = BCRYPT_LOW_RE.search(line)
if m:
rounds_str = m.group(2) or m.group(4)
if rounds_str and int(rounds_str) < 10:
findings.append(Finding(
check_id="CR007",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"bcrypt rounds={rounds_str} is too low. Min recommended: 12 (each +1 doubles attacker cost).",
cwe="CWE-916: Use of Password Hash With Insufficient Computational Effort",
fix="bcrypt.gensalt(rounds=12) — or 14+ for high-security contexts.",
))
continue
m2 = ARGON2_LOW_RE.search(line)
if m2:
val_str = m2.group(2) or m2.group(4)
if val_str:
val = int(val_str)
if "time_cost" in line and val < 2:
findings.append(Finding(
check_id="CR007",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"argon2 time_cost={val} is too low. Minimum recommended: 3 (OWASP cheat sheet).",
cwe="CWE-916",
fix="Use time_cost=3, memory_cost=65536 (64MB), parallelism=4 as minimum.",
))
elif "memory_cost" in line and val < 65536:
findings.append(Finding(
check_id="CR007",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"argon2 memory_cost={val} KiB is too low. Minimum recommended: 65536 (64MB).",
cwe="CWE-916",
fix="Use memory_cost=65536 (64MB) as minimum for Argon2.",
))
return findings
def check_cr008_jwt_weak_secret(filepath: str, lines: list) -> list:
"""CR008 — JWT signed with hardcoded or weak secret."""
findings = []
for i, line in enumerate(lines):
m = JWT_HARDCODED_RE.search(line)
if not m:
continue
if ENV_VAR_RE.search(line):
continue
if "algorithm" in line.lower() and "none" in line.lower():
findings.append(Finding(
check_id="CR008",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message="JWT signed with algorithm 'none' — signature verification bypassed (CVE-2015-9235).",
cwe="CWE-347: Improper Verification of Cryptographic Signature",
fix="Remove 'none' from allowed algorithms. Always validate `algorithms=['RS256']` or `['HS256']` explicitly.",
))
else:
findings.append(Finding(
check_id="CR008",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"JWT appears to use hardcoded secret: '{line.strip()[:70]}'",
cwe="CWE-321: Use of Hard-coded Cryptographic Key",
fix="Load JWT secret from environment. For stateless rotation, use RS256 (asymmetric) instead of HS256.",
))
return findings
def check_cr009_weak_tls(filepath: str, lines: list) -> list:
"""CR009 — TLS 1.0/1.1 accepted."""
findings = []
for i, line in enumerate(lines):
if WEAK_TLS_RE.search(line):
findings.append(Finding(
check_id="CR009",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message=f"TLS 1.0/1.1 accepted: '{line.strip()[:70]}'. Both deprecated (RFC 8996), vulnerable to POODLE/BEAST.",
cwe="CWE-326: Inadequate Encryption Strength",
fix="Set minimum TLS version to 1.2 (prefer 1.3). Python: ssl.TLSVersion.TLSv1_2, Go: tls.VersionTLS12.",
))
return findings
def check_cr010_hardcoded_iv(filepath: str, lines: list, content: str) -> list:
"""CR010 — Hardcoded IV near CBC mode."""
if not CBC_MODE_RE.search(content):
return [] # No CBC mode in file — IV hardcoding less critical
findings = []
for i, line in enumerate(lines):
if HARDCODED_IV_RE.search(line):
findings.append(Finding(
check_id="CR010",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message=f"Hardcoded IV detected near CBC mode usage: '{line.strip()[:70]}'. Reusing IV = partial ECB vulnerability.",
cwe="CWE-329: Not Using a Random IV with CBC Mode",
fix="Generate random IV per encryption: iv = os.urandom(16). Store IV alongside ciphertext (it's not secret).",
))
return findings
# ─── Main Audit ───────────────────────────────────────────────────────────────
def audit(path: str) -> AuditResult:
result = AuditResult(scan_root=path)
files = collect_files(path)
result.files_scanned = len(files)
for f in files:
try:
content = f.read_text(errors="ignore")
except Exception:
continue
lines = content.splitlines()
fp = str(f)
file_findings = []
file_findings.extend(check_cr001_weak_password_hash(fp, lines))
file_findings.extend(check_cr002_prng_for_security(fp, lines))
file_findings.extend(check_cr003_hardcoded_key(fp, lines))
file_findings.extend(check_cr004_ecb_mode(fp, lines))
file_findings.extend(check_cr005_weak_algorithms(fp, lines))
file_findings.extend(check_cr006_weak_rsa_key(fp, lines))
file_findings.extend(check_cr007_bcrypt_low_rounds(fp, lines))
file_findings.extend(check_cr008_jwt_weak_secret(fp, lines))
file_findings.extend(check_cr009_weak_tls(fp, lines))
file_findings.extend(check_cr010_hardcoded_iv(fp, lines, content))
if file_findings:
result.files_flagged += 1
result.findings.extend(file_findings)
return result
def format_report(result: AuditResult, ci_mode: bool = False) -> str:
out = []
out.append(f"\n{'='*60}")
out.append(f" Crypto Audit — {result.scan_root}")
out.append(f" Files scanned: {result.files_scanned} | Files flagged: {result.files_flagged}")
out.append(f"{'='*60}")
if not result.findings:
out.append("✅ No cryptographic weaknesses detected.")
return "\n".join(out)
for severity in ("CRITICAL", "HIGH", "MEDIUM"):
sev = [f for f in result.findings if f.severity == severity]
if sev:
out.append(f"\n── {severity} ({len(sev)}) {'─'*40}")
for finding in sev:
out.append(str(finding))
out.append(f"\n{'─'*60}")
out.append(
f" Total: {len(result.findings)} findings | "
f"Critical: {result.critical_count} High: {result.high_count} Medium: {result.medium_count}"
)
if ci_mode and (result.critical_count > 0 or result.high_count > 0):
out.append("\n ❌ CI GATE FAILED — resolve CRITICAL/HIGH findings before merging.")
return "\n".join(out)
# ─── CLI ─────────────────────────────────────────────────────────────────────
def main():
import argparse
parser = argparse.ArgumentParser(
description="phy-crypto-audit — Weak Cryptography Detector",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python crypto_audit.py ./src
python crypto_audit.py src/auth/password.py
python crypto_audit.py ./src --ci
python crypto_audit.py ./src --only-severity CRITICAL
""",
)
parser.add_argument("path", help="Directory or file to audit")
parser.add_argument("--ci", action="store_true", help="Exit 1 on CRITICAL or HIGH findings")
parser.add_argument(
"--only-severity",
choices=["CRITICAL", "HIGH", "MEDIUM"],
help="Filter to this severity and above",
)
args = parser.parse_args()
result = audit(args.path)
sev_order = ["CRITICAL", "HIGH", "MEDIUM"]
if args.only_severity:
cutoff = sev_order.index(args.only_severity)
result.findings = [f for f in result.findings if sev_order.index(f.severity) <= cutoff]
print(format_report(result, ci_mode=args.ci))
if args.ci and (result.critical_count > 0 or result.high_count > 0):
sys.exit(1)
if __name__ == "__main__":
main()
# GitHub Actions
- name: Crypto Security Audit
run: python crypto_audit.py ./src --ci
# Block only on CRITICAL findings
- name: Crypto Audit (critical only)
run: python crypto_audit.py ./src --only-severity CRITICAL --ci
hashlib.sha256 on a file checksum won't fire.random() without nearby token/session/secret context won't fire. Simulation code is safe.test*, example*, sample*, demo*, mock*, fake*. Also skips any line that loads from os.environ, os.getenv, process.env.RSA.import_key(pem) won't fire.os.environ/process.env for the secret.Canlah AI — Run performance marketing without breaking your brand.