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.
Files changed (62) hide show
  1. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/release.yml +2 -1
  2. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.gitignore +1 -0
  3. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CHANGELOG.md +23 -1
  4. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/PKG-INFO +1 -1
  5. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/pyproject.toml +1 -1
  6. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/__init__.py +1 -1
  7. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/cli.py +26 -1
  8. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/config.py +1 -1
  9. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/discovery.py +15 -7
  10. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/models.py +3 -4
  11. opsscript_gate-0.9.4/src/opsscript_gate/remediation.py +239 -0
  12. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/reporter.py +25 -12
  13. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/src/opsscript_gate/runner.py +92 -52
  14. opsscript_gate-0.9.4/tests/conftest.py +17 -0
  15. opsscript_gate-0.9.4/tests/test_audit_coverage.py +577 -0
  16. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/test_runner.py +94 -0
  17. opsscript_gate-0.9.2/.ai/v0.9.2_issues_report.md +0 -90
  18. opsscript_gate-0.9.2/src/opsscript_gate/remediation.py +0 -161
  19. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.gitattributes +0 -0
  20. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  21. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  22. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  23. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/assets/social-preview.png +0 -0
  24. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/pull_request_template.md +0 -0
  25. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflow-templates/opsscript-gate.properties.json +0 -0
  26. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflow-templates/opsscript-gate.yml +0 -0
  27. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/demo.yml +0 -0
  28. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/deploy-pages.yml +0 -0
  29. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/.github/workflows/test.yml +0 -0
  30. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CITATION.cff +0 -0
  31. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/CONTRIBUTING.md +0 -0
  32. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/LICENSE +0 -0
  33. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/README.md +0 -0
  34. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/README.zh-CN.md +0 -0
  35. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/ROADMAP.md +0 -0
  36. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/SECURITY.md +0 -0
  37. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/action.yml +0 -0
  38. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/demo.html +0 -0
  39. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/command-not-found.html +0 -0
  40. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/command-not-found.md +0 -0
  41. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/configuration.md +0 -0
  42. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/gpt-oss.md +0 -0
  43. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/index.html +0 -0
  44. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/llms.txt +0 -0
  45. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/robots.txt +0 -0
  46. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/sitemap.xml +0 -0
  47. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/docs/troubleshooting.md +0 -0
  48. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/alpine-incompatibility/README.md +0 -0
  49. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  50. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/basic/README.md +0 -0
  51. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/basic/clean_setup.sh +0 -0
  52. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/github-actions/README.md +0 -0
  53. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/github-actions/workflow.yml +0 -0
  54. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/interactive-hang/README.md +0 -0
  55. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/examples/interactive-hang/prompt_confirm.sh +0 -0
  56. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/index.html +0 -0
  57. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/llms.txt +0 -0
  58. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/fail_deps.sh +0 -0
  59. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/fail_hang.sh +0 -0
  60. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/fixtures/pass_basic.sh +0 -0
  61. {opsscript_gate-0.9.2 → opsscript_gate-0.9.4}/tests/test_action_contract.py +0 -0
  62. {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 it and handing publication to the tag workflow."
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
@@ -50,3 +50,4 @@ venv.bak/
50
50
  *~
51
51
  .DS_Store
52
52
  Thumbs.db
53
+ .ai/
@@ -1,6 +1,28 @@
1
1
  # Changelog
2
2
 
3
- ## [0.9.2] - Unreleased
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.2
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.2"
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" }
@@ -1,4 +1,4 @@
1
1
  """OpsScript Gate: Lightweight cross-distro compatibility pre-check for Linux ops scripts."""
2
2
 
3
- __version__ = "0.9.2"
3
+ __version__ = "0.9.4"
4
4
  __all__ = ["__version__"]
@@ -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.write_text(output + "\n", encoding="utf-8")
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
@@ -126,7 +126,7 @@ jobs:
126
126
  - uses: actions/checkout@v7
127
127
  with:
128
128
  persist-credentials: false
129
- - uses: Mresyzz/opsscript-gate@v0.9.2
129
+ - uses: Mresyzz/opsscript-gate@v0.9.4
130
130
  with:
131
131
  config: .opsscript-gate.json
132
132
  """,
@@ -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`` or ``./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
- if normalized_path.startswith("./"):
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
- if normalized_pattern.startswith("./"):
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.startswith("./"):
185
- clean_path = clean_path[2:]
186
- try:
187
- if os.path.isabs(clean_path):
188
- clean_path = os.path.relpath(clean_path, ".").replace("\\", "/")
189
- except Exception:
190
- pass
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(sanitize_display_text(norm_script))
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
- if clean == ".." or clean.startswith("../"):
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 and result.diagnostic.command:
576
- properties["command"] = sanitize_display_text(result.diagnostic.command)
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,