Skip to content
Back to skills

Phy Path Traversal Audit

BSecurity

Path traversal and Local File Inclusion (LFI) vulnerability scanner (OWASP A01:2021). Detects user-controlled paths passed to file system sinks in Python/Java/PHP/Node.js/Go/Ruby without containment checks. Identifies missing os.path.abspath+startswith, realpath validation, basename stripping, and PHP include/require with user input. Outputs CWE-22/CWE-23 findings with HTTP taint analysis and per-language safe-path-handling code snippets. Zero competitors on ClawHub.

  • 2,128 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added June 6, 2026
developmentjavascripttypescriptpythonrustgojavarubyphpbashnode

Works with

  • api

Security analysis

B75/100
  • criticalAccesses sensitive system or user directories

Pro scans all 2 files and shows the line behind each finding

Scanned June 6, 2026

npx -y skills add LeoYeAI/openclaw-master-skills --skill phy-path-traversal-audit --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Phy Path Traversal Audit?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Phy Path Traversal Audit
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/leoyeai-phy-path-traversal-audit/badge)](https://www.skillsdirectory.com/skills/leoyeai-phy-path-traversal-audit)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: phy-path-traversal-audit
description: Path traversal and Local File Inclusion (LFI) vulnerability scanner (OWASP A01:2021). Detects user-controlled paths passed to file system sinks in Python/Java/PHP/Node.js/Go/Ruby without containment checks. Identifies missing os.path.abspath+startswith, realpath validation, basename stripping, and PHP include/require with user input. Outputs CWE-22/CWE-23 findings with HTTP taint analysis and per-language safe-path-handling code snippets. Zero competitors on ClawHub.
license: Apache-2.0
tags:
  - security
  - path-traversal
  - lfi
  - owasp
  - python
  - java
  - php
  - nodejs
  - go
  - ruby
metadata:
  author: PHY041
  version: "1.0.0"
---

# phy-path-traversal-audit

Static scanner for **OWASP A01:2021 — Broken Access Control / Path Traversal** (CWE-22) and **Local File Inclusion** (CWE-98). Finds file system sinks that accept user-controlled paths, checks for missing containment guards, and flags PHP `include`/`require` patterns that allow template injection. Zero external API calls, zero dependencies beyond Python 3 stdlib.

## What Is Path Traversal?

An attacker passes `../../etc/passwd` or `..%2F..%2Fetc%2Fshadow` as a filename parameter. Without validation, your code reads arbitrary files outside the intended base directory. With PHP `include`, it can lead to Remote Code Execution.

**Classic exploit:**
```
GET /api/files?path=../../etc/passwd HTTP/1.1
```
If your handler does `open("uploads/" + request.args["path"])`, attacker reads `/etc/passwd`.

## What It Detects

### Python
| Pattern | Severity | Notes |
|---------|----------|-------|
| `open(user_path)` | CRITICAL | Direct file read with user path |
| `open(os.path.join(base, user_input))` without `abspath`+`startswith` | HIGH | Join doesn't sanitize `../` |
| `pathlib.Path(user_path).read_text()` | CRITICAL | pathlib doesn't sanitize traversal |
| `Path(base).joinpath(user_input)` without `.resolve().is_relative_to(base)` | HIGH | |
| `os.listdir(user_path)` | HIGH | Directory listing disclosure |
| `os.open(user_path, os.O_RDONLY)` | CRITICAL | Low-level file open |
| `shutil.copy/move(user_src, ...)` | HIGH | File operation with user src |
| `tarfile.open(user_path)` | HIGH | Zip/tar slip (CVE-class) |
| `zipfile.ZipFile(user_path)` | HIGH | Zip slip attack |

### Java
| Pattern | Severity | Notes |
|---------|----------|-------|
| `new File(baseDir + userInput)` | CRITICAL | String concat without normalization |
| `new FileInputStream(userInput)` | CRITICAL | Direct file read |
| `Paths.get(userInput)` | HIGH | Path construction without validation |
| `Files.readAllBytes(Path.of(userInput))` | CRITICAL | File read |
| `Files.newBufferedReader(Paths.get(userInput))` | CRITICAL | File read |
| `new File(request.getServletContext().getRealPath(userInput))` | CRITICAL | Servlet path traversal |
| `response.setHeader("Content-Disposition", "..."+userInput)` | MEDIUM | Filename injection |

### PHP
| Pattern | Severity | Notes |
|---------|----------|-------|
| `include($_GET['page'])` / `include($_POST['file'])` | CRITICAL | LFI → RCE via log poisoning |
| `require($_GET['file'])` | CRITICAL | LFI |
| `include_once($_GET[...])` / `require_once($_GET[...])` | CRITICAL | LFI |
| `readfile($_GET['file'])` | CRITICAL | File disclosure |
| `file_get_contents($_GET['path'])` | HIGH | File/URL read |
| `fopen($_GET['file'], 'r')` | CRITICAL | File open |
| `file($_GET['path'])` | HIGH | Read file into array |
| `highlight_file($_GET['file'])` | CRITICAL | PHP source disclosure |
| `include("pages/" . $_GET['page'] . ".php")` | HIGH | Partial mitigation (extension added) but still exploitable via null byte on older PHP |

### Node.js / TypeScript
| Pattern | Severity | Notes |
|---------|----------|-------|
| `fs.readFile(req.params.path, ...)` | CRITICAL | Direct file read |
| `fs.readFileSync(req.query.file)` | CRITICAL | Sync file read |
| `fs.createReadStream(req.body.path)` | CRITICAL | Stream file read |
| `res.sendFile(req.params.filename)` | HIGH | Express static file serve |
| `res.download(req.query.file)` | HIGH | File download |
| `path.join(__dirname, req.params.file)` without `path.resolve`+`startsWith` check | HIGH | Join alone is insufficient |
| `require(req.params.module)` | CRITICAL | Path traversal + arbitrary code execution |
| `fs.readdirSync(req.query.dir)` | HIGH | Directory listing |

### Go
| Pattern | Severity | Notes |
|---------|----------|-------|
| `os.Open(r.FormValue("path"))` | CRITICAL | Direct file open |
| `os.ReadFile(r.URL.Query().Get("file"))` | CRITICAL | File read |
| `http.ServeFile(w, r, r.FormValue("path"))` | CRITICAL | File serve — `http.ServeFile` has some built-in protection but verify |
| `filepath.Join(base, r.FormValue("name"))` without `filepath.Clean`+containment | HIGH | |
| `os.Stat(r.FormValue("path"))` | MEDIUM | Path existence disclosure |

### Ruby
| Pattern | Severity | Notes |
|---------|----------|-------|
| `File.open(params[:path])` | CRITICAL | File open |
| `File.read(params[:file])` | CRITICAL | File read |
| `IO.read(params[:file])` | CRITICAL | File read |
| `send_file(params[:path])` | CRITICAL | Rails file serve |
| `send_data(File.read(params[:file]))` | CRITICAL | File read + serve |
| `render params[:template]` | CRITICAL | Template injection (+ path traversal) |
| `erb.result(binding) where erb from params` | CRITICAL | Template injection |

## Containment Guard Detection

After finding a sink, the scanner checks if a safe-path guard exists within ±40 lines. If found, the finding is downgraded or suppressed:

**Python safe guards:**
```python
# Correct: resolve to absolute, then check containment
safe_path = os.path.abspath(os.path.join(BASE_DIR, user_input))
if not safe_path.startswith(BASE_DIR):
    raise PermissionError("path traversal detected")

# Also safe: pathlib.resolve() + is_relative_to()
resolved = (Path(BASE_DIR) / user_input).resolve()
if not resolved.is_relative_to(BASE_DIR):
    raise ValueError("path traversal")
```

**Java safe guards:**
```java
// Correct: normalize and verify containment
Path safePath = Paths.get(baseDir).resolve(userInput).normalize().toAbsolutePath();
if (!safePath.startsWith(Paths.get(baseDir).toAbsolutePath())) {
    throw new SecurityException("Path traversal detected");
}
```

**Node.js safe guards:**
```javascript
// Correct: resolve and check startsWith
const safePath = path.resolve(BASE_DIR, userInput);
if (!safePath.startsWith(BASE_DIR + path.sep)) {
    throw new Error("Path traversal detected");
}
```

**PHP safe guards:**
```php
// Correct: realpath + containment check
$path = realpath(BASE_DIR . '/' . $userInput);
if ($path === false || strpos($path, BASE_DIR) !== 0) {
    http_response_code(403);
    exit('Forbidden');
}
// Also: basename() strips directory components (partial mitigation)
$filename = basename($_GET['file']); // removes ../
```

**Go safe guards:**
```go
// Correct: Clean + containment check
safePath := filepath.Join(baseDir, filepath.Clean("/"+r.FormValue("path")))
if !strings.HasPrefix(safePath, baseDir+string(os.PathSeparator)) {
    http.Error(w, "Forbidden", 403)
    return
}
```

## Zip Slip Detection

Tar/zip extraction without path validation enables a special case of path traversal where malicious archives overwrite files outside the extraction directory:

```python
# DANGEROUS — zip slip
with zipfile.ZipFile(archive) as zf:
    zf.extractall(extract_dir)  # member names not validated

# SAFE
import zipfile, os
with zipfile.ZipFile(archive) as zf:
    for member in zf.infolist():
        member_path = os.path.abspath(os.path.join(extract_dir, member.filename))
        if not member_path.startswith(os.path.abspath(extract_dir) + os.sep):
            raise ValueError(f"Zip slip: {member.filename}")
        zf.extract(member, extract_dir)
```

The scanner detects `zipfile.ZipFile.extractall()`, `tarfile.TarFile.extractall()`, `ZipInputStream` in Java, `net/http` archiver patterns in Go.

## Implementation

```python
#!/usr/bin/env python3
"""
phy-path-traversal-audit — OWASP A01:2021 path traversal scanner
Usage: python3 audit_path_traversal.py [path] [--json] [--ci]
"""
import argparse
import json
import os
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Optional

CRITICAL, HIGH, MEDIUM, INFO = "CRITICAL", "HIGH", "MEDIUM", "INFO"
SEV_ORDER = {CRITICAL: 0, HIGH: 1, MEDIUM: 2, INFO: 3}
ICONS = {CRITICAL: "🔴", HIGH: "🟠", MEDIUM: "🟡", INFO: "⚪"}

@dataclass
class Finding:
    file: str
    line: int
    pattern_name: str
    matched_text: str
    severity: str
    cwe: str
    description: str
    fix: str
    has_http_taint: bool = False

PATTERNS = {
    ".py": [
        ("OPEN_DIRECT",
         re.compile(r'\bopen\s*\('),
         CRITICAL, "CWE-22",
         "open() with user-controlled path enables arbitrary file read/write.",
         "Resolve to absolute path, then verify it starts with the allowed base directory."),

        ("PATHLIB_READ",
         re.compile(r'\bPath\s*\([^)]+\)\.(?:read_text|read_bytes|open)\s*\('),
         CRITICAL, "CWE-22",
         "pathlib.Path().read_text/bytes with user path — Path() does not sanitize ../.",
         "Use (Path(BASE_DIR) / user_input).resolve().is_relative_to(BASE_DIR) before reading."),

        ("OS_PATH_JOIN_NO_ABSPATH",
         re.compile(r'\bos\.path\.join\s*\([^)]+\)'),
         HIGH, "CWE-22",
         "os.path.join() alone doesn't prevent traversal — '../' components are preserved.",
         "After join: safe = os.path.abspath(joined); assert safe.startswith(BASE_DIR)."),

        ("OS_LISTDIR",
         re.compile(r'\bos\.listdir\s*\('),
         HIGH, "CWE-22",
         "os.listdir() with user path exposes directory contents.",
         "Validate path against BASE_DIR allowlist before listing."),

        ("ZIP_EXTRACTALL",
         re.compile(r'\bextractall\s*\('),
         HIGH, "CWE-22",
         "extractall() without member path validation enables zip-slip attack.",
         "Validate each member path before extraction (see zip-slip safe pattern)."),

        ("TARFILE_EXTRACT",
         re.compile(r'\btarfile\.open\s*\(|\b\.extract\s*\('),
         HIGH, "CWE-22",
         "tarfile extract without member path validation enables tar-slip.",
         "Use tarfile.data_filter (Python 3.12+) or manually check member.name."),
    ],
    ".java": [
        ("NEW_FILE_CONCAT",
         re.compile(r'\bnew\s+File\s*\([^)]+\+\s*\w+\s*\)'),
         CRITICAL, "CWE-22",
         "new File(base + userInput) — string concatenation without path normalization.",
         "Use Paths.get(base).resolve(userInput).normalize() then check startsWith(base)."),

        ("NEW_FILE_INPUT_STREAM",
         re.compile(r'\bnew\s+FileInputStream\s*\('),
         CRITICAL, "CWE-22",
         "FileInputStream with user-controlled path enables arbitrary file read.",
         "Normalize and validate path before constructing FileInputStream."),

        ("FILES_READ",
         re.compile(r'\bFiles\.(readAllBytes|readString|newBufferedReader|newInputStream)\s*\('),
         CRITICAL, "CWE-22",
         "Files.readAllBytes/readString with user path enables path traversal.",
         "Validate path: Path.of(base).resolve(userInput).normalize().toAbsolutePath() starts with base."),

        ("PATHS_GET",
         re.compile(r'\bPaths\.get\s*\('),
         HIGH, "CWE-22",
         "Paths.get() with user input — check for subsequent normalize()+startsWith() guard.",
         "Always follow with .normalize().toAbsolutePath() and containment check."),

        ("SERVLET_REAL_PATH",
         re.compile(r'\.getRealPath\s*\('),
         CRITICAL, "CWE-22",
         "getRealPath() with user input translates to filesystem path — path traversal.",
         "Validate user input before passing to getRealPath(); prefer serving from classpath."),
    ],
    ".php": [
        ("PHP_INCLUDE_INPUT",
         re.compile(r'\b(?:include|require|include_once|require_once)\s*\(\s*\$(?:_GET|_POST|_REQUEST|_COOKIE)\b'),
         CRITICAL, "CWE-98",
         "PHP include/require with HTTP input — Local File Inclusion → potential RCE via log poisoning.",
         "NEVER use include/require with user input. Use a whitelist: $allowed = ['home', 'about']; "
         "if (!in_array($page, $allowed)) die('invalid');"),

        ("PHP_INCLUDE_VAR",
         re.compile(r'\b(?:include|require|include_once|require_once)\s*\([^)]*\$'),
         HIGH, "CWE-98",
         "PHP include/require with variable — verify variable is not user-controlled.",
         "Use a whitelist of allowed template names; never construct include path from user input."),

        ("PHP_READFILE_INPUT",
         re.compile(r'\b(?:readfile|highlight_file|show_source)\s*\(\s*\$(?:_GET|_POST|_REQUEST)\b'),
         CRITICAL, "CWE-22",
         "readfile/highlight_file with HTTP input — arbitrary file read/PHP source disclosure.",
         "Validate and sanitize filename; use basename() + allowlist; resolve + containment check."),

        ("PHP_FOPEN_INPUT",
         re.compile(r'\bfopen\s*\(\s*\$(?:_GET|_POST|_REQUEST|_COOKIE)\b'),
         CRITICAL, "CWE-22",
         "fopen() with HTTP input — arbitrary file open.",
         "Validate path with realpath(); check containment: strpos(realpath, BASE_DIR) === 0."),

        ("PHP_FILE_GET_CONTENTS",
         re.compile(r'\bfile(?:_get_contents|_put_contents|)\s*\(\s*\$(?:_GET|_POST|_REQUEST)\b'),
         CRITICAL, "CWE-22",
         "file_get_contents/file with HTTP input — arbitrary file read.",
         "Validate: $safe = realpath(BASE_DIR.'/'.basename($input)); if (!str_starts_with($safe, BASE_DIR)) { die(); }"),
    ],
    ".js": _build_js_path_patterns(),
    ".ts": _build_js_path_patterns(),
    ".go": [
        ("OS_OPEN_FORM",
         re.compile(r'\bos\.(?:Open|ReadFile|OpenFile|Create)\s*\(\s*r\.(?:FormValue|URL\.Query\(\)\.Get|Form\.Get)\s*\('),
         CRITICAL, "CWE-22",
         "os.Open/ReadFile with form value — direct path traversal.",
         "filepath.Clean(\"/\"+userInput) then filepath.Join(baseDir, cleaned); verify strings.HasPrefix(result, baseDir)."),

        ("FILEPATH_JOIN_NO_GUARD",
         re.compile(r'\bfilepath\.Join\s*\('),
         HIGH, "CWE-22",
         "filepath.Join with user input — Join alone doesn't prevent traversal.",
         "After join: safe := filepath.Clean(joined); if !strings.HasPrefix(safe, baseDir+sep) { error }"),

        ("HTTP_SERVE_FILE",
         re.compile(r'\bhttp\.ServeFile\s*\('),
         HIGH, "CWE-22",
         "http.ServeFile — Go's stdlib adds some protection but explicit validation is safer.",
         "Validate path against allowed directory before calling ServeFile."),
    ],
    ".rb": [
        ("FILE_OPEN_PARAMS",
         re.compile(r'\b(?:File\.open|File\.read|IO\.read|IO\.binread)\s*\(\s*params\['),
         CRITICAL, "CWE-22",
         "File.open/read with params — arbitrary file read.",
         "Validate: path = Rails.root.join('safe_dir', File.basename(params[:file]))\n"
         "raise unless path.to_s.start_with?(Rails.root.join('safe_dir').to_s)"),

        ("SEND_FILE_PARAMS",
         re.compile(r'\bsend_file\s*\(\s*params\['),
         CRITICAL, "CWE-22",
         "Rails send_file with params — arbitrary file download.",
         "Whitelist allowed filenames; never construct path from user input."),

        ("RENDER_PARAMS_TEMPLATE",
         re.compile(r'\brender\s+params\['),
         CRITICAL, "CWE-98",
         "Rails render with params — template injection + path traversal.",
         "Whitelist allowed template names: ALLOWED_TEMPLATES = ['home', 'about']\n"
         "render params[:page] if ALLOWED_TEMPLATES.include?(params[:page])"),
    ],
}

def _build_js_path_patterns():
    return [
        ("FS_READFILE_PARAM",
         re.compile(r'\bfs\.(?:readFile|readFileSync|createReadStream|readdirSync|stat|statSync)\s*\(\s*req\.[a-zA-Z.[\]'"]+'),
         CRITICAL, "CWE-22",
         "fs.readFile/readFileSync with request param — direct path traversal.",
         "const safe = path.resolve(BASE_DIR, req.params.file);\n"
         "if (!safe.startsWith(BASE_DIR + path.sep)) throw new Error('Forbidden');\n"
         "fs.readFile(safe, ...)"),

        ("RES_SENDFILE_PARAM",
         re.compile(r'\bres\.(?:sendFile|download)\s*\(\s*(?:path\.join\s*\([^)]+\)|req\.[a-zA-Z.[\]'"]+)'),
         HIGH, "CWE-22",
         "res.sendFile/download with request param — path traversal.",
         "Validate path before sending. res.sendFile only after containment check."),

        ("PATH_JOIN_NO_RESOLVE",
         re.compile(r'\bpath\.join\s*\([^)]*(?:req\.|__dirname)[^)]*\)'),
         HIGH, "CWE-22",
         "path.join() with request input — join preserves ../ components.",
         "After join: const safe = path.resolve(joined); assert safe.startsWith(BASE_DIR)."),

        ("REQUIRE_DYNAMIC",
         re.compile(r'\brequire\s*\(\s*(?:path\.join|req\.[a-zA-Z.[\]'"]+)'),
         CRITICAL, "CWE-22",
         "require() with user-controlled path — path traversal + arbitrary code execution.",
         "Never use require() with user input. Use a module whitelist."),

        ("FS_WRITE_PARAM",
         re.compile(r'\bfs\.(?:writeFile|writeFileSync|appendFile|appendFileSync)\s*\(\s*req\.[a-zA-Z.[\]'"]+'),
         CRITICAL, "CWE-22",
         "fs.writeFile with request param — arbitrary file write (can overwrite config/keys).",
         "Validate destination path against BASE_DIR; never allow writing outside allowed dirs."),
    ]

HTTP_MARKERS = {
    ".py":   re.compile(r'request\.(args|form|json|data|files|GET|POST|params)|flask\.request|starlette|fastapi|aiohttp\.web'),
    ".java": re.compile(r'HttpServletRequest|@RequestParam|@PathVariable|getParameter\(|request\.getParameter|@RequestBody'),
    ".php":  re.compile(r'\$_(?:GET|POST|REQUEST|COOKIE|FILES)'),
    ".rb":   re.compile(r'\bparams\[|request\.(body|params|query_string)'),
    ".js":   re.compile(r'\breq\.(body|params|query|headers)|ctx\.(query|body|params)'),
    ".ts":   re.compile(r'\breq\.(body|params|query|headers)|ctx\.(query|body|params)'),
    ".go":   re.compile(r'r\.(FormValue\(|URL\.Query\(\)\.Get|Form\.Get|Body|Header\.Get\()'),
}

GUARD_PATTERNS = {
    ".py":   re.compile(r'os\.path\.abspath|\.resolve\(\)|\.is_relative_to|startswith\s*\(.*BASE|startswith\s*\(.*base_dir'),
    ".java": re.compile(r'\.normalize\(\)|\.toAbsolutePath\(\)|startsWith\s*\('),
    ".php":  re.compile(r'\brealpath\s*\(|strpos\s*\(|str_starts_with|basename\s*\('),
    ".rb":   re.compile(r'start_with\?|File\.basename|realpath'),
    ".js":   re.compile(r'path\.resolve|\.startsWith\s*\(|realpathSync|path\.normalize'),
    ".ts":   re.compile(r'path\.resolve|\.startsWith\s*\(|realpathSync|path\.normalize'),
    ".go":   re.compile(r'filepath\.Clean|strings\.HasPrefix|strings\.Contains.*"\.\."'),
}

SKIP_DIRS = {".git", "node_modules", "vendor", "__pycache__", ".venv", "venv",
             "dist", "build", "target", "test", "tests", "__tests__", "spec", "fixtures"}

def scan_file(filepath: Path) -> list[Finding]:
    suffix = filepath.suffix.lower()
    if suffix not in PATTERNS:
        return []
    try:
        lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines()
    except (OSError, PermissionError):
        return []

    full_text = "\n".join(lines)
    findings: list[Finding] = []
    http_marker = HTTP_MARKERS.get(suffix)
    guard_pat = GUARD_PATTERNS.get(suffix)

    for (name, pat, base_sev, cwe, desc, fix) in PATTERNS[suffix]:
        for m in pat.finditer(full_text):
            lineno = full_text[:m.start()].count("\n") + 1
            line_text = lines[lineno - 1]

            start = max(0, lineno - 40)
            end = min(len(lines), lineno + 40)
            context = "\n".join(lines[start:end])

            has_http = bool(http_marker and http_marker.search(context))
            guard_found = bool(guard_pat and guard_pat.search(context))

            if guard_found:
                continue  # Safe pattern present — skip

            sev = base_sev
            if not has_http:
                if sev == CRITICAL:
                    sev = HIGH
                elif sev == HIGH:
                    sev = MEDIUM

            findings.append(Finding(
                file=str(filepath),
                line=lineno,
                pattern_name=name,
                matched_text=line_text.strip()[:120],
                severity=sev,
                cwe=cwe,
                description=desc,
                fix=fix,
                has_http_taint=has_http,
            ))
    return findings

def walk_files(root: Path) -> list[Path]:
    exts = {".py", ".java", ".php", ".rb", ".js", ".ts", ".go"}
    results = []
    for dirpath, dirnames, filenames in os.walk(root):
        dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
        for fname in filenames:
            if Path(fname).suffix.lower() in exts:
                results.append(Path(dirpath) / fname)
    return results

def format_report(findings: list[Finding], scanned: int) -> str:
    by_sev = {CRITICAL: [], HIGH: [], MEDIUM: [], INFO: []}
    for f in findings:
        by_sev[f.severity].append(f)

    lines = [
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        "  PATH TRAVERSAL AUDIT (OWASP A01:2021 — CWE-22/98)",
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        f"  Scanned:  {scanned} files",
        f"  Findings: {len(by_sev[CRITICAL])} CRITICAL  {len(by_sev[HIGH])} HIGH  {len(by_sev[MEDIUM])} MEDIUM",
        "",
    ]

    for sev in [CRITICAL, HIGH, MEDIUM]:
        group = by_sev[sev]
        if not group:
            continue
        lines.append(f"{ICONS[sev]} {sev} ({len(group)} findings)")
        lines.append("")
        for f in sorted(group, key=lambda x: x.file):
            rel = os.path.relpath(f.file)
            taint_str = "⚡ HTTP taint confirmed" if f.has_http_taint else "⚠️  HTTP taint unconfirmed — verify source"
            lines += [
                f"  {rel}:{f.line} — {f.pattern_name}",
                f"  Code:  {f.matched_text}",
                f"  Taint: {taint_str}",
                f"  Risk:  {f.description}",
                f"  {f.cwe}",
                f"  Fix:   {f.fix}",
                "",
            ]

    critical_count = len(by_sev[CRITICAL])
    lines += [
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
        f"  CI gate: {'exit 1 — CRITICAL findings present' if critical_count else 'exit 0 — clean'}",
        "  OWASP: https://owasp.org/Top10/A01_2021-Broken_Access_Control/",
        "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━",
    ]
    return "\n".join(lines)

def main():
    parser = argparse.ArgumentParser(description="Path traversal / LFI scanner")
    parser.add_argument("path", nargs="?", default=".", help="Root directory to scan")
    parser.add_argument("--json", action="store_true")
    parser.add_argument("--ci", action="store_true", help="Exit 1 if CRITICAL found")
    args = parser.parse_args()

    files = walk_files(Path(args.path).resolve())
    all_findings: list[Finding] = []
    for f in files:
        all_findings.extend(scan_file(f))
    all_findings.sort(key=lambda x: (SEV_ORDER[x.severity], x.file, x.line))

    if args.json:
        import dataclasses
        print(json.dumps([dataclasses.asdict(f) for f in all_findings], indent=2))
    else:
        print(format_report(all_findings, len(files)))

    if args.ci:
        sys.exit(1 if any(f.severity == CRITICAL for f in all_findings) else 0)

if __name__ == "__main__":
    main()
```

## Usage

```bash
# Scan current project
python3 audit_path_traversal.py

# Scan with CI fail-gate
python3 audit_path_traversal.py --ci

# JSON output
python3 audit_path_traversal.py --json | jq '[.[] | select(.severity == "CRITICAL")]'

# GitHub Actions
- name: Path Traversal Audit
  run: python3 .claude/skills/phy-path-traversal-audit/audit_path_traversal.py --ci
```

## Sample Output

```
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  PATH TRAVERSAL AUDIT (OWASP A01:2021 — CWE-22/98)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  Scanned:  54 files
  Findings: 3 CRITICAL  2 HIGH  0 MEDIUM

🔴 CRITICAL (3 findings)

  api/files.py:67 — OPEN_DIRECT
  Code:  return open(os.path.join("uploads", request.args["path"])).read()
  Taint: ⚡ HTTP taint confirmed
  Risk:  open() with user-controlled path enables arbitrary file read/write.
  CWE-22
  Fix:   safe = os.path.abspath(os.path.join("uploads", user_input))
         assert safe.startswith(os.path.abspath("uploads"))

  pages/api.php:34 — PHP_INCLUDE_INPUT
  Code:  include($_GET['page'] . '.php');
  Taint: ⚡ HTTP taint confirmed
  Risk:  PHP include with HTTP input — LFI → potential RCE via log poisoning.
  CWE-98
  Fix:   Use whitelist: $allowed = ['home', 'about'];
         if (!in_array($_GET['page'], $allowed)) die('invalid');

  src/routes/download.js:91 — FS_READFILE_PARAM
  Code:  fs.createReadStream(req.query.file)
  Taint: ⚡ HTTP taint confirmed
  Risk:  fs.createReadStream with request param — direct path traversal.
  CWE-22
  Fix:   const safe = path.resolve(BASE_DIR, req.query.file);
         if (!safe.startsWith(BASE_DIR + path.sep)) throw new Error('Forbidden');
         fs.createReadStream(safe)

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
  CI gate: exit 1 — CRITICAL findings present
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```

## Companion Skills

| Skill | Use Together For |
|-------|-----------------|
| `phy-ssrf-audit` | Complete input → file/URL access security sweep |
| `phy-deserialization-audit` | OWASP A08 + A01 — full untrusted input chain |
| `phy-cors-audit` | Network boundary + filesystem boundary protection |
| `phy-jwt-auth-audit` | Auth controls that should gate file access |

Files in this skill

  • SKILL.md26.2 KB
  • _meta.json300 B

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.