opsscript-gate 0.4.0__tar.gz → 0.4.1__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 (43) hide show
  1. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/CHANGELOG.md +14 -0
  2. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/PKG-INFO +14 -7
  3. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/README.md +13 -6
  4. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/SECURITY.md +6 -4
  5. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/pyproject.toml +1 -1
  6. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/__init__.py +1 -1
  7. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/reporter.py +151 -32
  8. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/runner.py +274 -120
  9. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/test_runner.py +449 -1
  10. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.gitattributes +0 -0
  11. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  12. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/distro_request.yml +0 -0
  13. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  14. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/assets/social-preview.png +0 -0
  15. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/pull_request_template.md +0 -0
  16. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/demo.yml +0 -0
  17. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/release.yml +0 -0
  18. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.github/workflows/test.yml +0 -0
  19. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/.gitignore +0 -0
  20. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/CONTRIBUTING.md +0 -0
  21. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/LICENSE +0 -0
  22. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/ROADMAP.md +0 -0
  23. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/action.yml +0 -0
  24. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/awesome-lists-pr.md +0 -0
  25. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/juejin-zhihu-article.md +0 -0
  26. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/reddit-devops.md +0 -0
  27. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/show-hn.md +0 -0
  28. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/docs/promotion/v2ex-post.md +0 -0
  29. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/alpine-incompatibility/README.md +0 -0
  30. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/alpine-incompatibility/apt_dependency.sh +0 -0
  31. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/basic/README.md +0 -0
  32. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/basic/clean_setup.sh +0 -0
  33. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/github-actions/README.md +0 -0
  34. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/github-actions/workflow.yml +0 -0
  35. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/interactive-hang/README.md +0 -0
  36. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/examples/interactive-hang/prompt_confirm.sh +0 -0
  37. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/cli.py +0 -0
  38. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/discovery.py +0 -0
  39. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/models.py +0 -0
  40. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/src/opsscript_gate/remediation.py +0 -0
  41. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/fail_deps.sh +0 -0
  42. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/fail_hang.sh +0 -0
  43. {opsscript_gate-0.4.0 → opsscript_gate-0.4.1}/tests/fixtures/pass_basic.sh +0 -0
@@ -5,6 +5,20 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.4.1] - 2026-09-19
9
+
10
+ ### Fixed & Hardened
11
+ - **Bounded container log capture**: Stream-captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), bounding retained container log data to reduce memory-exhaustion risk from runaway output.
12
+ - **Workflow command neutralization**: Neutralizes untrusted container logs starting with `::` into `[container] ::` to prevent malicious scripts from forging GitHub Actions workflow commands in Runner consoles, while keeping OpsScript Gate's own annotations intact.
13
+ - **Terminal ANSI and control character sanitization**: Strips ANSI escape sequences (CSI, OSC, 2-byte escapes), removes dangerous C0 control characters and DEL, and normalizes CRLF and standalone carriage returns (`\r`) to defeat terminal line overwrite and status spoofing, while preserving printable Unicode.
14
+ - **Context-sensitive Markdown and HTML injection defense**: Implemented dedicated escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent table cell tearing, HTML container breakout (`</details>`), premature code fence closure, and structural Markdown injection from runtime-derived values.
15
+ - **Bounded streaming script preparation**: Stream-normalizes CRLF in bounded 64 KiB chunks (`SCRIPT_READ_CHUNK_SIZE`), properly handling boundary-split CRLF while preserving lone CR bytes without whole-file memory allocation. Bounded shebang parsing to 4096 bytes (`MAX_SHEBANG_BYTES`).
16
+ - **Parallel matrix script preparation deduplication**: Pre-normalizes scripts once before parallel matrix execution, sharing the prepared file read-only across worker threads and centralizing cleanup to eliminate file locking and race conditions on Windows.
17
+ - **Shebang evaluation refactoring**: Deduplicated shebang resolution across `shebang` and `auto` modes via `_resolve_shell_command`.
18
+ - **Regression test coverage**: Added extensive adversarial and edge-case tests covering oversized streams, boundary splits, workflow spoofing, Markdown injections, and error branches.
19
+
20
+ ---
21
+
8
22
  ## [0.4.0] - 2026-09-19
9
23
 
10
24
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opsscript-gate
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures
5
5
  Project-URL: Homepage, https://github.com/Mresyzz/opsscript-gate
6
6
  Project-URL: Repository, https://github.com/Mresyzz/opsscript-gate
@@ -58,7 +58,7 @@ jobs:
58
58
  runs-on: ubuntu-latest
59
59
  steps:
60
60
  - uses: actions/checkout@v7
61
- - uses: Mresyzz/opsscript-gate@v0.4.0
61
+ - uses: Mresyzz/opsscript-gate@v0.4.1
62
62
  ```
63
63
 
64
64
  > **Zero Config**: If `script-path` is omitted, OpsScript Gate automatically discovers shell scripts in your repository and tests them concurrently!
@@ -66,7 +66,7 @@ jobs:
66
66
  Or test a specific script with custom execution modes:
67
67
 
68
68
  ```yaml
69
- - uses: Mresyzz/opsscript-gate@v0.4.0
69
+ - uses: Mresyzz/opsscript-gate@v0.4.1
70
70
  with:
71
71
  script-path: scripts/install.sh
72
72
  shell: auto
@@ -212,10 +212,17 @@ OpsScript Gate applies conservative, restricted container defaults when running
212
212
  5. **Hard Timeout & Container Cleanup**:
213
213
  - Enforces configurable timeout (default: 60s). Timed-out containers are sent `SIGKILL` and marked `TIMED_OUT`.
214
214
  - Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
215
- 6. **Command Injection Defense**:
216
- - Workflow commands (`::error`) apply strict percent-encoding for all properties and message bodies, preventing unclassified container logs from injecting GitHub Actions workflow commands.
217
- 7. **Windows CRLF Defense**:
218
- - Automatically detects and normalizes carriage returns (`\r\n` -> `\n`) before container execution, preventing false `\r: command not found` errors.
215
+ 6. **Bounded Output & Memory Protection**:
216
+ - Captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), which bounds retained container log data to reduce memory-exhaustion risk.
217
+ 7. **Untrusted Log Neutralization & Terminal Defense**:
218
+ - Neutralizes line-leading workflow commands (`[container] ::`) to prevent forged GitHub Actions annotations in CI runners.
219
+ - Strips ANSI escape sequences and dangerous C0 control characters, and normalizes carriage returns (`\r`) to defeat terminal line overwrite spoofing.
220
+ - Employs context-sensitive escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent Step Summary layout disruptions.
221
+ 8. **Command Injection Defense**:
222
+ - OpsScript Gate's own annotations (`::error`) apply strict percent-encoding for all properties and message bodies, preventing workflow command injection.
223
+ 9. **Bounded Streaming CRLF & Shebang Defense**:
224
+ - Stream-normalizes carriage returns in 64 KiB chunks, preserving lone CR bytes and bounds shebang parsing to 4096 bytes without whole-file memory allocation.
225
+ - Pre-normalizes scripts once before parallel matrix runs, sharing a read-only prepared path across worker threads.
219
226
 
220
227
  ---
221
228
 
@@ -30,7 +30,7 @@ jobs:
30
30
  runs-on: ubuntu-latest
31
31
  steps:
32
32
  - uses: actions/checkout@v7
33
- - uses: Mresyzz/opsscript-gate@v0.4.0
33
+ - uses: Mresyzz/opsscript-gate@v0.4.1
34
34
  ```
35
35
 
36
36
  > **Zero Config**: If `script-path` is omitted, OpsScript Gate automatically discovers shell scripts in your repository and tests them concurrently!
@@ -38,7 +38,7 @@ jobs:
38
38
  Or test a specific script with custom execution modes:
39
39
 
40
40
  ```yaml
41
- - uses: Mresyzz/opsscript-gate@v0.4.0
41
+ - uses: Mresyzz/opsscript-gate@v0.4.1
42
42
  with:
43
43
  script-path: scripts/install.sh
44
44
  shell: auto
@@ -184,10 +184,17 @@ OpsScript Gate applies conservative, restricted container defaults when running
184
184
  5. **Hard Timeout & Container Cleanup**:
185
185
  - Enforces configurable timeout (default: 60s). Timed-out containers are sent `SIGKILL` and marked `TIMED_OUT`.
186
186
  - Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
187
- 6. **Command Injection Defense**:
188
- - Workflow commands (`::error`) apply strict percent-encoding for all properties and message bodies, preventing unclassified container logs from injecting GitHub Actions workflow commands.
189
- 7. **Windows CRLF Defense**:
190
- - Automatically detects and normalizes carriage returns (`\r\n` -> `\n`) before container execution, preventing false `\r: command not found` errors.
187
+ 6. **Bounded Output & Memory Protection**:
188
+ - Captures container logs using an immediate rolling byte buffer capped at 256 KiB (`MAX_CAPTURED_LOG_BYTES`) and tail limited to 500 lines (`MAX_LOG_TAIL_LINES`), which bounds retained container log data to reduce memory-exhaustion risk.
189
+ 7. **Untrusted Log Neutralization & Terminal Defense**:
190
+ - Neutralizes line-leading workflow commands (`[container] ::`) to prevent forged GitHub Actions annotations in CI runners.
191
+ - Strips ANSI escape sequences and dangerous C0 control characters, and normalizes carriage returns (`\r`) to defeat terminal line overwrite spoofing.
192
+ - Employs context-sensitive escaping (`escape_inline_code`, `escape_markdown_text`, `escape_markdown_table_cell`, `escape_html_text`, `format_safe_code_fence`) to prevent Step Summary layout disruptions.
193
+ 8. **Command Injection Defense**:
194
+ - OpsScript Gate's own annotations (`::error`) apply strict percent-encoding for all properties and message bodies, preventing workflow command injection.
195
+ 9. **Bounded Streaming CRLF & Shebang Defense**:
196
+ - Stream-normalizes carriage returns in 64 KiB chunks, preserving lone CR bytes and bounds shebang parsing to 4096 bytes without whole-file memory allocation.
197
+ - Pre-normalizes scripts once before parallel matrix runs, sharing a read-only prepared path across worker threads.
191
198
 
192
199
  ---
193
200
 
@@ -7,8 +7,7 @@ Only the latest release of **OpsScript Gate** receives security updates and bug
7
7
  | Version | Supported |
8
8
  | :--- | :---: |
9
9
  | 0.4.x | :white_check_mark: |
10
- | 0.3.x | :white_check_mark: |
11
- | < 0.3.0 | :x: |
10
+ | < 0.4.0 | :x: |
12
11
 
13
12
  ---
14
13
 
@@ -32,9 +31,12 @@ Reports will be reviewed as maintainer availability allows.
32
31
 
33
32
  OpsScript Gate is not a security sandbox for untrusted code. Containers may run as the image's default user, and Docker containers still share the host kernel.
34
33
 
35
- OpsScript Gate applies conservative container defaults when running scripts:
34
+ OpsScript Gate applies conservative container defaults and hardened I/O boundaries when running scripts:
36
35
  - **Restricted Container Defaults**: Containers run with `privileged=False`, `cap_drop=["ALL"]`, and `security_opt=["no-new-privileges:true"]`.
37
36
  - **Resource Limits & Isolation**: Enforces memory caps (`--mem-limit`, default 256m), process table caps (`--pids-limit`, default 128), and optional network isolation (`--network none`).
38
37
  - **Read-Only Mounting**: Target scripts are mounted read-only (`:ro`). OpsScript Gate does not mount additional host filesystem paths into test containers.
39
38
  - **Resource Protection & Hard Timeout**: Containers are subject to hard timeouts (default 60s) with `SIGKILL` termination. Container removal is attempted from a `finally` block in normal, failure, and timeout execution paths.
40
- - **Workflow Command Injection Defense**: All GitHub Actions workflow commands (`::error`) apply strict percent-encoding for properties (`%`, `\r`, `\n`, `:`, `,`) and data (`%`, `\r`, `\n`), completely preventing command injection from unclassified container output.
39
+ - **Bounded Output & Rolling Buffer**: Container output capture is strictly bounded by tail lines (500) and byte caps (256 KiB) via an immediate rolling byte buffer, bounding retained container log data to reduce memory-exhaustion risk.
40
+ - **Untrusted Log Neutralization**: Container logs neutralize line-leading workflow commands (`[container] ::`), strip terminal ANSI/CSI/OSC sequences and dangerous C0 control characters, and apply context-sensitive Markdown/HTML escaping to protect Step Summaries.
41
+ - **Workflow Command Encoding**: OpsScript Gate's own GitHub Actions annotations (`::error`) apply strict percent-encoding for properties (`%`, `\r`, `\n`, `:`, `,`) and data (`%`, `\r`, `\n`), protecting GitHub Actions workflow-command fields against injection.
42
+ - **Bounded Streaming I/O**: Script preparation and line ending normalization operate in bounded 64 KiB chunks, and shebang line inspection is bounded to 4096 bytes.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "opsscript-gate"
7
- version = "0.4.0"
7
+ version = "0.4.1"
8
8
  description = "Run Linux shell scripts across Debian, Ubuntu and Alpine before release to catch runtime compatibility failures"
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.4.0"
3
+ __version__ = "0.4.1"
4
4
  __all__ = ["__version__"]
@@ -1,10 +1,110 @@
1
- from __future__ import annotations
2
-
1
+ import html
3
2
  import json
4
3
  import os
4
+ import re
5
5
  import sys
6
6
  from opsscript_gate.models import DistroStatus, MultiScriptReport, RunReport, SingleResult
7
7
 
8
+ # Structure-altering Markdown characters in regular prose:
9
+ # \ ` * _ [ ] ( ) # + - ! > ~ |
10
+ _MARKDOWN_SPECIAL_RE = re.compile(r"([\\`*_{}\[\]()#+\-.!>~|])")
11
+
12
+
13
+ def escape_inline_code(text: str) -> str:
14
+ """
15
+ Escape text for safe inclusion inside Markdown inline code spans (`...`).
16
+ Replaces backticks with single quotes and strips newlines to prevent code-span breakouts.
17
+ """
18
+ if not text:
19
+ return ""
20
+ cleaned = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
21
+ return cleaned.replace("`", "'")
22
+
23
+
24
+ def escape_markdown_text(text: str) -> str:
25
+ """
26
+ Escape structure-altering characters in normal Markdown text.
27
+ Prevents headings, blockquotes, raw HTML, links, and formatting injection
28
+ while preserving standard technical terms like 'non-zero' or 'x86_64'.
29
+ """
30
+ if not text:
31
+ return ""
32
+ # Neutralize HTML tags and entities
33
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
34
+ # Escape brackets, parentheses, pipes, backticks, and backslashes to prevent links/code spans
35
+ text = text.replace("\\", "\\\\")
36
+ text = text.replace("`", "\\`").replace("[", "\\[").replace("]", "\\]")
37
+ text = text.replace("(", "\\(").replace(")", "\\)").replace("|", "\\|")
38
+
39
+ # Neutralize line-leading markdown structures (headings, blockquotes, list markers)
40
+ def _neutralize_line_start(m: re.Match) -> str:
41
+ lead, ch = m.group(1), m.group(2)
42
+ if ch in ("#", ">", "-", "+", "*"):
43
+ return f"{lead}\\{ch}"
44
+ return m.group(0)
45
+
46
+ return re.sub(r"^(\s*)([#>+\-*])", _neutralize_line_start, text, flags=re.MULTILINE)
47
+
48
+
49
+ def escape_markdown_table_cell(text: str) -> str:
50
+ """
51
+ Escape content for safe inclusion in Markdown table cells.
52
+ Replaces newlines with spaces, escapes pipe characters,
53
+ and neutralizes HTML details/summary container tags to prevent breaking summary layout.
54
+ """
55
+ if not text:
56
+ return ""
57
+ cleaned = text.replace("\r\n", " ").replace("\r", " ").replace("\n", " ")
58
+ # Neutralize details/summary tags that could break surrounding HTML layout
59
+ cleaned = (
60
+ cleaned.replace("</details>", "<\\/details>")
61
+ .replace("<details>", "<\\details>")
62
+ .replace("</summary>", "<\\/summary>")
63
+ .replace("<summary>", "<\\summary>")
64
+ )
65
+ return cleaned.replace("|", "\\|")
66
+
67
+
68
+ def escape_html_text(text: str) -> str:
69
+ """
70
+ Escape text for safe inclusion in HTML tags and attributes (e.g. <summary>).
71
+ """
72
+ if not text:
73
+ return ""
74
+ return html.escape(text, quote=True)
75
+
76
+
77
+ def format_safe_code_fence(content: str, language: str = "text") -> list[str]:
78
+ """
79
+ Safely enclose content in a markdown code block.
80
+ Dynamically adjusts backtick count to prevent premature closing,
81
+ and neutralizes closing HTML tags (e.g. </details>) that could break surrounding containers.
82
+ """
83
+ if not content:
84
+ return [f"```{language}", "```"]
85
+
86
+ # Neutralize closing tags if present inside snippet so HTML details/summary container isn't closed
87
+ safe_content = (
88
+ content.replace("</details>", "<\\/details>")
89
+ .replace("</summary>", "<\\/summary>")
90
+ .replace("<details>", "<\\details>")
91
+ .replace("<summary>", "<\\summary>")
92
+ )
93
+
94
+ # Calculate max consecutive backticks in content
95
+ max_backticks = 3
96
+ current_run = 0
97
+ for ch in safe_content:
98
+ if ch == "`":
99
+ current_run += 1
100
+ if current_run >= max_backticks:
101
+ max_backticks = current_run + 1
102
+ else:
103
+ current_run = 0
104
+
105
+ fence = "`" * max_backticks
106
+ return [f"{fence}{language}", safe_content, fence]
107
+
8
108
 
9
109
  def escape_github_property(value: str) -> str:
10
110
  """
@@ -203,6 +303,7 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
203
303
  """
204
304
  Format an elegant GitHub-flavored Markdown Compatibility Card.
205
305
  Suitable for Step Summary and easily copyable into PR / Issue comments.
306
+ Applies context-sensitive escaping for Markdown/HTML output contexts.
206
307
  """
207
308
  passed_count = sum(1 for r in report.results if r.status == DistroStatus.PASS)
208
309
  total_count = len(report.results)
@@ -217,7 +318,8 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
217
318
  "",
218
319
  ]
219
320
  if script_path:
220
- lines.append(f"**Target Script**: `{script_path}` ")
321
+ safe_script_path = escape_inline_code(script_path)
322
+ lines.append(f"**Target Script**: `{safe_script_path}` ")
221
323
  lines.append(f"**Status**: {badge} ")
222
324
  lines.append(f"**Total Duration**: `{report.total_duration:.2f}s`")
223
325
  lines.append("")
@@ -236,24 +338,28 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
236
338
  else:
237
339
  status_icon = "⚠️ ERROR"
238
340
 
341
+ distro_cell = escape_markdown_table_cell(f"`{escape_inline_code(r.distro)}`")
239
342
  exit_code_str = f"`{r.exit_code}`" if r.exit_code is not None else "`N/A`"
240
343
  duration_str = f"`{r.duration:.2f}s`"
241
344
 
242
- diag_cell = "OK"
243
- if r.diagnostic:
345
+ if r.status == DistroStatus.PASS:
346
+ raw_diag = "OK"
347
+ elif r.diagnostic:
244
348
  line_str = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
245
- clean_cmd = f"`{r.diagnostic.command}`" if r.diagnostic.command else "command"
246
- diag_cell = f"⚠️ Missing command: {clean_cmd}{line_str}"
349
+ clean_cmd = f"`{escape_inline_code(r.diagnostic.command)}`" if r.diagnostic.command else "command"
350
+ raw_diag = f"⚠️ Missing command: {clean_cmd}{line_str}"
247
351
  if r.diagnostic.hint:
248
- diag_cell += f"<br>💡 *{r.diagnostic.hint}*"
352
+ raw_diag += f"<br>💡 *{r.diagnostic.hint}*"
249
353
  elif r.error_message:
250
- diag_cell = r.error_message
354
+ raw_diag = r.error_message
251
355
  elif r.status != DistroStatus.PASS:
252
- diag_cell = f"Non-zero exit code: {r.exit_code}"
356
+ raw_diag = f"Non-zero exit code: {r.exit_code}"
357
+ else:
358
+ raw_diag = "-"
253
359
 
254
- diag_cell_escaped = diag_cell.replace("|", "\\|")
360
+ diag_cell = escape_markdown_table_cell(raw_diag)
255
361
  lines.append(
256
- f"| `{r.distro}` | {status_icon} | {exit_code_str} | {duration_str} | {diag_cell_escaped} |"
362
+ f"| {distro_cell} | {status_icon} | {exit_code_str} | {duration_str} | {diag_cell} |"
257
363
  )
258
364
 
259
365
  lines.append("")
@@ -262,23 +368,33 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
262
368
  lines.append("<details>")
263
369
  lines.append("<summary>📋 <b>Copyable Markdown (Click to expand & copy to PR / Issue)</b></summary>")
264
370
  lines.append("")
265
- lines.append("```markdown")
266
- lines.append(f"### 🛡️ OpsScript Gate: {passed_count}/{total_count} Passed (`{script_path or 'target'}`)")
267
- lines.append("| Distribution | Status | Time | Details |")
268
- lines.append("| :--- | :---: | :---: | :--- |")
371
+ safe_copy_target = escape_inline_code(script_path or "target")
372
+ copy_lines = [
373
+ f"### 🛡️ OpsScript Gate: {passed_count}/{total_count} Passed (`{safe_copy_target}`)",
374
+ "| Distribution | Status | Time | Details |",
375
+ "| :--- | :---: | :---: | :--- |",
376
+ ]
269
377
  for r in report.results:
270
378
  icon = "✅" if r.status == DistroStatus.PASS else "❌"
271
- detail = "OK"
272
- if r.diagnostic:
273
- detail = f"Missing `{r.diagnostic.command}`"
379
+ distro_cell = escape_markdown_table_cell(f"`{escape_inline_code(r.distro)}`")
380
+ if r.status == DistroStatus.PASS:
381
+ raw_detail = "OK"
382
+ elif r.diagnostic:
383
+ safe_cmd = escape_inline_code(r.diagnostic.command or "")
384
+ raw_detail = f"Missing `{safe_cmd}`"
274
385
  if r.diagnostic.line:
275
- detail += f" (L{r.diagnostic.line})"
386
+ raw_detail += f" (L{r.diagnostic.line})"
276
387
  if r.diagnostic.hint:
277
- detail += f" — *{r.diagnostic.hint}*"
388
+ raw_detail += f" — *{r.diagnostic.hint}*"
278
389
  elif r.error_message:
279
- detail = r.error_message
280
- lines.append(f"| `{r.distro}` | {icon} {r.status.value} | `{r.duration:.2f}s` | {detail} |")
281
- lines.append("```")
390
+ raw_detail = r.error_message
391
+ else:
392
+ raw_detail = "-"
393
+ detail_cell = escape_markdown_table_cell(raw_detail)
394
+ copy_lines.append(f"| {distro_cell} | {icon} {r.status.value} | `{r.duration:.2f}s` | {detail_cell} |")
395
+
396
+ copy_content = "\n".join(copy_lines)
397
+ lines.extend(format_safe_code_fence(copy_content, language="markdown"))
282
398
  lines.append("</details>")
283
399
  lines.append("")
284
400
 
@@ -287,23 +403,26 @@ def format_markdown_compatibility_card(report: RunReport, script_path: str = "")
287
403
  if failures:
288
404
  lines.append("### 🔍 Failure Diagnostic Logs")
289
405
  for r in failures:
290
- lines.append(f"<details><summary><b>[{r.status.value}] {r.distro}</b></summary>")
406
+ safe_status = escape_html_text(r.status.value)
407
+ safe_distro = escape_html_text(r.distro)
408
+ lines.append(f"<details><summary><b>[{safe_status}] {safe_distro}</b></summary>")
291
409
  lines.append("")
292
410
  if r.diagnostic:
293
- clean_kind = r.diagnostic.kind.replace("`", "'")
294
- clean_msg = r.diagnostic.message.replace("`", "'")
411
+ clean_kind = escape_inline_code(r.diagnostic.kind)
412
+ clean_msg = escape_inline_code(r.diagnostic.message)
295
413
  line_info = f" (line {r.diagnostic.line})" if r.diagnostic.line else ""
296
414
  lines.append(f"> **Diagnostic:** `{clean_kind}` — `{clean_msg}`{line_info}")
297
415
  if r.diagnostic.hint:
298
- lines.append(f"> 💡 **Recommendation:** {r.diagnostic.hint}")
416
+ safe_hint = escape_markdown_text(r.diagnostic.hint)
417
+ lines.append(f"> 💡 **Recommendation:** {safe_hint}")
299
418
  lines.append("")
300
419
  if r.error_message:
301
- lines.append(f"> **Error:** {r.error_message}")
420
+ safe_err = escape_markdown_text(r.error_message)
421
+ lines.append(f"> **Error:** {safe_err}")
302
422
  lines.append("")
303
423
  if r.output_snippet:
304
- lines.append("```text")
305
- lines.append(r.output_snippet)
306
- lines.append("```")
424
+ fence_lines = format_safe_code_fence(r.output_snippet, language="text")
425
+ lines.extend(fence_lines)
307
426
  else:
308
427
  lines.append("*No output captured.*")
309
428
  lines.append("</details>")