
Gdpr Audit
- 45 installs
- 44 repo stars
- Updated June 5, 2026
- mastepanoski/claude-skills
Helps with ai & agent building tasks during AI-assisted development.
About
gdpr-audit is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- gdpr-audit
- AI & Agent Building
- AI-coding skill
Gdpr Audit by the numbers
- 45 all-time installs (skills.sh)
- +2 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #7,680 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/mastepanoski/claude-skills --skill gdpr-auditAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 45 |
|---|---|
| repo stars | ★ 44 |
| Last updated | June 5, 2026 |
| Repository | mastepanoski/claude-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
GDPR Audit
A detection-guide-driven audit skill. Reads code / plan / schema / IaC artifacts and returns a structured findings report with GDPR article citations, severity, confidence, evidence, and recommended fixes.
Output is "technical GDPR audit of provided materials" — never "GDPR compliant" or "non-compliant". Compliance is a legal determination; this skill provides technical evidence and gap analysis. Always include the disclaimer at the end of the report.
When to use
- Auditing an existing codebase, repo, or directory tree
- Reviewing an implementation plan, design doc, or RFC for GDPR implications
- Reviewing a database schema or data model for personal data handling
- Reviewing IaC (Terraform / Pulumi / CDK / k8s manifests) for transfers, encryption, region constraints
- Pre-DPIA technical scoping
- Vendor onboarding (auditing the integration code of a new processor / SDK)
When NOT to use
- Drafting privacy notices, DPAs, or DPIAs from scratch — that's a writing task, not an audit
- Compliance certification, regulatory filings, or DPA correspondence — needs a qualified DPO / lawyer
- Org-maturity assessment (training, governance, DPO independence) — not visible from code / plan input
- Auditing non-GDPR jurisdictions (CCPA, LGPD, PIPL) — citations and triggers will be wrong
---
Audit workflow
Run these phases in order. Do not skip phases.
Phase 1 — Scope statement
State explicitly what was reviewed and what wasn't, before any findings.
## Scope
- Materials reviewed: <repo, commit/SHA, branch, paths or "full tree">
- Date of audit: <YYYY-MM-DD>
- Excluded: <e.g., binary assets, vendored deps, test fixtures, .git history>
- Assumptions: <e.g., "controller role assumed unless evidence indicates processor role">
- Missing context: <list of org/policy artifacts not provided to the audit>This protects the audit from being misread as exhaustive.
Phase 2 — Processing map (build first, find against second)
Before scanning for issues, build a processing map. Every later finding ties back to a row here.
| Activity | Data categories | Subjects | Purpose | Lawful basis (claimed / inferred / unclear) | Recipients / processors | Country / region | Retention (claimed / inferred / unclear) |Sources for the map: API routes, DB schemas, env vars, IaC region constraints, third-party SDK invocations (Stripe, Auth0, Segment, GA, Sentry, Datadog, Supabase, AWS clients), background jobs, cron, queue consumers, ML / AI training pipelines.
If no processing map can be built (truly opaque artifact), say so and stop. A findings report without a processing map is unfounded. See references/01-data-discovery.md for detection signals.
Phase 3 — Detection sweep using the reference guides
For each row in the processing map, walk the relevant guides in references/. Each guide is a detection guide with: what to look for, signals, severity rules, false-positive controls, stack-specific examples, evidence needed.
Guides to walk for every audit: 01, 02, 06, 07, 09, 10, 14, 15. Conditional guides (see Trigger overlays below): 03, 04, 05, 08, 11, 12, 13.
Phase 4 — Findings emission
Emit findings using the schema below. One finding per discrete gap.
Phase 5 — Report assembly
Order: Scope → Processing map → Findings table (sorted Critical → Low within each finding type) → Finding details → Summary of finding-type distribution → Recommended next actions → Disclaimer.
Phase 6 — Drill-down offer
After the report, offer:
"I can draft concrete patches for any of these findings on request. Tell me the finding number(s) and I'll generate the actual code / SQL / IaC / markdown changes."
Do not produce patches in the initial report — they bury the audit signal.
---
Finding schema
Every finding has these fields. Missing fields = finding is incomplete.
id: F-NN # sequential within report
title: <short noun phrase>
severity: Critical | High | Medium | Low
finding_type: confirmed_issue | likely_issue | evidence_gap | advisory
confidence: High | Medium | Low
risk_to_rights: <what could happen to a data subject — not "fines">
location: <file:line | section heading | "(absence)">
articles: [Art. 5(1)(c), Art. 25(2), Recital 39]
guidance: [EDPB Guidelines 4/2019 on Article 25, ICO Right of Access guidance] # optional
affected_data: [email, IP, payment, health, children, employees, ...]
affected_subjects: [end users, employees, vendor staff, ...]
processing_activity: <signup | billing | analytics | recommendation | support_export | ...>
evidence: |
<verbatim snippet from code / plan, with file:line>
recommended_fix: |
<1–3 sentences. Not a patch.>
verification_needed: |
<what specific evidence would close this finding>Severity rubric
| Severity | Meaning |
|---|---|
| Critical | Direct violation with high risk to rights AND high confidence. Examples: clear-text PII in public logs; consumer EU data sent to third country with no transfer mechanism; high-risk profiling without DPIA. |
| High | Direct violation OR high-risk gap with material confidence. A supervisory authority would likely act if discovered. |
| Medium | Material gap with rectifiable design choice. Would be remediation in a real audit. |
| Low | Best-practice deviation. No immediate risk. |
Severity is not likelihood × impact — that pseudo-precision misleads. Severity captures the audit-judgment call; confidence is the separate axis.
finding_type
| Type | Meaning | Example |
|---|---|---|
confirmed_issue | Positive evidence shows a violation | analytics.track(user.email) fires before consent is recorded |
likely_issue | Strong signal but missing context to be certain | High-risk profiling code present; lawful basis claim unclear |
evidence_gap | A control SHOULD be visible but isn't | Stripe SDK detected; DPA evidence not found in provided materials |
advisory | Technically defensible, design-level improvement | Pseudonymization could replace direct identifiers in analytics_events |
Discipline rule for `evidence_gap`: only flag absence when there is positive evidence of processing AND a reasonably expected control. "No `docs/ropa.md`" is not a finding. "Stripe SDK + cross-border data flow + no DPA artifact in provided materials" is an evidence_gap. The skill does not invent organizational policy gaps from thin air.
confidence
| Level | Meaning |
|---|---|
| High | Direct code evidence, no plausible alternative interpretation |
| Medium | Strong inference from signals; alternative interpretations exist but are less likely |
| Low | Pattern-based suspicion; needs human verification |
---
Trigger overlays
Apply these when their triggers fire. They escalate severity, force chapter walks, and add national context.
Children's data overlay
Trigger: schema/forms accept date of birth, age, school, parent contact; product is consumer/social/educational; signup form lacks age gate. Effect: every finding involving children's data goes up one severity tier. Cite Art. 8 + Recital 38. Member State age threshold (13–16) flagged for verification. Data minimization scrutinized harder.
Special category overlay (Art. 9, 10)
Trigger: schema fields suggest health, biometric, genetic, racial/ethnic, political, religious, trade-union, sex life, sexual orientation, criminal convictions; medical/HR/insurance domain. Effect: severity floor of High for any unprotected processing. Art. 9(2) basis must be explicitly cited. Security expectations rise (Art. 32 + Art. 9 combined). DPIA almost certainly required (force ch14).
Profiling / automated decision overlay (Art. 22)
Trigger: code performs scoring, ranking, fraud detection, recommendation, embeddings on personal data, automated KYC, automated employment decisions. Effect: force walk of ch13 and ch14. Flag Art. 22 even if the system is not "solely automated" but is being used that way in practice (humans rubber-stamping the model output count as solely automated under EDPB guidance).
Cross-border / vendor overlay
Trigger: SDK or HTTP client points to non-EU endpoint; AWS region outside eu-*; GCP region outside europe-*; external SaaS without obvious EU data residency. Effect: force walk of ch11 and ch12.
National context overlay
Trigger: user mentions a specific Member State, repo contains DE/FR/IT/ES/NL locale, employment context, or healthcare/insurance/finance domain. Add notes per jurisdiction:
- Germany (BDSG): DPO mandatory ≥ 20 employees doing automated PII processing (§ 38); § 26 employment data; § 4 video surveillance; § 31 credit scoring; works council co-determination on monitoring tools.
- France (LIL/CNIL): stricter cookie guidance; employee monitoring requires CNIL-compliant impact assessment.
- UK (UK GDPR + DPA 2018): ICO guidance; international transfers use UK IDTA or Addendum to EU SCCs.
- Italy (Garante): opinions issued on AI/training data sources; cookie enforcement.
- Other Member States: flag for verification — derogations vary.
---
DPIA routing rule (mandatory regardless of which chapter triggers)
Before the report ships, check whether the processing activities crossed any DPIA threshold. If two or more of the EDPB 9 criteria apply, emit a finding for ch14 even if the rest of the audit found nothing wrong:
1. Evaluation or scoring (including profiling and predicting) 2. Automated decision-making with legal/significant effect 3. Systematic monitoring (incl. publicly accessible areas) 4. Sensitive data or data of a highly personal nature 5. Data processed on a large scale 6. Matching or combining datasets 7. Data concerning vulnerable subjects (children, employees, patients) 8. Innovative use or new technological/organizational solutions (AI, IoT, biometric) 9. Processing that prevents data subjects from exercising a right or using a service/contract
EDPB adopted (April 2026) a DPIA template for consultation — cite that, not legacy WP29 templates.
---
Output format
# GDPR Audit Report
## Scope
<Phase 1 block>
## Processing map
<Phase 2 table>
## Findings — confirmed issues (Critical → Low)
| ID | Sev | Conf | Article(s) | Activity | Title |
## Findings — likely issues
| ID | Sev | Conf | Article(s) | Activity | Title |
## Findings — evidence gaps
| ID | Sev | Conf | Article(s) | Activity | Title |
## Findings — advisory
| ID | Sev | Conf | Article(s) | Activity | Title |
## Finding details
F-01
Severity: Critical / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(f), Art. 32(1)
Risk to rights: <...>
Location: src/auth/login.py:42
Affected data: email, IP address
Affected subjects: end users
Processing activity: signup
Evidence:
logger.info(f"login attempt: {email} from {request.client_host}")
Recommended fix:
Hash the user identifier in log output; mask IP to /24 for security analytics if needed; configure structured-logging redaction.
Verification needed:
Show the redaction config and a sample log line after change.
F-02 ...
## Summary
- Confirmed issues: <N>
- Likely issues: <N>
- Evidence gaps: <N>
- Advisory: <N>
- DPIA recommended: yes/no — <reason>
## Recommended next actions
1. <ranked list, highest-leverage first>
## Disclaimer
This is a technical GDPR audit of the provided materials. It is not legal advice and does not constitute a compliance determination. Consult a qualified DPO or data protection lawyer for legal questions, supervisory authority engagement, or material decisions.---
Reference guide index
The detection guides live in references/, numbered 01–15 (referred to as ch01–ch15 in the overlays above).
| Ref | Title | Primary articles | Walk when |
|---|---|---|---|
| 01 | Data discovery, classification, processing map | Art. 4, 30 | Always (drives Phase 2) |
| 02 | Purpose limitation, lawful basis, special-category basis | Art. 5(1)(b), 6, 9, 10 | Always |
| 03 | Transparency and notices | Art. 12–14 | User-facing collection points exist |
| 04 | Consent and preference management | Art. 6(1)(a), 7, 8 | Consent claimed/required for any activity |
| 05 | Cookies, tracking, analytics, SDKs | Art. 6, ePrivacy 5(3) | Browser-side tracking/analytics/marketing detected |
| 06 | Privacy by design / default + minimization | Art. 5(1)(c), 25 | Always |
| 07 | Retention, deletion, backups, derived data | Art. 5(1)(e), 17 | Always |
| 08 | DSAR and data subject rights workflows | Art. 12, 15–22 | Product holds personal data of identifiable subjects |
| 09 | Security, access control, encryption, resilience | Art. 32 | Always |
| 10 | Logging, telemetry, audit trails, overcollection | Art. 5(1)(c), 5(2), 32 | Always |
| 11 | Vendors, processors, subprocessors, controller / processor roles | Art. 24, 26, 28, 29 | Third-party processors detected |
| 12 | International transfers and TIAs | Art. 44–49, Schrems II | Non-EU data flows detected |
| 13 | Profiling, automated decisions, AI, model training | Art. 22, Recital 71 | Profiling / AI overlay triggered |
| 14 | DPIA / high-risk triage + prior consultation | Art. 35, 36 | Always (DPIA routing rule) |
| 15 | Accountability evidence: RoPA, LIA, DPA, SCC/TIA, breach register, breach notification | Art. 5(2), 30, 33, 34 | Always |
---
Common rationalizations to resist
| Excuse | Reality |
|---|---|
| "There's no PII here, it's just emails." | Email is personal data (Art. 4(1)). |
| "We use legitimate interest, so we don't need consent." | LI requires a documented LIA. Direct marketing still triggers Art. 21 right to object. |
| "This is just analytics, it's anonymous." | Individual-level events with stable identifiers (cookie, fingerprint, user_id) = pseudonymous, not anonymous. Still personal data. |
| "We're a processor, not a controller." | Processors have direct obligations under Art. 28(3), 32, 33(2). |
| "Stripe handles payment compliance." | Stripe's posture covers Stripe's processing. The integrating system has its own controller obligations. |
| "We don't need a DPIA, we're not doing anything weird." | DPIA threshold = Art. 35(3) + EDPB 9-criteria. Many "normal" SaaS hits 2+ (analytics + employees + vendors + scoring). |
| "Our cloud provider is GDPR-compliant." | Provider compliance ≠ your compliance. You still need DPA, transfer mechanism, region selection. |
| "User opted in to ToS." | ToS opt-in is not consent under Art. 7. Bundled consent is invalid. |
| "We delete on request, that's enough." | Erasure (Art. 17) is one of seven rights. Access, rectification, portability, restriction, objection, automated-decision rights all need workflows. |
| "We don't need a DPA — they're a Data Sub-processor of our Data Sub-processor." | Sub-processor chain obligations under Art. 28(2) and 28(4). Each link needs contractual flow-through. |
Chapter 01 — Data discovery, classification, and processing map
Primary articles: Art. 4 (definitions), Art. 30 (records of processing). When to walk: always — drives Phase 2 of the workflow. Output: the processing map for the audit. Without this, every later finding is unfounded.
---
What this chapter detects
Where personal data enters, lives, moves, and leaves the system. Not violations — those come later. This chapter's deliverable is the processing-activity inventory that every subsequent finding ties back to.
---
Signals to scan
Schema-level signals (DB / ORM / Pydantic / GraphQL)
| Column / field name pattern | Likely category |
|---|---|
email, email_address, contact_email | Personal — direct identifier |
phone, mobile, phone_number | Personal — direct identifier |
name, first_name, last_name, full_name, display_name | Personal — direct identifier |
dob, date_of_birth, birthdate, age | Personal; children's overlay trigger |
ssn, national_id, nin, cpr, personnummer | Personal — high sensitivity |
passport, id_card, driver_license | Personal — high sensitivity |
address, street, postal_code, city, country | Personal — direct identifier |
ip, ip_address, last_ip, client_ip | Personal (Recital 30) |
user_agent, device_id, fingerprint | Personal — pseudonymous identifier |
latitude, longitude, geolocation | Personal — high precision = high sensitivity |
health_*, medical_*, diagnosis, prescription | Special category (Art. 9) |
biometric_*, fingerprint_hash, face_template, voice_print | Special category (Art. 9) |
genetic_*, dna_* | Special category (Art. 9) |
race, ethnicity, religion, political_*, trade_union_* | Special category (Art. 9) |
sexual_orientation, gender_identity | Special category (Art. 9) |
criminal_*, convictions, offenses, sanctions | Art. 10 |
salary, compensation, bank_account, iban, swift, card_* | Personal — financial |
Synonyms / common naming styles to match: snake_case, camelCase, PascalCase, kebab-case. Check schema.prisma, models.py, *.entity.ts, migrations in db/migrations/, prisma/migrations/, alembic/versions/, flyway/, liquibase/.
Application-level signals
- API routes / handlers:
/users,/auth,/signup,/login,/profile,/account,/payment,/checkout,/orders,/contacts,/messages. - Form definitions: HTML
<form>, React Hook FormuseForm, Formik schemas, Zod / Yup validators on user-data shapes. - Request body schemas: OpenAPI / Swagger / GraphQL SDL describing personal data input.
- Background jobs: Celery, Sidekiq, BullMQ, k8s CronJob — anything that reads from
users,orders,events,messages. - Export / import code: CSV / JSON exports of user data, ETL into warehouses (BigQuery, Snowflake, Redshift).
Third-party SDK signals (each one creates a row in the processing map)
| Detected import / call | Vendor role | Map row needs |
|---|---|---|
stripe, @stripe/stripe-js, stripe.charges.* | Processor (payment) | recipient=Stripe; data=email, name, address, IP, payment; transfer? |
@auth0/*, auth0.com URLs, okta, firebase/auth | Processor (identity) | recipient=Auth0/Okta/Firebase; data=email, name, profile; tenant region |
posthog, mixpanel, amplitude, heap, segment | Processor (analytics) | recipient=<vendor>; data=user_id, events, IP, UA; consent? |
@google-analytics/*, gtag(, ga( | Processor (analytics) | EU↔US transfer; consent gate critical (ch05) |
sentry, datadog, newrelic, bugsnag, rollbar | Processor (observability) | data=user_id in scope, request bodies in errors → PII risk |
mailgun, sendgrid, postmark, aws-sdk/ses | Processor (email) | data=email, name, content; recipient region |
twilio | Processor (SMS / voice) | data=phone, message; recipient=Twilio (US/regional) |
algolia, meilisearch, elasticsearch (managed) | Processor (search) | data=indexed user records; region of cluster |
cloudinary, imgix, s3 (uploads bucket) | Processor (media) | data=user-uploaded files; possibly biometric |
openai, anthropic, cohere, huggingface_hub | Processor (LLM) | personal data sent in prompts; transfer; profiling overlay trigger if used for decisions |
supabase-js, firebase, mongodb+srv://*.mongodb.net | Processor (DBaaS) | tenant region; data=full DB |
@hubspot/*, salesforce, intercom, zendesk | Processor (CRM / support) | data=email, name, history; recipient region |
Infrastructure / IaC signals
- AWS:
region: us-east-1/eu-west-1etc. — anything outsideeu-*triggers cross-border overlay. RDS / S3 / DynamoDB / Lambda regions. - GCP:
location: USvseurope-west*. BigQuery datasets default toUSif unset. - Azure:
location: eastusvswesteurope. - k8s:
topology.kubernetes.io/regionannotations. - Cloudflare R2 / Vercel Blob / Netlify Blobs: check vendor doc for region commitment.
- Supabase: region is selected at project creation; verify in the dashboard or via the Management API (the public
*.supabase.coURL does not encode the region).
Implementation-plan signals (markdown plans, RFCs, design docs)
- Sections titled "User data", "Account model", "Authentication", "Analytics", "Tracking", "Payments", "Subscriptions", "Notifications", "Profile".
- Tables listing fields a feature will collect.
- Architecture diagrams showing arrows between services and external vendors.
- "Out of scope" sections — read these carefully; "GDPR is out of scope for v1" is itself a finding (advisory, ch15).
---
Processing map output format
Build this table during the sweep. One row per processing activity (a coherent purpose + dataset combination), not per database table.
| Activity | Data categories | Subjects | Purpose | Lawful basis (claimed/inferred/unclear) | Recipients/processors | Country/region | Retention (claimed/inferred/unclear) |
|---------------------|----------------------------|--------------|------------------|-----------------------------------------|---------------------------------|----------------|--------------------------------------|
| signup | email, password_hash, name | end users | account creation | contract (inferred, Art. 6(1)(b)) | self-hosted Postgres | eu-west-1 | unclear |
| product analytics | user_id, event, IP, UA | end users | usage analytics | unclear | PostHog Cloud (US) | us | unclear |
| payment processing | name, address, card token | end users | order fulfillment| contract (Art. 6(1)(b)) | Stripe (US, with EU sub) | us / eu-mixed | 7 years (legal, inferred) |
| ML recommender | user_id, event embeddings | end users | personalization | unclear (legitimate interest claimed) | OpenAI API + internal Postgres | us / eu | unclear |Each row's lawful basis and retention columns must be one of:
claimed (with article cite)— explicitly stated in code/plan/commentsinferred (basis)— best guess from context, marked clearlyunclear— actively unknown; this itself becomes anevidence_gapfinding in ch02 / ch07
---
False-positive controls
Don't flag in the map:
- Test fixtures: files under
tests/,__tests__/,spec/, with obviously fake data (test@example.com,John Doe). - Synthetic data factories: Faker, factory_bot, factory_boy producers.
- Documentation examples: README code blocks demonstrating shapes.
- Schema migrations that drop or rename PII columns: these are typically remediation, not collection.
- Type definitions without instantiation: TypeScript
type User = {...}without any code that populates it from real input. (Still note the type in case it's used downstream.)
---
Severity guidance for findings sourced from this chapter
This chapter rarely produces standalone findings — it produces the map. The map shape itself can yield findings:
| Symptom | Severity | finding_type | Example |
|---|---|---|---|
| Personal data processing activity with no identifiable purpose | High | likely_issue | "Endpoint /admin/dump returns full user table; purpose unclear" |
unclear lawful basis for any activity | Medium-to-High | evidence_gap or likely_issue | downgrade/upgrade based on data sensitivity |
| Special category data detected without specific Art. 9(2) basis | High floor | likely_issue or confirmed_issue | "Health field diagnoses_json in users table" |
| Codebase clearly handles personal data but no scoping artifact (no doc / map / RoPA hint) | Medium | evidence_gap | walk to ch15 |
| Personal data collected but no user-visible feature uses it ("dead PII") | Medium | confirmed_issue | data minimization violation, see ch06 |
---
Sample findings sourced here
F-01
Severity: High / Confidence: High / Type: likely_issue
Articles: Art. 5(1)(b), Art. 30(1)(b)
Risk to rights: subjects cannot know why their data is processed; unbounded purpose drift.
Location: src/jobs/nightly_export.py:18
Affected data: email, full_name, phone, address
Affected subjects: end users
Processing activity: nightly_export (purpose unclear)
Evidence:
rows = db.query("SELECT email, full_name, phone, address FROM users")
s3.put("backups/users-$(date).csv", csv(rows))
Recommended fix: declare a purpose for this export in the plan, RoPA, and code comment; restrict columns to those required for the stated purpose; verify lawful basis covers it (likely Art. 6(1)(c) — legal obligation — or Art. 6(1)(f) — legitimate interest, with LIA).
Verification needed: a documented purpose, lawful basis, retention period, and access policy for this export.---
Evidence needed to close
- Processing map (RoPA-aligned per Art. 30(1)) signed off by a controller-side stakeholder.
- For each row: explicit lawful basis (claimed, with article), explicit retention period.
- For each
unclearcell at audit time: either filled in or escalated to a separate finding.
Chapter 02 — Purpose limitation, lawful basis, special-category basis
Primary articles: Art. 5(1)(a) (lawfulness, fairness, transparency), Art. 5(1)(b) (purpose limitation), Art. 6 (lawful basis), Art. 9 (special category), Art. 10 (criminal data). Walk when: always.
---
What this chapter detects
Whether every processing activity in the map has: 1. An explicit, specific purpose (not "for business reasons") 2. A claimed lawful basis under Art. 6(1)(a)–(f) 3. For special-category / criminal data: an additional Art. 9(2) / Art. 10 basis 4. A purpose-limited downstream — no silent reuse of the data for other purposes
Most code/plan inputs claim "we have consent" or stay silent. Both are findings.
---
Signals to scan
Purpose limitation (Art. 5(1)(b))
| Signal | Verdict |
|---|---|
| Spec/plan declares the purpose for each processing activity | good |
| Spec section "future possible uses" / "we'll figure out the use case" | confirmed_issue |
Code joins users with events and ships to a "data lake" with no purpose declaration | confirmed_issue |
| Production data flowed to ML training without separate purpose declaration | confirmed_issue (also ch06, ch13) |
| Marketing reuse of data collected for service delivery | confirmed_issue |
| Internal teams ad-hoc querying prod replica with no per-purpose access boundary | likely_issue |
Lawful-basis identification (Art. 6)
The audit must surface, for each processing activity, which basis applies. Look for:
- Comments / docstrings citing a basis ("// lawful basis: contract")
- Spec sections labeled "lawful basis"
- A
processing_activities.md/ RoPA stub - Configuration in code:
consent_required: ['analytics', 'marketing']etc.
Anti-patterns:
| Anti-pattern | Likely-correct basis | Severity |
|---|---|---|
| "Consent" claimed for service delivery | Art. 6(1)(b) contract | confirmed_issue (consent withdrawal would block service → not freely given) |
| "Consent" claimed for legal-obligation processing (tax, AML) | Art. 6(1)(c) | confirmed_issue |
| "Consent" claimed for fraud detection / security | Art. 6(1)(f) LI (Recital 49) | likely_issue |
| "Consent" claimed for employee processing | Art. 6(1)(b/c/f) | confirmed_issue (Art. 7(4) power imbalance — see ch04) |
| "Legitimate interest" claimed without LIA evidence | n/a | evidence_gap |
| "Legitimate interest" claimed for direct marketing without considering Art. 21 right to object | n/a | likely_issue |
| Multiple bases claimed for the same processing without justification | one applies | likely_issue |
| Basis silent / not documented | n/a | evidence_gap (if PII handling clear) or likely_issue |
Legitimate-interest assessment (LIA) discipline
LI requires a 3-part test (Recital 47, EDPB Guidelines 1/2024 on Art. 6(1)(f)): 1. Purpose test — is the interest legitimate? 2. Necessity test — is the processing necessary for that interest, or is there a less invasive alternative? 3. Balancing test — do the data subject's rights override the interest?
Audit signals:
- Repository contains
lia/<activity>.mdor similar → good (verify currency) - Plan describes the 3 tests for the activity → good
- LI claimed but no LIA artifact → evidence_gap
- LIA exists but boilerplate, no balancing → likely_issue
Special-category data (Art. 9)
Trigger fields (also in ch01 schema list): health, biometric, genetic, racial/ethnic, political, religious, trade-union, sex life, sexual orientation.
Required: Art. 6(1) basis AND Art. 9(2) basis. Not just one of them.
| Art. 9(2) sub-basis | When applicable |
|---|---|
| (a) explicit consent | most common in consumer apps |
| (b) employment / social security law | HR with national legal framework |
| (c) vital interests | emergency / life-threatening only |
| (d) not-for-profit body member processing | narrow |
| (e) data manifestly made public by subject | rare; high bar |
| (f) legal claims / judicial | litigation |
| (g) substantial public interest | requires specific Member State law |
| (h) preventive medicine, occupational medicine, health/social-care | with practitioner / equivalent obligation |
| (i) public health | epidemiology / safety threats |
| (j) archiving, scientific or historical research, statistics | with safeguards |
Anti-pattern: "consent" claimed under Art. 6 but no Art. 9(2)(a) explicit-consent UI.
Criminal data (Art. 10)
Processing of criminal-conviction or offence data is forbidden unless authorized by Union or Member State law providing appropriate safeguards. Code-level signals: criminal_record, convictions, offenses, sanctions_check, AML/PEP screening, fraud-blacklists.
Audit: must cite the legal authorization (Art. 10) AND have an Art. 6 basis.
Children's data (Art. 8) — overlay reinforcement
Consent-based processing of an information-society service offered directly to a child requires:
- Parental authorization for under-16 (or Member State threshold 13–16)
- Verifiable mechanism, not self-declared age
- Plain-language information (Art. 12(1) reinforced by Recital 58)
If the spec uses consent for a child-facing service without addressing Art. 8, finding.
Purpose-limitation drift signals
- Migration / spec adds new processing on existing data without revisiting basis ("we'll also use this for X").
- Internal tools join multiple datasets without a per-purpose boundary ("data analyst dashboards" with full prod read).
- ML feature engineering pulls fields not justified by the model's stated purpose.
- Logs / archives accessed for ad-hoc analytics — purpose creep.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Comments / docstrings | # Lawful basis: contract (Art. 6(1)(b)) annotation present → good |
| Migrations | comments on table-add migrations describing purpose → good |
| Plans / RFCs | "Lawful basis" section per processing activity → good |
| Auth0 actions | profile-enrichment that pulls extra data — verify purpose |
| Stripe | financial-transaction processing under Art. 6(1)(b) → contract |
| HRIS code (Workday, BambooHR exports) | usually Art. 6(1)(b/c/f); special category triggers Art. 9 |
| Healthcare integrations | Art. 9(2)(h) likely; verify practitioner-equivalent obligation cited |
---
False-positive controls
- A repo may legitimately have many activities under Art. 6(1)(b) (contract) — this isn't a finding by itself; the test is whether the claim is correct.
- Marketing communications to existing customers about similar products may be permitted as soft opt-in under ePrivacy national implementations + Art. 6(1)(f); flag as
evidence_gapif the basis isn't cited, not as a violation. - Security telemetry under Recital 49 LI is not a finding if the LI claim is documented.
- A processor doesn't always need to surface its own Art. 6 basis — it relies on the controller's basis (Art. 28). Audit the role first (ch11) before insisting on a basis claim.
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Special-category processing without Art. 9(2) basis | Critical | confirmed_issue |
| Criminal data processing without Art. 10 authorization | Critical | confirmed_issue |
| Children's processing without Art. 8 mechanism | Critical | confirmed_issue |
| "Consent" used for service-delivery / employee / legal-obligation | High | confirmed_issue |
| LI claimed without LIA artifact | Medium-to-High | evidence_gap |
| Lawful basis silent for any processing of personal data | Medium | evidence_gap (escalate if data-rich activity) |
| Production data reused without per-purpose declaration | High | confirmed_issue |
| Spec mentions "future use cases for this data" without basis | Medium | likely_issue |
| Multiple inconsistent bases claimed for same activity | Medium | likely_issue |
---
Sample findings
F-81
Severity: Critical / Confidence: High / Type: confirmed_issue
Articles: Art. 6(1), Art. 9(1), Art. 9(2)(a)
Risk to rights: special-category processing without an explicit, specific basis; subjects' most sensitive data handled unlawfully.
Location: db/migrations/0042_add_health_to_profile.sql, src/api/profile.ts:88
Affected data: health condition (free-text + ICD-10 code field)
Affected subjects: end users
Processing activity: profile enrichment for personalization
Evidence:
ALTER TABLE profiles ADD COLUMN health_condition_text text, ADD COLUMN icd10 text;
// route accepts and stores without explicit-consent gating
Recommended fix: stop collecting until an Art. 9(2)(a) explicit-consent flow is built (separate from Art. 6 consent — affirmative, plain-language, demonstrably explicit); cite the basis in the processing map; restrict access (ch09) and retention (ch07) accordingly; **route to ch14 — DPIA is required** (special-category data on a large scale crosses Art. 35(3)(b)).
Verification needed: explicit-consent UI screenshot + consent record schema; Art. 9(2)(a) documented in processing map; access policy reflecting the heightened classification.F-82
Severity: High / Confidence: Medium / Type: evidence_gap
Articles: Art. 6(1)(f), Recital 47
Risk to rights: legitimate-interest claim cannot be tested; subjects cannot exercise informed objection (Art. 21).
Location: spec/personalization-v2.md §4 ("Lawful basis: Legitimate Interest")
Affected data: behavior history, profile attributes
Affected subjects: end users
Processing activity: recommendation personalization
Evidence:
"We rely on legitimate interest for personalization. Users can opt out in settings."
No 3-part LIA artifact found in repo; no necessity/proportionality/balancing analysis.
Recommended fix: produce an LIA covering purpose, necessity, balancing, and Art. 21 objection consequence; commit to repo (e.g. `lias/personalization.md`); ensure objection mechanism (ch08) actually halts the processing, not just suppresses UI.
Verification needed: LIA document; objection mechanism end-to-end; recurring review schedule for the LIA.---
Evidence needed to close
- Per-row lawful-basis claim in the processing map (with article cite).
- LIA documents for every Art. 6(1)(f) claim.
- Art. 9(2) basis for every special-category activity (and Art. 10 for criminal data).
- Art. 8 mechanism for child-facing processing under consent.
- Stated purpose for each activity, with restriction language ("data collected for X is not used for Y without a fresh basis").
- Access boundaries enforcing per-purpose scope (cross-link ch09).
Chapter 03 — Transparency and notices
Primary articles: Art. 12 (modalities), Art. 13 (info when collected from subject), Art. 14 (info when collected indirectly), Recitals 39, 58, 60. Walk when: user-facing collection points exist OR data is obtained from third parties / scraping / enrichment.
---
What this chapter detects
Whether each collection point is paired with information delivered to the subject, in plain language, at the time of collection (Art. 13) or within a reasonable period (Art. 14, max 1 month). Privacy notices that exist on the site but are not surfaced at the relevant collection moment fail the "concise, transparent, intelligible, and easily accessible form" test in Art. 12(1).
Note: this chapter audits the delivery of transparency. The substantive content of the privacy notice (legal accuracy) is a writing task, not a code-audit task. The code-shaped findings here concern whether and where the notice is presented.
---
Signals to scan
Collection-point coverage
For each collection point identified in ch01 (form, API endpoint, signup, CRM import, third-party SDK, fingerprint scan, enrichment lookup), check:
| Question | Verdict if "no" |
|---|---|
| Is a privacy notice (or layered just-in-time notice) shown / linked at this point? | confirmed_issue |
| Is the notice version recorded with the consent / collection record? | likely_issue (versioning gap) |
| Is the notice in the subject's language? (i18n) | likely_issue |
| Is the link visible (not buried in a footer in 8pt grey) without scrolling on the form? | likely_issue or confirmed_issue per UX |
| Does the notice address the specific processing happening on this surface, or only the global notice? | likely_issue |
Indirect collection (Art. 14)
If the source is not the subject — e.g. data enriched from Clearbit, scraped from LinkedIn, imported from a partner, looked up via a credit bureau, fed by a referral — the controller must inform the subject within a reasonable period (max 1 month). Signals:
| Anti-pattern | Severity |
|---|---|
Enrichment / append code (clearbit.lookup, apollo.io, zoominfo) running silently with no Art. 14 notification path | High |
| B2B contacts imported and emailed without any introductory notice | High |
| Scraping public profiles into a CRM with no Art. 14 cadence | High |
| Data-broker integration with no Art. 14 documentation | High |
Exemptions exist (Art. 14(5)(a)–(d)) — disproportionate effort, legal obligation to keep secret, etc. — but require documentation. Default posture: if the audit can't see the notice path or the exemption rationale, this is a finding.
Required Art. 13(1)/(2) information
Every Art. 13 notice must contain:
1. Identity and contact details of the controller (and DPO if appointed) 2. Purposes of processing and lawful basis (LI must include the specific interest) 3. Recipients / categories of recipients 4. International transfer details + safeguards 5. Retention period or criteria 6. Data subject rights summary 7. Right to withdraw consent (where applicable) 8. Right to lodge complaint with supervisory authority 9. Whether provision is statutory/contractual and consequences of refusal 10. Existence of automated decision-making / profiling + meaningful info about the logic + significance + envisaged consequences
Art. 14 adds: source of the data + categories obtained.
The audit doesn't draft these; it checks the spec / privacy notice exists and is delivered. If the privacy notice file in repo (PRIVACY.md, legal/privacy.md, public/privacy/) is missing items 1–10, that's a writing-task finding, not a code finding — flag as evidence_gap and link to the chapter requirement.
Just-in-time / layered notice signals
EDPB Guidelines on transparency (WP260) endorse layered notices (short notice at point of collection + link to full text). Audit signals:
- Cookie banner with no link to fuller cookie / privacy policy → likely_issue (also ch05)
- Login form with no privacy link → likely_issue
- Signup form with privacy link in footer only → likely_issue
- Account-creation flow with no description of what fields are used for → confirmed_issue
- API onboarding (B2B) with no notice on intake → confirmed_issue
Children-facing transparency (Recital 58)
Notices to children must be in language a child understands. If the product targets children, generic legalese on the privacy page → confirmed_issue.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Next.js / Remix / Astro | Privacy link in <Footer> only; no per-page or per-form notice |
| Shopify / Stripe Checkout | Test that the integration surfaces the merchant's privacy notice, not just Stripe's |
| Auth0 | Universal login with no privacy link → finding |
| Marketing forms (HubSpot, Mailchimp, ConvertKit) | Embedded form lacks site-side notice; vendor's default notice points to vendor, not to controller |
| Clearbit / Apollo / Zoominfo | enrichment without Art. 14 notification path |
| Internal tooling | usually no notice; document in spec that internal users are informed via employment notice (HR domain) |
| In-app messaging (Intercom, Drift) | starting a chat ≠ consent / notice; notice must precede |
Plan / spec signals
- Spec section "Privacy Notice" missing → likely_issue
- Spec lists fields collected, but no plan to show users which fields are collected for what → likely_issue
- "Privacy review pending" left in plan → likely_issue
- Vendor SDK added without notice plan → confirmed_issue
---
False-positive controls
- A B2B service with a single privacy-notice page covering all activities is acceptable when the activities are reasonably foreseeable to the subject. The audit's standard: would a reasonable subject know what's happening?
- Strictly internal tools with no end-user surface inherit notice obligations via employment / HR data-protection notices, not site-wide notices.
- Some integrations rely on the vendor delivering its own notice (e.g. an OAuth consent screen at Auth0 / Google) — that does not relieve the controller of its own Art. 13 duty for downstream processing.
- "Privacy notice will be drafted before launch" in a pre-launch plan is a
likely_issuenot aconfirmed_issueif the spec acknowledges the obligation and the launch hasn't happened.
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| No privacy notice in repo AND no public-site notice referenced in spec/runbook (code-only audit context) | High | evidence_gap (escalates to confirmed_issue if positive evidence shows the deployed product has no notice — e.g. a screenshot, fetched page, or explicit "no notice yet" plan statement) |
| Privacy notice referenced but cannot be located in any provided material | Medium | evidence_gap |
| Notice exists but missing core Art. 13 items | High | confirmed_issue |
| Indirect collection without Art. 14 notice path | High | confirmed_issue |
| No just-in-time notice at high-impact collection points (signup, payment, sensitive forms) | Medium-to-High | confirmed_issue |
| Notice not versioned with consent record | Medium | confirmed_issue |
| Notice not in user's language | Medium | likely_issue |
| Children's surface with adult-language notice | High | confirmed_issue |
| Notice present but inaccessible (footer-only on critical surfaces) | Medium | likely_issue |
---
Sample findings
F-91
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 14(1), Art. 14(3)
Risk to rights: subjects unaware their data was collected; cannot exercise rights.
Location: src/jobs/enrich_contacts.py:14, plans/sales-enrichment.md
Affected data: name, email, employer, role (from Clearbit lookup)
Affected subjects: prospective contacts (B2B)
Processing activity: lead enrichment
Evidence:
enriched = clearbit.Person.find(email=email)
db.contacts.update(email=email, set=enriched)
# no record of Art. 14 notice cadence; sales emails go out next.
Recommended fix: build an Art. 14 notification step into the contact-creation flow — at first outbound contact at the latest, the introductory communication must include the required info (controller identity, purposes, basis, recipients, retention, rights, right to object); document the source ("Clearbit") and the categories obtained; consider whether the LI claim survives a balancing test for B2B cold outreach.
Verification needed: outbound template containing the Art. 14 information; per-contact log entry of when notice was delivered; LIA covering the enrichment activity (ch02).F-92
Severity: Medium / Confidence: High / Type: confirmed_issue
Articles: Art. 12(1), Art. 13
Risk to rights: subjects sign up unaware of what processing they enable; transparency principle defeated.
Location: app/(public)/signup/page.tsx
Affected data: email, password, name (collected at signup)
Affected subjects: prospective users
Processing activity: account creation, downstream onboarding
Evidence:
Signup form has fields and a "Sign up" button. No privacy-notice link near the form. The privacy page exists at /privacy but is referenced only in the footer.
Recommended fix: add a layered notice next to the submit button — one-sentence summary ("We use this to create your account; for more info see our Privacy Notice") with a link; ensure the privacy page covers the full Art. 13 list; record the privacy-notice version with the user record at creation time.
Verification needed: revised form UI; updated privacy page; user record showing notice_version_at_creation.---
Evidence needed to close
- A privacy notice file in repo or production page covering Art. 13(1)–(2) (and Art. 14 where applicable).
- Just-in-time notice rendered at high-impact collection points; layered architecture documented.
- Versioning of notice tied to consent / user records.
- Indirect-collection notice cadence with documented timing.
- For children: language-appropriate notice path.
- For each enrichment / data-broker integration: documented Art. 14 fulfillment plan or documented exemption rationale (Art. 14(5)).
Chapter 04 — Consent and preference management
Primary articles: Art. 6(1)(a) (consent as lawful basis), Art. 7 (conditions for consent), Art. 8 (children's consent — also overlay), Recital 32 (definition). Walk when: consent is claimed or required for any processing activity.
---
What this chapter detects
Whether the consent claim, where made, actually meets the Art. 7 / Recital 32 standard:
- Freely given (no detriment for refusal, no service-coupling, no power imbalance)
- Specific (per-purpose, granular, not bundled)
- Informed (subjects know who, what, why, recipients, retention, rights)
- Unambiguous (clear affirmative action — pre-ticked / inactivity / continued use does NOT count)
- Withdrawable (as easy as it was to give)
- Provable (controller must demonstrate consent — Art. 7(1))
Cookies / tracking SDKs have additional ePrivacy + EDPB requirements; see ch05.
---
Discipline
- Every finding here must name the processing-map row(s) it concerns (e.g. "marketing email", "product analytics", "profile enrichment"). If no row exists, create one in Phase 2 before emitting findings — consent without a declared purpose is itself a finding.
- If special-category data is processed under consent, severity floor escalates and ch14 (DPIA) routing applies (see SKILL.md DPIA routing rule).
---
Signals to scan
Consent UI / capture surface
| Anti-pattern | Signal |
|---|---|
| Pre-ticked checkbox for marketing/analytics | <input type="checkbox" checked> for non-strictly-necessary purposes |
| "I agree to the ToS and to receive marketing" — bundled | single checkbox covering multiple purposes |
| "By using this site you consent" | implied consent banners (rejected by EDPB / CJEU Planet49) |
| "Reject" only available behind extra clicks | dark patterns; equal-prominence rule violated |
| Accept-all button styled prominently; reject hidden | Art. 7(3) "as easy to withdraw" not respected at the capture step either |
| Modal cannot be dismissed without accepting | invalid consent (not freely given) |
| Tracking fires before user choice recorded | timing issue — ch05 also |
| Children's flow has no age gate | Art. 8 violation (overlay triggers) |
| One blanket "I consent to processing" choice for many purposes | not specific |
Server-side consent record
| Field needed | Why |
|---|---|
purpose (granular: marketing-email, marketing-sms, analytics, profiling, etc.) | Art. 7(2) granularity |
granted: bool | self-explanatory |
timestamp | proof |
version of policy / consent text | proof of what they agreed to |
source (web form, API, mobile app, support agent) | accountability |
ip_address, user_agent | proof, but minimize and bound retention |
| audit log of changes (granted → withdrawn → granted) | Art. 7(1) demonstration |
Anti-pattern: a single boolean marketing_opt_in: true on users with no history. This cannot demonstrate consent in a regulator inquiry.
Withdrawal
| Anti-pattern | Signal |
|---|---|
| Withdrawal not as easy as giving | consent given via 1 click; withdrawal requires email + 24h support response |
| Withdrawal does not propagate to processors | withdrawn locally; Mailgun/Segment/PostHog still receives data |
| Withdrawal silently does nothing | unsubscribe route logs but doesn't update the consent record |
| Re-consent prompt after withdrawal nags continuously | Recital 42: continued requests can vitiate consent |
| Withdrawal toggles bundled | "marketing preferences" page with one master switch |
Granularity
- Multiple distinct purposes treated as one (signup → "consent to processing" with no breakdown).
- "Personalization" used as a catch-all (ad personalization, content personalization, recommendations, profiling — all distinct).
- Consent for analytics conflated with consent for marketing.
Special consent contexts
- Special category data (Art. 9): consent must be explicit (Art. 9(2)(a)) — usually means a separate, plainly worded affirmative action, not the same checkbox as ordinary consent.
- Children (Art. 8): under 16 (or Member State threshold 13–16) requires parental authorization; need a verifiable mechanism, not just a self-declared age.
- Employees: consent rarely valid under Art. 7(4) — power imbalance. Most employee data needs Art. 6(1)(b)/(c)/(f), not consent. National context overlay applies (e.g. Germany § 26 BDSG).
- B2B contractual consent: signing the contract is not consent under Art. 7. The contract may rely on Art. 6(1)(b) (necessary for performance) or Art. 6(1)(f), but not Art. 6(1)(a).
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| React form with Yup/Zod | schema requires marketing checkbox .boolean().required() defaulting true → finding |
| Cookie consent libs (OneTrust, Cookiebot, Iubenda, Klaro) | check whether tags actually wait for consent or fire on load |
| GTM | tags fire on Page View trigger before consent state — finding (ch05 too) |
| Auth0 actions / Hooks | post-registration action sets marketing_opt_in: true by default |
| Stripe Checkout | bundled "agree to terms" without separate marketing opt-in is fine if no marketing consent is claimed; if claimed, must be separate |
| Intercom / HubSpot lifecycle | inbound sync overwrites local consent with vendor state |
| Mailgun / SendGrid | not bound to your consent record — must sync on every change |
| Mobile (iOS) | App Tracking Transparency prompt is not GDPR consent; a separate consent UI is required for processing under GDPR scope |
Plan / spec signals
- Spec says "users opt in by signing up" → confirmed_issue
- "We'll capture consent in the welcome email" → likely_issue (after-the-fact, not freely given for first processing)
- Spec lists multiple purposes under one consent flag → confirmed_issue
---
False-positive controls
- Strictly necessary processing (account login, billing for a service the user requested) needs Art. 6(1)(b) or (c), not consent. If the spec correctly cites the right basis instead of consent, that's not a finding.
- Soft opt-in for own-customer marketing (electronic communications about similar products to existing customers, with opt-out at every touch) is permitted under ePrivacy in many Member States — verify per jurisdiction.
- Re-consent campaigns triggered by policy changes are acceptable if not nagging.
- A consent record without
ip_addressis acceptable if there is another reliable proof (e.g., authenticated session at the time of capture).
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Pre-ticked consent checkbox | High | confirmed_issue |
| Bundled consent for multiple purposes | High | confirmed_issue |
| Implied consent ("by using this site...") | High | confirmed_issue |
| No consent record / audit history | High | confirmed_issue |
| Withdrawal harder than granting | High | confirmed_issue |
| Withdrawal not propagated to processors | High | confirmed_issue |
| No granularity per purpose | High | confirmed_issue |
| Special category data without explicit consent | Critical | confirmed_issue |
| Children: no age gate / no parental auth path | Critical | confirmed_issue |
| Employee consent used where Art. 6(1)(b/c/f) should apply | High | likely_issue |
| Reject button hidden / dark pattern | High | confirmed_issue |
| Consent record present but no version / timestamp | Medium | confirmed_issue |
Apply children's overlay → all severities go up; floor = High. Apply special-category overlay → floor = High; explicit consent required.
---
Sample findings
F-61
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 7(1), Art. 7(2), Art. 4(11), Recital 32
Risk to rights: subjects' "consent" does not meet legal threshold; processing has no valid basis.
Location: web/components/SignupForm.tsx:34, db/schema.prisma:55
Affected data: email, marketing preferences
Affected subjects: prospective and end users
Processing activity: marketing email
Evidence:
<Checkbox defaultChecked name="marketing">
I agree to the Terms and to receive marketing emails about products and partners.
</Checkbox>
// schema:
model User { marketingOptIn Boolean @default(true) }
Recommended fix: separate the ToS acceptance from the marketing checkbox; make marketing checkbox unchecked by default; split "products" and "partners" into two distinct opt-ins; replace the boolean with a `consents` table tracking purpose, granted, timestamp, version, source, IP/UA (with bounded retention), and a complete audit log.
Verification needed: signup flow with separate, unchecked marketing options; consents table schema; sample audit-log entries; one-click withdrawal in the same UX style as opt-in.F-62
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 7(3)
Risk to rights: subjects cannot effectively withdraw consent; ongoing processing without lawful basis.
Location: src/services/marketing.ts:48, integrations/mailgun.ts:12
Affected data: email
Affected subjects: end users
Processing activity: marketing email
Evidence:
Local DB sets marketingOptIn = false on opt-out; no API call to Mailgun suppression list; vendor-side list still has the user.
Recommended fix: on withdrawal event, propagate to all processors (Mailgun suppression API, Segment / PostHog identify with anonymized state); maintain an objection register (ch08 Art. 21) separate from consent state; add a contract-test asserting opt-out reaches the vendor.
Verification needed: integration test demonstrating end-to-end opt-out propagation; vendor-side audit confirming entry on suppression list.---
Evidence needed to close
- Per-purpose consent record with required fields and audit history.
- UI showing equal prominence and granularity at capture and withdrawal.
- Withdrawal pipeline that updates all processor states; contract test or integration test confirming propagation.
- Versioned consent text store (so a 2024-Q1 consent claim can be displayed back to the subject).
- For children: age-gate flow + verifiable parental auth + record of which threshold (13/14/15/16) is enforced per Member State.
- For special category: explicit-consent UI + Art. 9(2) basis cited per processing activity in the map.
- Documentation of which processing activities use consent vs. another basis (so Art. 7(4) bundling is avoided at the architecture level).
Chapter 05 — Cookies, tracking, analytics, and SDKs
Primary articles: Art. 6 (lawful basis for the data processing that follows), Art. 7 (consent quality — see ch04). Plus ePrivacy Directive Art. 5(3) (national implementation): consent required for storing or accessing information on a user's device that is not strictly necessary to deliver a service the user explicitly requested. Walk when: browser-side or mobile-side tracking / analytics / advertising / marketing SDKs are detected.
---
What this chapter detects
This chapter is not the same as ch04. ePrivacy adds a layer on top of GDPR: any storage in / read from the device (cookies, localStorage, IndexedDB, fingerprinting, SDK identifiers) needs prior consent when it isn't strictly necessary — regardless of whether GDPR also applies to the data. EDPB / national DPA enforcement here is heavy, especially for GA, Meta Pixel, and ad-tech.
Two things that confuse audits: 1. "Strictly necessary" is narrow. CSRF token = strictly necessary. Analytics = not. A/B-testing = not. Personalization = not. 2. Consent must precede the storage / read; firing on page-load and "waiting for the banner click" already violated.
---
Discipline
- Every finding here ties to a processing-map row covering the tracking purpose (analytics, advertising, A/B-testing, session replay). If no row exists, create one in Phase 2 first.
- Tracking that does any of: profiling, large-scale, special-category-adjacent inference, child surface — also routes to ch14 (DPIA).
---
Signals to scan
Tag-firing timing
| Signal | Verdict |
|---|---|
<script src="https://www.googletagmanager.com/gtag/js"> in <head> without consent gate | confirmed_issue |
GTM container on every page; tags configured to fire on Page View trigger | confirmed_issue |
gtag('config', 'G-XXX') called immediately on app mount | confirmed_issue |
| Tags wrapped in a consent check (e.g. Klaro / Cookiebot / OneTrust callback) that fires only after explicit accept | good signal |
| Conditional script injection after consent recorded | good signal |
Tag fires with consent_mode v2 default = 'denied' and waits for update | acceptable when implemented correctly; verify the update wires to actual consent |
Cookie-banner UX
| Anti-pattern | Signal |
|---|---|
| "Accept all" prominent, "Reject all" missing or hidden behind "Settings" | confirmed_issue (CNIL / EDPB enforcement) |
| Reject requires more clicks than accept | confirmed_issue |
| Color/contrast manipulated: accept = bright button, reject = grey text | confirmed_issue (dark pattern) |
| Banner can be dismissed via X with implicit consent | confirmed_issue (CJEU Planet49) |
| Granular toggles default ON for non-strictly-necessary | confirmed_issue |
| "Continue browsing = consent" message | confirmed_issue (Art. 4(11)) |
| Banner returns immediately after closing without choice | acceptable (no consent recorded → no tracking) |
| "Pay or consent" / "consent wall" forcing tracking acceptance | high-risk (EDPB Opinion 08/2024, Italy Garante actions); confirmed_issue floor |
| "Legitimate interest" toggle on advertising/analytics inside a TCF banner | likely_issue (EDPB has rejected LI for tracking ads) |
Storage actions on the device
document.cookie = '...'for non-essential purposes before consentlocalStorage.setItem(...)for non-essential purposes before consentindexedDB.open(...)for non-essential purposes before consent- Fingerprinting libs (FingerprintJS, ClientJS) running on every visit
- Service-worker registering and caching tracked pixels
Specific high-enforcement SDKs
| SDK | Risk |
|---|---|
| Google Analytics 4 | EU↔US transfer (with adequacy decision); CNIL Italian Garante and Austrian DSB historically required SCCs + supplementary measures pre-DPF; consent gate critical |
| Meta Pixel / Conversions API | health-data leakage class actions (US/EU); enforcement on unconsented firing |
| TikTok Pixel | regulator scrutiny (Ireland DPC) |
| Microsoft Clarity, Hotjar, FullStory | session replay → potential PII / sensitive-data capture; consent-gate strictly |
| LinkedIn Insight Tag | tracking, transfer to US |
| HubSpot tracking | analytics + marketing; needs consent |
| Segment | not a tracker by itself, but routes events to many trackers; verify downstream consent gating |
| Intercom Messenger | identifies users; verify the identify call is gated where applicable |
| Sentry/Datadog RUM | session telemetry; can be argued strictly-necessary for site stability OR not — depends on scope; never include PII (ch10) |
| OneTrust / Cookiebot / Iubenda / Klaro | consent management — verify it actually blocks tags, not just records preferences |
Mobile SDK signals
- iOS App Tracking Transparency prompt is not GDPR consent; cross-app tracking opt-in is separate from your in-app consent for processing
- Android Advertising ID used without consent → finding
- SDKs that initialize trackers in
Application.onCreatebefore any consent UI - Firebase Analytics enabled by default (
firebase_analytics_collection_enablednot gated) - Crashlytics shipping
userId/ email by default → ch10 finding too
Server-side tracking
- "Server-side GTM" / Conversions API does not bypass consent; it shifts the data path, not the legal basis. If the underlying purpose is non-essential, consent is still required.
- "Internal analytics" hosted on first-party domain (
analytics.example.com) is still tracking; ePrivacy applies if it stores/reads on the device.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Next.js / Remix / Astro | tracking script in <head> of _document or root layout — fires on every page render |
| Vue / Nuxt | global plugin registration of tracking SDK without consent guard |
| Cookie banner libs | check that tags are actually deferred (e.g., data-cookieconsent="statistics" attributes used) |
| GTM | gtm.js itself loading before consent → some treat the GTM script as essential, some don't; document the choice |
| Mobile (React Native / Flutter) | analytics SDK initialized in App.tsx mount |
| Cloudflare Web Analytics | claims privacy-first; verify whether it sets cookies / fingerprints |
| Plausible / Umami / Fathom | first-party, no cookies — typically lawful without consent under ePrivacy "strictly necessary or anonymous" framing, but verify the configuration (e.g., outbound link tracking might cross the line) |
---
False-positive controls
- Strictly-necessary cookies (session, CSRF, auth, load balancer affinity, cart) need no consent.
- Self-hosted, fully anonymous, privacy-respecting analytics (no fingerprinting, no cross-site tracking, no cookies, properly aggregated) may not need consent under some Member States — verify per jurisdiction (CNIL has guidance; Datatilsynet differs).
- A consent banner that records preferences but allows the user to navigate away with no tags fired is acceptable.
- A site without any tracking at all needs no banner.
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Tracking/analytics fires before consent | High | confirmed_issue |
| Reject hidden / dark pattern | High | confirmed_issue |
| "Continue = consent" banner | High | confirmed_issue |
| Pre-checked granular toggles | High | confirmed_issue |
| Pay-or-consent wall on a non-trivial service | High | likely_issue (regulator-dependent) |
| GA / Meta Pixel firing without consent | High | confirmed_issue |
| Session-replay / fingerprinting without consent | Critical | confirmed_issue |
| ATT prompt treated as GDPR consent | Medium | confirmed_issue |
| Server-side GTM bypassing consent | High | confirmed_issue |
| First-party privacy-respecting analytics with consent doc gap | Low | advisory |
Apply children's overlay → behavioral advertising / profiling on children faces a much higher bar (Art. 8, Recital 38; UK-only: ICO Age Appropriate Design Code applies). Severity floor High for tracking on a child-facing surface; escalate to Critical when the tracking is profiling-driven advertising or session-replay. The "Critical for any tracking" rule is too absolute under GDPR alone — anchor severity to the specific harm and the specific Member State / UK context that applies.
---
Sample findings
F-71
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 6, Art. 7; ePrivacy Art. 5(3)
Risk to rights: subjects' device storage and tracking activated without lawful basis; cross-border data transfer without their knowledge.
Location: app/layout.tsx:8
Affected data: device identifier, IP, page-view events, click events
Affected subjects: every visitor including non-customers
Processing activity: product analytics
Evidence:
<Script src="https://www.googletagmanager.com/gtag/js?id=G-XXX" strategy="afterInteractive" />
<Script id="ga-init">{`gtag('config', 'G-XXX');`}</Script>
{/* no consent gate; loads on every page render */}
Recommended fix: gate GA initialization behind a recorded affirmative consent for the "analytics" purpose; use Google Consent Mode v2 with `default = 'denied'` for `analytics_storage` / `ad_storage` and call `gtag('consent','update',...)` only after explicit accept; verify reject path actually prevents the network call to `*.google-analytics.com`.
Verification needed: network trace on first visit (no GA hits before consent); consent record matched to the exact tags initialized; reject path proven to keep all GA calls blocked.F-72
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 4(11), Art. 7(3); ePrivacy Art. 5(3); EDPB Cookie Banner Task Force findings
Risk to rights: consent not freely given; subjects nudged into accepting tracking they would otherwise reject.
Location: components/CookieBanner.tsx:12
Affected data: tracking identifiers
Affected subjects: all visitors
Processing activity: marketing/analytics tracking
Evidence:
<button class="primary big">Accept all</button>
<a class="muted small" href="#" onclick="openSettings()">Settings</a>
{/* no equally prominent reject; reject lives behind 'Settings' */}
Recommended fix: add an equal-prominence "Reject all" button at the same level as "Accept all", same styling and click cost; record consent and refusal symmetrically; remove framing language ("recommended", default-styled accept).
Verification needed: revised banner with side-by-side accept/reject; user-flow test confirming reject takes one click; updated consent record schema capturing refusal as a positive negative event.---
Evidence needed to close
- Network trace from a fresh session showing no non-essential storage, fetch, or set-cookie before consent.
- Consent banner with equal-prominence accept/reject; granular toggles default OFF for non-essential purposes.
- Consent Mode (or equivalent) configuration aligned to the recorded consent decision.
- Server-side / Conversions API paths consent-gated with the same logic.
- Mobile flows: tracking SDKs initialized only post-consent; ATT separate from in-app GDPR consent.
- For first-party / "privacy-friendly" analytics: jurisdiction-specific assessment of whether consent is required, documented in the spec.
- Cross-link to ch04 finding(s) on consent record completeness; cross-link to ch12 findings on transfers triggered by these SDKs.
Chapter 06 — Privacy by design / default + minimization
Primary articles: Art. 5(1)(c) (data minimization), Art. 25 (privacy by design and by default). Walk when: always.
---
What this chapter detects
The single most leveraged dimension of a code/plan audit. Privacy by design is not the encryption story (that's ch09) — it's about defaults, scope, and shape of processing. Most "we're GDPR-compliant" repos fail here because PbD requires choices made at design time that are hard to retrofit.
Two questions drive every finding: 1. Minimization (Art. 5(1)(c)): is the minimum necessary data collected, processed, exposed, and retained for the stated purpose? 2. By default (Art. 25(2)): does the out-of-the-box configuration deliver privacy protection without the user having to act?
DPIA routing: when minimization gaps stack (e.g. over-collection + secondary use + third-party flow), the activity often crosses ≥ 2 EDPB criteria — route to ch14.
---
Signals to scan
Over-collection at the point of intake
- Forms / API request bodies asking for fields that have no downstream use. Cross-reference: every collected field MUST appear in a downstream code path that justifies the stated purpose.
- "Just in case" optional fields: phone, address, DOB on a newsletter signup.
SELECT *against tables containing personal data when only a few columns are needed.- GraphQL resolvers that return entire
Usertypes when the consumer needs onlydisplay_name. - Public profile defaults that show email, full name, location.
Defaults that betray privacy-by-default
| Anti-pattern | Detection signal |
|---|---|
| Profile is public by default | is_public: true default in schema; no opt-in flow |
| Newsletter checkbox pre-ticked | <input type="checkbox" checked> for marketing/analytics |
| Friends/contacts visible by default | visibility: "public" default on social/comment models |
| Tracking scripts fire before consent | gtag('config', ...) in <head> without consent gate |
| Search engines indexed by default | no robots: noindex on profile pages until user opts in |
| New user activity shared across the platform | feed/activity model defaults to platform-wide audience |
| Mobile app permissions requested up-front, not contextually | one mega-permission prompt at launch |
Scope creep at the boundary
- API endpoints returning more than the caller's role needs (e.g., admin endpoint reused for end-user listing).
- Internal API exposed to third-party clients with no schema scoping.
- Webhook payloads containing full user objects when an ID would suffice.
- Audit/replication streams (CDC, Debezium, Kafka topics) carrying full PII rather than IDs + change diffs.
- Frontend code receiving full user records for rendering when only a name is shown.
Re-identification risks
- Pseudonymous identifiers that are stable and combinable with public data (e.g.
pseudo_id = sha256(email)reproducible by anyone holding the email). - Geolocation at higher precision than needed (full lat/lng instead of country/city).
- Coarse-grained data + rare attribute = re-identifiable (e.g., postal code + birth date + gender — the classic Sweeney triple).
- Aggregate metrics with low cell counts that allow inference (cohort tables with N<10).
Secondary use / purpose creep
- Production data flowing into analytics warehouses with no filtering / pseudonymization.
- Production data flowing into ML training pipelines with no consent or LIA.
- Support tooling that joins prod tables for "context" without access scoping.
- Logs / event streams treated as a free-form data lake reused across teams.
Stack-specific examples
| Stack | Anti-pattern signal |
|---|---|
| Postgres / MySQL | broad GRANT SELECT ON users TO app_user; missing column-level grants |
| Supabase | RLS disabled on tables with PII; default policy using (true) |
| Firestore / Firebase | rules allow read: if true; on user collections |
| MongoDB | no projection on find() calls; full document returned |
| Prisma | no field-level @@allow / @@deny rules in zenstack/projects using it |
| Hasura | no permission rules per role; select permission with no columns filter |
| Django REST | ModelSerializer with fields = '__all__' |
| FastAPI / Pydantic | response_model = full ORM model rather than a trimmed DTO |
| Express | res.json(user) returning the raw row |
| Next.js | server components fetching full user objects then dropping fields client-side (entire object reaches the bundle) |
| GraphQL | no field-level auth; introspection enabled in production |
Implementation-plan signals
- Plan section "Future use cases for this data" that lists purposes outside the current feature.
- "We'll collect X for now and figure out the use later."
- Architecture diagrams with a wide arrow into a data lake / warehouse with no scoping note.
- "ML team will use this" sections with no consent/LIA/retention design.
---
False-positive controls
- Required-by-law fields are not over-collection (e.g. tax ID for invoicing under Art. 6(1)(c)). Confirm the legal basis is stated.
- Security-purpose telemetry (failed-login IP, anti-fraud signals) has its own legitimate-interest justification under Recital 49 — not automatically a violation. Look for an LIA reference; mark as
evidence_gapif absent, notconfirmed_issue. - Internal admin tools showing full data are acceptable when access is controlled and logged (verify via ch09 and ch10).
- Fields needed only for one-time provisioning are OK if deleted after use — verify retention (ch07).
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Mandatory form field with no downstream use | Medium | confirmed_issue |
| Public-by-default profile/sharing setting | High | confirmed_issue |
| Pre-ticked marketing/analytics consent | High | confirmed_issue (also ch04) |
| Production data flowing to analytics with no filter | High | confirmed_issue |
| Production data flowing to ML training without consent/LIA | High floor (Critical if special category) | confirmed_issue or likely_issue |
| Pseudonymization missing where straightforward | Medium | advisory |
| Over-broad SELECT/projection in code | Medium | confirmed_issue |
| Re-identifiable "anonymous" identifier | High | confirmed_issue |
| Default permissions allow tracking-tool firing pre-consent | High | confirmed_issue (also ch05) |
Apply special-category overlay → severity floor = High.
---
Sample findings
F-12
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(c), Art. 25(1), Art. 25(2)
Risk to rights: subjects' personal data exposed beyond the stated purpose, undermining transparency and minimization.
Location: src/api/routes/users.ts:34
Affected data: email, phone, address, dob
Affected subjects: end users
Processing activity: profile_view (public-facing GET /api/users/:id)
Evidence:
router.get('/users/:id', async (req, res) => {
const u = await db.user.findUnique({ where: { id: req.params.id } })
return res.json(u) // full user record returned
})
Recommended fix: scope the public profile response to a minimal DTO (display_name, avatar_url, public bio); move private fields behind authenticated/owner-only routes; document the public-vs-private split in the data model and processing map.
Verification needed: a typed response model showing only public fields; access tests demonstrating private fields are not returned.F-13
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(b), Art. 5(1)(c), Art. 6
Risk to rights: subjects unaware their data is repurposed for model training; cannot exercise objection.
Location: ml/train.py:8 + plans/ml-recommender.md §3
Affected data: user_id, full event history, profile attributes
Affected subjects: end users
Processing activity: ML model training
Evidence:
df = pd.read_sql("SELECT u.*, e.* FROM users u JOIN events e ON u.id = e.user_id", conn)
model.fit(df)
Recommended fix: define a dedicated lawful basis for training (typically LI with documented LIA, or specific consent for sensitive product surfaces); pseudonymize identifiers before training; add a "purpose: model training" entry in the processing map; add an objection mechanism per Art. 21.
Verification needed: LIA document or consent flow; pseudonymization step in the training pipeline; objection workflow.---
Evidence needed to close
- DTO or projection list documenting which fields cross which trust boundaries.
- Per-collection-point justification for each field (in the processing map or the spec).
- Defaults: written rationale for each privacy-relevant default (visibility, sharing, tracking, notifications), reviewed in code review.
- Production-data-out flows have explicit purpose, lawful basis, retention, pseudonymization choice.
- For pseudonymization: documented salt/key separation (the salt/key is held by a different team/service than the data — otherwise it's not pseudonymization, it's just hashing).
Chapter 07 — Retention, deletion, backups, and derived data
Primary articles: Art. 5(1)(e) (storage limitation), Art. 17 (right to erasure). Walk when: always.
---
What this chapter detects
Whether personal data has a defined end-of-life and whether deletion actually removes it from every store, including derived copies. "Soft delete with deleted_at and forever-living rows" is the most common gap.
---
Signals to scan
Retention policy presence
| Signal | Verdict |
|---|---|
No retention_policy.md / data_retention.md / similar in repo or plan | evidence_gap (severity scales with PII volume) |
| Retention period defined per-table / per-purpose in code or migration comment | good evidence |
| Default retention "forever" or "until account deletion" with no further bound | likely_issue |
| Retention period not justified against purpose (Art. 5(1)(e)) | likely_issue |
Deletion code paths
| Anti-pattern | Signal |
|---|---|
| Soft delete only, never hard | rows with deleted_at flag, no scheduled hard-delete job |
| Hard delete missing for derived stores | code deletes from users but not from events, notifications, cached_profiles, search index, ML feature store |
| Foreign-key cascade unset; orphan rows persist | FKs without ON DELETE CASCADE or explicit cleanup |
| Backups never expire | snapshot retention unset / "indefinite"; cross-region replicas never pruned |
| Logs containing PII never expire | CloudWatch log group / Stackdriver bucket without retention_in_days |
| Search indices retain after primary deletion | Algolia/Elastic index not updated on user deletion |
| Analytics warehouse retains after primary deletion | BigQuery/Snowflake table holds events post-erasure |
| Vendor data retained beyond contract end | no purge step in vendor offboarding |
Erasure-request (Art. 17) workflow gaps
| Anti-pattern | Signal |
|---|---|
| No explicit deletion API or route | grep for delete_user, DELETE /api/users/me, "erasure" — none found |
| Deletion only via CSR queue, no SLA | manual ticket process; no programmatic deadline tracking |
| Deletion does not propagate to processors | no SDK call or webhook to Stripe / Auth0 / Mailgun for the deleted user |
| Cascade misses ML training data | training corpus rebuilt without honoring erasure list |
| Backups: no plan for restoring while honoring past erasures | restore re-introduces deleted users, no reconcile step |
Backup-specific scrutiny
Backups are not exempt from GDPR but are subject to a proportionality argument: you can retain backups beyond erasure provided they cannot be used for ongoing processing. Audit for:
- A documented "backup is dormant; restore requires erasure-reconcile step" policy.
- Restore procedure (runbook) explicitly lists the step to re-apply the erasure register after restore.
- Backup retention is bounded (not "indefinite").
- Backup access is restricted (verify with ch09 access controls).
Derived data and pipelines
- ML feature stores (Feast, Tecton, in-house) keyed by user_id without erasure propagation.
- Event sourcing / Kafka topics with infinite retention.
- Data lakes (S3 + Athena, GCS + BigQuery) holding raw events post-erasure.
- Customer success / CRM mirrors (HubSpot, Salesforce, Intercom) holding profile data without sync of erasure events.
- Embeddings / vector stores keyed by
user_id(each row is derived personal data; the model itself can also memorize).
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Postgres | users table has deleted_at; no cleanup_users cron job |
| Prisma | soft delete via middleware; no scheduled hard-delete |
| Django | UserManager overrides delete() to set flag; no purge command |
| Rails ActiveRecord | paranoia / acts_as_paranoid gem in use; no really_destroy! schedule |
| AWS S3 | bucket without LifecycleConfiguration |
| AWS RDS | BackupRetentionPeriod: 35 without business justification |
| Snowflake | TIME_TRAVEL retention default + FAIL_SAFE = 7+1 days; check if PII retained |
| Elasticsearch / Algolia | no programmatic delete on user-erasure event |
| Kafka | topics without retention.ms set, defaulting to forever |
| BigQuery | tables without partition expiration |
| Auth0 | users not deleted via Management API on app-side erasure |
| Stripe | customer not deleted (Stripe retains for legal accounting reasons; document this as legal-obligation retention, not a fix) |
| Mailgun / SendGrid | suppression lists keyed by email, retained indefinitely; document basis |
---
False-positive controls
- Retention required by law (tax records 7 years, AML records 5+ years) is a valid Art. 6(1)(c) basis. Verify that retention is bounded to the legal minimum and that the basis is documented per-store; mark as
evidence_gapif the law isn't cited, not asconfirmed_issue. - Backups beyond active retention are acceptable when: bounded period, restricted access, restore runbook reconciles erasure register.
- Stripe / Auth0 / payment processors typically retain data under their own legal obligations — that's the controller's compliance through Art. 28(3)(g), not a violation. Document the retention claim per processor.
- Suppression lists for marketing (Recital 70 — keep the email-on-do-not-contact-list permanently) are explicitly permitted; check that the suppression list contains only what's needed (email + opt-out timestamp).
- Anonymization (irreversible) takes a record out of GDPR scope — but verify it's actually anonymization, not pseudonymization (no key, no possible re-identification with reasonable effort, k-anonymity / differential privacy considered).
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| No retention policy at all for a personal-data store | High | evidence_gap |
| Soft delete only with no hard-delete schedule | High | confirmed_issue |
| Erasure does not propagate to processors / derived stores | High | confirmed_issue |
| Backups of unbounded retention with PII | High | confirmed_issue |
| Logs with PII and no retention | Medium-to-High | confirmed_issue (also ch10) |
| Restore runbook does not reconcile erasures | Medium | confirmed_issue |
| Vector store / ML feature store: no erasure pipeline | High | confirmed_issue (also ch13) |
| Retention defined but exceeds purpose duration | Medium | confirmed_issue |
Apply special-category overlay → severity floor High; retention without specific Art. 9(2) basis = High floor.
---
Sample findings
F-41
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(e), Art. 17(1)
Risk to rights: subjects' data persists indefinitely after they leave; erasure right is illusory.
Location: src/services/user.ts:88, schema.prisma:42, (absence) cron/jobs.yaml
Affected data: full user record including email, phone, profile
Affected subjects: end users
Processing activity: account lifecycle
Evidence:
// service: soft delete only
async deleteUser(id) { await db.user.update({ where:{id}, data:{ deletedAt: new Date() } }) }
// schema: no scheduled cleanup; deletedAt has no enforcement downstream
Recommended fix: add a scheduled hard-delete job (e.g., daily) that purges rows where `deletedAt < now() - retention_window`; cascade to events, notifications, search index; emit erasure events to processors (Auth0, Stripe, Mailgun); log each erasure to the audit register.
Verification needed: cron/job definition; sample run output showing rows purged across all stores; processor-side confirmation of deletion.F-42
Severity: High / Confidence: Medium / Type: evidence_gap
Articles: Art. 5(1)(e), Art. 17(1), Art. 32(1)
Risk to rights: backup restoration could re-introduce previously erased subjects.
Location: (absence) — runbook/restore.md
Affected data: full DB backup
Affected subjects: end users
Processing activity: disaster recovery
Evidence:
No restore runbook found in repo; AWS RDS automated backups configured with retention 35 days; no documented step to reconcile erasures post-restore.
Recommended fix: document that backups are dormant copies under restricted access; in the restore runbook, add an explicit step that re-applies the erasure register against the restored data; bound backup retention to a justified period.
Verification needed: published runbook; access policy on backup snapshots; erasure register schema.---
Evidence needed to close
- Per-store retention table tied to processing-map rows (each row → retention basis + period + Art. 5(1)(e) justification or Art. 6(1)(c) legal basis).
- Hard-delete schedule for soft-deleted rows.
- Erasure pipeline diagram covering: primary stores + analytics + ML + search + caches + 3rd parties + logs.
- Backup retention bounds + restricted access + erasure-reconcile step.
- For ML / embeddings / vector stores: documented unlearning approach (retraining schedule that excludes the erasure register, or model-level unlearning).
Chapter 08 — DSAR and data subject rights workflows
Primary articles: Art. 12 (modalities), Art. 15 (access), Art. 16 (rectification), Art. 17 (erasure — also ch07), Art. 18 (restriction), Art. 19 (notification), Art. 20 (portability), Art. 21 (objection), Art. 22 (automated decisions — also ch13). Walk when: product holds personal data of identifiable subjects.
---
What this chapter detects
Whether the system can fulfil all seven rights within the GDPR timeline (1 month, extendable to 3 for complex requests) without manual heroics. Most apps "support deletion" and stop there — that's one right out of seven.
Critical Art. 12 nuances (per ICO / EDPB Guidelines 01/2022 on right of access):
- A request need not be in any particular format. Verbal, email, social media, in-app DM all count.
- A request need not mention "subject access request" or cite Art. 15.
- Identity verification must be proportionate to the risk — demanding ID-card scans for every request is itself a violation.
- Response is free of charge unless manifestly unfounded or excessive.
- Default response is electronic if request was made electronically.
---
Signals to scan
Right-of-access (Art. 15) — the broadest right
| Signal | Verdict |
|---|---|
/api/users/me/export or similar self-service download | good signal |
| Output includes the Art. 15(1) supplementary fields (purposes, categories of data, recipients, retention, source if not from subject, automated-decision logic, rights summary, complaint right) | good |
| Output includes Art. 15(2) transfer safeguards info where third-country transfers exist | good |
| Output includes Art. 15(3) data copy with stated conditions on further copies | good |
Output is a JSON/CSV dump of users.* only, missing supplementary info | confirmed_issue |
| No data export route at all AND no manual DSAR process visible in plan/runbook/spec | confirmed_issue |
| No data export route, but manual/external DSAR process referenced | evidence_gap (verify the manual process actually fulfils Art. 15) |
| No data export route AND no plan/spec mention of DSAR handling | evidence_gap (escalate if PII volume warrants) |
| Manual ticket/email-only workflow with no SLA timer | likely_issue |
| Identity verification disproportionate (national-ID scan for every request) | confirmed_issue |
| Export pulls only one store; misses logs / analytics / ML / vendor copies | confirmed_issue |
No dsar_request table / tracking; deadline tracking by hand | likely_issue |
Discipline note: Art. 15 disclosure splits across Art. 15(1) (supplementary information), Art. 15(2) (transfer safeguards), Art. 15(3) (the data copy). Audit each section as applicable.
Rectification (Art. 16)
- User can edit their own profile core fields → good signal
- Free-text fields editable, but the cached / replicated copies (search index, analytics, vendor CRM) not updated → confirmed_issue
- No "rectification request" path for fields not user-editable (audit notes, manual support entries) → likely_issue
Erasure (Art. 17) — see ch07
Cross-link: ch07 owns the deletion mechanics. ch08 owns the workflow (request capture, identity check, exemption review, response, propagation).
Signals here:
- Deletion request route exists and emits events to all stores → good
- "Delete account" button leaves vendor data intact → confirmed_issue
- Erasure does not propagate to backups via reconcile-after-restore step → see ch07 finding
Restriction (Art. 18)
- A field-level "freeze" mechanism on the user record (e.g.,
processing_restricted: trueflag respected by all read paths) → good - No restriction mechanism at all → confirmed_issue (often missing entirely)
- Restriction implemented as soft-delete, blocking legitimate access (login, billing) → confirmed_issue
Notification of recipients (Art. 19)
- When a user rectifies / erases / restricts, recipients (processors) must be notified unless impossible/disproportionate.
- Webhook to processors on user-event change → good signal
- Erasure does not propagate to processors → confirmed_issue
Portability (Art. 20)
- Export available in machine-readable format (JSON, CSV) → good
- Export covers data the user provided OR generated by their use → check
- Export is human-only (PDF) → confirmed_issue
- Export is not actually portable (custom binary, no schema) → confirmed_issue
- Portability covers data processed under consent or contract; not legitimate-interest data → check the basis
Objection (Art. 21)
- Direct marketing must allow absolute objection (no balancing test) — opt-out works at first touch → good signal
- Profiling for direct marketing must respect objection
- General Art. 21(1) objection (against processing on legitimate-interest grounds): is there a mechanism? Often missing entirely → likely_issue or confirmed_issue
- Profile preferences page does not list "object to processing" choices → likely_issue
Automated decisions / profiling (Art. 22) — see ch13
Cross-link: ch13 covers the underlying processing. ch08 covers the user's right to:
- Obtain human intervention
- Express their point of view
- Contest the decision
Signals:
- "Why was I rejected?" pathway (KYC, credit, hiring, content moderation) → good
- "Talk to a human" exists for automated outcomes → good
- Decision is delivered with no explanation of the logic involved (Art. 13(2)(f) / 14(2)(g)) → confirmed_issue
Workflow infrastructure
- Deadline tracking: 1 month from receipt; ICO/EDPB allow clock pause for ID verification or clarification.
- Audit log of every DSAR (received, verified, fulfilled, extended, refused with reason).
- Exemption catalog (third-party data, legal privilege, trade secrets, crime prevention, management forecasting). Refusals must be explained and can be challenged.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Auth0 | use Management API to delete user; ensure cascade to app DB |
| Supabase | RLS-aware export query; trigger functions for cascade |
| Stripe | stripe.customers.del(id) — note Stripe retains for legal accounting; document per ch07 |
| Mailgun / SendGrid | suppression list update on erasure; remove from contact lists |
| Intercom / HubSpot | API call to delete contact; webhook from erasure event |
| BigQuery / Snowflake | UPDATE/DELETE with merge; or scheduled "erasure register" join |
| OpenAI / vector store | delete embedding rows; if model fine-tuned on user data, document model-side approach |
| Algolia / Meilisearch | delete object on erasure event |
| Firebase | delete user doc + cascade delete child docs via Cloud Function |
---
False-positive controls
- "Delete account" UI that promises deletion is a sufficient request signal — no need for a special "DSAR form".
- Export that excludes data the user did not provide AND was not generated by their use is fine (e.g., aggregated business metrics).
- Some refusals are lawful: third-party data in the export, legal-professional privilege, crime prevention. These need to be explained, not silently dropped.
- The 1-month deadline can be extended to 3 months for complex requests — the audit should look for evidence the extension is justified per request, not a blanket policy.
- Identity verification can use account context: "send the export to the email on file" is often proportionate; a passport scan is rarely.
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| No data-export mechanism in code AND no manual DSAR process visible in plan/runbook | High | evidence_gap (confirmed_issue if subjects can be shown to be refused) |
| Export missing Art. 15 supplementary info | Medium-to-High | confirmed_issue |
| No deletion / erasure mechanism | High | confirmed_issue |
| Deletion does not propagate beyond primary DB | High | confirmed_issue |
| No restriction (Art. 18) mechanism | Medium | confirmed_issue (often pragmatic) |
| Direct-marketing objection not honored / no opt-out | High | confirmed_issue |
| Disproportionate identity verification | High | confirmed_issue |
| No deadline tracking | Medium | likely_issue |
| Charge for first DSAR | High | confirmed_issue |
| Refusal with no explanation | High | confirmed_issue |
Apply children's overlay → request handling must accommodate parents/guardians (Art. 8); severity floor +1.
---
Sample findings
F-51
Severity: High / Confidence: Medium / Type: evidence_gap
Articles: Art. 15, Art. 12(1)
Risk to rights: cannot confirm subjects can exercise the broadest GDPR right; if no manual process exists, foundational compliance gap.
Location: (absence) — no /api/users/me/export, no DSAR route in router; spec/dsar.md not present
Affected data: full user data
Affected subjects: end users
Processing activity: subject-rights handling
Evidence:
Searched: grep -ri "export\|dsar\|subject access\|article 15" -l → 0 hits.
No tracker table; no rate-limit/auth guard for DSARs; no policy doc in provided materials.
Recommended fix: confirm whether a manual DSAR process exists outside the repo (procurement / GRC / runbook); if it does, document the process and verify it satisfies Art. 15(1)+(2)+(3) within deadline; if it does not, build a self-service authenticated export endpoint returning the user's data plus required supplementary information, create a `dsar_request` tracker, and write the DSAR runbook (intake channels, identity-verification proportionality, exemption catalog, refusal letter template).
Verification needed: either a documented manual process with sample fulfilled request, OR working export endpoint + tracker + runbook. This finding upgrades to confirmed_issue if positive evidence shows DSARs are refused or unhandled.F-52
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 21(2), Art. 21(3)
Risk to rights: subjects cannot stop direct marketing; ePrivacy violations compound.
Location: src/marketing/send.ts:24
Affected data: email
Affected subjects: end users
Processing activity: marketing email
Evidence:
if (await user.hasConsent('marketing')) sendCampaign(user)
// no separate objection registry; opt-out link routes to a soft-unsub that requires re-confirmation
Recommended fix: maintain an absolute objection list (email + opt-out timestamp) per Recital 70; opt-out at first touch with no friction; honor across all marketing channels and processors (Mailgun suppression sync); separate from consent table to survive consent-state changes.
Verification needed: objection list schema; one-click unsubscribe; SQL/test demonstrating opt-out is honored across all marketing code paths.---
Evidence needed to close
- DSAR runbook with: intake channels, identity-verification policy with proportionality criteria, deadline tracker, exemption catalog, refusal letter template.
- Self-service export route with Art. 15 supplementary fields.
- Erasure workflow with cascade to all stores listed in the processing map (cross-link ch07).
- Restriction implementation (a flag respected by all read paths or a moderation/freeze model).
- Objection register, separate from consent state.
- Notification mechanism to processors on rectify/erasure/restrict events.
- Per-DSAR audit-log entries (link to ch10).
Chapter 09 — Security, access control, encryption, resilience
Primary article: Art. 32 (security of processing), reinforced by Art. 5(1)(f) (integrity and confidentiality), Art. 25 (technical measures part of PbD). Walk when: always.
---
What this chapter detects
Whether the technical and organizational measures appropriate to the risk are visible in the code, infra, and plan. Art. 32 is risk-proportionate — there is no GDPR encryption checkbox. The audit's job is to surface the gap between the risk implied by the processing map and the controls observable in the artifact.
Five sub-areas: 1. Encryption (transit + rest + key management) 2. Pseudonymization 3. Access control (authn, authz, least privilege) 4. Resilience (availability, restorability, integrity) 5. Regular testing of measures (Art. 32(1)(d))
---
Signals to scan
Encryption — transit
| Anti-pattern | Signal |
|---|---|
| HTTP endpoint serving personal data | http:// URLs in code, no HSTS header, no redirect 80→443 in IaC |
| TLS termination only at edge, plaintext internal | service-to-service http:// in k8s manifests, internal API calls without TLS |
| Outbound calls to vendors over HTTP | http://api.<vendor>.com (rare but happens for self-hosted) |
| Webhook receivers without TLS | app.post('/webhook', ...) mounted on HTTP-only ingress |
| Database connections without TLS | sslmode=disable, ?ssl=false, tls=false in connection strings |
| Old TLS versions | minVersion: 'TLSv1.0', ALB listener with old policy ELBSecurityPolicy-2016-08 |
Encryption — at rest
| Anti-pattern | Signal |
|---|---|
| Cloud storage without encryption | S3 bucket without BucketEncryption; GCS without CMEK/Google-managed; Azure storage without encryption flag |
| Database without encryption | RDS without StorageEncrypted: true; self-hosted Postgres on unencrypted volume |
| Backups unencrypted | manual pg_dump > backup.sql to disk; backups copied to S3 without encryption flag |
| Customer-managed keys (CMK) without rotation | KeyPolicy allows everyone, no EnableKeyRotation: true |
| Application-layer secrets unencrypted at rest | .env files committed; secrets in plain config; Kubernetes secrets without etcd encryption-at-rest |
Pseudonymization (Art. 32(1)(a))
| Pattern | Verdict |
|---|---|
pseudo_id = hash(email) deterministic + key colocated with data | not pseudonymization — it's just hashing; finding |
| Tokenization service holds the mapping; app sees only tokens | proper pseudonymization |
user_id = uuid() separate from email in different table with restricted access | proper pseudonymization |
Aggregate analytics keyed by user_id (the same one used everywhere) | not pseudonymization |
| Salted hash but salt stored next to data | not pseudonymization |
Access control — authentication
| Anti-pattern | Signal |
|---|---|
| Long-lived static API keys for personal-data endpoints | Authorization: Bearer <static> in code without rotation |
| Hardcoded credentials | grep for password=, api_key=, AWS access keys in source |
| Password without sufficient hashing | md5, sha1, bcrypt(rounds=4) |
| MFA absent on admin/staff | no MFA enforcement in admin SSO config |
| Session fixation / no session invalidation on password change | session not regenerated post-auth-change |
| JWT without expiry | iat only, no exp; or excessive exp (>24h) for personal data access |
| Service-to-service auth via shared secrets only | no mTLS, no OIDC, no IAM role assumption |
Access control — authorization
| Anti-pattern | Signal |
|---|---|
| Missing authorization on personal-data routes | router.get('/users/:id', ...) with no auth middleware |
| IDOR | user_id from path used directly in query without ownership check |
| Admin endpoints accessible without role check | /admin/* reachable for non-admin tokens |
| Overprivileged DB roles | app runs as DB superuser; no role separation |
| Postgres RLS disabled in Supabase | ALTER TABLE ... DISABLE ROW LEVEL SECURITY |
Firestore/Firebase rules: allow read: if true; | open data |
| Cloud IAM: wildcard policies | Action: '*', Resource: '*', Principal: '*' |
| Kubernetes RBAC: cluster-admin for app | kind: ClusterRoleBinding with cluster-admin |
Access control — auditability of access (links to ch10)
- Read-access to PII not logged at all → finding
- Audit log uses same DB / writable by same role → integrity gap
- Internal admin tooling has no per-action audit trail → finding (severity scales with PII volume accessible)
Resilience — restorability and integrity
| Anti-pattern | Signal |
|---|---|
| Backups never tested | no documented restore drill in plan/runbook |
| Backups in same region only | RDS automated backups only; no cross-region copy for DR-relevant data |
| Single replica, no HA for personal-data store | DB plan in single AZ |
| No integrity checking on backup files | no checksums / signed manifests |
| Long RPO/RTO undocumented | plan/runbook does not state RTO/RPO for personal-data services |
| Restoration brings back data subjects who exercised erasure | erasure cascading to backups not addressed (link to ch07) |
Testing of measures (Art. 32(1)(d))
- Pen tests / regular security reviews never referenced in plan / repo (ch15 evidence_gap if processing is high-risk)
- Static analysis / SAST / dependency scanning absent from CI
- No vulnerability response process visible
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| AWS | aws_db_instance without storage_encrypted; aws_s3_bucket without server_side_encryption_configuration; security group 0.0.0.0/0:22 open |
| GCP | google_storage_bucket without encryption.default_kms_key_name; google_sql_database_instance without disk_encryption_key_name (CMEK optional but check policy) |
| Azure | storage account without enable_https_traffic_only; SQL server without extended_auditing_policy |
| k8s | imagePullPolicy: Always from public registry without signature verification; pods running as root; no NetworkPolicy |
| Terraform | secrets passed as variables → state file holds secrets; remote state without encryption |
| Postgres | pg_hba.conf trust auth on prod; column-level encryption absent for tokens / health data |
| MongoDB Atlas | network access list permits 0.0.0.0/0 |
| Supabase | RLS disabled; service-role key in client code |
| Auth0 | universal login disabled; no breached-password detection enabled |
| Stripe | publishable key correct, but secret key also exposed client-side |
---
False-positive controls
- TLS termination at a managed edge (ALB / Cloudflare / Vercel) with internal HTTP traffic in a private VPC is not automatically a violation if the threat model is documented. Mark
evidence_gapif no documentation. - Postgres
sslmode=disableon a localhost dev container is not a finding for prod review (verify scoping — the audit reviews the deployed configuration, not local dev). bcrypt(rounds=10)is fine;rounds=4is not. Argon2id with sane params is fine.- Session length is risk-proportionate: a banking app should not have 30-day sessions, a low-risk SaaS may.
- Audit access logs need not log every successful read of every personal-data row; logging staff-tool access and admin-API access is the proportionate floor.
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Personal data accessible without TLS | Critical | confirmed_issue |
| PII at rest unencrypted on cloud storage | High | confirmed_issue |
| Hardcoded credentials in source | Critical | confirmed_issue (also a security incident, not just GDPR) |
| Open IAM policies / RLS disabled with PII present | Critical | confirmed_issue |
| Weak password hashing | High | confirmed_issue |
| MFA absent for admin staff with PII access | High | confirmed_issue |
| Pseudonymization claimed but key colocated with data | High | confirmed_issue |
| No backup/restore drill documented | Medium | evidence_gap |
| Backups not encrypted | High | confirmed_issue |
| No CMK rotation | Medium | advisory |
| Audit log writable by app role | High | confirmed_issue (also ch10) |
Apply special-category overlay → Art. 32(1) + Art. 9 combined; severity floor escalates one tier on this chapter.
---
Sample findings
F-21
Severity: Critical / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(f), Art. 32(1)(a)
Risk to rights: third parties on the network path can read PII; account takeover, profiling, identity theft.
Location: deploy/k8s/api-svc.yaml:14
Affected data: email, profile data, session token
Affected subjects: end users
Processing activity: API /v1/users
Evidence:
spec:
ports:
- port: 80
targetPort: 8080
# no TLS termination; ingress is plain HTTP
Recommended fix: terminate TLS at the ingress (cert-manager + Let's Encrypt or ACM cert on ALB); enforce HSTS; redirect HTTP→HTTPS at the edge.
Verification needed: HTTPS-only ingress config; HSTS header in production response; TLS scan output (e.g., testssl.sh) confirming no plaintext listener.F-22
Severity: Critical / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(f), Art. 32(1)(b), Art. 25(2)
Risk to rights: data leak across tenants; subjects' data visible to other customers.
Location: supabase/migrations/0003_users.sql:12
Affected data: full users table
Affected subjects: end users (multi-tenant)
Processing activity: profile read
Evidence:
ALTER TABLE users DISABLE ROW LEVEL SECURITY;
Recommended fix: re-enable RLS; add per-tenant policies (`USING (org_id = auth.jwt()->>'org_id')`); audit all queries assuming RLS for ownership.
Verification needed: RLS enabled on `users`; policy SQL; test demonstrating cross-tenant isolation under both anon and authenticated roles.---
Evidence needed to close
- Encryption-at-rest evidence: IaC config + storage admin console screenshot showing encryption on (or KMS key used).
- Encryption-in-transit evidence: ingress config + TLS scan output.
- Access control: written role/permission model + per-route auth verification (test or schema).
- Pseudonymization: documented separation of pseudonym key from data, with the key under different access control.
- Backup/restore: documented runbook + last drill date.
- Logging access to PII: ch10 evidence.
Chapter 10 — Logging, telemetry, audit trails, and overcollection
Primary articles: Art. 5(1)(c) (minimization), Art. 5(2) (accountability), Art. 32 (security). Walk when: always.
---
What this chapter detects
Logs and telemetry are where well-designed apps leak personal data inadvertently. They are also where accountability evidence (Art. 5(2)) lives. The chapter audits both directions: PII leaking into logs that shouldn't have it, and PII access not being logged where it should be.
---
Discipline
- Every finding here ties to a processing-map row covering the originating activity (signup, billing, recommendation, support, …) — not just to the logger module. A leaked-email log line maps to the activity that produced it.
- Audit-log gaps for staff PII access route to ch15 accountability.
---
Signals to scan
PII leakage into application logs
| Anti-pattern | Signal |
|---|---|
| Email/phone/PII directly in log lines | logger.info(f"...{email}..."), console.log(user), log.Info(req.body) |
| Request body logged in full | middleware that prints the full request payload |
| Errors include the failing input | raise ValueError(f"invalid email: {email}") → caught by Sentry/Datadog with full message |
| ORM queries logged with parameters | Prisma query.event.params shown in logs; SQLAlchemy echo=True in prod |
| Exception traces with locals | traceback libraries dumping local-variable values containing PII |
| Stack traces shipped to vendor without scrubbing | Sentry / Datadog / Bugsnag without before-send filter |
| Audit log uses same store as application data | audit_log table writable by the same DB role; no append-only constraint |
| Print-debug left in code | print(user), pp.pprint(profile) in production paths |
Telemetry / metrics that re-introduce PII
- Custom metric tags / labels with high cardinality of personal IDs (
user_id,email) sent to Datadog/Prometheus. - OpenTelemetry spans tagged with PII attributes.
- Custom analytics events whose property bag includes the full user record.
- A/B-test exposure logging keyed on
emailor stableuser_idwithout pseudonymization.
Browser-side / mobile-side PII overcollection
- LocalStorage / IndexedDB persisting PII unnecessarily after logout.
- Service worker caches holding PII responses.
- Mobile crash reporters (Crashlytics, Sentry mobile) configured to attach user context including email/name.
Audit trail under-coverage (Art. 5(2) accountability)
| Should be logged | Detection |
|---|---|
| Staff/admin reads of PII | no audit hook on staff tools' GETs |
| Bulk exports of personal data | export endpoint doesn't write to audit log |
| Erasure requests fulfilled | no record of who/when erased |
| Consent given/withdrawn (timestamp + version + source) | consent table has no audit log structure |
| Privilege grants (admin role given to user X by Y at time Z) | IAM changes not audited |
| Cross-team access via support tooling | no per-action correlation log |
Audit trail integrity
- Logs written to the same DB and writable by the same role as data.
- No retention/protection on audit logs (rolling 7-day SaaS retention is not enough for accountability).
- Cleartext sensitive data in audit log records (the audit log is itself an Art. 32 target).
- Audit log can be silently deleted by a privileged user without secondary approval.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
Python logging | logger.info("user %s logged in", user) where user.__str__() includes email |
| FastAPI / Starlette | app.add_middleware(LoggingMiddleware) printing request.body |
| Express morgan | morgan('combined') includes IP + UA into stdout (often acceptable, but check retention) |
| Rails | Rails.logger.info params.inspect — params include passwords/email; check for filter_parameters configuration |
| Sentry | no beforeSend scrubber; sendDefaultPii: true in JS SDK |
| Datadog | tags: ["user_email:${email}"] on custom metrics |
| Cloud logging (Stackdriver / CloudWatch) | log retention indefinite by default → tied to ch07 |
| Audit log in Postgres | audit_log table without INSERT-only policy; RLS not enforced |
---
False-positive controls
- Security-purpose logging of failed-login IPs and rate-limit triggers is permitted under legitimate-interest (Recital 49). Retention should still be bounded.
- Pseudonymized identifiers in logs (
user_idmapped through a separate service) are fine if the mapping table itself is access-controlled. - Stack traces in development environments aren't a finding unless dev artifacts are shipped to prod.
- Logs that hash PII server-side before emit (
logger.info("user", id=hash(user.id))) are not over-collection — but verify the hash is salted with a server-side secret to prevent reversal. - Per-tenant logs in B2B SaaS may legitimately include tenant-internal user IDs without being a finding (verify tenant scoping).
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Cleartext email/phone/PII in app logs | High | confirmed_issue |
| Cleartext password / card / health data in logs | Critical | confirmed_issue |
| Sentry/Datadog with unfiltered request bodies | High | confirmed_issue |
| No PII filter middleware visible | Medium | likely_issue |
| Staff PII access not audited | High | confirmed_issue |
| Audit log writable/deletable by app role | High | confirmed_issue |
| Indefinite log retention with PII | Medium-to-High | confirmed_issue (link to ch07) |
| Mobile crash reporter ships PII | High | confirmed_issue |
| High-cardinality metric tags with stable user_id | Medium | advisory |
Apply special-category overlay → all severities go up one tier when health/biometric/etc. data is involved.
---
Sample findings
F-31
Severity: High / Confidence: High / Type: confirmed_issue
Articles: Art. 5(1)(c), Art. 5(1)(f), Art. 32(1)(b)
Risk to rights: PII exposed to anyone with log access (ops, vendors, anyone gaining a foothold); persists for the log retention window without subjects' awareness.
Location: src/middleware/request_logger.py:14
Affected data: email, phone, password (in failed-login attempts)
Affected subjects: end users
Processing activity: signup, login
Evidence:
@app.middleware("http")
async def log_requests(request, call_next):
body = await request.body()
logger.info(f"{request.method} {request.url.path} body={body.decode()}")
return await call_next(request)
Recommended fix: replace with a redacting logger that drops/masks known-sensitive fields (email, phone, password, token); log structured metadata (method, path, user_id_hash, status, latency) instead of raw body; configure Sentry/Datadog `beforeSend` scrubbers for the same fields.
Verification needed: redacting middleware in place; sample log line free of PII; SIEM/log search for `password` returning zero hits over 24h.F-32
Severity: High / Confidence: Medium / Type: evidence_gap
Articles: Art. 5(2), Art. 32(1)(d)
Risk to rights: subjects cannot trust that data access is monitored; insider misuse or breach goes undetected.
Location: (absence) — internal/admin/users.tsx + API
Affected data: full user record
Affected subjects: end users
Processing activity: staff support tooling
Evidence:
Internal admin route exposes user search and detail view with no audit hook. No `audit_log` insert visible on the GET handlers.
Recommended fix: log every PII read from staff tools with actor, target_user_id (hashed), timestamp, justification (ticket ID); store in append-only audit table or external service; alert on bulk-access patterns.
Verification needed: audit log entries from a sample staff session; integrity controls preventing deletion.---
Evidence needed to close
- Redacting log middleware + sample log output proving no PII leakage.
- Sentry/Datadog/Bugsnag config showing scrubber rules.
- Audit log schema with append-only constraint or external store.
- Staff-tool access logging with actor + correlation-id + justification.
- Log retention policy aligned to ch07.
- Mobile SDK config with PII off (e.g., Sentry
sendDefaultPii: false).
Chapter 11 — Vendors, processors, sub-processors, controller / processor roles
Primary articles: Art. 4(7)–(10) (definitions of controller / processor / joint controller), Art. 24 (controller responsibility), Art. 26 (joint controllers), Art. 28 (processor obligations + DPA), Art. 29 (processing under authority). Walk when: any third-party processor / SaaS / cloud / SDK detected in the processing map.
---
What this chapter detects
For every vendor surfaced during data discovery, two things: 1. Role clarity — controller, processor, joint controller, or independent controller? Most "we use Stripe" descriptions silently assume processor; that's not always right. 2. Article 28 evidence — is a Data Processing Agreement in place? Are sub-processors managed (Art. 28(2) and 28(4))? Are flow-down obligations preserved?
Real DPAs / sub-processor lists are usually outside the repo. The audit's job is to surface where DPA evidence is needed, not assert "missing DPA" without warrant. Use evidence_gap discipline.
DPIA routing: when the vendor performs profiling, scoring, large-scale processing, or biometric / special-category handling on behalf of the controller, the activity routes to ch14 — DPIA threshold check. A DPA does not eliminate the controller's DPIA duty.
---
Signals to scan
Per-vendor machine-checkable signals
For each vendor, gather these concrete artifacts before emitting a finding. Most cannot be inferred from a single API call — the audit must surface what is and is not visible.
| Signal | Where to look |
|---|---|
| API endpoint host (region/tenant clue) | imports, env vars, base URL, SDK config |
| Org plan / tier evidence | code comments, env var names (e.g. OPENAI_ORG_TYPE, _ENTERPRISE), config files, vendor management-API responses |
| Data residency config | tenant ID, region setting, init parameter, IaC variables |
| Retention / training-mode setting | request headers (e.g. OpenAI-Beta, zero-retention), org-level toggle, SDK config |
| DPA URL + effective date | vendors.md, DPAs/<vendor>.md, procurement record, comment linking to vendor's DPA URL |
| Subprocessor notice URL | vendors.md or vendor docs URL |
| Role classification proof | controller / processor declaration in spec or DPA reference |
| Vendor terms version at audit date | "verified-at" date in vendors.md or procurement record |
When fewer than 3 of these are visible for a high-impact vendor, the role-classification finding is evidence_gap, not confirmed_issue. Vendor terms change frequently — every audit must record the date the terms were verified, and findings should be re-checked at the next audit.
Per-vendor role classification
Build this table during the audit. One row per vendor identified in ch01.
| Vendor | Detected by | Likely role | Reason |
|---|---|---|---|
| Stripe | stripe.charges.* | Processor for payment, but Stripe is independent controller for fraud detection / regulatory compliance | hybrid; Stripe documents this |
| AWS / GCP / Azure | cloud SDK / IaC | Processor (infra) | standard processor relationship |
| Auth0 / Okta / Firebase Auth | identity SDK | Processor | controller delegates auth |
| Mailgun / SendGrid / Postmark | email API | Processor | controller defines campaigns |
| Google Analytics 4 | gtag | Processor (Google publishes that GA is processor for site-side data) | check current Google terms |
| Segment | client SDK | Processor (data router) | flow-through to other processors |
| HubSpot / Salesforce / Intercom / Zendesk | CRM / support APIs | Processor for hosted CRM data; check joint-controller scenarios | depends on use |
| OpenAI / Anthropic / Gemini APIs | LLM APIs | Processor (vendor terms) | check zero-retention / training-opt-out config |
| Sentry / Datadog / NewRelic | observability SDKs | Processor | telemetry data |
| Cloudflare / Vercel / Netlify | hosting / CDN | Processor | edge processing of personal data |
Joint-controller signals (Art. 26):
- Co-branded experience where a partner has independent decision-making over purposes (e.g., loyalty program shared with retail partner)
- Embedded social plugins (Facebook Pixel) — CJEU Fashion ID established joint-controllership for the collection step
- Affiliate partners receiving leads with their own marketing follow-up
Independent-controller signals:
- Payment networks (Visa / Mastercard) — independent controllers for the network
- Identity verification providers (Onfido, Persona) when retaining their own audit copy under their own legal obligations
- Tax / accounting integrations where the vendor is itself a controller for tax records
DPA evidence
| Anti-pattern | Severity |
|---|---|
Vendor SDK present; no DPA file in repo, no DPAs/ index, no comment referencing DPA URL | evidence_gap |
| DPA referenced but version unspecified | evidence_gap |
| DPA exists for vendor V1; new vendor V2 added in this PR with no DPA reference | likely_issue |
| Vendor's standard terms used as DPA (where not signed as a DPA) | likely_issue |
| Audit clauses missing from DPA (Art. 28(3)(h)) | likely_issue (writing-task finding, surfaced via spec review) |
Note: DPAs typically live outside source code. The audit creates a vendor inventory with DPA-evidence column — each cell is "linked DPA + version" or "DPA evidence not in provided materials".
Sub-processor management (Art. 28(2) and 28(4))
- Vendor sub-processor list referenced or linked? (Most vendors publish one — Stripe, AWS, etc.)
- Notification mechanism for sub-processor changes (often the controller's right to object)?
- Code awareness of sub-processor scope (e.g., Stripe sub-processors fed through to your DPA list)?
Anti-pattern: a vendor changes sub-processors silently and the controller has no monitoring.
Flow-down obligations
Art. 28(3)(a)–(h) requires the DPA to bind the processor to specific obligations: process only on documented instructions, confidentiality, security (Art. 32), engage sub-processors only with consent, assist with rights and breach notification, return / delete on termination, allow audits.
Audit signals (mostly via plan / DPA link, rarely in code):
- Termination cleanup: code sends data to vendor on contract end with no purge step → evidence_gap (link to ch07)
- Data export / portability from vendor on offboarding → check
- Sub-processor flow-down to fourth-parties → check
Controller / processor confusion
| Pattern | Likely issue |
|---|---|
| App says "we are a processor for our clients", but the audit shows the app makes purpose decisions on its own | role mislabel |
| App is a B2B SaaS embedding analytics for itself AND for clients without separating data scopes | role mislabel + tenant leakage risk |
| Multi-tenant SaaS pools tenant data into a shared analytics warehouse without instruction | controller drift |
Mislabelling carries Art. 28 / Art. 24 consequences either direction: a "processor" who acts as controller without basis is unlawfully processing; a "controller" claiming processor status to dodge transparency is non-compliant.
Stack-specific examples
| Stack | Concrete pattern |
|---|---|
| Vercel / Netlify / Cloudflare Pages | hosting + edge functions = processor; check region commitment |
| Supabase / PlanetScale / Neon | DBaaS = processor; check data residency |
| Segment / RudderStack | router = processor; downstream destinations multiply DPA obligations |
| Workato / Zapier / n8n | iPaaS = processor; check sub-processor exposure |
| AI APIs | check zero-retention setting, training-opt-out, region selection (e.g., Anthropic with regional endpoints; OpenAI Enterprise data-residency) |
| Slack (for support tooling) | Slack as processor when used for customer data; check enterprise plan controls |
| Github / GitLab (when processing user code with PII) | processor; check enterprise data-residency |
Plan / spec signals
- "Vendor: <X>" in a system-context section, with no DPA / sub-processor / region notes → evidence_gap
- New vendor added in a PR / RFC with no procurement-style review → likely_issue
- Contract notes "we'll sign their standard terms" without DPA review → likely_issue
---
False-positive controls
- An open-source library run on the controller's own infra is not a vendor relationship — no DPA needed (the library doesn't process data for the controller, the controller does).
- Standard cloud provider DPAs (AWS, GCP, Azure) are well-established and usually self-serve through the console; absence of a literal signed paper isn't a finding if the auto-DPA acceptance is documented.
- "Vendor terms include GDPR processor commitments" can substitute for a separate DPA when the terms cover Art. 28(3) — but verify, don't assume.
- A vendor that is genuinely unaware of personal data (e.g., a logging library that the controller misuses to log PII) is not at fault — the finding belongs to the controller (ch10).
---
Severity rules
| Symptom | Severity | finding_type |
|---|---|---|
| Vendor processing PII with no DPA evidence | High | evidence_gap (escalate to confirmed_issue if there's positive evidence DPA was not signed) |
| Joint controllership unrecognized (e.g., embedded social plugins without Art. 26 arrangement) | High | likely_issue |
| Role mislabel that affects Art. 13/14 transparency | High | confirmed_issue |
| Sub-processor changes with no monitoring | Medium | evidence_gap |
| Vendor termination without data-purge step | Medium | confirmed_issue (link to ch07) |
| AI vendor with default training-opt-in (e.g., default ChatGPT consumer terms) | Critical | confirmed_issue |
Apply special-category overlay → DPA must address Art. 9 specifically; severity floor +1.
---
Sample findings
F-101
Severity: Critical / Confidence: High / Type: confirmed_issue
Articles: Art. 28, Art. 32, Art. 5(1)(b)
Risk to rights: subject data sent to LLM vendor under default consumer terms; data may be used for model training; no processor commitments.
Location: src/services/ai_assist.ts:12
Affected data: user message, account context (full conversation transcript)
Affected subjects: end users
Processing activity: AI-assist feature
Evidence:
const r = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}` },
body: JSON.stringify({ model: 'gpt-4', messages: [...userHistory] }),
})
// no zero-retention header / org settings; no training opt-out documented
Recommended fix: move to an OpenAI enterprise / business plan (or equivalent processor terms); enable zero-retention / training-opt-out at the org level; sign a DPA; document the vendor in the processing map; check transfer mechanism (ch12); update privacy notice (ch03) to disclose the recipient and purpose.
Verification needed: signed DPA artifact (or terms link with effective date); vendor org settings screenshot; processing-map row for "AI-assist" with vendor and basis; updated notice copy.F-102
Severity: High / Confidence: Medium / Type: evidence_gap
Articles: Art. 28(1), Art. 28(3)
Risk to rights: processor relationship lacks documented obligations; controller cannot demonstrate Art. 24 accountability.
Location: src/integrations/segment.ts:5, plan/spec docs (no DPA reference)
Affected data: user_id, behavioral events, IP, UA
Affected subjects: end users
Processing activity: product analytics routing
Evidence:
Segment SDK initialized with write key; no `DPAs/segment.md`, no comment linking to vendor DPA, no procurement record in provided materials.
Recommended fix: locate the executed Segment DPA; link it from `vendors.md`; verify Art. 28(3) clauses (instructions, confidentiality, sub-processor, security, audit, return/delete); document Segment's downstream destinations as further sub-processor exposure.
Verification needed: signed DPA; vendor inventory; sub-processor-change notification setup.---
Evidence needed to close
- Vendor inventory tied to processing map: each row has role (controller / processor / joint / independent), DPA evidence, sub-processor list link, region, transfer mechanism if applicable.
- Procurement / change-management process for adding new vendors.
- Documented mechanism to receive sub-processor change notices and exercise objection rights.
- For AI vendors: enterprise/business terms + zero-retention + training-opt-out config screenshots / API headers.
- For joint controllers: Art. 26 arrangement (often a written addendum) and consistent privacy-notice language.
- For tenant-isolation in B2B: documented split between "controller-of-tenant-data" and "controller-of-product-telemetry" scopes.