@secureci/cli 1.0.1 → 1.1.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 +20 -11
- package/dist/advisories.js +33 -6
- package/dist/cli.js +46 -1
- package/dist/config.js +12 -1
- package/dist/org-scan-script.js +6 -0
- package/dist/org-scan.js +88 -0
- package/dist/parser/pipeline-contract-validator.js +56 -25
- package/dist/runner/action-fetcher.js +6 -1
- package/dist/runner/actions.js +7 -7
- package/dist/runner/checkout.js +124 -8
- package/dist/runner/container-policy.js +32 -0
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/executor.js +28 -6
- package/dist/runner/expression.js +125 -3
- package/dist/runner/job-container.js +57 -11
- 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/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.1.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.1.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,24 @@ 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:` your local
|
|
267
|
+
repository has is checked out only into a `path:`; other refs and
|
|
268
|
+
`fetch-depth:` don't change what you get. Other repositories check out for
|
|
269
|
+
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
|
+
- **Not emulated:** `concurrency:` (noted at run start), deployment
|
|
273
|
+
`environment:` (protection rules and environment-scoped secrets/vars: pass
|
|
274
|
+
them with `--secret`/`--var`), and OIDC tokens for `id-token: write`.
|
|
275
|
+
- **Contract checks** decide `if:` conditions only from the workflow's triggers
|
|
276
|
+
(`github.event_name`) and, without `--contract-recurse all`, don't look inside
|
|
277
|
+
_remote_ composite actions / reusable workflows.
|
|
269
278
|
|
|
270
279
|
The exact scope of each is documented in
|
|
271
280
|
[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))
|
|
@@ -386,7 +397,10 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
386
397
|
: { error: 'offline and not cached' };
|
|
387
398
|
}
|
|
388
399
|
const httpGet = options.httpGet ??
|
|
389
|
-
((url, headers) => (0, action_fetcher_1.
|
|
400
|
+
((url, headers) => (0, action_fetcher_1.getResponse)(url, headers, ADVISORY_TIMEOUT_MS).then((r) => ({
|
|
401
|
+
body: r.body,
|
|
402
|
+
link: [r.headers.link].flat()[0]
|
|
403
|
+
})));
|
|
390
404
|
const query = new URLSearchParams({
|
|
391
405
|
ecosystem: 'actions',
|
|
392
406
|
affects: `${owner}/${repo}`,
|
|
@@ -400,19 +414,32 @@ async function fetchRepoAdvisories(owner, repo, options = {}) {
|
|
|
400
414
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
401
415
|
});
|
|
402
416
|
let token = options.token === undefined ? (0, action_fetcher_1.githubToken)() : options.token;
|
|
403
|
-
|
|
417
|
+
const page = async (pageUrl) => {
|
|
418
|
+
const response = await httpGet(pageUrl, headers(token));
|
|
419
|
+
return typeof response === 'string' ? { body: response } : response;
|
|
420
|
+
};
|
|
404
421
|
try {
|
|
422
|
+
let response;
|
|
405
423
|
try {
|
|
406
|
-
|
|
424
|
+
response = await page(url);
|
|
407
425
|
}
|
|
408
426
|
catch (error) {
|
|
409
427
|
// A rejected token shouldn't block public advisories.
|
|
410
428
|
if (!token || error.status !== 401)
|
|
411
429
|
throw error;
|
|
412
430
|
token = null;
|
|
413
|
-
|
|
431
|
+
response = await page(url);
|
|
432
|
+
}
|
|
433
|
+
const advisories = parseAdvisories(JSON.parse(response.body));
|
|
434
|
+
// Later pages only from the API's own origin, which the token is for.
|
|
435
|
+
const origin = new URL(url).origin;
|
|
436
|
+
for (let pages = 1; pages < MAX_ADVISORY_PAGES; pages++) {
|
|
437
|
+
const next = nextPageUrl(response.link);
|
|
438
|
+
if (!next || new URL(next, url).origin !== origin)
|
|
439
|
+
break;
|
|
440
|
+
response = await page(new URL(next, url).toString());
|
|
441
|
+
advisories.push(...parseAdvisories(JSON.parse(response.body)));
|
|
414
442
|
}
|
|
415
|
-
const advisories = parseAdvisories(JSON.parse(body));
|
|
416
443
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
417
444
|
const entry = { api, fetchedAt: now, advisories };
|
|
418
445
|
fs.writeFileSync(file, JSON.stringify(entry, null, 2));
|
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. */
|
|
@@ -128,6 +133,8 @@ program
|
|
|
128
133
|
.option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
|
|
129
134
|
.option('--no-network', 'Turn off container network access (on by default)')
|
|
130
135
|
.option('--allow-network', 'Turn on container network access, overriding allow-network: false in the config')
|
|
136
|
+
.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")
|
|
137
|
+
.option('--no-isolate-workspace', 'Share the working copy between jobs, overriding isolate-workspace: true in the config')
|
|
131
138
|
.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))
|
|
132
139
|
.action(async (file, options) => {
|
|
133
140
|
if (configError) {
|
|
@@ -206,7 +213,8 @@ program
|
|
|
206
213
|
: undefined,
|
|
207
214
|
containerMemoryLimit: options.containerMemory,
|
|
208
215
|
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig),
|
|
209
|
-
parallel
|
|
216
|
+
parallel,
|
|
217
|
+
isolateWorkspace: (0, config_1.resolveIsolateWorkspace)(options.isolateWorkspace, globalConfig)
|
|
210
218
|
});
|
|
211
219
|
if (!result.success) {
|
|
212
220
|
process.exit(1);
|
|
@@ -994,6 +1002,43 @@ configCmd
|
|
|
994
1002
|
(0, config_1.saveConfig)(config);
|
|
995
1003
|
logger_1.logger.success(`Unset ${key} in ${(0, config_1.getConfigFilePath)()}`);
|
|
996
1004
|
});
|
|
1005
|
+
const orgCmd = program
|
|
1006
|
+
.command('org')
|
|
1007
|
+
.description('Organisation-wide commands (for a SecureCI supply-chain assessment)');
|
|
1008
|
+
orgCmd
|
|
1009
|
+
.command('scan')
|
|
1010
|
+
.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)')
|
|
1011
|
+
.argument('[org]', 'GitHub organisation (or user) to scan')
|
|
1012
|
+
.option('--repos <file>', 'Scan only the repositories in this file (one "name" or "owner/name" per line)')
|
|
1013
|
+
.option('--out <dir>', 'Output directory (default: ./secureci-org-scan-<org>)')
|
|
1014
|
+
.option('-c, --contract <file>', 'Also check workflows against this pipeline contract (local file or owner/repo/path@ref)')
|
|
1015
|
+
.option('--include-forks', 'Also scan forked repositories')
|
|
1016
|
+
.option('--print-script', 'Print the script this command runs, to review it, and exit')
|
|
1017
|
+
.action(async (org, options) => {
|
|
1018
|
+
if (options.printScript) {
|
|
1019
|
+
process.stdout.write(org_scan_script_1.ORG_SCAN_SCRIPT);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
if (!org) {
|
|
1023
|
+
logger_1.logger.error('Missing <org>: secureci org scan <org>');
|
|
1024
|
+
process.exit(2);
|
|
1025
|
+
}
|
|
1026
|
+
try {
|
|
1027
|
+
const code = await (0, org_scan_1.runOrgScan)(org, options, {
|
|
1028
|
+
spawn: (command, args, spawnOptions) => (0, child_process_1.spawn)(command, args, spawnOptions),
|
|
1029
|
+
tmpdir: os.tmpdir,
|
|
1030
|
+
sea: (0, node_binary_1.isSea)(),
|
|
1031
|
+
execPath: process.execPath,
|
|
1032
|
+
entry: process.argv[1],
|
|
1033
|
+
env: process.env
|
|
1034
|
+
});
|
|
1035
|
+
process.exit(code);
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
logger_1.logger.error(error instanceof Error ? error.message : String(error));
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
});
|
|
997
1042
|
const cacheCmd = program
|
|
998
1043
|
.command('cache')
|
|
999
1044
|
.description('Manage the cache of downloaded remote actions');
|
package/dist/config.js
CHANGED
|
@@ -44,6 +44,7 @@ exports.getConfigValue = getConfigValue;
|
|
|
44
44
|
exports.setConfigValue = setConfigValue;
|
|
45
45
|
exports.unsetConfigValue = unsetConfigValue;
|
|
46
46
|
exports.parseConfigValue = parseConfigValue;
|
|
47
|
+
exports.resolveIsolateWorkspace = resolveIsolateWorkspace;
|
|
47
48
|
exports.resolveNetworkDisabled = resolveNetworkDisabled;
|
|
48
49
|
const fs = __importStar(require("fs"));
|
|
49
50
|
const path = __importStar(require("path"));
|
|
@@ -114,6 +115,7 @@ exports.CONFIG_KEYS = [
|
|
|
114
115
|
'container-memory',
|
|
115
116
|
'allow-network',
|
|
116
117
|
'parallel',
|
|
118
|
+
'isolate-workspace',
|
|
117
119
|
'env-file',
|
|
118
120
|
'secret-file',
|
|
119
121
|
'input-file',
|
|
@@ -177,8 +179,9 @@ function parseConfigValue(key, rawValue) {
|
|
|
177
179
|
return num;
|
|
178
180
|
}
|
|
179
181
|
case 'allow-network':
|
|
182
|
+
case 'isolate-workspace':
|
|
180
183
|
if (rawValue !== 'true' && rawValue !== 'false') {
|
|
181
|
-
throw new Error(`Invalid value "${rawValue}" for
|
|
184
|
+
throw new Error(`Invalid value "${rawValue}" for ${key}. Must be "true" or "false".`);
|
|
182
185
|
}
|
|
183
186
|
return rawValue === 'true';
|
|
184
187
|
case 'parallel':
|
|
@@ -196,6 +199,14 @@ function parseConfigValue(key, rawValue) {
|
|
|
196
199
|
return rawValue;
|
|
197
200
|
}
|
|
198
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* Whether each job runs in its own copy of the working copy:
|
|
204
|
+
* `--isolate-workspace` / `--no-isolate-workspace`, else
|
|
205
|
+
* `isolate-workspace` in the config file, else no.
|
|
206
|
+
*/
|
|
207
|
+
function resolveIsolateWorkspace(flag, config = loadConfig()) {
|
|
208
|
+
return flag ?? config['isolate-workspace'] === true;
|
|
209
|
+
}
|
|
199
210
|
/**
|
|
200
211
|
* Whether container networking should be disabled. Networking is on by
|
|
201
212
|
* 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
|
|
@@ -43,6 +43,7 @@ const repository_1 = require("../utils/repository");
|
|
|
43
43
|
const action_fetcher_1 = require("../runner/action-fetcher");
|
|
44
44
|
const github_lockfile_1 = require("../github-lockfile");
|
|
45
45
|
const permissions_1 = require("./permissions");
|
|
46
|
+
const expression_1 = require("../runner/expression");
|
|
46
47
|
/** Maximum nesting of composite actions / reusable workflows. */
|
|
47
48
|
const MAX_DEPTH = 10;
|
|
48
49
|
/**
|
|
@@ -81,8 +82,17 @@ async function validatePipelineContract(parsed, spec, options = {}) {
|
|
|
81
82
|
(spec.required ?? []).forEach((entry, idx) => {
|
|
82
83
|
const path = `required[${idx}]`;
|
|
83
84
|
const name = normalizeActionName(entry.uses);
|
|
84
|
-
const
|
|
85
|
-
|
|
85
|
+
const found = sequence.filter((u) => u.name === name);
|
|
86
|
+
const candidates = found.filter((u) => u.runs !== 'never');
|
|
87
|
+
if (found.length > 0 && candidates.length === 0) {
|
|
88
|
+
errors.push({
|
|
89
|
+
path,
|
|
90
|
+
message: `Required action "${entry.uses}" never runs: its if: conditions (on its step or job) can't be true for this workflow's triggers (${(graph.events ?? []).join(', ')})`,
|
|
91
|
+
severity: 'error'
|
|
92
|
+
});
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (candidates.length > 0 && candidates.every((c) => c.runs === 'maybe')) {
|
|
86
96
|
errors.push({
|
|
87
97
|
path,
|
|
88
98
|
message: `Required action "${entry.uses}" only runs under an if: condition (on its step or job), so it may be skipped`,
|
|
@@ -275,6 +285,20 @@ function needsTransitively(nodes, from, to) {
|
|
|
275
285
|
async function buildGraph(parsed, recurse, offline) {
|
|
276
286
|
const graph = { sequence: [], nodes: new Map(), errors: [] };
|
|
277
287
|
const root = (0, repository_1.repositoryRoot)(parsed.path);
|
|
288
|
+
// `if:` conditions are decided, where they can be, for each trigger of
|
|
289
|
+
// the workflow (called workflows run with their caller's event). With
|
|
290
|
+
// workflow_call, the event is the caller's, so it isn't known.
|
|
291
|
+
graph.events = triggerEvents(parsed.file.on);
|
|
292
|
+
const known = graph.events
|
|
293
|
+
? graph.events.map((event) => ({ github: { event_name: event } }))
|
|
294
|
+
: [{}];
|
|
295
|
+
/** `runs`, narrowed by a job or step's own `if:`. */
|
|
296
|
+
const within = (runs, condition) => runs.map((outer, i) => {
|
|
297
|
+
const own = (0, expression_1.staticCondition)(condition, known[i]);
|
|
298
|
+
if (outer === false || own === false)
|
|
299
|
+
return false;
|
|
300
|
+
return outer === true && own === true ? true : undefined;
|
|
301
|
+
});
|
|
278
302
|
const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
|
|
279
303
|
/**
|
|
280
304
|
* The file behind a composite action / reusable workflow reference, or
|
|
@@ -355,31 +379,35 @@ async function buildGraph(parsed, recurse, offline) {
|
|
|
355
379
|
}
|
|
356
380
|
return true;
|
|
357
381
|
};
|
|
358
|
-
const addUsage = (node, index, at, uses,
|
|
382
|
+
const addUsage = (node, index, at, uses, runs) => {
|
|
359
383
|
graph.sequence.push({
|
|
360
384
|
node,
|
|
361
385
|
index,
|
|
362
386
|
path: at,
|
|
363
|
-
|
|
387
|
+
runs: runs.every((r) => r === false)
|
|
388
|
+
? 'never'
|
|
389
|
+
: runs.every((r) => r === true)
|
|
390
|
+
? 'always'
|
|
391
|
+
: 'maybe',
|
|
364
392
|
...splitActionRef(uses)
|
|
365
393
|
});
|
|
366
394
|
};
|
|
367
395
|
/** Flatten a job's steps (and composite action steps) into `node`. */
|
|
368
|
-
const addSteps = async (steps, node, at, counter, stack, workflowRoot,
|
|
396
|
+
const addSteps = async (steps, node, at, counter, stack, workflowRoot, runs) => {
|
|
369
397
|
for (const [i, step] of steps.entries()) {
|
|
370
398
|
const stepPath = `${at}steps[${i}]`;
|
|
371
399
|
if (!step.uses) {
|
|
372
400
|
counter.next++;
|
|
373
401
|
continue;
|
|
374
402
|
}
|
|
375
|
-
const
|
|
376
|
-
addUsage(node, counter.next++, stepPath, step.uses,
|
|
403
|
+
const stepRuns = within(runs, step.if);
|
|
404
|
+
addUsage(node, counter.next++, stepPath, step.uses, stepRuns);
|
|
377
405
|
const file = await resolveRef(step.uses, 'action', workflowRoot, stepPath);
|
|
378
406
|
if (!file || !canEnter(file, stack, stepPath))
|
|
379
407
|
continue;
|
|
380
408
|
const action = parse(file, stepPath);
|
|
381
409
|
if (action?.type === 'action' && action.file.runs.using === 'composite') {
|
|
382
|
-
await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot,
|
|
410
|
+
await addSteps(action.file.runs.steps ?? [], node, `${stepPath} > ${step.uses} > `, counter, [...stack, file], workflowRoot, stepRuns);
|
|
383
411
|
}
|
|
384
412
|
}
|
|
385
413
|
};
|
|
@@ -388,7 +416,7 @@ async function buildGraph(parsed, recurse, offline) {
|
|
|
388
416
|
* reusable-workflow call itself needs, plus the call). Returns the ids
|
|
389
417
|
* of the added jobs.
|
|
390
418
|
*/
|
|
391
|
-
const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack,
|
|
419
|
+
const addWorkflow = async (workflow, workflowRoot, prefix, labelPrefix, at, after, stack, runs) => {
|
|
392
420
|
const added = [];
|
|
393
421
|
// A job id -> the graph nodes standing for it (a reusable-workflow call
|
|
394
422
|
// stands for the call and all of its called jobs).
|
|
@@ -408,17 +436,17 @@ async function buildGraph(parsed, recurse, offline) {
|
|
|
408
436
|
});
|
|
409
437
|
added.push(node);
|
|
410
438
|
const jobPath = `${at}jobs.${jobId}`;
|
|
411
|
-
const
|
|
439
|
+
const jobRuns = within(runs, job.if);
|
|
412
440
|
if (job.uses) {
|
|
413
441
|
// The call itself is a one-step "job" its called jobs run after.
|
|
414
|
-
addUsage(node, 0, jobPath, job.uses,
|
|
442
|
+
addUsage(node, 0, jobPath, job.uses, jobRuns);
|
|
415
443
|
graph.nodes.get(node).length = 1;
|
|
416
444
|
const ids = [node];
|
|
417
445
|
const file = await resolveRef(job.uses, 'workflow', workflowRoot, jobPath);
|
|
418
446
|
if (file && canEnter(file, stack, jobPath)) {
|
|
419
447
|
const called = parse(file, jobPath);
|
|
420
448
|
if (called?.type === 'workflow') {
|
|
421
|
-
ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file],
|
|
449
|
+
ids.push(...(await addWorkflow(called.file, (0, repository_1.repositoryRoot)(file), `${node}>`, `${labelPrefix}${jobId} > ${job.uses} > `, `${jobPath} > ${job.uses} > `, [...needs, node], [...stack, file], jobRuns)));
|
|
422
450
|
}
|
|
423
451
|
}
|
|
424
452
|
alias.set(jobId, ids);
|
|
@@ -427,27 +455,30 @@ async function buildGraph(parsed, recurse, offline) {
|
|
|
427
455
|
}
|
|
428
456
|
alias.set(jobId, [node]);
|
|
429
457
|
const counter = { next: 0 };
|
|
430
|
-
await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot,
|
|
458
|
+
await addSteps(job.steps ?? [], node, `${jobPath}.`, counter, stack, workflowRoot, jobRuns);
|
|
431
459
|
graph.nodes.get(node).length = counter.next;
|
|
432
460
|
}
|
|
433
461
|
return added;
|
|
434
462
|
};
|
|
435
|
-
await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)],
|
|
463
|
+
await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], known.map(() => true));
|
|
436
464
|
return graph;
|
|
437
465
|
}
|
|
438
466
|
/**
|
|
439
|
-
*
|
|
440
|
-
*
|
|
467
|
+
* The events that trigger a workflow (`on:` as a string, list or map), or
|
|
468
|
+
* undefined when the event isn't known: none are listed, or the workflow
|
|
469
|
+
* is reusable (it runs with its caller's event).
|
|
441
470
|
*/
|
|
442
|
-
function
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
return
|
|
471
|
+
function triggerEvents(on) {
|
|
472
|
+
const events = typeof on === 'string'
|
|
473
|
+
? [on]
|
|
474
|
+
: Array.isArray(on)
|
|
475
|
+
? on.map(String)
|
|
476
|
+
: on && typeof on === 'object'
|
|
477
|
+
? Object.keys(on)
|
|
478
|
+
: [];
|
|
479
|
+
return events.length === 0 || events.includes('workflow_call')
|
|
480
|
+
? undefined
|
|
481
|
+
: events;
|
|
451
482
|
}
|
|
452
483
|
function splitActionRef(uses) {
|
|
453
484
|
const atIndex = uses.lastIndexOf('@');
|
|
@@ -43,6 +43,7 @@ exports.lsRemoteSha = lsRemoteSha;
|
|
|
43
43
|
exports.parseLsRemoteTags = parseLsRemoteTags;
|
|
44
44
|
exports.lsRemoteTags = lsRemoteTags;
|
|
45
45
|
exports.getText = getText;
|
|
46
|
+
exports.getResponse = getResponse;
|
|
46
47
|
exports.followRedirect = followRedirect;
|
|
47
48
|
exports.fetchAction = fetchAction;
|
|
48
49
|
exports.fetchRepoFile = fetchRepoFile;
|
|
@@ -222,6 +223,10 @@ async function lsRemoteTags(url) {
|
|
|
222
223
|
* with an error carrying `status`.
|
|
223
224
|
*/
|
|
224
225
|
function getText(url, headers, timeoutMs) {
|
|
226
|
+
return getResponse(url, headers, timeoutMs).then((r) => r.body);
|
|
227
|
+
}
|
|
228
|
+
/** getText, with the response headers (e.g. `link` for pagination). */
|
|
229
|
+
function getResponse(url, headers, timeoutMs) {
|
|
225
230
|
return new Promise((resolve, reject) => {
|
|
226
231
|
const client = url.startsWith('http://') ? http : https;
|
|
227
232
|
const request = client.get(url, { headers }, (response) => {
|
|
@@ -237,7 +242,7 @@ function getText(url, headers, timeoutMs) {
|
|
|
237
242
|
body += chunk;
|
|
238
243
|
});
|
|
239
244
|
response.on('end', () => response.statusCode === 200
|
|
240
|
-
? resolve(body)
|
|
245
|
+
? resolve({ body, headers: response.headers })
|
|
241
246
|
: reject(Object.assign(new Error(`HTTP ${response.statusCode}`), {
|
|
242
247
|
status: response.statusCode
|
|
243
248
|
})));
|
package/dist/runner/actions.js
CHANGED
|
@@ -93,7 +93,7 @@ function warnUnsandboxedActions(steps, ctx, options) {
|
|
|
93
93
|
async function stepFileSystem(runtime) {
|
|
94
94
|
const container = runtime.container;
|
|
95
95
|
if (!container)
|
|
96
|
-
return (0, builtin_actions_1.hostFileSystem)(
|
|
96
|
+
return (0, builtin_actions_1.hostFileSystem)(runtime.workspace);
|
|
97
97
|
if (container.home === undefined) {
|
|
98
98
|
const probe = await container.runtime.exec(container.id, {
|
|
99
99
|
command: 'printf %s "$HOME"'
|
|
@@ -102,7 +102,7 @@ async function stepFileSystem(runtime) {
|
|
|
102
102
|
}
|
|
103
103
|
return (0, builtin_actions_1.containerFileSystem)({
|
|
104
104
|
workspace: container_runtime_1.CONTAINER_WORKDIR,
|
|
105
|
-
hostWorkspace:
|
|
105
|
+
hostWorkspace: runtime.workspace,
|
|
106
106
|
home: container.home,
|
|
107
107
|
copyFrom: (from, to) => container.runtime.copyFrom(container.id, from, to),
|
|
108
108
|
copyTo: (from, to) => container.runtime.copyTo(container.id, from, to),
|
|
@@ -259,8 +259,8 @@ function isLocalActionRef(uses) {
|
|
|
259
259
|
* Resolve a local action (`uses: ./path/to/action`) relative to the
|
|
260
260
|
* workspace, like the runner does, to its action.yml / action.yaml.
|
|
261
261
|
*/
|
|
262
|
-
function resolveLocalAction(uses) {
|
|
263
|
-
const dir = path.resolve(
|
|
262
|
+
function resolveLocalAction(uses, workspace = process.cwd()) {
|
|
263
|
+
const dir = path.resolve(workspace, uses);
|
|
264
264
|
for (const name of ['action.yml', 'action.yaml']) {
|
|
265
265
|
const candidate = path.join(dir, name);
|
|
266
266
|
if (fs.existsSync(candidate))
|
|
@@ -297,7 +297,7 @@ async function executeAction(step, options, startTime, runtime, ctx) {
|
|
|
297
297
|
args: withValues.args ? (0, shell_1.splitArgs)(withValues.args) : []
|
|
298
298
|
}
|
|
299
299
|
};
|
|
300
|
-
return await (0, docker_action_1.executeDockerAction)(dockerAction, path.join(
|
|
300
|
+
return await (0, docker_action_1.executeDockerAction)(dockerAction, path.join(runtime.workspace, 'action.yml'), step, {}, callerCtx.env, options, runtime, startTime);
|
|
301
301
|
}
|
|
302
302
|
if (options.localCheckout !== false &&
|
|
303
303
|
(0, checkout_1.isLocalCheckout)(step.uses, step.with, callerCtx)) {
|
|
@@ -308,11 +308,11 @@ async function executeAction(step, options, startTime, runtime, ctx) {
|
|
|
308
308
|
return await runBuiltinAction(builtin, step, callerCtx, options, runtime, startTime);
|
|
309
309
|
}
|
|
310
310
|
const actionPath = isLocalActionRef(step.uses)
|
|
311
|
-
? resolveLocalAction(step.uses)
|
|
311
|
+
? resolveLocalAction(step.uses, runtime.workspace)
|
|
312
312
|
: await fetchActionOnce(step.uses, options, runtime);
|
|
313
313
|
if (!actionPath) {
|
|
314
314
|
const error = `Could not resolve action "${step.uses}"${isLocalActionRef(step.uses)
|
|
315
|
-
? ` (no action.yml or action.yaml in ${path.resolve(
|
|
315
|
+
? ` (no action.yml or action.yaml in ${path.resolve(runtime.workspace, step.uses)})`
|
|
316
316
|
: ''}`;
|
|
317
317
|
logger_1.logger.error(error);
|
|
318
318
|
return {
|