@secureci/cli 0.10.0 → 1.0.1
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 +18 -6
- package/dist/cli.js +57 -3
- package/dist/inventory.js +52 -0
- package/dist/runner/builtin-actions.js +1 -1
- package/dist/runner/docker-action.js +1 -1
- package/dist/runner/job-runtime.js +1 -1
- package/package.json +1 -1
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=0.
|
|
76
|
+
Set `SECURECI_VERSION=1.0.1` 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=0.
|
|
79
|
+
(e.g. `curl -fsSL https://secure-ci.dev/install.sh | SECURECI_VERSION=1.0.1 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
|
|
|
@@ -149,10 +158,13 @@ secureci pin
|
|
|
149
158
|
|
|
150
159
|
### Agent skill
|
|
151
160
|
|
|
152
|
-
[`skills/secureci`](skills/secureci/SKILL.md)
|
|
153
|
-
Codex, Cursor, …)
|
|
154
|
-
`secureci
|
|
155
|
-
|
|
161
|
+
[`skills/secureci`](skills/secureci/SKILL.md) lets a coding agent (Claude Code,
|
|
162
|
+
Codex, Cursor, …) check every workflow it creates or edits (`secureci validate`,
|
|
163
|
+
`secureci pin --check`), run workflows locally (`secureci run`), and set
|
|
164
|
+
SecureCI up in a repo: pin actions, commit `secureci.lock`, and add the
|
|
165
|
+
`secure-ci/cli` action as a step in an existing job (replacing `actionlint` if
|
|
166
|
+
present). It also picks up requests that name actionlint, zizmor, nektos/act,
|
|
167
|
+
pinact and similar tools.
|
|
156
168
|
|
|
157
169
|
```bash
|
|
158
170
|
npx skills add secure-ci/cli --skill secureci
|
package/dist/cli.js
CHANGED
|
@@ -292,6 +292,36 @@ function defaultWorkflowFiles() {
|
|
|
292
292
|
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
293
293
|
.map((f) => path.join('.github', 'workflows', f));
|
|
294
294
|
}
|
|
295
|
+
/** The workflows in .github/workflows that aren't among `targets`. */
|
|
296
|
+
function otherWorkflowFiles(targets) {
|
|
297
|
+
const given = new Set(targets.map((file) => path.resolve(file)));
|
|
298
|
+
return defaultWorkflowFiles().filter((file) => !given.has(path.resolve(file)));
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* The remote references these workflows use, read locally (no network), or
|
|
302
|
+
* undefined, with a warning, when one of them can't be read.
|
|
303
|
+
*/
|
|
304
|
+
async function workflowUses(files, lockName) {
|
|
305
|
+
const uses = new Set();
|
|
306
|
+
for (const file of files) {
|
|
307
|
+
try {
|
|
308
|
+
const parsed = (0, workflow_parser_1.parseFile)(file);
|
|
309
|
+
if (parsed.type !== 'workflow')
|
|
310
|
+
continue;
|
|
311
|
+
const inv = await (0, inventory_1.buildInventory)({
|
|
312
|
+
type: 'workflow',
|
|
313
|
+
path: file,
|
|
314
|
+
file: parsed.file
|
|
315
|
+
});
|
|
316
|
+
inv.actions.forEach((action) => uses.add(action.uses));
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
logger_1.logger.warn(`Could not read ${file} (${error instanceof Error ? error.message : 'Unknown error'}), so every entry in ${lockName} is kept.`);
|
|
320
|
+
return undefined;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return uses;
|
|
324
|
+
}
|
|
295
325
|
async function runInventory(files, options) {
|
|
296
326
|
const update = options.updateLock || options.updateLockfile;
|
|
297
327
|
const check = options.check || options.compare;
|
|
@@ -479,9 +509,33 @@ async function runInventory(files, options) {
|
|
|
479
509
|
if (online) {
|
|
480
510
|
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
511
|
}
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
512
|
+
// Given some of the workflows, lock what they use and keep the other
|
|
513
|
+
// workflows' entries as locked, instead of dropping them.
|
|
514
|
+
let entries = current;
|
|
515
|
+
const others = source ? otherWorkflowFiles(targets) : [];
|
|
516
|
+
if (source && others.length > 0) {
|
|
517
|
+
let previous;
|
|
518
|
+
try {
|
|
519
|
+
previous = (0, github_lockfile_1.readLockfile)(source);
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
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.`);
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
// actions.lock records no `via`, so its transitive entries can't be
|
|
526
|
+
// told apart from unused ones: keep them all.
|
|
527
|
+
const otherUses = github
|
|
528
|
+
? undefined
|
|
529
|
+
: await workflowUses(others, lockName);
|
|
530
|
+
const merged = (0, inventory_1.mergeLock)(previous.actions, current, otherUses);
|
|
531
|
+
entries = merged.actions;
|
|
532
|
+
if (merged.kept > 0) {
|
|
533
|
+
logger_1.logger.info(`Kept ${merged.kept} action(s) from ${lockName} for the other workflows.`);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
fs.writeFileSync(lockPath, (0, github_lockfile_1.serializeSecureciLockfile)(entries));
|
|
537
|
+
const transitive = Object.values(entries).filter((e) => e.via).length;
|
|
538
|
+
logger_1.logger.success(`Wrote ${Object.keys(entries).length} action(s) to ${LOCKFILE_NAME}` +
|
|
485
539
|
(transitive > 0 ? ` (${transitive} used by other actions)` : '') +
|
|
486
540
|
'. Commit it to track supply-chain drift.');
|
|
487
541
|
return;
|
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
|
|
@@ -391,7 +391,7 @@ async function uploadArtifact(fsys, inputs, runId) {
|
|
|
391
391
|
copyFile(file.host, path.join(target, ...rel.split('/')));
|
|
392
392
|
digest.update(rel).update(fs.readFileSync(file.host));
|
|
393
393
|
}
|
|
394
|
-
const id = String(parseInt(crypto.createHash('
|
|
394
|
+
const id = String(parseInt(crypto.createHash('sha256').update(target).digest('hex').slice(0, 8), 16));
|
|
395
395
|
return ok(`Uploaded ${files.length} file(s) as artifact "${name}" to ${target}`, {
|
|
396
396
|
'artifact-id': id,
|
|
397
397
|
'artifact-url': '',
|
|
@@ -90,7 +90,7 @@ async function executeDockerAction(action, actionPath, step, actionInputs, calle
|
|
|
90
90
|
duration: Date.now() - startTime
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
-
const tag = `secureci-action-${crypto.createHash('
|
|
93
|
+
const tag = `secureci-action-${crypto.createHash('sha256').update(actionDir).digest('hex').slice(0, 12)}`;
|
|
94
94
|
logger_1.logger.info(`Building Docker action image: ${action.name} (${tag})`);
|
|
95
95
|
const buildResult = await containerRuntime.build(actionDir, tag, dockerfileRelative);
|
|
96
96
|
if (!buildResult.success) {
|
|
@@ -285,7 +285,7 @@ function actionDirInContainer(container, actionDir) {
|
|
|
285
285
|
let containerDir = container.actionDirs.get(actionDir);
|
|
286
286
|
if (!containerDir) {
|
|
287
287
|
const hash = crypto
|
|
288
|
-
.createHash('
|
|
288
|
+
.createHash('sha256')
|
|
289
289
|
.update(actionDir)
|
|
290
290
|
.digest('hex')
|
|
291
291
|
.slice(0, 12);
|