actions-warden 0.1.0 → 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 CHANGED
@@ -1,13 +1,28 @@
1
1
  # actions-warden
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/actions-warden?color=cb3837&logo=npm)](https://www.npmjs.com/package/actions-warden)
4
+ [![provenance](https://img.shields.io/badge/npm-provenance-5b21b6?logo=npm)](https://www.npmjs.com/package/actions-warden?activeTab=code)
5
+ [![ci](https://github.com/chiz0me/actions-warden/actions/workflows/ci.yml/badge.svg)](https://github.com/chiz0me/actions-warden/actions/workflows/ci.yml)
6
+ [![CodeQL](https://img.shields.io/badge/CodeQL-active-2188ff?logo=github)](https://github.com/chiz0me/actions-warden/security/code-scanning)
7
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/chiz0me/actions-warden/badge)](https://securityscorecards.dev/viewer/?uri=github.com/chiz0me/actions-warden)
8
+ [![node](https://img.shields.io/node/v/actions-warden)](https://www.npmjs.com/package/actions-warden)
9
+ [![license: MIT](https://img.shields.io/badge/license-MIT-green)](./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 injection vulnerabilities
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 every workflow under .github/workflows
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 under `.github/workflows/` | repeatable path or glob |
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` |
@@ -185,6 +200,39 @@ making findings visible directly in the GitHub UI.
185
200
  which fails the job by default. To collect findings without failing the build,
186
201
  set `continue-on-error: true` on the step.
187
202
 
203
+ ## Use it from Claude Code
204
+
205
+ This repo is also a Claude Code plugin. Once installed, Claude will invoke
206
+ `actions-warden` automatically whenever a prompt asks to audit, pin, or
207
+ upgrade GitHub Actions workflows.
208
+
209
+ **Option A - via the plugin marketplace (recommended):**
210
+
211
+ ```
212
+ /plugin marketplace add chiz0me/claude-plugins
213
+ /plugin install actions-warden@chiz0me
214
+ ```
215
+
216
+ **Option B - drop the skill in directly (no marketplace):**
217
+
218
+ ```sh
219
+ mkdir -p ~/.claude/skills/actions-warden
220
+ curl -fsSL https://raw.githubusercontent.com/chiz0me/actions-warden/main/skills/actions-warden/SKILL.md \
221
+ -o ~/.claude/skills/actions-warden/SKILL.md
222
+ ```
223
+
224
+ After installation, prompts like *"audit my workflows"*, *"pin my actions to
225
+ SHAs"*, or *"check this workflow for script injection"* will route through the
226
+ skill, which runs the CLI via `npx actions-warden` and explains the TOON
227
+ output back to you.
228
+
229
+ Plugin layout (per the Claude Code plugin spec):
230
+
231
+ ```
232
+ .claude-plugin/plugin.json # plugin manifest
233
+ skills/actions-warden/SKILL.md # the skill itself
234
+ ```
235
+
188
236
  ## Programmatic API
189
237
 
190
238
  Each command is also exported as an async function, so an LLM agent or
@@ -200,14 +248,15 @@ for (const finding of result.findings) {
200
248
  ```
201
249
 
202
250
  Available functions: `audit`, `pin`, `upgrade`, `report`, `listRules`,
203
- `parseWorkflowFile`, `renderAudit`, `renderPin`, `renderUpgrade`,
204
- `renderReport`, `format`, `redact`.
251
+ `discoverWorkflows`, `parseWorkflowFile`, `parseWorkflowSource`, `collectUses`,
252
+ `parseActionRef`, `renderAudit`, `renderPin`, `renderUpgrade`, `renderReport`,
253
+ `format`, `redact`.
205
254
 
206
255
  ## Audit rules
207
256
 
208
257
  | id | severity | catches |
209
258
  |---|---|---|
210
- | `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 |
211
260
  | `excessive-permissions` | medium | `write-all` and broad write scopes |
212
261
  | `secrets-in-env` | critical | secrets at workflow/job env (leaks to every step) |
213
262
  | `script-injection` | critical | `github.event.*` interpolated into `run:` |
@@ -253,103 +302,15 @@ TTL defaults to 1 hour. Delete the directory to force a refresh.
253
302
  | `1` | findings reported, or errors during pin/upgrade |
254
303
  | `2` | invalid arguments |
255
304
 
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
305
  ## Security
350
306
 
351
307
  See [SECURITY.md](./SECURITY.md) for the disclosure policy.
352
308
 
309
+ ## Maintainers
310
+
311
+ Release process, npm/marketplace setup, and verification commands live in
312
+ [RELEASING.md](./RELEASING.md).
313
+
353
314
  ## License
354
315
 
355
316
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "actions-warden",
3
- "version": "0.1.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
- "prepublishOnly": "node scripts/verify-deps.js && npm test"
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`
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Actions Warden"
3
+ short_description: "Harden GitHub Actions supply-chain security"
4
+ default_prompt: "Use $actions-warden to audit this repository and explain every GitHub Actions security finding."
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
- const stepsPair = findPair(jobNode, 'steps');
185
- if (stepsPair && isSeq(stepsPair.value)) {
186
- for (const stepNode of stepsPair.value.items) {
187
- if (!isMap(stepNode)) continue;
188
- const usesPair = findPair(stepNode, 'uses');
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.steps.forEach((step, i) => {
275
- if (step.uses) out.push({ ref: step.uses, jobName: job.name, stepIndex: i });
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 directory globs.
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') {