[donny@scyber ~]$ cat ./blog/enterprise-devsecops-automation.md

Shift-left DevSecOps with GitHub Actions

August 3, 2025 • 12 min read •
DevSecOps GitHub Actions Security

A GitHub Actions suite that scans only what changed, runs the right tools per language, and lands the findings in GitHub's Security tab — plus the details that trip people up when they wire it together.

## Why the scans stopped being useful

Teams ship several times a day. A manual security review can't keep up with that, so it gets skipped, and the findings that surface after the merge are the expensive ones.

I've been building security automation for customers for years, and the failure mode is usually the same. The tools run fine, but developers stop reading the output: too many false positives, and too many findings that have nothing to do with the change in front of them.

The version of this I remember best was a financial-services platform whose scan pipeline had quietly stopped being useful — jobs coupled together so one tool's failure took the whole run down with it, and by then nobody was reading the results anyway. Rebuilding it meant making every scanner an independent job and letting change detection decide what ran at all. Same tools. The difference was that the output became trustworthy enough to act on.

So I built a GitHub Actions scanning suite around that problem. It only scans what changed, it runs thirteen off-the-shelf scanners across Python, TypeScript/JavaScript, Terraform, shell, and secrets plus a few wrappers of my own, and it puts findings in GitHub's Security tab where developers are already reading code.

## One workflow, parallel jobs

It's one workflow that fans out into per-stack jobs and converges on a single summary. The diagram below traces it from trigger event through to the GitHub Security tab:

graph TD %% Trigger and Setup Phase A[🚀 Trigger Event
Push/PR/Manual] --> B[📋 Setup & Change Detection
Decide what needs scanning] %% Setup Job Output B --> C[🎯 Execution Plan
Determines which scans to run] %% Independent Parallel Jobs C --> D{Backend
Scan Needed?} C --> E{API
Scan Needed?} C --> F{Frontend
Scan Needed?} C --> G{Infrastructure
Scan Needed?} C --> H{Security
Scan Needed?} %% Independent Scan Jobs D -->|Yes| D1[🐍 Backend Python Scan
Creates backend_results] D -->|No| D2[⏭️ Skip Backend] E -->|Yes| E1[📡 API TypeScript Scan
Creates api_results] E -->|No| E2[⏭️ Skip API] F -->|Yes| F1[🎨 Frontend TypeScript Scan
Creates frontend_results] F -->|No| F2[⏭️ Skip Frontend] G -->|Yes| G1[🏗️ Infrastructure Security Scan
Creates infrastructure_results] G -->|No| G2[⏭️ Skip Infrastructure] H -->|Yes| H1[🔐 Comprehensive Security Scan
Creates security_results] H -->|No| H2[⏭️ Skip Security] %% Independent Artifact Upload D1 --> D3[📤 Upload Backend Artifacts
backend-results-timestamp] E1 --> E3[📤 Upload API Artifacts
api-results-timestamp] F1 --> F3[📤 Upload Frontend Artifacts
frontend-results-timestamp] G1 --> G3[📤 Upload Infrastructure Artifacts
infrastructure-results-timestamp] H1 --> H3[📤 Upload Security Artifacts
security-results-timestamp] %% Summary Job Convergence D3 --> I[📊 Generate Summary
Download all individual artifacts] E3 --> I F3 --> I G3 --> I H3 --> I D2 --> I E2 --> I F2 --> I G2 --> I H2 --> I %% Enhanced Summary Processing I --> J[🔍 Enhanced Results Analysis
Parse findings from all scan formats] J --> K[📄 Generate Actionable Summary
Security/quality insights with priorities] %% GitHub Integration K --> L[📤 Upload Summary Artifact
comprehensive-summary-timestamp] L --> M{Is Pull Request?} M -->|Yes| N[💬 Add PR Comment
Status summary with links] M -->|No| N1[⏭️ Skip PR Comment] L --> O{SARIF Files Available?} O -->|Yes| P[🔐 Upload to GitHub Security Tab
Code scanning integration] O -->|No| P1[⏭️ No Security Upload] N --> Q[✅ Workflow Complete
All results available as artifacts] N1 --> Q P --> Q P1 --> Q %% Styling style A fill:#e1f5fe,stroke:#01579b,stroke-width:3px style B fill:#f3e5f5,stroke:#4a148c,stroke-width:2px style C fill:#fff3e0,stroke:#f57c00,stroke-width:2px style D1 fill:#e8f5e8,stroke:#1b5e20,stroke-width:2px style E1 fill:#fff3e0,stroke:#e65100,stroke-width:2px style F1 fill:#fce4ec,stroke:#880e4f,stroke-width:2px style G1 fill:#e0f2f1,stroke:#004d40,stroke-width:2px style H1 fill:#ffebee,stroke:#b71c1c,stroke-width:2px style I fill:#f1f8e9,stroke:#33691e,stroke-width:2px style J fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px style K fill:#e3f2fd,stroke:#0d47a1,stroke-width:2px style Q fill:#e8f5e8,stroke:#2e7d32,stroke-width:3px

## Change detection

Most pipelines run every scan on every push, whether or not the code in question was touched. That burns CI minutes, and worse, it trains developers to ignore the output. The pipeline diffs the changed paths first and decides what needs to run.

# Smart change detection logic
# NOTE: requires a full-history checkout — actions/checkout@v4 with fetch-depth: 0.
# The default shallow clone (depth 1) won't have the base commit to diff against.

BASE="${{ github.event.before }}"

# On a brand-new branch, github.event.before is all zeros (no prior commit).
# Fall back to diffing against the default branch instead of erroring.
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
  BASE="$(git rev-parse origin/${{ github.event.repository.default_branch }})"
fi

git diff --name-only "$BASE" "${{ github.sha }}" > changed_files.txt

# Backend changes trigger Python security analysis
if grep -E '^(backend|src|app)/' changed_files.txt; then
  echo "backend_scan=true" >> "$GITHUB_OUTPUT"
fi

# Frontend changes trigger TypeScript/JavaScript analysis
if grep -E '^(frontend|web|client|ui)/' changed_files.txt; then
  echo "frontend_scan=true" >> "$GITHUB_OUTPUT"
fi

# Infrastructure changes trigger Terraform security scans
if grep -E '^(infra|terraform|infrastructure)/' changed_files.txt; then
  echo "infrastructure_scan=true" >> "$GITHUB_OUTPUT"
fi

# Always run comprehensive security if workflow files change
if grep -E '^\.github/workflows/' changed_files.txt; then
  echo "force_full_scan=true" >> $GITHUB_OUTPUT
fi

The patterns cover the usual directory names. Any change under .github/workflows/ forces a full run, so an edit to the scanner itself never goes through on a partial scan.

Change Detection Patterns

  • • backend/, src/, app/ → Python scans
  • • frontend/, web/, client/ → TypeScript scans
  • • api/, services/ → API-specific scans
  • • infra/, terraform/ → Infrastructure scans
  • • .github/workflows/ → Force full scan

Overrides

  • • Runs on pushes and pull requests to main and develop, filtered to the source paths above
  • • workflow_dispatch runs it by hand. scan_scope is all, security-only, quality-only, or one component (backend-only, frontend-only, api-only, infrastructure-only)
  • • force_full_scan ignores the diff and runs everything

## One set of tools per language

Almost nobody ships one language. The suite covers Python, TypeScript/JavaScript, and Terraform, and each one gets the tools that actually fit it rather than a lowest-common- denominator scan. Thirteen off-the-shelf scanners in all. Python gets bandit, Safety, Semgrep, flake8, and black. TypeScript gets ESLint and tsc. Terraform gets tfsec, checkov, Terrascan, and tflint. gitleaks covers secrets, and shellcheck covers the wrapper scripts themselves.

Most of the tuning was deciding what not to show. bandit runs with -ll, so its low-severity rules (the ones that flag every assert and every subprocess import) never reach the summary. Semgrep gets a severity floor the same way. node_modules, dist, build, cdk.out, and venv are excluded from every scanner, because a finding in a vendored dependency is nobody's fix. And ESLint's base no-unused-vars is off in favor of the TypeScript-aware rule, which the base rule misreports.

Python Security Stack

# Python security scanning pipeline
# Code formatting and style consistency
black --check --diff backend/

# Code quality and style issues
# NOTE: flake8 ships only two formatters, `default` and `pylint` — `--format=json`
# needs the flake8-json plugin installed, or this fails.
flake8 backend/ --format=json --output-file=flake8-results.json

# Security vulnerability analysis
bandit -r backend/ -f json -o bandit-results.json

# Dependency vulnerability scanning (Safety CLI 3.x uses `scan`; `check` is legacy).
# `scan` requires authentication (`safety auth` / SAFETY_API_KEY in CI).
safety scan --json > safety-results.json

# Advanced security pattern detection
# Pin a ruleset (e.g. p/default) rather than --config=auto, which auto-selects
# rules via the Semgrep registry and reports project metadata — avoid that in
# private/enterprise repos. `semgrep scan` is the current subcommand.
semgrep scan --config=p/default backend/ --json --output=semgrep-results.json

TypeScript/JavaScript Analysis

# TypeScript security and quality analysis
# TypeScript compilation and type checking
tsc --noEmit --strict --skipLibCheck

# ESLint with security rules and custom configurations
eslint frontend/ --ext .ts,.tsx,.js,.jsx --format json \
  --output-file eslint-results.json

# Advanced pattern matching for security issues
eslint frontend/ --ext .ts,.tsx,.js,.jsx \
  --config .eslintrc.security.js --format json

Infrastructure Security (Terraform)

# Infrastructure security scanning suite
# Multi-tool Terraform security analysis via one wrapper script
./security/tfscan.sh --directory terraform/ --output-dir results/

# Tools the wrapper runs:
# - tfsec: AWS/Azure/GCP security issues
# - checkov: Policy-as-code violations
# - terrascan: Compliance and security rules
# - tflint: Terraform syntax and logic issues
#
# DATED, as of 2026: tfsec was folded into Trivy (no new checks — use `trivy config`),
# and Terrascan was archived in November 2025. If you're building this now, checkov
# + trivy + tflint covers the same ground with tools that still ship updates.

🔧 The wrapper scripts

Four small scripts in security/ do the work the jobs call into:

  • • a Terraform wrapper that runs the four IaC scanners and collects their output
  • • a Python wrapper that runs the stack above and normalizes the results
  • • a cross-language pattern scanner for the things no single tool catches
  • • the summary generator that parses every format and ranks what came back

## SARIF and the Security tab

The findings are uploaded as SARIF (Static Analysis Results Interchange Format), which GitHub's Security tab reads directly. That's the piece that gets findings in front of developers instead of burying them in a CI log nobody opens.

# GitHub Security tab integration
# upload-sarif needs the security-events: write permission
permissions:
  security-events: write

steps:
  # Stage every tool's SARIF output into one directory first...
  # (semgrep-results.sarif, bandit-results.sarif, tfsec-results.sarif, etc.)
  - name: Upload SARIF to GitHub Security
    uses: github/codeql-action/upload-sarif@v3   # v3 — v2 is deprecated
    if: always()
    with:
      # sarif_file takes a single .sarif file OR a directory of them — not a list
      sarif_file: sarif-results/
      category: comprehensive-security-scan

# Results appear in Security → Code scanning alerts
# Includes file locations, severity levels, and remediation guidance

Two details bit me on the first wiring: upload-sarif requires the security-events: write permission, and its sarif_file input takes a single file or a directory (not a list). So the pattern is to write each tool's SARIF output into one folder (here, sarif-results/) and point the action at that folder. Use @v3. The older @v2 major is deprecated.

Once findings land there they behave like any other GitHub alert, which is also where the audit trail comes from.

## The summary step

Raw scanner output is a wall of text, and every tool formats it differently. The summary step parses all of those formats and ranks what came back, so the answer to "what do I fix first" is at the top of the job summary.

# Enhanced summary generation logic
# Multi-format result parsing
def parse_scan_results(results_dir):
    findings = []

    # Parse JSON results (Bandit, ESLint, Flake8)
    for json_file in glob.glob(f"{results_dir}/**/*.json", recursive=True):
        findings.extend(parse_json_results(json_file))

    # Parse SARIF results (Semgrep, tfsec, Checkov)
    for sarif_file in glob.glob(f"{results_dir}/**/*.sarif", recursive=True):
        findings.extend(parse_sarif_results(sarif_file))

    # Parse Markdown results (custom tools)
    for md_file in glob.glob(f"{results_dir}/**/*.md", recursive=True):
        findings.extend(parse_markdown_results(md_file))

    return prioritize_findings(findings)

Ranking is by severity first, then by how many files a finding touches. The top of the summary is a short list for whoever is fixing things, and the counts and per-tool status under it are for reporting.

## Portability

The whole thing is two directories: the workflow files under .github/workflows/, and a security/ folder holding the wrapper scripts the jobs call. There is no separate config file: the detection patterns live in the workflow itself. The workflow reads the project layout and decides which scans apply, so it moves between repositories as long as the layout resembles one of the conventions below. Anything stranger than that needs the detection patterns adjusted, which is a few lines of grep.

Supported Project Structures

Backend Detection:

  • • backend/, src/, server/, app/
  • • Python files: *.py
  • • Package files: requirements.txt, poetry.lock

Frontend Detection:

  • • frontend/, web/, client/, ui/
  • • TypeScript/JS: *.ts, *.tsx, *.js, *.jsx
  • • Config files: package.json, tsconfig.json

Infrastructure Detection:

  • • infra/, infrastructure/, terraform/
  • • Terraform files: *.tf, *.tfvars
  • • Scanned as one component, keyed on the directory name alone

## What changed for the team

On the financial-services platform, security validation per release went from about two weeks to two to four hours, and the change detection cut CI cost by an estimated 40 to 60 percent. Issues get caught while the author still has the code in their head, and every change gets the same scan instead of whichever ones somebody remembered to look at.

The scoped runs are also shorter, so developers wait for them. Each finding has a file and a line number in the Security tab, so fixing one starts from the finding.

Every run uploads its artifacts and the SARIF files, so the audit trail needs no separate step.

## Wrapping up

If you're standing up something similar, start small: wire up one scanner per language, get the signal-to-noise right, then add change detection so you're only scanning what actually changed. Most of my time went into the severity floors and exclusions above, not the workflow.

Related writing: The Security Side of Vibe Coding, on what this looks like when the code is AI-generated.

DS
author.profile

Donny Schreiber

Cybersecurity consultant at Amazon Web Services (AWS). I write about cloud security, DevSecOps, infrastructure-as-code, and the security side of AI — drawn from daily practice.

DevSecOps Security GitHub Actions
# related_posts.available()
vibe-coding-security.md

The Security Side of Vibe Coding: What AI-Generated Code Gets Wrong

I use these tools every day, and I'm also a security professional. A field report on what AI-assisted coding gets wrong — missing authentication, agents with too much access, secrets in the wrong place — from my own code and the research.

Published February 13, 2026
aws-vpc-ipam.md

Building Hierarchical, Multi-Region AWS VPC IPAM with Terraform

Hierarchical, multi-Region IP address management on AWS VPC IPAM, automated in Terraform — 67 pools and a Streamlit planning tool.

Published July 26, 2025