@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 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.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.1 sh`).
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.** 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:` 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
- - **Contract checks** don't evaluate `if:` conditions and, without
267
- `--contract-recurse all`, don't look inside _remote_ composite actions /
268
- reusable workflows.
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).
@@ -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))
@@ -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
- if (cached && !isStale)
382
- return { advisories: cached.advisories };
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.getText)(url, headers, ADVISORY_TIMEOUT_MS));
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
- let body;
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
- body = await httpGet(url, headers(token));
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
- body = await httpGet(url, headers(null));
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 advisories = parseAdvisories(JSON.parse(body));
451
+ const truncated = sameOriginNext() !== undefined;
416
452
  fs.mkdirSync(path.dirname(file), { recursive: true });
417
- const entry = { api, fetchedAt: now, advisories };
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
- ? { advisories: cached.advisories, stale: true, error: message }
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 allow-network. Must be "true" or "false".`);
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
@@ -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