@secureci/cli 0.5.0 → 0.5.1

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
@@ -38,6 +38,7 @@ const commander_1 = require("commander");
38
38
  const dry_run_1 = require("./runner/dry-run");
39
39
  const executor_1 = require("./runner/executor");
40
40
  const action_fetcher_1 = require("./runner/action-fetcher");
41
+ const progress_1 = require("./utils/progress");
41
42
  const contract_source_1 = require("./contract-source");
42
43
  const validator_1 = require("./parser/validator");
43
44
  const workflow_parser_1 = require("./parser/workflow-parser");
@@ -248,9 +249,6 @@ async function runInventory(files, options) {
248
249
  process.exit(1);
249
250
  }
250
251
  targets.forEach(requireFile);
251
- const resolver = options.offline
252
- ? undefined
253
- : (uses) => (0, action_fetcher_1.resolveActionSha)(uses);
254
252
  // Merge every file's remote actions by `uses`.
255
253
  const byUses = new Map();
256
254
  for (const file of targets) {
@@ -258,7 +256,11 @@ async function runInventory(files, options) {
258
256
  const parsed = (0, workflow_parser_1.parseFile)(file);
259
257
  if (parsed.type !== 'workflow')
260
258
  continue;
261
- const inv = await (0, inventory_1.buildInventory)({ type: 'workflow', path: file, file: parsed.file }, resolver);
259
+ const inv = await (0, inventory_1.buildInventory)({
260
+ type: 'workflow',
261
+ path: file,
262
+ file: parsed.file
263
+ });
262
264
  for (const action of inv.actions) {
263
265
  const existing = byUses.get(action.uses);
264
266
  if (existing)
@@ -272,19 +274,7 @@ async function runInventory(files, options) {
272
274
  process.exit(1);
273
275
  }
274
276
  }
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));
277
+ const direct = [...byUses.values()];
288
278
  const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
289
279
  // secureci.lock is first-class; GitHub's actions.lock is used only when
290
280
  // it is the lockfile the repository provides.
@@ -293,10 +283,11 @@ async function runInventory(files, options) {
293
283
  // writes the lock; otherwise (`--check`, or bare `inventory` with a
294
284
  // lockfile) compares against it.
295
285
  const writing = update || (!check && !source);
286
+ const comparing = !writing && !options.dryRun;
296
287
  let lock;
297
288
  const github = source?.kind === 'github';
298
289
  const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
299
- if (!writing && !options.dryRun) {
290
+ if (comparing) {
300
291
  if (!source) {
301
292
  logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
302
293
  process.exit(1);
@@ -309,24 +300,83 @@ async function runInventory(files, options) {
309
300
  process.exit(1);
310
301
  }
311
302
  }
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();
303
+ // Download messages would corrupt --dry-run's JSON on stdout and clash
304
+ // with the progress lines; warnings and errors still go to stderr.
305
+ const level = logger_1.logger.getLevel();
306
+ if (level === logger_1.LogLevel.INFO)
307
+ logger_1.logger.setLevel(logger_1.LogLevel.WARN);
308
+ const online = !options.offline;
309
+ if (online && direct.length > 0) {
310
+ 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`);
311
+ }
312
+ // 1. Resolve each tag or branch to the commit it points at.
313
+ const toResolve = online ? direct.filter((a) => !a.resolvedSha) : [];
314
+ const resolving = new progress_1.Progress('Resolving tags', toResolve.length);
315
+ for (const action of toResolve) {
316
+ resolving.tick(action.uses);
317
+ action.resolvedSha = await (0, action_fetcher_1.resolveActionSha)(action.uses);
318
+ }
319
+ resolving.done(`Resolved ${toResolve.length} tag(s)`);
320
+ // 2. Follow remote composite actions and reusable workflows into the
321
+ // actions they use, downloaded at the resolved commit.
322
+ const reading = new progress_1.Progress('Reading actions', online ? direct.length : 0);
323
+ let read = 0;
324
+ const actions = (await (0, inventory_1.addTransitive)(direct, {
325
+ resolveSha: online ? (uses) => (0, action_fetcher_1.resolveActionSha)(uses) : undefined,
326
+ fetch: (uses, kind) => {
327
+ if (online)
328
+ reading.tick(uses.replace(/@([0-9a-f]{12})[0-9a-f]{28}$/, '@$1'));
329
+ read++;
330
+ return kind === 'action'
331
+ ? (0, action_fetcher_1.fetchAction)(uses, { offline: options.offline })
332
+ : (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline });
333
+ },
334
+ warn: (message) => logger_1.logger.warn(message),
335
+ onQueue: () => reading.grow()
336
+ })).sort((a, b) => a.uses.localeCompare(b.uses));
337
+ reading.done(`Read ${read} action(s) for the actions they use`);
338
+ const repos = new Map();
339
+ if (online) {
316
340
  for (const action of actions) {
317
341
  if (!action.resolvedSha)
318
342
  continue;
319
- if (lock && lock.actions[action.uses]?.repoId === undefined)
343
+ const identity = !lock || lock.actions[action.uses]?.repoId !== undefined;
344
+ const existence = comparing && action.pinned;
345
+ if (!identity && !existence)
320
346
  continue;
321
347
  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);
348
+ const entry = repos.get(key) ?? {
349
+ owner: action.owner,
350
+ repo: action.repo,
351
+ identity: false,
352
+ existence: false
353
+ };
354
+ entry.identity || (entry.identity = identity);
355
+ entry.existence || (entry.existence = existence);
356
+ repos.set(key, entry);
357
+ }
358
+ }
359
+ const identities = new Map();
360
+ const missingRepos = new Set();
361
+ const checking = new progress_1.Progress('Checking repositories', repos.size);
362
+ for (const [key, r] of repos) {
363
+ checking.tick(`${r.owner}/${r.repo}`);
364
+ const identity = r.identity
365
+ ? await (0, action_fetcher_1.resolveRepoIdentity)(r.owner, r.repo)
366
+ : undefined;
367
+ if (identity)
368
+ identities.set(key, identity);
369
+ else if (r.existence && !(await (0, action_fetcher_1.repoExists)(r.owner, r.repo))) {
370
+ missingRepos.add(key);
328
371
  }
329
372
  }
373
+ checking.done(`Checked ${repos.size} repositor${repos.size === 1 ? 'y' : 'ies'}`);
374
+ for (const action of actions) {
375
+ const identity = identities.get(`${action.owner}/${action.repo}`.toLowerCase());
376
+ if (identity && action.resolvedSha)
377
+ Object.assign(action, identity);
378
+ }
379
+ logger_1.logger.setLevel(level);
330
380
  const current = {};
331
381
  for (const action of actions) {
332
382
  if (!action.resolvedSha)
@@ -344,7 +394,12 @@ async function runInventory(files, options) {
344
394
  console.log(JSON.stringify({ actions }, null, 2));
345
395
  return;
346
396
  }
397
+ // Used references that couldn't be resolved to a commit now.
398
+ const unresolved = actions.filter((a) => !a.resolvedSha);
347
399
  if (writing) {
400
+ if (online) {
401
+ unresolved.forEach((a) => logger_1.logger.warn(`Not locked: could not resolve ${a.uses} (repository deleted, renamed or private, or the network is unavailable?)`));
402
+ }
348
403
  fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
349
404
  const transitive = Object.values(current).filter((e) => e.via).length;
350
405
  logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
@@ -363,16 +418,41 @@ async function runInventory(files, options) {
363
418
  for (const d of diff.identity) {
364
419
  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
420
  }
421
+ for (const a of actions) {
422
+ if (missingRepos.has(`${a.owner}/${a.repo}`.toLowerCase())) {
423
+ 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.`);
424
+ }
425
+ }
426
+ // Online, a used reference that doesn't resolve is a failure (its
427
+ // repository may be gone); offline, tags can't resolve, so it's a warning.
428
+ for (const a of unresolved) {
429
+ const locked = lock?.actions[a.uses];
430
+ const at = locked ? ` (locked at ${locked.commit.slice(0, 12)})` : '';
431
+ if (online) {
432
+ 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.`);
433
+ }
434
+ }
435
+ if (!online && unresolved.length > 0) {
436
+ logger_1.logger.warn(`Not checked offline (tags need the network): ${unresolved.map((a) => a.uses).join(', ')}`);
437
+ }
366
438
  diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${lockName}: ${u}`));
367
439
  // actions.lock is written by `gh actions-lock`, which may record entries
368
440
  // SecureCI doesn't follow, so "no longer used" is only for secureci.lock.
441
+ // A reference still in use but unresolved isn't "no longer used".
369
442
  if (!github) {
370
- diff.removed.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
443
+ const used = new Set(unresolved.map((a) => a.uses));
444
+ diff.removed
445
+ .filter((u) => !used.has(u))
446
+ .forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
371
447
  }
372
448
  if (diff.identity.length > 0) {
373
449
  logger_1.logger.error(`A locked repository is no longer the one that was locked. Verify it before trusting it, then ${fixHint}.`);
374
450
  process.exit(1);
375
451
  }
452
+ if (missingRepos.size > 0 || (online && unresolved.length > 0)) {
453
+ 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)'}.`);
454
+ process.exit(1);
455
+ }
376
456
  if (diff.drifted.length > 0) {
377
457
  logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
378
458
  process.exit(1);
package/dist/inventory.js CHANGED
@@ -247,6 +247,7 @@ async function addTransitive(direct, deps) {
247
247
  }
248
248
  byUses.set(dep.uses, dep);
249
249
  queue.push({ action: dep, depth: depth + 1 });
250
+ deps.onQueue?.(dep);
250
251
  }
251
252
  }
252
253
  return [...byUses.values()];
@@ -43,6 +43,8 @@ exports.fetchRepoFile = fetchRepoFile;
43
43
  exports.clearActionCache = clearActionCache;
44
44
  exports.resolveActionSha = resolveActionSha;
45
45
  exports.resolveRepoIdentity = resolveRepoIdentity;
46
+ exports.lsRemoteReachable = lsRemoteReachable;
47
+ exports.repoExists = repoExists;
46
48
  const fs = __importStar(require("fs"));
47
49
  const path = __importStar(require("path"));
48
50
  const http = __importStar(require("http"));
@@ -459,4 +461,39 @@ async function resolveRepoIdentity(owner, repo, options = {}) {
459
461
  return (await lookup(null)).identity;
460
462
  return undefined;
461
463
  }
464
+ /** Whether `git ls-remote` can read the repository at `url`. */
465
+ async function lsRemoteReachable(url) {
466
+ try {
467
+ await execFileAsync('git', ['ls-remote', url, 'HEAD'], {
468
+ encoding: 'utf8',
469
+ timeout: 15000,
470
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
471
+ });
472
+ return true;
473
+ }
474
+ catch {
475
+ return false;
476
+ }
477
+ }
478
+ /**
479
+ * Whether `owner/repo` can still be read: via the API when a token is
480
+ * available (404 means no), otherwise (or if the API can't answer) via
481
+ * anonymous `git ls-remote`. False also when the network is down.
482
+ */
483
+ async function repoExists(owner, repo, options = {}) {
484
+ const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
485
+ const token = options.token === undefined ? githubToken() : options.token;
486
+ if (token) {
487
+ try {
488
+ await getText(`${apiUrl()}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { ...requestHeaders(token), Accept: 'application/vnd.github+json' }, timeoutMs);
489
+ return true;
490
+ }
491
+ catch (error) {
492
+ if (error.status === 404)
493
+ return false;
494
+ // 401, rate limit or network: fall back to anonymous git.
495
+ }
496
+ }
497
+ return lsRemoteReachable(`${serverUrl()}/${owner}/${repo}.git`);
498
+ }
462
499
  //# sourceMappingURL=action-fetcher.js.map
@@ -17,6 +17,16 @@ class Logger {
17
17
  setLevel(level) {
18
18
  this.level = level;
19
19
  }
20
+ getLevel() {
21
+ return this.level;
22
+ }
23
+ /**
24
+ * Run `hook` before each message is written (e.g. to clear an in-place
25
+ * progress line); undefined removes it.
26
+ */
27
+ setBeforeWrite(hook) {
28
+ this.beforeWrite = hook;
29
+ }
20
30
  /**
21
31
  * Register secret values so they get masked (replaced with `***`) in any
22
32
  * subsequent log output, mirroring GitHub Actions' own secret masking.
@@ -48,35 +58,42 @@ class Logger {
48
58
  }
49
59
  debug(message, ...args) {
50
60
  if (this.level <= LogLevel.DEBUG) {
61
+ this.beforeWrite?.();
51
62
  console.log(ansi_1.colors.gray(`[DEBUG] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
52
63
  }
53
64
  }
54
65
  info(message, ...args) {
55
66
  if (this.level <= LogLevel.INFO) {
67
+ this.beforeWrite?.();
56
68
  console.log(ansi_1.colors.blue(`[INFO] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
57
69
  }
58
70
  }
59
71
  success(message, ...args) {
60
72
  if (this.level <= LogLevel.INFO) {
73
+ this.beforeWrite?.();
61
74
  console.log(ansi_1.colors.green(`✓ ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
62
75
  }
63
76
  }
64
77
  warn(message, ...args) {
65
78
  if (this.level <= LogLevel.WARN) {
79
+ this.beforeWrite?.();
66
80
  console.warn(ansi_1.colors.yellow(`[WARN] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
67
81
  }
68
82
  }
69
83
  error(message, ...args) {
70
84
  if (this.level <= LogLevel.ERROR) {
85
+ this.beforeWrite?.();
71
86
  console.error(ansi_1.colors.red(`[ERROR] ${this.mask(message)}`), ...args.map((a) => this.maskArg(a)));
72
87
  }
73
88
  }
74
89
  step(message) {
90
+ this.beforeWrite?.();
75
91
  console.log(ansi_1.colors.cyan(`\n→ ${this.mask(message)}`));
76
92
  }
77
93
  result(message, success) {
78
94
  const icon = success ? '✓' : '✗';
79
95
  const color = success ? ansi_1.colors.green : ansi_1.colors.red;
96
+ this.beforeWrite?.();
80
97
  console.log(color(`${icon} ${this.mask(message)}`));
81
98
  }
82
99
  }
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Progress = void 0;
4
+ const logger_1 = require("./logger");
5
+ const CLEAR_LINE = '\r\x1b[K';
6
+ /**
7
+ * Progress for a slow phase, e.g. resolving actions over the network. On a
8
+ * terminal it updates one line in place (`Label [3/10] item`); elsewhere
9
+ * (CI logs) it prints a line per item. The total can grow as work is found.
10
+ */
11
+ class Progress {
12
+ constructor(label, total, out = process.stderr) {
13
+ this.label = label;
14
+ this.total = total;
15
+ this.out = out;
16
+ this.count = 0;
17
+ this.drawn = false;
18
+ this.tty = !!out.isTTY;
19
+ // A log line would otherwise be appended to the half-drawn line.
20
+ if (this.tty)
21
+ logger_1.logger.setBeforeWrite(() => this.clear());
22
+ }
23
+ /** More items were found to process. */
24
+ grow(n = 1) {
25
+ this.total += n;
26
+ }
27
+ tick(item) {
28
+ this.count++;
29
+ if (this.count > this.total)
30
+ this.total = this.count;
31
+ const line = `${this.label} [${this.count}/${this.total}] ${item}`;
32
+ if (this.tty) {
33
+ this.out.write(CLEAR_LINE + line);
34
+ this.drawn = true;
35
+ }
36
+ else {
37
+ this.out.write(line + '\n');
38
+ }
39
+ }
40
+ /** End the phase, leaving `summary` (if any work was done). */
41
+ done(summary) {
42
+ if (this.tty)
43
+ logger_1.logger.setBeforeWrite(undefined);
44
+ if (this.count === 0)
45
+ return;
46
+ this.clear();
47
+ if (summary)
48
+ this.out.write(summary + '\n');
49
+ }
50
+ clear() {
51
+ if (this.drawn) {
52
+ this.out.write(CLEAR_LINE);
53
+ this.drawn = false;
54
+ }
55
+ }
56
+ }
57
+ exports.Progress = Progress;
58
+ //# sourceMappingURL=progress.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
5
  "main": "dist/index.js",
6
6
  "bin": {