Module 4: Secrets Environments And Identity

2. Why credentials never belong in the repository

Description

This lesson precisely names the exact antipattern the market audit cites: a long-lived AWS access key (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY from a traditional IAM user), written in plain text inside a file Git tracks. You're going to see, with a real experiment —not a prose claim— why the solution isn't "delete the file": once a credential enters a commit, it lives in Git's history, not in the repository's current state, and history doesn't forget anything by accident.

Connection to the module

Lesson 1 already flagged where this antipattern's shape lives today: ci.yml's env: block, with AWS_ACCESS_KEY_ID: test written directly in the YAML. This lesson explains exactly why that's a problem —even with a harmless credential— and what distinguishes it from a problem "fixed with one more commit." Lesson 3 builds GitHub's solution for this specific case: Secrets, managed outside the YAML. This lesson is the why; lesson 3 is the how.


Analogy: a secret club's notebook, where nobody tears out pages

Imagine a club that keeps a physical notebook where every member writes down, with a date, every decision they make. It's the club's rule: nothing gets forgotten, nothing gets rewritten, every page stays archived forever, even the ones that later get corrected. One day, someone accidentally writes the club safe's combination directly on a page. Realizing the mistake, they tear that page out of the current notebook — but the notebook has one more rule: every torn-out page gets filed in a separate folder, indexed, searchable by anyone who knows where to look. "Tearing out the page" didn't erase the combination; it just moved it to a different file, just as accessible. The only real way to protect the safe, at that point, is changing the combination — not rewriting the notebook.

Git works like that. A commit isn't a "version that replaces the previous one" — it's a new object, with its own identifier, added to a chain that doesn't rewrite itself backward by accident. "Deleting" a file in a new commit doesn't erase that file's content from the old commits where it existed: it only adds, to the chain, one more commit saying "from here on, this file is no longer here." The earlier commits —with the complete file, credential included— are still there, just as accessible as any other point in the history.


Worked example: recovering a "deleted" credential

You're going to reproduce, in a disposable repository —not in andes-cargo-infra/— exactly the mistake lesson 1 asked you to avoid. The goal is to see, with your own eyes, that "deleting the file" doesn't eliminate the problem.

Create a new repository, in any folder outside your real project:

mkdir git-secret-history-demo && cd git-secret-history-demo
git init -b main

Create a file with example credentials —never real credentials, not even in an experiment— and commit it, exactly as someone who hasn't read this lesson yet would:

cat > config.env <<'EOF'
DATABASE_URL=postgres://user:pass@localhost/db
AWS_ACCESS_KEY_ID=AKIAEXAMPLEDEMOKEY01
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIdemoDEMOsecretKEYexample
EOF
git add config.env
git commit -m "Add config.env with database and AWS credentials"

Note this commit's hash — you're going to need it in the next step:

git rev-parse HEAD

What to expect (representative for the hash — yours is going to be different, every commit generates a unique identifier based on its exact content and moment):

c0551d84d7d9f6e0e65b70cce9d86a328528cd43

Now, react the way someone realizing the mistake would: delete the file and commit the fix.

git rm config.env
git commit -m "Remove config.env (credentials were exposed)"

Confirm the file no longer exists in the working directory:

ls
git log --oneline

What to expect (literal for the structure, representative for the short hashes):

c45646e Remove config.env (credentials were exposed)
c0551d8 Add config.env with database and AWS credentials

ls shows no file — from the current working directory's perspective, config.env no longer exists. This is exactly the point where someone without this lesson would breathe easy, thinking the problem is solved.


The recovery: the moment that matters

Now, with the first commit's hash you noted above, ask Git to show you that file exactly as it existed at that specific commit, with no revert, no undoing of the deletion commit:

git show c0551d8:config.env

What to expect (literal, with your own first commit's hash):

DATABASE_URL=postgres://user:pass@localhost/db
AWS_ACCESS_KEY_ID=AKIAEXAMPLEDEMOKEY01
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMIdemoDEMOsecretKEYexample

There it is — complete, with no special effort, no forensic recovery tools. git show <commit>:<file> is a standard read command, the same kind of command you'd use to check any historical version of any file. The credential never stopped existing in the repository: the "deletion" commit only added a new page to the notebook, without tearing out any of the earlier ones.

This same repository, if you had pushed it to GitHub before the second commit —or even after— exposes that credential to anyone with read access to the repository, forever, until someone actively rewrites the history (a destructive, risky operation that rewrites later commits' hashes) or —the only correct, fast response— revokes the exposed credential at the provider where it lives (in this case, AWS IAM), rendering it useless no matter how many copies of it keep circulating.


Why "revoke," not "rewrite the history," is the right answer

Rewriting Git's history (with tools like git filter-repo or BFG Repo-Cleaner) is technically possible, but has a problem neither one solves: if the repository was already cloned, forked, or the exposed commit was already indexed by some automated scanning bot (GitHub itself runs secret scanning on public repositories, and many private ones), the credential already escaped the repository before you could rewrite it. Rewriting the history cleans your local copy and the main remote's — not the copies that already exist elsewhere.

The only action that neutralizes the real risk, no matter how many copies exist, is revoking the credential at the provider: on AWS, deactivating or deleting the exposed access key from IAM. Once revoked, that key stops working in every copy of the history, in every fork, in every bot that indexed it — the config.env file can keep living in a thousand old commits across a thousand different clones, and it doesn't matter, because the credential it contains no longer opens any door.

This is the exact reason behind a rule you might already know by instinct: never reuse a real credential in an experiment, not even "just to test." The example above used AKIAEXAMPLEDEMOKEY01 — an example value, with the EXAMPLE prefix AWS explicitly reserves for documentation, never an active key. If you ever copied a real credential into a test file "just to see how git rm works," you already have a pending task: revoke it now, not after finishing this lesson.


Back to andes-cargo-infra/: why this guide's case is different (and why it matters anyway)

Andes Cargo's ci.yml has AWS_ACCESS_KEY_ID: test written directly in the file, already committed since Module 3. Following the reasoning above, should you treat it as a real leak? No —and the reason is specific, not a generic exception: test isn't an AWS credential, it's a value LocalStack recognizes as "don't validate anything, this is a local lab." No real AWS account exists where that text string opens a door. Revoking "the test key" makes no sense, because it was never a key.

What does matter —and why this lesson isn't an isolated exercise with no connection to the rest of the guide— is the shape. This module's lesson 7 is going to migrate exactly that ci.yml block to read its values from a Secret instead of having them written there, not because test/test is dangerous, but because practicing the correct pattern with a harmless credential is how the pattern gets installed in your muscle memory for the day you work with a credential that does matter.


Common mistakes

Believing .gitignore protects retroactively (this lesson's central mistake). What happens: someone adds a file to .gitignore after already having committed it, and assumes that gets it out of the history. Why it happens: .gitignore does prevent Git from tracking a new file, and that was exactly the correct technique Module 2, lesson 7, used with .secrets — but it only works if applied before the first commit that includes the file. How to spot it: if a file shows up in git log -p or in git show <commit>:<file> despite being listed in .gitignore today. How to fix it: .gitignore is prevention, not a cure. If the file already entered a commit, adding it to .gitignore stops future commits with that content, but doesn't erase past commits — for that you need to revoke the credential (if it's real) and, if you also want to clean up the history, a rewriting tool like the ones named above.

Confusing "commit without push" with "safe commit" (scope-based). What happens: someone reasons that, since they haven't run git push yet, the commit with the credential "only exists on my machine" and there's no real risk. How to spot it: if your plan for "fixing" a commit with a credential is simply not pushing it, instead of fixing it before the possibility of pushing exists. How to fix it: it's true that a commit without push hasn't reached any remote yet — but the risk of an accidental push (a branch pushed by mistake, a git push --all, a collaborator cloning your machine) is real and common. The right practice is the one Module 2, lesson 7, used: gitignore before the file with the credential exists, don't trust yourself to remember not to push it.


Exercises

Exercise 1 — Reproduce the experiment with a second file. Repeat this lesson's exercise, but with a new file, api-token.txt, containing a single example value. After "deleting" it in a second commit, confirm with git show that it's still recoverable. Note the two hashes you need.

See solution
echo "API_TOKEN=demo-token-never-use-this-in-production" > api-token.txt
git add api-token.txt
git commit -m "Add api-token.txt"
git rev-parse HEAD   # note this hash, e.g. abc1234
git rm api-token.txt
git commit -m "Remove api-token.txt"
git show abc1234:api-token.txt

The last command should return API_TOKEN=demo-token-never-use-this-in-production, exactly like the lesson's exercise with config.env. The pattern is identical no matter the filename or the type of credential: any content that enters a commit is recoverable from that specific commit, forever, no matter how many later commits "delete" it.

Exercise 2 — Explain why test/test in ci.yml doesn't require revocation. A colleague, after this lesson, panics seeing AWS_ACCESS_KEY_ID: test in Andes Cargo's ci.yml and asks if something needs to be revoked in AWS right now. Answer them in two or three sentences.

See solution

A complete answer sounds, roughly, like this: "There's nothing to revoke, because test was never a real AWS account's credential — it's a special value LocalStack accepts without validating against any real AWS service, exactly like we've used it since terraform-and-iac-guide. The risk this lesson explains applies to credentials that do open a real door; test/test opens none. What we are going to fix, in lesson 7, is how it's written —directly in the YAML— because that's the habit we want installed before working with a credential that does matter."

Exercise 3 — Distinguish prevention from cure. Classify each of these three actions as "prevention" (avoids the problem before it happens) or "cure" (responds after the credential is already exposed): (a) adding .secrets to .gitignore before creating the file; (b) revoking an AWS key that showed up in a commit already pushed to a public repository; (c) rewriting Git's history with git filter-repo to remove a commit with a credential.

See solution

(a) Prevention — exactly Module 2, lesson 7's pattern: the file never enters a commit at all. (b) Cure, and the only one that really neutralizes the risk — the credential stops working no matter how many copies of the history exist in the world. (c) Partial cure, with limited value — it cleans your copy of the history and the main remote's, but doesn't reach copies already cloned, forked, or indexed by a scanning bot; it never substitutes for revocation, at best it complements it.


Summary and next step

In this lesson you saw, with a real, reproducible experiment, why a credential that enters a Git commit doesn't get eliminated when the file gets deleted in a later commit: the history is additive, not destructive, and git show <commit>:<file> recovers any historical content with no special tools. You also distinguished Andes Cargo's real case (test/test, harmless in content) from the general case this lesson teaches (the antipattern's shape, which is worth practicing correctly), and saw why revoking the credential —not rewriting the history— is the only response that neutralizes the risk no matter how many copies exist.

Before moving on you should be able to: explain, without using the word "delete," what actually happens to a file removed in a later Git commit; recover a file's content from a specific commit with git show; and explain why revoking an exposed credential is the right answer, even after rewriting the history.

Lesson 3 builds GitHub's solution so this doesn't happen again: Secrets, a mechanism managed completely outside the YAML file, which never enters Git's history because it never lives in a file Git tracks.

Resources

  1. Git Docs — git show — official documentation for the command used in this lesson to recover historical content.
  2. GitHub Docs — About secret scanning — GitHub's automatic system that detects exposed credentials in commits, the additional reason "nobody noticed" isn't a reliable strategy.
  3. AWS Docs — Identifying unintended access to your AWS resources — official AWS documentation on managing and rotating IAM access keys, the basis for this lesson's "revoke, don't rewrite."
  4. This guide's Module 2, lesson 7 (07-hands-on-passing-secrets-to-act.md) — the correct prevention pattern (gitignore before the first commit), already applied to .secrets.