actions-warden 0.1.1 → 0.2.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/README.md +27 -119
- package/package.json +5 -2
- package/skills/actions-warden/SKILL.md +139 -0
- package/skills/actions-warden/agents/openai.yaml +4 -0
- package/src/lib/parser.js +72 -44
- package/src/lib/paths.js +5 -1
- package/src/rules/excessive-permissions.js +3 -0
package/README.md
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
# actions-warden
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/actions-warden)
|
|
4
|
+
[](https://www.npmjs.com/package/actions-warden?activeTab=code)
|
|
5
|
+
[](https://github.com/chiz0me/actions-warden/actions/workflows/ci.yml)
|
|
6
|
+
[](https://github.com/chiz0me/actions-warden/security/code-scanning)
|
|
7
|
+
[](https://securityscorecards.dev/viewer/?uri=github.com/chiz0me/actions-warden)
|
|
8
|
+
[](https://www.npmjs.com/package/actions-warden)
|
|
9
|
+
[](./LICENSE)
|
|
10
|
+
|
|
3
11
|
Audit, pin, and upgrade GitHub Actions workflows. Designed for safe, hands-off
|
|
4
12
|
invocation by humans **or** LLMs.
|
|
5
13
|
|
|
6
|
-
- **Audit** - scan workflows for supply-chain and
|
|
14
|
+
- **Audit** - scan workflows for supply-chain/injection risks and composite actions for mutable dependencies
|
|
7
15
|
- **Pin** - rewrite tag refs (`@v3`) to immutable commit SHAs
|
|
8
16
|
- **Upgrade** - bump pinned actions to the newest permitted version
|
|
9
17
|
- **Report** - combined audit + dry-run plan, ideal for LLM context
|
|
10
18
|
|
|
19
|
+
Inspired by [`actions-up`](https://github.com/azat-io/actions-up). The
|
|
20
|
+
update/pin core borrows its overall shape - YAML parsing plus regex-based
|
|
21
|
+
source rewrites that preserve formatting - and adds an audit layer, an
|
|
22
|
+
LLM-friendly TOON output format, inline ignore directives, an upgrade
|
|
23
|
+
cooldown, a Claude Code plugin, and a composite GitHub Action so the same
|
|
24
|
+
engine runs locally, in CI, and from inside Claude.
|
|
25
|
+
|
|
11
26
|
## Why
|
|
12
27
|
|
|
13
28
|
Tag references in `uses:` are mutable - anyone with write access to the action
|
|
@@ -29,7 +44,7 @@ Requires Node.js 20 or newer.
|
|
|
29
44
|
## Quick start
|
|
30
45
|
|
|
31
46
|
```sh
|
|
32
|
-
# Audit
|
|
47
|
+
# Audit workflows, reusable workflow calls, and composite actions
|
|
33
48
|
actions-warden audit
|
|
34
49
|
|
|
35
50
|
# Audit a specific file with remediation hints
|
|
@@ -80,7 +95,7 @@ Scan workflows for security findings.
|
|
|
80
95
|
|
|
81
96
|
| flag | default | description |
|
|
82
97
|
|---|---|---|
|
|
83
|
-
| `-w, --workflow <pattern>` | discover
|
|
98
|
+
| `-w, --workflow <pattern>` | discover workflows and `**/action.yml|yaml` | repeatable path or glob |
|
|
84
99
|
| `--severity <level>` | `low` (i.e. include all) | minimum severity to report |
|
|
85
100
|
| `--explain` | `false` | include plain-English remediation hint per finding |
|
|
86
101
|
| `--format <fmt>` | `toon` | `toon`, `json`, or `text` |
|
|
@@ -233,14 +248,15 @@ for (const finding of result.findings) {
|
|
|
233
248
|
```
|
|
234
249
|
|
|
235
250
|
Available functions: `audit`, `pin`, `upgrade`, `report`, `listRules`,
|
|
236
|
-
`
|
|
237
|
-
`
|
|
251
|
+
`discoverWorkflows`, `parseWorkflowFile`, `parseWorkflowSource`, `collectUses`,
|
|
252
|
+
`parseActionRef`, `renderAudit`, `renderPin`, `renderUpgrade`, `renderReport`,
|
|
253
|
+
`format`, `redact`.
|
|
238
254
|
|
|
239
255
|
## Audit rules
|
|
240
256
|
|
|
241
257
|
| id | severity | catches |
|
|
242
258
|
|---|---|---|
|
|
243
|
-
| `unpinned-action` | high | `uses:` refs that aren't 40-char SHAs |
|
|
259
|
+
| `unpinned-action` | high | workflow steps, job-level reusable workflows, and composite-action `uses:` refs that aren't 40-char SHAs |
|
|
244
260
|
| `excessive-permissions` | medium | `write-all` and broad write scopes |
|
|
245
261
|
| `secrets-in-env` | critical | secrets at workflow/job env (leaks to every step) |
|
|
246
262
|
| `script-injection` | critical | `github.event.*` interpolated into `run:` |
|
|
@@ -286,123 +302,15 @@ TTL defaults to 1 hour. Delete the directory to force a refresh.
|
|
|
286
302
|
| `1` | findings reported, or errors during pin/upgrade |
|
|
287
303
|
| `2` | invalid arguments |
|
|
288
304
|
|
|
289
|
-
## Releasing
|
|
290
|
-
|
|
291
|
-
To cut a release:
|
|
292
|
-
|
|
293
|
-
1. Bump `version` in `package.json` (e.g. `0.1.0` → `0.2.0`).
|
|
294
|
-
2. Commit and push to `main`.
|
|
295
|
-
3. Create and push the tag:
|
|
296
|
-
|
|
297
|
-
```sh
|
|
298
|
-
git tag v0.2.0
|
|
299
|
-
git push origin v0.2.0
|
|
300
|
-
```
|
|
301
|
-
|
|
302
|
-
The `.github/workflows/release.yml` workflow then:
|
|
303
|
-
|
|
304
|
-
- verifies `package.json` version matches the tag and runs the test suite,
|
|
305
|
-
- creates a GitHub Release with auto-generated notes,
|
|
306
|
-
- force-updates the floating major tag (e.g. `v0`) to point at the new commit.
|
|
307
|
-
|
|
308
|
-
Consumers can pin precisely (`@v0.2.0`), float on the major (`@v0`), or
|
|
309
|
-
pin to a commit SHA (recommended - and what `actions-warden pin` will
|
|
310
|
-
produce when run against their workflow).
|
|
311
|
-
|
|
312
|
-
### Publishing to the GitHub Marketplace
|
|
313
|
-
|
|
314
|
-
The repo's `action.yml` already declares `branding`, so it is Marketplace-eligible.
|
|
315
|
-
After the first release tag is pushed, open the release on github.com and tick
|
|
316
|
-
"Publish this Action to the GitHub Marketplace" to list it. No automation
|
|
317
|
-
required - the release workflow above handles everything except that opt-in.
|
|
318
|
-
|
|
319
|
-
### Marketplace sync
|
|
320
|
-
|
|
321
|
-
When a `vX.Y.Z` tag is pushed, the `sync-marketplace` job in `release.yml`
|
|
322
|
-
updates the `actions-warden` plugin entry in
|
|
323
|
-
[chiz0me/claude-plugins/.claude-plugin/marketplace.json](https://github.com/chiz0me/claude-plugins/blob/main/.claude-plugin/marketplace.json)
|
|
324
|
-
to match. No-op when the marketplace is already on the target version.
|
|
325
|
-
|
|
326
|
-
**One-time setup (cross-repo write requires a token the default
|
|
327
|
-
`GITHUB_TOKEN` cannot provide):**
|
|
328
|
-
|
|
329
|
-
1. Create a **fine-grained personal access token** at
|
|
330
|
-
https://github.com/settings/personal-access-tokens/new with:
|
|
331
|
-
- Repository access: **Only select repositories → `chiz0me/claude-plugins`**
|
|
332
|
-
- Permissions: **Contents: Read and write**
|
|
333
|
-
2. In `chiz0me/actions-warden` repo settings, add it as an Actions secret
|
|
334
|
-
named **`MARKETPLACE_SYNC_TOKEN`**.
|
|
335
|
-
|
|
336
|
-
After that, every release tag also writes a `sync: bump actions-warden to vX.Y.Z`
|
|
337
|
-
commit to the marketplace repo.
|
|
338
|
-
|
|
339
|
-
### Publishing to npm
|
|
340
|
-
|
|
341
|
-
The release workflow includes a `publish-npm` job that publishes to npm with
|
|
342
|
-
provenance via OIDC trusted publishing - no long-lived `NPM_TOKEN` lives in
|
|
343
|
-
GitHub secrets.
|
|
344
|
-
|
|
345
|
-
**npm does not support pre-publish trusted-publisher configuration** — the
|
|
346
|
-
Trusted Publisher panel only appears on packages that already exist on the
|
|
347
|
-
registry. So the very first publish has to be done with a one-time automation
|
|
348
|
-
token; after that, OIDC takes over.
|
|
349
|
-
|
|
350
|
-
#### Step 1 — bootstrap publish (one time, locally)
|
|
351
|
-
|
|
352
|
-
```sh
|
|
353
|
-
npm login # browser auth
|
|
354
|
-
npm publish --access public # no --provenance on the first publish;
|
|
355
|
-
# local publishes can't sign provenance
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
#### Step 2 — configure the trusted publisher on npmjs.com
|
|
359
|
-
|
|
360
|
-
Once the package exists, go to:
|
|
361
|
-
|
|
362
|
-
**npmjs.com → Packages → actions-warden → Settings → Trusted publishing**
|
|
363
|
-
|
|
364
|
-
Add a new GitHub Actions publisher with:
|
|
365
|
-
|
|
366
|
-
- Organization or user: `chiz0me`
|
|
367
|
-
- Repository: `actions-warden`
|
|
368
|
-
- Workflow filename: `release.yml`
|
|
369
|
-
- Environment: *(leave blank)*
|
|
370
|
-
|
|
371
|
-
Save. From this point on, no token is needed.
|
|
372
|
-
|
|
373
|
-
#### Step 3 — release future versions
|
|
374
|
-
|
|
375
|
-
Bump `version` in `package.json`, push, then:
|
|
376
|
-
|
|
377
|
-
```sh
|
|
378
|
-
git tag v0.2.0
|
|
379
|
-
git push origin v0.2.0
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
The release workflow then:
|
|
383
|
-
|
|
384
|
-
- runs the full test suite and dependency-pin verification,
|
|
385
|
-
- publishes to npm with `--provenance --access public` (the published package
|
|
386
|
-
carries a verifiable link back to this exact commit and workflow run),
|
|
387
|
-
- creates the GitHub Release with auto-generated notes,
|
|
388
|
-
- force-moves the floating major tag (`v0`).
|
|
389
|
-
|
|
390
|
-
The package is published as **`actions-warden`** (unscoped, public). Consumers
|
|
391
|
-
install it with:
|
|
392
|
-
|
|
393
|
-
```sh
|
|
394
|
-
npm install -g actions-warden
|
|
395
|
-
# or
|
|
396
|
-
npx actions-warden audit
|
|
397
|
-
```
|
|
398
|
-
|
|
399
|
-
> **Requirements:** trusted publishing needs npm ≥ 11.5.1 and Node ≥ 24, so the
|
|
400
|
-
> `publish-npm` job uses Node 24 and upgrades npm to latest before publishing.
|
|
401
|
-
|
|
402
305
|
## Security
|
|
403
306
|
|
|
404
307
|
See [SECURITY.md](./SECURITY.md) for the disclosure policy.
|
|
405
308
|
|
|
309
|
+
## Maintainers
|
|
310
|
+
|
|
311
|
+
Release process, npm/marketplace setup, and verification commands live in
|
|
312
|
+
[RELEASING.md](./RELEASING.md).
|
|
313
|
+
|
|
406
314
|
## License
|
|
407
315
|
|
|
408
316
|
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "actions-warden",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Audit, pin, and upgrade GitHub Actions workflows. LLM-friendly TOON output, safe-by-default.",
|
|
5
5
|
"author": "Naveen Yagati",
|
|
6
6
|
"homepage": "https://github.com/chiz0me/actions-warden#readme",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"src",
|
|
28
|
+
"skills",
|
|
28
29
|
"README.md",
|
|
29
30
|
"SECURITY.md",
|
|
30
31
|
"LICENSE"
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"lint": "eslint src test",
|
|
43
44
|
"verify-deps": "node scripts/verify-deps.js",
|
|
44
45
|
"audit": "npm audit --audit-level=high",
|
|
45
|
-
"
|
|
46
|
+
"release:verify": "npm run verify-deps && npm run lint && npm test && npm audit --audit-level=high && npm pack --dry-run",
|
|
47
|
+
"prepublishOnly": "npm run release:verify"
|
|
46
48
|
},
|
|
47
49
|
"dependencies": {
|
|
48
50
|
"commander": "14.0.3",
|
|
@@ -51,6 +53,7 @@
|
|
|
51
53
|
"yaml": "2.9.0"
|
|
52
54
|
},
|
|
53
55
|
"devDependencies": {
|
|
56
|
+
"eslint": "10.8.0",
|
|
54
57
|
"vitest": "4.1.6"
|
|
55
58
|
},
|
|
56
59
|
"keywords": [
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: actions-warden
|
|
3
|
+
description: Audit, pin, and upgrade GitHub Actions workflows and composite actions. Use when the user asks to scan GitHub Actions for supply-chain or injection vulnerabilities, inspect reusable-workflow calls or composite-action dependencies, replace mutable tag or branch refs with commit SHAs, bump pinned action versions, or generate a CI security report. Triggers include "audit my workflows", "pin my actions", "upgrade actions", "check workflow security", "are my GitHub Actions safe", pull_request_target, unpinned actions, reusable workflows, composite actions, or script injection in CI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# actions-warden
|
|
7
|
+
|
|
8
|
+
`actions-warden` is a Node.js CLI on npm that audits, pins, and upgrades GitHub
|
|
9
|
+
Actions workflows. It is safe-by-default (every mutating command dry-runs unless
|
|
10
|
+
`--write` is passed) and emits TOON output — one labeled `KEY: k=v` record per
|
|
11
|
+
line, ending with `STATUS: OK` or `STATUS: FAIL` — which is parseable without
|
|
12
|
+
a schema.
|
|
13
|
+
|
|
14
|
+
## When to use this skill
|
|
15
|
+
|
|
16
|
+
Invoke `actions-warden` when the user:
|
|
17
|
+
|
|
18
|
+
- asks to **audit, scan, check, or review GitHub Actions workflows** for security
|
|
19
|
+
issues (unpinned actions, broad `permissions`, secrets exposed in env,
|
|
20
|
+
script injection, pull_request_target pwn-request);
|
|
21
|
+
- asks to **pin actions to commit SHAs** or replace tag refs (`@v3`) with
|
|
22
|
+
immutable hashes;
|
|
23
|
+
- asks to **upgrade or bump** workflow action versions (with optional cooldown);
|
|
24
|
+
- wants a **combined security report** on `.github/workflows`;
|
|
25
|
+
- references the `pull_request_target` event, `${{ github.event.* }}` interpolation
|
|
26
|
+
in run scripts, or supply-chain risk in CI.
|
|
27
|
+
|
|
28
|
+
## How to invoke
|
|
29
|
+
|
|
30
|
+
The package is on npm as `actions-warden`. Run it via `npx`:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
npx actions-warden audit # default: scan .github/workflows
|
|
34
|
+
npx actions-warden audit --severity=high --explain # high+, with remediation hints
|
|
35
|
+
npx actions-warden audit --format=json # JSON output
|
|
36
|
+
npx actions-warden pin # plan SHA pins (dry-run)
|
|
37
|
+
npx actions-warden pin --write # apply pins
|
|
38
|
+
npx actions-warden upgrade --mode=minor # bump within current major
|
|
39
|
+
npx actions-warden upgrade --min-age=14 --write # only accept tags >=14 days old, apply
|
|
40
|
+
npx actions-warden report --offline # audit + dry-run plan, no network
|
|
41
|
+
npx actions-warden rules # list rule catalog
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Useful global flags: `--workflow <path-or-glob>` (repeatable), `--cwd <dir>`,
|
|
45
|
+
`--format toon|json|text`, `--output stdout|file`, `--output-path <path>`,
|
|
46
|
+
`--token <gh-token>` (also reads `GITHUB_TOKEN` / `GH_TOKEN`).
|
|
47
|
+
|
|
48
|
+
Exit codes: `0` on success/no findings, `1` on findings or errors, `2` on
|
|
49
|
+
usage error.
|
|
50
|
+
|
|
51
|
+
## What to do when invoked
|
|
52
|
+
|
|
53
|
+
1. **Identify the Actions scope.** If the user named a file or directory,
|
|
54
|
+
pass it via `--workflow`. Otherwise let discovery scan `.github/workflows/`
|
|
55
|
+
plus repository `action.yml` and `action.yaml` files. Default discovery
|
|
56
|
+
excludes `.git` and `node_modules`.
|
|
57
|
+
|
|
58
|
+
2. **Pick the right command.**
|
|
59
|
+
- "audit / scan / check security / find issues" → `audit`
|
|
60
|
+
- "pin to SHAs / lock down / replace tags" → `pin`
|
|
61
|
+
- "upgrade / update / bump versions" → `upgrade`
|
|
62
|
+
- "give me a full report / what would change" → `report`
|
|
63
|
+
|
|
64
|
+
3. **Default to dry-run.** Never pass `--write` unless the user has explicitly
|
|
65
|
+
said apply / write / commit / mutate. The CLI exits `0` whether or not it
|
|
66
|
+
would have written anything — examine the output to plan next steps.
|
|
67
|
+
|
|
68
|
+
4. **Read the output.** Every TOON line is a record:
|
|
69
|
+
- `FINDING: id=<10-char-hex> type=<rule> sev=<critical|high|medium|low> file=<path> line=<n> ...`
|
|
70
|
+
- `PIN: id=<id> file=<path> action=<owner/repo> from=<tag> to=<sha> applied=<bool>`
|
|
71
|
+
- `UPGRADE: id=<id> action=<owner/repo> from=<tag> to=<newer-tag> level=<major|minor|patch>`
|
|
72
|
+
- `SKIP: action=<...> tag=<...> reason=cooldown age_days=<n>`
|
|
73
|
+
- `SUMMARY: files=<n> findings=<n> critical=<n> high=<n> medium=<n> low=<n>`
|
|
74
|
+
- `STATUS: OK` or `STATUS: FAIL` on the last line.
|
|
75
|
+
|
|
76
|
+
Every `id` is stable — to apply just one specific change, pass `--fix=<id>`
|
|
77
|
+
(works on `pin` and `upgrade`).
|
|
78
|
+
|
|
79
|
+
5. **Explain findings clearly.** Reference each finding by file and line. If
|
|
80
|
+
the user wants remediation guidance, re-run with `--explain` to get a
|
|
81
|
+
one-line hint embedded in each `FINDING:` record.
|
|
82
|
+
|
|
83
|
+
## Audit rules
|
|
84
|
+
|
|
85
|
+
| id | severity | catches |
|
|
86
|
+
|---|---|---|
|
|
87
|
+
| `unpinned-action` | high | workflow-step, reusable-workflow job, and composite-action `uses:` refs that aren't 40-char SHAs |
|
|
88
|
+
| `excessive-permissions` | medium | `write-all` or broad write scopes |
|
|
89
|
+
| `secrets-in-env` | critical | secrets at workflow- or job-level env (leaks to every step) |
|
|
90
|
+
| `script-injection` | critical | `${{ github.event.* }}` interpolated into `run:` |
|
|
91
|
+
| `pull-request-target-checkout` | critical | "pwn-request" — pull_request_target + PR head checkout |
|
|
92
|
+
|
|
93
|
+
## Inline ignore directives
|
|
94
|
+
|
|
95
|
+
If the user has reviewed a finding and wants to silence it without changing
|
|
96
|
+
the action ref, they can add a comment:
|
|
97
|
+
|
|
98
|
+
```yaml
|
|
99
|
+
- uses: actions/checkout@v3 # actions-warden-ignore: unpinned-action
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Other forms: `# actions-warden-ignore-file`, `# actions-warden-ignore-start`/`-end`,
|
|
103
|
+
`# actions-warden-ignore-next-line`. Bare directive silences all rules;
|
|
104
|
+
`# actions-warden-ignore: a,b,c` silences only the listed rule ids.
|
|
105
|
+
|
|
106
|
+
## Programmatic API (no subprocess)
|
|
107
|
+
|
|
108
|
+
When you want to consume results in JS without spawning the CLI:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
import { audit, pin, upgrade, report } from 'actions-warden';
|
|
112
|
+
|
|
113
|
+
const result = await audit({ cwd: '/repo', explain: true });
|
|
114
|
+
// result.findings: [{ id, ruleId, severity, file, line, fields, explain }]
|
|
115
|
+
// result.summary: { files, findings, critical, high, medium, low }
|
|
116
|
+
// result.status: 'OK' | 'FAIL'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Other exports: `listRules`, `discoverWorkflows`, `parseWorkflowFile`,
|
|
120
|
+
`parseWorkflowSource`, `collectUses`, `parseActionRef`, `renderAudit`,
|
|
121
|
+
`renderPin`, `renderUpgrade`, `renderReport`, `format`, `redact`,
|
|
122
|
+
`parseIgnoreDirectives`.
|
|
123
|
+
|
|
124
|
+
## Notes
|
|
125
|
+
|
|
126
|
+
- The CLI never prompts interactively — all decisions are flag-driven, so it
|
|
127
|
+
is safe to invoke without a TTY.
|
|
128
|
+
- Output is deterministic: running twice on an unchanged repo produces
|
|
129
|
+
identical bytes.
|
|
130
|
+
- For GitHub API calls (pin, upgrade), unauthenticated quota is 60 requests/hour.
|
|
131
|
+
Set `GITHUB_TOKEN` to raise it to 5,000/hour.
|
|
132
|
+
- Cache lives at `.actions-warden-cache/` (1-hour TTL). Delete the dir to
|
|
133
|
+
force a refresh.
|
|
134
|
+
|
|
135
|
+
## Source
|
|
136
|
+
|
|
137
|
+
- npm: https://www.npmjs.com/package/actions-warden
|
|
138
|
+
- repo: https://github.com/chiz0me/actions-warden
|
|
139
|
+
- GitHub Action: `uses: chiz0me/actions-warden@v0`
|
package/src/lib/parser.js
CHANGED
|
@@ -37,6 +37,7 @@ import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
|
|
|
37
37
|
* @property {object|null} permissions
|
|
38
38
|
* @property {string|null} runsOn
|
|
39
39
|
* @property {object|null} env
|
|
40
|
+
* @property {ActionRef|null} uses reusable workflow called at job level
|
|
40
41
|
* @property {StepNode[]} steps
|
|
41
42
|
* @property {number} line
|
|
42
43
|
*/
|
|
@@ -45,11 +46,13 @@ import { parseDocument, isMap, isSeq, isPair, isScalar } from 'yaml';
|
|
|
45
46
|
* @typedef {object} WorkflowDoc
|
|
46
47
|
* @property {string} path
|
|
47
48
|
* @property {string} source
|
|
49
|
+
* @property {'workflow'|'composite-action'|'unknown'} kind
|
|
48
50
|
* @property {string|null} name
|
|
49
51
|
* @property {unknown} on
|
|
50
52
|
* @property {object|null} permissions
|
|
51
53
|
* @property {object|null} env
|
|
52
54
|
* @property {JobNode[]} jobs
|
|
55
|
+
* @property {StepNode[]} actionSteps steps from a composite action's `runs` block
|
|
53
56
|
* @property {object} raw - the parsed plain object (for rules to query)
|
|
54
57
|
*/
|
|
55
58
|
|
|
@@ -89,17 +92,6 @@ export function parseActionRef(raw, line) {
|
|
|
89
92
|
return { ...base, owner, repo, subpath: rest || null, ref, kind };
|
|
90
93
|
}
|
|
91
94
|
|
|
92
|
-
/**
|
|
93
|
-
* @param {unknown} node
|
|
94
|
-
* @returns {number}
|
|
95
|
-
*/
|
|
96
|
-
function lineOf(node) {
|
|
97
|
-
if (node && typeof node === 'object' && 'range' in node && Array.isArray(node.range)) {
|
|
98
|
-
// We won't use this path; line is computed externally from the document.
|
|
99
|
-
}
|
|
100
|
-
return 0;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
95
|
/**
|
|
104
96
|
* Find the 1-based line of a Pair/Scalar node within the YAML document.
|
|
105
97
|
*
|
|
@@ -146,6 +138,48 @@ function findPair(map, key) {
|
|
|
146
138
|
return null;
|
|
147
139
|
}
|
|
148
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Extract a `steps` sequence from a workflow job or composite action `runs`
|
|
143
|
+
* mapping. Both locations use the same step-level `uses` syntax.
|
|
144
|
+
*
|
|
145
|
+
* @param {string} source
|
|
146
|
+
* @param {object} parentNode
|
|
147
|
+
* @returns {StepNode[]}
|
|
148
|
+
*/
|
|
149
|
+
function extractSteps(source, parentNode) {
|
|
150
|
+
const steps = [];
|
|
151
|
+
const stepsPair = findPair(parentNode, 'steps');
|
|
152
|
+
if (!stepsPair || !isSeq(stepsPair.value)) return steps;
|
|
153
|
+
|
|
154
|
+
for (const stepNode of stepsPair.value.items) {
|
|
155
|
+
if (!isMap(stepNode)) continue;
|
|
156
|
+
const usesPair = findPair(stepNode, 'uses');
|
|
157
|
+
const runPair = findPair(stepNode, 'run');
|
|
158
|
+
const namePair = findPair(stepNode, 'name');
|
|
159
|
+
const idPair = findPair(stepNode, 'id');
|
|
160
|
+
const envPair = findPair(stepNode, 'env');
|
|
161
|
+
const withPair = findPair(stepNode, 'with');
|
|
162
|
+
|
|
163
|
+
const step = {
|
|
164
|
+
name: namePair && isScalar(namePair.value) ? String(namePair.value.value) : null,
|
|
165
|
+
id: idPair && isScalar(idPair.value) ? String(idPair.value.value) : null,
|
|
166
|
+
uses: null,
|
|
167
|
+
run: runPair && isScalar(runPair.value) ? String(runPair.value.value) : null,
|
|
168
|
+
env: envPair ? toJs(envPair.value) : null,
|
|
169
|
+
with_: withPair ? toJs(withPair.value) : null,
|
|
170
|
+
line: lineFromRange(source, stepNode),
|
|
171
|
+
};
|
|
172
|
+
if (usesPair && isScalar(usesPair.value)) {
|
|
173
|
+
step.uses = parseActionRef(
|
|
174
|
+
String(usesPair.value.value),
|
|
175
|
+
lineFromRange(source, usesPair.value),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
steps.push(step);
|
|
179
|
+
}
|
|
180
|
+
return steps;
|
|
181
|
+
}
|
|
182
|
+
|
|
149
183
|
/**
|
|
150
184
|
* @param {string} source
|
|
151
185
|
* @param {object} doc - yaml Document
|
|
@@ -169,6 +203,7 @@ function extractJobs(source, doc) {
|
|
|
169
203
|
permissions: null,
|
|
170
204
|
runsOn: null,
|
|
171
205
|
env: null,
|
|
206
|
+
uses: null,
|
|
172
207
|
steps: [],
|
|
173
208
|
line: jobLine,
|
|
174
209
|
};
|
|
@@ -180,36 +215,14 @@ function extractJobs(source, doc) {
|
|
|
180
215
|
if (runsOn) job.runsOn = toJs(runsOn.value);
|
|
181
216
|
const env = findPair(jobNode, 'env');
|
|
182
217
|
if (env) job.env = toJs(env.value);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const runPair = findPair(stepNode, 'run');
|
|
190
|
-
const namePair = findPair(stepNode, 'name');
|
|
191
|
-
const idPair = findPair(stepNode, 'id');
|
|
192
|
-
const envPair = findPair(stepNode, 'env');
|
|
193
|
-
const withPair = findPair(stepNode, 'with');
|
|
194
|
-
|
|
195
|
-
const stepLine = lineFromRange(source, stepNode);
|
|
196
|
-
/** @type {StepNode} */
|
|
197
|
-
const step = {
|
|
198
|
-
name: namePair && isScalar(namePair.value) ? String(namePair.value.value) : null,
|
|
199
|
-
id: idPair && isScalar(idPair.value) ? String(idPair.value.value) : null,
|
|
200
|
-
uses: null,
|
|
201
|
-
run: runPair && isScalar(runPair.value) ? String(runPair.value.value) : null,
|
|
202
|
-
env: envPair ? toJs(envPair.value) : null,
|
|
203
|
-
with_: withPair ? toJs(withPair.value) : null,
|
|
204
|
-
line: stepLine,
|
|
205
|
-
};
|
|
206
|
-
if (usesPair && isScalar(usesPair.value)) {
|
|
207
|
-
const usesLine = lineFromRange(source, usesPair.value);
|
|
208
|
-
step.uses = parseActionRef(String(usesPair.value.value), usesLine);
|
|
209
|
-
}
|
|
210
|
-
job.steps.push(step);
|
|
211
|
-
}
|
|
218
|
+
const uses = findPair(jobNode, 'uses');
|
|
219
|
+
if (uses && isScalar(uses.value)) {
|
|
220
|
+
job.uses = parseActionRef(
|
|
221
|
+
String(uses.value.value),
|
|
222
|
+
lineFromRange(source, uses.value),
|
|
223
|
+
);
|
|
212
224
|
}
|
|
225
|
+
job.steps = extractSteps(source, jobNode);
|
|
213
226
|
}
|
|
214
227
|
jobs.push(job);
|
|
215
228
|
}
|
|
@@ -233,11 +246,13 @@ export function parseWorkflowSource(source, path) {
|
|
|
233
246
|
const result = {
|
|
234
247
|
path,
|
|
235
248
|
source,
|
|
249
|
+
kind: 'unknown',
|
|
236
250
|
name: null,
|
|
237
251
|
on: null,
|
|
238
252
|
permissions: null,
|
|
239
253
|
env: null,
|
|
240
254
|
jobs: [],
|
|
255
|
+
actionSteps: [],
|
|
241
256
|
raw: doc.toJS() ?? {},
|
|
242
257
|
};
|
|
243
258
|
if (!doc.contents || !isMap(doc.contents)) return result;
|
|
@@ -249,7 +264,14 @@ export function parseWorkflowSource(source, path) {
|
|
|
249
264
|
if (permPair) result.permissions = toJs(permPair.value);
|
|
250
265
|
const envPair = findPair(doc.contents, 'env');
|
|
251
266
|
if (envPair) result.env = toJs(envPair.value);
|
|
267
|
+
const jobsPair = findPair(doc.contents, 'jobs');
|
|
268
|
+
if (jobsPair) result.kind = 'workflow';
|
|
252
269
|
result.jobs = extractJobs(source, doc);
|
|
270
|
+
const runsPair = findPair(doc.contents, 'runs');
|
|
271
|
+
if (runsPair && isMap(runsPair.value)) {
|
|
272
|
+
if (!jobsPair) result.kind = 'composite-action';
|
|
273
|
+
result.actionSteps = extractSteps(source, runsPair.value);
|
|
274
|
+
}
|
|
253
275
|
return result;
|
|
254
276
|
}
|
|
255
277
|
|
|
@@ -266,14 +288,20 @@ export async function parseWorkflowFile(path) {
|
|
|
266
288
|
* Iterate every action reference in a workflow.
|
|
267
289
|
*
|
|
268
290
|
* @param {WorkflowDoc} workflow
|
|
269
|
-
* @returns {Array<{ref: ActionRef, jobName: string, stepIndex: number}>}
|
|
291
|
+
* @returns {Array<{ref: ActionRef, jobName: string|null, stepIndex: number, location: 'job'|'step'|'action-step'}>}
|
|
270
292
|
*/
|
|
271
293
|
export function collectUses(workflow) {
|
|
272
294
|
const out = [];
|
|
273
|
-
for (const job of workflow.jobs) {
|
|
274
|
-
job.
|
|
275
|
-
|
|
295
|
+
for (const job of workflow.jobs ?? []) {
|
|
296
|
+
if (job.uses) {
|
|
297
|
+
out.push({ ref: job.uses, jobName: job.name, stepIndex: -1, location: 'job' });
|
|
298
|
+
}
|
|
299
|
+
(job.steps ?? []).forEach((step, i) => {
|
|
300
|
+
if (step.uses) out.push({ ref: step.uses, jobName: job.name, stepIndex: i, location: 'step' });
|
|
276
301
|
});
|
|
277
302
|
}
|
|
303
|
+
(workflow.actionSteps ?? []).forEach((step, i) => {
|
|
304
|
+
if (step.uses) out.push({ ref: step.uses, jobName: null, stepIndex: i, location: 'action-step' });
|
|
305
|
+
});
|
|
278
306
|
return out;
|
|
279
307
|
}
|
package/src/lib/paths.js
CHANGED
|
@@ -7,11 +7,15 @@ import { resolve, join, relative } from 'node:path';
|
|
|
7
7
|
import picomatch from 'picomatch';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* Default workflow
|
|
10
|
+
* Default workflow and composite-action globs.
|
|
11
11
|
*/
|
|
12
12
|
export const DEFAULT_WORKFLOW_PATTERNS = [
|
|
13
13
|
'.github/workflows/*.yml',
|
|
14
14
|
'.github/workflows/*.yaml',
|
|
15
|
+
'action.yml',
|
|
16
|
+
'action.yaml',
|
|
17
|
+
'**/action.yml',
|
|
18
|
+
'**/action.yaml',
|
|
15
19
|
];
|
|
16
20
|
|
|
17
21
|
/**
|
|
@@ -44,6 +44,9 @@ function inspect(permissions) {
|
|
|
44
44
|
* @param {import('../lib/parser.js').WorkflowDoc} workflow
|
|
45
45
|
*/
|
|
46
46
|
export function check(workflow) {
|
|
47
|
+
// Composite actions inherit the calling workflow's token permissions and do
|
|
48
|
+
// not support a top-level permissions block of their own.
|
|
49
|
+
if (workflow.kind === 'composite-action') return [];
|
|
47
50
|
const findings = [];
|
|
48
51
|
const topScope = inspect(workflow.permissions);
|
|
49
52
|
if (topScope === 'unset-default') {
|