Scans source code for 10 session management vulnerabilities that enable session hijacking, CSRF attacks, session fixation, and account takeover. Maps to OWASP A07:2021 — Identification and Authentication Failures.
bash
# Scan a directory
python session_audit.py ./src
# Single file
python session_audit.py src/middleware/session.js
# CI mode — exit 1 on CRITICAL or HIGH
python session_audit.py ./src --ci
# Only show CRITICAL findings
python session_audit.py ./src --only-severity CRITICAL
IDSeverityCheckCWESS001CRITICALMissing HttpOnly flag on session cookieCWE-1004SS002HIGHMissing Secure flag on session cookieCWE-614SS003HIGHMissing SameSite attribute on session cookieCWE-352SS004CRITICALSession token / auth token in URL query stringCWE-598SS005CRITICALNo session regeneration after login (session fixation)CWE-384SS006HIGHMissing CSRF protection on state-changing routesCWE-352SS007HIGHSession/auth data stored in localStorageCWE-922SS008MEDIUMSession timeout not configured (indefinite sessions)CWE-613SS009MEDIUMWeak session ID — not using cryptographically secure sourceCWE-330SS010MEDIUMSession cookie domain scope too broad (e.g., domain=.example.com)CWE-565
Finds Set-Cookie headers and session configuration without httpOnly: true or HttpOnly flag. Without HttpOnly, any XSS vulnerability on any page can steal the session cookie via document.cookie.
Detected patterns: res.cookie(name, val, {}) without httpOnly: true, cookie_secure=False in Django, SESSION_COOKIE_HTTPONLY = False, set_cookie(key, value) without httponly=True.
Session cookies without Secure flag are sent over plain HTTP connections (if the browser is redirected or the user types http://). Attacker on same network can intercept with passive MITM.
Without SameSite=Strict or SameSite=Lax, the browser sends session cookies on cross-origin requests — enabling CSRF attacks even if a CSRF token library is used but misconfigured. Default before Chrome 80 was SameSite=None.
Finds req.query.token, request.args.get('session_id'), ?auth=, ?session=, ?token= used as authentication. URL tokens appear in server logs, browser history, Referer headers, and CDN access logs.
After successful login ( password.check(), authenticate(), login(user), bcrypt.compare()), the session ID must be regenerated. Finds login success paths without req.session.regenerate(), session.cycle_key(), request.session.flush(), session.save() after a fresh session create. Session fixation lets an attacker pre-set a known session ID and hijack the session post-login.
Finds POST/PUT/PATCH/DELETE route definitions without CSRF middleware or token validation nearby. Detects framework-specific CSRF patterns: csurf, csrf_token, CsrfViewMiddleware, @csrf_protect, protect_from_forgery, _token verification.
Finds localStorage.setItem('token', localStorage.setItem('session', localStorage.setItem('auth'. localStorage is accessible to JavaScript from any script on the page — a single XSS vulnerability exposes every stored credential. Use HttpOnly cookies instead.
Finds session configuration without maxAge, expires, cookie_age, SESSION_COOKIE_AGE, MaxInactiveInterval. Indefinite sessions mean a stolen session token is valid forever.
Finds session ID generation using random.random(), Math.random(), uuid.uuid4() without secrets module, str(time.time()), or sequential IDs. Session IDs must use a cryptographically secure source.
Finds domain: '.example.com' (leading dot) — this shares the cookie across all subdomains including potentially compromised ones (e.g., user-uploaded content on uploads.example.com).
text
============================================================
Session Audit — src/
Files scanned: 31 | Files flagged: 5
============================================================
── CRITICAL (3) ────────────────────────────────────────────
🔴 SS001 [CRITICAL] src/middleware/session.js:12
Session cookie set without httpOnly: true. XSS → instant session theft via document.cookie.
CWE: CWE-1004: Sensitive Cookie Without 'HttpOnly' Flag
Fix: res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'Strict' })
🔴 SS004 [CRITICAL] src/routes/auth.js:45
Auth token in URL query string: req.query.token used for authentication.
CWE: CWE-598: Token Exposed in URL
Fix: Move token to Authorization: Bearer header. Never authenticate via query string.
🔴 SS005 [CRITICAL] src/routes/login.js:78
Login success (bcrypt.compare) with no session regeneration nearby.
CWE: CWE-384: Session Fixation
Fix: req.session.regenerate((err) => { req.session.userId = user.id; res.redirect('/dashboard'); })
── HIGH (2) ────────────────────────────────────────────────
🟠 SS003 [HIGH] src/app.js:23
Session cookie has no SameSite attribute. CSRF attacks work even if CSRF token is missing.
CWE: CWE-352: Cross-Site Request Forgery
Fix: app.use(session({ cookie: { sameSite: 'Strict' } }))
🟠 SS007 [HIGH] src/utils/auth.js:91
JWT stored in localStorage: localStorage.setItem('token', jwt).
CWE: CWE-922: Insecure Storage of Sensitive Information
Fix: Store JWT in HttpOnly cookie. If SPA: use memory variable (lost on refresh) + refresh token in HttpOnly cookie.
── MEDIUM (1) ──────────────────────────────────────────────
🟡 SS008 [MEDIUM] src/app.js:18
Session configured without maxAge/expires. Sessions never expire — stolen tokens valid forever.
CWE: CWE-613: Insufficient Session Expiration
Fix: cookie: { maxAge: 8 * 60 * 60 * 1000 } (8 hours — adjust to your security requirements)
────────────────────────────────────────────────────────────
Total: 6 findings
Critical: 3 | High: 2 | Medium: 1
❌ CI GATE FAILED — resolve CRITICAL/HIGH findings before merging.
python
#!/usr/bin/env python3
"""
phy-session-audit — Session & Cookie Security Auditor
OWASP A07:2021 — Identification and Authentication Failures
Scans Express/Flask/Django/Go/Spring/Rails for session management vulnerabilities.
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 ────────────────────────────────────────────────────────────────
# SS001 — Missing HttpOnly
COOKIE_SET_RE = re.compile(
r"(res\.cookie\s*\(|response\.set_cookie\s*\(|set_cookie\s*\(|"
r"cookie\s*\(\s*['\"]|SESSION_COOKIE_HTTPONLY\s*=|"
r"cookie_httponly\s*=|setcookie\s*\()",
re.IGNORECASE,
)
HTTPONLY_PRESENT_RE = re.compile(
r"(httpOnly\s*:\s*true|httponly\s*=\s*True|HttpOnly|http_only\s*=\s*True|"
r"SESSION_COOKIE_HTTPONLY\s*=\s*True)",
re.IGNORECASE,
)
HTTPONLY_DISABLED_RE = re.compile(
r"(httpOnly\s*:\s*false|httponly\s*=\s*False|SESSION_COOKIE_HTTPONLY\s*=\s*False)",
re.IGNORECASE,
)
# SS002 — Missing Secure flag
SECURE_PRESENT_RE = re.compile(
r"(secure\s*:\s*true|secure\s*=\s*True|;\s*Secure|"
r"SESSION_COOKIE_SECURE\s*=\s*True|cookie_secure\s*=\s*True)",
re.IGNORECASE,
)
SECURE_DISABLED_RE = re.compile(
r"(secure\s*:\s*false|secure\s*=\s*False|SESSION_COOKIE_SECURE\s*=\s*False)",
re.IGNORECASE,
)
# SS003 — Missing SameSite
SAMESITE_PRESENT_RE = re.compile(
r"(sameSite|samesite|SameSite|same_site)",
re.IGNORECASE,
)
SAMESITE_NONE_RE = re.compile(
r"(sameSite\s*:\s*['\"]none['\"]|samesite\s*=\s*['\"]?none['\"]?)",
re.IGNORECASE,
)
# SS004 — Token in URL
URL_TOKEN_RE = re.compile(
r"(req\.query\.(token|session_id|auth|session|access_token|api_key)|"
r"request\.args\.get\s*\(\s*['\"](?:token|session_id|auth|session|access_token)['\"]|"
r"c\.Query\s*\(\s*['\"](?:token|session|auth)['\"]|"
r"request\.getParameter\s*\(\s*['\"](?:token|session|auth)['\"]|"
r"\?token=|\?session=|\?auth=|\?access_token=)",
re.IGNORECASE,
)
# SS005 — Session fixation (no regenerate after login)
LOGIN_SUCCESS_RE = re.compile(
r"(bcrypt\.compare|bcrypt\.check|password_verify|"
r"authenticate\s*\(|login\s*\(user|checkpw\s*\(|"
r"verify_password\s*\(|checkCredentials|validateLogin)",
re.IGNORECASE,
)
SESSION_REGENERATE_RE = re.compile(
r"(req\.session\.regenerate|session\.regenerate|"
r"session\.cycle_key\(\)|request\.session\.flush\(\)|"
r"session\.invalidate\(\)|SecurityContextHolder\.clearContext|"
r"reset_session\(\)|request\.change_session_id)",
re.IGNORECASE,
)
# SS006 — CSRF protection
POST_ROUTE_RE = re.compile(
r"""(app|router)\.(post|put|patch|delete)\s*\(\s*['"`]""",
re.IGNORECASE,
)
PYTHON_POST_ROUTE_RE = re.compile(
r"""@(app|router|blueprint)\.(post|put|patch|delete)\s*\(""",
re.IGNORECASE,
)
CSRF_GUARD_RE = re.compile(
r"(csurf|csrf_token|CsrfViewMiddleware|@csrf_protect|"
r"protect_from_forgery|_token|csrf\.protect|"
r"validateCsrfToken|verify_csrf_token|X-CSRF-Token|"
r"CsrfTokenRepository|@EnableWebSecurity)",
re.IGNORECASE,
)
# SS007 — Auth in localStorage
LOCALSTORAGE_AUTH_RE = re.compile(
r"localStorage\.setItem\s*\(\s*['\"](?:token|session|auth|jwt|access_token|"
r"refresh_token|bearer|user_token|authToken|sessionToken)['\"]",
re.IGNORECASE,
)
# SS008 — No session timeout
SESSION_CONFIG_RE = re.compile(
r"(app\.use\s*\(\s*session\s*\(|SessionMiddleware\s*\(|"
r"SESSION_COOKIE_AGE|session\.permanent|"
r"setMaxInactiveInterval|gorilla/sessions\.NewCookieStore)",
re.IGNORECASE,
)
TIMEOUT_PRESENT_RE = re.compile(
r"(maxAge|max_age|expires|SESSION_COOKIE_AGE|"
r"MaxInactiveInterval|cookie_age|session\.permanent\s*=\s*True)",
re.IGNORECASE,
)
# SS009 — Weak session ID
WEAK_SESSION_ID_RE = re.compile(
r"(random\.random\s*\(\)|random\.randint\s*\(|Math\.random\s*\(\)|"
r"str\s*\(\s*time\.time\s*\(\)\s*\)|time\.time\s*\(\)\s*\.hex|"
r"session_id\s*=\s*str\s*\(\s*\d|uuid\.uuid4\s*\(\)\s*\.hex)",
re.IGNORECASE,
)
# SS010 — Broad cookie domain
BROAD_DOMAIN_RE = re.compile(
r"(domain\s*:\s*['\"]\.[\w.-]+['\"]|"
r"domain\s*=\s*['\"]\.[\w.-]+['\"]|"
r"SESSION_COOKIE_DOMAIN\s*=\s*['\"]\.[\w.-]+['\"])",
re.IGNORECASE,
)
SUPPORTED_EXTENSIONS = {".js", ".ts", ".jsx", ".tsx", ".py", ".go", ".java", ".kt", ".rb", ".php"}
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 = 20) -> 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_ss001_missing_httponly(filepath: str, lines: list) -> list:
"""SS001 — Session cookie without HttpOnly flag."""
findings = []
for i, line in enumerate(lines):
if not COOKIE_SET_RE.search(line):
continue
ctx = get_context(lines, i, 10)
if HTTPONLY_DISABLED_RE.search(ctx):
findings.append(Finding(
check_id="SS001",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message="HttpOnly explicitly disabled on cookie. Any XSS can steal session via document.cookie.",
cwe="CWE-1004: Sensitive Cookie Without 'HttpOnly' Flag",
fix="Set httpOnly: true on all authentication cookies.",
))
elif not HTTPONLY_PRESENT_RE.search(ctx):
# Session-related cookie without explicit httpOnly
if re.search(r"(session|auth|token|jwt)", ctx, re.IGNORECASE):
findings.append(Finding(
check_id="SS001",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message="Session/auth cookie set without explicit HttpOnly flag — defaults to false in most frameworks.",
cwe="CWE-1004: Sensitive Cookie Without 'HttpOnly' Flag",
fix="res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'Strict' })",
))
return findings
def check_ss002_missing_secure(filepath: str, lines: list) -> list:
"""SS002 — Session cookie without Secure flag."""
findings = []
for i, line in enumerate(lines):
if not COOKIE_SET_RE.search(line):
continue
ctx = get_context(lines, i, 10)
if SECURE_DISABLED_RE.search(ctx):
findings.append(Finding(
check_id="SS002",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message="Secure flag explicitly disabled — cookie sent over HTTP. Passive MITM on same network intercepts session.",
cwe="CWE-614: Sensitive Cookie in HTTPS Session Without 'Secure' Attribute",
fix="Set secure: true (only disable in development, never production).",
))
elif not SECURE_PRESENT_RE.search(ctx):
if re.search(r"(session|auth|token|jwt)", ctx, re.IGNORECASE):
findings.append(Finding(
check_id="SS002",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message="Session/auth cookie missing Secure flag — sent over plain HTTP connections.",
cwe="CWE-614",
fix="Add secure: true to cookie options. Use SESSION_COOKIE_SECURE=True in Django.",
))
return findings
def check_ss003_missing_samesite(filepath: str, lines: list) -> list:
"""SS003 — Session cookie without SameSite attribute."""
findings = []
for i, line in enumerate(lines):
if not COOKIE_SET_RE.search(line):
continue
ctx = get_context(lines, i, 10)
if not SAMESITE_PRESENT_RE.search(ctx):
if re.search(r"(session|auth|token|jwt)", ctx, re.IGNORECASE):
findings.append(Finding(
check_id="SS003",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message="Session cookie missing SameSite attribute. Browser sends cookie on cross-origin requests — CSRF possible.",
cwe="CWE-352: Cross-Site Request Forgery",
fix="Add sameSite: 'Strict' (forms) or sameSite: 'Lax' (external links). Avoid sameSite: 'None' unless required.",
))
elif SAMESITE_NONE_RE.search(ctx):
findings.append(Finding(
check_id="SS003",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message="SameSite=None: cookie sent on ALL cross-origin requests. Combined with Secure, but CSRF still possible on some flows.",
cwe="CWE-352",
fix="Use sameSite: 'Strict' or 'Lax' unless cross-site cookie sharing is explicitly required (e.g., embedded iframe).",
))
return findings
def check_ss004_token_in_url(filepath: str, lines: list) -> list:
"""SS004 — Auth token passed in URL query string."""
findings = []
for i, line in enumerate(lines):
m = URL_TOKEN_RE.search(line)
if m:
findings.append(Finding(
check_id="SS004",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message=f"Auth token in URL query string: '{line.strip()[:70]}'. Tokens in URLs appear in server logs, browser history, and Referer headers.",
cwe="CWE-598: Token Exposed in URL",
fix="Use Authorization: Bearer <token> header. For redirect flows, use short-lived one-time codes, not long-lived tokens.",
))
return findings
def check_ss005_session_fixation(filepath: str, lines: list) -> list:
"""SS005 — No session regeneration after login (session fixation)."""
findings = []
for i, line in enumerate(lines):
if not LOGIN_SUCCESS_RE.search(line):
continue
ctx = get_context(lines, i, 25)
# Only flag if this looks like a login handler (has response/redirect)
if not re.search(r"(res\.|response\.|redirect|return|render|send)", ctx, re.IGNORECASE):
continue
if not SESSION_REGENERATE_RE.search(ctx):
findings.append(Finding(
check_id="SS005",
severity="CRITICAL",
location=f"{filepath}:{i + 1}",
message="Login success without session regeneration. Attacker can fix a known session ID pre-login and hijack post-login.",
cwe="CWE-384: Session Fixation",
fix="req.session.regenerate((err) => { req.session.userId = user.id; res.redirect('/dashboard'); })",
))
return findings
def check_ss006_missing_csrf(filepath: str, lines: list, all_content: str) -> list:
"""SS006 — State-changing route without CSRF protection."""
# If CSRF middleware is globally configured, skip per-route checks
if CSRF_GUARD_RE.search(all_content):
return []
findings = []
for i, line in enumerate(lines):
if not (POST_ROUTE_RE.search(line) or PYTHON_POST_ROUTE_RE.search(line)):
continue
if re.search(r"(webhook|health|api/|/api|grpc|rest)", line, re.IGNORECASE):
continue # Skip API-only or webhook endpoints
findings.append(Finding(
check_id="SS006",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"State-changing route '{line.strip()[:70]}' with no CSRF protection found in project.",
cwe="CWE-352: Cross-Site Request Forgery",
fix=(
"Express: npm install csurf; app.use(csrf())\n"
" Django: ensure CsrfViewMiddleware is in MIDDLEWARE\n"
" Rails: protect_from_forgery with: :exception"
),
))
if len(findings) >= 3:
break # Cap at 3 to avoid noise (global fix applies to all)
return findings
def check_ss007_localstorage_auth(filepath: str, lines: list) -> list:
"""SS007 — Auth/session data stored in localStorage."""
findings = []
for i, line in enumerate(lines):
if LOCALSTORAGE_AUTH_RE.search(line):
findings.append(Finding(
check_id="SS007",
severity="HIGH",
location=f"{filepath}:{i + 1}",
message=f"Auth token stored in localStorage: '{line.strip()[:70]}'. Accessible to any JS on the page — XSS = full credential theft.",
cwe="CWE-922: Insecure Storage of Sensitive Information",
fix=(
"Store JWT in HttpOnly cookie: res.cookie('token', jwt, { httpOnly: true, secure: true })\n"
" If SPA: store access token in memory + refresh token in HttpOnly cookie."
),
))
return findings
def check_ss008_no_session_timeout(filepath: str, lines: list) -> list:
"""SS008 — Session configured without timeout."""
findings = []
for i, line in enumerate(lines):
if not SESSION_CONFIG_RE.search(line):
continue
ctx = get_context(lines, i, 15)
if not TIMEOUT_PRESENT_RE.search(ctx):
findings.append(Finding(
check_id="SS008",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message="Session configured without maxAge/expires. Sessions never expire — stolen tokens remain valid indefinitely.",
cwe="CWE-613: Insufficient Session Expiration",
fix="cookie: { maxAge: 8 * 60 * 60 * 1000 } (8 hours). For high-security: 15-30 minutes with sliding window.",
))
return findings
def check_ss009_weak_session_id(filepath: str, lines: list) -> list:
"""SS009 — Weak (predictable) session ID generation."""
findings = []
for i, line in enumerate(lines):
if WEAK_SESSION_ID_RE.search(line):
# Check if this is actually being used as a session ID
ctx = get_context(lines, i, 5)
if re.search(r"(session|token|id|key|nonce)", ctx, re.IGNORECASE):
findings.append(Finding(
check_id="SS009",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message=f"Session ID generated with predictable source: '{line.strip()[:70]}'",
cwe="CWE-330: Use of Insufficiently Random Values",
fix=(
"Python: import secrets; session_id = secrets.token_urlsafe(32)\n"
" Node.js: require('crypto').randomBytes(32).toString('hex')\n"
" Use a battle-tested session library instead of rolling your own."
),
))
return findings
def check_ss010_broad_cookie_domain(filepath: str, lines: list) -> list:
"""SS010 — Cookie domain too broad (leading dot = all subdomains)."""
findings = []
for i, line in enumerate(lines):
m = BROAD_DOMAIN_RE.search(line)
if m:
findings.append(Finding(
check_id="SS010",
severity="MEDIUM",
location=f"{filepath}:{i + 1}",
message=f"Cookie domain with leading dot shares across ALL subdomains: '{m.group()}'",
cwe="CWE-565: Reliance on Cookies Without Validation and Integrity Checking",
fix=(
"Use exact domain (no leading dot): domain: 'app.example.com'\n"
" Leading dot .example.com shares cookie to uploads.example.com, static.example.com — attack surface."
),
))
return findings
# ─── Main Audit ───────────────────────────────────────────────────────────────
def audit(path: str) -> AuditResult:
result = AuditResult(scan_root=path)
files = collect_files(path)
result.files_scanned = len(files)
all_contents = {}
for f in files:
try:
all_contents[str(f)] = f.read_text(errors="ignore")
except Exception:
pass
for f in files:
content = all_contents.get(str(f), "")
lines = content.splitlines()
fp = str(f)
file_findings = []
file_findings.extend(check_ss001_missing_httponly(fp, lines))
file_findings.extend(check_ss002_missing_secure(fp, lines))
file_findings.extend(check_ss003_missing_samesite(fp, lines))
file_findings.extend(check_ss004_token_in_url(fp, lines))
file_findings.extend(check_ss005_session_fixation(fp, lines))
file_findings.extend(check_ss006_missing_csrf(fp, lines, content))
file_findings.extend(check_ss007_localstorage_auth(fp, lines))
file_findings.extend(check_ss008_no_session_timeout(fp, lines))
file_findings.extend(check_ss009_weak_session_id(fp, lines))
file_findings.extend(check_ss010_broad_cookie_domain(fp, lines))
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" Session 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 session management vulnerabilities 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-session-audit — Session & Cookie Security Auditor",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python session_audit.py ./src
python session_audit.py src/middleware/session.js
python session_audit.py ./src --ci
python session_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()
yaml
# GitHub Actions
- name: Session Security Audit
run: python session_audit.py ./src --ci
# Only CRITICAL findings block the build
- name: Session Audit (critical only)
run: python session_audit.py ./src --only-severity CRITICAL --ci
webhook, health, /api/, /grpc (typically stateless API routes using Bearer tokens instead of cookies). Caps at 3 findings (same global fix applies to all).uuid.uuid4() is flagged as MEDIUM when used as session ID — UUID4 uses OS entropy ( os.urandom) and is fine, but using .hex and assigning it as a session ID without wrapping in secrets is technically weaker than secrets.token_urlsafe(). Suppress if your framework handles session ID generation internally.CheckOWASP A07:2021 Sub-CategorySS001Session token exposure via XSSSS002Token transmission over cleartextSS003CSRF protection gapSS004Token exposure via URLSS005Session fixationSS006CSRF forgerySS007Insecure client-side storageSS008Infinite session lifetimeSS009Predictable session tokensSS010Overly permissive cookie scope
Canlah AI — Run performance marketing without breaking your brand.