Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

272 lines · 20.7 KBRaw History
1---
2title: Push protection
3description: How Diffinery scans git pushes, imported history, web editor commits and API and MCP writes for leaked credentials, and how to opt out or bypass.
4---
5
6Push protection scans the files in every `git push` for credentials such as cloud keys, API tokens and private keys, and rejects the whole push if it finds one. The same scan runs on every commit made without a Git client: the web editor, `POST /repos/{owner}/{repo}/diffinery/commits`, the contents API and the MCP `commit_files` tool, and on files written through the git data API. It is always on for every repository: there is nothing to enable, and nothing from a blocked push or write is stored. History brought in by an [import from GitHub](/docs/migrate-from-github) is scanned too (see [Imported history](#imported-history)).
7
8## How it works
9
10When you run `git push`, Diffinery receives your packfile and checks it before it writes anything:
11
121. It unpacks the packfile in memory. No object is written to the repository yet.
132. It scans the new file contents (blobs) in the pack against a fixed list of credential patterns.
143. If nothing matches, it stores the objects and then applies the usual checks (permissions and [branch protection](/docs/repositories/branch-protection)) to each ref you pushed.
154. If anything matches, it rejects every ref in the push, prints what it found, records the attempt in the [audit log](/docs/security/audit-log) and emails you. Nothing from the pack is kept, so a retry starts from a clean state, like git's own object quarantine.
16
17A push is all or nothing: if one commit on one branch contains a secret, every branch and tag in that push is rejected, not only the affected one.
18
19## What is detected
20
21Diffinery looks for these credential types. Each name below is exactly what appears in the push output, the audit log and the email.
22
23| Name | What matches |
24|---|---|
25| `AWS access key` | `AKIA` or `ASIA` followed by 16 uppercase letters or digits |
26| `GitHub token` | `ghp_`, `gho_`, `ghu_`, `ghs_` or `ghr_` followed by 36 letters or digits, or `github_pat_` followed by 82 letters, digits or underscores |
27| `Diffinery token` | `dfy_` followed by at least 32 letters, digits, `_` or `-` (a Diffinery personal or agent token) |
28| `Slack token` | `xoxb-`, `xoxa-`, `xoxp-`, `xoxr-` or `xoxs-` followed by at least 10 letters, digits or `-` |
29| `Stripe live key` | `sk_live_` or `rk_live_` followed by at least 20 letters or digits |
30| `Google API key` | `AIza` followed by 35 letters, digits, `_` or `-` |
31| `Anthropic API key` | `sk-ant-` followed by at least 20 letters, digits, `_` or `-` |
32| `OpenAI API key` | `sk-` or `sk-proj-` followed by at least 40 letters, digits, `_` or `-` |
33| `Private key` | A PEM header line: `-----BEGIN PRIVATE KEY-----`, or the same with `RSA`, `EC`, `OPENSSH` or `DSA` before `PRIVATE KEY`, or `-----BEGIN PGP PRIVATE KEY BLOCK-----` |
34
35Each pattern must stand on word boundaries, so a key embedded in a longer word does not match. Some other details:
36
37- A file is reported at most once per credential type, even if it holds several keys of that type.
38- One value can match more than one type. For example, an Anthropic key also has the shape of an OpenAI key, so the output can list both `Anthropic API key` and `OpenAI API key` for the same file.
39- Only live Stripe keys are detected. Test keys (`sk_test_...`) are not.
40- An encrypted PEM key (`-----BEGIN ENCRYPTED PRIVATE KEY-----`) is not matched by the `Private key` pattern.
41- The list is fixed. You cannot add your own patterns.
42
43## What is scanned
44
45| Scanned | Not scanned |
46|---|---|
47| Blobs (file contents) contained in the packfile of a `git push` | Commits, trees and tags themselves (commit messages are not scanned) |
48| Text files of any size, in a push or a commit made without Git | |
49| Every ref in the push: branches and tags | Binary files: any blob that contains a NUL byte anywhere |
50| Files you add or change in the web editor | Files that contain the text `diffinery:allow-secret` (see [Opting a file out](#opting-a-file-out)) |
51| Files in a commit made with `POST /repos/{owner}/{repo}/diffinery/commits` or the MCP `commit_files` tool | Objects the server already has, which git does not send again |
52| Files written with `PUT /repos/{owner}/{repo}/contents/{path}` | Git LFS objects, which are uploaded separately from the push |
53| Blobs created with `POST /repos/{owner}/{repo}/git/blobs`, and inline `content` in `POST /repos/{owner}/{repo}/git/trees` | Release assets |
54| | Deleted files |
55
56Pushes larger than 100 MB are refused before scanning with `push exceeds 100 MB; split it into smaller pushes`.
57
58> **Important:** Push protection is a safety net, not a guarantee. It finds only the formats listed above, in text files. Never rely on it as your only defense: keep secrets in [Actions secrets](/docs/actions/secrets-and-variables) and out of your repository.
59
60## When a push is blocked
61
62### What you see
63
64git prints Diffinery's messages with a `remote:` prefix, followed by a rejection for each ref:
65
66```text
67$ git push origin main
68Enumerating objects: 5, done.
69...
70remote: Push blocked: possible secrets detected.
71remote: AWS access key in blob 3f2a9c1d0e
72remote: Private key in blob 8b41e07a5c
73remote: Remove the secret from your commits, or rotate it and contact an admin.
74To https://diffinery.com/acme/website.git
75 ! [remote rejected] main -> main (secret detected (push protection))
76error: failed to push some refs to 'https://diffinery.com/acme/website.git'
77```
78
79The format is:
80
81- The first line is always `Push blocked: possible secrets detected.`
82- Then one line per finding, ` <type> in blob <first 10 characters of the blob id>`, for at most 10 findings.
83- Then `Remove the secret from your commits, or rotate it and contact an admin.`
84- If you passed `-o allow-secrets=...` but are not allowed to bypass, one more line: `Only repository admins (people, not agents) can bypass push protection.`
85- Every ref in the push is rejected with the reason `secret detected (push protection)`.
86
87### What else happens
88
89- The attempt is recorded in the [audit log](/docs/security/audit-log) as `push.blocked_secret`, with the repository as the target and the detected types and refs as detail. The secret values themselves are never recorded.
90- You get an email with the subject `[acme/website] Your push was blocked: possible secrets`. If an agent pushed, the email goes to the agent's owner (for an organization's agent, to the organization's owners and admins). This is a security email and is sent whatever your notification settings are.
91- Nothing from the push is stored in the repository.
92
93### How to fix it
94
951. Find the file. The blob id prefix in the output identifies the file version. In your local clone, run:
96
97 ```sh
98 git rev-list --objects --all | grep '^3f2a9c1d0e'
99 ```
100
101 The line it prints ends with the path of the file.
102
1032. Remove the secret from every commit you are pushing, not only the latest one. For the most recent commit:
104
105 ```sh
106 # edit the file to remove the secret, then
107 git add path/to/file
108 git commit --amend --no-edit
109 ```
110
111 If the secret was added in an earlier commit, rewrite those commits with an interactive rebase (`git rebase -i origin/main`) and edit each one that contains it.
112
1133. If the value was a real credential, rotate it at its provider. Treat it as exposed: it was on your machine in a commit and may have been pushed elsewhere.
114
1154. Push again.
116
117## Commits and writes without Git
118
119Commits made without a Git client run the same scan before anything is written. This covers:
120
121| Where | What is scanned |
122|---|---|
123| The web editor (**Commit changes** on a new or edited file) | The file's new content |
124| `POST /repos/{owner}/{repo}/diffinery/commits` | Every file in `files` that isn't a delete |
125| The MCP `commit_files` tool | Every file in `files` |
126| `PUT /repos/{owner}/{repo}/contents/{path}` | The new file content |
127| `POST /repos/{owner}/{repo}/git/blobs` | The blob |
128| `POST /repos/{owner}/{repo}/git/trees` | Each tree entry given with inline `content`; entries given by `sha` refer to blobs already stored and are not scanned again |
129
130The patterns, the binary-file rule and the `diffinery:allow-secret` opt-out are the same as for pushes. Every text file is scanned, whatever its size.
131
132### When a commit is refused
133
134A commit that contains a credential fails with `422` and nothing is stored. For the web editor, the commits API, the contents API and MCP, the message names the types and up to 10 paths:
135
136```json
137{
138 "message": "push protection: possible secret detected (AWS access key) in config/deploy.env. Remove it, or rotate it and ask an admin.",
139 "documentation_url": "https://docs.github.com/rest"
140}
141```
142
143The web editor shows the same message above the form and keeps your edit, so you can remove the value and commit again. If you sent `allow_secrets` but aren't allowed to bypass, the message ends with `Only repository admins (people, not agents) can bypass push protection.`
144
145The git data endpoints answer `422` with the shorter `push protection: possible secret detected (AWS access key)`.
146
147Each refused write is recorded in the [audit log](/docs/security/audit-log) as `api.blocked_secret`, against the person or agent who made the request, with the repository as the target and the detected types and paths (or, for the git data API, the blob id) as detail. You get an email with the subject `[acme/website] Your commit was blocked: possible secrets` (for an agent, its owner does).
148
149### Bypassing with the commits API
150
151A repository admin who is a person can commit a flagged file anyway through `POST /repos/{owner}/{repo}/diffinery/commits` by giving a reason in `allow_secrets`, the API counterpart of `git push -o allow-secrets=`:
152
153```sh
154curl -X POST https://diffinery.com/api/v3/repos/acme/website/diffinery/commits \
155 -H "Authorization: Bearer dfy_..." \
156 -H "Content-Type: application/json" \
157 -d '{
158 "branch": "main",
159 "message": "Add migration test fixture",
160 "allow_secrets": "revoked key kept in a migration test",
161 "files": [{ "path": "test/fixtures/old-key.txt", "content": "AKIA..." }]
162 }'
163```
164
165The same [requirements](#who-can-bypass) apply as for a push: the admin role on the repository, a person rather than an agent, a token with the `admin` scope, and a non-empty reason (trimmed and cut to 200 characters). A bypassed commit is recorded in the audit log as `api.secret_bypassed`, with the detected types, the paths and your reason, and the `[acme/website] Push protection was bypassed` email goes to you and to the repository's admins.
166
167The web editor, the contents API, the git data API and MCP have no bypass. If a file really must contain the value, add the `diffinery:allow-secret` marker to it, commit it with `allow_secrets` through the commits API, or push it with git and use [the bypass](#bypassing-push-protection).
168
169## Opting a file out
170
171Test fixtures sometimes need values that look like real keys. To let such a file through, put the text `diffinery:allow-secret` anywhere in it, usually in a comment:
172
173```ts
174// diffinery:allow-secret (fake keys for the parser tests)
175export const FIXTURE_KEY = "AKIAIOSFODNN7EXAMPL3";
176```
177
178The marker applies to the **whole file**, not to one line. If `diffinery:allow-secret` appears anywhere in a file's content, Diffinery skips every pattern for that entire file. Writing it at the end of a line, as in `key = AKIA... # diffinery:allow-secret`, works only because it puts the marker somewhere in the file; any other credential in the same file is skipped too.
179
180The opt-out is visible in the code and goes through review like any other change, so use it only for files whose secrets are fake. It matches the exact text `diffinery:allow-secret` (case-sensitive), in any comment syntax.
181
182## Bypassing push protection
183
184A repository admin who is a person can push a flagged change anyway by giving a reason with a git push option (for commits made through the API, see [Bypassing with the commits API](#bypassing-with-the-commits-api)):
185
186```sh
187git push -o allow-secrets="revoked key kept in a migration test" origin main
188```
189
190`--push-option=allow-secrets=...` is the long form of the same option.
191
192### Who can bypass
193
194All of these must be true:
195
196| Requirement | Detail |
197|---|---|
198| You have the **admin** role on the repository | The repository owner, an owner or admin of the organization that owns it, a collaborator with the admin role, or a site admin. See [Permissions](/docs/accounts/permissions). |
199| You are a person | Agents can never bypass push protection, whatever their role. |
200| Your token allows admin | git authenticates with a token. A `write`-scoped token caps your role at maintain, so you need a token with the `admin` scope. See [Tokens](/docs/accounts/tokens). |
201| You give a reason | The value after `allow-secrets=` must not be empty or only spaces. It is trimmed and cut to 200 characters. |
202
203If any requirement is missing, the push is blocked as usual. When you supplied the option but are not allowed to use it, the output adds `Only repository admins (people, not agents) can bypass push protection.`
204
205### What a bypass does
206
207- The push continues and the output shows `Push protection bypassed by diffy: <reason>`, followed by the findings (up to 10).
208- The bypass skips only the secret scan. Branch protection still applies to every ref.
209- It is recorded in the [audit log](/docs/security/audit-log) as `push.secret_bypassed`, with the detected types, your reason and the refs.
210- An email with the subject `[acme/website] Push protection was bypassed` goes to you and to the repository's admins (the owner or the organization's owners and admins, plus admin collaborators who are people). It names who pushed, the types found, the reason and the refs. You cannot turn this email off.
211
212Bypassing does not make a real credential safe: anyone who can read the repository can read it. Rotate real credentials even after a bypass.
213
214## Imported history
215
216An [import from GitHub](/docs/migrate-from-github) copies a repository's Git history by pushing it into Diffinery in chunks, and push protection scans every one of those pushes like any other: each new text blob, with the same patterns and the same `diffinery:allow-secret` opt-out. Git LFS objects and release assets are not scanned, as for any push. Old history often holds credentials that were committed and removed later, so every import has a setting for what happens when push protection finds one:
217
218| Web form choice (under **Secrets found in old history (push protection)**) | API, CLI and MCP value | What happens |
219|---|---|---|
220| **Import with an audited bypass** (the default) | `"options": {"secrets": "bypass"}` | The import pushes with a bypass whose reason is `Imported from GitHub <owner>/<name>`. It works only when the import may bypass (see below); otherwise the import fails. |
221| **Stop the import** | `"options": {"secrets": "stop"}` | The import fails at the first push with a finding, so you can rewrite the history on GitHub first. |
222
223### Who can import past a finding
224
225An import pushes as the person or agent who started it, with the token scope they started it with (a browser session counts as `admin`). So the bypass follows the same rules as a `git push -o allow-secrets=...`:
226
227- The import was started by a person, not an agent. **Agents never bypass push protection**: an import an agent starts always uses `stop`, whatever it asks for.
228- That person has the admin role on the new repository: it is in their own account, or in an organization they own or administer (site admins have it everywhere).
229- They started the import in the web UI or with an `admin`-scoped token. With a `write` token the bypass is refused.
230
231When a finding stops an import, the import fails with one of these errors, and the import page and `GET /api/v3/diffinery/imports/{id}` show it:
232
233| Error | Why |
234|---|---|
235| `Push protection found possible secrets in the history (<types>). The import was set to stop on secrets. Rotate them, then start a NEW import with the secrets option set to bypass (as a person who administers the target); a retry cannot change the options.` | The import used `stop`, or an agent started it. |
236| `Push protection found possible secrets in the history (<types>). Only a person who administers the target can import them with an audited bypass (agents and write-scoped tokens cannot); start a new import as such a person.` | The import used `bypass`, but whoever started it can't bypass. |
237
238The Git transfer isn't tried again after such a failure, and retrying the import doesn't help, because a retry keeps the options and runs as the same person or agent. Rotate the credentials, then start a new import (as a person who administers the target, with `bypass`), or rewrite the history on GitHub and import again. The failed import's repository stays, so delete it or give the new import another `name`.
239
240### What an imported bypass records
241
242- Each bypassed push writes a `push.secret_bypassed` entry to the [audit log](/docs/security/audit-log), with the person who started the import as the actor, the reason `Imported from GitHub <owner>/<name>`, and in its detail the import's id and up to 50 blob ids.
243- The person who started the import and the repository's admins get the `[acme/website] Push protection was bypassed` email.
244- The import's summary lists every finding with its type, the file path when the import agent saw one, and the blob id: on the import page under **Possible secrets in history (rotate these)**, and as `summary.findings` in the API. Rotate these credentials. They are readable by anyone who can read the repository, and were already readable on GitHub.
245- A push that push protection blocks during an import is recorded as `push.blocked_secret`, and the person who started the import (or the agent's owner) gets the `Your push was blocked: possible secrets` email.
246
247## Differences from GitHub push protection
248
249Diffinery's push protection follows the same idea as GitHub's: scan at push time, block before anything is stored, and allow an audited bypass. The differences:
250
251| | GitHub | Diffinery |
252|---|---|---|
253| Patterns | A large catalog of provider patterns, plus custom patterns | The nine types listed above; no custom patterns |
254| Enabling | A repository or organization setting | Always on for every repository; cannot be turned off |
255| Bypass | A link in the push output where you pick a reason, then push again | `git push -o allow-secrets=<reason>` in the same push, or `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`; repository admins who are people only |
256| Excluding files | Paths in `.github/secret_scanning.yml` | A `diffinery:allow-secret` marker inside each file |
257| Notification | Secret scanning alerts in the repository | Audit log entries and email to the pusher (and to admins on a bypass) |
258| Existing history | Secret scanning can also scan existing content | Only new blobs in each push, and each file written through the web editor, the commits, contents and git data APIs and MCP, are scanned. History imported from GitHub is scanned as it is pushed in. |
259
260## Troubleshooting
261
262| Symptom | Cause and fix |
263|---|---|
264| `secret detected (push protection)` on every ref | At least one file in the push matched. Read the `remote:` lines above it for the type and blob, then follow [How to fix it](#how-to-fix-it). |
265| The push is still blocked after you deleted the file in a new commit | The secret is still in an earlier commit you are pushing. Rewrite the commit that added it instead of adding a commit on top. |
266| `Only repository admins (people, not agents) can bypass push protection.` | You used `-o allow-secrets=...` without being allowed. Check your role, that you are not using an agent's token, and that your token has the `admin` scope. |
267| `-o allow-secrets=` seems to be ignored | The reason is empty. Give a non-empty reason, for example `-o allow-secrets="test fixture"`. |
268| A real key was not caught | It is not one of the formats above, it is in a binary file, or the file contains `diffinery:allow-secret`. Rotate the key and remove it. |
269| `push protection: possible secret detected (...)` (422) from the API, MCP or the web editor | A file you committed or wrote matched. Remove the value, or mark a test fixture with `diffinery:allow-secret`. A repository admin who is a person can commit it anyway with `allow_secrets` on `POST /repos/{owner}/{repo}/diffinery/commits`; the other endpoints have no bypass. |
270| `... Only repository admins (people, not agents) can bypass push protection.` (422) from the commits API | You sent `allow_secrets` without being allowed. Check your role, that you are not using an agent's token, and that your token has the `admin` scope. |
271| An import from GitHub failed with `Push protection found possible secrets in the history (...)` | The source's history contains credentials and the import couldn't bypass. See [Imported history](#imported-history). |
272| `push exceeds 100 MB; split it into smaller pushes` | The pack is over the push size limit. Push fewer commits at a time, or move large files to [Git LFS](/docs/repositories/git-lfs). |