Install
openclaw skills install phy-session-auditSession and cookie security auditor (OWASP A07:2021 — Identification and Authentication Failures). Scans Express/Koa/Fastify, Flask/Django/FastAPI, Go net/http, Spring Boot, and Rails source code for 10 session management weaknesses — missing HttpOnly/Secure/SameSite cookie flags, session token in URL, session fixation (no regenerate after login), missing CSRF protection, session data in localStorage, missing session timeout, weak session ID generation, and overly broad cookie domain scope. Zero external dependencies. CI fail-gate included.
openclaw skills install phy-session-auditScans 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.
# 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
| ID | Severity | Check | CWE |
|---|---|---|---|
| SS001 | CRITICAL | Missing HttpOnly flag on session cookie | CWE-1004 |
| SS002 | HIGH | Missing Secure flag on session cookie | CWE-614 |
| SS003 | HIGH | Missing SameSite attribute on session cookie | CWE-352 |
| SS004 | CRITICAL | Session token / auth token in URL query string | CWE-598 |
| SS005 | CRITICAL | No session regeneration after login (session fixation) | CWE-384 |
| SS006 | HIGH | Missing CSRF protection on state-changing routes | CWE-352 |
| SS007 | HIGH | Session/auth data stored in localStorage | CWE-922 |
| SS008 | MEDIUM | Session timeout not configured (indefinite sessions) | CWE-613 |
| SS009 | MEDIUM | Weak session ID — not using cryptographically secure source | CWE-330 |
| SS010 | MEDIUM | Session 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).
============================================================
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.
#!/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()
# 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.| Check | OWASP A07:2021 Sub-Category |
|---|---|
| SS001 | Session token exposure via XSS |
| SS002 | Token transmission over cleartext |
| SS003 | CSRF protection gap |
| SS004 | Token exposure via URL |
| SS005 | Session fixation |
| SS006 | CSRF forgery |
| SS007 | Insecure client-side storage |
| SS008 | Infinite session lifetime |
| SS009 | Predictable session tokens |
| SS010 | Overly permissive cookie scope |
Canlah AI — Run performance marketing without breaking your brand.