Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion src/opsscript_gate/__init__.py
Original file line number Diff line number Diff line change
@@ -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__"]
2 changes: 1 addition & 1 deletion src/opsscript_gate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
""",
Expand Down
3 changes: 3 additions & 0 deletions src/opsscript_gate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {
Expand All @@ -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


Expand Down
84 changes: 84 additions & 0 deletions src/opsscript_gate/remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 5 additions & 2 deletions src/opsscript_gate/reporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion src/opsscript_gate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -253,6 +254,7 @@ def extract_failure_diagnostic(
line=line_num,
distro=distro,
hint=hint,
suggested_package=suggested_pkg,
)

return None
Expand Down
120 changes: 117 additions & 3 deletions tests/test_audit_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Loading