@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/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 key_value_1 = require("./utils/key-value");
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, key_value_1.parseKeyValuePairs)(options.input, '--input');
138
- const secrets = (0, key_value_1.parseKeyValuePairs)(options.secret, '--secret', {
139
- redact: true,
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, key_value_1.parseKeyValuePairs)(options.var, '--var');
143
- const env = (0, key_value_1.parseKeyValuePairs)(options.env, '--env', {
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)({ type: 'workflow', path: file, file: parsed.file }, resolver);
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
- // Download progress would corrupt --dry-run's JSON on stdout; warnings
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 (!writing && !options.dryRun) {
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
- // Repository identity: recorded when writing; when checking, looked up
313
- // only for repositories the lock has an identity for.
314
- if (!options.offline) {
315
- const lookups = new Map();
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
- if (lock && lock.actions[action.uses]?.repoId === undefined)
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
- if (!lookups.has(key)) {
323
- lookups.set(key, (0, action_fetcher_1.resolveRepoIdentity)(action.owner, action.repo));
324
- }
325
- const identity = await lookups.get(key);
326
- if (identity)
327
- Object.assign(action, identity);
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({ actions }, null, 2));
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
- diff.removed.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
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 validateOneFile(filePath, contractFile, recurse, say) {
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
- return result;
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 = 'local', format = 'text') {
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
  });