@secureci/cli 0.2.0 → 0.4.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/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ const commander_1 = require("commander");
38
38
  const dry_run_1 = require("./runner/dry-run");
39
39
  const executor_1 = require("./runner/executor");
40
40
  const action_fetcher_1 = require("./runner/action-fetcher");
41
+ const contract_source_1 = require("./contract-source");
41
42
  const validator_1 = require("./parser/validator");
42
43
  const workflow_parser_1 = require("./parser/workflow-parser");
43
44
  const pipeline_contract_parser_1 = require("./parser/pipeline-contract-parser");
@@ -45,6 +46,7 @@ const pipeline_contract_validator_1 = require("./parser/pipeline-contract-valida
45
46
  const report_1 = require("./report");
46
47
  const init_1 = require("./init");
47
48
  const inventory_1 = require("./inventory");
49
+ const github_lockfile_1 = require("./github-lockfile");
48
50
  const logger_1 = require("./utils/logger");
49
51
  const key_value_1 = require("./utils/key-value");
50
52
  const config_1 = require("./config");
@@ -195,7 +197,7 @@ program
195
197
  .option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
196
198
  .option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
197
199
  .option('--update-lockfile', `Alias of --update-lock`)
198
- .option('--check', `Compare the current commit SHAs against ./${LOCKFILE_NAME} and fail on drift`)
200
+ .option('--check', `Compare the current commit SHAs against ./${LOCKFILE_NAME} (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE} when that is the only lockfile) and fail on drift`)
199
201
  .option('--compare', `Alias of --check`)
200
202
  .action(async (files, options) => {
201
203
  await runInventory(files, options);
@@ -283,38 +285,58 @@ async function runInventory(files, options) {
283
285
  return;
284
286
  }
285
287
  const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
286
- const lockExists = fs.existsSync(lockPath);
288
+ // secureci.lock is first-class; GitHub's actions.lock is used only when
289
+ // it is the lockfile the repository provides.
290
+ const source = (0, github_lockfile_1.findLockfile)(process.cwd());
287
291
  const writeLock = () => {
288
292
  const updated = { version: 1, actions: current };
289
293
  fs.writeFileSync(lockPath, JSON.stringify(updated, null, 2) + '\n');
290
294
  logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}. Commit it to track supply-chain drift.`);
291
295
  };
292
296
  // `--update-lock`, or bare `inventory` when no lockfile exists yet: write it.
293
- if (update || (!check && !lockExists)) {
297
+ if (update || (!check && !source)) {
294
298
  writeLock();
295
299
  return;
296
300
  }
297
301
  // Otherwise compare (explicit `--check`, or bare `inventory` with a lockfile).
298
- if (!lockExists) {
299
- logger_1.logger.error(`No ${LOCKFILE_NAME} found. Create one with: secureci inventory --update-lock`);
302
+ if (!source) {
303
+ logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
300
304
  process.exit(1);
301
305
  }
302
- const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
306
+ const github = source.kind === 'github';
307
+ const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
308
+ const fixHint = github
309
+ ? 'run `gh actions-lock` to update actions.lock'
310
+ : 'run with --update-lock to accept it';
311
+ let lock;
312
+ try {
313
+ const text = fs.readFileSync(source.file, 'utf-8');
314
+ lock = github ? (0, github_lockfile_1.parseGitHubLockfile)(text) : JSON.parse(text);
315
+ }
316
+ catch (error) {
317
+ logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
318
+ process.exit(1);
319
+ }
320
+ logger_1.logger.info(`Checking against ${github ? github_lockfile_1.GITHUB_LOCKFILE : LOCKFILE_NAME}`);
303
321
  const diff = (0, inventory_1.diffLock)(current, lock);
304
322
  for (const d of diff.drifted) {
305
323
  logger_1.logger.error(`Drift: ${d.uses} now resolves to ${d.to.slice(0, 12)} (was ${d.from.slice(0, 12)})`);
306
324
  }
307
- diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${LOCKFILE_NAME}: ${u}`));
308
- diff.removed.forEach((u) => logger_1.logger.info(`In ${LOCKFILE_NAME} but no longer used: ${u}`));
325
+ diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${lockName}: ${u}`));
326
+ // actions.lock also records transitive dependencies, which SecureCI
327
+ // doesn't scan, so "no longer used" only means something for secureci.lock.
328
+ if (!github) {
329
+ diff.removed.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
330
+ }
309
331
  if (diff.drifted.length > 0) {
310
- logger_1.logger.error(`Action commits drifted from ${LOCKFILE_NAME}. Review the change, then run with --update-lock to accept it.`);
332
+ logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
311
333
  process.exit(1);
312
334
  }
313
335
  if (diff.added.length > 0) {
314
- logger_1.logger.warn(`New actions are not yet in ${LOCKFILE_NAME} (run --update-lock).`);
336
+ logger_1.logger.warn(`New actions are not yet in ${lockName} (${fixHint}).`);
315
337
  process.exit(1);
316
338
  }
317
- logger_1.logger.success(`No action drift against ${LOCKFILE_NAME}.`);
339
+ logger_1.logger.success(`No action drift against ${lockName}.`);
318
340
  }
319
341
  async function validateOneFile(filePath, contractFile, recurse, say) {
320
342
  const parsed = (0, workflow_parser_1.parseFile)(filePath);
@@ -363,10 +385,42 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
363
385
  if (!quiet)
364
386
  fn();
365
387
  };
388
+ // A contract may be a local file or `owner/repo/path@ref` in another
389
+ // repository; resolve it once and record which version was enforced.
390
+ let contractPath = contractFile;
391
+ let contract;
392
+ let contractError;
393
+ if (contractFile) {
394
+ try {
395
+ const source = await (0, contract_source_1.resolveContractSource)(contractFile, {
396
+ fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses),
397
+ resolveSha: (uses) => (0, action_fetcher_1.resolveActionSha)(uses),
398
+ warn: (message) => logger_1.logger.warn(message)
399
+ });
400
+ contractPath = source.file;
401
+ const remote = source.remote;
402
+ contract = remote
403
+ ? {
404
+ source: remote.uses,
405
+ ref: remote.ref,
406
+ commit: remote.commit,
407
+ pinned: remote.pinned
408
+ }
409
+ : { source: contractFile };
410
+ if (remote) {
411
+ say(() => logger_1.logger.info(`Using contract ${remote.uses}${remote.commit ? ` at commit ${remote.commit.slice(0, 12)}` : ''}`));
412
+ }
413
+ }
414
+ catch (error) {
415
+ contractError = error instanceof Error ? error.message : 'Unknown error';
416
+ }
417
+ }
366
418
  const entries = [];
367
419
  for (const filePath of files) {
368
420
  try {
369
- const result = await validateOneFile(filePath, contractFile, recurse, say);
421
+ if (contractError)
422
+ throw new Error(contractError);
423
+ const result = await validateOneFile(filePath, contractPath, recurse, say);
370
424
  entries.push({ file: filePath, result });
371
425
  }
372
426
  catch (error) {
@@ -388,7 +442,7 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
388
442
  const allValid = entries.every((e) => e.result.valid);
389
443
  if (quiet) {
390
444
  // eslint-disable-next-line no-console
391
- console.log((0, report_1.formatReport)(entries, format));
445
+ console.log((0, report_1.formatReport)(entries, format, { contract }));
392
446
  if (!allValid)
393
447
  process.exit(1);
394
448
  return;
@@ -0,0 +1,63 @@
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.resolveContractSource = resolveContractSource;
37
+ const fs = __importStar(require("fs"));
38
+ // owner/repo/path/to/file@ref: the same form as a remote reusable workflow.
39
+ const REMOTE_CONTRACT = /^[^/.][^/]*\/[^/@]+\/[^@]+@([^@]+)$/;
40
+ const FULL_SHA = /^[0-9a-f]{40}$/;
41
+ /**
42
+ * Where to read `--contract` from. An existing local file always wins;
43
+ * otherwise `owner/repo/path@ref` is fetched like a remote reusable workflow.
44
+ * A ref that isn't a commit SHA can change under you, so it warns.
45
+ */
46
+ async function resolveContractSource(value, deps) {
47
+ if (fs.existsSync(value))
48
+ return { file: value };
49
+ const match = REMOTE_CONTRACT.exec(value);
50
+ if (!match)
51
+ throw new Error(`Contract file not found: ${value}`);
52
+ const ref = match[1];
53
+ const pinned = FULL_SHA.test(ref);
54
+ if (!pinned) {
55
+ deps.warn(`Contract ${value} uses the moving ref "${ref}": its owner can change the standard without a change in this repository. Pin it to a commit SHA to control when it changes.`);
56
+ }
57
+ const file = await deps.fetchFile(value);
58
+ if (!file)
59
+ throw new Error(`Could not fetch contract ${value}`);
60
+ const commit = pinned ? ref : await deps.resolveSha(value);
61
+ return { file, remote: { uses: value, ref, commit, pinned } };
62
+ }
63
+ //# sourceMappingURL=contract-source.js.map
@@ -0,0 +1,90 @@
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.GITHUB_LOCKFILE = exports.SECURECI_LOCKFILE = void 0;
37
+ exports.findLockfile = findLockfile;
38
+ exports.parseGitHubLockfile = parseGitHubLockfile;
39
+ const fs = __importStar(require("fs"));
40
+ const path = __importStar(require("path"));
41
+ const YAML = __importStar(require("yaml"));
42
+ /** SecureCI's own lockfile, at the repository root. */
43
+ exports.SECURECI_LOCKFILE = 'secureci.lock';
44
+ /**
45
+ * GitHub's workflow dependency lockfile, written by `gh actions-lock`
46
+ * (public preview). See
47
+ * https://docs.github.com/actions/concepts/security/workflow-dependency-lockfiles
48
+ */
49
+ exports.GITHUB_LOCKFILE = path.join('.github', 'workflows', 'actions.lock');
50
+ /**
51
+ * Versions of GitHub's format this release reads. The format is pre-1.0 and
52
+ * may change, so an unknown version is an error rather than a guess.
53
+ */
54
+ const SUPPORTED_GITHUB_VERSIONS = ['v0.0.2', 'v0.0.3'];
55
+ const SHA1_COMMIT = /^sha1-([0-9a-f]{40})$/;
56
+ /**
57
+ * The lockfile to check against: `secureci.lock` when present (it stays
58
+ * first-class), otherwise GitHub's `actions.lock`, otherwise none.
59
+ */
60
+ function findLockfile(repoRoot) {
61
+ const secureci = path.join(repoRoot, exports.SECURECI_LOCKFILE);
62
+ if (fs.existsSync(secureci))
63
+ return { kind: 'secureci', file: secureci };
64
+ const github = path.join(repoRoot, exports.GITHUB_LOCKFILE);
65
+ if (fs.existsSync(github))
66
+ return { kind: 'github', file: github };
67
+ return undefined;
68
+ }
69
+ /**
70
+ * Read GitHub's `actions.lock` as a `uses -> commit` map, the same shape as
71
+ * `secureci.lock`. It includes transitive dependencies (actions that your
72
+ * actions use), which SecureCI doesn't scan.
73
+ */
74
+ function parseGitHubLockfile(text) {
75
+ const doc = YAML.parse(text);
76
+ const version = String(doc?.version ?? '');
77
+ if (!SUPPORTED_GITHUB_VERSIONS.includes(version)) {
78
+ throw new Error(`Unsupported actions.lock version "${version}" (this release reads ${SUPPORTED_GITHUB_VERSIONS.join(', ')}). Update @secureci/cli, or use ${exports.SECURECI_LOCKFILE}.`);
79
+ }
80
+ const actions = {};
81
+ for (const [uses, dep] of Object.entries(doc?.dependencies ?? {})) {
82
+ const match = SHA1_COMMIT.exec(String(dep?.commit ?? ''));
83
+ if (!match) {
84
+ throw new Error(`actions.lock entry ${uses} has no sha1 commit (got "${String(dep?.commit)}").`);
85
+ }
86
+ actions[uses] = match[1];
87
+ }
88
+ return { version: 1, actions };
89
+ }
90
+ //# sourceMappingURL=github-lockfile.js.map
package/dist/report.js CHANGED
@@ -41,13 +41,15 @@ function sarifResults(entry) {
41
41
  * carrying every file's findings, suitable for GitHub code scanning (upload
42
42
  * with `github/codeql-action/upload-sarif`).
43
43
  */
44
- function formatReport(entries, format) {
44
+ function formatReport(entries, format, meta = {}) {
45
+ const contract = meta.contract ? { contract: meta.contract } : {};
45
46
  if (format === 'json') {
46
47
  const body = entries.length === 1
47
- ? jsonForFile(entries[0])
48
+ ? { ...jsonForFile(entries[0]), ...contract }
48
49
  : {
49
50
  valid: entries.every((e) => e.result.valid),
50
- files: entries.map(jsonForFile)
51
+ files: entries.map(jsonForFile),
52
+ ...contract
51
53
  };
52
54
  return JSON.stringify(body, null, 2);
53
55
  }
@@ -63,7 +65,8 @@ function formatReport(entries, format) {
63
65
  rules: Object.entries(RULES).map(([id, name]) => ({ id, name }))
64
66
  }
65
67
  },
66
- results: entries.flatMap(sarifResults)
68
+ results: entries.flatMap(sarifResults),
69
+ ...(meta.contract ? { properties: contract } : {})
67
70
  }
68
71
  ]
69
72
  }, null, 2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "SecureCI: validate, check against pipeline standards, and run GitHub Actions workflows locally",
5
5
  "main": "dist/index.js",
6
6
  "bin": {