360 Secure SkillHub
360 安全技能中心

Phy Path Traversal Audit

这是一款静态代码安全扫描工具,核心检测路径遍历(OWASP A01)与本地文件包含(LFI)漏洞,支持Python、Java、PHP等6种主流语言。能识别用户可控路径传入文件操作的风险,判断是否缺失安全防护,还可检测压缩包slip攻击。适用于项目安全审计、CI/CD集成,输入为代码目录,输出含严重等级、CWE编号及修复代码的报告,支持JSON格式与CI触发模式。

phy041
phy041
热度 35.60
最近更新 2026-06-23 13:16:20
分类:安全依赖:代码执行 / 读写文件应用场景:代码审查 / 报告撰写 / 自动化脚本

安装技能

复制下面的话术发给你的 Agent,它会通过 secure-skills 检索并安装该技能。

执行命令安装此 skill: npx secure-skills-cli add phy-path-traversal-audit
Metadata
name
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
homepage
https://canlah.ai
tags
["security","path-traversal","lfi","owasp","python","java","php","nodejs","go","ruby"]
metadata
{"author":"Canlah AI","version":"1.0.3"}

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

Author

Canlah AI — Run performance marketing without breaking your brand.