SAST and Secrets Detection
- SAST and Secrets Detection
- Finding the bug before the attacker does
- What SAST can and cannot find
- The tool landscape
- Semgrep: the primary tool
- Common patterns detected
- Triage: turning findings into decisions
- Secrets detection
- Bandit: Python-specific depth
- Snyk Code and commercial SAST
- Wiring SAST into CI
- Key takeaways
- References
Finding the bug before the attacker does
Static Application Security Testing (SAST) analyzes source code, bytecode, or binaries without running them, looking for patterns that produce vulnerabilities: unsanitized input reaching a SQL query, user-controlled data reaching a shell, a password hashed with MD5.
The economic argument for SAST is the entire reason “shift left” exists. A vulnerability caught by a pre-commit hook costs a developer two minutes. The same vulnerability caught in production costs an incident response, a customer notification, and possibly a regulatory filing. SAST is cheap, fast, and repeatable, so you run it constantly.
The engineering argument is narrower and more honest: SAST is a filter, not a guarantee. It catches a consistent, well-defined class of defects and is blind to everything else. A tool that reports zero findings has told you almost nothing about whether your application is secure.
This page uses Semgrep as the primary tool because it is multi-language, has a readable rule syntax you can extend, and its Community Edition is free and open source. Bandit and Snyk Code appear as complements, and secrets detection gets its own treatment because it is a different problem wearing similar clothes.
⚠️ A clean SAST report is not a security assessment. SAST reasons about code shape, not intent. It will never tell you that your “delete account” endpoint forgot to check whose account, because that is a business-logic flaw and every line of code involved looks perfectly normal. Pair SAST with threat modeling, code review, and runtime testing.
What SAST can and cannot find
| SAST finds reliably | SAST misses |
|---|---|
| Injection where input reaches a dangerous sink (SQL, shell, LDAP, XPath) | Business-logic flaws — missing authorization, broken workflow order |
Known-bad API use — yaml.load, pickle.loads, shell=True |
Vulnerabilities that only exist in configuration or deployment |
| Weak or misused cryptography — MD5/SHA-1, ECB mode, static IVs | Race conditions and most concurrency bugs |
| Hardcoded credentials and keys | Flaws in third-party dependencies (that is SCA’s job) |
Dangerous defaults — debug=True, permissive CORS, disabled TLS verification |
Anything whose danger depends on runtime data or environment |
The distinction that matters in triage: SAST reports code that matches a risky pattern, which is not the same as an exploitable vulnerability. Closing that gap is human work — see triage.
The tool landscape
| Tool | Type | Languages | Cost | Best at |
|---|---|---|---|---|
| Semgrep CE | Pattern + dataflow | 30+ | Free (LGPL-2.1) | General-purpose SAST, custom org-specific rules |
| Bandit | Python AST checks | Python only | Free (Apache-2.0) | Deep, opinionated Python coverage; zero config |
| Snyk Code | ML/semantic (DeepCode) | 10+ | Free tier, then per-dev | Low false-positive rate, IDE integration, fix suggestions |
| CodeQL | Query over code database | 10+ | Free for public repos | Deep interprocedural analysis; steep learning curve |
| gitleaks | Regex + entropy over git history | Any | Free (MIT) | Fast pre-commit and history secret scanning |
| TruffleHog | Detectors + live verification | Any | Free core | Confirming whether a found secret still works |
These overlap deliberately. A realistic pipeline runs Semgrep for breadth, adds Bandit if the codebase is Python-heavy, and runs a secrets scanner on every commit. Redundancy is acceptable here because each tool’s false negatives differ.
Semgrep: the primary tool
Semgrep matches patterns written in the syntax of the language being analyzed. A rule that finds dangerous execute() calls looks like Python, not like a regex. That is the design decision that makes custom rules practical for people who are not static-analysis researchers.
Semgrep Community Edition (CE) is free and open source (LGPL-2.1), scans 30+ languages, and runs entirely locally with no account required. The commercial AppSec Platform adds cross-file (interprocedural) analysis, managed triage state, and validated secrets — noted where relevant below.
Installing and running a first scan
python3 -m venv ~/.venvs/sast
~/.venvs/sast/bin/pip install semgrep
semgrep --version
Point it at a directory with a registry ruleset. Here it runs against a deliberately vulnerable Flask app:
semgrep scan --config p/python --metrics off vulnshop/
┌──────────────┐
│ Scan Summary │
└──────────────┘
✅ Scan completed successfully.
• Findings: 9 (9 blocking)
• Rules run: 151
• Targets scanned: 1
• Parsed lines: ~100.0%
Ran 151 rules on 1 file: 9 findings.
vulnshop/app.py
❯❯❱ python.flask.security.injection.subprocess-injection.subprocess-injection
Detected user input entering a `subprocess` call unsafely. This could result in a
command injection vulnerability. [...]
26┆ return subprocess.check_output("ping -c 1 " + host, shell=True)
❯❯❱ python.lang.security.audit.subprocess-shell-true.subprocess-shell-true
Found 'subprocess' function 'check_output' with 'shell=True'. [...]
26┆ return subprocess.check_output("ping -c 1 " + host, shell=True)
❯❱ python.lang.security.audit.md5-used-as-password.md5-used-as-password
It looks like MD5 is used as a password hash. [...] Use `hashlib.scrypt`.
36┆ return hashlib.md5(password.encode()).hexdigest()
❯❱ python.flask.security.audit.debug-enabled.debug-enabled
Detected Flask app with debug=True. [...]
44┆ app.run(debug=True, host="0.0.0.0")
Note that line 26 produced three findings from three different rules. Overlapping rules are normal and are the first thing you will deduplicate during triage.
--metrics off disables usage telemetry. Semgrep CE sends anonymous metrics by default when using registry rules; turn it off for coursework and for any codebase whose file paths you would rather not transmit.
Choosing rulesets
The --config flag accepts registry shorthands (p/…), local files, or directories, and can be repeated:
semgrep scan --config p/default # curated cross-language security rules
semgrep scan --config p/owasp-top-ten # mapped to OWASP Top 10 categories
semgrep scan --config p/python --config ./rules/ # registry + your own rules
semgrep scan --config auto # infer rulesets from the project's languages
Start with p/default or a language pack. p/security-audit is deliberately noisy — it flags things worth looking at rather than things that are definitely wrong — so it belongs in a scheduled audit, not a blocking CI gate.
Anatomy of a rule
Every Semgrep rule is YAML with the same handful of keys:
rules:
- id: no-raw-sql-execute # stable identifier; used for suppression
message: >- # what the developer reads; say how to fix it
Query built with string formatting and passed to execute(). Use a
parameterized query: cur.execute("... WHERE id = ?", (uid,)).
languages: [python]
severity: ERROR # INFO | WARNING | ERROR
metadata:
cwe: "CWE-89: SQL Injection"
owasp: "A03:2021 - Injection"
patterns: # ALL must match
- pattern-either: # ANY of these
- pattern: $CUR.execute("..." % ...)
- pattern: $CUR.execute("..." + ...)
- pattern: $CUR.execute("...".format(...))
- pattern: $CUR.execute(f"...")
- pattern-not: $CUR.execute("...") # exclude safe constant queries
The syntax elements to internalize:
...is the ellipsis — it matches any sequence of arguments, statements, or characters.$CURis a metavariable — it matches any expression and binds it, so the same name later in the rule must match the same code.patternsis AND,pattern-eitheris OR,pattern-notsubtracts. Precision comes almost entirely frompattern-not.metadatais free-form, butcweandowaspkeys are what let you roll findings up into reports.
Worked example: writing and running a custom rule
Save the rule above as rules/no-raw-sql.yaml and run it against the sample app:
semgrep scan --config rules/no-raw-sql.yaml --metrics off vulnshop/
┌────────────────┐
│ 1 Code Finding │
└────────────────┘
vulnshop/app.py
❯❯❱ rules.no-raw-sql-execute
❰❰ Blocking ❱❱
Query built with string formatting and passed to execute(). Use a parameterized
query: cur.execute("... WHERE id = ?", (uid,)).
19┆ cur.execute("SELECT name, email FROM users WHERE id = '%s'" % uid)
This is where custom rules earn their keep. Generic rulesets encode industry knowledge; custom rules encode your knowledge — that internal db.raw() helper is dangerous, that @requires_auth must decorate every route under /admin, that nobody may import the deprecated crypto wrapper again.
Worked example: taint mode, source to sink
Pattern matching alone flags shell=True wherever it appears, including in safe code with a hardcoded command. Taint mode instead tracks whether untrusted data actually reaches the dangerous call, which cuts false positives sharply.
rules/taint-cmd.yaml:
rules:
- id: flask-arg-to-shell
message: >-
Request parameter flows into a shell command. Pass an argument list with
shell=False, or validate against an allowlist.
languages: [python]
severity: ERROR
mode: taint
metadata:
cwe: "CWE-78: OS Command Injection"
owasp: "A03:2021 - Injection"
pattern-sources:
- pattern: flask.request.args.get(...)
- pattern: flask.request.form.get(...)
pattern-sanitizers:
- pattern: shlex.quote(...)
pattern-sinks:
- pattern: subprocess.$FN(..., shell=True, ...)
- pattern: os.system(...)
semgrep scan --config rules/taint-cmd.yaml --metrics off vulnshop/
vulnshop/app.py
❯❯❱ rules.flask-arg-to-shell
❰❰ Blocking ❱❱
Request parameter flows into a shell command. Pass an argument list with
shell=False, or validate against an allowlist.
26┆ return subprocess.check_output("ping -c 1 " + host, shell=True)
The four taint keys: pattern-sources (where untrusted data enters), pattern-sinks (where it must not arrive), pattern-sanitizers (what makes it safe again), and pattern-propagators (functions that pass taint through). Wrapping the input in shlex.quote() makes this finding disappear — which is exactly the behavior you want, and exactly what you should verify when you write a sanitizer.
⚠️ Semgrep CE analyzes one file at a time. Taint tracking works within a file; if the source is in
routes.pyand the sink is indb/helpers.py, CE will not connect them. Cross-file analysis is a paid AppSec Platform feature. Write rules that flag the dangerous sink itself when the data flow crosses module boundaries.
Worked example: testing your rules
A rule that silently stops matching is worse than no rule. Semgrep has a built-in test runner driven by annotation comments. Put a test file next to the rule with the same basename — rules/no-raw-sql.yaml pairs with rules/no-raw-sql.py:
def bad_percent(cur, uid):
# ruleid: no-raw-sql-execute
cur.execute("SELECT * FROM users WHERE id = '%s'" % uid)
def bad_fstring(cur, uid):
# ruleid: no-raw-sql-execute
cur.execute(f"SELECT * FROM users WHERE id = {uid}")
def good_parameterized(cur, uid):
# ok: no-raw-sql-execute
cur.execute("SELECT * FROM users WHERE id = ?", (uid,))
# ruleid: asserts the rule must fire on the next line; # ok: asserts it must not. Then:
semgrep test rules/
1/1: ✓ All tests passed
No tests for fixes found.
Every custom rule should ship with at least one true positive and one true negative. The # ok: cases are the important ones — they are what catch a rule that has quietly become over-broad.
ℹ️
semgrep testdoes not accept--metrics; that flag belongs tosemgrep scan. Rule tests run entirely locally.
Common patterns detected
These are the finding classes you will actually see, with the rule IDs from the run above:
| Pattern | Why it’s dangerous | Example rule IDs |
|---|---|---|
| SQL injection (CWE-89) | String-built queries let input change query structure | no-raw-sql-execute, Bandit B608 |
| Command injection (CWE-78) | User data in a shell string yields arbitrary execution | subprocess-injection, subprocess-shell-true, Bandit B602 |
| Unsafe deserialization (CWE-502) | yaml.load/pickle.loads instantiate arbitrary objects |
avoid-pyyaml-load, Bandit B506 |
| Weak hashing (CWE-327/916) | MD5/SHA-1 are collision-prone and far too fast for passwords | insecure-hash-algorithm-md5, md5-used-as-password, Bandit B324 |
| Hardcoded credentials (CWE-798/259) | Secrets in source leak through the repo, CI logs, and images | Bandit B105, gitleaks rules |
| Dangerous defaults (CWE-489) | debug=True exposes the Werkzeug console — remote code execution |
debug-enabled, Bandit B201 |
| Path traversal (CWE-22) | Unvalidated paths reach the filesystem | path-traversal-open |
| SSRF (CWE-918) | User-controlled URLs fetched server-side reach internal services | ssrf-requests |
| Disabled TLS verification (CWE-295) | verify=False silently accepts any certificate |
request-with-http, disabled-cert-validation |
Weak hashing deserves a note: the two MD5 findings above are different severities in practice. hashlib.md5() on a file checksum is usually fine; the same call on a password is a serious defect. Semgrep ships both a generic rule and a md5-used-as-password rule precisely because context changes the verdict — see cryptography for why.
Triage: turning findings into decisions
Nine findings on a 29-line file scales to thousands on a real codebase. Triage is the skill that determines whether SAST helps or gets switched off after a month.
Severity is not priority
Semgrep severities are INFO, WARNING, ERROR; Bandit reports severity and confidence independently. Filter before you read:
semgrep scan --config p/python --severity ERROR --metrics off vulnshop/
• Findings: 4 (4 blocking)
• Rules run: 55
Nine findings became four by dropping to ERROR-only. Real priority is a judgment combining severity, confidence, and reachability: is this code on a path an attacker can reach with data they control? A HIGH-severity finding in a script that only runs from cron with fixed arguments outranks nothing.
Classify every finding
Give each finding exactly one of three dispositions, and record the reasoning:
- True Positive (TP) — the pattern is real and exploitable or plausibly so. Fix it.
- False Positive (FP) — the pattern matched but the danger does not exist here (the “user input” is a constant; the sink is sanitized in a way the rule cannot see). Suppress it with the reason written down.
- Accepted Risk (AR) — the finding is real but you are choosing not to fix it now. This requires a rationale, an owner, and ideally an expiry date. AR is a decision, not a synonym for “ignore.”
The written rationale is the deliverable. “FP — host is validated against ALLOWED_HOSTS on line 22” is triage; marking something FP with no note is just deleting evidence.
Suppressing findings
Three levels, from narrowest to broadest — always use the narrowest that works.
In code. A # nosemgrep: <rule-id> comment on the line before (or at the end of) the finding:
def f(cur, uid):
# nosemgrep: no-raw-sql-execute
cur.execute("SELECT * FROM t WHERE id = %s" % uid)
semgrep scan --config rules/no-raw-sql.yaml --metrics off supp/
• Findings: 0 (0 blocking)
Always name the specific rule ID. A bare # nosemgrep suppresses every rule on that line, including ones written years later. Bandit’s equivalent is # nosec B608 — same principle, same reason to be specific.
In configuration. .semgrepignore uses .gitignore syntax and belongs in the repo root, for vendored code, generated files, and test fixtures full of deliberately bad examples:
vendor/
node_modules/
tests/fixtures/
*.min.js
In the platform. Semgrep AppSec Platform persists triage state server-side, so a finding marked FP stays marked across scans and branches without a code comment. This is the main workflow advantage of the paid tier.
Baselines and diff-aware scanning
Nobody adopts SAST on a mature codebase by fixing everything first. Scan only what changed:
semgrep scan --config p/python --baseline-commit $(git merge-base HEAD origin/main)
semgrep ci # diff-aware by default in CI
bandit -r src/ -f json -o baseline.json # Bandit's equivalent
bandit -r src/ -b baseline.json
--baseline-commit reports only findings not present in that commit. The legacy backlog stays visible in scheduled full scans while the gate on pull requests enforces “no new problems.” That asymmetry is what makes adoption politically survivable.
⚠️ Roll out in comment mode before block mode. Start with SAST reporting on pull requests without failing the build. Promote a rule to blocking only after it has produced zero or near-zero false positives on your codebase. A gate that cries wolf gets bypassed with
--no-verify, and then you have neither the gate nor the trust.
Secrets detection
A secret in source code is a different problem from a vulnerable code pattern, and it needs a different tool, for one reason: git remembers. Deleting the file fixes the working tree and does nothing about history.
Worked example: why history is the whole point
A repository where .env was committed and then removed in a later commit:
git log --oneline
88028c6 remove .env, add gitignore
f9cdb3c add config
Scanning the working tree finds nothing:
gitleaks dir .
INF scanned ~5 bytes (5 bytes) in 1.86ms
INF no leaks found
Scanning history tells the truth:
gitleaks git .
INF 2 commits scanned.
WRN leaks found: 2
gitleaks git . --report-format json --report-path leaks.json
RuleID : aws-access-token
Description: Identified a pattern that may indicate AWS credentials, risking
unauthorized cloud resource access and data breaches on AWS platforms.
File : .env
Commit : 40f2f704 | Demo | 2026-07-20T16:07:31Z
Secret : AKIA3RQV7T2M...
Entropy : 4.12
RuleID : generic-api-key
Description: Detected a Generic API Key, potentially exposing access to various
services and sensitive operations.
File : .env
Commit : 40f2f704 | Demo | 2026-07-20T16:07:31Z
Secret : Xg7pQm2LbVt9...
Entropy : 5.32
Two detection strategies are visible here. aws-access-token is a structural match — AKIA followed by 16 uppercase alphanumerics is unambiguously an AWS key ID. generic-api-key is an entropy match: at 5.32 bits per character, that string is too random to be prose. Structural rules are precise; entropy rules are the safety net that catches credentials nobody wrote a pattern for, at the cost of flagging the occasional hash or base64 blob.
ℹ️ Command names changed in gitleaks 8.19.
gitleaks detect --source .becamegitleaks git .,detect --no-gitbecamegitleaks dir ., and--pipebecamegitleaks stdin. The old commands still run but are hidden from--help. Older lab handouts and blog posts overwhelmingly usedetect; prefer the current forms.
Allowlists and known-fake secrets
Rerunning the same demo with AWS’s published documentation key (AKIAIOSFODNN7EXAMPLE) reports no leaks — gitleaks allowlists the well-known example credentials on purpose. That is the right default, and it is also a reminder that any allowlist is a place where a real secret can hide. Review .gitleaksignore and custom allowlist regexes with the same suspicion you would apply to a # nosemgrep comment.
Worked example: allowlisting a public GPG key
Entropy rules generate a predictable false positive: third-party APT repository signing keys. An ASCII-armored key is thousands of characters of base64, and base64 is exactly what an entropy rule is built to notice. These keys are public by design — publishing them is the entire point — so the finding is noise, but it is noise that trains people to ignore the scanner.
Start by identifying which rule fired, so the fix can be narrow:
gitleaks dir . --report-format json --report-path leaks.json
jq -r '.[] | "\(.RuleID) \(.File) line \(.StartLine)"' leaks.json
It will be generic-api-key or another entropy rule — not private-key. The default private-key rule requires the literal string PRIVATE KEY in the armor header, so a -----BEGIN PGP PUBLIC KEY BLOCK----- cannot match it. That distinction matters: it means you can suppress the public-key noise without blinding the rule that catches an actual private key.
Add a .gitleaks.toml at the repository root, where gitleaks discovers it automatically:
title = "course repo — gitleaks config"
[extend]
useDefault = true # keep every built-in rule
[[allowlists]]
description = "Third-party APT repository signing keys — public by design"
targetRules = ["generic-api-key"]
paths = [
'''(^|/)keys?/.*\.(asc|gpg|pub)$''',
'''(^|/)(etc/apt/keyrings|usr/share/keyrings)/''',
]
Scope by path, not by a regex matching -----BEGIN PGP PUBLIC KEY BLOCK-----. The finding’s matched secret is a high-entropy chunk from the middle of the armor block, not the header line, so a header regex usually will not match the thing you are trying to allowlist. If you do want regex matching, regexes is evaluated against regexTarget — "secret" (the default), "match", or "line" — and condition = "AND" requires every criterion to match rather than any one of them.
targetRules is what keeps this honest. A bare path allowlist would also suppress private-key, so a real private key dropped into keys/ would go unreported. Naming the entropy rule leaves the structural rules armed.
Verify that the allowlist does what you think, and only what you think:
gitleaks dir . --config .gitleaks.toml -v # the GPG finding is gone
⚠️ The plural forms are version-dependent.
[allowlist]became[[allowlists]]in gitleaks 8.25.0, and[rules.allowlist]became[[rules.allowlists]]in 8.21.0. Config using the new syntax on an older binary fails in confusing ways. Checkgitleaks versionbefore copying this.
The better answer, where you can reach it, is not to commit the key at all. Fetch it at provision time instead — Ansible’s get_url into /etc/apt/keyrings/ gives you nothing to scan and picks up key rotations for free:
- name: Install Docker's repository signing key
ansible.builtin.get_url:
url: https://download.docker.com/linux/ubuntu/gpg
dest: /etc/apt/keyrings/docker.asc
mode: "0644"
A repository that never contains the artifact needs no rule to ignore it. Reach for the allowlist when you genuinely must vendor a key — air-gapped builds, pinned supply chains — and keep it as tight as the vendoring requires.
For a one-off finding you would rather pin than generalize, .gitleaksignore takes a fingerprint of the form commit:file:ruleID:line:
cd5226711335c68be1e720b318b7bc3135a30eb2:keys/docker.asc:generic-api-key:23
That is the narrowest possible suppression, and also the most brittle: the fingerprint changes when the line moves, and it then silently stops suppressing.
Choosing a secrets scanner
| Tool | Approach | Use it for |
|---|---|---|
| gitleaks | Regex + entropy, single Go binary, no network | Pre-commit hooks and CI — fast enough to block every commit |
| TruffleHog | 800+ purpose-built detectors, live verification | History audits where you need to know which secrets still work |
| Semgrep Secrets | Semgrep rules + validation, code-context aware | Teams already standardized on Semgrep who want one dashboard (paid) |
| GitHub Secret Scanning | Platform-side, partner-notified | Backstop — providers can auto-revoke leaked tokens |
TruffleHog’s verification is the differentiator worth understanding: it calls the provider’s API to check whether a discovered credential is still valid. That converts a pile of maybe-secrets into a ranked list where the live ones sort to the top. The tradeoff is that verification makes network calls — never point it at a third party’s credentials, and expect rate limits.
Layer them: gitleaks in the pre-commit hook so secrets never land, TruffleHog periodically over full history for verified findings, platform scanning as the backstop.
Remediating a leaked secret
The order is not negotiable:
- Rotate the credential first. It is compromised the moment it is pushed. Assume it was scraped — automated crawlers watch public pushes within seconds.
- Revoke the old value and check provider audit logs for use you did not authorize.
- Then consider history rewriting (
git filter-repo, BFG). This breaks every clone and does nothing about copies already pulled, forked, or cached by the host — which is why it is step 3, not step 1. - Fix the cause: move the secret into OpenBao or the CI secret store, add the path to
.gitignore, and install the pre-commit hook.
⚠️ Rewriting history is not a remediation. It is cleanup. A rotated key in a public commit is harmless; an un-rotated key scrubbed from history is still valid for anyone who already has it.
Finding what was committed by accident
Before you can excise anything you need the list of things to excise. A useful heuristic: any file that your current .gitignore would block, but which nonetheless appears in history, was committed before the rule existed — that is exactly how .env, terraform.tfstate, private keys, and database dumps get in. The .gitignore you added after the mistake is a ready-made list of what to hunt for.
git check-ignore answers “would this path be ignored?” — but by default it stays silent for a tracked path, because ignore rules do not apply to tracked files. The --no-index flag removes that shortcut and evaluates the rules regardless, which is what makes it usable as an auditing tool. Feed it every path that has ever existed across all refs:
git log --all --pretty=format: --name-only | sort -u | grep -v '^$' |
while IFS= read -r f; do git check-ignore --no-index -- "$f" 2>/dev/null; done
.env
db/seed-dump.sql
secrets/terraform.tfstate
terraform/.terraform/providers/registry.opentofu.org/.../terraform-provider-proxmox
Reading the pipeline:
git log --all --name-onlylists every path touched on every branch and tag, not just what is checked out now;--pretty=format:blanks the commit headers andgrep -v '^$'drops the blank separators.--no-indexis the load-bearing flag. Without it, tracked files report as “not ignored” and the list comes back empty — the opposite of what you want.- The
whileloop instead ofcheck-ignore --stdinis defensive: if a historical path descends through something that is a symlink in the current working tree,check-ignoreprintsfatal: pathspec ... is beyond a symbolic linkand, in a single pipe, aborts the whole sweep. Per-path invocation sends that one error to/dev/nulland keeps going.
Add -v to see which ignore rule matched each path — useful when auditing whether a rule does what you think:
... | while IFS= read -r f; do git check-ignore --no-index -v -- "$f" 2>/dev/null; done
.gitignore:12:*.tfstate secrets/terraform.tfstate
.gitignore:3:.env .env
Now run gitleaks over that shortlist to confirm which files actually carried secrets (a .tfstate almost always does — Terraform/OpenTofu stores resource attributes, including provider tokens and generated passwords, in plaintext), then excise them.
Excising files and secrets with git-filter-repo
git-filter-repo is the maintainers’ recommended replacement for the deprecated git filter-branch (and a faster successor to the BFG Repo-Cleaner). It rewrites every commit, so every commit hash downstream of the change is replaced — which is why it is a “nuke and re-clone” operation, not an edit.
pip install git-filter-repo # or: apt/brew install git-filter-repo
ℹ️ It is a separate tool, not built into git. It needs git ≥ 2.36.0 and python3 ≥ 3.6, and by design aborts unless it is run from a fresh clone (guarding against destroying local-only history). Work from a throwaway clone; pass
--forceonly when you understand you are rewriting the real thing.
Remove an entire file — and every historical version of it — with --path (or --path-glob) plus --invert-paths, which means “keep everything except these”:
git clone https://example.com/ops/infra.git infra-clean # fresh clone, required
cd infra-clean
git filter-repo --path secrets/terraform.tfstate --invert-paths
git filter-repo --path-glob '**/*.tfstate' --invert-paths # by glob instead
Redact a secret string that lives inside otherwise-good files with --replace-text and an expressions file. Each line is match==>replacement; omit ==> and the replacement defaults to ***REMOVED***. Prefix a line with literal: (the default), regex:, or glob::
cat > /tmp/redact.txt <<'EOF'
AKIA3RQV7T2MEXAMPLE==>AWS_KEY_REDACTED
regex:ghp_[A-Za-z0-9]{36}==>GITHUB_PAT_REDACTED
literal:hunter2-not-a-real-password
EOF
git filter-repo --replace-text /tmp/redact.txt
Scrub the commit messages themselves — where secrets also hide, pasted into a git commit -m — with --replace-message, which takes the identical expressions-file syntax. --replace-text touches file contents only; messages need this separate flag:
cat > /tmp/redact-msg.txt <<'EOF'
regex:password\s*=\s*\S+==>password=REDACTED
EOF
git filter-repo --replace-message /tmp/redact-msg.txt
Push the rewrite and force everyone to re-sync. git-filter-repo deliberately runs git remote rm origin as part of the rewrite, so you cannot absent-mindedly reuse stale refs — re-add it explicitly:
git remote add origin https://example.com/ops/infra.git
git push --force --all
git push --force --tags
Every collaborator must now re-clone (or hard-reset), because their local history shares no commits with the rewritten repo; anyone who merges an old branch happily reintroduces the secret.
⚠️ A force-push does not purge the host’s copy. On GitHub the old commits linger as unreachable (“dangling”) objects — still fetchable by their SHA and cached in PR/compare views — until garbage collection runs. Forcing them out generally means opening a support request to run GC and clear caches, or deleting and recreating the repo. This is the concrete reason the remediation order puts rotate the credential first: you cannot guarantee the bytes are gone from every clone, fork, and cache.
Bandit: Python-specific depth
Bandit walks Python ASTs against a fixed set of plugins, each reporting severity and confidence separately. It is narrower than Semgrep and needs no configuration, which makes it a good second opinion on Python code.
bandit -r vulnshop/
>> Issue: [B105:hardcoded_password_string] Possible hardcoded password: 'hunter2-not-a-real-password'
Severity: Low Confidence: Medium
CWE: CWE-259
Location: vulnshop/app.py:11:14
>> Issue: [B608:hardcoded_sql_expressions] Possible SQL injection vector through string-based query construction.
Severity: Medium Confidence: Medium
CWE: CWE-89
Location: vulnshop/app.py:19:16
>> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue.
Severity: High Confidence: High
CWE: CWE-78
Location: vulnshop/app.py:26:11
>> Issue: [B506:yaml_load] Use of unsafe yaml load. Allows instantiation of arbitrary objects.
Severity: Medium Confidence: High
CWE: CWE-20
Location: vulnshop/app.py:32:15
>> Issue: [B324:hashlib] Use of weak MD5 hash for security. Consider usedforsecurity=False
Severity: High Confidence: High
CWE: CWE-327
Location: vulnshop/app.py:36:11
Run metrics:
Total issues (by severity):
Low: 2 Medium: 3 High: 3
Total issues (by confidence):
Medium: 4 High: 4
Read that two-dimensional grid as a work queue. High severity + high confidence (B602, B324) is drop-everything work. High severity + medium confidence (B201 Flask debug) is very likely real. Low severity + medium confidence (B105) is where most false positives live — Bandit flags any string assigned to a password-shaped variable name, so test fixtures light it up constantly.
Useful flags:
bandit -r src/ -ll # MEDIUM and above (-lll for HIGH only)
bandit -r src/ -iii # filter by confidence
bandit -r src/ -f json -o out.json
bandit -c pyproject.toml -r src/ # config via [tool.bandit]
Note that Bandit’s severity levels are set per-plugin and are not calibrated against Semgrep’s — a Bandit MEDIUM and a Semgrep WARNING are not the same claim. Never merge the two tools’ outputs into one severity-sorted list without normalizing.
Snyk Code and commercial SAST
Snyk Code is a semantic/ML analyzer (built on the DeepCode engine Snyk acquired in 2020) with a free tier suitable for coursework and solo projects:
npm install -g snyk
snyk auth
snyk code test # SAST on your own source
snyk code test --sarif-file-output=snyk.sarif
Its strengths are a genuinely low false-positive rate, interprocedural analysis across files, inline fix suggestions, and IDE integration. Its constraints are that it is closed-source (you cannot read the rules), requires an account, and the free tier is capped at 100 Code tests per month.
Distinguish snyk code (SAST — your source) from snyk test (SCA — your dependencies). They answer different questions, and the dependency side is covered in vulnerability management.
SARIF is the interchange format worth knowing: Semgrep (--sarif), Snyk (--sarif), and most other scanners emit SARIF 2.1.0, and GitHub code scanning, GitLab, and DefectDojo ingest it. Emitting SARIF is how you avoid coupling your pipeline to one vendor’s dashboard.
Wiring SAST into CI
The pattern generalizes across the tools:
# .github/workflows/sast.yml
name: SAST
on: [pull_request]
jobs:
semgrep:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history: needed for baseline diffs
- run: pip install semgrep
- run: semgrep scan --config p/default --sarif --output semgrep.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: gitleaks/gitleaks-action@v2
Four rules for making this survive contact with a team:
fetch-depth: 0— shallow clones break both baseline comparison and history-based secret scanning. This is the single most common misconfiguration, and it fails silently: gitleaks on a shallow clone reports0 commits scanned/no leaks foundand exits 0. Assert on the commit count, not just the leak count.- Fail the build only on what you mean. Gate on
--severity ERROR, let the rest report. - Keep it fast. Diff-aware scans on pull requests, full scans nightly. A five-minute PR check gets disabled.
- Never print findings containing secrets into CI logs. CI logs are frequently more widely readable than the repo. Write to a report artifact, and remember that pipeline security applies to the scanner too.
Key takeaways
- SAST is a fast, cheap filter for a specific class of defects — injection, dangerous API use, weak crypto, hardcoded credentials — and it is structurally blind to business-logic flaws. Zero findings means very little.
- Semgrep’s rules are written in the target language’s own syntax, which is what makes custom, organization-specific rules realistic to maintain;
patterns/pattern-either/pattern-notplus metavariables cover most needs. - Taint mode (
mode: taintwith sources, sinks, sanitizers) reports data flow rather than code shape, and is the main lever for cutting false positives — but Semgrep CE only tracks taint within a single file. - Ship tests with every custom rule (
# ruleid:/# ok:annotations,semgrep test); the negative cases catch over-broad rules. - Triage means assigning every finding TP, FP, or Accepted Risk with a written rationale. Suppress with a specific rule ID (
# nosemgrep: rule-id,# nosec B608), never a bare suppression. - Adopt with
--baseline-commitdiff scanning and comment mode, then promote rules to blocking once they are quiet on your codebase. - Secrets detection is a history problem:
gitleaks dirfound nothing wheregitleaks gitfound two live credentials. Structural rules catch known formats, entropy catches the rest. - Your post-mistake
.gitignoreis an audit list:git check-ignore --no-indexover every historical path surfaces the.env/.tfstate/keys that were committed before the rule existed. Excise them withgit-filter-repo(--path … --invert-paths,--replace-text,--replace-message) — from a fresh clone, then force-push and re-clone everywhere. - Rotate a leaked credential first; history rewriting is cleanup, not remediation.
- Entropy rules flag public GPG signing keys. Scope the allowlist by
pathsandtargetRules = ["generic-api-key"]soprivate-keystays armed — or better, fetch the key at provision time so there is nothing to scan. - Emit SARIF so findings flow into any dashboard, and set
fetch-depth: 0in CI or your baselines and history scans silently do nothing.
References
- Semgrep documentation — https://docs.semgrep.dev/
- Semgrep CLI reference — https://docs.semgrep.dev/cli-reference
- Semgrep rule syntax — https://semgrep.dev/docs/writing-rules/rule-syntax
- Semgrep taint mode overview — https://semgrep.dev/docs/writing-rules/data-flow/taint-mode/overview
- Semgrep Community Edition — https://semgrep.dev/products/community-edition/
- semgrep-rules (community rule source) — https://github.com/semgrep/semgrep-rules
- Trail of Bits Testing Handbook — Semgrep — https://appsec.guide/docs/static-analysis/semgrep/
- Bandit documentation — https://bandit.readthedocs.io/
- Bandit configuration and
# nosec— https://bandit.readthedocs.io/en/latest/config.html - gitleaks — https://github.com/gitleaks/gitleaks
- gitleaks configuration and allowlists — https://github.com/gitleaks/gitleaks#configuration
- gitleaks default rule set (
config/gitleaks.toml) — https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml - Ansible
get_urlmodule — https://docs.ansible.com/ansible/latest/collections/ansible/builtin/get_url_module.html - TruffleHog — https://github.com/trufflesecurity/trufflehog
- git-filter-repo — https://github.com/newren/git-filter-repo
- git-filter-repo manual — https://htmlpreview.github.io/?https://github.com/newren/git-filter-repo/blob/docs/html/git-filter-repo.html
- git check-ignore — https://git-scm.com/docs/git-check-ignore
- GitHub — removing sensitive data from a repository — https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/removing-sensitive-data-from-a-repository
- Snyk CLI
code test— https://docs.snyk.io/developer-tools/snyk-cli/commands/code-test - OASIS SARIF 2.1.0 specification — https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
- OWASP Top 10 — https://owasp.org/www-project-top-ten/
- OWASP Source Code Analysis Tools — https://owasp.org/www-community/Source_Code_Analysis_Tools
- CWE Top 25 Most Dangerous Software Weaknesses — https://cwe.mitre.org/top25/
- NIST SP 800-218 — Secure Software Development Framework (SSDF) — https://csrc.nist.gov/pubs/sp/800/218/final
Related course pages: DevSecOps fundamentals · Vulnerability management · CI/CD & DevOps · OpenBao · Threat modeling
🛠️ Maintenance note: Transcripts on this page were generated with Semgrep 1.170.0, Bandit 1.9.4, and gitleaks 8.30.1 (July 2026). Semgrep releases weekly and its registry rulesets change continuously — finding counts in the worked examples will drift, and rule IDs occasionally get renamed, so re-run before each term rather than trusting the numbers here. Watch three things specifically: the CE/Platform feature line (cross-file analysis and persistent triage state are paid, and the boundary moves), gitleaks’
detect→git/dircommand rename from 8.19 (the deprecated forms still work but may eventually be removed), and Snyk’s free-tier test cap, which has changed more than once. Bandit is comparatively stable but its plugin IDs are the thing to re-verify if a# nosecstops working.