{"id":4728,"date":"2026-08-04T10:13:58","date_gmt":"2026-08-04T10:13:58","guid":{"rendered":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/08\/04\/shift-left-security-4-automated-security-gates-in-github-actions\/"},"modified":"2026-08-04T10:13:58","modified_gmt":"2026-08-04T10:13:58","slug":"shift-left-security-4-automated-security-gates-in-github-actions","status":"publish","type":"post","link":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/2026\/08\/04\/shift-left-security-4-automated-security-gates-in-github-actions\/","title":{"rendered":"Shift Left Security: 4 Automated Security Gates in GitHub Actions"},"content":{"rendered":"<div><img data-opt-id=1820986271  fetchpriority=\"high\" decoding=\"async\" width=\"770\" height=\"330\" src=\"https:\/\/devops.com\/wp-content\/uploads\/2026\/08\/security-gates-770x330-1.jpg\" class=\"attachment-large size-large wp-post-image\" alt=\"\" \/><\/div>\n<p><img data-opt-id=815283930  fetchpriority=\"high\" decoding=\"async\" width=\"150\" height=\"150\" src=\"https:\/\/devops.com\/wp-content\/uploads\/2026\/08\/security-gates-770x330-1-150x150.jpg\" class=\"attachment-thumbnail size-thumbnail wp-post-image\" alt=\"\" \/><\/p>\n<p>A security researcher filed a report against our Node.js API two years ago. The vulnerability was prototype pollution in an npm package we had been shipping for eight months. The fix took about 20 minutes. The internal review to figure out how it got through took two days.<\/p>\n<p>The package had a known CVE. It had been sitting in the NVD for months before we found it. Our CI never checked. Code review didn\u2019t catch it. The researcher did.<\/p>\n<p>There\u2019s a stat from IBM\u2019s Cost of a Data Breach 2024 report that I keep referencing when this comes up: The average breach costs $4.88 million. Teams running DevSecOps practices save $1.68 million from that cost on average. The number I actually find more useful is smaller. A vulnerability caught in CI costs about $80 to fix. Caught in production, the same costs $7,600.<\/p>\n<p>That gap is why this article exists. Four automated security gates wired into GitHub Actions, running on every push and pull request \u2014 none of them require an enterprise license:<\/p>\n<ul>\n<li>Gate 1: Dependency scanning \u2014 finds CVEs in your npm packages before they ship.<\/li>\n<li>Gate 2: Container image scanning \u2014 finds CVEs in your Docker base image OS packages.<\/li>\n<li>Gate 3: Static analysis (SAST) \u2014 finds code-level vulnerabilities such as SQL injection and hardcoded secrets.<\/li>\n<li>Gate 4: Dynamic scanning (DAST) \u2014 finds runtime vulnerabilities in a running instance of your app.<\/li>\n<\/ul>\n<p>Wired together, the pipeline looks like this:<\/p>\n<p><code>Pull Request \/ Push to main<\/code><\/p>\n<p><code>           \u2502<\/code><\/p>\n<p><code>           \u25bc<\/code><\/p>\n<p><code>\u250c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510<\/code><\/p>\n<p><code>\u2502              GitHub Actions Pipeline                  \u2502<\/code><\/p>\n<p><code>\u2502                                                      \u2502<\/code><\/p>\n<p><code>\u2502  Gate 1: Dependency Scan         Gate 2: Image Scan  \u2502<\/code><\/p>\n<p><code>\u2502  (npm audit + Snyk)              (Trivy)             \u2502<\/code><\/p>\n<p><code>\u2502  Finds: vulnerable packages      Finds: OS + pkg CVEs\u2502<\/code><\/p>\n<p><code>\u2502  \u274c CRITICAL \u2192 fail build        \u274c CRITICAL \u2192 fail  \u2502<\/code><\/p>\n<p><code>\u2502                                                      \u2502<\/code><\/p>\n<p><code>\u2502  Gate 3: Static Analysis         Gate 4: DAST        \u2502<\/code><\/p>\n<p><code>\u2502  (CodeQL)                        (OWASP ZAP Baseline)\u2502<\/code><\/p>\n<p><code>\u2502  Finds: SQL injection, XSS,      Finds: runtime vulns\u2502<\/code><\/p>\n<p><code>\u2502  hardcoded secrets               in running app      \u2502<\/code><\/p>\n<p><code>\u2502  \u274c High severity \u2192 fail         \u274c Alerts \u2192 report  \u2502<\/code><\/p>\n<p><code>\u2502                                                      \u2502<\/code><\/p>\n<p><code>\u2502  \u2705 All gates pass \u2192 merge allowed \/ deploy          \u2502<\/code><\/p>\n<p><code>\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518<\/code><\/p>\n<p>Gates 1 through 3 run in parallel on every push. Gate 4 only starts after all three pass. It boots the app and probes it live.<\/p>\n<h3>Prerequisites<\/h3>\n<p>Docker needs to be installed locally before you start. Most of the local testing this article highlights relies on it. The Snyk step needs a free account at snyk.io, but npm audit alone covers the basics if you\u2019d rather not sign up. One other thing upfront: CodeQL runs free on public repositories; no paid GitHub plan is needed.<\/p>\n<p>The sample app used throughout this article is an Express API with a Dockerfile and docker-compose.yml. The full code is on GitHub at <a href=\"https:\/\/github.com\/ziaongit\/github-actions-security-gates\" target=\"_blank\" rel=\"noopener\">github-actions-security-gates<\/a>.<\/p>\n<h3>Gate 1: Dependency Scanning With Npm Audit and Snyk<\/h3>\n<p>Recently, I ran <code>npm<\/code><code> install<\/code> on a fresh Express app. It pulled in 847 packages. I explicitly chose maybe 12 of them. The rest came along for the ride. Each one was a potential CVE.<\/p>\n<p>Both tools scan the same dependency tree but do different things with what they find. <code>npm audit<\/code> catches what Node knows about and reports severity. Snyk goes further. The last time I ran it on a project, it surfaced issues npm audit missed, and gave me the exact upgrade version for each one, flagging which jumps would break the API. That detail cuts debug time significantly.<\/p>\n<p>Drop this in <code>.github\/workflows\/gate-1-dependency-scan.yml<\/code>:<\/p>\n<p><code>name: Gate 1 \u2014 Dependency Scan<\/code><\/p>\n<p><code>on:<\/code><\/p>\n<p><code>  push:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  pull_request:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>jobs:<\/code><\/p>\n<p><code>  dependency-scan:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - name: Checkout code<\/code><\/p>\n<p><code>        uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - name: Set up Node.js<\/code><\/p>\n<p><code>        uses: actions\/setup-node@v4<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          node-version: '20'<\/code><\/p>\n<p><code>          cache: 'npm'<\/code><\/p>\n<p><code>      - name: Install dependencies<\/code><\/p>\n<p><code>        run: npm ci<\/code><\/p>\n<p><code>      - name: npm audit<\/code><\/p>\n<p><code>        run: npm audit --audit-level=high<\/code><\/p>\n<p><code>      - name: Snyk dependency scan<\/code><\/p>\n<p><code>        uses: snyk\/actions\/node@v1.4.0<\/code><\/p>\n<p><code>        continue-on-error: false<\/code><\/p>\n<p><code>        env:<\/code><\/p>\n<p><code>          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          args: --severity-threshold=high<\/code><\/p>\n<p>The <code>--audit-level=high<\/code> flag in npm audit means the job only fails on high and critical vulnerabilities, not on every minor advisory. Without this flag, most real codebases will fail immediately on low-severity issues that have no fix and no meaningful risk.<\/p>\n<p>Snyk adds upgrade paths. When something gets flagged, the report shows which version resolves it and whether the jump is safe. I\u2019ve had engineers close a failing PR in five minutes with that information. Without it, they\u2019re opening changelogs and guessing.<\/p>\n<h3>Getting SNYK_TOKEN Into GitHub<\/h3>\n<p>Sign up at snyk.io, then go to your personal account settings at <code>https:\/\/app.snyk.io\/account<\/code>. The Auth Token is there \u2014 not on the Organization Settings page, which only has the org API key. Copy it, then add it to your repo at Settings \u2192 Secrets and variables \u2192 Actions \u2192 New repository secret. Name it <code>SNYK_TOKEN<\/code>. That\u2019s the exact name the workflow expects.<\/p>\n<h3>Handling False Positives With a `.snyk` Ignore File<\/h3>\n<p>Do not turn off a gate because one advisory is a false positive. Document the exception instead:<\/p>\n<p><code># .snyk<\/code><\/p>\n<p><code>version: v1.25.0<\/code><\/p>\n<p><code>ignore:<\/code><\/p>\n<p><code>  SNYK-JS-INFLIGHT-6095116:<\/code><\/p>\n<p><code>    - '*':<\/code><\/p>\n<p><code>        reason: No upstream fix available. Package used only in dev dependencies, not shipped to production.<\/code><\/p>\n<p><code>        expires: '2027-01-01T00:00:00.000Z'<\/code><\/p>\n<p><code>        created: '2026-07-01T00:00:00.000Z'<\/code><\/p>\n<p>Three fields matter: <code>reason<\/code> explains the decision, <code>expires<\/code> forces a review date and <code>created<\/code> creates an audit trail. If a SOC 2 auditor asks why you suppressed a CVE, this file is your answer.<\/p>\n<h3>Gate 2: Container Image Scanning With Trivy<\/h3>\n<p>Dependency scanning only looks at your npm packages. Your Docker base image carries its own OS-level packages: glibc, curl, openssl. Those have CVEs too.<\/p>\n<p>Trivy scans the entire container image: OS packages, language packages and configuration issues. It runs in CI before the image ever touches a registry.<\/p>\n<p>Create <code>.github\/workflows\/gate-2-image-scan.yml<\/code>:<\/p>\n<p><code>name: Gate 2 \u2014 Container Image Scan<\/code><\/p>\n<p><code>on:<\/code><\/p>\n<p><code>  push:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  pull_request:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>jobs:<\/code><\/p>\n<p><code>  image-scan:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - name: Checkout code<\/code><\/p>\n<p><code>        uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - name: Build Docker image<\/code><\/p>\n<p><code>        run: docker build -t myapp:${{ github.sha }} .<\/code><\/p>\n<p><code>      - name: Scan image with Trivy<\/code><\/p>\n<p><code>        uses: aquasecurity\/trivy-action@0.28.0<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          image-ref: myapp:${{ github.sha }}<\/code><\/p>\n<p><code>          format: table<\/code><\/p>\n<p><code>          exit-code: '1'<\/code><\/p>\n<p><code>          severity: 'CRITICAL,HIGH'<\/code><\/p>\n<p><code>          ignore-unfixed: true<\/code><\/p>\n<p>Two decisions here are worth explaining.<\/p>\n<p>First, <code>ignore-unfixed: true<\/code>. Some CVEs have no patch available. If you leave this out, your build fails on vulnerabilities that have no fix. It blocks your team with no action they can take. This flag filters those out. You still see them in the report; they just don\u2019t block the build.<\/p>\n<p>Second, scan before pushing to the registry. A lot of pipelines build the image, push it, then scan. That means a vulnerable image is sitting in your registry for however long the scan takes. Build it, scan it, push only if clean.<\/p>\n<h3>Suppressing Known False Positives With `.trivyignore`<\/h3>\n<p><code># .trivyignore<\/code><\/p>\n<p><code># CVE-2023-44487 HTTP\/2 Rapid Reset - handled at load balancer (nginx rate limits + conn caps)<\/code><\/p>\n<p><code># owner: zia.ullah@valueadd.se | next review: 2027-01-01<\/code><\/p>\n<p><code>CVE-2023-44487<\/code><\/p>\n<p>Same principle as the Snyk ignore file: Document the reason, add a review date.<\/p>\n<h3>Gate 3: Static Analysis With CodeQL<\/h3>\n<p>Dependency scanning and image scanning look at what you install. CodeQL looks at what you write.<\/p>\n<p>It traces data flows through your code. SQL injection, where input from a route param reaches a database call without sanitization; XSS, where a user-controlled string ends up in an HTML response; hardcoded secrets buried in config files; path traversal \u2014 these aren\u2019t hypothetical patterns; they\u2019re the vulnerability classes in most breach reports.<\/p>\n<p>Create <code>.github\/workflows\/gate-3-codeql.yml<\/code>:<\/p>\n<p><code>name: Gate 3 \u2014 Static Analysis (CodeQL)<\/code><\/p>\n<p><code>on:<\/code><\/p>\n<p><code>  push:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  pull_request:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  schedule:<\/code><\/p>\n<p><code>    - cron: '0 6 * * 1'  # weekly scan on Monday at 6am<\/code><\/p>\n<p><code>jobs:<\/code><\/p>\n<p><code>  static-analysis:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    permissions:<\/code><\/p>\n<p><code>      actions: read<\/code><\/p>\n<p><code>      contents: read<\/code><\/p>\n<p><code>      security-events: write<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - name: Checkout code<\/code><\/p>\n<p><code>        uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - name: Initialize CodeQL<\/code><\/p>\n<p><code>        uses: github\/codeql-action\/init@v3<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          languages: javascript, typescript<\/code><\/p>\n<p><code>          queries: security-extended<\/code><\/p>\n<p><code>      - name: Autobuild<\/code><\/p>\n<p><code>        uses: github\/codeql-action\/autobuild@v3<\/code><\/p>\n<p><code>      - name: Perform CodeQL Analysis<\/code><\/p>\n<p><code>        uses: github\/codeql-action\/analyze@v3<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          category: \"\/language:javascript\"<\/code><\/p>\n<p>The <code>queries: security-extended<\/code> line matters. The default query set catches the most obvious issues. The extended set adds more SQL injection patterns, SSRF detection and stricter taint tracking. For a production application, use extended.<\/p>\n<p>The <code>security-events: write<\/code> permission lets CodeQL post results to GitHub\u2019s Security tab. You\u2019ll see findings there even when the build passes.<\/p>\n<p><em>CodeQL is free for public repositories.<\/em> For private repositories, it requires GitHub Advanced Security. If you\u2019re on a private repo without GHAS, Semgrep OSS is a free alternative:<\/p>\n<p><code>- name: Semgrep scan<\/code><\/p>\n<p><code>  uses: returntocorp\/semgrep-action@v1<\/code><\/p>\n<p><code>  with:<\/code><\/p>\n<p><code>    config: p\/owasp-top-ten<\/code><\/p>\n<h3>Gate 4: DAST With OWASP ZAP<\/h3>\n<p>The first three gates analyze artifacts: Package manifests, container layers, source code. Gate 4 does something different. It actually starts your application and attacks it.<\/p>\n<p>Gate 4 was the one I was most curious about when I first set this up. It actually boots the app and fires real HTTP requests at it. What comes back tells you things the source code never could. A 500 where the app should return a 400. Missing headers on endpoints that aren\u2019t configured. Open redirects. None of that shows up in a static scan.<\/p>\n<p>The OWASP ZAP baseline scan is fast enough for CI. It runs passive checks. No brute-forcing, no fuzzing. It observes and probes. Most pipelines complete it in under three minutes.<\/p>\n<p>Create <code>.github\/workflows\/gate-4-dast.yml<\/code>:<\/p>\n<p><code>name: Gate 4 \u2014 DAST (OWASP ZAP)<\/code><\/p>\n<p><code>on:<\/code><\/p>\n<p><code>  push:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  pull_request:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>jobs:<\/code><\/p>\n<p><code>  dast:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - name: Checkout code<\/code><\/p>\n<p><code>        uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - name: Start application<\/code><\/p>\n<p><code>        run: |<\/code><\/p>\n<p><code>          docker compose up -d<\/code><\/p>\n<p><code>          sleep 15<\/code><\/p>\n<p><code>      - name: Wait for app to be ready<\/code><\/p>\n<p><code>        run: |<\/code><\/p>\n<p><code>          for i in {1..10}; do<\/code><\/p>\n<p><code>            curl -s http:\/\/localhost:3000\/health &amp;&amp; break<\/code><\/p>\n<p><code>            echo \"Waiting for app...\"<\/code><\/p>\n<p><code>            sleep 3<\/code><\/p>\n<p><code>          done<\/code><\/p>\n<p><code>      - name: ZAP Baseline Scan<\/code><\/p>\n<p><code>        uses: zaproxy\/action-baseline@v0.10.0<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          target: 'http:\/\/localhost:3000'<\/code><\/p>\n<p><code>          rules_file_name: '.zap\/rules.tsv'<\/code><\/p>\n<p><code>          fail_action: warn<\/code><\/p>\n<p><code>      - name: Stop application<\/code><\/p>\n<p><code>        if: always()<\/code><\/p>\n<p><code>        run: docker compose down<\/code><\/p>\n<p><code>fail_action: warn<\/code> is the right default for DAST. ZAP has a higher false-positive rate than the other gates. On the first run against a JSON API, you\u2019ll see alerts for missing CSP headers, missing HSTS and permissions policy. Most of those are intentional. Setting it to <code>warn<\/code> keeps the findings visible in the job output without blocking the build. Review the ZAP report on a schedule; don\u2019t let warnings pile up unreviewed.<\/p>\n<h3>Cutting ZAP Noise With `.zap\/rules.tsv`<\/h3>\n<p><code>10021<\/code><code>IGNORE<\/code><code>(Strict-Transport-Security not set on HTTP - intentional on local non-TLS endpoint)<\/code><\/p>\n<p><code>10038<\/code><code>IGNORE<\/code><code>(Content Security Policy not set - handled at reverse proxy level)<\/code><\/p>\n<p>The rule IDs are in the ZAP docs at zaproxy.org\/docs\/alerts. I kept that page open during the first few scans. 10038 is the CSP one. 10035 is HSTS. After looking them up a couple of times, you stop needing to.<\/p>\n<h3>Running All Four Gates in One Workflow<\/h3>\n<p>Four separate workflow files work fine for learning, but in practice, I merge them into one. The first three jobs run in parallel. Gate 4 holds until all three finish: No point scanning a live app if the code already has a known SQL injection in it.<\/p>\n<p>Create <code>.github\/workflows\/security-gates.yml<\/code>:<\/p>\n<p><code>name: Security Gates<\/code><\/p>\n<p><code>on:<\/code><\/p>\n<p><code>  push:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>  pull_request:<\/code><\/p>\n<p><code>    branches: [main]<\/code><\/p>\n<p><code>jobs:<\/code><\/p>\n<p><code>  dependency-scan:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - uses: actions\/setup-node@v4<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          node-version: '20'<\/code><\/p>\n<p><code>          cache: 'npm'<\/code><\/p>\n<p><code>      - run: npm ci<\/code><\/p>\n<p><code>      - run: npm audit --audit-level=high<\/code><\/p>\n<p><code>      - uses: snyk\/actions\/node@v1.4.0<\/code><\/p>\n<p><code>        continue-on-error: false<\/code><\/p>\n<p><code>        env:<\/code><\/p>\n<p><code>          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          args: --severity-threshold=high<\/code><\/p>\n<p><code>  image-scan:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - run: docker build -t myapp:${{ github.sha }} .<\/code><\/p>\n<p><code>      - uses: aquasecurity\/trivy-action@0.28.0<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          image-ref: myapp:${{ github.sha }}<\/code><\/p>\n<p><code>          format: table<\/code><\/p>\n<p><code>          exit-code: '1'<\/code><\/p>\n<p><code>          severity: 'CRITICAL,HIGH'<\/code><\/p>\n<p><code>          ignore-unfixed: true<\/code><\/p>\n<p><code>  static-analysis:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    permissions:<\/code><\/p>\n<p><code>      actions: read<\/code><\/p>\n<p><code>      contents: read<\/code><\/p>\n<p><code>      security-events: write<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - uses: github\/codeql-action\/init@v3<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          languages: javascript, typescript<\/code><\/p>\n<p><code>          queries: security-extended<\/code><\/p>\n<p><code>      - uses: github\/codeql-action\/autobuild@v3<\/code><\/p>\n<p><code>      - uses: github\/codeql-action\/analyze@v3<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          category: \"\/language:javascript\"<\/code><\/p>\n<p><code>  dast:<\/code><\/p>\n<p><code>    runs-on: ubuntu-latest<\/code><\/p>\n<p><code>    needs: [dependency-scan, image-scan, static-analysis]<\/code><\/p>\n<p><code>    steps:<\/code><\/p>\n<p><code>      - uses: actions\/checkout@v4<\/code><\/p>\n<p><code>      - name: Start application<\/code><\/p>\n<p><code>        run: |<\/code><\/p>\n<p><code>          docker compose up -d<\/code><\/p>\n<p><code>          sleep 15<\/code><\/p>\n<p><code>      - name: Wait for health check<\/code><\/p>\n<p><code>        run: |<\/code><\/p>\n<p><code>          for i in {1..10}; do<\/code><\/p>\n<p><code>            curl -s http:\/\/localhost:3000\/health &amp;&amp; break<\/code><\/p>\n<p><code>            sleep 3<\/code><\/p>\n<p><code>          done<\/code><\/p>\n<p><code>      - uses: zaproxy\/action-baseline@v0.10.0<\/code><\/p>\n<p><code>        with:<\/code><\/p>\n<p><code>          target: 'http:\/\/localhost:3000'<\/code><\/p>\n<p><code>          rules_file_name: '.zap\/rules.tsv'<\/code><\/p>\n<p><code>          fail_action: warn<\/code><\/p>\n<p><code>      - name: Stop application<\/code><\/p>\n<p><code>        if: always()<\/code><\/p>\n<p><code>        run: docker compose down<\/code><\/p>\n<p>The <code>needs: [dependency-scan, image-scan, static-analysis]<\/code> line is the key. Gate 4 only starts if the first three complete without error.<\/p>\n<h3>Handling False Positives Without Turning Off Gates<\/h3>\n<p>Every team that installs security tooling eventually hits a false positive and considers disabling the rule. I\u2019ve seen it happen within the first week on most teams. The right response is to suppress it with documentation, not remove it.<\/p>\n<p>Each tool has its own mechanism.<\/p>\n<p><em>Snyk<\/em> uses <code>.snyk<\/code> with expiry dates. A suppression that never expires is forgotten. Set an expiry. When the date passes, the build fails again and forces a review.<\/p>\n<p><em>Trivy<\/em> uses <code>.trivyignore<\/code> with CVE IDs and comments. One CVE per line, one comment explaining why.<\/p>\n<p><em>ZAP<\/em> uses <code>.zap\/rules.tsv<\/code> with rule IDs. The ZAP documentation lists every rule ID and what it detects. Rule 10038 is CSP. Rule 10035 is HSTS. Once you know the IDs, suppression is a one-liner.<\/p>\n<p><em>CodeQL<\/em> suppressions go in the code itself:<\/p>\n<p><code>\/\/ codeql[js\/sql-injection]<\/code><\/p>\n<p><code>const result = await db.query(query);<\/code><\/p>\n<p>The <code>codeql<\/code> comment tells CodeQL to ignore the finding on that line. Use it rarely, document why.<\/p>\n<p>If a SOC 2 auditor asks about your suppression decisions, these files are your evidence. They show that you evaluated each finding, made a documented decision and set a review date. That\u2019s more defensible than having no suppressions and no findings because the tool is turned off.<\/p>\n<h3>Troubleshooting<\/h3>\n<p>These are real issues that surfaced while building and testing this pipeline, not edge cases. Each one will stop your build until you know the fix.<\/p>\n<h3>ZAP Docker Image Not Found: `owasp\/zap2docker-stable`<\/h3>\n<p>This image was deprecated and removed from Docker Hub. Any article or Stack Overflow post referencing it is out of date. The correct image is:<\/p>\n<p><code>ghcr.io\/zaproxy\/zaproxy:stable<\/code><\/p>\n<p>Update your local test command and any workflow files that reference the old image name. The GitHub Container Registry image is maintained by the ZAP project team and receives regular updates.<\/p>\n<h3>Healthcheck Shows `(unhealthy)` \u2014 Curl: Command Not Found<\/h3>\n<p><code>node:20-slim<\/code> is a minimal Debian image. It does not include curl. A healthcheck that uses curl will silently fail and mark the container as unhealthy, which blocks ZAP from scanning a ready application.<\/p>\n<p>Switch the healthcheck to use Node\u2019s built-in <code>http<\/code> module instead:<\/p>\n<p><code>healthcheck:<\/code><\/p>\n<p><code>  test: [\"CMD\", \"node\", \"-e\", \"require('http').get('http:\/\/localhost:3000\/health', r =&gt; process.exit(r.statusCode === 200 ? 0 : 1))\"]<\/code><\/p>\n<p><code>  interval: 10s<\/code><\/p>\n<p><code>  timeout: 5s<\/code><\/p>\n<p><code>  retries: 3<\/code><\/p>\n<p><code>  start_period: 10s<\/code><\/p>\n<p>Node\u2019s <code>http<\/code> module ships with every Node image. No extra packages, no install step. Works on alpine, slim and full Debian variants.<\/p>\n<h3>Docker Compose Fails With \u201cport is Already Allocated\u201d<\/h3>\n<p>This one caught me the first time I ran the local setup. I had <code>npm start<\/code> running in another terminal, so docker compose couldn\u2019t bind port 3000. It also happens when a container from a previous run is still sitting there \u2014 <code>docker ps<\/code> will show it.<\/p>\n<p>Before running docker compose, stop anything using the port:<\/p>\n<p><code>npx kill-port 3000<\/code><\/p>\n<p><code>docker ps -a   # check for running containers from other projects<\/code><\/p>\n<p><code>docker stop &lt;container-name&gt;<\/code><\/p>\n<p>If you see <code>docker compose up<\/code> succeed but the container exits immediately, the port conflict is the first thing to check.<\/p>\n<h3>ZAP Scan Fails With \u201cConnection Refused\u201d or Downloads With EOF Error<\/h3>\n<p>Two separate issues that look the same.<\/p>\n<p>I hit this on the first local test. The container looked like it started fine, but ZAP couldn\u2019t reach it. <code>docker ps<\/code> still showed <code>(starting)<\/code>. The healthcheck needs to pass before ZAP can connect. If it\u2019s stuck there, add more time to <code>start_period<\/code> in docker-compose.yml and try again.<\/p>\n<p>The EOF error during the ZAP image download means the pull got cut off partway. Pull it separately first:<\/p>\n<p><code>docker pull ghcr.io\/zaproxy\/zaproxy:stable<\/code><\/p>\n<p>Then re-run your scan command. The cached layers will be used on the next attempt.<\/p>\n<h3>Snyk Returns 401 Unauthorized in GitHub Actions<\/h3>\n<p>It shows up as <code>SNYK-0005<\/code> in the workflow logs. Usually, one of two things caused it.<\/p>\n<p>First, the token location. Snyk has two types of keys: An Organization API key and a personal Auth Token. The GitHub Actions integration requires the personal Auth Token, not the org key. Get it from <code>https:\/\/app.snyk.io\/account<\/code> under \u201cAuth Token\u201d \u2014 not from the Organization Settings page.<\/p>\n<p>Second, the secret scope. The secret must be added to the specific repository where the workflow runs. Organization-level secrets are not automatically shared with all repositories. Go to the repository, then Settings \u2192 Secrets and variables \u2192 Actions \u2192 New repository secret. Name it <code>SNYK_TOKEN<\/code>.<\/p>\n<h3>Trivy Reports CVEs That Aren\u2019t in Your Code<\/h3>\n<p>The first time I ran Trivy against this project, it flagged 18 findings. My application code had zero issues. Everything else came from two places: The Debian base image packages and npm\u2019s own bundled toolchain. Trivy scans the whole container, not just what you wrote.<\/p>\n<p>Debian packages such as <code>libgnutls30<\/code> and <code>libcap2<\/code> come bundled with <code>node:20-slim<\/code>. In our case, most of those CVEs were DTLS vulnerabilities in a library our HTTP-only API never touches. They flag, but there\u2019s no exploitable path.<\/p>\n<p>The npm ones took me a minute to understand. The path gave it away \u2014 <code>\/usr\/local\/lib\/node_modules\/npm\/node_modules\/<\/code>. That\u2019s not your app. That\u2019s npm\u2019s own private dependencies: <code>tar<\/code>, <code>minimatch<\/code>, <code>cross-spawn<\/code>. That directory is npm eating its own dog food \u2014 packages npm uses internally when it installs things. Once the build is done and the container starts, nothing calls that code again.<\/p>\n<p>Handle both with <code>.trivyignore<\/code>. Document each suppression:<\/p>\n<p><code># .trivyignore<\/code><\/p>\n<p><code># libgnutls30 \u2014 DTLS vulnerability, not exploitable in this HTTP-only API<\/code><\/p>\n<p><code># Fixed version available in Debian but not yet in node:20-slim<\/code><\/p>\n<p><code># Approved: your.email@company.com | Review: 2027-01-01<\/code><\/p>\n<p><code>CVE-2026-33845<\/code><\/p>\n<p><code># tar (npm internal) \u2014 npm's own toolchain, not in runtime dependencies<\/code><\/p>\n<p><code># Only used during npm install, not exposed in the running container<\/code><\/p>\n<p><code>CVE-2026-23745<\/code><\/p>\n<p>The pattern is the same as <code>.snyk<\/code>: Reason, owner, review date.<\/p>\n<h3>Node_modules Committed to Git \u2014 Missing .gitignore<\/h3>\n<p>If you initialize a repository and run <code>npm install<\/code> before creating a <code>.gitignore<\/code>, Git will track <code>node_modules<\/code> and <code>.env<\/code>. A push with <code>node_modules<\/code> sends thousands of files to GitHub and can expose a committed <code>.env<\/code> file.<\/p>\n<p>Add this before you push anything:<\/p>\n<p><code>node_modules\/<\/code><\/p>\n<p><code>.env<\/code><\/p>\n<p><code>*.log<\/code><\/p>\n<p>If you pushed before adding <code>.gitignore<\/code>, <code>node_modules<\/code> is already in Git\u2019s history. You need to untrack it without deleting it from the disk:<\/p>\n<p><code>git rm -r --cached node_modules<\/code><\/p>\n<p><code>git rm --cached .env<\/code><\/p>\n<p><code>git commit -m \"Remove tracked files that should be ignored\"<\/code><\/p>\n<h3>CodeQL Fails With \u201cNo Source Code Was Seen During the Build\u201d<\/h3>\n<p>I hit this on a project where the build script wasn\u2019t the default. CodeQL\u2019s autobuild guesses your build command \u2014 it gets it right most of the time, but not always. When it misses, swap it out for an explicit run step:<\/p>\n<p><code>- name: Manual build<\/code><\/p>\n<p><code>  run: npm ci &amp;&amp; npm run build<\/code><\/p>\n<h3>ZAP Scan Fails Because the Application Won\u2019t Start<\/h3>\n<p>The sleep timer in the workflow might not be long enough for your app to initialize. Increase <code>sleep 15<\/code> to <code>sleep 30<\/code> or use the healthcheck loop shown in the full workflow above. If the app needs environment variables to start, pass them with a <code>.env.ci<\/code> file in the repository (with non-production values).<\/p>\n<h3>Wrapping Up<\/h3>\n<p>Four gates; none requiring an enterprise license \u2014 each one catches a different class of vulnerability at the point where it\u2019s cheapest to fix.<\/p>\n<p>The math isn\u2019t complicated. A critical CVE found in CI takes minutes to fix. The same CVE found after a breach starts the clock on $4.88 million in average costs. The tooling here is free. The GitHub Actions minutes are cheap. The only thing the pipeline costs is the time required to set it up. Once.<\/p>\n<p>One thing teams usually don\u2019t anticipate: False-positive management is ongoing work. You will suppress some findings. You will review them when the expiry dates pass. That\u2019s the point. Security tooling that runs without maintenance is security tooling that nobody trusts.<\/p>\n<p>The complete working example \u2014 including the Node.js app, Dockerfile, docker compose and all four workflow files \u2014 is available at github-actions-security-gates.<\/p>\n<p><a href=\"https:\/\/devops.com\/shift-left-security-4-automated-security-gates-in-github-actions\/\" target=\"_blank\" class=\"feedzy-rss-link-icon\">Read More<\/a><\/p>\n<p>\u200b<\/p>","protected":false},"excerpt":{"rendered":"<p>A security researcher filed a report against our Node.js API two years ago. The vulnerability was prototype pollution in an [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":4729,"comment_status":"","ping_status":"","sticky":false,"template":"","format":"standard","meta":{"site-sidebar-layout":"default","site-content-layout":"","ast-site-content-layout":"default","site-content-style":"default","site-sidebar-style":"default","ast-global-header-display":"","ast-banner-title-visibility":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","ast-disable-related-posts":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","astra-migrate-meta-layouts":"default","ast-page-background-enabled":"default","ast-page-background-meta":{"desktop":{"background-color":"var(--ast-global-color-4)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"ast-content-background-meta":{"desktop":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"tablet":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""},"mobile":{"background-color":"var(--ast-global-color-5)","background-image":"","background-repeat":"repeat","background-position":"center center","background-size":"auto","background-attachment":"scroll","background-type":"","background-media":"","overlay-type":"","overlay-color":"","overlay-opacity":"","overlay-gradient":""}},"footnotes":""},"categories":[5],"tags":[],"class_list":["post-4728","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-devops"],"_links":{"self":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/4728","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/comments?post=4728"}],"version-history":[{"count":0,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/posts\/4728\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media\/4729"}],"wp:attachment":[{"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/media?parent=4728"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/categories?post=4728"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rssfeedtelegrambot.bnaya.co.il\/index.php\/wp-json\/wp\/v2\/tags?post=4728"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}