## 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:
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
mainanddevelop, filtered to the source paths above - •
workflow_dispatchruns it by hand.scan_scopeisall,security-only,quality-only, or one component (backend-only,frontend-only,api-only,infrastructure-only) - •
force_full_scanignores 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.
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.
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.
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.