@cairnkeep/cli 2.22.4 → 2.22.5
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/CHANGELOG.md +23 -0
- package/docs/learning/CURRICULUM-MAP.md +1 -1
- package/docs/learning/FEATURE-GUIDE.md +1 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +1 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +1 -1
- package/docs/learning/lessons/L24-okf-exchange.md +1 -1
- package/docs/learning/lessons/L25-playbooks.md +1 -1
- package/docs/learning/lessons/L26-context-intelligence.md +1 -1
- package/docs/learning/lessons/L27-ecosystem-evidence.md +1 -1
- package/docs/learning/lessons/L28-security-assurance.md +22 -2
- package/docs/learning/tracks/operator.md +6 -1
- package/docs/learning/video-scripts/V28-security-assurance.md +20 -0
- package/docs/releasing.md +58 -0
- package/docs/security-assurance.md +15 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/note-store.js +24 -4
- package/package.json +1 -1
- package/scripts/lib/release-verification.mjs +131 -0
- package/scripts/test-release-verification.mjs +109 -0
- package/scripts/test-release-verification.sh +4 -0
- package/scripts/test-setup-reconcile.mjs +4 -2
- package/scripts/test-windows-native.mjs +1 -0
- package/scripts/verify-phase19-runtime-evidence.mjs +43 -3
- package/scripts/verify-published-release.mjs +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,29 @@ All notable user-facing changes are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [2.22.5] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Operations
|
|
11
|
+
|
|
12
|
+
- Add an explicit post-publication verifier for exact-tree CI candidates,
|
|
13
|
+
release and registry bytes, npm signatures/attestations, anonymous
|
|
14
|
+
dual-architecture OCI images, bound SBOMs and exact-source signed provenance.
|
|
15
|
+
Refuse partial verification and report replacement; keep artifact evidence
|
|
16
|
+
separate from deployment readiness. Add offline positive/mutation controls.
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
|
|
20
|
+
- Bind note-transaction pre-image hashes to bounded inspected descriptors;
|
|
21
|
+
reject linked, replaced, growing or over-64-MiB files before accepting their
|
|
22
|
+
hashes. Create developer note fixtures exclusively without overwriting a
|
|
23
|
+
competing file. Add actual runtime replacement and fixture race controls.
|
|
24
|
+
- Use bounded descriptor reads for developer benchmark/import/note snapshots,
|
|
25
|
+
capability leases, corrupt-database controls and runtime-evidence files.
|
|
26
|
+
Preserve existing corruption, permissions and exact-byte assertions; keep
|
|
27
|
+
remote-context test labels static so provider values cannot forge log lines.
|
|
28
|
+
These controls do not make note publication a directory-wide transaction or
|
|
29
|
+
certify that every historical security-analysis finding is resolved.
|
|
30
|
+
|
|
8
31
|
## [2.22.4] - 2026-10-07
|
|
9
32
|
|
|
10
33
|
### Security
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 15 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.5
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -26,7 +26,7 @@ ephemeral; the named course volume is persistent.
|
|
|
26
26
|
1. Pull the public image:
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
|
-
podman pull ghcr.io/cairnkeep/cairnkeep:2.22.
|
|
29
|
+
podman pull ghcr.io/cairnkeep/cairnkeep:2.22.5
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
2. Start the stdio server with a read-only root filesystem and no Linux
|
|
@@ -38,7 +38,7 @@ ephemeral; the named course volume is persistent.
|
|
|
38
38
|
--read-only --cap-drop=all --security-opt=no-new-privileges \
|
|
39
39
|
--tmpfs=/tmp:rw,noexec,nosuid,size=64m,mode=1777 \
|
|
40
40
|
--volume cairnkeep-course-data:/data:Z,U \
|
|
41
|
-
ghcr.io/cairnkeep/cairnkeep:2.22.
|
|
41
|
+
ghcr.io/cairnkeep/cairnkeep:2.22.5 stdio
|
|
42
42
|
```
|
|
43
43
|
|
|
44
44
|
3. The process waits quietly for MCP input. Press `Ctrl-C` after confirming it
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Quickstart
|
|
5
5
|
**Time:** 25 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -47,7 +47,7 @@ sync. Codex project trust remains an explicit operator decision.
|
|
|
47
47
|
2. Install Cairnkeep:
|
|
48
48
|
|
|
49
49
|
```bash
|
|
50
|
-
npm install --global @cairnkeep/cli@2.22.
|
|
50
|
+
npm install --global @cairnkeep/cli@2.22.5
|
|
51
51
|
cairn --version
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
**Status:** Ready
|
|
4
4
|
**Track:** Operator
|
|
5
5
|
**Time:** 30 minutes
|
|
6
|
-
**Tested with:** Cairnkeep 2.22.
|
|
6
|
+
**Tested with:** Cairnkeep 2.22.5, Windows x64, and Node.js 22 or newer
|
|
7
7
|
|
|
8
8
|
## Outcome
|
|
9
9
|
|
|
@@ -16,7 +16,7 @@ perform a reversible uninstall without relying on WSL or Git Bash.
|
|
|
16
16
|
From PowerShell:
|
|
17
17
|
|
|
18
18
|
```powershell
|
|
19
|
-
npm install --global @cairnkeep/cli@2.22.
|
|
19
|
+
npm install --global @cairnkeep/cli@2.22.5
|
|
20
20
|
cairn version
|
|
21
21
|
|
|
22
22
|
$Lab = Join-Path $env:TEMP 'Cairnkeep Windows Lab'
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# L28 - Verify Cairnkeep's security posture
|
|
2
2
|
|
|
3
3
|
**Status:** Ready
|
|
4
|
-
**Tested with:** Cairnkeep 2.22.
|
|
4
|
+
**Tested with:** Cairnkeep 2.22.5 and Node.js 22 or newer
|
|
5
5
|
|
|
6
6
|
## Outcome
|
|
7
7
|
|
|
@@ -11,7 +11,7 @@ and choose least authority for a network-facing MCP client.
|
|
|
11
11
|
|
|
12
12
|
## Prerequisites
|
|
13
13
|
|
|
14
|
-
- Install Cairnkeep 2.22.
|
|
14
|
+
- Install Cairnkeep 2.22.5 or use a disposable source checkout.
|
|
15
15
|
- Complete [L20 - Least-authority MCP tool profiles](L20-mcp-tool-profiles.md).
|
|
16
16
|
- Use synthetic values only; never paste a real bearer token into a recording,
|
|
17
17
|
terminal transcript, or issue.
|
|
@@ -76,6 +76,8 @@ node mcp-memory-server/scripts/smoke-outbound-security.mjs
|
|
|
76
76
|
node scripts/test-runtime-file-security.mjs
|
|
77
77
|
node scripts/test-omp-staged-security.mjs
|
|
78
78
|
node scripts/test-container-health.mjs
|
|
79
|
+
node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
|
|
80
|
+
node scripts/verify-phase19-runtime-evidence.mjs --self-test
|
|
79
81
|
```
|
|
80
82
|
|
|
81
83
|
The baseline rejects unpinned external Actions, `pull_request_target` and
|
|
@@ -89,6 +91,15 @@ These are static contract checks, not a live publication test. The npm secret
|
|
|
89
91
|
is a separate credential, and steps within one job share its token authority.
|
|
90
92
|
Do not republish an immutable version to demonstrate the test.
|
|
91
93
|
|
|
94
|
+
For post-publication evidence, run `node scripts/test-release-verification.mjs`
|
|
95
|
+
and read [Repeatable post-publication verification](../../releasing.md#repeatable-post-publication-verification).
|
|
96
|
+
The offline mutations reject mismatched trees/assets and failed signature or
|
|
97
|
+
provenance decisions; they do not validate real signatures. The explicit live
|
|
98
|
+
verifier checks the published artifacts with npm and the GitHub CLI and writes
|
|
99
|
+
a new report only after success. Its report says `deployment_verified: false`:
|
|
100
|
+
server backups, canaries and project doctors remain separate rollout evidence.
|
|
101
|
+
Never publish or upgrade a service just to record this exercise.
|
|
102
|
+
|
|
92
103
|
The smoke tests cover token non-disclosure, request bounds, and deterministic
|
|
93
104
|
portable-path adversarial cases.
|
|
94
105
|
|
|
@@ -141,6 +152,15 @@ retrieval still sends a query to the explicitly configured external provider.
|
|
|
141
152
|
AnythingLLM responses have an 8 MiB cap; OpenViking retains its 1 MiB cap and
|
|
142
153
|
separate consent gates. Prefer HTTPS outside loopback.
|
|
143
154
|
|
|
155
|
+
In a checkout with the developer-evidence follow-up, the focused note control
|
|
156
|
+
shows that exclusive fixture creation preserves a competing file and that the
|
|
157
|
+
actual note transaction rejects a substituted pre-image. Hashing is bounded
|
|
158
|
+
at 64 MiB, including same-inode growth checks. The runtime-evidence self-test
|
|
159
|
+
rejects changed or replaced log files under its existing 16 MiB cap. Developer
|
|
160
|
+
snapshots and corrupt-database controls still compare exact bytes; static log
|
|
161
|
+
labels cannot turn provider text into forged passing results. This does not
|
|
162
|
+
make multi-file note publication atomic or prove an empty analysis backlog.
|
|
163
|
+
|
|
144
164
|
## Common failures
|
|
145
165
|
|
|
146
166
|
- A private `.ai` file is group/world accessible on POSIX: restore mode `0600`.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**Audience:** advanced users and platform maintainers
|
|
4
4
|
**Prerequisite:** Practitioner track; L13 before operating captured evidence
|
|
5
|
-
**Total time:** about 6 hours 45 minutes
|
|
5
|
+
**Total time:** about 6 hours 45 minutes, plus optional evidence/security supplements
|
|
6
6
|
|
|
7
7
|
1. [L09 - Domain knowledge with RAG](../lessons/L09-domain-knowledge.md)
|
|
8
8
|
2. [L10 - Faster context exploration](../lessons/L10-context-exploration.md)
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
11. [L24 - Reviewed knowledge exchange](../lessons/L24-okf-exchange.md)
|
|
18
18
|
12. [L25 - Bounded workflow playbooks](../lessons/L25-playbooks.md)
|
|
19
19
|
13. [L26 - Context intelligence with explicit authority](../lessons/L26-context-intelligence.md)
|
|
20
|
+
14. [L27 - Evaluate ecosystem ideas with bounded evidence](../lessons/L27-ecosystem-evidence.md)
|
|
21
|
+
15. [L28 - Verify Cairnkeep's security posture](../lessons/L28-security-assurance.md)
|
|
20
22
|
|
|
21
23
|
All modules are optional. The track is complete when the operator can add and
|
|
22
24
|
remove each integration without breaking the standalone local workflow and can
|
|
@@ -27,6 +29,9 @@ L24 adds portable exchange only after the immutable-pack and privacy boundaries
|
|
|
27
29
|
are understood.
|
|
28
30
|
L25 adds policy enforcement and the unauthenticated local actor boundary before
|
|
29
31
|
any future team deployment.
|
|
32
|
+
L27 separates protocol observations from measured task benefits. L28 adds
|
|
33
|
+
deployment posture, adversarial source-checkout controls and exact-source
|
|
34
|
+
release verification; a verified artifact is not a verified fleet rollout.
|
|
30
35
|
|
|
31
36
|
Hands-on spine: `course-04-operation`, `course-06-governance`, and
|
|
32
37
|
`course-07-evaluation`, `course-10-trust-context`, and
|
|
@@ -121,6 +121,26 @@ permissions, but steps in a job still share authority and the npm secret is
|
|
|
121
121
|
separate. The baseline checks declared grants; do not present it as a successful
|
|
122
122
|
live publish or rerun a release merely for this recording.
|
|
123
123
|
|
|
124
|
+
In the release-verification supplement, run
|
|
125
|
+
`node scripts/test-release-verification.mjs` using only the synthetic corpus.
|
|
126
|
+
Show a mismatched tree, altered asset and failed provenance decision, then
|
|
127
|
+
contrast offline policy coverage with the explicitly invoked live verifier in
|
|
128
|
+
`docs/releasing.md`. Show only public artifact metadata from an already
|
|
129
|
+
completed release, never tokens or raw diagnostics. Pause on
|
|
130
|
+
`deployment_verified: false`: signed artifact evidence does not prove that a
|
|
131
|
+
server was upgraded or its project routes still work. Do not publish or
|
|
132
|
+
upgrade production for a demonstration.
|
|
133
|
+
|
|
134
|
+
For the developer-evidence supplement, run
|
|
135
|
+
`node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only` and
|
|
136
|
+
`node scripts/verify-phase19-runtime-evidence.mjs --self-test` in a built source
|
|
137
|
+
checkout. Pause on the competing-file preservation and actual transaction
|
|
138
|
+
pre-image replacement controls. Explain that hashing uses one bounded inspected
|
|
139
|
+
descriptor, not a pathname check followed by reopening. Contrast integrity of
|
|
140
|
+
a read with atomicity of a multi-file transaction: this patch improves the
|
|
141
|
+
former, not the latter. Use synthetic files and keep historical alert status
|
|
142
|
+
separate from a green test result; record only after the release gates pass.
|
|
143
|
+
|
|
124
144
|
### 9:20–10:30 — Read the residual risks
|
|
125
145
|
|
|
126
146
|
End on `docs/security-assurance.md`, not on a green checkmark. Call out the
|
package/docs/releasing.md
CHANGED
|
@@ -68,6 +68,64 @@ different version or tree.
|
|
|
68
68
|
|
|
69
69
|
## Repository configuration
|
|
70
70
|
|
|
71
|
+
### Repeatable post-publication verification
|
|
72
|
+
|
|
73
|
+
From a source checkout, use the maintained artifact verifier after the normal
|
|
74
|
+
publication workflow succeeds:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
node scripts/verify-published-release.mjs \
|
|
78
|
+
--version X.Y.Z --commit RELEASE_COMMIT --tree RELEASE_TREE \
|
|
79
|
+
--ci-run SUCCESSFUL_CI_RUN --security-run SUCCESSFUL_SECURITY_RUN \
|
|
80
|
+
--publish-run SUCCESSFUL_PUBLISH_RUN \
|
|
81
|
+
--out /private/evidence/release-verification.json
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Provide the exact 40-character commit and tree and numeric run IDs. The output
|
|
85
|
+
parent must already exist. The tool refuses to replace an existing report and
|
|
86
|
+
writes one only after all checks succeed, with mode `0600` on POSIX. It supports
|
|
87
|
+
stable releases whose npm version and both `latest` image tags are the expected
|
|
88
|
+
release; it does not reinterpret an old release as the current channel.
|
|
89
|
+
|
|
90
|
+
It requires a GitHub CLI supporting `attestation verify` and its exact-source,
|
|
91
|
+
workflow, certificate-identity and hosted-runner policy flags, plus a current
|
|
92
|
+
npm supporting registry-signature and provenance verification. Use `--gh PATH`
|
|
93
|
+
or `--npm PATH` for explicit tool selection; stale trust data or unsupported
|
|
94
|
+
flags fail closed rather than skipping a check. No runtime dependency is added.
|
|
95
|
+
|
|
96
|
+
The tool checks successful maintained CI, security and publication workflows, immutable
|
|
97
|
+
tag/commit/tree binding, retained CI candidate checksums and exact release-asset
|
|
98
|
+
bytes, CycloneDX 1.6, registry bytes/integrity, npm signatures/attestations, both
|
|
99
|
+
anonymous OCI architectures, per-architecture bound SPDX documents and signed
|
|
100
|
+
exact-source publication provenance. It uses bounded network responses and
|
|
101
|
+
subprocess deadlines. Registry blob redirects are limited to the designated
|
|
102
|
+
GitHub container storage host, without forwarding registry authorization.
|
|
103
|
+
|
|
104
|
+
Verification downloads assets and installs the exact registry package into
|
|
105
|
+
private disposable staging with lifecycle scripts disabled; staging is removed
|
|
106
|
+
on success or failure. It does not publish, move tags, change configuration,
|
|
107
|
+
restart a server, enable capture or upgrade a machine. The report explicitly
|
|
108
|
+
states `deployment_verified: false`: separately run the maintained backup,
|
|
109
|
+
upgrade/rollback, routed/legacy-session and project-doctor procedures. Artifact
|
|
110
|
+
verification is not deployment evidence or a security certification.
|
|
111
|
+
Anonymous OCI checks read manifests, configuration and bound SBOM blobs, not
|
|
112
|
+
every image layer. The report records `full_image_layer_pull_verified: false`;
|
|
113
|
+
the separate anonymous full-pull release gate above still applies.
|
|
114
|
+
|
|
115
|
+
Run the offline positive and mutation controls without network access:
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
node scripts/test-release-verification.mjs
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
These controls inject failed workflows, different trees/tags, expired
|
|
122
|
+
candidates, tampered assets, unsafe checksum paths, registry mismatches,
|
|
123
|
+
signature/provenance failures, missing architectures and unbound SBOMs. They
|
|
124
|
+
test policy decisions using synthetic inputs, not real cryptographic signatures;
|
|
125
|
+
the live verifier delegates signature validation to npm and the GitHub CLI.
|
|
126
|
+
|
|
127
|
+
### Publication permissions
|
|
128
|
+
|
|
71
129
|
The repository must provide an `NPM_TOKEN` Actions secret authorized to publish
|
|
72
130
|
`@cairnkeep/cli`. Keep that credential out of local files and rotate it according
|
|
73
131
|
to the npm account's security policy. Workflow defaults grant only
|
|
@@ -164,6 +164,8 @@ node mcp-memory-server/scripts/smoke-outbound-security.mjs
|
|
|
164
164
|
node scripts/test-runtime-file-security.mjs
|
|
165
165
|
node scripts/test-omp-staged-security.mjs
|
|
166
166
|
node scripts/test-container-health.mjs
|
|
167
|
+
node mcp-memory-server/scripts/smoke-note-mcp.mjs --fixture-security-only
|
|
168
|
+
node scripts/verify-phase19-runtime-evidence.mjs --self-test
|
|
167
169
|
```
|
|
168
170
|
|
|
169
171
|
Build the server first when testing a source checkout. These regressions do not
|
|
@@ -171,6 +173,19 @@ assert that every historical CodeQL/Scorecard alert is fixed. Triage findings
|
|
|
171
173
|
individually and use a fresh default-branch analysis to confirm alert status;
|
|
172
174
|
never dismiss an uninvestigated finding merely to improve the score.
|
|
173
175
|
|
|
176
|
+
The developer-evidence follow-up also hardens the shared runtime note pre-image
|
|
177
|
+
hasher: it reads from an inspected descriptor under a 64 MiB cap and rejects
|
|
178
|
+
replacement or growth before accepting a hash. Missing targets remain valid
|
|
179
|
+
pre-images; linked or unsafe targets do not. Fixture creation uses exclusive
|
|
180
|
+
creation, retaining a file created by a competing writer. Regression controls
|
|
181
|
+
exercise both the actual transaction entry point and fixture creation.
|
|
182
|
+
Developer snapshots retain exact hashes and modes from the same bounded read;
|
|
183
|
+
the runtime-evidence verifier retains its 16 MiB cap and rejects both growth
|
|
184
|
+
and replacement. Corrupt SQLite fixtures must still remain failed and unchanged
|
|
185
|
+
after doctor. Remote-context test labels never interpolate provider values.
|
|
186
|
+
These are read-integrity controls, not atomic multi-file note publication or a
|
|
187
|
+
same-account sandbox. Re-run the full native-platform gates before shipping.
|
|
188
|
+
|
|
174
189
|
The bounded-input control exercises real evaluation, OKF import/export, graph
|
|
175
190
|
status, receipt and progressive-cache readers. Valid-file substitution and
|
|
176
191
|
same-inode growth are injected at actual inspection/read boundaries; the test
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.
|
|
1
|
+
{"identifier":"cairn-offline-fake-v1","package_version":"2.22.5","schema_version":1,"task_set_digest":"13e95379f35839593047ec34521a65a8fa3ab92bd3993bca9dc8bf5c4a9ff36f"}
|
|
@@ -6,6 +6,7 @@ import { buildFailureSignature } from "./failure-signature.js";
|
|
|
6
6
|
import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
|
|
7
7
|
import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
|
|
8
8
|
import { atomicReplace } from "./platform-security.js";
|
|
9
|
+
import { readStableFile } from "./stable-file.js";
|
|
9
10
|
const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
|
|
10
11
|
const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
|
|
11
12
|
const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
|
|
@@ -569,7 +570,22 @@ export function doctorNoteStore(repair = false, options = {}) {
|
|
|
569
570
|
}
|
|
570
571
|
}
|
|
571
572
|
function fileHash(path) {
|
|
572
|
-
|
|
573
|
+
try {
|
|
574
|
+
const { bytes } = readStableFile(path, { label: "Note transaction pre-image", maxBytes: 64 * 1024 * 1024 });
|
|
575
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
576
|
+
}
|
|
577
|
+
catch (error) {
|
|
578
|
+
// Missing targets are valid pre-images. Diagnose absence only after
|
|
579
|
+
// the descriptor-first read failed; never authorize a read by a check.
|
|
580
|
+
try {
|
|
581
|
+
lstatSync(path);
|
|
582
|
+
}
|
|
583
|
+
catch (missing) {
|
|
584
|
+
if (missing.code === "ENOENT")
|
|
585
|
+
return null;
|
|
586
|
+
}
|
|
587
|
+
throw error;
|
|
588
|
+
}
|
|
573
589
|
}
|
|
574
590
|
function bytesHash(bytes) {
|
|
575
591
|
return bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
|
|
@@ -1024,9 +1040,13 @@ export async function commitNoteImport(plan) {
|
|
|
1024
1040
|
export function createNoteMutationFixture(options) {
|
|
1025
1041
|
const notesRoot = join(options.storeRoot, ".cairnkeep-note-fixture");
|
|
1026
1042
|
const target = join(notesRoot, "live.txt");
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
writeFileSync(target, "before\n");
|
|
1043
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1044
|
+
try {
|
|
1045
|
+
writeFileSync(target, "before\n", { flag: "wx", mode: 0o600 });
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
if (error.code !== "EEXIST")
|
|
1049
|
+
throw error;
|
|
1030
1050
|
}
|
|
1031
1051
|
const bytes = options.operation === "delete" ? null : `${options.operation}-after\n`;
|
|
1032
1052
|
return { schema_version: 1, operation: options.operation, notes_root: notesRoot, changes: [{ path: target, bytes, before_hash: fileHash(target), final_hash: bytesHash(bytes) }], result: { ok: true, fixture: true } };
|
package/package.json
CHANGED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function requireThat(condition, label) { if (!condition) throw new Error(`Release verification failed: ${label}.`); }
|
|
4
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
5
|
+
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
|
|
7
|
+
export function validateReleaseOptions(options) {
|
|
8
|
+
requireThat(/^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(options.repo ?? ""), "repository");
|
|
9
|
+
requireThat(/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(options.version ?? ""), "stable version");
|
|
10
|
+
for (const field of ["commit", "tree"]) requireThat(/^[a-f0-9]{40}$/.test(options[field] ?? ""), `exact ${field}`);
|
|
11
|
+
for (const field of ["ciRun", "securityRun", "publishRun"]) requireThat(/^[1-9][0-9]*$/.test(options[field] ?? ""), field);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function releaseAttestationArguments(image, digest, options) {
|
|
15
|
+
validateReleaseOptions(options);
|
|
16
|
+
requireThat(digestPattern.test(digest), "attestation subject digest");
|
|
17
|
+
requireThat(image === options.repo || image === `${options.repo}-workspace`, "attestation image repository");
|
|
18
|
+
// Exact certificate identity binds repository, workflow and tag. The CLI
|
|
19
|
+
// makes this mutually exclusive with its broader signer-workflow selector.
|
|
20
|
+
return ["attestation", "verify", `oci://ghcr.io/${image}@${digest}`, "--repo", options.repo,
|
|
21
|
+
"--source-ref", `refs/tags/v${options.version}`, "--source-digest", options.commit,
|
|
22
|
+
"--cert-identity", `https://github.com/${options.repo}/.github/workflows/publish.yml@refs/tags/v${options.version}`,
|
|
23
|
+
"--cert-oidc-issuer", "https://token.actions.githubusercontent.com",
|
|
24
|
+
"--predicate-type", "https://slsa.dev/provenance/v1", "--deny-self-hosted-runners", "--format", "json"];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function json(bytes, label) {
|
|
28
|
+
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
|
|
29
|
+
catch { throw new Error(`Release verification failed: ${label} encoding or JSON.`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function verifyAssets(options, candidate, released) {
|
|
33
|
+
const names = [`cairnkeep-cli-${options.version}.tgz`, `cairnkeep-cli-${options.version}.cdx.json`];
|
|
34
|
+
const exactKeys = (map, expected) => map instanceof Map && JSON.stringify([...map.keys()].sort()) === JSON.stringify(expected.sort());
|
|
35
|
+
requireThat(exactKeys(candidate, [...names, "SHA256SUMS", "RELEASE-CANDIDATE.json"]), "candidate file set");
|
|
36
|
+
requireThat(exactKeys(released, [...names, "SHA256SUMS"]), "release asset set");
|
|
37
|
+
const manifest = json(candidate.get("RELEASE-CANDIDATE.json"), "candidate manifest");
|
|
38
|
+
requireThat(Object.keys(manifest).sort().join(",") === "schema_version,tree,version"
|
|
39
|
+
&& manifest.schema_version === 1 && manifest.tree === options.tree && manifest.version === options.version, "candidate manifest binding");
|
|
40
|
+
const sums = new TextDecoder("utf-8", { fatal: true }).decode(candidate.get("SHA256SUMS")).trim().split("\n");
|
|
41
|
+
requireThat(sums.length === names.length, "checksum file count");
|
|
42
|
+
const seen = new Set();
|
|
43
|
+
for (const line of sums) {
|
|
44
|
+
const match = /^([a-f0-9]{64})\s+\*?(?:\.\/)?([A-Za-z0-9._-]+)$/.exec(line);
|
|
45
|
+
requireThat(match && names.includes(match[2]) && !seen.has(match[2]), "checksum paths and uniqueness");
|
|
46
|
+
seen.add(match[2]);
|
|
47
|
+
requireThat(hash(candidate.get(match[2])) === match[1], "candidate checksum");
|
|
48
|
+
requireThat(candidate.get(match[2]).equals(released.get(match[2])), "release/candidate exact bytes");
|
|
49
|
+
}
|
|
50
|
+
requireThat(candidate.get("SHA256SUMS").equals(released.get("SHA256SUMS")), "checksum asset exact bytes");
|
|
51
|
+
const sbom = json(candidate.get(names[1]), "SBOM");
|
|
52
|
+
requireThat(sbom.bomFormat === "CycloneDX" && sbom.specVersion === "1.6"
|
|
53
|
+
&& sbom.metadata?.component?.version === options.version, "CycloneDX release binding");
|
|
54
|
+
return candidate.get(names[0]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function verifyPublishedRelease(options, io) {
|
|
58
|
+
validateReleaseOptions(options);
|
|
59
|
+
const api = path => io.api(path);
|
|
60
|
+
const ci = await api(`actions/runs/${options.ciRun}`);
|
|
61
|
+
requireThat(ci.conclusion === "success" && ci.path === ".github/workflows/ci.yml", "successful maintained CI");
|
|
62
|
+
requireThat((await api(`git/commits/${ci.head_sha}`)).tree?.sha === options.tree, "tested tree");
|
|
63
|
+
requireThat((await api(`git/commits/${options.commit}`)).tree?.sha === options.tree, "release tree");
|
|
64
|
+
const security = await api(`actions/runs/${options.securityRun}`);
|
|
65
|
+
requireThat(security.conclusion === "success" && security.path === ".github/workflows/security.yml", "successful maintained security workflow");
|
|
66
|
+
requireThat((await api(`git/commits/${security.head_sha}`)).tree?.sha === options.tree, "security-tested tree");
|
|
67
|
+
let tag = (await api(`git/ref/tags/v${options.version}`)).object;
|
|
68
|
+
for (let depth = 0; tag?.type === "tag" && depth < 4; depth++) tag = (await api(`git/tags/${tag.sha}`)).object;
|
|
69
|
+
requireThat(tag?.type === "commit" && tag.sha === options.commit, "immutable tag source");
|
|
70
|
+
const publication = await api(`actions/runs/${options.publishRun}`);
|
|
71
|
+
requireThat(publication.conclusion === "success" && publication.path === ".github/workflows/publish.yml"
|
|
72
|
+
&& publication.event === "release" && publication.head_sha === options.commit, "successful exact-source publication");
|
|
73
|
+
const release = await api(`releases/tags/v${options.version}`);
|
|
74
|
+
requireThat(release.tag_name === `v${options.version}` && release.draft === false && release.prerelease === false, "published stable release");
|
|
75
|
+
const artifacts = (await api(`actions/runs/${options.ciRun}/artifacts?per_page=100`)).artifacts;
|
|
76
|
+
const matches = artifacts?.filter(a => a.name === `release-candidate-${options.tree}` && a.expired === false);
|
|
77
|
+
requireThat(matches?.length === 1, "one retained exact-tree candidate");
|
|
78
|
+
const tarball = verifyAssets(options, await io.candidate(matches[0]), await io.release());
|
|
79
|
+
const registry = await io.registry();
|
|
80
|
+
let url;
|
|
81
|
+
try { url = new URL(registry.dist?.tarball); } catch { /* fail below */ }
|
|
82
|
+
requireThat(registry.version === options.version && url?.protocol === "https:" && url.hostname === "registry.npmjs.org"
|
|
83
|
+
&& !url.username && !url.password && !url.port && !url.hash, "registry tarball authority");
|
|
84
|
+
const publishedTarball = await io.tarball(url.href);
|
|
85
|
+
requireThat(tarball.equals(publishedTarball), "registry/candidate exact bytes");
|
|
86
|
+
requireThat(registry.dist.integrity === `sha512-${createHash("sha512").update(tarball).digest("base64")}`, "registry integrity");
|
|
87
|
+
requireThat(await io.verifyNpm(tarball), "npm signatures and attestations");
|
|
88
|
+
|
|
89
|
+
const images = [];
|
|
90
|
+
const [owner, project] = options.repo.split("/");
|
|
91
|
+
for (const name of [project, `${project}-workspace`]) {
|
|
92
|
+
const image = `${owner}/${name}`;
|
|
93
|
+
const get = async (kind, ref) => {
|
|
94
|
+
requireThat([options.version, "latest"].includes(ref) || digestPattern.test(ref), "OCI reference");
|
|
95
|
+
const result = await io.oci(image, kind, ref);
|
|
96
|
+
requireThat(digestPattern.test(result.digest) && (!ref.startsWith("sha256:") || result.digest === ref), "OCI content digest");
|
|
97
|
+
return result;
|
|
98
|
+
};
|
|
99
|
+
const index = await get("manifests", options.version);
|
|
100
|
+
requireThat((await get("manifests", "latest")).digest === index.digest, "stable image channel");
|
|
101
|
+
const manifests = index.value.manifests;
|
|
102
|
+
requireThat(Array.isArray(manifests) && manifests.length <= 32, "bounded OCI index");
|
|
103
|
+
for (const arch of ["amd64", "arm64"]) {
|
|
104
|
+
const entries = manifests.filter(m => m.platform?.os === "linux" && m.platform?.architecture === arch);
|
|
105
|
+
requireThat(entries.length === 1, "one image per supported architecture");
|
|
106
|
+
const entry = entries[0];
|
|
107
|
+
const manifest = (await get("manifests", entry.digest)).value;
|
|
108
|
+
const config = (await get("blobs", manifest.config?.digest)).value;
|
|
109
|
+
requireThat(config.os === "linux" && config.architecture === arch
|
|
110
|
+
&& config.config?.Labels?.["org.opencontainers.image.revision"] === options.commit, "image platform/source binding");
|
|
111
|
+
const attestations = manifests.filter(m => m.annotations?.["vnd.docker.reference.digest"] === entry.digest
|
|
112
|
+
&& m.annotations?.["vnd.docker.reference.type"] === "attestation-manifest");
|
|
113
|
+
requireThat(attestations.length === 1, "bound build attestation");
|
|
114
|
+
const layers = (await get("manifests", attestations[0].digest)).value.layers;
|
|
115
|
+
requireThat(Array.isArray(layers) && layers.length <= 32, "bounded attestation layers");
|
|
116
|
+
let sbom = false;
|
|
117
|
+
for (const layer of layers) {
|
|
118
|
+
const statement = (await get("blobs", layer.digest)).value;
|
|
119
|
+
if (statement.predicateType === "https://spdx.dev/Document" && statement.predicate?.spdxVersion?.startsWith("SPDX-")
|
|
120
|
+
&& statement.subject?.some(s => s.digest?.sha256 === entry.digest.slice(7))) sbom = true;
|
|
121
|
+
}
|
|
122
|
+
requireThat(sbom, "per-architecture bound SPDX SBOM");
|
|
123
|
+
}
|
|
124
|
+
requireThat(await io.verifyAttestation(image, index.digest, options), "verified exact-source release-workflow provenance");
|
|
125
|
+
images.push({ image: `ghcr.io/${image}`, digest: index.digest, platforms: ["linux/amd64", "linux/arm64"] });
|
|
126
|
+
}
|
|
127
|
+
return { schema_version: 1, status: "verified", ...options, candidate_artifact: matches[0].id,
|
|
128
|
+
exact_bytes: true, cyclonedx: "1.6", npm_signatures_and_attestations: true, images,
|
|
129
|
+
scope: "release-artifacts-only", anonymous_oci_metadata_verified: true,
|
|
130
|
+
full_image_layer_pull_verified: false, deployment_verified: false };
|
|
131
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
|
|
9
|
+
|
|
10
|
+
const options = { repo: "example/project", version: "1.2.3", commit: "a".repeat(40), tree: "b".repeat(40), ciRun: "12", publishRun: "13", securityRun: "14" };
|
|
11
|
+
const hash = bytes => createHash("sha256").update(bytes).digest("hex");
|
|
12
|
+
function fixture() {
|
|
13
|
+
const tar = Buffer.from("synthetic-package");
|
|
14
|
+
const sbom = Buffer.from(JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.6", metadata: { component: { version: options.version } } }));
|
|
15
|
+
const sums = Buffer.from(`${hash(tar)} ./cairnkeep-cli-1.2.3.tgz\n${hash(sbom)} ./cairnkeep-cli-1.2.3.cdx.json\n`);
|
|
16
|
+
const candidate = new Map([["cairnkeep-cli-1.2.3.tgz", tar], ["cairnkeep-cli-1.2.3.cdx.json", sbom], ["SHA256SUMS", sums],
|
|
17
|
+
["RELEASE-CANDIDATE.json", Buffer.from(JSON.stringify({ schema_version: 1, tree: options.tree, version: options.version }))]]);
|
|
18
|
+
const released = new Map([...candidate].filter(([name]) => name !== "RELEASE-CANDIDATE.json"));
|
|
19
|
+
const api = new Map([
|
|
20
|
+
["actions/runs/12", { conclusion: "success", path: ".github/workflows/ci.yml", head_sha: options.commit }],
|
|
21
|
+
["actions/runs/13", { conclusion: "success", path: ".github/workflows/publish.yml", head_sha: options.commit, event: "release" }],
|
|
22
|
+
["actions/runs/14", { conclusion: "success", path: ".github/workflows/security.yml", head_sha: options.commit }],
|
|
23
|
+
[`git/commits/${options.commit}`, { tree: { sha: options.tree } }],
|
|
24
|
+
["git/ref/tags/v1.2.3", { object: { type: "commit", sha: options.commit } }],
|
|
25
|
+
["actions/runs/12/artifacts?per_page=100", { artifacts: [{ id: 5, name: `release-candidate-${options.tree}`, expired: false }] }],
|
|
26
|
+
["releases/tags/v1.2.3", { draft: false, prerelease: false, tag_name: "v1.2.3" }],
|
|
27
|
+
]);
|
|
28
|
+
const objects = new Map();
|
|
29
|
+
const add = value => { const digest = `sha256:${hash(Buffer.from(JSON.stringify(value)))}`; objects.set(digest, value); return digest; };
|
|
30
|
+
const manifests = [];
|
|
31
|
+
for (const architecture of ["amd64", "arm64"]) {
|
|
32
|
+
const config = add({ os: "linux", architecture, config: { Labels: { "org.opencontainers.image.revision": options.commit } } });
|
|
33
|
+
const image = add({ config: { digest: config } });
|
|
34
|
+
manifests.push({ digest: image, platform: { os: "linux", architecture } });
|
|
35
|
+
const statement = add({ predicateType: "https://spdx.dev/Document", subject: [{ digest: { sha256: image.slice(7) } }], predicate: { spdxVersion: "SPDX-2.3" } });
|
|
36
|
+
const attestation = add({ layers: [{ digest: statement }] });
|
|
37
|
+
manifests.push({ digest: attestation, annotations: { "vnd.docker.reference.digest": image, "vnd.docker.reference.type": "attestation-manifest" } });
|
|
38
|
+
}
|
|
39
|
+
const index = add({ manifests });
|
|
40
|
+
let signed = 0, signatures = 0;
|
|
41
|
+
const io = {
|
|
42
|
+
api: async path => { assert(api.has(path), `unexpected API request ${path}`); return api.get(path); },
|
|
43
|
+
candidate: async () => candidate, release: async () => released,
|
|
44
|
+
registry: async () => ({ version: options.version, dist: { tarball: "https://registry.npmjs.org/package.tgz", integrity: `sha512-${createHash("sha512").update(tar).digest("base64")}` } }),
|
|
45
|
+
tarball: async () => tar,
|
|
46
|
+
oci: async (_image, _kind, ref) => { const digest = ref.startsWith("sha256:") ? ref : index; return { digest, value: objects.get(digest) }; },
|
|
47
|
+
verifyAttestation: async (_image, digest, expected) => { assert.equal(digest, index); assert.deepEqual(expected, options); signed++; return true; },
|
|
48
|
+
verifyNpm: async bytes => { assert(bytes.equals(tar)); signatures++; return true; },
|
|
49
|
+
};
|
|
50
|
+
return { io, api, candidate, released, objects, manifests, index, counts: () => ({ signed, signatures }) };
|
|
51
|
+
}
|
|
52
|
+
const originalFetch = globalThis.fetch;
|
|
53
|
+
globalThis.fetch = () => { throw new Error("offline release controls must not use the network"); };
|
|
54
|
+
try {
|
|
55
|
+
for (const invalid of [{ repo: "../.." }, { repo: "owner/../secret" }, { version: "01.2.3" }, { version: "1.2.3-beta" },
|
|
56
|
+
{ commit: "main" }, { ciRun: "0" }, { publishRun: "not-a-run" }]) {
|
|
57
|
+
assert.throws(() => validateReleaseOptions({ ...options, ...invalid }));
|
|
58
|
+
}
|
|
59
|
+
const attestationArgs = releaseAttestationArguments("example/project", `sha256:${"d".repeat(64)}`, options);
|
|
60
|
+
for (const [flag, value] of [["--source-digest", options.commit], ["--source-ref", "refs/tags/v1.2.3"],
|
|
61
|
+
["--cert-identity", "https://github.com/example/project/.github/workflows/publish.yml@refs/tags/v1.2.3"],
|
|
62
|
+
["--cert-oidc-issuer", "https://token.actions.githubusercontent.com"], ["--predicate-type", "https://slsa.dev/provenance/v1"]]) {
|
|
63
|
+
assert.equal(attestationArgs[attestationArgs.indexOf(flag) + 1], value);
|
|
64
|
+
}
|
|
65
|
+
assert(attestationArgs.includes("--deny-self-hosted-runners"));
|
|
66
|
+
assert(!attestationArgs.includes("--signer-workflow"), "mutually exclusive identity flags cannot be combined");
|
|
67
|
+
assert.throws(() => releaseAttestationArguments("other/project", `sha256:${"d".repeat(64)}`, options));
|
|
68
|
+
const valid = fixture();
|
|
69
|
+
const report = await verifyPublishedRelease(options, valid.io);
|
|
70
|
+
assert.equal(report.status, "verified"); assert.equal(report.images.length, 2);
|
|
71
|
+
assert.equal(report.deployment_verified, false); assert.equal(report.full_image_layer_pull_verified, false);
|
|
72
|
+
assert.deepEqual(valid.counts(), { signed: 2, signatures: 1 });
|
|
73
|
+
for (const [name, mutate] of [
|
|
74
|
+
["failed CI", f => { f.api.get("actions/runs/12").conclusion = "failure"; }],
|
|
75
|
+
["wrong CI workflow", f => { f.api.get("actions/runs/12").path = ".github/workflows/other.yml"; }],
|
|
76
|
+
["failed security", f => { f.api.get("actions/runs/14").conclusion = "failure"; }],
|
|
77
|
+
["wrong security workflow", f => { f.api.get("actions/runs/14").path = ".github/workflows/other.yml"; }],
|
|
78
|
+
["different security tree", f => { const sha = "c".repeat(40); f.api.get("actions/runs/14").head_sha = sha; f.api.set(`git/commits/${sha}`, { tree: { sha: "d".repeat(40) } }); }],
|
|
79
|
+
["different tree", f => { f.api.get(`git/commits/${options.commit}`).tree.sha = "c".repeat(40); }],
|
|
80
|
+
["different tag", f => { f.api.get("git/ref/tags/v1.2.3").object.sha = "c".repeat(40); }],
|
|
81
|
+
["expired candidate", f => { f.api.get("actions/runs/12/artifacts?per_page=100").artifacts[0].expired = true; }],
|
|
82
|
+
["failed publication", f => { f.api.get("actions/runs/13").conclusion = "failure"; }],
|
|
83
|
+
["wrong publish source", f => { f.api.get("actions/runs/13").head_sha = "c".repeat(40); }],
|
|
84
|
+
["draft release", f => { f.api.get("releases/tags/v1.2.3").draft = true; }],
|
|
85
|
+
["candidate tampering", f => { f.candidate.set("cairnkeep-cli-1.2.3.tgz", Buffer.from("tampered")); }],
|
|
86
|
+
["asset tampering", f => { f.released.set("cairnkeep-cli-1.2.3.cdx.json", Buffer.from("tampered")); }],
|
|
87
|
+
["undeclared candidate", f => { f.candidate.set("extra", Buffer.from("extra")); }],
|
|
88
|
+
["checksum traversal", f => { f.candidate.set("SHA256SUMS", Buffer.from(`${"a".repeat(64)} ../escape\n`)); }],
|
|
89
|
+
["registry mismatch", f => { f.io.tarball = async () => Buffer.from("different"); }],
|
|
90
|
+
["registry credentials", f => { f.io.registry = async () => ({ version: "1.2.3", dist: { tarball: "https://secret@registry.npmjs.org/package.tgz" } }); }],
|
|
91
|
+
["signature failure", f => { f.io.verifyNpm = async () => false; }],
|
|
92
|
+
["missing architecture", f => { f.objects.get(f.index).manifests = f.manifests.filter(m => m.platform?.architecture !== "arm64"); }],
|
|
93
|
+
["wrong image revision", f => { for (const value of f.objects.values()) if (value.config?.Labels) value.config.Labels["org.opencontainers.image.revision"] = "c".repeat(40); }],
|
|
94
|
+
["unbound SBOM", f => { for (const value of f.objects.values()) if (value.subject) value.subject[0].digest.sha256 = "c".repeat(64); }],
|
|
95
|
+
["provenance failure", f => { f.io.verifyAttestation = async () => false; }],
|
|
96
|
+
]) { const f = fixture(); mutate(f); await assert.rejects(() => verifyPublishedRelease(options, f.io), undefined, name); }
|
|
97
|
+
const scratch = mkdtempSync(join(tmpdir(), "cairn-release-report-test-"));
|
|
98
|
+
try {
|
|
99
|
+
const output = join(scratch, "existing.json"); writeFileSync(output, "retained-report\n");
|
|
100
|
+
const command = fileURLToPath(new URL("verify-published-release.mjs", import.meta.url));
|
|
101
|
+
const result = spawnSync(process.execPath, [command, "--version", options.version, "--commit", options.commit,
|
|
102
|
+
"--tree", options.tree, "--ci-run", options.ciRun, "--security-run", options.securityRun, "--publish-run", options.publishRun,
|
|
103
|
+
"--out", output, "--gh", join(scratch, "missing-command")], { encoding: "utf8", timeout: 3000 });
|
|
104
|
+
assert.notEqual(result.status, 0); assert.match(result.stderr, /output already exists/);
|
|
105
|
+
assert.equal(readFileSync(output, "utf8"), "retained-report\n");
|
|
106
|
+
assert.equal(result.stdout, "");
|
|
107
|
+
} finally { rmSync(scratch, { recursive: true, force: true }); }
|
|
108
|
+
console.log("PASS: offline exact-tree release, assets, registry, signatures, architectures, SBOM and provenance controls");
|
|
109
|
+
} finally { globalThis.fetch = originalFetch; }
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { tmpdir } from "node:os";
|
|
19
19
|
import { dirname, join, resolve } from "node:path";
|
|
20
20
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
21
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
21
22
|
|
|
22
23
|
const EXPECTED_RED_EXIT = 86;
|
|
23
24
|
const RED_MARKER = "PHASE26_RED:SETUP_RECONCILE_MISSING";
|
|
@@ -172,8 +173,9 @@ async function testOwnershipBranches(reconciler, fixture, baseline) {
|
|
|
172
173
|
|
|
173
174
|
writeFileSync(ownedPath, "operator-owned\n");
|
|
174
175
|
chmodSync(ownedPath, 0o700);
|
|
175
|
-
const
|
|
176
|
-
const
|
|
176
|
+
const userSnapshot = readStableFile(ownedPath, { label: "Operator-owned setup fixture", maxBytes: 1024 * 1024 });
|
|
177
|
+
const userBytes = userSnapshot.bytes;
|
|
178
|
+
const userMtime = userSnapshot.stat.mtimeMs;
|
|
177
179
|
const diverged = await reconciler.reconcileSetupPlan(plan(fixture.target, [fixture.first]), { previousState: updated.state });
|
|
178
180
|
assertCounts(diverged, { created: 0, updated: 0, unchanged: 0, skipped: 1 });
|
|
179
181
|
assert.deepEqual(readFileSync(ownedPath), userBytes);
|
|
@@ -24,6 +24,7 @@ await import("./test-setup-diagnosis.mjs");
|
|
|
24
24
|
await import("./test-runtime-file-security.mjs");
|
|
25
25
|
await import("./test-container-health.mjs");
|
|
26
26
|
await import("./test-omp-staged-security.mjs");
|
|
27
|
+
await import("./test-release-verification.mjs");
|
|
27
28
|
const sandbox = mkdtempSync(join(tmpdir(), "cairn-windows-native-"));
|
|
28
29
|
const originalBase = process.env.CAIRN_AGENTFS_BASE_DIR;
|
|
29
30
|
const originalPackBase = process.env.CAIRN_PACK_BASE_DIR;
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import assert from "node:assert/strict";
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import { syncBuiltinESMExports } from "node:module";
|
|
5
7
|
import {
|
|
6
8
|
existsSync,
|
|
7
9
|
lstatSync,
|
|
@@ -15,6 +17,7 @@ import {
|
|
|
15
17
|
import { tmpdir } from "node:os";
|
|
16
18
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
18
21
|
|
|
19
22
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
23
|
const EVIDENCE_RELATIVE = join(
|
|
@@ -250,9 +253,7 @@ function assertNoSentinels(bytes, label) {
|
|
|
250
253
|
function readOwnedFile(root, relative) {
|
|
251
254
|
const label = safeRelative(relative, "evidence file");
|
|
252
255
|
const path = join(root, label);
|
|
253
|
-
const
|
|
254
|
-
if (!info.isFile() || info.isSymbolicLink() || info.size > 16 * 1024 * 1024) throw new Error(`${label}: unsafe evidence file`);
|
|
255
|
-
const bytes = readFileSync(path);
|
|
256
|
+
const { bytes } = readStableFile(path, { label: "Runtime evidence file", maxBytes: 16 * 1024 * 1024 });
|
|
256
257
|
assertNoSentinels(bytes, label);
|
|
257
258
|
return bytes;
|
|
258
259
|
}
|
|
@@ -762,6 +763,44 @@ function expectFailure(label, operation) {
|
|
|
762
763
|
assert.throws(operation, undefined, label);
|
|
763
764
|
}
|
|
764
765
|
|
|
766
|
+
function testEvidenceReadRaces(root) {
|
|
767
|
+
const path = join(root, "read-race.log");
|
|
768
|
+
const original = { readFileSync: fs.readFileSync, readSync: fs.readSync, lstatSync: fs.lstatSync };
|
|
769
|
+
let injected = false;
|
|
770
|
+
const grow = () => {
|
|
771
|
+
if (!injected) { injected = true; fs.appendFileSync(path, "changed-after-inspection\n"); }
|
|
772
|
+
};
|
|
773
|
+
writeFileSync(path, "original\n");
|
|
774
|
+
fs.readFileSync = (candidate, ...args) => { if (candidate === path) grow(); return original.readFileSync(candidate, ...args); };
|
|
775
|
+
fs.readSync = (...args) => { grow(); return original.readSync(...args); };
|
|
776
|
+
syncBuiltinESMExports();
|
|
777
|
+
try {
|
|
778
|
+
assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
|
|
779
|
+
"evidence reader accepted bytes changed after inspection");
|
|
780
|
+
assert.equal(injected, true, "evidence growth race was not exercised");
|
|
781
|
+
} finally { Object.assign(fs, original); syncBuiltinESMExports(); }
|
|
782
|
+
|
|
783
|
+
writeFileSync(path, "original\n");
|
|
784
|
+
const replacement = join(root, "replacement.log");
|
|
785
|
+
writeFileSync(replacement, "replacement\n");
|
|
786
|
+
injected = false;
|
|
787
|
+
fs.lstatSync = (candidate, ...args) => {
|
|
788
|
+
const result = original.lstatSync(candidate, ...args);
|
|
789
|
+
if (candidate === path && !injected) {
|
|
790
|
+
injected = true;
|
|
791
|
+
renameSync(path, join(root, "old.log"));
|
|
792
|
+
renameSync(replacement, path);
|
|
793
|
+
}
|
|
794
|
+
return result;
|
|
795
|
+
};
|
|
796
|
+
syncBuiltinESMExports();
|
|
797
|
+
try {
|
|
798
|
+
assert.throws(() => readOwnedFile(root, "read-race.log"), /unsafe|changed/,
|
|
799
|
+
"evidence reader accepted a pathname replacement");
|
|
800
|
+
assert.equal(injected, true, "evidence replacement race was not exercised");
|
|
801
|
+
} finally { Object.assign(fs, original); syncBuiltinESMExports(); }
|
|
802
|
+
}
|
|
803
|
+
|
|
765
804
|
function selfTest() {
|
|
766
805
|
const sourceCommit = requireSuccess(git(ROOT, ["rev-parse", "HEAD"]), "read self-test source commit");
|
|
767
806
|
requireCommit(sourceCommit);
|
|
@@ -787,6 +826,7 @@ function selfTest() {
|
|
|
787
826
|
"published contract mutation did not change its digest");
|
|
788
827
|
const root = mkdtempSync(join(tmpdir(), "cairn-phase19-evidence-"));
|
|
789
828
|
try {
|
|
829
|
+
testEvidenceReadRaces(root);
|
|
790
830
|
const valid = join(root, "valid");
|
|
791
831
|
writeFixture(valid, sourceCommit);
|
|
792
832
|
verifyEvidence(valid, sourceCommit);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { readStableFile } from "./lib/stable-file.mjs";
|
|
9
|
+
import { releaseAttestationArguments, validateReleaseOptions, verifyPublishedRelease } from "./lib/release-verification.mjs";
|
|
10
|
+
|
|
11
|
+
const usage = "Usage: node scripts/verify-published-release.mjs --version X.Y.Z --commit SHA --tree SHA --ci-run ID --security-run ID --publish-run ID --out FILE [--repo OWNER/REPO] [--gh PATH] [--npm PATH]";
|
|
12
|
+
function optionsFrom(args) {
|
|
13
|
+
const options = { repo: "cairnkeep/cairnkeep", gh: "gh", npm: "npm" };
|
|
14
|
+
const keys = new Map([["--version", "version"], ["--commit", "commit"], ["--tree", "tree"], ["--ci-run", "ciRun"],
|
|
15
|
+
["--security-run", "securityRun"], ["--publish-run", "publishRun"], ["--repo", "repo"], ["--gh", "gh"], ["--npm", "npm"], ["--out", "out"]]);
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
for (let i = 0; i < args.length; i += 2) {
|
|
18
|
+
const key = keys.get(args[i]);
|
|
19
|
+
if (!key || seen.has(key) || !args[i + 1] || args[i + 1].startsWith("--")) throw new Error(usage);
|
|
20
|
+
seen.add(key); options[key] = args[i + 1];
|
|
21
|
+
}
|
|
22
|
+
if (!options.out) throw new Error(usage);
|
|
23
|
+
validateReleaseOptions(options);
|
|
24
|
+
return options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(command, args, options = {}) {
|
|
28
|
+
const result = spawnSync(command, args, { encoding: "utf8", timeout: 60000, maxBuffer: 16 * 1024 * 1024, ...options });
|
|
29
|
+
if (result.error || result.status !== 0) {
|
|
30
|
+
const operation = ["api", "run", "release", "install", "audit", "attestation"].includes(args[0]) ? args[0] : "required";
|
|
31
|
+
throw new Error(`Release verification failed: ${operation} command unavailable, timed out or failed.`);
|
|
32
|
+
}
|
|
33
|
+
return result.stdout;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function bytesFrom(url, maxBytes, headers = {}, blobRedirects = false) {
|
|
37
|
+
const signal = AbortSignal.timeout(30000);
|
|
38
|
+
let response;
|
|
39
|
+
for (let redirects = 0; redirects <= 3; redirects++) {
|
|
40
|
+
response = await fetch(url, { headers, redirect: "manual", signal });
|
|
41
|
+
if (![301, 302, 303, 307, 308].includes(response.status)) break;
|
|
42
|
+
const next = new URL(response.headers.get("location"), url);
|
|
43
|
+
await response.body?.cancel();
|
|
44
|
+
if (!blobRedirects || redirects === 3 || next.protocol !== "https:"
|
|
45
|
+
|| next.hostname !== "pkg-containers.githubusercontent.com" || next.username || next.password || next.port) {
|
|
46
|
+
throw new Error("Release verification failed: unexpected registry redirect.");
|
|
47
|
+
}
|
|
48
|
+
url = next.href; headers = {}; // Never forward registry authorization.
|
|
49
|
+
}
|
|
50
|
+
if (!response.ok || !response.body) throw new Error("Release verification failed: remote response unavailable.");
|
|
51
|
+
const declared = Number(response.headers.get("content-length"));
|
|
52
|
+
if (Number.isFinite(declared) && declared > maxBytes) { await response.body.cancel(); throw new Error("Release verification failed: remote byte limit."); }
|
|
53
|
+
const reader = response.body.getReader();
|
|
54
|
+
const chunks = []; let total = 0;
|
|
55
|
+
try {
|
|
56
|
+
while (true) {
|
|
57
|
+
const { done, value } = await reader.read(); if (done) break;
|
|
58
|
+
total += value.byteLength;
|
|
59
|
+
if (total > maxBytes) throw new Error("Release verification failed: remote byte limit.");
|
|
60
|
+
chunks.push(Buffer.from(value));
|
|
61
|
+
}
|
|
62
|
+
return Buffer.concat(chunks, total);
|
|
63
|
+
} finally { await reader.cancel().catch(() => {}); reader.releaseLock(); }
|
|
64
|
+
}
|
|
65
|
+
const parse = bytes => JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
66
|
+
const digest = bytes => `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
67
|
+
|
|
68
|
+
async function main(args) {
|
|
69
|
+
if (args.length === 1 && ["--help", "-h"].includes(args[0])) { console.log(usage); return; }
|
|
70
|
+
const options = optionsFrom(args);
|
|
71
|
+
const { gh, npm, out, ...expected } = options;
|
|
72
|
+
const output = resolve(out);
|
|
73
|
+
try { fs.lstatSync(output); throw new Error("Release verification failed: output already exists."); }
|
|
74
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
75
|
+
const scratch = fs.mkdtempSync(join(tmpdir(), "cairn-release-verification-"));
|
|
76
|
+
fs.chmodSync(scratch, 0o700);
|
|
77
|
+
const ghRun = args => run(gh, args);
|
|
78
|
+
const readDirectory = directory => new Map(fs.readdirSync(directory).map(name => [name,
|
|
79
|
+
readStableFile(join(directory, name), { label: "Release verification input", maxBytes: 64 * 1024 * 1024 }).bytes]));
|
|
80
|
+
const tokens = new Map();
|
|
81
|
+
try {
|
|
82
|
+
const io = {
|
|
83
|
+
api: async suffix => JSON.parse(ghRun(["api", `repos/${expected.repo}/${suffix}`])),
|
|
84
|
+
candidate: async artifact => {
|
|
85
|
+
const directory = join(scratch, "candidate");
|
|
86
|
+
ghRun(["run", "download", expected.ciRun, "--repo", expected.repo, "--name", artifact.name, "--dir", directory]);
|
|
87
|
+
return readDirectory(directory);
|
|
88
|
+
},
|
|
89
|
+
release: async () => {
|
|
90
|
+
const directory = join(scratch, "release");
|
|
91
|
+
ghRun(["release", "download", `v${expected.version}`, "--repo", expected.repo, "--dir", directory]);
|
|
92
|
+
return readDirectory(directory);
|
|
93
|
+
},
|
|
94
|
+
registry: async () => parse(await bytesFrom(`https://registry.npmjs.org/@cairnkeep%2fcli/${expected.version}`, 8 * 1024 * 1024)),
|
|
95
|
+
tarball: async url => bytesFrom(url, 64 * 1024 * 1024),
|
|
96
|
+
verifyNpm: async tarball => {
|
|
97
|
+
const prefix = join(scratch, "npm");
|
|
98
|
+
run(npm, ["install", "--prefix", prefix, `@cairnkeep/cli@${expected.version}`, "--ignore-scripts", "--no-audit", "--no-fund", "--registry", "https://registry.npmjs.org"]);
|
|
99
|
+
const lock = parse(readStableFile(join(prefix, "package-lock.json"), { label: "Verification install lock", maxBytes: 8 * 1024 * 1024 }).bytes);
|
|
100
|
+
if (lock.packages?.["node_modules/@cairnkeep/cli"]?.integrity !== `sha512-${createHash("sha512").update(tarball).digest("base64")}`) {
|
|
101
|
+
throw new Error("Release verification failed: audited package/candidate binding.");
|
|
102
|
+
}
|
|
103
|
+
const audited = run(npm, ["audit", "signatures", "--prefix", prefix, "--registry", "https://registry.npmjs.org"]);
|
|
104
|
+
return /[1-9][0-9]* packages have verified registry signatures/.test(audited)
|
|
105
|
+
&& /[1-9][0-9]* packages have verified attestations/.test(audited);
|
|
106
|
+
},
|
|
107
|
+
oci: async (image, kind, ref) => {
|
|
108
|
+
if (!tokens.has(image)) {
|
|
109
|
+
const result = parse(await bytesFrom(`https://ghcr.io/token?service=ghcr.io&scope=repository:${image}:pull`, 64 * 1024));
|
|
110
|
+
if (typeof result.token !== "string" || !result.token) throw new Error("Release verification failed: anonymous OCI access.");
|
|
111
|
+
tokens.set(image, result.token);
|
|
112
|
+
}
|
|
113
|
+
const bytes = await bytesFrom(`https://ghcr.io/v2/${image}/${kind}/${ref}`, 16 * 1024 * 1024, {
|
|
114
|
+
Authorization: `Bearer ${tokens.get(image)}`,
|
|
115
|
+
Accept: "application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.manifest.v1+json",
|
|
116
|
+
}, kind === "blobs");
|
|
117
|
+
return { digest: digest(bytes), value: parse(bytes) };
|
|
118
|
+
},
|
|
119
|
+
verifyAttestation: async (image, imageDigest) => {
|
|
120
|
+
const verified = JSON.parse(ghRun(releaseAttestationArguments(image, imageDigest, expected)));
|
|
121
|
+
return Array.isArray(verified) && verified.length > 0;
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
const report = await verifyPublishedRelease(expected, io);
|
|
125
|
+
fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, { flag: "wx", mode: 0o600 });
|
|
126
|
+
console.log(JSON.stringify(report));
|
|
127
|
+
} finally { fs.rmSync(scratch, { recursive: true, force: true }); }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
131
|
+
const previousMask = process.umask(0o077);
|
|
132
|
+
try { await main(process.argv.slice(2)); }
|
|
133
|
+
catch (error) {
|
|
134
|
+
console.error(error.message === usage || error.message?.startsWith("Release verification failed:")
|
|
135
|
+
? error.message : "Release verification failed; no verified report was written.");
|
|
136
|
+
process.exitCode = 1;
|
|
137
|
+
} finally { process.umask(previousMask); }
|
|
138
|
+
}
|