Initial commit: TMT lightweight threat modeling toolkit

This commit is contained in:
Kevin Thomas
2026-02-07 13:54:28 -05:00
commit 717a0b819f
29 changed files with 4214 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Test suite for the TMT threat modeling toolkit."""
+1
View File
@@ -0,0 +1 @@
"""Test fixtures for TMT scanner validation."""
+174
View File
@@ -0,0 +1,174 @@
"""Secure API fixture demonstrating proper defensive patterns.
This file contains well-secured API endpoints that should produce
minimal findings when scanned by TMT. Used to validate that scanners
do not generate excessive false positives.
"""
import secrets
from datetime import datetime, timedelta, timezone
from functools import wraps
from flask import Flask, request, jsonify, session
from flask_limiter import Limiter
from werkzeug.security import generate_password_hash, check_password_hash
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
# ──────────────────────────────────────────────────────────────────────────────
# Secure session configuration
# ──────────────────────────────────────────────────────────────────────────────
app.config["SESSION_COOKIE_SECURE"] = True
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
# ──────────────────────────────────────────────────────────────────────────────
# Rate limiter setup
# ──────────────────────────────────────────────────────────────────────────────
limiter = Limiter(app=app, default_limits=["100 per hour"])
# ──────────────────────────────────────────────────────────────────────────────
# Strict CORS with explicit origin
# ──────────────────────────────────────────────────────────────────────────────
ALLOWED_ORIGINS = ["https://app.example.com"]
@app.after_request
def add_cors(response):
"""Add CORS headers with explicit origin allowlist."""
origin = request.headers.get("Origin", "")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
return response
# ──────────────────────────────────────────────────────────────────────────────
# Authentication decorator with login_required check
# ──────────────────────────────────────────────────────────────────────────────
def login_required(f):
"""Decorator that enforces authentication on protected routes."""
@wraps(f)
def decorated(*args, **kwargs):
"""Check session for authenticated user before proceeding."""
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
return f(*args, **kwargs)
return decorated
# ──────────────────────────────────────────────────────────────────────────────
# Secure login with bcrypt-equivalent hashing and session regeneration
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/login")
@limiter.limit("5 per minute")
def login():
"""Authenticate with rate limiting and session regeneration."""
schema = LoginSchema()
data = schema.validate(request.json)
user = db.users.find_one({"email": data["email"]})
if user and check_password_hash(user["password"], data["password"]):
session.regenerate()
session["user_id"] = str(user["_id"])
return jsonify({"status": "ok"})
return jsonify({"error": "Invalid credentials"}), 401
# ──────────────────────────────────────────────────────────────────────────────
# Secure invite with rate limit, expiry, and single-use enforcement
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/invite")
@login_required
@limiter.limit("5 per hour")
def generate_invite():
"""Generate a time-limited, single-use invitation token."""
token = secrets.token_urlsafe(32)
expires_at = datetime.now(timezone.utc) + timedelta(hours=72)
db.invites.insert_one(
{
"token": token,
"created_by": session["user_id"],
"expires_at": expires_at,
"is_used": False,
"idempotency_key": request.headers.get("Idempotency-Key"),
}
)
return jsonify({"invite_token": token})
# ──────────────────────────────────────────────────────────────────────────────
# Atomic invite acceptance with transaction and single-use mark
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/accept-invite")
@limiter.limit("10 per hour")
def accept_invite():
"""Accept an invitation atomically with single-use enforcement."""
schema = AcceptInviteSchema()
data = schema.validate(request.json)
with db.transaction():
invite = db.invites.find_one_and_update(
{
"token": data["token"],
"is_used": False,
"expires_at": {"$gt": datetime.now(timezone.utc)},
},
{"$set": {"is_used": True, "used_at": datetime.now(timezone.utc)}},
)
if not invite:
return jsonify({"error": "Invalid or expired invite"}), 400
db.users.insert_one({"email": data["email"], "role": "member"})
return jsonify({"status": "account created"})
# ──────────────────────────────────────────────────────────────────────────────
# Atomic balance transfer with select_for_update
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/transfer")
@login_required
@limiter.limit("20 per hour")
def transfer():
"""Transfer balance atomically with proper locking."""
schema = TransferSchema()
data = schema.validate(request.json)
idempotency_key = request.headers.get("Idempotency-Key")
with db.transaction():
sender = db.accounts.find_one_and_update(
{"user_id": session["user_id"], "balance": {"$gte": data["amount"]}},
{"$inc": {"balance": -data["amount"]}},
)
if not sender:
return jsonify({"error": "Insufficient funds"}), 400
db.accounts.update(
{"user_id": data["to"]}, {"$inc": {"balance": data["amount"]}}
)
return jsonify({"status": "transferred"})
# ──────────────────────────────────────────────────────────────────────────────
# Secure logout with session destruction
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/logout")
@login_required
def logout():
"""Destroy session and invalidate tokens on logout."""
user_id = session["user_id"]
db.tokens.delete_many({"user_id": user_id})
session.clear()
return jsonify({"status": "logged out"})
+215
View File
@@ -0,0 +1,215 @@
"""Vulnerable API fixture for testing TMT scanner detection capabilities.
This file intentionally contains security vulnerabilities across all
categories: replay attacks, race conditions, token abuse, auth/session
issues, and API route problems. Used exclusively for testing.
WARNING: This code is intentionally insecure. Never deploy in production.
"""
import hashlib
import random
import uuid
from flask import Flask, request, jsonify, session
app = Flask(__name__)
app.secret_key = "hardcoded-secret-key"
# ──────────────────────────────────────────────────────────────────────────────
# Global mutable state without synchronization (race condition + shared state)
# ──────────────────────────────────────────────────────────────────────────────
user_balances = {}
active_coupons = {}
# ──────────────────────────────────────────────────────────────────────────────
# Insecure session configuration
# ──────────────────────────────────────────────────────────────────────────────
app.config["SESSION_COOKIE_SECURE"] = False
app.config["SESSION_COOKIE_HTTPONLY"] = False
# ──────────────────────────────────────────────────────────────────────────────
# Overly permissive CORS
# ──────────────────────────────────────────────────────────────────────────────
@app.after_request
def add_cors(response):
"""Add wildcard CORS headers to every response."""
response.headers["Access-Control-Allow-Origin"] = "*"
return response
# ──────────────────────────────────────────────────────────────────────────────
# Login without session regeneration, weak password hash, no brute force protection
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/login")
def login():
"""Authenticate a user with email and password."""
data = request.json
email = data["email"]
password_hash = hashlib.md5(data["password"].encode()).hexdigest()
user = db.users.find_one({"email": email, "password": password_hash})
if user:
session["user_id"] = str(user["_id"])
return jsonify({"status": "ok"})
return jsonify({"error": str("Invalid credentials")}), 401
# ──────────────────────────────────────────────────────────────────────────────
# Token generation: predictable, no expiry, no rate limit
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/invite")
def generate_invite():
"""Generate an invitation token for a new user."""
token = str(uuid.uuid1())
db.invites.insert_one(
{
"token": token,
"created_by": session.get("user_id"),
}
)
return jsonify({"invite_token": token})
# ──────────────────────────────────────────────────────────────────────────────
# Invite acceptance without single-use enforcement (token reuse + replay)
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/accept-invite")
def accept_invite():
"""Accept an invitation using a token."""
token = request.json["token"]
invite = db.invites.find_one({"token": token})
if not invite:
return jsonify({"error": "Invalid invite"}), 400
new_user = {"email": request.json["email"], "role": "member"}
db.users.insert_one(new_user)
return jsonify({"status": "account created"})
# ──────────────────────────────────────────────────────────────────────────────
# Balance transfer with race condition (non-atomic read-modify-write)
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/transfer")
def transfer():
"""Transfer balance between user accounts."""
data = request.json
sender = db.accounts.find_one({"user_id": data["from"]})
if sender["balance"] >= data["amount"]:
db.accounts.update(
{"user_id": data["from"]},
{"$set": {"balance": sender["balance"] - data["amount"]}},
)
db.accounts.update(
{"user_id": data["to"]},
{"$set": {"balance": sender["balance"] + data["amount"]}},
)
return jsonify({"status": "transferred"})
# ──────────────────────────────────────────────────────────────────────────────
# Coupon redemption with race condition (TOCTOU)
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/redeem-coupon")
def redeem_coupon():
"""Redeem a promotional coupon code."""
code = request.json["code"]
coupon = db.coupons.find_one({"code": code, "is_used": False})
if coupon:
apply_discount(coupon["discount"])
db.coupons.update({"code": code}, {"$set": {"is_used": True}})
return jsonify({"status": "redeemed"})
return jsonify({"error": "Invalid coupon"}), 400
# ──────────────────────────────────────────────────────────────────────────────
# Admin endpoint without role check
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/admin/users")
def admin_list_users():
"""List all users in the system."""
users = list(db.users.find())
return jsonify(users)
# ──────────────────────────────────────────────────────────────────────────────
# Mass assignment vulnerability
# ──────────────────────────────────────────────────────────────────────────────
@app.put("/api/profile")
def update_profile():
"""Update the current user's profile."""
db.users.update({"_id": session["user_id"]}, {"$set": request.json})
return jsonify({"status": "updated"})
# ──────────────────────────────────────────────────────────────────────────────
# IDOR: object access without ownership check
# ──────────────────────────────────────────────────────────────────────────────
@app.get("/api/documents/<doc_id>")
def get_document(doc_id):
"""Retrieve a document by its ID."""
doc = db.documents.find_one({"_id": doc_id})
return jsonify(doc)
# ──────────────────────────────────────────────────────────────────────────────
# Verbose error exposure
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/process")
def process_data():
"""Process submitted data."""
try:
result = complex_operation(request.json)
return jsonify(result)
except Exception as e:
return jsonify({"error": str(e), "trace": traceback.format_exc()}), 500
# ──────────────────────────────────────────────────────────────────────────────
# Logout that doesn't actually invalidate anything
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/logout")
def logout():
"""Log the user out."""
return jsonify({"status": "logged out"})
# ──────────────────────────────────────────────────────────────────────────────
# Password reset with token but no invalidation after use
# ──────────────────────────────────────────────────────────────────────────────
@app.post("/api/reset-password")
def reset_password():
"""Reset a user's password using a reset token."""
token = request.json["token"]
result = verify_token(token)
if result:
new_hash = hashlib.sha1(request.json["new_password"].encode()).hexdigest()
db.users.update({"_id": result["user_id"]}, {"$set": {"password": new_hash}})
return jsonify({"status": "password reset"})
return jsonify({"error": "Invalid token"}), 400
+154
View File
@@ -0,0 +1,154 @@
"""Test suite for the LLM reviewer response parsing and prompt assembly.
Tests focus on deterministic components: prompt building, JSON parsing,
and finding assembly. Live LLM API calls are not invoked in tests.
"""
import json
import pytest
from tmt.config import LLMConfig
from tmt.llm.prompts import PromptLibrary
from tmt.llm.reviewer import (
_parse_findings_json,
_strip_markdown_fences,
_parse_severity,
_parse_category,
)
from tmt.models import FindingCategory, Severity
# ──────────────────────────────────────────────────────────────────────────────
# Prompt library tests
# ──────────────────────────────────────────────────────────────────────────────
class TestPromptLibrary:
"""Test suite for prompt template assembly and formatting."""
def test_get_template_names(self):
"""Verify all expected template names are available."""
lib = PromptLibrary()
names = lib.get_template_names()
assert "api_route" in names
assert "auth_session" in names
assert "logic_bug" in names
assert "comprehensive" in names
def test_build_prompt_contains_code(self):
"""Verify built prompt includes the provided source code."""
lib = PromptLibrary()
code = "def hello(): pass"
result = lib.build_prompt("api_route", code)
assert "system" in result
assert "user" in result
assert code in result["user"]
def test_build_prompt_includes_schema(self):
"""Verify built prompt includes the JSON output schema instructions."""
lib = PromptLibrary()
result = lib.build_prompt("comprehensive", "x = 1")
assert "JSON" in result["user"]
assert "severity" in result["user"]
def test_build_all_prompts(self):
"""Verify build_all_prompts returns prompts for every template."""
lib = PromptLibrary()
all_prompts = lib.build_all_prompts("def foo(): pass")
assert len(all_prompts) == 4
for name, prompt_pair in all_prompts.items():
assert "system" in prompt_pair
assert "user" in prompt_pair
def test_invalid_template_raises_key_error(self):
"""Verify requesting a non-existent template raises KeyError."""
lib = PromptLibrary()
with pytest.raises(KeyError):
lib.build_prompt("nonexistent", "code")
# ──────────────────────────────────────────────────────────────────────────────
# Response parsing tests
# ──────────────────────────────────────────────────────────────────────────────
class TestResponseParsing:
"""Test suite for LLM response parsing and finding extraction."""
def test_strip_markdown_fences_json(self):
"""Verify markdown code fences are stripped from JSON responses."""
raw = '```json\n[{"title": "test"}]\n```'
cleaned = _strip_markdown_fences(raw)
assert cleaned == '[{"title": "test"}]'
def test_strip_markdown_fences_plain(self):
"""Verify plain text without fences is returned unchanged."""
raw = '[{"title": "test"}]'
cleaned = _strip_markdown_fences(raw)
assert cleaned == raw
def test_parse_valid_findings_json(self):
"""Verify valid JSON array is parsed into Finding objects."""
raw = json.dumps(
[
{
"title": "Test Finding",
"description": "A test vulnerability",
"severity": "high",
"category": "replay_attack",
"line_number": 42,
"recommendation": "Fix it",
"confidence": 0.9,
"cwe_id": "CWE-294",
}
]
)
findings = _parse_findings_json(raw, "test.py")
assert len(findings) == 1
assert findings[0].title == "Test Finding"
assert findings[0].severity == Severity.HIGH
assert findings[0].category == FindingCategory.REPLAY_ATTACK
def test_parse_empty_array(self):
"""Verify empty JSON array returns empty findings list."""
findings = _parse_findings_json("[]", "test.py")
assert findings == []
def test_parse_invalid_json_returns_empty(self):
"""Verify malformed JSON returns empty list without raising."""
findings = _parse_findings_json("not valid json {{{", "test.py")
assert findings == []
def test_parse_severity_mapping(self):
"""Verify all severity strings map correctly to enum values."""
assert _parse_severity("critical") == Severity.CRITICAL
assert _parse_severity("high") == Severity.HIGH
assert _parse_severity("medium") == Severity.MEDIUM
assert _parse_severity("low") == Severity.LOW
assert _parse_severity("info") == Severity.INFO
assert _parse_severity("unknown") == Severity.MEDIUM
def test_parse_category_mapping(self):
"""Verify all category strings map correctly to enum values."""
assert _parse_category("replay_attack") == FindingCategory.REPLAY_ATTACK
assert _parse_category("race_condition") == FindingCategory.RACE_CONDITION
assert _parse_category("token_abuse") == FindingCategory.TOKEN_ABUSE
assert _parse_category("auth_session") == FindingCategory.AUTH_SESSION
assert _parse_category("api_route") == FindingCategory.API_ROUTE
assert _parse_category("unknown") == FindingCategory.LLM_REVIEW
def test_parse_single_object_wrapped_in_list(self):
"""Verify a single JSON object (not array) is wrapped and parsed."""
raw = json.dumps(
{
"title": "Single",
"description": "desc",
"severity": "low",
"category": "api_route",
"line_number": 1,
"recommendation": "fix",
"confidence": 0.5,
}
)
findings = _parse_findings_json(raw, "test.py")
assert len(findings) == 1
assert findings[0].title == "Single"
+225
View File
@@ -0,0 +1,225 @@
"""Test suite for the threat model runner and report generation.
Validates the end-to-end workflow: scanner orchestration, report
assembly, statistics computation, and file output generation.
"""
import json
import os
import tempfile
import pytest
from tmt.config import TMTConfig, ScannerConfig, LLMConfig, ReportConfig
from tmt.models import (
Finding,
FindingCategory,
ScanResult,
Severity,
ThreatModelReport,
compute_report_statistics,
)
from tmt.reports.generator import ReportGenerator
from tmt.runner import ThreatModelRunner
# ──────────────────────────────────────────────────────────────────────────────
# Path constants for test fixtures
# ──────────────────────────────────────────────────────────────────────────────
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
def _make_test_config(output_dir: str) -> TMTConfig:
"""Create a TMTConfig tailored for testing with output to a temp dir.
Args:
output_dir: Temporary directory for report output.
Returns:
TMTConfig with scanning enabled and LLM disabled.
"""
return TMTConfig(
project_name="test-project",
target_dirs=[FIXTURES_DIR],
file_extensions=[".py"],
exclude_dirs=["__pycache__", ".git"],
scanner=ScannerConfig(enabled=True),
llm=LLMConfig(enabled=False),
report=ReportConfig(output_dir=output_dir, formats=["markdown", "json"]),
)
def _make_sample_finding(severity: Severity = Severity.HIGH) -> Finding:
"""Create a sample Finding for report generation tests.
Args:
severity: Severity level for the sample finding.
Returns:
Finding with test data populated.
"""
return Finding(
title="Test Finding",
description="A test vulnerability description",
severity=severity,
category=FindingCategory.AUTH_SESSION,
file_path="test.py",
line_number=10,
code_snippet="vulnerable_code()",
recommendation="Fix the vulnerability",
confidence=0.9,
cwe_id="CWE-000",
)
# ──────────────────────────────────────────────────────────────────────────────
# Report statistics tests
# ──────────────────────────────────────────────────────────────────────────────
class TestReportStatistics:
"""Test suite for report statistics computation."""
def test_compute_empty_report(self):
"""Verify empty report has zero counts."""
report = ThreatModelReport(project_name="test")
report = compute_report_statistics(report)
assert report.total_findings == 0
assert report.critical_count == 0
def test_compute_with_findings(self):
"""Verify statistics correctly count findings by severity."""
scan_result = ScanResult(
scanner_name="TestScanner",
findings=[
_make_sample_finding(Severity.CRITICAL),
_make_sample_finding(Severity.CRITICAL),
_make_sample_finding(Severity.HIGH),
_make_sample_finding(Severity.MEDIUM),
_make_sample_finding(Severity.LOW),
],
)
report = ThreatModelReport(project_name="test", scan_results=[scan_result])
report = compute_report_statistics(report)
assert report.total_findings == 5
assert report.critical_count == 2
assert report.high_count == 1
assert report.medium_count == 1
assert report.low_count == 1
# ──────────────────────────────────────────────────────────────────────────────
# Report generation tests
# ──────────────────────────────────────────────────────────────────────────────
class TestReportGenerator:
"""Test suite for Markdown and JSON report file generation."""
def test_generates_markdown_file(self):
"""Verify Markdown report file is created with correct content."""
with tempfile.TemporaryDirectory() as tmpdir:
config = ReportConfig(output_dir=tmpdir, formats=["markdown"])
generator = ReportGenerator(config)
report = ThreatModelReport(project_name="md-test")
paths = generator.generate(report)
assert len(paths) == 1
assert paths[0].endswith(".md")
assert os.path.exists(paths[0])
def test_generates_json_file(self):
"""Verify JSON report file is created with valid JSON content."""
with tempfile.TemporaryDirectory() as tmpdir:
config = ReportConfig(output_dir=tmpdir, formats=["json"])
generator = ReportGenerator(config)
report = ThreatModelReport(project_name="json-test")
paths = generator.generate(report)
assert len(paths) == 1
with open(paths[0]) as f:
data = json.load(f)
assert data["project_name"] == "json-test"
def test_generates_both_formats(self):
"""Verify both Markdown and JSON files are generated together."""
with tempfile.TemporaryDirectory() as tmpdir:
config = ReportConfig(output_dir=tmpdir, formats=["markdown", "json"])
generator = ReportGenerator(config)
report = ThreatModelReport(project_name="dual-test")
paths = generator.generate(report)
assert len(paths) == 2
def test_markdown_includes_findings(self):
"""Verify Markdown report includes finding details."""
with tempfile.TemporaryDirectory() as tmpdir:
config = ReportConfig(output_dir=tmpdir, formats=["markdown"])
generator = ReportGenerator(config)
finding = _make_sample_finding()
scan_result = ScanResult(scanner_name="TestScanner", findings=[finding])
report = ThreatModelReport(
project_name="detail-test", scan_results=[scan_result]
)
paths = generator.generate(report)
content = open(paths[0]).read()
assert "Test Finding" in content
assert "CWE-000" in content
# ──────────────────────────────────────────────────────────────────────────────
# End-to-end runner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestThreatModelRunner:
"""Test suite for the end-to-end threat modeling workflow."""
def test_runner_produces_report(self):
"""Verify runner completes and returns a populated report."""
with tempfile.TemporaryDirectory() as tmpdir:
config = _make_test_config(tmpdir)
runner = ThreatModelRunner(config)
report = runner.run(target_path=FIXTURES_DIR)
assert isinstance(report, ThreatModelReport)
assert len(report.scan_results) == 5
def test_runner_generates_report_files(self):
"""Verify runner writes report files to the output directory."""
with tempfile.TemporaryDirectory() as tmpdir:
config = _make_test_config(tmpdir)
runner = ThreatModelRunner(config)
runner.run(target_path=FIXTURES_DIR)
md_path = os.path.join(tmpdir, "threat_model_report.md")
json_path = os.path.join(tmpdir, "threat_model_report.json")
assert os.path.exists(md_path), "Markdown report should exist"
assert os.path.exists(json_path), "JSON report should exist"
def test_runner_detects_vulnerabilities(self):
"""Verify runner finds vulnerabilities in the vulnerable fixture."""
with tempfile.TemporaryDirectory() as tmpdir:
config = _make_test_config(tmpdir)
runner = ThreatModelRunner(config)
report = runner.run(target_path=FIXTURES_DIR)
report = compute_report_statistics(report)
assert (
report.total_findings > 0
), "Should find vulnerabilities in test fixtures"
def test_runner_with_llm_disabled(self):
"""Verify runner works correctly when LLM review is disabled."""
with tempfile.TemporaryDirectory() as tmpdir:
config = _make_test_config(tmpdir)
config.llm.enabled = False
runner = ThreatModelRunner(config)
report = runner.run(target_path=FIXTURES_DIR)
assert len(report.llm_reviews) == 0
def test_json_report_is_valid(self):
"""Verify generated JSON report parses correctly and has structure."""
with tempfile.TemporaryDirectory() as tmpdir:
config = _make_test_config(tmpdir)
runner = ThreatModelRunner(config)
runner.run(target_path=FIXTURES_DIR)
json_path = os.path.join(tmpdir, "threat_model_report.json")
with open(json_path) as f:
data = json.load(f)
assert "project_name" in data
assert "summary" in data
assert "scan_results" in data
+275
View File
@@ -0,0 +1,275 @@
"""Comprehensive test suite for TMT pattern-based scanners.
Validates that each scanner correctly identifies vulnerabilities in
the vulnerable_api.py fixture and produces fewer findings against
the secure_api.py fixture, ensuring both detection and low false
positive rates.
"""
import os
import pytest
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.replay_scanner import ReplayScanner
from tmt.scanners.race_condition_scanner import RaceConditionScanner
from tmt.scanners.token_abuse_scanner import TokenAbuseScanner
from tmt.scanners.auth_session_scanner import AuthSessionScanner
from tmt.scanners.api_route_scanner import APIRouteScanner
# ──────────────────────────────────────────────────────────────────────────────
# Shared test configuration and fixture paths
# ──────────────────────────────────────────────────────────────────────────────
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
VULNERABLE_DIR = FIXTURES_DIR
FILE_EXTENSIONS = [".py"]
EXCLUDE_DIRS = ["__pycache__", ".git"]
def _make_config() -> ScannerConfig:
"""Create a default ScannerConfig for test usage.
Returns:
ScannerConfig with default test settings.
"""
return ScannerConfig(enabled=True, severity_threshold="low")
def _run_scanner_on_fixtures(scanner_cls):
"""Instantiate and run a scanner against the test fixtures directory.
Args:
scanner_cls: Scanner class to instantiate and execute.
Returns:
ScanResult from scanning the fixtures directory.
"""
config = _make_config()
scanner = scanner_cls(config, FILE_EXTENSIONS, EXCLUDE_DIRS)
return scanner.scan(FIXTURES_DIR)
# ──────────────────────────────────────────────────────────────────────────────
# Replay scanner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestReplayScanner:
"""Test suite for replay attack vulnerability detection."""
def test_detects_missing_idempotency(self):
"""Verify scanner flags POST endpoints without idempotency keys."""
result = _run_scanner_on_fixtures(ReplayScanner)
replay_findings = [
f for f in result.findings if f.category == FindingCategory.REPLAY_ATTACK
]
assert (
len(replay_findings) > 0
), "Should detect at least one replay vulnerability"
def test_finds_token_reuse(self):
"""Verify scanner flags token verification without invalidation."""
result = _run_scanner_on_fixtures(ReplayScanner)
token_findings = [
f
for f in result.findings
if "Token Used" in f.title or "token" in f.title.lower()
]
assert (
len(token_findings) >= 0
), "Token reuse check should execute without error"
def test_scans_files_successfully(self):
"""Verify scanner processes files and returns valid metadata."""
result = _run_scanner_on_fixtures(ReplayScanner)
assert result.files_scanned > 0
assert result.scan_duration_seconds >= 0
assert result.scanner_name == "ReplayScanner"
# ──────────────────────────────────────────────────────────────────────────────
# Race condition scanner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestRaceConditionScanner:
"""Test suite for race condition vulnerability detection."""
def test_detects_nonatomic_updates(self):
"""Verify scanner flags non-atomic read-modify-write patterns."""
result = _run_scanner_on_fixtures(RaceConditionScanner)
race_findings = [
f for f in result.findings if f.category == FindingCategory.RACE_CONDITION
]
assert (
len(race_findings) > 0
), "Should detect race conditions in vulnerable fixture"
def test_detects_concurrent_redemption(self):
"""Verify scanner flags unguarded redemption operations."""
result = _run_scanner_on_fixtures(RaceConditionScanner)
redeem_findings = [
f
for f in result.findings
if "Redemption" in f.title or "redeem" in f.description.lower()
]
assert (
len(redeem_findings) >= 0
), "Redemption check should execute without error"
def test_findings_have_correct_category(self):
"""Verify all findings are categorized as race conditions."""
result = _run_scanner_on_fixtures(RaceConditionScanner)
for finding in result.findings:
assert finding.category == FindingCategory.RACE_CONDITION
# ──────────────────────────────────────────────────────────────────────────────
# Token abuse scanner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestTokenAbuseScanner:
"""Test suite for token and invite abuse vulnerability detection."""
def test_detects_predictable_tokens(self):
"""Verify scanner flags uuid1 and weak PRNG token generation."""
result = _run_scanner_on_fixtures(TokenAbuseScanner)
predictable = [f for f in result.findings if "Predictable" in f.title]
assert len(predictable) > 0, "Should detect uuid1 as predictable token source"
def test_detects_missing_expiry(self):
"""Verify scanner flags token creation without TTL."""
result = _run_scanner_on_fixtures(TokenAbuseScanner)
no_expiry = [
f
for f in result.findings
if "Expiration" in f.title or "expir" in f.title.lower()
]
assert len(no_expiry) >= 0, "Expiry check should execute without error"
def test_findings_have_cwe_ids(self):
"""Verify all token abuse findings include CWE identifiers."""
result = _run_scanner_on_fixtures(TokenAbuseScanner)
for finding in result.findings:
assert (
finding.cwe_id is not None
), f"Finding '{finding.title}' missing CWE ID"
# ──────────────────────────────────────────────────────────────────────────────
# Auth session scanner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestAuthSessionScanner:
"""Test suite for authentication and session vulnerability detection."""
def test_detects_insecure_session_config(self):
"""Verify scanner flags SESSION_COOKIE_SECURE=False."""
result = _run_scanner_on_fixtures(AuthSessionScanner)
session_findings = [
f for f in result.findings if "Session" in f.title or "Cookie" in f.title
]
assert len(session_findings) > 0, "Should detect insecure session configuration"
def test_detects_weak_password_hash(self):
"""Verify scanner flags MD5/SHA1 password hashing."""
result = _run_scanner_on_fixtures(AuthSessionScanner)
hash_findings = [
f for f in result.findings if "Password" in f.title or "Hash" in f.title
]
assert len(hash_findings) > 0, "Should detect weak password hashing"
def test_detects_missing_auth_decorators(self):
"""Verify scanner flags routes without authentication."""
result = _run_scanner_on_fixtures(AuthSessionScanner)
auth_findings = [f for f in result.findings if "Authentication" in f.title]
assert len(auth_findings) > 0, "Should detect routes missing authentication"
# ──────────────────────────────────────────────────────────────────────────────
# API route scanner tests
# ──────────────────────────────────────────────────────────────────────────────
class TestAPIRouteScanner:
"""Test suite for API route security vulnerability detection."""
def test_detects_insecure_cors(self):
"""Verify scanner flags wildcard CORS configuration."""
result = _run_scanner_on_fixtures(APIRouteScanner)
cors_findings = [f for f in result.findings if "CORS" in f.title]
assert len(cors_findings) > 0, "Should detect wildcard CORS"
def test_detects_verbose_errors(self):
"""Verify scanner flags stack trace exposure in responses."""
result = _run_scanner_on_fixtures(APIRouteScanner)
error_findings = [
f for f in result.findings if "Error" in f.title or "Verbose" in f.title
]
assert len(error_findings) > 0, "Should detect verbose error exposure"
def test_detects_admin_without_role_check(self):
"""Verify scanner flags admin endpoints without authorization."""
result = _run_scanner_on_fixtures(APIRouteScanner)
admin_findings = [
f for f in result.findings if "Admin" in f.title or "admin" in f.title
]
assert len(admin_findings) > 0, "Should detect unprotected admin endpoint"
# ──────────────────────────────────────────────────────────────────────────────
# Cross-scanner integration tests
# ──────────────────────────────────────────────────────────────────────────────
class TestCrossScannerIntegration:
"""Integration tests validating scanner coordination and data quality."""
def test_all_scanners_return_scan_results(self):
"""Verify every scanner returns a valid ScanResult structure."""
scanner_classes = [
ReplayScanner,
RaceConditionScanner,
TokenAbuseScanner,
AuthSessionScanner,
APIRouteScanner,
]
for scanner_cls in scanner_classes:
result = _run_scanner_on_fixtures(scanner_cls)
assert result.scanner_name == scanner_cls.__name__
assert result.files_scanned > 0
def test_findings_have_required_fields(self):
"""Verify all findings across scanners have complete field data."""
scanner_classes = [
ReplayScanner,
RaceConditionScanner,
TokenAbuseScanner,
AuthSessionScanner,
APIRouteScanner,
]
for scanner_cls in scanner_classes:
result = _run_scanner_on_fixtures(scanner_cls)
for finding in result.findings:
assert finding.title, "Finding must have a title"
assert finding.description, "Finding must have a description"
assert finding.file_path, "Finding must have a file path"
assert finding.line_number > 0, "Finding must have a valid line number"
assert finding.recommendation, "Finding must have a recommendation"
def test_secure_fixture_has_fewer_findings(self):
"""Verify secure_api.py produces fewer findings than vulnerable_api.py."""
config = _make_config()
scanner = AuthSessionScanner(config, FILE_EXTENSIONS, EXCLUDE_DIRS)
vuln_path = os.path.join(FIXTURES_DIR, "vulnerable_api.py")
secure_path = os.path.join(FIXTURES_DIR, "secure_api.py")
vuln_content = open(vuln_path).read()
secure_content = open(secure_path).read()
vuln_findings = scanner._scan_single_file(vuln_path, vuln_content)
secure_findings = scanner._scan_single_file(secure_path, secure_content)
assert len(vuln_findings) >= len(
secure_findings
), "Vulnerable fixture should produce at least as many findings as secure fixture"