@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 +67 -13
- package/dist/contract-source.js +63 -0
- package/dist/github-lockfile.js +90 -0
- package/dist/report.js +7 -4
- package/package.json +1 -1
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
|
-
|
|
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 && !
|
|
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 (!
|
|
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
|
|
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 ${
|
|
308
|
-
|
|
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 ${
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
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);
|