Ctrl K
ComunidadGuías…

backend/tools/lint_is_safe_path.py

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""AST linter — flag reversed is_safe_path(base, candidate) calls.

Sublarr's `security_utils.is_safe_path(candidate, base_dir)` takes the
user-supplied candidate first and the trusted base directory second.
Reversing the two arguments turns the gate into a no-op (or worse, a
universally-passing check) — see commits b03e305b (remux) and the cleanup
audit on 2026-04-30 for past instances.

This linter walks every Python file under backend/ and flags any
`is_safe_path(...)` call whose first positional argument looks like a
trust-base name and whose second looks like a user-controlled path.

Heuristic: classify each argument by the variable name. The known sets
were grown empirically from the codebase. Anything outside both sets is
"unknown" and we don't flag it.

Exit code 1 on any reversed call, 0 otherwise. Designed for pre-commit
and CI.
"""

from __future__ import annotations

import ast
import sys
from pathlib import Path

# Variable names that name a TRUST BOUNDARY (allowed as 2nd arg).
BASE_NAMES = {
    "media_path",
    "config_dir",
    "cache_dir",
    "trash_root",
    "tessdata_dir",
    "_trash_root",
    "_hooks_dir",
    "_batch_dir",
    "_video_root",
    "_media_path",
    "base_dir",
    "base",
    "root",
    "trust_root",
    "allowed_root",
    "scripts_root",
    "config_root",
}
# Names that hint at USER-CONTROLLED candidate paths (not allowed as 2nd arg).
CANDIDATE_NAMES = {
    "path",
    "file_path",
    "subtitle_path",
    "video_path",
    "output_path",
    "input_path",
    "tmp_path",
    "mapped_path",
    "target",
    "target_path",
    "script_path",
    "remote_path",
    "local_path",
    "backup_path",
    "remux_path",
    "trashed",
    "original",
    "candidate",
    "user_path",
    "fp",
    "f",
    "raw_path",
    "src",
    "dst",
    "src_path",
    "dst_path",
    "source_path",
    "dest_path",
}


def _classify(node: ast.AST) -> str:
    """Return 'base', 'candidate', or 'unknown' for an argument node."""
    # Bare name: foo
    if isinstance(node, ast.Name):
        if node.id in BASE_NAMES:
            return "base"
        if node.id in CANDIDATE_NAMES:
            return "candidate"
        # Suffix heuristic — anything ending _root or _dir leans base
        if node.id.endswith(("_root", "_dir")):
            return "base"
        if node.id.endswith("_path"):
            return "candidate"
        return "unknown"
    # Attribute: settings.media_path / s.config_dir
    if isinstance(node, ast.Attribute):
        if node.attr in BASE_NAMES or node.attr.endswith(("_root", "_dir")):
            return "base"
        if node.attr in CANDIDATE_NAMES or node.attr.endswith("_path"):
            return "candidate"
        return "unknown"
    # getattr(settings, "media_path", "/media")
    if (
        isinstance(node, ast.Call)
        and isinstance(node.func, ast.Name)
        and node.func.id == "getattr"
        and len(node.args) >= 2
        and isinstance(node.args[1], ast.Constant)
        and isinstance(node.args[1].value, str)
    ):
        return _classify(ast.Name(id=node.args[1].value, ctx=ast.Load()))
    # String literal — bases sometimes appear as "/media" or similar.
    if isinstance(node, ast.Constant) and isinstance(node.value, str):
        # Plain absolute paths only — leans base. user-controlled strings
        # would normally arrive as variables, not literals.
        if node.value.startswith("/"):
            return "base"
        return "unknown"
    return "unknown"


class IsSafePathChecker(ast.NodeVisitor):
    def __init__(self, file_path: Path) -> None:
        self.file_path = file_path
        self.violations: list[tuple[int, str, str]] = []

    def visit_Call(self, node: ast.Call) -> None:  # noqa: N802 — ast API
        # Match is_safe_path(...) and security_utils.is_safe_path(...)
        if isinstance(node.func, ast.Name):
            name = node.func.id
        elif isinstance(node.func, ast.Attribute):
            name = node.func.attr
        else:
            self.generic_visit(node)
            return

        if name == "is_safe_path" and len(node.args) >= 2:
            first_kind = _classify(node.args[0])
            second_kind = _classify(node.args[1])
            if first_kind == "base" and second_kind == "candidate":
                self.violations.append(
                    (
                        node.lineno,
                        ast.unparse(node.args[0]),
                        ast.unparse(node.args[1]),
                    )
                )
        self.generic_visit(node)


_SKIP_DIR_PARTS = {"venv", ".venv", "site-packages", "__pycache__", "node_modules"}


def scan(root: Path) -> list[tuple[Path, int, str, str]]:
    findings: list[tuple[Path, int, str, str]] = []
    for py in root.rglob("*.py"):
        # Skip the linter itself, vendored deps, virtualenvs, build artifacts.
        parts = set(py.parts)
        if parts & _SKIP_DIR_PARTS:
            continue
        rel = py.relative_to(root).as_posix()
        if rel.startswith("tools/lint_is_safe_path") or rel.startswith("tests/"):
            continue
        try:
            tree = ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
        except (SyntaxError, UnicodeDecodeError):
            continue
        checker = IsSafePathChecker(py)
        checker.visit(tree)
        for line, first, second in checker.violations:
            findings.append((py, line, first, second))
    return findings


def main(argv: list[str]) -> int:
    root = Path(argv[1]) if len(argv) > 1 else Path(__file__).resolve().parent.parent
    findings = scan(root)
    if not findings:
        print(f"is_safe_path linter: clean ({root})")
        return 0
    print(f"is_safe_path linter: {len(findings)} reversed-arg violation(s):")
    for path, line, first, second in findings:
        rel = path.relative_to(root.parent) if path.is_absolute() else path
        print(f"  {rel}:{line}: is_safe_path({first}, {second})")
        print("    -> first arg looks like a TRUST BASE; second looks USER-CONTROLLED.")
        print("       Expected: is_safe_path(candidate, base_dir).")
    return 1


if __name__ == "__main__":  # pragma: no cover
    sys.exit(main(sys.argv))