Files
SecWatch/secwatch.html
T

936 lines
34 KiB
HTML
Raw Normal View History

2026-07-21 10:21:06 +02:00
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SecWatch — IT-Sicherheitsbriefing</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
<style>
:root{
--bg: #0B0E14;
--panel: #121722;
--panel-2: #171D2B;
--line: #232B3D;
--text: #DCE3EE;
--muted: #7C8AA5;
--crit: #E5484D;
--high: #F5A623;
--med: #3FB8AF;
--low: #4CAF7D;
--mono: 'IBM Plex Mono', ui-monospace, monospace;
--sans: 'IBM Plex Sans', system-ui, sans-serif;
}
*{box-sizing:border-box;}
html,body{margin:0;padding:0;}
body{
background:
radial-gradient(1200px 600px at 80% -10%, rgba(63,184,175,0.06), transparent 60%),
var(--bg);
color:var(--text);
font-family:var(--sans);
min-height:100vh;
}
a{color:var(--med);}
.wrap{max-width:880px;margin:0 auto;padding:28px 20px 80px;}
header{
display:flex;justify-content:space-between;align-items:flex-start;
gap:16px;flex-wrap:wrap;
border-bottom:1px solid var(--line);
padding-bottom:20px;margin-bottom:6px;
}
.brand{display:flex;align-items:baseline;gap:10px;}
.brand-mark{
font-family:var(--mono);font-weight:700;font-size:13px;
color:var(--bg);background:var(--med);
padding:2px 6px;border-radius:3px;letter-spacing:0.03em;
}
h1{
font-family:var(--mono);font-size:22px;font-weight:600;
margin:0;letter-spacing:-0.01em;
}
.tagline{color:var(--muted);font-size:13px;margin-top:4px;}
.controls{display:flex;flex-direction:column;align-items:flex-end;gap:8px;}
.btn-row{display:flex;gap:8px;}
button{
font-family:var(--mono);font-size:13px;font-weight:600;
background:var(--panel-2);color:var(--text);
border:1px solid var(--line);border-radius:6px;
padding:9px 14px;cursor:pointer;
display:flex;align-items:center;gap:8px;
transition:border-color 0.15s ease, background 0.15s ease;
}
button:hover:not(:disabled){border-color:var(--med);}
button:focus-visible{outline:2px solid var(--med);outline-offset:2px;}
button:disabled{opacity:0.45;cursor:not-allowed;}
button.ghost{background:transparent;}
.dot{width:7px;height:7px;border-radius:50%;background:var(--muted);flex-shrink:0;}
.dot.live{background:var(--low);box-shadow:0 0 0 0 rgba(76,175,125,0.6);animation:pulse 1.6s infinite;}
@keyframes pulse{
0%{box-shadow:0 0 0 0 rgba(76,175,125,0.55);}
70%{box-shadow:0 0 0 7px rgba(76,175,125,0);}
100%{box-shadow:0 0 0 0 rgba(76,175,125,0);}
}
.meta{font-family:var(--mono);font-size:11px;color:var(--muted);text-align:right;}
.toggle-row{
font-family:var(--mono);font-size:11px;color:var(--muted);
display:flex;align-items:center;gap:6px;
}
.toggle-row input{accent-color:var(--med);}
.pdf-link{
display:none;align-items:center;gap:8px;
font-family:var(--mono);font-size:11px;font-weight:600;
color:var(--bg);background:var(--low);
border-radius:6px;padding:8px 12px;text-decoration:none;
}
.pdf-link:hover{filter:brightness(1.08);}
.pdf-hint{font-family:var(--mono);font-size:10.5px;color:var(--muted);margin-top:2px;}
.sources-row{
font-family:var(--mono);font-size:11px;color:var(--muted);
display:flex;align-items:center;gap:8px;flex-wrap:wrap;
}
.sources-row .src{
display:inline-flex;align-items:center;gap:4px;
padding:3px 8px;border-radius:4px;
border:1px solid var(--line);
}
.sources-row .src.ok{border-color:rgba(76,175,125,0.4);color:var(--low);}
.sources-row .src.fail{border-color:rgba(229,72,77,0.3);color:var(--crit);}
.sources-row .src.loading{border-color:rgba(63,184,175,0.3);color:var(--med);}
.status-bar{
font-family:var(--mono);font-size:11px;color:var(--muted);
display:flex;justify-content:space-between;align-items:center;
padding:10px 2px;border-bottom:1px dashed var(--line);margin-bottom:16px;
flex-wrap:wrap;gap:6px;
}
.status-bar .cursor{display:inline-block;width:7px;height:12px;background:var(--med);margin-left:2px;animation:blink 1s steps(1) infinite;}
@keyframes blink{50%{opacity:0;}}
.history{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:22px;}
.history-chip{
font-family:var(--mono);font-size:11px;color:var(--muted);
background:var(--panel);border:1px solid var(--line);border-radius:20px;
padding:5px 12px;cursor:pointer;
}
.history-chip:hover{border-color:var(--med);}
.history-chip.active{color:var(--bg);background:var(--med);border-color:var(--med);font-weight:600;}
.history-empty{font-family:var(--mono);font-size:11px;color:var(--muted);}
.empty{
text-align:center;padding:60px 20px;color:var(--muted);
border:1px dashed var(--line);border-radius:10px;
}
.empty p{margin:6px 0;}
.empty .big{font-family:var(--mono);font-size:14px;color:var(--text);}
.error-box{
background:rgba(229,72,77,0.08);border:1px solid rgba(229,72,77,0.35);
color:#F3B4B6;border-radius:8px;padding:14px 16px;font-size:13px;margin-bottom:20px;
}
.group{margin-bottom:28px;}
.group-title{
font-family:var(--mono);font-size:12px;letter-spacing:0.08em;text-transform:uppercase;
color:var(--muted);margin:0 0 10px 2px;
display:flex;align-items:center;gap:8px;
}
.group-title::after{content:"";flex:1;height:1px;background:var(--line);}
.card{
background:var(--panel);border:1px solid var(--line);border-radius:8px;
padding:14px 16px;margin-bottom:10px;
border-left:3px solid var(--line);
}
.card.sev-kritisch{border-left-color:var(--crit);}
.card.sev-hoch{border-left-color:var(--high);}
.card.sev-mittel{border-left-color:var(--med);}
.card.sev-niedrig{border-left-color:var(--low);}
.card-top{display:flex;justify-content:space-between;gap:10px;align-items:flex-start;flex-wrap:wrap;}
.card-title{font-weight:600;font-size:15px;margin:0;}
.card-title a{color:var(--text);text-decoration:none;}
.card-title a:hover{color:var(--med);text-decoration:underline;}
.badge{
font-family:var(--mono);font-size:10px;font-weight:700;letter-spacing:0.05em;
text-transform:uppercase;padding:2px 7px;border-radius:4px;white-space:nowrap;
}
.badge.sev-kritisch{color:var(--crit);background:rgba(229,72,77,0.12);}
.badge.sev-hoch{color:var(--high);background:rgba(245,166,35,0.12);}
.badge.sev-mittel{color:var(--med);background:rgba(63,184,175,0.12);}
.badge.sev-niedrig{color:var(--low);background:rgba(76,175,125,0.12);}
.badge.badge-kev{color:var(--crit);background:rgba(229,72,77,0.2);border:1px solid rgba(229,72,77,0.3);}
.card-summary{font-size:13.5px;line-height:1.55;color:var(--text);margin:8px 0 10px;}
.card-foot{
display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap;
font-family:var(--mono);font-size:11px;color:var(--muted);
}
.card-foot a{color:var(--med);}
footer{
margin-top:40px;padding-top:16px;border-top:1px solid var(--line);
font-size:11px;color:var(--muted);
}
@media (prefers-reduced-motion: reduce){
.dot.live, .cursor{animation:none;}
}
@media (max-width:600px){
header{flex-direction:column;}
.controls{align-items:flex-start;width:100%;}
.meta{text-align:left;}
.btn-row{width:100%;}
.btn-row button{flex:1;justify-content:center;}
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<div class="brand">
<span class="brand-mark">SEC</span>
<h1>SecWatch — Sicherheitsbriefing</h1>
</div>
<div class="tagline">CVEs, Vorfälle und relevante IT-Sicherheitsmeldungen — frei aus öffentlichen Quellen</div>
</div>
<div class="controls">
<div class="btn-row">
<button id="refresh">
<span class="dot" id="statusDot"></span>
<span id="refreshLabel">Tagesbericht abrufen</span>
</button>
<button id="exportPdf" class="ghost" disabled>PDF exportieren</button>
</div>
<a id="pdfLink" class="pdf-link" target="_blank" rel="noopener"></a>
<label class="toggle-row">
<input type="checkbox" id="autoPdfToggle">
vor jeder Auto-Aktualisierung als PDF sichern
</label>
<span class="pdf-hint">Auf dem Handy öffnet sich das PDF als Link zum Teilen/Sichern statt als Download</span>
<span class="meta" id="lastUpdated">noch keine Abfrage</span>
</div>
</header>
<div class="status-bar">
<span id="statusText">Bereit<span class="cursor"></span></span>
<span id="nextRefresh"></span>
<span id="itemCount"></span>
</div>
<div id="sourcesStatus" class="sources-row" style="margin-bottom:14px;"></div>
<div class="history" id="history"></div>
<main id="results">
<div class="empty">
<p class="big">Kein Bericht geladen</p>
<p>Klicke auf „Tagesbericht abrufen", um aktuelle Meldungen aus folgenden <strong>kostenfreien Quellen</strong> zu laden:</p>
<p style="font-family:var(--mono);font-size:12px;color:var(--text);margin-top:12px;">
Heise Security (RSS) · CERT-Bund (RSS) · NVD/CVE API · CISA KEV Catalog
</p>
<p style="margin-top:12px;">Kein API-Key, kein Abo, kein Abo. Danach automatisch alle 2 Stunden aktualisiert.</p>
</div>
</main>
<footer>
Datenquellen: heise.de (RSS), CERT-Bund/BSI (RSS), NIST NVD (API), CISA KEV (optional).
Alle Quellen sind frei zugänglich. *CISA KEV kann CORS-bedingt aus dem Browser heraus eingeschränkt sein.
Zusammenfassungen werden maschinell erstellt und können Fehler enthalten. Für kritische Entscheidungen bitte Originalquellen prüfen.
Berichte werden 24 Stunden lokal in diesem Browser gehalten und danach automatisch entfernt.
</footer>
</div>
<script>
/* ================================================================
SecWatch API-freies IT-Sicherheitsbriefing
Quellen: Heise RSS, CERT-Bund RSS, NVD API, CISA KEV
================================================================ */
const $ = (id) => document.getElementById(id);
// --- DOM ---
const refreshBtn = $('refresh');
const refreshLabel = $('refreshLabel');
const exportBtn = $('exportPdf');
const autoPdfToggle = $('autoPdfToggle');
const statusDot = $('statusDot');
const statusText = $('statusText');
const nextRefreshEl = $('nextRefresh');
const lastUpdated = $('lastUpdated');
const itemCount = $('itemCount');
const results = $('results');
const historyEl = $('history');
const pdfLink = $('pdfLink');
const sourcesStatus = $('sourcesStatus');
// --- Konstanten ---
const SEVERITY_ORDER = ['kritisch', 'hoch', 'mittel', 'niedrig'];
const CATEGORY_ORDER = ['CVE', 'Vorfall', 'Allgemein'];
const CATEGORY_LABEL = {
CVE: 'CVEs & Schwachstellen',
Vorfall: 'Cybervorfälle & Warnungen',
Allgemein: 'Sicherheitsmeldungen'
};
const TWO_HOURS = 2 * 60 * 60 * 1000;
const TWENTY_FOUR_HOURS = 24 * 60 * 60 * 1000;
// --- Zustand ---
let allReports = [];
let currentReport = null;
let autoTimer = null;
let countdownTimer = null;
let lastPdfUrl = null;
/* ================================================================
Storage (localStorage)
================================================================ */
const storage = {
_p: 'sw:',
set(k, v) { try { localStorage.setItem(this._p + k, v); } catch(e) {} },
get(k) { try { return localStorage.getItem(this._p + k); } catch(e) { return null; } },
del(k) { try { localStorage.removeItem(this._p + k); } catch(e) {} },
list(pfx) {
const full = this._p + pfx;
const ks = [];
for (let i = 0; i < localStorage.length; i++) {
const k = localStorage.key(i);
if (k && k.startsWith(full)) ks.push(k.slice(this._p.length));
}
return ks;
}
};
async function saveReport(r) { storage.set('r:' + r.ts, JSON.stringify(r)); }
async function deleteReport(ts) { storage.del('r:' + ts); }
async function loadAllReports() {
const reports = storage.list('r:').map(k => {
try { return JSON.parse(storage.get(k)); } catch(e) { return null; }
}).filter(Boolean);
reports.sort((a, b) => new Date(b.ts) - new Date(a.ts));
return reports;
}
async function pruneOld(cutoff) {
const keep = [];
for (const r of allReports) {
if (new Date(r.ts).getTime() < cutoff) await deleteReport(r.ts);
else keep.push(r);
}
allReports = keep;
}
/* ================================================================
Hilfsfunktionen
================================================================ */
function fmtTime(d) {
return d.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function fmtHM(d) {
return d.toLocaleTimeString('de-DE', { hour:'2-digit', minute:'2-digit' });
}
function esc(s) {
const el = document.createElement('span');
el.textContent = s == null ? '' : String(s);
return el.innerHTML;
}
function daysAgoDate(days) {
const d = new Date();
d.setDate(d.getDate() - days);
return d.toISOString().slice(0, 10);
}
/* ================================================================
Quellen-Status
================================================================ */
const sourceStates = {};
function setSourceState(name, state) {
sourceStates[name] = state;
renderSourceStates();
}
function renderSourceStates() {
const labels = { heise: 'Heise', certbund: 'CERT-Bund', nvd: 'NVD', cisa: 'CISA KEV*' };
const hints = { cisa: ' (CORS-eingeschränkt)' };
sourcesStatus.innerHTML = Object.entries(sourceStates).map(([k, v]) => {
const label = labels[k] || k;
if (v === 'fail' && k === 'cisa') {
return `<span class="src fail" title="CISA liefert keine CORS-Header Daten können nur über Server abgerufen werden">${label}: Übersprungen</span>`;
}
return `<span class="src ${v}">${label}: ${v === 'ok' ? 'OK' : v === 'fail' ? 'Fehler' : '…'}</span>`;
}).join('');
}
/* ================================================================
rss2json.com RSS-Feeds als JSON (CORS-frei, kein Key nötig)
================================================================ */
const RSS2JSON = 'https://api.rss2json.com/v1/api.json?rss_url=';
async function fetchRssJson(feedUrl) {
const resp = await fetch(RSS2JSON + encodeURIComponent(feedUrl));
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
if (data.status !== 'ok') throw new Error(data.message || 'rss2json Fehler');
return data.items || [];
}
/* ================================================================
Quelle: Heise Security RSS
================================================================ */
async function fetchHeise() {
setSourceState('heise', 'loading');
try {
const items = await fetchRssJson('https://www.heise.de/security/rss/news-atom.xml');
const cutoff = Date.now() - 48 * 60 * 60 * 1000;
return items.filter(item => {
if (!item.pubDate) return true;
const d = new Date(item.pubDate).getTime();
return !isNaN(d) && d >= cutoff;
}).map(item => ({
category: 'Allgemein',
severity: guessSeverity(item.title + ' ' + (item.description || '')),
title: item.title || '',
summary: stripHtml(item.description || item.content || item.title || ''),
source: 'Heise Security',
date: item.pubDate ? new Date(item.pubDate).toLocaleDateString('de-DE') : '',
link: item.link || ''
}));
} catch(e) {
console.warn('Heise RSS:', e);
setSourceState('heise', 'fail');
return [];
}
}
/* ================================================================
Quelle: CERT-Bund RSS
================================================================ */
async function fetchCertBund() {
setSourceState('certbund', 'loading');
try {
const items = await fetchRssJson('https://wid.cert-bund.de/content/public/securityAdvisory/rss');
return items.slice(0, 20).map(item => {
const sev = extractCertSeverity(item.title);
const cleanTitle = (item.title || '').replace(/\[.*?\]\s*/g, '').trim();
return {
category: 'CVE',
severity: sev,
title: cleanTitle,
summary: stripHtml(item.description || item.content || cleanTitle),
source: 'CERT-Bund',
date: item.pubDate ? new Date(item.pubDate).toLocaleDateString('de-DE') : '',
link: item.link || ''
};
});
} catch(e) {
console.warn('CERT-Bund RSS:', e);
setSourceState('certbund', 'fail');
return [];
}
}
function stripHtml(html) {
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim();
}
function extractCertSeverity(title) {
const t = (title || '').toLowerCase();
if (t.includes('[kritisch]') || t.includes('[critical]')) return 'kritisch';
if (t.includes('[hoch]') || t.includes('[high]')) return 'hoch';
if (t.includes('[mittel]') || t.includes('[medium]') || t.includes('[moderate]')) return 'mittel';
if (t.includes('[niedrig]') || t.includes('[low]')) return 'niedrig';
return 'mittel';
}
/* ================================================================
Quelle: NVD API (kostenlos, kein Key)
================================================================ */
async function fetchNVD() {
setSourceState('nvd', 'loading');
try {
const end = new Date();
const start = new Date();
start.setDate(start.getDate() - 3);
const fmt = d => d.toISOString().replace('Z', '').replace(/\.\d{3}/, '');
// NVD akzeptiert nur EIN cvssV3Severity wir holen CRITICAL und filtern danach clientseitig
const params = new URLSearchParams({
pubStartDate: fmt(start),
pubEndDate: fmt(end),
resultsPerPage: '40'
});
const resp = await fetch(`https://services.nvd.nist.gov/rest/json/cves/2.0?${params}`);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
return (data.vulnerabilities || [])
.map(vuln => {
const cve = vuln.cve;
const desc = (cve.descriptions || []).find(d => d.lang === 'en')?.value || '';
const metrics = vuln.metrics || {};
let score = 0;
let severity = 'mittel';
const cvss = metrics.cvssMetricV31?.[0]?.cvssData
|| metrics.cvssMetricV30?.[0]?.cvssData
|| metrics.cvssMetricV40?.[0]?.cvssData;
if (cvss) {
score = cvss.baseScore || 0;
severity = mapCvssSeverity(cvss.baseSeverity || score);
}
const affected = (cve.affected || [])
.flatMap(a => a.affectedData || [])
.map(a => `${a.vendor} ${a.product}`)
.filter(Boolean)
.slice(0, 2)
.join(', ');
return {
category: 'CVE',
severity,
title: `${cve.id}${affected || 'Unbekanntes Produkt'}`,
summary: buildCveSummary(desc, affected, score, cve.id),
source: 'NVD',
date: cve.published ? new Date(cve.published).toLocaleDateString('de-DE') : '',
link: `https://nvd.nist.gov/vuln/detail/${cve.id}`,
score
};
})
// Nur Critical (>=9) und High (>=7) behalten
.filter(item => item.score >= 7.0)
.sort((a, b) => b.score - a.score);
} catch(e) {
console.warn('NVD API:', e);
setSourceState('nvd', 'fail');
return [];
}
}
function mapCvssSeverity(sevOrScore) {
if (typeof sevOrScore === 'number') {
if (sevOrScore >= 9.0) return 'kritisch';
if (sevOrScore >= 7.0) return 'hoch';
if (sevOrScore >= 4.0) return 'mittel';
return 'niedrig';
}
const s = (sevOrScore || '').toLowerCase();
if (s.includes('critical')) return 'kritisch';
if (s.includes('high')) return 'hoch';
if (s.includes('medium')) return 'mittel';
return 'niedrig';
}
function buildCveSummary(desc, affected, score, cveId) {
// Versuche deutsche Zusammenfassung aus der Beschreibung zu bauen
let summary = desc;
// Einige typische Englisch → Deutsch Übersetzungen für gängige Begriffe
const translations = [
[/A vulnerability was identified in/i, 'Eine Schwachstelle wurde identifiziert in'],
[/contains a/i, 'enthält eine'],
[/that could allow/i, 'die es ermöglichen könnte'],
[/that allows/i, 'die es ermöglicht'],
[/remote attacker/i, 'entfernten Angreifer'],
[/unauthenticated attacker/i, 'nicht-authentifizierten Angreifer'],
[/authenticated attacker/i, 'authentifizierten Angreifer'],
[/to execute arbitrary code/i, 'beliebigen Code auszuführen'],
[/to execute code/i, 'Code auszuführen'],
[/to bypass authentication/i, 'die Authentifizierung zu umgehen'],
[/cross-site scripting/i, 'Cross-Site-Scripting'],
[/SQL injection/i, 'SQL-Injection'],
[/denial of service/i, 'Dienstverweigerung'],
[/information disclosure/i, 'Informationsoffenlegung'],
[/privilege escalation/i, 'Privilegieneskalation'],
[/remote code execution/i, 'Remote-Code-Ausführung'],
[/path traversal/i, 'Pfadtraversale'],
[/command injection/i, 'Kommando-Injection'],
];
for (const [pat, repl] of translations) {
summary = summary.replace(pat, repl);
}
if (affected) {
summary = `Betroffene Systeme: ${affected}. ${summary}`;
}
return summary;
}
/* ================================================================
Quelle: CISA Known Exploited Vulnerabilities
Hinweis: CISA liefert keine CORS-Header wird als "optional"
markiert. Bei Fehler wird die Quelle einfach übersprungen.
================================================================ */
async function fetchCISA() {
setSourceState('cisa', 'loading');
try {
const resp = await fetch('https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 14);
const cutoffStr = cutoff.toISOString().slice(0, 10);
return (data.vulnerabilities || [])
.filter(v => v.dateAdded >= cutoffStr)
.slice(0, 15)
.map(v => ({
category: 'CVE',
severity: 'kritisch',
title: `${v.cveID}${v.vendorProject} ${v.product}`,
summary: buildKevSummary(v),
source: 'CISA KEV',
date: v.dateAdded || '',
link: `https://nvd.nist.gov/vuln/detail/${v.cveID}`,
isKev: true,
dueDate: v.dueDate || '',
ransomware: v.knownRansomwareCampaignUse || 'Unknown'
}));
} catch(e) {
console.warn('CISA KEV (CORS-Einschränkung):', e.message);
setSourceState('cisa', 'fail');
return [];
}
}
function buildKevSummary(v) {
let summary = v.shortDescription || '';
// Englisch → Deutsch Grundlagen
summary = summary
.replace(/contains a /i, 'enthält eine ')
.replace(/that allows/i, 'die es ermöglicht')
.replace(/that could allow/i, 'die es ermöglichen könnte')
.replace(/unauthenticated attacker/i, 'nicht-authentifizierter Angreifer')
.replace(/remote attacker/i, 'entfernter Angreifer')
.replace(/authenticated attacker/i, 'authentifizierter Angreifer')
.replace(/execute code/i, 'Code auszuführen')
.replace(/execute commands/i, 'Befehle auszuführen')
.replace(/elevate privileges/i, 'Privilegien zu erhöhen')
.replace(/bypass authentication/i, 'die Authentifizierung zu umgehen');
const ransomware = v.knownRansomwareCampaignUse;
if (ransomware === 'Known') {
summary += ' **AKTIV IN RANSOMWARE-KAMPAGEN EINGESETZT**';
}
if (v.dueDate) {
summary += ` (Frist: ${v.dueDate})`;
}
return summary;
}
/* ================================================================
.SEVERITY-Erkennung für Heise-Artikel
================================================================ */
function guessSeverity(text) {
const t = (text || '').toLowerCase();
if (t.includes('kritisch') || t.includes('critical') || t.includes('zero-day') || t.includes('0-day'))
return 'kritisch';
if (t.includes('hoch') || t.includes('high') || t.includes('schwer') || t.includes('ernst'))
return 'hoch';
if (t.includes('niedrig') || t.includes('low') || t.includes('gering'))
return 'niedrig';
return 'mittel';
}
/* ================================================================
Rendering
================================================================ */
function renderHistory() {
if (allReports.length === 0) {
historyEl.innerHTML = '<span class="history-empty">Noch kein Verlauf (letzte 24 Std.)</span>';
return;
}
historyEl.innerHTML = allReports.map(r => {
const active = currentReport && r.ts === currentReport.ts;
return `<button class="history-chip${active ? ' active' : ''}" data-ts="${esc(r.ts)}">${fmtHM(new Date(r.ts))}</button>`;
}).join('');
}
historyEl.addEventListener('click', (e) => {
const chip = e.target.closest('.history-chip');
if (!chip) return;
const report = allReports.find(r => r.ts === chip.dataset.ts);
if (report) {
currentReport = report;
renderItems(report.items);
lastUpdated.textContent = `Stand: ${fmtTime(new Date(report.ts))}`;
renderHistory();
}
});
function setBusy(isBusy, text) {
refreshBtn.disabled = isBusy;
statusDot.classList.toggle('live', isBusy);
statusText.innerHTML = `${esc(text)}<span class="cursor"></span>`;
}
function renderError(message) {
results.innerHTML = `<div class="error-box">Fehler: ${esc(message)}</div>`;
}
function renderItems(items) {
exportBtn.disabled = !items || items.length === 0;
if (!Array.isArray(items) || items.length === 0) {
results.innerHTML = `
<div class="empty">
<p class="big">Keine relevanten Meldungen gefunden</p>
<p>Versuche es später erneut.</p>
</div>`;
itemCount.textContent = '';
return;
}
// Deduplizieren nach Title
const seen = new Set();
const unique = items.filter(item => {
const key = item.title.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 40);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
// Nach Kategorie gruppieren
const byCategory = {};
for (const item of unique) {
const cat = CATEGORY_ORDER.includes(item.category) ? item.category : 'Allgemein';
(byCategory[cat] ||= []).push(item);
}
for (const cat in byCategory) {
byCategory[cat].sort((a, b) => SEVERITY_ORDER.indexOf(a.severity) - SEVERITY_ORDER.indexOf(b.severity));
}
let html = '';
for (const cat of CATEGORY_ORDER) {
const group = byCategory[cat];
if (!group || group.length === 0) continue;
html += `<div class="group">
<div class="group-title">${CATEGORY_LABEL[cat]} · ${group.length}</div>`;
for (const item of group) {
const sev = SEVERITY_ORDER.includes(item.severity) ? item.severity : 'mittel';
const titleHtml = item.link
? `<a href="${esc(item.link)}" target="_blank" rel="noopener">${esc(item.title || 'Ohne Titel')}</a>`
: esc(item.title || 'Ohne Titel');
const kevBadge = item.isKev ? '<span class="badge badge-kev" style="margin-left:6px;">KEV</span>' : '';
html += `
<div class="card sev-${sev}">
<div class="card-top">
<h3 class="card-title">${titleHtml}${kevBadge}</h3>
<span class="badge sev-${sev}">${sev}</span>
</div>
<p class="card-summary">${esc(item.summary || '')}</p>
<div class="card-foot">
<span>${esc(item.source || '')}</span>
<span>${esc(item.date || '')}</span>
</div>
</div>`;
}
html += '</div>';
}
results.innerHTML = html;
itemCount.textContent = `${unique.length} Meldungen`;
}
/* ================================================================
PDF-Export
================================================================ */
function buildPdf(report) {
if (!report || !report.items || report.items.length === 0) return null;
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ unit: 'pt', format: 'a4' });
const pw = doc.internal.pageSize.getWidth();
const ph = doc.internal.pageSize.getHeight();
const m = 44;
const bottom = ph - 50;
let y = 54;
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.text('SecWatch IT-Sicherheitsbriefing', m, y);
y += 20;
doc.setFont('helvetica', 'normal');
doc.setFontSize(10);
doc.setTextColor(90);
doc.text(`Stand: ${fmtTime(new Date(report.ts))} · Quellen: Heise, CERT-Bund, NVD, CISA`, m, y);
doc.setTextColor(0);
y += 26;
for (const cat of CATEGORY_ORDER) {
const group = report.items.filter(i =>
(CATEGORY_ORDER.includes(i.category) ? i.category : 'Allgemein') === cat
);
if (group.length === 0) continue;
if (y > bottom - 20) { doc.addPage(); y = 54; }
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text(CATEGORY_LABEL[cat], m, y);
y += 8;
doc.setDrawColor(210);
doc.line(m, y, pw - m, y);
y += 16;
for (const item of group) {
const sev = SEVERITY_ORDER.includes(item.severity) ? item.severity : 'mittel';
const titleText = `[${sev.toUpperCase()}] ${item.title || ''}`;
const bodyText = `${item.summary || ''} (${item.source || ''}, ${item.date || ''})`;
const tl = doc.splitTextToSize(titleText, pw - m * 2);
const bl = doc.splitTextToSize(bodyText, pw - m * 2);
const bh = tl.length * 13 + bl.length * 11.5 + 14;
if (y + bh > bottom) { doc.addPage(); y = 54; }
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.text(tl, m, y);
y += tl.length * 13 + 3;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9.5);
doc.setTextColor(60);
doc.text(bl, m, y);
doc.setTextColor(0);
y += bl.length * 11.5 + 12;
}
y += 6;
}
const fn = `secwatch-${report.ts.slice(0, 16).replace(/[:T]/g, '-')}.pdf`;
return { blob: doc.output('blob'), filename: fn };
}
function presentPdf(report, auto) {
const built = buildPdf(report);
if (!built) return;
if (lastPdfUrl) URL.revokeObjectURL(lastPdfUrl);
lastPdfUrl = URL.createObjectURL(built.blob);
pdfLink.href = lastPdfUrl;
pdfLink.download = built.filename;
pdfLink.textContent = `${auto ? 'Auto-PDF bereit' : 'PDF bereit'} tippen zum Öffnen: ${built.filename}`;
pdfLink.style.display = 'inline-flex';
if (!auto) { try { pdfLink.click(); } catch(e) {} }
}
exportBtn.addEventListener('click', () => presentPdf(currentReport, false));
/* ================================================================
Hauptabfrage
================================================================ */
async function fetchBriefing(isAuto) {
if (isAuto && autoPdfToggle.checked && currentReport) {
presentPdf(currentReport, true);
}
setBusy(true, 'Quellen werden abgefragt …');
refreshLabel.textContent = 'Läuft …';
// Quellen-Status zurücksetzen
sourcesStatus.innerHTML = '';
['heise', 'certbund', 'nvd', 'cisa'].forEach(s => setSourceState(s, 'loading'));
try {
// Alle Quellen parallel abfragen
const [heiseItems, certItems, nvdItems, cisaItems] = await Promise.all([
fetchHeise(),
fetchCertBund(),
fetchNVD(),
fetchCISA()
]);
// Quellen-Status setzen
if (heiseItems.length > 0) setSourceState('heise', 'ok');
else if (sourceStates.heise === 'loading') setSourceState('heise', 'ok'); // empty but no error
if (certItems.length > 0) setSourceState('certbund', 'ok');
else if (sourceStates.certbund === 'loading') setSourceState('certbund', 'ok');
if (nvdItems.length > 0) setSourceState('nvd', 'ok');
else if (sourceStates.nvd === 'loading') setSourceState('nvd', 'ok');
// CISA wird oben schon gesetzt (fail = CORS-Problem, ok = hat geklappt)
// Zusammenführen: CISA KEV zuerst, dann NVD, dann CERT-Bund, dann Heise
const items = [...cisaItems, ...nvdItems, ...certItems, ...heiseItems];
if (items.length === 0) {
throw new Error('Keine Daten aus den Quellen erhalten. Möglicherweise werden die Anfragen blockiert (CORS).');
}
const report = { ts: new Date().toISOString(), items };
await saveReport(report);
allReports.push(report);
allReports.sort((a, b) => new Date(b.ts) - new Date(a.ts));
await pruneOld(Date.now() - TWENTY_FOUR_HOURS);
currentReport = report;
renderItems(items);
renderHistory();
lastUpdated.textContent = `Stand: ${fmtTime(new Date(report.ts))}`;
setBusy(false, 'Bereit');
} catch(err) {
console.error(err);
renderError(err.message || String(err));
setBusy(false, 'Fehler');
} finally {
refreshLabel.textContent = 'Tagesbericht abrufen';
scheduleAutoRefresh();
}
}
function scheduleAutoRefresh() {
clearTimeout(autoTimer);
clearInterval(countdownTimer);
if (!currentReport) { nextRefreshEl.textContent = ''; return; }
const nextTime = new Date(currentReport.ts).getTime() + TWO_HOURS;
const delay = Math.max(nextTime - Date.now(), 0);
autoTimer = setTimeout(() => fetchBriefing(true), delay);
const tick = () => {
const rem = Math.max(nextTime - Date.now(), 0);
const mins = Math.floor(rem / 60000);
const h = Math.floor(mins / 60);
const m = mins % 60;
nextRefreshEl.textContent = `nächste Aktualisierung in ${h}h ${m}min (${fmtHM(new Date(nextTime))})`;
};
tick();
countdownTimer = setInterval(tick, 30000);
}
refreshBtn.addEventListener('click', () => fetchBriefing(false));
/* ================================================================
Initialisierung
================================================================ */
(async function init() {
allReports = await loadAllReports();
const cutoff = Date.now() - TWENTY_FOUR_HOURS;
allReports = allReports.filter(r => new Date(r.ts).getTime() >= cutoff);
if (allReports.length > 0) {
currentReport = allReports[0];
renderItems(currentReport.items);
lastUpdated.textContent = `Stand: ${fmtTime(new Date(currentReport.ts))}`;
}
renderHistory();
scheduleAutoRefresh();
})();
</script>
</body>
</html>