@secureci/cli 1.0.0 → 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
@@ -9,6 +9,12 @@ them, and checks them against your organization's pipeline standards — without
9
9
  committing, pushing, or waiting on a CI runner. It is the free, open-source part
10
10
  of [SecureCI](https://secure-ci.dev).
11
11
 
12
+ <a href="https://secure-ci.dev/assets/video/secureci-walkthrough-16x9.mp4"><img src="https://secure-ci.dev/assets/readme-demo.svg" width="848" alt="Terminal demo of the secureci CLI on a demo repository: run executes a workflow locally and its tests pass; validate fails a deploy workflow against a pipeline contract (security scan below the minimum version, wrong step order, actions not pinned to a commit SHA); pin pins actions/checkout@v4 to its full commit SHA, keeping # v4 for Dependabot; inventory --check fails because the v4 tag now resolves to a different commit than the one locked."></a>
13
+
14
+ <sub>Real output from the CLI on a demo repository.</sub>
15
+
16
+ ▶️ **[Watch the 60-second walkthrough](https://secure-ci.dev/assets/video/secureci-walkthrough-16x9.mp4)**
17
+
12
18
  > 📖 This README is the quick tour. Every flag, context and edge case lives in
13
19
  > the **[full reference → DOCS.md](DOCS.md)**.
14
20
 
@@ -67,10 +73,13 @@ less install.sh
67
73
  sh install.sh
68
74
  ```
69
75
 
70
- Set `SECURECI_VERSION=1.0.0` to install a specific release,
76
+ Set `SECURECI_VERSION=1.1.0` to install a specific release,
71
77
  `SECURECI_INSTALL_DIR` to install somewhere else, and
72
78
  `SECURECI_REQUIRE_ATTESTATION=1` to fail unless `gh` verifies build provenance
73
- (e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.0.0 sh`).
79
+ (e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.1.0 sh`).
80
+ If the release's download link fails (GitHub has returned `504` for one while
81
+ its API still served the files), the script fetches the same files through the
82
+ GitHub API instead; the `SHA256SUMS` and provenance checks are unchanged.
74
83
  `secure-ci.dev/install.sh` redirects to [`install.sh`](install.sh) on this
75
84
  repository's `main` branch.
76
85
 
@@ -145,14 +154,20 @@ secureci inventory --check # fail on drift (use in CI)
145
154
 
146
155
  # Pin action tags to commit SHAs in place (keeps `# v4` for Dependabot/Renovate)
147
156
  secureci pin
157
+
158
+ # Scan every repository in an organisation (JSON results, never source code)
159
+ secureci org scan my-org
148
160
  ```
149
161
 
150
162
  ### Agent skill
151
163
 
152
- [`skills/secureci`](skills/secureci/SKILL.md) walks a coding agent (Claude Code,
153
- Codex, Cursor, …) through setting SecureCI up in a repo: pin actions, commit
154
- `secureci.lock`, and add the `secure-ci/cli` action as a step in an existing job
155
- (replacing `actionlint` if present).
164
+ [`skills/secureci`](skills/secureci/SKILL.md) lets a coding agent (Claude Code,
165
+ Codex, Cursor, …) check every workflow it creates or edits (`secureci validate`,
166
+ `secureci pin --check`), run workflows locally (`secureci run`), and set
167
+ SecureCI up in a repo: pin actions, commit `secureci.lock`, and add the
168
+ `secure-ci/cli` action as a step in an existing job (replacing `actionlint` if
169
+ present). It also picks up requests that name actionlint, zizmor, nektos/act,
170
+ pinact and similar tools.
156
171
 
157
172
  ```bash
158
173
  npx skills add secure-ci/cli --skill secureci
@@ -242,18 +257,24 @@ things to know:
242
257
 
243
258
  - **Linux containers only.** `windows-*` / `macos-*` jobs run in a Linux image
244
259
  with a warning.
245
- - **Jobs running in parallel share your working copy.** Each job has its own
246
- container, but every job mounts the same directory, so jobs that write the
247
- same files (e.g. `node_modules`) can collide; use `--parallel 1` to run jobs
248
- 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.
249
265
  - **`actions/checkout` of the current repo doesn't clone** — it uses your working
250
- copy as-is (so uncommitted changes are visible); `ref:`/`fetch-depth:` are
251
- 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.
252
270
  - **Artifacts and cache are local stand-ins**, stored under `~/.secureci/`;
253
271
  nothing is uploaded to or downloaded from GitHub.
254
- - **Contract checks** don't evaluate `if:` conditions and, without
255
- `--contract-recurse all`, don't look inside _remote_ composite actions /
256
- 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.
257
278
 
258
279
  The exact scope of each is documented in
259
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);
@@ -292,6 +300,36 @@ function defaultWorkflowFiles() {
292
300
  .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
293
301
  .map((f) => path.join('.github', 'workflows', f));
294
302
  }
303
+ /** The workflows in .github/workflows that aren't among `targets`. */
304
+ function otherWorkflowFiles(targets) {
305
+ const given = new Set(targets.map((file) => path.resolve(file)));
306
+ return defaultWorkflowFiles().filter((file) => !given.has(path.resolve(file)));
307
+ }
308
+ /**
309
+ * The remote references these workflows use, read locally (no network), or
310
+ * undefined, with a warning, when one of them can't be read.
311
+ */
312
+ async function workflowUses(files, lockName) {
313
+ const uses = new Set();
314
+ for (const file of files) {
315
+ try {
316
+ const parsed = (0, workflow_parser_1.parseFile)(file);
317
+ if (parsed.type !== 'workflow')
318
+ continue;
319
+ const inv = await (0, inventory_1.buildInventory)({
320
+ type: 'workflow',
321
+ path: file,
322
+ file: parsed.file
323
+ });
324
+ inv.actions.forEach((action) => uses.add(action.uses));
325
+ }
326
+ catch (error) {
327
+ logger_1.logger.warn(`Could not read ${file} (${error instanceof Error ? error.message : 'Unknown error'}), so every entry in ${lockName} is kept.`);
328
+ return undefined;
329
+ }
330
+ }
331
+ return uses;
332
+ }
295
333
  async function runInventory(files, options) {
296
334
  const update = options.updateLock || options.updateLockfile;
297
335
  const check = options.check || options.compare;
@@ -479,9 +517,33 @@ async function runInventory(files, options) {
479
517
  if (online) {
480
518
  unresolved.forEach((a) => logger_1.logger.warn(`Not locked: could not resolve ${a.uses} (repository deleted, renamed or private, or the network is unavailable?)`));
481
519
  }
482
- fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
483
- const transitive = Object.values(current).filter((e) => e.via).length;
484
- logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
520
+ // Given some of the workflows, lock what they use and keep the other
521
+ // workflows' entries as locked, instead of dropping them.
522
+ let entries = current;
523
+ const others = source ? otherWorkflowFiles(targets) : [];
524
+ if (source && others.length > 0) {
525
+ let previous;
526
+ try {
527
+ previous = (0, github_lockfile_1.readLockfile)(source);
528
+ }
529
+ catch (error) {
530
+ logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}. Run without file arguments to rewrite it from every workflow.`);
531
+ process.exit(1);
532
+ }
533
+ // actions.lock records no `via`, so its transitive entries can't be
534
+ // told apart from unused ones: keep them all.
535
+ const otherUses = github
536
+ ? undefined
537
+ : await workflowUses(others, lockName);
538
+ const merged = (0, inventory_1.mergeLock)(previous.actions, current, otherUses);
539
+ entries = merged.actions;
540
+ if (merged.kept > 0) {
541
+ logger_1.logger.info(`Kept ${merged.kept} action(s) from ${lockName} for the other workflows.`);
542
+ }
543
+ }
544
+ fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(entries));
545
+ const transitive = Object.values(entries).filter((e) => e.via).length;
546
+ logger_1.logger.success(`Wrote ${Object.keys(entries).length} action(s) to ${LOCKFILE_NAME}` +
485
547
  (transitive > 0 ? ` (${transitive} used by other actions)` : '') +
486
548
  '. Commit it to track supply-chain drift.');
487
549
  return;
@@ -940,6 +1002,43 @@ configCmd
940
1002
  (0, config_1.saveConfig)(config);
941
1003
  logger_1.logger.success(`Unset ${key} in ${(0, config_1.getConfigFilePath)()}`);
942
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
+ });
943
1042
  const cacheCmd = program
944
1043
  .command('cache')
945
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
package/dist/inventory.js CHANGED
@@ -37,6 +37,7 @@ exports.collectReferences = collectReferences;
37
37
  exports.buildInventory = buildInventory;
38
38
  exports.addTransitive = addTransitive;
39
39
  exports.diffLock = diffLock;
40
+ exports.mergeLock = mergeLock;
40
41
  const fs = __importStar(require("fs"));
41
42
  const path = __importStar(require("path"));
42
43
  const workflow_parser_1 = require("./parser/workflow-parser");
@@ -293,4 +294,55 @@ function diffLock(current, lock) {
293
294
  identity
294
295
  };
295
296
  }
297
+ /**
298
+ * Merge entries resolved for some workflow files into the previous lock, so
299
+ * locking those files doesn't drop the other workflows' entries.
300
+ *
301
+ * `updated` wins. From `previous`, keep what the other workflows still use
302
+ * (`otherUses`, their direct references) and, following `via`, what those
303
+ * pull in; drop the rest. A reference used directly by another workflow has
304
+ * no `via`, and a dependency both sides pull in lists every reference that
305
+ * pulls it in. When `otherUses` is unknown (GitHub's actions.lock records no
306
+ * `via`, or a workflow couldn't be read), keep every previous entry.
307
+ */
308
+ function mergeLock(previous, updated, otherUses) {
309
+ if (!otherUses) {
310
+ const kept = Object.keys(previous).filter((uses) => !(uses in updated));
311
+ return { actions: { ...previous, ...updated }, kept: kept.length };
312
+ }
313
+ // Previous entries the other workflows reach, taken as locked.
314
+ const kept = new Set([...otherUses].filter((uses) => uses in previous && !(uses in updated)));
315
+ for (let grew = true; grew;) {
316
+ grew = false;
317
+ for (const [uses, entry] of Object.entries(previous)) {
318
+ if (kept.has(uses) || uses in updated)
319
+ continue;
320
+ if (entry.via?.some((v) => kept.has(v))) {
321
+ kept.add(uses);
322
+ grew = true;
323
+ }
324
+ }
325
+ }
326
+ const withVia = (entry, via) => {
327
+ const out = { ...entry };
328
+ delete out.via;
329
+ if (via.length > 0)
330
+ out.via = [...new Set(via)].sort();
331
+ return out;
332
+ };
333
+ const stillLocked = (via) => (via ?? []).filter((v) => kept.has(v));
334
+ const actions = {};
335
+ for (const [uses, entry] of Object.entries(updated)) {
336
+ actions[uses] = otherUses.has(uses)
337
+ ? withVia(entry, [])
338
+ : entry.via
339
+ ? withVia(entry, [...entry.via, ...stillLocked(previous[uses]?.via)])
340
+ : entry;
341
+ }
342
+ for (const uses of kept) {
343
+ const entry = previous[uses];
344
+ actions[uses] = entry.via ? withVia(entry, stillLocked(entry.via)) : entry;
345
+ }
346
+ return { actions, kept: kept.size };
347
+ }
296
348
  //# sourceMappingURL=inventory.js.map
@@ -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