@secureci/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -11
- package/dist/advisories.js +603 -0
- package/dist/cli.js +365 -63
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/inventory.js +1 -0
- package/dist/parser/dangerous-triggers.js +125 -0
- package/dist/parser/injection.js +281 -0
- package/dist/parser/permissions.js +145 -0
- package/dist/parser/pipeline-contract-parser.js +70 -0
- package/dist/parser/pipeline-contract-validator.js +18 -4
- package/dist/parser/validator.js +26 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +7 -1
- package/dist/runner/action-fetcher.js +137 -4
- package/dist/runner/builtin-actions.js +13 -3
- package/dist/runner/dry-run.js +57 -12
- package/dist/runner/executor.js +368 -254
- package/dist/runner/expression.js +106 -8
- package/dist/runner/job-plan.js +58 -1
- package/dist/runner/job-runtime.js +3 -3
- package/dist/runner/node-action.js +11 -3
- package/dist/runner/node-binary.js +108 -0
- package/dist/runner/scheduler.js +207 -0
- package/dist/runner/steps.js +54 -2
- package/dist/utils/env-file.js +185 -0
- package/dist/utils/logger.js +53 -7
- package/dist/utils/progress.js +58 -0
- package/dist/version.js +56 -0
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -36,8 +36,10 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
36
36
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
37
|
const commander_1 = require("commander");
|
|
38
38
|
const dry_run_1 = require("./runner/dry-run");
|
|
39
|
+
const job_plan_1 = require("./runner/job-plan");
|
|
39
40
|
const executor_1 = require("./runner/executor");
|
|
40
41
|
const action_fetcher_1 = require("./runner/action-fetcher");
|
|
42
|
+
const progress_1 = require("./utils/progress");
|
|
41
43
|
const contract_source_1 = require("./contract-source");
|
|
42
44
|
const validator_1 = require("./parser/validator");
|
|
43
45
|
const workflow_parser_1 = require("./parser/workflow-parser");
|
|
@@ -45,24 +47,19 @@ const pipeline_contract_parser_1 = require("./parser/pipeline-contract-parser");
|
|
|
45
47
|
const pipeline_contract_validator_1 = require("./parser/pipeline-contract-validator");
|
|
46
48
|
const report_1 = require("./report");
|
|
47
49
|
const init_1 = require("./init");
|
|
50
|
+
const pin_1 = require("./pin");
|
|
51
|
+
const advisories_1 = require("./advisories");
|
|
48
52
|
const inventory_1 = require("./inventory");
|
|
49
53
|
const github_lockfile_1 = require("./github-lockfile");
|
|
50
54
|
const logger_1 = require("./utils/logger");
|
|
51
|
-
const
|
|
55
|
+
const env_file_1 = require("./utils/env-file");
|
|
52
56
|
const config_1 = require("./config");
|
|
57
|
+
const scheduler_1 = require("./runner/scheduler");
|
|
58
|
+
const version_1 = require("./version");
|
|
53
59
|
const fs = __importStar(require("fs"));
|
|
54
60
|
const path = __importStar(require("path"));
|
|
55
61
|
/** Committed at the repo root, like package-lock.json / yarn.lock. */
|
|
56
62
|
const LOCKFILE_NAME = 'secureci.lock';
|
|
57
|
-
function readVersion() {
|
|
58
|
-
try {
|
|
59
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
60
|
-
return String(pkg.version);
|
|
61
|
-
}
|
|
62
|
-
catch {
|
|
63
|
-
return '0.0.0';
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
63
|
const program = new commander_1.Command();
|
|
67
64
|
const globalConfig = (0, config_1.loadConfig)();
|
|
68
65
|
// An invalid container-runtime in the config file only stops `run`, so
|
|
@@ -76,10 +73,17 @@ catch (error) {
|
|
|
76
73
|
configError = error instanceof Error ? error.message : String(error);
|
|
77
74
|
defaultContainerRuntime = 'docker';
|
|
78
75
|
}
|
|
76
|
+
let defaultParallel = 1;
|
|
77
|
+
try {
|
|
78
|
+
defaultParallel = (0, config_1.getDefaultParallel)(globalConfig);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
configError ?? (configError = error instanceof Error ? error.message : String(error));
|
|
82
|
+
}
|
|
79
83
|
program
|
|
80
84
|
.name('secureci')
|
|
81
85
|
.description('SecureCI: validate, check and run GitHub Actions workflows locally')
|
|
82
|
-
.version(readVersion(), '-V, --version', 'Print the version')
|
|
86
|
+
.version((0, version_1.readVersion)(), '-V, --version', 'Print the version')
|
|
83
87
|
.helpOption('-h, --help', 'Show help')
|
|
84
88
|
.addHelpCommand('help [command]', 'Show help for a command')
|
|
85
89
|
.option('--verbose', 'Log debug output')
|
|
@@ -104,9 +108,14 @@ program
|
|
|
104
108
|
.option('-s, --secret <key=value|NAME...>', 'Set a secret for the secrets context. A bare NAME reads $NAME from your shell, keeping the value off the command line. Repeatable')
|
|
105
109
|
.option('--env <key=value|NAME...>', 'Set an env var for every step, below workflow/job/step env:. A bare NAME passes $NAME from your shell (steps get nothing else from it). Repeatable')
|
|
106
110
|
.option('--var <key=value...>', 'Set a configuration variable for the vars context. Repeatable')
|
|
111
|
+
.option('--env-file <file...>', 'Read --env values from a .env-style file. Repeatable; later files win, --env wins over files. Config: env-file')
|
|
112
|
+
.option('--secret-file <file...>', 'Read --secret values from a .env-style file; every value is masked. Repeatable; later files win, --secret wins over files. Config: secret-file')
|
|
113
|
+
.option('--input-file <file...>', 'Read --input values from a .env-style file. Repeatable; later files win, --input wins over files. Config: input-file')
|
|
114
|
+
.option('--var-file <file...>', 'Read --var values from a .env-style file. Repeatable; later files win, --var wins over files. Config: var-file')
|
|
107
115
|
.option('--event-path <file>', 'Read the event payload (github.event, $GITHUB_EVENT_PATH) from a JSON file')
|
|
108
116
|
.option('--event-name <name>', 'Set github.event_name. Default: $GITHUB_EVENT_NAME, else workflow_dispatch if declared, else the first trigger')
|
|
109
117
|
.option('-j, --job <name>', 'Run only this job')
|
|
118
|
+
.option('--matrix <key:value...>', 'Run only the matrix combinations whose key has this value (as shown in the job name). Repeat for more keys (all must match) or values (any matches)')
|
|
110
119
|
.option('--offline', 'Use only cached remote actions and workflows; never download')
|
|
111
120
|
.option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
|
|
112
121
|
.addOption(new commander_1.Option('--container-runtime <name>', 'Container engine for jobs. "host" runs steps directly on this machine, without a container. Config: container-runtime')
|
|
@@ -119,14 +128,23 @@ program
|
|
|
119
128
|
.option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
|
|
120
129
|
.option('--no-network', 'Turn off container network access (on by default)')
|
|
121
130
|
.option('--allow-network', 'Turn on container network access, overriding allow-network: false in the config')
|
|
131
|
+
.option('--parallel <n>', 'Run up to this many jobs (and matrix combinations) at once; 1 runs them one after another. Default: the number of CPUs. Config: parallel', String(defaultParallel))
|
|
122
132
|
.action(async (file, options) => {
|
|
123
133
|
if (configError) {
|
|
124
134
|
logger_1.logger.error(configError);
|
|
125
135
|
process.exit(1);
|
|
126
136
|
}
|
|
127
137
|
requireFile(file);
|
|
138
|
+
let matrix;
|
|
139
|
+
try {
|
|
140
|
+
matrix = (0, job_plan_1.parseMatrixFilter)(options.matrix);
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
logger_1.logger.error(error instanceof Error ? error.message : String(error));
|
|
144
|
+
process.exit(1);
|
|
145
|
+
}
|
|
128
146
|
if (options.dryRun) {
|
|
129
|
-
const result = (0, dry_run_1.dryRun)(file);
|
|
147
|
+
const result = (0, dry_run_1.dryRun)(file, { job: options.job, matrix });
|
|
130
148
|
if (!result.success) {
|
|
131
149
|
process.exit(1);
|
|
132
150
|
}
|
|
@@ -134,15 +152,38 @@ program
|
|
|
134
152
|
}
|
|
135
153
|
logger_1.logger.info("Running the workflow for real (not a dry run). Use --dry-run to simulate without executing, or 'secureci validate' to only check for errors.");
|
|
136
154
|
try {
|
|
137
|
-
const inputs = (0,
|
|
138
|
-
|
|
139
|
-
|
|
155
|
+
const inputs = (0, env_file_1.loadKeyValues)({
|
|
156
|
+
flagName: '--input',
|
|
157
|
+
fileFlagName: '--input-file',
|
|
158
|
+
pairs: options.input,
|
|
159
|
+
files: options.inputFile,
|
|
160
|
+
configFiles: globalConfig['input-file']
|
|
161
|
+
});
|
|
162
|
+
const secrets = (0, env_file_1.loadKeyValues)({
|
|
163
|
+
flagName: '--secret',
|
|
164
|
+
fileFlagName: '--secret-file',
|
|
165
|
+
pairs: options.secret,
|
|
166
|
+
files: options.secretFile,
|
|
167
|
+
configFiles: globalConfig['secret-file'],
|
|
168
|
+
secret: true,
|
|
140
169
|
fromEnv: process.env
|
|
141
170
|
});
|
|
142
|
-
const vars = (0,
|
|
143
|
-
|
|
171
|
+
const vars = (0, env_file_1.loadKeyValues)({
|
|
172
|
+
flagName: '--var',
|
|
173
|
+
fileFlagName: '--var-file',
|
|
174
|
+
pairs: options.var,
|
|
175
|
+
files: options.varFile,
|
|
176
|
+
configFiles: globalConfig['var-file']
|
|
177
|
+
});
|
|
178
|
+
const env = (0, env_file_1.loadKeyValues)({
|
|
179
|
+
flagName: '--env',
|
|
180
|
+
fileFlagName: '--env-file',
|
|
181
|
+
pairs: options.env,
|
|
182
|
+
files: options.envFile,
|
|
183
|
+
configFiles: globalConfig['env-file'],
|
|
144
184
|
fromEnv: process.env
|
|
145
185
|
});
|
|
186
|
+
const parallel = (0, scheduler_1.parseParallel)(options.parallel);
|
|
146
187
|
const result = await (0, executor_1.executeWorkflow)(file, {
|
|
147
188
|
dryRun: false,
|
|
148
189
|
inputs,
|
|
@@ -152,6 +193,7 @@ program
|
|
|
152
193
|
eventPath: options.eventPath,
|
|
153
194
|
eventName: options.eventName,
|
|
154
195
|
job: options.job,
|
|
196
|
+
matrix,
|
|
155
197
|
localCheckout: options.localCheckout,
|
|
156
198
|
offline: options.offline,
|
|
157
199
|
verbose: program.opts().verbose,
|
|
@@ -163,7 +205,8 @@ program
|
|
|
163
205
|
? Number(options.containerTimeout)
|
|
164
206
|
: undefined,
|
|
165
207
|
containerMemoryLimit: options.containerMemory,
|
|
166
|
-
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig)
|
|
208
|
+
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig),
|
|
209
|
+
parallel
|
|
167
210
|
});
|
|
168
211
|
if (!result.success) {
|
|
169
212
|
process.exit(1);
|
|
@@ -185,15 +228,16 @@ program
|
|
|
185
228
|
.addOption(new commander_1.Option('--format <format>', 'Output format: text (default), or json / sarif for machine-readable results (sarif uploads to GitHub code scanning)')
|
|
186
229
|
.choices(['text', 'json', 'sarif'])
|
|
187
230
|
.default('text'))
|
|
231
|
+
.option('--offline', 'Do not use the network: skip the security advisory lookup (cached advisories are still used), and read remote contracts and actions only from the cache')
|
|
188
232
|
.action(async (files, options) => {
|
|
189
233
|
files.forEach(requireFile);
|
|
190
|
-
await validateFiles(files, options.contract, options.contractRecurse, options.format);
|
|
234
|
+
await validateFiles(files, options.contract, options.contractRecurse, options.format, options.offline ?? false);
|
|
191
235
|
});
|
|
192
236
|
program
|
|
193
237
|
.command('inventory')
|
|
194
238
|
.description('List the actions and reusable workflows a workflow uses (including the actions those actions use), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
|
|
195
239
|
.argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
|
|
196
|
-
.option('--offline', 'Do not resolve tags to commit SHAs over the network (already-pinned refs still resolve)')
|
|
240
|
+
.option('--offline', 'Do not resolve tags to commit SHAs or query security advisories over the network (already-pinned refs still resolve; cached advisories are used)')
|
|
197
241
|
.option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
|
|
198
242
|
.option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
|
|
199
243
|
.option('--update-lockfile', `Alias of --update-lock`)
|
|
@@ -202,6 +246,17 @@ program
|
|
|
202
246
|
.action(async (files, options) => {
|
|
203
247
|
await runInventory(files, options);
|
|
204
248
|
});
|
|
249
|
+
program
|
|
250
|
+
.command('pin')
|
|
251
|
+
.description('Pin action and reusable-workflow tags to commit SHAs in place (`owner/repo@<sha> # v4`), keeping formatting and comments')
|
|
252
|
+
.argument('[file...]', 'Workflow or action files (default: every file in .github/workflows, plus the local actions and reusable workflows they use)')
|
|
253
|
+
.option('--check', 'Change nothing; exit non-zero if any reference would be pinned or cannot be resolved (for CI)')
|
|
254
|
+
.option('-c, --contract <file>', "Leave the owners/actions in this pipeline contract's pins.allow on their tags")
|
|
255
|
+
.option('--offline', 'Do not use the network: only refs already in the action cache resolve; others are reported, not changed')
|
|
256
|
+
.option('--update-lock', `Afterwards, write ./${LOCKFILE_NAME} from the pinned workflows (as \`inventory --update-lock\`)`)
|
|
257
|
+
.action(async (files, options) => {
|
|
258
|
+
await runPin(files, options);
|
|
259
|
+
});
|
|
205
260
|
program
|
|
206
261
|
.command('list')
|
|
207
262
|
.description('List workflows in .github/workflows/')
|
|
@@ -248,9 +303,6 @@ async function runInventory(files, options) {
|
|
|
248
303
|
process.exit(1);
|
|
249
304
|
}
|
|
250
305
|
targets.forEach(requireFile);
|
|
251
|
-
const resolver = options.offline
|
|
252
|
-
? undefined
|
|
253
|
-
: (uses) => (0, action_fetcher_1.resolveActionSha)(uses);
|
|
254
306
|
// Merge every file's remote actions by `uses`.
|
|
255
307
|
const byUses = new Map();
|
|
256
308
|
for (const file of targets) {
|
|
@@ -258,7 +310,11 @@ async function runInventory(files, options) {
|
|
|
258
310
|
const parsed = (0, workflow_parser_1.parseFile)(file);
|
|
259
311
|
if (parsed.type !== 'workflow')
|
|
260
312
|
continue;
|
|
261
|
-
const inv = await (0, inventory_1.buildInventory)({
|
|
313
|
+
const inv = await (0, inventory_1.buildInventory)({
|
|
314
|
+
type: 'workflow',
|
|
315
|
+
path: file,
|
|
316
|
+
file: parsed.file
|
|
317
|
+
});
|
|
262
318
|
for (const action of inv.actions) {
|
|
263
319
|
const existing = byUses.get(action.uses);
|
|
264
320
|
if (existing)
|
|
@@ -272,19 +328,7 @@ async function runInventory(files, options) {
|
|
|
272
328
|
process.exit(1);
|
|
273
329
|
}
|
|
274
330
|
}
|
|
275
|
-
|
|
276
|
-
// still go to stderr.
|
|
277
|
-
if (options.dryRun)
|
|
278
|
-
logger_1.logger.setLevel(logger_1.LogLevel.WARN);
|
|
279
|
-
// Follow remote composite actions and reusable workflows into the
|
|
280
|
-
// actions they use, downloaded at the resolved commit.
|
|
281
|
-
const actions = (await (0, inventory_1.addTransitive)([...byUses.values()], {
|
|
282
|
-
resolveSha: resolver,
|
|
283
|
-
fetch: (uses, kind) => kind === 'action'
|
|
284
|
-
? (0, action_fetcher_1.fetchAction)(uses, { offline: options.offline })
|
|
285
|
-
: (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline }),
|
|
286
|
-
warn: (message) => logger_1.logger.warn(message)
|
|
287
|
-
})).sort((a, b) => a.uses.localeCompare(b.uses));
|
|
331
|
+
const direct = [...byUses.values()];
|
|
288
332
|
const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
|
|
289
333
|
// secureci.lock is first-class; GitHub's actions.lock is used only when
|
|
290
334
|
// it is the lockfile the repository provides.
|
|
@@ -293,10 +337,11 @@ async function runInventory(files, options) {
|
|
|
293
337
|
// writes the lock; otherwise (`--check`, or bare `inventory` with a
|
|
294
338
|
// lockfile) compares against it.
|
|
295
339
|
const writing = update || (!check && !source);
|
|
340
|
+
const comparing = !writing && !options.dryRun;
|
|
296
341
|
let lock;
|
|
297
342
|
const github = source?.kind === 'github';
|
|
298
343
|
const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
|
|
299
|
-
if (
|
|
344
|
+
if (comparing) {
|
|
300
345
|
if (!source) {
|
|
301
346
|
logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
|
|
302
347
|
process.exit(1);
|
|
@@ -309,24 +354,94 @@ async function runInventory(files, options) {
|
|
|
309
354
|
process.exit(1);
|
|
310
355
|
}
|
|
311
356
|
}
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
357
|
+
// Download messages would corrupt --dry-run's JSON on stdout and clash
|
|
358
|
+
// with the progress lines; warnings and errors still go to stderr.
|
|
359
|
+
const level = logger_1.logger.getLevel();
|
|
360
|
+
if (level === logger_1.LogLevel.INFO)
|
|
361
|
+
logger_1.logger.setLevel(logger_1.LogLevel.WARN);
|
|
362
|
+
const online = !options.offline;
|
|
363
|
+
if (online && direct.length > 0) {
|
|
364
|
+
process.stderr.write(`Resolving ${direct.length} action(s) over the network, and reading the actions they use. This can take a while (--offline uses only what is cached).\n`);
|
|
365
|
+
}
|
|
366
|
+
// 1. Resolve each tag or branch to the commit it points at.
|
|
367
|
+
const toResolve = online ? direct.filter((a) => !a.resolvedSha) : [];
|
|
368
|
+
const resolving = new progress_1.Progress('Resolving tags', toResolve.length);
|
|
369
|
+
for (const action of toResolve) {
|
|
370
|
+
resolving.tick(action.uses);
|
|
371
|
+
action.resolvedSha = await (0, action_fetcher_1.resolveActionSha)(action.uses);
|
|
372
|
+
}
|
|
373
|
+
resolving.done(`Resolved ${toResolve.length} tag(s)`);
|
|
374
|
+
// 2. Follow remote composite actions and reusable workflows into the
|
|
375
|
+
// actions they use, downloaded at the resolved commit.
|
|
376
|
+
const reading = new progress_1.Progress('Reading actions', online ? direct.length : 0);
|
|
377
|
+
let read = 0;
|
|
378
|
+
const actions = (await (0, inventory_1.addTransitive)(direct, {
|
|
379
|
+
resolveSha: online ? (uses) => (0, action_fetcher_1.resolveActionSha)(uses) : undefined,
|
|
380
|
+
fetch: (uses, kind) => {
|
|
381
|
+
if (online)
|
|
382
|
+
reading.tick(uses.replace(/@([0-9a-f]{12})[0-9a-f]{28}$/, '@$1'));
|
|
383
|
+
read++;
|
|
384
|
+
return kind === 'action'
|
|
385
|
+
? (0, action_fetcher_1.fetchAction)(uses, { offline: options.offline })
|
|
386
|
+
: (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline });
|
|
387
|
+
},
|
|
388
|
+
warn: (message) => logger_1.logger.warn(message),
|
|
389
|
+
onQueue: () => reading.grow()
|
|
390
|
+
})).sort((a, b) => a.uses.localeCompare(b.uses));
|
|
391
|
+
reading.done(`Read ${read} action(s) for the actions they use`);
|
|
392
|
+
const repos = new Map();
|
|
393
|
+
if (online) {
|
|
316
394
|
for (const action of actions) {
|
|
317
395
|
if (!action.resolvedSha)
|
|
318
396
|
continue;
|
|
319
|
-
|
|
397
|
+
const identity = !lock || lock.actions[action.uses]?.repoId !== undefined;
|
|
398
|
+
const existence = comparing && action.pinned;
|
|
399
|
+
if (!identity && !existence)
|
|
320
400
|
continue;
|
|
321
401
|
const key = `${action.owner}/${action.repo}`.toLowerCase();
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
402
|
+
const entry = repos.get(key) ?? {
|
|
403
|
+
owner: action.owner,
|
|
404
|
+
repo: action.repo,
|
|
405
|
+
identity: false,
|
|
406
|
+
existence: false
|
|
407
|
+
};
|
|
408
|
+
entry.identity || (entry.identity = identity);
|
|
409
|
+
entry.existence || (entry.existence = existence);
|
|
410
|
+
repos.set(key, entry);
|
|
328
411
|
}
|
|
329
412
|
}
|
|
413
|
+
const identities = new Map();
|
|
414
|
+
const missingRepos = new Set();
|
|
415
|
+
const checking = new progress_1.Progress('Checking repositories', repos.size);
|
|
416
|
+
for (const [key, r] of repos) {
|
|
417
|
+
checking.tick(`${r.owner}/${r.repo}`);
|
|
418
|
+
const identity = r.identity
|
|
419
|
+
? await (0, action_fetcher_1.resolveRepoIdentity)(r.owner, r.repo)
|
|
420
|
+
: undefined;
|
|
421
|
+
if (identity)
|
|
422
|
+
identities.set(key, identity);
|
|
423
|
+
else if (r.existence && !(await (0, action_fetcher_1.repoExists)(r.owner, r.repo))) {
|
|
424
|
+
missingRepos.add(key);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
checking.done(`Checked ${repos.size} repositor${repos.size === 1 ? 'y' : 'ies'}`);
|
|
428
|
+
for (const action of actions) {
|
|
429
|
+
const identity = identities.get(`${action.owner}/${action.repo}`.toLowerCase());
|
|
430
|
+
if (identity && action.resolvedSha)
|
|
431
|
+
Object.assign(action, identity);
|
|
432
|
+
}
|
|
433
|
+
// 4. Known security advisories for every action, direct and transitive.
|
|
434
|
+
const repoCount = new Set(actions.map((a) => `${a.owner}/${a.repo}`.toLowerCase())).size;
|
|
435
|
+
const advising = new progress_1.Progress('Checking security advisories', online ? repoCount : 0);
|
|
436
|
+
const advisoryReport = await new advisories_1.AdvisoryChecker({
|
|
437
|
+
offline: !online,
|
|
438
|
+
onLookup: (repo) => {
|
|
439
|
+
if (online)
|
|
440
|
+
advising.tick(repo);
|
|
441
|
+
}
|
|
442
|
+
}).check((0, advisories_1.targetsFromInventory)(actions));
|
|
443
|
+
advising.done(`Checked security advisories for ${repoCount} repositor${repoCount === 1 ? 'y' : 'ies'}`);
|
|
444
|
+
logger_1.logger.setLevel(level);
|
|
330
445
|
const current = {};
|
|
331
446
|
for (const action of actions) {
|
|
332
447
|
if (!action.resolvedSha)
|
|
@@ -341,10 +456,28 @@ async function runInventory(files, options) {
|
|
|
341
456
|
// --dry-run: print the resolved inventory as JSON, touching nothing.
|
|
342
457
|
if (options.dryRun) {
|
|
343
458
|
// eslint-disable-next-line no-console
|
|
344
|
-
console.log(JSON.stringify({
|
|
459
|
+
console.log(JSON.stringify({
|
|
460
|
+
actions,
|
|
461
|
+
advisories: advisoryReport.findings,
|
|
462
|
+
advisoryUnknownVersions: advisoryReport.unknownVersions
|
|
463
|
+
}, null, 2));
|
|
464
|
+
// The findings are in the JSON; say on stderr what wasn't checked.
|
|
465
|
+
(0, advisories_1.describeReport)(advisoryReport)
|
|
466
|
+
.filter((line) => line.kind === 'note' && line.level === 'warn')
|
|
467
|
+
.forEach((line) => logger_1.logger.warn(line.text));
|
|
345
468
|
return;
|
|
346
469
|
}
|
|
470
|
+
// Advisories are reported, but don't change the exit code here: gate on
|
|
471
|
+
// them with `secureci validate` (rule secureci/vulnerable-action).
|
|
472
|
+
for (const line of (0, advisories_1.describeReport)(advisoryReport)) {
|
|
473
|
+
logger_1.logger[line.level](line.text);
|
|
474
|
+
}
|
|
475
|
+
// Used references that couldn't be resolved to a commit now.
|
|
476
|
+
const unresolved = actions.filter((a) => !a.resolvedSha);
|
|
347
477
|
if (writing) {
|
|
478
|
+
if (online) {
|
|
479
|
+
unresolved.forEach((a) => logger_1.logger.warn(`Not locked: could not resolve ${a.uses} (repository deleted, renamed or private, or the network is unavailable?)`));
|
|
480
|
+
}
|
|
348
481
|
fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
|
|
349
482
|
const transitive = Object.values(current).filter((e) => e.via).length;
|
|
350
483
|
logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
|
|
@@ -363,16 +496,41 @@ async function runInventory(files, options) {
|
|
|
363
496
|
for (const d of diff.identity) {
|
|
364
497
|
logger_1.logger.error(`Repository changed: ${d.uses} ${d.field} is now ${d.to} (was ${d.from}). The name now points at a different ${d.field === 'repo_id' ? 'repository (deleted and re-created?)' : 'owner (transferred, or the account re-registered?)'}.`);
|
|
365
498
|
}
|
|
499
|
+
for (const a of actions) {
|
|
500
|
+
if (missingRepos.has(`${a.owner}/${a.repo}`.toLowerCase())) {
|
|
501
|
+
logger_1.logger.error(`Repository gone: ${a.uses} can no longer be read (deleted, renamed or made private?). The locked commit can't be fetched, and the name could be re-registered by someone else.`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
// Online, a used reference that doesn't resolve is a failure (its
|
|
505
|
+
// repository may be gone); offline, tags can't resolve, so it's a warning.
|
|
506
|
+
for (const a of unresolved) {
|
|
507
|
+
const locked = lock?.actions[a.uses];
|
|
508
|
+
const at = locked ? ` (locked at ${locked.commit.slice(0, 12)})` : '';
|
|
509
|
+
if (online) {
|
|
510
|
+
logger_1.logger.error(`Could not resolve ${a.uses}${at}: the repository or ref may have been deleted, renamed or made private, or the network is unavailable.`);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
if (!online && unresolved.length > 0) {
|
|
514
|
+
logger_1.logger.warn(`Not checked offline (tags need the network): ${unresolved.map((a) => a.uses).join(', ')}`);
|
|
515
|
+
}
|
|
366
516
|
diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${lockName}: ${u}`));
|
|
367
517
|
// actions.lock is written by `gh actions-lock`, which may record entries
|
|
368
518
|
// SecureCI doesn't follow, so "no longer used" is only for secureci.lock.
|
|
519
|
+
// A reference still in use but unresolved isn't "no longer used".
|
|
369
520
|
if (!github) {
|
|
370
|
-
|
|
521
|
+
const used = new Set(unresolved.map((a) => a.uses));
|
|
522
|
+
diff.removed
|
|
523
|
+
.filter((u) => !used.has(u))
|
|
524
|
+
.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
|
|
371
525
|
}
|
|
372
526
|
if (diff.identity.length > 0) {
|
|
373
527
|
logger_1.logger.error(`A locked repository is no longer the one that was locked. Verify it before trusting it, then ${fixHint}.`);
|
|
374
528
|
process.exit(1);
|
|
375
529
|
}
|
|
530
|
+
if (missingRepos.size > 0 || (online && unresolved.length > 0)) {
|
|
531
|
+
logger_1.logger.error(`Locked actions could not be verified. Check that each repository and ref still exists and is readable${(0, action_fetcher_1.githubToken)() ? '' : ' (set GITHUB_TOKEN for private repositories)'}.`);
|
|
532
|
+
process.exit(1);
|
|
533
|
+
}
|
|
376
534
|
if (diff.drifted.length > 0) {
|
|
377
535
|
logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
|
|
378
536
|
process.exit(1);
|
|
@@ -383,7 +541,130 @@ async function runInventory(files, options) {
|
|
|
383
541
|
}
|
|
384
542
|
logger_1.logger.success(`No action drift against ${lockName}.`);
|
|
385
543
|
}
|
|
386
|
-
async function
|
|
544
|
+
async function runPin(files, options) {
|
|
545
|
+
if (options.check && options.updateLock) {
|
|
546
|
+
logger_1.logger.error('Use either --update-lock or --check, not both.');
|
|
547
|
+
process.exit(1);
|
|
548
|
+
}
|
|
549
|
+
const given = files.length > 0 ? files : defaultWorkflowFiles();
|
|
550
|
+
if (given.length === 0) {
|
|
551
|
+
logger_1.logger.error('No workflow files given and none found in .github/workflows');
|
|
552
|
+
process.exit(1);
|
|
553
|
+
}
|
|
554
|
+
given.forEach(requireFile);
|
|
555
|
+
// Explicit files are pinned as given; the default run covers the whole
|
|
556
|
+
// repository, including local actions the workflows use.
|
|
557
|
+
const targets = files.length > 0 ? given : (0, pin_1.localPinTargets)(given);
|
|
558
|
+
let allow = [];
|
|
559
|
+
if (options.contract) {
|
|
560
|
+
try {
|
|
561
|
+
const source = await (0, contract_source_1.resolveContractSource)(options.contract, {
|
|
562
|
+
fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline }),
|
|
563
|
+
resolveSha: (uses) => options.offline
|
|
564
|
+
? Promise.resolve((0, action_fetcher_1.cachedActionSha)(uses))
|
|
565
|
+
: (0, action_fetcher_1.resolveActionSha)(uses),
|
|
566
|
+
warn: (message) => logger_1.logger.warn(message)
|
|
567
|
+
});
|
|
568
|
+
allow = (0, pipeline_contract_parser_1.parsePipelineContract)(source.file).pins?.allow ?? [];
|
|
569
|
+
}
|
|
570
|
+
catch (error) {
|
|
571
|
+
logger_1.logger.error(`Could not read contract ${options.contract}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
572
|
+
process.exit(1);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
// Each distinct reference is resolved once, across all files.
|
|
576
|
+
const resolving = new progress_1.Progress('Resolving tags', 0);
|
|
577
|
+
const resolved = new Map();
|
|
578
|
+
const resolve = (uses) => {
|
|
579
|
+
let sha = resolved.get(uses);
|
|
580
|
+
if (!sha) {
|
|
581
|
+
if (options.offline) {
|
|
582
|
+
sha = Promise.resolve((0, action_fetcher_1.cachedActionSha)(uses));
|
|
583
|
+
}
|
|
584
|
+
else {
|
|
585
|
+
resolving.grow();
|
|
586
|
+
resolving.tick(uses);
|
|
587
|
+
sha = (0, action_fetcher_1.resolveActionSha)(uses);
|
|
588
|
+
}
|
|
589
|
+
resolved.set(uses, sha);
|
|
590
|
+
}
|
|
591
|
+
return sha;
|
|
592
|
+
};
|
|
593
|
+
const display = (file) => path.relative(process.cwd(), path.resolve(file)) || file;
|
|
594
|
+
let changes = 0;
|
|
595
|
+
let changedFiles = 0;
|
|
596
|
+
let unresolved = 0;
|
|
597
|
+
let unreadable = false;
|
|
598
|
+
const report = options.check
|
|
599
|
+
? (m) => logger_1.logger.error(m)
|
|
600
|
+
: (m) => logger_1.logger.warn(m);
|
|
601
|
+
for (const file of targets) {
|
|
602
|
+
const name = display(file);
|
|
603
|
+
let result;
|
|
604
|
+
try {
|
|
605
|
+
result = await (0, pin_1.pinContent)(fs.readFileSync(file, 'utf-8'), resolve, {
|
|
606
|
+
allow
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
catch (error) {
|
|
610
|
+
logger_1.logger.error(`Could not read ${name}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
611
|
+
unreadable = true;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
for (const a of result.allowed) {
|
|
615
|
+
logger_1.logger.info(`${name}:${a.line} ${a.uses} left on its tag (pins.allow)`);
|
|
616
|
+
}
|
|
617
|
+
for (const c of result.changes) {
|
|
618
|
+
logger_1.logger.info(`${name}:${c.line} ${c.uses} -> ${c.sha.slice(0, 12)} (${c.ref})`);
|
|
619
|
+
}
|
|
620
|
+
for (const u of result.unresolved) {
|
|
621
|
+
report(`${name}:${u.line} ${u.uses} ${u.reason}` +
|
|
622
|
+
(options.offline && u.reason.startsWith('could not')
|
|
623
|
+
? ' (offline: only refs in the action cache resolve)'
|
|
624
|
+
: '') +
|
|
625
|
+
'; left unchanged');
|
|
626
|
+
}
|
|
627
|
+
changes += result.changes.length;
|
|
628
|
+
unresolved += result.unresolved.length;
|
|
629
|
+
if (result.changes.length > 0) {
|
|
630
|
+
changedFiles++;
|
|
631
|
+
if (!options.check)
|
|
632
|
+
fs.writeFileSync(file, result.content);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
resolving.done(`Resolved ${resolved.size} reference(s)`);
|
|
636
|
+
const fileCount = `${changedFiles} file(s)`;
|
|
637
|
+
const unresolvedHint = options.offline
|
|
638
|
+
? ''
|
|
639
|
+
: ` (check that each repository and ref exists${(0, action_fetcher_1.githubToken)() ? '' : '; set GITHUB_TOKEN for private repositories'})`;
|
|
640
|
+
if (options.check) {
|
|
641
|
+
if (changes > 0) {
|
|
642
|
+
logger_1.logger.error(`${changes} reference(s) would be pinned in ${fileCount}. Run \`secureci pin\` to pin them.`);
|
|
643
|
+
}
|
|
644
|
+
if (unresolved > 0) {
|
|
645
|
+
logger_1.logger.error(`${unresolved} reference(s) could not be resolved${unresolvedHint}.`);
|
|
646
|
+
}
|
|
647
|
+
if (changes > 0 || unresolved > 0 || unreadable)
|
|
648
|
+
process.exit(1);
|
|
649
|
+
logger_1.logger.success('Nothing to pin: every remote reference is pinned.');
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
if (changes > 0) {
|
|
653
|
+
logger_1.logger.success(`Pinned ${changes} reference(s) in ${fileCount}.`);
|
|
654
|
+
}
|
|
655
|
+
else if (unresolved === 0) {
|
|
656
|
+
logger_1.logger.success('Nothing to pin: every remote reference is pinned.');
|
|
657
|
+
}
|
|
658
|
+
if (unresolved > 0) {
|
|
659
|
+
logger_1.logger.warn(`${unresolved} reference(s) could not be resolved and were left unchanged${unresolvedHint}.`);
|
|
660
|
+
}
|
|
661
|
+
if (unreadable)
|
|
662
|
+
process.exit(1);
|
|
663
|
+
if (options.updateLock) {
|
|
664
|
+
await runInventory(files, { offline: options.offline, updateLock: true });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
async function validateOneFile(filePath, contractFile, recurse, say, advisories, offline) {
|
|
387
668
|
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
388
669
|
let result;
|
|
389
670
|
if (parsed.type === 'workflow') {
|
|
@@ -394,6 +675,7 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
|
394
675
|
result = (0, validator_1.validateAction)(filePath);
|
|
395
676
|
say(() => logger_1.logger.info(`Validating action: ${filePath}`));
|
|
396
677
|
}
|
|
678
|
+
let failOn;
|
|
397
679
|
if (contractFile) {
|
|
398
680
|
say(() => logger_1.logger.info(`Validating against pipeline contract: ${contractFile}`));
|
|
399
681
|
if (parsed.type !== 'workflow') {
|
|
@@ -411,18 +693,23 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
|
411
693
|
}
|
|
412
694
|
else {
|
|
413
695
|
const spec = (0, pipeline_contract_parser_1.parsePipelineContract)(contractFile);
|
|
696
|
+
failOn = spec.advisories?.failOn;
|
|
414
697
|
const contractResult = await (0, pipeline_contract_validator_1.validatePipelineContract)(parsed, spec, {
|
|
415
|
-
recurse
|
|
698
|
+
recurse,
|
|
699
|
+
offline
|
|
416
700
|
});
|
|
417
|
-
result =
|
|
418
|
-
valid: result.valid && contractResult.valid,
|
|
419
|
-
errors: [...result.errors, ...contractResult.errors]
|
|
420
|
-
};
|
|
701
|
+
result = (0, validator_1.mergeValidationResults)(result, contractResult);
|
|
421
702
|
}
|
|
422
703
|
}
|
|
423
|
-
|
|
704
|
+
// Known security advisories for the file's own uses: references.
|
|
705
|
+
const { errors, report } = await (0, advisories_1.checkFileAdvisories)(parsed, advisories.checker, failOn);
|
|
706
|
+
advisories.reports.push(report);
|
|
707
|
+
return {
|
|
708
|
+
valid: result.valid && errors.every((e) => e.severity !== 'error'),
|
|
709
|
+
errors: [...result.errors, ...errors]
|
|
710
|
+
};
|
|
424
711
|
}
|
|
425
|
-
async function validateFiles(files, contractFile, recurse
|
|
712
|
+
async function validateFiles(files, contractFile, recurse, format, offline) {
|
|
426
713
|
// Only text mode narrates; json/sarif write nothing but the report to
|
|
427
714
|
// stdout, so the output is pipeable.
|
|
428
715
|
const quiet = format !== 'text';
|
|
@@ -438,8 +725,8 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
438
725
|
if (contractFile) {
|
|
439
726
|
try {
|
|
440
727
|
const source = await (0, contract_source_1.resolveContractSource)(contractFile, {
|
|
441
|
-
fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses),
|
|
442
|
-
resolveSha: (uses) => (0, action_fetcher_1.resolveActionSha)(uses),
|
|
728
|
+
fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses, { offline }),
|
|
729
|
+
resolveSha: async (uses) => offline ? undefined : (0, action_fetcher_1.resolveActionSha)(uses),
|
|
443
730
|
warn: (message) => logger_1.logger.warn(message)
|
|
444
731
|
});
|
|
445
732
|
contractPath = source.file;
|
|
@@ -460,12 +747,17 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
460
747
|
contractError = error instanceof Error ? error.message : 'Unknown error';
|
|
461
748
|
}
|
|
462
749
|
}
|
|
750
|
+
// One lookup per repository across all files (and a 24h disk cache).
|
|
751
|
+
const advisories = {
|
|
752
|
+
checker: new advisories_1.AdvisoryChecker({ offline }),
|
|
753
|
+
reports: []
|
|
754
|
+
};
|
|
463
755
|
const entries = [];
|
|
464
756
|
for (const filePath of files) {
|
|
465
757
|
try {
|
|
466
758
|
if (contractError)
|
|
467
759
|
throw new Error(contractError);
|
|
468
|
-
const result = await validateOneFile(filePath, contractPath, recurse, say);
|
|
760
|
+
const result = await validateOneFile(filePath, contractPath, recurse, say, advisories, offline);
|
|
469
761
|
entries.push({ file: filePath, result });
|
|
470
762
|
}
|
|
471
763
|
catch (error) {
|
|
@@ -485,6 +777,16 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
485
777
|
}
|
|
486
778
|
}
|
|
487
779
|
const allValid = entries.every((e) => e.result.valid);
|
|
780
|
+
// What couldn't be checked, once for all files: warnings on stderr, and
|
|
781
|
+
// (text mode only) unknown versions as info. Findings are in the results.
|
|
782
|
+
for (const line of (0, advisories_1.describeReport)((0, advisories_1.mergeReports)(advisories.reports))) {
|
|
783
|
+
if (line.kind !== 'note')
|
|
784
|
+
continue;
|
|
785
|
+
if (line.level === 'warn')
|
|
786
|
+
logger_1.logger.warn(line.text);
|
|
787
|
+
else
|
|
788
|
+
say(() => logger_1.logger.info(line.text));
|
|
789
|
+
}
|
|
488
790
|
if (quiet) {
|
|
489
791
|
// eslint-disable-next-line no-console
|
|
490
792
|
console.log((0, report_1.formatReport)(entries, format, { contract }));
|
|
@@ -512,7 +814,7 @@ function runInit(options) {
|
|
|
512
814
|
process.exit(1);
|
|
513
815
|
}
|
|
514
816
|
const content = (0, init_1.renderInitWorkflow)({
|
|
515
|
-
version: readVersion(),
|
|
817
|
+
version: (0, version_1.readVersion)(),
|
|
516
818
|
contract: options.contract,
|
|
517
819
|
upload: options.upload
|
|
518
820
|
});
|