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
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ jobs:
echo "Tag $TARGET_TAG was released from earlier commit ($TAG_SHA != ${{ github.sha }}). Skipping release."
fi
else
echo "Target tag $TARGET_TAG does not exist. Creating it and handing publication to the tag workflow."
echo "Target tag $TARGET_TAG does not exist. Creating tag and publishing release."
IS_NEW_RELEASE="true"
SHOULD_TAG="true"
fi
else
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# Changelog

## [0.9.4] - 2026-10-11

### Fixed
- Fixed release workflow publishing deadlock where `main` pushes skipped distribution package builds and release creation due to GITHUB_TOKEN tag events not triggering new workflows.
- Unified command path extraction in remediation rules to ensure full-path commands (e.g. `/usr/bin/apt-get`, `/sbin/apk`, `/bin/yum`, `/usr/local/bin/bash`) accurately match package manager and shell hints.
- Added native RHEL/Fedora/CentOS package mappings in `COMMON_COMMAND_PACKAGES` to prevent fallback to invalid Debian package names.
- Added native container package pre-installation support for `dnf`, `microdnf`, and `yum` in `runner._wrap_with_package_setup`.
- Fixed `RunReport` and `MultiScriptReport` `__post_init__` to preserve explicitly passed `all_passed=False` when result collections are empty.
- Supported bare directory exclusions (e.g. `tests` or `tests/`) in `_matches_exclude` to match both directories and all contained files.

## [0.9.3] - 2026-10-11

### Added
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.3"
version = "0.9.4"
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.3"
__version__ = "0.9.4"
__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.3
- uses: Mresyzz/opsscript-gate@v0.9.4
with:
config: .opsscript-gate.json
""",
Expand Down
7 changes: 6 additions & 1 deletion src/opsscript_gate/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@


def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool:
"""Match exclusions consistently whether callers use ``foo.sh``, ``./foo.sh``, or ``/foo.sh``."""
"""Match exclusions consistently whether callers use ``foo.sh``, ``./foo.sh``, ``/foo.sh``, or directory names like ``tests``."""
normalized_path = rel_path.replace("\\", "/")
while normalized_path.startswith("./"):
normalized_path = normalized_path[2:]
Expand All @@ -51,8 +51,13 @@ def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool:
while normalized_pattern.startswith("./"):
normalized_pattern = normalized_pattern[2:]
normalized_pattern = normalized_pattern.lstrip("/")
if not normalized_pattern:
continue
if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
return True
pat = normalized_pattern.rstrip("/")
if normalized_path == pat or normalized_path.startswith(f"{pat}/") or fnmatch.fnmatchcase(normalized_path, f"{pat}/*"):
return True
return False


Expand Down
4 changes: 0 additions & 4 deletions src/opsscript_gate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,6 @@ class RunReport:
def __post_init__(self) -> None:
if self.results:
self.all_passed = all(r.status == DistroStatus.PASS for r in self.results)
else:
self.all_passed = True

def to_dict(self) -> dict[str, Any]:
return {
Expand All @@ -134,8 +132,6 @@ class MultiScriptReport:
def __post_init__(self) -> None:
if self.reports:
self.all_passed = all(r.all_passed for r in self.reports.values())
else:
self.all_passed = True

def to_dict(self) -> dict[str, Any]:
return {
Expand Down
100 changes: 47 additions & 53 deletions src/opsscript_gate/remediation.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,113 +12,107 @@ class RemediationRule:
hint: str


def _normalize_cmd_basename(command: str | None) -> str:
"""Normalize command path and extract clean executable basename."""
if not command:
return ""
norm = command.lower().replace("\\", "/").split("/")[-1]
if norm.endswith(".exe"):
norm = norm[:-4]
return norm


def _match_alpine_apt(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
return "alpine" in distro.lower() and command.lower() in ("apt", "apt-get")
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("apt", "apt-get")


def _match_debian_ubuntu_apk(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
return ("debian" in d or "ubuntu" in d) and command.lower() == "apk"
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) == "apk"


def _match_debian_ubuntu_yum(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
return ("debian" in d or "ubuntu" in d) and command.lower() in ("yum", "dnf")
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) in ("yum", "dnf")


def _match_alpine_yum(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
return "alpine" in distro.lower() and command.lower() in ("yum", "dnf")
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("yum", "dnf")


def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
d = distro.lower()
is_rhel = any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma"))
return is_rhel and command.lower() in ("apt", "apt-get", "apk")

return is_rhel and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk")


def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bool:
if not distro or not command:
return False
return "alpine" in distro.lower() and command.lower() in ("bash", "/bin/bash", "/usr/bin/bash")
return "alpine" in distro.lower() and _normalize_cmd_basename(command) == "bash"


def _match_curl(distro: str | None, command: str | None, kind: str) -> bool:
if not command:
return False
normalized = command.lower().replace("\\", "/").split("/")[-1]
if normalized.endswith(".exe"):
normalized = normalized[:-4]
return normalized == "curl"
return _normalize_cmd_basename(command) == "curl"


def _match_wget(distro: str | None, command: str | None, kind: str) -> bool:
if not command:
return False
normalized = command.lower().replace("\\", "/").split("/")[-1]
if normalized.endswith(".exe"):
normalized = normalized[:-4]
return normalized == "wget"
return _normalize_cmd_basename(command) == "wget"


def _match_sudo(distro: str | None, command: str | None, kind: str) -> bool:
if not command:
return False
normalized = command.lower().replace("\\", "/").split("/")[-1]
if normalized.endswith(".exe"):
normalized = normalized[:-4]
return normalized == "sudo"
return _normalize_cmd_basename(command) == "sudo"


def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bool:
if kind == "missing_interpreter":
return True
if command and (command.startswith("/bin/") or command.startswith("/usr/bin/")):
base = command.split("/")[-1]
base = _normalize_cmd_basename(command)
if base in ("bash", "sh", "zsh", "python", "python3", "perl"):
return True
return False


# Mapping of common ops CLI command binaries to distro-specific package names.
# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux).
# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux), "rhel" (Fedora/CentOS/RHEL).
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"},
"curl": {"debian": "curl", "alpine": "curl", "rhel": "curl"},
"wget": {"debian": "wget", "alpine": "wget", "rhel": "wget"},
"jq": {"debian": "jq", "alpine": "jq", "rhel": "jq"},
"git": {"debian": "git", "alpine": "git", "rhel": "git"},
"tar": {"debian": "tar", "alpine": "tar", "rhel": "tar"},
"gzip": {"debian": "gzip", "alpine": "gzip", "rhel": "gzip"},
"bzip2": {"debian": "bzip2", "alpine": "bzip2", "rhel": "bzip2"},
"xz": {"debian": "xz-utils", "alpine": "xz", "rhel": "xz"},
"unzip": {"debian": "unzip", "alpine": "unzip", "rhel": "unzip"},
"zip": {"debian": "zip", "alpine": "zip", "rhel": "zip"},
"python": {"debian": "python3", "alpine": "python3", "rhel": "python3"},
"python3": {"debian": "python3", "alpine": "python3", "rhel": "python3"},
"pip": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"},
"pip3": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"},
"sudo": {"debian": "sudo", "alpine": "sudo", "rhel": "sudo"},
"bash": {"debian": "bash", "alpine": "bash", "rhel": "bash"},
"make": {"debian": "build-essential", "alpine": "build-base", "rhel": "make"},
"gcc": {"debian": "gcc", "alpine": "gcc", "rhel": "gcc"},
"ca-certificates": {"debian": "ca-certificates", "alpine": "ca-certificates", "rhel": "ca-certificates"},
"openssl": {"debian": "openssl", "alpine": "openssl", "rhel": "openssl"},
"nc": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"},
"netcat": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"},
"socat": {"debian": "socat", "alpine": "socat", "rhel": "socat"},
"envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
"crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
"ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
}


Expand Down
8 changes: 7 additions & 1 deletion src/opsscript_gate/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,13 @@ def _wrap_with_package_setup(command: Sequence[str], packages: Sequence[str]) ->
"(rm -rf /tmp/opsscript-apt-archives /var/lib/apt/lists/* || true) || setup_failed; "
"elif command -v apk >/dev/null 2>&1; then "
f"apk add --no-cache {package_args} || setup_failed; "
f"else echo '{PACKAGE_SETUP_ERROR_MARKER} no supported package manager found (apt-get or apk)' >&2; exit 125; fi"
"elif command -v dnf >/dev/null 2>&1; then "
f"dnf install -y --setopt=install_weak_deps=False {package_args} || setup_failed; "
"elif command -v microdnf >/dev/null 2>&1; then "
f"microdnf install -y {package_args} || setup_failed; "
"elif command -v yum >/dev/null 2>&1; then "
f"yum install -y {package_args} || setup_failed; "
f"else echo '{PACKAGE_SETUP_ERROR_MARKER} no supported package manager found (apt-get, apk, dnf, microdnf, yum)' >&2; exit 125; fi"
)
target_command = " ".join(shlex.quote(part) for part in command)
return ["/bin/sh", "-c", f"{install} && exec {target_command}"]
Expand Down
96 changes: 96 additions & 0 deletions tests/test_audit_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -479,3 +479,99 @@ def test_failure_diagnostic_and_sarif_suggested_package():
props = sarif["runs"][0]["results"][0]["properties"]
assert props.get("command") == "jq"
assert props.get("suggestedPackage") == "jq"


# ==============================================================================
# 7. 缺陷深度修复专项回归测试
# ==============================================================================

def test_remediation_matchers_with_absolute_paths():
from opsscript_gate.remediation import generate_remediation_hint

# 验证全路径命令(如 /usr/bin/apt-get, /sbin/apk, /bin/yum, /usr/local/bin/bash)均能精准命中
assert generate_remediation_hint(
distro="alpine:3.20", command="/usr/bin/apt-get", kind="missing_command"
) == "Alpine normally uses apk instead of apt-get."

assert generate_remediation_hint(
distro="ubuntu:22.04", command="/sbin/apk", kind="missing_command"
) == "Debian/Ubuntu normally uses APT (apt-get) instead of apk."

assert generate_remediation_hint(
distro="debian:12-slim", command="/usr/bin/yum", kind="missing_command"
) == "Debian/Ubuntu normally uses APT (apt-get) instead of yum or dnf."

assert generate_remediation_hint(
distro="alpine:3.20", command="/usr/bin/yum", kind="missing_command"
) == "Alpine normally uses apk instead of yum or dnf."

assert generate_remediation_hint(
distro="fedora:40", command="/usr/bin/apt-get", kind="missing_command"
) == "RHEL/Fedora family distributions normally use dnf or yum instead of apt-get or apk."

assert generate_remediation_hint(
distro="alpine:3.20", command="/usr/local/bin/bash", kind="missing_command"
) == "Alpine minimal images do not include Bash by default; consider using POSIX sh or installing bash."


def test_rhel_package_recommendations():
from opsscript_gate.remediation import resolve_package_recommendation

# RHEL 原生包名映射
pkg_make, cmd_make = resolve_package_recommendation("make", distro="fedora:40")
assert pkg_make == "make"
assert cmd_make == "dnf install -y make"

pkg_xz, cmd_xz = resolve_package_recommendation("xz", distro="rocky:9")
assert pkg_xz == "xz"
assert cmd_xz == "dnf install -y xz"

pkg_nc, cmd_nc = resolve_package_recommendation("nc", distro="centos:7")
assert pkg_nc == "nmap-ncat"
assert cmd_nc == "dnf install -y nmap-ncat"

pkg_cron, cmd_cron = resolve_package_recommendation("crontab", distro="rhel:9")
assert pkg_cron == "cronie"
assert cmd_cron == "dnf install -y cronie"

# 全路径命令也可以正常识别
pkg_curl, cmd_curl = resolve_package_recommendation("/usr/bin/curl", distro="fedora:40")
assert pkg_curl == "curl"
assert cmd_curl == "dnf install -y curl"


def test_package_setup_rhel_package_managers():
from opsscript_gate.runner import _wrap_with_package_setup

wrapped = _wrap_with_package_setup(["/bin/sh", "-c", "echo 1"], ["curl", "jq"])
cmd_str = " ".join(wrapped)
# 验证生成的预装包装命令已包含 dnf、microdnf、yum 支持
assert "command -v dnf" in cmd_str
assert "command -v microdnf" in cmd_str
assert "command -v yum" in cmd_str


def test_run_report_empty_explicit_all_passed():
from opsscript_gate.models import RunReport, MultiScriptReport

# 验证空结果集但显式指定 all_passed=False 时不被篡改为 True
report = RunReport(results=[], all_passed=False)
assert report.all_passed is False

multi = MultiScriptReport(reports={}, all_passed=False)
assert multi.all_passed is False

# 默认值依然保持 True
assert RunReport().all_passed is True
assert MultiScriptReport().all_passed is True


def test_discovery_matches_exclude_directory():
from opsscript_gate.discovery import _matches_exclude

# 验证目录名排除规则能够正确匹配目录本身及目录下所有文件
assert _matches_exclude("tests/foo.sh", ["tests"]) is True
assert _matches_exclude("tests/sub/bar.sh", ["tests"]) is True
assert _matches_exclude("tests/foo.sh", ["tests/"]) is True
assert _matches_exclude("tests/foo.sh", ["./tests/"]) is True
assert _matches_exclude("other/foo.sh", ["tests"]) is False
Loading