@secureci/cli 0.1.1 → 0.3.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 +8 -3
- package/dist/cli.js +33 -11
- package/dist/github-lockfile.js +90 -0
- package/dist/init.js +3 -2
- package/dist/report.js +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Test and vet your GitHub Actions **before you push**.
|
|
|
7
7
|
`@secureci/cli` (the `secureci` command) runs your workflows locally, validates
|
|
8
8
|
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
|
-
of [SecureCI](https://
|
|
10
|
+
of [SecureCI](https://secure-ci.dev).
|
|
11
11
|
|
|
12
12
|
> 📖 This README is the quick tour. Every flag, context and edge case lives in
|
|
13
13
|
> the **[full reference → DOCS.md](DOCS.md)**.
|
|
@@ -38,8 +38,8 @@ npm install -g @secureci/cli
|
|
|
38
38
|
npx @secureci/cli --help
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
-
Node.js 20+ is required.
|
|
42
|
-
|
|
41
|
+
Node.js 20+ is required. To build from source instead:
|
|
42
|
+
`npm install && npm run build`.
|
|
43
43
|
|
|
44
44
|
## First run
|
|
45
45
|
|
|
@@ -74,6 +74,11 @@ secureci inventory --check # fail on drift (use in CI)
|
|
|
74
74
|
check.
|
|
75
75
|
- **Machine-readable output** — `--format json|sarif` drops straight into CI and
|
|
76
76
|
GitHub code scanning.
|
|
77
|
+
- **Audit evidence** — the contract, `secureci.lock` and reports are plain files
|
|
78
|
+
you can commit and keep. See
|
|
79
|
+
[using output as audit evidence](DOCS.md#using-output-as-audit-evidence) and
|
|
80
|
+
the [compliance mapping](https://secure-ci.dev/compliance) (PCI DSS, DORA,
|
|
81
|
+
NIS2, ISO 27001, SOC 2 and more).
|
|
77
82
|
|
|
78
83
|
See the [reference](DOCS.md) for the complete command list and flags.
|
|
79
84
|
|
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ const pipeline_contract_validator_1 = require("./parser/pipeline-contract-valida
|
|
|
45
45
|
const report_1 = require("./report");
|
|
46
46
|
const init_1 = require("./init");
|
|
47
47
|
const inventory_1 = require("./inventory");
|
|
48
|
+
const github_lockfile_1 = require("./github-lockfile");
|
|
48
49
|
const logger_1 = require("./utils/logger");
|
|
49
50
|
const key_value_1 = require("./utils/key-value");
|
|
50
51
|
const config_1 = require("./config");
|
|
@@ -195,7 +196,7 @@ program
|
|
|
195
196
|
.option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
|
|
196
197
|
.option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
|
|
197
198
|
.option('--update-lockfile', `Alias of --update-lock`)
|
|
198
|
-
.option('--check', `Compare the current commit SHAs against ./${LOCKFILE_NAME} and fail on drift`)
|
|
199
|
+
.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
200
|
.option('--compare', `Alias of --check`)
|
|
200
201
|
.action(async (files, options) => {
|
|
201
202
|
await runInventory(files, options);
|
|
@@ -283,38 +284,58 @@ async function runInventory(files, options) {
|
|
|
283
284
|
return;
|
|
284
285
|
}
|
|
285
286
|
const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
|
|
286
|
-
|
|
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());
|
|
287
290
|
const writeLock = () => {
|
|
288
291
|
const updated = { version: 1, actions: current };
|
|
289
292
|
fs.writeFileSync(lockPath, JSON.stringify(updated, null, 2) + '\n');
|
|
290
293
|
logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}. Commit it to track supply-chain drift.`);
|
|
291
294
|
};
|
|
292
295
|
// `--update-lock`, or bare `inventory` when no lockfile exists yet: write it.
|
|
293
|
-
if (update || (!check && !
|
|
296
|
+
if (update || (!check && !source)) {
|
|
294
297
|
writeLock();
|
|
295
298
|
return;
|
|
296
299
|
}
|
|
297
300
|
// 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`);
|
|
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`);
|
|
300
303
|
process.exit(1);
|
|
301
304
|
}
|
|
302
|
-
const
|
|
305
|
+
const github = source.kind === 'github';
|
|
306
|
+
const lockName = github ? 'actions.lock' : LOCKFILE_NAME;
|
|
307
|
+
const fixHint = github
|
|
308
|
+
? 'run `gh actions-lock` to update actions.lock'
|
|
309
|
+
: '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
|
+
logger_1.logger.info(`Checking against ${github ? github_lockfile_1.GITHUB_LOCKFILE : LOCKFILE_NAME}`);
|
|
303
320
|
const diff = (0, inventory_1.diffLock)(current, lock);
|
|
304
321
|
for (const d of diff.drifted) {
|
|
305
322
|
logger_1.logger.error(`Drift: ${d.uses} now resolves to ${d.to.slice(0, 12)} (was ${d.from.slice(0, 12)})`);
|
|
306
323
|
}
|
|
307
|
-
diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${
|
|
308
|
-
|
|
324
|
+
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.
|
|
327
|
+
if (!github) {
|
|
328
|
+
diff.removed.forEach((u) => logger_1.logger.info(`In ${lockName} but no longer used: ${u}`));
|
|
329
|
+
}
|
|
309
330
|
if (diff.drifted.length > 0) {
|
|
310
|
-
logger_1.logger.error(`Action commits drifted from ${
|
|
331
|
+
logger_1.logger.error(`Action commits drifted from ${lockName}. Review the change, then ${fixHint}.`);
|
|
311
332
|
process.exit(1);
|
|
312
333
|
}
|
|
313
334
|
if (diff.added.length > 0) {
|
|
314
|
-
logger_1.logger.warn(`New actions are not yet in ${
|
|
335
|
+
logger_1.logger.warn(`New actions are not yet in ${lockName} (${fixHint}).`);
|
|
315
336
|
process.exit(1);
|
|
316
337
|
}
|
|
317
|
-
logger_1.logger.success(`No action drift against ${
|
|
338
|
+
logger_1.logger.success(`No action drift against ${lockName}.`);
|
|
318
339
|
}
|
|
319
340
|
async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
320
341
|
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
@@ -413,6 +434,7 @@ function runInit(options) {
|
|
|
413
434
|
process.exit(1);
|
|
414
435
|
}
|
|
415
436
|
const content = (0, init_1.renderInitWorkflow)({
|
|
437
|
+
version: readVersion(),
|
|
416
438
|
contract: options.contract,
|
|
417
439
|
upload: options.upload
|
|
418
440
|
});
|
|
@@ -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/init.js
CHANGED
|
@@ -3,7 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.renderInitWorkflow = renderInitWorkflow;
|
|
4
4
|
/**
|
|
5
5
|
* Render a ready-to-commit `.github/workflows/secureci.yml` that runs SecureCI
|
|
6
|
-
* on every pull request via the published `secure-ci/cli` action
|
|
6
|
+
* on every pull request via the published `secure-ci/cli` action, pinned to
|
|
7
|
+
* the release tag matching this CLI's version.
|
|
7
8
|
*
|
|
8
9
|
* The output is deterministic for a given set of options, so it can be
|
|
9
10
|
* regenerated and diffed. With uploads on (the default) the workflow asks for
|
|
@@ -26,7 +27,7 @@ function renderInitWorkflow(options) {
|
|
|
26
27
|
];
|
|
27
28
|
if (upload)
|
|
28
29
|
lines.push(' security-events: write');
|
|
29
|
-
lines.push('', 'jobs:', ' secureci:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4',
|
|
30
|
+
lines.push('', 'jobs:', ' secureci:', ' runs-on: ubuntu-latest', ' steps:', ' - uses: actions/checkout@v4', ` - uses: secure-ci/cli@v${options.version}`);
|
|
30
31
|
const withLines = [];
|
|
31
32
|
if (contract)
|
|
32
33
|
withLines.push(` contract: ${yamlScalar(contract)}`);
|
package/dist/report.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@secureci/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
}
|
|
43
43
|
],
|
|
44
44
|
"license": "MIT",
|
|
45
|
-
"homepage": "https://
|
|
45
|
+
"homepage": "https://secure-ci.dev",
|
|
46
46
|
"repository": {
|
|
47
47
|
"type": "git",
|
|
48
48
|
"url": "git+https://github.com/secure-ci/cli.git"
|