@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 CHANGED
@@ -73,10 +73,10 @@ less install.sh
73
73
  sh install.sh
74
74
  ```
75
75
 
76
- Set `SECURECI_VERSION=1.0.1` to install a specific release,
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.1 sh`).
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.** Each job has its own
258
- container, but every job mounts the same directory, so jobs that write the
259
- same files (e.g. `node_modules`) can collide; use `--parallel 1` to run jobs
260
- one after another.
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); `ref:`/`fetch-depth:` are
263
- ignored. Other repositories check out for real and need a token.
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
- - **Contract checks** don't evaluate `if:` conditions and, without
267
- `--contract-recurse all`, don't look inside _remote_ composite actions /
268
- reusable workflows.
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).
@@ -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
- * Only the first page (100 advisories) is read.
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.getText)(url, headers, ADVISORY_TIMEOUT_MS));
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
- let body;
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
- body = await httpGet(url, headers(token));
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
- body = await httpGet(url, headers(null));
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 allow-network. Must be "true" or "false".`);
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
@@ -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 candidates = sequence.filter((u) => u.name === name);
85
- if (candidates.length > 0 && candidates.every((c) => c.conditional)) {
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, conditional) => {
382
+ const addUsage = (node, index, at, uses, runs) => {
359
383
  graph.sequence.push({
360
384
  node,
361
385
  index,
362
386
  path: at,
363
- conditional,
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, conditional) => {
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 stepConditional = conditional || hasCondition(step.if);
376
- addUsage(node, counter.next++, stepPath, step.uses, stepConditional);
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, stepConditional);
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, conditional) => {
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 jobConditional = conditional || hasCondition(job.if);
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, jobConditional);
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], jobConditional)));
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, jobConditional);
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)], false);
463
+ await addWorkflow(parsed.file, root, '', '', '', [], [path.resolve(parsed.path)], known.map(() => true));
436
464
  return graph;
437
465
  }
438
466
  /**
439
- * Whether an `if:` can skip the step/job: anything but a missing value,
440
- * `true`, `success()` (the default) or `always()`.
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 hasCondition(condition) {
443
- if (condition === undefined || condition === null || condition === true) {
444
- return false;
445
- }
446
- const text = String(condition)
447
- .trim()
448
- .replace(/^\$\{\{([\s\S]*)\}\}$/, '$1')
449
- .trim();
450
- return !['', 'true', 'success()', 'always()'].includes(text);
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
  })));
@@ -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)(process.cwd());
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: process.cwd(),
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(process.cwd(), uses);
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(process.cwd(), 'action.yml'), step, {}, callerCtx.env, options, runtime, startTime);
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(process.cwd(), step.uses)})`
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 {