DevOps made deployment faster and more frequent—but that speed can be dangerous if you’re not paying attention to security. For SQL Server teams, that means catching vulnerabilities before they hit production. That’s where SAST and DAST come in. They’re not complicated concepts—one checks your code before it runs, the other pokes at your running system to find weaknesses.

SAST vs DAST: How They Work Together

SAST (Static Analysis) is like a code review from a security bot. It reads your code, your infrastructure templates, and your configurations before anything runs. It catches hardcoded passwords, SQL injection vulnerabilities, and missing input validation.

DAST (Dynamic Analysis) is the hacker simulator. It takes your running application and tries to break it—sending malicious input, trying to access restricted data, looking for configuration mistakes. It finds vulnerabilities in actual behavior, not just patterns.

The Tools You Actually Need

Scanning SQL Code for Injection Vulnerabilities

If you’re writing T-SQL, you want tools that understand SQL patterns. Here are the practical options:

  • Redgate SQL Code Analysis — Commercial, understands T-SQL anti-patterns including dynamic SQL and injection risks
  • tSQLt with custom checks — If you’re already using tSQLt for unit tests, you can add static checks
  • Microsoft SQL ScriptDom — Free, open-source. Build your own analyzer if you have specific rules

Checking Your Infrastructure Code (Bicep, Terraform, ARM Templates)

This is where most teams start because misconfigured databases are low-hanging fruit:

  • Checkov — Scans Terraform, Bicep, and ARM templates. Free, catches exposed SQL servers and unencrypted backups
  • PSRule for Azure — Microsoft’s tool, purpose-built for Azure. Good integration if you’re all-in on Azure
  • TFLint — Focused on Terraform but really good at catching configuration mistakes

Scanning Your Application Code

Your web app or API is the gateway to SQL Server:

  • SonarQube / SonarCloud — Works with most languages, has SQL plugin support
  • GitHub Advanced Security (CodeQL) — Free if you’re already using GitHub. Catches SQL injection patterns in application code
  • Checkmarx — Enterprise tool if you need it, but overkill for most teams

Testing Your Running Application (DAST)

  • OWASP ZAP — Free, open-source. Great for testing web apps that connect to SQL. You can run it in your pipeline
  • Burp Suite Community — Also free. More powerful if you need it, but steeper learning curve
  • Microsoft Defender for DevOps — If you’re on Azure, this is native integration

Both OWASP ZAP and Burp can test for SQL injection, insecure API endpoints, and other web vulnerabilities that lead to database breaches.

Real Examples

The Bad SQL Code

Here’s a stored procedure that’s vulnerable to SQL injection:

-- DON'T DO THIS
CREATE PROCEDURE sp_GetUserByUsername @username NVARCHAR(100)
AS
BEGIN
    DECLARE @sql NVARCHAR(MAX)
    SET @sql = 'SELECT UserId, Email FROM Users WHERE Username = ''' + @username + ''''
    EXEC sp_executesql @sql
END

If someone passes admin' OR '1'='1 as the username, the query becomes:

SELECT UserId, Email FROM Users WHERE Username = 'admin' OR '1'='1'

And now they get all users. Oops.

How to fix it:

-- DO THIS INSTEAD
CREATE PROCEDURE sp_GetUserByUsername @username NVARCHAR(100)
AS
BEGIN
    SELECT UserId, Email FROM Users WHERE Username = @username
END

Parameterized queries protect you. SAST tools will flag the first version immediately.

The Bad Bicep Configuration

param adminPassword string = 'DefaultP@ss123'

resource sqlServer 'Microsoft.Sql/servers@2022-02-01-preview' = {
  name: 'contoso-sql-prod'
  location: 'eastus'
  properties: {
    administratorLogin: 'sqladmin'
    administratorLoginPassword: adminPassword
    publicNetworkAccess: 'Enabled'
  }
}

resource firewallRule 'Microsoft.Sql/servers/firewallRules@2022-02-01-preview' = {
  parent: sqlServer
  name: 'AllowAllAzureIps'
  properties: {
    startIpAddress: '0.0.0.0'
    endIpAddress: '255.255.255.255'
  }
}

Problems Checkov will find:

  • ✗ Hardcoded password
  • ✗ SQL Server exposed to the internet (publicNetworkAccess: 'Enabled')
  • ✗ Firewall rule allows all IPs
  • ✗ Missing encryption settings

Fixed version:

param keyVaultId string
param adminUsername string = 'sqladmin'

resource sqlServer 'Microsoft.Sql/servers@2022-02-01-preview' = {
  name: 'contoso-sql-prod'
  location: 'eastus'
  properties: {
    administratorLogin: adminUsername
    administratorLoginPassword: listSecrets(keyVaultId, '2021-06-01').value
    publicNetworkAccess: 'Disabled'
    minimalTlsVersion: '1.2'
  }
}

resource firewallRule 'Microsoft.Sql/servers/firewallRules@2022-02-01-preview' = {
  parent: sqlServer
  name: 'AllowAppSubnet'
  properties: {
    startIpAddress: '10.0.1.0'
    endIpAddress: '10.0.1.255'
  }
}

Run Checkov against it:

$ checkov -f main.bicep --framework bicep

Check: CKV_AZURE_11: "Ensure that SQL server enables encrypted connections"
  PASSED
Check: CKV_AZURE_28: "Ensure that sql servers do not allow ingress 0.0.0.0/0 (ALL ip)"
  PASSED
Check: CKV_AZURE_68: "Ensure that sql servers do not have public network access enabled"
  PASSED

The Bad Web App (Node.js)

const express = require('express');
const mssql = require('mssql');
const app = express();

const config = {
  user: 'sa',
  password: 'YourPassword123',
  server: 'localhost',
  database: 'userdb',
};

app.get('/api/users/:id', async (req, res) => {
  try {
    const userId = req.params.id;
    
    // VULNERABLE: user input concatenated directly into query
    const pool = new mssql.ConnectionPool(config);
    const result = await pool
      .request()
      .query(`SELECT * FROM Users WHERE UserId = '${userId}'`);
    
    res.json(result.recordset);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

An attacker calls /api/users/1' OR '1'='1 and gets all users.

Fixed version:

app.get('/api/users/:id', async (req, res) => {
  try {
    const userId = req.params.id;
    
    // SAFE: parameterized query
    const pool = new mssql.ConnectionPool(config);
    const result = await pool
      .request()
      .input('userId', mssql.Int, userId)
      .query('SELECT * FROM Users WHERE UserId = @userId');
    
    res.json(result.recordset);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

CodeQL (via GitHub Advanced Security) will catch the first version automatically.

Building Your Pipeline (The Practical Part)

Here’s an Azure DevOps pipeline that actually works:

trigger:
  branches:
    include:
    - main
    - develop

pool:
  vmImage: 'ubuntu-latest'

variables:
  buildConfiguration: 'Release'

jobs:
- job: SAST_IaC
  displayName: 'Scan Infrastructure Code'
  steps:
  - checkout: self
  
  - task: Bash@3
    displayName: 'Install Checkov'
    inputs:
      targetType: 'inline'
      script: |
        pip install checkov        
  
  - task: Bash@3
    displayName: 'Run Checkov on Bicep'
    inputs:
      targetType: 'inline'
      script: |
        checkov -f ./infra/main.bicep \
          --framework bicep \
          --check CKV_AZURE_28,CKV_AZURE_68,CKV_AZURE_11 \
          --output cli        
    continueOnError: false

- job: SAST_SQLCode
  displayName: 'Scan SQL Scripts'
  steps:
  - checkout: self
  
  - task: PowerShell@2
    displayName: 'Analyze T-SQL for Dynamic SQL'
    inputs:
      targetType: 'inline'
      script: |
        $sqlFiles = Get-ChildItem -Path './database' -Filter '*.sql' -Recurse
        $issues = @()
        
        foreach ($file in $sqlFiles) {
          $content = Get-Content $file.FullName -Raw
          
          if ($content -match "EXEC\s*\(" -and $content -match "\+|''" ) {
            $issues += "Dynamic SQL with string concatenation: $($file.Name)"
          }
          if ($content -match "sp_executesql.*\+" ) {
            $issues += "Concatenation in sp_executesql: $($file.Name)"
          }
        }
        
        if ($issues.Count -gt 0) {
          Write-Host "##vso[task.logissue type=error]Found security issues in SQL code:"
          $issues | ForEach-Object { Write-Host "  - $_" }
          exit 1
        }
        Write-Host "No dynamic SQL issues found ✓"        

- job: BuildAndTest
  displayName: 'Build Application'
  dependsOn: [SAST_IaC, SAST_SQLCode]
  condition: succeeded()
  steps:
  - checkout: self
  
  - task: UseDotNet@2
    displayName: 'Use .NET 8.0'
    inputs:
      packageType: sdk
      version: '8.0.x'
  
  - task: DotNetCoreCLI@2
    displayName: 'Restore NuGet packages'
    inputs:
      command: 'restore'
      projects: '**/*.csproj'
  
  - task: DotNetCoreCLI@2
    displayName: 'Build'
    inputs:
      command: 'build'
      arguments: '--configuration $(buildConfiguration)'

- job: DeployToStaging
  displayName: 'Deploy to Staging'
  dependsOn: BuildAndTest
  condition: succeeded()
  steps:
  - task: AzureResourceGroupDeployment@2
    displayName: 'Deploy Bicep Template'
    inputs:
      azureSubscription: 'MyAzureConnection'
      action: 'Create Update'
      resourceGroupName: 'rg-staging'
      location: 'eastus'
      templateLocation: 'Linked artifact'
      csmFile: './infra/main.bicep'
      overrideParameters: '-environment staging'

- job: DAST_SecurityTest
  displayName: 'Run Security Tests on Staging'
  dependsOn: DeployToStaging
  condition: succeeded()
  steps:
  - checkout: self
  
  - task: Bash@3
    displayName: 'Install OWASP ZAP'
    inputs:
      targetType: 'inline'
      script: |
        docker pull owasp/zap2docker-stable        
  
  - task: Bash@3
    displayName: 'Run ZAP Baseline Scan'
    inputs:
      targetType: 'inline'
      script: |
        docker run -v $(Build.ArtifactStagingDirectory):/zap/wrk:rw \
          --rm owasp/zap2docker-stable zap-baseline.py \
          -t http://staging-app.azurewebsites.net \
          -r zap_report.html        
    continueOnError: true
  
  - task: PublishBuildArtifacts@1
    displayName: 'Publish ZAP Report'
    inputs:
      PathtoPublish: '$(Build.ArtifactStagingDirectory)/zap_report.html'
      ArtifactName: 'security-reports'

This pipeline:

  1. Scans Bicep and SQL code before anything gets deployed
  2. Builds and tests the application
  3. Deploys to staging only if SAST checks pass
  4. Runs OWASP ZAP against the staging environment
  5. Publishes the security report as an artifact

How to Start (Realistically)

You don’t need to implement everything at once. Here’s a reasonable roadmap:

Week 1: Add basic SQL code scanning to your pipeline (the PowerShell script above)

Week 2: Add Checkov for your infrastructure code. Takes 10 minutes to set up.

Week 3: Integrate into your existing CI/CD. Make failed checks block deployments.

Week 4: Set up OWASP ZAP against your staging environment. You can run it manually first, then automate it.

Month 2: Start requiring teams to fix findings before merging PRs.

Month 3: Review what you’ve caught and iterate on your rules.