End to End: Security Gates, Exception Handling and Metrics in the Bookstore Pipeline
A quiet security pipeline runs only the checks a developer can fix within minutes on the PR and the deep checks on main; fixable critical and high findings fail, and the rest are tracked through exceptions with an owner and an expiry date. With real output we show Bookstore's single workflow, a gate that catches an expired exception, vulnerability age and MTTR, OpenSSF Scorecard and the mapping of the controls to the OWASP Top 10:2025.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.402); the workflows were validated with actionlint 1.7.12, image scans were done with Trivy 0.76.2, and the OpenSSF Scorecard data was taken from the Scorecard API on 13 November 2026.
Guess First
Bookstore's pipeline now has every control we built over eight posts: secret scanning, SCA, SAST, image and IaC scanning, DAST, SBOM and signing. One morning the tech lead asks two questions: "How many days did it take to get a high vulnerability out of production last month?" and "How many 'we'll look at it later' exceptions do we have right now, and which ones have expired?" All the controls are running; guess where in the pipeline the answers to these two questions live.
In the last post of the series we bring the controls together in a single workflow: fast checks on PRs, deep checks on main, the "fixable critical and high fail" gate policy, exceptions with an owner and an expiry date, measuring vulnerability age and MTTR, repo hygiene with OpenSSF (Open Source Security Foundation) Scorecard, and a table showing which risk in the OWASP Top 10:2025 each control covers.
One Workflow, Two Speeds
In the first post we drew a skeleton. Here is the real thing: I added a workflow_call trigger to the SAST, DAST and release workflows from the previous posts and call them from a single orchestration workflow.
name: security
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: security-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
# PR and main: fast checks in parallel; budget 10 minutes
secrets:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
- name: Install gitleaks
env:
GITLEAKS_VERSION: 8.30.1
run: |
base="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}"
file="gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
curl -sSfL -o "$file" "$base/$file"
curl -sSfL -o checksums.txt "$base/gitleaks_${GITLEAKS_VERSION}_checksums.txt"
grep " $file\$" checksums.txt | sha256sum -c -
tar -xzf "$file" gitleaks
- name: Scan new commits
env:
BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }}
run: ./gitleaks git --redact --no-banner --log-opts="$BASE_SHA..$GITHUB_SHA" .
dependencies:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
dotnet-version: 10.0.x
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
node-version: 24
- run: dotnet restore Bookstore.slnx -warnaserror:NU1903,NU1904
- run: npm ci --prefix web --ignore-scripts
- run: npm audit --prefix web --audit-level=high --omit=dev
sast:
permissions:
contents: read
security-events: write
uses: ./.github/workflows/sast.yml
test-and-exceptions:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
dotnet-version: 10.0.x
# Regression tests derived from DAST findings run here too
- run: dotnet test --solution Bookstore.slnx
- name: Validate the exception register
run: dotnet run tools/security-gate.cs
# main only: deep checks, after all fast ones have passed
image:
if: github.event_name == 'push'
needs: [secrets, dependencies, sast, test-and-exceptions]
runs-on: ubuntu-latest
timeout-minutes: 15
env:
TRIVY_VERSION: 0.76.2
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@0f4a354dd5547a28c70b7853dbd95d5b67886e73 # v6.1.0
with:
dotnet-version: 10.0.x
- uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- name: Install and verify Trivy
run: |
base="https://github.com/aquasecurity/trivy/releases/download/v${TRIVY_VERSION}"
file="trivy_${TRIVY_VERSION}_Linux-64bit.tar.gz"
curl -sSfL -o "$file" "$base/$file"
curl -sSfL -o "$file.sigstore.json" "$base/$file.sigstore.json"
cosign verify-blob "$file" --bundle "$file.sigstore.json" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v${TRIVY_VERSION}"
tar -xzf "$file" trivy
- run: docker build -t "bookstore-api:$GITHUB_SHA" .
- run: ./trivy image --scanners vuln -f json -o trivy.json "bookstore-api:$GITHUB_SHA"
# fixable critical/high fails unless an exception is active; the rest is reported
- run: dotnet run tools/security-gate.cs -- trivy.json
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always()
with:
name: trivy-${{ github.sha }}
path: trivy.json
dast:
if: github.event_name == 'push'
needs: [secrets, dependencies, sast, test-and-exceptions]
uses: ./.github/workflows/dast.yml
secrets:
DAST_SIGNING_KEY: ${{ secrets.DAST_SIGNING_KEY }}
release:
needs: [image, dast]
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
uses: ./.github/workflows/release-image.yml
secrets: inherit
I validated all four workflow files (orchestration, SAST, DAST and release) together with actionlint 1.7.12; actionlint also checks that secrets and permissions match across reusable workflow calls. I did not run it on GitHub; we ran the step commands locally one by one in the earlier posts.
The logic of the structure is this. On a PR only the checks that the author can fix within minutes run, and the four run in parallel: secrets, dependencies, SAST and tests. The total time is the slowest job; Bookstore's budget is 10 minutes and each job's timeout-minutes enforces it. On main, image scanning and DAST start in parallel after all fast checks have passed; the release (signature, attestations, SBOM upload) runs only if both pass. In the sixth post the active ZAP scan alone took two minutes; that is why DAST is not on the PR. The concurrency setting cancels the old run when a new commit arrives on the same PR, but not on main; interrupting a main run halfway can leave an unsigned release. Building in a reusable workflow also prepares the path to SLSA (Supply-chain Levels for Software Artifacts) Build L3 from the eighth post.
Gate Policy: What Fails and What Gets Reported
The rule of a quiet gate has to be simple enough that everyone knows it by heart. Bookstore's is: a fixable critical or high finding fails the build; findings without a fix, or medium and below, are reported and tracked; for secrets and SAST only new findings added by the PR fail. The "fixable" part matters: a failed build must offer the developer an action. The fix for the libc6 findings from the fifth post is in the next version of the base image; since the developer cannot do anything about them today, they do not fail the build, but they stay on the list.
When each tool keeps its own ignore mechanism (NuGetAuditSuppress, .trivyignore.yaml, alertFilter), the answer to "how many exceptions do we have?" is scattered across seven files. At Bookstore I collected the exceptions for the image gate in a single register.
Exceptions: Debt with an Owner and an Expiry Date
{
"exceptions": [
{
"id": "CVE-2024-0056",
"package": "Microsoft.Data.SqlClient",
"owner": "team-orders",
"reason": "Upgrade to SqlClient 7 ships in 1.5.0; the major upgrade needs its own PR and test run.",
"expires": "2026-11-30"
},
{
"id": "CVE-2023-36414",
"package": "Azure.Identity",
"owner": "team-orders",
"reason": "Removed together with SqlClient 5.1.1.",
"expires": "2026-11-01"
}
]
}
I wrote the gate as a .NET 10 file-based app; it runs with dotnet run tools/security-gate.cs without a project file in the repo:
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
// Usage: dotnet run tools/security-gate.cs -- [trivy-report.json] [--today yyyy-MM-dd]
// Without a report only the exception register is validated (PR job)
string? reportPath = args.Length > 0 && !args[0].StartsWith("--") ? args[0] : null;
DateOnly today = args.Contains("--today")
? DateOnly.ParseExact(args[Array.IndexOf(args, "--today") + 1], "yyyy-MM-dd", CultureInfo.InvariantCulture)
: DateOnly.FromDateTime(DateTime.UtcNow);
// File-based apps are AOT-compatible by default: a source generator instead of reflection
List<SecurityException> exceptions = JsonSerializer.Deserialize(
File.ReadAllText("security/exceptions.json"), GateJsonContext.Default.ExceptionFile)!.Exceptions;
int invalid = 0;
foreach (SecurityException e in exceptions)
{
// Exceptions without an owner or reason, or longer than 90 days, are rejected
if (string.IsNullOrWhiteSpace(e.Owner) || string.IsNullOrWhiteSpace(e.Reason) || e.Expires > today.AddDays(90))
{
Console.WriteLine($"INVALID {e.Id} {e.Package}: owner, reason and an expiry within 90 days are required");
invalid++;
}
else if (e.Expires < today)
{
Console.WriteLine($"EXPIRED {e.Id} {e.Package} expired {e.Expires:yyyy-MM-dd} (owner {e.Owner})");
}
}
int blocking = 0;
using JsonDocument report = JsonDocument.Parse(reportPath is null ? """{"Results":[]}""" : File.ReadAllText(reportPath));
foreach (JsonElement result in report.RootElement.GetProperty("Results").EnumerateArray())
{
if (!result.TryGetProperty("Vulnerabilities", out JsonElement vulnerabilities))
{
continue;
}
foreach (JsonElement v in vulnerabilities.EnumerateArray())
{
string id = v.GetProperty("VulnerabilityID").GetString()!;
string package = v.GetProperty("PkgName").GetString()!;
string severity = v.GetProperty("Severity").GetString()!;
string fix = v.TryGetProperty("FixedVersion", out JsonElement f) ? f.GetString() ?? "" : "";
string finding = $"{severity,-8} {package} {v.GetProperty("InstalledVersion").GetString()} {id}";
SecurityException? active = exceptions.FirstOrDefault(e =>
e.Id == id && e.Package == package && e.Expires >= today && !string.IsNullOrWhiteSpace(e.Owner));
if (active is not null)
{
Console.WriteLine($"EXCEPT {finding} until {active.Expires:yyyy-MM-dd} ({active.Owner})");
}
else if (severity is "CRITICAL" or "HIGH" && fix.Length > 0)
{
Console.WriteLine($"BLOCK {finding} (fix: {fix})");
blocking++;
}
else
{
Console.WriteLine($"REPORT {finding}");
}
}
}
Console.WriteLine($"Result: {blocking} blocking finding(s), {invalid} invalid exception(s)");
return blocking + invalid > 0 ? 1 : 0;
record SecurityException(string Id, string Package, string Owner, string Reason, DateOnly Expires);
record ExceptionFile(List<SecurityException> Exceptions);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(ExceptionFile))]
partial class GateJsonContext : JsonSerializerContext;
On my first try I used JsonSerializer.Deserialize<ExceptionFile>, and the tool crashed with the exception Reflection-based serialization has been disabled for this application. .NET 10's file-based apps come with PublishAot on by default and reflection-based serialization off; a source-generated JsonSerializerContext solved it. A second surprise came from NuGetAudit: with SDK 10.0.402 on my machine, restoring the tool raised NU1903 (high) for the AOT compiler package Microsoft.DotNet.ILCompiler 10.0.13; the fix in the advisory is 10.0.14, and SDK 10.0.404, published on 10 November, brings it. Our security tool is a dependency too; and because it is not in the solution, the restore in the dependencies job does not see it.
I first ran the gate against the real Trivy report of the Bookstore 1.4.0 image, dated 13 November:
dotnet run tools/security-gate.cs -- reports/image-1.4.0.json --today 2026-11-13
EXPIRED CVE-2023-36414 Azure.Identity expired 2026-11-01 (owner team-orders)
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-18374
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-84243
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-8674
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-86805
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-95818
REPORT MEDIUM libc6 2.39-0ubuntu8.9 CVE-2026-97399
BLOCK HIGH Azure.Identity 1.7.0 CVE-2023-36414 (fix: 1.10.2)
REPORT MEDIUM Azure.Identity 1.7.0 CVE-2024-29992
REPORT MEDIUM Azure.Identity 1.7.0 CVE-2024-35255
EXCEPT HIGH Microsoft.Data.SqlClient 5.1.1 CVE-2024-0056 until 2026-11-30 (team-orders)
Result: 1 blocking finding(s), 0 invalid exception(s)
Exit code 1. The SqlClient finding passed with a valid exception; the Azure.Identity exception had expired on 1 November, so the same finding now fails. An expired exception not quietly extending itself is the whole point of this design. On the 1.5.0 image, which moved to SqlClient 7, the six libc6 findings stayed as REPORT and the result was 0 blocking finding(s); the EXPIRED line kept showing up as leftover waiting to be removed from the register. When I tried an exception with an empty owner and an expiry at the end of 2027, the tool wrote an INVALID line, returned 1, and the exception did not suppress the finding.
New Code and Existing Debt
The principle from the first post, "block new findings, move old ones to the backlog", is applied through a different mechanism in each control. Secrets and SAST have a baseline: gitleaks fails only on the PR's commit range, and Semgrep only on findings new relative to the base branch. For dependencies and images, "new" is a weak concept: a package that was clean yesterday turns red because of an advisory published today, and that is not the fault of the developer's PR. So there, the decision to fail depends not on novelty but on whether action is possible: if there is a fix it fails, and if the team needs more time it opens an exception with an owner and a date. Existing debt thus stays visible and time-boxed; nobody has to lower a threshold to silence the gate.
An important distinction: VEX and an exception are not the same thing. The VEX from the seventh post is a permanent statement saying "this version is not affected, because..."; an exception is a temporary decision saying "we are affected and accept the risk until this date". The first makes the scanner not produce the finding at all; the second lets the finding pass the gate temporarily.
Measuring: Vulnerability Age, MTTR and Exception Count
The answer to the opening questions is in a metric. Three metrics are enough for Bookstore: the age of open vulnerabilities (how many days the oldest open high has been waiting), MTTR (the average time from the day a vulnerability is first seen to the day it is closed) and the number of active and expired exceptions. For this I wrote a second file-based app that compares each main run's Trivy report with a history file; it records the first-seen date for new findings and the fixed date for findings that disappear from the report. Its core is:
// An open finding that is no longer in the report has been fixed
foreach ((string key, Finding finding) in history.Where(h => h.Value.FixedOn is null && !seen.Contains(h.Key)).ToList())
{
history[key] = finding with { FixedOn = date };
}
I fed three real reports in order, passing their dates as parameters: the old image from the fifth post (5 October), 1.4.0 (26 October) and 1.5.0 (13 November):
dotnet run tools/vuln-metrics.cs -- reports/legacy.json history.json --date 2026-10-05
dotnet run tools/vuln-metrics.cs -- reports/image-1.4.0.json history.json --date 2026-10-26
dotnet run tools/vuln-metrics.cs -- reports/image-1.5.0.json history.json --date 2026-11-13
2026-10-05: 12 open, 0 fixed
HIGH 2 open, oldest 0 days; no fixes yet
MEDIUM 10 open, oldest 0 days; no fixes yet
2026-10-26: 10 open, 2 fixed
HIGH 2 open, oldest 21 days; no fixes yet
MEDIUM 8 open, oldest 21 days; MTTR 21.0 days over 2 fixed
2026-11-13: 6 open, 6 fixed
HIGH 0 open; MTTR 39.0 days over 2 fixed
MEDIUM 6 open, oldest 39 days; MTTR 30.0 days over 4 fixed
Closing the two high findings took 39 days; our gate rule only forced them once the exception expired. Six medium findings have been open for 39 days and have fixes: measurable proof that we have not turned rebuilding the base image into a habit. On the first run the MTTR output came out with a comma, like 21,0, because the machine's culture is Turkish. If the pipeline output goes to a dashboard or another tool, format numbers with CultureInfo.InvariantCulture; that is how I fixed it.
This small tool is there to show the idea; in production it is more robust to take the same metrics from the finding history of Dependency-Track from the seventh post, or from GitHub's security overview. Do not keep the history file in actions/cache; a cache is not storage and can be evicted. Reduce the metrics to two numbers discussed in the sprint review: "how many days is the oldest open high?" and "how many expired exceptions are there?".
Repo Hygiene: OpenSSF Scorecard
So far we have measured the product; Scorecard measures the repo itself: it scores a series of checks from 0 to 10, such as workflow token permissions, pinning dependencies by hash, branch protection, signed releases and SAST usage. Results for public repos are in an API; I looked at the Trivy repo we discussed in the fifth post and listed the checks below 8:
curl -s https://api.scorecard.dev/projects/github.com/aquasecurity/trivy \
| jq -r '.date, .score, (.checks[] | select(.score < 8) | "\(.score)\t\(.name)")'
2026-03-16
6.4
0 CII-Best-Practices
0 Token-Permissions
0 Fuzzing
3 Signed-Releases
4 Branch-Protection
7 Pinned-Dependencies
1 SAST
The latest assessment in the API is dated 16 March 2026; the reason given for Token-Permissions is detected GitHub workflow tokens with excessive permissions. Scorecard does not predict incidents, and drawing blame from a score would be wrong; but what this check measures, workflow tokens running with least privilege, is the subject of this series and of the GitHub Actions series. At Bookstore, running Scorecard in a weekly job with ossf/scorecard-action and uploading the SARIF output to code scanning makes regressions in repo settings visible like a PR comment; I did not run that job in this post.
Which Control Covers Which Risk
| Control (post) | Place in the pipeline | Risk it covers | OWASP Top 10:2025 |
|---|---|---|---|
| Secret scanning (2) | pre-commit, PR, push protection | Credentials embedded in code | A07 (CWE-798), A04 |
| SCA and prioritization (3) | PR, Dependabot | Dependencies with known vulnerabilities | A03 |
| SAST (4) | PR, with a baseline | Injection, path traversal, weak crypto calls | A05, A01, A04 |
| Image and IaC scanning (5) | main | Operating system packages, privileged containers, open storage | A02, A03 |
| DAST (6) | main, ephemeral environment | Headers, cookies, error leaks, reflected XSS (Cross-Site Scripting) | A02, A05, A10 |
| SBOM and VEX (7) | main, release | No answer to "are we affected?" | A03 |
| Signing, provenance, admission (8) | release, deploy | Tampered artifacts or artifacts built elsewhere | A08, A03 |
| Gates, exceptions, metrics (9) | everywhere | Controls being switched off quietly | process |
The gaps in the table say something too. There is no scanner in the pipeline for A06 (Insecure Design) and A09 (Security Logging and Alerting Failures); they are covered by threat modeling, code review and runtime monitoring. As we saw in the sixth post, DAST cannot find the IDOR part of A01; the authorization regression tests in the API Security series cover it. The categories are covered in detail in the OWASP series post "An Introduction to Web Security: What Is the OWASP Top 10:2025 and How to Read It".
To close, Bookstore's checklist:
- Every developer machine has the gitleaks pre-commit hook, and push protection is on at GitHub.
- NuGetAudit and npm audit fail the restore on high and critical; Dependabot keeps security and version updates separate.
- SAST fails only on new findings on PRs; Bookstore-specific rules are tested.
- The image is chiseled and non-root; a fixable critical or high stops the image on main; the base image is rebuilt regularly.
- DAST runs on main in an ephemeral environment, authenticated and with session checks; findings turn into regression tests.
- Every release's image SBOM is bound to the image as an attestation and queryable in Dependency-Track; VEX documents are versioned with the release.
- The image is keyless-signed, and the cluster rejects unsigned and tag-referenced images.
- Every exception has an owner, a justification and an expiry of at most 90 days; the oldest open high and the number of expired exceptions are discussed every sprint.
Production Notes
Do not switch a gate on strictly on day one. Run a new control in report mode for a sprint or two, move the existing debt into the exception register, then start failing. A pipeline that turns red one day for a reason nobody knows gets switched off "temporarily" with continue-on-error: true the next day.
Measure and protect the time budget. Track job durations weekly; when the total of fast checks on PRs starts exceeding the budget, move the slowest to main or to a nightly schedule, not by slowing down the PR.
Put the exception register through code review and write the owner as a team, not a person; when the person leaves the team, the exception becomes ownerless. Set up a reminder (a weekly job or an issue) for exceptions that are about to expire.
Treat security tools as dependencies too. In this post the gate tool itself produced a NuGetAudit warning because of an old SDK; we verify scanner binaries by their signatures, pin actions by SHA and keep the SDK current.
Common Pitfalls
First, putting every control on the PR. A slow PR pipeline pushes developers to look for ways around the checks; the place for deep checks is main.
Second, spreading exceptions across each tool's own file and giving none of them an expiry date. An ignore line without an owner and a date is a permanent blind spot.
Third, silencing the gate by lowering the threshold. A decision like "only critical fails" quietly carries high findings into production; the right tool is a justified, time-boxed exception.
Fourth, measuring only the number of findings. The number goes up when a new scanner is added and down when one is removed; age and MTTR measure the team's behavior.
Fifth, considering the program finished. Controls, tools and threats change; the tool versions in this series will be outdated within a few months. Review the gates and the exception register from scratch at least twice a year.
A Small Experiment
★☆☆ Place every security control in your project's pipeline into a PR column and a main column, and write down each one's average duration over the last month. Does the PR's total exceed your budget?
★★☆ Write a gate like the one in this post for your own scanner's JSON (JavaScript Object Notation) report: fixable critical and high fail, and exceptions come from a single file. See the gate really fail with an expired exception.
★★★ Calculate vulnerability age and MTTR from the last three months of scan reports (or Dependabot alerts), then look at your own repo's Scorecard result. Open one PR each for the two lowest checks.
The series ends here, but the topic does not. Where pipeline secrets are stored and how they are rotated is the subject of the upcoming Configuration and Secret Management series, setting up analyzers and quality gates is the subject of the Static Analysis series, and the cryptography behind signatures is the subject of the Cryptography for Developers series. Bookstore's pipeline now carries security not as a checklist but as a process that is measured and has owners.