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.
- package/AGENTS.md +199 -0
- package/CONTRIBUTING.md +109 -0
- package/README.md +284 -224
- package/RELEASING.md +338 -0
- package/SECURITY.md +30 -3
- package/docs/AI-AGENTS.md +474 -0
- package/docs/CLI.md +474 -0
- package/docs/CONFIGURATION.md +340 -0
- package/docs/DEVELOPMENT.md +373 -0
- package/docs/GITHUB-ACTION.md +285 -0
- package/docs/JAVASCRIPT-API.md +357 -0
- package/docs/OUTPUTS.md +414 -0
- package/docs/README.md +27 -0
- package/examples/org-scan.yml +42 -0
- package/examples/upgrade-pr.yml +57 -0
- package/llms.txt +38 -0
- package/package.json +32 -10
- package/skills/actions-warden/SKILL.md +151 -40
- package/src/action.js +306 -0
- package/src/cli.js +494 -56
- package/src/commands/audit.js +189 -36
- package/src/commands/org-scan.js +544 -0
- package/src/commands/pin.js +59 -56
- package/src/commands/report.js +122 -10
- package/src/commands/upgrade.js +102 -62
- package/src/commands/verify.js +193 -0
- package/src/index.js +21 -4
- package/src/lib/action-status.js +27 -0
- package/src/lib/agent-mode.js +174 -0
- package/src/lib/annotations.js +250 -0
- package/src/lib/baseline.js +103 -0
- package/src/lib/cache.js +47 -10
- package/src/lib/concurrency.js +27 -0
- package/src/lib/config.js +185 -0
- package/src/lib/execution.js +71 -0
- package/src/lib/formatter.js +127 -8
- package/src/lib/github-org.js +374 -0
- package/src/lib/identity.js +62 -0
- package/src/lib/ignore.js +7 -6
- package/src/lib/org-checkpoint.js +461 -0
- package/src/lib/org-progress.js +60 -0
- package/src/lib/parser.js +326 -52
- package/src/lib/patcher.js +199 -0
- package/src/lib/path-equality.js +30 -0
- package/src/lib/paths.js +35 -12
- package/src/lib/redact.js +65 -4
- package/src/lib/resolver.js +225 -43
- package/src/lib/targets.js +28 -0
- package/src/lib/triggers.js +12 -0
- package/src/lib/writer.js +48 -8
- package/src/rules/excessive-permissions.js +24 -33
- package/src/rules/index.js +19 -1
- package/src/rules/pull-request-target-checkout.js +149 -18
- package/src/rules/reusable-workflow-secrets.js +32 -0
- package/src/rules/script-injection.js +77 -12
- package/src/rules/secrets-in-env.js +101 -18
- package/src/rules/unpinned-action.js +3 -2
- package/src/rules/unpinned-container-image.js +39 -0
- package/src/rules/unpinned-docker-action.js +30 -0
- package/src/rules/untrusted-self-hosted-runner.js +109 -0
- package/src/rules/workflow-run-artifact-execution.js +122 -0
- package/src/rules/workflow-structure.js +396 -0
- 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
|
-
*
|
|
5
|
+
* Commands: audit, pin, upgrade, verify, report, org-scan, rules
|
|
6
|
+
* Common command flags include --format, --output, --output-path, and --workflow.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
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
|
|
13
|
-
* 1
|
|
14
|
-
* 2
|
|
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 {
|
|
18
|
-
import { resolve } from 'node:path';
|
|
19
|
-
import {
|
|
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
|
|
32
|
-
.version(
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
.
|
|
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
|
-
|
|
50
|
-
await
|
|
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'
|
|
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.
|
|
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
|
-
.
|
|
75
|
-
.
|
|
76
|
-
.
|
|
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
|
-
|
|
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.
|
|
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
|
-
.
|
|
94
|
-
.
|
|
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
|
-
.
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
const
|
|
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.
|
|
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
|
-
.
|
|
123
|
-
.option('--
|
|
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
|
|
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.
|
|
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(
|
|
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 })), {
|
|
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.
|
|
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
|
+
}
|