@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 +110 -30
- package/dist/inventory.js +1 -0
- package/dist/runner/action-fetcher.js +37 -0
- package/dist/utils/logger.js +17 -0
- package/dist/utils/progress.js +58 -0
- package/package.json +1 -1
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)({
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
package/dist/utils/logger.js
CHANGED
|
@@ -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
|