html-Code hinzugefuegt
This commit is contained in:
+935
@@ -0,0 +1,935 @@
|
|||||||
|
<!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>
|
||||||
Reference in New Issue
Block a user