opsscript-gate 0.9.2__tar.gz → 0.9.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/release.yml +2 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.gitignore +1 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CHANGELOG.md +23 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/PKG-INFO +1 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/pyproject.toml +1 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/__init__.py +1 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/cli.py +26 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/config.py +1 -1
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/discovery.py +15 -7
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/models.py +3 -4
- opsscript_gate-0.9.4/src/opsscript_gate/remediation.py +239 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/reporter.py +25 -12
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/runner.py +92 -52
- opsscript_gate-0.9.4/tests/conftest.py +17 -0
- opsscript_gate-0.9.4/tests/test_audit_coverage.py +577 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/test_runner.py +94 -0
- opsscript_gate-0.9.2/.ai/v0.9.2_issues_report.md +0 -90
- opsscript_gate-0.9.2/src/opsscript_gate/remediation.py +0 -161
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.gitattributes +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/assets/social-preview.png +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/pull_request_template.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflow-templates/opsscript-gate.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/demo.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/deploy-pages.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/test.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CITATION.cff +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CONTRIBUTING.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/LICENSE +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/README.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/README.zh-CN.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/ROADMAP.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/SECURITY.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/action.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/demo.html +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/command-not-found.html +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/command-not-found.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/configuration.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/gpt-oss.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/index.html +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/llms.txt +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/robots.txt +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/sitemap.xml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/troubleshooting.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/alpine-incompatibility/README.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/basic/README.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/basic/clean_setup.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/github-actions/README.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/github-actions/workflow.yml +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/interactive-hang/README.md +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/interactive-hang/prompt_confirm.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/index.html +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/llms.txt +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/fail_deps.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/fail_hang.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/pass_basic.sh +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/test_action_contract.py +0 -0
- {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/test_onboarding.py +0 -0
|
@@ -128,7 +128,8 @@ jobs:
|
|
|
128
128
|
echo "Tag $TARGET_TAG was released from earlier commit ($TAG_SHA != ${{ github.sha }}). Skipping release."
|
|
129
129
|
fi
|
|
130
130
|
else
|
|
131
|
-
echo "Target tag $TARGET_TAG does not exist. Creating
|
|
131
|
+
echo "Target tag $TARGET_TAG does not exist. Creating tag and publishing release."
|
|
132
|
+
IS_NEW_RELEASE="true"
|
|
132
133
|
SHOULD_TAG="true"
|
|
133
134
|
fi
|
|
134
135
|
else
|
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## [0.9.
|
|
3
|
+
## [0.9.4] - 2026-10-11
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- Added native RHEL/Fedora/CentOS package mappings in `COMMON_COMMAND_PACKAGES` to prevent fallback to invalid Debian package names.
|
|
9
|
+
- Added native container package pre-installation support for `dnf`, `microdnf`, and `yum` in `runner._wrap_with_package_setup`.
|
|
10
|
+
- Fixed `RunReport` and `MultiScriptReport` `__post_init__` to preserve explicitly passed `all_passed=False` when result collections are empty.
|
|
11
|
+
- Supported bare directory exclusions (e.g. `tests` or `tests/`) in `_matches_exclude` to match both directories and all contained files.
|
|
12
|
+
|
|
13
|
+
## [0.9.3] - 2026-10-11
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- 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.).
|
|
17
|
+
- Extended `FailureDiagnostic` and SARIF output with structured `suggested_package` property.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Fixed unreadable shell script candidate misclassification during discovery.
|
|
21
|
+
- Ensured temporary script files are safely cleaned up during distribution preparation errors.
|
|
22
|
+
- Added preflight validation to reject directory and missing script paths before creating worker threads.
|
|
23
|
+
- Enforced consistent POSIX LF line endings in report output files across platforms.
|
|
24
|
+
|
|
25
|
+
## [0.9.2] - 2026-10-05
|
|
4
26
|
|
|
5
27
|
### Fixed
|
|
6
28
|
- Hardened SARIF, Markdown, terminal, and machine-readable output handling.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opsscript-gate
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.4
|
|
4
4
|
Summary: Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found.
|
|
5
5
|
Project-URL: Homepage, https://mresyzz.github.io/opsscript-gate/
|
|
6
6
|
Project-URL: Demo, https://mresyzz.github.io/opsscript-gate/
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "opsscript-gate"
|
|
7
|
-
version = "0.9.
|
|
7
|
+
version = "0.9.4"
|
|
8
8
|
description = "Cross-distro shell script compatibility and portability checker for Debian, Ubuntu, and Alpine. Runs real containers to diagnose command not found."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -30,6 +30,17 @@ from opsscript_gate.runner import (
|
|
|
30
30
|
run_matrix,
|
|
31
31
|
)
|
|
32
32
|
|
|
33
|
+
CRITICAL_PROJECT_FILES: tuple[str, ...] = (
|
|
34
|
+
"pyproject.toml",
|
|
35
|
+
"action.yml",
|
|
36
|
+
"action.yaml",
|
|
37
|
+
"setup.py",
|
|
38
|
+
"setup.cfg",
|
|
39
|
+
"README.md",
|
|
40
|
+
"CHANGELOG.md",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
33
44
|
|
|
34
45
|
def build_parser() -> argparse.ArgumentParser:
|
|
35
46
|
"""Build the command-line argument parser."""
|
|
@@ -319,6 +330,12 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
319
330
|
if not os.path.isfile(args.script_path):
|
|
320
331
|
sys.stderr.write(f"Error: Script file not found: {args.script_path}\n")
|
|
321
332
|
return 1
|
|
333
|
+
try:
|
|
334
|
+
with open(args.script_path, "rb") as probe:
|
|
335
|
+
probe.read(1)
|
|
336
|
+
except OSError as err:
|
|
337
|
+
sys.stderr.write(f"Error: Cannot read script {args.script_path}: {err}\n")
|
|
338
|
+
return 1
|
|
322
339
|
target_scripts = [args.script_path]
|
|
323
340
|
elif changed_selection:
|
|
324
341
|
try:
|
|
@@ -351,8 +368,15 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
351
368
|
if output_path.is_dir():
|
|
352
369
|
sys.stderr.write("Error: output must name a file, not an existing directory\n")
|
|
353
370
|
return 1
|
|
371
|
+
if any(part == ".git" for part in output_path.parts):
|
|
372
|
+
sys.stderr.write("Error: output must not target files inside .git directory\n")
|
|
373
|
+
return 1
|
|
354
374
|
protected = [Path(p).resolve() for p in target_scripts]
|
|
355
375
|
protected.append(Path(args.config or ".opsscript-gate.json").resolve())
|
|
376
|
+
for critical_name in CRITICAL_PROJECT_FILES:
|
|
377
|
+
critical_path = Path(critical_name).resolve()
|
|
378
|
+
if critical_path.exists():
|
|
379
|
+
protected.append(critical_path)
|
|
356
380
|
if _output_overwrites_protected(output_path, protected):
|
|
357
381
|
sys.stderr.write("Error: output must not overwrite a target script or configuration\n")
|
|
358
382
|
return 1
|
|
@@ -465,7 +489,8 @@ def publish_output(output: str, filename: str | None) -> bool:
|
|
|
465
489
|
try:
|
|
466
490
|
path = Path(filename)
|
|
467
491
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
468
|
-
path.
|
|
492
|
+
with path.open("w", encoding="utf-8", newline="\n") as stream:
|
|
493
|
+
stream.write(output + "\n")
|
|
469
494
|
except OSError as err:
|
|
470
495
|
sys.stderr.write(f"Error writing report: {err}\n")
|
|
471
496
|
return False
|
|
@@ -41,16 +41,23 @@ MAX_SCRIPT_SIZE_BYTES = 1024 * 1024 # 1 MB
|
|
|
41
41
|
|
|
42
42
|
|
|
43
43
|
def _matches_exclude(rel_path: str, patterns: list[str] | None) -> bool:
|
|
44
|
-
"""Match exclusions consistently whether callers use ``foo.sh
|
|
44
|
+
"""Match exclusions consistently whether callers use ``foo.sh``, ``./foo.sh``, ``/foo.sh``, or directory names like ``tests``."""
|
|
45
45
|
normalized_path = rel_path.replace("\\", "/")
|
|
46
|
-
|
|
46
|
+
while normalized_path.startswith("./"):
|
|
47
47
|
normalized_path = normalized_path[2:]
|
|
48
|
+
normalized_path = normalized_path.lstrip("/")
|
|
48
49
|
for pattern in patterns or []:
|
|
49
50
|
normalized_pattern = pattern.replace("\\", "/")
|
|
50
|
-
|
|
51
|
+
while normalized_pattern.startswith("./"):
|
|
51
52
|
normalized_pattern = normalized_pattern[2:]
|
|
53
|
+
normalized_pattern = normalized_pattern.lstrip("/")
|
|
54
|
+
if not normalized_pattern:
|
|
55
|
+
continue
|
|
52
56
|
if fnmatch.fnmatchcase(normalized_path, normalized_pattern):
|
|
53
57
|
return True
|
|
58
|
+
pat = normalized_pattern.rstrip("/")
|
|
59
|
+
if normalized_path == pat or normalized_path.startswith(f"{pat}/") or fnmatch.fnmatchcase(normalized_path, f"{pat}/*"):
|
|
60
|
+
return True
|
|
54
61
|
return False
|
|
55
62
|
|
|
56
63
|
|
|
@@ -62,6 +69,7 @@ def _is_ignored_path(rel_path: str) -> bool:
|
|
|
62
69
|
parts = normalized.split("/")
|
|
63
70
|
return any(
|
|
64
71
|
part in IGNORED_DIRS
|
|
72
|
+
or part == ".."
|
|
65
73
|
or part.endswith(".egg-info")
|
|
66
74
|
or (part.startswith(".") and part not in (".", ".."))
|
|
67
75
|
for part in parts
|
|
@@ -75,6 +83,7 @@ def is_shell_script(file_path: Path, max_size_bytes: int = MAX_SCRIPT_SIZE_BYTES
|
|
|
75
83
|
Check if a file is a candidate shell script:
|
|
76
84
|
- Must be a regular file, never a symlink.
|
|
77
85
|
- File size must not exceed max_size_bytes.
|
|
86
|
+
- Must be readable by the current process.
|
|
78
87
|
- Extension is .sh or .bash, OR the first line is a recognized shell shebang.
|
|
79
88
|
"""
|
|
80
89
|
try:
|
|
@@ -84,14 +93,13 @@ def is_shell_script(file_path: Path, max_size_bytes: int = MAX_SCRIPT_SIZE_BYTES
|
|
|
84
93
|
if stat.st_size == 0 or stat.st_size > max_size_bytes:
|
|
85
94
|
return False
|
|
86
95
|
|
|
96
|
+
with open(file_path, "rb") as f:
|
|
97
|
+
first_line_bytes = f.readline(256)
|
|
98
|
+
|
|
87
99
|
ext = file_path.suffix.lower()
|
|
88
100
|
if ext in (".sh", ".bash"):
|
|
89
101
|
return True
|
|
90
102
|
|
|
91
|
-
# If extension is not .sh/.bash, inspect the first line
|
|
92
|
-
with open(file_path, "rb") as f:
|
|
93
|
-
first_line_bytes = f.readline(256)
|
|
94
|
-
|
|
95
103
|
first_line = first_line_bytes.decode("utf-8-sig", errors="ignore").strip()
|
|
96
104
|
return any(first_line == prefix or first_line.startswith(prefix + " ")
|
|
97
105
|
for prefix in SHELL_SHEBANG_PREFIXES)
|
|
@@ -30,6 +30,7 @@ class FailureDiagnostic:
|
|
|
30
30
|
line: int | None = None
|
|
31
31
|
distro: str | None = None
|
|
32
32
|
hint: str | None = None
|
|
33
|
+
suggested_package: str | None = None
|
|
33
34
|
|
|
34
35
|
def to_dict(self) -> dict[str, Any]:
|
|
35
36
|
d: dict[str, Any] = {
|
|
@@ -44,6 +45,8 @@ class FailureDiagnostic:
|
|
|
44
45
|
d["distro"] = self.distro
|
|
45
46
|
if self.hint is not None:
|
|
46
47
|
d["hint"] = self.hint
|
|
48
|
+
if self.suggested_package is not None:
|
|
49
|
+
d["suggested_package"] = self.suggested_package
|
|
47
50
|
return d
|
|
48
51
|
|
|
49
52
|
|
|
@@ -110,8 +113,6 @@ class RunReport:
|
|
|
110
113
|
def __post_init__(self) -> None:
|
|
111
114
|
if self.results:
|
|
112
115
|
self.all_passed = all(r.status == DistroStatus.PASS for r in self.results)
|
|
113
|
-
else:
|
|
114
|
-
self.all_passed = True
|
|
115
116
|
|
|
116
117
|
def to_dict(self) -> dict[str, Any]:
|
|
117
118
|
return {
|
|
@@ -131,8 +132,6 @@ class MultiScriptReport:
|
|
|
131
132
|
def __post_init__(self) -> None:
|
|
132
133
|
if self.reports:
|
|
133
134
|
self.all_passed = all(r.all_passed for r in self.reports.values())
|
|
134
|
-
else:
|
|
135
|
-
self.all_passed = True
|
|
136
135
|
|
|
137
136
|
def to_dict(self) -> dict[str, Any]:
|
|
138
137
|
return {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Callable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class RemediationRule:
|
|
9
|
+
"""Rule for generating conservative failure remediation hints."""
|
|
10
|
+
rule_id: str
|
|
11
|
+
matcher: Callable[[str | None, str | None, str], bool]
|
|
12
|
+
hint: str
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_cmd_basename(command: str | None) -> str:
|
|
16
|
+
"""Normalize command path and extract clean executable basename."""
|
|
17
|
+
if not command:
|
|
18
|
+
return ""
|
|
19
|
+
norm = command.lower().replace("\\", "/").split("/")[-1]
|
|
20
|
+
if norm.endswith(".exe"):
|
|
21
|
+
norm = norm[:-4]
|
|
22
|
+
return norm
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _match_alpine_apt(distro: str | None, command: str | None, kind: str) -> bool:
|
|
26
|
+
if not distro or not command:
|
|
27
|
+
return False
|
|
28
|
+
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("apt", "apt-get")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _match_debian_ubuntu_apk(distro: str | None, command: str | None, kind: str) -> bool:
|
|
32
|
+
if not distro or not command:
|
|
33
|
+
return False
|
|
34
|
+
d = distro.lower()
|
|
35
|
+
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) == "apk"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _match_debian_ubuntu_yum(distro: str | None, command: str | None, kind: str) -> bool:
|
|
39
|
+
if not distro or not command:
|
|
40
|
+
return False
|
|
41
|
+
d = distro.lower()
|
|
42
|
+
return ("debian" in d or "ubuntu" in d) and _normalize_cmd_basename(command) in ("yum", "dnf")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _match_alpine_yum(distro: str | None, command: str | None, kind: str) -> bool:
|
|
46
|
+
if not distro or not command:
|
|
47
|
+
return False
|
|
48
|
+
return "alpine" in distro.lower() and _normalize_cmd_basename(command) in ("yum", "dnf")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _match_rhel_fedora_apt(distro: str | None, command: str | None, kind: str) -> bool:
|
|
52
|
+
if not distro or not command:
|
|
53
|
+
return False
|
|
54
|
+
d = distro.lower()
|
|
55
|
+
is_rhel = any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma"))
|
|
56
|
+
return is_rhel and _normalize_cmd_basename(command) in ("apt", "apt-get", "apk")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _match_alpine_bash(distro: str | None, command: str | None, kind: str) -> bool:
|
|
60
|
+
if not distro or not command:
|
|
61
|
+
return False
|
|
62
|
+
return "alpine" in distro.lower() and _normalize_cmd_basename(command) == "bash"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _match_curl(distro: str | None, command: str | None, kind: str) -> bool:
|
|
66
|
+
return _normalize_cmd_basename(command) == "curl"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _match_wget(distro: str | None, command: str | None, kind: str) -> bool:
|
|
70
|
+
return _normalize_cmd_basename(command) == "wget"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _match_sudo(distro: str | None, command: str | None, kind: str) -> bool:
|
|
74
|
+
return _normalize_cmd_basename(command) == "sudo"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _match_interpreter(distro: str | None, command: str | None, kind: str) -> bool:
|
|
78
|
+
if kind == "missing_interpreter":
|
|
79
|
+
return True
|
|
80
|
+
if command and (command.startswith("/bin/") or command.startswith("/usr/bin/")):
|
|
81
|
+
base = _normalize_cmd_basename(command)
|
|
82
|
+
if base in ("bash", "sh", "zsh", "python", "python3", "perl"):
|
|
83
|
+
return True
|
|
84
|
+
return False
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# Mapping of common ops CLI command binaries to distro-specific package names.
|
|
88
|
+
# Supported distro families: "debian" (Debian/Ubuntu), "alpine" (Alpine Linux), "rhel" (Fedora/CentOS/RHEL).
|
|
89
|
+
COMMON_COMMAND_PACKAGES: dict[str, dict[str, str]] = {
|
|
90
|
+
"curl": {"debian": "curl", "alpine": "curl", "rhel": "curl"},
|
|
91
|
+
"wget": {"debian": "wget", "alpine": "wget", "rhel": "wget"},
|
|
92
|
+
"jq": {"debian": "jq", "alpine": "jq", "rhel": "jq"},
|
|
93
|
+
"git": {"debian": "git", "alpine": "git", "rhel": "git"},
|
|
94
|
+
"tar": {"debian": "tar", "alpine": "tar", "rhel": "tar"},
|
|
95
|
+
"gzip": {"debian": "gzip", "alpine": "gzip", "rhel": "gzip"},
|
|
96
|
+
"bzip2": {"debian": "bzip2", "alpine": "bzip2", "rhel": "bzip2"},
|
|
97
|
+
"xz": {"debian": "xz-utils", "alpine": "xz", "rhel": "xz"},
|
|
98
|
+
"unzip": {"debian": "unzip", "alpine": "unzip", "rhel": "unzip"},
|
|
99
|
+
"zip": {"debian": "zip", "alpine": "zip", "rhel": "zip"},
|
|
100
|
+
"python": {"debian": "python3", "alpine": "python3", "rhel": "python3"},
|
|
101
|
+
"python3": {"debian": "python3", "alpine": "python3", "rhel": "python3"},
|
|
102
|
+
"pip": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"},
|
|
103
|
+
"pip3": {"debian": "python3-pip", "alpine": "py3-pip", "rhel": "python3-pip"},
|
|
104
|
+
"sudo": {"debian": "sudo", "alpine": "sudo", "rhel": "sudo"},
|
|
105
|
+
"bash": {"debian": "bash", "alpine": "bash", "rhel": "bash"},
|
|
106
|
+
"make": {"debian": "build-essential", "alpine": "build-base", "rhel": "make"},
|
|
107
|
+
"gcc": {"debian": "gcc", "alpine": "gcc", "rhel": "gcc"},
|
|
108
|
+
"ca-certificates": {"debian": "ca-certificates", "alpine": "ca-certificates", "rhel": "ca-certificates"},
|
|
109
|
+
"openssl": {"debian": "openssl", "alpine": "openssl", "rhel": "openssl"},
|
|
110
|
+
"nc": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"},
|
|
111
|
+
"netcat": {"debian": "netcat-openbsd", "alpine": "netcat-openbsd", "rhel": "nmap-ncat"},
|
|
112
|
+
"socat": {"debian": "socat", "alpine": "socat", "rhel": "socat"},
|
|
113
|
+
"envsubst": {"debian": "gettext-base", "alpine": "gettext", "rhel": "gettext"},
|
|
114
|
+
"crontab": {"debian": "cron", "alpine": "dcron", "rhel": "cronie"},
|
|
115
|
+
"ps": {"debian": "procps", "alpine": "procps", "rhel": "procps-ng"},
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def detect_distro_family(distro: str | None) -> str:
|
|
120
|
+
"""Identify the distribution family for package manager commands."""
|
|
121
|
+
if not distro:
|
|
122
|
+
return "unknown"
|
|
123
|
+
d = distro.lower()
|
|
124
|
+
if "alpine" in d:
|
|
125
|
+
return "alpine"
|
|
126
|
+
if any(name in d for name in ("debian", "ubuntu", "mint")):
|
|
127
|
+
return "debian"
|
|
128
|
+
if any(name in d for name in ("fedora", "centos", "rhel", "redhat", "rocky", "alma")):
|
|
129
|
+
return "rhel"
|
|
130
|
+
return "unknown"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def resolve_package_recommendation(
|
|
134
|
+
command: str | None,
|
|
135
|
+
distro: str | None,
|
|
136
|
+
) -> tuple[str | None, str | None]:
|
|
137
|
+
"""
|
|
138
|
+
Resolve suggested package name and actionable install command for a missing command.
|
|
139
|
+
Returns (suggested_package_name, install_command).
|
|
140
|
+
"""
|
|
141
|
+
if not command:
|
|
142
|
+
return None, None
|
|
143
|
+
norm_cmd = command.lower().replace("\\", "/").split("/")[-1]
|
|
144
|
+
if norm_cmd.endswith(".exe"):
|
|
145
|
+
norm_cmd = norm_cmd[:-4]
|
|
146
|
+
|
|
147
|
+
packages = COMMON_COMMAND_PACKAGES.get(norm_cmd)
|
|
148
|
+
if not packages:
|
|
149
|
+
return None, None
|
|
150
|
+
|
|
151
|
+
family = detect_distro_family(distro)
|
|
152
|
+
package_name = packages.get(family)
|
|
153
|
+
if not package_name and family in ("unknown", "rhel"):
|
|
154
|
+
package_name = packages.get("debian") or packages.get("alpine")
|
|
155
|
+
|
|
156
|
+
if not package_name:
|
|
157
|
+
return None, None
|
|
158
|
+
|
|
159
|
+
if family == "alpine":
|
|
160
|
+
install_cmd = f"apk add --no-cache {package_name}"
|
|
161
|
+
elif family == "debian":
|
|
162
|
+
install_cmd = f"apt-get install -y {package_name}"
|
|
163
|
+
elif family == "rhel":
|
|
164
|
+
install_cmd = f"dnf install -y {package_name}"
|
|
165
|
+
else:
|
|
166
|
+
install_cmd = None
|
|
167
|
+
|
|
168
|
+
return package_name, install_cmd
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
REMEDIATION_RULES: list[RemediationRule] = [
|
|
172
|
+
RemediationRule(
|
|
173
|
+
rule_id="alpine_apt",
|
|
174
|
+
matcher=_match_alpine_apt,
|
|
175
|
+
hint="Alpine normally uses apk instead of apt-get.",
|
|
176
|
+
),
|
|
177
|
+
RemediationRule(
|
|
178
|
+
rule_id="debian_ubuntu_apk",
|
|
179
|
+
matcher=_match_debian_ubuntu_apk,
|
|
180
|
+
hint="Debian/Ubuntu normally uses APT (apt-get) instead of apk.",
|
|
181
|
+
),
|
|
182
|
+
RemediationRule(
|
|
183
|
+
rule_id="debian_ubuntu_yum",
|
|
184
|
+
matcher=_match_debian_ubuntu_yum,
|
|
185
|
+
hint="Debian/Ubuntu normally uses APT (apt-get) instead of yum or dnf.",
|
|
186
|
+
),
|
|
187
|
+
RemediationRule(
|
|
188
|
+
rule_id="alpine_yum",
|
|
189
|
+
matcher=_match_alpine_yum,
|
|
190
|
+
hint="Alpine normally uses apk instead of yum or dnf.",
|
|
191
|
+
),
|
|
192
|
+
RemediationRule(
|
|
193
|
+
rule_id="rhel_fedora_apt",
|
|
194
|
+
matcher=_match_rhel_fedora_apt,
|
|
195
|
+
hint="RHEL/Fedora family distributions normally use dnf or yum instead of apt-get or apk.",
|
|
196
|
+
),
|
|
197
|
+
RemediationRule(
|
|
198
|
+
rule_id="alpine_bash",
|
|
199
|
+
matcher=_match_alpine_bash,
|
|
200
|
+
hint="Alpine minimal images do not include Bash by default; consider using POSIX sh or installing bash.",
|
|
201
|
+
),
|
|
202
|
+
RemediationRule(
|
|
203
|
+
rule_id="missing_curl",
|
|
204
|
+
matcher=_match_curl,
|
|
205
|
+
hint="Minimal images may not include curl; consider declaring it as a prerequisite or installing it.",
|
|
206
|
+
),
|
|
207
|
+
RemediationRule(
|
|
208
|
+
rule_id="missing_wget",
|
|
209
|
+
matcher=_match_wget,
|
|
210
|
+
hint="Minimal images may not include wget; consider declaring it as a prerequisite or installing it.",
|
|
211
|
+
),
|
|
212
|
+
RemediationRule(
|
|
213
|
+
rule_id="missing_sudo",
|
|
214
|
+
matcher=_match_sudo,
|
|
215
|
+
hint="Minimal container images do not include sudo by default; containers typically run as root or require installing sudo.",
|
|
216
|
+
),
|
|
217
|
+
RemediationRule(
|
|
218
|
+
rule_id="missing_interpreter",
|
|
219
|
+
matcher=_match_interpreter,
|
|
220
|
+
hint="Ensure the required interpreter is installed or adjust the shebang path.",
|
|
221
|
+
),
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def generate_remediation_hint(
|
|
226
|
+
distro: str | None = None,
|
|
227
|
+
command: str | None = None,
|
|
228
|
+
kind: str = "missing_command",
|
|
229
|
+
) -> str | None:
|
|
230
|
+
"""
|
|
231
|
+
Generate a conservative, testable remediation hint for a failure.
|
|
232
|
+
|
|
233
|
+
Uses strict conservative wording ('normally', 'may', 'consider').
|
|
234
|
+
Returns None if no specific high-confidence recommendation applies.
|
|
235
|
+
"""
|
|
236
|
+
for rule in REMEDIATION_RULES:
|
|
237
|
+
if rule.matcher(distro, command, kind):
|
|
238
|
+
return rule.hint
|
|
239
|
+
return None
|
|
@@ -180,18 +180,23 @@ def format_github_annotations(report: RunReport, script_path: str = "") -> list[
|
|
|
180
180
|
"""
|
|
181
181
|
annotations: list[str] = []
|
|
182
182
|
if script_path:
|
|
183
|
-
clean_path = script_path.replace("\\", "/")
|
|
184
|
-
if clean_path.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
clean_path = os.path.
|
|
189
|
-
|
|
190
|
-
|
|
183
|
+
clean_path = sanitize_display_text(script_path).replace("\\", "/")
|
|
184
|
+
if os.path.isabs(clean_path) or re.match(r"^[A-Za-z]:/", clean_path):
|
|
185
|
+
try:
|
|
186
|
+
clean_path = os.path.relpath(clean_path, os.getcwd()).replace("\\", "/")
|
|
187
|
+
except Exception:
|
|
188
|
+
clean_path = os.path.basename(clean_path)
|
|
189
|
+
norm = os.path.normpath(clean_path).replace("\\", "/")
|
|
190
|
+
while norm.startswith("./"):
|
|
191
|
+
norm = norm[2:]
|
|
192
|
+
if norm == ".." or norm.startswith("../") or "/../" in f"/{norm}/":
|
|
193
|
+
clean_path = os.path.basename(clean_path)
|
|
194
|
+
else:
|
|
195
|
+
clean_path = norm
|
|
191
196
|
norm_script = clean_path if clean_path else "script.sh"
|
|
192
197
|
else:
|
|
193
198
|
norm_script = "script.sh"
|
|
194
|
-
escaped_file = escape_github_property(
|
|
199
|
+
escaped_file = escape_github_property(norm_script)
|
|
195
200
|
|
|
196
201
|
for r in report.results:
|
|
197
202
|
if r.status == DistroStatus.PASS:
|
|
@@ -534,8 +539,13 @@ def _sarif_path(script_path: str) -> str:
|
|
|
534
539
|
clean = os.path.basename(clean)
|
|
535
540
|
while clean.startswith("./"):
|
|
536
541
|
clean = clean[2:]
|
|
537
|
-
|
|
542
|
+
norm = os.path.normpath(clean).replace("\\", "/")
|
|
543
|
+
while norm.startswith("./"):
|
|
544
|
+
norm = norm[2:]
|
|
545
|
+
if norm == ".." or norm.startswith("../") or "/../" in f"/{norm}/":
|
|
538
546
|
clean = os.path.basename(clean)
|
|
547
|
+
else:
|
|
548
|
+
clean = norm
|
|
539
549
|
return clean or "script.sh"
|
|
540
550
|
|
|
541
551
|
|
|
@@ -572,8 +582,11 @@ def _sarif_result(result: SingleResult, script_path: str) -> dict[str, object] |
|
|
|
572
582
|
}
|
|
573
583
|
if result.exit_code is not None:
|
|
574
584
|
properties["exitCode"] = result.exit_code
|
|
575
|
-
if result.diagnostic
|
|
576
|
-
|
|
585
|
+
if result.diagnostic:
|
|
586
|
+
if result.diagnostic.command:
|
|
587
|
+
properties["command"] = sanitize_display_text(result.diagnostic.command)
|
|
588
|
+
if result.diagnostic.suggested_package:
|
|
589
|
+
properties["suggestedPackage"] = sanitize_display_text(result.diagnostic.suggested_package)
|
|
577
590
|
|
|
578
591
|
return {
|
|
579
592
|
"ruleId": rule_id,
|