Upload folder using huggingface_hub
Browse files- __pycache__/server.cpython-310.pyc +2 -2
- dashboard.css +8 -1
- dashboard.html +45 -15
- dashboard.js +48 -10
- server.py +408 -0
__pycache__/server.cpython-310.pyc
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:60a16111c476fdd2fa4b44436a4e38eed423d6d9656d9823088fd54a3258f9d8
|
| 3 |
+
size 141610
|
dashboard.css
CHANGED
|
@@ -995,7 +995,7 @@ body.theme-light .profile-submenu .profile-dropdown__item { color: var(--text-pr
|
|
| 995 |
|
| 996 |
.flow-canvas {
|
| 997 |
position: relative;
|
| 998 |
-
min-width:
|
| 999 |
height: 580px;
|
| 1000 |
border-radius: 18px;
|
| 1001 |
background:
|
|
@@ -1235,6 +1235,13 @@ body.theme-light .profile-submenu .profile-dropdown__item { color: var(--text-pr
|
|
| 1235 |
font: 12px/1.55 'JetBrains Mono', monospace;
|
| 1236 |
}
|
| 1237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
.tool-timeline {
|
| 1239 |
display: grid;
|
| 1240 |
gap: 10px;
|
|
|
|
| 995 |
|
| 996 |
.flow-canvas {
|
| 997 |
position: relative;
|
| 998 |
+
min-width: 1100px;
|
| 999 |
height: 580px;
|
| 1000 |
border-radius: 18px;
|
| 1001 |
background:
|
|
|
|
| 1235 |
font: 12px/1.55 'JetBrains Mono', monospace;
|
| 1236 |
}
|
| 1237 |
|
| 1238 |
+
#flow-api-table td,
|
| 1239 |
+
#flow-register td {
|
| 1240 |
+
max-width: 280px;
|
| 1241 |
+
white-space: normal;
|
| 1242 |
+
word-break: break-word;
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
.tool-timeline {
|
| 1246 |
display: grid;
|
| 1247 |
gap: 10px;
|
dashboard.html
CHANGED
|
@@ -371,12 +371,16 @@
|
|
| 371 |
<input type="checkbox" id="flow-cookies" checked>
|
| 372 |
<span>Cookies</span>
|
| 373 |
</label>
|
| 374 |
-
<label class="ai-leak-toggle">
|
| 375 |
-
<input type="checkbox" id="flow-ai" checked>
|
| 376 |
-
<span>AI</span>
|
| 377 |
-
</label>
|
| 378 |
-
<
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
<div class="flow-progress" id="flow-progress" style="display:none;">
|
| 381 |
<span></span>
|
| 382 |
<div>Scanning forms, cookies, trackers, processors, and public PII paths...</div>
|
|
@@ -425,12 +429,18 @@
|
|
| 425 |
</div>
|
| 426 |
|
| 427 |
<div class="flow-side">
|
| 428 |
-
<div class="chart-card">
|
| 429 |
-
<div class="chart-card__header">
|
| 430 |
-
<h3><i data-lucide="
|
| 431 |
-
</div>
|
| 432 |
-
<div class="flow-chip-list" id="flow-
|
| 433 |
-
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 434 |
<div class="chart-card">
|
| 435 |
<div class="chart-card__header">
|
| 436 |
<h3><i data-lucide="shield-alert"></i> Controls</h3>
|
|
@@ -438,9 +448,29 @@
|
|
| 438 |
<ol class="flow-remediation" id="flow-remediation"></ol>
|
| 439 |
</div>
|
| 440 |
</div>
|
| 441 |
-
</div>
|
| 442 |
-
|
| 443 |
-
<div class="chart-card">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
<div class="chart-card__header">
|
| 445 |
<h3><i data-lucide="list-tree"></i> Flow Register</h3>
|
| 446 |
<span class="badge" id="flow-register-count">0 rows</span>
|
|
|
|
| 371 |
<input type="checkbox" id="flow-cookies" checked>
|
| 372 |
<span>Cookies</span>
|
| 373 |
</label>
|
| 374 |
+
<label class="ai-leak-toggle">
|
| 375 |
+
<input type="checkbox" id="flow-ai" checked>
|
| 376 |
+
<span>AI</span>
|
| 377 |
+
</label>
|
| 378 |
+
<label class="ai-leak-toggle">
|
| 379 |
+
<input type="checkbox" id="flow-runtime" checked>
|
| 380 |
+
<span>Runtime</span>
|
| 381 |
+
</label>
|
| 382 |
+
<button class="btn btn--primary" id="flow-scan-btn"><i data-lucide="route"></i> Map Data Flow</button>
|
| 383 |
+
</div>
|
| 384 |
<div class="flow-progress" id="flow-progress" style="display:none;">
|
| 385 |
<span></span>
|
| 386 |
<div>Scanning forms, cookies, trackers, processors, and public PII paths...</div>
|
|
|
|
| 429 |
</div>
|
| 430 |
|
| 431 |
<div class="flow-side">
|
| 432 |
+
<div class="chart-card">
|
| 433 |
+
<div class="chart-card__header">
|
| 434 |
+
<h3><i data-lucide="server-cog"></i> Services</h3>
|
| 435 |
+
</div>
|
| 436 |
+
<div class="flow-chip-list" id="flow-services"></div>
|
| 437 |
+
</div>
|
| 438 |
+
<div class="chart-card">
|
| 439 |
+
<div class="chart-card__header">
|
| 440 |
+
<h3><i data-lucide="fingerprint"></i> PII Types</h3>
|
| 441 |
+
</div>
|
| 442 |
+
<div class="flow-chip-list" id="flow-pii-types"></div>
|
| 443 |
+
</div>
|
| 444 |
<div class="chart-card">
|
| 445 |
<div class="chart-card__header">
|
| 446 |
<h3><i data-lucide="shield-alert"></i> Controls</h3>
|
|
|
|
| 448 |
<ol class="flow-remediation" id="flow-remediation"></ol>
|
| 449 |
</div>
|
| 450 |
</div>
|
| 451 |
+
</div>
|
| 452 |
+
|
| 453 |
+
<div class="chart-card">
|
| 454 |
+
<div class="chart-card__header">
|
| 455 |
+
<h3><i data-lucide="radio-tower"></i> API Calls & Provider Evidence</h3>
|
| 456 |
+
<span class="badge" id="flow-api-count">0 calls</span>
|
| 457 |
+
</div>
|
| 458 |
+
<div class="table-wrap">
|
| 459 |
+
<table class="data-table">
|
| 460 |
+
<thead>
|
| 461 |
+
<tr>
|
| 462 |
+
<th>Host</th>
|
| 463 |
+
<th>Type</th>
|
| 464 |
+
<th>Observed In</th>
|
| 465 |
+
<th>Evidence</th>
|
| 466 |
+
</tr>
|
| 467 |
+
</thead>
|
| 468 |
+
<tbody id="flow-api-table"></tbody>
|
| 469 |
+
</table>
|
| 470 |
+
</div>
|
| 471 |
+
</div>
|
| 472 |
+
|
| 473 |
+
<div class="chart-card">
|
| 474 |
<div class="chart-card__header">
|
| 475 |
<h3><i data-lucide="list-tree"></i> Flow Register</h3>
|
| 476 |
<span class="badge" id="flow-register-count">0 rows</span>
|
dashboard.js
CHANGED
|
@@ -804,31 +804,56 @@ function initDataFlowVisualizer() {
|
|
| 804 |
const columns = {
|
| 805 |
subject: 60,
|
| 806 |
client: 230,
|
| 807 |
-
first_party:
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 815 |
};
|
| 816 |
const x = columns[node.kind] || 625;
|
| 817 |
const y = 70 + (groupIndex * 92);
|
| 818 |
return { x, y };
|
| 819 |
}
|
| 820 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 821 |
function renderFlowMap(data) {
|
| 822 |
const nodes = data.nodes || [];
|
| 823 |
const edges = data.edges || [];
|
| 824 |
const groupCounts = {};
|
| 825 |
const positions = {};
|
| 826 |
nodes.forEach((node, index) => {
|
| 827 |
-
const key = node
|
| 828 |
const groupIndex = groupCounts[key] || 0;
|
| 829 |
groupCounts[key] = groupIndex + 1;
|
| 830 |
positions[node.id] = nodePosition(node, index, groupIndex);
|
| 831 |
});
|
|
|
|
| 832 |
|
| 833 |
const lineSvg = edges.map(edge => {
|
| 834 |
const from = positions[edge.source];
|
|
@@ -854,7 +879,7 @@ function initDataFlowVisualizer() {
|
|
| 854 |
|
| 855 |
mapEl.innerHTML = `
|
| 856 |
<div class="flow-canvas">
|
| 857 |
-
<svg viewBox="0 0
|
| 858 |
<defs>
|
| 859 |
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
| 860 |
<path d="M0,0 L0,6 L8,3 z" fill="currentColor"></path>
|
|
@@ -864,6 +889,7 @@ function initDataFlowVisualizer() {
|
|
| 864 |
</svg>
|
| 865 |
${nodeHtml}
|
| 866 |
</div>`;
|
|
|
|
| 867 |
}
|
| 868 |
|
| 869 |
function renderFlowReport(data) {
|
|
@@ -882,8 +908,18 @@ function initDataFlowVisualizer() {
|
|
| 882 |
document.getElementById('flow-register-count').textContent = `${edges.length} rows`;
|
| 883 |
|
| 884 |
const piiTypes = summary.pii_types || [];
|
|
|
|
|
|
|
|
|
|
| 885 |
document.getElementById('flow-pii-types').innerHTML = piiTypes.map(type => `<span>${escapeFlowHtml(type)}</span>`).join('');
|
|
|
|
| 886 |
document.getElementById('flow-remediation').innerHTML = (data.remediation || []).map(item => `<li>${escapeFlowHtml(item)}</li>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
document.getElementById('flow-register').innerHTML = edges.map(edge => {
|
| 888 |
const from = (data.nodes || []).find(n => n.id === edge.source);
|
| 889 |
const to = (data.nodes || []).find(n => n.id === edge.target);
|
|
@@ -922,6 +958,8 @@ function initDataFlowVisualizer() {
|
|
| 922 |
include_trackers: document.getElementById('flow-trackers')?.checked !== false,
|
| 923 |
include_cookies: document.getElementById('flow-cookies')?.checked !== false,
|
| 924 |
include_ai: document.getElementById('flow-ai')?.checked !== false,
|
|
|
|
|
|
|
| 925 |
}),
|
| 926 |
});
|
| 927 |
const data = await res.json();
|
|
|
|
| 804 |
const columns = {
|
| 805 |
subject: 60,
|
| 806 |
client: 230,
|
| 807 |
+
first_party: 390,
|
| 808 |
+
api_endpoint: 565,
|
| 809 |
+
external_api: 565,
|
| 810 |
+
database: 740,
|
| 811 |
+
storage: 740,
|
| 812 |
+
processor: 740,
|
| 813 |
+
third_party: 740,
|
| 814 |
+
third_party_domain: 740,
|
| 815 |
+
runtime_service: 740,
|
| 816 |
+
high_risk_processor: 740,
|
| 817 |
+
governance: 915,
|
| 818 |
+
exposure: 915,
|
| 819 |
};
|
| 820 |
const x = columns[node.kind] || 625;
|
| 821 |
const y = 70 + (groupIndex * 92);
|
| 822 |
return { x, y };
|
| 823 |
}
|
| 824 |
|
| 825 |
+
function nodeColumn(node) {
|
| 826 |
+
const columns = {
|
| 827 |
+
subject: 60,
|
| 828 |
+
client: 230,
|
| 829 |
+
first_party: 390,
|
| 830 |
+
api_endpoint: 565,
|
| 831 |
+
external_api: 565,
|
| 832 |
+
database: 740,
|
| 833 |
+
storage: 740,
|
| 834 |
+
processor: 740,
|
| 835 |
+
third_party: 740,
|
| 836 |
+
third_party_domain: 740,
|
| 837 |
+
runtime_service: 740,
|
| 838 |
+
high_risk_processor: 740,
|
| 839 |
+
governance: 915,
|
| 840 |
+
exposure: 915,
|
| 841 |
+
};
|
| 842 |
+
return columns[node.kind] || 625;
|
| 843 |
+
}
|
| 844 |
+
|
| 845 |
function renderFlowMap(data) {
|
| 846 |
const nodes = data.nodes || [];
|
| 847 |
const edges = data.edges || [];
|
| 848 |
const groupCounts = {};
|
| 849 |
const positions = {};
|
| 850 |
nodes.forEach((node, index) => {
|
| 851 |
+
const key = String(nodeColumn(node));
|
| 852 |
const groupIndex = groupCounts[key] || 0;
|
| 853 |
groupCounts[key] = groupIndex + 1;
|
| 854 |
positions[node.id] = nodePosition(node, index, groupIndex);
|
| 855 |
});
|
| 856 |
+
const canvasHeight = Math.max(640, Math.max(...Object.values(groupCounts), 1) * 92 + 180);
|
| 857 |
|
| 858 |
const lineSvg = edges.map(edge => {
|
| 859 |
const from = positions[edge.source];
|
|
|
|
| 879 |
|
| 880 |
mapEl.innerHTML = `
|
| 881 |
<div class="flow-canvas">
|
| 882 |
+
<svg viewBox="0 0 1100 ${canvasHeight}" preserveAspectRatio="none" aria-hidden="true">
|
| 883 |
<defs>
|
| 884 |
<marker id="flow-arrow" markerWidth="10" markerHeight="10" refX="8" refY="3" orient="auto" markerUnits="strokeWidth">
|
| 885 |
<path d="M0,0 L0,6 L8,3 z" fill="currentColor"></path>
|
|
|
|
| 889 |
</svg>
|
| 890 |
${nodeHtml}
|
| 891 |
</div>`;
|
| 892 |
+
mapEl.querySelector('.flow-canvas').style.height = canvasHeight + 'px';
|
| 893 |
}
|
| 894 |
|
| 895 |
function renderFlowReport(data) {
|
|
|
|
| 908 |
document.getElementById('flow-register-count').textContent = `${edges.length} rows`;
|
| 909 |
|
| 910 |
const piiTypes = summary.pii_types || [];
|
| 911 |
+
const services = data.services || [];
|
| 912 |
+
const apiCalls = data.api_calls || [];
|
| 913 |
+
document.getElementById('flow-api-count').textContent = `${apiCalls.length} calls`;
|
| 914 |
document.getElementById('flow-pii-types').innerHTML = piiTypes.map(type => `<span>${escapeFlowHtml(type)}</span>`).join('');
|
| 915 |
+
document.getElementById('flow-services').innerHTML = services.length ? services.slice(0, 18).map(service => `<span>${escapeFlowHtml(service.name)} · ${escapeFlowHtml(service.category)}</span>`).join('') : '<span>No provider signals found in public surface</span>';
|
| 916 |
document.getElementById('flow-remediation').innerHTML = (data.remediation || []).map(item => `<li>${escapeFlowHtml(item)}</li>`).join('');
|
| 917 |
+
document.getElementById('flow-api-table').innerHTML = apiCalls.slice(0, 50).map(call => `<tr>
|
| 918 |
+
<td>${escapeFlowHtml(call.host || '')}</td>
|
| 919 |
+
<td>${escapeFlowHtml(call.first_party ? 'First-party API' : 'External API')}</td>
|
| 920 |
+
<td>${escapeFlowHtml(call.asset_type || call.confidence || '')}</td>
|
| 921 |
+
<td>${escapeFlowHtml((call.evidence || call.url || '').slice(0, 160))}</td>
|
| 922 |
+
</tr>`).join('');
|
| 923 |
document.getElementById('flow-register').innerHTML = edges.map(edge => {
|
| 924 |
const from = (data.nodes || []).find(n => n.id === edge.source);
|
| 925 |
const to = (data.nodes || []).find(n => n.id === edge.target);
|
|
|
|
| 958 |
include_trackers: document.getElementById('flow-trackers')?.checked !== false,
|
| 959 |
include_cookies: document.getElementById('flow-cookies')?.checked !== false,
|
| 960 |
include_ai: document.getElementById('flow-ai')?.checked !== false,
|
| 961 |
+
include_runtime: document.getElementById('flow-runtime')?.checked !== false,
|
| 962 |
+
include_source_maps: true,
|
| 963 |
}),
|
| 964 |
});
|
| 965 |
const data = await res.json();
|
server.py
CHANGED
|
@@ -2628,6 +2628,9 @@ class DataFlowVisualizeRequest(BaseModel):
|
|
| 2628 |
include_cookies: bool = True
|
| 2629 |
include_trackers: bool = True
|
| 2630 |
include_ai: bool = True
|
|
|
|
|
|
|
|
|
|
| 2631 |
|
| 2632 |
|
| 2633 |
class DPDPQuickCheckRequest(BaseModel):
|
|
@@ -2663,8 +2666,319 @@ def _flow_risk(score: int) -> str:
|
|
| 2663 |
return "low"
|
| 2664 |
|
| 2665 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2666 |
def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequest) -> dict:
|
| 2667 |
domain = scan_report.get("domain") or scan_report.get("url", "product")
|
|
|
|
|
|
|
| 2668 |
pii_inputs = scan_report.get("pii_collection", {}).get("inputs", []) or []
|
| 2669 |
trackers = scan_report.get("trackers", {}).get("items", []) or []
|
| 2670 |
tracker_categories = scan_report.get("trackers", {}).get("categories", {}) or {}
|
|
@@ -2675,6 +2989,9 @@ def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequ
|
|
| 2675 |
cookie_summary = scan_report.get("cookies", {}).get("summary", {}) or {}
|
| 2676 |
compliance = scan_report.get("compliance", {}) or {}
|
| 2677 |
blacklight = scan_report.get("blacklight", {}) or {}
|
|
|
|
|
|
|
|
|
|
| 2678 |
|
| 2679 |
nodes = []
|
| 2680 |
edges = []
|
|
@@ -2701,6 +3018,7 @@ def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequ
|
|
| 2701 |
"risk": risk,
|
| 2702 |
"evidence": evidence[:260],
|
| 2703 |
"control": control,
|
|
|
|
| 2704 |
})
|
| 2705 |
|
| 2706 |
collected_types = sorted({(item.get("type") or "personal_data").replace("_", " ") for item in pii_inputs})
|
|
@@ -2717,6 +3035,80 @@ def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequ
|
|
| 2717 |
add_node("product", domain, "first_party", _flow_risk(scan_report.get("risk_score", 0)), "First-party product surface")
|
| 2718 |
add_node("policy", "Notice, Consent, Retention", "governance", "low" if compliance.get("cookie_consent") and compliance.get("privacy_policy") else "high", "DPDP/GDPR-style control plane")
|
| 2719 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2720 |
add_edge(
|
| 2721 |
"user",
|
| 2722 |
"browser",
|
|
@@ -2846,6 +3238,10 @@ def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequ
|
|
| 2846 |
remediation.append("Remove visible PII from public content, metadata, caches, and example payloads.")
|
| 2847 |
if ai_endpoints:
|
| 2848 |
remediation.append("Redact PII before LLM prompts and route AI processing through a monitored backend.")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2849 |
if not compliance.get("privacy_policy"):
|
| 2850 |
remediation.append("Publish a discoverable privacy notice that maps purposes, retention, sharing, and user rights.")
|
| 2851 |
if not remediation:
|
|
@@ -2864,13 +3260,25 @@ def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequ
|
|
| 2864 |
"flows": len(edges),
|
| 2865 |
"pii_types": collected_types,
|
| 2866 |
"processors": len([n for n in nodes if n["kind"] in {"third_party", "third_party_domain", "processor", "high_risk_processor"}]),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2867 |
"high_risk_flows": len([e for e in edges if e["risk"] in {"high", "critical"}]),
|
| 2868 |
"tracker_categories": tracker_categories,
|
| 2869 |
"cookie_summary": cookie_summary,
|
| 2870 |
},
|
| 2871 |
"nodes": nodes,
|
| 2872 |
"edges": edges,
|
|
|
|
|
|
|
|
|
|
| 2873 |
"remediation": remediation,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2874 |
"source_report": {
|
| 2875 |
"risk_factors": scan_report.get("risk_factors", []),
|
| 2876 |
"dpdp": scan_report.get("dpdp", {}),
|
|
|
|
| 2628 |
include_cookies: bool = True
|
| 2629 |
include_trackers: bool = True
|
| 2630 |
include_ai: bool = True
|
| 2631 |
+
include_runtime: bool = True
|
| 2632 |
+
include_source_maps: bool = True
|
| 2633 |
+
repo_url: Optional[str] = None
|
| 2634 |
|
| 2635 |
|
| 2636 |
class DPDPQuickCheckRequest(BaseModel):
|
|
|
|
| 2666 |
return "low"
|
| 2667 |
|
| 2668 |
|
| 2669 |
+
SERVICE_PROVIDER_SIGNATURES = [
|
| 2670 |
+
{"name": "Supabase", "category": "database", "patterns": ["supabase.co", "supabase.com", "createclient(", "@supabase/supabase-js", "/rest/v1", "/auth/v1"], "data": ["database rows", "auth tokens", "PII records"]},
|
| 2671 |
+
{"name": "Firebase / Firestore", "category": "database", "patterns": ["firebaseio.com", "firestore.googleapis.com", "firebaseapp.com", "identitytoolkit.googleapis.com", "firebase/auth"], "data": ["user profile", "auth identity", "documents"]},
|
| 2672 |
+
{"name": "MongoDB Atlas Data API", "category": "database", "patterns": ["data.mongodb-api.com", "mongodb+srv://", "realm.mongodb.com"], "data": ["documents", "database records"]},
|
| 2673 |
+
{"name": "Neon Postgres", "category": "database", "patterns": ["neon.tech", "neon database", "postgresql://", "DATABASE_URL"], "data": ["SQL rows", "PII records"]},
|
| 2674 |
+
{"name": "PlanetScale", "category": "database", "patterns": ["planetscale.com", "pscale_pw_", "mysql://"], "data": ["SQL rows", "PII records"]},
|
| 2675 |
+
{"name": "Upstash Redis", "category": "database", "patterns": ["upstash.io", "UPSTASH_REDIS", "redis://"], "data": ["cache keys", "session data"]},
|
| 2676 |
+
{"name": "Hasura", "category": "database_api", "patterns": ["hasura.app", "/v1/graphql", "x-hasura"], "data": ["GraphQL records", "PII records"]},
|
| 2677 |
+
{"name": "Appwrite", "category": "database_api", "patterns": ["appwrite.io", "/v1/databases", "appwrite"], "data": ["database documents", "auth identity"]},
|
| 2678 |
+
{"name": "Convex", "category": "database_api", "patterns": ["convex.cloud", "convex.site", "convex/react"], "data": ["application records"]},
|
| 2679 |
+
{"name": "Clerk", "category": "auth", "patterns": ["clerk.accounts.dev", "clerk.com", "@clerk/", "__clerk"], "data": ["identity", "session", "email"]},
|
| 2680 |
+
{"name": "Auth0", "category": "auth", "patterns": ["auth0.com", "auth0", "/oauth/token"], "data": ["identity", "session", "email"]},
|
| 2681 |
+
{"name": "NextAuth", "category": "auth", "patterns": ["/api/auth", "next-auth", "authjs"], "data": ["session", "identity"]},
|
| 2682 |
+
{"name": "Stripe", "category": "payment", "patterns": ["js.stripe.com", "api.stripe.com", "stripe.confirm", "stripe.redirecttocheckout"], "data": ["payment metadata", "billing contact"]},
|
| 2683 |
+
{"name": "Razorpay", "category": "payment", "patterns": ["checkout.razorpay.com", "api.razorpay.com", "razorpay"], "data": ["payment metadata", "billing contact"]},
|
| 2684 |
+
{"name": "Paddle", "category": "payment", "patterns": ["paddle.com", "paddle.js"], "data": ["billing contact", "subscription metadata"]},
|
| 2685 |
+
{"name": "Google Analytics", "category": "analytics", "patterns": ["googletagmanager.com", "google-analytics.com", "gtag(", "G-"], "data": ["page view", "device identifiers", "events"]},
|
| 2686 |
+
{"name": "Vercel Analytics", "category": "analytics", "patterns": ["va.vercel-scripts.com", "vercel analytics", "_vercel/insights"], "data": ["page view", "performance events", "device metadata"]},
|
| 2687 |
+
{"name": "PostHog", "category": "analytics", "patterns": ["posthog.com", "posthog-js", "posthog.capture"], "data": ["product events", "user identifiers"]},
|
| 2688 |
+
{"name": "Mixpanel", "category": "analytics", "patterns": ["mixpanel.com", "mixpanel.track"], "data": ["product events", "user identifiers"]},
|
| 2689 |
+
{"name": "Segment", "category": "analytics", "patterns": ["segment.com", "analytics.identify", "analytics.track"], "data": ["event stream", "traits"]},
|
| 2690 |
+
{"name": "Sentry", "category": "error_monitoring", "patterns": ["sentry.io", "Sentry.init", "@sentry/"], "data": ["errors", "user context", "stack traces"]},
|
| 2691 |
+
{"name": "LogRocket", "category": "session_replay", "patterns": ["logrocket.com", "LogRocket.init"], "data": ["session replay", "user events"]},
|
| 2692 |
+
{"name": "Intercom", "category": "support", "patterns": ["intercom.io", "intercomcdn.com", "Intercom("], "data": ["support identity", "messages"]},
|
| 2693 |
+
{"name": "Zendesk", "category": "support", "patterns": ["zendesk.com", "zdassets.com", "zE("], "data": ["support identity", "tickets"]},
|
| 2694 |
+
{"name": "OpenAI", "category": "ai", "patterns": ["api.openai.com", "/v1/chat/completions", "/v1/responses", "openai"], "data": ["prompt text", "user message", "metadata"]},
|
| 2695 |
+
{"name": "Anthropic", "category": "ai", "patterns": ["api.anthropic.com", "claude-", "anthropic"], "data": ["prompt text", "user message", "metadata"]},
|
| 2696 |
+
{"name": "Google Gemini", "category": "ai", "patterns": ["generativelanguage.googleapis.com", "gemini-"], "data": ["prompt text", "user message", "metadata"]},
|
| 2697 |
+
{"name": "Pinecone", "category": "vector_db", "patterns": ["pinecone.io", "pinecone", "pcsk_"], "data": ["embeddings", "document chunks", "metadata"]},
|
| 2698 |
+
{"name": "Qdrant", "category": "vector_db", "patterns": ["qdrant", "qdrant.tech"], "data": ["embeddings", "document chunks", "metadata"]},
|
| 2699 |
+
{"name": "Cloudinary", "category": "storage", "patterns": ["cloudinary.com", "res.cloudinary.com"], "data": ["uploaded files", "media metadata"]},
|
| 2700 |
+
{"name": "AWS S3", "category": "storage", "patterns": ["amazonaws.com", ".s3.", "s3.amazonaws.com"], "data": ["files", "exports", "media"]},
|
| 2701 |
+
]
|
| 2702 |
+
|
| 2703 |
+
|
| 2704 |
+
def _service_node_kind(category: str) -> str:
|
| 2705 |
+
if category in {"database", "database_api", "vector_db"}:
|
| 2706 |
+
return "database"
|
| 2707 |
+
if category in {"auth", "payment", "ai", "storage", "support"}:
|
| 2708 |
+
return "processor"
|
| 2709 |
+
if category in {"analytics", "error_monitoring", "session_replay"}:
|
| 2710 |
+
return "third_party"
|
| 2711 |
+
return "service"
|
| 2712 |
+
|
| 2713 |
+
|
| 2714 |
+
def _service_risk(category: str) -> str:
|
| 2715 |
+
if category in {"database", "database_api", "vector_db", "ai", "session_replay"}:
|
| 2716 |
+
return "high"
|
| 2717 |
+
if category in {"auth", "payment", "analytics", "support", "storage"}:
|
| 2718 |
+
return "medium"
|
| 2719 |
+
return "low"
|
| 2720 |
+
|
| 2721 |
+
|
| 2722 |
+
def _absolute_url(base_url: str, value: str) -> str:
|
| 2723 |
+
from urllib.parse import urljoin
|
| 2724 |
+
value = (value or "").strip()
|
| 2725 |
+
if not value:
|
| 2726 |
+
return ""
|
| 2727 |
+
if value.startswith("//"):
|
| 2728 |
+
return "https:" + value
|
| 2729 |
+
return urljoin(base_url, value)
|
| 2730 |
+
|
| 2731 |
+
|
| 2732 |
+
def _extract_public_service_map(url: str, include_source_maps: bool = True, max_assets: int = 36) -> dict:
|
| 2733 |
+
from urllib.parse import urljoin, urlparse
|
| 2734 |
+
|
| 2735 |
+
target_url = url.strip()
|
| 2736 |
+
if not target_url.startswith(("http://", "https://")):
|
| 2737 |
+
target_url = "https://" + target_url
|
| 2738 |
+
parsed = urlparse(target_url)
|
| 2739 |
+
base_origin = f"{parsed.scheme}://{parsed.netloc}"
|
| 2740 |
+
headers = {
|
| 2741 |
+
"User-Agent": "Mozilla/5.0 RedactAI-ServiceMap/1.0",
|
| 2742 |
+
"Accept": "text/html,application/javascript,text/plain,*/*",
|
| 2743 |
+
}
|
| 2744 |
+
assets = []
|
| 2745 |
+
errors = []
|
| 2746 |
+
|
| 2747 |
+
def add_asset(asset_url, asset_type, text, status=200, content_type=""):
|
| 2748 |
+
assets.append({
|
| 2749 |
+
"url": asset_url,
|
| 2750 |
+
"type": asset_type,
|
| 2751 |
+
"text": text[:1_000_000],
|
| 2752 |
+
"status": status,
|
| 2753 |
+
"content_type": content_type,
|
| 2754 |
+
})
|
| 2755 |
+
|
| 2756 |
+
def fetch(asset_url, asset_type, max_bytes=1_000_000):
|
| 2757 |
+
try:
|
| 2758 |
+
resp = http_requests.get(asset_url, headers=headers, timeout=14, allow_redirects=True)
|
| 2759 |
+
text = resp.text[:max_bytes]
|
| 2760 |
+
add_asset(str(resp.url), asset_type, text, resp.status_code, resp.headers.get("content-type", ""))
|
| 2761 |
+
return text, resp
|
| 2762 |
+
except Exception as exc:
|
| 2763 |
+
errors.append({"url": asset_url, "error": str(exc)[:160]})
|
| 2764 |
+
return "", None
|
| 2765 |
+
|
| 2766 |
+
html, resp = fetch(target_url, "html", 1_200_000)
|
| 2767 |
+
if resp is None:
|
| 2768 |
+
return {"assets": [], "api_calls": [], "services": [], "errors": errors}
|
| 2769 |
+
|
| 2770 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 2771 |
+
discovered = []
|
| 2772 |
+
for script in soup.find_all("script"):
|
| 2773 |
+
src = script.get("src")
|
| 2774 |
+
if src:
|
| 2775 |
+
discovered.append((_absolute_url(target_url, src), "javascript"))
|
| 2776 |
+
else:
|
| 2777 |
+
inline = script.string or script.get_text() or ""
|
| 2778 |
+
if inline.strip():
|
| 2779 |
+
add_asset(target_url + "#inline-script", "inline_script", inline)
|
| 2780 |
+
for link in soup.find_all("link", href=True):
|
| 2781 |
+
rel = " ".join(link.get("rel") or []).lower()
|
| 2782 |
+
href = link.get("href")
|
| 2783 |
+
if "preload" in rel or "modulepreload" in rel or str(href).endswith((".js", ".mjs", ".map")):
|
| 2784 |
+
discovered.append((_absolute_url(target_url, href), "linked_asset"))
|
| 2785 |
+
|
| 2786 |
+
seen = {asset["url"] for asset in assets}
|
| 2787 |
+
for asset_url, asset_type in discovered[:max_assets]:
|
| 2788 |
+
if asset_url in seen:
|
| 2789 |
+
continue
|
| 2790 |
+
seen.add(asset_url)
|
| 2791 |
+
text, asset_resp = fetch(asset_url, asset_type)
|
| 2792 |
+
if include_source_maps and text:
|
| 2793 |
+
for map_match in re.finditer(r"sourceMappingURL=([^\s*]+)", text, re.IGNORECASE):
|
| 2794 |
+
map_url = urljoin(asset_url, map_match.group(1).strip())
|
| 2795 |
+
if map_url in seen or len(assets) >= max_assets + 12:
|
| 2796 |
+
continue
|
| 2797 |
+
seen.add(map_url)
|
| 2798 |
+
map_text, map_resp = fetch(map_url, "source_map", 1_500_000)
|
| 2799 |
+
if map_resp is not None and map_resp.status_code < 400:
|
| 2800 |
+
try:
|
| 2801 |
+
source_map = json.loads(map_text)
|
| 2802 |
+
sources = source_map.get("sources") or []
|
| 2803 |
+
for idx, source_text in enumerate(source_map.get("sourcesContent") or []):
|
| 2804 |
+
if isinstance(source_text, str) and source_text.strip():
|
| 2805 |
+
source_name = sources[idx] if idx < len(sources) else f"source-{idx}"
|
| 2806 |
+
add_asset(f"{map_url}#{source_name}", "source_map_source", source_text, 200, "text/source")
|
| 2807 |
+
except Exception:
|
| 2808 |
+
pass
|
| 2809 |
+
|
| 2810 |
+
api_calls = []
|
| 2811 |
+
services = []
|
| 2812 |
+
service_seen = set()
|
| 2813 |
+
api_patterns = [
|
| 2814 |
+
r"""(?i)\bfetch\(\s*["']([^"']{2,240})["']""",
|
| 2815 |
+
r"""(?i)\baxios\.(?:get|post|put|patch|delete)\(\s*["']([^"']{2,240})["']""",
|
| 2816 |
+
r"""(?i)\b(?:baseURL|apiUrl|apiURL|endpoint|url)\s*[:=]\s*["']([^"']{2,240})["']""",
|
| 2817 |
+
r"""https?://[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]{6,240}""",
|
| 2818 |
+
]
|
| 2819 |
+
for asset in assets:
|
| 2820 |
+
text = asset.get("text") or ""
|
| 2821 |
+
lower = text.lower()
|
| 2822 |
+
for pattern_index, pattern in enumerate(api_patterns):
|
| 2823 |
+
for match in re.finditer(pattern, text):
|
| 2824 |
+
raw = match.group(1) if match.groups() else match.group(0)
|
| 2825 |
+
if raw.startswith(("data:", "blob:", "javascript:", "#")):
|
| 2826 |
+
continue
|
| 2827 |
+
full = _absolute_url(base_origin, raw)
|
| 2828 |
+
if not full.startswith(("http://", "https://", "/")):
|
| 2829 |
+
continue
|
| 2830 |
+
if full.lower().split("?", 1)[0].endswith((".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ico", ".css", ".woff", ".woff2", ".ttf", ".mp4", ".mp3")):
|
| 2831 |
+
continue
|
| 2832 |
+
host = urlparse(full).netloc or parsed.netloc
|
| 2833 |
+
if not host:
|
| 2834 |
+
continue
|
| 2835 |
+
evidence = _text_window(text, match.start(), match.end(), radius=55)
|
| 2836 |
+
url_lower = (full + " " + evidence).lower()
|
| 2837 |
+
provider_like = any(any(p.lower() in url_lower for p in signature["patterns"]) for signature in SERVICE_PROVIDER_SIGNATURES)
|
| 2838 |
+
api_path_like = any(token in url_lower for token in ["/api/", "/api?", "/graphql", "/rest/", "/rpc/", "/auth/", "/v1/", "/v2/", "/trpc", "/functions/", "/checkout", "/webhook"])
|
| 2839 |
+
path = urlparse(full).path or ""
|
| 2840 |
+
host_lower = host.lower()
|
| 2841 |
+
if host_lower.endswith(("w3.org", "schema.org", "mozilla.org")):
|
| 2842 |
+
continue
|
| 2843 |
+
if path in {"", "/"} and host_lower.endswith(parsed.netloc.lower()):
|
| 2844 |
+
continue
|
| 2845 |
+
if path in {"", "/"} and not provider_like and not api_path_like:
|
| 2846 |
+
continue
|
| 2847 |
+
if pattern_index == 3 and not provider_like and not api_path_like:
|
| 2848 |
+
continue
|
| 2849 |
+
api_id = hashlib.sha256((full + asset["url"]).encode("utf-8", errors="ignore")).hexdigest()[:14]
|
| 2850 |
+
if not any(call["id"] == api_id for call in api_calls):
|
| 2851 |
+
api_calls.append({
|
| 2852 |
+
"id": api_id,
|
| 2853 |
+
"url": full[:300],
|
| 2854 |
+
"host": host,
|
| 2855 |
+
"first_party": host.lower().endswith(parsed.netloc.lower()),
|
| 2856 |
+
"asset": asset["url"],
|
| 2857 |
+
"asset_type": asset["type"],
|
| 2858 |
+
"evidence": evidence[:240],
|
| 2859 |
+
"confidence": "static_js",
|
| 2860 |
+
})
|
| 2861 |
+
|
| 2862 |
+
for signature in SERVICE_PROVIDER_SIGNATURES:
|
| 2863 |
+
matched = [p for p in signature["patterns"] if p.lower() in lower or p.lower() in asset["url"].lower()]
|
| 2864 |
+
if not matched:
|
| 2865 |
+
continue
|
| 2866 |
+
key = (signature["name"], signature["category"])
|
| 2867 |
+
if key in service_seen:
|
| 2868 |
+
continue
|
| 2869 |
+
service_seen.add(key)
|
| 2870 |
+
services.append({
|
| 2871 |
+
"name": signature["name"],
|
| 2872 |
+
"category": signature["category"],
|
| 2873 |
+
"matched": matched[:4],
|
| 2874 |
+
"asset": asset["url"],
|
| 2875 |
+
"asset_type": asset["type"],
|
| 2876 |
+
"data_types": signature["data"],
|
| 2877 |
+
"confidence": "public_bundle" if asset["type"] != "html" else "public_html",
|
| 2878 |
+
})
|
| 2879 |
+
|
| 2880 |
+
for call in api_calls:
|
| 2881 |
+
call_text = (call["url"] + " " + call["evidence"]).lower()
|
| 2882 |
+
for signature in SERVICE_PROVIDER_SIGNATURES:
|
| 2883 |
+
if any(p.lower() in call_text for p in signature["patterns"]):
|
| 2884 |
+
key = (signature["name"], signature["category"])
|
| 2885 |
+
if key not in service_seen:
|
| 2886 |
+
service_seen.add(key)
|
| 2887 |
+
services.append({
|
| 2888 |
+
"name": signature["name"],
|
| 2889 |
+
"category": signature["category"],
|
| 2890 |
+
"matched": [call["host"]],
|
| 2891 |
+
"asset": call["asset"],
|
| 2892 |
+
"asset_type": "api_call",
|
| 2893 |
+
"data_types": signature["data"],
|
| 2894 |
+
"confidence": "api_url",
|
| 2895 |
+
})
|
| 2896 |
+
|
| 2897 |
+
return {
|
| 2898 |
+
"assets": [{"url": a["url"], "type": a["type"], "status": a["status"], "content_type": a["content_type"]} for a in assets],
|
| 2899 |
+
"api_calls": api_calls[:120],
|
| 2900 |
+
"services": services,
|
| 2901 |
+
"errors": errors[:10],
|
| 2902 |
+
}
|
| 2903 |
+
|
| 2904 |
+
|
| 2905 |
+
def _extract_runtime_service_map(url: str, seconds: int = 8) -> dict:
|
| 2906 |
+
import asyncio
|
| 2907 |
+
import threading
|
| 2908 |
+
|
| 2909 |
+
try:
|
| 2910 |
+
asyncio.get_running_loop()
|
| 2911 |
+
in_async_loop = True
|
| 2912 |
+
except RuntimeError:
|
| 2913 |
+
in_async_loop = False
|
| 2914 |
+
|
| 2915 |
+
if not in_async_loop:
|
| 2916 |
+
return _extract_runtime_service_map_sync(url, seconds)
|
| 2917 |
+
|
| 2918 |
+
holder = {}
|
| 2919 |
+
|
| 2920 |
+
def worker():
|
| 2921 |
+
holder["result"] = _extract_runtime_service_map_sync(url, seconds)
|
| 2922 |
+
|
| 2923 |
+
thread = threading.Thread(target=worker, daemon=True)
|
| 2924 |
+
thread.start()
|
| 2925 |
+
thread.join(timeout=max(12, min(seconds, 20) + 8))
|
| 2926 |
+
if thread.is_alive():
|
| 2927 |
+
return {"available": True, "requests": [], "services": [], "error": "Runtime capture timed out"}
|
| 2928 |
+
return holder.get("result", {"available": False, "requests": [], "services": [], "reason": "Runtime capture did not return"})
|
| 2929 |
+
|
| 2930 |
+
|
| 2931 |
+
def _extract_runtime_service_map_sync(url: str, seconds: int = 8) -> dict:
|
| 2932 |
+
try:
|
| 2933 |
+
from playwright.sync_api import sync_playwright
|
| 2934 |
+
except Exception:
|
| 2935 |
+
return {"available": False, "requests": [], "services": [], "reason": "Playwright is not installed on this host"}
|
| 2936 |
+
|
| 2937 |
+
target_url = url.strip()
|
| 2938 |
+
if not target_url.startswith(("http://", "https://")):
|
| 2939 |
+
target_url = "https://" + target_url
|
| 2940 |
+
requests_seen = []
|
| 2941 |
+
services = []
|
| 2942 |
+
service_seen = set()
|
| 2943 |
+
try:
|
| 2944 |
+
with sync_playwright() as p:
|
| 2945 |
+
browser = p.chromium.launch(headless=True)
|
| 2946 |
+
try:
|
| 2947 |
+
page = browser.new_page()
|
| 2948 |
+
|
| 2949 |
+
def on_request(request):
|
| 2950 |
+
req_url = request.url
|
| 2951 |
+
requests_seen.append({"url": req_url, "method": request.method, "resource_type": request.resource_type})
|
| 2952 |
+
low = req_url.lower()
|
| 2953 |
+
for signature in SERVICE_PROVIDER_SIGNATURES:
|
| 2954 |
+
if any(pattern.lower() in low for pattern in signature["patterns"]):
|
| 2955 |
+
key = (signature["name"], signature["category"])
|
| 2956 |
+
if key not in service_seen:
|
| 2957 |
+
service_seen.add(key)
|
| 2958 |
+
services.append({
|
| 2959 |
+
"name": signature["name"],
|
| 2960 |
+
"category": signature["category"],
|
| 2961 |
+
"matched": [request.url[:160]],
|
| 2962 |
+
"asset": request.url,
|
| 2963 |
+
"asset_type": "runtime_request",
|
| 2964 |
+
"data_types": signature["data"],
|
| 2965 |
+
"confidence": "runtime_observed",
|
| 2966 |
+
})
|
| 2967 |
+
|
| 2968 |
+
page.on("request", on_request)
|
| 2969 |
+
page.goto(target_url, wait_until="domcontentloaded", timeout=max(5000, min(seconds, 20) * 1000))
|
| 2970 |
+
page.wait_for_timeout(max(1500, min(seconds, 10) * 1000))
|
| 2971 |
+
finally:
|
| 2972 |
+
browser.close()
|
| 2973 |
+
except Exception as exc:
|
| 2974 |
+
return {"available": True, "requests": requests_seen[:80], "services": services, "error": str(exc)[:220]}
|
| 2975 |
+
return {"available": True, "requests": requests_seen[:120], "services": services}
|
| 2976 |
+
|
| 2977 |
+
|
| 2978 |
def _build_data_flow_visualization(scan_report: dict, req: DataFlowVisualizeRequest) -> dict:
|
| 2979 |
domain = scan_report.get("domain") or scan_report.get("url", "product")
|
| 2980 |
+
public_services = _extract_public_service_map(scan_report.get("url") or req.url, req.include_source_maps)
|
| 2981 |
+
runtime_services = _extract_runtime_service_map(scan_report.get("url") or req.url) if req.include_runtime else {"available": False, "requests": [], "services": [], "reason": "Runtime capture disabled"}
|
| 2982 |
pii_inputs = scan_report.get("pii_collection", {}).get("inputs", []) or []
|
| 2983 |
trackers = scan_report.get("trackers", {}).get("items", []) or []
|
| 2984 |
tracker_categories = scan_report.get("trackers", {}).get("categories", {}) or {}
|
|
|
|
| 2989 |
cookie_summary = scan_report.get("cookies", {}).get("summary", {}) or {}
|
| 2990 |
compliance = scan_report.get("compliance", {}) or {}
|
| 2991 |
blacklight = scan_report.get("blacklight", {}) or {}
|
| 2992 |
+
detected_services = public_services.get("services", []) + runtime_services.get("services", [])
|
| 2993 |
+
api_calls = public_services.get("api_calls", [])
|
| 2994 |
+
runtime_requests = runtime_services.get("requests", [])
|
| 2995 |
|
| 2996 |
nodes = []
|
| 2997 |
edges = []
|
|
|
|
| 3018 |
"risk": risk,
|
| 3019 |
"evidence": evidence[:260],
|
| 3020 |
"control": control,
|
| 3021 |
+
"confidence": "observed" if "runtime" in evidence.lower() else "inferred",
|
| 3022 |
})
|
| 3023 |
|
| 3024 |
collected_types = sorted({(item.get("type") or "personal_data").replace("_", " ") for item in pii_inputs})
|
|
|
|
| 3035 |
add_node("product", domain, "first_party", _flow_risk(scan_report.get("risk_score", 0)), "First-party product surface")
|
| 3036 |
add_node("policy", "Notice, Consent, Retention", "governance", "low" if compliance.get("cookie_consent") and compliance.get("privacy_policy") else "high", "DPDP/GDPR-style control plane")
|
| 3037 |
|
| 3038 |
+
for call in api_calls[:40]:
|
| 3039 |
+
call_host = call.get("host") or "api"
|
| 3040 |
+
path_hint = call.get("url", "").split(call_host, 1)[-1][:42] if call_host in call.get("url", "") else call.get("url", "")[:42]
|
| 3041 |
+
node_id = "api_" + call["id"]
|
| 3042 |
+
node_label = ("First-party API " if call.get("first_party") else "External API ") + call_host
|
| 3043 |
+
add_node(
|
| 3044 |
+
node_id,
|
| 3045 |
+
node_label,
|
| 3046 |
+
"api_endpoint" if call.get("first_party") else "external_api",
|
| 3047 |
+
"medium" if call.get("first_party") else "high",
|
| 3048 |
+
path_hint or call.get("confidence", "api call"),
|
| 3049 |
+
)
|
| 3050 |
+
add_edge(
|
| 3051 |
+
"browser",
|
| 3052 |
+
node_id,
|
| 3053 |
+
"calls API",
|
| 3054 |
+
["request metadata", "possible form data", "session identifiers"],
|
| 3055 |
+
"medium" if call.get("first_party") else "high",
|
| 3056 |
+
f"{call.get('confidence')} {call.get('evidence', '')}",
|
| 3057 |
+
"Review request payloads, auth, rate limits, and whether PII is sent to this endpoint.",
|
| 3058 |
+
)
|
| 3059 |
+
|
| 3060 |
+
service_seen_ids = set()
|
| 3061 |
+
for service in detected_services:
|
| 3062 |
+
service_key = f"{service.get('name')}:{service.get('category')}"
|
| 3063 |
+
if service_key in service_seen_ids:
|
| 3064 |
+
continue
|
| 3065 |
+
service_seen_ids.add(service_key)
|
| 3066 |
+
category = service.get("category", "service")
|
| 3067 |
+
node_id = "service_" + hashlib.sha256(service_key.encode("utf-8", errors="ignore")).hexdigest()[:10]
|
| 3068 |
+
kind = _service_node_kind(category)
|
| 3069 |
+
risk = _service_risk(category)
|
| 3070 |
+
add_node(
|
| 3071 |
+
node_id,
|
| 3072 |
+
service.get("name", "External service"),
|
| 3073 |
+
kind,
|
| 3074 |
+
risk,
|
| 3075 |
+
f"{category} - {service.get('confidence', 'detected')}",
|
| 3076 |
+
)
|
| 3077 |
+
source = "browser" if service.get("confidence") in {"runtime_observed", "api_url", "public_html"} else "product"
|
| 3078 |
+
if category in {"database", "database_api", "vector_db"} and service.get("confidence") in {"public_bundle", "source_map_source", "api_url"}:
|
| 3079 |
+
source = "product"
|
| 3080 |
+
add_edge(
|
| 3081 |
+
source,
|
| 3082 |
+
node_id,
|
| 3083 |
+
"connects to " + category.replace("_", " "),
|
| 3084 |
+
service.get("data_types") or ["application data"],
|
| 3085 |
+
risk,
|
| 3086 |
+
f"{service.get('confidence')} via {service.get('asset', '')[:140]} matched {', '.join(service.get('matched', [])[:3])}",
|
| 3087 |
+
"Confirm whether this provider receives PII, whether access is server-only, and whether keys/tokens are protected.",
|
| 3088 |
+
)
|
| 3089 |
+
|
| 3090 |
+
if runtime_requests:
|
| 3091 |
+
runtime_hosts = {}
|
| 3092 |
+
from urllib.parse import urlparse
|
| 3093 |
+
for item in runtime_requests:
|
| 3094 |
+
host = urlparse(item.get("url", "")).netloc
|
| 3095 |
+
if host and not host.endswith(domain):
|
| 3096 |
+
runtime_hosts[host] = runtime_hosts.get(host, 0) + 1
|
| 3097 |
+
for host, count in sorted(runtime_hosts.items(), key=lambda pair: pair[1], reverse=True)[:15]:
|
| 3098 |
+
node_id = "runtime_" + hashlib.sha256(host.encode("utf-8", errors="ignore")).hexdigest()[:10]
|
| 3099 |
+
if any(node["id"] == node_id for node in nodes):
|
| 3100 |
+
continue
|
| 3101 |
+
add_node(node_id, host, "runtime_service", "medium", f"{count} runtime request(s)")
|
| 3102 |
+
add_edge(
|
| 3103 |
+
"browser",
|
| 3104 |
+
node_id,
|
| 3105 |
+
"loads/calls at runtime",
|
| 3106 |
+
["IP address", "user agent", "referrer", "event metadata"],
|
| 3107 |
+
"medium",
|
| 3108 |
+
f"runtime observed {count} request(s)",
|
| 3109 |
+
"Classify this host as a vendor, CDN, API, analytics, or storage provider and document purpose.",
|
| 3110 |
+
)
|
| 3111 |
+
|
| 3112 |
add_edge(
|
| 3113 |
"user",
|
| 3114 |
"browser",
|
|
|
|
| 3238 |
remediation.append("Remove visible PII from public content, metadata, caches, and example payloads.")
|
| 3239 |
if ai_endpoints:
|
| 3240 |
remediation.append("Redact PII before LLM prompts and route AI processing through a monitored backend.")
|
| 3241 |
+
if detected_services:
|
| 3242 |
+
remediation.append("Review every detected provider, API host, and database signal for data category, purpose, auth, retention, and contract owner.")
|
| 3243 |
+
if not any(service.get("category") in {"database", "database_api", "vector_db"} for service in detected_services):
|
| 3244 |
+
remediation.append("No database was publicly observable. To map private DB calls, connect a GitHub repo, AppMap/OpenTelemetry traces, or backend logs.")
|
| 3245 |
if not compliance.get("privacy_policy"):
|
| 3246 |
remediation.append("Publish a discoverable privacy notice that maps purposes, retention, sharing, and user rights.")
|
| 3247 |
if not remediation:
|
|
|
|
| 3260 |
"flows": len(edges),
|
| 3261 |
"pii_types": collected_types,
|
| 3262 |
"processors": len([n for n in nodes if n["kind"] in {"third_party", "third_party_domain", "processor", "high_risk_processor"}]),
|
| 3263 |
+
"api_calls": len(api_calls),
|
| 3264 |
+
"runtime_requests": len(runtime_requests),
|
| 3265 |
+
"services_detected": len(detected_services),
|
| 3266 |
+
"databases_detected": len([s for s in detected_services if s.get("category") in {"database", "database_api", "vector_db"}]),
|
| 3267 |
+
"runtime_capture": runtime_services,
|
| 3268 |
"high_risk_flows": len([e for e in edges if e["risk"] in {"high", "critical"}]),
|
| 3269 |
"tracker_categories": tracker_categories,
|
| 3270 |
"cookie_summary": cookie_summary,
|
| 3271 |
},
|
| 3272 |
"nodes": nodes,
|
| 3273 |
"edges": edges,
|
| 3274 |
+
"api_calls": api_calls,
|
| 3275 |
+
"services": detected_services,
|
| 3276 |
+
"assets": public_services.get("assets", []),
|
| 3277 |
"remediation": remediation,
|
| 3278 |
+
"limitations": [
|
| 3279 |
+
"Public URL scans can only observe browser-visible services, public bundles, source maps, metadata, and runtime network requests.",
|
| 3280 |
+
"Private backend-to-database calls require source repository analysis, AppMap/OpenTelemetry traces, logs, or cloud account integration.",
|
| 3281 |
+
],
|
| 3282 |
"source_report": {
|
| 3283 |
"risk_factors": scan_report.get("risk_factors", []),
|
| 3284 |
"dpdp": scan_report.get("dpdp", {}),
|