OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

index.php

25 KB
<?php $this->layout('layouts/main', ['page_title' => $page_title, 'current_route' => 'cve', 'full_width' => true]); ?>
<style>
/* CVE-Modul – modul-spezifische Komponenten (Prefix: cve-).
   Standard-Komponenten kommen aus app.css: page-body, kpi-row/kpi-col,
   table-wrapper/data-table, status-pill, empty-state. */

/* Aufgeklappte Detail-Zeile */
tr.cve-open td { background:var(--sky-50); }
.cve-detail-row { display:none; }
.cve-detail-row.open { display:table-row; }
.cve-detail-cell { padding:.75rem 1rem 1rem 2.5rem !important; background:var(--slate-50); border-bottom:2px solid var(--gray-200) !important; }
.cve-detail-inner { font-size:.82rem; color:var(--gray-700); line-height:1.6; }
.cve-affected-chip { display:inline-block; background:var(--gray-100); border:1px solid var(--gray-200); border-radius:4px; font-size:.72rem; padding:.1rem .45rem; color:var(--gray-700); margin:.1rem .15rem; }

/* Keyword-Chips */
.cve-keyword-chip { display:inline-flex; align-items:center; gap:.35rem; background:var(--blue-50); border:1px solid var(--blue-200); border-radius:20px; padding:.25rem .75rem; font-size:.82rem; color:var(--blue-700); }
.cve-keyword-del { background:none; border:none; color:var(--blue-300); cursor:pointer; font-size:1rem; padding:0; line-height:1; }
.cve-keyword-del:hover { color:var(--red-600); }

/* NVD-Launchpad */
.cve-nvd-card { background:var(--color-bg); border:1px solid var(--gray-200); border-radius:.75rem; padding:1.25rem 1.5rem; margin-top:1.25rem; }
.cve-nvd-card h3 { margin:0 0 .35rem; font-size:.95rem; font-weight:700; color:var(--gray-900); display:flex; align-items:center; gap:.4rem; }
.cve-nvd-card p { margin:0 0 .9rem; font-size:.83rem; color:var(--gray-500); }
.cve-nvd-row { display:flex; gap:.5rem; flex-wrap:wrap; align-items:center; }
.cve-nvd-crit { border-color:var(--red-300); color:var(--red-600); }
.cve-nvd-high { border-color:var(--orange-300); color:var(--orange-600); }
.cve-nvd-search { display:flex; gap:.5rem; margin-top:.85rem; max-width:480px; }

/* Filter-Buttons (innerhalb der Standard-Filter-Card / .filter-card-body) */
.cve-filterbar { margin-bottom:0; }
.cve-kw-list { display:inline-flex; flex-wrap:wrap; gap:.2rem; align-items:center; }
.cve-kw-tag { background:var(--blue-50); border:1px solid var(--blue-200); border-radius:4px; padding:.05rem .4rem; font-size:.72rem; color:var(--blue-700); }

/* Refresh-Button Spin */
@keyframes cve-spin { to { transform:rotate(360deg); } }
.cve-spin { animation:cve-spin .8s linear infinite; display:inline-block; }

/* Keyword-Modal: Overlay/Karte kommen zentral aus app.css (.modal-overlay/.modal-card);
   lokal nur Zustands-Toggle (.open), z-index über der Topbar und die Modul-Breite. */
.cve-modal { display:none; z-index:1050; }
.cve-modal.open { display:flex; }
.cve-modal .modal-card { max-width:520px; }
.cve-pager { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.5rem; margin:.75rem 0 0; }
.cve-pager-info { font-size:.8rem; color:var(--gray-500); }

/* BSI-Verknüpfung */
.cve-bsi-badge { display:inline-block; margin-left:.4rem; padding:.05rem .4rem; border-radius:4px; background:var(--amber-100); border:1px solid var(--amber-300); color:var(--amber-800); font-size:.68rem; font-weight:700; text-decoration:none; vertical-align:middle; white-space:nowrap; }
.cve-bsi-badge:hover { background:var(--amber-200); }
.cve-sources-note { display:flex; align-items:flex-start; gap:.6rem; background:var(--blue-50); border:1px solid var(--blue-200); border-radius:.5rem; padding:.7rem 1rem; margin-bottom:1.25rem; font-size:.83rem; color:var(--color-primary-deep); line-height:1.55; }
.cve-sources-icon { font-size:1.05rem; line-height:1.4; }
</style>

<?php
$keywordCves = $keyword_cves ?? [];
$keywords    = $keywords     ?? [];
$stats       = $stats        ?? ['CRITICAL'=>0,'HIGH'=>0,'MEDIUM'=>0,'LOW'=>0,'total'=>0];
$lastRefresh = $last_refresh ?? null;
$filterSev   = $filter_sev   ?? '';
$filterQ     = $filter_q     ?? '';
$canManage   = $can_manage   ?? false;
$cacheStale  = $cache_stale  ?? false;
$csrfToken   = $this->csrf();
$hasKeywords = !empty($keywords);
$bsiEnabled    = $bsi_enabled    ?? false;
$bsiRelevant   = $bsi_relevant   ?? [];
$bsiOther      = $bsi_other      ?? [];
$bsiByCve      = $bsi_by_cve     ?? [];
$cvePage     = $page     ?? 1;
$cvePerPage  = $per_page ?? 100;
$cveTotal    = $total    ?? count($keywordCves);
$cvePages    = max(1, (int)ceil($cveTotal / $cvePerPage));
// Aktuelle Sortierung
$sortCol = $sort_col ?? 'published';
$sortDir = $sort_dir ?? 'desc';

// Filter + Sortierung für Pager-Links erhalten
$pagerQuery = function (int $p) use ($filterSev, $filterQ, $sortCol, $sortDir): string {
    $qs = ['page' => $p];
    if ($filterSev !== '') { $qs['severity'] = $filterSev; }
    if ($filterQ !== '')   { $qs['q'] = $filterQ; }
    $qs['sort'] = $sortCol;
    $qs['dir']  = $sortDir;
    return '/cve?' . http_build_query($qs);
};

// Sortier-Link je Spalte: klick = aufsteigend, erneuter Klick = absteigend.
// Filter bleiben erhalten; Seite springt auf 1 zurück.
$sortUrl = function (string $col) use ($filterSev, $filterQ, $sortCol, $sortDir): string {
    $newDir = ($sortCol === $col && $sortDir === 'asc') ? 'desc' : 'asc';
    $qs = ['sort' => $col, 'dir' => $newDir];
    if ($filterSev !== '') { $qs['severity'] = $filterSev; }
    if ($filterQ !== '')   { $qs['q'] = $filterQ; }
    return '/cve?' . http_build_query($qs);
};

$sevClass = fn($s) => match(strtoupper($s)) {
    'CRITICAL' => 'status-pill--danger', 'HIGH' => 'status-pill--orange',
    'MEDIUM' => 'status-pill--warning', 'LOW' => 'status-pill--info', default => 'status-pill--neutral',
};
$sevLabel = fn($s) => match(strtoupper($s)) {
    'CRITICAL' => 'Kritisch', 'HIGH' => 'Hoch', 'MEDIUM' => 'Mittel', 'LOW' => 'Niedrig',
    default => strtoupper($s ?: 'Unbekannt'),
};
$nvdSearch = 'https://nvd.nist.gov/vuln/search/results?form_type=Basic&results_type=overview&search_type=all&isCpeNameSearch=false';
?>

<div class="cve-outer">
<!-- Content Header -->
<div class="content-header">
    <div>
        <h1><?= $this->te('CVE-Sicherheitsmeldungen') ?></h1>
        <ol class="breadcrumb">
            <li class="breadcrumb-item"><a href="/"><?= $this->te('Home') ?></a></li>
            <li class="breadcrumb-item active"><?= $this->te('CVE-Meldungen') ?></li>
        </ol>
    </div>
    <div class="content-header-actions">
        <?php if ((int)($stats['CRITICAL'] ?? 0) > 0): ?>
        <span class="status-pill status-pill--danger">&#128680; <?= $this->te('{anzahl} kritisch', ['anzahl' => (int)$stats['CRITICAL']]) ?></span>
        <?php endif; ?>
        <?php if ($lastRefresh): ?>
        <span style="font-size:.78rem;color:var(--gray-400);"><?= $this->te('Aktualisiert:') ?> <?= $this->te('{zeitpunkt} Uhr', ['zeitpunkt' => date('d.m.Y H:i', strtotime($lastRefresh))]) ?></span>
        <?php endif; ?>
        <?php if ($canManage): ?>
        <button class="btn btn-default" onclick="document.getElementById('cveKeywordModal').classList.add('open')">
            <?= $this->t('Keywords verwalten') ?>
            <?php if ($hasKeywords): ?>
            <span style="background:var(--color-primary);color:var(--color-on-accent);border-radius:9999px;padding:.05rem .45rem;font-size:.72rem;margin-left:.25rem;"><?= count($keywords) ?></span>
            <?php endif; ?>
        </button>
        <button class="btn btn-default" id="refreshBtn" onclick="doCveRefresh()" <?= $hasKeywords ? '' : 'disabled title="Erst Keywords anlegen"' ?>>
            <span id="refreshIcon">&#8635;</span> <?= $this->te('Aktualisieren') ?>
        </button>
        <?php endif; ?>
    </div>
</div>

<div class="page-body">

    <span id="cveRefreshMsg" style="display:none;"></span>

    <?php if ($bsiEnabled && $hasKeywords): ?>
    <div class="cve-sources-note">
        <?= $this->t('<span class="cve-sources-icon">&#128218;</span> <span> <strong>Zwei Quellen, eine Sicht:</strong> Oben die technischen <strong>NVD/CVE</strong>-Einträge zu deinen überwachten Produkten (Quelle der Wahrheit, englisch). Darunter die kuratierten deutschen <strong>BSI CERT-Bund</strong>-Warnungen dazu. Eine CVE mit deutscher Warnung trägt ein <span class="cve-bsi-badge" style="position:static;">&#127465;&#127466; BSI</span>-Badge &ndash; Klick führt zur Einordnung. </span>') ?>
    </div>
    <?php endif; ?>

    <?php if ($cacheStale && $hasKeywords): ?>
    <div class="alert alert-warning" style="margin-bottom:1.25rem;display:flex;align-items:flex-start;gap:.75rem;">
        <span style="font-size:1.1rem;">&#9888;</span>
        <span>
            <?= $this->t('<strong>Cache veraltet</strong> &ndash; Die CVE-Daten wurden seit mehr als 6 Stunden nicht aktualisiert.') ?>
            <?php if ($canManage): ?>
            <?= $this->t('Klicke auf <strong>Aktualisieren</strong>, oder aktiviere den geplanten Task <strong>„CVE-Cache aktualisieren"</strong> unter <a href="/admin/cronjobs">Einstellungen → Cronjobs</a> (läuft über den zentralen Scheduler).') ?>
            <?php else: ?>
            <?= $this->te('Bitte einen Administrator bitten, den Cache zu aktualisieren.') ?>
            <?php endif; ?>
        </span>
    </div>
    <?php endif; ?>

    <!-- KPI small-boxes (Watchlist) -->
    <?php if ($hasKeywords): ?>
    <div class="kpi-row">
        <div class="kpi-col">
            <a href="/cve?severity=CRITICAL">
                <div class="small-box bg-danger">
                    <div class="inner"><h3><?= (int)$stats['CRITICAL'] ?></h3><p><?= $this->te('Kritisch') ?></p></div>
                    <div class="icon"><i class="fas fa-exclamation-circle"></i></div>
                </div>
            </a>
        </div>
        <div class="kpi-col">
            <a href="/cve?severity=HIGH">
                <div class="small-box bg-warning">
                    <div class="inner"><h3><?= (int)$stats['HIGH'] ?></h3><p><?= $this->te('Hoch') ?></p></div>
                    <div class="icon"><i class="fas fa-exclamation-triangle"></i></div>
                </div>
            </a>
        </div>
        <div class="kpi-col">
            <a href="/cve?severity=MEDIUM">
                <div class="small-box bg-info">
                    <div class="inner"><h3><?= (int)$stats['MEDIUM'] ?></h3><p><?= $this->te('Mittel') ?></p></div>
                    <div class="icon"><i class="fas fa-info-circle"></i></div>
                </div>
            </a>
        </div>
        <div class="kpi-col">
            <a href="/cve">
                <div class="small-box bg-secondary">
                    <div class="inner"><h3><?= (int)$stats['total'] ?></h3><p><?= $this->te('Watchlist gesamt') ?></p></div>
                    <div class="icon"><i class="fas fa-shield-alt"></i></div>
                    <span class="small-box-footer"><?= $this->te('Alle anzeigen') ?></span>
                </div>
            </a>
        </div>
    </div>
    <?php endif; ?>

    <?php if (!$hasKeywords): ?>
    <!-- Kein Keyword: CTA + NVD-Launchpad -->
    <div class="empty-state">
        <span class="empty-icon">&#128269;</span>
        <strong><?= $this->te('Keine Keywords konfiguriert') ?></strong><br>
        <span style="font-size:.88rem;">
            <?= $this->te('Dieses Modul überwacht gezielt deine Produkte – es spiegelt nicht die komplette NVD-Datenbank.') ?>
            <?php if ($canManage): ?><?= $this->t('Lege Keywords an (z.&nbsp;B. <em>nginx</em>, <em>fortigate</em>, <em>exchange</em>), um relevante CVEs zu sammeln.') ?><?php endif; ?>
        </span>
        <?php if ($canManage): ?>
        <div style="margin-top:1.1rem;">
            <button class="btn btn-primary" onclick="document.getElementById('cveKeywordModal').classList.add('open')"><?= $this->te('Keywords anlegen') ?></button>
        </div>
        <?php endif; ?>
    </div>
    <?php else: ?>

    <!-- Filterleiste (Styleguide § 8) -->
    <div class="card card-outline card-primary mb-3">
        <div class="filter-card-body cve-filterbar">
            <?php
            $sevFilters  = ['' => 'Alle', 'CRITICAL' => '&#128680; Kritisch', 'HIGH' => '&#9888; Hoch', 'MEDIUM' => '&#9432; Mittel', 'LOW' => 'Niedrig'];
            $sevBtnClass = ['' => 'btn-primary', 'CRITICAL' => 'btn-danger', 'HIGH' => 'btn-warning', 'MEDIUM' => 'btn-info', 'LOW' => 'btn-default'];
            foreach ($sevFilters as $val => $label):
                $cls = ($filterSev === $val) ? ($sevBtnClass[$val] ?? 'btn-default') : 'btn-default';
            ?>
            <a href="/cve?severity=<?= urlencode($val) ?>&q=<?= urlencode($filterQ) ?>" class="btn btn-sm <?= $cls ?>"><?= $label ?></a>
            <?php endforeach; ?>
            <form method="get" action="/cve" class="filter-input-wrap" style="max-width:340px;">
                <input type="hidden" name="severity" value="<?= $this->e($filterSev) ?>">
                <svg class="filter-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="15" height="15" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
                <input type="search" name="q" class="form-control form-control-sm"
                       value="<?= $this->e($filterQ) ?>" placeholder="<?= $this->te('In Watchlist suchen…') ?>"
                       oninput="this.form.submit()" autocomplete="off">
            </form>
            <span class="cve-kw-list">
                <?php foreach ($keywords as $kw): ?>
                <span class="cve-kw-tag"><?= $this->e($kw['keyword']) ?></span>
                <?php endforeach; ?>
            </span>
        </div>
    </div>

    <!-- Watchlist-Tabelle -->
    <?php if (!empty($keywordCves)): ?>
    <?php $tableCves = $keywordCves; $bsiByCveMap = $bsiByCve; include __DIR__ . '/_cve_table.php'; ?>
    <?php
        $firstRow = ($cvePage - 1) * $cvePerPage + 1;
        $lastRow  = min($cveTotal, $cvePage * $cvePerPage);
    ?>
    <div class="cve-pager">
        <span class="cve-pager-info"><?= $this->te('{von}–{bis} von {gesamt} Meldungen', ['von' => $firstRow, 'bis' => $lastRow, 'gesamt' => $cveTotal]) ?></span>
        <?php if ($cvePages > 1): ?>
        <div class="pagination">
            <a class="pagination-link <?= $cvePage <= 1 ? 'disabled' : '' ?>" href="<?= $cvePage <= 1 ? '#' : $this->e($pagerQuery($cvePage - 1)) ?>"><?= $this->te('&laquo; Zurück') ?></a>
            <span class="pagination-link active"><?= $this->te('Seite {seite} / {gesamt}', ['seite' => $cvePage, 'gesamt' => $cvePages]) ?></span>
            <a class="pagination-link <?= $cvePage >= $cvePages ? 'disabled' : '' ?>" href="<?= $cvePage >= $cvePages ? '#' : $this->e($pagerQuery($cvePage + 1)) ?>"><?= $this->te('Weiter &raquo;') ?></a>
        </div>
        <?php endif; ?>
    </div>
    <?php else: ?>
    <div class="empty-state">
        <span class="empty-icon">&#128270;</span>
        <strong><?= ($filterSev !== '' || $filterQ !== '') ? 'Keine Treffer für den Filter' : 'Noch keine CVEs im Cache' ?></strong><br>
        <span style="font-size:.85rem;">
            <?php if ($filterSev !== '' || $filterQ !== ''): ?>
            <a href="/cve"><?= $this->te('Filter zurücksetzen') ?></a>
            <?php else: ?>
            <?= $this->te('Klicke auf „Aktualisieren", um die Keywords bei der NVD abzufragen.') ?>
            <?php endif; ?>
        </span>
    </div>
    <?php endif; ?>
    <?php endif; ?>

    <?php if ($bsiEnabled): ?>
    <!-- BSI CERT-Bund Warnungen – mit der Watchlist verknüpft -->
    <section class="detail-card" style="margin-top:1.5rem;">
        <div class="detail-card-header">
            <h2 class="detail-card-title"><?= $this->te('&#127465;&#127466; BSI CERT-Bund &ndash; deutschsprachige Warnungen zu deiner Watchlist') ?></h2>
        </div>
        <div class="detail-card-body">
            <p style="margin:0 0 1rem;font-size:.83rem;color:var(--gray-500);line-height:1.55;">
                <?= $this->te('Hier siehst du die deutschsprachigen CERT-Bund-Warnungen, die eine deiner überwachten CVEs referenzieren oder ein Watchlist-Stichwort treffen &ndash; als verständliche Einordnung und Handlungshilfe zu den technischen NVD-Einträgen oben.') ?>
            </p>
            <?php if (empty($bsiRelevant)): ?>
                <div class="empty-state" style="padding:1.25rem;">
                    <span style="font-size:.88rem;color:var(--gray-500);">
                        <?= $filterQ !== ''
                            ? $this->te('Aktuell keine CERT-Bund-Warnung mit Bezug zu deiner Watchlist für den Filter.')
                            : $this->te('Aktuell keine CERT-Bund-Warnung mit Bezug zu deiner Watchlist.') ?>
                        <?php if (!empty($bsiOther)): ?><?= $this->te('Weiter unten findest du die aktuellsten Warnungen allgemein.') ?><?php endif; ?>
                    </span>
                </div>
            <?php else: ?>
                <?php $bsiRows = $bsiRelevant; include __DIR__ . '/_bsi_table.php'; ?>
            <?php endif; ?>

            <?php if (!empty($bsiOther)): ?>
            <details style="margin-top:1rem;">
                <summary style="cursor:pointer;font-size:.85rem;color:var(--blue-700);font-weight:600;">
                    <?= $this->te('Weitere aktuelle CERT-Bund-Warnungen anzeigen ({anzahl})', ['anzahl' => count($bsiOther)]) ?>
                </summary>
                <div style="margin-top:.75rem;">
                    <?php $bsiRows = $bsiOther; include __DIR__ . '/_bsi_table.php'; ?>
                </div>
            </details>
            <?php endif; ?>
        </div>
    </section>
    <?php endif; ?>

    <!-- NVD-Launchpad: alles Übrige direkt bei der Quelle -->
    <div class="cve-nvd-card">
        <h3><?= $this->te('&#8599; Weitere CVEs direkt bei der NVD suchen') ?></h3>
        <p><?= $this->te('Für alles außerhalb deiner Watchlist springst du am besten direkt zur offiziellen NVD-Datenbank – so bleibt diese Anwendung schlank.') ?></p>
        <div class="cve-nvd-row">
            <a class="btn btn-sm btn-default cve-nvd-crit" href="https://nvd.nist.gov/vuln/search/results?cvss_version=3&cvss_v3_severity=CRITICAL" target="_blank" rel="noopener"><?= $this->te('&#128680; Kritische CVEs') ?></a>
            <a class="btn btn-sm btn-default cve-nvd-high" href="https://nvd.nist.gov/vuln/search/results?cvss_version=3&cvss_v3_severity=HIGH" target="_blank" rel="noopener"><?= $this->te('&#9888; Hohe CVEs') ?></a>
            <a class="btn btn-sm btn-default" href="https://nvd.nist.gov/vuln/search" target="_blank" rel="noopener"><?= $this->te('Erweiterte Suche') ?></a>
        </div>
        <div class="cve-nvd-search">
            <input type="search" id="nvdSearchInput" class="form-control form-control-sm" placeholder="<?= $this->te('Begriff bei der NVD suchen…') ?>"
                   onkeydown="if(event.key==='Enter'){event.preventDefault();nvdSearch();}">
            <button type="button" class="btn btn-default btn-sm" onclick="nvdSearch()"><?= $this->te('Bei NVD suchen') ?></button>
        </div>
    </div>

</div><!-- /page-body -->
</div><!-- /cve-outer -->

<?php if ($canManage): ?>
<!-- Keyword-Modal (zentrale Modal-Komponenten aus app.css) -->
<div class="modal-overlay cve-modal" id="cveKeywordModal">
    <div class="modal-card" role="dialog" aria-modal="true" aria-label="<?= $this->te('Keywords verwalten') ?>">
        <div class="modal-header">
            <h2><?= $this->te('&#128269; Keyword-Watchlist verwalten') ?></h2>
            <button type="button" class="modal-close" onclick="document.getElementById('cveKeywordModal').classList.remove('open')" title="<?= $this->te('Schließen') ?>">&times;</button>
        </div>
        <div class="modal-body">
            <p style="font-size:.85rem;color:var(--gray-500);margin-bottom:1rem;">
                <?= $this->t('Trage Produktnamen oder Begriffe ein (z.&nbsp;B. <em>nginx</em>, <em>apache</em>, <em>windows</em>). Beim nächsten Refresh werden gezielt diese bei der NVD abgefragt und gespeichert.') ?>
            </p>
            <?php if ($hasKeywords): ?>
            <div style="display:flex;flex-wrap:wrap;gap:.5rem;margin-bottom:1.25rem;">
                <?php foreach ($keywords as $kw): ?>
                <span class="cve-keyword-chip">
                    <?= $this->e($kw['keyword']) ?>
                    <form action="/cve/keywords/<?= (int)$kw['id'] ?>" method="POST" style="display:inline;">
                        <input type="hidden" name="_csrf"   value="<?= $csrfToken ?>">
                        <input type="hidden" name="_method" value="DELETE">
                        <button type="button" class="cve-keyword-del" title="<?= $this->te('Entfernen') ?>"
                                onclick="itConfirmSubmit(this, 'Keyword entfernen?', 'Das Keyword „<?= $this->e(addslashes($kw['keyword'])) ?>“ wird aus der Watchlist entfernt.', 'Entfernen')">&times;</button>
                    </form>
                </span>
                <?php endforeach; ?>
            </div>
            <?php else: ?>
            <p style="font-size:.85rem;color:var(--gray-400);margin-bottom:1rem;"><?= $this->te('Noch keine Keywords eingetragen.') ?></p>
            <?php endif; ?>
            <form action="/cve/keywords" method="POST" style="display:flex;gap:.5rem;">
                <input type="hidden" name="_csrf" value="<?= $csrfToken ?>">
                <input type="text" name="keyword" class="form-control form-control-sm" placeholder="<?= $this->te('Keyword hinzufügen…') ?>" required autofocus>
                <button type="submit" class="btn btn-primary btn-sm"><?= $this->te('Hinzufügen') ?></button>
            </form>
        </div>
    </div>
</div>
<?php endif; ?>

<?= $this->partial('confirm_modal') ?>

<script>
function nvdSearch() {
    var v = (document.getElementById('nvdSearchInput').value || '').trim();
    var url = v
        ? 'https://nvd.nist.gov/vuln/search/results?form_type=Basic&results_type=overview&search_type=all&query=' + encodeURIComponent(v)
        : 'https://nvd.nist.gov/vuln/search';
    window.open(url, '_blank', 'noopener');
}

var CVE_BSI_ENABLED = <?= $bsiEnabled ? 'true' : 'false' ?>;

function doCveRefresh() {
    var btn  = document.getElementById('refreshBtn');
    var icon = document.getElementById('refreshIcon');
    var msg  = document.getElementById('cveRefreshMsg');
    if (!btn || btn.disabled) return;
    btn.disabled = true;
    icon.innerHTML = '<span class="cve-spin">&#8635;</span>';
    if (msg) msg.style.display = 'none';

    // Der NVD-Abruf kann je nach Watchlist/Rate-Limit lange dauern. Da die
    // BSI-Warnungen serverseitig ZUERST aktualisiert werden, laden wir nach
    // einem Timeout trotzdem neu, damit die deutschen Meldungen erscheinen –
    // der NVD-Abruf läuft serverseitig weiter.
    var ctrl    = new AbortController();
    var timedOut = false;
    var timer   = setTimeout(function () { timedOut = true; ctrl.abort(); }, 25000);

    fetch('/cve/refresh', {
        method: 'POST',
        signal: ctrl.signal,
        headers: {
            'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content,
            'X-Requested-With': 'XMLHttpRequest'
        }
    })
    .then(r => r.json())
    .then(data => {
        clearTimeout(timer);
        if (data.ok) { window.location.reload(); }
        else if (data.bsi_count !== null && data.bsi_count !== undefined) {
            // NVD fehlgeschlagen, aber BSI aktualisiert – neu laden zeigt die Warnungen.
            window.location.reload();
        } else { showRefreshError(data.error || 'Unbekannter Fehler'); }
    })
    .catch(() => {
        clearTimeout(timer);
        if (timedOut && CVE_BSI_ENABLED) {
            // BSI ist bereits aktualisiert; NVD läuft im Hintergrund weiter.
            showRefreshInfo('Die BSI-Warnungen wurden aktualisiert. Der NVD-Abruf läuft im Hintergrund weiter – die Seite wird neu geladen…');
            setTimeout(function () { window.location.reload(); }, 1800);
        } else if (timedOut) {
            showRefreshInfo('Der NVD-Abruf dauert länger und läuft im Hintergrund weiter. Lade die Seite in einigen Minuten erneut.');
            btn.disabled = false; icon.textContent = '⟳';
        } else {
            showRefreshError('Verbindungsfehler');
        }
    });

    function showRefreshError(text) {
        btn.disabled = false; icon.textContent = '⟳';
        if (!msg) return;
        msg.textContent = 'Aktualisierung fehlgeschlagen: ' + text;
        msg.style.cssText = 'display:block;margin-bottom:1rem;padding:.6rem 1rem;background:#fef2f2;border:1px solid #fca5a5;border-radius:.4rem;color:#dc2626;font-size:.83rem;';
    }
    function showRefreshInfo(text) {
        if (!msg) return;
        msg.textContent = text;
        msg.style.cssText = 'display:block;margin-bottom:1rem;padding:.6rem 1rem;background:#eff6ff;border:1px solid #bfdbfe;border-radius:.4rem;color:#1d4ed8;font-size:.83rem;';
    }
}

// Keyword-Modal per Backdrop-Klick schließen
document.getElementById('cveKeywordModal')?.addEventListener('click', function(e) {
    if (e.target === this) this.classList.remove('open');
});

// Detail-Aufklapp per Zeilen-Klick
document.addEventListener('click', function(e) {
    var row = e.target.closest('tr[data-cve]');
    if (!row) return;
    var id  = row.dataset.cve;
    var det = document.getElementById('cve-det-' + id);
    if (!det) return;
    var isOpen = det.classList.contains('open');
    document.querySelectorAll('.cve-detail-row.open').forEach(function(el) {
        el.classList.remove('open');
        el.previousElementSibling?.classList.remove('cve-open');
    });
    if (!isOpen) { det.classList.add('open'); row.classList.add('cve-open'); }
});
</script>