Skip to main content
Security·15 min read

Static Code Analysis in CI/CD — Catching SQL Injection Before Code Review Misses It

Complete guide to implementing Static Application Security Testing across Java, Python, JavaScript, Go, and C#. Includes tool configuration, CI/CD pipeline YAML, vulnerable code examples, and metrics reporting.

DT

DevOps Engineer & Technical Writer

Why SAST Matters in Your Pipeline

SAST in CI/CD Pipeline Source Code Git commit SAST Scanner Semgrep / SonarQube Analyze AST + Pattern Match Critical: 2 findings High: 5 findings Med/Low: 12 findings Quality Gate Policy Critical → Block merge High → Warn

Static Application Security Testing analyzes source code without executing it. You catch SQL injection, cross-site scripting, hardcoded credentials, and buffer overflows before the code ever runs. The earlier you catch these, the cheaper they are to fix — a vulnerability found in development costs 6x less than one found in production.

This guide covers five languages with real tool configurations you can drop into your pipeline today. No theory-only explanations here — every section includes the install command, the run command, the CI step, and an example of what the scanner actually catches.

---

Java — SonarQube with Maven and Gradle

Why SonarQube for Java

SonarQube has the deepest rule set for Java — over 600 rules covering security, reliability, and maintainability. Its quality gate mechanism lets you block merges when security issues exist.

Installation and Setup

For local development, run SonarQube in Docker:

docker run -d --name sonarqube \

-p 9000:9000 \

-v sonarqube_data:/opt/sonarqube/data \

-v sonarqube_logs:/opt/sonarqube/logs \

sonarqube:lts-community

Access the dashboard at http://localhost:9000 (default credentials: admin/admin).

Generate a project token from Administration then Security then Users then Tokens.

Maven Integration

Add the SonarQube plugin to your pom.xml:

<plugin>

<groupId>org.sonarsource.scanner.maven</groupId>

<artifactId>sonar-maven-plugin</artifactId>

<version>3.11.0.3922</version>

</plugin>

Run the analysis:

mvn clean verify sonar:sonar \

-Dsonar.projectKey=my-java-app \

-Dsonar.host.url=http://localhost:9000 \

-Dsonar.token=sqp_your_token_here

Gradle Integration

In build.gradle:

plugins {

id "org.sonarqube" version "5.0.0.4638"

}

sonar {

properties {

property "sonar.projectKey", "my-java-app"

property "sonar.host.url", "http://localhost:9000"

property "sonar.token", System.getenv("SONAR_TOKEN")

}

}

Run with:

./gradlew sonar

Quality Gate Configuration

In SonarQube, navigate to Quality Gates and create a custom gate:

  • New Security Hotspots: 0 (no new security issues allowed)
  • New Vulnerabilities: 0
  • New Code Coverage: greater than 80 percent
  • New Duplicated Lines: less than 3 percent

This means any PR introducing a security vulnerability will fail the quality gate.

GitHub Actions Step

- name: SonarQube Analysis

uses: sonarsource/sonarqube-scan-action@v2

env:

SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

with:

args: >

-Dsonar.projectKey=my-java-app

-Dsonar.java.binaries=target/classes

  • name: SonarQube Quality Gate
uses: sonarsource/sonarqube-quality-gate-action@v1

timeout-minutes: 5

env:

SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

Sample Vulnerable Code

public User findUser(String username) {

String query = "SELECT * FROM users WHERE name = '" + username + "'";

return jdbcTemplate.queryForObject(query, new UserRowMapper());

}

SonarQube flags this as a Blocker vulnerability with rule java:S3649: "Change this code to not construct SQL queries directly from user-controlled data." The fix uses parameterized queries:

public User findUser(String username) {

String query = "SELECT * FROM users WHERE name = ?";

return jdbcTemplate.queryForObject(query, new UserRowMapper(), username);

}

Suppressing False Positives

For inline suppression, annotate the method with the rule key. For bulk suppression in sonar-project.properties:

sonar.issue.ignore.multicriteria=e1

sonar.issue.ignore.multicriteria.e1.ruleKey=java:S3649

sonar.issue.ignore.multicriteria.e1.resourceKey=/test/

This tells SonarQube to ignore SQL injection findings in test directories where you might intentionally test with raw queries.

---

Python — Bandit and Semgrep

Why Bandit + Semgrep

Bandit is the standard Python security linter — it catches hardcoded passwords, SQL injection, subprocess shells, and insecure deserialization. Semgrep adds custom rules and cross-function taint analysis.

Bandit Installation and Configuration

pip install bandit

Create a .bandit configuration in pyproject.toml:

[tool.bandit]

exclude_dirs = ["tests", "venv", ".venv"]

skips = ["B101"]

targets = ["src"]

[tool.bandit.assert_used]

skips = ["_test.py", "test_.py"]

Run locally:

bandit -r src/ -f json -o bandit-report.json

For a quick severity-filtered scan:

bandit -r src/ -ll -ii

The -ll flag shows only medium severity and above. The -ii flag shows only medium confidence and above.

Semgrep Installation and Custom Rules

pip install semgrep

Run with the default security ruleset:

semgrep scan --config=p/python --config=p/security-audit src/

Create a custom rule in .semgrep/custom-rules.yml:

rules:

- id: flask-debug-enabled

patterns:

- pattern: app.run(..., debug=True, ...)

message: "Flask debug mode must not be enabled in production"

languages: [python]

severity: ERROR

metadata:

category: security

cwe: "CWE-489: Active Debug Code"

- id: unsafe-yaml-load

patterns:

- pattern: yaml.load($X)

- pattern-not: yaml.load($X, Loader=yaml.SafeLoader)

message: "Use yaml.safe_load() or specify Loader=yaml.SafeLoader"

languages: [python]

severity: WARNING

metadata:

category: security

cwe: "CWE-502: Deserialization of Untrusted Data"

Run custom rules:

semgrep scan --config=.semgrep/custom-rules.yml src/

GitHub Actions Step

- name: Run Bandit Security Scan

run: |

pip install bandit

bandit -r src/ -f sarif -o bandit-results.sarif -ll || true

  • name: Upload Bandit SARIF
uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: bandit-results.sarif

  • name: Run Semgrep
uses: returntocorp/semgrep-action@v1

with:

config: >-

p/python

p/security-audit

.semgrep/custom-rules.yml

Sample Vulnerable Code

import subprocess

def run_command(user_input):

result = subprocess.Popen(

f"grep {user_input} /var/log/app.log",

shell=True,

stdout=subprocess.PIPE

)

return result.stdout.read()

Bandit reports: B602:subprocess_popen_with_shell_equals_true — Consider possible security implications associated with Popen call. Severity: High. Confidence: High.

The fix avoids shell=True and uses a list:

import subprocess

import shlex

def run_command(user_input):

sanitized = shlex.quote(user_input)

result = subprocess.run(

["grep", sanitized, "/var/log/app.log"],

capture_output=True,

text=True

)

return result.stdout

Suppressing False Positives

Inline suppression for Bandit:

result = subprocess.run(cmd, shell=False)  # nosec B603

For Semgrep, use inline comments:

app.run(debug=True)  # nosemgrep: flask-debug-enabled

---

JavaScript/TypeScript — ESLint Security Plugins

Why ESLint for Security

ESLint is already in most JS/TS projects. Adding security-focused plugins catches DOM-based XSS, prototype pollution, regex denial-of-service, and unsafe eval usage without introducing a new tool into your workflow.

Installation

npm install --save-dev eslint eslint-plugin-security eslint-plugin-no-unsanitized @typescript-eslint/eslint-plugin @typescript-eslint/parser

Configuration

In .eslintrc.json:

{

"extends": [

"eslint:recommended",

"plugin:@typescript-eslint/recommended",

"plugin:security/recommended-legacy"

],

"plugins": ["security", "no-unsanitized"],

"rules": {

"security/detect-object-injection": "warn",

"security/detect-non-literal-regexp": "error",

"security/detect-unsafe-regex": "error",

"security/detect-buffer-noassert": "error",

"security/detect-eval-with-expression": "error",

"security/detect-no-csrf-before-method-override": "error",

"security/detect-possible-timing-attacks": "warn",

"no-unsanitized/method": "error",

"no-unsanitized/property": "error"

},

"overrides": [

{

"files": [".ts", ".tsx"],

"parser": "@typescript-eslint/parser",

"rules": {

"@typescript-eslint/no-explicit-any": "warn"

}

}

]

}

Run locally:

npx eslint src/ --ext .js,.ts,.tsx --format json --output-file eslint-security.json

Node.js Specific Patterns

For server-side Node.js applications, enforce additional rules:

{

"rules": {

"no-eval": "error",

"no-implied-eval": "error",

"no-new-func": "error",

"security/detect-child-process": "warn",

"security/detect-non-literal-fs-filename": "warn"

}

}

These catch patterns like eval(userInput), setTimeout(userString, 0), new Function(userCode), and fs.readFile(userPath).

GitHub Actions Step

- name: Install Dependencies

run: npm ci

  • name: ESLint Security Scan
run: |

npx eslint src/ --ext .js,.ts,.tsx \

--format @microsoft/eslint-formatter-sarif \

--output-file eslint-results.sarif

continue-on-error: true

  • name: Upload ESLint SARIF
uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: eslint-results.sarif

Sample Vulnerable Code

// Prototype Pollution

function merge(target, source) {

for (let key in source) {

target[key] = source[key];

}

return target;

}

// ReDoS vulnerability

const emailRegex = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z]{2,4})+$/;

// DOM XSS

element.innerHTML = userInput;

ESLint reports:

  • security/detect-object-injection — Generic Object Injection Sink
  • security/detect-unsafe-regex — Unsafe regular expression
  • no-unsanitized/property — Unsafe assignment to innerHTML

Suppressing False Positives

// eslint-disable-next-line security/detect-object-injection

target[knownSafeKey] = value;

Or disable for an entire file when the risk is accepted:

/ eslint-disable security/detect-object-injection /

Always add a comment explaining why suppression is justified.

---

Go — gosec, go vet, and staticcheck

Why gosec for Go

Go's type system prevents many vulnerability classes, but SQL injection, hardcoded credentials, weak crypto, and path traversal still happen. gosec catches these with deep understanding of Go patterns.

Installation

go install github.com/securego/gosec/v2/cmd/gosec@latest

go install honnef.co/go/tools/cmd/staticcheck@latest

Running Locally

gosec -fmt=json -out=gosec-results.json ./...

go vet ./...

staticcheck ./...

gosec Configuration

Create a configuration to focus on specific rules:

gosec -include=G101,G201,G301,G401,G501 -fmt=sarif -out=gosec.sarif ./...

Key rules:

  • G101: Hardcoded credentials
  • G201: SQL string concatenation
  • G301: Poor file permissions on creation
  • G401: Use of weak crypto (MD5, SHA1 for security purposes)
  • G501: Insecure TLS configuration

GitHub Actions Step

- name: Run gosec Security Scanner

uses: securego/gosec@master

with:

args: '-fmt sarif -out gosec-results.sarif ./...'

  • name: Upload gosec SARIF
uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: gosec-results.sarif

  • name: Run staticcheck
uses: dominikh/staticcheck-action@v1

with:

version: "latest"

Sample Vulnerable Code

package main

import (

"crypto/md5"

"database/sql"

"fmt"

"net/http"

)

func getUser(db sql.DB, r http.Request) (*sql.Row, error) {

username := r.URL.Query().Get("user")

query := fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", username)

return db.QueryRow(query), nil

}

const dbPassword = "supersecret123"

func hashData(data []byte) []byte {

h := md5.Sum(data)

return h[:]

}

gosec reports:

  • G201 (CWE-89): SQL string formatting — Severity: MEDIUM, Confidence: HIGH
  • G101 (CWE-798): Potential hardcoded credentials — Severity: HIGH, Confidence: LOW
  • G401 (CWE-326): Use of weak cryptographic primitive — Severity: MEDIUM, Confidence: HIGH

The fixed version uses parameterized queries, environment variables for secrets, and SHA-256 for hashing:

func getUser(db sql.DB, r http.Request) (*sql.Row, error) {

username := r.URL.Query().Get("user")

return db.QueryRow("SELECT * FROM users WHERE name = $1", username), nil

}

Suppressing False Positives

Use the nosec annotation:

const testToken = "fake-token-for-tests" // #nosec G101 -- test fixture, not real credential

---

C# and .NET — Roslyn Analyzers and SecurityCodeScan

Why Roslyn Analyzers

.NET has first-class security analysis through Roslyn analyzers that run during compilation. You get immediate feedback in your IDE and your build pipeline without separate tooling.

Installation

Add security analyzers to your .csproj:

<ItemGroup>

<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="8.0.0" />

<PackageReference Include="SecurityCodeScan.VS2019" Version="5.6.7" />

<PackageReference Include="Roslynator.Analyzers" Version="4.12.0" />

</ItemGroup>

Or install via CLI:

dotnet add package SecurityCodeScan.VS2019

dotnet add package Microsoft.CodeAnalysis.NetAnalyzers

Configuration

Create a .editorconfig for rule severity:

[*.cs]

dotnet_diagnostic.SCS0002.severity = error

dotnet_diagnostic.SCS0029.severity = error

dotnet_diagnostic.SCS0001.severity = error

dotnet_diagnostic.SCS0010.severity = warning

dotnet_diagnostic.SCS0015.severity = error

dotnet_diagnostic.SCS0016.severity = warning

These rule IDs correspond to:

  • SCS0002: SQL Injection
  • SCS0029: Cross-Site Scripting (XSS)
  • SCS0001: Command Injection
  • SCS0010: Weak Cipher Algorithm
  • SCS0015: Hardcoded Password
  • SCS0016: Cross-Site Request Forgery (CSRF)

Running Locally

dotnet build /p:RunAnalyzersDuringBuild=true

dotnet build /p:ErrorLog=security-results.sarif

GitHub Actions Step

- name: Setup .NET

uses: actions/setup-dotnet@v4

with:

dotnet-version: '8.0.x'

  • name: Restore and Build with Security Analysis
run: |

dotnet restore

dotnet build --no-restore \

/p:RunAnalyzersDuringBuild=true \

/p:ErrorLog=security-results.sarif \

/warnaserror:SCS0001,SCS0002,SCS0029

  • name: Upload SARIF
uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: security-results.sarif

Sample Vulnerable Code

public async Task<User> GetUser(string username)

{

var query = $"SELECT * FROM Users WHERE Name = '{username}'";

return await _context.Users.FromSqlRaw(query).FirstOrDefaultAsync();

}

public string RunDiagnostics(string host)

{

var process = Process.Start("cmd.exe", $"/c ping {host}");

return process.StandardOutput.ReadToEnd();

}

SecurityCodeScan reports:

  • SCS0002: SQL Injection vulnerability — using string interpolation in SQL
  • SCS0001: Command Injection vulnerability — unsanitized input in process execution

Suppressing False Positives

[System.Diagnostics.CodeAnalysis.SuppressMessage(

"Security", "SCS0002:SQL Injection",

Justification = "Input is from internal service, validated upstream")]

public async Task<Report> GetReport(string internalId) { }

---

Cross-Language Pipeline: Putting It All Together

Here is a complete GitHub Actions workflow that runs SAST across a polyglot repository:

name: SAST Security Scan

on:

pull_request:

branches: [main]

push:

branches: [main]

jobs:

sast-java:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-java@v4

with:

distribution: 'temurin'

java-version: '21'

- name: SonarQube Scan

uses: sonarsource/sonarqube-scan-action@v2

env:

SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}

sast-python:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-python@v5

with:

python-version: '3.12'

- name: Bandit Scan

run: |

pip install bandit

bandit -r src/ -f sarif -o bandit.sarif -ll || true

- name: Upload Bandit SARIF

uses: github/codeql-action/upload-sarif@v3

with:

sarif_file: bandit.sarif

sast-javascript:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

with:

node-version: '20'

- name: ESLint Security

run: |

npm ci

npx eslint src/ --ext .js,.ts,.tsx --format json --output-file eslint.json

sast-go:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-go@v5

with:

go-version: '1.22'

- name: gosec Scan

uses: securego/gosec@master

with:

args: '-fmt sarif -out gosec.sarif ./...'

sast-dotnet:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-dotnet@v4

with:

dotnet-version: '8.0.x'

- name: Security Analyzers

run: |

dotnet restore

dotnet build /p:RunAnalyzersDuringBuild=true /warnaserror:SCS0001,SCS0002

---

What Your Manager Wants to See

Security scanning only has organizational value if you can report on it. Here are the metrics that demonstrate your pipeline is working.

Key Metrics to Track

Findings Trend — Total open vulnerabilities over time. Target: decreasing month-over-month.

Fix Rate — Percentage of findings resolved within SLA. Target: greater than 90 percent for CRITICAL, greater than 75 percent for HIGH.

Mean Time to Remediate (MTTR) — Average days from detection to fix. Target: less than 3 days for CRITICAL, less than 14 days for HIGH.

False Positive Rate — Percentage of findings marked as false positive. Target: less than 20 percent. If higher, tune your rules.

New vs. Inherited — New findings introduced in PRs vs. existing technical debt. New findings should trend toward zero.

Coverage — Percentage of repositories with SAST enabled. Target: 100 percent for production services.

Building the Dashboard

Pull data from SonarQube's API:

curl -u "$SONAR_TOKEN:" \

"$SONAR_HOST/api/measures/component?component=my-app&metricKeys=vulnerabilities,security_hotspots,security_rating"

For non-SonarQube tools, parse SARIF output and push counts to your metrics system:

jq '[.runs[].results[] | .level] | group_by(.) | map({level: .[0], count: length})' results.sarif

Monthly Report Template

Present this to leadership monthly:

  • Security Score — A through E rating from SonarQube
  • Open Critical and High — Number and age of outstanding issues
  • Resolved This Month — Count with trend direction
  • Pipeline Block Rate — How many PRs were blocked by security gates
  • Top 3 Recurring Patterns — What developers keep writing wrong, which identifies training opportunities
  • The goal is not zero findings forever — it is a consistent downward trend with fast remediation when issues appear.

    ---

    Frequently Asked Questions

    What is SAST and how does it work?

    SAST (Static Application Security Testing) analyzes source code, bytecode, or binaries for security vulnerabilities without executing the application. It examines code paths, data flows, and patterns to identify issues like SQL injection, XSS, hardcoded secrets, and insecure configurations. SAST runs early in development, enabling developers to fix vulnerabilities before deployment.

    What are the best SAST tools for DevOps pipelines?

    Popular open-source options include Semgrep (multi-language, fast, customizable rules), Bandit (Python), SpotBugs with FindSecBugs (Java), and ESLint security plugins (JavaScript). Commercial options include SonarQube, Checkmarx, and Snyk Code. Choose based on language support, CI/CD integration, and false positive rates. Semgrep offers the best balance of speed and accuracy for most teams.

    How do I reduce SAST false positives?

    Tune the ruleset to your specific frameworks (e.g., disable SQL injection rules if you use an ORM exclusively), suppress verified false positives with inline annotations, and use severity filters to focus on high-confidence findings. Establish a triage workflow where security champions review and categorize new findings weekly. Custom rules tailored to your codebase produce the best signal-to-noise ratio.

    When should I run SAST in the development lifecycle?

    Run lightweight SAST checks (Semgrep, ESLint security rules) on every commit in CI — they complete in seconds. Run comprehensive SAST scans nightly or on pull requests to main branches. Provide IDE plugins so developers catch issues before committing. The key principle is fast feedback: scanning should not add more than 2-3 minutes to your pipeline.

    What vulnerabilities can SAST detect that DAST cannot?

    SAST excels at finding injection flaws (SQL, XSS, command injection), hardcoded credentials, insecure cryptographic implementations, race conditions, buffer overflows, and insecure deserialization patterns. It can trace data flow from user input to dangerous sinks. DAST cannot see code paths that aren't exercised during testing or detect vulnerabilities that require specific input to trigger.

    ---