Skip to content

Commit f26fef2

Browse files
committed
feat(cli,core): verify manifest.integrity at plugin publish preflight (#13464)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KX8wnyjStaZcuMyAMNsy3N
1 parent c38b7ef commit f26fef2

9 files changed

Lines changed: 426 additions & 18 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@objectstack/cli": patch
3+
"@objectstack/core": patch
4+
---
5+
6+
`os plugin publish` now verifies the artifact's own declared `manifest.integrity` digests before uploading, and refuses the publish on a digest mismatch, a declared entry with no file, or a packaged file the map does not declare (an absent map still publishes — the field is optional). The pure checker, `verifyIntegrity`, lives in `@objectstack/core` beside the artifact-signature contract. Unpack-time re-verification remains the cloud control plane's obligation (#11331) and is not changed by this release.

packages/cli/src/commands/plugin/publish.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77
* Flow:
88
* 1. Read the `.osplugin` bytes + the detached `.sig` (publisher signature).
99
* 2. Extract the compiled `objectstack.plugin.json` from inside the
10-
* artifact (id / version / name / runtime / permissions / integrity).
10+
* artifact (id / version / name / runtime / permissions / integrity),
11+
* then preflight the artifact files against the manifest's declared
12+
* per-file `integrity` digests (ADR-0025 §3.2) — refuse on any
13+
* mismatch / missing / extra file; an absent map skips the check
14+
* (the field is optional).
1115
* 3. POST /cloud/packages — ensure the sys_package row exists.
1216
* 4. POST /cloud/packages/:id/versions with `artifact_kind: 'plugin'`,
1317
* the base64 artifact, the declared manifest, the signature, and the
@@ -25,7 +29,15 @@ import { resolve as resolvePath, basename } from 'node:path';
2529
import { Args, Command, Flags } from '@oclif/core';
2630
import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js';
2731
import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js';
28-
import { OSPLUGIN_EXT, sha256Hex, readOspluginManifest } from '../../utils/osplugin.js';
32+
import {
33+
OSPLUGIN_EXT,
34+
MANIFEST_FILENAME,
35+
SIGNATURE_FILENAME,
36+
sha256Hex,
37+
readTarGz,
38+
type ArchiveFile,
39+
} from '../../utils/osplugin.js';
40+
import { verifyIntegrity, formatIntegrityViolation } from '@objectstack/core';
2941

3042
interface PostResult { ok: boolean; status: number; body: any; error?: string }
3143

@@ -80,8 +92,12 @@ export default class PluginPublish extends Command {
8092

8193
// 2. Extract the compiled manifest from inside the artifact. ────────
8294
let manifest: Record<string, any>;
95+
let archiveFiles: ArchiveFile[];
8396
try {
84-
manifest = readOspluginManifest(bytes);
97+
archiveFiles = readTarGz(bytes);
98+
const entry = archiveFiles.find((f) => f.path === MANIFEST_FILENAME);
99+
if (!entry) throw new Error(`${MANIFEST_FILENAME} not found in artifact`);
100+
manifest = JSON.parse(Buffer.from(entry.data).toString('utf8')) as Record<string, any>;
85101
} catch (err: any) {
86102
printError(`Cannot read manifest from artifact: ${err?.message ?? err}`);
87103
this.exit(1);
@@ -93,6 +109,39 @@ export default class PluginPublish extends Command {
93109
if (!id || !version) { printError('Artifact manifest is missing id or version.'); this.exit(1); return; }
94110
printStep(`${id}@${version} (${(bytes.byteLength / 1024).toFixed(1)} KB, runtime: ${manifest.runtime ?? 'unset'})`);
95111

112+
// 2b. Integrity preflight (ADR-0025 §3.2) — self-check the artifact
113+
// bytes against the manifest's own declared per-file digests before
114+
// upload. Absent map = permissive by contract (the field is
115+
// `.optional()`; artifacts built before integrity computation stay
116+
// publishable). Unpack-time re-verification remains the cloud control
117+
// plane's obligation (#11331) — this preflight does not discharge it.
118+
const declaredIntegrity = manifest.integrity;
119+
if (
120+
declaredIntegrity !== undefined && declaredIntegrity !== null
121+
&& (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity))
122+
) {
123+
printError('Artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild with `os plugin build`.');
124+
this.exit(1);
125+
return;
126+
}
127+
const integrityCheck = verifyIntegrity(
128+
archiveFiles,
129+
declaredIntegrity as Record<string, string> | undefined,
130+
{ exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] },
131+
);
132+
if (!integrityCheck.ok) {
133+
printError(`Integrity preflight failed — the artifact's bytes no longer match its own manifest \`integrity\` digests (${integrityCheck.violations.length} violation${integrityCheck.violations.length === 1 ? '' : 's'}):`);
134+
for (const v of integrityCheck.violations) console.log(` • ${formatIntegrityViolation(v)}`);
135+
console.log('\n Rebuild the artifact with `os plugin build` (then re-sign with `os plugin sign`) so the digests match the packaged files, and publish the fresh artifact.');
136+
this.exit(1);
137+
return;
138+
}
139+
if (integrityCheck.skipped) {
140+
printStep('No `integrity` map in the manifest — per-file integrity preflight skipped.');
141+
} else {
142+
printKV(' Integrity', `${integrityCheck.checked} file(s) verified against the manifest digests`);
143+
}
144+
96145
// 3. Detached publisher signature. ─────────────────────────────────
97146
const sigPath = resolvePath(process.cwd(), flags.sig ?? `${artifactPath}.sig`);
98147
let signature: string | undefined;

packages/cli/src/utils/osplugin.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,12 @@
1313
* SIGNATURE ← detached publisher signature (placeholder
1414
* until `os plugin sign`; ADR §3.4)
1515
*
16-
* The control plane (cloud) stores this blob opaquely and re-verifies the
17-
* per-file `integrity` at install/load time when the runtime unpacks it
18-
* (ADR §3.5 step 5). This module owns the two contracts the runtime and
19-
* cloud must agree on byte-for-byte:
16+
* The control plane (cloud) stores this blob opaquely. The per-file
17+
* `integrity` map is computed here at build time and self-checked by the
18+
* `os plugin publish` preflight; re-verification at install/load-time
19+
* unpack (ADR §3.5 step 5) is the cloud control plane's obligation and is
20+
* not implemented in this repo (#11331). This module owns the two
21+
* contracts the runtime and cloud must agree on byte-for-byte:
2022
*
2123
* 1. The integrity digest STRING FORMAT — Subresource-Integrity style
2224
* `sha256-<base64>` (matches ADR-0025 §3.2's example). See
@@ -40,7 +42,9 @@ export interface ArchiveFile {
4042
/**
4143
* Subresource-Integrity-style digest of `bytes`: `sha256-<base64>`.
4244
* This is the canonical per-file integrity string written into the
43-
* compiled manifest's `integrity` map and re-verified by the runtime.
45+
* compiled manifest's `integrity` map and checked back at the
46+
* `os plugin publish` preflight (unpack-time re-verification is the
47+
* cloud control plane's obligation, #11331).
4448
*/
4549
export function sriDigest(bytes: Uint8Array): string {
4650
return 'sha256-' + createHash('sha256').update(bytes).digest('base64');

packages/cli/test/plugin-publish.test.ts

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,29 @@ import {
99
readOspluginManifest,
1010
readTarGz,
1111
sha256Hex,
12+
sriDigest,
1213
MANIFEST_FILENAME,
1314
SIGNATURE_FILENAME,
1415
type ArchiveFile,
1516
} from '../src/utils/osplugin.js';
1617
import PluginPublish from '../src/commands/plugin/publish.js';
1718

19+
const distData = new Uint8Array(Buffer.from('export const x=1;\n'));
20+
1821
const manifest = {
1922
id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin',
2023
runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs',
2124
permissions: { services: ['object'] },
22-
integrity: { 'dist/index.mjs': 'sha256-abc' },
25+
// Real digest of `distData` — the publish preflight verifies it.
26+
integrity: { 'dist/index.mjs': sriDigest(distData) },
2327
};
2428

25-
function buildArtifact(): Uint8Array {
29+
function buildArtifact(manifestOverride: Record<string, unknown> = manifest, extraFiles: ArchiveFile[] = []): Uint8Array {
2630
const files: ArchiveFile[] = [
27-
{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) },
28-
{ path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2))) },
31+
{ path: 'dist/index.mjs', data: distData },
32+
{ path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifestOverride, null, 2))) },
2933
{ path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n')) },
34+
...extraFiles,
3035
];
3136
return new Uint8Array(createTarGz(files));
3237
}
@@ -95,4 +100,77 @@ describe('os plugin publish (end-to-end, mocked cloud)', () => {
95100
expect(Buffer.from(calls[1].body.osplugin, 'base64').equals(Buffer.from(blob))).toBe(true);
96101
expect(calls[1].body.plugin_manifest).toMatchObject({ id: 'com.acme.demo', runtime: 'sandbox' });
97102
});
103+
104+
async function runExpectingRefusal(blob: Uint8Array): Promise<{ output: string; fetchCalls: number }> {
105+
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-'));
106+
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
107+
await writeFile(artifactPath, blob);
108+
process.env.OS_CLOUD_URL = 'http://cloud.test';
109+
process.env.OS_CLOUD_API_KEY = 'tok_123';
110+
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), statusText: 'OK' } as any));
111+
vi.stubGlobal('fetch', fetchMock);
112+
const lines: string[] = [];
113+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
114+
lines.push(args.map(String).join(' '));
115+
});
116+
try {
117+
await expect(PluginPublish.run([artifactPath])).rejects.toThrow(/EEXIT: 1/);
118+
} finally {
119+
logSpy.mockRestore();
120+
}
121+
return { output: lines.join('\n'), fetchCalls: fetchMock.mock.calls.length };
122+
}
123+
124+
it('refuses the publish before any upload when a declared digest mismatches', async () => {
125+
const tampered = { ...manifest, integrity: { 'dist/index.mjs': sriDigest(new Uint8Array(Buffer.from('other bytes'))) } };
126+
const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(tampered));
127+
expect(fetchCalls).toBe(0); // refused pre-upload — nothing reached the cloud
128+
expect(output).toContain('Integrity preflight failed');
129+
expect(output).toContain('dist/index.mjs');
130+
expect(output).toContain('digest mismatch');
131+
expect(output).toContain('os plugin build');
132+
});
133+
134+
it('refuses when the integrity map declares a file the artifact lacks', async () => {
135+
const withGhost = { ...manifest, integrity: { ...manifest.integrity, 'dist/ghost.mjs': sriDigest(distData) } };
136+
const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(withGhost));
137+
expect(fetchCalls).toBe(0);
138+
expect(output).toContain('dist/ghost.mjs');
139+
expect(output).toContain('absent from the artifact');
140+
});
141+
142+
it('refuses on a packaged file the integrity map does not declare (stale map)', async () => {
143+
const blob = buildArtifact(manifest, [{ path: 'dist/extra.mjs', data: distData }]);
144+
const { output, fetchCalls } = await runExpectingRefusal(blob);
145+
expect(fetchCalls).toBe(0);
146+
expect(output).toContain('dist/extra.mjs');
147+
expect(output).toContain('not in the integrity map');
148+
});
149+
150+
it('absent integrity map is permissive: publish proceeds with a skip notice (the field is optional)', async () => {
151+
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-'));
152+
const withoutIntegrity: Record<string, unknown> = { ...manifest };
153+
delete withoutIntegrity.integrity;
154+
const blob = buildArtifact(withoutIntegrity);
155+
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
156+
await writeFile(artifactPath, blob);
157+
process.env.OS_CLOUD_URL = 'http://cloud.test';
158+
process.env.OS_CLOUD_API_KEY = 'tok_123';
159+
const fetchMock = vi.fn(async (url: string) => {
160+
const data = url.endsWith('/versions') ? { version: '1.2.0' } : { id: 'pkg_1', created: true };
161+
return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any;
162+
});
163+
vi.stubGlobal('fetch', fetchMock);
164+
const lines: string[] = [];
165+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
166+
lines.push(args.map(String).join(' '));
167+
});
168+
try {
169+
await PluginPublish.run([artifactPath]);
170+
} finally {
171+
logSpy.mockRestore();
172+
}
173+
expect(fetchMock.mock.calls.length).toBe(2); // both uploads still happened
174+
expect(lines.join('\n')).toContain('integrity preflight skipped');
175+
});
98176
});

packages/core/src/security/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ export {
3434
verifyPluginArtifact,
3535
} from './plugin-artifact-signature.js';
3636

37+
// Per-file artifact integrity verification (ADR-0025 §3.2) — pure and
38+
// portable like the signature contract above; consumed by the
39+
// `os plugin publish` preflight. Unpack-time re-verification stays the
40+
// cloud control plane's obligation (#11331).
41+
export {
42+
verifyIntegrity,
43+
formatIntegrityViolation,
44+
type IntegrityFile,
45+
type IntegrityViolation,
46+
type IntegrityViolationKind,
47+
type VerifyIntegrityResult,
48+
} from './plugin-artifact-integrity.js';
49+
3750
// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
3851
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
3952
// The kernel never received a plugin's config to validate — factories close
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { createHash } from 'node:crypto';
5+
import {
6+
verifyIntegrity,
7+
formatIntegrityViolation,
8+
type IntegrityFile,
9+
} from './plugin-artifact-integrity.js';
10+
11+
function sri(data: Uint8Array, alg = 'sha256'): string {
12+
return `${alg}-${createHash(alg).update(data).digest('base64')}`;
13+
}
14+
15+
const codeBytes = new Uint8Array(Buffer.from('export const x = 1;\n'));
16+
const assetBytes = new Uint8Array(Buffer.from('body { color: red }\n'));
17+
18+
function files(): IntegrityFile[] {
19+
return [
20+
{ path: 'dist/index.mjs', data: codeBytes },
21+
{ path: 'assets/app.css', data: assetBytes },
22+
];
23+
}
24+
25+
describe('verifyIntegrity', () => {
26+
it('passes when every declared digest matches and every file is declared', () => {
27+
const res = verifyIntegrity(files(), {
28+
'dist/index.mjs': sri(codeBytes),
29+
'assets/app.css': sri(assetBytes),
30+
});
31+
expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] });
32+
});
33+
34+
it('refuses on a single-file digest mismatch, naming declared and actual', () => {
35+
const declared = sri(new Uint8Array(Buffer.from('tampered')));
36+
const res = verifyIntegrity(files(), {
37+
'dist/index.mjs': declared,
38+
'assets/app.css': sri(assetBytes),
39+
});
40+
expect(res.ok).toBe(false);
41+
expect(res.skipped).toBe(false);
42+
expect(res.checked).toBe(2);
43+
expect(res.violations).toEqual([
44+
{ kind: 'digest_mismatch', path: 'dist/index.mjs', declared, actual: sri(codeBytes) },
45+
]);
46+
});
47+
48+
it('refuses when a declared entry has no corresponding file', () => {
49+
const res = verifyIntegrity([files()[0]], {
50+
'dist/index.mjs': sri(codeBytes),
51+
'assets/app.css': sri(assetBytes),
52+
});
53+
expect(res.ok).toBe(false);
54+
expect(res.violations).toEqual([
55+
{ kind: 'missing_file', path: 'assets/app.css', declared: sri(assetBytes) },
56+
]);
57+
});
58+
59+
it('refuses on a file the integrity map does not declare (stale-map drift)', () => {
60+
const res = verifyIntegrity(files(), { 'dist/index.mjs': sri(codeBytes) });
61+
expect(res.ok).toBe(false);
62+
expect(res.violations).toEqual([{ kind: 'extra_file', path: 'assets/app.css' }]);
63+
});
64+
65+
it('absent map is a permissive pass (the manifest field is optional): ok + skipped, nothing checked', () => {
66+
for (const absent of [undefined, null] as const) {
67+
const res = verifyIntegrity(files(), absent);
68+
expect(res).toEqual({ ok: true, skipped: true, checked: 0, violations: [] });
69+
}
70+
});
71+
72+
it('exempt paths are outside the map coverage in both directions', () => {
73+
const manifestFile: IntegrityFile = {
74+
path: 'objectstack.plugin.json',
75+
data: new Uint8Array(Buffer.from('{}')),
76+
};
77+
const res = verifyIntegrity([...files(), manifestFile], {
78+
'dist/index.mjs': sri(codeBytes),
79+
'assets/app.css': sri(assetBytes),
80+
// A (mis)declared exempt entry is skipped rather than compared.
81+
'objectstack.plugin.json': 'sha256-not-checked',
82+
}, { exempt: ['objectstack.plugin.json'] });
83+
expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] });
84+
});
85+
86+
it('verifies sha384/sha512 SRI digests by their own algorithm', () => {
87+
const res = verifyIntegrity(files(), {
88+
'dist/index.mjs': sri(codeBytes, 'sha512'),
89+
'assets/app.css': sri(assetBytes, 'sha384'),
90+
});
91+
expect(res.ok).toBe(true);
92+
expect(res.checked).toBe(2);
93+
});
94+
95+
it('an unrecognized digest shape is a mismatch (compared as sha256), never a silent pass', () => {
96+
const res = verifyIntegrity([files()[0]], { 'dist/index.mjs': 'md5-abc' });
97+
expect(res.ok).toBe(false);
98+
expect(res.violations[0]).toMatchObject({
99+
kind: 'digest_mismatch',
100+
path: 'dist/index.mjs',
101+
declared: 'md5-abc',
102+
actual: sri(codeBytes),
103+
});
104+
});
105+
106+
it('reports every violation, deterministically ordered (map order, then sorted extras)', () => {
107+
const res = verifyIntegrity(
108+
[files()[1], { path: 'dist/extra.mjs', data: codeBytes }],
109+
{
110+
'dist/index.mjs': sri(codeBytes),
111+
'assets/app.css': sri(codeBytes), // wrong bytes declared
112+
},
113+
);
114+
expect(res.ok).toBe(false);
115+
expect(res.violations.map((v) => `${v.kind}:${v.path}`)).toEqual([
116+
'missing_file:dist/index.mjs',
117+
'digest_mismatch:assets/app.css',
118+
'extra_file:dist/extra.mjs',
119+
]);
120+
});
121+
});
122+
123+
describe('formatIntegrityViolation', () => {
124+
it('renders one actionable line per kind', () => {
125+
expect(
126+
formatIntegrityViolation({ kind: 'digest_mismatch', path: 'a', declared: 'sha256-x', actual: 'sha256-y' }),
127+
).toContain('digest mismatch');
128+
expect(formatIntegrityViolation({ kind: 'missing_file', path: 'a', declared: 'sha256-x' })).toContain(
129+
'absent from the artifact',
130+
);
131+
expect(formatIntegrityViolation({ kind: 'extra_file', path: 'a' })).toContain('not in the integrity map');
132+
});
133+
});

0 commit comments

Comments
 (0)