Three Gates

chapter 03

Gates in Action

Every block below is real, captured directly from the pipeline running in the public repository. None of the screenshots here are illustrative.

Gate 1 β€” Gitleaks

A test PR (#1) added a file with a fake AWS credential, in the format recognized by the aws-access-token rule. The first attempt used the most famous example on the internet β€” AKIAIOSFODNN7EXAMPLE β€” and sailed right through: that string is on Gitleaks' own internal allowlist, because it shows up in thousands of official AWS tutorials. Swapping in a different fake key, the block actually happened:

GitHub Actions check showing Gitleaks blocking the PR, with the aws-access-token rule detected in the secret-test.txt file
gitleaks-action failing PR #1's check β€” aws-access-token rule, secret-test.txt file
same PR, two gates, two different outcomes

In PR #2 (gate 2, below), a hardcoded password "supersecret123" sailed cleanly through Gitleaks β€” low entropy, no known provider pattern β€” but got caught by SonarCloud. It's practical proof that the two gates cover different things, not the same thing twice.

Gate 2 β€” SonarCloud

PR #2 introduced a login.js file with classic problems, on purpose:

const DB_PASSWORD = "supersecret123";

function buildQuery(userId) {
  return "SELECT * FROM users WHERE id = " + userId;
}

function runCommand(input) {
  return eval(input);
}

Hardcoded credential, SQL built by string concatenation, eval() on unvalidated input, and zero test coverage on any of it. The Quality Gate failed:

SonarCloud comment on the PR showing Quality Gate failed, with the condition Security Rating on New Code = D, required A or better
Quality Gate failed β€” Security Rating on New Code = D (required β‰₯ A)

Gate 3 β€” OWASP ZAP

The pre-deploy-dast.yml workflow spins up Juice Shop in a container on the Actions runner itself, waits for it to respond, and runs ZAP's baseline scan against http://localhost:3000:

- name: Start Juice Shop (staging surrogate)
  run: docker run -d --name juice-shop -p 3000:3000 bkimminich/juice-shop

- name: ZAP Baseline Scan
  uses: zaproxy/action-baseline@v0.15.0
  with:
    target: 'http://localhost:3000'
    rules_file_name: 'zap/zap-baseline-rules.conf'
    fail_action: true

Real result, against the real target: 59 rules passed, 8 had a real finding.

RuleFinding
10038Content Security Policy (CSP) Header Not Set
10049Non-Storable Content
10063Deprecated Feature Policy Header Set
10096Timestamp Disclosure - Unix
10098Cross-Domain Misconfiguration
10109Modern Web Application
10110Dangerous JS Functions
90004Cross-Origin-Embedder-Policy Header Missing or Invalid
GitHub Actions ZAP Baseline Scan job failing after detecting the findings above on Juice Shop
ZAP Baseline Scan failing (exit code 2) against Juice Shop running on the runner

Validating from the terminal, no browser needed

Every result above can also be confirmed straight from the command line, without relying on GitHub's UI:

RUN_ID=$(gh run list --workflow=pre-deploy-dast.yml --limit 1 --json databaseId --jq '.[0].databaseId')
gh run watch $RUN_ID   # required: a still-running run has no log available yet
JOB_ID=$(gh run view $RUN_ID --json jobs --jq '.jobs[0].databaseId')
gh run view $RUN_ID --log --job=$JOB_ID | grep -iE "WARN-NEW|FAIL-NEW|PASS:|Total of"

Each piece solves a specific problem: gh run list --json databaseId --jq grabs the run's numeric ID without opening a browser; gh run watch is necessary because asking for the log of a still-running job just returns "logs will be available when it is complete"; gh run view --json jobs resolves the specific job ID inside the run (a run can have more than one job); and the final grep -iE isolates just ZAP's own summary lines from the full, verbose log.