courses

SAST and Secrets Detection

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:

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.py and the sink is in db/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 basenamerules/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 test does not accept --metrics; that flag belongs to semgrep 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:

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 . became gitleaks git ., detect --no-git became gitleaks dir ., and --pipe became gitleaks stdin. The old commands still run but are hidden from --help. Older lab handouts and blog posts overwhelmingly use detect; 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. Check gitleaks version before 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:

  1. Rotate the credential first. It is compromised the moment it is pushed. Assume it was scraped — automated crawlers watch public pushes within seconds.
  2. Revoke the old value and check provider audit logs for use you did not authorize.
  3. 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.
  4. 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:

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 --force only 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:

  1. 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 reports 0 commits scanned / no leaks found and exits 0. Assert on the commit count, not just the leak count.
  2. Fail the build only on what you mean. Gate on --severity ERROR, let the rest report.
  3. Keep it fast. Diff-aware scans on pull requests, full scans nightly. A five-minute PR check gets disabled.
  4. 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

References


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’ detectgit/dir command 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 # nosec stops working.