Part 9: An Exception Process for Security Findings You Can't Fix Yet

· 21 min read

Indonesia
//TUTORIAL · 9 OF 10 — A Secure CI/CD Pipeline with Go, GitHub Actions, Vault, and Dokploy

By Part 8 the pipeline is strict. A secret, a CVE, a Dockerfile misconfig or dangerous code turns the PR red and blocks the merge. The deploy stops too if the image has a CRITICAL CVE that already has a fix.

The real world isn't that tidy. Sometimes a CVE in the base image has no patch yet, and the feature still has to ship. Sometimes a scanner gets it wrong: a fixed test string in the code looks like an API key. Without an official path for these cases, people find a shortcut: turn the scanner off, lower the severity, or quietly add an ignore. Once that happens, the gate is just for show.

In this part I build an exception process: an official way to accept one finding, for a limited time, with the name of the person responsible and the date the permission runs out. It's also the classic auditor question, especially in a bank: "This finding you didn't fix, who approved it, and until when?" After this part, the answer lives in Git history.

The concept first: a healthy exception

The analogy I use is a sick note. There's a letter, a reason, someone who signs it, and it's valid until a certain date. After that date you're absent without leave again. It's not a lifetime pass.

From that I get four rules:

Rule

Why

Enforced by

Through a PR, stored in the repo

Git history becomes the audit trail: who, when, why

The rulesets from Part 6: every change goes through a PR

Owner + reason

Someone is responsible for keeping an eye on it

scripts/check-exceptions.sh

Expires within 90 days

The risk is accepted temporarily. After that, fix it or renew it with a fresh reason

The script + Trivy's own exp: + a weekly workflow

Narrow: 1 CVE, 1 fingerprint, 1 rule on 1 line

A broad exception (a whole rule, a whole folder) can hide new findings nobody ever assessed

The script bans broad suppressions

The end result looks like this:

text
 Scanner finding in a PR   │   ├─ can it be fixed? ──► fix it. Done.   │   └─ not yet (no patch / false positive)        │        ▼   Exception PR: 1 entry = 1 finding, plus a line above it:   exception: owner=@user exp=YYYY-MM-DD reason="..."        │        ▼   "exceptions" check (scripts/check-exceptions.sh), required by the ruleset     ✗ no owner/reason/date            ✗ already expired     ✗ more than 90 days               ✗ nosemgrep without a rule id     ✗ gitleaks:allow, .semgrepignore, .gitleaks.toml, trivy.yaml        │        ▼   merge → recorded in Git history        │        ▼   every Monday: "exception-expiry" workflow → expired? red   Trivy also reports the finding again by itself after exp:

Why it needs its own script

I first assumed the scanners' built-in features would be enough. After trying each one locally, they weren't:

Scanner

How to suppress

Has an expiry?

Trivy

.trivyignore, entry CVE-... exp:YYYY-MM-DD

Yes. After exp: the finding comes back

Semgrep

// nosemgrep: <rule-id>

No

Gitleaks

A fingerprint in .gitleaksignore

No

Something that surprised me: Semgrep also accepts a nosemgrep without a rule id, and that silences every rule on that line. So "owner, reason, date" for Semgrep and Gitleaks can only be enforced by an extra check. While I was at it, the script gives every scanner the same exception format.

Note

govulncheck and dependency-review have no exception on purpose

govulncheck only reports when your code actually calls the vulnerable function. That's real risk: upgrade, or stop calling it. dependency-review only checks dependencies the PR adds, so the fix is to pick another version. Not every gate needs a way around it.

Write the policy down first

An unwritten rule is just a habit. I put the policy in docs/exception-process.md, in English because the repo is public. Trimmed here; I left out the examples and limits sections:

docs/exception-process.md markdown
# Security finding exceptions The PR gates block a merge on any finding. Sometimes a finding has to be accepted for a while: a CVEwith no fixed version yet, or a scanner false positive. This is the only allowed way to do that. ## Rules 1. **Fix first.** An exception is for a finding that cannot be fixed now, not one that is inconvenient.2. **Through a PR.** Exceptions live in the repo, so Git history records who added one, when and why.   The PR description links an issue with the details.3. **One finding per entry.** One CVE ID, one Gitleaks fingerprint, or one `nosemgrep` with a rule id.   Never a whole rule, a whole severity, or a whole path.4. **Owner, reason and expiry.** Every entry has this line directly above it:    ```   exception: owner=@github-user exp=YYYY-MM-DD reason="at least 10 characters, link to the issue"   ```    `exp` is at most **90 days** away. To keep an exception longer, renew it in a new PR with a fresh reason.5. **Real secrets are never excepted.** A Gitleaks finding that is a real credential is revoked and   rotated. Gitleaks exceptions are for false positives only. ## Per scanner | Scanner | Where | Entry || --- | --- | --- || Trivy (image, config) | `.trivyignore` | `CVE-2026-12345 exp:2026-12-31` or `DS-0002 exp:2026-12-31` || Gitleaks | `.gitleaksignore` | Fingerprint `<commit>:<file>:<rule-id>:<line>` || Semgrep | the source line | `// nosemgrep: <rule-id>`, with the exception line directly above || govulncheck | none | No suppression || dependency-review | none | No suppression | Not allowed, because they suppress findings without an owner or expiry: `.semgrepignore`, `.gitleaks.toml`,`.trivyignore.yaml`, `trivy.yaml`, a bare `nosemgrep`, and `gitleaks:allow`.

Rule 5 matters most. If Gitleaks finds a real secret, the answer is never an exception. Revoke it, rotate it, done. Gitleaks exceptions are for false positives only.

The official exception files

I create both files now, even though they only hold a comment header. The reason is practical: the workflow will point at .trivyignore explicitly, and if the file is missing, trivy-action stops with cannot find ignorefile.

.trivyignore text
# Trivy exceptions (image CVEs and config checks). Rules: docs/exception-process.md# Each entry needs this line directly above it, and the same date as Trivy's exp:#   # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is accepted, link to the issue"#   CVE-YYYY-NNNNN exp:YYYY-MM-DD
.gitleaksignore text
# Gitleaks exceptions, for false positives only. A real leaked secret is rotated, never ignored.# Rules: docs/exception-process.md. Each fingerprint needs this line directly above it:#   # exception: owner=@github-user exp=YYYY-MM-DD reason="why it is not a secret, link to the issue"#   <commit>:<file>:<rule-id>:<line>

Trivy and Gitleaks both treat lines starting with # as comments. I tested it, so the header doesn't except anything.

The enforcer: scripts/check-exceptions.sh

This is the core of the part. The script reads every place a suppression can hide and rejects anything that breaks the rules. I'm showing it in full because every piece has a reason:

scripts/check-exceptions.sh bash
#!/usr/bin/env bash# Enforces docs/exception-process.md: every scanner suppression carries an owner, a reason and an expiry# no more than MAX_DAYS away. Run from the repo root. Exits 1 on any violation.set -euo pipefail MAX_DAYS="${MAX_DAYS:-90}"WARN_DAYS="${WARN_DAYS:-14}"TODAY="${TODAY:-$(date -u +%F)}"META_RE='exception: owner=@[A-Za-z0-9-]+ exp=([0-9]{4}-[0-9]{2}-[0-9]{2}) reason="[^"]{10,}"'NOSEM_RE='nosemgrep: ?[A-Za-z0-9_.-]+'errors=0 fail() { echo "::error file=$1,line=$2::$3"; errors=$((errors + 1)); } # GNU date on the runner, BSD date on macOS. valid_date prints nothing for an impossible date.valid_date() { date -u -d "$1" +%F 2>/dev/null || date -u -j -f %F "$1" +%F 2>/dev/null || true; }add_days() { date -u -d "$1 + $2 days" +%F 2>/dev/null || date -u -j -v+"$2"d -f %F "$1" +%F; } LIMIT="$(add_days "$TODAY" "$MAX_DAYS")"WARN_FROM="$(add_days "$TODAY" "$WARN_DAYS")" # check_meta FILE LINE TEXT: TEXT is the line directly above the suppression. Sets $exp when it is valid.check_meta() {  exp=""  if [[ ! "$3" =~ $META_RE ]]; then    fail "$1" "$2" "suppression needs 'exception: owner=@user exp=YYYY-MM-DD reason=\"...\"' on the line directly above"    return 1  fi  exp="${BASH_REMATCH[1]}"  if [ "$(valid_date "$exp")" != "$exp" ]; then    fail "$1" "$2" "exp=$exp is not a real date"    return 1  elif [[ "$exp" < "$TODAY" ]]; then    fail "$1" "$2" "exception expired on $exp: fix the finding or renew it in a new PR"    return 1  elif [[ "$exp" > "$LIMIT" ]]; then    fail "$1" "$2" "exp=$exp is more than $MAX_DAYS days away (latest allowed $LIMIT)"    return 1  elif [[ "$exp" < "$WARN_FROM" ]]; then    echo "::warning file=$1,line=$2::exception expires on $exp"  fi} # Plain ignore files: every entry needs the exception line directly above it.# shellcheck disable=SC2094 # fail only prints the file name, it never writes to the filecheck_ignore_file() {  local file="$1" n=0 prev="" line  [ -f "$file" ] || return 0  while IFS= read -r line || [ -n "$line" ]; do    n=$((n + 1))    case "$line" in      '' | '#'*) prev="$line"; continue ;;    esac    # Trivy only re-reports a finding after its own exp: date, so it must match the exception line.    if check_meta "$file" "$n" "$prev" && [ "$file" = .trivyignore ] && [[ "$line" != *" exp:$exp" ]]; then      fail "$file" "$n" "Trivy entry must end with ' exp:$exp'"    fi    prev="$line"  done < "$file"} # These files suppress findings silently, with no owner or expiry, so they are not allowed at all.for f in .semgrepignore .gitleaks.toml .trivyignore.yaml trivy.yaml; do  if [ -e "$f" ]; then    fail "$f" 1 "not allowed: use the exception process in docs/exception-process.md"  fidone check_ignore_file .trivyignorecheck_ignore_file .gitleaksignore # Inline suppressions. gitleaks:allow is banned because a .gitleaksignore fingerprint pins one finding, not a whole line.while IFS=: read -r file n text; do  if [[ "$text" == *gitleaks:allow* ]]; then    fail "$file" "$n" "gitleaks:allow is not allowed: add the finding's fingerprint to .gitleaksignore"    continue  fi  if [[ ! "$text" =~ $NOSEM_RE ]]; then    fail "$file" "$n" "nosemgrep must name the rule id (nosemgrep: <rule-id>)"    continue  fi  prev=""  [ "$n" -gt 1 ] && prev="$(sed -n "$((n - 1))p" "$file")"  check_meta "$file" "$n" "$prev" || truedone < <(git grep -n -I -E 'nosemgrep|gitleaks:allow' -- . ':!*.md' ':!scripts/check-exceptions.sh' || true) if [ "$errors" -gt 0 ]; then  echo "$errors exception problem(s). See docs/exception-process.md" >&2  exit 1fiecho "exceptions OK (today $TODAY, latest allowed exp $LIMIT)"

The parts that matter:

Part

What it does

META_RE

The regex for the exception line: owner=@ plus a username, exp= a date (captured), and reason="..." of at least 10 characters, so nobody can write "x"

fail()

Prints ::error file=X,line=N::message. GitHub Actions reads that format and shows it as a red annotation on the line in the PR's Files changed tab

valid_date / add_days

Try GNU date (the Linux runner) first, then BSD date (macOS). 2026-11-31 is rejected as a fake date

[[ "$exp" < "$TODAY" ]]

A string comparison. YYYY-MM-DD sorts the same as text and as dates, so no conversion is needed

14-day warning

Under 14 days left prints a yellow ::warning. It doesn't block, it just reminds

.trivyignore only

The entry must end with exp:<the same date>. That way Trivy itself also stops ignoring on that date, not just our script

Banned files

.semgrepignore, .gitleaks.toml, .trivyignore.yaml and trivy.yaml can silence findings broadly with no owner or date. trivy.yaml is banned because Trivy reads it automatically and it can change severities

git grep ... ':!*.md'

Finds nosemgrep and gitleaks:allow in tracked files. Docs and this script are excluded because they mention those words as text

Why ban gitleaks:allow when it's an official Gitleaks feature? Because that comment ignores one line, forever, no matter what the line later contains. A fingerprint in .gitleaksignore is much narrower: one finding in one commit.

Warning

`git grep` only sees tracked files

A new file you haven't git add-ed isn't checked. In CI everything is committed, so that's fine. When testing locally, git add first. This is exactly why I once had exceptions OK locally and a red check in CI.

Check: run it from the repo root.

bash
chmod +x scripts/check-exceptions.shgit add -Abash scripts/check-exceptions.sh
text
exceptions OK (today 2026-10-31, latest allowed exp 2027-01-29)

Then a quick negative test: a bare nosemgrep must be rejected.

bash
printf 'package demo\n\nconst a = "x" // nosemgrep\n' > internal/tmp_test_nosem.gogit add internal/tmp_test_nosem.gobash scripts/check-exceptions.sh; echo "exit=$?"git rm -q --cached internal/tmp_test_nosem.go && rm internal/tmp_test_nosem.go
text
::error file=internal/tmp_test_nosem.go,line=3::nosemgrep must name the rule id (nosemgrep: <rule-id>)1 exception problem(s). See docs/exception-process.mdexit=1

Beyond that I ran 24 other test cases: no exception line, a blank line in between, a mismatched Trivy date, exp equal to today, exactly 90 days (passes), 91 days (rejected), a reason that's too short, gitleaks:allow, .semgrepignore, and so on. All passed on macOS (bash 3.2) and Ubuntu (bash 5.2, same as the GitHub runner). The script also passes shellcheck.

Wiring it into the workflows

The exceptions job on PRs

In pr-checks.yml I add one job between semgrep and govulncheck:

.github/workflows/pr-checks.yml yaml
  exceptions:    runs-on: ubuntu-24.04    timeout-minutes: 5    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Check scanner exceptions        run: bash scripts/check-exceptions.sh

It needs no extra permissions, the workflow-level contents: read is enough.

Trivy reads .trivyignore explicitly

Trivy picks up .trivyignore by itself. I still add trivyignores: .trivyignore to all five trivy-action steps: four in pr-checks.yml (config SARIF, config gate, image SARIF, image gate) and the gate in deploy.yml.

yaml
      - name: Trivy image (gate)        uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0        with:          version: v0.76.1          trivyignores: .trivyignore          scan-type: image          # ...the rest is the same as in Part 5

Three reasons:

  • With trivyignores set, the action prints the ignore file into the log (Found ignorefile '.trivyignore':). Every run shows which exceptions were active. Good for audits.

  • If the file is missing, the action fails instead of quietly scanning without exceptions.

  • The gate in deploy.yml reads the same file. An exception approved in a PR also applies when the deploy builds the image. One source of truth.

The weekly exception-expiry run

PR checks only run when there's a PR. If nobody opens one for two months, an expired exception goes unnoticed. So I add a scheduled workflow:

.github/workflows/exception-expiry.yml yaml
name: exception-expiry # PR checks only run when someone opens a PR. This weekly run catches exceptions that expire in between,# and GitHub emails the failure.on:  schedule:    - cron: "0 1 * * 1" # Monday 01:00 UTC = 08:00 WIB  workflow_dispatch: permissions:  contents: read jobs:  exception-expiry:    runs-on: ubuntu-24.04    timeout-minutes: 5    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1        with:          persist-credentials: false      - name: Check scanner exceptions        run: bash scripts/check-exceptions.sh

Part

Meaning

cron: "0 1 * * 1"

Minute 0, 01:00 UTC, every Monday. That's 08:00 in Jakarta (WIB)

workflow_dispatch

Can be run by hand: gh workflow run exception-expiry.yml

Branch checked

Scheduled workflows always run on the default branch, which is development

Notification

On failure, GitHub emails whoever last changed this cron

Job name exception-expiry

Deliberately different from exceptions, so it never gets confused with the required check

Note

GitHub schedules can run minutes to an hour late when it's busy, and they get disabled after 60 days without repo activity. For a weekly check that's acceptable, since Trivy's exp: and the exceptions check still run on every PR and deploy.

Make exceptions a required check

In docs/rulesets/protect-development.json and protect-main.json I add exceptions to the required checks, then apply them with gh api -X PUT as in Part 6. Check:

bash
for f in docs/rulesets/protect-development.json docs/rulesets/protect-main.json; do  jq -c '[.rules[] | select(.type=="required_status_checks").parameters.required_status_checks[].context]' "$f"done
text
["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions"]["test","gitleaks","semgrep","govulncheck","dependency-review","trivy-config","trivy-image","exceptions","source-branch"]

Warning

Apply the ruleset after the merge, not before

If exceptions becomes required before the job exists on development, any PR branched from the old development never reports that check. It waits forever with "Expected — Waiting for status to be reported". So the order is: merge the PR that adds the job, then PUT the rulesets.

I opened the PR with all of this (9 files) against development. It showed the 7 existing gates plus exceptions, all green, and I merged it. Only then did I apply the rulesets.

Demo A: an accepted risk that then expires

Now the proof. Scenario: the container has to run as root, say a legacy app that needs port 80. Two scanners catch this: Trivy config (DS-0002) and Semgrep (dockerfile.security.last-user-is-root.last-user-is-root). One finding means an exception in both scanners. It's tedious on purpose, so exceptions stay rare.

This PR is a demo and gets closed without merging.

bash
git switch development && git pull --ff-onlygit switch -c demo/exception-accepted-riskEXP=$(date -v+30d +%F); echo "$EXP"    # macOS. On Linux: date -d '+30 days' +%F

In the Dockerfile I replace the USER 65532:65532 line with these three lines (put the date from above in place of <EXP>):

Dockerfile dockerfile
# exception: owner=@dimss113 exp=<EXP> reason="demo: root needed to bind port 80, tracked in issue"# nosemgrep: dockerfile.security.last-user-is-root.last-user-is-rootUSER root

Order matters: the exception: line goes directly above nosemgrep, and nosemgrep directly above USER root.

Warning

`suppression needs 'exception: ...'` in the Dockerfile

This one got me. Almost certainly <EXP> is still in there. The script only accepts a YYYY-MM-DD date, so exp=<EXP> isn't seen as an exception line. Run sed -i '' "s/exp=<EXP>/exp=$EXP/" Dockerfile. If it's a fresh terminal and $EXP is empty, set the variable again first.

Then the Trivy entry, with the same date:

bash
printf '# exception: owner=@dimss113 exp=%s reason="demo: root needed to bind port 80, tracked in issue"\nDS-0002 exp:%s\n' "$EXP" "$EXP" >> .trivyignoregit add -A && bash scripts/check-exceptions.sh     # expect: exceptions OKgit commit -m "demo: accept root user with an expiring exception"git push -u origin demo/exception-accepted-riskgh pr create --base development --title "demo: accepted risk with expiry" \  --body "Demo only, not to be merged."gh pr checks --watch

Check: all eight required checks are green, including trivy-config, semgrep and exceptions. The PR can be merged.

One thing confused me at first: Code scanning results / Semgrep OSS stays red. That isn't our semgrep job. It's a separate check GitHub creates from the uploaded SARIF. Semgrep still writes the nosemgrep-ed finding into the SARIF, marked suppressions: inSource, but GitHub code scanning ignores that mark and opens a new alert. That check isn't in the ruleset, so it doesn't block:

bash
gh pr view --json mergeStateStatus,mergeable
text
{"mergeStateStatus":"UNSTABLE","mergeable":"MERGEABLE"}

UNSTABLE means an optional check is red but the merge is still allowed. That's actually good for audits: the exception stays visible in the Security tab. For a real exception, close the alert with Dismiss alert → Won't fix, using the same reason and date, so the decision is also recorded in GitHub along with who made it.

Expired means red again

Now I move the date back to yesterday, in both places at once:

bash
OLD=$(date -v-1d +%F)sed -i '' "s/$EXP/$OLD/g" Dockerfile .trivyignoregit commit -am "demo: let the exception expire"git pushgh pr checks --watch

Check:

Check

Status

Why

exceptions

red

exception expired on <yesterday> in .trivyignore and Dockerfile, shown as annotations in Files changed

trivy-config

red

Trivy itself stops ignoring DS-0002 after exp:

semgrep

green

Semgrep has no idea about dates. Without the exceptions check, this nosemgrep would last forever

That green semgrep row is the clearest proof of why the exceptions check is needed. Then I close the PR:

bash
gh pr close --delete-branch --comment "Demo only: exception accepted, then expired and blocked."

Demo B: a Gitleaks false positive by fingerprint

Second scenario: a fixed test value in the code that looks like an API key. Gitleaks and Semgrep (p/secrets) flag it. It's a false positive, so an exception is allowed.

bash
git switch development && git pull --ff-onlygit switch -c demo/exception-false-positivemkdir -p internal/demoV=$(openssl rand -hex 20)printf 'package demo\n\n// Demo only: a fixed test value that looks like an API key.\nconst apiKey = "%s"\n\nfunc Key() string { return apiKey }\n' "$V" > internal/demo/vector.gounset Vgit add -A && git commit -m "demo: add a fixed test value that looks like an api key"

openssl rand -hex 20 gives 40 random hex characters, clearly nobody's real credential.

Get the fingerprint

bash
gitleaks git . --no-banner --redact -v --log-opts="origin/development..HEAD"

--log-opts="origin/development..HEAD" limits the scan to this branch's commits, the same way CI only scans the PR's commits. -v prints the details, including the fingerprint:

text
RuleID:      generic-api-key...Fingerprint: <commit-sha>:internal/demo/vector.go:generic-api-key:4

The format is <commit>:<file>:<rule>:<line>. So this exception covers one finding in one commit. Paste the same value into another commit and Gitleaks still catches it. That's exactly what I want.

Store it in a variable instead of copying by hand:

bash
FP=$(gitleaks git . --no-banner --redact --log-opts="origin/development..HEAD" \  --report-format json --report-path /tmp/gl.json >/dev/null 2>&1; jq -r '.[0].Fingerprint' /tmp/gl.json)rm /tmp/gl.json

Add the Gitleaks and Semgrep exceptions

bash
EXP=$(date -v+30d +%F)printf '# exception: owner=@dimss113 exp=%s reason="demo: fixed test value, not a real credential"\n%s\n' "$EXP" "$FP" >> .gitleaksignore

For Semgrep, add two lines above const apiKey:

internal/demo/vector.go go
// exception: owner=@dimss113 exp=<EXP> reason="demo: fixed test value, not a real credential"// nosemgrep: generic.secrets.security.detected-generic-api-key.detected-generic-api-keyconst apiKey = "..."

Warning

Don't put `nosemgrep` at the end of the `const` line

If you edit the const line, the new commit re-adds the line holding the value. Gitleaks sees it as a new finding with a new commit SHA, and the fingerprint no longer matches. Put it on its own line above. Semgrep still honours a nosemgrep on the line above; I tested it. The same goes for amend or rebase: the SHA changes, so grab the fingerprint again.

bash
gofmt -l internal/demo      # must print nothinggit add -A && bash scripts/check-exceptions.shgit commit -m "demo: except the false positive by fingerprint"git push -u origin demo/exception-false-positivegh pr create --base development --title "demo: false positive exception" --body "Demo only, not to be merged."gh pr checks --watch

Check: every required check is green, including gitleaks, semgrep and exceptions. Code scanning / Semgrep OSS is red for the same reason as in Demo A. Code scanning / gitleaks stays green, because findings that match a fingerprint aren't written to the SARIF.

Tip

Want a "before and after" screenshot? Push only the first commit, watch gitleaks and semgrep go red, then add the exceptions.

Close the demo with gh pr close --delete-branch.

When things go wrong

Symptom

Cause

Fix

Trivy fails: cannot find ignorefile '.trivyignore'

.trivyignore wasn't committed

git add .trivyignore

suppression needs 'exception: ...' on the line directly above

The exception line isn't directly above, the reason is under 10 characters, or owner=@ is missing

Put it directly above, in the exact format

Trivy entry must end with ' exp:...'

The Trivy entry's date differs from the exception line

Make the dates match

more than 90 days away

exp is too far out

90 days max, renew later in a new PR

gitleaks still red after adding the fingerprint

The commit was amended/rebased, or the line with the value changed

Get the fingerprint again from the latest commit

semgrep still red after nosemgrep

Typo in the rule id

Copy the exact rule id from the Semgrep log

Limits I accept

The process isn't perfect, and it's better to say so plainly:

  • Anyone can be written as the owner. The script checks the format, not whether that person agreed. In a team, add CODEOWNERS for .trivyignore, .gitleaksignore, the script and the workflows, plus "require code owner review" in the ruleset.

  • A PR author can change the script or workflow in the same PR, because pull_request uses the workflow from the PR branch. The mitigation is careful review of .github/ and scripts/, or required workflows at the organization level.

  • A Gitleaks fingerprint keeps hiding one old finding after it expires, since Gitleaks has no dates. The exceptions check stays red until the entry is removed, and a fingerprint only ever matches one commit.

  • No central dashboard yet. Exceptions live in three places, but git grep -n "exception:" lists them all.

Recap

  • docs/exception-process.md: the written policy. Fix first, one finding per entry, owner + reason + an expiry of at most 90 days, real secrets never excepted.

  • .trivyignore and .gitleaksignore as the only official exception files.

  • scripts/check-exceptions.sh, which rejects exceptions with missing metadata, expired or too-long ones, a bare nosemgrep, gitleaks:allow, and broad suppression files.

  • The exceptions job as a required check on development and main, plus the weekly exception-expiry workflow.

  • Proof: a valid exception turns the PR green, and once it expires the gate goes red again. Including proof that Semgrep on its own would never go red again without this check.

In Part 10, the last one, we deal with the only credential still stored in GitHub: DOKPLOY_API_KEY. I delete that secret, and CI gets its Dokploy token straight from Vault through GitHub OIDC.

Related articles