#232: plugin allowlist with SHA-256 hash pinning #298

Merged
fable-5 merged 1 commits from issue-232-plugin-hash-pinning into main 2026-07-31 21:43:35 +02:00
20 changed files with 511 additions and 60 deletions

View File

@ -0,0 +1,4 @@
-- #232: SHA-256 of the installed bundle ZIP, observed at install time.
-- NULL for plugins installed before this migration — the admin UI says so
-- and a reinstall records it.
ALTER TABLE "plugins" ADD COLUMN "bundle_hash" TEXT;

View File

@ -885,6 +885,8 @@ model Plugin {
mode PluginInstanceMode @default(DISABLED) mode PluginInstanceMode @default(DISABLED)
/// The full manifest as validated at install time (@dorfteich/plugin-sdk). /// The full manifest as validated at install time (@dorfteich/plugin-sdk).
manifest Json manifest Json
/// SHA-256 (hex) of the installed bundle ZIP (#232); null = pre-#232 install.
bundleHash String? @map("bundle_hash")
installedAt DateTime @default(now()) @map("installed_at") installedAt DateTime @default(now()) @map("installed_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
/// Set when uninstalled; active queries filter `removedAt: null`. /// Set when uninstalled; active queries filter `removedAt: null`.

View File

@ -36,6 +36,7 @@ export const AUDIT_EVENTS = {
'page.classification_lowered': { severity: 'warning' }, 'page.classification_lowered': { severity: 'warning' },
'page.classification_raised': { severity: 'notice' }, 'page.classification_raised': { severity: 'notice' },
'plugin.installed': { severity: 'notice' }, 'plugin.installed': { severity: 'notice' },
'plugin.rejected': { severity: 'warning' },
'plugin.mode_set': { severity: 'notice' }, 'plugin.mode_set': { severity: 'notice' },
'plugin.pond_toggled': { severity: 'info' }, 'plugin.pond_toggled': { severity: 'info' },
'plugin.uninstalled': { severity: 'notice' }, 'plugin.uninstalled': { severity: 'notice' },

View File

@ -2,6 +2,7 @@ import {
BadRequestException, BadRequestException,
Body, Body,
ConflictException, ConflictException,
ForbiddenException,
Controller, Controller,
Delete, Delete,
Get, Get,
@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException {
case 'plugin_version_not_higher': case 'plugin_version_not_higher':
case 'plugin_not_optional': case 'plugin_not_optional':
return new ConflictException(body); return new ConflictException(body);
// Hash pinning (#232): the upload is well-formed, the policy says no.
case 'plugin_not_pinned':
case 'plugin_hash_mismatch':
return new ForbiddenException(body);
case 'plugin_too_large': case 'plugin_too_large':
return new PayloadTooLargeException(body); return new PayloadTooLargeException(body);
default: default:

View File

@ -96,7 +96,9 @@ export class PluginAssetsController {
@Param('version') version: string, @Param('version') version: string,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<string> { ): Promise<string> {
const plugin = await this.plugins.get(id); // getServable enforces the hash-pinning allowlist (#232): a blocked
// plugin 404s here exactly like a missing one, and is audited.
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException(); if (!plugin || plugin.version !== version) throw new NotFoundException();
// Asset base is built from the configured public origin, not the request // Asset base is built from the configured public origin, not the request
@ -122,8 +124,9 @@ export class PluginAssetsController {
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
): Promise<StreamableFile> { ): Promise<StreamableFile> {
// Only serve assets for an installed, current version — a removed plugin or // Only serve assets for an installed, current version — a removed plugin or
// a stale version pointer must not leak files. // a stale version pointer must not leak files. getServable additionally
const plugin = await this.plugins.get(id); // enforces the hash-pinning allowlist (#232).
const plugin = await this.plugins.getServable(id);
if (!plugin || plugin.version !== version) throw new NotFoundException(); if (!plugin || plugin.version !== version) throw new NotFoundException();
const rest = (request.params as Record<string, unknown>).rest; const rest = (request.params as Record<string, unknown>).rest;

View File

@ -0,0 +1,172 @@
import { createHash } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { zipSync } from 'fflate';
import request from 'supertest';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { createTestApp, sessionCookieOf } from '../testing/test-app';
import { createTestPrisma, hasTestDb, uniqueSuffix } from '../testing/test-db';
import { UsersService } from '../users/users.service';
const enc = (text: string) => new TextEncoder().encode(text);
function manifest(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
id: 'pin-me',
name: 'Pin Me',
version: '1.0.0',
apiVersion: '1',
kind: 'code',
extensionPoints: [{ type: 'pageTool', id: 'pin', title: { de: 'Pin', en: 'Pin' } }],
permissions: [],
license: 'MIT',
...overrides,
};
}
function pluginZip(m: Record<string, unknown>, bundle = 'export default {}'): Buffer {
return Buffer.from(
zipSync({ 'manifest.json': enc(JSON.stringify(m)), 'plugin.js': enc(bundle) }),
);
}
const sha256 = (buffer: Buffer) => createHash('sha256').update(buffer).digest('hex');
/**
* Hash-pinning allowlist (issue #232, ADR 0025): with a non-empty
* `plugins.allowlist`, only pinned ids with the exact bundle hash install
* and load; tampered or unpinned bundles fail closed with a stable code,
* every rejection is audited, and a version bump requires an explicit
* re-pin. Empty allowlist = unchanged behaviour (backward compatible).
*/
describe.skipIf(!hasTestDb)('plugin hash pinning (e2e, issue #232)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let settings: InstanceSettingsService;
const suffix = uniqueSuffix();
const password = 'gepinnt ist gepinnt 1';
let adminCookie: string;
let adminId: string;
const api = () => request(app.getHttpServer());
const zipV1 = pluginZip(manifest());
const zipV1Tampered = pluginZip(manifest(), 'export default { evil: true }');
const zipV2 = pluginZip(manifest({ version: '1.1.0' }));
async function setAllowlist(entries: { id: string; sha256: string }[]): Promise<void> {
await settings.set('plugins.allowlist', entries, adminId);
}
beforeAll(async () => {
prisma = createTestPrisma();
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
app = await createTestApp();
settings = app.get(InstanceSettingsService);
const users = app.get(UsersService);
const adminUser = await users.createUser({
username: `pin-admin-${suffix}`,
email: `pin-admin-${suffix}@example.org`,
displayName: 'Pin Admin',
password,
locale: 'en',
});
await users.markEmailVerified(adminUser.id);
await prisma.user.update({ where: { id: adminUser.id }, data: { isSiteAdmin: true } });
adminId = adminUser.id;
adminCookie = sessionCookieOf(
await api()
.post('/api/v1/auth/login')
.send({ usernameOrEmail: `pin-admin-${suffix}`, password })
.expect(200),
);
});
afterAll(async () => {
await prisma.instanceSetting.deleteMany({ where: { key: 'plugins.allowlist' } });
await prisma.pondPlugin.deleteMany({});
await prisma.plugin.deleteMany({});
await prisma.auditEntry.deleteMany({ where: { targetId: { in: ['pin-me', 'stranger'] } } });
await prisma.user.deleteMany({ where: { username: { contains: suffix } } });
await prisma.$disconnect();
await app.close();
});
it('empty allowlist: installs record the hash, nothing is enforced', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1, 'pin-me.zip')
.expect(201);
expect(res.body.pinning).toBe('not_enforced');
expect(res.body.bundleSha256).toBe(sha256(zipV1));
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('pinned hash: plugin stays loadable and reports pinned', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
expect(list.body[0].pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(200);
});
it('unpinned plugin: install rejected with the stable code and audited', async () => {
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', pluginZip(manifest({ id: 'stranger', name: 'Stranger' })), 'stranger.zip')
.expect(403);
expect(res.body.code).toBe('plugin_not_pinned');
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'stranger' },
});
expect(audit).not.toBeNull();
});
it('tampered bundle: same id + pin, different bytes — install rejected', async () => {
// A tampered re-delivery of the pinned version arrives as an update
// attempt; the hash gate must fire before any version comparison.
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV1Tampered, 'pin-me.zip')
.expect(403);
expect(res.body.code).toBe('plugin_hash_mismatch');
});
it('pin changed away from the installed hash: plugin no longer loads, audited', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1Tampered) }]);
const list = await api().get('/api/v1/admin/plugins').set('Cookie', adminCookie).expect(200);
// The admin still SEES the plugin, with the deviation named…
expect(list.body[0].pinning).toBe('mismatch');
// …but nothing serves it: frame 404s and the rejection is audited.
await api().get(`/api/v1/plugins/pin-me/1.0.0/frame`).expect(404);
const audit = await prisma.auditEntry.findFirst({
where: { action: 'plugin.rejected', targetId: 'pin-me' },
});
expect(audit).not.toBeNull();
});
it('version bump requires an explicit re-pin', async () => {
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV1) }]);
const rejected = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(403);
expect(rejected.body.code).toBe('plugin_hash_mismatch');
await setAllowlist([{ id: 'pin-me', sha256: sha256(zipV2) }]);
const res = await api()
.post('/api/v1/admin/plugins')
.set('Cookie', adminCookie)
.attach('file', zipV2, 'pin-me-1.1.zip')
.expect(201);
expect(res.body.pinning).toBe('pinned');
await api().get(`/api/v1/plugins/pin-me/1.1.0/frame`).expect(200);
});
});

View File

@ -1,3 +1,5 @@
import { createHash } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
@ -12,6 +14,7 @@ import type {
import { ClockService } from '../common/clock.service'; import { ClockService } from '../common/clock.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { InstanceSettingsService } from '../settings/instance-settings.service';
import { PluginPackageService } from './plugin-package.service'; import { PluginPackageService } from './plugin-package.service';
import { PluginStorageService } from './plugin-storage.service'; import { PluginStorageService } from './plugin-storage.service';
@ -44,6 +47,7 @@ export class PluginsService {
private readonly storage: PluginStorageService, private readonly storage: PluginStorageService,
private readonly clock: ClockService, private readonly clock: ClockService,
private readonly audit: AuditService, private readonly audit: AuditService,
private readonly settings: InstanceSettingsService,
private readonly logger: PinoLogger, private readonly logger: PinoLogger,
) { ) {
this.logger.setContext(PluginsService.name); this.logger.setContext(PluginsService.name);
@ -58,6 +62,32 @@ export class PluginsService {
/** `actor` is absent for dropzone installs (watcher, no session). */ /** `actor` is absent for dropzone installs (watcher, no session). */
async install(zip: Buffer, actor?: User): Promise<PluginView> { async install(zip: Buffer, actor?: User): Promise<PluginView> {
const { manifest, files } = this.packages.parse(zip); const { manifest, files } = this.packages.parse(zip);
const bundleHash = createHash('sha256').update(zip).digest('hex');
// Hash pinning (#232, ADR 0025): while the allowlist is non-empty,
// only listed ids with the exact pinned bundle hash may install.
// Fail closed with a distinct code per cause; every rejection is
// audited so a tampering attempt leaves a trace.
const allowlist = await this.settings.get('plugins.allowlist');
if (allowlist.length > 0) {
const pin = allowlist.find((entry) => entry.id === manifest.id);
if (!pin || pin.sha256 !== bundleHash) {
const reason = pin ? 'hash_mismatch' : 'not_pinned';
await this.audit.record({
action: 'plugin.rejected',
actorId: actor?.id,
targetType: 'plugin',
targetId: manifest.id,
details: { surface: 'install', reason, version: manifest.version, bundleHash },
});
throw new PluginPackageError(
pin ? 'plugin_hash_mismatch' : 'plugin_not_pinned',
pin
? `Bundle hash ${bundleHash} does not match the pinned hash for ${manifest.id}`
: `Plugin ${manifest.id} is not on the allowlist`,
);
}
}
const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } }); const existing = await this.prisma.plugin.findUnique({ where: { id: manifest.id } });
const isActiveUpdate = existing !== null && existing.removedAt === null; const isActiveUpdate = existing !== null && existing.removedAt === null;
@ -81,6 +111,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion, apiVersion: manifest.apiVersion,
kind: manifest.kind, kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue, manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
}, },
update: { update: {
name: manifest.name, name: manifest.name,
@ -88,6 +119,7 @@ export class PluginsService {
apiVersion: manifest.apiVersion, apiVersion: manifest.apiVersion,
kind: manifest.kind, kind: manifest.kind,
manifest: manifest as unknown as Prisma.InputJsonValue, manifest: manifest as unknown as Prisma.InputJsonValue,
bundleHash,
// Reinstalling a previously removed plugin clears the tombstone. // Reinstalling a previously removed plugin clears the tombstone.
removedAt: null, removedAt: null,
}, },
@ -105,7 +137,7 @@ export class PluginsService {
targetId: manifest.id, targetId: manifest.id,
details: { version: manifest.version, update: isActiveUpdate }, details: { version: manifest.version, update: isActiveUpdate },
}); });
return this.toView(record); return this.toView(record, await this.settings.get('plugins.allowlist'));
} }
/** /**
@ -131,7 +163,7 @@ export class PluginsService {
targetId: id, targetId: id,
details: { mode }, details: { mode },
}); });
return this.toView(updated); return this.toView(updated, await this.settings.get('plugins.allowlist'));
} }
/** /**
@ -182,9 +214,18 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }), this.prisma.pondPlugin.findMany({ where: { pondId } }),
]); ]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins const allowlist = await this.settings.get('plugins.allowlist');
.filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true)) return (
.map((p) => this.toView(p)); plugins
.filter(
(p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true),
)
// Hash pinning (#232): a plugin outside the allowlist, or with a
// deviating bundle hash, does not load — it simply never appears in
// the pond's mount list. Existing blocks render their fallback.
.filter((p) => this.loadable(this.verdict(p, allowlist)))
.map((p) => this.toView(p, allowlist))
);
} }
/** /**
@ -219,7 +260,11 @@ export class PluginsService {
this.prisma.pondPlugin.findMany({ where: { pondId } }), this.prisma.pondPlugin.findMany({ where: { pondId } }),
]); ]);
const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled]));
return plugins.map((p) => ({ plugin: this.toView(p), enabled: enabled.get(p.id) === true })); const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((p) => ({
plugin: this.toView(p, allowlist),
enabled: enabled.get(p.id) === true,
}));
} }
/** /**
@ -287,19 +332,66 @@ export class PluginsService {
where: { removedAt: null }, where: { removedAt: null },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return plugins.map((plugin) => this.toView(plugin)); const allowlist = await this.settings.get('plugins.allowlist');
return plugins.map((plugin) => this.toView(plugin, allowlist));
} }
/** One installed plugin, or `null` if absent/removed. */ /** One installed plugin, or `null` if absent/removed. */
async get(id: string): Promise<PluginView | null> { async get(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } }); const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null; if (!plugin || plugin.removedAt !== null) return null;
return this.toView(plugin); return this.toView(plugin, await this.settings.get('plugins.allowlist'));
} }
private toView(plugin: Plugin): PluginView { /**
* Pinning verdict against `plugins.allowlist` (#232). The observed hash
* is the one recorded at install: post-install tampering with unpacked
* files on disk is platform integrity (ADR 0019), not this check's
* scope the pin answers "is this the reviewed bundle".
*/
private verdict(
plugin: Plugin,
allowlist: { id: string; sha256: string }[],
): PluginView['pinning'] {
if (allowlist.length === 0) return 'not_enforced';
const pin = allowlist.find((entry) => entry.id === plugin.id);
if (!pin) return 'unpinned';
return plugin.bundleHash === pin.sha256 ? 'pinned' : 'mismatch';
}
private loadable(verdict: PluginView['pinning']): boolean {
return verdict === 'not_enforced' || verdict === 'pinned';
}
/**
* The load gate for code-serving surfaces (frame + assets, #232): an
* installed plugin outside the allowlist, or with a deviating bundle
* hash, does not load 404 like a missing plugin, and the rejection is
* audited (unlike the silent list filtering, an asset request proves
* something actively referenced the blocked plugin).
*/
async getServable(id: string): Promise<PluginView | null> {
const plugin = await this.prisma.plugin.findUnique({ where: { id } });
if (!plugin || plugin.removedAt !== null) return null;
const allowlist = await this.settings.get('plugins.allowlist');
const verdict = this.verdict(plugin, allowlist);
if (!this.loadable(verdict)) {
await this.audit.record({
action: 'plugin.rejected',
targetType: 'plugin',
targetId: id,
details: { surface: 'load', reason: verdict, version: plugin.version },
});
return null;
}
return this.toView(plugin, allowlist);
}
private toView(plugin: Plugin, allowlist: { id: string; sha256: string }[]): PluginView {
const manifest = plugin.manifest as unknown as PluginManifest; const manifest = plugin.manifest as unknown as PluginManifest;
return { return {
bundleSha256: plugin.bundleHash,
pinning: this.verdict(plugin, allowlist),
id: plugin.id, id: plugin.id,
name: plugin.name, name: plugin.name,
version: plugin.version, version: plugin.version,

View File

@ -140,6 +140,26 @@ export const INSTANCE_SETTINGS = {
// (an image fallback degrades to neutral text: its bytes live on the // (an image fallback degrades to neutral text: its bytes live on the
// disabled asset surface). // disabled asset surface).
'plugins.enabled': z.boolean().default(true), 'plugins.enabled': z.boolean().default(true),
// Plugin allowlist with SHA-256 hash pinning (issue #232, ADR 0025).
// Empty (the default) = pinning is NOT enforced — plugins load as
// before, which keeps existing instances working. Non-empty = only the
// listed plugin ids load, and only while the installed bundle's
// observed hash equals the pinned one; installs of unlisted or
// mismatching bundles are rejected, loads fail closed (assets/frame
// 404, audited `plugin.rejected`). A version bump changes the bundle
// hash, so it requires an explicit re-pin — the intended friction.
'plugins.allowlist': z
.array(
z.object({
id: z.string().min(1),
sha256: z
.string()
.trim()
.toLowerCase()
.regex(/^[a-f0-9]{64}$/),
}),
)
.default([]),
// Atom feed master switch (issue #191). Default ON: feeds predate the // Atom feed master switch (issue #191). Default ON: feeds predate the
// switch, so existing instances and their subscribed readers keep // switch, so existing instances and their subscribed readers keep
// working; the VS-NfD reference configuration (#227) turns it off. // working; the VS-NfD reference configuration (#227) turns it off.

View File

@ -51,10 +51,34 @@ export function PluginManager(): React.JSX.Element {
onError: (error) => setUploadError(error), onError: (error) => setUploadError(error),
}); });
// Hash-pinning allowlist (#232, ADR 0025): stored in instance settings;
// pinning the FIRST plugin turns enforcement on for every plugin.
const settings = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => apiGet<Record<string, unknown>>('/admin/settings'),
});
const allowlist = (settings.data?.['plugins.allowlist'] ?? []) as {
id: string;
sha256: string;
}[];
const saveAllowlist = useMutation({
mutationFn: (next: { id: string; sha256: string }[]) =>
apiPatch('/admin/settings', { 'plugins.allowlist': next }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['admin', 'settings'] });
void invalidate();
},
onError: (error) => setUploadError(error),
});
const pinnedHashOf = (id: string) => allowlist.find((entry) => entry.id === id)?.sha256;
return ( return (
<section className="settings-section plugin-manager"> <section className="settings-section plugin-manager">
<h2>{t('admin.title')}</h2> <h2>{t('admin.title')}</h2>
<p className="plugin-manager__intro">{t('admin.intro')}</p> <p className="plugin-manager__intro">{t('admin.intro')}</p>
<p className="plugin-manager__intro">
{allowlist.length === 0 ? t('admin.pinning.hintOff') : t('admin.pinning.hintOn')}
</p>
<div className="plugin-manager__upload"> <div className="plugin-manager__upload">
<label className="button"> <label className="button">
@ -97,6 +121,51 @@ export function PluginManager(): React.JSX.Element {
)} )}
</div> </div>
<div className="plugin-manager__pinning">
<span>{t('admin.pinning.state')}:</span>{' '}
<strong>{t(`admin.pinning.verdict.${plugin.pinning}`)}</strong>
<br />
<span>{t('admin.pinning.observed')}:</span>{' '}
{plugin.bundleSha256 ? (
<code className="plugin-manager__hash">{plugin.bundleSha256}</code>
) : (
<em>{t('admin.pinning.unknownHash')}</em>
)}
{pinnedHashOf(plugin.id) && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<>
<br />
<span>{t('admin.pinning.pinnedHash')}:</span>{' '}
<code className="plugin-manager__hash">{pinnedHashOf(plugin.id)}</code>
</>
)}
<br />
{plugin.bundleSha256 && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate([
...allowlist.filter((entry) => entry.id !== plugin.id),
{ id: plugin.id, sha256: plugin.bundleSha256! },
])
}
>
{pinnedHashOf(plugin.id) ? t('admin.pinning.repin') : t('admin.pinning.pin')}
</button>
)}
{pinnedHashOf(plugin.id) && (
<button
type="button"
className="linklike"
onClick={() =>
saveAllowlist.mutate(allowlist.filter((entry) => entry.id !== plugin.id))
}
>
{t('admin.pinning.unpin')}
</button>
)}
</div>
<div className="plugin-manager__controls"> <div className="plugin-manager__controls">
<label> <label>
{t('admin.mode')}:{' '} {t('admin.mode')}:{' '}

View File

@ -1,7 +1,7 @@
# Audit event catalogue # Audit event catalogue
**Catalogue version 1.4 (2026-07-31; 1.4 adds `auth.proxy_rejected`, **Catalogue version 1.5 (2026-07-31; 1.5 adds `plugin.rejected`,
issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added issue #232; 1.4 added `auth.proxy_rejected`, issue #215; 1.3 added `auth.identity_linked`, issue #214; 1.2 added
`read_trail.pruned`, issue #224; 1.1 added `page.classification_*`, `read_trail.pruned`, issue #224; 1.1 added `page.classification_*`,
issue #205).** issue #205).**
@ -124,12 +124,13 @@ failure), `warning` = feeds detection (suspicious or destructive),
### Plugins (`plugin.*`) ### Plugins (`plugin.*`)
| Id | Trigger | Severity | Actor | Target | Fields | | Id | Trigger | Severity | Actor | Target | Fields |
| --------------------- | -------------------------------------------------- | -------- | ------------------------------- | -------- | -------------------------- | | --------------------- | ------------------------------------------------------------ | -------- | ------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) | | `plugin.installed` | Plugin package installed or updated | notice | admin, `null` for dropzone drop | `plugin` | `version`, `update` (bool) |
| `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` | | `plugin.rejected` | Install or load blocked by the hash-pinning allowlist (#232) | warning | admin for installs, `null` for loads | `plugin` | `surface` (`install`/`load`), `reason` (`not_pinned`/`hash_mismatch`/`unpinned`/`mismatch`), `version`, `bundleHash` (installs) |
| `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — | | `plugin.mode_set` | Instance mode changed (disabled/optional/required) | notice | the admin | `plugin` | `mode` |
| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` | | `plugin.uninstalled` | Plugin removed | notice | the admin | `plugin` | — |
| `plugin.pond_toggled` | Optional plugin toggled for one pond | info | the pond admin | `pond` | `plugin`, `enabled` |
### Backup & restore (`backup.*`) ### Backup & restore (`backup.*`)

View File

@ -132,6 +132,33 @@ single, verifiable off-switch is what answers "code execution inside the
zone?" at the offer stage — cheaper than per-plugin trust machinery zone?" at the offer stage — cheaper than per-plugin trust machinery
(#232) and sufficient because it removes the surface entirely. (#232) and sufficient because it removes the surface entirely.
## Trust: hash-pinning allowlist (issue #232, ADR 0025)
Real code signing is unavailable without a legal entity to hold a signing
identity, so bundle trust is hash pinning:
- At install the api records the SHA-256 of the delivered bundle ZIP on
the plugin row (`plugins.bundle_hash`; plugins installed before #232
show it as unknown until reinstalled).
- `plugins.allowlist` in `instance_settings` names permitted plugin ids
with their pinned hashes. **Empty (the default) = pinning is not
enforced** — plugins load as before. Non-empty = enforcement for every
plugin: installs of unlisted ids or deviating bundles are rejected
(`plugin_not_pinned` / `plugin_hash_mismatch`), and an installed plugin
outside the list or with a deviating hash does not load — it disappears
from the pond mount lists and its frame/asset routes answer 404. Every
rejection is audited (`plugin.rejected`, catalogue v1.5).
- A version bump changes the bundle, hence the hash, hence requires an
explicit re-pin in the admin UI — deliberate friction (ADR 0025).
- Scope, stated honestly: the pin answers "is this the reviewed bundle".
The observed hash is recorded at install; tampering with the unpacked
files on disk afterwards is platform integrity (ADR 0019), and what the
loaded code may do remains the sandbox's job — neither substitutes for
the other.
- The VS-NfD reference configuration keeps the allowlist empty because it
turns plugins off entirely (`plugins.enabled=false`, #200); the
allowlist is for deployments that deviate and run plugins.
## Reference plugins (shipped with the product, also serving as examples) ## Reference plugins (shipped with the product, also serving as examples)
- `section-styles-basic` (`section_style`): a set of colored callout/box - `section-styles-basic` (`section_style`): a set of colored callout/box

View File

@ -167,12 +167,13 @@ _Meilenstein: `M31 — VS-NfD: backlog`_
Nur noch ein Punkt bleibt draußen: Nur noch ein Punkt bleibt draußen:
- **Plugin-Allowlist mit Hash-Pinning** · 810 AT · #232 - [x] **Plugin-Allowlist mit Hash-Pinning** · 810 AT · #232 (erledigt
Manifest mit SHA-256, Allowlist in `instance_settings`, Prüfung beim Laden, 31.07.2026)
Admin-UI. Bleibt zurückgestellt, weil Phase 2 mit der harten Abschaltung das SHA-256-Erfassung beim Install, Allowlist in `instance_settings`
Risiko bereits schließt — und weil echte Code-Signierung ohne juristische (`plugins.allowlist`), Prüfung bei Install und Load (fail-closed,
Person ohnehin nicht verfügbar ist. Hash-Pinning ist die richtige Antwort, auditiert), Admin-UI mit „gesehen vs. gepinnt". Restrisiko R-03 damit
aber nicht die dringendste. aufgelöst; Code-Signierung bleibt mangels juristischer Person
verworfen (ADR 0025).
--- ---

View File

@ -26,24 +26,25 @@ Produkt-Beleg).
Nach jeder Änderung an Instanz-Settings die api neu starten — der Nach jeder Änderung an Instanz-Settings die api neu starten — der
Settings-Cache ist in-process (operations.md). Settings-Cache ist in-process (operations.md).
| Setting | Referenzwert | Default | Warum | | Setting | Referenzwert | Default | Warum |
| ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. | | `auth.registrationMode` | `closed` | `open` | Konten entstehen in einer VS-Umgebung nur kontrolliert; Selbstregistrierung öffnet den Nutzerkreis unkontrolliert. |
| `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). | | `api.enabled` | `false` | `false` | Public REST API ist ein zusätzlicher Egress-Kanal; ohne dokumentierten Bedarf bleibt er zu (404 auf allen `/api/public/v1`-Routen). |
| `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. | | `mcp.enabled` | `false` | `false` | gleiches Argument für den MCP-Endpoint (`/api/mcp`); unabhängiger Schalter. |
| `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. | | `feeds.enabled` | `false` | `true` | **explizit setzen** — Atom-Feeds liefern Inhalte an Reader außerhalb der Kontrolle der Instanz (Feed-Token umgehen die Session); Kopien in Feed-Readern sind nicht einholbar (Kopienliste, Sicherheitsdokumentation §5). Schaltet Routen UND Feed-Token-Verwaltung auf 404. |
| `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Hash-Pinning ist verschoben (#232, Restrisikoliste) — der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig. | | `plugins.enabled` | `false` | `true` | **explizit setzen** — kein Fremdcode in der VS-Zone (#200): alle Plugin-Flächen 404, Dropzone quarantänisiert; bestehende Blöcke degradieren zu ihrem deklarierten Text-Fallback. Der Kill-Switch deckt das Risiko für diesen Betriebsmodus vollständig; wer abweichend Plugins betreibt, nutzt zusätzlich das Hash-Pinning (`plugins.allowlist`, #232). |
| `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. | | `plugins.allowlist` | leer (Plugins sind aus) | leer | Hash-Pinning-Allowlist (#232, ADR 0025): nicht-leer erzwingt, dass nur gepinnte Plugin-Ids mit exakt übereinstimmendem Bundle-SHA-256 installieren und laden (Abweisung auditiert `plugin.rejected`; Versionswechsel = explizites Re-Pin). In der Referenzkonfiguration bleibt sie leer, weil `plugins.enabled=false` das Risiko vollständig schließt; wer abweichend Plugins betreibt, pinnt GENAU die geprüften Bundles. |
| `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. | | `classification.newPageDefault` | `vs_nfd` | `unclassified` | in einer VS-NfD-Instanz beginnt nichts unmarkiert (#204); die Vererbung (#205) hält den Baum konsistent. |
| `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. | | `classification.uploadPolicy` | `block` | `warn` | Anhänge können die Kennzeichnung im Inhalt nicht tragen (#212) — die Referenzkonfiguration lehnt Uploads auf eingestufte Seiten serverseitig ab (403 `classified_upload_blocked`, #213) statt nur zu warnen. |
| `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). | | `upload.svgPolicy` | `reject` | `sanitize` | SVG ist aktiver Inhalt; die Sanitisierung ist gut getestet, aber Ablehnen ist die kleinere Angriffsfläche. Abweichung vertretbar, wenn SVG gebraucht wird. |
| `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). | | `upload.allowedExtensions` | nur das dienstlich Nötige (z. B. `pdf`) | Standardliste | jede zusätzliche Endung vergrößert die Menge nicht prüfbarer Binärformate im Bestand. Bilder sind davon unabhängig immer erlaubt (Magic-Byte-geprüft). |
| `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). | | `backup.nextcloud.enabled` | `false` | `false` | „Backup nur lokal": kein Anwendungs-Upload von Restore-Sets zu Drittdiensten. Fernspiegel regelt ausschließlich die Deploy-Allowlist (1.2). |
| `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. | | `trash.retentionDays`, `audit.retentionDays`, `conversion.payloadRetentionDays`, `mail.outboxRetentionDays` | Defaults (30/365/30/30) | ebd. | Aufbewahrung bewusst begrenzt; Verkürzung nach Betreiber-Löschkonzept zulässig (Betriebshandbuch §5). |
| `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. | | `readTrail.enabled` | `true` | `false` | **explizit setzen** — der Lesetrail (#222#225) evidenziert Lesezugriffe auf eingestufte Seiten; Default aus, weil Lesebeobachtung mitbestimmungsrelevant ist. Einschalten NUR zusammen mit der Zweckbindung (Sicherheitsdokumentation §7); die api meldet die Schalterstellung beim Start. |
| `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. | | `readTrail.dedupWindowMinutes` | Default (5) | `5` | Dedup-Fenster des Lesetrails (#223): je (Sitzung, Seite, Kanal) ein Ereignis pro Fenster — begrenzt die Ereignisflut einer Live-Sitzung auf ~12/h. Kleiner = feineres Protokoll und mehr Zeilen; Änderung mit dem Zweckbindungs-Dokument (#225) abstimmen. |
| `idpMapping.rules` | Gruppen→Rollen der Behörde abbilden | `[]` | deklaratives Claim-Mapping (#217): IdP-Gruppen werden bei jedem OIDC-Login auf Teich-Rollen und das Site-Admin-Flag abgeglichen — sonst pflegt die Behörde Berechtigungen doppelt und die zweite Kopie driftet. Mapping fasst nur eigene Grants an (manuell gewinnt); Details: permissions.md §IdP claim mapping. | | `readTrail.retentionDays` | Default (365) | `365` | eigene Aufbewahrung des Lesetrails (#224), bewusst getrennt von `audit.retentionDays`; Löschläufe sind selbst auditiert (`read_trail.pruned`). Dauer mit der Zweckbindung (#225) und dem Betreiber-Löschkonzept abstimmen. |
| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. | | `idpMapping.rules` | Gruppen→Rollen der Behörde abbilden | `[]` | deklaratives Claim-Mapping (#217): IdP-Gruppen werden bei jedem OIDC-Login auf Teich-Rollen und das Site-Admin-Flag abgeglichen — sonst pflegt die Behörde Berechtigungen doppelt und die zweite Kopie driftet. Mapping fasst nur eigene Grants an (manuell gewinnt); Details: permissions.md §IdP claim mapping. |
| `legal.imprint`, `legal.privacyPolicy` | befüllt | leer | Betreiberpflicht; leere Seiten zeigen einen Warnbanner. |
### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI) ### 1.2 Deploy-Konfiguration (`.env` / Compose — nur Plattformzugriff, bewusst nicht per Admin-UI)

View File

@ -49,19 +49,25 @@ nachvollziehbar.
- **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche - **Entscheidung:** Projektleitung, Issue #216, 31.07.2026 (ursprüngliche
Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026). Aufnahme: Maßnahmenplan Rev. 2, 30.07.2026).
## R-03 Plugin-Hash-Pinning verschoben ## R-03 Plugin-Hash-Pinning verschoben — AUFGELÖST (31.07.2026, #232)
- **Risiko:** Installierte Plugin-Pakete sind nicht gegen einen - **Ursprüngliches Risiko:** Installierte Plugin-Pakete waren nicht gegen
festgeschriebenen Hash verankert (#232); ein manipuliertes Paket einen festgeschriebenen Hash verankert; ein manipuliertes Paket
gleichen Namens wäre beim Neuinstallieren nicht erkennbar. gleichen Namens wäre beim Neuinstallieren nicht erkennbar gewesen.
- **Warum akzeptiert:** Die Referenzkonfiguration betreibt Plugins - **Auflösung:** Hash-Pinning ist mit #232 umgesetzt (ADR 0025):
**gar nicht** (`plugins.enabled=false`, #200) — der Kill-Switch deckt SHA-256-Erfassung beim Install, Allowlist `plugins.allowlist`
den VS-NfD-Betriebsmodus vollständig; Pinning lohnt erst, wenn ein (id + gepinnter Hash), Durchsetzung bei Install UND Load (fail-closed,
Betreiber Plugins tatsächlich freigibt. Abweisungen auditiert `plugin.rejected`), Versionswechsel nur per
- **Kompensation:** Kill-Switch (alle Plugin-Flächen 404, Dropzone explizitem Re-Pin. Die Referenzkonfiguration betreibt Plugins weiterhin
quarantänisiert); Sandbox mit Capability-Modell + CI-Escape- gar nicht (`plugins.enabled=false`); für abweichende Betreiber ist das
Regressionstest (ADR 0008/0025); Install nur durch Site-Admin. Pinning jetzt verfügbar statt zurückgestellt.
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232, 30.07.2026. - **Verbleibender Hinweis (dokumentiert):** Der gesehene Hash wird beim
Install erfasst; nachträgliche Manipulation entpackter Dateien auf der
Platte ist Plattform-Integrität (ADR 0019), nicht Gegenstand der
Prüfung. Vor #232 installierte Plugins tragen bis zur Neuinstallation
keinen erfassten Hash.
- **Entscheidung:** Projektleitung, ADR 0025 / Issue #232; aufgelöst
31.07.2026 (ursprüngliche Aufnahme 30.07.2026).
## R-04 Git-Historie: einmalige Secret-Prüfung mit begrenztem Muster ## R-04 Git-Historie: einmalige Secret-Prüfung mit begrenztem Muster

View File

@ -112,5 +112,7 @@
"pond_not_found": "Der Teich existiert nicht.", "pond_not_found": "Der Teich existiert nicht.",
"classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).", "classification_lower_forbidden": "Zum Herabstufen der Einstufung fehlt die Berechtigung (Teich-Admin erforderlich).",
"classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.", "classified_upload_blocked": "Uploads auf eingestufte Seiten sind auf dieser Instanz blockiert.",
"vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced)." "vs_nfd_profile_violation": "Diese Einstellung würde vom VS-NfD-Referenzprofil abweichen — das Deployment erzwingt das Profil (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "Dieses Plugin steht nicht auf der Allowlist (Hash-Pinning aktiv) — erst pinnen, dann installieren.",
"plugin_hash_mismatch": "Der Bundle-Hash weicht vom gepinnten Hash ab — das Bundle ist nicht das geprüfte (oder eine neue Version braucht ein Re-Pin)."
} }

View File

@ -31,7 +31,24 @@
"mode": "Modus", "mode": "Modus",
"preview": "Vorschau", "preview": "Vorschau",
"uninstall": "Deinstallieren", "uninstall": "Deinstallieren",
"requiredLocked": "Ein erforderliches Plugin kann nicht deinstalliert werden." "requiredLocked": "Ein erforderliches Plugin kann nicht deinstalliert werden.",
"pinning": {
"state": "Hash-Pinning",
"verdict": {
"not_enforced": "nicht erzwungen (Allowlist leer)",
"pinned": "gepinnt — Bundle entspricht dem geprüften Stand",
"unpinned": "NICHT auf der Allowlist — lädt nicht",
"mismatch": "HASH WEICHT AB — lädt nicht"
},
"observed": "Gesehener Bundle-Hash (SHA-256)",
"pinnedHash": "Gepinnter Hash",
"unknownHash": "unbekannt (vor der Hash-Erfassung installiert — neu installieren, um ihn zu erfassen)",
"pin": "Diesen Hash pinnen",
"repin": "Pin auf diesen Hash aktualisieren",
"unpin": "Pin entfernen",
"hintOff": "Hash-Pinning ist aus (Allowlist leer): Plugins laden wie bisher. Das Pinnen des ersten Plugins schaltet die Durchsetzung für ALLE Plugins ein.",
"hintOn": "Hash-Pinning aktiv: Nur gepinnte Plugins mit übereinstimmendem Bundle-Hash werden installiert und geladen; jede Abweisung wird auditiert. Ein Versions-Update braucht ein explizites Re-Pin."
}
}, },
"mode": { "mode": {
"disabled": "Deaktiviert", "disabled": "Deaktiviert",

View File

@ -112,5 +112,7 @@
"pond_not_found": "The pond does not exist.", "pond_not_found": "The pond does not exist.",
"classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).", "classification_lower_forbidden": "You lack the permission to lower the classification (Pond Admin required).",
"classified_upload_blocked": "Uploads to classified pages are blocked on this instance.", "classified_upload_blocked": "Uploads to classified pages are blocked on this instance.",
"vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced)." "vs_nfd_profile_violation": "This setting would deviate from the VS-NfD reference profile — the deployment enforces the profile (VS_NFD_MODE=enforced).",
"plugin_not_pinned": "This plugin is not on the allowlist (hash pinning active) — pin it first, then install.",
"plugin_hash_mismatch": "The bundle hash deviates from the pinned hash — the bundle is not the reviewed one (or a new version needs a re-pin)."
} }

View File

@ -31,7 +31,24 @@
"mode": "Mode", "mode": "Mode",
"preview": "Preview", "preview": "Preview",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"requiredLocked": "A required plugin cannot be uninstalled." "requiredLocked": "A required plugin cannot be uninstalled.",
"pinning": {
"state": "Hash pinning",
"verdict": {
"not_enforced": "not enforced (allowlist empty)",
"pinned": "pinned — bundle matches the reviewed state",
"unpinned": "NOT on the allowlist — does not load",
"mismatch": "HASH DEVIATES — does not load"
},
"observed": "Observed bundle hash (SHA-256)",
"pinnedHash": "Pinned hash",
"unknownHash": "unknown (installed before hash recording — reinstall to record it)",
"pin": "Pin this hash",
"repin": "Update pin to this hash",
"unpin": "Remove pin",
"hintOff": "Hash pinning is off (allowlist empty): plugins load as before. Pinning the first plugin turns enforcement on for ALL plugins.",
"hintOn": "Hash pinning active: only pinned plugins with a matching bundle hash install and load; every rejection is audited. A version update requires an explicit re-pin."
}
}, },
"mode": { "mode": {
"disabled": "Disabled", "disabled": "Disabled",

View File

@ -36,6 +36,12 @@ export interface PluginView {
assetBasePath: string; assetBasePath: string;
license: string; license: string;
homepage?: string; homepage?: string;
/** SHA-256 (hex) of the installed bundle ZIP (#232); null = installed
* before hash recording existed reinstall to record it. */
bundleSha256: string | null;
/** Verdict against `plugins.allowlist` (#232): `not_enforced` while the
* allowlist is empty; otherwise pinned / unpinned / mismatch. */
pinning: 'not_enforced' | 'pinned' | 'unpinned' | 'mismatch';
installedAt: string; installedAt: string;
updatedAt: string; updatedAt: string;
} }
@ -58,6 +64,8 @@ export const PLUGIN_ERROR_CODES = [
'plugin_required_cannot_uninstall', 'plugin_required_cannot_uninstall',
'plugin_not_found', 'plugin_not_found',
'plugin_not_optional', 'plugin_not_optional',
'plugin_not_pinned',
'plugin_hash_mismatch',
] as const; ] as const;
export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number]; export type PluginErrorCode = (typeof PLUGIN_ERROR_CODES)[number];

View File

@ -159,6 +159,7 @@ export const VS_NFD_PROFILE: readonly VsNfdProfileEntry[] = [
*/ */
export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [ export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [
{ scope: 'instance', key: 'upload.allowedExtensions' }, { scope: 'instance', key: 'upload.allowedExtensions' },
{ scope: 'instance', key: 'plugins.allowlist' },
{ scope: 'instance', key: 'trash.retentionDays' }, { scope: 'instance', key: 'trash.retentionDays' },
{ scope: 'instance', key: 'audit.retentionDays' }, { scope: 'instance', key: 'audit.retentionDays' },
{ scope: 'instance', key: 'conversion.payloadRetentionDays' }, { scope: 'instance', key: 'conversion.payloadRetentionDays' },