Add DPDP policy control matrix
Browse files- __pycache__/server.cpython-310.pyc +2 -2
- dashboard.css +14 -0
- dashboard.js +13 -0
- server.py +264 -17
__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:13079c3489f54c20fc3d1d8607ac76848be9a8065fae61fbe98b229b29782a62
|
| 3 |
+
size 155172
|
dashboard.css
CHANGED
|
@@ -1180,6 +1180,14 @@ body.theme-light .profile-submenu .profile-dropdown__item { color: var(--text-pr
|
|
| 1180 |
margin-bottom: 16px;
|
| 1181 |
}
|
| 1182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1183 |
.tool-score strong {
|
| 1184 |
color: var(--text-primary);
|
| 1185 |
font: 800 30px/1 'JetBrains Mono', monospace;
|
|
@@ -1202,6 +1210,12 @@ body.theme-light .profile-submenu .profile-dropdown__item { color: var(--text-pr
|
|
| 1202 |
margin-top: 16px;
|
| 1203 |
}
|
| 1204 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1205 |
.tool-result-item {
|
| 1206 |
padding: 14px;
|
| 1207 |
border: 1px solid rgba(255,255,255,0.08);
|
|
|
|
| 1180 |
margin-bottom: 16px;
|
| 1181 |
}
|
| 1182 |
|
| 1183 |
+
.tool-score--compact {
|
| 1184 |
+
padding: 14px 16px;
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
.tool-score--compact strong {
|
| 1188 |
+
font-size: 22px;
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
.tool-score strong {
|
| 1192 |
color: var(--text-primary);
|
| 1193 |
font: 800 30px/1 'JetBrains Mono', monospace;
|
|
|
|
| 1210 |
margin-top: 16px;
|
| 1211 |
}
|
| 1212 |
|
| 1213 |
+
.tool-section-title {
|
| 1214 |
+
color: var(--text-primary);
|
| 1215 |
+
font-size: 16px;
|
| 1216 |
+
margin: 4px 0 12px;
|
| 1217 |
+
}
|
| 1218 |
+
|
| 1219 |
.tool-result-item {
|
| 1220 |
padding: 14px;
|
| 1221 |
border: 1px solid rgba(255,255,255,0.08);
|
dashboard.js
CHANGED
|
@@ -1641,11 +1641,23 @@ function initProductTools() {
|
|
| 1641 |
};
|
| 1642 |
const evidenceLine = check => (check.evidence || []).slice(0, 2).map(e => `<span>${escapeFlowHtml(e)}</span>`).join('');
|
| 1643 |
const processors = (data.detected_processors || []).slice(0, 8).map(p => `<span class="badge badge--warning">${escapeFlowHtml(p.name || 'Provider')} · ${escapeFlowHtml(p.category || 'processor')}</span>`).join('');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
render(out, `
|
| 1645 |
<div class="tool-score">
|
| 1646 |
<strong>${escapeFlowHtml(data.grade)}</strong>
|
| 1647 |
<span>${escapeFlowHtml(data.verdict)} · ${escapeFlowHtml(data.score)}% · ${escapeFlowHtml(data.overall_risk || 'Review')} risk</span>
|
| 1648 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1649 |
${processors ? `<div class="tool-result-item"><strong>Detected processors / providers</strong><span>${processors}</span></div>` : ''}
|
| 1650 |
<div class="tool-grid">${(data.checks || []).map(c => `
|
| 1651 |
<div class="tool-result-item">
|
|
@@ -1655,6 +1667,7 @@ function initProductTools() {
|
|
| 1655 |
<span>${escapeFlowHtml(c.section || '')}</span>
|
| 1656 |
</div>`).join('')}</div>
|
| 1657 |
<div class="tool-result-list">
|
|
|
|
| 1658 |
<div class="tool-result-item"><strong>Priority actions</strong>${(data.priority_actions || []).slice(0, 4).map(a => `<span>${escapeFlowHtml(a)}</span>`).join('')}</div>
|
| 1659 |
<div class="tool-result-item"><strong>Scanner limitations</strong>${(data.limitations || []).map(a => `<span>${escapeFlowHtml(a)}</span>`).join('')}</div>
|
| 1660 |
</div>
|
|
|
|
| 1641 |
};
|
| 1642 |
const evidenceLine = check => (check.evidence || []).slice(0, 2).map(e => `<span>${escapeFlowHtml(e)}</span>`).join('');
|
| 1643 |
const processors = (data.detected_processors || []).slice(0, 8).map(p => `<span class="badge badge--warning">${escapeFlowHtml(p.name || 'Provider')} · ${escapeFlowHtml(p.category || 'processor')}</span>`).join('');
|
| 1644 |
+
const policy = data.policy_control_matrix || {};
|
| 1645 |
+
const policyControls = (policy.controls || []).slice(0, 12).map(c => `
|
| 1646 |
+
<div class="tool-result-item">
|
| 1647 |
+
<strong>${statusBadge(c.status)} ${escapeFlowHtml(c.title || c.id)}</strong>
|
| 1648 |
+
<span>${escapeFlowHtml(c.act || '')}</span>
|
| 1649 |
+
<span>${escapeFlowHtml(c.rules || '')}</span>
|
| 1650 |
+
${(c.evidence || []).slice(0, 1).map(e => `<span>${escapeFlowHtml(e)}</span>`).join('')}
|
| 1651 |
+
</div>`).join('');
|
| 1652 |
render(out, `
|
| 1653 |
<div class="tool-score">
|
| 1654 |
<strong>${escapeFlowHtml(data.grade)}</strong>
|
| 1655 |
<span>${escapeFlowHtml(data.verdict)} · ${escapeFlowHtml(data.score)}% · ${escapeFlowHtml(data.overall_risk || 'Review')} risk</span>
|
| 1656 |
</div>
|
| 1657 |
+
<div class="tool-score tool-score--compact">
|
| 1658 |
+
<strong>${escapeFlowHtml(policy.grade || '-')}</strong>
|
| 1659 |
+
<span>Policy evidence · ${escapeFlowHtml(policy.score ?? 'n/a')}% · ${escapeFlowHtml((policy.coverage && `${policy.coverage.pass || 0} pass / ${policy.coverage.review || 0} review / ${policy.coverage.fail || 0} fail`) || 'no policy matrix')}</span>
|
| 1660 |
+
</div>
|
| 1661 |
${processors ? `<div class="tool-result-item"><strong>Detected processors / providers</strong><span>${processors}</span></div>` : ''}
|
| 1662 |
<div class="tool-grid">${(data.checks || []).map(c => `
|
| 1663 |
<div class="tool-result-item">
|
|
|
|
| 1667 |
<span>${escapeFlowHtml(c.section || '')}</span>
|
| 1668 |
</div>`).join('')}</div>
|
| 1669 |
<div class="tool-result-list">
|
| 1670 |
+
${policyControls ? `<div><h3 class="tool-section-title">Policy Control Matrix</h3><div class="tool-grid">${policyControls}</div></div>` : ''}
|
| 1671 |
<div class="tool-result-item"><strong>Priority actions</strong>${(data.priority_actions || []).slice(0, 4).map(a => `<span>${escapeFlowHtml(a)}</span>`).join('')}</div>
|
| 1672 |
<div class="tool-result-item"><strong>Scanner limitations</strong>${(data.limitations || []).map(a => `<span>${escapeFlowHtml(a)}</span>`).join('')}</div>
|
| 1673 |
</div>
|
server.py
CHANGED
|
@@ -3293,6 +3293,191 @@ def _simple_grade(score: int) -> str:
|
|
| 3293 |
return "F"
|
| 3294 |
|
| 3295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3296 |
def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) -> dict:
|
| 3297 |
compliance = report.get("compliance", {}) or {}
|
| 3298 |
dpdp = report.get("dpdp", {}) or {}
|
|
@@ -3303,6 +3488,8 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3303 |
exposed = report.get("exposed_pii", {}) or {}
|
| 3304 |
headers = compliance.get("security_headers", {}) or {}
|
| 3305 |
blacklight = report.get("blacklight", {}) or {}
|
|
|
|
|
|
|
| 3306 |
service_map = service_map or {}
|
| 3307 |
services = service_map.get("services", []) or []
|
| 3308 |
api_calls = service_map.get("api_calls", []) or []
|
|
@@ -3331,6 +3518,23 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3331 |
def header_pass(name: str) -> bool:
|
| 3332 |
return bool((headers.get(name) or {}).get("present")) and (headers.get(name) or {}).get("rating") in {"pass", "warn"}
|
| 3333 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3334 |
def make_check(
|
| 3335 |
check_id: str,
|
| 3336 |
label: str,
|
|
@@ -3360,20 +3564,21 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3360 |
checks = []
|
| 3361 |
|
| 3362 |
privacy_url = compliance.get("privacy_policy_url")
|
|
|
|
| 3363 |
checks.append(make_check(
|
| 3364 |
"privacy_notice",
|
| 3365 |
-
"Privacy notice is discoverable",
|
| 3366 |
-
"pass" if compliance.get("privacy_policy") and privacy_url else "fail",
|
| 3367 |
"DPDP Act Section 5 - Notice",
|
| 3368 |
"high",
|
| 3369 |
"A Data Principal should be able to find a clear notice explaining what personal data is collected and why.",
|
| 3370 |
-
"Publish a visible privacy notice
|
| 3371 |
-
[f"Privacy URL: {privacy_url}" if privacy_url else "No privacy policy URL discovered on the scanned page"],
|
| 3372 |
16,
|
| 3373 |
"high" if privacy_url else "medium",
|
| 3374 |
))
|
| 3375 |
|
| 3376 |
-
consent_ready = bool(compliance.get("cookie_consent")) and dpdp_pass("consent_mechanism")
|
| 3377 |
consent_status = "pass" if consent_ready else "fail" if (tracker_count or non_essential_processors) else "review"
|
| 3378 |
checks.append(make_check(
|
| 3379 |
"consent_mechanism",
|
|
@@ -3395,44 +3600,56 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3395 |
checks.append(make_check(
|
| 3396 |
"consent_withdrawal",
|
| 3397 |
"Consent withdrawal or preference management is disclosed",
|
| 3398 |
-
"pass" if dpdp_pass("consent_withdrawal") else "fail" if (tracker_count or non_essential_processors) else "review",
|
| 3399 |
"DPDP Act Section 6(4) - Consent withdrawal",
|
| 3400 |
"high",
|
| 3401 |
"Users should have an easy way to withdraw consent or change preferences after accepting.",
|
| 3402 |
"Add a persistent cookie/privacy preferences link and describe withdrawal steps in the privacy notice.",
|
| 3403 |
-
|
| 3404 |
12,
|
| 3405 |
))
|
| 3406 |
|
| 3407 |
checks.append(make_check(
|
| 3408 |
"grievance_redressal",
|
| 3409 |
"Grievance/contact path is published",
|
| 3410 |
-
"pass" if dpdp_pass("grievance_officer") else "fail",
|
| 3411 |
"DPDP Act Section 13 and Section 8(7) - Grievance redressal",
|
| 3412 |
"high",
|
| 3413 |
"A user should know who to contact for privacy complaints, correction, erasure, and escalation.",
|
| 3414 |
"Add a privacy contact or grievance officer section with email, response process, and escalation path.",
|
| 3415 |
-
|
| 3416 |
12,
|
| 3417 |
))
|
| 3418 |
|
| 3419 |
checks.append(make_check(
|
| 3420 |
"retention_and_erasure",
|
| 3421 |
"Retention, deletion, correction, and erasure rights are disclosed",
|
| 3422 |
-
"pass" if dpdp_pass("data_retention_policy") else "fail",
|
| 3423 |
"DPDP Act Section 8(6), Section 11, and Section 12",
|
| 3424 |
"high",
|
| 3425 |
"Users should know how long data is kept and how they can request correction or deletion.",
|
| 3426 |
"Document retention periods by data category and add correction/deletion request instructions.",
|
| 3427 |
-
|
| 3428 |
12,
|
| 3429 |
))
|
| 3430 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3431 |
vendor_status = "pass" if processor_count == 0 and tracker_count == 0 else "review"
|
| 3432 |
checks.append(make_check(
|
| 3433 |
"processor_inventory",
|
| 3434 |
"Third-party processors and API destinations are inventoried",
|
| 3435 |
-
vendor_status,
|
| 3436 |
"DPDP Act Section 8 - Data Fiduciary accountability",
|
| 3437 |
"medium" if vendor_status == "review" else "low",
|
| 3438 |
"Every analytics, database, payment, support, AI, and auth provider needs a purpose, data category, contract owner, and retention note.",
|
|
@@ -3440,11 +3657,24 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3440 |
[
|
| 3441 |
f"Detected processors/providers: {', '.join(sorted({p.get('name') for p in detected_processors if p.get('name')}))}" if detected_processors else "No third-party processor signal detected",
|
| 3442 |
f"API calls discovered: {len(api_calls)}" if api_calls else "",
|
| 3443 |
-
],
|
| 3444 |
12,
|
| 3445 |
"medium" if processor_count else "high",
|
| 3446 |
))
|
| 3447 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3448 |
checks.append(make_check(
|
| 3449 |
"public_pii_exposure",
|
| 3450 |
"No obvious personal data is exposed on public pages",
|
|
@@ -3462,7 +3692,7 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3462 |
checks.append(make_check(
|
| 3463 |
"secure_transport_headers",
|
| 3464 |
"HTTPS and browser privacy/security headers are configured",
|
| 3465 |
-
"pass" if browser_security_ok else "fail",
|
| 3466 |
"DPDP Act Section 8(5) - Reasonable security safeguards",
|
| 3467 |
"medium",
|
| 3468 |
"Transport security and browser headers reduce accidental disclosure and client-side abuse.",
|
|
@@ -3471,10 +3701,22 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3471 |
f"HTTPS: {bool(compliance.get('https'))}",
|
| 3472 |
f"HSTS: {(headers.get('strict-transport-security') or {}).get('note', 'missing')}",
|
| 3473 |
f"Referrer-Policy: {(headers.get('referrer-policy') or {}).get('note', 'missing')}",
|
| 3474 |
-
],
|
| 3475 |
8,
|
| 3476 |
))
|
| 3477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3478 |
child_status = "pass" if dpdp_pass("children_protection") else "review"
|
| 3479 |
checks.append(make_check(
|
| 3480 |
"children_data",
|
|
@@ -3530,10 +3772,12 @@ def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) ->
|
|
| 3530 |
"manual_review": review[:4],
|
| 3531 |
"detected_processors": detected_processors,
|
| 3532 |
"evidence_register": evidence_register,
|
|
|
|
| 3533 |
"plain_english": [
|
| 3534 |
f"We found {pii.get('pii_input_count', 0)} personal-data input signal(s).",
|
| 3535 |
f"We found {tracker_count} tracker signal(s) and {processor_count} processor/provider signal(s).",
|
| 3536 |
f"Public PII exposure count: {exposed.get('count', 0)}.",
|
|
|
|
| 3537 |
f"DPDP text-evidence checks passed: {dpdp.get('score', 'not available')} of {dpdp.get('total_checks', 'unknown')}.",
|
| 3538 |
],
|
| 3539 |
"priority_actions": [
|
|
@@ -4345,8 +4589,10 @@ async def scan_url(req: URLScanRequest):
|
|
| 4345 |
except Exception as e:
|
| 4346 |
print(f"[!] Privacy policy page fetch failed: {e}")
|
| 4347 |
|
| 4348 |
-
# Combine homepage + privacy page for DPDP analysis
|
| 4349 |
-
|
|
|
|
|
|
|
| 4350 |
|
| 4351 |
# ---- 15. DPDP ACT 2023 (India) COMPLIANCE CHECKS ----
|
| 4352 |
# Based on the Digital Personal Data Protection Act, 2023
|
|
@@ -4644,6 +4890,7 @@ async def scan_url(req: URLScanRequest):
|
|
| 4644 |
"https": is_https,
|
| 4645 |
"privacy_policy": has_privacy_policy,
|
| 4646 |
"privacy_policy_url": privacy_url_found,
|
|
|
|
| 4647 |
"cookie_consent": has_cookie_consent,
|
| 4648 |
"cmp_platforms": cmp_detected,
|
| 4649 |
"terms_of_service": has_terms,
|
|
|
|
| 3293 |
return "F"
|
| 3294 |
|
| 3295 |
|
| 3296 |
+
DPDP_POLICY_CONTROL_CATALOG = [
|
| 3297 |
+
{
|
| 3298 |
+
"id": "notice_plain_language",
|
| 3299 |
+
"title": "Standalone notice in clear language",
|
| 3300 |
+
"act": "DPDP Act Section 5",
|
| 3301 |
+
"rules": "DPDP Rules 2025 - notice requirements",
|
| 3302 |
+
"keywords": ["privacy notice", "privacy policy", "personal data", "collect", "purpose", "processing"],
|
| 3303 |
+
"required_hits": 3,
|
| 3304 |
+
"weight": 10,
|
| 3305 |
+
"fix": "Make the notice standalone, plain-English, and separate from unrelated terms.",
|
| 3306 |
+
},
|
| 3307 |
+
{
|
| 3308 |
+
"id": "itemized_personal_data",
|
| 3309 |
+
"title": "Itemized personal data categories",
|
| 3310 |
+
"act": "DPDP Act Section 5",
|
| 3311 |
+
"rules": "DPDP Rules 2025 - itemized data collection notice",
|
| 3312 |
+
"keywords": ["name", "email", "phone", "address", "payment", "device", "location", "personal information", "personal data we collect"],
|
| 3313 |
+
"required_hits": 2,
|
| 3314 |
+
"weight": 9,
|
| 3315 |
+
"fix": "List each data category collected, grouped by product workflow.",
|
| 3316 |
+
},
|
| 3317 |
+
{
|
| 3318 |
+
"id": "purpose_specificity",
|
| 3319 |
+
"title": "Specific purpose for each collection",
|
| 3320 |
+
"act": "DPDP Act Section 5 and Section 7",
|
| 3321 |
+
"rules": "DPDP Rules 2025 - purpose description in notice",
|
| 3322 |
+
"keywords": ["purpose", "to provide", "to process", "to improve", "to communicate", "for marketing", "for analytics", "services enabled"],
|
| 3323 |
+
"required_hits": 2,
|
| 3324 |
+
"weight": 10,
|
| 3325 |
+
"fix": "Map every data category to a specific purpose and service enabled by processing.",
|
| 3326 |
+
},
|
| 3327 |
+
{
|
| 3328 |
+
"id": "consent_withdrawal",
|
| 3329 |
+
"title": "Consent withdrawal and preference management",
|
| 3330 |
+
"act": "DPDP Act Section 6(4)",
|
| 3331 |
+
"rules": "DPDP Rules 2025 - withdrawal comparable to giving consent",
|
| 3332 |
+
"keywords": ["withdraw consent", "revoke consent", "manage consent", "manage preferences", "cookie settings", "opt out", "unsubscribe"],
|
| 3333 |
+
"required_hits": 1,
|
| 3334 |
+
"weight": 12,
|
| 3335 |
+
"fix": "Provide a persistent preference link and make withdrawal as easy as giving consent.",
|
| 3336 |
+
},
|
| 3337 |
+
{
|
| 3338 |
+
"id": "data_principal_rights",
|
| 3339 |
+
"title": "Access, correction, erasure, grievance, and nomination rights",
|
| 3340 |
+
"act": "DPDP Act Sections 11, 12, 13, and 14",
|
| 3341 |
+
"rules": "DPDP Rules 2025 - rights request handling",
|
| 3342 |
+
"keywords": ["access your data", "correct", "correction", "erase", "erasure", "delete your data", "grievance", "complaint", "nominate"],
|
| 3343 |
+
"required_hits": 3,
|
| 3344 |
+
"weight": 13,
|
| 3345 |
+
"fix": "Add a rights section covering access, correction, deletion, grievance, and nomination workflows.",
|
| 3346 |
+
},
|
| 3347 |
+
{
|
| 3348 |
+
"id": "grievance_contact",
|
| 3349 |
+
"title": "Grievance/contact channel and escalation",
|
| 3350 |
+
"act": "DPDP Act Section 13 and Section 8(7)",
|
| 3351 |
+
"rules": "DPDP Rules 2025 - complaint and Board communication link",
|
| 3352 |
+
"keywords": ["grievance officer", "grievance", "privacy@", "dpo@", "data protection officer", "complaint", "data protection board"],
|
| 3353 |
+
"required_hits": 1,
|
| 3354 |
+
"weight": 12,
|
| 3355 |
+
"fix": "Publish a privacy contact, grievance process, response path, and escalation route.",
|
| 3356 |
+
},
|
| 3357 |
+
{
|
| 3358 |
+
"id": "retention_deletion",
|
| 3359 |
+
"title": "Retention schedule and deletion policy",
|
| 3360 |
+
"act": "DPDP Act Section 8(6)",
|
| 3361 |
+
"rules": "DPDP Rules 2025 - retention/deletion accountability",
|
| 3362 |
+
"keywords": ["retention", "retain", "how long", "delete", "deletion", "erasure", "storage period", "no longer necessary"],
|
| 3363 |
+
"required_hits": 2,
|
| 3364 |
+
"weight": 12,
|
| 3365 |
+
"fix": "State retention periods by data category and explain deletion triggers.",
|
| 3366 |
+
},
|
| 3367 |
+
{
|
| 3368 |
+
"id": "security_safeguards",
|
| 3369 |
+
"title": "Reasonable security safeguards",
|
| 3370 |
+
"act": "DPDP Act Section 8(5)",
|
| 3371 |
+
"rules": "DPDP Rules 2025 - security safeguards and breach duties",
|
| 3372 |
+
"keywords": ["security", "safeguards", "encryption", "access control", "confidentiality", "incident", "breach", "unauthorized"],
|
| 3373 |
+
"required_hits": 2,
|
| 3374 |
+
"weight": 10,
|
| 3375 |
+
"fix": "Describe encryption, access controls, audit logging, incident response, and vendor security safeguards.",
|
| 3376 |
+
},
|
| 3377 |
+
{
|
| 3378 |
+
"id": "breach_notification",
|
| 3379 |
+
"title": "Breach notification process",
|
| 3380 |
+
"act": "DPDP Act Section 8(6) and security obligations",
|
| 3381 |
+
"rules": "DPDP Rules 2025 - personal data breach notice",
|
| 3382 |
+
"keywords": ["data breach", "security breach", "personal data breach", "notify", "notification", "incident response", "data protection board"],
|
| 3383 |
+
"required_hits": 2,
|
| 3384 |
+
"weight": 9,
|
| 3385 |
+
"fix": "Document breach notification to affected users and the Data Protection Board.",
|
| 3386 |
+
},
|
| 3387 |
+
{
|
| 3388 |
+
"id": "children_data",
|
| 3389 |
+
"title": "Children's data posture",
|
| 3390 |
+
"act": "DPDP Act Section 9",
|
| 3391 |
+
"rules": "DPDP Rules 2025 - verifiable parental consent",
|
| 3392 |
+
"keywords": ["child", "children", "minor", "under 18", "parental consent", "guardian", "age verification"],
|
| 3393 |
+
"required_hits": 1,
|
| 3394 |
+
"weight": 7,
|
| 3395 |
+
"fix": "State whether children can use the service; if yes, document parental consent and no tracking/profiling controls.",
|
| 3396 |
+
},
|
| 3397 |
+
{
|
| 3398 |
+
"id": "processor_vendor_sharing",
|
| 3399 |
+
"title": "Processors, vendors, and sharing purposes",
|
| 3400 |
+
"act": "DPDP Act Section 8 - accountability",
|
| 3401 |
+
"rules": "DPDP Rules 2025 - fiduciary accountability",
|
| 3402 |
+
"keywords": ["third party", "service provider", "processor", "vendor", "affiliate", "share", "sub-processor", "analytics"],
|
| 3403 |
+
"required_hits": 2,
|
| 3404 |
+
"weight": 10,
|
| 3405 |
+
"fix": "List processor categories, sharing purposes, safeguards, and contract ownership.",
|
| 3406 |
+
},
|
| 3407 |
+
{
|
| 3408 |
+
"id": "cross_border_transfer",
|
| 3409 |
+
"title": "Cross-border transfer disclosure",
|
| 3410 |
+
"act": "DPDP Act Section 16",
|
| 3411 |
+
"rules": "DPDP Rules 2025 - cross-border transfer restrictions",
|
| 3412 |
+
"keywords": ["transfer", "outside india", "cross-border", "international", "global", "countries", "jurisdiction"],
|
| 3413 |
+
"required_hits": 1,
|
| 3414 |
+
"weight": 6,
|
| 3415 |
+
"fix": "Disclose whether personal data is transferred outside India and how transfer restrictions are handled.",
|
| 3416 |
+
},
|
| 3417 |
+
]
|
| 3418 |
+
|
| 3419 |
+
|
| 3420 |
+
def _snippet_for_keywords(text: str, keywords: list, max_snippets: int = 3) -> list:
|
| 3421 |
+
snippets = []
|
| 3422 |
+
if not text:
|
| 3423 |
+
return snippets
|
| 3424 |
+
compact = re.sub(r"\s+", " ", text).strip()
|
| 3425 |
+
lower = compact.lower()
|
| 3426 |
+
for keyword in keywords:
|
| 3427 |
+
idx = lower.find(keyword.lower())
|
| 3428 |
+
if idx == -1:
|
| 3429 |
+
continue
|
| 3430 |
+
start = max(0, idx - 90)
|
| 3431 |
+
end = min(len(compact), idx + len(keyword) + 140)
|
| 3432 |
+
snippet = compact[start:end].strip()
|
| 3433 |
+
if snippet and snippet not in snippets:
|
| 3434 |
+
snippets.append(snippet)
|
| 3435 |
+
if len(snippets) >= max_snippets:
|
| 3436 |
+
break
|
| 3437 |
+
return snippets
|
| 3438 |
+
|
| 3439 |
+
|
| 3440 |
+
def _analyze_dpdp_policy_text(policy_text: str, privacy_url: Optional[str] = None) -> dict:
|
| 3441 |
+
text = re.sub(r"\s+", " ", (policy_text or "")).strip()
|
| 3442 |
+
lower = text.lower()
|
| 3443 |
+
controls = []
|
| 3444 |
+
total_weight = sum(control["weight"] for control in DPDP_POLICY_CONTROL_CATALOG)
|
| 3445 |
+
earned = 0
|
| 3446 |
+
for control in DPDP_POLICY_CONTROL_CATALOG:
|
| 3447 |
+
matched = sorted({kw for kw in control["keywords"] if kw.lower() in lower})
|
| 3448 |
+
passed = len(matched) >= control["required_hits"]
|
| 3449 |
+
partial = bool(matched) and not passed
|
| 3450 |
+
if passed:
|
| 3451 |
+
earned += control["weight"]
|
| 3452 |
+
elif partial:
|
| 3453 |
+
earned += control["weight"] * 0.35
|
| 3454 |
+
snippets = _snippet_for_keywords(text, matched or control["keywords"], max_snippets=3)
|
| 3455 |
+
controls.append({
|
| 3456 |
+
"id": control["id"],
|
| 3457 |
+
"title": control["title"],
|
| 3458 |
+
"status": "pass" if passed else "review" if partial else "fail",
|
| 3459 |
+
"act": control["act"],
|
| 3460 |
+
"rules": control["rules"],
|
| 3461 |
+
"matched_terms": matched[:8],
|
| 3462 |
+
"evidence": snippets,
|
| 3463 |
+
"weight": control["weight"],
|
| 3464 |
+
"fix": control["fix"],
|
| 3465 |
+
})
|
| 3466 |
+
score = round((earned / total_weight) * 100) if total_weight else 0
|
| 3467 |
+
return {
|
| 3468 |
+
"privacy_url": privacy_url,
|
| 3469 |
+
"text_length": len(text),
|
| 3470 |
+
"score": score,
|
| 3471 |
+
"grade": _simple_grade(score),
|
| 3472 |
+
"controls": controls,
|
| 3473 |
+
"coverage": {
|
| 3474 |
+
"pass": sum(1 for c in controls if c["status"] == "pass"),
|
| 3475 |
+
"review": sum(1 for c in controls if c["status"] == "review"),
|
| 3476 |
+
"fail": sum(1 for c in controls if c["status"] == "fail"),
|
| 3477 |
+
},
|
| 3478 |
+
}
|
| 3479 |
+
|
| 3480 |
+
|
| 3481 |
def _dpdp_quick_from_report(report: dict, service_map: Optional[dict] = None) -> dict:
|
| 3482 |
compliance = report.get("compliance", {}) or {}
|
| 3483 |
dpdp = report.get("dpdp", {}) or {}
|
|
|
|
| 3488 |
exposed = report.get("exposed_pii", {}) or {}
|
| 3489 |
headers = compliance.get("security_headers", {}) or {}
|
| 3490 |
blacklight = report.get("blacklight", {}) or {}
|
| 3491 |
+
policy_analysis = compliance.get("policy_analysis", {}) or {}
|
| 3492 |
+
policy_controls = {control.get("id"): control for control in (policy_analysis.get("controls") or [])}
|
| 3493 |
service_map = service_map or {}
|
| 3494 |
services = service_map.get("services", []) or []
|
| 3495 |
api_calls = service_map.get("api_calls", []) or []
|
|
|
|
| 3518 |
def header_pass(name: str) -> bool:
|
| 3519 |
return bool((headers.get(name) or {}).get("present")) and (headers.get(name) or {}).get("rating") in {"pass", "warn"}
|
| 3520 |
|
| 3521 |
+
def policy_status(control_id: str) -> str:
|
| 3522 |
+
return (policy_controls.get(control_id) or {}).get("status", "fail")
|
| 3523 |
+
|
| 3524 |
+
def policy_pass(control_id: str) -> bool:
|
| 3525 |
+
return policy_status(control_id) == "pass"
|
| 3526 |
+
|
| 3527 |
+
def policy_evidence(control_id: str, fallback: str) -> list:
|
| 3528 |
+
control = policy_controls.get(control_id) or {}
|
| 3529 |
+
snippets = control.get("evidence") or []
|
| 3530 |
+
terms = control.get("matched_terms") or []
|
| 3531 |
+
evidence = snippets[:2]
|
| 3532 |
+
if terms:
|
| 3533 |
+
evidence.append("Matched terms: " + ", ".join(terms[:6]))
|
| 3534 |
+
if not evidence:
|
| 3535 |
+
evidence.append(fallback)
|
| 3536 |
+
return evidence
|
| 3537 |
+
|
| 3538 |
def make_check(
|
| 3539 |
check_id: str,
|
| 3540 |
label: str,
|
|
|
|
| 3564 |
checks = []
|
| 3565 |
|
| 3566 |
privacy_url = compliance.get("privacy_policy_url")
|
| 3567 |
+
notice_quality = policy_pass("notice_plain_language") and policy_pass("itemized_personal_data") and policy_pass("purpose_specificity")
|
| 3568 |
checks.append(make_check(
|
| 3569 |
"privacy_notice",
|
| 3570 |
+
"Privacy notice is discoverable and meaningfully itemized",
|
| 3571 |
+
"pass" if compliance.get("privacy_policy") and privacy_url and notice_quality else "review" if compliance.get("privacy_policy") and privacy_url else "fail",
|
| 3572 |
"DPDP Act Section 5 - Notice",
|
| 3573 |
"high",
|
| 3574 |
"A Data Principal should be able to find a clear notice explaining what personal data is collected and why.",
|
| 3575 |
+
"Publish a visible privacy notice and itemize personal data categories, purpose, goods/services enabled, rights, and complaint links.",
|
| 3576 |
+
[f"Privacy URL: {privacy_url}" if privacy_url else "No privacy policy URL discovered on the scanned page"] + policy_evidence("notice_plain_language", "No clear notice evidence found")[:1],
|
| 3577 |
16,
|
| 3578 |
"high" if privacy_url else "medium",
|
| 3579 |
))
|
| 3580 |
|
| 3581 |
+
consent_ready = bool(compliance.get("cookie_consent")) and (dpdp_pass("consent_mechanism") or policy_pass("consent_withdrawal"))
|
| 3582 |
consent_status = "pass" if consent_ready else "fail" if (tracker_count or non_essential_processors) else "review"
|
| 3583 |
checks.append(make_check(
|
| 3584 |
"consent_mechanism",
|
|
|
|
| 3600 |
checks.append(make_check(
|
| 3601 |
"consent_withdrawal",
|
| 3602 |
"Consent withdrawal or preference management is disclosed",
|
| 3603 |
+
"pass" if dpdp_pass("consent_withdrawal") or policy_pass("consent_withdrawal") else "fail" if (tracker_count or non_essential_processors) else "review",
|
| 3604 |
"DPDP Act Section 6(4) - Consent withdrawal",
|
| 3605 |
"high",
|
| 3606 |
"Users should have an easy way to withdraw consent or change preferences after accepting.",
|
| 3607 |
"Add a persistent cookie/privacy preferences link and describe withdrawal steps in the privacy notice.",
|
| 3608 |
+
policy_evidence("consent_withdrawal", "No withdrawal/preference evidence found in public text"),
|
| 3609 |
12,
|
| 3610 |
))
|
| 3611 |
|
| 3612 |
checks.append(make_check(
|
| 3613 |
"grievance_redressal",
|
| 3614 |
"Grievance/contact path is published",
|
| 3615 |
+
"pass" if dpdp_pass("grievance_officer") or policy_pass("grievance_contact") else "fail",
|
| 3616 |
"DPDP Act Section 13 and Section 8(7) - Grievance redressal",
|
| 3617 |
"high",
|
| 3618 |
"A user should know who to contact for privacy complaints, correction, erasure, and escalation.",
|
| 3619 |
"Add a privacy contact or grievance officer section with email, response process, and escalation path.",
|
| 3620 |
+
policy_evidence("grievance_contact", "No grievance officer, DPO, privacy@, or redressal signal found"),
|
| 3621 |
12,
|
| 3622 |
))
|
| 3623 |
|
| 3624 |
checks.append(make_check(
|
| 3625 |
"retention_and_erasure",
|
| 3626 |
"Retention, deletion, correction, and erasure rights are disclosed",
|
| 3627 |
+
"pass" if dpdp_pass("data_retention_policy") and policy_pass("data_principal_rights") else "review" if dpdp_pass("data_retention_policy") or policy_pass("data_principal_rights") or policy_pass("retention_deletion") else "fail",
|
| 3628 |
"DPDP Act Section 8(6), Section 11, and Section 12",
|
| 3629 |
"high",
|
| 3630 |
"Users should know how long data is kept and how they can request correction or deletion.",
|
| 3631 |
"Document retention periods by data category and add correction/deletion request instructions.",
|
| 3632 |
+
policy_evidence("retention_deletion", "No retention period, deletion, or erasure evidence found") + policy_evidence("data_principal_rights", "")[:1],
|
| 3633 |
12,
|
| 3634 |
))
|
| 3635 |
|
| 3636 |
+
checks.append(make_check(
|
| 3637 |
+
"rights_request_workflow",
|
| 3638 |
+
"Data Principal rights workflow is operationally described",
|
| 3639 |
+
"pass" if policy_pass("data_principal_rights") else "review" if policy_status("data_principal_rights") == "review" else "fail",
|
| 3640 |
+
"DPDP Act Sections 11, 12, 13, and 14",
|
| 3641 |
+
"high",
|
| 3642 |
+
"A policy should tell users how to access, correct, erase, complain, and nominate a representative.",
|
| 3643 |
+
"Add a rights request workflow with channel, expected response path, identity verification, and nomination language.",
|
| 3644 |
+
policy_evidence("data_principal_rights", "No access/correction/erasure/grievance/nomination workflow evidence found"),
|
| 3645 |
+
10,
|
| 3646 |
+
))
|
| 3647 |
+
|
| 3648 |
vendor_status = "pass" if processor_count == 0 and tracker_count == 0 else "review"
|
| 3649 |
checks.append(make_check(
|
| 3650 |
"processor_inventory",
|
| 3651 |
"Third-party processors and API destinations are inventoried",
|
| 3652 |
+
"pass" if vendor_status == "pass" and policy_pass("processor_vendor_sharing") else "review",
|
| 3653 |
"DPDP Act Section 8 - Data Fiduciary accountability",
|
| 3654 |
"medium" if vendor_status == "review" else "low",
|
| 3655 |
"Every analytics, database, payment, support, AI, and auth provider needs a purpose, data category, contract owner, and retention note.",
|
|
|
|
| 3657 |
[
|
| 3658 |
f"Detected processors/providers: {', '.join(sorted({p.get('name') for p in detected_processors if p.get('name')}))}" if detected_processors else "No third-party processor signal detected",
|
| 3659 |
f"API calls discovered: {len(api_calls)}" if api_calls else "",
|
| 3660 |
+
] + policy_evidence("processor_vendor_sharing", "")[:1],
|
| 3661 |
12,
|
| 3662 |
"medium" if processor_count else "high",
|
| 3663 |
))
|
| 3664 |
|
| 3665 |
+
checks.append(make_check(
|
| 3666 |
+
"cross_border_transfer",
|
| 3667 |
+
"Cross-border transfer position is disclosed",
|
| 3668 |
+
"pass" if policy_pass("cross_border_transfer") else "review",
|
| 3669 |
+
"DPDP Act Section 16 and DPDP Rules 2025 transfer restrictions",
|
| 3670 |
+
"medium",
|
| 3671 |
+
"Users and auditors should know whether data leaves India and which safeguards/restrictions apply.",
|
| 3672 |
+
"Add a cross-border transfer statement, countries/regions where practical, and controls for restricted transfers.",
|
| 3673 |
+
policy_evidence("cross_border_transfer", "No cross-border transfer disclosure found; manual review needed"),
|
| 3674 |
+
6,
|
| 3675 |
+
"low",
|
| 3676 |
+
))
|
| 3677 |
+
|
| 3678 |
checks.append(make_check(
|
| 3679 |
"public_pii_exposure",
|
| 3680 |
"No obvious personal data is exposed on public pages",
|
|
|
|
| 3692 |
checks.append(make_check(
|
| 3693 |
"secure_transport_headers",
|
| 3694 |
"HTTPS and browser privacy/security headers are configured",
|
| 3695 |
+
"pass" if browser_security_ok and (policy_pass("security_safeguards") or policy_status("security_safeguards") == "review") else "review" if browser_security_ok else "fail",
|
| 3696 |
"DPDP Act Section 8(5) - Reasonable security safeguards",
|
| 3697 |
"medium",
|
| 3698 |
"Transport security and browser headers reduce accidental disclosure and client-side abuse.",
|
|
|
|
| 3701 |
f"HTTPS: {bool(compliance.get('https'))}",
|
| 3702 |
f"HSTS: {(headers.get('strict-transport-security') or {}).get('note', 'missing')}",
|
| 3703 |
f"Referrer-Policy: {(headers.get('referrer-policy') or {}).get('note', 'missing')}",
|
| 3704 |
+
] + policy_evidence("security_safeguards", "")[:1],
|
| 3705 |
8,
|
| 3706 |
))
|
| 3707 |
|
| 3708 |
+
checks.append(make_check(
|
| 3709 |
+
"breach_notification",
|
| 3710 |
+
"Personal data breach notification process is documented",
|
| 3711 |
+
"pass" if dpdp_pass("breach_notification") or policy_pass("breach_notification") else "fail",
|
| 3712 |
+
"DPDP Act security obligations and DPDP Rules 2025 breach notice",
|
| 3713 |
+
"high",
|
| 3714 |
+
"A real audit needs evidence that affected users and the Data Protection Board can be notified when a breach occurs.",
|
| 3715 |
+
"Add breach notification language, incident response owner, timelines, and Data Protection Board reporting path.",
|
| 3716 |
+
policy_evidence("breach_notification", "No personal data breach notification evidence found"),
|
| 3717 |
+
9,
|
| 3718 |
+
))
|
| 3719 |
+
|
| 3720 |
child_status = "pass" if dpdp_pass("children_protection") else "review"
|
| 3721 |
checks.append(make_check(
|
| 3722 |
"children_data",
|
|
|
|
| 3772 |
"manual_review": review[:4],
|
| 3773 |
"detected_processors": detected_processors,
|
| 3774 |
"evidence_register": evidence_register,
|
| 3775 |
+
"policy_control_matrix": policy_analysis,
|
| 3776 |
"plain_english": [
|
| 3777 |
f"We found {pii.get('pii_input_count', 0)} personal-data input signal(s).",
|
| 3778 |
f"We found {tracker_count} tracker signal(s) and {processor_count} processor/provider signal(s).",
|
| 3779 |
f"Public PII exposure count: {exposed.get('count', 0)}.",
|
| 3780 |
+
f"Policy evidence score: {policy_analysis.get('score', 'not available')}%.",
|
| 3781 |
f"DPDP text-evidence checks passed: {dpdp.get('score', 'not available')} of {dpdp.get('total_checks', 'unknown')}.",
|
| 3782 |
],
|
| 3783 |
"priority_actions": [
|
|
|
|
| 4589 |
except Exception as e:
|
| 4590 |
print(f"[!] Privacy policy page fetch failed: {e}")
|
| 4591 |
|
| 4592 |
+
# Combine visible homepage text + privacy page text for DPDP analysis.
|
| 4593 |
+
# Use clean text rather than raw HTML so evidence snippets are audit-readable.
|
| 4594 |
+
combined_compliance_text = visible_text.lower() + " " + privacy_page_text
|
| 4595 |
+
dpdp_policy_analysis = _analyze_dpdp_policy_text(combined_compliance_text, privacy_url_found)
|
| 4596 |
|
| 4597 |
# ---- 15. DPDP ACT 2023 (India) COMPLIANCE CHECKS ----
|
| 4598 |
# Based on the Digital Personal Data Protection Act, 2023
|
|
|
|
| 4890 |
"https": is_https,
|
| 4891 |
"privacy_policy": has_privacy_policy,
|
| 4892 |
"privacy_policy_url": privacy_url_found,
|
| 4893 |
+
"policy_analysis": dpdp_policy_analysis,
|
| 4894 |
"cookie_consent": has_cookie_consent,
|
| 4895 |
"cmp_platforms": cmp_detected,
|
| 4896 |
"terms_of_service": has_terms,
|