@secureci/cli 0.3.0 → 0.5.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
@@ -71,7 +71,8 @@ secureci inventory --check # fail on drift (use in CI)
71
71
  is checked against it, locally and in CI.
72
72
  - **Supply-chain visibility** — [`secureci inventory`](DOCS.md#action-inventory-and-drift-secureci-inventory)
73
73
  with a committed lockfile turns a silently moved action tag into a failing
74
- check.
74
+ check, including tags inside the actions you use, and catches a locked
75
+ repository that was deleted and re-created.
75
76
  - **Machine-readable output** — `--format json|sarif` drops straight into CI and
76
77
  GitHub code scanning.
77
78
  - **Audit evidence** — the contract, `secureci.lock` and reports are plain files
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");
@@ -190,7 +191,7 @@ program
190
191
  });
191
192
  program
192
193
  .command('inventory')
193
- .description('List the actions and reusable workflows a workflow uses (resolved into called workflows/actions), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
194
+ .description('List the actions and reusable workflows a workflow uses (including the actions those actions use), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
194
195
  .argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
195
196
  .option('--offline', 'Do not resolve tags to commit SHAs over the network (already-pinned refs still resolve)')
196
197
  .option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
@@ -271,11 +272,71 @@ async function runInventory(files, options) {
271
272
  process.exit(1);
272
273
  }
273
274
  }
274
- const actions = [...byUses.values()].sort((a, b) => a.uses.localeCompare(b.uses));
275
+ // Download progress would corrupt --dry-run's JSON on stdout; warnings
276
+ // still go to stderr.
277
+ if (options.dryRun)
278
+ logger_1.logger.setLevel(logger_1.LogLevel.WARN);
279
+ // Follow remote composite actions and reusable workflows into the
280
+ // actions they use, downloaded at the resolved commit.
281
+ const actions = (await (0, inventory_1.addTransitive)([...byUses.values()], {
282
+ resolveSha: resolver,
283
+ fetch: (uses, kind) => kind === 'action'
284
+ ? (0, action_fetcher_1.fetchAction)(uses, { offline: options.offline })
285
+ : (0, action_fetcher_1.fetchRepoFile)(uses, { offline: options.offline }),
286
+ warn: (message) => logger_1.logger.warn(message)
287
+ })).sort((a, b) => a.uses.localeCompare(b.uses));
288
+ const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
289
+ // secureci.lock is first-class; GitHub's actions.lock is used only when
290
+ // it is the lockfile the repository provides.
291
+ const source = (0, github_lockfile_1.findLockfile)(process.cwd());
292
+ // `--update-lock`, or bare `inventory` when no lockfile exists yet,
293
+ // writes the lock; otherwise (`--check`, or bare `inventory` with a
294
+ // lockfile) compares against it.
295
+ const writing = update || (!check && !source);
296
+ let lock;
297
+ const github = source?.kind === 'github';
298
+ const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
299
+ if (!writing && !options.dryRun) {
300
+ if (!source) {
301
+ logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
302
+ process.exit(1);
303
+ }
304
+ try {
305
+ lock = (0, github_lockfile_1.readLockfile)(source);
306
+ }
307
+ catch (error) {
308
+ logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
309
+ process.exit(1);
310
+ }
311
+ }
312
+ // Repository identity: recorded when writing; when checking, looked up
313
+ // only for repositories the lock has an identity for.
314
+ if (!options.offline) {
315
+ const lookups = new Map();
316
+ for (const action of actions) {
317
+ if (!action.resolvedSha)
318
+ continue;
319
+ if (lock && lock.actions[action.uses]?.repoId === undefined)
320
+ continue;
321
+ const key = `${action.owner}/${action.repo}`.toLowerCase();
322
+ if (!lookups.has(key)) {
323
+ lookups.set(key, (0, action_fetcher_1.resolveRepoIdentity)(action.owner, action.repo));
324
+ }
325
+ const identity = await lookups.get(key);
326
+ if (identity)
327
+ Object.assign(action, identity);
328
+ }
329
+ }
275
330
  const current = {};
276
331
  for (const action of actions) {
277
- if (action.resolvedSha)
278
- current[action.uses] = action.resolvedSha;
332
+ if (!action.resolvedSha)
333
+ continue;
334
+ current[action.uses] = {
335
+ commit: action.resolvedSha,
336
+ ...(action.ownerId !== undefined ? { ownerId: action.ownerId } : {}),
337
+ ...(action.repoId !== undefined ? { repoId: action.repoId } : {}),
338
+ ...(action.via ? { via: action.via } : {})
339
+ };
279
340
  }
280
341
  // --dry-run: print the resolved inventory as JSON, touching nothing.
281
342
  if (options.dryRun) {
@@ -283,50 +344,35 @@ async function runInventory(files, options) {
283
344
  console.log(JSON.stringify({ actions }, null, 2));
284
345
  return;
285
346
  }
286
- const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
287
- // secureci.lock is first-class; GitHub's actions.lock is used only when
288
- // it is the lockfile the repository provides.
289
- const source = (0, github_lockfile_1.findLockfile)(process.cwd());
290
- const writeLock = () => {
291
- const updated = { version: 1, actions: current };
292
- fs.writeFileSync(lockPath, JSON.stringify(updated, null, 2) + '\n');
293
- logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}. Commit it to track supply-chain drift.`);
294
- };
295
- // `--update-lock`, or bare `inventory` when no lockfile exists yet: write it.
296
- if (update || (!check && !source)) {
297
- writeLock();
347
+ if (writing) {
348
+ fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(current));
349
+ const transitive = Object.values(current).filter((e) => e.via).length;
350
+ logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}` +
351
+ (transitive > 0 ? ` (${transitive} used by other actions)` : '') +
352
+ '. Commit it to track supply-chain drift.');
298
353
  return;
299
354
  }
300
- // Otherwise compare (explicit `--check`, or bare `inventory` with a lockfile).
301
- if (!source) {
302
- logger_1.logger.error(`No ${LOCKFILE_NAME} found (or GitHub's ${github_lockfile_1.GITHUB_LOCKFILE}). Create one with: secureci inventory --update-lock`);
303
- process.exit(1);
304
- }
305
- const github = source.kind === 'github';
306
- const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
307
355
  const fixHint = github
308
356
  ? 'run `gh actions-lock` to update actions.lock'
309
357
  : 'run with --update-lock to accept it';
310
- let lock;
311
- try {
312
- const text = fs.readFileSync(source.file, 'utf-8');
313
- lock = github ? (0, github_lockfile_1.parseGitHubLockfile)(text) : JSON.parse(text);
314
- }
315
- catch (error) {
316
- logger_1.logger.error(`Could not read ${lockName}: ${error instanceof Error ? error.message : 'Unknown error'}`);
317
- process.exit(1);
318
- }
319
358
  logger_1.logger.info(`Checking against ${github ? github_lockfile_1.GITHUB_LOCKFILE : LOCKFILE_NAME}`);
320
359
  const diff = (0, inventory_1.diffLock)(current, lock);
321
360
  for (const d of diff.drifted) {
322
361
  logger_1.logger.error(`Drift: ${d.uses} now resolves to ${d.to.slice(0, 12)} (was ${d.from.slice(0, 12)})`);
323
362
  }
363
+ for (const d of diff.identity) {
364
+ logger_1.logger.error(`Repository changed: ${d.uses} ${d.field} is now ${d.to} (was ${d.from}). The name now points at a different ${d.field === 'repo_id' ? 'repository (deleted and re-created?)' : 'owner (transferred, or the account re-registered?)'}.`);
365
+ }
324
366
  diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${lockName}: ${u}`));
325
- // actions.lock also records transitive dependencies, which SecureCI
326
- // doesn't scan, so "no longer used" only means something for secureci.lock.
367
+ // actions.lock is written by `gh actions-lock`, which may record entries
368
+ // SecureCI doesn't follow, so "no longer used" is only for secureci.lock.
327
369
  if (!github) {
328
370
  diff.removed.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
329
371
  }
372
+ if (diff.identity.length > 0) {
373
+ logger_1.logger.error(`A locked repository is no longer the one that was locked. Verify it before trusting it, then ${fixHint}.`);
374
+ process.exit(1);
375
+ }
330
376
  if (diff.drifted.length > 0) {
331
377
  logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
332
378
  process.exit(1);
@@ -384,10 +430,42 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
384
430
  if (!quiet)
385
431
  fn();
386
432
  };
433
+ // A contract may be a local file or `owner/repo/path@ref` in another
434
+ // repository; resolve it once and record which version was enforced.
435
+ let contractPath = contractFile;
436
+ let contract;
437
+ let contractError;
438
+ if (contractFile) {
439
+ try {
440
+ const source = await (0, contract_source_1.resolveContractSource)(contractFile, {
441
+ fetchFile: (uses) => (0, action_fetcher_1.fetchRepoFile)(uses),
442
+ resolveSha: (uses) => (0, action_fetcher_1.resolveActionSha)(uses),
443
+ warn: (message) => logger_1.logger.warn(message)
444
+ });
445
+ contractPath = source.file;
446
+ const remote = source.remote;
447
+ contract = remote
448
+ ? {
449
+ source: remote.uses,
450
+ ref: remote.ref,
451
+ commit: remote.commit,
452
+ pinned: remote.pinned
453
+ }
454
+ : { source: contractFile };
455
+ if (remote) {
456
+ say(() => logger_1.logger.info(`Using contract ${remote.uses}${remote.commit ? ` at commit ${remote.commit.slice(0, 12)}` : ''}`));
457
+ }
458
+ }
459
+ catch (error) {
460
+ contractError = error instanceof Error ? error.message : 'Unknown error';
461
+ }
462
+ }
387
463
  const entries = [];
388
464
  for (const filePath of files) {
389
465
  try {
390
- const result = await validateOneFile(filePath, contractFile, recurse, say);
466
+ if (contractError)
467
+ throw new Error(contractError);
468
+ const result = await validateOneFile(filePath, contractPath, recurse, say);
391
469
  entries.push({ file: filePath, result });
392
470
  }
393
471
  catch (error) {
@@ -409,7 +487,7 @@ async function validateFiles(files, contractFile, recurse = 'local', format = 't
409
487
  const allValid = entries.every((e) => e.result.valid);
410
488
  if (quiet) {
411
489
  // eslint-disable-next-line no-console
412
- console.log((0, report_1.formatReport)(entries, format));
490
+ console.log((0, report_1.formatReport)(entries, format, { contract }));
413
491
  if (!allValid)
414
492
  process.exit(1);
415
493
  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
@@ -36,6 +36,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.GITHUB_LOCKFILE = exports.SECURECI_LOCKFILE = void 0;
37
37
  exports.findLockfile = findLockfile;
38
38
  exports.parseGitHubLockfile = parseGitHubLockfile;
39
+ exports.parseSecureciLockfile = parseSecureciLockfile;
40
+ exports.serializeSecureciLockfile = serializeSecureciLockfile;
41
+ exports.readLockfile = readLockfile;
39
42
  const fs = __importStar(require("fs"));
40
43
  const path = __importStar(require("path"));
41
44
  const YAML = __importStar(require("yaml"));
@@ -66,10 +69,23 @@ function findLockfile(repoRoot) {
66
69
  return { kind: 'github', file: github };
67
70
  return undefined;
68
71
  }
72
+ /** A positive integer id, or undefined. */
73
+ function id(value) {
74
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
75
+ ? value
76
+ : undefined;
77
+ }
78
+ function entry(commit, ownerId, repoId, via) {
79
+ return {
80
+ commit,
81
+ ...(ownerId !== undefined ? { ownerId } : {}),
82
+ ...(repoId !== undefined ? { repoId } : {}),
83
+ ...(via && via.length > 0 ? { via } : {})
84
+ };
85
+ }
69
86
  /**
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.
87
+ * Read GitHub's `actions.lock`: each dependency's commit and repository
88
+ * identity (`owner_id`, `repo_id`). It includes transitive dependencies.
73
89
  */
74
90
  function parseGitHubLockfile(text) {
75
91
  const doc = YAML.parse(text);
@@ -83,8 +99,62 @@ function parseGitHubLockfile(text) {
83
99
  if (!match) {
84
100
  throw new Error(`actions.lock entry ${uses} has no sha1 commit (got "${String(dep?.commit)}").`);
85
101
  }
86
- actions[uses] = match[1];
102
+ actions[uses] = entry(match[1], id(dep?.owner_id), id(dep?.repo_id));
87
103
  }
88
- return { version: 1, actions };
104
+ return { version: 2, actions };
105
+ }
106
+ const FULL_SHA = /^[0-9a-f]{40}$/;
107
+ /**
108
+ * Read `secureci.lock`. Version 1 maps each reference to a commit; version
109
+ * 2 maps it to `{ commit, owner_id?, repo_id?, via? }`.
110
+ */
111
+ function parseSecureciLockfile(text) {
112
+ const doc = JSON.parse(text);
113
+ const raw = (doc?.actions ?? {});
114
+ const actions = {};
115
+ const bad = (uses) => new Error(`${exports.SECURECI_LOCKFILE} entry ${uses} has no valid commit.`);
116
+ if (doc?.version === 1) {
117
+ for (const [uses, commit] of Object.entries(raw)) {
118
+ if (typeof commit !== 'string' || !FULL_SHA.test(commit))
119
+ throw bad(uses);
120
+ actions[uses] = { commit };
121
+ }
122
+ return { version: 1, actions };
123
+ }
124
+ if (doc?.version === 2) {
125
+ for (const [uses, value] of Object.entries(raw)) {
126
+ const e = (value ?? {});
127
+ if (typeof e.commit !== 'string' || !FULL_SHA.test(e.commit)) {
128
+ throw bad(uses);
129
+ }
130
+ const via = Array.isArray(e.via)
131
+ ? e.via.filter((v) => typeof v === 'string')
132
+ : undefined;
133
+ actions[uses] = entry(e.commit, id(e.owner_id), id(e.repo_id), via);
134
+ }
135
+ return { version: 2, actions };
136
+ }
137
+ throw new Error(`Unsupported ${exports.SECURECI_LOCKFILE} version "${String(doc?.version)}". Update @secureci/cli.`);
138
+ }
139
+ /** `secureci.lock` (version 2) for these entries, keys sorted. */
140
+ function serializeSecureciLockfile(actions) {
141
+ const out = {};
142
+ for (const uses of Object.keys(actions).sort()) {
143
+ const e = actions[uses];
144
+ out[uses] = {
145
+ commit: e.commit,
146
+ ...(e.ownerId !== undefined ? { owner_id: e.ownerId } : {}),
147
+ ...(e.repoId !== undefined ? { repo_id: e.repoId } : {}),
148
+ ...(e.via && e.via.length > 0 ? { via: e.via } : {})
149
+ };
150
+ }
151
+ return JSON.stringify({ version: 2, actions: out }, null, 2) + '\n';
152
+ }
153
+ /** Read whichever lockfile `findLockfile` found. */
154
+ function readLockfile(source) {
155
+ const text = fs.readFileSync(source.file, 'utf-8');
156
+ return source.kind === 'github'
157
+ ? parseGitHubLockfile(text)
158
+ : parseSecureciLockfile(text);
89
159
  }
90
160
  //# sourceMappingURL=github-lockfile.js.map
package/dist/inventory.js CHANGED
@@ -35,8 +35,8 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.collectReferences = collectReferences;
37
37
  exports.buildInventory = buildInventory;
38
+ exports.addTransitive = addTransitive;
38
39
  exports.diffLock = diffLock;
39
- exports.lockEntries = lockEntries;
40
40
  const fs = __importStar(require("fs"));
41
41
  const path = __importStar(require("path"));
42
42
  const workflow_parser_1 = require("./parser/workflow-parser");
@@ -70,8 +70,13 @@ function classify(uses, kind) {
70
70
  * actions (`./path`) and local reusable workflows (`jobs.<id>.uses: ./x.yml`)
71
71
  * so wrapped references are still listed. Remote references are listed but
72
72
  * not followed. Order is the walk order; each carries its call path.
73
+ *
74
+ * Also takes a composite action (its steps). `localActions: false` lists
75
+ * but doesn't follow `./` actions: in a downloaded action or workflow they
76
+ * resolve against whatever the caller checked out, which isn't known here.
73
77
  */
74
- function collectReferences(parsed) {
78
+ function collectReferences(parsed, options = {}) {
79
+ const followLocalActions = options.localActions ?? true;
75
80
  const root = (0, repository_1.repositoryRoot)(parsed.path);
76
81
  const refs = [];
77
82
  const resolveLocal = (uses, names) => {
@@ -92,7 +97,7 @@ function collectReferences(parsed) {
92
97
  return;
93
98
  const ref = classify(step.uses, 'action');
94
99
  refs.push({ ...ref, path: at });
95
- if (!isLocal(step.uses))
100
+ if (!isLocal(step.uses) || !followLocalActions)
96
101
  return;
97
102
  const file = resolveLocal(step.uses, ['action.yml', 'action.yaml']);
98
103
  if (!file || stack.includes(file) || stack.length >= MAX_DEPTH)
@@ -137,7 +142,12 @@ function collectReferences(parsed) {
137
142
  (job.steps ?? []).forEach((step, i) => addStep(step, `${jobPath}.steps[${i}]`, stack));
138
143
  }
139
144
  };
140
- addWorkflow(parsed.file, '', [path.resolve(parsed.path)]);
145
+ if (parsed.type === 'workflow') {
146
+ addWorkflow(parsed.file, '', [path.resolve(parsed.path)]);
147
+ }
148
+ else if (parsed.file.runs.using === 'composite') {
149
+ (parsed.file.runs.steps ?? []).forEach((s, i) => addStep(s, `steps[${i}]`, [path.resolve(parsed.path)]));
150
+ }
141
151
  return refs;
142
152
  }
143
153
  /**
@@ -150,22 +160,10 @@ async function buildInventory(parsed, resolveSha) {
150
160
  const remote = references.filter((r) => r.kind === 'action' || r.kind === 'reusable-workflow');
151
161
  const byUses = new Map();
152
162
  for (const ref of remote) {
153
- if (!ref.owner || !ref.repo)
163
+ const action = byUses.get(ref.uses) ?? toAction(ref);
164
+ if (!action)
154
165
  continue;
155
- let action = byUses.get(ref.uses);
156
- if (!action) {
157
- action = {
158
- uses: ref.uses,
159
- owner: ref.owner,
160
- repo: ref.repo,
161
- subpath: ref.subpath,
162
- ref: ref.ref,
163
- pinned: ref.pinned,
164
- resolvedSha: ref.ref && FULL_SHA.test(ref.ref) ? ref.ref.toLowerCase() : undefined,
165
- paths: []
166
- };
167
- byUses.set(ref.uses, action);
168
- }
166
+ byUses.set(ref.uses, action);
169
167
  action.paths.push(ref.path);
170
168
  }
171
169
  if (resolveSha) {
@@ -181,32 +179,117 @@ async function buildInventory(parsed, resolveSha) {
181
179
  references
182
180
  };
183
181
  }
184
- /** Compare current `uses -> sha` against a lockfile's recorded commits. */
182
+ /** A remote reference as an inventory entry (no paths yet), or null. */
183
+ function toAction(ref) {
184
+ if (!ref.owner || !ref.repo)
185
+ return null;
186
+ if (ref.kind !== 'action' && ref.kind !== 'reusable-workflow')
187
+ return null;
188
+ return {
189
+ uses: ref.uses,
190
+ kind: ref.kind,
191
+ owner: ref.owner,
192
+ repo: ref.repo,
193
+ subpath: ref.subpath,
194
+ ref: ref.ref,
195
+ pinned: ref.pinned,
196
+ resolvedSha: ref.ref && FULL_SHA.test(ref.ref) ? ref.ref.toLowerCase() : undefined,
197
+ paths: []
198
+ };
199
+ }
200
+ /**
201
+ * The inventory plus every transitive dependency: remote composite actions
202
+ * and remote reusable workflows are downloaded at their resolved commit
203
+ * (so what's read is what's locked) and their own `uses:` added, up to
204
+ * MAX_DEPTH levels. A dependency records `via`, the references that pull it
205
+ * in. Node and Docker actions have no `uses:` to follow.
206
+ */
207
+ async function addTransitive(direct, deps) {
208
+ const byUses = new Map(direct.map((a) => [a.uses, a]));
209
+ const queue = direct.map((action) => ({ action, depth: 1 }));
210
+ while (queue.length > 0) {
211
+ const { action, depth } = queue.shift();
212
+ const at = action.resolvedSha ?? action.ref;
213
+ if (!at || depth > MAX_DEPTH)
214
+ continue;
215
+ const name = [action.owner, action.repo, action.subpath]
216
+ .filter(Boolean)
217
+ .join('/');
218
+ const file = await deps.fetch(`${name}@${at}`, action.kind);
219
+ let parsed;
220
+ try {
221
+ parsed = file ? (0, workflow_parser_1.parseFile)(file) : undefined;
222
+ }
223
+ catch {
224
+ parsed = undefined;
225
+ }
226
+ if (!parsed) {
227
+ deps.warn(`Could not read ${action.uses}, so the actions it uses are not listed.`);
228
+ continue;
229
+ }
230
+ for (const ref of collectReferences(parsed, { localActions: false })) {
231
+ const refPath = `${action.uses} > ${ref.path}`;
232
+ const known = byUses.get(ref.uses);
233
+ if (known) {
234
+ known.paths.push(refPath);
235
+ if (known.via && !known.via.includes(action.uses)) {
236
+ known.via = [...known.via, action.uses].sort();
237
+ }
238
+ continue;
239
+ }
240
+ const dep = toAction(ref);
241
+ if (!dep)
242
+ continue;
243
+ dep.paths.push(refPath);
244
+ dep.via = [action.uses];
245
+ if (!dep.resolvedSha && deps.resolveSha) {
246
+ dep.resolvedSha = await deps.resolveSha(dep.uses);
247
+ }
248
+ byUses.set(dep.uses, dep);
249
+ queue.push({ action: dep, depth: depth + 1 });
250
+ }
251
+ }
252
+ return [...byUses.values()];
253
+ }
254
+ /**
255
+ * Compare the current entries against a lockfile: moved commits, new and
256
+ * removed refs, and repositories whose identity changed (compared only
257
+ * when both sides know it).
258
+ */
185
259
  function diffLock(current, lock) {
186
260
  const drifted = [];
261
+ const identity = [];
187
262
  const added = [];
188
- for (const [uses, sha] of Object.entries(current)) {
263
+ for (const [uses, entry] of Object.entries(current)) {
189
264
  const known = lock.actions[uses];
190
- if (known === undefined)
265
+ if (known === undefined) {
191
266
  added.push(uses);
192
- else if (known !== sha)
193
- drifted.push({ uses, from: known, to: sha });
267
+ continue;
268
+ }
269
+ if (known.commit !== entry.commit) {
270
+ drifted.push({ uses, from: known.commit, to: entry.commit });
271
+ }
272
+ for (const [field, key] of [
273
+ ['owner_id', 'ownerId'],
274
+ ['repo_id', 'repoId']
275
+ ]) {
276
+ const from = known[key];
277
+ const to = entry[key];
278
+ if (from !== undefined && to !== undefined && from !== to) {
279
+ identity.push({ uses, field, from, to });
280
+ }
281
+ }
194
282
  }
195
283
  const removed = Object.keys(lock.actions).filter((uses) => !(uses in current));
196
284
  return {
197
- changed: drifted.length > 0 || added.length > 0 || removed.length > 0,
285
+ changed: drifted.length > 0 ||
286
+ added.length > 0 ||
287
+ removed.length > 0 ||
288
+ identity.length > 0,
198
289
  drifted,
199
290
  added: added.sort(),
200
- removed: removed.sort()
291
+ removed: removed.sort(),
292
+ identity
201
293
  };
202
294
  }
203
- /** The `uses -> resolvedSha` map an inventory contributes to a lockfile. */
204
- function lockEntries(inv) {
205
- const entries = {};
206
- for (const action of inv.actions) {
207
- if (action.resolvedSha)
208
- entries[action.uses] = action.resolvedSha;
209
- }
210
- return entries;
211
- }
212
295
  //# sourceMappingURL=inventory.js.map
@@ -96,6 +96,21 @@ function parsePipelineContract(filePath) {
96
96
  result.pins.allow = parseStringList(pins.allow, 'pins.allow');
97
97
  }
98
98
  }
99
+ if (spec.lockfile !== undefined) {
100
+ if (typeof spec.lockfile !== 'object' ||
101
+ spec.lockfile === null ||
102
+ Array.isArray(spec.lockfile)) {
103
+ throw new Error('Contract "lockfile" must be a mapping');
104
+ }
105
+ const lockfile = spec.lockfile;
106
+ result.lockfile = {};
107
+ if (lockfile.required !== undefined) {
108
+ if (typeof lockfile.required !== 'boolean') {
109
+ throw new Error('Contract "lockfile.required" must be true or false');
110
+ }
111
+ result.lockfile.required = lockfile.required;
112
+ }
113
+ }
99
114
  if (spec['allowed-owners'] !== undefined) {
100
115
  result.allowedOwners = parseStringList(spec['allowed-owners'], 'allowed-owners');
101
116
  }
@@ -40,6 +40,7 @@ const job_plan_1 = require("../runner/job-plan");
40
40
  const workflow_parser_1 = require("./workflow-parser");
41
41
  const repository_1 = require("../utils/repository");
42
42
  const action_fetcher_1 = require("../runner/action-fetcher");
43
+ const github_lockfile_1 = require("../github-lockfile");
43
44
  /** Maximum nesting of composite actions / reusable workflows. */
44
45
  const MAX_DEPTH = 10;
45
46
  /**
@@ -195,11 +196,55 @@ async function validatePipelineContract(parsed, spec, options = {}) {
195
196
  }
196
197
  }
197
198
  }
199
+ if (spec.lockfile?.required) {
200
+ errors.push(...checkLockfile(parsed, sequence.filter(isRemote)));
201
+ }
198
202
  return {
199
203
  valid: errors.every((e) => e.severity !== 'error'),
200
204
  errors
201
205
  };
202
206
  }
207
+ /**
208
+ * `lockfile.required`: the repository commits a lockfile (secureci.lock,
209
+ * else GitHub's actions.lock) and it lists every remote reference used.
210
+ * Offline: whether the locked commits still match is `inventory --check`.
211
+ */
212
+ function checkLockfile(parsed, remote) {
213
+ const source = (0, github_lockfile_1.findLockfile)((0, repository_1.repositoryRoot)(parsed.path));
214
+ if (!source) {
215
+ return [
216
+ {
217
+ path: 'lockfile',
218
+ message: 'The contract requires a lockfile, but the repository has no secureci.lock (or .github/workflows/actions.lock). Create one with: secureci inventory --update-lock',
219
+ severity: 'error'
220
+ }
221
+ ];
222
+ }
223
+ const name = path.basename(source.file);
224
+ let locked;
225
+ try {
226
+ locked = new Set(Object.keys((0, github_lockfile_1.readLockfile)(source).actions).map((uses) => {
227
+ const { name: n, version } = splitActionRef(uses);
228
+ return version === undefined ? n : `${n}@${version}`;
229
+ }));
230
+ }
231
+ catch (error) {
232
+ return [
233
+ {
234
+ path: 'lockfile',
235
+ message: `Could not read ${name}: ${error instanceof Error ? error.message : String(error)}`,
236
+ severity: 'error'
237
+ }
238
+ ];
239
+ }
240
+ return remote
241
+ .filter((u) => !locked.has(`${u.name}@${u.version}`))
242
+ .map((u) => ({
243
+ path: u.path,
244
+ message: `Action "${u.name}${u.version ? '@' + u.version : ''}" is not in ${name}; lock it with ${source.kind === 'github' ? '`gh actions-lock`' : '`secureci inventory --update-lock`'}`,
245
+ severity: 'error'
246
+ }));
247
+ }
203
248
  /** Whether job `from` (transitively) needs job `to`. */
204
249
  function needsTransitively(nodes, from, to) {
205
250
  const seen = new Set();
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);
@@ -42,6 +42,7 @@ exports.fetchAction = fetchAction;
42
42
  exports.fetchRepoFile = fetchRepoFile;
43
43
  exports.clearActionCache = clearActionCache;
44
44
  exports.resolveActionSha = resolveActionSha;
45
+ exports.resolveRepoIdentity = resolveRepoIdentity;
45
46
  const fs = __importStar(require("fs"));
46
47
  const path = __importStar(require("path"));
47
48
  const http = __importStar(require("http"));
@@ -428,4 +429,34 @@ async function resolveActionSha(uses, options = {}) {
428
429
  }
429
430
  return undefined;
430
431
  }
432
+ /**
433
+ * GitHub's numeric ids for a repository and its owner, from the REST API,
434
+ * or undefined if they can't be looked up (offline, rate-limited, gone).
435
+ * The ids survive a rename but not a delete-and-recreate, so a changed id
436
+ * means a different repository now answers to the same name.
437
+ */
438
+ async function resolveRepoIdentity(owner, repo, options = {}) {
439
+ const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
440
+ const token = options.token === undefined ? githubToken() : options.token;
441
+ const lookup = async (auth) => {
442
+ try {
443
+ const body = JSON.parse(await getText(`${apiUrl()}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}`, { ...requestHeaders(auth), Accept: 'application/vnd.github+json' }, timeoutMs));
444
+ const repoId = body.id;
445
+ const ownerId = body.owner?.id;
446
+ return typeof repoId === 'number' && typeof ownerId === 'number'
447
+ ? { identity: { ownerId, repoId } }
448
+ : {};
449
+ }
450
+ catch (error) {
451
+ return { unauthorized: error.status === 401 };
452
+ }
453
+ };
454
+ const first = await lookup(token);
455
+ if (first.identity)
456
+ return first.identity;
457
+ // A rejected token shouldn't block the lookup: retry anonymously.
458
+ if (token && first.unauthorized)
459
+ return (await lookup(null)).identity;
460
+ return undefined;
461
+ }
431
462
  //# sourceMappingURL=action-fetcher.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@secureci/cli",
3
- "version": "0.3.0",
3
+ "version": "0.5.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": {