disclosegate pre-push
the git hook: ref lines on stdin — or pre-commit's PRE_COMMIT_* variables
- <remote> <url> | --pre-commit
Status: 0.0.5, on npm — run it in
auditmode. The hook, the four rules, the config with its trust order,scan,install,init,doctorandcheckare written and covered by tests that run realgit pushcommands against a bare remote. What is still missing is evidence from real work: the false-positive rate on real pushes is unmeasured, so set"mode": "audit"— findings are printed, the push goes through — until 0.1.0. v0.1.0 waits for a week of the guard inauditmode on the maintainer's own repositories, every finding classified true or false and the result recorded here, dated (DG-14 in BACKLOG.md).
gitleaks, git-secrets and trufflehog find secrets: keys, tokens, passwords — strings with a shape and an entropy, the same for everyone. Use one of them; disclosegate does not try to.
What they do not find is internal detail, because it has no shape and differs per
person: your work address as the author of a commit to a personal project, a
Co-authored-by: trailer naming a colleague's corporate address, the name of a private
service or a client in a commit message, a home-directory path pasted into a README. None
of these is a credential, and every one of them is something a maintainer's own rule
forbids in public. disclosegate checks that rule — from a list only you hold — at the
last moment it can still be applied.
npm install -g disclosegate
cd your-repository
disclosegate init # ~/.disclosegate.json, placeholders only
disclosegate install # .git/hooks/pre-push
disclosegate doctor
Then set "mode": "audit" in ~/.disclosegate.json until 0.1.0 (see the status above).
disclosegate install once in each repository, or once into a global core.hooksPath.
Install it globally, not through npx: the hook remembers the script that installed
it, and npm prunes its npx cache, after which the hook falls back to a disclosegate
on PATH and, finding none, refuses the push rather than silently stop guarding.
Node 18 or later. No runtime dependency.
disclosegate install writes .git/hooks/pre-push — or into core.hooksPath when that
is set, so a global core.hooksPath covers every repository at once. The file carries a
marker comment; install never overwrites a hook without it unless given --force, which
moves the other hook to pre-push.before-disclosegate, and uninstall removes only its
own hook and puts the moved one back. Neither replaces a file it finds in its way: --force
stops when a hook was already moved aside, and install refuses a pre-push it cannot
read — a link to a missing file, which a plain write would create through. A hook that is
a symbolic link is moved aside as one.
A hook moved aside keeps running: disclosegate goes first, and when it passes the push the
other hook runs with the same arguments and the same stdin, byte for byte, and its exit
code is the hook's — so either of the two can refuse the push. A push disclosegate refuses
never reaches the other hook. It runs from its new name, so a hook that dispatches on its
own file name ($0) sees pre-push.before-disclosegate; one that is not executable is
skipped with a warning, as git skips it. disclosegate doctor says when a hook is chained.
A hook installed by 0.0.3 or earlier does not chain: run disclosegate install again to
update it.
git runs the hook with the remote's name and URL, and one line per ref on stdin:
| The push | What is read |
|---|---|
| deletes a branch | nothing — a deletion publishes nothing |
| creates a branch | every commit no ref of that remote already has: git rev-list <local> --not --remotes=<remote> |
| updates a branch | <remote-sha>..<local-sha>; if this repository lacks the remote tip, the new-branch rule |
| pushes an annotated tag | the tag object — its tagger and message, and those of a tag it points at — and its commits by the two rules above |
A finding refuses the whole push, and nothing reaches the remote:
disclosegate: 2 findings in 1 of 3 commits — push refused
email 4e1f0a2 author bo… (20 chars) not in publicEmails
term 4e1f0a2 deploy.txt:2 ni… (13 chars) term
Nothing has left this machine. Rewrite the commits, then push again:
...
git push --no-verify skips the hook, as it skips every pre-push hook — knowingly, once.
A repository whose hooks are managed by pre-commit can run
disclosegate from its config instead — .pre-commit-hooks.yaml defines it as a
pre-push stage hook (pre-commit 3.2 or later):
repos:
- repo: https://github.com/Allan-Nava/disclosegate
rev: disclosegate--vX.Y.Z # a release tag — the first after 0.0.3 carries the definition
hooks:
- id: disclosegate
pre-commit install --hook-type pre-push
It reads the same user file and applies the same rules, modes and remote enforcement.
pre-commit reads git's stdin itself, so its entry, disclosegate pre-push --pre-commit,
reads what pre-commit passes instead: PRE_COMMIT_REMOTE_NAME and PRE_COMMIT_REMOTE_URL,
and either PRE_COMMIT_FROM_REF..PRE_COMMIT_TO_REF or, for a branch that starts at a root
commit, everything PRE_COMMIT_LOCAL_BRANCH has that no ref of the remote has. A missing
variable or a file name on the command line is a usage error, and refuses the push.
That interface is narrower than git's, and the difference is pre-commit's: it hands its
hooks one ref — the first that sends anything. A push of several refs at once is
checked on that one only, and a tag pushed onto commits the remote already has runs no
hook at all, so its tagger and message go unread. The git hook reads every ref. For the
whole push and pre-commit's other hooks, install both: disclosegate install --force
moves pre-commit's pre-push hook aside and chains it after disclosegate.
Two files, in a trust order.
The user file, ~/.disclosegate.json (or the path in DISCLOSEGATE_CONFIG), holds
the private lists. It must never live in a repository — what it lists is exactly
what must not be published — and disclosegate refuses to run with a user file inside the
repository it is checking. disclosegate init writes a template of placeholders with
comments, and never overwrites an existing file nor writes through a symbolic link:
{
"publicEmails": ["you@personal.example", "*@users.noreply.example"],
"blockedDomains": ["work.example"],
"terms": ["internal-host.example", "/client-[a-z]+\\.example/i"],
"blockedNames": [],
"allowPaths": [],
"mode": "block",
"remotes": { "enforce": ["github.com/*"], "skip": [] }
}
The repository file, .disclosegate.json at the top of a repository, can be written
by anyone with commit access, so it may only tighten: add terms and
blockedDomains. It may also set allowPaths, globs such as test/fixtures/** for files
that have to quote a path. allowPaths exempts files from the path rule only — never from the email or term rules.
Every other key in it — publicEmails, mode, remotes — is ignored, a warning says
so on every run, and doctor lists it. The file's own lines are exempt from the terms
it adds — or the commit that adds it would be refused by it — but not from a term in the
user file or from any other rule; a term written there is public, so it fits a name that
is already out, never a private one.
mode is block (the default: a finding refuses the push) or audit (findings are
printed and the push goes through). remotes.enforce and remotes.skip are globs, *
the wildcard, matched against the remote URL normalised to host/path — so
github.com/* covers the SSH and HTTPS forms alike; skip wins, and no enforce list
means every remote. A missing user file is not an error: the path rule needs no list and
still runs, and doctor says what is missing.
| Rule | Reads | A finding when |
|---|---|---|
email |
author, committer, tagger, every trailer address (Co-authored-by:, Signed-off-by:, any Token: … <address>); any address in a commit or tag message or an added line |
the address is not in publicEmails (off while that list is empty, and never applied to an address in a message or a line); or its domain, or a parent of it, is in blockedDomains — whatever publicEmails says, wherever the address is |
term |
commit and tag messages, added lines, the names of files with added lines | an entry of terms matches: a plain string case-insensitively, /source/flags as a regular expression |
path |
commit and tag messages, added lines | /Users/<name>/, /home/<name>/, C:\Users\<name>\ or a file:/// URL naming a path. Built in, always on; a URL such as https://example.com/home/about/ is not a home |
name |
author, committer, tagger and trailer names | the name is in blockedNames |
An added line is one a commit adds to its parent. A merge's are the lines new to every
parent — what resolving a conflict writes — read from its combined diff (git log --cc);
a line one side already had was read in that side's own commit, or is already public.
Nothing a commit carries can change where the reading thinks one commit ends and the
next begins: a control byte, a NUL that git still prints as text, a line shaped like a
commit header, in a file, a message or a name, is read as what it is. If the output of
git is ever not in the shape disclosegate reads, that is an error — exit 2, and in the
hook a refused push — never a shorter scan.
Nothing is binary to the reading. git prints Binary files … differ instead of the
lines of a file marked -diff or binary — in .gitattributes, in
.git/info/attributes or in the file your core.attributesFile names — of a file whose
diff driver says binary, of one larger than core.bigFileThreshold, and of one with a
NUL in its first 8,000 bytes. Lock files are often marked that way. disclosegate reads all
of them as text (git log --text), byte for byte: an image, an archive or a compiled
binary goes through every rule line by line, a line being whatever ends in a newline
byte. A textconv or external diff driver in your config is never run — the bytes are
read, not a rendering of them. A merge's own lines in such a file, where git's combined
diff ignores --text, are read through one diff per parent. What a binary holds
compressed — a zip entry, a PDF stream, a PNG text chunk — is bytes to the rules, not
text.
A Git LFS file is read as its content, not its pointer. The commit holds a pointer;
the content goes to the forge's LFS store through git-lfs's own pre-push hook. disclosegate
reads that content from where git-lfs reads it, the repository's local LFS object store,
as the file's lines, through every rule — and install --force chains git-lfs's hook
after its own, so a refused push uploads nothing to the LFS store either. Install git-lfs's
hooks first, then disclosegate install --force; git lfs update --force afterwards would
put git-lfs's hook back in disclosegate's place, which disclosegate doctor shows. Content
that is not on this machine is an unread finding: git-lfs could not upload it either, but
the guard cannot vouch for it.
One finding is about the reading itself rather than a rule: unread. A file's added
lines are read up to 100 MiB in one commit — the size GitHub refuses a file at — and a
commit's up to 512 MiB in all, so a commit of large binaries costs bounded time and
memory. A file past either is read up to there and then named as an unread finding:
nothing is skipped without saying so. It refuses the push in block mode like any other
finding — the guard cannot vouch for what it did not read — and is printed in audit mode
and in --json. Read the file yourself; if it is clean, git push --no-verify is your
decision to make.
Findings are listed worst first: a blocked domain, an address outside the allowlist, a term, a name, a path, a file not read in full. A file whose name carries a term is never printed by name; its lines are shown under a masked one.
Every finding has the commit's short sha — or the tag object's — where it is (author,
committer, tagger, trailer Co-authored-by, message, tag message, file:line,
file name), the rule, and the match masked — its first two characters, an ellipsis
and its length. The output of a hook lands in terminals, CI logs and pasted issues, and a
guard that printed what it found would publish it itself. --show prints matches in full, and only when stdout is a
terminal; elsewhere it is ignored with a note. Paths under your home directory are shown
with ~.
--json gives the same findings for machines, masked by the same rule. Exit codes: 0
clean, or any result in audit mode; 1 findings in block mode; 2 a usage or
configuration error — which, in the hook, also refuses the push.
disclosegate scan # what a push would send now: @{upstream}..HEAD, or what no remote has
disclosegate scan --range main..HEAD
disclosegate scan --staged # the index, and the identity the next commit would carry
disclosegate scan --history # every commit and annotated tag reachable from any ref — the audit of a repository
disclosegate install [--force] | uninstall
disclosegate init # the template user file; refuses to overwrite or follow a link
disclosegate doctor # config found, rules active, hook installed, remotes enforced
Every reading — the hook's, scan's, scan --history's — is a stream: each commit goes
through the rules as git writes it and only its findings are kept, so a push or a history
of any size is read in about the memory of its largest commit — on a synthetic
4,000-commit history with 250 MB of patches, a peak of 209 MB against 1.1 GB when it was
read whole, with the same output. Reading binary files costs time where there are large
ones: a synthetic history with 336 MiB of random blobs under -diff takes 5.5 s, where
0.2 s read none of them.
unread finding, so scan --history over such a clone names
each. Run git lfs fetch --all first.unread, never
skipped silently./regex/ term runs as you wrote it. The built-in rules take time in proportion
to a line — a minified bundle or a base64 line of several MB is read in milliseconds —
but a term written as a regular expression is yours, and one with nested quantifiers,
such as /(a+)+b/, can take minutes on a long line. That costs time, never coverage:
a hook that is interrupted refuses the push. A plain-string term is a literal search
and stays fast. The same holds for a term the repository file adds.git, reads two files, and prints.doctor, which
shows counts and key names.Straight from the usage comment in bin/disclosegate.mjs — what disclosegate help prints.
disclosegate pre-pushthe git hook: ref lines on stdin — or pre-commit's PRE_COMMIT_* variables
disclosegate scanthe same rules, by hand
disclosegate installwrite the pre-push hook, never over another tool's
disclosegate uninstallremove the hook, only if disclosegate wrote it
disclosegate initwrite a template user config of placeholders
disclosegate doctorconfig, active rules, hook, remote enforcement
disclosegate checkthis repository's own invariants (npm test)