<!-- Demo: attribute-disclosure attack on a k-anonymous table, then l-Diversity and t-Closeness fixes -->
<div id="app">
<div class="controls">
<button id="btn-attack" type="button">Attack</button>
<button id="btn-ldiv" type="button">Apply l-Diversity</button>
<button id="btn-tclos" type="button">Apply t-Closeness</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status-bar" id="status"></div>
<div class="legend">
<span class="badge badge-anon">k=3 k-anonymous</span>
<span class="badge badge-leak" id="badge-leak" style="display:none">Attribute leak!</span>
<span class="badge badge-ldiv" id="badge-ldiv" style="display:none">l=2 l-diverse</span>
<span class="badge badge-tclos" id="badge-tclos" style="display:none">t=0.2 t-close</span>
</div>
<div class="table-wrap">
<table id="tbl">
<thead>
<tr>
<th>Age</th>
<th>ZIP</th>
<th>Sex</th>
<th class="sensitive">Diagnosis (sensitive)</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="footnote" id="footnote"></div>
</div>
/* Styles for the privacy demo table */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; font-size: 14px; }
#app { padding: 0 2px; }
.controls { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.status-bar { font-size: .88rem; font-weight: 600; min-height: 1.3em;
margin-bottom: .35rem; color: #555; }
.status-bar.attack { color: #c0392b; }
.status-bar.ok { color: #0a7d33; }
.legend { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
.badge { font-size: .75rem; font-weight: 700; padding: .15rem .5rem;
border-radius: 12px; border: 1px solid transparent; }
.badge-anon { background: #dce8f7; color: #1d3557; border-color: #b8d0ef; }
.badge-leak { background: #fde8e8; color: #a31515; border-color: #f5b8b8; }
.badge-ldiv { background: #e5f5e8; color: #0a5c1e; border-color: #a8d8b4; }
.badge-tclos { background: #f0eaff; color: #4a1ea0; border-color: #c5aef5; }
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; min-width: 320px; }
th, td { padding: .3rem .55rem; border: 1px solid #d0d7de; text-align: left; }
th { background: #f0f4f8; font-weight: 700; font-size: .82rem; }
th.sensitive { background: #fff3cd; color: #7a5000; }
td.sensitive { font-style: italic; }
tr.group-a td { background: #f7f9fc; }
tr.group-b td { background: #fafcff; }
tr.highlight td { background: #ffe8e8 !important; }
tr.highlight td.sensitive { font-weight: 700; color: #a31515; }
tr.changed td { background: #e8f9ec !important; }
.footnote { font-size: .78rem; color: #777; margin-top: .5rem; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Dataset: a k-anonymous (k=3) patient table with two equivalence classes */
/* Each group has identical quasi-identifiers (age, zip, sex) — k-anonymity holds */
const ORIGINAL = [
/* Group A: age 20-30, zip 1300*, male — all diagnosed with flu (homogeneous!) */
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
/* Group B: age 40-50, zip 2100*, female — all diagnosed with cancer (homogeneous!) */
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
];
/* l-Diverse version: at least 2 distinct diagnoses per group */
const LDIV = [
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a', changed: false },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Diabetes', group: 'a', changed: true },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Hypertension', group: 'a', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b', changed: false },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Flu', group: 'b', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Diabetes', group: 'b', changed: true },
];
/* t-Close version: local distribution within t=0.3 of the global distribution */
/* Global distribution across all 6 records */
const GLOBAL_DIST = { 'Flu': 2/6, 'Cancer': 2/6, 'Diabetes': 1/6, 'Hypertension': 1/6 };
const TCLOS = [
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a', changed: false },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Cancer', group: 'a', changed: true },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Diabetes', group: 'a', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b', changed: false },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Flu', group: 'b', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Hypertension', group: 'b', changed: true },
];
/* Application state */
let currentRows = ORIGINAL.map(r => ({ ...r, highlight: false, changed: false }));
let mode = 'original';
function countDist(rows) {
/* Count how many times each diagnosis appears in a group */
const map = {};
for (const r of rows) map[r.diagnosis] = (map[r.diagnosis] || 0) + 1;
return map;
}
function tvDistance(P, Q) {
/* Total variation distance = half the L1 distance between distributions */
const keys = new Set([...Object.keys(P), ...Object.keys(Q)]);
let sum = 0;
for (const k of keys) sum += Math.abs((P[k] || 0) - (Q[k] || 0));
return sum / 2;
}
function renderTable(rows) {
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
for (const r of rows) {
const tr = document.createElement('tr');
tr.className = r.highlight ? 'highlight' : r.changed ? 'changed' : ('group-' + r.group);
['age', 'zip', 'sex', 'diagnosis'].forEach(k => {
const td = document.createElement('td');
td.textContent = r[k];
if (k === 'diagnosis') td.className = 'sensitive';
tr.appendChild(td);
});
tbody.appendChild(tr);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status-bar ' + (cls || '');
}
function showBadge(id, show) {
document.getElementById(id).style.display = show ? '' : 'none';
}
function attack() {
/* Find groups where all records share the same sensitive value */
const rows = ORIGINAL.map(r => ({ ...r }));
const groups = {};
for (const r of rows) { if (!groups[r.group]) groups[r.group] = []; groups[r.group].push(r); }
let leaked = 0;
for (const g of Object.values(groups)) {
const dist = countDist(g);
const vals = Object.keys(dist);
if (vals.length === 1) {
/* All records in this group have the same diagnosis — an attacker learns it */
g.forEach(r => r.highlight = true);
leaked++;
}
}
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', leaked > 0);
showBadge('badge-ldiv', false);
showBadge('badge-tclos', false);
if (leaked > 0) {
setStatus(i18nFmt('%n% group(s) have a single sensitive value — the attacker knows your diagnosis!', { n: leaked }), 'attack');
document.getElementById('footnote').textContent = 'Red rows: every person in these groups shares the same diagnosis. k-anonymity protects identity but not the sensitive attribute.';
} else {
setStatus('No homogeneous groups found.', 'ok');
}
}
function applyLDiv() {
/* Show the l-diverse version of the table */
const rows = LDIV.map(r => ({ ...r, highlight: false }));
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', true);
showBadge('badge-tclos', false);
setStatus('l-Diversity applied: each group now has 2+ distinct diagnoses.', 'ok');
document.getElementById('footnote').textContent = 'Green rows: replaced with diverse diagnoses. An attacker no longer learns your exact diagnosis from group membership.';
}
function applyTClos() {
/* Show the t-close version and compute the max TV distance */
const rows = TCLOS.map(r => ({ ...r, highlight: false }));
const groups = {};
for (const r of rows) { if (!groups[r.group]) groups[r.group] = []; groups[r.group].push(r); }
let maxDist = 0;
for (const g of Object.values(groups)) {
const localDist = countDist(g);
const total = g.length;
/* Convert counts to probabilities for the distance calculation */
const localP = {};
for (const k of Object.keys(localDist)) localP[k] = localDist[k] / total;
const d = tvDistance(localP, GLOBAL_DIST);
if (d > maxDist) maxDist = d;
}
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', true);
showBadge('badge-tclos', true);
setStatus(i18nFmt('t-Closeness applied (t=0.2): max TV distance from global = %d%.', { d: maxDist.toFixed(2) }), 'ok');
document.getElementById('footnote').textContent = 'Green rows: distributions tuned to stay within t=0.2 of the global. Even a skewed count no longer leaks more than the global base rate.';
}
function reset() {
currentRows = ORIGINAL.map(r => ({ ...r, highlight: false, changed: false }));
renderTable(currentRows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', false);
showBadge('badge-tclos', false);
setStatus('Click Attack to see what an adversary can learn.', '');
document.getElementById('footnote').textContent = '';
}
document.getElementById('btn-attack').addEventListener('click', attack);
document.getElementById('btn-ldiv').addEventListener('click', applyLDiv);
document.getElementById('btn-tclos').addEventListener('click', applyTClos);
document.getElementById('btn-reset').addEventListener('click', reset);
/* Initial render */
renderTable(currentRows);
setStatus('Click Attack to see what an adversary can learn.', '');
<!-- Demo: attribute-disclosure attack on a k-anonymous table, then l-Diversity and t-Closeness fixes -->
<div id="app">
<div class="controls">
<button id="btn-attack" type="button">Attack</button>
<button id="btn-ldiv" type="button">Apply l-Diversity</button>
<button id="btn-tclos" type="button">Apply t-Closeness</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="status-bar" id="status"></div>
<div class="legend">
<span class="badge badge-anon">k=3 k-anonymous</span>
<span class="badge badge-leak" id="badge-leak" style="display:none">Attribute leak!</span>
<span class="badge badge-ldiv" id="badge-ldiv" style="display:none">l=2 l-diverse</span>
<span class="badge badge-tclos" id="badge-tclos" style="display:none">t=0.2 t-close</span>
</div>
<div class="table-wrap">
<table id="tbl">
<thead>
<tr>
<th>Age</th>
<th>ZIP</th>
<th>Sex</th>
<th class="sensitive">Diagnosis (sensitive)</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
</div>
<div class="footnote" id="footnote"></div>
</div>
/* Styles for the privacy demo table */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; font-size: 14px; }
#app { padding: 0 2px; }
.controls { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
button { font: 600 13px system-ui; padding: .38rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
.status-bar { font-size: .88rem; font-weight: 600; min-height: 1.3em;
margin-bottom: .35rem; color: #555; }
.status-bar.attack { color: #c0392b; }
.status-bar.ok { color: #0a7d33; }
.legend { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .5rem; }
.badge { font-size: .75rem; font-weight: 700; padding: .15rem .5rem;
border-radius: 12px; border: 1px solid transparent; }
.badge-anon { background: #dce8f7; color: #1d3557; border-color: #b8d0ef; }
.badge-leak { background: #fde8e8; color: #a31515; border-color: #f5b8b8; }
.badge-ldiv { background: #e5f5e8; color: #0a5c1e; border-color: #a8d8b4; }
.badge-tclos { background: #f0eaff; color: #4a1ea0; border-color: #c5aef5; }
.table-wrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; min-width: 320px; }
th, td { padding: .3rem .55rem; border: 1px solid #d0d7de; text-align: left; }
th { background: #f0f4f8; font-weight: 700; font-size: .82rem; }
th.sensitive { background: #fff3cd; color: #7a5000; }
td.sensitive { font-style: italic; }
tr.group-a td { background: #f7f9fc; }
tr.group-b td { background: #fafcff; }
tr.highlight td { background: #ffe8e8 !important; }
tr.highlight td.sensitive { font-weight: 700; color: #a31515; }
tr.changed td { background: #e8f9ec !important; }
.footnote { font-size: .78rem; color: #777; margin-top: .5rem; line-height: 1.5; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Dataset: a k-anonymous (k=3) patient table with two equivalence classes */
/* Each group has identical quasi-identifiers (age, zip, sex) — k-anonymity holds */
const ORIGINAL = [
/* Group A: age 20-30, zip 1300*, male — all diagnosed with flu (homogeneous!) */
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a' },
/* Group B: age 40-50, zip 2100*, female — all diagnosed with cancer (homogeneous!) */
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b' },
];
/* l-Diverse version: at least 2 distinct diagnoses per group */
const LDIV = [
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a', changed: false },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Diabetes', group: 'a', changed: true },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Hypertension', group: 'a', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b', changed: false },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Flu', group: 'b', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Diabetes', group: 'b', changed: true },
];
/* t-Close version: local distribution within t=0.3 of the global distribution */
/* Global distribution across all 6 records */
const GLOBAL_DIST = { 'Flu': 2/6, 'Cancer': 2/6, 'Diabetes': 1/6, 'Hypertension': 1/6 };
const TCLOS = [
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Flu', group: 'a', changed: false },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Cancer', group: 'a', changed: true },
{ age: '20-30', zip: '1300*', sex: 'M', diagnosis: 'Diabetes', group: 'a', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Cancer', group: 'b', changed: false },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Flu', group: 'b', changed: true },
{ age: '40-50', zip: '2100*', sex: 'F', diagnosis: 'Hypertension', group: 'b', changed: true },
];
/* Application state */
let currentRows = ORIGINAL.map(r => ({ ...r, highlight: false, changed: false }));
let mode = 'original';
function countDist(rows) {
/* Count how many times each diagnosis appears in a group */
const map = {};
for (const r of rows) map[r.diagnosis] = (map[r.diagnosis] || 0) + 1;
return map;
}
function tvDistance(P, Q) {
/* Total variation distance = half the L1 distance between distributions */
const keys = new Set([...Object.keys(P), ...Object.keys(Q)]);
let sum = 0;
for (const k of keys) sum += Math.abs((P[k] || 0) - (Q[k] || 0));
return sum / 2;
}
function renderTable(rows) {
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
for (const r of rows) {
const tr = document.createElement('tr');
tr.className = r.highlight ? 'highlight' : r.changed ? 'changed' : ('group-' + r.group);
['age', 'zip', 'sex', 'diagnosis'].forEach(k => {
const td = document.createElement('td');
td.textContent = r[k];
if (k === 'diagnosis') td.className = 'sensitive';
tr.appendChild(td);
});
tbody.appendChild(tr);
}
}
function setStatus(msg, cls) {
const el = document.getElementById('status');
el.textContent = msg;
el.className = 'status-bar ' + (cls || '');
}
function showBadge(id, show) {
document.getElementById(id).style.display = show ? '' : 'none';
}
function attack() {
/* Find groups where all records share the same sensitive value */
const rows = ORIGINAL.map(r => ({ ...r }));
const groups = {};
for (const r of rows) { if (!groups[r.group]) groups[r.group] = []; groups[r.group].push(r); }
let leaked = 0;
for (const g of Object.values(groups)) {
const dist = countDist(g);
const vals = Object.keys(dist);
if (vals.length === 1) {
/* All records in this group have the same diagnosis — an attacker learns it */
g.forEach(r => r.highlight = true);
leaked++;
}
}
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', leaked > 0);
showBadge('badge-ldiv', false);
showBadge('badge-tclos', false);
if (leaked > 0) {
setStatus(i18nFmt('%n% group(s) have a single sensitive value — the attacker knows your diagnosis!', { n: leaked }), 'attack');
document.getElementById('footnote').textContent = 'Red rows: every person in these groups shares the same diagnosis. k-anonymity protects identity but not the sensitive attribute.';
} else {
setStatus('No homogeneous groups found.', 'ok');
}
}
function applyLDiv() {
/* Show the l-diverse version of the table */
const rows = LDIV.map(r => ({ ...r, highlight: false }));
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', true);
showBadge('badge-tclos', false);
setStatus('l-Diversity applied: each group now has 2+ distinct diagnoses.', 'ok');
document.getElementById('footnote').textContent = 'Green rows: replaced with diverse diagnoses. An attacker no longer learns your exact diagnosis from group membership.';
}
function applyTClos() {
/* Show the t-close version and compute the max TV distance */
const rows = TCLOS.map(r => ({ ...r, highlight: false }));
const groups = {};
for (const r of rows) { if (!groups[r.group]) groups[r.group] = []; groups[r.group].push(r); }
let maxDist = 0;
for (const g of Object.values(groups)) {
const localDist = countDist(g);
const total = g.length;
/* Convert counts to probabilities for the distance calculation */
const localP = {};
for (const k of Object.keys(localDist)) localP[k] = localDist[k] / total;
const d = tvDistance(localP, GLOBAL_DIST);
if (d > maxDist) maxDist = d;
}
currentRows = rows;
renderTable(rows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', true);
showBadge('badge-tclos', true);
setStatus(i18nFmt('t-Closeness applied (t=0.2): max TV distance from global = %d%.', { d: maxDist.toFixed(2) }), 'ok');
document.getElementById('footnote').textContent = 'Green rows: distributions tuned to stay within t=0.2 of the global. Even a skewed count no longer leaks more than the global base rate.';
}
function reset() {
currentRows = ORIGINAL.map(r => ({ ...r, highlight: false, changed: false }));
renderTable(currentRows);
showBadge('badge-leak', false);
showBadge('badge-ldiv', false);
showBadge('badge-tclos', false);
setStatus('Click Attack to see what an adversary can learn.', '');
document.getElementById('footnote').textContent = '';
}
document.getElementById('btn-attack').addEventListener('click', attack);
document.getElementById('btn-ldiv').addEventListener('click', applyLDiv);
document.getElementById('btn-tclos').addEventListener('click', applyTClos);
document.getElementById('btn-reset').addEventListener('click', reset);
/* Initial render */
renderTable(currentRows);
setStatus('Click Attack to see what an adversary can learn.', '');
Comments
Loading comments...