jpskill.com
🛠️ 開発・MCP コミュニティ 🔴 エンジニア向け 👤 エンジニア・AI開発者

🛠️ PhyPathTraversal監査

phy-path-traversal-audit

PythonやJavaなどのコードにおけるファイルパス操作の脆弱性を検出し、安全なコード例を提示するSkill。

⏱ ライブラリ調査+組込 半日 → 1時間
📜 元の英語説明(参考)

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.

🇯🇵 日本人クリエイター向け解説

一言でいうと

PythonやJavaなどのコードにおけるファイルパス操作の脆弱性を検出し、安全なコード例を提示するSkill。

※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。

詳しい使い方ガイドを見る →
最終更新
2026-05-17
取得日時
2026-05-17
同梱ファイル
1

💬 こう話しかけるだけ — サンプルプロンプト

  • Phy Path Traversal Audit を使って、最小構成のサンプルコードを示して
  • Phy Path Traversal Audit の主な使い方と注意点を教えて
  • Phy Path Traversal Audit を既存プロジェクトに組み込む方法を教えて

これをClaude Code に貼るだけで、このSkillが自動発動します。

📖 Claude が読む原文 SKILL.md(中身を展開)

この本文は AI(Claude)が読むための原文(英語または中国語)です。日本語訳は順次追加中。

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:

# 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:

// 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:

// 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:

// 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:

// 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:

# 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

#!/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

# 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