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
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
227 lines
7.3 KiB
TypeScript
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';
|
|
}
|
|
}
|