Stop committing secrets with pre-commit and gitleaks
This page has been translated by machine translation. View original
I'm emi, a coffee lover.
When leaving development to AI agents like Claude Code, there's a possibility that generated code containing API keys or passwords may be committed as-is.
As a countermeasure, I tried pre-commit and gitleaks, a mechanism that detects secrets and stops commits right before they happen.
Conclusion First
When configured to call gitleaks via pre-commit, secrets contained in files are detected the moment you try to git commit them, and the commit is aborted.
gitleaks and pre-commit
Since I don't do much development work on a regular basis, I'll include an overview of what these tools are for my own reference as well.
gitleaks
gitleaks is a tool that detects secrets such as API keys and passwords from source code and commit history.
- Scans code and commits, and reports any strings that look like secrets as findings
- When secrets are detected, it returns exit code 1, which allows
pre-committo abort the commit
Git hooks
Git has a feature called hooks. It's a mechanism that automatically runs predefined scripts at timings such as commit or push.
There are the following types of hooks. ※ These are just some examples; there are others as well.
- pre-commit hook
- A hook that runs right before a commit is finalized. If something returns an error here, the
commitis aborted. For example, stopping acommitif secrets are mixed in
- A hook that runs right before a commit is finalized. If something returns an error here, the
- commit-msg hook
- A hook that runs after a commit message has been entered
- pre-push hook
- A hook that runs right before a push
The following image shows where in the flow of work (working tree) → stage → commit → push each hook runs.

Uses of commit-msg hook
-
pre-commit hook: runs before the commit message is retrieved, before the commit is created
-
commit-msg hook: runs receiving as an argument the file that holds the proposed commit message
- The order is: pre-commit hook → message input → commit-msg hook
-
Uses of commit-msg hook
- Since the pre-commit hook doesn't receive the message, this is used when you want to inspect the message itself
- Normalizing commit messages to a standard format
- Inspecting the message and rejecting the commit if there's a problem
- If the exit code is non-zero, the commit is aborted
- The default bundled with Git stops the commit when duplicate
Signed-off-bylines are detected - Examples
- Enforcing Conventional Commits format (like
feat:orfix:), requiring a ticket number to always be included
- Enforcing Conventional Commits format (like
The actual body of these hooks are shell scripts placed under the repository's .git/hooks/. The timing of execution is determined by the filename, and they are placed as .git/hooks/pre-commit, .git/hooks/commit-msg, .git/hooks/pre-push, and so on. No file extension is needed.
repository/
└── .git/
└── hooks/
├── pre-commit
├── commit-msg
└── pre-push
pre-commit sample
When you create a repository with git init, Git-bundled samples are placed in .git/hooks/ with a .sample extension. The pre-commit hook sample is pre-commit.sample, and its contents are a shell script like the following (some comments omitted).
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# (omitted) stops with exit 1 if a non-ASCII filename is about to be added
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --
This sample exits with exit 1 (non-zero) when it finds non-ASCII filenames or trailing whitespace errors, stopping the commit.
The comment says "exit with non-zero status to stop the commit" and "rename the file to pre-commit to enable this hook."
In other words, remove the .sample extension and rename it to pre-commit when you want to use it.
.git/ is the local working directory of the repository and is not included in git clone or git push targets. Therefore, scripts placed directly in this location cannot be shared with the team, and each person needs to set them up manually.
pre-commit (tool)
The naming is a bit confusing, but there is a tool called pre-commit that is separate from Git's pre-commit hook. It's a Python program installed via pip that makes it easier to manage the checks run by the pre-commit hook.
The pre-commit tool, when called from the pre-commit hook, executes the checks written in the configuration file (.pre-commit-config.yaml) (in this case, gitleaks).
Differences from a plain pre-commit hook
Let's compare the flow for achieving "detect secrets with gitleaks before committing."
With a plain pre-commit hook
With a plain pre-commit hook, you write the check content directly into .git/hooks/pre-commit yourself.
#!/bin/sh
# gitleaks needs to be installed separately by each person
gitleaks protect --staged || exit 1
This approach has drawbacks.
- Each person needs to separately install
gitleaksthemselves - Since
.git/hooks/is not included ingit cloneorgit push, this script cannot be shared with the team, and each member has to place it manually - Versions and implementations tend to vary from person to person
Using pre-commit (tool)
When using the pre-commit tool, the only thing you need to prepare yourself is a single configuration file called .pre-commit-config.yaml. In .pre-commit-config.yaml, you only write the check tool to use (gitleaks, etc.) and its version.
The contents to write are as follows.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
Once you place .pre-commit-config.yaml, run pre-commit install just once. This only needs to be done once at the beginning, and running it causes the pre-commit tool to create a script at .git/hooks/pre-commit. The contents of the generated file are as follows.
#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.com
# start templated
INSTALL_PYTHON=/usr/bin/python3
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated
HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")
if [ -x "$INSTALL_PYTHON" ]; then
exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
elif command -v pre-commit > /dev/null; then
exec pre-commit "${ARGS[@]}"
else
echo '`pre-commit` not found. Did you forget to activate your virtualenv?' 1>&2
exit 1
fi
All this script does is call the pre-commit tool. It passes the configuration file to read via --config=.pre-commit-config.yaml.
From then on, every time you run git commit, the following flow runs automatically.
git commitexecutes.git/hooks/pre-commit(the script)- The script calls the
pre-committool - The
pre-committool reads.pre-commit-config.yaml - It runs
gitleaksas written in the configuration file
With this approach, the drawbacks of the plain pre-commit hook are resolved.
- Even without installing
gitleaksyourself,pre-commitwill fetch it from therepoin the configuration file - Since
.pre-commit-config.yamlis placed in the repository root (the same level as.git/), it can be committed along with the repository. Unlike the approach of placing it directly in.git/hooks/, the configuration can be shared with the team - By fixing the version with
rev, the same check runs for all team members
It's a bit confusing. To summarize:
- At
pre-commit installtime (the first time only)- The
pre-committool creates the.git/hooks/pre-commitscript
- The
- At
git committimegit commitcalls.git/hooks/pre-commit(the script), and.git/hooks/pre-commit(the script) calls thepre-committool- The
pre-committool reads.pre-commit-config.yamland runsgitleaks
Folder Structure Used for Verification
This time I created a disposable verification repository lab/ to test with. The folder structure is as follows.
(working folder)/
└── lab/ ← repository initialized with git init for verification
├── .git/
│ └── hooks/
│ └── pre-commit ← auto-generated by pre-commit install
├── .pre-commit-config.yaml ← configuration file written by yourself (repository root)
├── config.py ← verification file with secrets mixed in
└── README.md
Operating Environment
- Ubuntu on WSL
- Git 2.34.1
- pre-commit 4.6.2 (installed via
pip) - gitleaks v8.30.1 (fetched via
pre-commit) - Claude Code v2.1.248
gitleaks is not separately installed by myself; I leave it to pre-commit to fetch it. If you write the gitleaks repository in .pre-commit-config.yaml, it will be fetched at runtime.
Step 1: Check Installation Status
First, I checked whether gitleaks and pre-commit were already installed in the current environment, as well as whether the candidate installation methods (apt / snap / go / Homebrew / pip) were available.
echo "=== gitleaks ==="; which gitleaks; echo "=== pre-commit ==="; which pre-commit; echo "=== pipx ==="; which pipx; echo "=== pip3 ==="; which pip3; echo "=== go ==="; which go && go version; echo "=== snap ==="; which snap; echo "=== brew ==="; which brew; echo "=== apt-cache gitleaks ==="; apt-cache policy gitleaks 2>/dev/null | head -n 5
▼ Execution result
emiki@<hostname>:~/work/xx$ echo "=== gitleaks ==="; which gitleaks; echo "=== pre-commit ==="; which pre-commit; echo "=== pipx ==="; which pipx; echo "=== pip3 ==="; which pip3; echo "=== go ==="; which go && go version; echo "=== snap ==="; which snap; echo "=== brew ==="; which brew; echo "=== apt-cache gitleaks ==="; apt-cache policy gitleaks 2>/dev/null | head -n 5
=== gitleaks ===
=== pre-commit ===
=== pipx ===
=== pip3 ===
/usr/bin/pip3
=== go ===
=== snap ===
/usr/bin/snap
=== brew ===
=== apt-cache gitleaks ===
emiki@<hostname>:~/work/xx$
Neither gitleaks nor pre-commit were installed, and pip3 was available. I decided to use pip to install pre-commit this time.
Step 2: Install pre-commit
Install pre-commit with pip.
pip3 install --user pre-commit
Execution result
emiki@<hostname>:~/work/xx$ pip3 install --user pre-commit
Collecting pre-commit
Downloading pre_commit-4.6.2-py2.py3-none-any.whl (226 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 226.2/226.2 KB 2.1 MB/s eta 0:00:00
Collecting identify>=1.0.0
Downloading identify-2.6.19-py2.py3-none-any.whl (99 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 99.4/99.4 KB 7.8 MB/s eta 0:00:00
Collecting virtualenv>=20.10.0
Downloading virtualenv-21.7.11-py3-none-any.whl (5.3 MB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 5.3/5.3 MB 24.9 MB/s eta 0:00:00
Requirement already satisfied: pyyaml>=5.1 in /usr/lib/python3/dist-packages (from pre-commit) (5.4.1)
Collecting nodeenv>=0.11.1
Downloading nodeenv-1.10.0-py2.py3-none-any.whl (23 kB)
Collecting cfgv>=2.0.0
Downloading cfgv-3.5.0-py2.py3-none-any.whl (7.4 kB)
Collecting distlib<1,>=0.3.7
Downloading distlib-0.4.3-py2.py3-none-any.whl (470 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 470.6/470.6 KB 12.4 MB/s eta 0:00:00
Collecting python-discovery>=1.6
Downloading python_discovery-1.6.0-py3-none-any.whl (38 kB)
Collecting typing-extensions>=4.13.2
Downloading typing_extensions-4.16.0-py3-none-any.whl (45 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 45.6/45.6 KB 4.0 MB/s eta 0:00:00
Collecting filelock<4,>=3.24.2
Downloading filelock-3.32.7-py3-none-any.whl (100 kB)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 100.2/100.2 KB 7.1 MB/s eta 0:00:00
Collecting platformdirs<5,>=3.9.1
Downloading platformdirs-4.11.9-py3-none-any.whl (24 kB)
Installing collected packages: distlib, typing-extensions, platformdirs, nodeenv, identify, filelock, cfgv, python-discovery, virtualenv, pre-commit
Successfully installed cfgv-3.5.0 distlib-0.4.3 filelock-3.32.7 identify-2.6.19 nodeenv-1.10.0 platformdirs-4.11.9 pre-commit-4.6.2 python-discovery-1.6.0 typing-extensions-4.16.0 virtualenv-21.7.11
emiki@<hostname>:~/work/xx$
It was installed in ~/.local/bin, and the PATH was also set correctly.
which pre-commit; pre-commit --version
▼ Execution result
emiki@<hostname>:~/work/xx$ which pre-commit; pre-commit --version
/home/emiki/.local/bin/pre-commit
pre-commit 4.6.2
emiki@<hostname>:~/work/xx$
Step 3: Create a Verification Repository
Create a disposable verification repository. The working directory path is stored in a variable called BASE, which will be reused from here on (the path is masked as /path/to/project).
BASE="/path/to/project"; mkdir -p "$BASE/lab" && cd "$BASE/lab" && git init -q && git config user.email "test@example.com" && git config user.name "test" && echo "# gitleaks lab" > README.md && git add README.md && git commit -qm "init" && echo "OK: lab created" && pwd && git log --oneline
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; mkdir -p "$BASE/lab" && cd "$BASE/lab" && git init -q && git config user.email "test@example.com" && git config user.name "test" && echo "# gitleaks lab" > README.md && git add README.md && git commit -qm "init" && echo "OK: lab created" && pwd && git log --oneline
OK: lab created
/path/to/project/lab
a12414b (HEAD -> master) init
emiki@<hostname>:~/work/xx$
Step 4: Create .pre-commit-config.yaml
Create .pre-commit-config.yaml and write it to call gitleaks. The writing format is summarized in the official pre-commit documentation.
The contents I wrote this time are as follows.
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.30.1
hooks:
- id: gitleaks
repos- A list of check tools (repositories) to use. This time it's just
gitleaks
- A list of check tools (repositories) to use. This time it's just
repo- The source repository URL from which to
git clonethe hook code. Specifies thegitleaksGitHub repository - Since
pre-commitfetchesgitleaksfrom here, you can use it without separately installinggitleaksyourself
- The source repository URL from which to
rev- Specifies which version of the
gitleakscode to fetch from GitHub.revstands for revision - Ways to refer to "a specific point in time of code" in Git include tag names (e.g.
v8.30.1), commit SHAs (e.g.84f01ac...), branch names, andHEAD. Of these, tags and SHAs don't change, but branch names andHEADchange when new commits are added - What can be written in
revis a tag or SHA whose content doesn't change. Sincepre-commitcaches the fetchedgitleaksfor reuse, branch names likemainorHEAD(= things that change) cannot be used - This time I specified the tag
v8.30.1for the latest release at the time of writing this article. By fixing to a tag, the samev8.30.1ofgitleaksruns no matter who runs it or when
- Specifies which version of the
hooks- A list of which hooks provided by that repository to use
- A single repository may provide multiple hooks, each with an
id(name).repodetermines "which repository to fetch from," and theidinhooksselects "which check among those to use" idis not something you name yourself; it is determined by the providing repository. The hooks provided by the providing repository are defined with IDs in.pre-commit-hooks.yamlat the root of that repository, so users look there to find and specify the IDs they want to useid- The identifier (name) of the hook to use. The
gitleaksrepository's.pre-commit-hooks.yamldefinesgitleaks(standard version),gitleaks-docker, andgitleaks-system. This time I specified the standard versionid: gitleaks
- The identifier (name) of the hook to use. The
When you want to use multiple checks, list the id entries under hooks. For example, to use two from the popular pre-commit/pre-commit-hooks repository, write it as follows.
pre-commit-config.yaml (example with multiple ids listed)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace # check that removes trailing whitespace
- id: end-of-file-fixer # check that fixes end of file
This time it's just gitleaks, so there's only one id.
The .pre-commit-config.yaml prepared this time means "fetch gitleaks v8.30.1 from GitHub and run it just before each commit."
Step 5: Register with Git Hook
Register with the Git hook.
Run pre-commit install. This registers the contents of .pre-commit-config.yaml with the Git pre-commit hook and creates .git/hooks/pre-commit.
At the same time, I also run ls -la .git/hooks/pre-commit. This is to confirm whether .git/hooks/pre-commit was actually created and whether it has execute permissions (the leading -rwxr-xr-x).
BASE="/path/to/project"; cd "$BASE/lab" && pre-commit install && ls -la .git/hooks/pre-commit
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && pre-commit install && ls -la .git/hooks/pre-commit
pre-commit installed at .git/hooks/pre-commit
-rwxr-xr-x 1 emiki emiki 598 Sep 18 09:42 .git/hooks/pre-commit
emiki@<hostname>:~/work/xx$
Step 6: Check the Contents of the Generated Hook
Verify that the .git/hooks/pre-commit generated by the registration is indeed "just a script that calls the pre-commit tool."
BASE="/path/to/project"; cd "$BASE/lab" && cat .git/hooks/pre-commit
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && cat .git/hooks/pre-commit
#!/usr/bin/env bash
# File generated by pre-commit: https://pre-commit.com
# ID: 138fd403232d2ddd5efb44317e38bf03
# start templated
INSTALL_PYTHON=/usr/bin/python3
ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)
# end templated
HERE="$(cd "$(dirname "$0")" && pwd)"
ARGS+=(--hook-dir "$HERE" -- "$@")
if [ -x "$INSTALL_PYTHON" ]; then
exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"
elif command -v pre-commit > /dev/null; then
exec pre-commit "${ARGS[@]}"
else
echo '`pre-commit` not found. Did you forget to activate your virtualenv?' 1>&2
exit 1
fi
emiki@<hostname>:~/work/xx$
As seen at the beginning, the check itself is not written here; it simply calls the pre-commit tool.
Step 7: Prepare a File with Detectable Dummy Values
I prepare config.py under the assumption that Claude Code created a configuration file containing AWS credentials.
config.py is a file with secrets mixed in, placed at the root of the verification repository lab/.
(working folder)/
└── lab/ ← repository initialized with git init for verification
├── .git/
│ └── hooks/
│ └── pre-commit ← auto-generated by pre-commit install
├── .pre-commit-config.yaml ← configuration file written by yourself (repository root)
├── config.py ← verification file with secrets mixed in
└── README.md
Regarding the dummy values to put here, since I don't want to risk coincidentally matching someone else's actual secrets if I create random values myself, I'll use sample values that have already been published by gitleaks.
AKIAIMNOJVGFDXXXE4OA
An example of this value being detected as an AWS access key is also shown in the gitleaks documentation.
Note that AWS authentication uses a combination of an Access Key ID and a Secret Access Key. Since I used a known sample value for the Access Key ID, I created a randomly generated string on my own that just resembles the format for the Secret side.
AWS_ACCESS_KEY_ID = "AKIAIMNOJVGFDXXXE4OA"
AWS_SECRET_ACCESS_KEY = "kQ9pxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
Actually, at the start of the verification I had used a commonly seen sample value, and at that time the commit went through. I'll mention that later.
Step 8: Stage the Changed Values
Since I don't usually do much development work, I'll organize "stage" and "commit" here for my own benefit.
In Git, rather than immediately recording file changes to history, you proceed in two stages.
git add(stage)- The operation of selecting "include this file in the next record" and loading it into a waiting area called the staging area
- At this point, nothing is recorded in the history yet. This is the stage of selecting what to record ahead of time
git commit(commit)- The operation of recording the files loaded onto the stage as a unified set of changes in the history
- This is when it first gets recorded in the repository's history

The pre-commit and gitleaks checks this time run right before this git commit (commit finalization). So the flow is: first load the file onto the stage with git add, and then when you run git commit, the check runs.
First, load config.py and .pre-commit-config.yaml onto the stage with git add.
BASE="/path/to/project"; cd "$BASE/lab" && git add config.py .pre-commit-config.yaml && echo "---- staged ----" && git status
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git add config.py .pre-commit-config.yaml && echo "---- staged ----" && git status
---- staged ----
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: .pre-commit-config.yaml
new file: config.py
emiki@<hostname>:~/work/xx$
The reason I'm adding .pre-commit-config.yaml together with config.py here is due to the trial-and-error process mentioned in the side note at the end.
※ Naturally, .pre-commit-config.yaml would normally already be committed beforehand.
Step 9: The Commit Was Stopped
Commit with the files staged.
BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"
Detect hardcoded secrets.................................................Failed
- hook id: gitleaks
- exit code: 1
○
│╲
│ ○
○ ░
░ gitleaks
Finding: AWS_SECRET_ACCESS_KEY = "REDACTED"
Secret: REDACTED
RuleID: generic-api-key
Entropy: 5.131556
File: config.py
Line: 2
Fingerprint: config.py:generic-api-key:2
Finding: ...WS_ACCESS_KEY_ID = "REDACTED
Secret: REDACTED
RuleID: aws-access-token
Entropy: 3.646439
File: config.py
Line: 1
Fingerprint: config.py:aws-access-token:1
11:45AM INF 0 commits scanned.
11:45AM INF scanned ~212 bytes (212 bytes) in 295ms
11:45AM WRN leaks found: 2
---- exit code: 1 ----
emiki@<hostname>:~/work/xx$
It resulted in Failed and the commit was aborted. The flow was that gitleaks returned exit code 1, and git commit failed in response.
There were 2 detections. Line 1 is the Access Key ID (aws-access-token), and line 2 is the string on the Secret side, which was picked up as a generic API key (generic-api-key) because of its high randomness. This was an unexpected bonus, but it became a real-world example of detecting suspicious strings as well.
Step 10: Confirm the Commit Was Actually Stopped
Confirm that the commit was actually stopped.
BASE="/path/to/project"; cd "$BASE/lab" && echo "=== log ===" && git log --oneline && echo "=== status ===" && git status
▼ Execution result
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && echo "=== log ===" && git log --oneline && echo "=== status ===" && git status
=== log ===
a12414b (HEAD -> master) init
=== status ===
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: .pre-commit-config.yaml
new file: config.py
emiki@<hostname>:~/work/xx$
- Below
=== log ===(output ofgit log --oneline)- A command that outputs commit history one line at a time
- Only the initial
initline is shown - If the commit in Step 9 had succeeded, there should be an
add configline added, but since it's absent, we can see that thecommitwas not made
- Below
=== status ===underChanges to be committed:(output ofgit status)- A command to view the current state
config.pyand.pre-commit-config.yamlappear asnew file- The files are still loaded onto the stage via
git add, but have not yet beengit committed (recorded in history)
Only the initial init remains in the command history, and add config is not there. The files remain locally but have not been recorded in the history. We can confirm that the process was stopped right before the commit.
Side Note: Sample Values Allowed the Commit to Pass
During verification, I initially entered commonly used sample values. The config.py at that time was as follows.
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
AKIAIOSFODNN7EXAMPLE is a dummy sample value also used in the official AWS documentation.
I created config.py with these values, ran git add, and tried to commit.
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && cat > config.py <<'EOF'
# Configuration file assumed to be generated by Claude Code
AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
EOF
git add config.py && echo "OK: config.py staged"
OK: config.py staged
emiki@<hostname>:~/work/xx$
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git commit -m "add config"; echo "---- exit code: $? ----"
[INFO] Initializing environment for https://github.com/gitleaks/gitleaks.
[INFO] Installing environment for https://github.com/gitleaks/gitleaks.
[INFO] Once installed this environment will be reused.
[INFO] This may take a few minutes...
Detect hardcoded secrets.................................................Passed
[master b850bb8] add config
1 file changed, 3 insertions(+)
create mode 100644 config.py
---- exit code: 0 ----
emiki@<hostname>:~/work/xx$
It showed Passed and the commit went through. I wanted it to be blocked, but it wasn't.
I'll revert this commit for now.
emiki@<hostname>:~/work/xx$ BASE="/path/to/project"; cd "$BASE/lab" && git reset --soft HEAD~1 && echo "---- reset done ----" && git status
---- reset done ----
On branch master
Changes to be committed:
(use "git restore --staged <file>..." to unstage)
new file: config.py
Untracked files:
(use "git add <file>..." to include in what will be committed)
.pre-commit-config.yaml
emiki@<hostname>:~/work/xx$
Here, using git reset --soft HEAD~1 only reverted the commit, so config.py, which had already been added, remained in the staging area, and .pre-commit-config.yaml remained uncommitted. The reason I added config.py and .pre-commit-config.yaml together with git add in step 8 was because of this background.
The cause was that this value was in gitleaks's exclusion list. The AWS detection rule in gitleaks has an allowlist that excludes values matching .+EXAMPLE$ (values ending with EXAMPLE).
AKIAIOSFODNN7EXAMPLE is commonly shown in the official AWS documentation as a placeholder example, as in "put your access key here."
Because this value is reused across documentation and sample code, it appears throughout code in the wild.
From gitleaks's perspective, detecting this as a secret every time would increase false positives — warnings for values that aren't real secrets.
For this reason, such sample values may be excluded from detection via the allowlist to avoid false positives. In a GitLab Issue as well, a proposal was made to exclude AWS sample keys from detection targets.
After replacing the value with gitleaks's own public test value, I was able to confirm the expected behavior of the commit being blocked.
Conclusion
By setting up pre-commit and gitleaks, commits containing secrets are blocked locally. Even if secrets accidentally get mixed into code written by Claude Code, you can catch them before they end up in the history.
However, this mechanism only protects at the point of committing.
Dangerous operations other than commits — such as Claude Code deleting files or communicating externally — cannot be stopped by gitleaks, so PreToolUse hooks and sandboxes are candidates as means to block such operations.
For questions or requests about this article, please contact us via "Feedback for DevelopersIO" at the bottom of the page.
References