From 75c3457b44510b518a52a055c3b4f5176f0ac434 Mon Sep 17 00:00:00 2001 From: Mresyzz Date: Sun, 11 Oct 2026 19:03:57 +0800 Subject: [PATCH] feat: add cross-distro package recommendations and bump version to v0.9.3 --- CHANGELOG.md | 14 +++- pyproject.toml | 2 +- src/opsscript_gate/__init__.py | 2 +- src/opsscript_gate/config.py | 2 +- src/opsscript_gate/models.py | 3 + src/opsscript_gate/remediation.py | 84 +++++++++++++++++++++ src/opsscript_gate/reporter.py | 7 +- src/opsscript_gate/runner.py | 4 +- tests/test_audit_coverage.py | 120 +++++++++++++++++++++++++++++- 9 files changed, 228 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1019e9a..52696a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog -## [0.9.2] - Unreleased +## [0.9.3] - 2026-10-11 + +### Added +- Added cross-distribution package name mapping and actionable remediation suggestions (`apt-get`, `apk`, `dnf`) for common missing operations commands (`curl`, `wget`, `jq`, `git`, `tar`, `python3`, `pip`, `sudo`, `make`, `gcc`, etc.). +- Extended `FailureDiagnostic` and SARIF output with structured `suggested_package` property. + +### Fixed +- Fixed unreadable shell script candidate misclassification during discovery. +- Ensured temporary script files are safely cleaned up during distribution preparation errors. +- Added preflight validation to reject directory and missing script paths before creating worker threads. +- Enforced consistent POSIX LF line endings in report output files across platforms. + +## [0.9.2] - 2026-10-05 ### Fixed - Hardened SARIF, Markdown, terminal, and machine-readable output handling. diff --git a/pyproject.toml b/pyproject.toml index 449dc81..012386c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "opsscript-gate" -version = "0.9.2" +version = "0.9.3" description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found." readme = "README.md" license = { text = "MIT" } diff --git a/src/opsscript_gate/__init__.py b/src/opsscript_gate/__init__.py index 4bac816..185d856 100644 --- a/src/opsscript_gate/__init__.py +++ b/src/opsscript_gate/__init__.py @@ -1,4 +1,4 @@ """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts.""" -__version__ = "0.9.2" +__version__ = "0.9.3" __all__ = ["__version__"] diff --git a/src/opsscript_gate/config.py b/src/opsscript_gate/config.py index 48c7d9b..9089056 100644 --- a/src/opsscript_gate/config.py +++ b/src/opsscript_gate/config.py @@ -126,7 +126,7 @@ def init_project() -> list[str]: - uses: actions/checkout@v7 with: persist-credentials: false - - uses: Mresyzz/opsscript-gate@v0.9.2 + - uses: Mresyzz/opsscript-gate@v0.9.3 with: config: .opsscript-gate.json """, diff --git a/src/opsscript_gate/models.py b/src/opsscript_gate/models.py index 5a87808..81b8643 100644 --- a/src/opsscript_gate/models.py +++ b/src/opsscript_gate/models.py @@ -30,6 +30,7 @@ class FailureDiagnostic: line: int | None = None distro: str | None = None hint: str | None = None + suggested_package: str | None = None def to_dict(self) -> dict[str, Any]: d: dict[str, Any] = { @@ -44,6 +45,8 @@ def to_dict(self) -> dict[str, Any]: d["distro"] = self.distro if self.hint is not None: d["hint"] = self.hint + if self.suggested_package is not None: + d["suggested_package"] = self.suggested_package return d diff --git a/src/opsscript_gate/remediation.py b/src/opsscript_gate/remediation.py index 01ae836..7e3d9bb 100644 --- a/src/opsscript_gate/remediation.py +++ b/src/opsscript_gate/remediation.py @@ -90,6 +90,90 @@ def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bo return False +# Mapping of common ops CLI command binaries to distro-specific package names. +# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux). +COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = { + "curl": {"debian": "curl", "alpine": "curl"}, + "wget": {"debian": "wget", "alpine": "wget"}, + "jq": {"debian": "jq", "alpine": "jq"}, + "git": {"debian": "git", "alpine": "git"}, + "tar": {"debian": "tar", "alpine": "tar"}, + "gzip": {"debian": "gzip", "alpine": "gzip"}, + "bzip2": {"debian": "bzip2", "alpine": "bzip2"}, + "xz": {"debian": "xz-utils", "alpine": "xz"}, + "unzip": {"debian": "unzip", "alpine": "unzip"}, + "zip": {"debian": "zip", "alpine": "zip"}, + "python": {"debian": "python3", "alpine": "python3"}, + "python3": {"debian": "python3", "alpine": "python3"}, + "pip": {"debian": "python3-pip", "alpine": "py3-pip"}, + "pip3": {"debian": "python3-pip", "alpine": "py3-pip"}, + "sudo": {"debian": "sudo", "alpine": "sudo"}, + "bash": {"debian": "bash", "alpine": "bash"}, + "make": {"debian": "build-essential", "alpine": "build-base"}, + "gcc": {"debian": "gcc", "alpine": "gcc"}, + "ca-certificates": {"debian": "ca-certificates", "alpine": "ca-certificates"}, + "openssl": {"debian": "openssl", "alpine": "openssl"}, + "nc": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd"}, + "netcat": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd"}, + "socat": {"debian": "socat", "alpine": "socat"}, + "envsubst": {"debian": "gettext-base", "alpine": "gettext"}, + "crontab": {"debian": "cron", "alpine": "dcron"}, + "ps": {"debian": "procps", "alpine": "procps"}, +} + + +def detect_distro_family(distro: str | None) -> str: + """Identify the distribution family for package manager commands.""" + if not distro: + return "unknown" + d = distro.lower() + if "alpine" in d: + return "alpine" + if any(name in d for name in ("debian", "ubuntu", "mint")): + return "debian" + if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")): + return "rhel" + return "unknown" + + +def resolve_package_recommendation( + command: str | None, + distro: str | None, +) -> tuple[str | None, str | None]: + """ + Resolve suggested package name and actionable install command for a missing command. + Returns (suggested_package_name, install_command). + """ + if not command: + return None, None + norm_cmd = command.lower().replace("\\", "/").split("/")[-1] + if norm_cmd.endswith(".exe"): + norm_cmd = norm_cmd[:-4] + + packages = COMMON_COMMAND_PACKAGES.get(norm_cmd) + if not packages: + return None, None + + family = detect_distro_family(distro) + package_name = packages.get(family) + if not package_name and family in ("unknown", "rhel"): + package_name = packages.get("debian") or packages.get("alpine") + + if not package_name: + return None, None + + if family == "alpine": + install_cmd = f"apk add --no-cache {package_name}" + elif family == "debian": + install_cmd = f"apt-get install -y {package_name}" + elif family == "rhel": + install_cmd = f"dnf install -y {package_name}" + else: + install_cmd = None + + return package_name, install_cmd + + REMEDIATION_RULES: list[RemediationRule] = [ RemediationRule( rule_id="alpine_apt", diff --git a/src/opsscript_gate/reporter.py b/src/opsscript_gate/reporter.py index f4e66df..10e7e7a 100644 --- a/src/opsscript_gate/reporter.py +++ b/src/opsscript_gate/reporter.py @@ -582,8 +582,11 @@ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] | } if result.exit_code is not None: properties["exitCode"] = result.exit_code - if result.diagnostic and result.diagnostic.command: - properties["command"] = sanitize_display_text(result.diagnostic.command) + if result.diagnostic: + if result.diagnostic.command: + properties["command"] = sanitize_display_text(result.diagnostic.command) + if result.diagnostic.suggested_package: + properties["suggestedPackage"] = sanitize_display_text(result.diagnostic.suggested_package) return { "ruleId": rule_id, diff --git a/src/opsscript_gate/runner.py b/src/opsscript_gate/runner.py index aded70d..bfec2d4 100644 --- a/src/opsscript_gate/runner.py +++ b/src/opsscript_gate/runner.py @@ -18,7 +18,7 @@ from enum import Enum from opsscript_gate.config import MAX_MATRIX_IMAGES, MAX_REPEAT_ATTEMPTS, MAX_TIMEOUT_SECONDS, PACKAGE_NAME_RE, validate_memory_limit, validate_packages from opsscript_gate.models import DistroStatus, FailureDiagnostic, RunReport, ShellMode, SingleResult -from opsscript_gate.remediation import generate_remediation_hint +from opsscript_gate.remediation import generate_remediation_hint, resolve_package_recommendation DEFAULT_MATRIX: list[str] = [ "debian:12-slim", @@ -245,6 +245,7 @@ def extract_failure_diagnostic( command=sanitized_cmd, kind="missing_command", ) + suggested_pkg, _ = resolve_package_recommendation(sanitized_cmd, distro) return FailureDiagnostic( kind="missing_command", @@ -253,6 +254,7 @@ def extract_failure_diagnostic( line=line_num, distro=distro, hint=hint, + suggested_package=suggested_pkg, ) return None diff --git a/tests/test_audit_coverage.py b/tests/test_audit_coverage.py index 979ee0c..19cd6d9 100644 --- a/tests/test_audit_coverage.py +++ b/tests/test_audit_coverage.py @@ -279,13 +279,35 @@ def test_remediation_interpreter_matchers(): def test_remediation_hint_for_interpreter(): - # 测试通过顶级接口 generate_remediation_hint 生成解释器修复建议 - hint = generate_remediation_hint( + # kind == "missing_interpreter" 优先级高于普通命令 + hint_kind = generate_remediation_hint( distro="ubuntu:22.04", command="/usr/bin/python3", + kind="missing_interpreter", + ) + assert hint_kind == "Ensure the required interpreter is installed or adjust the shebang path." + + # 未被包名映射接管的解释器路径(如 zsh)应触发解释器修复建议 + hint_zsh = generate_remediation_hint( + distro="ubuntu:22.04", + command="/usr/bin/zsh", kind="missing_command", ) - assert hint == "Ensure the required interpreter is installed or adjust the shebang path." + assert hint_zsh == "Ensure the required interpreter is installed or adjust the shebang path." + + # 包含在解释器规则中的路径(如 python3),generate_remediation_hint 保持保守提示 + hint_py = generate_remediation_hint( + distro="ubuntu:22.04", + command="/usr/bin/python3", + kind="missing_command", + ) + assert hint_py == "Ensure the required interpreter is installed or adjust the shebang path." + + # resolve_package_recommendation 则针对该命令提供精准的跨发行版包名与安装命令 + from opsscript_gate.remediation import resolve_package_recommendation + pkg_py, cmd_py = resolve_package_recommendation("/usr/bin/python3", "ubuntu:22.04") + assert pkg_py == "python3" + assert "apt-get install -y python3" in cmd_py # 不匹配时返回 None assert generate_remediation_hint(distro="ubuntu:22.04", command="/usr/bin/unknown", kind="missing_command") is None @@ -365,3 +387,95 @@ def test_publish_output_preserves_posix_newline(tmp_path, capsys): raw_bytes = out_file.read_bytes() assert raw_bytes == b"hello\nworld\n" assert b"\r" not in raw_bytes + + +# ============================================================================== +# 6. 跨发行版包名推荐与自动安装建议功能测试 +# ============================================================================== + +def test_detect_distro_family(): + from opsscript_gate.remediation import detect_distro_family + + assert detect_distro_family("alpine:3.20") == "alpine" + assert detect_distro_family("debian:12-slim") == "debian" + assert detect_distro_family("ubuntu:22.04") == "debian" + assert detect_distro_family("fedora:40") == "rhel" + assert detect_distro_family("centos:7") == "rhel" + assert detect_distro_family("rocky:9") == "rhel" + assert detect_distro_family("archlinux:latest") == "unknown" + assert detect_distro_family(None) == "unknown" + + +def test_resolve_package_recommendation(): + from opsscript_gate.remediation import resolve_package_recommendation + + # 跨发行版包名差异测试:pip 在 Alpine 为 py3-pip,在 Debian 为 python3-pip + alp_pip, alp_cmd = resolve_package_recommendation("pip", "alpine:3.20") + assert alp_pip == "py3-pip" + assert alp_cmd == "apk add --no-cache py3-pip" + + deb_pip, deb_cmd = resolve_package_recommendation("pip", "debian:12-slim") + assert deb_pip == "python3-pip" + assert deb_cmd == "apt-get install -y python3-pip" + + # xz 在 Debian 为 xz-utils,在 Alpine 为 xz + deb_xz, deb_xz_cmd = resolve_package_recommendation("xz", "ubuntu:24.04") + assert deb_xz == "xz-utils" + assert deb_xz_cmd == "apt-get install -y xz-utils" + + alp_xz, alp_xz_cmd = resolve_package_recommendation("xz", "alpine:3.20") + assert alp_xz == "xz" + assert alp_xz_cmd == "apk add --no-cache xz" + + # 常见运维命令 jq, curl, git + jq_pkg, jq_cmd = resolve_package_recommendation("jq", "alpine:3.20") + assert jq_pkg == "jq" + assert jq_cmd == "apk add --no-cache jq" + + # 未知命令或空输入 + assert resolve_package_recommendation("unknown_custom_tool", "debian:12-slim") == (None, None) + assert resolve_package_recommendation(None, "debian:12-slim") == (None, None) + + +def test_resolve_package_recommendation_install_commands(): + from opsscript_gate.remediation import resolve_package_recommendation, generate_remediation_hint + + # 针对 jq 的精准包名与安装命令 + pkg_alp, cmd_alp = resolve_package_recommendation("jq", distro="alpine:3.20") + assert pkg_alp == "jq" + assert cmd_alp == "apk add --no-cache jq" + + pkg_deb, cmd_deb = resolve_package_recommendation("jq", distro="debian:12-slim") + assert pkg_deb == "jq" + assert cmd_deb == "apt-get install -y jq" + + # 确保原有包管理器误用规则依然优先且行为稳定 + hint_apt = generate_remediation_hint(distro="alpine:3.20", command="apt-get", kind="missing_command") + assert hint_apt == "Alpine normally uses apk instead of apt-get." + + +def test_failure_diagnostic_and_sarif_suggested_package(): + from opsscript_gate.models import FailureDiagnostic, SingleResult, RunReport, DistroStatus + from opsscript_gate.reporter import format_sarif + + diag = FailureDiagnostic( + kind="missing_command", + command="jq", + message="command not found: jq", + suggested_package="jq", + ) + d_dict = diag.to_dict() + assert d_dict.get("suggested_package") == "jq" + + res = SingleResult( + distro="alpine:3.20", + status=DistroStatus.FAIL, + exit_code=127, + duration=0.1, + diagnostic=diag, + ) + report = RunReport(results=[res], total_duration=0.1) + sarif = json.loads(format_sarif(report, script_path="deploy.sh")) + props = sarif["runs"][0]["results"][0]["properties"] + assert props.get("command") == "jq" + assert props.get("suggestedPackage") == "jq"