Inspiration

A customer data leak does not need an attacker. A log line prints the request body, an error report attaches the submitted form, an analytics call sends the whole user, and a password now sits in three places nobody reviews. In 2024, Ireland's Data Protection Commission fined Meta 91 million euro for passwords kept in plaintext on internal systems. GitHub and Twitter both found passwords written to internal logs by bugs in 2018. Code review rarely catches this, because nothing in a diff says "this field now goes to a vendor".

What it does

Leak Gate turns every merge request into a privacy test that runs after the code is written and before it merges.

  1. GitLab CI deploys a review copy of the app for the merge request.
  2. The drill plants a synthetic customer whose name, email, phone, password and address each carry a unique tag, and runs her through signup, checkout and support.
  3. It searches every place her data can land: the app's CloudWatch logs, the requests its vendors received, and its database.
  4. A planted value found anywhere privacy/datamap.yml does not allow is a leak. Matching is exact, so no model decides what counts.
  5. The drill fails the pipeline and publishes a JUnit report and a GitLab security report.
  6. The failure triggers a GitLab Duo custom flow. Its agent reads the failed job, traces each captured line to the code that wrote it, commits the smallest change that stops it, and explains each change on the merge request. If the data went somewhere the data map has never heard of, it does not guess: it holds the merge request for the privacy lead and changes no code.
  7. The pipeline drills the fix. After the merge, a person runs the manual production deploy, production is drilled, and the release carries an updated record of processing for the privacy lead.
  8. An hourly pipeline schedule drills production and opens an incident if a planted value turns up where it should not.

On merge request !1, a newsletter opt-in leaked name, email, phone and password to app logs, the error tracker and analytics: 11 leaks. The flow committed one fix as its own service account, the same drill found 0 leaks, and the change shipped with a release.

Merge request !2 syncs new customers to a CRM that the data map has never heard of. The drill found name, email and phone going there. The flow held the merge request for the privacy lead and changed no code, writing that "either change is a privacy decision". The privacy lead added the CRM to the data map for name and email, not phone. The drill then found one leak left, phone to the CRM, and the flow removed it. The drill passed, a person ran the production deploy, and the release lists the CRM in its record of processing.

When the production canary was aimed at the leaky build on purpose, it failed and opened an incident, and the same flow found no merge request and made no change. Production stays a person's call.

The console shows all of it without a sign-in, and anyone can run a live drill against the kept leaky build with a first name of their choosing.

How I built it

  • Shop: a fictional plant-subscription app in Next.js on AWS Lambda. It is the code under test.
  • Drill: a TypeScript package that plants the customer, runs the journeys over HTTP, reads CloudWatch Logs and DynamoDB, compares against the data map and writes JSON, JUnit, GitLab security and Markdown reports.
  • Gate: the console, simulated vendor endpoints that record exactly what the shop sends them, and a runs API. Next.js on AWS Lambda, runs stored in DynamoDB.
  • Pipeline: .gitlab-ci.yml with test, SAST and secret detection, build, package, review deploy, drill, manual production deploy, production drill, release, and an hourly canary. CI reaches AWS with its GitLab ID token through an OIDC role, so no AWS keys are stored in GitLab.
  • Fix flow: .gitlab/duo/flows/leak-fix.yaml, a GitLab Duo Agent Platform custom flow with a fixed toolset. Its instructions limit edits to the shop's code and treat captured log text as data, never as instructions.

How GitLab automates the post-code lifecycle here, stage by stage:

  • Plan: issues #1 and #3 for the features, closed by their merge requests; incidents opened by the canary.
  • Create: the Duo flow commits the fix to the merge request branch, or holds it for the privacy lead.
  • Verify: unit tests and the drill job, both with JUnit reports.
  • Package: each shop build goes to the project's generic package registry.
  • Secure: SAST, secret detection, and the drill's findings as a security report.
  • Release: a review environment per merge request, a production environment, and releases carrying the record of processing.
  • Configure: the data map, CI/CD variables and OIDC trust to AWS.
  • Monitor: the hourly schedule that drills production and opens an incident on a leak.
  • Govern: any leak fails the merge request pipeline, and production deploys only when a person runs the manual job.

Challenges I ran into

  • CloudWatch Logs makes a Lambda's lines searchable a few seconds after they are written, so a drill that searched straight away found nothing. The shop now writes a tagged access line for every drill request, and the drill waits until its own run's lines are searchable before it compares.
  • The flow reads captured log lines, and a log line holds whatever a user typed. Its prompt treats every excerpt, log and merge request text as untrusted, and its toolset has no shell.
  • The first version of the production canary read the wrong exit code, so a leak in production would have passed. It now keeps the drill's own exit code and opens an incident only on a leak.
  • The flow found a bug in Leak Gate itself. On merge request !2 its note said the report blamed the checkout route for a call made during signup. The CI runner's clock and AWS's disagreed, so captures fell into the wrong journey's window. Each journey is now timed from the shop's own access line for it.

What worked

The loop on merge request !1 ran without a human code edit: 11 leaks, one flow commit, 0 leaks, merged, deployed, drilled in production, released. On merge request !2 the only human step was the privacy decision, one change to the data map. The privacy lead was played by the builder. A live drill on the console takes about 7 to 13 seconds.

What's next

  • Find planted values after hashing or encoding. Matching is exact today.
  • Cover browser storage and other databases.
  • Describe journeys in a file instead of code, so Leak Gate can drill an app other than this shop.

Built With

Share this project:

Updates

Submission history