actions-warden 0.3.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.
- package/AGENTS.md +14 -4
- package/README.md +14 -2
- package/SECURITY.md +10 -5
- package/docs/AI-AGENTS.md +21 -5
- package/docs/CLI.md +76 -23
- package/docs/DEVELOPMENT.md +25 -2
- package/docs/GITHUB-ACTION.md +11 -7
- package/docs/JAVASCRIPT-API.md +7 -5
- package/docs/OUTPUTS.md +10 -5
- package/examples/upgrade-pr.yml +1 -1
- package/package.json +1 -1
- package/skills/actions-warden/SKILL.md +31 -22
- package/src/action.js +4 -15
- package/src/cli.js +314 -121
- package/src/commands/org-scan.js +6 -11
- package/src/lib/agent-mode.js +5 -6
- package/src/lib/org-checkpoint.js +90 -7
- package/src/lib/path-equality.js +30 -0
- package/src/lib/writer.js +3 -0
- package/src/version.js +1 -1
package/src/action.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { appendFile
|
|
4
|
-
import {
|
|
3
|
+
import { appendFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
6
|
import { audit, renderAudit } from './commands/audit.js';
|
|
7
7
|
import { pin, renderPin } from './commands/pin.js';
|
|
@@ -15,6 +15,7 @@ import { redact } from './lib/redact.js';
|
|
|
15
15
|
import { writeFileGuarded } from './lib/writer.js';
|
|
16
16
|
import { shouldFailAction } from './lib/action-status.js';
|
|
17
17
|
import { formatOrganizationProgress } from './lib/org-progress.js';
|
|
18
|
+
import { sameFilePath } from './lib/path-equality.js';
|
|
18
19
|
import {
|
|
19
20
|
collectAnnotations,
|
|
20
21
|
limitAnnotations,
|
|
@@ -119,7 +120,7 @@ async function main() {
|
|
|
119
120
|
if (
|
|
120
121
|
(resumeFrom ?? checkpointPath)
|
|
121
122
|
&& input('output-path')
|
|
122
|
-
&& await
|
|
123
|
+
&& await sameFilePath(
|
|
123
124
|
resolve(cwd, resumeFrom ?? checkpointPath),
|
|
124
125
|
resolve(cwd, input('output-path')),
|
|
125
126
|
)
|
|
@@ -254,18 +255,6 @@ function optionalPositiveInteger(value, name) {
|
|
|
254
255
|
return value ? positiveInteger(value, name) : undefined;
|
|
255
256
|
}
|
|
256
257
|
|
|
257
|
-
async function samePath(left, right) {
|
|
258
|
-
const [leftParent, rightParent] = await Promise.all([
|
|
259
|
-
realpath(dirname(left)),
|
|
260
|
-
realpath(dirname(right)),
|
|
261
|
-
]);
|
|
262
|
-
const leftTarget = join(leftParent, basename(left));
|
|
263
|
-
const rightTarget = join(rightParent, basename(right));
|
|
264
|
-
return process.platform === 'win32'
|
|
265
|
-
? leftTarget.toLowerCase() === rightTarget.toLowerCase()
|
|
266
|
-
: leftTarget === rightTarget;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
258
|
async function setOutput(name, value) {
|
|
270
259
|
if (!process.env.GITHUB_OUTPUT) return;
|
|
271
260
|
const delimiter = `actions_warden_${randomUUID().replaceAll('-', '')}`;
|
package/src/cli.js
CHANGED
|
@@ -3,20 +3,20 @@
|
|
|
3
3
|
* actions-warden CLI entry point.
|
|
4
4
|
*
|
|
5
5
|
* Commands: audit, pin, upgrade, verify, report, org-scan, rules
|
|
6
|
-
*
|
|
6
|
+
* Common command flags include --format, --output, --output-path, and --workflow.
|
|
7
7
|
*
|
|
8
8
|
* Mutating operations (pin, upgrade) only write when --write is present.
|
|
9
9
|
*
|
|
10
10
|
* Exit codes:
|
|
11
|
-
* 0
|
|
12
|
-
* 1
|
|
13
|
-
* 2
|
|
11
|
+
* 0 completed with status OK (or displayed help/version)
|
|
12
|
+
* 1 completed with a structured FAIL result
|
|
13
|
+
* 2 invocation-level failure
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
16
|
+
import { stat } from 'node:fs/promises';
|
|
17
|
+
import { relative, resolve, sep } from 'node:path';
|
|
18
18
|
import { createRequire } from 'node:module';
|
|
19
|
-
import { Command, Option } from 'commander';
|
|
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';
|
|
@@ -29,6 +29,8 @@ import { format as fmt } from './lib/formatter.js';
|
|
|
29
29
|
import { writeFileGuarded } from './lib/writer.js';
|
|
30
30
|
import { serializeBaseline } from './lib/baseline.js';
|
|
31
31
|
import { formatOrganizationProgress } from './lib/org-progress.js';
|
|
32
|
+
import { resolveTargets } from './lib/targets.js';
|
|
33
|
+
import { sameFilePath } from './lib/path-equality.js';
|
|
32
34
|
import {
|
|
33
35
|
AGENT_MODE_ENVIRONMENT_VARIABLE,
|
|
34
36
|
createOrganizationAgentArtifacts,
|
|
@@ -39,22 +41,56 @@ import {
|
|
|
39
41
|
const program = new Command();
|
|
40
42
|
const require = createRequire(import.meta.url);
|
|
41
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'];
|
|
42
46
|
program
|
|
43
47
|
.name('actions-warden')
|
|
44
48
|
.description('Audit GitHub Actions across repositories and organizations; pin, verify, and upgrade dependencies.')
|
|
45
|
-
.version(version)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
.
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
78
|
+
|
|
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
|
+
}
|
|
83
|
+
|
|
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());
|
|
58
94
|
}
|
|
59
95
|
|
|
60
96
|
async function emit(payload, opts) {
|
|
@@ -71,22 +107,27 @@ async function emit(payload, opts) {
|
|
|
71
107
|
process.stdout.write(payload);
|
|
72
108
|
}
|
|
73
109
|
|
|
74
|
-
addCommonOptions(program.command('audit'))
|
|
110
|
+
addCommonOptions(program.command('audit'), { includeToken: false })
|
|
75
111
|
.description('Scan workflows for security findings')
|
|
76
112
|
.addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
|
|
77
|
-
.option('--explain', 'include plain-English remediation hint for each finding'
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
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'))
|
|
82
124
|
.action(async (opts) => {
|
|
83
125
|
const createBaseline = opts.createBaseline;
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
126
|
+
const destinations = await preflightRepositoryCommand(opts, {
|
|
127
|
+
additionalDestinations: createBaseline
|
|
128
|
+
? [{ option: '--create-baseline', path: createBaseline }]
|
|
129
|
+
: [],
|
|
130
|
+
});
|
|
90
131
|
const result = await audit({
|
|
91
132
|
cwd: opts.cwd,
|
|
92
133
|
workflows: opts.workflow,
|
|
@@ -96,6 +137,10 @@ addCommonOptions(program.command('audit'))
|
|
|
96
137
|
baseline: opts.baseline,
|
|
97
138
|
ignoreBaseline: Boolean(createBaseline),
|
|
98
139
|
});
|
|
140
|
+
await assertDestinationsDoNotReplaceControls(destinations, [
|
|
141
|
+
{ label: 'active config', path: result.configPath },
|
|
142
|
+
...(!createBaseline ? [{ label: 'active baseline', path: result.baseline.path }] : []),
|
|
143
|
+
]);
|
|
99
144
|
if (createBaseline) {
|
|
100
145
|
await writeFileGuarded({
|
|
101
146
|
path: resolve(opts.cwd, createBaseline),
|
|
@@ -129,15 +174,11 @@ addCommonOptions(program.command('audit'))
|
|
|
129
174
|
|
|
130
175
|
addCommonOptions(program.command('pin'))
|
|
131
176
|
.description('Pin tag/branch refs to immutable commit SHAs')
|
|
132
|
-
.
|
|
133
|
-
.
|
|
134
|
-
.
|
|
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))
|
|
135
180
|
.action(async (opts) => {
|
|
136
|
-
|
|
137
|
-
process.stderr.write('error: --write and --dry-run cannot be used together\n');
|
|
138
|
-
process.exitCode = 2;
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
181
|
+
await preflightRepositoryCommand(opts);
|
|
141
182
|
const dryRun = !opts.write;
|
|
142
183
|
const result = await pin({
|
|
143
184
|
cwd: opts.cwd,
|
|
@@ -153,24 +194,16 @@ addCommonOptions(program.command('pin'))
|
|
|
153
194
|
|
|
154
195
|
addCommonOptions(program.command('upgrade'))
|
|
155
196
|
.description('Upgrade pinned/tagged actions to a newer version')
|
|
156
|
-
.
|
|
157
|
-
.
|
|
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'))
|
|
158
199
|
.addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
|
|
159
|
-
.
|
|
160
|
-
|
|
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))
|
|
161
204
|
.action(async (opts) => {
|
|
162
|
-
|
|
163
|
-
process.stderr.write('error: --write and --dry-run cannot be used together\n');
|
|
164
|
-
process.exitCode = 2;
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
205
|
+
await preflightRepositoryCommand(opts);
|
|
167
206
|
const dryRun = !opts.write;
|
|
168
|
-
const minAgeDays = Number.parseInt(opts.minAge, 10);
|
|
169
|
-
if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
|
|
170
|
-
process.stderr.write('error: --min-age must be a non-negative integer\n');
|
|
171
|
-
process.exitCode = 2;
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
207
|
const result = await upgrade({
|
|
175
208
|
cwd: opts.cwd,
|
|
176
209
|
workflows: opts.workflow,
|
|
@@ -178,7 +211,7 @@ addCommonOptions(program.command('upgrade'))
|
|
|
178
211
|
token: opts.token,
|
|
179
212
|
mode: opts.mode,
|
|
180
213
|
fix: opts.fix,
|
|
181
|
-
minAgeDays,
|
|
214
|
+
minAgeDays: opts.minAge,
|
|
182
215
|
});
|
|
183
216
|
const payload = renderUpgrade(result, { format: opts.format, dryRun, mode: opts.mode, cwd: opts.cwd });
|
|
184
217
|
await emit(payload, opts);
|
|
@@ -189,22 +222,20 @@ addCommonOptions(program.command('report'))
|
|
|
189
222
|
.description('Combined audit + pin (dry) + upgrade (dry) report')
|
|
190
223
|
.addOption(new Option('--mode <m>', 'upgrade scope').choices(['major', 'minor', 'patch']).default('minor'))
|
|
191
224
|
.addOption(new Option('--severity <level>', 'minimum audit severity').choices(['low', 'medium', 'high', 'critical']))
|
|
192
|
-
.option('--explain', 'include remediation hints in audit findings'
|
|
193
|
-
.
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
.
|
|
197
|
-
|
|
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')))
|
|
198
237
|
.action(async (opts) => {
|
|
199
|
-
const
|
|
200
|
-
if (Number.isNaN(minAgeDays) || minAgeDays < 0) {
|
|
201
|
-
process.stderr.write('error: --min-age must be a non-negative integer\n');
|
|
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');
|
|
207
|
-
}
|
|
238
|
+
const destinations = await preflightRepositoryCommand(opts);
|
|
208
239
|
const result = await report({
|
|
209
240
|
cwd: opts.cwd,
|
|
210
241
|
workflows: opts.workflow,
|
|
@@ -213,10 +244,14 @@ addCommonOptions(program.command('report'))
|
|
|
213
244
|
severity: opts.severity,
|
|
214
245
|
explain: Boolean(opts.explain),
|
|
215
246
|
skipResolve: Boolean(opts.offline),
|
|
216
|
-
minAgeDays,
|
|
247
|
+
minAgeDays: opts.minAge,
|
|
217
248
|
configPath: opts.ignoreConfig ? false : opts.config,
|
|
218
249
|
baseline: opts.baseline,
|
|
219
250
|
});
|
|
251
|
+
await assertDestinationsDoNotReplaceControls(destinations, [
|
|
252
|
+
{ label: 'active config', path: result.audit.configPath },
|
|
253
|
+
{ label: 'active baseline', path: result.audit.baseline.path },
|
|
254
|
+
]);
|
|
220
255
|
const payload = renderReport(result, { format: opts.format, mode: opts.mode, cwd: opts.cwd });
|
|
221
256
|
await emit(payload, opts);
|
|
222
257
|
process.exitCode = result.status === 'OK' ? 0 : 1;
|
|
@@ -225,6 +260,7 @@ addCommonOptions(program.command('report'))
|
|
|
225
260
|
addCommonOptions(program.command('verify'))
|
|
226
261
|
.description('Verify pinned SHAs and version metadata against GitHub')
|
|
227
262
|
.action(async (opts) => {
|
|
263
|
+
await preflightRepositoryCommand(opts);
|
|
228
264
|
const result = await verify({
|
|
229
265
|
cwd: opts.cwd,
|
|
230
266
|
workflows: opts.workflow,
|
|
@@ -237,39 +273,44 @@ addCommonOptions(program.command('verify'))
|
|
|
237
273
|
|
|
238
274
|
program.command('org-scan <organization>')
|
|
239
275
|
.description('Scan workflow security across a GitHub organization')
|
|
240
|
-
.
|
|
276
|
+
.addOption(new Option('-r, --repository <pattern...>', 'repository name or glob (repeatable)')
|
|
277
|
+
.argParser((value, previous) => collectNonEmpty(value, previous, '--repository')))
|
|
241
278
|
.addOption(new Option('--visibility <visibility>', 'repository visibility').choices(['all', 'public', 'private', 'internal']).default('all'))
|
|
242
|
-
.option('--include-archived', 'include archived repositories'
|
|
243
|
-
.option('--include-disabled', 'include disabled repositories'
|
|
244
|
-
.option('--include-forks', 'include forked repositories'
|
|
245
|
-
.
|
|
246
|
-
|
|
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))
|
|
247
287
|
.addOption(new Option('--severity <level>', 'minimum severity').choices(['low', 'medium', 'high', 'critical']))
|
|
248
|
-
.option('--explain', 'include plain-English remediation hint for each finding'
|
|
249
|
-
.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
.
|
|
253
|
-
.
|
|
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'))
|
|
254
301
|
.addOption(new Option('--progress <mode>', 'live progress on stderr').choices(['auto', 'always', 'never']).default('auto'))
|
|
255
302
|
.option('--agent-mode', 'use bounded AI-agent output and automatic artifacts')
|
|
256
303
|
.option('--no-agent-mode', 'disable agent mode from the environment')
|
|
257
|
-
.
|
|
258
|
-
.
|
|
259
|
-
.addOption(
|
|
260
|
-
.addOption(
|
|
261
|
-
.
|
|
304
|
+
.addOption(createCwdOption())
|
|
305
|
+
.addOption(createTokenOption())
|
|
306
|
+
.addOption(createFormatOption())
|
|
307
|
+
.addOption(createOutputOption())
|
|
308
|
+
.addOption(createOutputPathOption())
|
|
262
309
|
.action(async (organization, opts, command) => {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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');
|
|
310
|
+
await validateWorkingDirectory(opts.cwd);
|
|
311
|
+
assertMutuallyExclusiveFlags('--agent-mode', '--no-agent-mode');
|
|
312
|
+
const concurrency = opts.concurrency;
|
|
313
|
+
const maxRepositories = opts.maxRepos;
|
|
273
314
|
const agentMode = resolveAgentMode({
|
|
274
315
|
optionValue: opts.agentMode,
|
|
275
316
|
optionSource: command.getOptionValueSource('agentMode'),
|
|
@@ -301,20 +342,32 @@ program.command('org-scan <organization>')
|
|
|
301
342
|
if (agentMode && effective.output === 'file' && !effective.outputPath) {
|
|
302
343
|
effective.outputPath = agentArtifacts.reportPath;
|
|
303
344
|
}
|
|
345
|
+
const outputDestination = await validateOutputDestination(effective);
|
|
346
|
+
await assertDestinationsUseSafeNames(
|
|
347
|
+
outputDestination ? [outputDestination] : [],
|
|
348
|
+
effective.cwd,
|
|
349
|
+
);
|
|
304
350
|
let checkpointPath = effective.resume ?? effective.checkpoint;
|
|
305
351
|
let resume = Boolean(effective.resume);
|
|
306
352
|
if (agentMode && !checkpointPath) {
|
|
307
353
|
checkpointPath = agentArtifacts.checkpointPath;
|
|
308
354
|
resume = agentArtifacts.resume;
|
|
309
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
|
+
);
|
|
310
367
|
if (
|
|
311
|
-
|
|
312
|
-
&&
|
|
313
|
-
&&
|
|
314
|
-
&& await samePath(
|
|
315
|
-
resolve(effective.cwd, checkpointPath),
|
|
316
|
-
resolve(effective.cwd, effective.outputPath),
|
|
317
|
-
)
|
|
368
|
+
checkpointDestination
|
|
369
|
+
&& outputDestination
|
|
370
|
+
&& await sameFilePath(checkpointDestination.path, outputDestination.path)
|
|
318
371
|
) {
|
|
319
372
|
throw new Error('checkpoint and report output paths must be different');
|
|
320
373
|
}
|
|
@@ -342,6 +395,13 @@ program.command('org-scan <organization>')
|
|
|
342
395
|
}
|
|
343
396
|
: undefined,
|
|
344
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
|
+
);
|
|
345
405
|
const payload = renderOrganizationScan(result, {
|
|
346
406
|
format: effective.format,
|
|
347
407
|
cwd: effective.cwd,
|
|
@@ -361,7 +421,7 @@ program.command('org-scan <organization>')
|
|
|
361
421
|
|
|
362
422
|
program.command('rules')
|
|
363
423
|
.description('List available audit rules')
|
|
364
|
-
.addOption(
|
|
424
|
+
.addOption(createFormatOption())
|
|
365
425
|
.action((opts) => {
|
|
366
426
|
const rules = listRules();
|
|
367
427
|
const payload = fmt(opts.format, rules.map(r => ({ label: 'RULE', fields: r })), {
|
|
@@ -372,29 +432,162 @@ program.command('rules')
|
|
|
372
432
|
});
|
|
373
433
|
|
|
374
434
|
program.parseAsync(process.argv).catch((err) => {
|
|
435
|
+
if (err instanceof CommanderError) {
|
|
436
|
+
process.exitCode = err.exitCode;
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
375
439
|
process.stderr.write(`error: ${err.message}\n`);
|
|
376
440
|
process.exitCode = 2;
|
|
377
441
|
});
|
|
378
442
|
|
|
379
|
-
function
|
|
380
|
-
if (
|
|
381
|
-
throw new
|
|
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');
|
|
382
474
|
}
|
|
383
|
-
return
|
|
475
|
+
return value.toLowerCase();
|
|
384
476
|
}
|
|
385
477
|
|
|
386
478
|
function progressEnabled(mode) {
|
|
387
479
|
return mode === 'always' || (mode === 'auto' && Boolean(process.stderr.isTTY));
|
|
388
480
|
}
|
|
389
481
|
|
|
390
|
-
async function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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);
|
|
400
593
|
}
|