@secureci/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -11
- package/dist/advisories.js +603 -0
- package/dist/cli.js +365 -63
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/inventory.js +1 -0
- package/dist/parser/dangerous-triggers.js +125 -0
- package/dist/parser/injection.js +281 -0
- package/dist/parser/permissions.js +145 -0
- package/dist/parser/pipeline-contract-parser.js +70 -0
- package/dist/parser/pipeline-contract-validator.js +18 -4
- package/dist/parser/validator.js +26 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +7 -1
- package/dist/runner/action-fetcher.js +137 -4
- package/dist/runner/builtin-actions.js +13 -3
- package/dist/runner/dry-run.js +57 -12
- package/dist/runner/executor.js +368 -254
- package/dist/runner/expression.js +106 -8
- package/dist/runner/job-plan.js +58 -1
- package/dist/runner/job-runtime.js +3 -3
- package/dist/runner/node-action.js +11 -3
- package/dist/runner/node-binary.js +108 -0
- package/dist/runner/scheduler.js +207 -0
- package/dist/runner/steps.js +54 -2
- package/dist/utils/env-file.js +185 -0
- package/dist/utils/logger.js +53 -7
- package/dist/utils/progress.js +58 -0
- package/dist/version.js +56 -0
- package/package.json +3 -1
|
@@ -37,12 +37,20 @@ exports.parseActionReference = parseActionReference;
|
|
|
37
37
|
exports.getActionCacheDir = getActionCacheDir;
|
|
38
38
|
exports.githubToken = githubToken;
|
|
39
39
|
exports._resetGithubTokenForTests = _resetGithubTokenForTests;
|
|
40
|
+
exports.serverUrl = serverUrl;
|
|
41
|
+
exports.apiUrl = apiUrl;
|
|
40
42
|
exports.lsRemoteSha = lsRemoteSha;
|
|
43
|
+
exports.parseLsRemoteTags = parseLsRemoteTags;
|
|
44
|
+
exports.lsRemoteTags = lsRemoteTags;
|
|
45
|
+
exports.getText = getText;
|
|
41
46
|
exports.fetchAction = fetchAction;
|
|
42
47
|
exports.fetchRepoFile = fetchRepoFile;
|
|
43
48
|
exports.clearActionCache = clearActionCache;
|
|
49
|
+
exports.cachedActionSha = cachedActionSha;
|
|
44
50
|
exports.resolveActionSha = resolveActionSha;
|
|
45
51
|
exports.resolveRepoIdentity = resolveRepoIdentity;
|
|
52
|
+
exports.lsRemoteReachable = lsRemoteReachable;
|
|
53
|
+
exports.repoExists = repoExists;
|
|
46
54
|
const fs = __importStar(require("fs"));
|
|
47
55
|
const path = __importStar(require("path"));
|
|
48
56
|
const http = __importStar(require("http"));
|
|
@@ -108,9 +116,14 @@ function githubToken() {
|
|
|
108
116
|
function _resetGithubTokenForTests() {
|
|
109
117
|
discoveredToken = undefined;
|
|
110
118
|
}
|
|
119
|
+
/** The GitHub server ($GITHUB_SERVER_URL, default https://github.com). */
|
|
111
120
|
function serverUrl() {
|
|
112
121
|
return (process.env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/+$/, '');
|
|
113
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The GitHub REST API base: $GITHUB_API_URL, else api.github.com, or
|
|
125
|
+
* `<server>/api/v3` for GitHub Enterprise Server.
|
|
126
|
+
*/
|
|
114
127
|
function apiUrl() {
|
|
115
128
|
if (process.env.GITHUB_API_URL) {
|
|
116
129
|
return process.env.GITHUB_API_URL.replace(/\/+$/, '');
|
|
@@ -153,7 +166,51 @@ async function lsRemoteSha(url, ref) {
|
|
|
153
166
|
refs.get(`refs/tags/${ref}`) ??
|
|
154
167
|
refs.get(`refs/heads/${ref}`));
|
|
155
168
|
}
|
|
156
|
-
/**
|
|
169
|
+
/**
|
|
170
|
+
* Tag name -> commit SHA from `git ls-remote --tags` output. An annotated
|
|
171
|
+
* tag's peeled entry (`refs/tags/x^{}`) wins, so every SHA is a commit.
|
|
172
|
+
*/
|
|
173
|
+
function parseLsRemoteTags(output) {
|
|
174
|
+
const tags = new Map();
|
|
175
|
+
const peeled = new Set();
|
|
176
|
+
for (const line of output.split('\n')) {
|
|
177
|
+
const [sha, ref] = line.trim().split(/\s+/);
|
|
178
|
+
if (!sha || !FULL_SHA.test(sha) || !ref?.startsWith('refs/tags/'))
|
|
179
|
+
continue;
|
|
180
|
+
let name = ref.slice('refs/tags/'.length);
|
|
181
|
+
const isPeeled = name.endsWith('^{}');
|
|
182
|
+
if (isPeeled)
|
|
183
|
+
name = name.slice(0, -3);
|
|
184
|
+
if (isPeeled || !peeled.has(name))
|
|
185
|
+
tags.set(name, sha.toLowerCase());
|
|
186
|
+
if (isPeeled)
|
|
187
|
+
peeled.add(name);
|
|
188
|
+
}
|
|
189
|
+
return tags;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Every tag of the repository at `url` and the commit it points at, via
|
|
193
|
+
* anonymous `git ls-remote --tags` (no API rate limit), or undefined if
|
|
194
|
+
* the repository can't be read.
|
|
195
|
+
*/
|
|
196
|
+
async function lsRemoteTags(url) {
|
|
197
|
+
try {
|
|
198
|
+
const { stdout } = await execFileAsync('git', ['ls-remote', '--tags', url], {
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
timeout: 15000,
|
|
201
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
202
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
203
|
+
});
|
|
204
|
+
return parseLsRemoteTags(stdout);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* GET a small text response (API lookups). Rejects on a non-200 status
|
|
212
|
+
* with an error carrying `status`.
|
|
213
|
+
*/
|
|
157
214
|
function getText(url, headers, timeoutMs) {
|
|
158
215
|
return new Promise((resolve, reject) => {
|
|
159
216
|
const client = url.startsWith('http://') ? http : https;
|
|
@@ -276,7 +333,27 @@ function writeRefIndex(dir, refs) {
|
|
|
276
333
|
* picked up. If the ref can't be resolved (offline, rate-limited), the
|
|
277
334
|
* last commit cached for it is used. Returns the repository root.
|
|
278
335
|
*/
|
|
279
|
-
|
|
336
|
+
const inflight = new Map();
|
|
337
|
+
/**
|
|
338
|
+
* fetchRepositoryOnce, shared by concurrent callers asking for the same
|
|
339
|
+
* repository and ref (jobs running in parallel), so it is downloaded and
|
|
340
|
+
* written into the cache once.
|
|
341
|
+
*/
|
|
342
|
+
function fetchRepository(ref, uses, options) {
|
|
343
|
+
const key = JSON.stringify([
|
|
344
|
+
repoCacheDir(ref),
|
|
345
|
+
ref.ref,
|
|
346
|
+
!!options.offline,
|
|
347
|
+
options.token === undefined ? 'discovered' : options.token
|
|
348
|
+
]);
|
|
349
|
+
let pending = inflight.get(key);
|
|
350
|
+
if (!pending) {
|
|
351
|
+
pending = fetchRepositoryOnce(ref, uses, options).finally(() => inflight.delete(key));
|
|
352
|
+
inflight.set(key, pending);
|
|
353
|
+
}
|
|
354
|
+
return pending;
|
|
355
|
+
}
|
|
356
|
+
async function fetchRepositoryOnce(ref, uses, options) {
|
|
280
357
|
const dir = repoCacheDir(ref);
|
|
281
358
|
const refs = readRefIndex(dir);
|
|
282
359
|
const cachedSha = FULL_SHA.test(ref.ref)
|
|
@@ -332,8 +409,12 @@ async function fetchRepository(ref, uses, options) {
|
|
|
332
409
|
const [top] = fs.readdirSync(extractDir);
|
|
333
410
|
if (!top)
|
|
334
411
|
throw new Error('No files extracted from tarball');
|
|
335
|
-
|
|
336
|
-
|
|
412
|
+
// A commit's content never changes: keep a copy another job (asking
|
|
413
|
+
// by another ref) put there meanwhile, as it may be in use.
|
|
414
|
+
if (!sha || !fs.existsSync(target)) {
|
|
415
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
416
|
+
fs.renameSync(path.join(extractDir, top), target);
|
|
417
|
+
}
|
|
337
418
|
if (sha) {
|
|
338
419
|
writeRefIndex(dir, { ...readRefIndex(dir), [ref.ref]: sha });
|
|
339
420
|
}
|
|
@@ -408,6 +489,23 @@ function clearActionCache() {
|
|
|
408
489
|
logger_1.logger.info('Action cache cleared');
|
|
409
490
|
}
|
|
410
491
|
}
|
|
492
|
+
/**
|
|
493
|
+
* The commit a ref resolved to when it was last downloaded into the action
|
|
494
|
+
* cache, without touching the network (for offline use), or undefined when
|
|
495
|
+
* that commit isn't cached. An already-pinned SHA is returned as is. For a
|
|
496
|
+
* moving ref (`@main`) this is the last commit seen, not necessarily the
|
|
497
|
+
* current one.
|
|
498
|
+
*/
|
|
499
|
+
function cachedActionSha(uses) {
|
|
500
|
+
const ref = parseActionReference(uses);
|
|
501
|
+
if (!ref)
|
|
502
|
+
return undefined;
|
|
503
|
+
if (FULL_SHA.test(ref.ref))
|
|
504
|
+
return ref.ref.toLowerCase();
|
|
505
|
+
const dir = repoCacheDir(ref);
|
|
506
|
+
const sha = readRefIndex(dir)[ref.ref];
|
|
507
|
+
return sha && fs.existsSync(path.join(dir, sha)) ? sha : undefined;
|
|
508
|
+
}
|
|
411
509
|
/**
|
|
412
510
|
* Resolve a `uses:` action reference to its current commit SHA, for the
|
|
413
511
|
* inventory and lockfile. Returns undefined if the ref can't be resolved
|
|
@@ -459,4 +557,39 @@ async function resolveRepoIdentity(owner, repo, options = {}) {
|
|
|
459
557
|
return (await lookup(null)).identity;
|
|
460
558
|
return undefined;
|
|
461
559
|
}
|
|
560
|
+
/** Whether `git ls-remote` can read the repository at `url`. */
|
|
561
|
+
async function lsRemoteReachable(url) {
|
|
562
|
+
try {
|
|
563
|
+
await execFileAsync('git', ['ls-remote', url, 'HEAD'], {
|
|
564
|
+
encoding: 'utf8',
|
|
565
|
+
timeout: 15000,
|
|
566
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
567
|
+
});
|
|
568
|
+
return true;
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Whether `owner/repo` can still be read: via the API when a token is
|
|
576
|
+
* available (404 means no), otherwise (or if the API can't answer) via
|
|
577
|
+
* anonymous `git ls-remote`. False also when the network is down.
|
|
578
|
+
*/
|
|
579
|
+
async function repoExists(owner, repo, options = {}) {
|
|
580
|
+
const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
581
|
+
const token = options.token === undefined ? githubToken() : options.token;
|
|
582
|
+
if (token) {
|
|
583
|
+
try {
|
|
584
|
+
await getText(`${apiUrl()}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { ...requestHeaders(token), Accept: 'application/vnd.github+json' }, timeoutMs);
|
|
585
|
+
return true;
|
|
586
|
+
}
|
|
587
|
+
catch (error) {
|
|
588
|
+
if (error.status === 404)
|
|
589
|
+
return false;
|
|
590
|
+
// 401, rate limit or network: fall back to anonymous git.
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return lsRemoteReachable(`${serverUrl()}/${owner}/${repo}.git`);
|
|
594
|
+
}
|
|
462
595
|
//# sourceMappingURL=action-fetcher.js.map
|
|
@@ -435,14 +435,24 @@ async function saveCache(fsys, scope, key, paths) {
|
|
|
435
435
|
warning: 'Path Validation Error: Path(s) specified in the action for caching do(es) not exist, hence no cache is being saved.'
|
|
436
436
|
};
|
|
437
437
|
}
|
|
438
|
-
|
|
439
|
-
|
|
438
|
+
// Unique per save: jobs running at once may save the same key.
|
|
439
|
+
const staging = `${dir}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
|
|
440
440
|
for (const file of files) {
|
|
441
441
|
copyFile(file.host, path.join(staging, 'files', ...cachedLocation(fsys, file.logical).split('/')));
|
|
442
442
|
}
|
|
443
443
|
fs.writeFileSync(path.join(staging, 'meta.json'), JSON.stringify({ key, version, created: Date.now() }));
|
|
444
444
|
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
445
|
-
|
|
445
|
+
try {
|
|
446
|
+
fs.renameSync(staging, dir);
|
|
447
|
+
}
|
|
448
|
+
catch (error) {
|
|
449
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
450
|
+
// Another job saved this key meanwhile; cache entries are immutable.
|
|
451
|
+
if (fs.existsSync(path.join(dir, 'meta.json'))) {
|
|
452
|
+
return ok(`Cache for key "${key}" already exists; not saving`);
|
|
453
|
+
}
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
446
456
|
return ok(`Cache saved with key: ${key} (${files.length} file(s))`);
|
|
447
457
|
}
|
|
448
458
|
/** `actions/cache`, `actions/cache/restore` and `actions/cache/save`. */
|
package/dist/runner/dry-run.js
CHANGED
|
@@ -4,11 +4,15 @@ exports.dryRun = dryRun;
|
|
|
4
4
|
const workflow_parser_1 = require("../parser/workflow-parser");
|
|
5
5
|
const logger_1 = require("../utils/logger");
|
|
6
6
|
const validator_1 = require("../parser/validator");
|
|
7
|
+
const expression_1 = require("./expression");
|
|
8
|
+
const job_plan_1 = require("./job-plan");
|
|
9
|
+
// A matrix expression reading a context a dry run doesn't have.
|
|
10
|
+
const RUN_TIME_MATRIX = /\$\{\{[^}]*\b(?:needs|inputs|vars|github|secrets)\b/;
|
|
7
11
|
/**
|
|
8
12
|
* Perform a dry run of a workflow or action file
|
|
9
13
|
* Shows what would be executed without actually running anything
|
|
10
14
|
*/
|
|
11
|
-
function dryRun(filePath) {
|
|
15
|
+
function dryRun(filePath, options = {}) {
|
|
12
16
|
try {
|
|
13
17
|
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
14
18
|
// A file a real run would reject fails the dry run too.
|
|
@@ -23,7 +27,7 @@ function dryRun(filePath) {
|
|
|
23
27
|
return { success: false, type: parsed.type };
|
|
24
28
|
}
|
|
25
29
|
if (parsed.type === 'workflow') {
|
|
26
|
-
return dryRunWorkflow(parsed);
|
|
30
|
+
return dryRunWorkflow(parsed, options);
|
|
27
31
|
}
|
|
28
32
|
else {
|
|
29
33
|
return dryRunAction(parsed);
|
|
@@ -37,22 +41,56 @@ function dryRun(filePath) {
|
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
|
-
function dryRunWorkflow(parsed) {
|
|
44
|
+
function dryRunWorkflow(parsed, options) {
|
|
41
45
|
const workflow = parsed.file;
|
|
46
|
+
const filter = options.matrix ?? {};
|
|
47
|
+
const definedKeys = new Set();
|
|
48
|
+
let runTimeMatrix = false;
|
|
49
|
+
let unmatched = false;
|
|
42
50
|
logger_1.logger.info(`Workflow: ${workflow.name}`);
|
|
43
51
|
logger_1.logger.info(`Trigger: ${JSON.stringify(workflow.on)}`);
|
|
44
|
-
const
|
|
52
|
+
const selectedJobs = Object.entries(workflow.jobs).filter(([jobId]) => !options.job || jobId === options.job);
|
|
53
|
+
const jobs = selectedJobs.flatMap(([jobId, job]) => {
|
|
54
|
+
// Matrix combinations, as far as they can be known without running.
|
|
55
|
+
let matrix;
|
|
56
|
+
const rawMatrix = job.strategy?.matrix;
|
|
57
|
+
const atRunTime = rawMatrix !== undefined &&
|
|
58
|
+
RUN_TIME_MATRIX.test(JSON.stringify(rawMatrix));
|
|
59
|
+
if (atRunTime) {
|
|
60
|
+
runTimeMatrix = true;
|
|
61
|
+
}
|
|
62
|
+
else if (rawMatrix !== undefined) {
|
|
63
|
+
const combinations = (0, job_plan_1.expandMatrix)(rawMatrix, (0, expression_1.createContext)());
|
|
64
|
+
combinations.forEach((c) => Object.keys(c).forEach((k) => definedKeys.add(k)));
|
|
65
|
+
const { keys, matches } = (0, job_plan_1.filterMatrix)(combinations, filter);
|
|
66
|
+
if (keys.length > 0 && matches.length === 0) {
|
|
67
|
+
logger_1.logger.warn((0, job_plan_1.noMatrixMatchMessage)(jobId, filter, combinations));
|
|
68
|
+
unmatched = true;
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
matrix = matches.map((m) => (0, job_plan_1.matrixLabel)(m.values));
|
|
72
|
+
}
|
|
45
73
|
logger_1.logger.step(`Job: ${jobId} (${job.name || jobId})`);
|
|
74
|
+
if (atRunTime) {
|
|
75
|
+
logger_1.logger.info(' Matrix: resolved at run time');
|
|
76
|
+
}
|
|
77
|
+
else if (matrix) {
|
|
78
|
+
logger_1.logger.info(` Matrix (${matrix.length} combination(s)):`);
|
|
79
|
+
matrix.forEach((label) => logger_1.logger.info(` ${label}`));
|
|
80
|
+
}
|
|
46
81
|
// Handle reusable workflow calls
|
|
47
82
|
if (job.uses) {
|
|
48
83
|
logger_1.logger.info(` Uses workflow: ${job.uses}`);
|
|
49
84
|
if (job.with) {
|
|
50
85
|
logger_1.logger.info(` With inputs: ${JSON.stringify(job.with)}`);
|
|
51
86
|
}
|
|
52
|
-
return
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
87
|
+
return [
|
|
88
|
+
{
|
|
89
|
+
name: jobId,
|
|
90
|
+
steps: [`Calls reusable workflow: ${job.uses}`],
|
|
91
|
+
...(matrix ? { matrix } : {})
|
|
92
|
+
}
|
|
93
|
+
];
|
|
56
94
|
}
|
|
57
95
|
// Handle regular jobs
|
|
58
96
|
if (job['runs-on']) {
|
|
@@ -74,11 +112,18 @@ function dryRunWorkflow(parsed) {
|
|
|
74
112
|
logger_1.logger.info(stepDesc);
|
|
75
113
|
return stepDesc;
|
|
76
114
|
});
|
|
77
|
-
return {
|
|
78
|
-
name: jobId,
|
|
79
|
-
steps
|
|
80
|
-
};
|
|
115
|
+
return [{ name: jobId, steps, ...(matrix ? { matrix } : {}) }];
|
|
81
116
|
});
|
|
117
|
+
// A key might belong to a matrix only known at run time.
|
|
118
|
+
if (!runTimeMatrix) {
|
|
119
|
+
for (const key of (0, job_plan_1.unknownMatrixKeys)(filter, definedKeys)) {
|
|
120
|
+
logger_1.logger.warn(`--matrix ${key}: no matrix job defines this key; it was ignored`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (unmatched) {
|
|
124
|
+
logger_1.logger.error('Dry run failed: a --matrix filter matched no combination');
|
|
125
|
+
return { success: false, type: 'workflow', jobs };
|
|
126
|
+
}
|
|
82
127
|
logger_1.logger.success('Dry run completed successfully');
|
|
83
128
|
return {
|
|
84
129
|
success: true,
|