Security to-do list: the Security page, and critical items on the dashboard #59
No files matched your search
@@ -60,9 +60,9 @@ const ActionPage = lazyFeature(
|
||||
() => import('@/features/actions/ActionPage'),
|
||||
(m) => m.ActionPage,
|
||||
);
|
||||
const LegacyProtocolsPage = lazyFeature(
|
||||
() => import('@/features/hardening/LegacyProtocolsPage'),
|
||||
(m) => m.LegacyProtocolsPage,
|
||||
const SecurityPage = lazyFeature(
|
||||
() => import('@/features/security/SecurityPage'),
|
||||
(m) => m.SecurityPage,
|
||||
);
|
||||
// inbuxa: Management › Compliance › Overview and Data Inventory
|
||||
// (personal-data catalog spec, §8).
|
||||
@@ -211,8 +211,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
return <LiveTracingPage />;
|
||||
}
|
||||
// INBUXA: Settings › Security › Hardening (legacy-protocols spec).
|
||||
// inbuxa: the security to-do list, which holds the legacy-protocols switch
|
||||
if (componentName === 'LegacyProtocols') {
|
||||
return <LegacyProtocolsPage />;
|
||||
return <SecurityPage />;
|
||||
}
|
||||
// inbuxa: Settings › Overview (settings-reorg).
|
||||
if (componentName === 'SettingsOverview') {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { CircleAlert } from 'lucide-react';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import type { ServerFacts } from '../serverFacts';
|
||||
import { hrefFor, type DashLink } from '../links';
|
||||
import { useCriticalCount } from '@/features/security/useCriticalCount';
|
||||
|
||||
interface Phrase {
|
||||
text: string;
|
||||
@@ -25,6 +26,8 @@ interface Phrase {
|
||||
export function StatusLine({ facts }: { facts: ServerFacts | null }) {
|
||||
const { t } = useTranslation();
|
||||
const { canViewObject } = usePermissions();
|
||||
// inbuxa: SS-27, critical security items not accepted
|
||||
const critical = useCriticalCount();
|
||||
if (!facts) return null;
|
||||
|
||||
const n = (key: string, count: number, one: string, other: string) =>
|
||||
@@ -50,6 +53,17 @@ export function StatusLine({ facts }: { facts: ServerFacts | null }) {
|
||||
|
||||
// Quiet when all is well: the line only appears when something needs a look.
|
||||
const visible = attention.filter((p) => canViewObject(p.link.viewName));
|
||||
// Already limited to those who may see the Security page
|
||||
if (critical)
|
||||
visible.push({
|
||||
text: n(
|
||||
'status.securityCritical',
|
||||
critical,
|
||||
'{{count}} critical security item',
|
||||
'{{count}} critical security items',
|
||||
),
|
||||
link: { viewName: 'CustomComponent/LegacyProtocols', section: 'Settings', label: '' },
|
||||
});
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
const lead = n('status.needsLook', visible.length, 'One thing needs a look:', '{{count}} things need a look:');
|
||||
|
||||
@@ -154,7 +154,7 @@ export function PortsTable({ onChanged }: { onChanged?: () => void }) {
|
||||
className="text-xs text-primary hover:underline"
|
||||
to={`/${hardeningSection}/CustomComponent/LegacyProtocols`}
|
||||
>
|
||||
{t('ports.onHardening', 'Switched on Hardening')}
|
||||
{t('ports.onHardening', 'Switched on the Security page')}
|
||||
</Link>
|
||||
) : confirming === s.id ? (
|
||||
<span className="flex flex-wrap items-center justify-end gap-2 text-xs">
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: Settings › Security › Security (security to-do list spec). What
|
||||
* isn't as it should be, most serious first, each with one action: Fix (one
|
||||
* field, undoable from the toast), Review (the setting, with why), or the
|
||||
* legacy-protocols switch below. Any item can be accepted with a note,
|
||||
* which every administrator then sees. No score, and no "all good" banner:
|
||||
* when nothing is to do, the page says nothing about it.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ChevronDown, CircleSlash, Loader2, Wrench } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { toast, useToast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ExplainButton } from '@/features/ai/explain/ExplainButton';
|
||||
import { LegacyProtocolsPage } from '@/features/hardening/LegacyProtocolsPage';
|
||||
import { HelpPanel } from '@/help/HelpPanel';
|
||||
import { evaluate, type Item, type Passed, type Severity } from './checks';
|
||||
import { loadSnapshot } from './load';
|
||||
import {
|
||||
accept,
|
||||
AcceptancesUnavailable,
|
||||
fetchAcceptances,
|
||||
removeAcceptance,
|
||||
sortOut,
|
||||
type Acceptance,
|
||||
type Sorted,
|
||||
} from './acceptances';
|
||||
|
||||
interface Loaded {
|
||||
sorted: Sorted;
|
||||
passed: Passed[];
|
||||
/** Null when the server can't keep acceptances: the page works without Accept. */
|
||||
acceptances: Acceptance[] | null;
|
||||
}
|
||||
|
||||
const SEVERITY_STYLE: Record<Severity, string> = {
|
||||
critical: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||
important: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30',
|
||||
good: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
async function writePatch(object: string, id: string, patch: Record<string, unknown>) {
|
||||
const responses = await jmapRequest([
|
||||
[`${object}/set`, { accountId: getAccountId(object), update: { [id]: patch } }, '0'],
|
||||
]);
|
||||
const [name, body] = responses[0] ?? [];
|
||||
if (name !== `${object}/set`) {
|
||||
throw new Error((body as { description?: string } | undefined)?.description ?? 'Request failed');
|
||||
}
|
||||
const failed = (body as { notUpdated?: Record<string, { description?: string; type?: string }> }).notUpdated?.[id];
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
}
|
||||
|
||||
function scrollToHardening() {
|
||||
document.getElementById('hardening')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
export function SecurityPage() {
|
||||
const { t } = useTranslation();
|
||||
const { dismiss } = useToast();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const canAccept = useAccountStore((s) => s.hasPermission('sysSecurityAccept'));
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [accepting, setAccepting] = useState<string | null>(null);
|
||||
const [note, setNote] = useState('');
|
||||
const [showPassed, setShowPassed] = useState(false);
|
||||
|
||||
const defaults = useCallback((object: string, field: string) => schema?.fields[object]?.defaults?.[field], [schema]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [snapshot, acceptances] = await Promise.all([
|
||||
loadSnapshot(),
|
||||
fetchAcceptances().catch((e: unknown) => {
|
||||
if (e instanceof AcceptancesUnavailable) return null;
|
||||
throw e;
|
||||
}),
|
||||
]);
|
||||
const { items, passed } = evaluate(snapshot, defaults);
|
||||
return { sorted: sortOut(items, acceptances ?? []), passed, acceptances };
|
||||
}, [defaults]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
setLoaded(await load());
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
load()
|
||||
.then((l) => live && setLoaded(l))
|
||||
.catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const key = (item: Item) => `${item.check}|${item.subject}`;
|
||||
const href = (viewName: string, id?: string) =>
|
||||
`/${viewToSection[viewName] ?? 'Settings'}/${viewName}${id ? `/${encodeURIComponent(id)}` : ''}`;
|
||||
|
||||
const fix = async (item: Item) => {
|
||||
if (item.action.kind !== 'fix') return;
|
||||
const { object, id, patch, undo } = item.action;
|
||||
setBusy(key(item));
|
||||
try {
|
||||
await writePatch(object, id, patch);
|
||||
toast({
|
||||
title: t('security.fixed', 'Fixed: {{what}}', { what: item.title }),
|
||||
duration: 10_000,
|
||||
description: (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-1"
|
||||
onClick={() => {
|
||||
dismiss();
|
||||
void writePatch(object, id, undo)
|
||||
.then(refresh)
|
||||
.catch((e: unknown) =>
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('security.undoFailed', 'Not undone'),
|
||||
description: String(e),
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t('security.undo', 'Undo')}
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('security.fixFailed', 'Not fixed'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doAccept = async (item: Item, replaces?: Acceptance) => {
|
||||
setBusy(key(item));
|
||||
try {
|
||||
await accept(item, note.trim(), replaces);
|
||||
setAccepting(null);
|
||||
setNote('');
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('security.acceptFailed', 'Not accepted'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const unaccept = async (acceptance: Acceptance) => {
|
||||
setBusy(acceptance.id);
|
||||
try {
|
||||
await removeAcceptance(acceptance);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('security.removeFailed', 'Not removed'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const todo = loaded?.sorted.todo ?? [];
|
||||
return { all: todo.length, critical: todo.filter((x) => x.item.severity === 'critical').length };
|
||||
}, [loaded]);
|
||||
|
||||
const severityLabel: Record<Severity, string> = {
|
||||
critical: t('security.critical', 'Critical'),
|
||||
important: t('security.important', 'Important'),
|
||||
good: t('security.good', 'Good practice'),
|
||||
};
|
||||
|
||||
const actions = (item: Item) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{item.action.kind === 'fix' && (
|
||||
<Button type="button" size="sm" onClick={() => void fix(item)} disabled={busy !== null}>
|
||||
{busy === key(item) ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Wrench className="mr-2 h-4 w-4" />}
|
||||
{t('security.fix', 'Fix')}
|
||||
</Button>
|
||||
)}
|
||||
{item.action.kind === 'review' && (
|
||||
<Button asChild type="button" size="sm" variant="outline">
|
||||
<Link to={href(item.action.viewName, item.action.id)}>{t('security.review', 'Review')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{item.action.kind === 'hardening' && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={scrollToHardening}>
|
||||
{t('security.toSwitch', 'Go to the switch')}
|
||||
</Button>
|
||||
)}
|
||||
{item.explain && (
|
||||
<ExplainButton
|
||||
subject={{
|
||||
'@type': 'Setting',
|
||||
object: item.explain.object,
|
||||
id: item.explain.id,
|
||||
property: item.explain.field,
|
||||
}}
|
||||
title={item.title}
|
||||
/>
|
||||
)}
|
||||
{loaded?.acceptances !== null && canAccept && accepting !== key(item) && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setAccepting(key(item));
|
||||
setNote('');
|
||||
}}
|
||||
>
|
||||
{t('security.accept', 'Accept the risk…')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (error && !loaded) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!loaded) return <LoadingFallback />;
|
||||
|
||||
const { sorted, passed } = loaded;
|
||||
const notChecked = passed.filter((p) => p.notChecked);
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="mx-auto max-w-3xl space-y-6">
|
||||
<header className="space-y-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">{t('security.title', 'Security')}</h1>
|
||||
<HelpPanel viewName="CustomComponent/LegacyProtocols" title={t('security.title', 'Security')} />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'security.subtitle',
|
||||
'Settings that leave this server more open than it needs to be, most serious first. Checked when this page opens.',
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{counts.all > 0 && (
|
||||
<p className="text-sm font-medium">
|
||||
{counts.critical > 0
|
||||
? t('security.countsCritical', '{{all}} to do, {{critical}} of them critical', counts)
|
||||
: t('security.counts', '{{all}} to do', counts)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{sorted.todo.length > 0 && (
|
||||
<ul className="space-y-3">
|
||||
{sorted.todo.map(({ item, stale }) => (
|
||||
<li key={key(item)} className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
'shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium',
|
||||
SEVERITY_STYLE[item.severity],
|
||||
)}
|
||||
>
|
||||
{severityLabel[item.severity]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">{item.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{item.why}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.action.kind === 'fix' && (
|
||||
<ul className="space-y-0.5 text-sm">
|
||||
{item.action.changes.map((c) => (
|
||||
<li key={c.field}>
|
||||
{c.field}: <span className="text-muted-foreground">{c.from}</span> → {c.to}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{item.action.kind === 'review' && item.action.details && (
|
||||
<dl className="grid gap-1 text-sm sm:grid-cols-[auto_1fr] sm:gap-x-4">
|
||||
{item.action.details.map((d) => (
|
||||
<div key={d.label} className="contents">
|
||||
<dt className="text-muted-foreground">{d.label}</dt>
|
||||
<dd className="break-all font-mono text-xs leading-5">{d.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)}
|
||||
|
||||
{stale && (
|
||||
<p className="flex flex-wrap items-center gap-2 text-sm text-amber-700 dark:text-amber-400">
|
||||
{t('security.staleAccepted', 'Accepted earlier for a different value, by {{who}}: “{{note}}”', {
|
||||
who: stale.acceptedBy,
|
||||
note: stale.note,
|
||||
})}
|
||||
{canAccept && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void unaccept(stale)}
|
||||
>
|
||||
{t('security.removeOld', 'Remove the old acceptance')}
|
||||
</Button>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{actions(item)}
|
||||
|
||||
{accepting === key(item) && (
|
||||
<div className="space-y-2 rounded-lg border bg-muted/30 p-3">
|
||||
<label className="text-sm font-medium" htmlFor={`note-${key(item)}`}>
|
||||
{t('security.noteLabel', 'Why is this accepted?')}
|
||||
</label>
|
||||
<Textarea
|
||||
id={`note-${key(item)}`}
|
||||
value={note}
|
||||
maxLength={500}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
placeholder={t('security.notePlaceholder', 'Every administrator will see this note.')}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!note.trim() || busy !== null}
|
||||
onClick={() => void doAccept(item, stale)}
|
||||
>
|
||||
{busy === key(item) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('security.acceptConfirm', 'Accept')}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" onClick={() => setAccepting(null)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{sorted.accepted.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('security.accepted', 'Accepted')}
|
||||
</h2>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{sorted.accepted.map(({ item, acceptance }) => (
|
||||
<li key={acceptance.id} className="flex flex-wrap items-start gap-3 px-4 py-3">
|
||||
<CircleSlash className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-sm font-medium">{item.title}</p>
|
||||
<p className="text-sm">“{acceptance.note}”</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('security.acceptedBy', '{{who}}, {{when}}', {
|
||||
who: acceptance.acceptedBy,
|
||||
when: new Date(acceptance.acceptedAt).toLocaleString(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
{canAccept && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy !== null}
|
||||
onClick={() => void unaccept(acceptance)}
|
||||
>
|
||||
{t('security.unaccept', 'Put back on the list')}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassed((v) => !v)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showPassed}
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', !showPassed && '-rotate-90')} />
|
||||
{t('security.passed', 'Passed checks ({{count}})', { count: passed.length - notChecked.length })}
|
||||
{notChecked.length > 0 &&
|
||||
t('security.notCheckedCount', ', {{count}} not checked', { count: notChecked.length })}
|
||||
</button>
|
||||
{showPassed && (
|
||||
<ul className="mt-2 space-y-1 text-sm">
|
||||
{passed.map((p) => (
|
||||
<li key={p.check} className="flex flex-wrap gap-x-2">
|
||||
<span className={p.notChecked ? 'text-muted-foreground' : ''}>{p.title}</span>
|
||||
{p.notChecked && (
|
||||
<span className="text-muted-foreground">
|
||||
{t('security.notChecked', '— not checked: {{why}}', { why: p.notChecked })}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="hardening" className="scroll-mt-20">
|
||||
<LegacyProtocolsPage />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { sameValue, sortOut, type Acceptance } from './acceptances';
|
||||
import type { Item } from './checks';
|
||||
|
||||
const item = (check: string, value: unknown, subject = ''): Item => ({
|
||||
check,
|
||||
severity: 'critical',
|
||||
subject,
|
||||
value,
|
||||
title: check,
|
||||
why: '',
|
||||
action: { kind: 'review', viewName: 'x:Imap' },
|
||||
});
|
||||
|
||||
const acceptance = (check: string, value: unknown, subject = ''): Acceptance => ({
|
||||
id: `${check}-${subject}`,
|
||||
check,
|
||||
subject,
|
||||
acceptedValue: value,
|
||||
note: 'why',
|
||||
acceptedBy: 'admin',
|
||||
acceptedAt: '2026-09-29T00:00:00Z',
|
||||
});
|
||||
|
||||
describe('acceptances (acceptance test 9)', () => {
|
||||
it('an accepted item leaves the to-do list', () => {
|
||||
const r = sortOut([item('SS-1', true)], [acceptance('SS-1', true)]);
|
||||
expect(r.todo).toEqual([]);
|
||||
expect(r.accepted.map((a) => a.item.check)).toEqual(['SS-1']);
|
||||
});
|
||||
|
||||
it('comes back, marked, when the value it was accepted for changes', () => {
|
||||
const before = { rules: [], else: 'is_local_ip(remote_ip)' };
|
||||
const after = { rules: [], else: 'true' };
|
||||
const r = sortOut([item('SS-2', after)], [acceptance('SS-2', before)]);
|
||||
expect(r.accepted).toEqual([]);
|
||||
expect(r.todo[0].stale?.check).toBe('SS-2');
|
||||
});
|
||||
|
||||
it('holds per subject, and compares values whatever their key order', () => {
|
||||
const r = sortOut(
|
||||
[item('SS-13', ['dkim'], 'a.example'), item('SS-13', ['dkim'], 'b.example')],
|
||||
[acceptance('SS-13', ['dkim'], 'a.example')],
|
||||
);
|
||||
expect(r.todo.map((t) => t.item.subject)).toEqual(['b.example']);
|
||||
expect(sameValue({ a: 1, b: [2, { c: 3, d: 4 }] }, { b: [2, { d: 4, c: 3 }], a: 1 })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: accepted security items (security to-do list spec, SS-23 to
|
||||
* SS-26), kept on the server as inbuxa:SecurityAcceptance so every
|
||||
* administrator sees them. An acceptance holds only while its check still
|
||||
* sees the value it was accepted for (SS-24).
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { INBUXA_CAPABILITY } from '@/features/hardening/protocolPolicy';
|
||||
import type { Item } from './checks';
|
||||
|
||||
const OBJECT = 'inbuxa:SecurityAcceptance';
|
||||
|
||||
export interface Acceptance {
|
||||
id: string;
|
||||
check: string;
|
||||
subject: string;
|
||||
acceptedValue: unknown;
|
||||
note: string;
|
||||
acceptedBy: string;
|
||||
acceptedAt: string;
|
||||
}
|
||||
|
||||
/** The server has no acceptances: the page works without the Accept action. */
|
||||
export class AcceptancesUnavailable extends Error {}
|
||||
|
||||
function stable(v: unknown): string {
|
||||
if (Array.isArray(v)) return `[${v.map(stable).join(',')}]`;
|
||||
if (v && typeof v === 'object') {
|
||||
const o = v as Record<string, unknown>;
|
||||
return `{${Object.keys(o)
|
||||
.sort()
|
||||
.map((k) => `${JSON.stringify(k)}:${stable(o[k])}`)
|
||||
.join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(v ?? null);
|
||||
}
|
||||
|
||||
export function sameValue(a: unknown, b: unknown): boolean {
|
||||
return stable(a) === stable(b);
|
||||
}
|
||||
|
||||
export interface Sorted {
|
||||
/** Still to do, each with the acceptance it had for another value, if any. */
|
||||
todo: { item: Item; stale?: Acceptance }[];
|
||||
/** Accepted, and still the same value. */
|
||||
accepted: { item: Item; acceptance: Acceptance }[];
|
||||
}
|
||||
|
||||
/** Splits the failing checks into what's still to do and what's accepted (SS-24, SS-25). */
|
||||
export function sortOut(items: Item[], acceptances: Acceptance[]): Sorted {
|
||||
const out: Sorted = { todo: [], accepted: [] };
|
||||
for (const item of items) {
|
||||
const mine = acceptances.filter((a) => a.check === item.check && a.subject === item.subject);
|
||||
const holding = mine.find((a) => sameValue(a.acceptedValue, item.value));
|
||||
if (holding) out.accepted.push({ item, acceptance: holding });
|
||||
else out.todo.push({ item, stale: mine[0] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function call(method: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const responses = await jmapRequest(
|
||||
[[`${OBJECT}/${method}`, { accountId: getAccountId('x:Security'), ...args }, '0']],
|
||||
undefined,
|
||||
[INBUXA_CAPABILITY],
|
||||
);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== `${OBJECT}/${method}`) {
|
||||
const r = result as { type?: string; description?: string } | undefined;
|
||||
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') throw new AcceptancesUnavailable(r.type);
|
||||
throw new Error(r?.description ?? r?.type ?? 'Request failed');
|
||||
}
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchAcceptances(): Promise<Acceptance[]> {
|
||||
const result = await call('get', { ids: null });
|
||||
return ((result.list as Record<string, unknown>[] | undefined) ?? []).map((a) => ({
|
||||
id: String(a.id),
|
||||
check: String(a.check),
|
||||
subject: String(a.subject ?? ''),
|
||||
acceptedValue: a.acceptedValue,
|
||||
note: String(a.note ?? ''),
|
||||
acceptedBy: String(a.acceptedBy ?? ''),
|
||||
acceptedAt: String(a.acceptedAt ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Accepts an item with a note, removing any earlier acceptance for another value in the same call. */
|
||||
export async function accept(item: Item, note: string, replaces?: Acceptance): Promise<void> {
|
||||
const result = await call('set', {
|
||||
create: { a: { check: item.check, subject: item.subject, acceptedValue: item.value ?? null, note } },
|
||||
...(replaces ? { destroy: [replaces.id] } : {}),
|
||||
});
|
||||
const failed = (result.notCreated as Record<string, { description?: string; type?: string }> | undefined)?.a;
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
}
|
||||
|
||||
export async function removeAcceptance(acceptance: Acceptance): Promise<void> {
|
||||
const result = await call('set', { destroy: [acceptance.id] });
|
||||
const failed = (result.notDestroyed as Record<string, { description?: string; type?: string }> | undefined)?.[
|
||||
acceptance.id
|
||||
];
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluate, normalizeExpr, sameExpr, type Defaults, type Snapshot } from './checks';
|
||||
|
||||
const DEFAULTS: Record<string, unknown> = {
|
||||
'x:MtaStageRcpt.allowRelaying': { default: '!is_empty(authenticated_as)' },
|
||||
'x:MtaStageAuth.require': { default: 'local_port != 25' },
|
||||
'x:Security.authBanRate': { count: 100, period: '1d' },
|
||||
'x:Security.abuseBanRate': { count: 35, period: '1d' },
|
||||
'x:Security.scanBanRate': { count: 30, period: '1d' },
|
||||
'x:Http.rateLimitAnonymous': { count: 100, period: '1m' },
|
||||
'x:Authentication.passwordMinStrength': 'three',
|
||||
'x:Authentication.passwordMinLength': 8,
|
||||
'x:SenderAuth.dmarcVerify': { else: 'disable', match: [{ if: 'local_port == 25', then: 'relaxed' }] },
|
||||
};
|
||||
const defaults: Defaults = (o, f) => DEFAULTS[`${o}.${f}`];
|
||||
|
||||
const NOW = Date.parse('2026-09-29T00:00:00Z');
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** A server at every default, with DNS live: what a fresh install looks like. */
|
||||
function fresh(): Snapshot {
|
||||
return {
|
||||
now: NOW,
|
||||
imap: { allowPlainTextAuth: false },
|
||||
rcpt: { allowRelaying: { match: {}, else: '!is_empty(authenticated_as)' } },
|
||||
auth: { require: { match: {}, else: 'local_port != 25' } },
|
||||
certificates: [{ id: 'c1', names: ['mail.example.org'], notValidAfter: new Date(NOW + 80 * DAY).toISOString() }],
|
||||
security: { authBanRate: DEFAULTS['x:Security.authBanRate'], abuseBanRate: {}, scanBanRate: {} },
|
||||
http: { rateLimitAnonymous: { count: 100, period: '1m' } },
|
||||
oidc: { anonymousClientRegistration: false, requireClientRegistration: true },
|
||||
authentication: { passwordMinStrength: 'three', passwordMinLength: 8, passwordHashAlgorithm: 'argon2id' },
|
||||
tlsStrategies: [{ id: 't1', name: 'default', allowInvalidCerts: false }],
|
||||
senderAuth: { dmarcVerify: { match: { 0: { if: 'local_port == 25', then: 'relaxed' } }, else: 'disable' } },
|
||||
metrics: { prometheus: { '@type': 'Disabled' } },
|
||||
mtaSts: { mode: 'testing' },
|
||||
domains: [
|
||||
{ id: 'd1', name: 'example.org', enabled: true, allowRelaying: false, missing: [], dmarcPolicy: 'reject' },
|
||||
],
|
||||
legacyOn: true,
|
||||
unavailable: {},
|
||||
};
|
||||
}
|
||||
|
||||
const ids = (s: Snapshot) => evaluate(s, defaults).items.map((i) => i.check);
|
||||
|
||||
describe('expressions', () => {
|
||||
it('read the same whichever shape they come in', () => {
|
||||
expect(sameExpr({ default: 'a == b' }, { match: {}, else: 'a == b' })).toBe(true);
|
||||
expect(
|
||||
sameExpr({ match: [{ if: 'x', then: 'y' }], else: 'z' }, { match: { 0: { if: 'x', then: 'y' } }, else: 'z' }),
|
||||
).toBe(true);
|
||||
expect(normalizeExpr(undefined)).toEqual({ rules: [], else: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the checks (acceptance tests 1-8)', () => {
|
||||
it('1: a fresh install shows only legacy protocols and MTA-STS', () => {
|
||||
expect(ids(fresh())).toEqual(['SS-15', 'SS-17']);
|
||||
const tidy = { ...fresh(), legacyOn: false, mtaSts: { mode: 'enforce' } };
|
||||
expect(ids(tidy)).toEqual([]);
|
||||
});
|
||||
|
||||
it('2: plain-text IMAP is critical, with a one-field fix and its undo', () => {
|
||||
const r = evaluate({ ...fresh(), imap: { allowPlainTextAuth: true } }, defaults);
|
||||
const item = r.items[0];
|
||||
expect(item.check).toBe('SS-1');
|
||||
expect(item.severity).toBe('critical');
|
||||
expect(item.action).toMatchObject({
|
||||
kind: 'fix',
|
||||
object: 'x:Imap',
|
||||
patch: { allowPlainTextAuth: false },
|
||||
undo: { allowPlainTextAuth: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('3: an edited relaying rule is a Review with both expressions, never a Fix', () => {
|
||||
const item = evaluate({ ...fresh(), rcpt: { allowRelaying: { match: {}, else: 'true' } } }, defaults).items[0];
|
||||
expect(item.check).toBe('SS-2');
|
||||
expect(item.action.kind).toBe('review');
|
||||
if (item.action.kind === 'review') {
|
||||
expect(item.action.details).toEqual([
|
||||
{ label: 'Now', value: 'true' },
|
||||
{ label: 'Default', value: '!is_empty(authenticated_as)' },
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('4: a certificate 5 days from expiry is SS-5, not SS-14; 20 days is SS-14', () => {
|
||||
const at = (days: number): Snapshot => ({
|
||||
...fresh(),
|
||||
certificates: [
|
||||
{ id: 'c1', names: ['mail.example.org'], notValidAfter: new Date(NOW + days * DAY).toISOString() },
|
||||
],
|
||||
});
|
||||
expect(ids(at(5)).filter((c) => c === 'SS-5' || c === 'SS-14')).toEqual(['SS-5']);
|
||||
expect(ids(at(20)).filter((c) => c === 'SS-5' || c === 'SS-14')).toEqual(['SS-14']);
|
||||
expect(ids(at(-1))).toContain('SS-5');
|
||||
});
|
||||
|
||||
it('5: an empty ban rate is fixed back to its default', () => {
|
||||
const item = evaluate({ ...fresh(), security: { authBanRate: null, abuseBanRate: {}, scanBanRate: {} } }, defaults)
|
||||
.items[0];
|
||||
expect(item.check).toBe('SS-6');
|
||||
expect(item.action).toMatchObject({ kind: 'fix', patch: { authBanRate: { count: 100, period: '1d' } } });
|
||||
});
|
||||
|
||||
it('6: a part that couldn’t be read is “not checked”, not a to-do item', () => {
|
||||
const r = evaluate(
|
||||
{ ...fresh(), security: undefined, unavailable: { security: 'No permission to read it' } },
|
||||
defaults,
|
||||
);
|
||||
expect(r.items.map((i) => i.check)).not.toContain('SS-6');
|
||||
expect(r.passed.find((p) => p.check === 'SS-6')).toEqual({
|
||||
check: 'SS-6',
|
||||
title: 'Automatic bans',
|
||||
notChecked: 'No permission to read it',
|
||||
});
|
||||
});
|
||||
|
||||
it('7: one item per domain missing a record, naming it', () => {
|
||||
const r = evaluate(
|
||||
{
|
||||
...fresh(),
|
||||
domains: [
|
||||
{ id: 'd1', name: 'example.org', enabled: true, allowRelaying: false, missing: [], dmarcPolicy: 'reject' },
|
||||
{
|
||||
id: 'd2',
|
||||
name: 'example.net',
|
||||
enabled: true,
|
||||
allowRelaying: false,
|
||||
missing: ['dkim'],
|
||||
dmarcPolicy: 'reject',
|
||||
},
|
||||
],
|
||||
},
|
||||
defaults,
|
||||
);
|
||||
const dns = r.items.filter((i) => i.check === 'SS-13');
|
||||
expect(dns).toHaveLength(1);
|
||||
expect(dns[0].subject).toBe('example.net');
|
||||
expect(dns[0].title).toContain('DKIM');
|
||||
});
|
||||
|
||||
it('8: legacy protocols on point to the switch, with no Fix', () => {
|
||||
const item = evaluate(fresh(), defaults).items.find((i) => i.check === 'SS-15');
|
||||
expect(item?.action).toEqual({ kind: 'hardening' });
|
||||
});
|
||||
|
||||
it('orders by severity, and turning DMARC checks off is flagged while a stricter rule is not', () => {
|
||||
const s: Snapshot = {
|
||||
...fresh(),
|
||||
imap: { allowPlainTextAuth: true },
|
||||
authentication: { passwordMinStrength: 'three', passwordMinLength: 8, passwordHashAlgorithm: 'pbkdf2' },
|
||||
senderAuth: { dmarcVerify: { match: {}, else: 'disable' } },
|
||||
};
|
||||
expect(ids(s)).toEqual(['SS-1', 'SS-11', 'SS-15', 'SS-16', 'SS-17']);
|
||||
const strict = {
|
||||
...fresh(),
|
||||
senderAuth: { dmarcVerify: { match: { 0: { if: 'local_port == 25', then: 'strict' } }, else: 'disable' } },
|
||||
};
|
||||
expect(ids(strict)).not.toContain('SS-11');
|
||||
});
|
||||
|
||||
it('open metrics and p=none are flagged; a set secret is not', () => {
|
||||
const s: Snapshot = {
|
||||
...fresh(),
|
||||
metrics: { prometheus: { '@type': 'Enabled', authSecret: null } },
|
||||
domains: [
|
||||
{ id: 'd1', name: 'example.org', enabled: true, allowRelaying: false, missing: [], dmarcPolicy: 'none' },
|
||||
],
|
||||
};
|
||||
expect(ids(s)).toEqual(expect.arrayContaining(['SS-12', 'SS-18']));
|
||||
const locked = { ...fresh(), metrics: { prometheus: { '@type': 'Enabled', authSecret: '********' } } };
|
||||
expect(ids(locked)).not.toContain('SS-12');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,647 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: the security to-do list's checks (security to-do list spec,
|
||||
* SS-1 to SS-18). Pure: each check reads a snapshot of what the console
|
||||
* loaded and says whether it holds. There is no score; severity only
|
||||
* orders the list.
|
||||
*
|
||||
* A Fix writes one field (SS-8 two, on one object) back to its default or
|
||||
* to the value named here, and never a secret, a DNS record or a listener
|
||||
* (SS-21): those are Review items.
|
||||
*/
|
||||
|
||||
export type Severity = 'critical' | 'important' | 'good';
|
||||
|
||||
export const SEVERITY_ORDER: Severity[] = ['critical', 'important', 'good'];
|
||||
|
||||
export interface Change {
|
||||
field: string;
|
||||
/** Before and after, in words. */
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| {
|
||||
kind: 'fix';
|
||||
object: string;
|
||||
id: string;
|
||||
/** What the fix writes. */
|
||||
patch: Record<string, unknown>;
|
||||
/** What it writes back on Undo. */
|
||||
undo: Record<string, unknown>;
|
||||
changes: Change[];
|
||||
}
|
||||
| {
|
||||
kind: 'review';
|
||||
/** The page to open. */
|
||||
viewName: string;
|
||||
/** A record on that page, when there's one to open. */
|
||||
id?: string;
|
||||
/** What to look at there, side by side or listed. */
|
||||
details?: { label: string; value: string }[];
|
||||
}
|
||||
| { kind: 'hardening' };
|
||||
|
||||
export interface Item {
|
||||
check: string;
|
||||
severity: Severity;
|
||||
/** What within the check: '' for a server-wide setting, else the domain, strategy or certificate. */
|
||||
subject: string;
|
||||
/** What the check saw: an acceptance holds only while this stays the same (SS-24). */
|
||||
value: unknown;
|
||||
title: string;
|
||||
why: string;
|
||||
action: Action;
|
||||
/** The object and field Explain would ask about. */
|
||||
explain?: { object: string; id: string; field: string };
|
||||
}
|
||||
|
||||
export interface Passed {
|
||||
check: string;
|
||||
title: string;
|
||||
/** Why it couldn't run, when it couldn't (SS-29). */
|
||||
notChecked?: string;
|
||||
}
|
||||
|
||||
export interface Expr {
|
||||
match?: unknown;
|
||||
else?: string;
|
||||
default?: string;
|
||||
}
|
||||
|
||||
export interface DomainSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
allowRelaying: boolean;
|
||||
/** Core records missing from public DNS (SS-13), or null if not checked. */
|
||||
missing: string[] | null;
|
||||
/** The live DMARC policy (SS-18), or null if there's none or it wasn't checked. */
|
||||
dmarcPolicy: string | null;
|
||||
}
|
||||
|
||||
export interface Snapshot {
|
||||
now: number;
|
||||
imap?: { allowPlainTextAuth?: boolean };
|
||||
rcpt?: { allowRelaying?: Expr };
|
||||
auth?: { require?: Expr };
|
||||
certificates?: { id: string; names: string[]; notValidAfter: string | null }[];
|
||||
security?: { authBanRate?: unknown; abuseBanRate?: unknown; scanBanRate?: unknown };
|
||||
http?: { rateLimitAnonymous?: unknown };
|
||||
oidc?: { anonymousClientRegistration?: boolean; requireClientRegistration?: boolean };
|
||||
authentication?: { passwordMinStrength?: string; passwordMinLength?: number; passwordHashAlgorithm?: string };
|
||||
tlsStrategies?: { id: string; name: string; allowInvalidCerts?: boolean }[];
|
||||
senderAuth?: { dmarcVerify?: Expr };
|
||||
metrics?: { prometheus?: { '@type'?: string; authSecret?: unknown } };
|
||||
mtaSts?: { mode?: string };
|
||||
domains?: DomainSnapshot[];
|
||||
/** The legacy-protocols switch: true while any legacy protocol is on. */
|
||||
legacyOn?: boolean;
|
||||
/** Why a part couldn't be read, by part name. */
|
||||
unavailable: Partial<Record<Part, string>>;
|
||||
}
|
||||
|
||||
export type Part =
|
||||
| 'imap'
|
||||
| 'rcpt'
|
||||
| 'auth'
|
||||
| 'certificates'
|
||||
| 'security'
|
||||
| 'http'
|
||||
| 'oidc'
|
||||
| 'authentication'
|
||||
| 'tlsStrategies'
|
||||
| 'senderAuth'
|
||||
| 'metrics'
|
||||
| 'mtaSts'
|
||||
| 'domains'
|
||||
| 'dns'
|
||||
| 'legacy';
|
||||
|
||||
/** The schema's defaults, as the console knows them. */
|
||||
export type Defaults = (object: string, field: string) => unknown;
|
||||
|
||||
// --- Expressions --------------------------------------------------------
|
||||
|
||||
interface Rule {
|
||||
if: string;
|
||||
then: string;
|
||||
}
|
||||
|
||||
const squash = (s: unknown) =>
|
||||
String(s ?? '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
/** An expression in one shape, however it came: `{default}`, or `match` as a list or a map. */
|
||||
export function normalizeExpr(e: Expr | undefined | null): { rules: Rule[]; else: string } {
|
||||
if (!e) return { rules: [], else: '' };
|
||||
if (typeof e.default === 'string') return { rules: [], else: squash(e.default) };
|
||||
const raw = e.match;
|
||||
const list: unknown[] = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? Object.keys(raw as object)
|
||||
.sort((a, b) => Number(a) - Number(b) || a.localeCompare(b))
|
||||
.map((k) => (raw as Record<string, unknown>)[k])
|
||||
: [];
|
||||
const rules = list.map((r) => {
|
||||
const rule = r as Partial<Rule>;
|
||||
return { if: squash(rule.if), then: squash(rule.then) };
|
||||
});
|
||||
return { rules, else: squash(e.else) };
|
||||
}
|
||||
|
||||
export function sameExpr(a: Expr | undefined | null, b: Expr | undefined | null): boolean {
|
||||
return JSON.stringify(normalizeExpr(a)) === JSON.stringify(normalizeExpr(b));
|
||||
}
|
||||
|
||||
/** An expression as one readable line. */
|
||||
export function exprText(e: Expr | undefined | null): string {
|
||||
const n = normalizeExpr(e);
|
||||
if (n.rules.length === 0) return n.else || '(empty)';
|
||||
return [...n.rules.map((r) => `if ${r.if} then ${r.then}`), `else ${n.else}`].join('; ');
|
||||
}
|
||||
|
||||
/** What an expression gives for port 25 connections, reading only a `local_port == 25` rule. */
|
||||
function onPort25(e: Expr | undefined): string {
|
||||
const n = normalizeExpr(e);
|
||||
const rule = n.rules.find((r) => r.if === 'local_port == 25');
|
||||
return rule ? rule.then : n.else;
|
||||
}
|
||||
|
||||
// --- Words --------------------------------------------------------------
|
||||
|
||||
function rateText(r: unknown): string {
|
||||
if (!r || typeof r !== 'object') return 'off';
|
||||
const { count, period } = r as { count?: number; period?: string | number };
|
||||
return `${count} per ${period}`;
|
||||
}
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
// --- The checks ---------------------------------------------------------
|
||||
|
||||
interface Result {
|
||||
items: Item[];
|
||||
passed: Passed[];
|
||||
}
|
||||
|
||||
/** Every check against the snapshot: the to-do items, most serious first, and what passed or couldn't run. */
|
||||
export function evaluate(s: Snapshot, defaults: Defaults): Result {
|
||||
const items: Item[] = [];
|
||||
const passed: Passed[] = [];
|
||||
|
||||
/** Runs one check, or lists it as not checked when a part it needs couldn't be read. */
|
||||
const check = (id: string, title: string, parts: Part[], run: () => Item[]) => {
|
||||
const missing = parts.find((p) => s.unavailable[p]);
|
||||
if (missing) {
|
||||
passed.push({ check: id, title, notChecked: s.unavailable[missing] });
|
||||
return;
|
||||
}
|
||||
const found = run();
|
||||
if (found.length === 0) passed.push({ check: id, title });
|
||||
else items.push(...found);
|
||||
};
|
||||
|
||||
// Critical
|
||||
|
||||
check('SS-1', 'Plain-text passwords over unencrypted IMAP', ['imap'], () =>
|
||||
s.imap?.allowPlainTextAuth
|
||||
? [
|
||||
{
|
||||
check: 'SS-1',
|
||||
severity: 'critical',
|
||||
subject: '',
|
||||
value: true,
|
||||
title: 'IMAP accepts passwords over unencrypted connections',
|
||||
why: 'Anyone on the network path can read those passwords.',
|
||||
action: {
|
||||
kind: 'fix',
|
||||
object: 'x:Imap',
|
||||
id: 'singleton',
|
||||
patch: { allowPlainTextAuth: false },
|
||||
undo: { allowPlainTextAuth: true },
|
||||
changes: [{ field: 'Allow plain text authentication', from: 'on', to: 'off' }],
|
||||
},
|
||||
explain: { object: 'x:Imap', id: 'singleton', field: 'allowPlainTextAuth' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
check('SS-2', 'Relaying rule', ['rcpt'], () => {
|
||||
const now = s.rcpt?.allowRelaying;
|
||||
const def = defaults('x:MtaStageRcpt', 'allowRelaying') as Expr | undefined;
|
||||
if (!now || !def || sameExpr(now, def)) return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-2',
|
||||
severity: 'critical',
|
||||
subject: '',
|
||||
value: normalizeExpr(now),
|
||||
title: 'The rule for who may relay mail has been changed',
|
||||
why: 'A wrong rule makes this an open relay that spammers find within hours. It may be deliberate, so check it.',
|
||||
action: {
|
||||
kind: 'review',
|
||||
viewName: 'x:MtaStageRcpt',
|
||||
details: [
|
||||
{ label: 'Now', value: exprText(now) },
|
||||
{ label: 'Default', value: exprText(def) },
|
||||
],
|
||||
},
|
||||
explain: { object: 'x:MtaStageRcpt', id: 'singleton', field: 'allowRelaying' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-3', 'Domains that relay for anyone', ['domains'], () => {
|
||||
const relaying = (s.domains ?? []).filter((d) => d.allowRelaying).map((d) => d.name);
|
||||
if (relaying.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-3',
|
||||
severity: 'critical',
|
||||
subject: '',
|
||||
value: [...relaying].sort(),
|
||||
title:
|
||||
relaying.length === 1
|
||||
? `${relaying[0]} relays mail for anyone`
|
||||
: `${relaying.length} domains relay mail for anyone`,
|
||||
why: 'Mail claiming to be from these domains is passed on without sign-in, which spammers can use.',
|
||||
action: {
|
||||
kind: 'review',
|
||||
viewName: 'x:Domain',
|
||||
details: [{ label: 'Domains', value: relaying.join(', ') }],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-4', 'Sign-in for sending', ['auth'], () => {
|
||||
const now = s.auth?.require;
|
||||
const def = defaults('x:MtaStageAuth', 'require') as Expr | undefined;
|
||||
if (!now || !def || sameExpr(now, def)) return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-4',
|
||||
severity: 'critical',
|
||||
subject: '',
|
||||
value: normalizeExpr(now),
|
||||
title: 'The rule for when sending needs a sign-in has been changed',
|
||||
why: 'If sending from mail apps doesn’t require a sign-in, anyone can send through this server.',
|
||||
action: {
|
||||
kind: 'review',
|
||||
viewName: 'x:MtaStageAuth',
|
||||
details: [
|
||||
{ label: 'Now', value: exprText(now) },
|
||||
{ label: 'Default', value: exprText(def) },
|
||||
],
|
||||
},
|
||||
explain: { object: 'x:MtaStageAuth', id: 'singleton', field: 'require' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// SS-5 and SS-14 read the same certificates: within 7 days is critical and
|
||||
// replaces the 30-day item.
|
||||
const certItems = (within: number, severity: Severity, id: string) => () =>
|
||||
(s.certificates ?? []).flatMap((c): Item[] => {
|
||||
if (!c.notValidAfter) return [];
|
||||
const until = Date.parse(c.notValidAfter);
|
||||
if (Number.isNaN(until)) return [];
|
||||
const left = until - s.now;
|
||||
if (id === 'SS-14' && left <= 7 * DAY) return [];
|
||||
if (left > within) return [];
|
||||
const name = c.names[0] ?? c.id;
|
||||
const expired = left <= 0;
|
||||
const days = Math.max(0, Math.ceil(left / DAY));
|
||||
return [
|
||||
{
|
||||
check: id,
|
||||
severity,
|
||||
subject: c.id,
|
||||
value: c.notValidAfter,
|
||||
title: expired
|
||||
? `The certificate for ${name} has expired`
|
||||
: `The certificate for ${name} expires in ${days} day${days === 1 ? '' : 's'}`,
|
||||
why: expired
|
||||
? 'Mail apps and browsers refuse the connection, or warn about it.'
|
||||
: 'Once it expires, mail apps and browsers refuse the connection, or warn about it.',
|
||||
action: { kind: 'review', viewName: 'x:Certificate', id: c.id },
|
||||
},
|
||||
];
|
||||
});
|
||||
check(
|
||||
'SS-5',
|
||||
'Certificates expired or expiring within 7 days',
|
||||
['certificates'],
|
||||
certItems(7 * DAY, 'critical', 'SS-5'),
|
||||
);
|
||||
|
||||
// Important
|
||||
|
||||
check('SS-6', 'Automatic bans', ['security'], () => {
|
||||
const rates = ['authBanRate', 'abuseBanRate', 'scanBanRate'] as const;
|
||||
const off = rates.filter((r) => s.security?.[r] == null);
|
||||
if (off.length === 0) return [];
|
||||
const words: Record<(typeof rates)[number], string> = {
|
||||
authBanRate: 'Failed sign-ins',
|
||||
abuseBanRate: 'Abuse',
|
||||
scanBanRate: 'Port scans',
|
||||
};
|
||||
const patch = Object.fromEntries(off.map((r) => [r, defaults('x:Security', r) ?? null]));
|
||||
return [
|
||||
{
|
||||
check: 'SS-6',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: off,
|
||||
title: 'Automatic IP bans are partly off',
|
||||
why: 'Without them, an address can keep guessing passwords or probing the server for as long as it likes.',
|
||||
action: {
|
||||
kind: 'fix',
|
||||
object: 'x:Security',
|
||||
id: 'singleton',
|
||||
patch,
|
||||
undo: Object.fromEntries(off.map((r) => [r, null])),
|
||||
changes: off.map((r) => ({ field: `${words[r]} ban`, from: 'off', to: rateText(patch[r]) })),
|
||||
},
|
||||
explain: { object: 'x:Security', id: 'singleton', field: off[0] },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-7', 'HTTP rate limits', ['http'], () => {
|
||||
if (s.http?.rateLimitAnonymous != null) return [];
|
||||
const def = defaults('x:Http', 'rateLimitAnonymous') ?? null;
|
||||
return [
|
||||
{
|
||||
check: 'SS-7',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: null,
|
||||
title: 'Requests from people who aren’t signed in aren’t rate limited',
|
||||
why: 'The sign-in page and the API can then be hammered without limit.',
|
||||
action: {
|
||||
kind: 'fix',
|
||||
object: 'x:Http',
|
||||
id: 'singleton',
|
||||
patch: { rateLimitAnonymous: def },
|
||||
undo: { rateLimitAnonymous: null },
|
||||
changes: [{ field: 'Anonymous rate limit', from: 'off', to: rateText(def) }],
|
||||
},
|
||||
explain: { object: 'x:Http', id: 'singleton', field: 'rateLimitAnonymous' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-8', 'OAuth client registration', ['oidc'], () => {
|
||||
const anon = s.oidc?.anonymousClientRegistration === true;
|
||||
const unreq = s.oidc?.requireClientRegistration === false;
|
||||
if (!anon && !unreq) return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-8',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: { anonymousClientRegistration: anon, requireClientRegistration: !unreq },
|
||||
title: 'Apps can sign people in without being registered first',
|
||||
why: 'Any app, including a phishing one, could then ask your people to sign in with their inbuxa password.',
|
||||
action: {
|
||||
kind: 'fix',
|
||||
object: 'x:OidcProvider',
|
||||
id: 'singleton',
|
||||
patch: { anonymousClientRegistration: false, requireClientRegistration: true },
|
||||
undo: {
|
||||
anonymousClientRegistration: s.oidc?.anonymousClientRegistration ?? false,
|
||||
requireClientRegistration: s.oidc?.requireClientRegistration ?? true,
|
||||
},
|
||||
changes: [
|
||||
...(anon ? [{ field: 'Anonymous client registration', from: 'on', to: 'off' }] : []),
|
||||
...(unreq ? [{ field: 'Require client registration', from: 'off', to: 'on' }] : []),
|
||||
],
|
||||
},
|
||||
explain: { object: 'x:OidcProvider', id: 'singleton', field: 'anonymousClientRegistration' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-9', 'Password rules', ['authentication'], () => {
|
||||
const STRENGTHS = ['zero', 'one', 'two', 'three', 'four'];
|
||||
const a = s.authentication ?? {};
|
||||
const minStrength = String(defaults('x:Authentication', 'passwordMinStrength') ?? 'three');
|
||||
const minLength = Number(defaults('x:Authentication', 'passwordMinLength') ?? 8);
|
||||
const weakStrength =
|
||||
a.passwordMinStrength !== undefined && STRENGTHS.indexOf(a.passwordMinStrength) < STRENGTHS.indexOf(minStrength);
|
||||
const shortLength = typeof a.passwordMinLength === 'number' && a.passwordMinLength < minLength;
|
||||
if (!weakStrength && !shortLength) return [];
|
||||
const patch: Record<string, unknown> = {};
|
||||
const undo: Record<string, unknown> = {};
|
||||
const changes: Change[] = [];
|
||||
if (weakStrength) {
|
||||
patch.passwordMinStrength = minStrength;
|
||||
undo.passwordMinStrength = a.passwordMinStrength;
|
||||
changes.push({ field: 'Minimum password strength', from: String(a.passwordMinStrength), to: minStrength });
|
||||
}
|
||||
if (shortLength) {
|
||||
patch.passwordMinLength = minLength;
|
||||
undo.passwordMinLength = a.passwordMinLength;
|
||||
changes.push({ field: 'Minimum password length', from: String(a.passwordMinLength), to: String(minLength) });
|
||||
}
|
||||
return [
|
||||
{
|
||||
check: 'SS-9',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: { passwordMinStrength: a.passwordMinStrength, passwordMinLength: a.passwordMinLength },
|
||||
title: 'Password rules are weaker than the default',
|
||||
why: 'Short or guessable passwords are the easiest way into a mailbox. Raising the rules doesn’t touch existing passwords; they apply at the next change.',
|
||||
action: { kind: 'fix', object: 'x:Authentication', id: 'singleton', patch, undo, changes },
|
||||
explain: {
|
||||
object: 'x:Authentication',
|
||||
id: 'singleton',
|
||||
field: weakStrength ? 'passwordMinStrength' : 'passwordMinLength',
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-10', 'Certificate checks on outgoing mail', ['tlsStrategies'], () =>
|
||||
(s.tlsStrategies ?? [])
|
||||
.filter((t) => t.allowInvalidCerts)
|
||||
.map((t) => ({
|
||||
check: 'SS-10',
|
||||
severity: 'important' as const,
|
||||
subject: t.id,
|
||||
value: true,
|
||||
title: `Outgoing mail accepts invalid certificates (${t.name || t.id})`,
|
||||
why: 'Mail sent with this TLS strategy can be intercepted by anyone who can present any certificate at all.',
|
||||
action: { kind: 'review' as const, viewName: 'x:MtaTlsStrategy', id: t.id },
|
||||
explain: { object: 'x:MtaTlsStrategy', id: t.id, field: 'allowInvalidCerts' },
|
||||
})),
|
||||
);
|
||||
|
||||
check('SS-11', 'DMARC checks on incoming mail', ['senderAuth'], () => {
|
||||
const now = s.senderAuth?.dmarcVerify;
|
||||
const def = defaults('x:SenderAuth', 'dmarcVerify') as Expr | undefined;
|
||||
if (!now || (def && sameExpr(now, def)) || onPort25(now) !== 'disable') return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-11',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: normalizeExpr(now),
|
||||
title: 'Incoming mail isn’t checked against the sender domain’s DMARC policy',
|
||||
why: 'Mail forged in the name of a bank or a colleague then gets the same treatment as the real thing.',
|
||||
action: {
|
||||
kind: 'review',
|
||||
viewName: 'x:SenderAuth',
|
||||
details: [
|
||||
{ label: 'Now', value: exprText(now) },
|
||||
...(def ? [{ label: 'Default', value: exprText(def) }] : []),
|
||||
],
|
||||
},
|
||||
explain: { object: 'x:SenderAuth', id: 'singleton', field: 'dmarcVerify' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-12', 'Metrics access', ['metrics'], () => {
|
||||
const p = s.metrics?.prometheus;
|
||||
if (p?.['@type'] !== 'Enabled') return [];
|
||||
const secret = p.authSecret;
|
||||
const set =
|
||||
secret != null &&
|
||||
secret !== '' &&
|
||||
!(typeof secret === 'object' && (secret as { '@type'?: string })['@type'] === 'None');
|
||||
if (set) return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-12',
|
||||
severity: 'important',
|
||||
subject: '',
|
||||
value: 'open',
|
||||
title: 'Anyone who can reach the server can read its metrics',
|
||||
why: 'They show how busy the server is and when. Give Prometheus a user name and password.',
|
||||
action: { kind: 'review', viewName: 'x:Metrics/CollectorPrometheus' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-13', 'Mail records in DNS', ['domains', 'dns'], () =>
|
||||
(s.domains ?? [])
|
||||
.filter((d) => d.enabled && d.missing && d.missing.length > 0)
|
||||
.map((d) => ({
|
||||
check: 'SS-13',
|
||||
severity: 'important' as const,
|
||||
subject: d.name,
|
||||
value: [...(d.missing ?? [])].sort(),
|
||||
title: `${d.name} is missing ${listWords((d.missing ?? []).map((k) => k.toUpperCase()))} in public DNS`,
|
||||
why: 'Without them, other servers can’t find where to deliver, or can’t tell real mail from forged mail, so more of it lands in spam.',
|
||||
action: { kind: 'review' as const, viewName: 'x:Domain', id: d.id },
|
||||
})),
|
||||
);
|
||||
|
||||
check('SS-14', 'Certificates expiring within 30 days', ['certificates'], certItems(30 * DAY, 'important', 'SS-14'));
|
||||
|
||||
// Good practice
|
||||
|
||||
check('SS-15', 'Legacy mail protocols', ['legacy'], () =>
|
||||
s.legacyOn
|
||||
? [
|
||||
{
|
||||
check: 'SS-15',
|
||||
severity: 'good',
|
||||
subject: '',
|
||||
value: 'on',
|
||||
title: 'Legacy mail protocols are on',
|
||||
why: 'IMAP, POP3 and ManageSieve are more to guard. If nobody uses them, turning them off closes their ports.',
|
||||
action: { kind: 'hardening' },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
check('SS-16', 'Password hashing', ['authentication'], () => {
|
||||
const algo = s.authentication?.passwordHashAlgorithm;
|
||||
if (algo !== 'pbkdf2') return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-16',
|
||||
severity: 'good',
|
||||
subject: '',
|
||||
value: algo,
|
||||
title: 'New passwords are hashed with PBKDF2, not Argon2id',
|
||||
why: 'Argon2id makes a stolen password database much slower to crack. Existing hashes stay as they are until each password changes.',
|
||||
action: {
|
||||
kind: 'fix',
|
||||
object: 'x:Authentication',
|
||||
id: 'singleton',
|
||||
patch: { passwordHashAlgorithm: 'argon2id' },
|
||||
undo: { passwordHashAlgorithm: 'pbkdf2' },
|
||||
changes: [{ field: 'Password hash algorithm', from: 'PBKDF2', to: 'Argon2id' }],
|
||||
},
|
||||
explain: { object: 'x:Authentication', id: 'singleton', field: 'passwordHashAlgorithm' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-17', 'MTA-STS', ['mtaSts'], () => {
|
||||
const mode = s.mtaSts?.mode;
|
||||
if (mode === undefined || mode === 'enforce') return [];
|
||||
return [
|
||||
{
|
||||
check: 'SS-17',
|
||||
severity: 'good',
|
||||
subject: '',
|
||||
value: mode,
|
||||
title: `MTA-STS is in ${mode} mode, not enforced`,
|
||||
why: 'Until it’s enforced, other servers may still deliver to you unencrypted if someone interferes. Move to enforce once the TLS reports look clean.',
|
||||
action: { kind: 'review', viewName: 'x:MtaSts' },
|
||||
explain: { object: 'x:MtaSts', id: 'singleton', field: 'mode' },
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
check('SS-18', 'DMARC policies', ['domains', 'dns'], () =>
|
||||
(s.domains ?? [])
|
||||
.filter((d) => d.enabled && d.dmarcPolicy === 'none')
|
||||
.map((d) => ({
|
||||
check: 'SS-18',
|
||||
severity: 'good' as const,
|
||||
subject: d.name,
|
||||
value: 'none',
|
||||
title: `${d.name}’s DMARC policy is p=none`,
|
||||
why: 'Receiving servers are told to deliver mail forged in its name anyway. Once DMARC reports show your own mail passing, move to quarantine or reject.',
|
||||
action: { kind: 'review' as const, viewName: 'x:Domain', id: d.id },
|
||||
})),
|
||||
);
|
||||
|
||||
return { items: sortItems(items), passed };
|
||||
}
|
||||
|
||||
function listWords(parts: string[]): string {
|
||||
if (parts.length <= 1) return parts.join('');
|
||||
return `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}`;
|
||||
}
|
||||
|
||||
function checkNumber(id: string): number {
|
||||
return Number(id.replace('SS-', ''));
|
||||
}
|
||||
|
||||
/** Most serious first, then in check order. */
|
||||
export function sortItems(items: Item[]): Item[] {
|
||||
return [...items].sort(
|
||||
(a, b) =>
|
||||
SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity) ||
|
||||
checkNumber(a.check) - checkNumber(b.check) ||
|
||||
a.subject.localeCompare(b.subject),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: what the security checks read, loaded when the page opens (SS-28):
|
||||
* one JMAP request for the settings, public DNS for each domain, and the
|
||||
* legacy-protocols switch. A part that can't be read is marked with why, so
|
||||
* its checks show as "not checked" rather than as passed or failed (SS-29).
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import type { JmapMethodCall } from '@/types/jmap';
|
||||
import { coreChecks } from '@/features/hovercards/facts';
|
||||
import { parseZone } from '@/features/dns/zone';
|
||||
import { dohQuery } from '@/features/dns/liveCheck';
|
||||
import { fetchProtocolPolicy, SWITCHED } from '@/features/hardening/protocolPolicy';
|
||||
import type { DomainSnapshot, Part, Snapshot } from './checks';
|
||||
|
||||
interface Call {
|
||||
part: Part;
|
||||
method: string;
|
||||
ids: string[] | null;
|
||||
properties: string[];
|
||||
}
|
||||
|
||||
const CALLS: Call[] = [
|
||||
{ part: 'imap', method: 'x:Imap/get', ids: ['singleton'], properties: ['allowPlainTextAuth'] },
|
||||
{ part: 'rcpt', method: 'x:MtaStageRcpt/get', ids: ['singleton'], properties: ['allowRelaying'] },
|
||||
{ part: 'auth', method: 'x:MtaStageAuth/get', ids: ['singleton'], properties: ['require'] },
|
||||
{
|
||||
part: 'certificates',
|
||||
method: 'x:Certificate/get',
|
||||
ids: null,
|
||||
properties: ['subjectAlternativeNames', 'notValidAfter'],
|
||||
},
|
||||
{
|
||||
part: 'security',
|
||||
method: 'x:Security/get',
|
||||
ids: ['singleton'],
|
||||
properties: ['authBanRate', 'abuseBanRate', 'scanBanRate'],
|
||||
},
|
||||
{ part: 'http', method: 'x:Http/get', ids: ['singleton'], properties: ['rateLimitAnonymous'] },
|
||||
{
|
||||
part: 'oidc',
|
||||
method: 'x:OidcProvider/get',
|
||||
ids: ['singleton'],
|
||||
properties: ['anonymousClientRegistration', 'requireClientRegistration'],
|
||||
},
|
||||
{
|
||||
part: 'authentication',
|
||||
method: 'x:Authentication/get',
|
||||
ids: ['singleton'],
|
||||
properties: ['passwordMinStrength', 'passwordMinLength', 'passwordHashAlgorithm'],
|
||||
},
|
||||
{
|
||||
part: 'tlsStrategies',
|
||||
method: 'x:MtaTlsStrategy/get',
|
||||
ids: null,
|
||||
properties: ['name', 'allowInvalidCerts'],
|
||||
},
|
||||
{ part: 'senderAuth', method: 'x:SenderAuth/get', ids: ['singleton'], properties: ['dmarcVerify'] },
|
||||
{ part: 'metrics', method: 'x:Metrics/get', ids: ['singleton'], properties: ['prometheus'] },
|
||||
{ part: 'mtaSts', method: 'x:MtaSts/get', ids: ['singleton'], properties: ['mode'] },
|
||||
{
|
||||
part: 'domains',
|
||||
method: 'x:Domain/get',
|
||||
ids: null,
|
||||
properties: ['name', 'isEnabled', 'allowRelaying', 'dnsZoneFile'],
|
||||
},
|
||||
];
|
||||
|
||||
/** Why a call failed, in words. */
|
||||
function reasonOf(body: unknown): string {
|
||||
const b = body as { type?: string; description?: string } | undefined;
|
||||
if (b?.type === 'forbidden') return 'No permission to read it';
|
||||
return b?.description ?? b?.type ?? 'The server didn’t answer';
|
||||
}
|
||||
|
||||
/** The live DMARC policy of a domain: none, quarantine, reject, or null when there's no record. */
|
||||
export function dmarcPolicyOf(txt: string[]): string | null {
|
||||
const record = txt
|
||||
.map((t) => t.replace(/^"|"$/g, '').replace(/"\s*"/g, ''))
|
||||
.find((t) => /^v=DMARC1\b/i.test(t.trim()));
|
||||
if (!record) return null;
|
||||
const p = /(?:^|;)\s*p\s*=\s*([a-z]+)/i.exec(record);
|
||||
return p ? p[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
const DNS_TTL_MS = 60_000;
|
||||
const dnsCache = new Map<string, { at: number; result: Promise<Pick<DomainSnapshot, 'missing' | 'dmarcPolicy'>> }>();
|
||||
|
||||
/** A domain's DNS, kept for a minute as the hover cards keep theirs (SS-28), so a Fix doesn't ask again. */
|
||||
function domainDns(
|
||||
name: string,
|
||||
zoneFile: string | undefined,
|
||||
): Promise<Pick<DomainSnapshot, 'missing' | 'dmarcPolicy'>> {
|
||||
const key = `${name}\n${zoneFile ?? ''}`;
|
||||
const hit = dnsCache.get(key);
|
||||
if (hit && Date.now() - hit.at < DNS_TTL_MS) return hit.result;
|
||||
const result = askDns(name, zoneFile);
|
||||
dnsCache.set(key, { at: Date.now(), result });
|
||||
result.catch(() => dnsCache.delete(key));
|
||||
return result;
|
||||
}
|
||||
|
||||
async function askDns(
|
||||
name: string,
|
||||
zoneFile: string | undefined,
|
||||
): Promise<Pick<DomainSnapshot, 'missing' | 'dmarcPolicy'>> {
|
||||
const [checks, dmarc] = await Promise.all([coreChecks(parseZone(zoneFile), name), dohQuery(`_dmarc.${name}`, 'TXT')]);
|
||||
return {
|
||||
missing: checks.filter((c) => !c.live).map((c) => c.kind),
|
||||
dmarcPolicy: dmarcPolicyOf(dmarc.answer.filter((a) => a.type === 16).map((a) => a.data)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The parts the critical checks (SS-1 to SS-5) read: enough for the dashboard's line (SS-27). */
|
||||
const CRITICAL_PARTS: Part[] = ['imap', 'rcpt', 'auth', 'certificates', 'domains'];
|
||||
|
||||
/**
|
||||
* What the checks read. `critical` loads only what SS-1 to SS-5 need, with
|
||||
* no DNS and no legacy switch, for the dashboard.
|
||||
*/
|
||||
export async function loadSnapshot(signal?: AbortSignal, critical = false): Promise<Snapshot> {
|
||||
const accountId = getAccountId('x:Imap');
|
||||
const wanted = critical ? CALLS.filter((c) => CRITICAL_PARTS.includes(c.part)) : CALLS;
|
||||
const calls: JmapMethodCall[] = wanted.map((c, i) => [
|
||||
c.method,
|
||||
{ accountId, ids: c.ids, properties: c.properties },
|
||||
String(i),
|
||||
]);
|
||||
const responses = await jmapRequest(calls, signal);
|
||||
const snapshot: Snapshot = { now: Date.now(), unavailable: {} };
|
||||
const lists: Partial<Record<Part, Record<string, unknown>[]>> = {};
|
||||
|
||||
wanted.forEach((c, i) => {
|
||||
const response = responses.find((r) => r[2] === String(i));
|
||||
if (!response || response[0] === 'error') {
|
||||
snapshot.unavailable[c.part] = reasonOf(response?.[1]);
|
||||
return;
|
||||
}
|
||||
lists[c.part] = ((response[1] as { list?: Record<string, unknown>[] }).list ?? []) as Record<string, unknown>[];
|
||||
});
|
||||
|
||||
const one = (part: Part) => lists[part]?.[0];
|
||||
if (lists.imap) snapshot.imap = one('imap') as Snapshot['imap'];
|
||||
if (lists.rcpt) snapshot.rcpt = one('rcpt') as Snapshot['rcpt'];
|
||||
if (lists.auth) snapshot.auth = one('auth') as Snapshot['auth'];
|
||||
if (lists.security) snapshot.security = one('security') as Snapshot['security'];
|
||||
if (lists.http) snapshot.http = one('http') as Snapshot['http'];
|
||||
if (lists.oidc) snapshot.oidc = one('oidc') as Snapshot['oidc'];
|
||||
if (lists.authentication) snapshot.authentication = one('authentication') as Snapshot['authentication'];
|
||||
if (lists.senderAuth) snapshot.senderAuth = one('senderAuth') as Snapshot['senderAuth'];
|
||||
if (lists.metrics) snapshot.metrics = one('metrics') as Snapshot['metrics'];
|
||||
if (lists.mtaSts) snapshot.mtaSts = one('mtaSts') as Snapshot['mtaSts'];
|
||||
if (lists.certificates) {
|
||||
snapshot.certificates = lists.certificates.map((c) => {
|
||||
const names = c.subjectAlternativeNames;
|
||||
return {
|
||||
id: String(c.id),
|
||||
names: Array.isArray(names) ? names.map(String) : names && typeof names === 'object' ? Object.keys(names) : [],
|
||||
notValidAfter: typeof c.notValidAfter === 'string' ? c.notValidAfter : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
if (lists.tlsStrategies) {
|
||||
snapshot.tlsStrategies = lists.tlsStrategies.map((t) => ({
|
||||
id: String(t.id),
|
||||
name: String(t.name ?? ''),
|
||||
allowInvalidCerts: t.allowInvalidCerts === true,
|
||||
}));
|
||||
}
|
||||
|
||||
if (critical) {
|
||||
snapshot.domains = lists.domains?.map((d) => ({
|
||||
id: String(d.id),
|
||||
name: String(d.name),
|
||||
enabled: d.isEnabled !== false,
|
||||
allowRelaying: d.allowRelaying === true,
|
||||
missing: null,
|
||||
dmarcPolicy: null,
|
||||
}));
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
const [domains, legacy] = await Promise.all([
|
||||
lists.domains
|
||||
? Promise.all(
|
||||
lists.domains.map(async (d): Promise<DomainSnapshot> => {
|
||||
const name = String(d.name);
|
||||
const enabled = d.isEnabled !== false;
|
||||
const dns = enabled ? await domainDns(name, d.dnsZoneFile as string | undefined).catch(() => null) : null;
|
||||
return {
|
||||
id: String(d.id),
|
||||
name,
|
||||
enabled,
|
||||
allowRelaying: d.allowRelaying === true,
|
||||
missing: dns?.missing ?? null,
|
||||
dmarcPolicy: dns?.dmarcPolicy ?? null,
|
||||
};
|
||||
}),
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
fetchProtocolPolicy(signal).catch((e: unknown) => e as Error),
|
||||
]);
|
||||
|
||||
if (domains) {
|
||||
snapshot.domains = domains;
|
||||
// DNS couldn't be asked about any enabled domain: say so rather than pass them
|
||||
const enabled = domains.filter((d) => d.enabled);
|
||||
if (enabled.length > 0 && enabled.every((d) => d.missing === null)) {
|
||||
snapshot.unavailable.dns = 'Public DNS couldn’t be reached from this browser';
|
||||
}
|
||||
}
|
||||
if (legacy instanceof Error) {
|
||||
snapshot.unavailable.legacy = legacy.message || 'The server didn’t say';
|
||||
} else {
|
||||
snapshot.legacyOn = SWITCHED.some((p) => legacy.switches[p] === 'enabled');
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: how many critical security items are on the to-do list and not
|
||||
* accepted, for the dashboard's status line (SS-27). Only for those who may
|
||||
* see the Security page; null until known, or when it can't be.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { evaluate } from './checks';
|
||||
import { loadSnapshot } from './load';
|
||||
import { fetchAcceptances, sortOut } from './acceptances';
|
||||
|
||||
export function useCriticalCount(): number | null {
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const allowed = useAccountStore((s) => s.hasObjectPermission('sysSecurity', 'Get'));
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed || !schema) return;
|
||||
let live = true;
|
||||
const controller = new AbortController();
|
||||
Promise.all([loadSnapshot(controller.signal, true), fetchAcceptances().catch(() => [])])
|
||||
.then(([snapshot, acceptances]) => {
|
||||
const { items } = evaluate(snapshot, (o, f) => schema.fields[o]?.defaults?.[f]);
|
||||
const critical = items.filter((i) => i.severity === 'critical');
|
||||
if (live) setCount(sortOut(critical, acceptances).todo.length);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
live = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [allowed, schema]);
|
||||
|
||||
return allowed ? count : null;
|
||||
}
|
||||
+22
-3
@@ -30,7 +30,18 @@ interface OptionHelp {
|
||||
*/
|
||||
function pageHelp(schema: Schema, viewName: string) {
|
||||
const obj = resolveObject(schema, viewName);
|
||||
if (!obj) return null;
|
||||
if (!obj) {
|
||||
// inbuxa: a page with no object of its own (the Security page) brings its own list
|
||||
const own = PAGE_HELP[viewName];
|
||||
if (!own) return null;
|
||||
return {
|
||||
id: viewName,
|
||||
about: own.about,
|
||||
tasks: own.tasks ?? [],
|
||||
options: (own.options ?? []).map((o) => ({ id: `${viewName}.${o.label}`, ...o })),
|
||||
optionsTitle: own.optionsTitle,
|
||||
};
|
||||
}
|
||||
const sch = resolveSchema(schema, obj.objectName);
|
||||
const ours = PAGE_HELP[viewName] ?? PAGE_HELP[obj.objectName];
|
||||
const about =
|
||||
@@ -57,7 +68,13 @@ function pageHelp(schema: Schema, viewName: string) {
|
||||
const text = fieldHelp(id, field.description);
|
||||
if (text) options.push({ id, label: label || humanize(name), text });
|
||||
}
|
||||
return { id: obj.objectName, about, tasks: ours?.tasks ?? [], options };
|
||||
return {
|
||||
id: obj.objectName,
|
||||
about,
|
||||
tasks: ours?.tasks ?? [],
|
||||
options,
|
||||
optionsTitle: undefined as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -116,7 +133,9 @@ export function HelpPanel({ viewName, title }: { viewName: string; title: string
|
||||
)}
|
||||
{help.options.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t('help.options', 'The options on this page')}</h3>
|
||||
<h3 className="text-sm font-semibold">
|
||||
{help.optionsTitle ?? t('help.options', 'The options on this page')}
|
||||
</h3>
|
||||
<dl className="divide-y rounded-xl border">
|
||||
{help.options.map((o) => (
|
||||
<div key={o.id} className="px-4 py-3" data-help-id={o.id}>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { evaluate, type Part } from '@/features/security/checks';
|
||||
import { SECURITY_CHECK_HELP } from './securityChecks';
|
||||
|
||||
const PARTS: Part[] = [
|
||||
'imap',
|
||||
'rcpt',
|
||||
'auth',
|
||||
'certificates',
|
||||
'security',
|
||||
'http',
|
||||
'oidc',
|
||||
'authentication',
|
||||
'tlsStrategies',
|
||||
'senderAuth',
|
||||
'metrics',
|
||||
'mtaSts',
|
||||
'domains',
|
||||
'dns',
|
||||
'legacy',
|
||||
];
|
||||
|
||||
describe('security page help', () => {
|
||||
it('explains every check the page runs, once, in order', () => {
|
||||
// With nothing readable, every check reports itself as not checked
|
||||
const unavailable = Object.fromEntries(PARTS.map((p) => [p, 'x']));
|
||||
const { passed } = evaluate({ now: 0, unavailable }, () => undefined);
|
||||
const byNumber = (a: string, b: string) => Number(a.slice(3)) - Number(b.slice(3));
|
||||
expect(SECURITY_CHECK_HELP.map((c) => c.check)).toEqual(passed.map((p) => p.check).sort(byNumber));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: the Security page's help (security to-do list spec): what each
|
||||
* check looks at, when it counts as something to do, and what its action
|
||||
* does. Kept in the same order as the checks in features/security/checks.ts.
|
||||
*/
|
||||
|
||||
import type { PageHelp } from './texts';
|
||||
|
||||
export const SECURITY_CHECK_HELP: { check: string; label: string; text: string }[] = [
|
||||
{
|
||||
check: 'SS-1',
|
||||
label: 'Plain-text passwords over unencrypted IMAP',
|
||||
text: 'Critical when IMAP accepts a password on a connection that isn’t encrypted. Fix turns that off; mail apps then sign in over TLS, as nearly all do already.',
|
||||
},
|
||||
{
|
||||
check: 'SS-2',
|
||||
label: 'The relaying rule',
|
||||
text: 'Critical when the rule for who may send mail on to other servers differs from the default (only people who signed in). An edit may be deliberate, so this is a Review: both rules are shown side by side, and nothing is reset for you.',
|
||||
},
|
||||
{
|
||||
check: 'SS-3',
|
||||
label: 'Domains that relay for anyone',
|
||||
text: 'Critical when a domain passes on mail without a sign-in. That’s for a domain split between two mail systems; anywhere else it lets strangers send through this server. Review lists the domains.',
|
||||
},
|
||||
{
|
||||
check: 'SS-4',
|
||||
label: 'Sign-in for sending',
|
||||
text: 'Critical when the rule for when sending needs a sign-in differs from the default (everywhere but port 25). Review shows both rules.',
|
||||
},
|
||||
{
|
||||
check: 'SS-5',
|
||||
label: 'Certificates expired or expiring within 7 days',
|
||||
text: 'Critical for each certificate that has expired or will within a week. Review opens the certificate; renewing isn’t a one-click fix, and a certificate the server gets itself renews on its own well before this.',
|
||||
},
|
||||
{
|
||||
check: 'SS-6',
|
||||
label: 'Automatic bans',
|
||||
text: 'Important when any of the three automatic IP bans (failed sign-ins, abuse, port scans) is switched off. Fix puts back the default rate for each one that’s off.',
|
||||
},
|
||||
{
|
||||
check: 'SS-7',
|
||||
label: 'Rate limits for people who aren’t signed in',
|
||||
text: 'Important when requests from people who aren’t signed in have no rate limit. Fix puts back the default.',
|
||||
},
|
||||
{
|
||||
check: 'SS-8',
|
||||
label: 'OAuth client registration',
|
||||
text: 'Important when apps may register themselves, or sign people in without being registered. Fix turns off anonymous registration and requires registration again; apps you registered keep working.',
|
||||
},
|
||||
{
|
||||
check: 'SS-9',
|
||||
label: 'Password rules',
|
||||
text: 'Important when the minimum password strength or length is below the default. Fix raises whichever is lower. Existing passwords aren’t touched; the rules apply the next time each one changes.',
|
||||
},
|
||||
{
|
||||
check: 'SS-10',
|
||||
label: 'Certificate checks on outgoing mail',
|
||||
text: 'Important for each TLS strategy that accepts invalid certificates when delivering. Review opens the strategy: a relay with a self-signed certificate may need it, nothing else does.',
|
||||
},
|
||||
{
|
||||
check: 'SS-11',
|
||||
label: 'DMARC checks on incoming mail',
|
||||
text: 'Important when the DMARC check no longer runs on port 25, where other servers deliver. A stricter rule than the default is fine and isn’t flagged. Review shows the rule and the default.',
|
||||
},
|
||||
{
|
||||
check: 'SS-12',
|
||||
label: 'Metrics access',
|
||||
text: 'Important when Prometheus metrics are on with no password. Review opens the page where Make a password sets one; the password is yours to keep, so it’s never set for you.',
|
||||
},
|
||||
{
|
||||
check: 'SS-13',
|
||||
label: 'Mail records in DNS',
|
||||
text: 'Important for each enabled domain missing its MX, SPF, DKIM or DMARC record in public DNS, as seen through Cloudflare’s resolver from your browser. Review opens the domain’s records. DNS is never written from this page.',
|
||||
},
|
||||
{
|
||||
check: 'SS-14',
|
||||
label: 'Certificates expiring within 30 days',
|
||||
text: 'Important for each certificate that expires within a month but not within a week (that’s SS-5).',
|
||||
},
|
||||
{
|
||||
check: 'SS-15',
|
||||
label: 'Legacy mail protocols',
|
||||
text: 'Good practice while any of IMAP, POP3 or ManageSieve is on. The switch further down this page shows who used each one lately; it’s never a one-click fix, since it closes ports.',
|
||||
},
|
||||
{
|
||||
check: 'SS-16',
|
||||
label: 'Password hashing',
|
||||
text: 'Good practice when new passwords are hashed with PBKDF2. Fix switches to Argon2id; existing hashes stay as they are until each password changes.',
|
||||
},
|
||||
{
|
||||
check: 'SS-17',
|
||||
label: 'MTA-STS',
|
||||
text: 'Good practice until MTA-STS is enforced. It starts in testing on purpose: move to enforce once the TLS reports other servers send you look clean. Review opens the setting.',
|
||||
},
|
||||
{
|
||||
check: 'SS-18',
|
||||
label: 'DMARC policies',
|
||||
text: 'Good practice for each domain whose DMARC record says p=none. Once DMARC reports show your own mail passing, move to quarantine or reject at your DNS host.',
|
||||
},
|
||||
];
|
||||
|
||||
export const SECURITY_PAGE_HELP: PageHelp = {
|
||||
about: 'What leaves this server more open than it needs to be, most serious first, checked each time the page opens.',
|
||||
tasks: [
|
||||
'Work down the list. There is no score: an item is to do, accepted, or passed.',
|
||||
'Fix an item in one click. The change is shown first, and the message after it has Undo.',
|
||||
'Accept an item you’ve decided to live with, saying why. Every administrator sees the note, and the item comes back if the setting changes.',
|
||||
'Turn legacy mail protocols off, all at once or one at a time, after seeing who still uses them.',
|
||||
],
|
||||
options: SECURITY_CHECK_HELP.map((c) => ({ label: `${c.check} · ${c.label}`, text: c.text })),
|
||||
optionsTitle: 'The checks',
|
||||
};
|
||||
+10
-1
@@ -15,6 +15,7 @@
|
||||
*/
|
||||
|
||||
import { SETTINGS_PAGE_HELP } from './settingsPages';
|
||||
import { SECURITY_PAGE_HELP } from './securityChecks';
|
||||
|
||||
export const FIELD_HELP: Record<string, string> = {
|
||||
// Domains
|
||||
@@ -73,6 +74,10 @@ export interface PageHelp {
|
||||
about: string;
|
||||
/** The things people come here to do. */
|
||||
tasks?: string[];
|
||||
/** inbuxa: for a page with no form of its own (the Security page), what it lists, explained. */
|
||||
options?: { label: string; text: string }[];
|
||||
/** What to call that list; "The options on this page" otherwise. */
|
||||
optionsTitle?: string;
|
||||
}
|
||||
|
||||
const MANAGEMENT_PAGE_HELP: Record<string, PageHelp> = {
|
||||
@@ -150,7 +155,11 @@ const MANAGEMENT_PAGE_HELP: Record<string, PageHelp> = {
|
||||
};
|
||||
|
||||
/** Every page's own words: Management's here, Settings' in settingsPages.ts. */
|
||||
export const PAGE_HELP: Record<string, PageHelp> = { ...SETTINGS_PAGE_HELP, ...MANAGEMENT_PAGE_HELP };
|
||||
export const PAGE_HELP: Record<string, PageHelp> = {
|
||||
...SETTINGS_PAGE_HELP,
|
||||
...MANAGEMENT_PAGE_HELP,
|
||||
'CustomComponent/LegacyProtocols': SECURITY_PAGE_HELP,
|
||||
};
|
||||
|
||||
/** A page's one-line intro: ours when written, else the schema's subtitle. */
|
||||
export function pageAbout(viewName: string, fallback?: string | null): string | undefined {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
@@ -19,6 +22,8 @@ type ToasterToast = {
|
||||
variant?: ToastVariant;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
/** inbuxa: how long it stays up, in ms; longer for one that offers Undo. */
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
|
||||
+4
-3
@@ -65,10 +65,11 @@ function checkSpecialLink(
|
||||
return { visible: allowed, enterprise: true };
|
||||
}
|
||||
|
||||
// INBUXA: the legacy protocols switch takes listeners away and puts them back,
|
||||
// so whoever may see a listener may see it (legacy-protocols spec).
|
||||
// INBUXA: the security to-do list and the legacy-protocols switch. Every
|
||||
// check is server-wide, so it's for whoever may read the server's security
|
||||
// settings, which tenant administrators can't (security to-do list spec).
|
||||
if (viewName === 'CustomComponent/LegacyProtocols') {
|
||||
return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false };
|
||||
return { visible: canGet ? canGet('sysSecurity') : true, enterprise: false };
|
||||
}
|
||||
|
||||
// inbuxa: the Local AI page is for whoever may see the AI classifier.
|
||||
|
||||
@@ -163,8 +163,9 @@ export const SETTINGS_CATEGORIES: SettingsCategory[] = [
|
||||
{
|
||||
name: 'Protection',
|
||||
pages: [
|
||||
// inbuxa: the security to-do list, with the legacy-protocols switch (was Hardening)
|
||||
p('CustomComponent/LegacyProtocols', 'Security'),
|
||||
p('x:Security', 'Settings'),
|
||||
p('CustomComponent/LegacyProtocols', 'Hardening'),
|
||||
p('x:BlockedIp', 'Blocked IPs'),
|
||||
p('x:AllowedIp', 'Allowed IPs'),
|
||||
p('x:Asn', 'ASN & GeoIP'),
|
||||
|
||||
Reference in new issue
Block a user