DevSecOps & Supply Chain Security

SAST & Code Scanning

18 min Lesson 3 of 28

SAST & Code Scanning

Static Application Security Testing (SAST) analyzes source code, bytecode, or binary without executing the program. Unlike runtime testing, SAST runs at the speed of a compiler — it can scan every pull request in seconds and report findings before a single line reaches production. At big-tech scale, Google's internal static analysis infrastructure (Tricorder) surfaces millions of findings per week directly in code review, treating security as a first-class property of code, not an afterthought gate at the end of a release.

This lesson covers the two dominant open-source SAST engines — CodeQL and Semgrep — explains how they work under the hood, and gives you a practical playbook for integrating both without drowning your team in noise.

How SAST Engines Work

There are two fundamentally different approaches to static analysis, and understanding the distinction tells you which tool to reach for:

  • Semantic / dataflow analysis (CodeQL). The code is compiled into a relational database of facts: every function call, variable assignment, data flow edge, and control flow path is stored as a queryable relation. Security engineers write SQL-like queries against this database to express vulnerability patterns — e.g., "find all paths where data from an HTTP request flows into a SQL query without passing through a sanitizer." This catches complex, multi-file vulnerabilities that span many call frames. Cost: slow (minutes per scan), requires a full build.
  • Pattern-based / AST matching (Semgrep). You write patterns in a language that looks like the code you are searching for, and Semgrep matches them against the parsed syntax tree. A pattern like $X.execute($QUERY) will match any call to any method named execute with any argument named QUERY. This catches well-understood, localized patterns extremely fast (seconds). Cost: can miss multi-hop vulnerabilities; false-positive rate depends on pattern quality.

In practice, production SAST pipelines run both: Semgrep as a fast gate on every PR (findings block merge), CodeQL as a deeper nightly scan whose results feed a security backlog rather than blocking day-to-day development.

SAST Pipeline: PR gate vs. nightly deep scan Developer PR Opened Semgrep Fast gate (~30s) BLOCK merge PASS — merge HIGH sev clean CodeQL Deep scan (~8min) Nightly schedule Security Backlog GitHub Security tab (SARIF) Semgrep blocks the PR gate; CodeQL feeds the security backlog. Both upload SARIF to GitHub.
Two-tier SAST: Semgrep runs on every PR as a fast gate; CodeQL runs nightly for deep dataflow analysis. Both report via SARIF to GitHub Advanced Security.

Integrating Semgrep in GitHub Actions

The Semgrep ruleset p/owasp-top-ten covers the ten most critical web application security risks and is maintained by the Semgrep team. Start here; add custom rules incrementally. The --severity ERROR flag means only findings at ERROR severity block the pipeline — WARNING and INFO findings are reported but do not fail the job.

# .github/workflows/sast.yml name: SAST on: pull_request: branches: ["main", "release/*"] push: branches: ["main"] jobs: semgrep: name: Semgrep — fast PR gate runs-on: ubuntu-24.04 container: image: semgrep/semgrep:1.75.0 # pin version for reproducibility permissions: contents: read security-events: write # needed to upload SARIF steps: - uses: actions/checkout@v4 - name: Run Semgrep run: | semgrep ci \ --config p/owasp-top-ten \ --config p/secrets \ --severity ERROR \ --sarif \ --output semgrep.sarif env: SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} - name: Upload SARIF to GitHub if: always() # upload even on failure so findings appear in Security tab uses: github/codeql-action/upload-sarif@v3 with: sarif_file: semgrep.sarif category: semgrep codeql: name: CodeQL — deep nightly scan runs-on: ubuntu-24.04 if: github.event_name == 'push' && github.ref == 'refs/heads/main' permissions: contents: read security-events: write actions: read steps: - uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: javascript-typescript, python # Add go, java, csharp, ruby, swift as needed. - name: Autobuild uses: github/codeql-action/autobuild@v3 # For compiled languages, replace with your real build command. - name: Analyze uses: github/codeql-action/analyze@v3 with: category: codeql upload: true
Key idea: Both jobs upload results in SARIF (Static Analysis Results Interchange Format), the open standard for security findings. GitHub Advanced Security parses SARIF and renders findings inline on pull requests and in the repository's Security tab. This gives security engineers a single pane of glass across all tools without custom integrations.

Writing a Custom Semgrep Rule

The real power of Semgrep is writing rules tailored to your codebase — patterns that generic rulesets cannot know about. A common example: your team wraps all database queries in a custom helper, so any direct call to the underlying ORM method bypasses input validation. Semgrep can catch that in one rule.

# .semgrep/rules/no-raw-orm-calls.yml # Detects direct use of the raw ORM query builder bypassing the # approved db.safeQuery() wrapper. Severity ERROR blocks the PR. rules: - id: no-raw-orm-query languages: [typescript] severity: ERROR message: | Direct ORM query bypasses the approved db.safeQuery() wrapper. Use db.safeQuery() which validates and parameterises inputs. metadata: cwe: "CWE-89: SQL Injection" owasp: "A03:2021 - Injection" pattern-either: - pattern: $ORM.query($QUERY, ...) - pattern: $ORM.raw($QUERY, ...) paths: exclude: - "**/*.test.ts" # test files legitimately use raw queries - "db/migrations/**" # migrations need raw SQL # Run only this rule locally: # semgrep --config .semgrep/rules/no-raw-orm-calls.yml --severity ERROR src/

Managing Findings Without Drowning

The biggest SAST failure mode at real organizations is not that the tools find nothing — it is that they find too much. A scan that reports 8,000 findings on day one teaches engineers to ignore the tool entirely. The path out is a triage-first strategy used at companies like Stripe and Shopify:

  1. Start with a narrow, high-precision ruleset. Use p/owasp-top-ten plus your language's recommended pack. Disable rules with high false-positive rates immediately.
  2. Audit and baseline existing findings. Run the full scan on main once, export the SARIF, and import all current findings as dismissed with a justification in GitHub Security. Now only new findings on new code will block PRs. This is critical — penalizing engineers for pre-existing debt they did not introduce destroys adoption.
  3. Suppress intentionally with inline comments, not blanket disables. When a finding is a confirmed false positive, suppress the specific line with a justification. Never disable an entire category globally.
  4. Track suppression debt. Count suppressions per ruleset in your security dashboard. Suppression growth without corresponding fixes is a leading indicator of a deteriorating security posture.
# Suppressing a specific Semgrep finding with a justification comment. # The comment must be on the line immediately before the flagged line. # nosemgrep: no-raw-orm-query -- This is a schema migration; raw SQL is required here. await orm.raw('ALTER TABLE users ADD COLUMN last_login TIMESTAMP'); # For CodeQL, use a CODEQL_SUPPRESS annotation comment: # lgtm[js/sql-injection] -- Sanitized by validateInput() three calls up the stack. # GitHub Advanced Security: dismiss a finding via API with a reason gh api -X PATCH \ /repos/ORG/REPO/code-scanning/alerts/42 \ -f dismissed_reason="false positive" \ -f dismissed_comment="Input is validated by the AuthMiddleware before this point."
Pro practice: Treat the SAST false-positive rate as an SLO. At Google, the policy is that any analysis tool with a false-positive rate above 10% gets disabled until the rule is fixed. A tool that cries wolf teaches engineers to ignore all alerts — including the real ones. Track the ratio of dismissed/suppressed findings to actioned findings per rule, and aggressively prune high-noise rules from your gating set.
Production pitfall: A common mistake is running SAST only on changed files in a PR diff rather than on the full codebase. Many security vulnerabilities span multiple files — a source defined in file A flows through file B and sinks in file C. Diff-only scanning misses these cross-file data flows entirely. CodeQL always scans the full codebase; for Semgrep, ensure you run semgrep ci (which uses the full repository context) rather than semgrep scan --diff-filter=A,M alone.

CodeQL Custom Queries

When the built-in CodeQL query packs do not cover a vulnerability pattern specific to your stack, you can write a custom query in CodeQL Query Language (QL). This is an advanced capability used by security teams at GitHub, Microsoft, and large financial institutions to enforce organization-specific invariants.

// custom-queries/js/express-path-traversal.ql // Finds Express route handlers where req.params or req.query values // flow into fs.readFile / fs.readFileSync without path sanitization. /** * @name Path traversal from Express route parameter * @description Unsanitized route parameter used in file system access. * @kind path-problem * @problem.severity error * @id custom/express-path-traversal * @tags security * cwe-022 */ import javascript import DataFlow::PathGraph class ExpressRouteParam extends DataFlow::SourceNode { ExpressRouteParam() { // req.params.*, req.query.*, req.body.* exists(DataFlow::PropRead pr | pr.getBase().asExpr().(PropAccess).getPropertyName() = ["params","query","body"] and this = pr ) } } class FsReadSink extends DataFlow::SinkNode { FsReadSink() { exists(DataFlow::CallNode call | call.getCalleeName() = ["readFile","readFileSync","createReadStream"] and this = call.getArgument(0) ) } } from DataFlow::PathNode source, DataFlow::PathNode sink where DataFlow::localFlowPath(source.getNode(), sink.getNode()) and source.getNode() instanceof ExpressRouteParam and sink.getNode() instanceof FsReadSink select sink.getNode(), source, sink, "Path traversal: route parameter flows into file read."
Key idea: SAST is a shift-left discipline — its value compounds when it is integrated early in the development lifecycle, not bolted on at release. Every finding caught in a PR costs ten minutes to fix; the same finding caught post-deployment costs hours plus incident overhead. The goal is not zero findings in the scan output — it is zero unaddressed findings of severity ERROR or CRITICAL in code that ships to production.

Severity Classification and SLA

Not all findings are equal. Production-grade SAST programs define a Service Level Agreement by severity:

  • CRITICAL (RCE, auth bypass, mass data exposure) — block deploy, fix within 24 hours.
  • HIGH (SQL injection, path traversal, SSRF) — block PR merge, fix within 72 hours.
  • MEDIUM (XSS, insecure deserialization) — do not block merge, fix within 14 days, tracked in security backlog.
  • LOW / INFO — advisory only, fix at convenience or suppress with justification.

Encode these SLAs in your triage workflow: GitHub Issues labels, Jira security components, or SIEM tickets — whichever system your security team actually reviews. An SLA without a tracking mechanism is aspirational, not operational.