syncova-backup/apps/web/src/features/security/SecurityPage.tsx
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

306 lines
9.1 KiB
TypeScript

/**
* Security Center.
*
* Die Seite, die in Phase 12 als „noch nicht verfuegbar" stand — mit dem
* Hinweis, die Einzelangaben seien vorhanden, aber keine Gesamtbewertung. Jetzt
* gibt es beides.
*
* Der Aufbau folgt der Regel aus dem Plan (§17): Jeder Befund traegt
* Schweregrad, Erklaerung, betroffenes Objekt und Empfehlung. Die Empfehlung ist
* der wichtigste Teil — ein Befund ohne sie ist eine Beunruhigung.
*/
import { useCallback } from 'react';
import { requestApi } from '../../api/client';
import { useApiResource } from '../../api/useApiResource';
import { ErrorState, LoadingState } from '../../components/PageState';
/** Schweregrad eines Befunds. */
type FindingSeverity = 'information' | 'warning' | 'high' | 'critical';
/** Ein Sicherheitsbefund. */
interface SecurityFinding {
/** Pruefbereich. */
readonly area: string;
/** Schweregrad. */
readonly severity: FindingSeverity;
/** Ueberschrift. */
readonly title: string;
/** Erklaerung, warum der Zustand ein Problem ist. */
readonly explanation: string;
/** Das betroffene Objekt. */
readonly affected_object: string;
/** Die naechste Handlung. */
readonly recommendation: string;
}
/** Ergebnis eines Pruefbereichs. */
interface AreaResult {
/** Bezeichner. */
readonly area: string;
/** Bezeichnung. */
readonly title: string;
/** Meldet, ob der Bereich geprueft werden konnte. */
readonly available: boolean;
/** Erklaert einen nicht pruefbaren Bereich. */
readonly unavailable_reason?: string;
/** Befunde des Bereichs. */
readonly findings: readonly SecurityFinding[];
/** Zusammenfassung. */
readonly summary: string;
/** Gewicht in der Gesamtbewertung. */
readonly weight: number;
/** Erreichte Punkte. */
readonly earned_points: number;
}
/** Die Sicherheitslage. */
interface SecurityAssessment {
/** Gesamtbewertung in Prozent. */
readonly score: number;
/** Erreichbare Punkte der geprueften Bereiche. */
readonly maximum_score: number;
/** Einstufung. */
readonly grade: string;
/** Ergebnisse je Bereich. */
readonly areas: readonly AreaResult[];
/** Zahl nicht pruefbarer Bereiche. */
readonly unchecked_area_count: number;
/** Zahl kritischer Befunde. */
readonly critical_finding_count: number;
/** Zahl ernster Befunde. */
readonly high_finding_count: number;
/** Zusammenfassung. */
readonly summary: string;
/** Nicht gepruefte Bereiche. */
readonly unchecked_areas: readonly string[];
}
/** Antwort des Security Center. */
interface SecurityResponse {
/** Die Sicherheitslage. */
readonly assessment: SecurityAssessment;
/** Meldet eine belastbare Bewertung. */
readonly is_trustworthy: boolean;
}
/** Zeigt das Security Center. */
export function SecurityPage(): React.JSX.Element {
const loadAssessment = useCallback(
(abortSignal: AbortSignal) => requestApi<SecurityResponse>('/security', { signal: abortSignal }),
[],
);
const { loadState, data, loadError, reload } = useApiResource<SecurityResponse>(loadAssessment);
return (
<section className="page">
<header className="page__header">
<h1 className="page__title">Sicherheit</h1>
</header>
{loadState === 'loading' && <LoadingState what="Die Sicherheitslage" />}
{loadState === 'failed' && loadError !== null && (
<ErrorState error={loadError} onRetry={reload} />
)}
{loadState === 'loaded' && data !== null && (
<>
<ScoreHeader response={data} />
<FindingList assessment={data.assessment} />
<AreaTable assessment={data.assessment} />
</>
)}
</section>
);
}
/** Eigenschaften der Gesamtbewertung. */
interface ScoreHeaderProperties {
/** Die Antwort des Security Center. */
readonly response: SecurityResponse;
}
/** Zeigt die Gesamtbewertung. */
function ScoreHeader({ response }: ScoreHeaderProperties): React.JSX.Element {
const { assessment } = response;
return (
<div className={`score-card score-card--${gradeClassOf(assessment)}`}>
<div className="score-card__figure">
<span className="score-card__value">{assessment.score}</span>
<span className="score-card__unit">%</span>
</div>
<div className="score-card__text">
<p className="score-card__grade">{assessment.grade}</p>
<p className="score-card__summary">{assessment.summary}</p>
{/* Die nicht gepruefte Bereiche stehen namentlich da: Sie sind die
Handlungsanweisung, nicht nur eine Einschraenkung. */}
{assessment.unchecked_areas.length > 0 && (
<p className="score-card__unchecked">
Nicht geprueft: {assessment.unchecked_areas.join(', ')}
</p>
)}
</div>
</div>
);
}
/** Eigenschaften der Befundliste. */
interface FindingListProperties {
/** Die Sicherheitslage. */
readonly assessment: SecurityAssessment;
}
/** Zeigt die Befunde nach Schweregrad geordnet. */
function FindingList({ assessment }: FindingListProperties): React.JSX.Element {
const allFindings = assessment.areas.flatMap((areaResult) => areaResult.findings);
const orderedFindings = [...allFindings].sort(
(firstFinding, secondFinding) =>
severityRankOf(secondFinding.severity) - severityRankOf(firstFinding.severity),
);
if (orderedFindings.length === 0) {
return (
<p className="page__state page__state--empty">
Keine Befunde in den geprueften Bereichen.
</p>
);
}
return (
<ul className="finding-list">
{orderedFindings.map((finding) => (
<li className={`finding finding--${finding.severity}`} key={`${finding.area}-${finding.title}`}>
<div className="finding__head">
<span className={`badge badge--${severityClassOf(finding.severity)}`}>
{finding.severity}
</span>
<h2 className="finding__title">{finding.title}</h2>
</div>
<p className="finding__explanation">{finding.explanation}</p>
<dl className="finding__details">
<div className="finding__detail">
<dt>Betroffen</dt>
<dd>{finding.affected_object}</dd>
</div>
{/* Die Empfehlung steht hervorgehoben: Sie ist der Grund, warum der
Befund ueberhaupt angezeigt wird. */}
<div className="finding__detail finding__detail--recommendation">
<dt>Naechster Schritt</dt>
<dd>{finding.recommendation}</dd>
</div>
</dl>
</li>
))}
</ul>
);
}
/** Eigenschaften der Bereichsuebersicht. */
interface AreaTableProperties {
/** Die Sicherheitslage. */
readonly assessment: SecurityAssessment;
}
/** Zeigt die Ergebnisse je Pruefbereich. */
function AreaTable({ assessment }: AreaTableProperties): React.JSX.Element {
return (
<section className="area-overview">
<h2 className="area-overview__title">Geprueft wurde</h2>
<div className="table-wrapper">
<table className="data-table">
<thead>
<tr>
<th scope="col">Bereich</th>
<th scope="col">Ergebnis</th>
<th scope="col">Punkte</th>
</tr>
</thead>
<tbody>
{assessment.areas.map((areaResult) => (
<tr key={areaResult.area}>
<td>{areaResult.title}</td>
<td>
{areaResult.available ? (
areaResult.summary
) : (
<>
<span className="badge badge--neutral">nicht pruefbar</span>
<span className="data-table__secondary">{areaResult.unavailable_reason}</span>
</>
)}
</td>
<td className="data-table__number">
{areaResult.available ? (
`${areaResult.earned_points} / ${areaResult.weight}`
) : (
// Ein ungeprueft Bereich geht nicht in die Rechnung ein —
// weder positiv noch negativ.
<span className="data-table__unknown">zaehlt nicht</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
);
}
/** Bildet die Einstufung auf eine Statusfarbe ab. */
function gradeClassOf(assessment: SecurityAssessment): string {
if (assessment.critical_finding_count > 0) {
return 'critical';
}
if (assessment.score >= 90) {
return 'healthy';
}
if (assessment.score >= 70) {
return 'warning';
}
return 'high';
}
/** Bildet einen Schweregrad auf eine Statusfarbe ab. */
function severityClassOf(severity: FindingSeverity): string {
switch (severity) {
case 'critical':
return 'critical';
case 'high':
return 'high';
case 'warning':
return 'warning';
default:
return 'info';
}
}
/** Liefert die Ordnungszahl eines Schweregrads. */
function severityRankOf(severity: FindingSeverity): number {
switch (severity) {
case 'critical':
return 4;
case 'high':
return 3;
case 'warning':
return 2;
default:
return 1;
}
}