mirror of
https://github.com/mytechnotalent/Threat-Modeling-Toolkit.git
synced 2026-08-15 04:50:18 +02:00
Initial commit: TMT lightweight threat modeling toolkit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Test suite for the TMT threat modeling toolkit."""
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
"""Test fixtures for TMT scanner validation."""
|
||||
Vendored
+174
@@ -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"})
|
||||
Vendored
+215
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user