From 9b7acab294d54be342293a7de47f5d473a1bcfc7 Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Fri, 31 Jul 2026 21:26:36 +0200 Subject: [PATCH] #232: plugin allowlist with SHA-256 hash pinning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install path records the SHA-256 of the delivered bundle ZIP (plugins.bundle_hash; pre-#232 installs show it as unknown until reinstalled). plugins.allowlist in instance_settings names permitted ids with their pinned hashes: empty (default) = not enforced, existing instances unchanged; non-empty = installs of unlisted or deviating bundles are rejected (plugin_not_pinned / plugin_hash_mismatch, 403), and an installed plugin outside the list or with a deviating hash does not load — absent from pond mount lists, frame/assets 404. Every rejection is audited (plugin.rejected, catalogue v1.5). A version bump changes the hash and therefore requires an explicit re-pin — the intended friction (ADR 0025). Admin UI shows observed vs pinned hash per plugin with pin/re-pin/unpin. Scope stated honestly in plugin-architecture.md: the pin answers "is this the reviewed bundle"; post-install disk tampering is platform integrity (ADR 0019), sandbox containment stays the sandbox's job. Hardening guide row + catalog advisory triage; residual risk R-03 resolved. e2e: empty-allowlist compatibility, pinned load, unpinned and tampered installs rejected and audited, pin drift blocks loading while the admin still sees the mismatch, version bump needs re-pin. Full api suite 101 files / 561 green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8 --- .../migration.sql | 4 + apps/api/prisma/schema.prisma | 2 + apps/api/src/audit/audit-actions.ts | 1 + .../src/plugins/plugin-admin.controller.ts | 5 + .../src/plugins/plugin-assets.controller.ts | 9 +- .../src/plugins/plugin-pinning.e2e.db.test.ts | 172 ++++++++++++++++++ apps/api/src/plugins/plugins.service.ts | 110 ++++++++++- .../src/settings/instance-settings.service.ts | 20 ++ apps/web/src/pages/PluginManager.tsx | 69 +++++++ docs/architecture/audit-events.md | 17 +- docs/architecture/plugin-architecture.md | 27 +++ docs/vs-nfd/20-massnahmenplan.md | 13 +- docs/vs-nfd/50-haertungsleitfaden.md | 37 ++-- docs/vs-nfd/90-restrisiken.md | 30 +-- packages/shared/i18n/de/errors.json | 4 +- packages/shared/i18n/de/plugins.json | 19 +- packages/shared/i18n/en/errors.json | 4 +- packages/shared/i18n/en/plugins.json | 19 +- packages/shared/src/plugins.ts | 8 + packages/shared/src/vs-nfd-profile.ts | 1 + 20 files changed, 511 insertions(+), 60 deletions(-) create mode 100644 apps/api/prisma/migrations/20260731210000_plugin_bundle_hash/migration.sql create mode 100644 apps/api/src/plugins/plugin-pinning.e2e.db.test.ts diff --git a/apps/api/prisma/migrations/20260731210000_plugin_bundle_hash/migration.sql b/apps/api/prisma/migrations/20260731210000_plugin_bundle_hash/migration.sql new file mode 100644 index 0000000..19838e6 --- /dev/null +++ b/apps/api/prisma/migrations/20260731210000_plugin_bundle_hash/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 835e7e9..c6377bd 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -885,6 +885,8 @@ model Plugin { mode PluginInstanceMode @default(DISABLED) /// The full manifest as validated at install time (@dorfteich/plugin-sdk). 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") updatedAt DateTime @updatedAt @map("updated_at") /// Set when uninstalled; active queries filter `removedAt: null`. diff --git a/apps/api/src/audit/audit-actions.ts b/apps/api/src/audit/audit-actions.ts index 7ebf176..5ed0f89 100644 --- a/apps/api/src/audit/audit-actions.ts +++ b/apps/api/src/audit/audit-actions.ts @@ -36,6 +36,7 @@ export const AUDIT_EVENTS = { 'page.classification_lowered': { severity: 'warning' }, 'page.classification_raised': { severity: 'notice' }, 'plugin.installed': { severity: 'notice' }, + 'plugin.rejected': { severity: 'warning' }, 'plugin.mode_set': { severity: 'notice' }, 'plugin.pond_toggled': { severity: 'info' }, 'plugin.uninstalled': { severity: 'notice' }, diff --git a/apps/api/src/plugins/plugin-admin.controller.ts b/apps/api/src/plugins/plugin-admin.controller.ts index 7d50236..0505da9 100644 --- a/apps/api/src/plugins/plugin-admin.controller.ts +++ b/apps/api/src/plugins/plugin-admin.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, ConflictException, + ForbiddenException, Controller, Delete, Get, @@ -40,6 +41,10 @@ function toHttpException(error: PluginPackageError): HttpException { case 'plugin_version_not_higher': case 'plugin_not_optional': 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': return new PayloadTooLargeException(body); default: diff --git a/apps/api/src/plugins/plugin-assets.controller.ts b/apps/api/src/plugins/plugin-assets.controller.ts index a20a20b..51583c3 100644 --- a/apps/api/src/plugins/plugin-assets.controller.ts +++ b/apps/api/src/plugins/plugin-assets.controller.ts @@ -96,7 +96,9 @@ export class PluginAssetsController { @Param('version') version: string, @Res({ passthrough: true }) response: Response, ): Promise { - 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(); // Asset base is built from the configured public origin, not the request @@ -122,8 +124,9 @@ export class PluginAssetsController { @Res({ passthrough: true }) response: Response, ): Promise { // Only serve assets for an installed, current version — a removed plugin or - // a stale version pointer must not leak files. - const plugin = await this.plugins.get(id); + // a stale version pointer must not leak files. getServable additionally + // enforces the hash-pinning allowlist (#232). + const plugin = await this.plugins.getServable(id); if (!plugin || plugin.version !== version) throw new NotFoundException(); const rest = (request.params as Record).rest; diff --git a/apps/api/src/plugins/plugin-pinning.e2e.db.test.ts b/apps/api/src/plugins/plugin-pinning.e2e.db.test.ts new file mode 100644 index 0000000..55f5e95 --- /dev/null +++ b/apps/api/src/plugins/plugin-pinning.e2e.db.test.ts @@ -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 = {}): Record { + 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, 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 { + 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); + }); +}); diff --git a/apps/api/src/plugins/plugins.service.ts b/apps/api/src/plugins/plugins.service.ts index d1a77f0..e5288de 100644 --- a/apps/api/src/plugins/plugins.service.ts +++ b/apps/api/src/plugins/plugins.service.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto'; + import { Injectable } from '@nestjs/common'; import { Plugin, PluginInstanceMode as DbPluginMode, Prisma, User } from '@prisma/client'; import { PinoLogger } from 'nestjs-pino'; @@ -12,6 +14,7 @@ import type { import { ClockService } from '../common/clock.service'; import { AuditService } from '../audit/audit.service'; import { PrismaService } from '../prisma/prisma.service'; +import { InstanceSettingsService } from '../settings/instance-settings.service'; import { PluginPackageService } from './plugin-package.service'; import { PluginStorageService } from './plugin-storage.service'; @@ -44,6 +47,7 @@ export class PluginsService { private readonly storage: PluginStorageService, private readonly clock: ClockService, private readonly audit: AuditService, + private readonly settings: InstanceSettingsService, private readonly logger: PinoLogger, ) { this.logger.setContext(PluginsService.name); @@ -58,6 +62,32 @@ export class PluginsService { /** `actor` is absent for dropzone installs (watcher, no session). */ async install(zip: Buffer, actor?: User): Promise { 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 isActiveUpdate = existing !== null && existing.removedAt === null; @@ -81,6 +111,7 @@ export class PluginsService { apiVersion: manifest.apiVersion, kind: manifest.kind, manifest: manifest as unknown as Prisma.InputJsonValue, + bundleHash, }, update: { name: manifest.name, @@ -88,6 +119,7 @@ export class PluginsService { apiVersion: manifest.apiVersion, kind: manifest.kind, manifest: manifest as unknown as Prisma.InputJsonValue, + bundleHash, // Reinstalling a previously removed plugin clears the tombstone. removedAt: null, }, @@ -105,7 +137,7 @@ export class PluginsService { targetId: manifest.id, 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, 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 } }), ]); const enabled = new Map(activations.map((a) => [a.pluginId, a.enabled])); - return plugins - .filter((p) => p.mode === 'REQUIRED' || (p.mode === 'OPTIONAL' && enabled.get(p.id) === true)) - .map((p) => this.toView(p)); + const allowlist = await this.settings.get('plugins.allowlist'); + return ( + 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 } }), ]); 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 }, 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. */ async get(id: string): Promise { const plugin = await this.prisma.plugin.findUnique({ where: { id } }); 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 { + 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; return { + bundleSha256: plugin.bundleHash, + pinning: this.verdict(plugin, allowlist), id: plugin.id, name: plugin.name, version: plugin.version, diff --git a/apps/api/src/settings/instance-settings.service.ts b/apps/api/src/settings/instance-settings.service.ts index d69a71b..edf01f0 100644 --- a/apps/api/src/settings/instance-settings.service.ts +++ b/apps/api/src/settings/instance-settings.service.ts @@ -140,6 +140,26 @@ export const INSTANCE_SETTINGS = { // (an image fallback degrades to neutral text: its bytes live on the // disabled asset surface). '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 // switch, so existing instances and their subscribed readers keep // working; the VS-NfD reference configuration (#227) turns it off. diff --git a/apps/web/src/pages/PluginManager.tsx b/apps/web/src/pages/PluginManager.tsx index ce1772b..5c25bcb 100644 --- a/apps/web/src/pages/PluginManager.tsx +++ b/apps/web/src/pages/PluginManager.tsx @@ -51,10 +51,34 @@ export function PluginManager(): React.JSX.Element { 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>('/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 (

{t('admin.title')}

{t('admin.intro')}

+

+ {allowlist.length === 0 ? t('admin.pinning.hintOff') : t('admin.pinning.hintOn')} +

+
+ {t('admin.pinning.state')}:{' '} + {t(`admin.pinning.verdict.${plugin.pinning}`)} +
+ {t('admin.pinning.observed')}:{' '} + {plugin.bundleSha256 ? ( + {plugin.bundleSha256} + ) : ( + {t('admin.pinning.unknownHash')} + )} + {pinnedHashOf(plugin.id) && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && ( + <> +
+ {t('admin.pinning.pinnedHash')}:{' '} + {pinnedHashOf(plugin.id)} + + )} +
+ {plugin.bundleSha256 && pinnedHashOf(plugin.id) !== plugin.bundleSha256 && ( + + )} + {pinnedHashOf(plugin.id) && ( + + )} +
+