Compare commits

..

No commits in common. "92b203cbbc532fb7bed1745bbd418c0e068e7c7d" and "dc6505a2f2c5123bce3903af715280ac4ce8a2f3" have entirely different histories.

3 changed files with 4 additions and 281 deletions

View File

@ -1,21 +1,3 @@
secubox-toolbox (2.6.18-1~bookworm1) bookworm; urgency=medium
* Cartographie donut-bubble view + geography rollups (#553).
- social.py fetch_graph: nodes gain country_iso / country_flag /
continent / tier ; new by_country, by_continent (flags + per-tier
breakdown) and by_tier aggregations (additive, read-time). stats
gains total_countries / total_continents. IP/ASN intentionally
NOT used as a graph dimension. Helpers _flag_emoji / _continent_of
/ _tracker_tier (no GeoIP/publicsuffix dep).
- social.js: new "🍩 Donuts" view (default, ⇄ "👁️ Œil" toggle) —
a d3.pack of continent backdrop bubbles, each country a donut sized
by tracking impact (hits), ring split by severity tier, flag in the
hole ; tier legend ; click → country summary in the detail panel.
Eye force-graph kept as one-click fallback ; node detail now fills
the country flag.
-- Gerald KERMA <devel@cybermind.fr> Sat, 13 Jun 2026 14:30:00 +0200
secubox-toolbox (2.6.17-1~bookworm1) bookworm; urgency=medium
* Social correlation: domain-rollup + history + target↔tracker (#549).

View File

@ -708,58 +708,6 @@ def _registrable_domain(host: str) -> str:
return last_two
# ── geography helpers (#553) : flag + continent rollup, no IP/ASN ──
def _flag_emoji(iso2: Optional[str]) -> str:
"""ISO-3166 alpha-2 → flag emoji (regional indicator pair). '' if unknown."""
if not iso2 or len(iso2) != 2 or not iso2.isalpha():
return ""
base = 0x1F1E6
return "".join(chr(base + (ord(ch) - ord("A"))) for ch in iso2.upper())
# Compact ISO-3166 alpha-2 → continent map (covers the countries a French
# civic kiosk realistically surfaces ; unknowns fall back to "Autre").
_CONTINENT = {
# Europe
"FR": "Europe", "DE": "Europe", "GB": "Europe", "UK": "Europe", "IE": "Europe",
"NL": "Europe", "BE": "Europe", "LU": "Europe", "ES": "Europe", "PT": "Europe",
"IT": "Europe", "CH": "Europe", "AT": "Europe", "SE": "Europe", "NO": "Europe",
"FI": "Europe", "DK": "Europe", "PL": "Europe", "CZ": "Europe", "RO": "Europe",
"BG": "Europe", "GR": "Europe", "HU": "Europe", "SK": "Europe", "HR": "Europe",
"RS": "Europe", "UA": "Europe", "RU": "Europe", "IS": "Europe", "EE": "Europe",
"LV": "Europe", "LT": "Europe", "SI": "Europe",
# Americas
"US": "Amériques", "CA": "Amériques", "MX": "Amériques", "BR": "Amériques",
"AR": "Amériques", "CL": "Amériques", "CO": "Amériques", "PE": "Amériques",
# Asia
"CN": "Asie", "JP": "Asie", "KR": "Asie", "IN": "Asie", "SG": "Asie",
"HK": "Asie", "TW": "Asie", "TH": "Asie", "ID": "Asie", "VN": "Asie",
"IL": "Asie", "TR": "Asie", "AE": "Asie", "SA": "Asie",
# Africa
"ZA": "Afrique", "EG": "Afrique", "MA": "Afrique", "DZ": "Afrique",
"TN": "Afrique", "NG": "Afrique", "KE": "Afrique", "SN": "Afrique",
# Oceania
"AU": "Océanie", "NZ": "Océanie",
}
def _continent_of(iso2: Optional[str]) -> str:
if not iso2:
return "Inconnu"
return _CONTINENT.get(iso2.upper(), "Autre")
def _tracker_tier(opgrade, antibot, cdn) -> str:
"""Severity tier used for the donut breakdown (highest wins)."""
if opgrade:
return "opgrade"
if antibot:
return "antibot"
if cdn:
return "cdn"
return "other"
def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"""Return the per-client graph JSON contract.
@ -779,8 +727,8 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
# can colour/label by edge-network vendor.
for r in c.execute(
"SELECT n.tracker_domain, n.hits, n.first_seen, n.last_seen, "
"n.sites_jsonl, n.country_iso, m.cdn_vendor, m.cache_status, "
"m.antibot_vendor, m.opgrade_vendor, m.opgrade_category "
"n.sites_jsonl, m.cdn_vendor, m.cache_status, m.antibot_vendor, "
"m.opgrade_vendor, m.opgrade_category "
"FROM social_nodes n "
"LEFT JOIN social_host_meta m ON m.tracker_domain = n.tracker_domain "
"WHERE n.client_mac_hash = ? AND n.last_seen >= ? "
@ -791,7 +739,6 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
sites = json.loads(r["sites_jsonl"])
except Exception:
sites = []
cc = (r["country_iso"] or "").upper() or None
out["nodes"].append(
{
"id": r["tracker_domain"],
@ -801,11 +748,6 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
"sites": sites,
"first_seen": r["first_seen"],
"last_seen": r["last_seen"],
"country_iso": cc,
"country_flag": _flag_emoji(cc),
"continent": _continent_of(cc),
"tier": _tracker_tier(r["opgrade_vendor"], r["antibot_vendor"],
r["cdn_vendor"]),
"cdn_vendor": r["cdn_vendor"],
"cache_status": r["cache_status"],
"antibot_vendor": r["antibot_vendor"],
@ -929,52 +871,10 @@ def fetch_graph(mac_hash: str, since_seconds: int = 86400) -> Dict:
})
out["history"] = history
# (d) #553 geography + tier rollups for the donut-bubble view.
_TIERS = ("opgrade", "antibot", "cdn", "other")
tier_tot = {t: 0 for t in _TIERS}
_ctry: Dict[str, dict] = {}
_cont: Dict[str, dict] = {}
for n in out["nodes"]:
tier = n["tier"]; hits = n["hits"] or 0
tier_tot[tier] += hits
cc = n["country_iso"] or "??"
ct = _ctry.setdefault(cc, {
"country_iso": None if cc == "??" else cc,
"flag": n["country_flag"], "continent": n["continent"],
"tracker_count": 0, "hits": 0,
"tiers": {t: 0 for t in _TIERS}, "_sites": set(),
})
ct["tracker_count"] += 1; ct["hits"] += hits
ct["tiers"][tier] += hits; ct["_sites"].update(n["sites"])
co = _cont.setdefault(n["continent"], {
"continent": n["continent"], "country_count_set": set(),
"tracker_count": 0, "hits": 0, "tiers": {t: 0 for t in _TIERS},
})
co["country_count_set"].add(cc)
co["tracker_count"] += 1; co["hits"] += hits
co["tiers"][tier] += hits
by_country = sorted(
({"country_iso": v["country_iso"], "flag": v["flag"],
"continent": v["continent"], "tracker_count": v["tracker_count"],
"hits": v["hits"], "sites_count": len(v["_sites"]),
"tiers": v["tiers"]} for v in _ctry.values()),
key=lambda x: -x["hits"])
by_continent = sorted(
({"continent": v["continent"],
"country_count": len(v["country_count_set"]),
"tracker_count": v["tracker_count"], "hits": v["hits"],
"tiers": v["tiers"]} for v in _cont.values()),
key=lambda x: -x["hits"])
out["by_country"] = by_country
out["by_continent"] = by_continent
out["by_tier"] = tier_tot
out["stats"] = {
"total_trackers": (stats_row["total_trackers"] or 0) if stats_row else 0,
"total_sites": sites_count,
"total_domains": len(by_domain),
"total_countries": len([c for c in by_country if c["country_iso"]]),
"total_continents": len([c for c in by_continent if c["continent"] not in ("Inconnu",)]),
"first_seen": stats_row["first_seen"] if stats_row else None,
"last_seen": stats_row["last_seen"] if stats_row else None,
"antibot_sites": len({a["src_site"] for a in antibot}),

View File

@ -70,54 +70,6 @@
// ─── graph state ───
let simulation = null;
let lastGraph = null;
let view = 'donuts'; // #553 : 'donuts' (default) | 'eye'
// Severity-tier palette shared by the donut view + legend.
const TIER = {
opgrade: { c: 'var(--void-purple)', label: '📡 opérateur' },
antibot: { c: 'var(--cinnabar)', label: '🤖 anti-bot' },
cdn: { c: 'var(--cyber-cyan)', label: '☁ CDN' },
other: { c: 'var(--gold-hermetic)', label: '• autre' },
};
const TIER_ORDER = ['opgrade', 'antibot', 'cdn', 'other'];
function draw(graph) {
if (!graph) return;
if (view === 'donuts') renderDonuts(graph);
else render(graph);
}
// Inject the view toggle (🍩 donuts ⇄ 👁️ œil) once, above the svg.
function ensureToggle() {
if (document.getElementById('view-toggle') || !svgEl) return;
const bar = document.createElement('div');
bar.id = 'view-toggle';
bar.style.cssText = 'display:flex;gap:.4rem;justify-content:center;margin:.4rem 0';
const mk = (id, txt) => {
const b = document.createElement('button');
b.type = 'button'; b.dataset.view = id; b.textContent = txt;
b.style.cssText = 'cursor:pointer;border:1px solid var(--void-purple,#6e40c9);'
+ 'background:transparent;color:var(--text-primary,#e8e6d9);border-radius:6px;'
+ 'padding:.35rem .8rem;font:inherit';
b.addEventListener('click', () => {
view = id; syncToggle();
draw(lastGraph);
});
return b;
};
bar.appendChild(mk('donuts', '🍩 Donuts'));
bar.appendChild(mk('eye', '👁️ Œil'));
svgEl.parentNode.insertBefore(bar, svgEl);
syncToggle();
}
function syncToggle() {
document.querySelectorAll('#view-toggle button').forEach((b) => {
const on = b.dataset.view === view;
b.style.background = on ? 'var(--void-purple,#6e40c9)' : 'transparent';
b.style.color = on ? '#0a0a0f' : 'var(--text-primary,#e8e6d9)';
});
}
function svgSize() {
// Measure actual rendered size so the force center scales with the
@ -178,8 +130,6 @@
sites: n.sites,
first_seen: n.first_seen,
last_seen: n.last_seen,
country_iso: n.country_iso || null,
country_flag: n.country_flag || '',
cdn_vendor: n.cdn_vendor || null,
cache_status: n.cache_status || null,
antibot_vendor: n.antibot_vendor || null,
@ -389,118 +339,11 @@
});
}
// ─── #553 donut-bubble view ───
// Continents are faint backdrop bubbles ; each country is a donut sized
// by tracking impact (hits), its ring split by severity tier, with the
// flag in the centre. No IP/ASN — geography + impact + tier only.
function renderDonuts(graph) {
clearGraph();
const { W, H } = svgSize();
svg.attr('viewBox', `0 0 ${W} ${H}`);
bind('total_trackers', graph.stats.total_trackers || 0);
bind('total_sites', graph.stats.total_sites || 0);
updateAntibotTile(graph.stats.antibot_sites || 0, graph.stats.antibot_vendors || []);
updateOpgradeTile(graph.stats.opgrade_sites || 0, graph.stats.opgrade_vendors || []);
const countries = graph.by_country || [];
if (!countries.length) return;
// hierarchy : root → continent → country(leaf, value=hits)
const byCont = new Map();
for (const c of countries) {
const k = c.continent || 'Autre';
if (!byCont.has(k)) byCont.set(k, []);
byCont.get(k).push(c);
}
const root = {
name: 'root',
children: [...byCont.entries()].map(([cont, list]) => ({
name: cont, continent: true,
children: list.map(c => ({
leaf: true, name: c.country_iso || '??', flag: c.flag || '🏴',
value: Math.max(c.hits || 0, 1), tiers: c.tiers || {},
tracker_count: c.tracker_count || 0,
})),
})),
};
const h = d3.hierarchy(root).sum(d => d.value || 0)
.sort((a, b) => (b.value || 0) - (a.value || 0));
d3.pack().size([W, H]).padding(d => d.depth === 1 ? 14 : 4)(h);
const content = svg.append('g').attr('class', 'content');
// continent backdrop bubbles + labels
content.append('g').selectAll('circle.cont')
.data(h.descendants().filter(d => d.depth === 1)).join('circle')
.attr('class', 'cont')
.attr('cx', d => d.x).attr('cy', d => d.y).attr('r', d => d.r)
.attr('fill', 'rgba(110,64,201,0.06)')
.attr('stroke', 'rgba(110,64,201,0.45)').attr('stroke-dasharray', '3,3');
content.append('g').selectAll('text.cont')
.data(h.descendants().filter(d => d.depth === 1)).join('text')
.attr('class', 'cont').attr('x', d => d.x).attr('y', d => d.y - d.r + 14)
.attr('text-anchor', 'middle').attr('fill', 'var(--void-purple,#9e76ff)')
.attr('font-size', 12).attr('font-weight', 'bold')
.text(d => '🌍 ' + d.data.name);
// country donut bubbles
const pie = d3.pie().sort(null).value(d => d[1]);
const leaves = content.append('g').selectAll('g.country')
.data(h.leaves()).join('g')
.attr('class', 'country node')
.attr('transform', d => `translate(${d.x},${d.y})`)
.style('cursor', 'pointer')
.on('click', (ev, d) => focusCountry(d.data));
leaves.each(function (d) {
const g = d3.select(this);
const r = d.r;
const inner = Math.max(r * 0.5, r - 7);
const arc = d3.arc().innerRadius(inner).outerRadius(r);
const data = TIER_ORDER.map(k => [k, (d.data.tiers && d.data.tiers[k]) || 0])
.filter(e => e[1] > 0);
const slices = data.length ? pie(data) : pie([['other', 1]]);
g.selectAll('path').data(slices).join('path')
.attr('d', arc)
.attr('fill', s => (TIER[s.data[0]] || TIER.other).c)
.attr('stroke', '#0a0a0f').attr('stroke-width', 0.6);
// flag (or ISO) in the hole
g.append('text').attr('text-anchor', 'middle').attr('dy', '.35em')
.attr('font-size', Math.max(9, Math.min(inner * 1.1, 26)))
.text(d.data.flag || d.data.name);
});
// tier legend (bottom-left)
const lg = content.append('g').attr('transform', `translate(12,${H - 12 - TIER_ORDER.length * 16})`);
TIER_ORDER.forEach((k, i) => {
const row = lg.append('g').attr('transform', `translate(0,${i * 16})`);
row.append('rect').attr('width', 10).attr('height', 10).attr('rx', 2)
.attr('fill', TIER[k].c);
row.append('text').attr('x', 15).attr('y', 9).attr('font-size', 10)
.attr('fill', 'var(--text-muted,#6b6b7a)').text(TIER[k].label);
});
}
// Donut country click → reuse the detail panel with a country summary.
function focusCountry(c) {
if (!ndEl) return;
bind('nd_domain', (c.flag || '') + ' ' + (c.name || '?'));
bind('nd_country', (c.flag || '') + ' ' + (c.name || '—'));
bind('nd_asn', '—');
const tier = TIER_ORDER.filter(k => (c.tiers || {})[k])
.map(k => `${TIER[k].label}:${c.tiers[k]}`).join(' · ') || '—';
bind('nd_cdn', tier);
bind('nd_antibot', (c.tiers && c.tiers.antibot) ? '🤖 ' + c.tiers.antibot : '—');
bind('nd_opgrade', (c.tiers && c.tiers.opgrade) ? '📡 ' + c.tiers.opgrade : '—');
bind('nd_sites', (c.tracker_count || 0) + ' traceurs');
bind('nd_first_seen', '—'); bind('nd_last_seen', '—');
ndEl.hidden = false;
}
// ─── focus / detail panel ───
function focusNode(node, linkSel) {
if (node.kind !== 'tracker') { ndEl.hidden = true; return; }
bind('nd_domain', node.label);
bind('nd_country', node.country_flag ? (node.country_flag + ' ' + (node.country_iso || '')) : '—');
bind('nd_country', '—'); // Phase C dependency (GeoIP)
bind('nd_asn', '—');
bind('nd_cdn', node.cdn_vendor ? (node.cdn_vendor + (node.cache_status ? ' · ' + node.cache_status : '')) : '—');
bind('nd_antibot', node.antibot_vendor ? ('🤖 ' + node.antibot_vendor) : '—');
@ -570,9 +413,7 @@
const r = await fetch(`/social/graph/${encodeURIComponent(token)}?since=86400`);
if (!r.ok) throw new Error('http ' + r.status);
const g = await r.json();
lastGraph = g;
ensureToggle();
draw(g);
render(g);
} catch (e) {
console.error('[social] fetch failed', e);
}