Ctrl K
ComunidadGuías…

scripts/audit_destructive_sinks.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Require an explicit safety justification on raw recursive deletion commands."""

from __future__ import annotations

import re
import shlex
import sys
from pathlib import Path


PROJECT_ROOT = Path(__file__).resolve().parents[1]
SAFE_MARKER = re.compile(r"\s#\s*SAFE:\s+\S")
RM_COMMAND = re.compile(r"(?<![A-Za-z0-9_])(?:(?:/usr)?/bin/)?r\\?m(?=\s)")
FIND_DELETE = re.compile(r"(?:^|\s)-delete(?=\s|[;&|)]|$)")
OUTPUT_ONLY_PREFIXES = ("echo ", "echo\t", "printf ", "printf\t", "log_")


def default_sources() -> list[Path]:
    sources = [PROJECT_ROOT / "mole", PROJECT_ROOT / "install.sh"]
    for directory in ("bin", "lib", "scripts"):
        sources.extend((PROJECT_ROOT / directory).rglob("*.sh"))
    return sorted({path.resolve() for path in sources if path.is_file()})


def shell_words(fragment: str) -> list[str]:
    try:
        lexer = shlex.shlex(fragment, posix=True, punctuation_chars=";&|")
        lexer.whitespace_split = True
        lexer.commenters = "#"
        return list(lexer)
    except ValueError:
        # A malformed or multi-line quoting context is still inspected by the
        # conservative token fallback rather than disappearing from the gate.
        return fragment.split()


def is_output_only_reference(command: str, match_start: int) -> bool:
    prefix = command[:match_start].lstrip()
    if not prefix.startswith(OUTPUT_ONLY_PREFIXES):
        return False
    # A command separator before the sink means the output helper finished and
    # the later rm/find is executable code, not prose inside its argument.
    return re.search(r"(?:;|&&|\|\||\||\$\()", prefix) is None


def has_recursive_force_rm(command: str) -> bool:
    for match in RM_COMMAND.finditer(command):
        if is_output_only_reference(command, match.start()):
            continue
        flags: set[str] = set()
        for token in shell_words(command[match.end() :]):
            if token in {";", "&&", "||", "|"}:
                break
            if token == "--":
                continue
            if token == "--force":
                flags.add("f")
                continue
            if token == "--recursive":
                flags.add("r")
                continue
            if token.startswith("-") and not token.startswith("--"):
                flags.update(token[1:])
                continue
            # Redirections can appear after the target. They do not change the
            # rm option set, but the first ordinary argv ends option parsing.
            if token[:1].isdigit() and ">" in token:
                continue
            # rm option parsing remains active until an explicit `--`; an
            # option-looking token after another operand is still dangerous.
            continue
        if "f" in flags and ("r" in flags or "R" in flags):
            return True
    return False


def has_find_delete(command: str) -> bool:
    return any(
        not is_output_only_reference(command, match.start())
        for match in FIND_DELETE.finditer(command)
    )


def has_unquoted_comment(line: str) -> bool:
    single_quoted = False
    double_quoted = False
    escaped = False
    for index, char in enumerate(line):
        if escaped:
            escaped = False
            continue
        if char == "\\" and not single_quoted:
            escaped = True
            continue
        if char == "'" and not double_quoted:
            single_quoted = not single_quoted
            continue
        if char == '"' and not single_quoted:
            double_quoted = not double_quoted
            continue
        if (
            char == "#"
            and not single_quoted
            and not double_quoted
            and (index == 0 or line[index - 1].isspace())
        ):
            return True
    return False


def logical_blocks(lines: list[str]) -> list[tuple[int, str, list[str]]]:
    blocks: list[tuple[int, str, list[str]]] = []
    start = 1
    physical: list[str] = []
    joined: list[str] = []
    for line_number, line in enumerate(lines, start=1):
        if not physical:
            start = line_number
        physical.append(line)
        stripped = line.rstrip()
        if stripped.endswith("\\") and not has_unquoted_comment(stripped):
            joined.append(stripped[:-1])
            continue
        joined.append(line)
        blocks.append((start, " ".join(joined), list(physical)))
        physical.clear()
        joined.clear()
    if physical:
        blocks.append((start, " ".join(joined), list(physical)))
    return blocks


def inspect_file(path: Path) -> list[tuple[int, str]]:
    try:
        lines = path.read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeError) as exc:
        return [(0, f"could not read source: {exc}")]

    findings: list[tuple[int, str]] = []
    for line_number, command, physical_lines in logical_blocks(lines):
        stripped = command.lstrip()
        if not stripped or stripped.startswith("#"):
            continue
        recursive_rm = has_recursive_force_rm(command)
        find_delete = has_find_delete(command)
        if not recursive_rm and not find_delete:
            continue
        if recursive_rm:
            annotated = any(
                RM_COMMAND.search(line) and SAFE_MARKER.search(line)
                for line in physical_lines
            )
        else:
            annotated = any(
                FIND_DELETE.search(line) and SAFE_MARKER.search(line)
                for line in physical_lines
            )
        if annotated:
            continue
        kind = "raw recursive rm" if recursive_rm else "raw find -delete"
        findings.append(
            (line_number, f"{kind} requires '# SAFE: <one-sentence reason>'")
        )
    return findings


def main(argv: list[str]) -> int:
    sources = [Path(arg).resolve() for arg in argv] if argv else default_sources()
    findings: list[tuple[Path, int, str]] = []
    for source in sources:
        if not source.is_file():
            findings.append((source, 0, "source file does not exist"))
            continue
        findings.extend((source, line, message) for line, message in inspect_file(source))

    if findings:
        for source, line, message in findings:
            print(f"{source}:{line}: {message}")
        return 1

    print(f"destructive-sink-audit-ok files={len(sources)}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))