alerts: new alerts

This commit is contained in:
Lopinosaurus 2026-03-03 14:07:31 +01:00
parent 51c2ac521e
commit 5bed00bd9a
7 changed files with 205 additions and 73 deletions

View File

@ -23,16 +23,19 @@ if [ -z "$MESSAGE" ] && [ -z "$HTML_FILE" ] && [ -z "$IMAGE_FILE" ]; then
exit 1
fi
# Build jq args
JQARGS=(--arg msg "$MESSAGE")
TMPDIR_PAYLOAD=$(mktemp -d)
trap 'rm -rf "$TMPDIR_PAYLOAD"' EXIT
HTML=""
# HTML
HTML_TMP="$TMPDIR_PAYLOAD/html.txt"
if [ -n "$HTML_FILE" ] && [ -f "$HTML_FILE" ]; then
HTML=$(cat "$HTML_FILE")
cp "$HTML_FILE" "$HTML_TMP"
else
printf '' > "$HTML_TMP"
fi
JQARGS+=(--arg html "$HTML")
IMAGE=""
# Image : encodée en base64 dans un fichier temporaire (évite "Argument list too long")
IMAGE_TMP="$TMPDIR_PAYLOAD/image.txt"
if [ -n "$IMAGE_FILE" ] && [ -f "$IMAGE_FILE" ]; then
case "${IMAGE_FILE##*.}" in
jpg|jpeg) MIME="image/jpeg" ;;
@ -41,14 +44,21 @@ if [ -n "$IMAGE_FILE" ] && [ -f "$IMAGE_FILE" ]; then
webp) MIME="image/webp" ;;
*) MIME="image/png" ;;
esac
IMAGE="data:${MIME};base64,$(base64 "$IMAGE_FILE" | tr -d '\n')"
printf 'data:%s;base64,' "$MIME" > "$IMAGE_TMP"
base64 -i "$IMAGE_FILE" | tr -d '\n' >> "$IMAGE_TMP"
else
printf '' > "$IMAGE_TMP"
fi
JQARGS+=(--arg image "$IMAGE")
PAYLOAD=$(jq -n "${JQARGS[@]}" '{"message":$msg,"html":$html,"image":$image}')
PAYLOAD_TMP="$TMPDIR_PAYLOAD/payload.json"
jq -n \
--arg msg "$MESSAGE" \
--rawfile html "$HTML_TMP" \
--rawfile image "$IMAGE_TMP" \
'{"message":$msg,"html":$html,"image":$image}' > "$PAYLOAD_TMP"
curl -sf -X POST "http://localhost:${PORT}/api/alert" \
-H "Content-Type: application/json" \
-d "$PAYLOAD" \
-d "@$PAYLOAD_TMP" \
&& echo "✓ Alerte déclenchée : $MESSAGE" \
|| echo "✗ Serveur non disponible sur le port ${PORT}"

BIN
news.mp3 Normal file

Binary file not shown.

View File

@ -56,6 +56,38 @@ const alertIconEl = document.getElementById('alert-icon');
const alertImageEl = document.getElementById('alert-image');
const infoAlert = document.getElementById('info-alert');
// ── Notification (ANSSI / geo) ────────────────────────────────────────────────
const notifOverlay = document.getElementById('notif-overlay');
const notifMessage = document.getElementById('notif-message');
const notifBarInner = document.getElementById('notif-bar-inner');
const softAlarmAudio = new Audio('/soft_alarm.mp3');
let notifTimer = null;
function showNotif(message) {
notifMessage.textContent = message;
// Re-déclencher l'animation de la barre
notifBarInner.style.animation = 'none';
void notifBarInner.offsetWidth;
notifBarInner.style.animation = '';
notifOverlay.style.animation = 'none';
void notifOverlay.offsetWidth;
notifOverlay.style.animation = '';
notifOverlay.classList.remove('hidden');
softAlarmAudio.currentTime = 0;
softAlarmAudio.play().catch(err => console.error('soft_alarm:', err));
if (notifTimer) clearTimeout(notifTimer);
notifTimer = setTimeout(() => {
notifOverlay.classList.add('hidden');
notifTimer = null;
}, 10_000);
}
// ── Alarm sound ───────────────────────────────────────────────────────────────
const alarmAudio = new Audio('/alert.mp3');
@ -141,6 +173,7 @@ function updateClock() {
const anssiList = document.getElementById('anssi-list');
const anssiStatus = document.getElementById('anssi-status');
let seenAnssiLinks = null;
async function loadAnssi() {
anssiStatus.textContent = '...';
@ -156,6 +189,17 @@ async function loadAnssi() {
return;
}
const currentAnssiLinks = new Set(items.map(i => i.link));
if (seenAnssiLinks === null) {
seenAnssiLinks = currentAnssiLinks;
} else {
const newItems = items.filter(i => !seenAnssiLinks.has(i.link));
if (newItems.length) {
showNotif(`Nouveau bulletin ANSSI : ${newItems[0].title}`);
seenAnssiLinks = currentAnssiLinks;
}
}
items.forEach(item => {
const li = document.createElement('li');
li.className = 'anssi-item';
@ -180,66 +224,58 @@ async function loadAnssi() {
}
}
// ── CVE feed ──────────────────────────────────────────────────────────────────
// ── Géopolitique feed ─────────────────────────────────────────────────────────
const cveList = document.getElementById('cve-list');
const cveStatus = document.getElementById('cve-status');
const geoList = document.getElementById('geo-list');
const geoStatus = document.getElementById('geo-status');
let seenGeoLinks = null;
function severityClass(cvss) {
if (cvss === undefined || cvss === null) return 'unknown';
const score = parseFloat(cvss);
if (isNaN(score)) return 'unknown';
if (score >= 9.0) return 'critical';
if (score >= 7.0) return 'high';
if (score >= 4.0) return 'medium';
return 'low';
}
function severityLabel(cvss) {
const cls = severityClass(cvss);
const map = { critical: 'CRITIQUE', high: 'ÉLEVÉ', medium: 'MOYEN', low: 'FAIBLE', unknown: '???' };
return map[cls];
}
async function loadCve() {
cveStatus.textContent = '...';
cveStatus.className = 'widget-status';
async function loadGeo() {
geoStatus.textContent = '...';
geoStatus.className = 'widget-status';
try {
const resp = await fetch('/api/feeds/cve');
const resp = await fetch('/api/feeds/geo');
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const items = await resp.json();
cveList.innerHTML = '';
geoList.innerHTML = '';
if (!items.length) {
cveList.innerHTML = '<li class="feed-loading">Aucune CVE</li>';
geoList.innerHTML = '<li class="feed-loading">Aucune actualité</li>';
return;
}
items.forEach(item => {
const cvss = item.cvss ?? item['cvss-score'] ?? null;
const cls = severityClass(cvss);
const label = severityLabel(cvss);
const desc = item.summary || item.description || '';
const cveId = item.id || item['cve-id'] || 'CVE-????-????';
const currentLinks = new Set(items.map(i => i.link));
if (seenGeoLinks === null) {
// Premier chargement : on mémorise sans jouer
seenGeoLinks = currentLinks;
} else {
const hasNew = items.some(i => !seenGeoLinks.has(i.link));
if (hasNew) {
showNotif('Nouvelle actualité géopolitique');
seenGeoLinks = currentLinks;
}
}
items.forEach(item => {
const li = document.createElement('li');
li.className = 'cve-item';
li.className = 'anssi-item';
const date = item.pubDate
? new Date(item.pubDate).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })
: '';
li.innerHTML = `
<span class="cve-badge badge-${cls}">${escapeHtml(label)}</span>
<div class="cve-info">
<div class="cve-id">${escapeHtml(cveId)}</div>
<div class="cve-desc" title="${escapeAttr(desc)}">${escapeHtml(desc)}</div>
</div>
<a href="${escapeAttr(item.link)}" target="_blank" rel="noopener noreferrer">${escapeHtml(item.title)}</a>
${date ? `<span class="anssi-date">${escapeHtml(item.source ? item.source + ' · ' : '')}${escapeHtml(date)}</span>` : ''}
`;
cveList.appendChild(li);
geoList.appendChild(li);
});
cveStatus.textContent = `${items.length} CVEs`;
cveStatus.className = 'widget-status ok';
geoStatus.textContent = `${items.length} articles`;
geoStatus.className = 'widget-status ok';
} catch (err) {
cveStatus.textContent = 'ERREUR';
cveStatus.className = 'widget-status err';
cveList.innerHTML = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
geoStatus.textContent = 'ERREUR';
geoStatus.className = 'widget-status err';
geoList.innerHTML = `<li class="feed-loading">Erreur : ${escapeHtml(err.message)}</li>`;
}
}
@ -253,9 +289,9 @@ function init() {
setInterval(updateClock, 1000);
loadAnssi();
loadCve();
loadGeo();
setInterval(loadAnssi, 5 * 60 * 1000);
setInterval(loadCve, 5 * 60 * 1000);
setInterval(loadGeo, 5 * 60 * 1000);
}
// ── Helpers ───────────────────────────────────────────────────────────────────

View File

@ -8,6 +8,15 @@
</head>
<body>
<!-- Notification overlay (ANSSI / geo) -->
<div id="notif-overlay" class="hidden">
<div id="notif-content">
<div id="notif-icon">&#9432;</div>
<div id="notif-message"></div>
<div id="notif-bar"><div id="notif-bar-inner"></div></div>
</div>
</div>
<!-- Alert overlay -->
<div id="alert-overlay" class="hidden">
<div id="alert-content">
@ -59,14 +68,14 @@
</div>
</section>
<!-- Widget 3: CVEs -->
<section class="widget" id="widget-cve">
<!-- Widget 3: Géopolitique -->
<section class="widget" id="widget-geo">
<div class="widget-header">
<span class="widget-title">DERNIÈRES CVEs</span>
<span class="widget-status" id="cve-status">...</span>
<span class="widget-title">SITUATION GÉOPOLITIQUE</span>
<span class="widget-status" id="geo-status">...</span>
</div>
<div class="widget-body">
<ul id="cve-list" class="feed-list">
<ul id="geo-list" class="feed-list">
<li class="feed-loading">Chargement...</li>
</ul>
</div>

View File

@ -258,6 +258,68 @@ main.grid {
letter-spacing: 1px;
}
/* ── Notification overlay ────────────────────────────────────────────────── */
#notif-overlay {
position: fixed;
inset: 0;
z-index: 9998;
background: rgba(180, 100, 0, 0.88);
display: flex;
align-items: center;
justify-content: center;
animation: flash-notif 1s ease-in-out infinite;
}
#notif-overlay.hidden { display: none; }
@keyframes flash-notif {
0% { background: rgba(180, 100, 0, 0.88); }
50% { background: rgba(90, 50, 0, 0.95); }
100% { background: rgba(180, 100, 0, 0.88); }
}
#notif-content {
text-align: center;
max-width: 70vw;
padding: 40px;
}
#notif-icon {
font-size: 60px;
color: #fff;
margin-bottom: 16px;
}
#notif-message {
font-size: 24px;
color: #fff;
font-weight: bold;
letter-spacing: 3px;
text-transform: uppercase;
line-height: 1.4;
}
#notif-bar {
margin-top: 24px;
height: 4px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
overflow: hidden;
}
#notif-bar-inner {
height: 100%;
background: #fff;
width: 100%;
transform-origin: left;
animation: notif-countdown 10s linear forwards;
}
@keyframes notif-countdown {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
/* ── Alert overlay ───────────────────────────────────────────────────────── */
#alert-overlay {
position: fixed;

View File

@ -44,6 +44,8 @@ wss.on('connection', ws => {
// ── Routes ──────────────────────────────────────────────────────────────────
app.get('/alert.mp3', (_req, res) => res.sendFile(path.resolve('alert.mp3')));
app.get('/news.mp3', (_req, res) => res.sendFile(path.resolve('news.mp3')));
app.get('/soft_alarm.mp3', (_req, res) => res.sendFile(path.resolve('soft_alarm.mp3')));
// Proxy: strip X-Frame-Options and CSP, forward any URL
app.get('/proxy', async (req, res) => {
@ -96,29 +98,42 @@ app.get('/api/feeds/anssi', async (req, res) => {
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(xml);
const items = parsed?.rss?.channel?.item || [];
const entries = (Array.isArray(items) ? items : [items]).map(item => ({
const entries = (Array.isArray(items) ? items : [items])
.map(item => ({
title: item.title || '',
link: item.link || '',
pubDate: item.pubDate || '',
description: item.description || ''
}));
}))
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate));
res.json(entries);
} catch (err) {
res.status(502).json({ error: 'Feed fetch failed', detail: err.message });
}
});
// Last 10 CVEs from CIRCL
app.get('/api/feeds/cve', async (req, res) => {
// Géopolitique — Google News RSS (conflits, cyberattaques, Ukraine, Iran…)
app.get('/api/feeds/geo', async (req, res) => {
const query = encodeURIComponent('Ukraine OR Iran OR "Moyen-Orient" OR cyberattaque OR guerre');
const url = `https://news.google.com/rss/search?q=${query}&hl=fr&gl=FR&ceid=FR:fr`;
try {
const response = await fetch('https://cve.circl.lu/api/last/10', {
const response = await fetch(url, {
headers: { 'User-Agent': 'CyberDashboard/1.0' },
timeout: 10000
});
const data = await response.json();
res.json(data);
const xml = await response.text();
const parser = new XMLParser({ ignoreAttributes: false });
const parsed = parser.parse(xml);
const items = parsed?.rss?.channel?.item || [];
const entries = (Array.isArray(items) ? items : [items]).slice(0, 15).map(item => ({
title: item.title || '',
link: item.link || '',
pubDate: item.pubDate || '',
source: item.source?.['#text'] || item.source || ''
}));
res.json(entries);
} catch (err) {
res.status(502).json({ error: 'CVE fetch failed', detail: err.message });
res.status(502).json({ error: 'Geo feed fetch failed', detail: err.message });
}
});

BIN
soft_alarm.mp3 Normal file

Binary file not shown.