actions-warden 0.2.0 → 0.3.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.
Files changed (62) hide show
  1. package/AGENTS.md +189 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +272 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +25 -3
  6. package/docs/AI-AGENTS.md +458 -0
  7. package/docs/CLI.md +421 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +350 -0
  10. package/docs/GITHUB-ACTION.md +281 -0
  11. package/docs/JAVASCRIPT-API.md +355 -0
  12. package/docs/OUTPUTS.md +409 -0
  13. package/docs/README.md +27 -0
  14. package/examples/org-scan.yml +42 -0
  15. package/examples/upgrade-pr.yml +57 -0
  16. package/llms.txt +38 -0
  17. package/package.json +32 -10
  18. package/skills/actions-warden/SKILL.md +140 -38
  19. package/src/action.js +317 -0
  20. package/src/cli.js +267 -22
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +549 -0
  23. package/src/commands/pin.js +59 -56
  24. package/src/commands/report.js +122 -10
  25. package/src/commands/upgrade.js +102 -62
  26. package/src/commands/verify.js +193 -0
  27. package/src/index.js +21 -4
  28. package/src/lib/action-status.js +27 -0
  29. package/src/lib/agent-mode.js +175 -0
  30. package/src/lib/annotations.js +250 -0
  31. package/src/lib/baseline.js +103 -0
  32. package/src/lib/cache.js +47 -10
  33. package/src/lib/concurrency.js +27 -0
  34. package/src/lib/config.js +185 -0
  35. package/src/lib/execution.js +71 -0
  36. package/src/lib/formatter.js +127 -8
  37. package/src/lib/github-org.js +374 -0
  38. package/src/lib/identity.js +62 -0
  39. package/src/lib/ignore.js +7 -6
  40. package/src/lib/org-checkpoint.js +378 -0
  41. package/src/lib/org-progress.js +60 -0
  42. package/src/lib/parser.js +326 -52
  43. package/src/lib/patcher.js +199 -0
  44. package/src/lib/paths.js +35 -12
  45. package/src/lib/redact.js +65 -4
  46. package/src/lib/resolver.js +225 -43
  47. package/src/lib/targets.js +28 -0
  48. package/src/lib/triggers.js +12 -0
  49. package/src/lib/writer.js +45 -8
  50. package/src/rules/excessive-permissions.js +24 -33
  51. package/src/rules/index.js +19 -1
  52. package/src/rules/pull-request-target-checkout.js +149 -18
  53. package/src/rules/reusable-workflow-secrets.js +32 -0
  54. package/src/rules/script-injection.js +77 -12
  55. package/src/rules/secrets-in-env.js +101 -18
  56. package/src/rules/unpinned-action.js +3 -2
  57. package/src/rules/unpinned-container-image.js +39 -0
  58. package/src/rules/unpinned-docker-action.js +30 -0
  59. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  60. package/src/rules/workflow-run-artifact-execution.js +122 -0
  61. package/src/rules/workflow-structure.js +396 -0
  62. package/src/version.js +3 -0
package/src/cli.js CHANGED
@@ -2,11 +2,10 @@
2
2
  /**
3
3
  * actions-warden CLI entry point.
4
4
  *
5
- * Commands: audit, pin, upgrade, report, rules
5
+ * Commands: audit, pin, upgrade, verify, report, org-scan, rules
6
6
  * Global flags: --format, --output, --output-path, --workflow, --token
7
7
  *
8
- * Destructive operations (pin, upgrade) default to --dry-run=true. Pass
9
- * --write to mutate workflow files.
8
+ * Mutating operations (pin, upgrade) only write when --write is present.
10
9
  *
11
10
  * Exit codes:
12
11
  * 0 no findings / no errors
@@ -14,24 +13,38 @@
14
13
  * 2 invalid arguments
15
14
  */
16
15
 
17
- import { writeFile } from 'node:fs/promises';
18
- import { resolve } from 'node:path';
16
+ import { realpath } from 'node:fs/promises';
17
+ import { basename, dirname, join, resolve } from 'node:path';
18
+ import { createRequire } from 'node:module';
19
19
  import { Command, Option } from 'commander';
20
20
 
21
21
  import { audit, renderAudit } from './commands/audit.js';
22
22
  import { pin, renderPin } from './commands/pin.js';
23
23
  import { upgrade, renderUpgrade } from './commands/upgrade.js';
24
24
  import { report, renderReport } from './commands/report.js';
25
+ import { verify, renderVerify } from './commands/verify.js';
26
+ import { scanOrganization, renderOrganizationScan } from './commands/org-scan.js';
25
27
  import { listRules } from './rules/index.js';
26
28
  import { format as fmt } from './lib/formatter.js';
29
+ import { writeFileGuarded } from './lib/writer.js';
30
+ import { serializeBaseline } from './lib/baseline.js';
31
+ import { formatOrganizationProgress } from './lib/org-progress.js';
32
+ import {
33
+ AGENT_MODE_ENVIRONMENT_VARIABLE,
34
+ createOrganizationAgentArtifacts,
35
+ renderOrganizationAgentReceipt,
36
+ resolveAgentMode,
37
+ } from './lib/agent-mode.js';
27
38
 
28
39
  const program = new Command();
40
+ const require = createRequire(import.meta.url);
41
+ const { version } = require('../package.json');
29
42
  program
30
43
  .name('actions-warden')
31
- .description('Audit, pin, and upgrade GitHub Actions workflows.')
32
- .version('0.1.0');
44
+ .description('Audit GitHub Actions across repositories and organizations; pin, verify, and upgrade dependencies.')
45
+ .version(version);
33
46
 
34
- const formatOption = new Option('--format <fmt>', 'output format').choices(['toon', 'json', 'text']).default('toon');
47
+ const formatOption = new Option('--format <fmt>', 'output format').choices(['toon', 'json', 'text', 'sarif']).default('toon');
35
48
  const outputOption = new Option('--output <dest>', 'output destination').choices(['stdout', 'file']).default('stdout');
36
49
 
37
50
  function addCommonOptions(cmd) {
@@ -46,8 +59,13 @@ function addCommonOptions(cmd) {
46
59
 
47
60
  async function emit(payload, opts) {
48
61
  if (opts.output === 'file') {
49
- const path = opts.outputPath ?? 'actions-warden-output.txt';
50
- await writeFile(resolve(opts.cwd ?? process.cwd(), path), payload, 'utf8');
62
+ if (!opts.outputPath) throw new Error('--output-path is required when --output=file');
63
+ await writeFileGuarded({
64
+ path: resolve(opts.cwd ?? process.cwd(), opts.outputPath),
65
+ content: payload,
66
+ dryRun: false,
67
+ cwd: opts.cwd ?? process.cwd(),
68
+ });
51
69
  return;
52
70
  }
53
71
  process.stdout.write(payload);
@@ -57,25 +75,70 @@ addCommonOptions(program.command('audit'))
57
75
  .description('Scan workflows for security findings')
58
76
  .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
59
77
  .option('--explain', 'include plain-English remediation hint for each finding', false)
78
+ .option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
79
+ .option('--ignore-config', 'do not load repository policy', false)
80
+ .option('--baseline <path>', 'suppress findings recorded in a baseline')
81
+ .option('--create-baseline <path>', 'write the current findings as a baseline')
60
82
  .action(async (opts) => {
83
+ const createBaseline = opts.createBaseline;
84
+ if (createBaseline && opts.baseline) {
85
+ throw new Error('--baseline and --create-baseline cannot be used together');
86
+ }
87
+ if (opts.ignoreConfig && opts.config) {
88
+ throw new Error('--config and --ignore-config cannot be used together');
89
+ }
61
90
  const result = await audit({
62
91
  cwd: opts.cwd,
63
92
  workflows: opts.workflow,
64
93
  severity: opts.severity,
65
94
  explain: Boolean(opts.explain),
95
+ configPath: opts.ignoreConfig ? false : opts.config,
96
+ baseline: opts.baseline,
97
+ ignoreBaseline: Boolean(createBaseline),
66
98
  });
99
+ if (createBaseline) {
100
+ await writeFileGuarded({
101
+ path: resolve(opts.cwd, createBaseline),
102
+ content: serializeBaseline(result.allFindings, opts.cwd),
103
+ dryRun: false,
104
+ cwd: opts.cwd,
105
+ });
106
+ const payload = fmt(opts.format, [{
107
+ label: 'BASELINE',
108
+ fields: {
109
+ path: createBaseline,
110
+ findings: result.allFindings.length,
111
+ },
112
+ }], {
113
+ status: 'OK',
114
+ json: {
115
+ schemaVersion: '1.0',
116
+ path: createBaseline,
117
+ findings: result.allFindings.length,
118
+ status: 'OK',
119
+ },
120
+ });
121
+ await emit(payload, opts);
122
+ process.exitCode = 0;
123
+ return;
124
+ }
67
125
  const payload = renderAudit(result, { format: opts.format, explain: Boolean(opts.explain), cwd: opts.cwd });
68
126
  await emit(payload, opts);
69
- process.exit(result.status === 'OK' ? 0 : 1);
127
+ process.exitCode = result.status === 'OK' ? 0 : 1;
70
128
  });
71
129
 
72
130
  addCommonOptions(program.command('pin'))
73
131
  .description('Pin tag/branch refs to immutable commit SHAs')
74
132
  .option('--write', 'apply changes (disables dry-run)', false)
75
- .option('--dry-run <bool>', 'dry-run mode', 'true')
133
+ .option('--dry-run', 'explicitly keep dry-run mode (the default)', false)
76
134
  .option('--fix <id>', 'apply only the change with this id')
77
135
  .action(async (opts) => {
78
- const dryRun = !opts.write && opts.dryRun !== 'false';
136
+ if (opts.write && opts.dryRun) {
137
+ process.stderr.write('error: --write and --dry-run cannot be used together\n');
138
+ process.exitCode = 2;
139
+ return;
140
+ }
141
+ const dryRun = !opts.write;
79
142
  const result = await pin({
80
143
  cwd: opts.cwd,
81
144
  workflows: opts.workflow,
@@ -85,22 +148,28 @@ addCommonOptions(program.command('pin'))
85
148
  });
86
149
  const payload = renderPin(result, { format: opts.format, dryRun, cwd: opts.cwd });
87
150
  await emit(payload, opts);
88
- process.exit(result.status === 'OK' ? 0 : 1);
151
+ process.exitCode = result.status === 'OK' ? 0 : 1;
89
152
  });
90
153
 
91
154
  addCommonOptions(program.command('upgrade'))
92
155
  .description('Upgrade pinned/tagged actions to a newer version')
93
156
  .option('--write', 'apply changes (disables dry-run)', false)
94
- .option('--dry-run <bool>', 'dry-run mode', 'true')
157
+ .option('--dry-run', 'explicitly keep dry-run mode (the default)', false)
95
158
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
96
159
  .option('--min-age <days>', 'cooldown: only accept tags older than this many days', '7')
97
160
  .option('--fix <id>', 'apply only the change with this id')
98
161
  .action(async (opts) => {
99
- const dryRun = !opts.write && opts.dryRun !== 'false';
162
+ if (opts.write && opts.dryRun) {
163
+ process.stderr.write('error: --write and --dry-run cannot be used together\n');
164
+ process.exitCode = 2;
165
+ return;
166
+ }
167
+ const dryRun = !opts.write;
100
168
  const minAgeDays = Number.parseInt(opts.minAge, 10);
101
169
  if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
102
170
  process.stderr.write('error: --min-age must be a non-negative integer\n');
103
- process.exit(2);
171
+ process.exitCode = 2;
172
+ return;
104
173
  }
105
174
  const result = await upgrade({
106
175
  cwd: opts.cwd,
@@ -113,31 +182,181 @@ addCommonOptions(program.command('upgrade'))
113
182
  });
114
183
  const payload = renderUpgrade(result, { format: opts.format, dryRun, mode: opts.mode, cwd: opts.cwd });
115
184
  await emit(payload, opts);
116
- process.exit(result.status === 'OK' ? 0 : 1);
185
+ process.exitCode = result.status === 'OK' ? 0 : 1;
117
186
  });
118
187
 
119
188
  addCommonOptions(program.command('report'))
120
189
  .description('Combined audit + pin (dry) + upgrade (dry) report')
121
190
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
191
+ .addOption(new Option('--severity <level>', 'minimum audit severity').choices(['low', 'medium', 'high', 'critical']))
192
+ .option('--explain', 'include remediation hints in audit findings', false)
122
193
  .option('--min-age <days>', 'cooldown for upgrades (days)', '7')
123
194
  .option('--offline', 'skip network calls (audit only)', false)
195
+ .option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
196
+ .option('--ignore-config', 'do not load repository policy', false)
197
+ .option('--baseline <path>', 'suppress audit findings recorded in a baseline')
124
198
  .action(async (opts) => {
125
199
  const minAgeDays = Number.parseInt(opts.minAge, 10);
126
200
  if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
127
201
  process.stderr.write('error: --min-age must be a non-negative integer\n');
128
- process.exit(2);
202
+ process.exitCode = 2;
203
+ return;
204
+ }
205
+ if (opts.ignoreConfig && opts.config) {
206
+ throw new Error('--config and --ignore-config cannot be used together');
129
207
  }
130
208
  const result = await report({
131
209
  cwd: opts.cwd,
132
210
  workflows: opts.workflow,
133
211
  token: opts.token,
134
212
  mode: opts.mode,
213
+ severity: opts.severity,
214
+ explain: Boolean(opts.explain),
135
215
  skipResolve: Boolean(opts.offline),
136
216
  minAgeDays,
217
+ configPath: opts.ignoreConfig ? false : opts.config,
218
+ baseline: opts.baseline,
137
219
  });
138
220
  const payload = renderReport(result, { format: opts.format, mode: opts.mode, cwd: opts.cwd });
139
221
  await emit(payload, opts);
140
- process.exit(result.status === 'OK' ? 0 : 1);
222
+ process.exitCode = result.status === 'OK' ? 0 : 1;
223
+ });
224
+
225
+ addCommonOptions(program.command('verify'))
226
+ .description('Verify pinned SHAs and version metadata against GitHub')
227
+ .action(async (opts) => {
228
+ const result = await verify({
229
+ cwd: opts.cwd,
230
+ workflows: opts.workflow,
231
+ token: opts.token,
232
+ });
233
+ const payload = renderVerify(result, { format: opts.format, cwd: opts.cwd });
234
+ await emit(payload, opts);
235
+ process.exitCode = result.status === 'OK' ? 0 : 1;
236
+ });
237
+
238
+ program.command('org-scan <organization>')
239
+ .description('Scan workflow security across a GitHub organization')
240
+ .option('-r, --repository <pattern...>', 'repository name or glob (repeatable)')
241
+ .addOption(new Option('--visibility <visibility>', 'repository visibility').choices(['all', 'public', 'private', 'internal']).default('all'))
242
+ .option('--include-archived', 'include archived repositories', false)
243
+ .option('--include-disabled', 'include disabled repositories', false)
244
+ .option('--include-forks', 'include forked repositories', false)
245
+ .option('--max-repos <count>', 'scan at most this many repositories')
246
+ .option('--concurrency <count>', 'concurrent repository scans (1-16)', '4')
247
+ .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
248
+ .option('--explain', 'include plain-English remediation hint for each finding', false)
249
+ .option('--config <path>', 'organization-wide policy file (default: .actions-warden.yml)')
250
+ .option('--ignore-config', 'do not load organization-wide policy', false)
251
+ .option('--baseline <path>', 'suppress findings recorded in an organization baseline')
252
+ .option('--checkpoint <path>', 'create or replace a resumable scan checkpoint')
253
+ .option('--resume <path>', 'resume from and update an existing checkpoint')
254
+ .addOption(new Option('--progress <mode>', 'live progress on stderr').choices(['auto', 'always', 'never']).default('auto'))
255
+ .option('--agent-mode', 'use bounded AI-agent output and automatic artifacts')
256
+ .option('--no-agent-mode', 'disable agent mode from the environment')
257
+ .option('--cwd <dir>', 'working directory', process.cwd())
258
+ .option('--token <token>', 'GitHub token (overrides GITHUB_TOKEN / GH_TOKEN)')
259
+ .addOption(formatOption)
260
+ .addOption(outputOption)
261
+ .option('--output-path <path>', 'file path when --output=file')
262
+ .action(async (organization, opts, command) => {
263
+ if (opts.ignoreConfig && opts.config) {
264
+ throw new Error('--config and --ignore-config cannot be used together');
265
+ }
266
+ if (opts.checkpoint && opts.resume) {
267
+ throw new Error('--checkpoint and --resume cannot be used together');
268
+ }
269
+ const concurrency = parsePositiveInteger(opts.concurrency, '--concurrency');
270
+ const maxRepositories = opts.maxRepos === undefined
271
+ ? undefined
272
+ : parsePositiveInteger(opts.maxRepos, '--max-repos');
273
+ const agentMode = resolveAgentMode({
274
+ optionValue: opts.agentMode,
275
+ optionSource: command.getOptionValueSource('agentMode'),
276
+ environmentValue: process.env[AGENT_MODE_ENVIRONMENT_VARIABLE],
277
+ });
278
+ const effective = { ...opts };
279
+ if (agentMode) {
280
+ if (command.getOptionValueSource('format') === 'default') effective.format = 'json';
281
+ if (command.getOptionValueSource('output') === 'default') effective.output = 'file';
282
+ if (command.getOptionValueSource('progress') === 'default') effective.progress = 'never';
283
+ }
284
+ const agentArtifacts = agentMode
285
+ ? await createOrganizationAgentArtifacts({
286
+ organization,
287
+ cwd: effective.cwd,
288
+ repositories: effective.repository,
289
+ visibility: effective.visibility,
290
+ includeArchived: Boolean(effective.includeArchived),
291
+ includeDisabled: Boolean(effective.includeDisabled),
292
+ includeForks: Boolean(effective.includeForks),
293
+ maxRepositories,
294
+ severity: effective.severity,
295
+ explain: Boolean(effective.explain),
296
+ configPath: effective.ignoreConfig ? false : effective.config,
297
+ baseline: effective.baseline,
298
+ reportFormat: effective.format,
299
+ })
300
+ : null;
301
+ if (agentMode && effective.output === 'file' && !effective.outputPath) {
302
+ effective.outputPath = agentArtifacts.reportPath;
303
+ }
304
+ let checkpointPath = effective.resume ?? effective.checkpoint;
305
+ let resume = Boolean(effective.resume);
306
+ if (agentMode && !checkpointPath) {
307
+ checkpointPath = agentArtifacts.checkpointPath;
308
+ resume = agentArtifacts.resume;
309
+ }
310
+ if (
311
+ checkpointPath
312
+ && effective.output === 'file'
313
+ && effective.outputPath
314
+ && await samePath(
315
+ resolve(effective.cwd, checkpointPath),
316
+ resolve(effective.cwd, effective.outputPath),
317
+ )
318
+ ) {
319
+ throw new Error('checkpoint and report output paths must be different');
320
+ }
321
+ const result = await scanOrganization({
322
+ organization,
323
+ cwd: effective.cwd,
324
+ token: effective.token,
325
+ repositories: effective.repository,
326
+ visibility: effective.visibility,
327
+ includeArchived: Boolean(effective.includeArchived),
328
+ includeDisabled: Boolean(effective.includeDisabled),
329
+ includeForks: Boolean(effective.includeForks),
330
+ maxRepositories,
331
+ concurrency,
332
+ severity: effective.severity,
333
+ explain: Boolean(effective.explain),
334
+ configPath: effective.ignoreConfig ? false : effective.config,
335
+ baseline: effective.baseline,
336
+ checkpointPath,
337
+ resume,
338
+ onProgress: progressEnabled(effective.progress)
339
+ ? event => {
340
+ const message = formatOrganizationProgress(event);
341
+ if (message) process.stderr.write(message);
342
+ }
343
+ : undefined,
344
+ });
345
+ const payload = renderOrganizationScan(result, {
346
+ format: effective.format,
347
+ cwd: effective.cwd,
348
+ });
349
+ await emit(payload, effective);
350
+ if (agentMode && effective.output === 'file') {
351
+ process.stdout.write(renderOrganizationAgentReceipt({
352
+ result,
353
+ reportPath: effective.outputPath,
354
+ reportFormat: effective.format,
355
+ checkpointPath,
356
+ resumed: resume,
357
+ }));
358
+ }
359
+ process.exitCode = result.status === 'OK' ? 0 : 1;
141
360
  });
142
361
 
143
362
  program.command('rules')
@@ -145,11 +364,37 @@ program.command('rules')
145
364
  .addOption(formatOption)
146
365
  .action((opts) => {
147
366
  const rules = listRules();
148
- const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), { status: 'OK', json: { rules, status: 'OK' } });
367
+ const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), {
368
+ status: 'OK',
369
+ json: { schemaVersion: '1.0', rules, status: 'OK' },
370
+ });
149
371
  process.stdout.write(payload);
150
372
  });
151
373
 
152
374
  program.parseAsync(process.argv).catch((err) => {
153
375
  process.stderr.write(`error: ${err.message}\n`);
154
- process.exit(2);
376
+ process.exitCode = 2;
155
377
  });
378
+
379
+ function parsePositiveInteger(value, option) {
380
+ if (!/^\d+$/.test(value) || Number.parseInt(value, 10) < 1) {
381
+ throw new Error(`${option} must be a positive integer`);
382
+ }
383
+ return Number.parseInt(value, 10);
384
+ }
385
+
386
+ function progressEnabled(mode) {
387
+ return mode === 'always' || (mode === 'auto' && Boolean(process.stderr.isTTY));
388
+ }
389
+
390
+ async function samePath(left, right) {
391
+ const [leftParent, rightParent] = await Promise.all([
392
+ realpath(dirname(left)),
393
+ realpath(dirname(right)),
394
+ ]);
395
+ const leftTarget = join(leftParent, basename(left));
396
+ const rightTarget = join(rightParent, basename(right));
397
+ return process.platform === 'win32'
398
+ ? leftTarget.toLowerCase() === rightTarget.toLowerCase()
399
+ : leftTarget === rightTarget;
400
+ }