dorfteich/packages/shared/src/vs-nfd-profile.ts
Claude Fable 5 9b7acab294
All checks were successful
CI / Lint, typecheck, test (pull_request) Successful in 6m21s
CI / Build container images (pull_request) Successful in 3m59s
CI / Auth e2e pack (pull_request) Successful in 8m35s
CI / Import/export fidelity gate (pull_request) Successful in 1m1s
CD / Build and push images (push) Successful in 17s
CD / Deploy to Test (push) Successful in 14s
CD / Smoke tests against Test (push) Successful in 1m17s
CD / Promote to Int (push) Successful in 12s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m27s
CI / Import/export fidelity gate (push) Successful in 58s
CI / Lint, typecheck, test (push) Successful in 6m30s
#232: plugin allowlist with SHA-256 hash pinning
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUtYMxwTCMHG9mVHnwbFg8
2026-07-31 21:26:36 +02:00

227 lines
7.3 KiB
TypeScript

import { z } from 'zod';
/**
* The VS-NfD hardening-profile mode and the machine-readable configuration
* catalog (issue #243, ADR 0027).
*
* The deployment declares through `VS_NFD_MODE` how the application treats
* configuration options that violate the VS-NfD reference profile
* (docs/vs-nfd/50-haertungsleitfaden.md):
*
* - `off` — VS-NfD is not a topic; no marking anywhere (the default).
* - `marked` — options stay available, violations are marked (#244).
* - `hidden` — violating options disappear, the hiding is marked (#245).
* - `enforced` — violating writes are rejected server-side (#246).
*
* Deploy-level (env) on purpose, like BACKUP_ALLOWED_TARGETS (#192): a
* compromised Site Admin must not be able to widen the mode at runtime.
*
* The catalog below is the single source of truth for "profile-relevant":
* every entry names the setting, the machine-checkable compliant value and
* the hardening-guide section it comes from. A fence test keeps catalog
* and guide from drifting (vs-nfd-profile-catalogue.test.ts, pattern
* #201): every switch listed in the guide is either here or in the
* explicit advisory list — never silently absent.
*/
export const VS_NFD_MODES = ['off', 'marked', 'hidden', 'enforced'] as const;
export type VsNfdMode = (typeof VS_NFD_MODES)[number];
export const vsNfdModeSchema = z.enum(VS_NFD_MODES);
/**
* The decidable compliance predicates. Deliberately tiny: a catalog entry
* must be checkable without prose interpretation, or it belongs in the
* advisory list instead.
*/
export type VsNfdCompliance =
| { kind: 'equals'; value: boolean | string }
| { kind: 'maxNumber'; value: number }
| { kind: 'nonEmpty' };
export interface VsNfdProfileEntry {
/** `instance` = instance_settings key; `deploy` = env variable. */
scope: 'instance' | 'deploy';
key: string;
compliance: VsNfdCompliance;
/** Section of docs/vs-nfd/50-haertungsleitfaden.md the value comes from. */
hardeningRef: '1.1' | '1.2';
}
/**
* Profile entries with a machine-checkable compliant value. Order mirrors
* the hardening guide. Pond-level opt-ins (`apiEnabled`, `mcpEnabled`)
* are deliberately absent: their instance master switches govern, so a
* pond opt-in cannot violate the profile on its own (ADR 0027).
*/
export const VS_NFD_PROFILE: readonly VsNfdProfileEntry[] = [
{
scope: 'instance',
key: 'auth.registrationMode',
compliance: { kind: 'equals', value: 'closed' },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'api.enabled',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'mcp.enabled',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'feeds.enabled',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'plugins.enabled',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'classification.newPageDefault',
compliance: { kind: 'equals', value: 'vs_nfd' },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'classification.uploadPolicy',
compliance: { kind: 'equals', value: 'block' },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'upload.svgPolicy',
compliance: { kind: 'equals', value: 'reject' },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'backup.nextcloud.enabled',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'readTrail.enabled',
compliance: { kind: 'equals', value: true },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'legal.imprint',
compliance: { kind: 'nonEmpty' },
hardeningRef: '1.1',
},
{
scope: 'instance',
key: 'legal.privacyPolicy',
compliance: { kind: 'nonEmpty' },
hardeningRef: '1.1',
},
{
scope: 'deploy',
key: 'SESSION_ABSOLUTE_HOURS',
compliance: { kind: 'maxNumber', value: 12 },
hardeningRef: '1.2',
},
{
scope: 'deploy',
key: 'SESSION_IDLE_HOURS',
compliance: { kind: 'maxNumber', value: 2 },
hardeningRef: '1.2',
},
{
scope: 'deploy',
key: 'LOG_LEVEL',
compliance: { kind: 'equals', value: 'info' },
hardeningRef: '1.2',
},
{
scope: 'deploy',
key: 'AUTH_LOCAL_ENABLED',
compliance: { kind: 'equals', value: false },
hardeningRef: '1.2',
},
] as const;
/**
* Guide entries whose reference value is a judgement call ("only what the
* service needs", "configure the agency IdP", "leave unset or one host") —
* profile-relevant, but not machine-checkable. Listed explicitly so the
* fence test still notices when the guide gains a switch nobody triaged.
*/
export const VS_NFD_PROFILE_ADVISORY: readonly { scope: 'instance' | 'deploy'; key: string }[] = [
{ scope: 'instance', key: 'upload.allowedExtensions' },
{ scope: 'instance', key: 'plugins.allowlist' },
{ scope: 'instance', key: 'trash.retentionDays' },
{ scope: 'instance', key: 'audit.retentionDays' },
{ scope: 'instance', key: 'conversion.payloadRetentionDays' },
{ scope: 'instance', key: 'mail.outboxRetentionDays' },
{ scope: 'instance', key: 'readTrail.dedupWindowMinutes' },
{ scope: 'instance', key: 'readTrail.retentionDays' },
{ scope: 'instance', key: 'idpMapping.rules' },
{ scope: 'deploy', key: 'VS_NFD_MODE' },
{ scope: 'deploy', key: 'BACKUP_ALLOWED_TARGETS' },
{ scope: 'deploy', key: 'SMTP_HOST' },
{ scope: 'deploy', key: 'WEB_PORT' },
{ scope: 'deploy', key: 'API_PORT' },
{ scope: 'deploy', key: 'COLLAB_PORT' },
{ scope: 'deploy', key: 'OIDC_ISSUER' },
{ scope: 'deploy', key: 'OIDC_CLIENT_ID' },
{ scope: 'deploy', key: 'OIDC_CLIENT_SECRET' },
{ scope: 'deploy', key: 'OIDC_SCOPES' },
{ scope: 'deploy', key: 'OIDC_PROVIDER_LABEL' },
{ scope: 'deploy', key: 'AUTH_PROXY_HEADER' },
{ scope: 'deploy', key: 'AUTH_PROXY_TRUSTED_PEERS' },
{ scope: 'deploy', key: 'AUTH_PROXY_MAP' },
{ scope: 'deploy', key: 'AUTH_PROXY_MODE' },
{ scope: 'deploy', key: 'AUTH_PROXY_DN_ATTRIBUTE' },
] as const;
/** True when `value` satisfies the entry's compliance predicate. */
export function isVsNfdCompliant(entry: VsNfdProfileEntry, value: unknown): boolean {
switch (entry.compliance.kind) {
case 'equals':
return value === entry.compliance.value;
case 'maxNumber':
return typeof value === 'number' && value <= entry.compliance.value;
case 'nonEmpty':
return typeof value === 'string' && value.trim().length > 0;
}
}
/** One evaluated catalog row, as the admin endpoint reports it (#243). */
export interface VsNfdProfileEntryView {
scope: 'instance' | 'deploy';
key: string;
compliant: boolean;
/** The compliant value, rendered for display (booleans/numbers stringified). */
compliantValue: string;
hardeningRef: '1.1' | '1.2';
}
export interface VsNfdProfileView {
mode: VsNfdMode;
entries: VsNfdProfileEntryView[];
violations: number;
}
/** Display form of a predicate — shared so api and web render it alike. */
export function describeCompliance(compliance: VsNfdCompliance): string {
switch (compliance.kind) {
case 'equals':
return String(compliance.value);
case 'maxNumber':
return `<= ${compliance.value}`;
case 'nonEmpty':
return 'non-empty';
}
}