P1-1: Permission instructions now correctly reference Account →
Account Analytics → Read, matching Cloudflare's Radar API docs.
P1-2: Layer 3 DDoS endpoint fixed to use /summary/{dimension}
(protocol, vector) and parse summary_0 response format per API
contract. Returns protocol breakdown and attack vector percentages.
225 lines
7.2 KiB
JavaScript
225 lines
7.2 KiB
JavaScript
// Cloudflare Radar — Internet traffic anomalies and outages
|
|
// Requires a free Cloudflare API token (CLOUDFLARE_API_TOKEN).
|
|
// Get one at: https://dash.cloudflare.com/profile/api-tokens
|
|
// Create a custom token with Account → Account Analytics → Read permission.
|
|
//
|
|
// Monitors internet outages, traffic anomalies, and attack trends
|
|
// that correlate with conflict, censorship, and infrastructure disruption.
|
|
|
|
import { safeFetch } from '../utils/fetch.mjs';
|
|
import '../utils/env.mjs';
|
|
|
|
const RADAR_BASE = 'https://api.cloudflare.com/client/v4/radar';
|
|
|
|
// Countries of intelligence interest for internet monitoring
|
|
const WATCHLIST_COUNTRIES = [
|
|
'RU', 'UA', 'CN', 'IR', 'KP', 'SY', 'MM', 'ET', 'SD',
|
|
'YE', 'AF', 'IQ', 'LB', 'PS', 'TW', 'BY', 'VE', 'CU'
|
|
];
|
|
|
|
function getAuthHeaders() {
|
|
const token = process.env.CLOUDFLARE_API_TOKEN;
|
|
if (!token) return null;
|
|
return { Authorization: `Bearer ${token}` };
|
|
}
|
|
|
|
async function fetchAnnotations() {
|
|
const headers = getAuthHeaders();
|
|
if (!headers) return { error: 'no_credentials' };
|
|
|
|
// Cloudflare Radar Annotations — internet outages and government shutdowns
|
|
const url = `${RADAR_BASE}/annotations/outages?dateRange=30d&format=json`;
|
|
const data = await safeFetch(url, { timeout: 15000, headers });
|
|
|
|
if (data.error) return { error: data.error };
|
|
|
|
const annotations = data.result?.annotations || [];
|
|
return annotations.map(a => ({
|
|
id: a.id,
|
|
description: (a.description || '').substring(0, 500),
|
|
startDate: a.startDate,
|
|
endDate: a.endDate,
|
|
linkedUrl: a.linkedUrl || null,
|
|
scope: a.scope || null,
|
|
asns: a.asns || [],
|
|
locations: a.locations || [],
|
|
eventType: a.eventType || 'outage',
|
|
}));
|
|
}
|
|
|
|
async function fetchAttackSummary() {
|
|
const headers = getAuthHeaders();
|
|
if (!headers) return { error: 'no_credentials' };
|
|
|
|
// Layer 3 DDoS attack summaries by protocol and vector
|
|
// API requires a dimension: /summary/{dimension}
|
|
const [byProtocol, byVector] = await Promise.all([
|
|
safeFetch(`${RADAR_BASE}/attacks/layer3/summary/protocol?dateRange=7d&format=json`, { timeout: 15000, headers }),
|
|
safeFetch(`${RADAR_BASE}/attacks/layer3/summary/vector?dateRange=7d&format=json`, { timeout: 15000, headers }),
|
|
]);
|
|
|
|
const result = {};
|
|
|
|
if (!byProtocol.error && byProtocol.result) {
|
|
result.byProtocol = byProtocol.result.summary_0 || byProtocol.result;
|
|
}
|
|
if (!byVector.error && byVector.result) {
|
|
result.byVector = byVector.result.summary_0 || byVector.result;
|
|
}
|
|
|
|
if (!result.byProtocol && !result.byVector) {
|
|
return { error: byProtocol.error || byVector.error || 'No attack data returned' };
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function fetchTrafficAnomalies() {
|
|
const headers = getAuthHeaders();
|
|
if (!headers) return { error: 'no_credentials' };
|
|
|
|
// Traffic anomalies — significant deviations from normal patterns
|
|
const url = `${RADAR_BASE}/traffic_anomalies?dateRange=7d&format=json&limit=50`;
|
|
const data = await safeFetch(url, { timeout: 15000, headers });
|
|
|
|
if (data.error) return { error: data.error };
|
|
|
|
const anomalies = data.result?.trafficAnomalies || [];
|
|
return anomalies.map(a => ({
|
|
startDate: a.startDate,
|
|
endDate: a.endDate,
|
|
type: a.type || 'unknown',
|
|
status: a.status,
|
|
asnDetails: a.asnDetails || null,
|
|
locationDetails: a.locationDetails || null,
|
|
visibleInAllDataSources: a.visibleInAllDataSources || false,
|
|
}));
|
|
}
|
|
|
|
function buildSignals(outages, anomalies) {
|
|
const signals = [];
|
|
|
|
if (!Array.isArray(outages)) return signals;
|
|
|
|
// Check for outages in watchlist countries
|
|
const watchlistOutages = outages.filter(o => {
|
|
const locations = o.locations || [];
|
|
return locations.some(l => WATCHLIST_COUNTRIES.includes(l));
|
|
});
|
|
|
|
if (watchlistOutages.length > 0) {
|
|
const countries = [...new Set(watchlistOutages.flatMap(o => o.locations))].filter(l => WATCHLIST_COUNTRIES.includes(l));
|
|
signals.push({
|
|
severity: 'high',
|
|
signal: `Internet outages detected in ${countries.join(', ')} — possible government shutdown or infrastructure attack`,
|
|
});
|
|
}
|
|
|
|
// Multiple outages in same country = sustained disruption
|
|
const locationCounts = {};
|
|
for (const o of outages) {
|
|
for (const loc of (o.locations || [])) {
|
|
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
|
|
}
|
|
}
|
|
|
|
const repeated = Object.entries(locationCounts)
|
|
.filter(([, count]) => count >= 3)
|
|
.map(([loc]) => loc);
|
|
|
|
if (repeated.length > 0) {
|
|
signals.push({
|
|
severity: 'medium',
|
|
signal: `Sustained internet disruptions in ${repeated.join(', ')} — ${repeated.length} locations with 3+ outage events in 30 days`,
|
|
});
|
|
}
|
|
|
|
// Traffic anomalies
|
|
if (Array.isArray(anomalies) && anomalies.length > 10) {
|
|
signals.push({
|
|
severity: 'medium',
|
|
signal: `${anomalies.length} traffic anomalies detected globally in last 7 days — elevated internet instability`,
|
|
});
|
|
}
|
|
|
|
return signals;
|
|
}
|
|
|
|
export async function briefing() {
|
|
if (!process.env.CLOUDFLARE_API_TOKEN) {
|
|
return {
|
|
source: 'Cloudflare-Radar',
|
|
timestamp: new Date().toISOString(),
|
|
status: 'no_credentials',
|
|
message: 'Set CLOUDFLARE_API_TOKEN in .env. Get a free token at https://dash.cloudflare.com/profile/api-tokens with Account → Account Analytics → Read permission.',
|
|
};
|
|
}
|
|
|
|
const [outages, attacks, anomalies] = await Promise.all([
|
|
fetchAnnotations(),
|
|
fetchAttackSummary(),
|
|
fetchTrafficAnomalies(),
|
|
]);
|
|
|
|
// Handle complete failure
|
|
if (outages?.error && attacks?.error && anomalies?.error) {
|
|
return {
|
|
source: 'Cloudflare-Radar',
|
|
timestamp: new Date().toISOString(),
|
|
error: outages.error || attacks.error || anomalies.error,
|
|
};
|
|
}
|
|
|
|
const outageList = Array.isArray(outages) ? outages : [];
|
|
const anomalyList = Array.isArray(anomalies) ? anomalies : [];
|
|
|
|
// Separate active vs resolved outages
|
|
const now = new Date();
|
|
const activeOutages = outageList.filter(o => !o.endDate || new Date(o.endDate) > now);
|
|
const recentResolved = outageList.filter(o => o.endDate && new Date(o.endDate) <= now).slice(0, 10);
|
|
|
|
// Group outages by location
|
|
const outagesByLocation = {};
|
|
for (const o of outageList) {
|
|
for (const loc of (o.locations || ['unknown'])) {
|
|
if (!outagesByLocation[loc]) outagesByLocation[loc] = [];
|
|
outagesByLocation[loc].push(o);
|
|
}
|
|
}
|
|
|
|
const topAffectedLocations = Object.entries(outagesByLocation)
|
|
.sort((a, b) => b[1].length - a[1].length)
|
|
.slice(0, 15)
|
|
.map(([location, events]) => ({
|
|
location,
|
|
eventCount: events.length,
|
|
activeCount: events.filter(e => !e.endDate || new Date(e.endDate) > now).length,
|
|
}));
|
|
|
|
const signals = buildSignals(outageList, anomalyList);
|
|
|
|
return {
|
|
source: 'Cloudflare-Radar',
|
|
timestamp: new Date().toISOString(),
|
|
outages: {
|
|
total: outageList.length,
|
|
active: activeOutages.length,
|
|
activeEvents: activeOutages.slice(0, 20),
|
|
recentResolved: recentResolved,
|
|
topAffectedLocations,
|
|
},
|
|
anomalies: {
|
|
total: anomalyList.length,
|
|
events: anomalyList.slice(0, 20),
|
|
},
|
|
attacks: attacks?.error ? { error: attacks.error } : attacks,
|
|
signals,
|
|
};
|
|
}
|
|
|
|
// Run standalone
|
|
if (process.argv[1]?.endsWith('cloudflare-radar.mjs')) {
|
|
const data = await briefing();
|
|
console.log(JSON.stringify(data, null, 2));
|
|
}
|