Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/plugin-publish-integrity-preflight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@objectstack/cli": patch
"@objectstack/core": patch
---

`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.
55 changes: 52 additions & 3 deletions packages/cli/src/commands/plugin/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@
* Flow:
* 1. Read the `.osplugin` bytes + the detached `.sig` (publisher signature).
* 2. Extract the compiled `objectstack.plugin.json` from inside the
* artifact (id / version / name / runtime / permissions / integrity).
* artifact (id / version / name / runtime / permissions / integrity),
* then preflight the artifact files against the manifest's declared
* per-file `integrity` digests (ADR-0025 §3.2) — refuse on any
* mismatch / missing / extra file; an absent map skips the check
* (the field is optional).
* 3. POST /cloud/packages — ensure the sys_package row exists.
* 4. POST /cloud/packages/:id/versions with `artifact_kind: 'plugin'`,
* the base64 artifact, the declared manifest, the signature, and the
Expand All @@ -25,7 +29,15 @@ import { resolve as resolvePath, basename } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
import { printHeader, printKV, printSuccess, printError, printStep } from '../../utils/format.js';
import { DEFAULT_CLOUD_URL, tryReadCloudConfig } from '../../utils/cloud-config.js';
import { OSPLUGIN_EXT, sha256Hex, readOspluginManifest } from '../../utils/osplugin.js';
import {
OSPLUGIN_EXT,
MANIFEST_FILENAME,
SIGNATURE_FILENAME,
sha256Hex,
readTarGz,
type ArchiveFile,
} from '../../utils/osplugin.js';
import { verifyIntegrity, formatIntegrityViolation } from '@objectstack/core';

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

Expand Down Expand Up @@ -80,8 +92,12 @@ export default class PluginPublish extends Command {

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

// 2b. Integrity preflight (ADR-0025 §3.2) — self-check the artifact
// bytes against the manifest's own declared per-file digests before
// upload. Absent map = permissive by contract (the field is
// `.optional()`; artifacts built before integrity computation stay
// publishable). Unpack-time re-verification remains the cloud control
// plane's obligation (#11331) — this preflight does not discharge it.
const declaredIntegrity = manifest.integrity;
if (
declaredIntegrity !== undefined && declaredIntegrity !== null
&& (typeof declaredIntegrity !== 'object' || Array.isArray(declaredIntegrity))
) {
printError('Artifact manifest has a malformed `integrity` map (expected an object of path → digest). Rebuild with `os plugin build`.');
this.exit(1);
return;
}
const integrityCheck = verifyIntegrity(
archiveFiles,
declaredIntegrity as Record<string, string> | undefined,
{ exempt: [MANIFEST_FILENAME, SIGNATURE_FILENAME] },
);
if (!integrityCheck.ok) {
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'}):`);
for (const v of integrityCheck.violations) console.log(` • ${formatIntegrityViolation(v)}`);
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.');
this.exit(1);
return;
}
if (integrityCheck.skipped) {
printStep('No `integrity` map in the manifest — per-file integrity preflight skipped.');
} else {
printKV(' Integrity', `${integrityCheck.checked} file(s) verified against the manifest digests`);
}

// 3. Detached publisher signature. ─────────────────────────────────
const sigPath = resolvePath(process.cwd(), flags.sig ?? `${artifactPath}.sig`);
let signature: string | undefined;
Expand Down
14 changes: 9 additions & 5 deletions packages/cli/src/utils/osplugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
* SIGNATURE ← detached publisher signature (placeholder
* until `os plugin sign`; ADR §3.4)
*
* The control plane (cloud) stores this blob opaquely and re-verifies the
* per-file `integrity` at install/load time when the runtime unpacks it
* (ADR §3.5 step 5). This module owns the two contracts the runtime and
* cloud must agree on byte-for-byte:
* The control plane (cloud) stores this blob opaquely. The per-file
* `integrity` map is computed here at build time and self-checked by the
* `os plugin publish` preflight; re-verification at install/load-time
* unpack (ADR §3.5 step 5) is the cloud control plane's obligation and is
* not implemented in this repo (#11331). This module owns the two
* contracts the runtime and cloud must agree on byte-for-byte:
*
* 1. The integrity digest STRING FORMAT — Subresource-Integrity style
* `sha256-<base64>` (matches ADR-0025 §3.2's example). See
Expand All @@ -40,7 +42,9 @@ export interface ArchiveFile {
/**
* Subresource-Integrity-style digest of `bytes`: `sha256-<base64>`.
* This is the canonical per-file integrity string written into the
* compiled manifest's `integrity` map and re-verified by the runtime.
* compiled manifest's `integrity` map and checked back at the
* `os plugin publish` preflight (unpack-time re-verification is the
* cloud control plane's obligation, #11331).
*/
export function sriDigest(bytes: Uint8Array): string {
return 'sha256-' + createHash('sha256').update(bytes).digest('base64');
Expand Down
86 changes: 82 additions & 4 deletions packages/cli/test/plugin-publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,29 @@ import {
readOspluginManifest,
readTarGz,
sha256Hex,
sriDigest,
MANIFEST_FILENAME,
SIGNATURE_FILENAME,
type ArchiveFile,
} from '../src/utils/osplugin.js';
import PluginPublish from '../src/commands/plugin/publish.js';

const distData = new Uint8Array(Buffer.from('export const x=1;\n'));

const manifest = {
id: 'com.acme.demo', name: 'Demo', version: '1.2.0', type: 'plugin',
runtime: 'sandbox', packaging: 'bundled', main: 'dist/index.mjs',
permissions: { services: ['object'] },
integrity: { 'dist/index.mjs': 'sha256-abc' },
// Real digest of `distData` — the publish preflight verifies it.
integrity: { 'dist/index.mjs': sriDigest(distData) },
};

function buildArtifact(): Uint8Array {
function buildArtifact(manifestOverride: Record<string, unknown> = manifest, extraFiles: ArchiveFile[] = []): Uint8Array {
const files: ArchiveFile[] = [
{ path: 'dist/index.mjs', data: new Uint8Array(Buffer.from('export const x=1;\n')) },
{ path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifest, null, 2))) },
{ path: 'dist/index.mjs', data: distData },
{ path: MANIFEST_FILENAME, data: new Uint8Array(Buffer.from(JSON.stringify(manifestOverride, null, 2))) },
{ path: SIGNATURE_FILENAME, data: new Uint8Array(Buffer.from('unsigned\n')) },
...extraFiles,
];
return new Uint8Array(createTarGz(files));
}
Expand Down Expand Up @@ -95,4 +100,77 @@ describe('os plugin publish (end-to-end, mocked cloud)', () => {
expect(Buffer.from(calls[1].body.osplugin, 'base64').equals(Buffer.from(blob))).toBe(true);
expect(calls[1].body.plugin_manifest).toMatchObject({ id: 'com.acme.demo', runtime: 'sandbox' });
});

async function runExpectingRefusal(blob: Uint8Array): Promise<{ output: string; fetchCalls: number }> {
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-'));
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
await writeFile(artifactPath, blob);
process.env.OS_CLOUD_URL = 'http://cloud.test';
process.env.OS_CLOUD_API_KEY = 'tok_123';
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({}), statusText: 'OK' } as any));
vi.stubGlobal('fetch', fetchMock);
const lines: string[] = [];
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.map(String).join(' '));
});
try {
await expect(PluginPublish.run([artifactPath])).rejects.toThrow(/EEXIT: 1/);
} finally {
logSpy.mockRestore();
}
return { output: lines.join('\n'), fetchCalls: fetchMock.mock.calls.length };
}

it('refuses the publish before any upload when a declared digest mismatches', async () => {
const tampered = { ...manifest, integrity: { 'dist/index.mjs': sriDigest(new Uint8Array(Buffer.from('other bytes'))) } };
const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(tampered));
expect(fetchCalls).toBe(0); // refused pre-upload — nothing reached the cloud
expect(output).toContain('Integrity preflight failed');
expect(output).toContain('dist/index.mjs');
expect(output).toContain('digest mismatch');
expect(output).toContain('os plugin build');
});

it('refuses when the integrity map declares a file the artifact lacks', async () => {
const withGhost = { ...manifest, integrity: { ...manifest.integrity, 'dist/ghost.mjs': sriDigest(distData) } };
const { output, fetchCalls } = await runExpectingRefusal(buildArtifact(withGhost));
expect(fetchCalls).toBe(0);
expect(output).toContain('dist/ghost.mjs');
expect(output).toContain('absent from the artifact');
});

it('refuses on a packaged file the integrity map does not declare (stale map)', async () => {
const blob = buildArtifact(manifest, [{ path: 'dist/extra.mjs', data: distData }]);
const { output, fetchCalls } = await runExpectingRefusal(blob);
expect(fetchCalls).toBe(0);
expect(output).toContain('dist/extra.mjs');
expect(output).toContain('not in the integrity map');
});

it('absent integrity map is permissive: publish proceeds with a skip notice (the field is optional)', async () => {
dir = await mkdtemp(join(tmpdir(), 'plugin-publish-'));
const withoutIntegrity: Record<string, unknown> = { ...manifest };
delete withoutIntegrity.integrity;
const blob = buildArtifact(withoutIntegrity);
const artifactPath = join(dir, 'com.acme.demo-1.2.0.osplugin');
await writeFile(artifactPath, blob);
process.env.OS_CLOUD_URL = 'http://cloud.test';
process.env.OS_CLOUD_API_KEY = 'tok_123';
const fetchMock = vi.fn(async (url: string) => {
const data = url.endsWith('/versions') ? { version: '1.2.0' } : { id: 'pkg_1', created: true };
return { ok: true, status: 200, json: async () => ({ success: true, data }), statusText: 'OK' } as any;
});
vi.stubGlobal('fetch', fetchMock);
const lines: string[] = [];
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => {
lines.push(args.map(String).join(' '));
});
try {
await PluginPublish.run([artifactPath]);
} finally {
logSpy.mockRestore();
}
expect(fetchMock.mock.calls.length).toBe(2); // both uploads still happened
expect(lines.join('\n')).toContain('integrity preflight skipped');
});
});
13 changes: 13 additions & 0 deletions packages/core/src/security/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ export {
verifyPluginArtifact,
} from './plugin-artifact-signature.js';

// Per-file artifact integrity verification (ADR-0025 §3.2) — pure and
// portable like the signature contract above; consumed by the
// `os plugin publish` preflight. Unpack-time re-verification stays the
// cloud control plane's obligation (#11331).
export {
verifyIntegrity,
formatIntegrityViolation,
type IntegrityFile,
type IntegrityViolation,
type IntegrityViolationKind,
type VerifyIntegrityResult,
} from './plugin-artifact-integrity.js';

// `PluginConfigValidator` / `createPluginConfigValidator` were RETIRED here on
// 2026-08-27 (#11982, ADR-0049 enforce-or-remove; recorded in ADR-0025 §3.7).
// The kernel never received a plugin's config to validate — factories close
Expand Down
133 changes: 133 additions & 0 deletions packages/core/src/security/plugin-artifact-integrity.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { createHash } from 'node:crypto';
import {
verifyIntegrity,
formatIntegrityViolation,
type IntegrityFile,
} from './plugin-artifact-integrity.js';

function sri(data: Uint8Array, alg = 'sha256'): string {
return `${alg}-${createHash(alg).update(data).digest('base64')}`;
}

const codeBytes = new Uint8Array(Buffer.from('export const x = 1;\n'));
const assetBytes = new Uint8Array(Buffer.from('body { color: red }\n'));

function files(): IntegrityFile[] {
return [
{ path: 'dist/index.mjs', data: codeBytes },
{ path: 'assets/app.css', data: assetBytes },
];
}

describe('verifyIntegrity', () => {
it('passes when every declared digest matches and every file is declared', () => {
const res = verifyIntegrity(files(), {
'dist/index.mjs': sri(codeBytes),
'assets/app.css': sri(assetBytes),
});
expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] });
});

it('refuses on a single-file digest mismatch, naming declared and actual', () => {
const declared = sri(new Uint8Array(Buffer.from('tampered')));
const res = verifyIntegrity(files(), {
'dist/index.mjs': declared,
'assets/app.css': sri(assetBytes),
});
expect(res.ok).toBe(false);
expect(res.skipped).toBe(false);
expect(res.checked).toBe(2);
expect(res.violations).toEqual([
{ kind: 'digest_mismatch', path: 'dist/index.mjs', declared, actual: sri(codeBytes) },
]);
});

it('refuses when a declared entry has no corresponding file', () => {
const res = verifyIntegrity([files()[0]], {
'dist/index.mjs': sri(codeBytes),
'assets/app.css': sri(assetBytes),
});
expect(res.ok).toBe(false);
expect(res.violations).toEqual([
{ kind: 'missing_file', path: 'assets/app.css', declared: sri(assetBytes) },
]);
});

it('refuses on a file the integrity map does not declare (stale-map drift)', () => {
const res = verifyIntegrity(files(), { 'dist/index.mjs': sri(codeBytes) });
expect(res.ok).toBe(false);
expect(res.violations).toEqual([{ kind: 'extra_file', path: 'assets/app.css' }]);
});

it('absent map is a permissive pass (the manifest field is optional): ok + skipped, nothing checked', () => {
for (const absent of [undefined, null] as const) {
const res = verifyIntegrity(files(), absent);
expect(res).toEqual({ ok: true, skipped: true, checked: 0, violations: [] });
}
});

it('exempt paths are outside the map coverage in both directions', () => {
const manifestFile: IntegrityFile = {
path: 'objectstack.plugin.json',
data: new Uint8Array(Buffer.from('{}')),
};
const res = verifyIntegrity([...files(), manifestFile], {
'dist/index.mjs': sri(codeBytes),
'assets/app.css': sri(assetBytes),
// A (mis)declared exempt entry is skipped rather than compared.
'objectstack.plugin.json': 'sha256-not-checked',
}, { exempt: ['objectstack.plugin.json'] });
expect(res).toEqual({ ok: true, skipped: false, checked: 2, violations: [] });
});

it('verifies sha384/sha512 SRI digests by their own algorithm', () => {
const res = verifyIntegrity(files(), {
'dist/index.mjs': sri(codeBytes, 'sha512'),
'assets/app.css': sri(assetBytes, 'sha384'),
});
expect(res.ok).toBe(true);
expect(res.checked).toBe(2);
});

it('an unrecognized digest shape is a mismatch (compared as sha256), never a silent pass', () => {
const res = verifyIntegrity([files()[0]], { 'dist/index.mjs': 'md5-abc' });
expect(res.ok).toBe(false);
expect(res.violations[0]).toMatchObject({
kind: 'digest_mismatch',
path: 'dist/index.mjs',
declared: 'md5-abc',
actual: sri(codeBytes),
});
});

it('reports every violation, deterministically ordered (map order, then sorted extras)', () => {
const res = verifyIntegrity(
[files()[1], { path: 'dist/extra.mjs', data: codeBytes }],
{
'dist/index.mjs': sri(codeBytes),
'assets/app.css': sri(codeBytes), // wrong bytes declared
},
);
expect(res.ok).toBe(false);
expect(res.violations.map((v) => `${v.kind}:${v.path}`)).toEqual([
'missing_file:dist/index.mjs',
'digest_mismatch:assets/app.css',
'extra_file:dist/extra.mjs',
]);
});
});

describe('formatIntegrityViolation', () => {
it('renders one actionable line per kind', () => {
expect(
formatIntegrityViolation({ kind: 'digest_mismatch', path: 'a', declared: 'sha256-x', actual: 'sha256-y' }),
).toContain('digest mismatch');
expect(formatIntegrityViolation({ kind: 'missing_file', path: 'a', declared: 'sha256-x' })).toContain(
'absent from the artifact',
);
expect(formatIntegrityViolation({ kind: 'extra_file', path: 'a' })).toContain('not in the integrity map');
});
});
Loading
Loading