actions-warden 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +355 -0
- package/SECURITY.md +52 -0
- package/package.json +68 -0
- package/src/cli.js +155 -0
- package/src/commands/audit.js +161 -0
- package/src/commands/pin.js +199 -0
- package/src/commands/report.js +93 -0
- package/src/commands/upgrade.js +281 -0
- package/src/index.js +19 -0
- package/src/lib/cache.js +68 -0
- package/src/lib/formatter.js +136 -0
- package/src/lib/ignore.js +134 -0
- package/src/lib/parser.js +279 -0
- package/src/lib/paths.js +99 -0
- package/src/lib/redact.js +44 -0
- package/src/lib/resolver.js +224 -0
- package/src/lib/writer.js +56 -0
- package/src/rules/excessive-permissions.js +79 -0
- package/src/rules/index.js +18 -0
- package/src/rules/pull-request-target-checkout.js +46 -0
- package/src/rules/script-injection.js +59 -0
- package/src/rules/secrets-in-env.js +51 -0
- package/src/rules/unpinned-action.js +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 actions-warden contributors
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
# actions-warden
|
|
2
|
+
|
|
3
|
+
Audit, pin, and upgrade GitHub Actions workflows. Designed for safe, hands-off
|
|
4
|
+
invocation by humans **or** LLMs.
|
|
5
|
+
|
|
6
|
+
- **Audit** - scan workflows for supply-chain and injection vulnerabilities
|
|
7
|
+
- **Pin** - rewrite tag refs (`@v3`) to immutable commit SHAs
|
|
8
|
+
- **Upgrade** - bump pinned actions to the newest permitted version
|
|
9
|
+
- **Report** - combined audit + dry-run plan, ideal for LLM context
|
|
10
|
+
|
|
11
|
+
## Why
|
|
12
|
+
|
|
13
|
+
Tag references in `uses:` are mutable - anyone with write access to the action
|
|
14
|
+
repo (or a stolen maintainer token) can rewrite `v3` to point at malicious code.
|
|
15
|
+
Pinning to a 40-character commit SHA closes that hole. `actions-warden` finds
|
|
16
|
+
the holes, plans the fix, and applies it - but never without your explicit
|
|
17
|
+
say-so. `--dry-run` is the default for every destructive command.
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
npm install -g actions-warden
|
|
23
|
+
# or one-shot
|
|
24
|
+
npx actions-warden audit
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Requires Node.js 20 or newer.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
# Audit every workflow under .github/workflows
|
|
33
|
+
actions-warden audit
|
|
34
|
+
|
|
35
|
+
# Audit a specific file with remediation hints
|
|
36
|
+
actions-warden audit -w .github/workflows/release.yml --explain
|
|
37
|
+
|
|
38
|
+
# Plan a SHA-pinning pass (does NOT write)
|
|
39
|
+
actions-warden pin
|
|
40
|
+
|
|
41
|
+
# Actually write the pins
|
|
42
|
+
actions-warden pin --write
|
|
43
|
+
|
|
44
|
+
# Plan upgrades within the same major version
|
|
45
|
+
actions-warden upgrade --mode=minor
|
|
46
|
+
|
|
47
|
+
# Get one combined LLM-friendly report
|
|
48
|
+
actions-warden report --format=toon
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Output formats
|
|
52
|
+
|
|
53
|
+
### TOON (default - `--format=toon`)
|
|
54
|
+
|
|
55
|
+
Token-Oriented Object Notation. Each line is a labeled record with `key=value`
|
|
56
|
+
fields. Parses cleanly without a schema and ends with a machine-readable
|
|
57
|
+
`STATUS:` trailer.
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
SCAN: file=.github/workflows/release.yml
|
|
61
|
+
FINDING: id=18b82e86d7 type=unpinned-action sev=high action=actions/checkout@v3 line=15
|
|
62
|
+
FINDING: id=b50d0d45ab type=secrets-in-env sev=critical key=AWS_SECRET line=4
|
|
63
|
+
SUMMARY: files=1 findings=2 critical=1 high=1 medium=0 low=0
|
|
64
|
+
STATUS: FAIL
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### JSON (`--format=json`)
|
|
68
|
+
|
|
69
|
+
Structured payload for programmatic integrations.
|
|
70
|
+
|
|
71
|
+
### Text (`--format=text`)
|
|
72
|
+
|
|
73
|
+
Plain human-readable lines - useful when piping through `less`.
|
|
74
|
+
|
|
75
|
+
## Commands
|
|
76
|
+
|
|
77
|
+
### `audit`
|
|
78
|
+
|
|
79
|
+
Scan workflows for security findings.
|
|
80
|
+
|
|
81
|
+
| flag | default | description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `-w, --workflow <pattern>` | discover under `.github/workflows/` | repeatable path or glob |
|
|
84
|
+
| `--severity <level>` | `low` (i.e. include all) | minimum severity to report |
|
|
85
|
+
| `--explain` | `false` | include plain-English remediation hint per finding |
|
|
86
|
+
| `--format <fmt>` | `toon` | `toon`, `json`, or `text` |
|
|
87
|
+
| `--output <dest>` | `stdout` | `stdout` or `file` |
|
|
88
|
+
| `--output-path <path>` | - | required when `--output=file` |
|
|
89
|
+
| `--cwd <dir>` | `.` | working directory |
|
|
90
|
+
|
|
91
|
+
Exit codes: `0` if no findings, `1` if any finding reported, `2` on usage error.
|
|
92
|
+
|
|
93
|
+
### `pin`
|
|
94
|
+
|
|
95
|
+
Resolve every tag/branch ref to a 40-char commit SHA. Preserves the original
|
|
96
|
+
tag as an inline `# v3` comment so `upgrade` can find it later.
|
|
97
|
+
|
|
98
|
+
| flag | default | description |
|
|
99
|
+
|---|---|---|
|
|
100
|
+
| `-w, --workflow <pattern>` | discover | repeatable |
|
|
101
|
+
| `--write` | `false` | apply changes (otherwise dry-run) |
|
|
102
|
+
| `--dry-run <bool>` | `true` | explicit dry-run toggle |
|
|
103
|
+
| `--token <token>` | `$GITHUB_TOKEN` | GitHub API token |
|
|
104
|
+
| `--fix <id>` | - | apply only the change with this id |
|
|
105
|
+
| `--format <fmt>` | `toon` | |
|
|
106
|
+
|
|
107
|
+
### `upgrade`
|
|
108
|
+
|
|
109
|
+
Bump pinned/tagged actions to the newest version allowed by `--mode`.
|
|
110
|
+
|
|
111
|
+
| flag | default | description |
|
|
112
|
+
|---|---|---|
|
|
113
|
+
| `--mode <m>` | `minor` | `major`, `minor`, or `patch` |
|
|
114
|
+
| `--write` | `false` | apply changes |
|
|
115
|
+
| `--fix <id>` | - | apply only this change id |
|
|
116
|
+
| `--token <token>` | `$GITHUB_TOKEN` | |
|
|
117
|
+
|
|
118
|
+
### `report`
|
|
119
|
+
|
|
120
|
+
Runs `audit`, plus dry-run `pin` and `upgrade`. Single combined output.
|
|
121
|
+
|
|
122
|
+
| flag | default | description |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `--mode <m>` | `minor` | upgrade scope |
|
|
125
|
+
| `--offline` | `false` | skip network-dependent stages |
|
|
126
|
+
|
|
127
|
+
### `rules`
|
|
128
|
+
|
|
129
|
+
Print the rule catalog.
|
|
130
|
+
|
|
131
|
+
## Use it as a GitHub Action
|
|
132
|
+
|
|
133
|
+
`actions-warden` ships an `action.yml` at the repo root, so you can drop it
|
|
134
|
+
into any workflow.
|
|
135
|
+
|
|
136
|
+
```yaml
|
|
137
|
+
permissions:
|
|
138
|
+
contents: read
|
|
139
|
+
|
|
140
|
+
jobs:
|
|
141
|
+
audit:
|
|
142
|
+
runs-on: ubuntu-latest
|
|
143
|
+
steps:
|
|
144
|
+
- uses: actions/checkout@v5
|
|
145
|
+
with:
|
|
146
|
+
persist-credentials: false
|
|
147
|
+
- uses: <owner>/actions-warden@<commit-sha> # pin to a SHA
|
|
148
|
+
with:
|
|
149
|
+
command: audit
|
|
150
|
+
severity: high
|
|
151
|
+
explain: 'true'
|
|
152
|
+
token: ${{ github.token }}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Inputs (all optional unless noted):
|
|
156
|
+
|
|
157
|
+
| input | default | applies to | description |
|
|
158
|
+
|---|---|---|---|
|
|
159
|
+
| `command` | `audit` | all | `audit`, `pin`, `upgrade`, `report`, `rules` |
|
|
160
|
+
| `workflow` | discover | all | space-separated paths or globs |
|
|
161
|
+
| `severity` | - | audit/report | `low` / `medium` / `high` / `critical` |
|
|
162
|
+
| `format` | `toon` | all | `toon` / `json` / `text` |
|
|
163
|
+
| `mode` | `minor` | upgrade/report | `major` / `minor` / `patch` |
|
|
164
|
+
| `min-age` | `7` | upgrade/report | cooldown in days before accepting a new tag |
|
|
165
|
+
| `write` | `false` | pin/upgrade | `true` to apply changes |
|
|
166
|
+
| `explain` | `false` | audit | include remediation hints |
|
|
167
|
+
| `offline` | `false` | report | skip network calls |
|
|
168
|
+
| `output-path` | - | all | also save the report to this file |
|
|
169
|
+
| `token` | - | all | GitHub token (pass `${{ github.token }}`) |
|
|
170
|
+
| `working-directory` | `$GITHUB_WORKSPACE` | all | directory to scan |
|
|
171
|
+
| `node-version` | `20` | - | Node.js version to install |
|
|
172
|
+
|
|
173
|
+
Outputs:
|
|
174
|
+
|
|
175
|
+
| output | description |
|
|
176
|
+
|---|---|
|
|
177
|
+
| `status` | `OK` or `FAIL` (mirrors the CLI's exit signal) |
|
|
178
|
+
| `findings` | number of audit findings (audit/report) |
|
|
179
|
+
| `report-path` | absolute path of the saved report, if `output-path` was set |
|
|
180
|
+
|
|
181
|
+
The action also writes a job summary block with the output of the command,
|
|
182
|
+
making findings visible directly in the GitHub UI.
|
|
183
|
+
|
|
184
|
+
**Important:** the audit command exits non-zero when findings are reported,
|
|
185
|
+
which fails the job by default. To collect findings without failing the build,
|
|
186
|
+
set `continue-on-error: true` on the step.
|
|
187
|
+
|
|
188
|
+
## Programmatic API
|
|
189
|
+
|
|
190
|
+
Each command is also exported as an async function, so an LLM agent or
|
|
191
|
+
larger Node tool can invoke it without spawning a subprocess.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
import { audit, pin, upgrade, report } from 'actions-warden';
|
|
195
|
+
|
|
196
|
+
const result = await audit({ cwd: '/path/to/repo', explain: true });
|
|
197
|
+
for (const finding of result.findings) {
|
|
198
|
+
// result.findings[i].id is stable; pass it to pin({ fix: id })
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Available functions: `audit`, `pin`, `upgrade`, `report`, `listRules`,
|
|
203
|
+
`parseWorkflowFile`, `renderAudit`, `renderPin`, `renderUpgrade`,
|
|
204
|
+
`renderReport`, `format`, `redact`.
|
|
205
|
+
|
|
206
|
+
## Audit rules
|
|
207
|
+
|
|
208
|
+
| id | severity | catches |
|
|
209
|
+
|---|---|---|
|
|
210
|
+
| `unpinned-action` | high | `uses:` refs that aren't 40-char SHAs |
|
|
211
|
+
| `excessive-permissions` | medium | `write-all` and broad write scopes |
|
|
212
|
+
| `secrets-in-env` | critical | secrets at workflow/job env (leaks to every step) |
|
|
213
|
+
| `script-injection` | critical | `github.event.*` interpolated into `run:` |
|
|
214
|
+
| `pull-request-target-checkout` | critical | "pwn-request" pattern |
|
|
215
|
+
|
|
216
|
+
Run `actions-warden rules` for the live list.
|
|
217
|
+
|
|
218
|
+
## LLM invocation safety
|
|
219
|
+
|
|
220
|
+
Every command is designed to be safe to invoke from an autonomous agent:
|
|
221
|
+
|
|
222
|
+
- `pin` and `upgrade` default to `--dry-run=true`. You cannot accidentally
|
|
223
|
+
mutate files without explicitly passing `--write`.
|
|
224
|
+
- Output is deterministic and idempotent - re-running on an unchanged repo
|
|
225
|
+
produces identical bytes.
|
|
226
|
+
- Every finding and every planned change carries a stable `id`. To apply a
|
|
227
|
+
single fix without scope creep, pass `--fix=<id>`.
|
|
228
|
+
- The CLI never prompts interactively. All decisions are flag-driven.
|
|
229
|
+
- Secrets that leak into log lines (tokens, AWS keys, PEM blocks) are passed
|
|
230
|
+
through a redactor before output.
|
|
231
|
+
|
|
232
|
+
## Authentication
|
|
233
|
+
|
|
234
|
+
```sh
|
|
235
|
+
export GITHUB_TOKEN=$(gh auth token) # or set GH_TOKEN
|
|
236
|
+
actions-warden pin
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
The `--token` flag takes precedence. Without a token the GitHub API allows 60
|
|
240
|
+
requests/hour, which is enough for small repos but will rate-limit on larger
|
|
241
|
+
audits.
|
|
242
|
+
|
|
243
|
+
## Caching
|
|
244
|
+
|
|
245
|
+
GitHub API responses are cached in `.actions-warden-cache/` (gitignore it).
|
|
246
|
+
TTL defaults to 1 hour. Delete the directory to force a refresh.
|
|
247
|
+
|
|
248
|
+
## Exit codes
|
|
249
|
+
|
|
250
|
+
| code | meaning |
|
|
251
|
+
|---|---|
|
|
252
|
+
| `0` | success, no findings |
|
|
253
|
+
| `1` | findings reported, or errors during pin/upgrade |
|
|
254
|
+
| `2` | invalid arguments |
|
|
255
|
+
|
|
256
|
+
## Releasing
|
|
257
|
+
|
|
258
|
+
To cut a release:
|
|
259
|
+
|
|
260
|
+
1. Bump `version` in `package.json` (e.g. `0.1.0` → `0.2.0`).
|
|
261
|
+
2. Commit and push to `main`.
|
|
262
|
+
3. Create and push the tag:
|
|
263
|
+
|
|
264
|
+
```sh
|
|
265
|
+
git tag v0.2.0
|
|
266
|
+
git push origin v0.2.0
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
The `.github/workflows/release.yml` workflow then:
|
|
270
|
+
|
|
271
|
+
- verifies `package.json` version matches the tag and runs the test suite,
|
|
272
|
+
- creates a GitHub Release with auto-generated notes,
|
|
273
|
+
- force-updates the floating major tag (e.g. `v0`) to point at the new commit.
|
|
274
|
+
|
|
275
|
+
Consumers can pin precisely (`@v0.2.0`), float on the major (`@v0`), or
|
|
276
|
+
pin to a commit SHA (recommended - and what `actions-warden pin` will
|
|
277
|
+
produce when run against their workflow).
|
|
278
|
+
|
|
279
|
+
### Publishing to the GitHub Marketplace
|
|
280
|
+
|
|
281
|
+
The repo's `action.yml` already declares `branding`, so it is Marketplace-eligible.
|
|
282
|
+
After the first release tag is pushed, open the release on github.com and tick
|
|
283
|
+
"Publish this Action to the GitHub Marketplace" to list it. No automation
|
|
284
|
+
required - the release workflow above handles everything except that opt-in.
|
|
285
|
+
|
|
286
|
+
### Publishing to npm
|
|
287
|
+
|
|
288
|
+
The release workflow includes a `publish-npm` job that publishes to npm with
|
|
289
|
+
provenance via OIDC trusted publishing - no long-lived `NPM_TOKEN` lives in
|
|
290
|
+
GitHub secrets.
|
|
291
|
+
|
|
292
|
+
**npm does not support pre-publish trusted-publisher configuration** — the
|
|
293
|
+
Trusted Publisher panel only appears on packages that already exist on the
|
|
294
|
+
registry. So the very first publish has to be done with a one-time automation
|
|
295
|
+
token; after that, OIDC takes over.
|
|
296
|
+
|
|
297
|
+
#### Step 1 — bootstrap publish (one time, locally)
|
|
298
|
+
|
|
299
|
+
```sh
|
|
300
|
+
npm login # browser auth
|
|
301
|
+
npm publish --access public # no --provenance on the first publish;
|
|
302
|
+
# local publishes can't sign provenance
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
#### Step 2 — configure the trusted publisher on npmjs.com
|
|
306
|
+
|
|
307
|
+
Once the package exists, go to:
|
|
308
|
+
|
|
309
|
+
**npmjs.com → Packages → actions-warden → Settings → Trusted publishing**
|
|
310
|
+
|
|
311
|
+
Add a new GitHub Actions publisher with:
|
|
312
|
+
|
|
313
|
+
- Organization or user: `chiz0me`
|
|
314
|
+
- Repository: `actions-warden`
|
|
315
|
+
- Workflow filename: `release.yml`
|
|
316
|
+
- Environment: *(leave blank)*
|
|
317
|
+
|
|
318
|
+
Save. From this point on, no token is needed.
|
|
319
|
+
|
|
320
|
+
#### Step 3 — release future versions
|
|
321
|
+
|
|
322
|
+
Bump `version` in `package.json`, push, then:
|
|
323
|
+
|
|
324
|
+
```sh
|
|
325
|
+
git tag v0.2.0
|
|
326
|
+
git push origin v0.2.0
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
The release workflow then:
|
|
330
|
+
|
|
331
|
+
- runs the full test suite and dependency-pin verification,
|
|
332
|
+
- publishes to npm with `--provenance --access public` (the published package
|
|
333
|
+
carries a verifiable link back to this exact commit and workflow run),
|
|
334
|
+
- creates the GitHub Release with auto-generated notes,
|
|
335
|
+
- force-moves the floating major tag (`v0`).
|
|
336
|
+
|
|
337
|
+
The package is published as **`actions-warden`** (unscoped, public). Consumers
|
|
338
|
+
install it with:
|
|
339
|
+
|
|
340
|
+
```sh
|
|
341
|
+
npm install -g actions-warden
|
|
342
|
+
# or
|
|
343
|
+
npx actions-warden audit
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
> **Requirements:** trusted publishing needs npm ≥ 11.5.1 and Node ≥ 24, so the
|
|
347
|
+
> `publish-npm` job uses Node 24 and upgrades npm to latest before publishing.
|
|
348
|
+
|
|
349
|
+
## Security
|
|
350
|
+
|
|
351
|
+
See [SECURITY.md](./SECURITY.md) for the disclosure policy.
|
|
352
|
+
|
|
353
|
+
## License
|
|
354
|
+
|
|
355
|
+
MIT
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Reporting a vulnerability
|
|
4
|
+
|
|
5
|
+
Please **do not** open public issues for security-sensitive reports. Send the
|
|
6
|
+
details to the maintainers via one of:
|
|
7
|
+
|
|
8
|
+
- GitHub Security Advisory: open a private advisory on this repository.
|
|
9
|
+
- Email: see the maintainer profile linked from `package.json`.
|
|
10
|
+
|
|
11
|
+
Please include:
|
|
12
|
+
|
|
13
|
+
- A clear description of the issue.
|
|
14
|
+
- A minimal reproduction (workflow YAML, command line, expected vs. actual).
|
|
15
|
+
- The version of `actions-warden` (`npx actions-warden --version`).
|
|
16
|
+
- Any disclosure constraints on your side.
|
|
17
|
+
|
|
18
|
+
We aim to acknowledge within **3 business days** and to ship a fix or a
|
|
19
|
+
documented mitigation within **30 days** of triage for high-severity issues.
|
|
20
|
+
|
|
21
|
+
## Scope
|
|
22
|
+
|
|
23
|
+
In scope:
|
|
24
|
+
|
|
25
|
+
- Path traversal or arbitrary file write in `pin`, `upgrade`, or `report`.
|
|
26
|
+
- Bypasses of the `--dry-run` guard.
|
|
27
|
+
- Logging or persisting credentials anywhere (cache, output, stderr).
|
|
28
|
+
- Workflow parser crashes on attacker-controlled YAML.
|
|
29
|
+
- Network requests sent to hosts other than `api.github.com`.
|
|
30
|
+
|
|
31
|
+
Out of scope:
|
|
32
|
+
|
|
33
|
+
- Findings produced by audit rules themselves (those describe vulnerabilities
|
|
34
|
+
in users' workflows, not in `actions-warden`).
|
|
35
|
+
- Rate-limit responses from GitHub API.
|
|
36
|
+
- Outdated transitive dependencies that do not reach our code paths.
|
|
37
|
+
|
|
38
|
+
## Supply chain
|
|
39
|
+
|
|
40
|
+
- All runtime dependencies are pinned to exact versions (`save-exact=true`).
|
|
41
|
+
- `ignore-scripts=true` in `.npmrc` to disable arbitrary postinstall scripts.
|
|
42
|
+
- CI runs `npm audit --audit-level=high` on every PR.
|
|
43
|
+
- Releases are published with `npm publish --provenance`.
|
|
44
|
+
|
|
45
|
+
## Responsible-disclosure timeline
|
|
46
|
+
|
|
47
|
+
| event | target |
|
|
48
|
+
|---|---|
|
|
49
|
+
| Acknowledgement | 3 business days |
|
|
50
|
+
| Triage + severity rating | 7 days |
|
|
51
|
+
| Fix or mitigation for critical/high | 30 days |
|
|
52
|
+
| Public disclosure | by mutual agreement, default 90 days |
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "actions-warden",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Audit, pin, and upgrade GitHub Actions workflows. LLM-friendly TOON output, safe-by-default.",
|
|
5
|
+
"author": "Naveen Yagati",
|
|
6
|
+
"homepage": "https://github.com/chiz0me/actions-warden#readme",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/chiz0me/actions-warden.git"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/chiz0me/actions-warden/issues"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"main": "./src/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./src/index.js",
|
|
18
|
+
"./commands/audit": "./src/commands/audit.js",
|
|
19
|
+
"./commands/pin": "./src/commands/pin.js",
|
|
20
|
+
"./commands/upgrade": "./src/commands/upgrade.js",
|
|
21
|
+
"./commands/report": "./src/commands/report.js"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"actions-warden": "./src/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"README.md",
|
|
29
|
+
"SECURITY.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=20.0.0"
|
|
34
|
+
},
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"provenance": true
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest --run",
|
|
41
|
+
"test:watch": "vitest",
|
|
42
|
+
"lint": "eslint src test",
|
|
43
|
+
"verify-deps": "node scripts/verify-deps.js",
|
|
44
|
+
"audit": "npm audit --audit-level=high",
|
|
45
|
+
"prepublishOnly": "node scripts/verify-deps.js && npm test"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"commander": "14.0.3",
|
|
49
|
+
"picomatch": "4.0.4",
|
|
50
|
+
"semver": "7.8.1",
|
|
51
|
+
"yaml": "2.9.0"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"vitest": "4.1.6"
|
|
55
|
+
},
|
|
56
|
+
"keywords": [
|
|
57
|
+
"github-actions",
|
|
58
|
+
"security",
|
|
59
|
+
"audit",
|
|
60
|
+
"pin",
|
|
61
|
+
"sha-pin",
|
|
62
|
+
"workflow",
|
|
63
|
+
"supply-chain",
|
|
64
|
+
"toon",
|
|
65
|
+
"llm"
|
|
66
|
+
],
|
|
67
|
+
"license": "MIT"
|
|
68
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* actions-warden CLI entry point.
|
|
4
|
+
*
|
|
5
|
+
* Commands: audit, pin, upgrade, report, rules
|
|
6
|
+
* Global flags: --format, --output, --output-path, --workflow, --token
|
|
7
|
+
*
|
|
8
|
+
* Destructive operations (pin, upgrade) default to --dry-run=true. Pass
|
|
9
|
+
* --write to mutate workflow files.
|
|
10
|
+
*
|
|
11
|
+
* Exit codes:
|
|
12
|
+
* 0 no findings / no errors
|
|
13
|
+
* 1 findings reported (audit FAIL) or errors during pin/upgrade
|
|
14
|
+
* 2 invalid arguments
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { writeFile } from 'node:fs/promises';
|
|
18
|
+
import { resolve } from 'node:path';
|
|
19
|
+
import { Command, Option } from 'commander';
|
|
20
|
+
|
|
21
|
+
import { audit, renderAudit } from './commands/audit.js';
|
|
22
|
+
import { pin, renderPin } from './commands/pin.js';
|
|
23
|
+
import { upgrade, renderUpgrade } from './commands/upgrade.js';
|
|
24
|
+
import { report, renderReport } from './commands/report.js';
|
|
25
|
+
import { listRules } from './rules/index.js';
|
|
26
|
+
import { format as fmt } from './lib/formatter.js';
|
|
27
|
+
|
|
28
|
+
const program = new Command();
|
|
29
|
+
program
|
|
30
|
+
.name('actions-warden')
|
|
31
|
+
.description('Audit, pin, and upgrade GitHub Actions workflows.')
|
|
32
|
+
.version('0.1.0');
|
|
33
|
+
|
|
34
|
+
const formatOption = new Option('--format <fmt>', 'output format').choices(['toon', 'json', 'text']).default('toon');
|
|
35
|
+
const outputOption = new Option('--output <dest>', 'output destination').choices(['stdout', 'file']).default('stdout');
|
|
36
|
+
|
|
37
|
+
function addCommonOptions(cmd) {
|
|
38
|
+
return cmd
|
|
39
|
+
.option('-w, --workflow <pattern...>', 'workflow path or glob (repeatable)')
|
|
40
|
+
.option('--cwd <dir>', 'working directory', process.cwd())
|
|
41
|
+
.option('--token <token>', 'GitHub token (overrides GITHUB_TOKEN / GH_TOKEN)')
|
|
42
|
+
.addOption(formatOption)
|
|
43
|
+
.addOption(outputOption)
|
|
44
|
+
.option('--output-path <path>', 'file path when --output=file');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function emit(payload, opts) {
|
|
48
|
+
if (opts.output === 'file') {
|
|
49
|
+
const path = opts.outputPath ?? 'actions-warden-output.txt';
|
|
50
|
+
await writeFile(resolve(opts.cwd ?? process.cwd(), path), payload, 'utf8');
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
process.stdout.write(payload);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
addCommonOptions(program.command('audit'))
|
|
57
|
+
.description('Scan workflows for security findings')
|
|
58
|
+
.addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
|
|
59
|
+
.option('--explain', 'include plain-English remediation hint for each finding', false)
|
|
60
|
+
.action(async (opts) => {
|
|
61
|
+
const result = await audit({
|
|
62
|
+
cwd: opts.cwd,
|
|
63
|
+
workflows: opts.workflow,
|
|
64
|
+
severity: opts.severity,
|
|
65
|
+
explain: Boolean(opts.explain),
|
|
66
|
+
});
|
|
67
|
+
const payload = renderAudit(result, { format: opts.format, explain: Boolean(opts.explain), cwd: opts.cwd });
|
|
68
|
+
await emit(payload, opts);
|
|
69
|
+
process.exit(result.status === 'OK' ? 0 : 1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
addCommonOptions(program.command('pin'))
|
|
73
|
+
.description('Pin tag/branch refs to immutable commit SHAs')
|
|
74
|
+
.option('--write', 'apply changes (disables dry-run)', false)
|
|
75
|
+
.option('--dry-run <bool>', 'dry-run mode', 'true')
|
|
76
|
+
.option('--fix <id>', 'apply only the change with this id')
|
|
77
|
+
.action(async (opts) => {
|
|
78
|
+
const dryRun = !opts.write && opts.dryRun !== 'false';
|
|
79
|
+
const result = await pin({
|
|
80
|
+
cwd: opts.cwd,
|
|
81
|
+
workflows: opts.workflow,
|
|
82
|
+
dryRun,
|
|
83
|
+
token: opts.token,
|
|
84
|
+
fix: opts.fix,
|
|
85
|
+
});
|
|
86
|
+
const payload = renderPin(result, { format: opts.format, dryRun, cwd: opts.cwd });
|
|
87
|
+
await emit(payload, opts);
|
|
88
|
+
process.exit(result.status === 'OK' ? 0 : 1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
addCommonOptions(program.command('upgrade'))
|
|
92
|
+
.description('Upgrade pinned/tagged actions to a newer version')
|
|
93
|
+
.option('--write', 'apply changes (disables dry-run)', false)
|
|
94
|
+
.option('--dry-run <bool>', 'dry-run mode', 'true')
|
|
95
|
+
.addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
|
|
96
|
+
.option('--min-age <days>', 'cooldown: only accept tags older than this many days', '7')
|
|
97
|
+
.option('--fix <id>', 'apply only the change with this id')
|
|
98
|
+
.action(async (opts) => {
|
|
99
|
+
const dryRun = !opts.write && opts.dryRun !== 'false';
|
|
100
|
+
const minAgeDays = Number.parseInt(opts.minAge, 10);
|
|
101
|
+
if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
|
|
102
|
+
process.stderr.write('error: --min-age must be a non-negative integer\n');
|
|
103
|
+
process.exit(2);
|
|
104
|
+
}
|
|
105
|
+
const result = await upgrade({
|
|
106
|
+
cwd: opts.cwd,
|
|
107
|
+
workflows: opts.workflow,
|
|
108
|
+
dryRun,
|
|
109
|
+
token: opts.token,
|
|
110
|
+
mode: opts.mode,
|
|
111
|
+
fix: opts.fix,
|
|
112
|
+
minAgeDays,
|
|
113
|
+
});
|
|
114
|
+
const payload = renderUpgrade(result, { format: opts.format, dryRun, mode: opts.mode, cwd: opts.cwd });
|
|
115
|
+
await emit(payload, opts);
|
|
116
|
+
process.exit(result.status === 'OK' ? 0 : 1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
addCommonOptions(program.command('report'))
|
|
120
|
+
.description('Combined audit + pin (dry) + upgrade (dry) report')
|
|
121
|
+
.addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
|
|
122
|
+
.option('--min-age <days>', 'cooldown for upgrades (days)', '7')
|
|
123
|
+
.option('--offline', 'skip network calls (audit only)', false)
|
|
124
|
+
.action(async (opts) => {
|
|
125
|
+
const minAgeDays = Number.parseInt(opts.minAge, 10);
|
|
126
|
+
if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
|
|
127
|
+
process.stderr.write('error: --min-age must be a non-negative integer\n');
|
|
128
|
+
process.exit(2);
|
|
129
|
+
}
|
|
130
|
+
const result = await report({
|
|
131
|
+
cwd: opts.cwd,
|
|
132
|
+
workflows: opts.workflow,
|
|
133
|
+
token: opts.token,
|
|
134
|
+
mode: opts.mode,
|
|
135
|
+
skipResolve: Boolean(opts.offline),
|
|
136
|
+
minAgeDays,
|
|
137
|
+
});
|
|
138
|
+
const payload = renderReport(result, { format: opts.format, mode: opts.mode, cwd: opts.cwd });
|
|
139
|
+
await emit(payload, opts);
|
|
140
|
+
process.exit(result.status === 'OK' ? 0 : 1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
program.command('rules')
|
|
144
|
+
.description('List available audit rules')
|
|
145
|
+
.addOption(formatOption)
|
|
146
|
+
.action((opts) => {
|
|
147
|
+
const rules = listRules();
|
|
148
|
+
const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), { status: 'OK', json: { rules, status: 'OK' } });
|
|
149
|
+
process.stdout.write(payload);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
153
|
+
process.stderr.write(`error: ${err.message}\n`);
|
|
154
|
+
process.exit(2);
|
|
155
|
+
});
|