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
+8
View File
@@ -0,0 +1,8 @@
"""TMT - Lightweight Threat Modeling Toolkit for Release Cycles.
Provides automated pattern-based scanning and LLM-powered review
for detecting logic bugs, replay attacks, race conditions, and
token/invite abuse in API routes and auth/session logic.
"""
__version__ = "1.0.0"
+242
View File
@@ -0,0 +1,242 @@
"""Configuration management for the TMT threat modeling toolkit.
Loads and validates YAML-based configuration files with environment
variable fallbacks for sensitive values like API keys.
"""
import os
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional
import yaml
logger = logging.getLogger(__name__)
@dataclass
class ScannerConfig:
"""Configuration for pattern-based security scanners.
Attributes:
enabled: Whether pattern-based scanning is active.
severity_threshold: Minimum severity level to report.
custom_patterns: Additional user-defined vulnerability patterns.
"""
enabled: bool = True
severity_threshold: str = "low"
custom_patterns: Dict[str, List[str]] = field(default_factory=dict)
@dataclass
class LLMConfig:
"""Configuration for LLM-powered security review.
Attributes:
enabled: Whether LLM review is active.
provider: LLM provider name (huggingface, openai, or anthropic).
model: Model identifier to use for reviews.
api_key: API key for the LLM provider.
base_url: Optional custom base URL for OpenAI-compatible APIs.
temperature: Sampling temperature for LLM responses.
max_tokens: Maximum tokens for LLM response generation.
timeout_seconds: Request timeout in seconds.
"""
enabled: bool = False
provider: str = "huggingface"
model: str = "Qwen/Qwen2.5-72B-Instruct"
api_key: str = ""
base_url: Optional[str] = None
temperature: float = 0.1
max_tokens: int = 4096
timeout_seconds: int = 120
@dataclass
class ReportConfig:
"""Configuration for report generation output.
Attributes:
output_dir: Directory path for generated reports.
formats: List of output formats to generate.
include_code_snippets: Whether to embed code in reports.
max_snippet_lines: Maximum lines per code snippet.
"""
output_dir: str = "reports"
formats: List[str] = field(default_factory=lambda: ["markdown", "json"])
include_code_snippets: bool = True
max_snippet_lines: int = 10
@dataclass
class TMTConfig:
"""Top-level configuration for the threat modeling toolkit.
Attributes:
project_name: Human-readable project identifier.
target_dirs: Directories to scan for source files.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during scanning.
scanner: Pattern-based scanner configuration.
llm: LLM-powered review configuration.
report: Report generation configuration.
"""
project_name: str = "unnamed-project"
target_dirs: List[str] = field(default_factory=lambda: ["src", "app", "api"])
file_extensions: List[str] = field(default_factory=lambda: [".py", ".js", ".ts"])
exclude_dirs: List[str] = field(
default_factory=lambda: ["node_modules", ".venv", "__pycache__", ".git"]
)
scanner: ScannerConfig = field(default_factory=ScannerConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
report: ReportConfig = field(default_factory=ReportConfig)
def _read_yaml_file(config_path: str) -> dict:
"""Read and parse a YAML configuration file from disk.
Args:
config_path: Absolute or relative path to the YAML file.
Returns:
Parsed dictionary from the YAML file contents.
"""
with open(config_path, "r") as f:
data = yaml.safe_load(f) or {}
logger.info("Loaded configuration from %s", config_path)
return data
def _build_scanner_config(raw: dict) -> ScannerConfig:
"""Build a ScannerConfig from a raw dictionary section.
Args:
raw: Dictionary containing scanner configuration keys.
Returns:
Populated ScannerConfig dataclass instance.
"""
return ScannerConfig(
enabled=raw.get("enabled", True),
severity_threshold=raw.get("severity_threshold", "low"),
custom_patterns=raw.get("custom_patterns", {}),
)
def _build_llm_basics(raw: dict) -> dict:
"""Extract basic LLM fields from raw configuration.
Args:
raw: Dictionary containing LLM configuration keys.
Returns:
Dictionary with provider, model, and auth fields.
"""
api_key = raw.get("api_key", os.environ.get("TMT_LLM_API_KEY", ""))
return {
"enabled": raw.get("enabled", False),
"provider": raw.get("provider", "huggingface"),
"model": raw.get("model", "Qwen/Qwen2.5-72B-Instruct"),
"api_key": api_key,
"base_url": raw.get("base_url"),
}
def _build_llm_tuning(raw: dict) -> dict:
"""Extract tuning parameter fields from raw LLM configuration.
Args:
raw: Dictionary containing LLM tuning keys.
Returns:
Dictionary with temperature, max_tokens, and timeout fields.
"""
return {
"temperature": raw.get("temperature", 0.1),
"max_tokens": raw.get("max_tokens", 4096),
"timeout_seconds": raw.get("timeout_seconds", 120),
}
def _build_llm_config(raw: dict) -> LLMConfig:
"""Build an LLMConfig from a raw dictionary with env var fallbacks.
Args:
raw: Dictionary containing LLM configuration keys.
Returns:
Populated LLMConfig dataclass instance.
"""
basics = _build_llm_basics(raw)
tuning = _build_llm_tuning(raw)
return LLMConfig(**basics, **tuning)
def _build_report_config(raw: dict) -> ReportConfig:
"""Build a ReportConfig from a raw dictionary section.
Args:
raw: Dictionary containing report configuration keys.
Returns:
Populated ReportConfig dataclass instance.
"""
return ReportConfig(
output_dir=raw.get("output_dir", "reports"),
formats=raw.get("formats", ["markdown", "json"]),
include_code_snippets=raw.get("include_code_snippets", True),
max_snippet_lines=raw.get("max_snippet_lines", 10),
)
def _build_tmt_config(data: dict) -> TMTConfig:
"""Build a complete TMTConfig from parsed YAML data.
Args:
data: Root dictionary from the parsed YAML config file.
Returns:
Fully populated TMTConfig dataclass instance.
"""
scanner = _build_scanner_config(data.get("scanner", {}))
llm = _build_llm_config(data.get("llm", {}))
report = _build_report_config(data.get("report", {}))
return TMTConfig(
project_name=data.get("project_name", "unnamed-project"),
target_dirs=data.get("target_dirs", ["src", "app", "api"]),
file_extensions=data.get("file_extensions", [".py", ".js", ".ts"]),
exclude_dirs=data.get(
"exclude_dirs", ["node_modules", ".venv", "__pycache__", ".git"]
),
scanner=scanner,
llm=llm,
report=report,
)
def load_config(config_path: str) -> TMTConfig:
"""Load and parse a TMT configuration file into a typed config object.
Args:
config_path: Path to the YAML configuration file.
Returns:
Fully populated TMTConfig instance ready for use.
"""
data = _read_yaml_file(config_path)
config = _build_tmt_config(data)
logger.info("Configuration built for project: %s", config.project_name)
return config
def default_config() -> TMTConfig:
"""Create a TMTConfig with all default values for quick startup.
Returns:
TMTConfig instance with sensible default values.
"""
return TMTConfig()
+9
View File
@@ -0,0 +1,9 @@
"""LLM-powered security review modules."""
from tmt.llm.prompts import PromptLibrary
from tmt.llm.reviewer import LLMReviewer
__all__ = [
"PromptLibrary",
"LLMReviewer",
]
+237
View File
@@ -0,0 +1,237 @@
"""Structured prompt templates for LLM-powered security reviews.
Provides battle-tested prompt templates for reviewing API routes,
authentication/session logic, and business logic for replay attacks,
race conditions, and token abuse. Each prompt enforces structured
JSON output to minimize noise and maximize actionable findings.
"""
from typing import Dict
# ──────────────────────────────────────────────────────────────────────────────
# System persona prompt shared across all review types
# ──────────────────────────────────────────────────────────────────────────────
SYSTEM_PERSONA = (
"You are a senior application security engineer performing a focused "
"code review. You specialize in finding logic bugs, authentication "
"bypasses, race conditions, and business logic flaws. You only report "
"findings you are confident about (>70 percent confidence) with concrete "
"evidence from the code provided. You never report theoretical "
"vulnerabilities without specific code references."
)
# ──────────────────────────────────────────────────────────────────────────────
# JSON output schema enforced in all prompts
# ──────────────────────────────────────────────────────────────────────────────
OUTPUT_SCHEMA = """
Respond ONLY with a JSON array of findings. Each finding must follow this exact schema:
{
"title": "Short descriptive title",
"description": "Detailed explanation with specific code references",
"severity": "critical|high|medium|low|info",
"category": "replay_attack|race_condition|token_abuse|auth_session|api_route",
"line_number": <approximate line number in the provided code>,
"recommendation": "Specific actionable fix with code example if possible",
"confidence": <float between 0.0 and 1.0>,
"cwe_id": "CWE-XXX"
}
If you find NO issues, return an empty array: []
Do NOT wrap the JSON in markdown code blocks. Return raw JSON only.
"""
# ──────────────────────────────────────────────────────────────────────────────
# API route review prompt
# ──────────────────────────────────────────────────────────────────────────────
API_ROUTE_REVIEW_PROMPT = """Review the following API route code for security vulnerabilities.
FOCUS AREAS (check each one systematically):
1. **Authentication**: Is every non-public endpoint protected with auth middleware/decorators?
2. **Authorization**: Are object-level permissions checked before returning data (IDOR)?
3. **Input Validation**: Is all user input validated with schemas/types before use?
4. **Rate Limiting**: Are sensitive endpoints (login, signup, token generation) rate-limited?
5. **Mass Assignment**: Is raw request data spread into database models without field filtering?
6. **Error Handling**: Are internal details (stack traces, DB errors) leaked in responses?
7. **CORS**: Is Access-Control-Allow-Origin overly permissive (wildcard with credentials)?
8. **SQL/NoSQL Injection**: Are queries parameterized or using ORM safely?
CODE TO REVIEW:
```
{code}
```
{output_schema}
"""
# ──────────────────────────────────────────────────────────────────────────────
# Auth and session logic review prompt
# ──────────────────────────────────────────────────────────────────────────────
AUTH_SESSION_REVIEW_PROMPT = """Review the following authentication and session management code for security vulnerabilities.
FOCUS AREAS (check each one systematically):
1. **Password Storage**: Are passwords hashed with bcrypt/argon2/scrypt (not MD5/SHA1/SHA256)?
2. **Session Fixation**: Is the session ID regenerated after successful login?
3. **Token Handling**: Are JWTs validated properly (signature, expiration, issuer, audience)?
4. **Cookie Security**: Are session cookies set with Secure, HttpOnly, SameSite flags?
5. **Brute Force**: Is there account lockout or progressive delays after failed login attempts?
6. **Privilege Escalation**: Can users modify their own role/permission fields?
7. **Logout**: Does logout actually invalidate the session/token server-side?
8. **MFA Bypass**: Can MFA verification be skipped by manipulating request flow?
9. **Password Reset**: Are reset tokens single-use, time-limited, and securely generated?
10. **OAuth/SSO**: Are redirect URIs validated strictly (no open redirects)?
CODE TO REVIEW:
```
{code}
```
{output_schema}
"""
# ──────────────────────────────────────────────────────────────────────────────
# Logic bug review prompt (replay, race, token abuse)
# ──────────────────────────────────────────────────────────────────────────────
LOGIC_BUG_REVIEW_PROMPT = """Review the following code for logic bugs related to replay attacks, race conditions, and token/invite abuse.
FOCUS AREAS (check each one systematically):
1. **Replay Attack**: Can captured requests be re-submitted? Are there idempotency keys or nonces?
2. **Race Condition - Read/Modify/Write**: Are balance changes, counter increments, or stock decrements atomic?
3. **Race Condition - TOCTOU**: Is there a gap between checking permissions/existence and acting on it?
4. **Race Condition - Double Spend**: Can a token/coupon/credit be redeemed concurrently before being marked as used?
5. **Token Reuse**: Are one-time tokens (reset, verify, invite) invalidated after successful use?
6. **Invite Abuse**: Can invite links be shared and reused by multiple users?
7. **State Machine Violations**: Can operations be performed out of expected order?
8. **Enumeration**: Can sequential/predictable IDs be enumerated to discover resources?
THINK STEP BY STEP about request concurrency and timing. Consider what happens when:
- The same request arrives twice within 1ms
- Two users click the same invite link simultaneously
- A token is used in two concurrent requests before the DB marks it as consumed
CODE TO REVIEW:
```
{code}
```
{output_schema}
"""
# ──────────────────────────────────────────────────────────────────────────────
# Comprehensive single-pass review prompt
# ──────────────────────────────────────────────────────────────────────────────
COMPREHENSIVE_REVIEW_PROMPT = """Perform a comprehensive security review of the following code, covering all threat categories.
THREAT CATEGORIES TO CHECK:
**A. Replay Attacks**
- Missing idempotency keys on mutating endpoints
- Tokens verifiable but not invalidated after use
- No request timestamp/nonce validation
**B. Race Conditions**
- Non-atomic read-modify-write (balance, inventory, counters)
- TOCTOU gaps between check and action
- Concurrent token/coupon/invite redemption without locks
- Shared mutable state without synchronization
**C. Token & Invite Abuse**
- Token generation without rate limiting
- Predictable token generation (weak PRNG, UUID1, timestamp-based)
- Tokens without expiration
- Invite tokens usable multiple times
- Missing token revocation on logout
**D. Auth & Session**
- Missing authentication on endpoints
- Missing authorization/ownership checks (IDOR)
- Weak password hashing
- Session fixation (no regeneration after login)
- Insecure cookie settings
**E. API Security**
- Missing input validation/sanitization
- Verbose error messages leaking internals
- Overly permissive CORS
- Mass assignment via raw request data spreading
CODE TO REVIEW:
```
{code}
```
{output_schema}
"""
class PromptLibrary:
"""Registry of security review prompt templates for LLM-powered analysis.
Provides access to specialized and comprehensive prompt templates
with consistent formatting, output schema enforcement, and
systematic checklist-based review instructions.
"""
def __init__(self):
"""Initialize the prompt library with all available templates."""
self.system_persona = SYSTEM_PERSONA
self.output_schema = OUTPUT_SCHEMA
self._templates = {
"api_route": API_ROUTE_REVIEW_PROMPT,
"auth_session": AUTH_SESSION_REVIEW_PROMPT,
"logic_bug": LOGIC_BUG_REVIEW_PROMPT,
"comprehensive": COMPREHENSIVE_REVIEW_PROMPT,
}
def get_template_names(self) -> list:
"""Return a list of all available prompt template names.
Returns:
List of string template identifiers.
"""
return list(self._templates.keys())
def _format_template(self, template: str, code: str) -> str:
"""Inject code and output schema into a prompt template.
Args:
template: Raw prompt template string with placeholders.
code: Source code to embed in the prompt.
Returns:
Fully formatted prompt string ready to send to an LLM.
"""
return template.format(code=code, output_schema=self.output_schema)
def build_prompt(self, template_name: str, code: str) -> Dict[str, str]:
"""Build a complete system + user prompt pair for LLM submission.
Args:
template_name: Name of the template to use from the registry.
code: Source code to include in the review prompt.
Returns:
Dictionary with 'system' and 'user' keys containing prompt text.
Raises:
KeyError: If template_name is not found in the registry.
"""
template = self._templates[template_name]
user_prompt = self._format_template(template, code)
return {"system": self.system_persona, "user": user_prompt}
def build_all_prompts(self, code: str) -> Dict[str, Dict[str, str]]:
"""Build prompt pairs for every template in the library.
Args:
code: Source code to include in all review prompts.
Returns:
Dictionary mapping template names to system/user prompt pairs.
"""
return {name: self.build_prompt(name, code) for name in self._templates}
+395
View File
@@ -0,0 +1,395 @@
"""LLM-powered security reviewer with multi-provider support.
Integrates with Hugging Face, OpenAI, and Anthropic APIs to perform
deep security reviews of source code using structured prompts. Parses
JSON responses into Finding objects and aggregates results into
LLMReview containers.
"""
import json
import logging
import os
import time
from typing import Dict, List, Optional
from tmt.config import LLMConfig
from tmt.llm.prompts import PromptLibrary
from tmt.models import (
Finding,
FindingCategory,
LLMReview,
Severity,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────────────────────
# Severity and category mapping from LLM string output to enums
# ──────────────────────────────────────────────────────────────────────────────
SEVERITY_MAP = {
"critical": Severity.CRITICAL,
"high": Severity.HIGH,
"medium": Severity.MEDIUM,
"low": Severity.LOW,
"info": Severity.INFO,
}
CATEGORY_MAP = {
"replay_attack": FindingCategory.REPLAY_ATTACK,
"race_condition": FindingCategory.RACE_CONDITION,
"token_abuse": FindingCategory.TOKEN_ABUSE,
"auth_session": FindingCategory.AUTH_SESSION,
"api_route": FindingCategory.API_ROUTE,
"llm_review": FindingCategory.LLM_REVIEW,
}
def _call_openai(config: LLMConfig, system: str, user: str) -> Dict:
"""Send a review prompt to the OpenAI API and return the response.
Args:
config: LLM configuration with API key and model settings.
system: System persona message content.
user: User prompt message content.
Returns:
Dictionary with 'content', 'prompt_tokens', and 'completion_tokens'.
"""
from openai import OpenAI
client_kwargs = {"api_key": config.api_key, "timeout": config.timeout_seconds}
if config.base_url:
client_kwargs["base_url"] = config.base_url
client = OpenAI(**client_kwargs)
response = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=config.temperature,
max_tokens=config.max_tokens,
)
return {
"content": response.choices[0].message.content,
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
}
def _resolve_hf_api_key(config: LLMConfig) -> str:
"""Resolve the Hugging Face API key from config or environment.
Args:
config: LLM configuration that may contain an explicit api_key.
Returns:
API key string from config, HF_TOKEN env var, or TMT_LLM_API_KEY.
"""
if config.api_key:
return config.api_key
return os.environ.get("HF_TOKEN", os.environ.get("TMT_LLM_API_KEY", ""))
def _call_huggingface(config: LLMConfig, system: str, user: str) -> Dict:
"""Send a review prompt to the Hugging Face Inference API.
Uses the OpenAI-compatible chat completions endpoint provided by
Hugging Face's free serverless Inference API. Supports all models
available on the HF Hub with the Inference API enabled.
Args:
config: LLM configuration with model and optional api_key.
system: System persona message content.
user: User prompt message content.
Returns:
Dictionary with 'content', 'prompt_tokens', and 'completion_tokens'.
"""
from huggingface_hub import InferenceClient
api_key = _resolve_hf_api_key(config)
client = InferenceClient(api_key=api_key or None, timeout=config.timeout_seconds)
response = client.chat.completions.create(
model=config.model,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
temperature=config.temperature,
max_tokens=config.max_tokens,
)
return {
"content": response.choices[0].message.content,
"prompt_tokens": response.usage.prompt_tokens if response.usage else 0,
"completion_tokens": response.usage.completion_tokens if response.usage else 0,
}
def _call_anthropic(config: LLMConfig, system: str, user: str) -> Dict:
"""Send a review prompt to the Anthropic API and return the response.
Args:
config: LLM configuration with API key and model settings.
system: System persona message content.
user: User prompt message content.
Returns:
Dictionary with 'content', 'prompt_tokens', and 'completion_tokens'.
"""
from anthropic import Anthropic
client = Anthropic(api_key=config.api_key, timeout=config.timeout_seconds)
response = client.messages.create(
model=config.model,
max_tokens=config.max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
temperature=config.temperature,
)
return {
"content": response.content[0].text,
"prompt_tokens": response.usage.input_tokens,
"completion_tokens": response.usage.output_tokens,
}
def _select_provider_call(provider: str):
"""Select the appropriate API call function for the configured provider.
Args:
provider: LLM provider name ('huggingface', 'openai', or 'anthropic').
Returns:
Callable that sends prompts to the selected provider API.
Raises:
ValueError: If the provider is not supported.
"""
providers = {
"huggingface": _call_huggingface,
"openai": _call_openai,
"anthropic": _call_anthropic,
}
if provider not in providers:
raise ValueError(f"Unsupported LLM provider: {provider}")
return providers[provider]
def _strip_markdown_fences(text: str) -> str:
"""Remove markdown code fences from LLM response text.
Args:
text: Raw LLM response that may contain code fence markers.
Returns:
Cleaned text with markdown fences stripped.
"""
text = text.strip()
if text.startswith("```json"):
text = text[7:]
if text.startswith("```"):
text = text[3:]
if text.endswith("```"):
text = text[:-3]
return text.strip()
def _parse_severity(raw_severity: str) -> Severity:
"""Convert a raw severity string to a Severity enum value.
Args:
raw_severity: Severity string from LLM JSON output.
Returns:
Corresponding Severity enum value, defaulting to MEDIUM.
"""
return SEVERITY_MAP.get(raw_severity.lower(), Severity.MEDIUM)
def _parse_category(raw_category: str) -> FindingCategory:
"""Convert a raw category string to a FindingCategory enum value.
Args:
raw_category: Category string from LLM JSON output.
Returns:
Corresponding FindingCategory enum value, defaulting to LLM_REVIEW.
"""
return CATEGORY_MAP.get(raw_category.lower(), FindingCategory.LLM_REVIEW)
def _parse_single_finding(item: dict, file_path: str) -> Finding:
"""Parse a single finding dictionary from LLM output into a Finding object.
Args:
item: Dictionary containing finding fields from LLM JSON response.
file_path: Source file path the finding relates to.
Returns:
Populated Finding dataclass instance.
"""
return Finding(
title=item.get("title", "LLM Finding"),
description=item.get("description", ""),
severity=_parse_severity(item.get("severity", "medium")),
category=_parse_category(item.get("category", "llm_review")),
file_path=file_path,
line_number=item.get("line_number", 0),
code_snippet="",
recommendation=item.get("recommendation", ""),
confidence=float(item.get("confidence", 0.7)),
cwe_id=item.get("cwe_id"),
)
def _parse_findings_json(raw_text: str, file_path: str) -> List[Finding]:
"""Parse LLM JSON response text into a list of Finding objects.
Args:
raw_text: Raw JSON text from the LLM response.
file_path: Source file path the findings relate to.
Returns:
List of parsed Finding objects, empty list on parse failure.
"""
try:
cleaned = _strip_markdown_fences(raw_text)
items = json.loads(cleaned)
if not isinstance(items, list):
items = [items]
return [_parse_single_finding(item, file_path) for item in items]
except (json.JSONDecodeError, TypeError, KeyError) as exc:
logger.warning("Failed to parse LLM response as JSON: %s", exc)
return []
class LLMReviewer:
"""Orchestrates LLM-powered security reviews of source code files.
Manages prompt construction, API communication, response parsing,
and finding assembly for OpenAI and Anthropic providers.
"""
def __init__(self, config: LLMConfig):
"""Initialize the LLM reviewer with provider configuration.
Args:
config: LLM configuration controlling provider, model, and limits.
"""
self.config = config
self.prompt_library = PromptLibrary()
self._call_fn = _select_provider_call(config.provider)
def _send_review_request(self, system: str, user: str) -> Dict:
"""Send a prompt pair to the configured LLM provider.
Args:
system: System persona prompt text.
user: User review prompt text with code.
Returns:
Provider response dictionary with content and token counts.
"""
logger.info(
"Sending review request to %s/%s", self.config.provider, self.config.model
)
return self._call_fn(self.config, system, user)
def _build_review_result(
self, response: Dict, file_path: str, template_name: str
) -> LLMReview:
"""Assemble an LLMReview from a provider response and parsed findings.
Args:
response: Provider response with content and token usage.
file_path: Source file that was reviewed.
template_name: Name of the prompt template used.
Returns:
Populated LLMReview with parsed findings.
"""
findings = _parse_findings_json(response["content"], file_path)
return LLMReview(
reviewer_name=f"llm_{template_name}",
model_used=self.config.model,
prompt_tokens=response.get("prompt_tokens", 0),
completion_tokens=response.get("completion_tokens", 0),
findings=findings,
raw_response=response["content"],
)
def review_file(
self, file_path: str, code: str, template_name: str = "comprehensive"
) -> LLMReview:
"""Review a single source file using a specified prompt template.
Args:
file_path: Path to the source file being reviewed.
code: Full source code content of the file.
template_name: Prompt template to use for the review.
Returns:
LLMReview containing all findings from the review.
"""
prompts = self.prompt_library.build_prompt(template_name, code)
response = self._send_review_request(prompts["system"], prompts["user"])
review = self._build_review_result(response, file_path, template_name)
logger.info("Review of %s found %d findings", file_path, len(review.findings))
return review
def _read_file_safe(self, file_path: str) -> Optional[str]:
"""Read a file with graceful error handling for LLM review.
Args:
file_path: Absolute path to the file to read.
Returns:
File contents as string, or None on read failure.
"""
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
except OSError as exc:
logger.warning("Could not read %s for LLM review: %s", file_path, exc)
return None
def _review_single_file(
self, file_path: str, template_name: str
) -> Optional[LLMReview]:
"""Read and review a single file, handling errors gracefully.
Args:
file_path: Path to the source file to review.
template_name: Prompt template name to use.
Returns:
LLMReview if successful, None if file could not be read.
"""
code = self._read_file_safe(file_path)
if not code:
return None
return self.review_file(file_path, code, template_name)
def review_files(
self, file_paths: List[str], template_name: str = "comprehensive"
) -> List[LLMReview]:
"""Review multiple files sequentially with the specified template.
Args:
file_paths: List of source file paths to review.
template_name: Prompt template to use for all reviews.
Returns:
List of LLMReview objects, one per successfully reviewed file.
"""
reviews = []
for file_path in file_paths:
review = self._review_single_file(file_path, template_name)
if review:
reviews.append(review)
logger.info(
"Completed LLM review of %d/%d files", len(reviews), len(file_paths)
)
return reviews
+197
View File
@@ -0,0 +1,197 @@
"""Data models for threat modeling findings, scan results, and reports.
Provides dataclass-based models for representing security findings,
scan results from pattern-based scanners, LLM review outputs, and
aggregated threat model reports.
"""
import datetime
from dataclasses import dataclass, field
from enum import Enum
from typing import List, Optional
class Severity(Enum):
"""Enumeration of finding severity levels aligned with CVSS qualitative ratings."""
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFO = "info"
class FindingCategory(Enum):
"""Enumeration of threat finding categories tracked by the toolkit."""
REPLAY_ATTACK = "replay_attack"
RACE_CONDITION = "race_condition"
TOKEN_ABUSE = "token_abuse"
AUTH_SESSION = "auth_session"
API_ROUTE = "api_route"
LLM_REVIEW = "llm_review"
SEVERITY_RANK = {
Severity.CRITICAL: 5,
Severity.HIGH: 4,
Severity.MEDIUM: 3,
Severity.LOW: 2,
Severity.INFO: 1,
}
"""Numeric ranking for severity comparison and sorting."""
@dataclass
class Finding:
"""Represents a single security finding from a scan or LLM review.
Attributes:
title: Short descriptive title of the finding.
description: Detailed explanation of the vulnerability.
severity: Severity level of the finding.
category: Category classification of the finding.
file_path: Path to the affected source file.
line_number: Line number where the issue was detected.
code_snippet: Relevant code excerpt surrounding the finding.
recommendation: Actionable remediation guidance.
confidence: Confidence score between 0.0 and 1.0.
cwe_id: Optional CWE identifier for the vulnerability class.
"""
title: str
description: str
severity: Severity
category: FindingCategory
file_path: str
line_number: int
code_snippet: str
recommendation: str
confidence: float = 0.8
cwe_id: Optional[str] = None
def _utc_now_iso() -> str:
"""Return the current UTC time as an ISO 8601 formatted string.
Returns:
ISO 8601 timestamp string with UTC timezone.
"""
return datetime.datetime.now(datetime.timezone.utc).isoformat()
@dataclass
class ScanResult:
"""Container for results produced by a single scanner execution.
Attributes:
scanner_name: Identifier of the scanner that produced results.
findings: List of security findings detected.
files_scanned: Number of files analyzed during the scan.
scan_duration_seconds: Wall-clock time taken for the scan.
timestamp: ISO 8601 timestamp of when the scan completed.
"""
scanner_name: str
findings: List[Finding] = field(default_factory=list)
files_scanned: int = 0
scan_duration_seconds: float = 0.0
timestamp: str = field(default_factory=_utc_now_iso)
@dataclass
class LLMReview:
"""Container for results from an LLM-powered security review.
Attributes:
reviewer_name: Identifier of the review workflow used.
model_used: LLM model identifier used for the review.
prompt_tokens: Number of input tokens consumed.
completion_tokens: Number of output tokens generated.
findings: List of security findings from the review.
raw_response: Unprocessed LLM response text.
timestamp: ISO 8601 timestamp of when the review completed.
"""
reviewer_name: str
model_used: str
prompt_tokens: int = 0
completion_tokens: int = 0
findings: List[Finding] = field(default_factory=list)
raw_response: str = ""
timestamp: str = field(default_factory=_utc_now_iso)
@dataclass
class ThreatModelReport:
"""Complete threat model report aggregating all scan and review results.
Attributes:
project_name: Name of the project being assessed.
scan_results: Results from all pattern-based scanners.
llm_reviews: Results from all LLM-powered reviews.
total_findings: Total count of all findings across sources.
critical_count: Number of critical severity findings.
high_count: Number of high severity findings.
medium_count: Number of medium severity findings.
low_count: Number of low severity findings.
info_count: Number of informational findings.
timestamp: ISO 8601 timestamp of report generation.
"""
project_name: str = ""
scan_results: List[ScanResult] = field(default_factory=list)
llm_reviews: List[LLMReview] = field(default_factory=list)
total_findings: int = 0
critical_count: int = 0
high_count: int = 0
medium_count: int = 0
low_count: int = 0
info_count: int = 0
timestamp: str = field(default_factory=_utc_now_iso)
def _count_by_severity(findings: List[Finding], severity: Severity) -> int:
"""Count findings matching a specific severity level.
Args:
findings: List of Finding objects to count.
severity: Target severity level to match.
Returns:
Integer count of findings with the specified severity.
"""
return sum(1 for f in findings if f.severity == severity)
def _gather_all_findings(report: ThreatModelReport) -> List[Finding]:
"""Collect all findings from scan results and LLM reviews into one list.
Args:
report: ThreatModelReport containing scan results and LLM reviews.
Returns:
Flat list of all Finding objects from every source.
"""
scan_findings = [f for sr in report.scan_results for f in sr.findings]
llm_findings = [f for lr in report.llm_reviews for f in lr.findings]
return scan_findings + llm_findings
def compute_report_statistics(report: ThreatModelReport) -> ThreatModelReport:
"""Compute and populate severity counts on a ThreatModelReport.
Args:
report: ThreatModelReport to update with computed statistics.
Returns:
The same ThreatModelReport with updated count fields.
"""
all_findings = _gather_all_findings(report)
report.total_findings = len(all_findings)
report.critical_count = _count_by_severity(all_findings, Severity.CRITICAL)
report.high_count = _count_by_severity(all_findings, Severity.HIGH)
report.medium_count = _count_by_severity(all_findings, Severity.MEDIUM)
report.low_count = _count_by_severity(all_findings, Severity.LOW)
report.info_count = _count_by_severity(all_findings, Severity.INFO)
return report
+233
View File
@@ -0,0 +1,233 @@
"""Threat model runner orchestrating the full scan-review-report loop.
Coordinates pattern-based scanners, optional LLM-powered reviews,
and report generation into a single repeatable workflow that teams
can execute each release cycle.
"""
import logging
import os
import time
from typing import List, Optional
from tmt.config import TMTConfig
from tmt.llm.reviewer import LLMReviewer
from tmt.models import LLMReview, ScanResult, ThreatModelReport
from tmt.reports.generator import ReportGenerator
from tmt.scanners.api_route_scanner import APIRouteScanner
from tmt.scanners.auth_session_scanner import AuthSessionScanner
from tmt.scanners.base_scanner import BaseScanner
from tmt.scanners.race_condition_scanner import RaceConditionScanner
from tmt.scanners.replay_scanner import ReplayScanner
from tmt.scanners.token_abuse_scanner import TokenAbuseScanner
logger = logging.getLogger(__name__)
def _create_scanner(scanner_cls, config: TMTConfig) -> BaseScanner:
"""Instantiate a scanner with shared configuration parameters.
Args:
scanner_cls: Scanner class to instantiate.
config: Top-level TMT configuration.
Returns:
Initialized scanner instance.
"""
return scanner_cls(
config=config.scanner,
file_extensions=config.file_extensions,
exclude_dirs=config.exclude_dirs,
)
def _build_all_scanners(config: TMTConfig) -> List[BaseScanner]:
"""Build the complete set of pattern-based scanners.
Args:
config: Top-level TMT configuration.
Returns:
List of initialized scanner instances ordered by category.
"""
scanner_classes = [
ReplayScanner,
RaceConditionScanner,
TokenAbuseScanner,
AuthSessionScanner,
APIRouteScanner,
]
return [_create_scanner(cls, config) for cls in scanner_classes]
def _run_all_scanners(
scanners: List[BaseScanner], target_path: str
) -> List[ScanResult]:
"""Execute all scanners against a target directory.
Args:
scanners: List of initialized scanner instances.
target_path: Root directory path to scan.
Returns:
List of ScanResult objects from all scanners.
"""
results = []
for scanner in scanners:
logger.info("Running %s...", scanner.scanner_name)
result = scanner.scan(target_path)
results.append(result)
return results
def _collect_llm_target_files(config: TMTConfig, target_path: str) -> List[str]:
"""Collect files for LLM review using the first scanner's file collection.
Args:
config: Top-level TMT configuration.
target_path: Root directory path to collect files from.
Returns:
List of file paths suitable for LLM review.
"""
collector = _create_scanner(ReplayScanner, config)
return collector._collect_files(target_path)
def _run_llm_reviews(config: TMTConfig, target_path: str) -> List[LLMReview]:
"""Execute LLM-powered reviews against target files.
Args:
config: Top-level TMT configuration with LLM settings.
target_path: Root directory path containing files to review.
Returns:
List of LLMReview objects from completed reviews.
"""
reviewer = LLMReviewer(config.llm)
file_paths = _collect_llm_target_files(config, target_path)
logger.info("Submitting %d files for LLM review", len(file_paths))
return reviewer.review_files(file_paths)
def _build_report(
config: TMTConfig, scan_results: List[ScanResult], llm_reviews: List[LLMReview]
) -> ThreatModelReport:
"""Assemble a ThreatModelReport from scan results and LLM reviews.
Args:
config: Top-level TMT configuration.
scan_results: Results from pattern-based scanners.
llm_reviews: Results from LLM-powered reviews.
Returns:
Assembled ThreatModelReport ready for rendering.
"""
return ThreatModelReport(
project_name=config.project_name,
scan_results=scan_results,
llm_reviews=llm_reviews,
)
class ThreatModelRunner:
"""Orchestrates the complete threat modeling loop for a release cycle.
Manages the lifecycle of initializing scanners, executing pattern-based
scans, optionally running LLM-powered reviews, and generating reports.
"""
def __init__(self, config: TMTConfig):
"""Initialize the runner with project configuration.
Args:
config: Top-level TMT configuration for all components.
"""
self.config = config
self.scanners = _build_all_scanners(config)
self.report_generator = ReportGenerator(config.report)
def _resolve_target_path(self, target_path: Optional[str]) -> str:
"""Resolve the target scan directory from explicit path or config.
Args:
target_path: Optional explicit target directory override.
Returns:
Absolute path to the target directory for scanning.
"""
if target_path:
return os.path.abspath(target_path)
return os.path.abspath(".")
def _execute_scans(self, target_path: str) -> List[ScanResult]:
"""Run all pattern-based scanners if scanning is enabled.
Args:
target_path: Resolved absolute path to scan.
Returns:
List of ScanResult objects, empty if scanning disabled.
"""
if not self.config.scanner.enabled:
logger.info("Pattern scanning disabled, skipping")
return []
return _run_all_scanners(self.scanners, target_path)
def _execute_llm_reviews(self, target_path: str) -> List[LLMReview]:
"""Run LLM-powered reviews if LLM integration is enabled.
Args:
target_path: Resolved absolute path to review.
Returns:
List of LLMReview objects, empty if LLM disabled.
"""
if not self.config.llm.enabled:
logger.info("LLM review disabled, skipping")
return []
return _run_llm_reviews(self.config, target_path)
def _log_completion_summary(
self, report: ThreatModelReport, elapsed: float
) -> None:
"""Log a summary of the completed threat model run.
Args:
report: Completed report with computed statistics.
elapsed: Total wall-clock time in seconds.
"""
logger.info(
"Threat model complete in %.1fs: %d findings "
"(%d critical, %d high, %d medium, %d low, %d info)",
elapsed,
report.total_findings,
report.critical_count,
report.high_count,
report.medium_count,
report.low_count,
report.info_count,
)
def run(self, target_path: Optional[str] = None) -> ThreatModelReport:
"""Execute the complete threat modeling loop and generate reports.
Args:
target_path: Optional directory to scan. Defaults to current directory.
Returns:
Completed ThreatModelReport with all findings and statistics.
"""
start_time = time.time()
resolved_path = self._resolve_target_path(target_path)
logger.info(
"Starting threat model for '%s' at %s",
self.config.project_name,
resolved_path,
)
scan_results = self._execute_scans(resolved_path)
llm_reviews = self._execute_llm_reviews(resolved_path)
report = _build_report(self.config, scan_results, llm_reviews)
output_paths = self.report_generator.generate(report)
self._log_completion_summary(report, time.time() - start_time)
return report
+17
View File
@@ -0,0 +1,17 @@
"""Pattern-based security scanners for threat modeling."""
from tmt.scanners.base_scanner import BaseScanner
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
__all__ = [
"BaseScanner",
"ReplayScanner",
"RaceConditionScanner",
"TokenAbuseScanner",
"AuthSessionScanner",
"APIRouteScanner",
]
+192
View File
@@ -0,0 +1,192 @@
"""Scanner for detecting API route security vulnerabilities.
Identifies missing input validation, absent rate limiting, verbose
error exposure, insecure CORS configuration, unprotected admin
endpoints, and mass assignment risks in API route handlers.
"""
from typing import List
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.base_scanner import BaseScanner, VulnerabilityPattern
# ──────────────────────────────────────────────────────────────────────
# Missing input validation
# ──────────────────────────────────────────────────────────────────────
MISSING_INPUT_VALIDATION = VulnerabilityPattern(
name="Endpoint Missing Input Validation",
trigger_pattern=r"(request\.(json|form|data|body|args|params|query)\s*(\[|\.get))",
defense_pattern=r"(validate|schema|serializer|pydantic|marshmallow|cerberus|joi\.|yup\.|zod\.|express-validator|class-validator)",
context_window=15,
description=(
"Request data is accessed directly without visible schema validation. "
"Missing input validation can lead to injection attacks, type confusion, "
"and unexpected application behavior from malformed data."
),
severity=Severity.MEDIUM,
category=FindingCategory.API_ROUTE,
recommendation=(
"Validate all input using a schema library: Pydantic or Marshmallow "
"for Python, Joi or Zod for JavaScript. Define strict schemas with "
"type constraints, length limits, and allowed value ranges."
),
cwe_id="CWE-20",
confidence=0.6,
)
# ──────────────────────────────────────────────────────────────────────
# Missing rate limiting
# ──────────────────────────────────────────────────────────────────────
MISSING_RATE_LIMIT = VulnerabilityPattern(
name="Endpoint Missing Rate Limiting",
trigger_pattern=r"@(app|router|blueprint)\.(post|put|patch|delete)\s*\(",
defense_pattern=r"(rate_limit|throttle|RateLimit|slowapi|ratelimit|limiter|express-rate-limit|bottleneck)",
context_window=10,
description=(
"Mutating endpoint has no rate limiting. Without rate limiting, "
"attackers can brute-force credentials, exhaust resources, or "
"abuse business logic at scale."
),
severity=Severity.MEDIUM,
category=FindingCategory.API_ROUTE,
recommendation=(
"Apply rate limiting to all endpoints, with stricter limits on "
"authentication and resource-creation routes. Use per-user and "
"per-IP limits with a sliding window algorithm."
),
cwe_id="CWE-770",
confidence=0.5,
)
# ──────────────────────────────────────────────────────────────────────
# Verbose error exposure
# ──────────────────────────────────────────────────────────────────────
VERBOSE_ERROR_EXPOSURE = VulnerabilityPattern(
name="Verbose Error Details Exposed in Response",
trigger_pattern=r"(traceback\.|str\(e\)|str\(err\)|exc_info|stack.*trace|error.*message.*str\(|\.message\s*\})",
defense_pattern=r"(if\s+.*DEBUG|production|sanitize.*error|generic.*error|log.*error.*return|sentry|logging\.exception)",
context_window=8,
description=(
"Exception details or stack traces may be returned to API clients. "
"Verbose errors leak implementation details, library versions, file "
"paths, and database structure to attackers."
),
severity=Severity.MEDIUM,
category=FindingCategory.API_ROUTE,
recommendation=(
"Return generic error messages to clients and log full details "
"server-side. Use a global error handler that returns sanitized "
"responses with error codes rather than internal messages."
),
cwe_id="CWE-209",
confidence=0.7,
)
# ──────────────────────────────────────────────────────────────────────
# Insecure CORS configuration
# ──────────────────────────────────────────────────────────────────────
INSECURE_CORS = VulnerabilityPattern(
name="Overly Permissive CORS Configuration",
trigger_pattern=r"""(origins?\s*=\s*['"]\*['"]|Access-Control-Allow-Origin.*\*|allow_origins\s*=\s*\[['"]?\*['"]?\])""",
defense_pattern=None,
context_window=5,
description=(
"CORS is configured to allow all origins with a wildcard. This "
"permits any website to make authenticated cross-origin requests "
"to the API when credentials are included."
),
severity=Severity.HIGH,
category=FindingCategory.API_ROUTE,
recommendation=(
"Specify an explicit allowlist of trusted origins. Never combine "
"wildcard origins with allow_credentials=True. Use environment-based "
"configuration to set different origins per deployment."
),
cwe_id="CWE-942",
confidence=0.9,
)
# ──────────────────────────────────────────────────────────────────────
# Unprotected admin endpoints
# ──────────────────────────────────────────────────────────────────────
UNPROTECTED_ADMIN = VulnerabilityPattern(
name="Admin Endpoint Without Role Check",
trigger_pattern=r"""(admin|superuser|staff|management|internal)[/'"]""",
defense_pattern=r"(is_admin|is_superuser|is_staff|role.*admin|admin_required|@admin|permission_classes|has_role|authorize.*admin)",
context_window=10,
description=(
"An endpoint with an admin-related path does not have visible "
"role-based authorization checks. Access to admin functionality "
"without proper role verification is a privilege escalation risk."
),
severity=Severity.CRITICAL,
category=FindingCategory.API_ROUTE,
recommendation=(
"Enforce role-based access control on all admin endpoints. Use "
"decorators or middleware that verify the user has an admin role "
"before executing handler logic. Apply defense in depth."
),
cwe_id="CWE-269",
confidence=0.7,
)
# ──────────────────────────────────────────────────────────────────────
# Mass assignment
# ──────────────────────────────────────────────────────────────────────
MASS_ASSIGNMENT = VulnerabilityPattern(
name="Potential Mass Assignment Vulnerability",
trigger_pattern=r"(\*\*request\.(json|data|form|body)|\*\*req\.body|\.update\s*\(\s*request\.(json|data)|\.create\s*\(\s*\*\*)",
defense_pattern=r"(schema|serializer|allow(ed)?_fields|pick\s*\(|whitelist|only\s*=|exclude\s*=|fields\s*=)",
context_window=10,
description=(
"User input is spread directly into a model create or update call. "
"An attacker can inject unexpected fields like is_admin=True or "
"role=superuser to escalate their privileges."
),
severity=Severity.CRITICAL,
category=FindingCategory.API_ROUTE,
recommendation=(
"Never spread raw request data into models. Use a schema or "
"serializer to explicitly define which fields are accepted. "
"Reject unknown fields and validate types strictly."
),
cwe_id="CWE-915",
confidence=0.8,
)
class APIRouteScanner(BaseScanner):
"""Scanner specialized in detecting API route security vulnerabilities.
Detects missing input validation, absent rate limiting, verbose errors,
insecure CORS, unprotected admin endpoints, and mass assignment risks
across Python and JavaScript codebases.
"""
PATTERNS: List[VulnerabilityPattern] = [
MISSING_INPUT_VALIDATION,
MISSING_RATE_LIMIT,
VERBOSE_ERROR_EXPOSURE,
INSECURE_CORS,
UNPROTECTED_ADMIN,
MASS_ASSIGNMENT,
]
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the API route scanner.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
super().__init__(config, file_extensions, exclude_dirs)
+192
View File
@@ -0,0 +1,192 @@
"""Scanner for detecting authentication and session management vulnerabilities.
Identifies missing authentication decorators, insecure session configuration,
absent CSRF protection, weak password handling, session fixation risks,
and missing authorization checks on protected endpoints.
"""
from typing import List
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.base_scanner import BaseScanner, VulnerabilityPattern
# ──────────────────────────────────────────────────────────────────────
# Missing authentication on route handlers
# ──────────────────────────────────────────────────────────────────────
MISSING_AUTH_DECORATOR = VulnerabilityPattern(
name="Route Handler Missing Authentication",
trigger_pattern=r"@(app|router|blueprint)\.(get|post|put|patch|delete)\s*\(",
defense_pattern=r"(login_required|auth_required|authenticated|Depends.*auth|jwt_required|token_required|IsAuthenticated|@require|@protect|@secured)",
context_window=8,
description=(
"An API route handler does not have a visible authentication "
"decorator or dependency. Unauthenticated access to endpoints "
"can expose sensitive data or allow unauthorized operations."
),
severity=Severity.HIGH,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"Apply an authentication decorator or dependency to every non-public "
"endpoint. Use a whitelist approach where routes are authenticated "
"by default and explicitly marked as public."
),
cwe_id="CWE-306",
confidence=0.6,
)
# ──────────────────────────────────────────────────────────────────────
# Insecure session configuration
# ──────────────────────────────────────────────────────────────────────
INSECURE_SESSION_CONFIG = VulnerabilityPattern(
name="Insecure Session Cookie Configuration",
trigger_pattern=r"(SESSION_COOKIE_SECURE|session.*secure|cookie.*secure)\s*=\s*(False|false|0)",
defense_pattern=None,
context_window=5,
description=(
"Session cookie is configured without the Secure flag. Cookies "
"will be transmitted over unencrypted HTTP connections, allowing "
"session hijacking via network sniffing."
),
severity=Severity.HIGH,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"Set SESSION_COOKIE_SECURE=True and SESSION_COOKIE_HTTPONLY=True. "
"Also set SESSION_COOKIE_SAMESITE='Lax' or 'Strict' to prevent "
"CSRF attacks via cookie inclusion."
),
cwe_id="CWE-614",
confidence=0.95,
)
# ──────────────────────────────────────────────────────────────────────
# Missing CSRF protection
# ──────────────────────────────────────────────────────────────────────
MISSING_CSRF = VulnerabilityPattern(
name="Missing CSRF Protection on State-Changing Endpoint",
trigger_pattern=r"@(app|router|blueprint)\.(post|put|patch|delete)\s*\(",
defense_pattern=r"(csrf|CSRFProtect|CsrfViewMiddleware|csurf|_token|xsrf|anti_forgery|SameSite|Bearer)",
context_window=20,
description=(
"State-changing endpoint lacks visible CSRF protection. Without CSRF "
"tokens or SameSite cookie policy, an attacker can craft malicious "
"pages that trigger authenticated actions on behalf of users."
),
severity=Severity.MEDIUM,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"For cookie-based auth: implement CSRF tokens on all state-changing "
"endpoints. For token-based auth (Bearer tokens): ensure tokens are "
"not stored in cookies. Set SameSite=Lax on session cookies."
),
cwe_id="CWE-352",
confidence=0.5,
)
# ──────────────────────────────────────────────────────────────────────
# Weak password hashing
# ──────────────────────────────────────────────────────────────────────
WEAK_PASSWORD_HASH = VulnerabilityPattern(
name="Weak Password Hashing Algorithm",
trigger_pattern=r"(hashlib\.(md5|sha1|sha256)\s*\(|MD5|SHA1|createHash\s*\(\s*['\"](?:md5|sha1)['\"])",
defense_pattern=r"(bcrypt|argon2|scrypt|pbkdf2|passlib|password_hash|hash_password)",
context_window=10,
description=(
"Password hashing uses a fast, non-salted algorithm like MD5 or SHA1. "
"These can be reversed with rainbow tables or brute-forced at billions "
"of attempts per second on modern GPUs."
),
severity=Severity.CRITICAL,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"Use bcrypt, argon2id, or scrypt for password hashing. These algorithms "
"include salting and configurable work factors that resist brute-force. "
"Migrate existing hashes on next user login."
),
cwe_id="CWE-916",
confidence=0.85,
)
# ──────────────────────────────────────────────────────────────────────
# Session fixation risk
# ──────────────────────────────────────────────────────────────────────
SESSION_FIXATION = VulnerabilityPattern(
name="Session Not Regenerated After Authentication",
trigger_pattern=r"(def\s+login|def\s+authenticate|def\s+sign_in|async\s+def\s+login)\s*\(",
defense_pattern=r"(session\.regenerate|cycle_key|rotate.*session|new_session|session\.clear|flush.*session|create_session)",
context_window=15,
description=(
"Login handler does not regenerate the session ID after successful "
"authentication. An attacker who sets a session cookie before login "
"retains access to the authenticated session."
),
severity=Severity.HIGH,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"Regenerate the session ID immediately after successful authentication. "
"In Django use request.session.cycle_key(), in Flask use "
"session.regenerate(), in Express use req.session.regenerate()."
),
cwe_id="CWE-384",
confidence=0.7,
)
# ──────────────────────────────────────────────────────────────────────
# Missing authorization (IDOR risk)
# ──────────────────────────────────────────────────────────────────────
MISSING_AUTHORIZATION_CHECK = VulnerabilityPattern(
name="Object Access Without Authorization Check",
trigger_pattern=r"\.(get|filter|find_one|findById|findOne)\s*\(\s*(request\.(args|params|query|json|form|data)|req\.(params|query|body))",
defense_pattern=r"(owner|user_id.*current|current_user|request\.user|belongs_to|authorize|permission|can\s*\(|has_perm)",
context_window=10,
description=(
"Database query uses user-supplied ID without checking ownership or "
"permissions. An attacker can modify the ID parameter to access "
"other users' data (Insecure Direct Object Reference)."
),
severity=Severity.CRITICAL,
category=FindingCategory.AUTH_SESSION,
recommendation=(
"Always filter queries by the authenticated user's ID or check "
"object ownership before returning data. Use a policy layer or "
"scope queries: Model.objects.filter(user=request.user, id=obj_id)."
),
cwe_id="CWE-639",
confidence=0.75,
)
class AuthSessionScanner(BaseScanner):
"""Scanner specialized in detecting authentication and session vulnerabilities.
Detects missing authentication, insecure session configuration,
CSRF gaps, weak password hashing, session fixation, and IDOR
risks across Python and JavaScript codebases.
"""
PATTERNS: List[VulnerabilityPattern] = [
MISSING_AUTH_DECORATOR,
INSECURE_SESSION_CONFIG,
MISSING_CSRF,
WEAK_PASSWORD_HASH,
SESSION_FIXATION,
MISSING_AUTHORIZATION_CHECK,
]
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the authentication and session scanner.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
super().__init__(config, file_extensions, exclude_dirs)
+285
View File
@@ -0,0 +1,285 @@
"""Base scanner providing shared file collection and pattern matching logic.
All concrete scanners inherit from BaseScanner, which handles directory
traversal, file reading, regex-based vulnerability detection, and
finding creation in a framework-agnostic manner.
"""
import logging
import os
import re
import time
from dataclasses import dataclass
from typing import List, Optional
from tmt.config import ScannerConfig
from tmt.models import Finding, FindingCategory, ScanResult, Severity
logger = logging.getLogger(__name__)
@dataclass
class VulnerabilityPattern:
"""Defines a single vulnerability detection rule.
Attributes:
name: Human-readable name of the vulnerability.
trigger_pattern: Regex that identifies potentially vulnerable code.
defense_pattern: Regex for defensive code that mitigates the issue.
context_window: Number of lines around a match to check for defenses.
description: Detailed description of the vulnerability.
severity: Severity level assigned to findings from this pattern.
category: Finding category classification.
recommendation: Remediation guidance for developers.
cwe_id: CWE identifier for the vulnerability class.
confidence: Default confidence score for matches.
"""
name: str
trigger_pattern: str
defense_pattern: Optional[str]
context_window: int
description: str
severity: Severity
category: FindingCategory
recommendation: str
cwe_id: Optional[str] = None
confidence: float = 0.8
class BaseScanner:
"""Abstract base scanner providing pattern-based vulnerability detection.
Subclasses must define their own PATTERNS list of VulnerabilityPattern
objects. The base class handles file traversal, content reading,
pattern matching, and finding assembly.
"""
PATTERNS: List[VulnerabilityPattern] = []
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the base scanner with configuration parameters.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
self.config = config
self.file_extensions = file_extensions
self.exclude_dirs = set(exclude_dirs)
self.scanner_name = self.__class__.__name__
def _is_excluded_dir(self, dir_name: str) -> bool:
"""Check whether a directory name should be excluded from scanning.
Args:
dir_name: Name of the directory to check.
Returns:
True if the directory should be skipped.
"""
return dir_name in self.exclude_dirs or dir_name.startswith(".")
def _has_valid_extension(self, file_name: str) -> bool:
"""Check whether a file has an extension included in the scan scope.
Args:
file_name: Name of the file to check.
Returns:
True if the file extension matches a configured extension.
"""
return any(file_name.endswith(ext) for ext in self.file_extensions)
def _collect_files(self, target_path: str) -> List[str]:
"""Walk a directory tree and collect all files matching scan criteria.
Args:
target_path: Root directory path to begin traversal.
Returns:
List of absolute file paths matching extension and exclusion rules.
"""
collected = []
for root, dirs, files in os.walk(target_path):
dirs[:] = [d for d in dirs if not self._is_excluded_dir(d)]
for fname in files:
if self._has_valid_extension(fname):
collected.append(os.path.join(root, fname))
return collected
def _read_file_safe(self, file_path: str) -> Optional[str]:
"""Read a file's contents with graceful error handling.
Args:
file_path: Absolute path to the file to read.
Returns:
File contents as a string, or None if reading failed.
"""
try:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
return f.read()
except OSError as exc:
logger.warning("Could not read %s: %s", file_path, exc)
return None
def _extract_context(self, lines: List[str], line_num: int, window: int) -> str:
"""Extract a context window of code lines around a specific line.
Args:
lines: All lines of the source file.
line_num: Zero-based line index of the match.
window: Number of lines above and below to include.
Returns:
Concatenated string of context lines.
"""
start = max(0, line_num - window)
end = min(len(lines), line_num + window + 1)
return "\n".join(lines[start:end])
def _has_defense(self, context: str, defense_pattern: Optional[str]) -> bool:
"""Check whether defensive code exists within a context block.
Args:
context: Code context string to search.
defense_pattern: Regex pattern indicating proper defense.
Returns:
True if defense pattern found or no defense pattern required.
"""
if not defense_pattern:
return False
return bool(re.search(defense_pattern, context, re.IGNORECASE))
def _find_trigger_lines(self, content: str, trigger_pattern: str) -> List[int]:
"""Find all line numbers where a trigger pattern matches.
Args:
content: Full file content to search.
trigger_pattern: Regex pattern identifying potentially vulnerable code.
Returns:
List of zero-based line numbers with matches.
"""
lines = content.split("\n")
matched = []
for i, line in enumerate(lines):
if re.search(trigger_pattern, line, re.IGNORECASE):
matched.append(i)
return matched
def _create_finding(
self, pattern: VulnerabilityPattern, file_path: str, line_num: int, snippet: str
) -> Finding:
"""Create a Finding object from a matched vulnerability pattern.
Args:
pattern: The VulnerabilityPattern that was matched.
file_path: Path to the file containing the finding.
line_num: One-based line number of the finding.
snippet: Code snippet from the surrounding context.
Returns:
Populated Finding dataclass instance.
"""
return Finding(
title=pattern.name,
description=pattern.description,
severity=pattern.severity,
category=pattern.category,
file_path=file_path,
line_number=line_num,
code_snippet=snippet,
recommendation=pattern.recommendation,
confidence=pattern.confidence,
cwe_id=pattern.cwe_id,
)
def _scan_file_for_pattern(
self, content: str, file_path: str, pattern: VulnerabilityPattern
) -> List[Finding]:
"""Scan a single file's content against one vulnerability pattern.
Args:
content: Full text content of the source file.
file_path: Path to the source file being scanned.
pattern: VulnerabilityPattern to match against.
Returns:
List of Finding objects for undefended trigger matches.
"""
findings = []
lines = content.split("\n")
trigger_lines = self._find_trigger_lines(content, pattern.trigger_pattern)
for line_num in trigger_lines:
context = self._extract_context(lines, line_num, pattern.context_window)
if not self._has_defense(context, pattern.defense_pattern):
finding = self._create_finding(
pattern, file_path, line_num + 1, context
)
findings.append(finding)
return findings
def _scan_single_file(self, file_path: str, content: str) -> List[Finding]:
"""Apply all vulnerability patterns against a single file.
Args:
file_path: Path to the file being scanned.
content: Full text content of the file.
Returns:
Aggregated list of findings from all pattern checks.
"""
findings = []
for pattern in self.PATTERNS:
pattern_findings = self._scan_file_for_pattern(content, file_path, pattern)
findings.extend(pattern_findings)
return findings
def _process_files(self, file_paths: List[str]) -> List[Finding]:
"""Read and scan each file, collecting all findings.
Args:
file_paths: List of absolute file paths to scan.
Returns:
Combined list of findings from all files.
"""
findings = []
for file_path in file_paths:
content = self._read_file_safe(file_path)
if content:
findings.extend(self._scan_single_file(file_path, content))
return findings
def scan(self, target_path: str) -> ScanResult:
"""Execute the full scan workflow against a target directory.
Args:
target_path: Root directory path to scan for vulnerabilities.
Returns:
ScanResult containing all findings and scan metadata.
"""
start_time = time.time()
file_paths = self._collect_files(target_path)
findings = self._process_files(file_paths)
elapsed = time.time() - start_time
logger.info(
"%s scanned %d files in %.2fs, found %d issues",
self.scanner_name,
len(file_paths),
elapsed,
len(findings),
)
return ScanResult(
scanner_name=self.scanner_name,
findings=findings,
files_scanned=len(file_paths),
scan_duration_seconds=round(elapsed, 3),
)
+165
View File
@@ -0,0 +1,165 @@
"""Scanner for detecting race condition vulnerabilities in application code.
Identifies non-atomic read-modify-write sequences, time-of-check to
time-of-use (TOCTOU) patterns, concurrent resource access without locking,
and unprotected shared state modifications that enable race conditions.
"""
from typing import List
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.base_scanner import BaseScanner, VulnerabilityPattern
# ──────────────────────────────────────────────────────────────────────
# Read-modify-write without atomicity
# ──────────────────────────────────────────────────────────────────────
NON_ATOMIC_READ_MODIFY_WRITE = VulnerabilityPattern(
name="Non-Atomic Read-Modify-Write Sequence",
trigger_pattern=r"(\.\s*save\s*\(|\.\s*update\s*\(|UPDATE\s+.*SET)",
defense_pattern=r"(select_for_update|FOR UPDATE|atomic|transaction|lock|mutex|semaphore|compare_and_swap|F\s*\()",
context_window=10,
description=(
"A database record is read and then updated without atomic protection. "
"Concurrent requests can read stale state and apply conflicting writes, "
"leading to lost updates (e.g., balance overdraws, inventory oversells)."
),
severity=Severity.HIGH,
category=FindingCategory.RACE_CONDITION,
recommendation=(
"Use SELECT FOR UPDATE, database-level atomic operations (e.g., "
"Django F() expressions), or application-level distributed locks. "
"Wrap read-modify-write in a serializable transaction."
),
cwe_id="CWE-362",
confidence=0.7,
)
# ──────────────────────────────────────────────────────────────────────
# TOCTOU (Time-of-Check to Time-of-Use)
# ──────────────────────────────────────────────────────────────────────
TOCTOU_CHECK_THEN_ACT = VulnerabilityPattern(
name="TOCTOU Check-Then-Act Pattern",
trigger_pattern=r"(if\s+.*\.(exists|count|filter|find|get)\s*\(.*\).*:[\s\S]*?\.(create|save|insert|delete|remove)\s*\()",
defense_pattern=r"(atomic|transaction|lock|unique_together|unique=True|get_or_create|upsert|ON CONFLICT)",
context_window=12,
description=(
"Code checks for existence then acts on the result without atomicity. "
"Between the check and the action, another request can change the state, "
"causing phantom reads or duplicate inserts."
),
severity=Severity.HIGH,
category=FindingCategory.RACE_CONDITION,
recommendation=(
"Replace check-then-act with atomic operations like get_or_create, "
"upsert, or INSERT ON CONFLICT. If not possible, wrap both the check "
"and action in a serializable transaction with proper locking."
),
cwe_id="CWE-367",
confidence=0.7,
)
# ──────────────────────────────────────────────────────────────────────
# Concurrent token/coupon redemption
# ──────────────────────────────────────────────────────────────────────
CONCURRENT_REDEMPTION = VulnerabilityPattern(
name="Unguarded Concurrent Redemption",
trigger_pattern=r"(redeem|claim|activate|consume|use_coupon|apply_code|accept_invite)\s*\(",
defense_pattern=r"(atomic|transaction|lock|select_for_update|FOR UPDATE|mutex|semaphore|compare_and_swap)",
context_window=15,
description=(
"A redemption or claim operation is not protected against concurrent "
"execution. Multiple simultaneous requests can redeem the same token, "
"coupon, or invite before any single request marks it as consumed."
),
severity=Severity.CRITICAL,
category=FindingCategory.RACE_CONDITION,
recommendation=(
"Use SELECT FOR UPDATE or a distributed lock around the redemption "
"check and mark-as-used operation. Ensure both steps execute within "
"a single atomic transaction."
),
cwe_id="CWE-362",
confidence=0.8,
)
# ──────────────────────────────────────────────────────────────────────
# Shared mutable state without synchronization
# ──────────────────────────────────────────────────────────────────────
UNPROTECTED_SHARED_STATE = VulnerabilityPattern(
name="Shared Mutable State Without Synchronization",
trigger_pattern=r"(global\s+\w|threading\.Thread|asyncio\.\w+|celery.*\.delay|\.apply_async)",
defense_pattern=r"(Lock|RLock|Semaphore|Event|Condition|Queue|atomic|mutex|synchronized)",
context_window=10,
description=(
"Code uses global mutable state or spawns concurrent execution without "
"visible synchronization primitives. Unsynchronized shared state leads "
"to data corruption and non-deterministic behavior."
),
severity=Severity.MEDIUM,
category=FindingCategory.RACE_CONDITION,
recommendation=(
"Use threading.Lock, asyncio.Lock, or move shared state to a "
"thread-safe data structure like queue.Queue. Prefer stateless "
"request handlers with database-backed state."
),
cwe_id="CWE-362",
confidence=0.6,
)
# ──────────────────────────────────────────────────────────────────────
# JavaScript concurrent state patterns
# ──────────────────────────────────────────────────────────────────────
JS_ASYNC_RACE = VulnerabilityPattern(
name="JS Async Race Condition",
trigger_pattern=r"(await\s+.*find|await\s+.*get)[\s\S]*?(await\s+.*save|await\s+.*update)",
defense_pattern=r"(transaction|findOneAndUpdate|atomicUpdate|\$inc|\$set.*upsert|lock|mutex|semaphore)",
context_window=12,
description=(
"An async find-then-update pattern without atomicity. In Node.js with "
"concurrent request handling, this window allows race conditions."
),
severity=Severity.HIGH,
category=FindingCategory.RACE_CONDITION,
recommendation=(
"Use MongoDB findOneAndUpdate, Sequelize transactions, or Prisma "
"interactive transactions. Avoid separate find-then-save in "
"concurrent contexts."
),
cwe_id="CWE-362",
confidence=0.7,
)
class RaceConditionScanner(BaseScanner):
"""Scanner specialized in detecting race condition vulnerabilities.
Detects non-atomic operations, TOCTOU patterns, unguarded concurrent
redemptions, unsynchronized shared state, and async race conditions
across Python and JavaScript codebases.
"""
PATTERNS: List[VulnerabilityPattern] = [
NON_ATOMIC_READ_MODIFY_WRITE,
TOCTOU_CHECK_THEN_ACT,
CONCURRENT_REDEMPTION,
UNPROTECTED_SHARED_STATE,
JS_ASYNC_RACE,
]
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the race condition scanner.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
super().__init__(config, file_extensions, exclude_dirs)
+130
View File
@@ -0,0 +1,130 @@
"""Scanner for detecting replay attack vulnerabilities in API endpoints.
Identifies mutating endpoints that lack idempotency keys, nonce validation,
timestamp checks, and request deduplication defenses that prevent replay
attacks where captured requests are maliciously re-submitted.
"""
from typing import List
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.base_scanner import BaseScanner, VulnerabilityPattern
# ──────────────────────────────────────────────────────────────────────
# Python / Flask / FastAPI replay attack patterns
# ──────────────────────────────────────────────────────────────────────
PYTHON_POST_WITHOUT_IDEMPOTENCY = VulnerabilityPattern(
name="POST Endpoint Missing Idempotency Key",
trigger_pattern=r"@(app|router|blueprint)\.(post|put|patch)\s*\(",
defense_pattern=r"idempoten|nonce|request_id|x-request-id|dedup|unique_token",
context_window=15,
description=(
"Mutating endpoint does not check for an idempotency key or nonce. "
"An attacker can replay a captured POST/PUT/PATCH request to cause "
"duplicate side effects such as double charges or duplicate records."
),
severity=Severity.MEDIUM,
category=FindingCategory.REPLAY_ATTACK,
recommendation=(
"Accept an Idempotency-Key header or request_id field. Store processed "
"keys server-side (e.g., Redis with TTL) and reject duplicates before "
"executing business logic."
),
cwe_id="CWE-294",
confidence=0.7,
)
PYTHON_NO_TIMESTAMP_VALIDATION = VulnerabilityPattern(
name="Request Missing Timestamp Validation",
trigger_pattern=r"@(app|router|blueprint)\.(post|put|patch|delete)\s*\(",
defense_pattern=r"timestamp|expires?_at|valid_until|time_window|max_age|request_time",
context_window=20,
description=(
"Endpoint does not validate a request timestamp or expiration window. "
"Captured requests can be replayed hours or days later without detection."
),
severity=Severity.LOW,
category=FindingCategory.REPLAY_ATTACK,
recommendation=(
"Include a timestamp in signed requests and reject any request older "
"than a configurable window (e.g., 5 minutes). Combine with nonce "
"tracking for best protection."
),
cwe_id="CWE-294",
confidence=0.6,
)
# ──────────────────────────────────────────────────────────────────────
# JavaScript / Express / Node replay attack patterns
# ──────────────────────────────────────────────────────────────────────
JS_POST_WITHOUT_IDEMPOTENCY = VulnerabilityPattern(
name="JS POST Endpoint Missing Idempotency Key",
trigger_pattern=r"(app|router)\.(post|put|patch)\s*\(",
defense_pattern=r"idempoten|nonce|requestId|x-request-id|dedup|uniqueToken",
context_window=15,
description=(
"JavaScript mutating endpoint lacks idempotency key or nonce validation. "
"Replayed requests may cause duplicate side effects."
),
severity=Severity.MEDIUM,
category=FindingCategory.REPLAY_ATTACK,
recommendation=(
"Require an Idempotency-Key header on mutating endpoints. Store "
"processed keys in Redis with a TTL and return cached responses "
"for duplicate keys."
),
cwe_id="CWE-294",
confidence=0.7,
)
PYTHON_TOKEN_REUSE_NO_INVALIDATION = VulnerabilityPattern(
name="Token Used Without Single-Use Invalidation",
trigger_pattern=r"(verify_token|validate_token|check_token|decode_token)\s*\(",
defense_pattern=r"(delete|invalidat|revoke|mark_used|consume|burn).*token",
context_window=20,
description=(
"A token is verified but not invalidated after use. One-time tokens "
"(e.g., password reset, email verification) that remain valid after "
"consumption are vulnerable to replay."
),
severity=Severity.HIGH,
category=FindingCategory.REPLAY_ATTACK,
recommendation=(
"Immediately invalidate single-use tokens after successful verification. "
"Use a database flag or delete the token record within the same "
"transaction as the action it authorizes."
),
cwe_id="CWE-294",
confidence=0.75,
)
class ReplayScanner(BaseScanner):
"""Scanner specialized in detecting replay attack vulnerabilities.
Detects missing idempotency keys, absent timestamp validation,
and token reuse without invalidation across Python and JavaScript
web application codebases.
"""
PATTERNS: List[VulnerabilityPattern] = [
PYTHON_POST_WITHOUT_IDEMPOTENCY,
PYTHON_NO_TIMESTAMP_VALIDATION,
JS_POST_WITHOUT_IDEMPOTENCY,
PYTHON_TOKEN_REUSE_NO_INVALIDATION,
]
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the replay attack scanner.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
super().__init__(config, file_extensions, exclude_dirs)
+166
View File
@@ -0,0 +1,166 @@
"""Scanner for detecting token and invite abuse vulnerabilities.
Identifies unbounded token generation, predictable token creation,
missing expiration on tokens, multi-use invite tokens, and absent
rate limiting on token issuance endpoints.
"""
from typing import List
from tmt.config import ScannerConfig
from tmt.models import FindingCategory, Severity
from tmt.scanners.base_scanner import BaseScanner, VulnerabilityPattern
# ──────────────────────────────────────────────────────────────────────
# Token generation without rate limiting
# ──────────────────────────────────────────────────────────────────────
UNBOUNDED_TOKEN_GENERATION = VulnerabilityPattern(
name="Token Generation Without Rate Limiting",
trigger_pattern=r"(generate_token|create_token|issue_token|create_invite|generate_invite|send_invite)\s*\(",
defense_pattern=r"(rate_limit|throttle|cooldown|max_attempts|limit_per|RateLimit|slowapi|ratelimit)",
context_window=20,
description=(
"Token or invite generation endpoint lacks rate limiting. An attacker "
"can flood the endpoint to generate excessive tokens, exhausting "
"resources or creating mass invite abuse."
),
severity=Severity.HIGH,
category=FindingCategory.TOKEN_ABUSE,
recommendation=(
"Apply rate limiting per user/IP on token generation endpoints. "
"Use a sliding window counter (e.g., Redis-based) with reasonable "
"limits such as 5 invites per hour per user."
),
cwe_id="CWE-799",
confidence=0.75,
)
# ──────────────────────────────────────────────────────────────────────
# Predictable token generation
# ──────────────────────────────────────────────────────────────────────
PREDICTABLE_TOKEN = VulnerabilityPattern(
name="Predictable Token Generation",
trigger_pattern=r"(uuid\.uuid1|random\.random|random\.randint|Math\.random|hashlib\.(md5|sha1)\(.*time|str\(.*id\))",
defense_pattern=r"(secrets\.|crypto\.random|uuid\.uuid4|os\.urandom|token_hex|token_urlsafe|randomBytes)",
context_window=8,
description=(
"Token generation uses predictable sources like UUID1 (MAC-based), "
"Python's random module (not CSPRNG), or timestamp-based hashing. "
"Predictable tokens can be guessed or brute-forced by attackers."
),
severity=Severity.CRITICAL,
category=FindingCategory.TOKEN_ABUSE,
recommendation=(
"Use cryptographically secure random generators: secrets.token_urlsafe() "
"in Python, crypto.randomBytes() in Node.js, or uuid4 for identifiers. "
"Never derive tokens from timestamps, sequential IDs, or weak PRNGs."
),
cwe_id="CWE-330",
confidence=0.85,
)
# ──────────────────────────────────────────────────────────────────────
# Tokens without expiration
# ──────────────────────────────────────────────────────────────────────
TOKEN_NO_EXPIRY = VulnerabilityPattern(
name="Token Created Without Expiration",
trigger_pattern=r"(Token\.create|Token\.objects\.create|create_token|generate_token|new\s+Token|InviteToken)\s*\(",
defense_pattern=r"(expir|ttl|valid_until|max_age|lifetime|duration|exp\s*=|expiresAt|expires_at)",
context_window=10,
description=(
"Tokens are created without an expiration time. Long-lived tokens "
"increase the window for token theft and abuse, and make revocation "
"more critical and harder to enforce."
),
severity=Severity.HIGH,
category=FindingCategory.TOKEN_ABUSE,
recommendation=(
"Set a reasonable TTL on all tokens: 15 minutes for password reset, "
"24-72 hours for invites, 1 hour for session tokens. Store the "
"expiration and check it on every validation."
),
cwe_id="CWE-613",
confidence=0.75,
)
# ──────────────────────────────────────────────────────────────────────
# Multi-use invite tokens
# ──────────────────────────────────────────────────────────────────────
MULTI_USE_INVITE = VulnerabilityPattern(
name="Invite Token Allows Multiple Redemptions",
trigger_pattern=r"(accept_invite|redeem_invite|use_invite|claim_invite|join_.*invite)\s*\(",
defense_pattern=r"(is_used|used_at|redeemed|consumed|single_use|max_uses|use_count|delete.*invite|mark.*used)",
context_window=15,
description=(
"Invite acceptance logic does not check or enforce single-use. "
"An invite link can be shared and used by multiple unauthorized "
"users to gain access to the system."
),
severity=Severity.HIGH,
category=FindingCategory.TOKEN_ABUSE,
recommendation=(
"Track invite usage with a used_at timestamp or use_count field. "
"Atomically mark invites as consumed during acceptance. Consider "
"binding invites to specific email addresses."
),
cwe_id="CWE-841",
confidence=0.8,
)
# ──────────────────────────────────────────────────────────────────────
# Missing token revocation
# ──────────────────────────────────────────────────────────────────────
NO_TOKEN_REVOCATION = VulnerabilityPattern(
name="No Token Revocation Mechanism",
trigger_pattern=r"(def\s+logout|def\s+revoke|def\s+invalidate|signOut|logOut)\s*\(",
defense_pattern=r"(delete.*token|revoke.*token|blacklist|blocklist|token.*delete|destroy.*session|clear.*token)",
context_window=15,
description=(
"Logout or revocation endpoint does not actually invalidate the "
"token server-side. The token remains valid and usable even after "
"the user believes they have logged out."
),
severity=Severity.HIGH,
category=FindingCategory.TOKEN_ABUSE,
recommendation=(
"Maintain a server-side token blocklist or delete the token record "
"on logout. For JWTs, use short expiration combined with a refresh "
"token that can be revoked from the database."
),
cwe_id="CWE-613",
confidence=0.7,
)
class TokenAbuseScanner(BaseScanner):
"""Scanner specialized in detecting token and invite abuse vulnerabilities.
Detects unbounded generation, predictable tokens, missing expiration,
multi-use invites, and absent revocation mechanisms across Python
and JavaScript codebases.
"""
PATTERNS: List[VulnerabilityPattern] = [
UNBOUNDED_TOKEN_GENERATION,
PREDICTABLE_TOKEN,
TOKEN_NO_EXPIRY,
MULTI_USE_INVITE,
NO_TOKEN_REVOCATION,
]
def __init__(
self, config: ScannerConfig, file_extensions: List[str], exclude_dirs: List[str]
):
"""Initialize the token abuse scanner.
Args:
config: Scanner configuration controlling behavior.
file_extensions: File extensions to include in scanning.
exclude_dirs: Directory names to skip during traversal.
"""
super().__init__(config, file_extensions, exclude_dirs)