RC RANDOM CHAOS

The next commit captures the file you never named

A .gitignore is a name-based deny-list on a capture-by-default store, so it cannot stop the unnamed secret that lands permanently in git history.

· 7 min read
The next commit captures the file you never named

A .gitignore file grants no protection. It is a list of path names git agrees not to track. Nothing in that file inspects content, classifies sensitivity, or blocks a commit. It matches strings against paths in the working tree and removes matches from staging. That is the full extent of its function.

The industry treats it as a control. It is not a control. It is a noise filter for the working directory. It exists so build artifacts and local scratch files do not clutter git status. Reducing clutter is not the same as enforcing a boundary. A control decides what is permitted to cross a trust boundary and stops what is not. A .gitignore decides nothing. It applies a fixed set of patterns and defers every unmatched file to the default behaviour of version control, which is to capture it.

For the operator the position is narrow and specific. Every file present in a tracked path is a candidate for permanent capture at the next commit. The boundary is not the .gitignore. The boundary is which files exist in the tracked path at the moment staging runs. That distinction decides whether a credential leaves the machine, and the .gitignore only participates if the exact path was named in advance.

The common assumption is that dropping a standard template into a repository protects secrets. It does not. Default templates are language and framework specific. A Node template names node_modules and build output. A Python template names pycache and .venv. These entries cover generated artifacts. They do not cover secrets, because the author of a shared template cannot know the name of your environment file, your exported key, or a database dump a teammate left in a data folder.

The observable behaviour is direct. Git stages every file in the working tree that is not matched by an active ignore pattern. Run git add with a wildcard or git add against the directory, and every unignored file is staged in one action. The default template matches build noise. It does not match a secrets file saved under a name the template never listed, a private key written to the repository root, or a dump placed in a folder the pattern set does not cover. Those files are unmatched, so they are staged, and staging is the last step before they enter history.

The failure is a scope mismatch. The protection scope is fixed when the template is authored. The risk is defined when the commit runs. The two never align. The file that leaks is, by definition, the file nobody named in advance. A pattern set that only excludes anticipated names cannot exclude the unanticipated ones, and the unanticipated ones are where sensitive data lives. Once such a file is staged, committed, and pushed, it exists in the remote and in the history of every clone made from it.

The reason this fails is the model itself. A .gitignore is a deny-list. It instructs the system to track everything and subtract a named set. That inverts the boundary. An allow-list names what may enter and rejects the rest. A deny-list names what must stay out and carries the burden of naming every possible sensitive artifact before that artifact exists. That burden cannot be met. Completeness is impossible when sensitive files are created continuously by people who never read the ignore file.

The second condition is the medium. Version control is a networked, mirrored, permanent store. A commit is not a save. It writes an object into history addressed by hash. A push replicates that object to the remote and to every clone and fork. Deleting the file in a later commit does not remove it. The object remains reachable by its hash in history. The exposure is not the current state of the tree. It is the full retained history, distributed to every party that pulled.

The third condition is enforcement, and by default there is none. The .gitignore is advisory. It applies at staging time, it depends on pattern match, and it can be overridden with git add -f. No identity check, no content classification, and no policy gate sits between the operator and the remote. The system permits the secret to enter. Controls that are not enforced are not controls, and an advisory pattern list is not an enforcement point. If the system allows the secret to be committed, it will be committed.

The failure runs as a chain, and every link in it is default behaviour. A file is written into a tracked path under a name the pattern set does not contain. Staging runs. The pattern match returns nothing, so the file is not subtracted. It is staged with everything else in one action. The commit writes it into history as an object addressed by hash. The push replicates that object to the remote and to every clone. Five steps, and not one of them is an error. Nothing malfunctioned. Git did what it is built to do, and the pattern list did what it is built to do. The secret left the machine because the system operated correctly.

The mechanism is capture by default, subtraction of a named set, no gate, and permanent replication. Each condition is individually sufficient to permit capture, and together they fix the outcome. The deny-list guarantees the unnamed file is retained in the working tree. The absence of enforcement guarantees nothing stops that retained file at staging. The medium guarantees that once the object is written it cannot be recalled. Combine the three and the result is not probable, it is determined. The unnamed sensitive file reaches permanent, distributed storage on the first commit that includes it.

This is a missing control, not a broken one. A broken control implies something existed on the path and did not perform. Here nothing sat between the working tree and the remote. The absence is the mechanism itself. The only window in which the file could have been excluded is the instant staging runs, and exclusion at that instant depends entirely on a name that had to be decided before the file existed. After staging, the window is closed. That is why post-commit removal does not restore the prior state. There was no state to restore to. The object was already written and already replicated.

The pattern this exposes is not specific to git. A deny-list applied to a system that captures by default fails on the first input nobody named. The population of sensitive artifacts grows continuously and is produced by people who never read the list. The list is fixed at the moment it is authored. Completeness is the requirement, and completeness is unreachable. Any boundary built on this model inherits the same failure. The construct fails on the case it did not anticipate, and anticipation is exactly what it depends on and cannot guarantee.

The same mechanism appears wherever a default-permit system is paired with a named block set. A firewall that permits by default and blocks listed ports. An output filter that strips known-bad strings. Data-loss prevention keyed on a keyword list. Each one passes by default and subtracts named values. Each one fails on the value that is not on the list. The failing case is always the unanticipated one, because the model can only act on what was named in advance. The .gitignore is one instance of this class. Its behaviour is the class behaviour.

The second exposure is where the effort goes. Teams curate templates and review the ignore file, and treat that review as coverage. That artifact has no relationship to the file that leaks, because the file that leaks is by definition the one no name predicted. Auditing the deny-list measures nothing about the risk it is believed to reduce. The boundary people think they are maintaining is not the boundary that decides exposure. The boundary that decides exposure is which files exist in tracked paths at the moment staging runs, and that boundary is unmanaged.

A .gitignore must not appear as a control in any threat model, risk register, or audit. If it is listed as a mitigation for secret exposure, it is a false entry, and it must be removed. Counting a noise filter as a boundary overstates control coverage and conceals the fact that the path from working tree to remote has no gate. The first correction is to stop crediting protection that does not exist.

The boundary has to sit before the object is written, because after the write the exposure is permanent and distributed. A boundary keyed on file name cannot hold, because the failing file is the one no name predicted. Enforcement has to act on content, and it has to be non-advisory. An advisory check can be overridden, and it defaults to capture when it stays silent. Git provides the override directly through forced add. A gate that can be skipped at will is not a gate.

Secrets that already reached history are already gone. The object persists in history and in every clone and fork taken from it. Deleting the file in a later commit changes the current tree and nothing else. The value remains reachable by its hash to everyone who pulled. The only action that changes the exposure is invalidating the secret itself. A credential that reached history is compromised. Treat it as compromised and rotate it. The file is recoverable from history. The trust placed in that value is not. If a system allows a secret to be committed, it will be committed. Operate on the commit that already happened, not the one you expect to prevent.

Share

Keep Reading

Stay in the loop

New writing delivered when it's ready. No schedule, no spam.