Flamki commited on
Commit
1d69cba
·
verified ·
1 Parent(s): 9a9c066

Upload folder using huggingface_hub

Browse files
Files changed (5) hide show
  1. __pycache__/server.cpython-310.pyc +2 -2
  2. dashboard.css +8 -1
  3. dashboard.html +45 -15
  4. dashboard.js +48 -10
  5. 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:3c97a8de2cad0418387614cfc47d4309539f6a69dd5a32f0ed78d66fd339e09a
3
- size 126116
 
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: 1000px;
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
- <button class="btn btn--primary" id="flow-scan-btn"><i data-lucide="route"></i> Map Data Flow</button>
379
- </div>
 
 
 
 
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="fingerprint"></i> PII Types</h3>
431
- </div>
432
- <div class="flow-chip-list" id="flow-pii-types"></div>
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: 405,
808
- storage: 405,
809
- processor: 625,
810
- third_party: 625,
811
- third_party_domain: 625,
812
- high_risk_processor: 625,
813
- governance: 810,
814
- exposure: 810,
 
 
 
 
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.kind || 'processor';
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 1000 580" preserveAspectRatio="none" aria-hidden="true">
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", {}),