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 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` |
@@ -233,14 +248,15 @@ for (const finding of result.findings) {
233
248
  ```
234
249
 
235
250
  Available functions: `audit`, `pin`, `upgrade`, `report`, `listRules`,
236
- `parseWorkflowFile`, `renderAudit`, `renderPin`, `renderUpgrade`,
237
- `renderReport`, `format`, `redact`.
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.1.1",
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') {