DEV Community

jaryn
jaryn

Posted on

Audit a git diff Before a Model Sees the Password You Deleted

The patch looked harmless. A reviewer asked an agent to summarize a PR. The model got git diff origin/main...HEAD. In the deleted lines sat DB_PASSWORD=P@ssw0rd-from-staging. Nobody added a secret. They removed one. The model still saw it.

That is the failure. Not a novel exploit. A trust-boundary violation hiding in a minus line. Can your context packer tell the difference?

I did not pull this from a production incident. I keep a folder of failing patches because unified diffs are the most common thing teams feed a coding model, and deleted hunks are the least reviewed surface. If the packer ships the whole patch, the model inherits every secret that ever lived in that file during the branch.

The minus-line problem

Added lines get scrutiny. Deleted lines do not. Humans skim the green. Models consume both sides of a hunk. A revert of .env.local, a rollback of application.yml, or a “moved the token to CI” commit is a leak in disguise.

Ask the ugly question. If the password is gone from HEAD, is it gone from the prompt? Usually no.

Remote models make this worse: the secret leaves your network. Local or self-hosted models do not magically fix it. Prompt caches, debug logs, and operator consoles still sit on the other side of the same boundary. Free inference is not a privacy control.

Trust boundaries for a review agent

Treat the review path as four rooms. Secrets may exist in the workspace. They must not cross into model input.

[git working tree] --patch--> [context packer] --prompt--> [model runtime]
        |                           |                         |
   secrets OK                  MUST DENY                 never stores
   on disk                  secret-shaped            credentials by design
Enter fullscreen mode Exit fullscreen mode

Three assets matter here:

  1. Deleted credentials in unified diffs (- KEY=value).
  2. Local override files that never belonged in a review (*.local, .npmrc, docker-compose.override.yml).
  3. The packer’s own logs. If the packer prints the rejected prompt “for debugging,” you just moved the leak.

Threat actors are boring. A developer pasting a patch. A CI job that dumps git show. An agent that glob-reads **/* before the review prompt. None of them need to be malicious.

Fixtures: one must fail, one must pass

Label these as unexecuted templates. Pin the shape, not a CVE. I use Python 3.12 and a unified diff, nothing else.

Negative fixture (must fail the gate). A password appears only on a deleted line. HEAD is clean. The prompt is not.

--- a/.env.local
+++ b/.env.local
@@ -1,3 +1,3 @@
-DB_PASSWORD=P@ssw0rd-from-staging
+DB_PASSWORD=${DB_PASSWORD}
 LOG_LEVEL=info
Enter fullscreen mode Exit fullscreen mode

Positive fixture (must pass). Public config only. No credential-shaped assignments in added or deleted hunks.

--- a/config/app.toml
+++ b/config/app.toml
@@ -1,3 +1,4 @@
 log_level = "info"
+feature_flags.refresh = true
 request_timeout_ms = 2500
Enter fullscreen mode Exit fullscreen mode

Second negative fixture. An .npmrc auth token in an added line. Different file class, same invariant.

--- a/.npmrc
+++ b/.npmrc
@@ -1,2 +1,2 @@
 registry=https://registry.npmjs.org/
+//registry.npmjs.org/:_authToken=npm_1a2b3c4d5e6f
Enter fullscreen mode Exit fullscreen mode

If your packer ships any of the negative patches to a model, the gate is missing. Do not call that “a finding in product X.” Call it a missing invariant in your pipeline.

A gate you can run before any model call

The rule is simple. Scan the patch the agent would receive. Fail closed on credential-shaped lines in both + and - hunks. Ignore file headers. Ignore hunk headers. Do not pretend this is DLP. It is a regression fixture.

Save as scan_diff_secrets.py:

#!/usr/bin/env python3
"""Fail closed if a unified diff still carries secret-shaped lines.

Unexecuted template. Tune patterns to your repo. Not a DLP product.
"""
from __future__ import annotations

import re
import sys
from pathlib import Path

SECRET_LINE = re.compile(
    r"""(?ix)
    (?:
        (?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)
        \s*[=:]\s*\S+
      | _authToken\s*=\s*\S+
      | BEGIN\ (?:RSA|OPENSSH|EC)\ PRIVATE\ KEY
    )
    """
)

SKIP_PREFIXES = ("diff ", "index ", "--- ", "+++ ", "@@ ")


def offending_lines(patch: str) -> list[str]:
    hits: list[str] = []
    for raw in patch.splitlines():
        if not raw or raw.startswith(SKIP_PREFIXES):
            continue
        if raw[0] not in "+-" or raw.startswith(("+++", "---")):
            continue
        body = raw[1:]
        if SECRET_LINE.search(body):
            hits.append(raw)
    return hits


def main(argv: list[str]) -> int:
    if len(argv) != 2:
        print("usage: scan_diff_secrets.py <patch>", file=sys.stderr)
        return 2
    patch = Path(argv[1]).read_text(encoding="utf-8", errors="replace")
    hits = offending_lines(patch)
    if hits:
        print("REFUSE: secret-shaped lines in model-bound diff")
        for line in hits:
            print(f"  {line[:120]}")
        return 1
    print("ALLOW: no secret-shaped +/- lines")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
Enter fullscreen mode Exit fullscreen mode

Wire it in front of the packer. Not after. Not “in the model system prompt.” After is a diary of the leak.

# Unexecuted template. Run in a throwaway clone.
git diff origin/main...HEAD > /tmp/review.patch
python3 scan_diff_secrets.py /tmp/review.patch
echo "exit=$?"
Enter fullscreen mode Exit fullscreen mode

Expected evidence:

Fixture Exit Why
Deleted DB_PASSWORD=... 1 Minus line still enters the prompt
Added _authToken= 1 New secret in the review context
Public app.toml change 0 No credential-shaped assignment

If exit 1 does not block the HTTP call to the model, you built a linter, not a boundary. I want the packer to refuse. Do you?

Prevent, detect, recover

Layer Action What it actually enforces
Prevent Allowlist files the packer may read (*.md, *.toml, src/**). Deny *.local, .env*, .npmrc, id_rsa, *.tfvars, *.pem. Secrets never become tokens.
Prevent Run scan_diff_secrets.py on the exact bytes you would send. Deleted hunks cannot sneak through.
Detect Log hashes of refused patches, never the patch body, to the packer audit stream. You can prove a deny without reprinting the password.
Recover Rotate any credential that matched a refused pattern if the prompt might already have left the host. The gate is not a time machine.

A system prompt that says “do not look at secrets” is not a row in this table. The model cannot unsee a minus line.

What still must not go to a model

Even a passing diff is not a blank check. Keep these out of prompts whether the runtime is remote, free, or in your rack:

  • Private keys and kubeconfigs (file contents, not just filenames).
  • Session cookies, Authorization headers, and signed URLs.
  • Production connection strings in stack traces.
  • Customer payloads sitting in fixture JSON.

A filename allowlist is cheaper than a regex. Regex is the backstop for the files you do send.

Where a self-hosted coding stack fits

The gate above is useful with any model. It stays useful if you strip every product name out of this article. The product question is only this: once the patch is clean, where does the prompt run?

Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source AI development platform. Operator-supplied availability for evaluation includes free model access and a free server option. I am not attaching token quotas, hardware SKUs, or permanence claims here; those numbers rot, and a quota is not a trust boundary.

If you are evaluating a self-hosted coding stack, run the fixture against your context packer before you point a model at a real branch. Local inference can shrink the blast radius after the gate. It does not replace the gate.

Limitations — who should not use this

This scanner is a coarse regex over unified diffs. It will miss encodings, split strings, Kubernetes Secret YAML that uses data: base64, and secrets with unusual key names. It will false-positive on docs that say password=example. That is acceptable for a CI reject of model context. It is not acceptable as your only control for regulated data.

Do not use this approach if:

  1. You need guaranteed DLP, redaction, or legal hold. Buy or build that separately.
  2. Your agent reads files by glob and never produces a patch. Gate the glob, not git diff.
  3. You plan to log refused prompts in plaintext “just for a week.” That week is the incident.

I would rather fail a review job than debug a leaked staging password in a model log. Which invariant belongs in CI, and which layer should enforce it: the packer, the model host, or both?

Top comments (0)