@secureci/cli 1.0.1 → 1.2.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 +26 -11
- package/dist/advisories.js +72 -12
- package/dist/cli.js +53 -1
- package/dist/config.js +24 -1
- package/dist/org-scan-script.js +6 -0
- package/dist/org-scan.js +88 -0
- package/dist/parser/pipeline-contract-validator.js +152 -24
- package/dist/runner/action-fetcher.js +6 -1
- package/dist/runner/actions.js +10 -8
- package/dist/runner/checkout.js +225 -9
- package/dist/runner/container-policy.js +32 -0
- package/dist/runner/container-runtime.js +74 -1
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/executor.js +78 -8
- package/dist/runner/expression.js +165 -3
- package/dist/runner/job-container.js +147 -14
- package/dist/runner/job-runtime.js +4 -3
- package/dist/runner/node-action.js +1 -1
- package/dist/runner/runner-env.js +20 -0
- package/dist/runner/scheduler.js +84 -2
- package/dist/runner/steps.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -73,10 +73,10 @@ less install.sh
|
|
|
73
73
|
sh install.sh
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
Set `SECURECI_VERSION=1.0
|
|
76
|
+
Set `SECURECI_VERSION=1.2.0` to install a specific release,
|
|
77
77
|
`SECURECI_INSTALL_DIR` to install somewhere else, and
|
|
78
78
|
`SECURECI_REQUIRE_ATTESTATION=1` to fail unless `gh` verifies build provenance
|
|
79
|
-
(e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.0
|
|
79
|
+
(e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.2.0 sh`).
|
|
80
80
|
If the release's download link fails (GitHub has returned `504` for one while
|
|
81
81
|
its API still served the files), the script fetches the same files through the
|
|
82
82
|
GitHub API instead; the `SHA256SUMS` and provenance checks are unchanged.
|
|
@@ -154,6 +154,9 @@ secureci inventory --check # fail on drift (use in CI)
|
|
|
154
154
|
|
|
155
155
|
# Pin action tags to commit SHAs in place (keeps `# v4` for Dependabot/Renovate)
|
|
156
156
|
secureci pin
|
|
157
|
+
|
|
158
|
+
# Scan every repository in an organisation (JSON results, never source code)
|
|
159
|
+
secureci org scan my-org
|
|
157
160
|
```
|
|
158
161
|
|
|
159
162
|
### Agent skill
|
|
@@ -254,18 +257,30 @@ things to know:
|
|
|
254
257
|
|
|
255
258
|
- **Linux containers only.** `windows-*` / `macos-*` jobs run in a Linux image
|
|
256
259
|
with a warning.
|
|
257
|
-
- **Jobs running in parallel share your working copy
|
|
258
|
-
container, but every job mounts the same directory, so jobs that
|
|
259
|
-
same files (e.g. `node_modules`) can collide;
|
|
260
|
-
|
|
260
|
+
- **Jobs running in parallel share your working copy** by default. Each job has
|
|
261
|
+
its own container, but every job mounts the same directory, so jobs that
|
|
262
|
+
write the same files (e.g. `node_modules`) can collide; pass
|
|
263
|
+
`--isolate-workspace` to give each job its own copy, or `--parallel 1` to run
|
|
264
|
+
jobs one after another.
|
|
261
265
|
- **`actions/checkout` of the current repo doesn't clone** — it uses your working
|
|
262
|
-
copy as-is (so uncommitted changes are visible)
|
|
263
|
-
|
|
266
|
+
copy as-is (so uncommitted changes are visible). A `ref:` is checked out only
|
|
267
|
+
into a `path:`, from your local repository, or fetched from `origin` with
|
|
268
|
+
`--fetch-refs` (opt-in); `fetch-depth:` applies to that clone. Other
|
|
269
|
+
repositories check out for real and need a token.
|
|
264
270
|
- **Artifacts and cache are local stand-ins**, stored under `~/.secureci/`;
|
|
265
271
|
nothing is uploaded to or downloaded from GitHub.
|
|
266
|
-
-
|
|
267
|
-
`--
|
|
268
|
-
|
|
272
|
+
- **`concurrency:`** groups of jobs run one at a time within a run;
|
|
273
|
+
`--concurrency github` also cancels as GitHub does. Workflow-level
|
|
274
|
+
`concurrency:` has no other runs to apply to.
|
|
275
|
+
- **Container `credentials:`** are used only with `--registry-login`, through a
|
|
276
|
+
temporary login removed right after the pull; otherwise use `docker login`.
|
|
277
|
+
- **Not emulated:** deployment `environment:` (protection rules and
|
|
278
|
+
environment-scoped secrets/vars: pass them with `--secret`/`--var`), and OIDC
|
|
279
|
+
tokens for `id-token: write`.
|
|
280
|
+
- **Contract checks** decide `if:` conditions only from the workflow's triggers
|
|
281
|
+
(`github.event_name`, and `github.ref` / `inputs` where the triggers pin
|
|
282
|
+
them) and, without `--contract-recurse all`, don't look inside _remote_
|
|
283
|
+
composite actions / reusable workflows.
|
|
269
284
|
|
|
270
285
|
The exact scope of each is documented in
|
|
271
286
|
[DOCS.md](DOCS.md#contract-limitations-v1-scope).
|
package/dist/advisories.js
CHANGED
|
@@ -61,6 +61,17 @@ exports.VULNERABLE_ACTION_RULE = 'secureci/vulnerable-action';
|
|
|
61
61
|
/** Cached advisories are re-fetched after this long. */
|
|
62
62
|
exports.ADVISORY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
63
63
|
const ADVISORY_TIMEOUT_MS = 15000;
|
|
64
|
+
/** Pages of advisories read per repository (100 each). */
|
|
65
|
+
const MAX_ADVISORY_PAGES = 10;
|
|
66
|
+
/** The `rel="next"` URL of a `Link` header, if any. */
|
|
67
|
+
function nextPageUrl(link) {
|
|
68
|
+
for (const part of (link ?? '').split(',')) {
|
|
69
|
+
const match = part.match(/<([^>]+)>\s*;\s*rel="?next"?/);
|
|
70
|
+
if (match)
|
|
71
|
+
return match[1];
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
64
75
|
const VERSION = /^v?(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
65
76
|
/** `v1.2.3`, `1.2`, `v4`, `2.0.0-rc.1`; null for anything else. */
|
|
66
77
|
function parseVersion(text) {
|
|
@@ -359,7 +370,7 @@ function describeError(error, authenticated) {
|
|
|
359
370
|
* The advisories naming `owner/repo` in the actions ecosystem, cached per
|
|
360
371
|
* repository for `ttlMs` (default 24h). Offline, the cache is used at any
|
|
361
372
|
* age. If the API fails, a stale cache is used when there is one.
|
|
362
|
-
*
|
|
373
|
+
* Pages are followed (on the API's own host) up to 1,000 advisories.
|
|
363
374
|
*/
|
|
364
375
|
async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
365
376
|
if (!validName(owner) || !validName(repo))
|
|
@@ -378,15 +389,20 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
378
389
|
cached = undefined;
|
|
379
390
|
}
|
|
380
391
|
const isStale = cached ? now - cached.fetchedAt >= ttl : false;
|
|
381
|
-
|
|
382
|
-
|
|
392
|
+
const cachedTruncated = cached?.truncated ? { truncated: true } : {};
|
|
393
|
+
if (cached && !isStale) {
|
|
394
|
+
return { advisories: cached.advisories, ...cachedTruncated };
|
|
395
|
+
}
|
|
383
396
|
if (options.offline) {
|
|
384
397
|
return cached
|
|
385
|
-
? { advisories: cached.advisories, stale: true }
|
|
398
|
+
? { advisories: cached.advisories, stale: true, ...cachedTruncated }
|
|
386
399
|
: { error: 'offline and not cached' };
|
|
387
400
|
}
|
|
388
401
|
const httpGet = options.httpGet ??
|
|
389
|
-
((url, headers) => (0, action_fetcher_1.
|
|
402
|
+
((url, headers) => (0, action_fetcher_1.getResponse)(url, headers, ADVISORY_TIMEOUT_MS).then((r) => ({
|
|
403
|
+
body: r.body,
|
|
404
|
+
link: [r.headers.link].flat()[0]
|
|
405
|
+
})));
|
|
390
406
|
const query = new URLSearchParams({
|
|
391
407
|
ecosystem: 'actions',
|
|
392
408
|
affects: `${owner}/${repo}`,
|
|
@@ -400,28 +416,58 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
400
416
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
401
417
|
});
|
|
402
418
|
let token = options.token === undefined ? (0, action_fetcher_1.githubToken)() : options.token;
|
|
403
|
-
|
|
419
|
+
const page = async (pageUrl) => {
|
|
420
|
+
const response = await httpGet(pageUrl, headers(token));
|
|
421
|
+
return typeof response === 'string' ? { body: response } : response;
|
|
422
|
+
};
|
|
404
423
|
try {
|
|
424
|
+
let response;
|
|
405
425
|
try {
|
|
406
|
-
|
|
426
|
+
response = await page(url);
|
|
407
427
|
}
|
|
408
428
|
catch (error) {
|
|
409
429
|
// A rejected token shouldn't block public advisories.
|
|
410
430
|
if (!token || error.status !== 401)
|
|
411
431
|
throw error;
|
|
412
432
|
token = null;
|
|
413
|
-
|
|
433
|
+
response = await page(url);
|
|
434
|
+
}
|
|
435
|
+
const advisories = parseAdvisories(JSON.parse(response.body));
|
|
436
|
+
// Later pages only from the API's own origin, which the token is for.
|
|
437
|
+
const origin = new URL(url).origin;
|
|
438
|
+
const sameOriginNext = () => {
|
|
439
|
+
const next = nextPageUrl(response.link);
|
|
440
|
+
return next && new URL(next, url).origin === origin
|
|
441
|
+
? new URL(next, url).toString()
|
|
442
|
+
: undefined;
|
|
443
|
+
};
|
|
444
|
+
for (let pages = 1; pages < MAX_ADVISORY_PAGES; pages++) {
|
|
445
|
+
const next = sameOriginNext();
|
|
446
|
+
if (!next)
|
|
447
|
+
break;
|
|
448
|
+
response = await page(next);
|
|
449
|
+
advisories.push(...parseAdvisories(JSON.parse(response.body)));
|
|
414
450
|
}
|
|
415
|
-
const
|
|
451
|
+
const truncated = sameOriginNext() !== undefined;
|
|
416
452
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
417
|
-
const entry = {
|
|
453
|
+
const entry = {
|
|
454
|
+
api,
|
|
455
|
+
fetchedAt: now,
|
|
456
|
+
advisories,
|
|
457
|
+
...(truncated ? { truncated } : {})
|
|
458
|
+
};
|
|
418
459
|
fs.writeFileSync(file, JSON.stringify(entry, null, 2));
|
|
419
|
-
return { advisories };
|
|
460
|
+
return { advisories, ...(truncated ? { truncated } : {}) };
|
|
420
461
|
}
|
|
421
462
|
catch (error) {
|
|
422
463
|
const message = describeError(error, !!token);
|
|
423
464
|
return cached
|
|
424
|
-
? {
|
|
465
|
+
? {
|
|
466
|
+
advisories: cached.advisories,
|
|
467
|
+
stale: true,
|
|
468
|
+
error: message,
|
|
469
|
+
...cachedTruncated
|
|
470
|
+
}
|
|
425
471
|
: { error: message };
|
|
426
472
|
}
|
|
427
473
|
}
|
|
@@ -480,6 +526,9 @@ class AdvisoryChecker {
|
|
|
480
526
|
else if (result.stale) {
|
|
481
527
|
report.stale.push(repoName);
|
|
482
528
|
}
|
|
529
|
+
if (result.truncated) {
|
|
530
|
+
(report.truncated ?? (report.truncated = [])).push(repoName);
|
|
531
|
+
}
|
|
483
532
|
}
|
|
484
533
|
const advisories = relevant(target, result.advisories ?? []);
|
|
485
534
|
if (advisories.length === 0)
|
|
@@ -562,6 +611,13 @@ function describeReport(report, failOn) {
|
|
|
562
611
|
text: `Used cached security advisories older than 24h for ${report.stale.join(', ')} (they could not be refreshed).`
|
|
563
612
|
});
|
|
564
613
|
}
|
|
614
|
+
if (report.truncated?.length) {
|
|
615
|
+
lines.push({
|
|
616
|
+
level: 'warn',
|
|
617
|
+
kind: 'note',
|
|
618
|
+
text: `Only the first ${(MAX_ADVISORY_PAGES * 100).toLocaleString('en-US')} security advisories were read for ${report.truncated.join(', ')}; later ones were not checked.`
|
|
619
|
+
});
|
|
620
|
+
}
|
|
565
621
|
return lines;
|
|
566
622
|
}
|
|
567
623
|
/**
|
|
@@ -597,6 +653,10 @@ function mergeReports(reports) {
|
|
|
597
653
|
if (!merged.stale.includes(s))
|
|
598
654
|
merged.stale.push(s);
|
|
599
655
|
}
|
|
656
|
+
for (const t of r.truncated ?? []) {
|
|
657
|
+
if (!merged.truncated?.includes(t))
|
|
658
|
+
(merged.truncated ?? (merged.truncated = [])).push(t);
|
|
659
|
+
}
|
|
600
660
|
}
|
|
601
661
|
return merged;
|
|
602
662
|
}
|
package/dist/cli.js
CHANGED
|
@@ -56,6 +56,11 @@ const env_file_1 = require("./utils/env-file");
|
|
|
56
56
|
const config_1 = require("./config");
|
|
57
57
|
const scheduler_1 = require("./runner/scheduler");
|
|
58
58
|
const version_1 = require("./version");
|
|
59
|
+
const org_scan_1 = require("./org-scan");
|
|
60
|
+
const org_scan_script_1 = require("./org-scan-script");
|
|
61
|
+
const node_binary_1 = require("./runner/node-binary");
|
|
62
|
+
const child_process_1 = require("child_process");
|
|
63
|
+
const os = __importStar(require("os"));
|
|
59
64
|
const fs = __importStar(require("fs"));
|
|
60
65
|
const path = __importStar(require("path"));
|
|
61
66
|
/** Committed at the repo root, like package-lock.json / yarn.lock. */
|
|
@@ -118,6 +123,8 @@ program
|
|
|
118
123
|
.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)')
|
|
119
124
|
.option('--offline', 'Use only cached remote actions and workflows; never download')
|
|
120
125
|
.option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
|
|
126
|
+
.option('--fetch-refs', "Fetch an actions/checkout ref: the local repository doesn't have (e.g. refs/pull/N/merge) from origin into its path: copy. Uses your git credentials, or GITHUB_TOKEN for the server's own host. Off by default: a run doesn't touch the network for checkouts")
|
|
127
|
+
.option('--registry-login', 'Pull job and service container images that set credentials: with them: a temporary login, removed right after the pull, with the password on stdin and masked in logs. Off by default: credentials: are warned about and your own docker login is used')
|
|
121
128
|
.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')
|
|
122
129
|
.choices(['auto', 'docker', 'podman', 'containerd', 'host'])
|
|
123
130
|
.default(defaultContainerRuntime))
|
|
@@ -128,7 +135,10 @@ program
|
|
|
128
135
|
.option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
|
|
129
136
|
.option('--no-network', 'Turn off container network access (on by default)')
|
|
130
137
|
.option('--allow-network', 'Turn on container network access, overriding allow-network: false in the config')
|
|
138
|
+
.option('--isolate-workspace', "Run each job in its own copy of the working copy, removed when the job ends, so parallel jobs can't overwrite each other's files. Config: isolate-workspace")
|
|
139
|
+
.option('--no-isolate-workspace', 'Share the working copy between jobs, overriding isolate-workspace: true in the config')
|
|
131
140
|
.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))
|
|
141
|
+
.option('--concurrency <mode>', 'How jobs sharing a concurrency: group run: serialize (one at a time, all of them) or github (also cancel as GitHub does: one pending per group, newer pending replaces older, cancel-in-progress). Default: serialize. Config: concurrency')
|
|
132
142
|
.action(async (file, options) => {
|
|
133
143
|
if (configError) {
|
|
134
144
|
logger_1.logger.error(configError);
|
|
@@ -184,6 +194,7 @@ program
|
|
|
184
194
|
fromEnv: process.env
|
|
185
195
|
});
|
|
186
196
|
const parallel = (0, scheduler_1.parseParallel)(options.parallel);
|
|
197
|
+
const concurrency = (0, config_1.resolveConcurrency)(options.concurrency, globalConfig);
|
|
187
198
|
const result = await (0, executor_1.executeWorkflow)(file, {
|
|
188
199
|
dryRun: false,
|
|
189
200
|
inputs,
|
|
@@ -195,6 +206,8 @@ program
|
|
|
195
206
|
job: options.job,
|
|
196
207
|
matrix,
|
|
197
208
|
localCheckout: options.localCheckout,
|
|
209
|
+
fetchRefs: options.fetchRefs === true,
|
|
210
|
+
registryLogin: options.registryLogin === true,
|
|
198
211
|
offline: options.offline,
|
|
199
212
|
verbose: program.opts().verbose,
|
|
200
213
|
containerRuntime: options.containerRuntime,
|
|
@@ -206,7 +219,9 @@ program
|
|
|
206
219
|
: undefined,
|
|
207
220
|
containerMemoryLimit: options.containerMemory,
|
|
208
221
|
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig),
|
|
209
|
-
parallel
|
|
222
|
+
parallel,
|
|
223
|
+
concurrency,
|
|
224
|
+
isolateWorkspace: (0, config_1.resolveIsolateWorkspace)(options.isolateWorkspace, globalConfig)
|
|
210
225
|
});
|
|
211
226
|
if (!result.success) {
|
|
212
227
|
process.exit(1);
|
|
@@ -994,6 +1009,43 @@ configCmd
|
|
|
994
1009
|
(0, config_1.saveConfig)(config);
|
|
995
1010
|
logger_1.logger.success(`Unset ${key} in ${(0, config_1.getConfigFilePath)()}`);
|
|
996
1011
|
});
|
|
1012
|
+
const orgCmd = program
|
|
1013
|
+
.command('org')
|
|
1014
|
+
.description('Organisation-wide commands (for a SecureCI supply-chain assessment)');
|
|
1015
|
+
orgCmd
|
|
1016
|
+
.command('scan')
|
|
1017
|
+
.description('Scan the GitHub Actions workflows of every repository in an organisation and pack the JSON results for a SecureCI supply-chain assessment (runs org-scan.sh; source code is never downloaded)')
|
|
1018
|
+
.argument('[org]', 'GitHub organisation (or user) to scan')
|
|
1019
|
+
.option('--repos <file>', 'Scan only the repositories in this file (one "name" or "owner/name" per line)')
|
|
1020
|
+
.option('--out <dir>', 'Output directory (default: ./secureci-org-scan-<org>)')
|
|
1021
|
+
.option('-c, --contract <file>', 'Also check workflows against this pipeline contract (local file or owner/repo/path@ref)')
|
|
1022
|
+
.option('--include-forks', 'Also scan forked repositories')
|
|
1023
|
+
.option('--print-script', 'Print the script this command runs, to review it, and exit')
|
|
1024
|
+
.action(async (org, options) => {
|
|
1025
|
+
if (options.printScript) {
|
|
1026
|
+
process.stdout.write(org_scan_script_1.ORG_SCAN_SCRIPT);
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (!org) {
|
|
1030
|
+
logger_1.logger.error('Missing <org>: secureci org scan <org>');
|
|
1031
|
+
process.exit(2);
|
|
1032
|
+
}
|
|
1033
|
+
try {
|
|
1034
|
+
const code = await (0, org_scan_1.runOrgScan)(org, options, {
|
|
1035
|
+
spawn: (command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions),
|
|
1036
|
+
tmpdir: os.tmpdir,
|
|
1037
|
+
sea: (0, node_binary_1.isSea)(),
|
|
1038
|
+
execPath: process.execPath,
|
|
1039
|
+
entry: process.argv[1],
|
|
1040
|
+
env: process.env
|
|
1041
|
+
});
|
|
1042
|
+
process.exit(code);
|
|
1043
|
+
}
|
|
1044
|
+
catch (error) {
|
|
1045
|
+
logger_1.logger.error(error instanceof Error ? error.message : String(error));
|
|
1046
|
+
process.exit(1);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
997
1049
|
const cacheCmd = program
|
|
998
1050
|
.command('cache')
|
|
999
1051
|
.description('Manage the cache of downloaded remote actions');
|
package/dist/config.js
CHANGED
|
@@ -44,6 +44,8 @@ exports.getConfigValue = getConfigValue;
|
|
|
44
44
|
exports.setConfigValue = setConfigValue;
|
|
45
45
|
exports.unsetConfigValue = unsetConfigValue;
|
|
46
46
|
exports.parseConfigValue = parseConfigValue;
|
|
47
|
+
exports.resolveIsolateWorkspace = resolveIsolateWorkspace;
|
|
48
|
+
exports.resolveConcurrency = resolveConcurrency;
|
|
47
49
|
exports.resolveNetworkDisabled = resolveNetworkDisabled;
|
|
48
50
|
const fs = __importStar(require("fs"));
|
|
49
51
|
const path = __importStar(require("path"));
|
|
@@ -114,6 +116,8 @@ exports.CONFIG_KEYS = [
|
|
|
114
116
|
'container-memory',
|
|
115
117
|
'allow-network',
|
|
116
118
|
'parallel',
|
|
119
|
+
'isolate-workspace',
|
|
120
|
+
'concurrency',
|
|
117
121
|
'env-file',
|
|
118
122
|
'secret-file',
|
|
119
123
|
'input-file',
|
|
@@ -177,12 +181,15 @@ function parseConfigValue(key, rawValue) {
|
|
|
177
181
|
return num;
|
|
178
182
|
}
|
|
179
183
|
case 'allow-network':
|
|
184
|
+
case 'isolate-workspace':
|
|
180
185
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
181
|
-
throw new Error(`Invalid value "${rawValue}" for
|
|
186
|
+
throw new Error(`Invalid value "${rawValue}" for ${key}. Must be "true" or "false".`);
|
|
182
187
|
}
|
|
183
188
|
return rawValue === 'true';
|
|
184
189
|
case 'parallel':
|
|
185
190
|
return (0, scheduler_1.parseParallel)(rawValue);
|
|
191
|
+
case 'concurrency':
|
|
192
|
+
return (0, scheduler_1.parseConcurrencyMode)(rawValue);
|
|
186
193
|
case 'container-image':
|
|
187
194
|
case 'container-memory':
|
|
188
195
|
return rawValue;
|
|
@@ -196,6 +203,22 @@ function parseConfigValue(key, rawValue) {
|
|
|
196
203
|
return rawValue;
|
|
197
204
|
}
|
|
198
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Whether each job runs in its own copy of the working copy:
|
|
208
|
+
* `--isolate-workspace` / `--no-isolate-workspace`, else
|
|
209
|
+
* `isolate-workspace` in the config file, else no.
|
|
210
|
+
*/
|
|
211
|
+
function resolveIsolateWorkspace(flag, config = loadConfig()) {
|
|
212
|
+
return flag ?? config['isolate-workspace'] === true;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* How job `concurrency:` groups apply: `--concurrency`, else
|
|
216
|
+
* `concurrency` in the config file, else serialize.
|
|
217
|
+
*/
|
|
218
|
+
function resolveConcurrency(flag, config = loadConfig()) {
|
|
219
|
+
const value = flag ?? config.concurrency;
|
|
220
|
+
return value === undefined ? 'serialize' : (0, scheduler_1.parseConcurrencyMode)(value);
|
|
221
|
+
}
|
|
199
222
|
/**
|
|
200
223
|
* Whether container networking should be disabled. Networking is on by
|
|
201
224
|
* default (matching GitHub-hosted runners); `--no-network` turns it off and
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ORG_SCAN_SCRIPT = void 0;
|
|
4
|
+
// Generated from org-scan.sh by scripts/gen-org-scan-script.mjs; don't edit.
|
|
5
|
+
exports.ORG_SCAN_SCRIPT = "#!/usr/bin/env bash\n# Scan every GitHub Actions workflow in an organisation with the SecureCI CLI,\n# for a SecureCI supply-chain assessment.\n#\n# bash org-scan.sh <org>\n#\n# Read it before you run it (it's short, on purpose):\n#\n# curl -fsSL https://secure-ci.dev/org-scan.sh -o org-scan.sh\n# less org-scan.sh\n# GITHUB_TOKEN=... bash org-scan.sh my-org\n#\n# or, with the CLI installed: `secureci org scan my-org` runs this same script\n# (`secureci org scan --print-script` prints it).\n#\n# What it does, for each repository in the organisation (or in --repos):\n#\n# 1. Clones only the .github/ directory (a shallow, sparse clone: your\n# application code is never downloaded) into a temporary directory.\n# 2. Runs `secureci validate --format json` on its workflows, and\n# `secureci inventory --dry-run` on each workflow.\n# 3. Saves that JSON under the output directory and deletes the clone.\n#\n# Then it writes manifest.json (what was scanned, when, with which versions)\n# and packs everything into <output>.tar.gz. That archive is what you email to\n# hello@secure-ci.dev (if your policy doesn't allow email, tell us and we'll\n# agree another way).\n# It holds only the JSON results (repository and workflow names, the actions\n# they use, and the findings), never your source code. Look inside before you\n# send it: `tar -tzf <output>.tar.gz`.\n#\n# Archived repositories are skipped, and so are forks unless --include-forks.\n#\n# Your token stays on this machine: it is passed to curl on stdin and to git\n# through environment variables, never on a command line, and it is not\n# written to any output file.\n#\n# Requirements: bash, git 2.31+, curl, base64, tar, and the SecureCI CLI\n# (https://secure-ci.dev/install.sh or `npm install -g @secureci/cli`).\n#\n# Environment variables:\n# GITHUB_TOKEN or GH_TOKEN token that can read the organisation's\n# repositories (contents: read, metadata: read).\n# Needed for private repositories, and it raises\n# API rate limits. The CLI also uses it to resolve\n# action tags and look up security advisories.\n# GITHUB_SERVER_URL default https://github.com; set it for GitHub\n# Enterprise Server, e.g. https://github.example.com\n# GITHUB_API_URL default https://api.github.com, or\n# $GITHUB_SERVER_URL/api/v3 for Enterprise Server\n# SECURECI the secureci executable (default: secureci on PATH)\n#\n# Exit status: 0 when the scan finished (some repositories may have failed:\n# see the summary and errors.log), 1 on an error, 2 on a usage error.\n\nset -euo pipefail\n\nSCAN_SCRIPT_VERSION=1\nINSTALL_URL=\"https://secure-ci.dev/install.sh\"\nNAME_RE='^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'\n\nusage() {\n cat <<'EOF'\nUsage: org-scan.sh [options] <org>\n\nScan every GitHub Actions workflow in <org> with the SecureCI CLI and pack the\nJSON results into <output>.tar.gz for a SecureCI supply-chain assessment.\n\nOptions:\n --repos <file> scan only these repositories: one per line, \"name\" or\n \"owner/name\"; blank lines and # comments are ignored\n --out <dir> output directory (default: ./secureci-org-scan-<org>)\n --contract <file> also check workflows against this pipeline contract\n (a local file, or owner/repo/path@ref)\n --include-forks also scan forked repositories\n -h, --help show this help\n\nEnvironment: GITHUB_TOKEN (or GH_TOKEN), GITHUB_SERVER_URL, GITHUB_API_URL,\nSECURECI. See the comments at the top of this script.\nEOF\n}\n\nsay() {\n printf 'secureci-org-scan: %s\\n' \"$*\" >&2\n}\n\ndie() {\n say \"error: $*\"\n exit 1\n}\n\nusage_error() {\n say \"$*\"\n usage >&2\n exit 2\n}\n\nhas() {\n command -v \"$1\" >/dev/null 2>&1\n}\n\n# JSON string literal for $1.\njson_str() {\n local s=$1\n s=${s//\\\\/\\\\\\\\}\n s=${s//\\\"/\\\\\\\"}\n s=${s//$'\\t'/\\\\t}\n s=${s//$'\\n'/\\\\n}\n printf '\"%s\"' \"$s\"\n}\n\n# Run the secureci CLI (SECURECI_ENTRY is set when SECURECI is node itself).\nsecureci() {\n if [ -n \"${SECURECI_ENTRY:-}\" ]; then\n \"$SECURECI\" \"$SECURECI_ENTRY\" \"$@\"\n else\n \"$SECURECI\" \"$@\"\n fi\n}\n\n# GET an API URL into a file; prints the HTTP status. The token goes to curl\n# on stdin (--config -), so it never shows up in the process list.\napi_get() {\n local url=$1 out=$2\n {\n printf 'url = \"%s\"\\n' \"$url\"\n printf 'header = \"Accept: application/vnd.github+json\"\\n'\n printf 'header = \"X-GitHub-Api-Version: 2022-11-28\"\\n'\n if [ -n \"$TOKEN\" ]; then\n printf 'header = \"Authorization: Bearer %s\"\\n' \"$TOKEN\"\n fi\n } | curl --silent --show-error --location --config - \\\n --output \"$out\" --write-out '%{http_code}' || echo 000\n}\n\n# Print owner/name for each repository in a page of the repos API, skipping\n# archived repositories and (unless --include-forks) forks. Splitting on\n# commas puts each top-level key on its own line; keys inside strings are\n# escaped (\\\"full_name\\\") and can't match the anchored patterns.\nparse_repos() {\n tr ',' '\\n' <\"$1\" | awk -v forks=\"$INCLUDE_FORKS\" '\n function flush() {\n if (name != \"\" && archived != \"true\" && (forks == \"1\" || fork != \"true\"))\n print name\n name = \"\"; fork = \"\"; archived = \"\"\n }\n /^[[:space:]{[]*\"full_name\"[[:space:]]*:/ {\n flush()\n line = $0\n sub(/^[^:]*:[[:space:]]*\"/, \"\", line)\n sub(/\".*$/, \"\", line)\n name = line\n next\n }\n /^[[:space:]]*\"fork\"[[:space:]]*:/ {\n fork = ($0 ~ /true/) ? \"true\" : \"false\"; next\n }\n /^[[:space:]]*\"archived\"[[:space:]]*:/ {\n archived = ($0 ~ /true/) ? \"true\" : \"false\"; next\n }\n END { flush() }\n '\n}\n\n# List the organisation's repositories over the API (or the user's, when\n# <org> is a user account), one owner/name per line.\nlist_repos() {\n local base=\"$API_URL/orgs/$ORG/repos?per_page=100\" page=1 status\n local body=\"$WORK/page.json\"\n while :; do\n status=$(api_get \"$base&page=$page\" \"$body\")\n if [ \"$status\" = 404 ] && [ \"$page\" = 1 ] && [ \"${base#*/users/}\" = \"$base\" ]; then\n base=\"$API_URL/users/$ORG/repos?per_page=100&type=owner\"\n continue\n fi\n [ \"$status\" = 200 ] || die \"listing repositories for $ORG failed: HTTP $status from ${base%%\\?*} (check GITHUB_TOKEN and GITHUB_API_URL)\"\n # An empty page ends the listing.\n if ! grep -q '\"full_name\"' \"$body\"; then\n break\n fi\n parse_repos \"$body\"\n page=$((page + 1))\n done\n}\n\n# Repositories from a --repos file, one owner/name per line.\nread_repo_file() {\n local line\n while IFS= read -r line || [ -n \"$line\" ]; do\n line=${line%%#*}\n line=$(printf '%s' \"$line\" | tr -d '[:space:]')\n [ -n \"$line\" ] || continue\n case \"$line\" in\n */*) printf '%s\\n' \"$line\" ;;\n *) printf '%s/%s\\n' \"$ORG\" \"$line\" ;;\n esac\n done <\"$1\"\n}\n\n# git with the token as an HTTP header scoped to the server, passed through\n# GIT_CONFIG_* environment variables rather than arguments or a URL.\ngit_auth() {\n if [ -n \"$TOKEN\" ]; then\n local basic\n basic=$(printf 'x-access-token:%s' \"$TOKEN\" | base64 | tr -d '\\n')\n GIT_CONFIG_COUNT=1 \\\n GIT_CONFIG_KEY_0=\"http.$SERVER_URL/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"AUTHORIZATION: basic $basic\" \\\n GIT_TERMINAL_PROMPT=0 git \"$@\"\n else\n GIT_TERMINAL_PROMPT=0 git \"$@\"\n fi\n}\n\n# Clone .github/ of one repository and scan it. Sets STATUS and WORKFLOWS.\nscan_repo() {\n local full=$1 dir=\"$WORK/clone\" dest wf files=()\n STATUS=clone-failed\n WORKFLOWS=0\n rm -rf \"$dir\"\n if ! git_auth clone --quiet --depth 1 --filter=blob:none --sparse --no-tags \\\n \"$SERVER_URL/$full.git\" \"$dir\" 2>>\"$OUT/errors.log\" ||\n ! git -C \"$dir\" sparse-checkout set .github 2>>\"$OUT/errors.log\"; then\n echo \"[$full] clone failed\" >>\"$OUT/errors.log\"\n return\n fi\n\n for wf in \"$dir\"/.github/workflows/*.yml \"$dir\"/.github/workflows/*.yaml; do\n [ -f \"$wf\" ] && files+=(\".github/workflows/${wf##*/}\")\n done\n WORKFLOWS=${#files[@]}\n if [ \"$WORKFLOWS\" = 0 ]; then\n STATUS=no-workflows\n rm -rf \"$dir\"\n return\n fi\n\n dest=\"$OUT/repos/${full%%/*}__${full#*/}\"\n mkdir -p \"$dest/inventory\"\n echo \"[$full] secureci output:\" >>\"$OUT/errors.log\"\n # validate exits 1 when it finds errors; the JSON is still the result.\n (cd \"$dir\" && secureci validate --format json ${CONTRACT_ARG:+--contract \"$CONTRACT_ARG\"} \"${files[@]}\") \\\n >\"$dest/validate.json\" 2>>\"$OUT/errors.log\" || true\n for wf in \"${files[@]}\"; do\n (cd \"$dir\" && secureci inventory --dry-run \"$wf\") \\\n >\"$dest/inventory/${wf##*/}.json\" 2>>\"$OUT/errors.log\" || true\n done\n STATUS=scanned\n rm -rf \"$dir\"\n}\n\nmain() {\n ORG=\"\"\n OUT=\"\"\n REPO_FILE=\"\"\n CONTRACT=\"\"\n INCLUDE_FORKS=0\n while [ $# -gt 0 ]; do\n case \"$1\" in\n -h | --help) usage; exit 0 ;;\n --repos) [ $# -ge 2 ] || usage_error \"--repos needs a file\"; REPO_FILE=$2; shift 2 ;;\n --out) [ $# -ge 2 ] || usage_error \"--out needs a directory\"; OUT=$2; shift 2 ;;\n --contract) [ $# -ge 2 ] || usage_error \"--contract needs a file\"; CONTRACT=$2; shift 2 ;;\n --include-forks) INCLUDE_FORKS=1; shift ;;\n -*) usage_error \"unknown option: $1\" ;;\n *)\n [ -z \"$ORG\" ] || usage_error \"unexpected argument: $1\"\n ORG=$1; shift ;;\n esac\n done\n [ -n \"$ORG\" ] || usage_error \"missing <org>\"\n [[ $ORG =~ ^[A-Za-z0-9._-]+$ ]] || usage_error \"not an organisation name: $ORG\"\n\n SECURECI=${SECURECI:-secureci}\n for tool in git curl base64 tar; do\n has \"$tool\" || die \"$tool is required\"\n done\n has \"$SECURECI\" || die \"the SecureCI CLI ($SECURECI) was not found. Install it: curl -fsSL $INSTALL_URL | sh (or npm install -g @secureci/cli)\"\n SECURECI_VERSION=$(secureci --version 2>/dev/null) || die \"could not run $SECURECI --version\"\n\n TOKEN=${GITHUB_TOKEN:-${GH_TOKEN:-}}\n SERVER_URL=${GITHUB_SERVER_URL:-https://github.com}\n SERVER_URL=${SERVER_URL%/}\n if [ -z \"${GITHUB_API_URL:-}\" ]; then\n if [ \"$SERVER_URL\" = https://github.com ]; then\n API_URL=https://api.github.com\n else\n API_URL=\"$SERVER_URL/api/v3\"\n fi\n else\n API_URL=${GITHUB_API_URL%/}\n fi\n\n # A local contract is resolved now, because secureci runs inside each clone.\n CONTRACT_ARG=$CONTRACT\n if [ -n \"$CONTRACT\" ] && [ -f \"$CONTRACT\" ]; then\n CONTRACT_ARG=\"$(cd \"$(dirname \"$CONTRACT\")\" && pwd -P)/$(basename \"$CONTRACT\")\"\n fi\n if [ -n \"$REPO_FILE\" ] && [ ! -f \"$REPO_FILE\" ]; then\n die \"--repos file not found: $REPO_FILE\"\n fi\n\n OUT=${OUT:-secureci-org-scan-$ORG}\n OUT=${OUT%/}\n [ ! -e \"$OUT\" ] || die \"$OUT already exists; remove it or pass --out <dir>\"\n [ ! -e \"$OUT.tar.gz\" ] || die \"$OUT.tar.gz already exists; remove it or pass --out <dir>\"\n WORK=$(mktemp -d \"${TMPDIR:-/tmp}/secureci-org-scan.XXXXXX\")\n trap 'rm -rf \"$WORK\"' EXIT\n\n local repos=() full\n if [ -n \"$REPO_FILE\" ]; then\n while IFS= read -r full; do repos+=(\"$full\"); done < <(read_repo_file \"$REPO_FILE\")\n else\n say \"listing repositories in $ORG ($API_URL)\"\n list_repos >\"$WORK/repos.txt\"\n while IFS= read -r full; do repos+=(\"$full\"); done <\"$WORK/repos.txt\"\n fi\n\n local valid=()\n for full in ${repos[@]+\"${repos[@]}\"}; do\n if [[ $full =~ $NAME_RE ]] && [[ $full != *..* ]]; then\n valid+=(\"$full\")\n else\n say \"skipping unexpected repository name: $full\"\n fi\n done\n [ ${#valid[@]} -gt 0 ] || die \"no repositories to scan\"\n\n mkdir -p \"$OUT/repos\"\n : >\"$OUT/errors.log\"\n OUT=$(cd \"$OUT\" && pwd -P)\n\n local total=${#valid[@]} i=0 scanned=0 empty=0 failed=0 entries=\"\"\n for full in \"${valid[@]}\"; do\n i=$((i + 1))\n scan_repo \"$full\"\n case \"$STATUS\" in\n scanned) scanned=$((scanned + 1)) ;;\n no-workflows) empty=$((empty + 1)) ;;\n *) failed=$((failed + 1)) ;;\n esac\n say \"[$i/$total] $full: $STATUS ($WORKFLOWS workflows)\"\n entries+=\"${entries:+,}\n {\\\"name\\\": $(json_str \"$full\"), \\\"status\\\": \\\"$STATUS\\\", \\\"workflows\\\": $WORKFLOWS}\"\n done\n\n local contract_json=null\n [ -z \"$CONTRACT\" ] || contract_json=$(json_str \"$CONTRACT\")\n cat >\"$OUT/manifest.json\" <<EOF\n{\n \"tool\": \"secureci-org-scan\",\n \"scriptVersion\": $SCAN_SCRIPT_VERSION,\n \"secureciVersion\": $(json_str \"$SECURECI_VERSION\"),\n \"org\": $(json_str \"$ORG\"),\n \"server\": $(json_str \"$SERVER_URL\"),\n \"scannedAt\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\n \"contract\": $contract_json,\n \"repos\": [$entries\n ]\n}\nEOF\n\n tar -czf \"$OUT.tar.gz\" -C \"$(dirname \"$OUT\")\" \"$(basename \"$OUT\")\"\n\n cat <<EOF\n\nScanned $total repositories in $ORG: $scanned scanned, $empty without workflows, $failed failed.\n\nResults: $OUT/\nArchive: $OUT.tar.gz\n\nThe archive holds only JSON results (repository and workflow names, the\nactions they use and the findings) plus errors.log, never source code.\nCheck it before you send it: tar -tzf $OUT.tar.gz\n\nEmail it to hello@secure-ci.dev. If your policy doesn't allow sending it by\nemail, write to us there and we'll agree another way.\nEOF\n}\n\nmain \"$@\"\n";
|
|
6
|
+
//# sourceMappingURL=org-scan-script.js.map
|
package/dist/org-scan.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.orgScanArgs = orgScanArgs;
|
|
37
|
+
exports.orgScanEnv = orgScanEnv;
|
|
38
|
+
exports.runOrgScan = runOrgScan;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const org_scan_script_1 = require("./org-scan-script");
|
|
42
|
+
/** The script's arguments for `org` and the command's options. */
|
|
43
|
+
function orgScanArgs(org, options) {
|
|
44
|
+
const args = [];
|
|
45
|
+
if (options.repos)
|
|
46
|
+
args.push('--repos', options.repos);
|
|
47
|
+
if (options.out)
|
|
48
|
+
args.push('--out', options.out);
|
|
49
|
+
if (options.contract)
|
|
50
|
+
args.push('--contract', options.contract);
|
|
51
|
+
if (options.includeForks)
|
|
52
|
+
args.push('--include-forks');
|
|
53
|
+
return [...args, org];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* How the script should run this same CLI: the standalone binary directly,
|
|
57
|
+
* or node with the CLI's entry file when installed with npm.
|
|
58
|
+
*/
|
|
59
|
+
function orgScanEnv(self) {
|
|
60
|
+
if (self.sea || !self.entry)
|
|
61
|
+
return { SECURECI: self.execPath };
|
|
62
|
+
return { SECURECI: self.execPath, SECURECI_ENTRY: self.entry };
|
|
63
|
+
}
|
|
64
|
+
/** Run the scan; resolves with the script's exit status. */
|
|
65
|
+
async function runOrgScan(org, options, deps) {
|
|
66
|
+
const dir = fs.mkdtempSync(path.join(deps.tmpdir(), 'secureci-org-scan-'));
|
|
67
|
+
const script = path.join(dir, 'org-scan.sh');
|
|
68
|
+
try {
|
|
69
|
+
fs.writeFileSync(script, org_scan_script_1.ORG_SCAN_SCRIPT, { mode: 0o700 });
|
|
70
|
+
const env = { ...deps.env, ...orgScanEnv(deps) };
|
|
71
|
+
return await new Promise((resolve, reject) => {
|
|
72
|
+
const child = deps.spawn('bash', [script, ...orgScanArgs(org, options)], {
|
|
73
|
+
stdio: 'inherit',
|
|
74
|
+
env
|
|
75
|
+
});
|
|
76
|
+
child.on('error', (error) => {
|
|
77
|
+
reject(error.code === 'ENOENT'
|
|
78
|
+
? new Error('`secureci org scan` needs bash. On Windows, run it from Git Bash or WSL.')
|
|
79
|
+
: error);
|
|
80
|
+
});
|
|
81
|
+
child.on('close', (code) => resolve(code ?? 1));
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
//# sourceMappingURL=org-scan.js.map
|