actions-warden 0.2.0 → 0.4.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 (63) hide show
  1. package/AGENTS.md +199 -0
  2. package/CONTRIBUTING.md +109 -0
  3. package/README.md +284 -224
  4. package/RELEASING.md +338 -0
  5. package/SECURITY.md +30 -3
  6. package/docs/AI-AGENTS.md +474 -0
  7. package/docs/CLI.md +474 -0
  8. package/docs/CONFIGURATION.md +340 -0
  9. package/docs/DEVELOPMENT.md +373 -0
  10. package/docs/GITHUB-ACTION.md +285 -0
  11. package/docs/JAVASCRIPT-API.md +357 -0
  12. package/docs/OUTPUTS.md +414 -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 +151 -40
  19. package/src/action.js +306 -0
  20. package/src/cli.js +494 -56
  21. package/src/commands/audit.js +189 -36
  22. package/src/commands/org-scan.js +544 -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 +174 -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 +461 -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/path-equality.js +30 -0
  45. package/src/lib/paths.js +35 -12
  46. package/src/lib/redact.js +65 -4
  47. package/src/lib/resolver.js +225 -43
  48. package/src/lib/targets.js +28 -0
  49. package/src/lib/triggers.js +12 -0
  50. package/src/lib/writer.js +48 -8
  51. package/src/rules/excessive-permissions.js +24 -33
  52. package/src/rules/index.js +19 -1
  53. package/src/rules/pull-request-target-checkout.js +149 -18
  54. package/src/rules/reusable-workflow-secrets.js +32 -0
  55. package/src/rules/script-injection.js +77 -12
  56. package/src/rules/secrets-in-env.js +101 -18
  57. package/src/rules/unpinned-action.js +3 -2
  58. package/src/rules/unpinned-container-image.js +39 -0
  59. package/src/rules/unpinned-docker-action.js +30 -0
  60. package/src/rules/untrusted-self-hosted-runner.js +109 -0
  61. package/src/rules/workflow-run-artifact-execution.js +122 -0
  62. package/src/rules/workflow-structure.js +396 -0
  63. package/src/version.js +3 -0
package/src/cli.js CHANGED
@@ -2,80 +2,184 @@
2
2
  /**
3
3
  * actions-warden CLI entry point.
4
4
  *
5
- * Commands: audit, pin, upgrade, report, rules
6
- * Global flags: --format, --output, --output-path, --workflow, --token
5
+ * Commands: audit, pin, upgrade, verify, report, org-scan, rules
6
+ * Common command flags include --format, --output, --output-path, and --workflow.
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
- * 0 no findings / no errors
13
- * 1 findings reported (audit FAIL) or errors during pin/upgrade
14
- * 2 invalid arguments
11
+ * 0 completed with status OK (or displayed help/version)
12
+ * 1 completed with a structured FAIL result
13
+ * 2 invocation-level failure
15
14
  */
16
15
 
17
- import { writeFile } from 'node:fs/promises';
18
- import { resolve } from 'node:path';
19
- import { Command, Option } from 'commander';
16
+ import { stat } from 'node:fs/promises';
17
+ import { relative, resolve, sep } from 'node:path';
18
+ import { createRequire } from 'node:module';
19
+ import { Command, CommanderError, InvalidArgumentError, 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 { resolveTargets } from './lib/targets.js';
33
+ import { sameFilePath } from './lib/path-equality.js';
34
+ import {
35
+ AGENT_MODE_ENVIRONMENT_VARIABLE,
36
+ createOrganizationAgentArtifacts,
37
+ renderOrganizationAgentReceipt,
38
+ resolveAgentMode,
39
+ } from './lib/agent-mode.js';
27
40
 
28
41
  const program = new Command();
42
+ const require = createRequire(import.meta.url);
43
+ const { version } = require('../package.json');
44
+ const FIX_ID_RE = /^[0-9a-f]{16}$/i;
45
+ const DEFAULT_CONFIG_PATHS = ['.actions-warden.yml', '.actions-warden.yaml'];
29
46
  program
30
47
  .name('actions-warden')
31
- .description('Audit, pin, and upgrade GitHub Actions workflows.')
32
- .version('0.1.0');
48
+ .description('Audit GitHub Actions across repositories and organizations; pin, verify, and upgrade dependencies.')
49
+ .version(version)
50
+ .exitOverride(error => {
51
+ if (error.exitCode !== 0) error.exitCode = 2;
52
+ throw error;
53
+ });
54
+
55
+ function createFormatOption() {
56
+ return new Option('--format <fmt>', 'output format')
57
+ .choices(['toon', 'json', 'text', 'sarif'])
58
+ .default('toon');
59
+ }
60
+
61
+ function createOutputOption() {
62
+ return new Option('--output <dest>', 'output destination')
63
+ .choices(['stdout', 'file'])
64
+ .default('stdout');
65
+ }
66
+
67
+ function createOutputPathOption() {
68
+ return new Option('--output-path <path>', 'report path (implies --output=file)')
69
+ .argParser(value => parseNonEmpty(value, '--output-path'))
70
+ .implies({ output: 'file' });
71
+ }
72
+
73
+ function createCwdOption() {
74
+ return new Option('--cwd <dir>', 'working directory')
75
+ .argParser(value => parseNonEmpty(value, '--cwd'))
76
+ .default(process.cwd(), 'current directory');
77
+ }
33
78
 
34
- const formatOption = new Option('--format <fmt>', 'output format').choices(['toon', 'json', 'text']).default('toon');
35
- const outputOption = new Option('--output <dest>', 'output destination').choices(['stdout', 'file']).default('stdout');
79
+ function createTokenOption() {
80
+ return new Option('--token <token>', 'GitHub token override (prefer GITHUB_TOKEN or GH_TOKEN)')
81
+ .argParser(value => parseNonEmpty(value, '--token'));
82
+ }
36
83
 
37
- function addCommonOptions(cmd) {
38
- return cmd
39
- .option('-w, --workflow <pattern...>', 'workflow path or glob (repeatable)')
40
- .option('--cwd <dir>', 'working directory', process.cwd())
41
- .option('--token <token>', 'GitHub token (overrides GITHUB_TOKEN / GH_TOKEN)')
42
- .addOption(formatOption)
43
- .addOption(outputOption)
44
- .option('--output-path <path>', 'file path when --output=file');
84
+ function addCommonOptions(cmd, { includeToken = true } = {}) {
85
+ const configured = cmd
86
+ .addOption(new Option('-w, --workflow <pattern...>', 'workflow path or glob (repeatable)')
87
+ .argParser((value, previous) => collectNonEmpty(value, previous, '--workflow')))
88
+ .addOption(createCwdOption());
89
+ if (includeToken) configured.addOption(createTokenOption());
90
+ return configured
91
+ .addOption(createFormatOption())
92
+ .addOption(createOutputOption())
93
+ .addOption(createOutputPathOption());
45
94
  }
46
95
 
47
96
  async function emit(payload, opts) {
48
97
  if (opts.output === 'file') {
49
- const path = opts.outputPath ?? 'actions-warden-output.txt';
50
- await writeFile(resolve(opts.cwd ?? process.cwd(), path), payload, 'utf8');
98
+ if (!opts.outputPath) throw new Error('--output-path is required when --output=file');
99
+ await writeFileGuarded({
100
+ path: resolve(opts.cwd ?? process.cwd(), opts.outputPath),
101
+ content: payload,
102
+ dryRun: false,
103
+ cwd: opts.cwd ?? process.cwd(),
104
+ });
51
105
  return;
52
106
  }
53
107
  process.stdout.write(payload);
54
108
  }
55
109
 
56
- addCommonOptions(program.command('audit'))
110
+ addCommonOptions(program.command('audit'), { includeToken: false })
57
111
  .description('Scan workflows for security findings')
58
112
  .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
59
- .option('--explain', 'include plain-English remediation hint for each finding', false)
113
+ .option('--explain', 'include plain-English remediation hint for each finding')
114
+ .addOption(new Option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
115
+ .argParser(value => parseNonEmpty(value, '--config'))
116
+ .conflicts('ignoreConfig'))
117
+ .addOption(new Option('--ignore-config', 'do not load repository policy').conflicts('config'))
118
+ .addOption(new Option('--baseline <path>', 'suppress findings recorded in a baseline')
119
+ .argParser(value => parseNonEmpty(value, '--baseline'))
120
+ .conflicts('createBaseline'))
121
+ .addOption(new Option('--create-baseline <path>', 'write the current findings as a baseline')
122
+ .argParser(value => parseNonEmpty(value, '--create-baseline'))
123
+ .conflicts('baseline'))
60
124
  .action(async (opts) => {
125
+ const createBaseline = opts.createBaseline;
126
+ const destinations = await preflightRepositoryCommand(opts, {
127
+ additionalDestinations: createBaseline
128
+ ? [{ option: '--create-baseline', path: createBaseline }]
129
+ : [],
130
+ });
61
131
  const result = await audit({
62
132
  cwd: opts.cwd,
63
133
  workflows: opts.workflow,
64
134
  severity: opts.severity,
65
135
  explain: Boolean(opts.explain),
136
+ configPath: opts.ignoreConfig ? false : opts.config,
137
+ baseline: opts.baseline,
138
+ ignoreBaseline: Boolean(createBaseline),
66
139
  });
140
+ await assertDestinationsDoNotReplaceControls(destinations, [
141
+ { label: 'active config', path: result.configPath },
142
+ ...(!createBaseline ? [{ label: 'active baseline', path: result.baseline.path }] : []),
143
+ ]);
144
+ if (createBaseline) {
145
+ await writeFileGuarded({
146
+ path: resolve(opts.cwd, createBaseline),
147
+ content: serializeBaseline(result.allFindings, opts.cwd),
148
+ dryRun: false,
149
+ cwd: opts.cwd,
150
+ });
151
+ const payload = fmt(opts.format, [{
152
+ label: 'BASELINE',
153
+ fields: {
154
+ path: createBaseline,
155
+ findings: result.allFindings.length,
156
+ },
157
+ }], {
158
+ status: 'OK',
159
+ json: {
160
+ schemaVersion: '1.0',
161
+ path: createBaseline,
162
+ findings: result.allFindings.length,
163
+ status: 'OK',
164
+ },
165
+ });
166
+ await emit(payload, opts);
167
+ process.exitCode = 0;
168
+ return;
169
+ }
67
170
  const payload = renderAudit(result, { format: opts.format, explain: Boolean(opts.explain), cwd: opts.cwd });
68
171
  await emit(payload, opts);
69
- process.exit(result.status === 'OK' ? 0 : 1);
172
+ process.exitCode = result.status === 'OK' ? 0 : 1;
70
173
  });
71
174
 
72
175
  addCommonOptions(program.command('pin'))
73
176
  .description('Pin tag/branch refs to immutable commit SHAs')
74
- .option('--write', 'apply changes (disables dry-run)', false)
75
- .option('--dry-run <bool>', 'dry-run mode', 'true')
76
- .option('--fix <id>', 'apply only the change with this id')
177
+ .addOption(new Option('--write', 'apply changes (disables dry-run)').conflicts('dryRun'))
178
+ .addOption(new Option('--dry-run', 'explicitly keep dry-run mode (the default)').conflicts('write'))
179
+ .addOption(new Option('--fix <id>', 'select only the 16-hex change id').argParser(parseFixId))
77
180
  .action(async (opts) => {
78
- const dryRun = !opts.write && opts.dryRun !== 'false';
181
+ await preflightRepositoryCommand(opts);
182
+ const dryRun = !opts.write;
79
183
  const result = await pin({
80
184
  cwd: opts.cwd,
81
185
  workflows: opts.workflow,
@@ -85,23 +189,21 @@ addCommonOptions(program.command('pin'))
85
189
  });
86
190
  const payload = renderPin(result, { format: opts.format, dryRun, cwd: opts.cwd });
87
191
  await emit(payload, opts);
88
- process.exit(result.status === 'OK' ? 0 : 1);
192
+ process.exitCode = result.status === 'OK' ? 0 : 1;
89
193
  });
90
194
 
91
195
  addCommonOptions(program.command('upgrade'))
92
196
  .description('Upgrade pinned/tagged actions to a newer version')
93
- .option('--write', 'apply changes (disables dry-run)', false)
94
- .option('--dry-run <bool>', 'dry-run mode', 'true')
197
+ .addOption(new Option('--write', 'apply changes (disables dry-run)').conflicts('dryRun'))
198
+ .addOption(new Option('--dry-run', 'explicitly keep dry-run mode (the default)').conflicts('write'))
95
199
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
96
- .option('--min-age <days>', 'cooldown: only accept tags older than this many days', '7')
97
- .option('--fix <id>', 'apply only the change with this id')
200
+ .addOption(new Option('--min-age <days>', 'cooldown: only accept tags older than this many days')
201
+ .argParser(value => parseInteger(value, '--min-age', { min: 0 }))
202
+ .default(7))
203
+ .addOption(new Option('--fix <id>', 'select only the 16-hex change id').argParser(parseFixId))
98
204
  .action(async (opts) => {
99
- const dryRun = !opts.write && opts.dryRun !== 'false';
100
- const minAgeDays = Number.parseInt(opts.minAge, 10);
101
- if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
102
- process.stderr.write('error: --min-age must be a non-negative integer\n');
103
- process.exit(2);
104
- }
205
+ await preflightRepositoryCommand(opts);
206
+ const dryRun = !opts.write;
105
207
  const result = await upgrade({
106
208
  cwd: opts.cwd,
107
209
  workflows: opts.workflow,
@@ -109,47 +211,383 @@ addCommonOptions(program.command('upgrade'))
109
211
  token: opts.token,
110
212
  mode: opts.mode,
111
213
  fix: opts.fix,
112
- minAgeDays,
214
+ minAgeDays: opts.minAge,
113
215
  });
114
216
  const payload = renderUpgrade(result, { format: opts.format, dryRun, mode: opts.mode, cwd: opts.cwd });
115
217
  await emit(payload, opts);
116
- process.exit(result.status === 'OK' ? 0 : 1);
218
+ process.exitCode = result.status === 'OK' ? 0 : 1;
117
219
  });
118
220
 
119
221
  addCommonOptions(program.command('report'))
120
222
  .description('Combined audit + pin (dry) + upgrade (dry) report')
121
223
  .addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
122
- .option('--min-age <days>', 'cooldown for upgrades (days)', '7')
123
- .option('--offline', 'skip network calls (audit only)', false)
224
+ .addOption(new Option('--severity <level>', 'minimum audit severity').choices(['low', 'medium', 'high', 'critical']))
225
+ .option('--explain', 'include remediation hints in audit findings')
226
+ .addOption(new Option('--min-age <days>', 'cooldown for upgrades (days)')
227
+ .argParser(value => parseInteger(value, '--min-age', { min: 0 }))
228
+ .default(7))
229
+ .addOption(new Option('--offline', 'skip network calls (audit only)')
230
+ .conflicts(['token', 'mode', 'minAge']))
231
+ .addOption(new Option('--config <path>', 'repository policy file (default: .actions-warden.yml)')
232
+ .argParser(value => parseNonEmpty(value, '--config'))
233
+ .conflicts('ignoreConfig'))
234
+ .addOption(new Option('--ignore-config', 'do not load repository policy').conflicts('config'))
235
+ .addOption(new Option('--baseline <path>', 'suppress audit findings recorded in a baseline')
236
+ .argParser(value => parseNonEmpty(value, '--baseline')))
124
237
  .action(async (opts) => {
125
- const minAgeDays = Number.parseInt(opts.minAge, 10);
126
- if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
127
- process.stderr.write('error: --min-age must be a non-negative integer\n');
128
- process.exit(2);
129
- }
238
+ const destinations = await preflightRepositoryCommand(opts);
130
239
  const result = await report({
131
240
  cwd: opts.cwd,
132
241
  workflows: opts.workflow,
133
242
  token: opts.token,
134
243
  mode: opts.mode,
244
+ severity: opts.severity,
245
+ explain: Boolean(opts.explain),
135
246
  skipResolve: Boolean(opts.offline),
136
- minAgeDays,
247
+ minAgeDays: opts.minAge,
248
+ configPath: opts.ignoreConfig ? false : opts.config,
249
+ baseline: opts.baseline,
137
250
  });
251
+ await assertDestinationsDoNotReplaceControls(destinations, [
252
+ { label: 'active config', path: result.audit.configPath },
253
+ { label: 'active baseline', path: result.audit.baseline.path },
254
+ ]);
138
255
  const payload = renderReport(result, { format: opts.format, mode: opts.mode, cwd: opts.cwd });
139
256
  await emit(payload, opts);
140
- process.exit(result.status === 'OK' ? 0 : 1);
257
+ process.exitCode = result.status === 'OK' ? 0 : 1;
258
+ });
259
+
260
+ addCommonOptions(program.command('verify'))
261
+ .description('Verify pinned SHAs and version metadata against GitHub')
262
+ .action(async (opts) => {
263
+ await preflightRepositoryCommand(opts);
264
+ const result = await verify({
265
+ cwd: opts.cwd,
266
+ workflows: opts.workflow,
267
+ token: opts.token,
268
+ });
269
+ const payload = renderVerify(result, { format: opts.format, cwd: opts.cwd });
270
+ await emit(payload, opts);
271
+ process.exitCode = result.status === 'OK' ? 0 : 1;
272
+ });
273
+
274
+ program.command('org-scan <organization>')
275
+ .description('Scan workflow security across a GitHub organization')
276
+ .addOption(new Option('-r, --repository <pattern...>', 'repository name or glob (repeatable)')
277
+ .argParser((value, previous) => collectNonEmpty(value, previous, '--repository')))
278
+ .addOption(new Option('--visibility <visibility>', 'repository visibility').choices(['all', 'public', 'private', 'internal']).default('all'))
279
+ .option('--include-archived', 'include archived repositories')
280
+ .option('--include-disabled', 'include disabled repositories')
281
+ .option('--include-forks', 'include forked repositories')
282
+ .addOption(new Option('--max-repos <count>', 'scan at most this many repositories')
283
+ .argParser(value => parseInteger(value, '--max-repos', { min: 1 })))
284
+ .addOption(new Option('--concurrency <count>', 'concurrent repository scans (1-16)')
285
+ .argParser(value => parseInteger(value, '--concurrency', { min: 1, max: 16 }))
286
+ .default(4))
287
+ .addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
288
+ .option('--explain', 'include plain-English remediation hint for each finding')
289
+ .addOption(new Option('--config <path>', 'organization-wide policy file (default: .actions-warden.yml)')
290
+ .argParser(value => parseNonEmpty(value, '--config'))
291
+ .conflicts('ignoreConfig'))
292
+ .addOption(new Option('--ignore-config', 'do not load organization-wide policy').conflicts('config'))
293
+ .addOption(new Option('--baseline <path>', 'suppress findings recorded in an organization baseline')
294
+ .argParser(value => parseNonEmpty(value, '--baseline')))
295
+ .addOption(new Option('--checkpoint <path>', 'create or replace a resumable scan checkpoint')
296
+ .argParser(value => parseNonEmpty(value, '--checkpoint'))
297
+ .conflicts('resume'))
298
+ .addOption(new Option('--resume <path>', 'resume from and update an existing checkpoint')
299
+ .argParser(value => parseNonEmpty(value, '--resume'))
300
+ .conflicts('checkpoint'))
301
+ .addOption(new Option('--progress <mode>', 'live progress on stderr').choices(['auto', 'always', 'never']).default('auto'))
302
+ .option('--agent-mode', 'use bounded AI-agent output and automatic artifacts')
303
+ .option('--no-agent-mode', 'disable agent mode from the environment')
304
+ .addOption(createCwdOption())
305
+ .addOption(createTokenOption())
306
+ .addOption(createFormatOption())
307
+ .addOption(createOutputOption())
308
+ .addOption(createOutputPathOption())
309
+ .action(async (organization, opts, command) => {
310
+ await validateWorkingDirectory(opts.cwd);
311
+ assertMutuallyExclusiveFlags('--agent-mode', '--no-agent-mode');
312
+ const concurrency = opts.concurrency;
313
+ const maxRepositories = opts.maxRepos;
314
+ const agentMode = resolveAgentMode({
315
+ optionValue: opts.agentMode,
316
+ optionSource: command.getOptionValueSource('agentMode'),
317
+ environmentValue: process.env[AGENT_MODE_ENVIRONMENT_VARIABLE],
318
+ });
319
+ const effective = { ...opts };
320
+ if (agentMode) {
321
+ if (command.getOptionValueSource('format') === 'default') effective.format = 'json';
322
+ if (command.getOptionValueSource('output') === 'default') effective.output = 'file';
323
+ if (command.getOptionValueSource('progress') === 'default') effective.progress = 'never';
324
+ }
325
+ const agentArtifacts = agentMode
326
+ ? await createOrganizationAgentArtifacts({
327
+ organization,
328
+ cwd: effective.cwd,
329
+ repositories: effective.repository,
330
+ visibility: effective.visibility,
331
+ includeArchived: Boolean(effective.includeArchived),
332
+ includeDisabled: Boolean(effective.includeDisabled),
333
+ includeForks: Boolean(effective.includeForks),
334
+ maxRepositories,
335
+ severity: effective.severity,
336
+ explain: Boolean(effective.explain),
337
+ configPath: effective.ignoreConfig ? false : effective.config,
338
+ baseline: effective.baseline,
339
+ reportFormat: effective.format,
340
+ })
341
+ : null;
342
+ if (agentMode && effective.output === 'file' && !effective.outputPath) {
343
+ effective.outputPath = agentArtifacts.reportPath;
344
+ }
345
+ const outputDestination = await validateOutputDestination(effective);
346
+ await assertDestinationsUseSafeNames(
347
+ outputDestination ? [outputDestination] : [],
348
+ effective.cwd,
349
+ );
350
+ let checkpointPath = effective.resume ?? effective.checkpoint;
351
+ let resume = Boolean(effective.resume);
352
+ if (agentMode && !checkpointPath) {
353
+ checkpointPath = agentArtifacts.checkpointPath;
354
+ resume = agentArtifacts.resume;
355
+ }
356
+ const checkpointDestination = checkpointPath
357
+ ? await validateWriteDestination({
358
+ option: resume ? '--resume' : '--checkpoint',
359
+ path: checkpointPath,
360
+ cwd: effective.cwd,
361
+ })
362
+ : null;
363
+ await assertDestinationsUseSafeNames(
364
+ checkpointDestination ? [checkpointDestination] : [],
365
+ effective.cwd,
366
+ );
367
+ if (
368
+ checkpointDestination
369
+ && outputDestination
370
+ && await sameFilePath(checkpointDestination.path, outputDestination.path)
371
+ ) {
372
+ throw new Error('checkpoint and report output paths must be different');
373
+ }
374
+ const result = await scanOrganization({
375
+ organization,
376
+ cwd: effective.cwd,
377
+ token: effective.token,
378
+ repositories: effective.repository,
379
+ visibility: effective.visibility,
380
+ includeArchived: Boolean(effective.includeArchived),
381
+ includeDisabled: Boolean(effective.includeDisabled),
382
+ includeForks: Boolean(effective.includeForks),
383
+ maxRepositories,
384
+ concurrency,
385
+ severity: effective.severity,
386
+ explain: Boolean(effective.explain),
387
+ configPath: effective.ignoreConfig ? false : effective.config,
388
+ baseline: effective.baseline,
389
+ checkpointPath,
390
+ resume,
391
+ onProgress: progressEnabled(effective.progress)
392
+ ? event => {
393
+ const message = formatOrganizationProgress(event);
394
+ if (message) process.stderr.write(message);
395
+ }
396
+ : undefined,
397
+ });
398
+ await assertDestinationsDoNotReplaceControls(
399
+ outputDestination ? [outputDestination] : [],
400
+ [
401
+ { label: 'active config', path: result.configPath },
402
+ { label: 'active baseline', path: result.baseline.path },
403
+ ],
404
+ );
405
+ const payload = renderOrganizationScan(result, {
406
+ format: effective.format,
407
+ cwd: effective.cwd,
408
+ });
409
+ await emit(payload, effective);
410
+ if (agentMode && effective.output === 'file') {
411
+ process.stdout.write(renderOrganizationAgentReceipt({
412
+ result,
413
+ reportPath: effective.outputPath,
414
+ reportFormat: effective.format,
415
+ checkpointPath,
416
+ resumed: resume,
417
+ }));
418
+ }
419
+ process.exitCode = result.status === 'OK' ? 0 : 1;
141
420
  });
142
421
 
143
422
  program.command('rules')
144
423
  .description('List available audit rules')
145
- .addOption(formatOption)
424
+ .addOption(createFormatOption())
146
425
  .action((opts) => {
147
426
  const rules = listRules();
148
- const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), { status: 'OK', json: { rules, status: 'OK' } });
427
+ const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), {
428
+ status: 'OK',
429
+ json: { schemaVersion: '1.0', rules, status: 'OK' },
430
+ });
149
431
  process.stdout.write(payload);
150
432
  });
151
433
 
152
434
  program.parseAsync(process.argv).catch((err) => {
435
+ if (err instanceof CommanderError) {
436
+ process.exitCode = err.exitCode;
437
+ return;
438
+ }
153
439
  process.stderr.write(`error: ${err.message}\n`);
154
- process.exit(2);
440
+ process.exitCode = 2;
155
441
  });
442
+
443
+ function parseNonEmpty(value, option) {
444
+ if (value.length === 0) {
445
+ throw new InvalidArgumentError(`${option} must be non-empty`);
446
+ }
447
+ return value;
448
+ }
449
+
450
+ function collectNonEmpty(value, previous, option) {
451
+ return [...(Array.isArray(previous) ? previous : []), parseNonEmpty(value, option)];
452
+ }
453
+
454
+ function parseInteger(value, option, { min, max = Number.MAX_SAFE_INTEGER }) {
455
+ if (!/^\d+$/.test(value)) {
456
+ throw new InvalidArgumentError(`${option} must be an integer`);
457
+ }
458
+ const parsed = Number(value);
459
+ if (!Number.isSafeInteger(parsed) || parsed < min || parsed > max) {
460
+ if (min === 0 && max === Number.MAX_SAFE_INTEGER) {
461
+ throw new InvalidArgumentError(`${option} must be a non-negative integer`);
462
+ }
463
+ if (min === 1 && max === Number.MAX_SAFE_INTEGER) {
464
+ throw new InvalidArgumentError(`${option} must be a positive integer`);
465
+ }
466
+ throw new InvalidArgumentError(`${option} must be an integer from ${min} to ${max}`);
467
+ }
468
+ return parsed;
469
+ }
470
+
471
+ function parseFixId(value) {
472
+ if (!FIX_ID_RE.test(value)) {
473
+ throw new InvalidArgumentError('--fix must be a 16-character hexadecimal change id');
474
+ }
475
+ return value.toLowerCase();
476
+ }
477
+
478
+ function progressEnabled(mode) {
479
+ return mode === 'always' || (mode === 'auto' && Boolean(process.stderr.isTTY));
480
+ }
481
+
482
+ async function preflightRepositoryCommand(opts, { additionalDestinations = [] } = {}) {
483
+ await validateWorkingDirectory(opts.cwd);
484
+ const outputDestination = await validateOutputDestination(opts);
485
+ const destinations = outputDestination ? [outputDestination] : [];
486
+ for (const destination of additionalDestinations) {
487
+ destinations.push(await validateWriteDestination({
488
+ ...destination,
489
+ cwd: opts.cwd,
490
+ }));
491
+ }
492
+ await assertDestinationsAreDistinct(destinations);
493
+ await assertDestinationsUseSafeNames(destinations, opts.cwd);
494
+ if (destinations.length > 0) {
495
+ const workflows = await resolveTargets({ workflows: opts.workflow, cwd: opts.cwd });
496
+ await assertDestinationsDoNotReplaceControls(
497
+ destinations,
498
+ workflows.map(path => ({ label: 'selected workflow', path })),
499
+ );
500
+ }
501
+ return destinations;
502
+ }
503
+
504
+ async function assertDestinationsUseSafeNames(destinations, cwd) {
505
+ await assertDestinationsDoNotReplaceControls(
506
+ destinations,
507
+ DEFAULT_CONFIG_PATHS.map(path => ({ label: 'reserved config path', path: resolve(cwd, path) })),
508
+ );
509
+ for (const destination of destinations) {
510
+ if (isDefaultWorkflowPath(destination.path, cwd)) {
511
+ throw new Error(`${destination.option} cannot use a default workflow discovery path`);
512
+ }
513
+ }
514
+ }
515
+
516
+ async function validateWorkingDirectory(cwd) {
517
+ if (typeof cwd !== 'string' || cwd.length === 0 || cwd.includes('\0')) {
518
+ throw new Error('--cwd must be an existing directory');
519
+ }
520
+ let metadata;
521
+ try {
522
+ metadata = await stat(resolve(cwd));
523
+ } catch (error) {
524
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
525
+ throw new Error('--cwd must be an existing directory');
526
+ }
527
+ throw error;
528
+ }
529
+ if (!metadata.isDirectory()) throw new Error('--cwd must be an existing directory');
530
+ }
531
+
532
+ async function validateOutputDestination(opts) {
533
+ if (opts.outputPath && opts.output !== 'file') {
534
+ throw new Error('--output-path cannot be used with --output=stdout');
535
+ }
536
+ if (opts.output !== 'file') return null;
537
+ if (!opts.outputPath) throw new Error('--output-path is required when --output=file');
538
+ return validateWriteDestination({
539
+ option: '--output-path',
540
+ path: opts.outputPath,
541
+ cwd: opts.cwd ?? process.cwd(),
542
+ });
543
+ }
544
+
545
+ async function validateWriteDestination({ option, path, cwd }) {
546
+ if (typeof path !== 'string' || path.length === 0 || path.includes('\0')) {
547
+ throw new Error(`${option} must be a non-empty path`);
548
+ }
549
+ try {
550
+ await writeFileGuarded({ path, content: '', dryRun: true, cwd });
551
+ } catch (error) {
552
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
553
+ throw new Error(`parent directory for ${option} must exist`);
554
+ }
555
+ throw error;
556
+ }
557
+ return { option, path: resolve(cwd, path) };
558
+ }
559
+
560
+ async function assertDestinationsAreDistinct(destinations) {
561
+ for (let left = 0; left < destinations.length; left += 1) {
562
+ for (let right = left + 1; right < destinations.length; right += 1) {
563
+ if (await sameFilePath(destinations[left].path, destinations[right].path)) {
564
+ throw new Error(
565
+ `${destinations[left].option} and ${destinations[right].option} must use different paths`,
566
+ );
567
+ }
568
+ }
569
+ }
570
+ }
571
+
572
+ async function assertDestinationsDoNotReplaceControls(destinations, controls) {
573
+ for (const destination of destinations) {
574
+ for (const control of controls) {
575
+ if (control.path && await sameFilePath(destination.path, resolve(control.path))) {
576
+ throw new Error(`${destination.option} cannot replace the ${control.label}`);
577
+ }
578
+ }
579
+ }
580
+ }
581
+
582
+ function assertMutuallyExclusiveFlags(left, right) {
583
+ const args = process.argv.slice(2);
584
+ if (args.includes(left) && args.includes(right)) {
585
+ throw new Error(`${left} and ${right} cannot be used together`);
586
+ }
587
+ }
588
+
589
+ function isDefaultWorkflowPath(path, cwd) {
590
+ const localPath = relative(resolve(cwd), path).split(sep).join('/');
591
+ return /^\.github\/workflows\/[^/]+\.ya?ml$/i.test(localPath)
592
+ || /(^|\/)action\.ya?ml$/i.test(localPath);
593
+ }