@secureci/cli 0.5.1 → 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 +255 -33
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- 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 +100 -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 +36 -7
- package/dist/version.js +56 -0
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ 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");
|
|
41
42
|
const progress_1 = require("./utils/progress");
|
|
@@ -46,24 +47,19 @@ const pipeline_contract_parser_1 = require("./parser/pipeline-contract-parser");
|
|
|
46
47
|
const pipeline_contract_validator_1 = require("./parser/pipeline-contract-validator");
|
|
47
48
|
const report_1 = require("./report");
|
|
48
49
|
const init_1 = require("./init");
|
|
50
|
+
const pin_1 = require("./pin");
|
|
51
|
+
const advisories_1 = require("./advisories");
|
|
49
52
|
const inventory_1 = require("./inventory");
|
|
50
53
|
const github_lockfile_1 = require("./github-lockfile");
|
|
51
54
|
const logger_1 = require("./utils/logger");
|
|
52
|
-
const
|
|
55
|
+
const env_file_1 = require("./utils/env-file");
|
|
53
56
|
const config_1 = require("./config");
|
|
57
|
+
const scheduler_1 = require("./runner/scheduler");
|
|
58
|
+
const version_1 = require("./version");
|
|
54
59
|
const fs = __importStar(require("fs"));
|
|
55
60
|
const path = __importStar(require("path"));
|
|
56
61
|
/** Committed at the repo root, like package-lock.json / yarn.lock. */
|
|
57
62
|
const LOCKFILE_NAME = 'secureci.lock';
|
|
58
|
-
function readVersion() {
|
|
59
|
-
try {
|
|
60
|
-
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
61
|
-
return String(pkg.version);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return '0.0.0';
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
63
|
const program = new commander_1.Command();
|
|
68
64
|
const globalConfig = (0, config_1.loadConfig)();
|
|
69
65
|
// An invalid container-runtime in the config file only stops `run`, so
|
|
@@ -77,10 +73,17 @@ catch (error) {
|
|
|
77
73
|
configError = error instanceof Error ? error.message : String(error);
|
|
78
74
|
defaultContainerRuntime = 'docker';
|
|
79
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
|
+
}
|
|
80
83
|
program
|
|
81
84
|
.name('secureci')
|
|
82
85
|
.description('SecureCI: validate, check and run GitHub Actions workflows locally')
|
|
83
|
-
.version(readVersion(), '-V, --version', 'Print the version')
|
|
86
|
+
.version((0, version_1.readVersion)(), '-V, --version', 'Print the version')
|
|
84
87
|
.helpOption('-h, --help', 'Show help')
|
|
85
88
|
.addHelpCommand('help [command]', 'Show help for a command')
|
|
86
89
|
.option('--verbose', 'Log debug output')
|
|
@@ -105,9 +108,14 @@ program
|
|
|
105
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')
|
|
106
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')
|
|
107
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')
|
|
108
115
|
.option('--event-path <file>', 'Read the event payload (github.event, $GITHUB_EVENT_PATH) from a JSON file')
|
|
109
116
|
.option('--event-name <name>', 'Set github.event_name. Default: $GITHUB_EVENT_NAME, else workflow_dispatch if declared, else the first trigger')
|
|
110
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)')
|
|
111
119
|
.option('--offline', 'Use only cached remote actions and workflows; never download')
|
|
112
120
|
.option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
|
|
113
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')
|
|
@@ -120,14 +128,23 @@ program
|
|
|
120
128
|
.option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
|
|
121
129
|
.option('--no-network', 'Turn off container network access (on by default)')
|
|
122
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))
|
|
123
132
|
.action(async (file, options) => {
|
|
124
133
|
if (configError) {
|
|
125
134
|
logger_1.logger.error(configError);
|
|
126
135
|
process.exit(1);
|
|
127
136
|
}
|
|
128
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
|
+
}
|
|
129
146
|
if (options.dryRun) {
|
|
130
|
-
const result = (0, dry_run_1.dryRun)(file);
|
|
147
|
+
const result = (0, dry_run_1.dryRun)(file, { job: options.job, matrix });
|
|
131
148
|
if (!result.success) {
|
|
132
149
|
process.exit(1);
|
|
133
150
|
}
|
|
@@ -135,15 +152,38 @@ program
|
|
|
135
152
|
}
|
|
136
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.");
|
|
137
154
|
try {
|
|
138
|
-
const inputs = (0,
|
|
139
|
-
|
|
140
|
-
|
|
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,
|
|
141
169
|
fromEnv: process.env
|
|
142
170
|
});
|
|
143
|
-
const vars = (0,
|
|
144
|
-
|
|
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'],
|
|
145
184
|
fromEnv: process.env
|
|
146
185
|
});
|
|
186
|
+
const parallel = (0, scheduler_1.parseParallel)(options.parallel);
|
|
147
187
|
const result = await (0, executor_1.executeWorkflow)(file, {
|
|
148
188
|
dryRun: false,
|
|
149
189
|
inputs,
|
|
@@ -153,6 +193,7 @@ program
|
|
|
153
193
|
eventPath: options.eventPath,
|
|
154
194
|
eventName: options.eventName,
|
|
155
195
|
job: options.job,
|
|
196
|
+
matrix,
|
|
156
197
|
localCheckout: options.localCheckout,
|
|
157
198
|
offline: options.offline,
|
|
158
199
|
verbose: program.opts().verbose,
|
|
@@ -164,7 +205,8 @@ program
|
|
|
164
205
|
? Number(options.containerTimeout)
|
|
165
206
|
: undefined,
|
|
166
207
|
containerMemoryLimit: options.containerMemory,
|
|
167
|
-
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
|
|
168
210
|
});
|
|
169
211
|
if (!result.success) {
|
|
170
212
|
process.exit(1);
|
|
@@ -186,15 +228,16 @@ program
|
|
|
186
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)')
|
|
187
229
|
.choices(['text', 'json', 'sarif'])
|
|
188
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')
|
|
189
232
|
.action(async (files, options) => {
|
|
190
233
|
files.forEach(requireFile);
|
|
191
|
-
await validateFiles(files, options.contract, options.contractRecurse, options.format);
|
|
234
|
+
await validateFiles(files, options.contract, options.contractRecurse, options.format, options.offline ?? false);
|
|
192
235
|
});
|
|
193
236
|
program
|
|
194
237
|
.command('inventory')
|
|
195
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')
|
|
196
239
|
.argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
|
|
197
|
-
.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)')
|
|
198
241
|
.option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
|
|
199
242
|
.option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
|
|
200
243
|
.option('--update-lockfile', `Alias of --update-lock`)
|
|
@@ -203,6 +246,17 @@ program
|
|
|
203
246
|
.action(async (files, options) => {
|
|
204
247
|
await runInventory(files, options);
|
|
205
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
|
+
});
|
|
206
260
|
program
|
|
207
261
|
.command('list')
|
|
208
262
|
.description('List workflows in .github/workflows/')
|
|
@@ -376,6 +430,17 @@ async function runInventory(files, options) {
|
|
|
376
430
|
if (identity && action.resolvedSha)
|
|
377
431
|
Object.assign(action, identity);
|
|
378
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'}`);
|
|
379
444
|
logger_1.logger.setLevel(level);
|
|
380
445
|
const current = {};
|
|
381
446
|
for (const action of actions) {
|
|
@@ -391,9 +456,22 @@ async function runInventory(files, options) {
|
|
|
391
456
|
// --dry-run: print the resolved inventory as JSON, touching nothing.
|
|
392
457
|
if (options.dryRun) {
|
|
393
458
|
// eslint-disable-next-line no-console
|
|
394
|
-
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));
|
|
395
468
|
return;
|
|
396
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
|
+
}
|
|
397
475
|
// Used references that couldn't be resolved to a commit now.
|
|
398
476
|
const unresolved = actions.filter((a) => !a.resolvedSha);
|
|
399
477
|
if (writing) {
|
|
@@ -463,7 +541,130 @@ async function runInventory(files, options) {
|
|
|
463
541
|
}
|
|
464
542
|
logger_1.logger.success(`No action drift against ${lockName}.`);
|
|
465
543
|
}
|
|
466
|
-
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) {
|
|
467
668
|
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
468
669
|
let result;
|
|
469
670
|
if (parsed.type === 'workflow') {
|
|
@@ -474,6 +675,7 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
|
474
675
|
result = (0, validator_1.validateAction)(filePath);
|
|
475
676
|
say(() => logger_1.logger.info(`Validating action: ${filePath}`));
|
|
476
677
|
}
|
|
678
|
+
let failOn;
|
|
477
679
|
if (contractFile) {
|
|
478
680
|
say(() => logger_1.logger.info(`Validating against pipeline contract: ${contractFile}`));
|
|
479
681
|
if (parsed.type !== 'workflow') {
|
|
@@ -491,18 +693,23 @@ async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
|
491
693
|
}
|
|
492
694
|
else {
|
|
493
695
|
const spec = (0, pipeline_contract_parser_1.parsePipelineContract)(contractFile);
|
|
696
|
+
failOn = spec.advisories?.failOn;
|
|
494
697
|
const contractResult = await (0, pipeline_contract_validator_1.validatePipelineContract)(parsed, spec, {
|
|
495
|
-
recurse
|
|
698
|
+
recurse,
|
|
699
|
+
offline
|
|
496
700
|
});
|
|
497
|
-
result =
|
|
498
|
-
valid: result.valid && contractResult.valid,
|
|
499
|
-
errors: [...result.errors, ...contractResult.errors]
|
|
500
|
-
};
|
|
701
|
+
result = (0, validator_1.mergeValidationResults)(result, contractResult);
|
|
501
702
|
}
|
|
502
703
|
}
|
|
503
|
-
|
|
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
|
+
};
|
|
504
711
|
}
|
|
505
|
-
async function validateFiles(files, contractFile, recurse
|
|
712
|
+
async function validateFiles(files, contractFile, recurse, format, offline) {
|
|
506
713
|
// Only text mode narrates; json/sarif write nothing but the report to
|
|
507
714
|
// stdout, so the output is pipeable.
|
|
508
715
|
const quiet = format !== 'text';
|
|
@@ -518,8 +725,8 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
518
725
|
if (contractFile) {
|
|
519
726
|
try {
|
|
520
727
|
const source = await (0, contract_source_1.resolveContractSource)(contractFile, {
|
|
521
|
-
fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses),
|
|
522
|
-
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),
|
|
523
730
|
warn: (message) => logger_1.logger.warn(message)
|
|
524
731
|
});
|
|
525
732
|
contractPath = source.file;
|
|
@@ -540,12 +747,17 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
540
747
|
contractError = error instanceof Error ? error.message : 'Unknown error';
|
|
541
748
|
}
|
|
542
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
|
+
};
|
|
543
755
|
const entries = [];
|
|
544
756
|
for (const filePath of files) {
|
|
545
757
|
try {
|
|
546
758
|
if (contractError)
|
|
547
759
|
throw new Error(contractError);
|
|
548
|
-
const result = await validateOneFile(filePath, contractPath, recurse, say);
|
|
760
|
+
const result = await validateOneFile(filePath, contractPath, recurse, say, advisories, offline);
|
|
549
761
|
entries.push({ file: filePath, result });
|
|
550
762
|
}
|
|
551
763
|
catch (error) {
|
|
@@ -565,6 +777,16 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
|
|
|
565
777
|
}
|
|
566
778
|
}
|
|
567
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
|
+
}
|
|
568
790
|
if (quiet) {
|
|
569
791
|
// eslint-disable-next-line no-console
|
|
570
792
|
console.log((0, report_1.formatReport)(entries, format, { contract }));
|
|
@@ -592,7 +814,7 @@ function runInit(options) {
|
|
|
592
814
|
process.exit(1);
|
|
593
815
|
}
|
|
594
816
|
const content = (0, init_1.renderInitWorkflow)({
|
|
595
|
-
version: readVersion(),
|
|
817
|
+
version: (0, version_1.readVersion)(),
|
|
596
818
|
contract: options.contract,
|
|
597
819
|
upload: options.upload
|
|
598
820
|
});
|
package/dist/config.js
CHANGED
|
@@ -38,6 +38,7 @@ exports.getConfigFilePath = getConfigFilePath;
|
|
|
38
38
|
exports.loadConfig = loadConfig;
|
|
39
39
|
exports.saveConfig = saveConfig;
|
|
40
40
|
exports.getDefaultContainerRuntime = getDefaultContainerRuntime;
|
|
41
|
+
exports.getDefaultParallel = getDefaultParallel;
|
|
41
42
|
exports.isValidConfigKey = isValidConfigKey;
|
|
42
43
|
exports.getConfigValue = getConfigValue;
|
|
43
44
|
exports.setConfigValue = setConfigValue;
|
|
@@ -47,6 +48,7 @@ exports.resolveNetworkDisabled = resolveNetworkDisabled;
|
|
|
47
48
|
const fs = __importStar(require("fs"));
|
|
48
49
|
const path = __importStar(require("path"));
|
|
49
50
|
const YAML = __importStar(require("yaml"));
|
|
51
|
+
const scheduler_1 = require("./runner/scheduler");
|
|
50
52
|
/** Built-in defaults, used when a key is absent from the config file. */
|
|
51
53
|
exports.DEFAULT_CONFIG = {
|
|
52
54
|
'container-runtime': 'docker'
|
|
@@ -100,12 +102,22 @@ function getDefaultContainerRuntime(config = loadConfig()) {
|
|
|
100
102
|
}
|
|
101
103
|
return value;
|
|
102
104
|
}
|
|
105
|
+
/** Resolve the effective `parallel`: config, else the CPU count. */
|
|
106
|
+
function getDefaultParallel(config = loadConfig()) {
|
|
107
|
+
const value = config.parallel;
|
|
108
|
+
return value === undefined ? (0, scheduler_1.defaultParallelism)() : (0, scheduler_1.parseParallel)(value);
|
|
109
|
+
}
|
|
103
110
|
exports.CONFIG_KEYS = [
|
|
104
111
|
'container-runtime',
|
|
105
112
|
'container-image',
|
|
106
113
|
'container-timeout',
|
|
107
114
|
'container-memory',
|
|
108
|
-
'allow-network'
|
|
115
|
+
'allow-network',
|
|
116
|
+
'parallel',
|
|
117
|
+
'env-file',
|
|
118
|
+
'secret-file',
|
|
119
|
+
'input-file',
|
|
120
|
+
'var-file'
|
|
109
121
|
];
|
|
110
122
|
/** `runner-images.<label>`: one entry of the runs-on -> image mapping. */
|
|
111
123
|
const RUNNER_IMAGE_KEY = /^runner-images\.(.+)$/;
|
|
@@ -169,9 +181,19 @@ function parseConfigValue(key, rawValue) {
|
|
|
169
181
|
throw new Error(`Invalid value "${rawValue}" for allow-network. Must be "true" or "false".`);
|
|
170
182
|
}
|
|
171
183
|
return rawValue === 'true';
|
|
184
|
+
case 'parallel':
|
|
185
|
+
return (0, scheduler_1.parseParallel)(rawValue);
|
|
172
186
|
case 'container-image':
|
|
173
187
|
case 'container-memory':
|
|
174
188
|
return rawValue;
|
|
189
|
+
case 'env-file':
|
|
190
|
+
case 'secret-file':
|
|
191
|
+
case 'input-file':
|
|
192
|
+
case 'var-file':
|
|
193
|
+
if (rawValue === '') {
|
|
194
|
+
throw new Error(`Invalid value "" for ${key}. Must be a file path.`);
|
|
195
|
+
}
|
|
196
|
+
return rawValue;
|
|
175
197
|
}
|
|
176
198
|
}
|
|
177
199
|
/**
|
package/dist/init.js
CHANGED
|
@@ -7,7 +7,7 @@ exports.renderInitWorkflow = renderInitWorkflow;
|
|
|
7
7
|
* the release tag matching this CLI's version.
|
|
8
8
|
*
|
|
9
9
|
* The output is deterministic for a given set of options, so it can be
|
|
10
|
-
* regenerated and diffed. With uploads on (the default) the
|
|
10
|
+
* regenerated and diffed. With uploads on (the default) the job asks for
|
|
11
11
|
* `security-events: write` and lets the action upload SARIF; with uploads off
|
|
12
12
|
* it keeps `contents: read` only and passes `upload-sarif: false`.
|
|
13
13
|
*/
|
|
@@ -23,11 +23,18 @@ function renderInitWorkflow(options) {
|
|
|
23
23
|
' - pull_request',
|
|
24
24
|
'',
|
|
25
25
|
'permissions:',
|
|
26
|
-
' contents: read'
|
|
26
|
+
' contents: read',
|
|
27
|
+
'',
|
|
28
|
+
'jobs:',
|
|
29
|
+
' secureci:',
|
|
30
|
+
' runs-on: ubuntu-latest'
|
|
27
31
|
];
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
// Write scopes belong on the job, not the workflow. Job-level permissions
|
|
33
|
+
// replace the top-level ones, so contents: read is repeated for checkout.
|
|
34
|
+
if (upload) {
|
|
35
|
+
lines.push(' permissions:', ' contents: read', ' security-events: write');
|
|
36
|
+
}
|
|
37
|
+
lines.push(' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
|
|
31
38
|
const withLines = [];
|
|
32
39
|
if (contract)
|
|
33
40
|
withLines.push(` contract: ${yamlScalar(contract)}`);
|