Security

SAST and DAST Integration in CI/CD

Complete guide to integrating Static Application Security Testing (SAST) and Dynamic Application Security Testing (DAST) into CI/CD pipelines, covering ESLint security rules, SonarQube analysis, OWASP ZAP scanning, and building automated security feedback loops.

SAST and DAST Integration in CI/CD

Overview

Static Application Security Testing (SAST) analyzes your source code for vulnerabilities without running it. Dynamic Application Security Testing (DAST) attacks your running application to find vulnerabilities you cannot see in code alone. Together, they cover both sides of the security equation — what your code says and what your application does. I have integrated both into dozens of CI/CD pipelines, and the teams that run both consistently catch more vulnerabilities earlier than teams running either one alone.

Prerequisites

  • Azure DevOps project with Azure Pipelines configured
  • Node.js 16 or later for SAST tooling
  • Docker installed on build agents (for DAST scanners)
  • A deployable application with accessible endpoints (for DAST)
  • SonarQube server or SonarCloud account (for SonarQube integration)
  • Basic understanding of OWASP Top 10 vulnerability categories
  • Azure Pipelines agent with at least 4GB RAM for scanning tools

Understanding SAST vs DAST

SAST and DAST find different classes of vulnerabilities. Neither is complete on its own.

Aspect SAST DAST
When Build time After deployment
What Source code, bytecode Running application
Finds SQL injection patterns, XSS sinks, hardcoded secrets, insecure crypto Authentication flaws, CSRF, header misconfigurations, runtime injection
Misses Runtime configuration, authentication logic Code-level issues not triggered by test inputs
False positives Higher (no runtime context) Lower (confirms exploitability)
Speed Fast (minutes) Slower (minutes to hours)

SAST: ESLint Security Rules

The fastest way to add SAST to a Node.js pipeline is ESLint with security-focused plugins. These catch common vulnerability patterns in JavaScript code.

Setting Up eslint-plugin-security

npm install --save-dev eslint eslint-plugin-security eslint-plugin-no-unsanitized
// .eslintrc.js - Security-focused ESLint configuration
module.exports = {
  plugins: ["security", "no-unsanitized"],
  extends: [
    "plugin:security/recommended",
    "plugin:no-unsanitized/DOM"
  ],
  rules: {
    // Detect potential command injection
    "security/detect-child-process": "error",

    // Detect non-literal require calls (potential path traversal)
    "security/detect-non-literal-require": "error",

    // Detect non-literal fs operations
    "security/detect-non-literal-fs-filename": "warn",

    // Detect eval and similar dangerous functions
    "security/detect-eval-with-expression": "error",

    // Detect potential RegExp DoS
    "security/detect-unsafe-regex": "error",

    // Detect SQL injection patterns
    "security/detect-sql-injection": "warn",

    // Detect non-literal RegExp
    "security/detect-non-literal-regexp": "warn",

    // Detect object injection (prototype pollution)
    "security/detect-object-injection": "warn",

    // Detect potential timing attacks in comparisons
    "security/detect-possible-timing-attacks": "warn",

    // Detect innerHTML and similar DOM XSS sinks
    "no-unsanitized/method": "error",
    "no-unsanitized/property": "error"
  },
  overrides: [
    {
      files: ["test/**/*.js", "**/*.test.js"],
      rules: {
        "security/detect-non-literal-fs-filename": "off",
        "security/detect-object-injection": "off"
      }
    }
  ]
};

Pipeline Integration

# SAST with ESLint in Azure Pipelines
steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'

  - script: npm ci
    displayName: 'Install dependencies'

  - script: |
      npx eslint . \
        --format json \
        --output-file eslint-security-report.json \
        --no-eslintrc \
        --config .eslintrc.security.js \
        --ext .js \
        --ignore-pattern "node_modules/" \
        --ignore-pattern "dist/" \
        || true
    displayName: 'Run SAST with ESLint'

  - script: |
      node -e "
      var fs = require('fs');
      var results = JSON.parse(fs.readFileSync('eslint-security-report.json', 'utf8'));

      var errors = 0;
      var warnings = 0;
      var findings = [];

      results.forEach(function(file) {
        file.messages.forEach(function(msg) {
          if (msg.ruleId && msg.ruleId.indexOf('security') !== -1) {
            if (msg.severity === 2) {
              errors++;
              findings.push({
                rule: msg.ruleId,
                file: file.filePath.replace(process.cwd() + '/', ''),
                line: msg.line,
                message: msg.message
              });
            } else {
              warnings++;
            }
          }
        });
      });

      console.log('SAST Results (ESLint Security):');
      console.log('  Security Errors: ' + errors);
      console.log('  Security Warnings: ' + warnings);

      if (findings.length > 0) {
        console.log('\\nSecurity Findings:');
        findings.forEach(function(f) {
          console.log('  [' + f.rule + '] ' + f.file + ':' + f.line);
          console.log('    ' + f.message);
        });
      }

      if (errors > 0) {
        console.log('##vso[task.complete result=Failed;]SAST found ' + errors + ' security errors');
      }
      "
    displayName: 'Evaluate SAST results'

  - task: PublishBuildArtifacts@1
    inputs:
      pathToPublish: 'eslint-security-report.json'
      artifactName: 'sast-report'
    condition: always()

SAST: SonarQube Integration

SonarQube provides deeper static analysis with taint tracking, cross-file data flow analysis, and a quality gate system.

Setting Up SonarQube for Azure DevOps

# sonar-project.properties
sonar.projectKey=my-project
sonar.projectName=My Application
sonar.sources=src
sonar.tests=test
sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.exclusions=node_modules/**,dist/**,coverage/**
sonar.coverage.exclusions=test/**,**/*.test.js

# Security-specific settings
sonar.security.hotspots.reviewed=true
sonar.qualitygate.wait=true

Azure Pipelines Integration

# SonarQube analysis in Azure Pipelines
steps:
  - task: SonarQubePrepare@5
    inputs:
      SonarQube: 'SonarQube-Connection'  # Service connection name
      scannerMode: 'CLI'
      configMode: 'manual'
      cliProjectKey: 'my-project'
      cliProjectName: 'My Application'
      cliSources: 'src'
      extraProperties: |
        sonar.javascript.lcov.reportPaths=coverage/lcov.info
        sonar.exclusions=node_modules/**,dist/**

  - script: npm ci && npm test -- --coverage
    displayName: 'Install and run tests with coverage'

  - task: SonarQubeAnalyze@5
    displayName: 'Run SonarQube analysis'

  - task: SonarQubePublish@5
    inputs:
      pollingTimeoutSec: '300'

  - script: |
      node -e "
      var https = require('https');

      var sonarUrl = process.env.SONAR_HOST;
      var sonarToken = process.env.SONAR_TOKEN;
      var projectKey = 'my-project';

      // Fetch security hotspots
      var options = {
        hostname: sonarUrl.replace('https://', ''),
        path: '/api/hotspots/search?projectKey=' + projectKey + '&status=TO_REVIEW',
        headers: {
          'Authorization': 'Basic ' + Buffer.from(sonarToken + ':').toString('base64')
        }
      };

      https.get(options, function(res) {
        var body = '';
        res.on('data', function(chunk) { body += chunk; });
        res.on('end', function() {
          var data = JSON.parse(body);
          var hotspots = data.hotspots || [];

          console.log('SonarQube Security Hotspots: ' + hotspots.length);
          hotspots.forEach(function(h) {
            console.log('  [' + h.vulnerabilityProbability + '] ' + h.message);
            console.log('    File: ' + h.component + ':' + h.line);
            console.log('    Category: ' + h.securityCategory);
          });

          var highHotspots = hotspots.filter(function(h) {
            return h.vulnerabilityProbability === 'HIGH';
          });

          if (highHotspots.length > 0) {
            console.log('##vso[task.logissue type=warning]' + highHotspots.length + ' high-probability security hotspots need review');
          }
        });
      });
      "
    displayName: 'Check security hotspots'
    env:
      SONAR_HOST: $(SONAR_HOST_URL)
      SONAR_TOKEN: $(SONAR_TOKEN)

Custom Security Quality Gate

var https = require("https");

// Check SonarQube quality gate status with security focus
function checkQualityGate(sonarHost, sonarToken, projectKey) {
  var auth = Buffer.from(sonarToken + ":").toString("base64");

  // Get quality gate status
  var options = {
    hostname: sonarHost.replace("https://", ""),
    path: "/api/qualitygates/project_status?projectKey=" + projectKey,
    headers: { "Authorization": "Basic " + auth }
  };

  return new Promise(function(resolve, reject) {
    https.get(options, function(res) {
      var body = "";
      res.on("data", function(chunk) { body += chunk; });
      res.on("end", function() {
        var data = JSON.parse(body);
        var status = data.projectStatus;

        console.log("Quality Gate: " + status.status);

        (status.conditions || []).forEach(function(condition) {
          var icon = condition.status === "OK" ? "[OK]" : "[XX]";
          console.log("  " + icon + " " + condition.metricKey + ": "
            + condition.actualValue + " (threshold: " + condition.errorThreshold + ")");
        });

        // Get security-specific metrics
        var metricsPath = "/api/measures/component?component=" + projectKey
          + "&metricKeys=security_rating,security_hotspots_reviewed,vulnerabilities,security_remediation_effort";

        var metricsOptions = {
          hostname: sonarHost.replace("https://", ""),
          path: metricsPath,
          headers: { "Authorization": "Basic " + auth }
        };

        https.get(metricsOptions, function(metricsRes) {
          var metricsBody = "";
          metricsRes.on("data", function(chunk) { metricsBody += chunk; });
          metricsRes.on("end", function() {
            var metrics = JSON.parse(metricsBody);
            var measures = metrics.component.measures || [];

            console.log("\nSecurity Metrics:");
            measures.forEach(function(m) {
              console.log("  " + m.metric + ": " + m.value);
            });

            resolve({
              passed: status.status === "OK",
              conditions: status.conditions,
              securityMetrics: measures
            });
          });
        });
      });
    });
  });
}

checkQualityGate(
  process.env.SONAR_HOST,
  process.env.SONAR_TOKEN,
  "my-project"
).then(function(result) {
  if (!result.passed) {
    console.log("##vso[task.complete result=Failed;]SonarQube quality gate failed");
    process.exit(1);
  }
});

SAST: Custom Security Rules

Sometimes off-the-shelf rules are not enough. Custom rules catch organization-specific patterns.

// custom-security-rules/no-raw-sql.js
// ESLint rule to prevent raw SQL string concatenation

module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "Disallow raw SQL string concatenation (use parameterized queries)",
      category: "Security",
      recommended: true
    },
    messages: {
      noRawSql: "Potential SQL injection: use parameterized queries instead of string concatenation in SQL statements"
    }
  },
  create: function(context) {
    var sqlKeywords = ["SELECT", "INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "EXEC"];

    function containsSqlKeyword(str) {
      var upper = str.toUpperCase();
      return sqlKeywords.some(function(keyword) {
        return upper.indexOf(keyword) !== -1;
      });
    }

    return {
      BinaryExpression: function(node) {
        if (node.operator !== "+") return;

        // Check if either side is a SQL-containing string literal
        var left = node.left;
        var right = node.right;

        var hasSqlString = false;
        var hasVariable = false;

        if (left.type === "Literal" && typeof left.value === "string") {
          if (containsSqlKeyword(left.value)) hasSqlString = true;
        }
        if (right.type === "Literal" && typeof right.value === "string") {
          if (containsSqlKeyword(right.value)) hasSqlString = true;
        }

        if (left.type === "Identifier" || left.type === "MemberExpression") hasVariable = true;
        if (right.type === "Identifier" || right.type === "MemberExpression") hasVariable = true;

        if (hasSqlString && hasVariable) {
          context.report({ node: node, messageId: "noRawSql" });
        }
      },

      TemplateLiteral: function(node) {
        if (node.expressions.length === 0) return;

        var fullText = node.quasis.map(function(q) { return q.value.raw; }).join("");
        if (containsSqlKeyword(fullText)) {
          context.report({ node: node, messageId: "noRawSql" });
        }
      }
    };
  }
};
// custom-security-rules/no-unvalidated-redirect.js
// Detect open redirect vulnerabilities

module.exports = {
  meta: {
    type: "problem",
    docs: {
      description: "Detect potential open redirect vulnerabilities",
      category: "Security"
    },
    messages: {
      openRedirect: "Potential open redirect: validate the redirect URL against an allowlist before redirecting"
    }
  },
  create: function(context) {
    return {
      CallExpression: function(node) {
        // Match res.redirect(req.query.xxx) or res.redirect(req.body.xxx)
        if (node.callee.type !== "MemberExpression") return;
        if (node.callee.property.name !== "redirect") return;

        var arg = node.arguments[0] || node.arguments[1]; // redirect can take status code as first arg
        if (!arg) return;

        function isUserInput(expr) {
          if (expr.type === "MemberExpression") {
            var source = context.getSourceCode().getText(expr);
            return source.indexOf("req.query") !== -1
              || source.indexOf("req.body") !== -1
              || source.indexOf("req.params") !== -1;
          }
          return false;
        }

        if (isUserInput(arg)) {
          context.report({ node: node, messageId: "openRedirect" });
        }
      }
    };
  }
};

DAST: OWASP ZAP Integration

OWASP ZAP (Zed Attack Proxy) is the most widely used open-source DAST tool. It crawls your running application and tests for vulnerabilities.

Baseline Scan (Passive)

The baseline scan passively proxies requests and reports on security headers, cookie flags, and information disclosure — without actively attacking the application.

# DAST baseline scan with OWASP ZAP
stages:
  - stage: DAST
    displayName: 'Dynamic Security Testing'
    dependsOn: DeployStaging
    jobs:
      - job: ZAPBaseline
        displayName: 'OWASP ZAP Baseline Scan'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: |
              docker pull ghcr.io/zaproxy/zaproxy:stable
            displayName: 'Pull ZAP image'

          - script: |
              mkdir -p $(Build.ArtifactStagingDirectory)/zap-reports
              docker run --rm \
                -v $(Build.ArtifactStagingDirectory)/zap-reports:/zap/wrk:rw \
                ghcr.io/zaproxy/zaproxy:stable \
                zap-baseline.py \
                -t https://staging.myapp.com \
                -J zap-baseline-report.json \
                -r zap-baseline-report.html \
                -c zap-baseline-rules.conf \
                -I
            displayName: 'Run ZAP baseline scan'

          - script: |
              node -e "
              var fs = require('fs');
              var report = JSON.parse(fs.readFileSync('$(Build.ArtifactStagingDirectory)/zap-reports/zap-baseline-report.json', 'utf8'));

              var alerts = report.site ? report.site[0].alerts : [];
              var high = 0;
              var medium = 0;
              var low = 0;
              var info = 0;

              alerts.forEach(function(alert) {
                var risk = parseInt(alert.riskcode);
                if (risk === 3) high++;
                else if (risk === 2) medium++;
                else if (risk === 1) low++;
                else info++;
              });

              console.log('ZAP Baseline Scan Results:');
              console.log('  High Risk: ' + high);
              console.log('  Medium Risk: ' + medium);
              console.log('  Low Risk: ' + low);
              console.log('  Informational: ' + info);

              if (alerts.length > 0) {
                console.log('\\nFindings:');
                alerts.forEach(function(alert) {
                  var risks = ['Info', 'Low', 'Medium', 'High'];
                  console.log('  [' + risks[parseInt(alert.riskcode)] + '] ' + alert.name);
                  console.log('    Instances: ' + alert.instances.length);
                  console.log('    Solution: ' + alert.solution.substring(0, 100));
                });
              }

              if (high > 0) {
                console.log('##vso[task.complete result=Failed;]ZAP found ' + high + ' high-risk issues');
              }
              "
            displayName: 'Evaluate ZAP results'

          - task: PublishBuildArtifacts@1
            inputs:
              pathToPublish: '$(Build.ArtifactStagingDirectory)/zap-reports'
              artifactName: 'dast-reports'
            condition: always()

ZAP rules configuration:

# zap-baseline-rules.conf
# Format: rule_id  action  (IGNORE, WARN, FAIL)
10035	FAIL	# Strict-Transport-Security Header Not Set
10021	FAIL	# X-Content-Type-Options Header Missing
10038	WARN	# Content Security Policy Header Not Set
10098	WARN	# Cross-Domain Misconfiguration
10011	WARN	# Cookie Without Secure Flag
10054	IGNORE	# Cookie Without SameSite Attribute (informational)
10096	IGNORE	# Timestamp Disclosure

Full Active Scan

The active scan actively probes for SQL injection, XSS, command injection, and other vulnerabilities. This takes longer and should target staging environments, never production.

# Full DAST active scan
steps:
  - script: |
      docker run --rm \
        -v $(Build.ArtifactStagingDirectory)/zap-reports:/zap/wrk:rw \
        -v $(Build.SourcesDirectory)/zap-config:/zap/config:ro \
        ghcr.io/zaproxy/zaproxy:stable \
        zap-full-scan.py \
        -t https://staging.myapp.com \
        -J zap-full-report.json \
        -r zap-full-report.html \
        -n /zap/config/context.xml \
        -U "scan-user" \
        -z "-config scanner.attackStrength=LOW -config scanner.alertThreshold=MEDIUM" \
        -I
    displayName: 'Run ZAP full scan'
    timeoutInMinutes: 60

ZAP with Authentication

Most applications require authentication to reach the interesting attack surface.

# ZAP context with authentication
# zap-config/context.xml
// generate-zap-context.js
// Generate ZAP context file with authentication configuration
var fs = require("fs");

var context = {
  name: "MyApp",
  targetUrl: "https://staging.myapp.com",
  authentication: {
    type: "form",
    loginUrl: "https://staging.myapp.com/login",
    loginBody: "username={%username%}&password={%password%}",
    loggedInIndicator: "\\QLogout\\E",
    loggedOutIndicator: "\\QSign In\\E"
  },
  users: [
    {
      name: "scan-user",
      username: process.env.ZAP_SCAN_USERNAME,
      password: process.env.ZAP_SCAN_PASSWORD
    }
  ],
  excludeFromScan: [
    "https://staging.myapp.com/logout",
    "https://staging.myapp.com/api/health",
    ".*\\.css$",
    ".*\\.js$",
    ".*\\.png$"
  ]
};

// Generate the ZAP automation framework YAML
var automationConfig = {
  env: {
    contexts: [{
      name: context.name,
      urls: [context.targetUrl],
      authentication: {
        method: "form",
        parameters: {
          loginPageUrl: context.authentication.loginUrl,
          loginRequestUrl: context.authentication.loginUrl,
          loginRequestBody: context.authentication.loginBody
        },
        verification: {
          method: "response",
          loggedInRegex: context.authentication.loggedInIndicator,
          loggedOutRegex: context.authentication.loggedOutIndicator
        }
      },
      users: context.users.map(function(u) {
        return {
          name: u.name,
          credentials: {
            username: u.username,
            password: u.password
          }
        };
      })
    }]
  },
  jobs: [
    { type: "spider", parameters: { context: context.name, user: "scan-user", maxDuration: 5 } },
    { type: "spiderAjax", parameters: { context: context.name, user: "scan-user", maxDuration: 5 } },
    { type: "passiveScan-wait", parameters: { maxDuration: 5 } },
    { type: "activeScan", parameters: { context: context.name, user: "scan-user", policy: "Default Policy" } },
    {
      type: "report",
      parameters: {
        template: "traditional-json",
        reportDir: "/zap/wrk",
        reportFile: "zap-authenticated-report.json"
      }
    }
  ]
};

// Write YAML-like config (simplified)
var yaml = require("js-yaml");
fs.writeFileSync("zap-automation.yaml", yaml.dump(automationConfig));
console.log("Generated ZAP automation config");

API Scanning with OpenAPI

For API-only applications, ZAP can import your OpenAPI spec to discover endpoints:

steps:
  - script: |
      docker run --rm \
        -v $(Build.ArtifactStagingDirectory)/zap-reports:/zap/wrk:rw \
        ghcr.io/zaproxy/zaproxy:stable \
        zap-api-scan.py \
        -t https://staging.myapp.com/api/docs/openapi.json \
        -f openapi \
        -J zap-api-report.json \
        -r zap-api-report.html \
        -I
    displayName: 'Run ZAP API scan'
    timeoutInMinutes: 30

Building the SAST/DAST Feedback Loop

The real value comes from connecting scan results back to developers in ways they will actually act on.

PR Comments for SAST Findings

var https = require("https");
var fs = require("fs");

// Post SAST findings as PR comments in Azure DevOps
function postSastFindingsToPR(organization, project, prId, findings, pat) {
  var threads = findings.map(function(finding) {
    return {
      comments: [{
        parentCommentId: 0,
        content: "**Security Finding** [" + finding.rule + "]\n\n"
          + finding.message + "\n\n"
          + "**Severity:** " + finding.severity + "\n"
          + "**File:** `" + finding.file + ":" + finding.line + "`\n\n"
          + "Please review and fix before merging.",
        commentType: 1
      }],
      status: 1, // Active
      threadContext: {
        filePath: "/" + finding.file,
        rightFileStart: { line: finding.line, offset: 1 },
        rightFileEnd: { line: finding.line, offset: 200 }
      }
    };
  });

  return threads.reduce(function(chain, thread) {
    return chain.then(function() {
      var postData = JSON.stringify(thread);
      var options = {
        hostname: "dev.azure.com",
        path: "/" + organization + "/" + project + "/_apis/git/repositories/"
          + project + "/pullRequests/" + prId + "/threads?api-version=7.1",
        method: "POST",
        headers: {
          "Authorization": "Basic " + Buffer.from(":" + pat).toString("base64"),
          "Content-Type": "application/json",
          "Content-Length": Buffer.byteLength(postData)
        }
      };

      return new Promise(function(resolve, reject) {
        var req = https.request(options, function(res) {
          var body = "";
          res.on("data", function(chunk) { body += chunk; });
          res.on("end", function() { resolve(JSON.parse(body)); });
        });
        req.on("error", reject);
        req.write(postData);
        req.end();
      });
    });
  }, Promise.resolve());
}

// Parse ESLint results and post to PR
var eslintResults = JSON.parse(fs.readFileSync("eslint-security-report.json", "utf8"));
var findings = [];

eslintResults.forEach(function(file) {
  file.messages.forEach(function(msg) {
    if (msg.ruleId && (msg.ruleId.indexOf("security") !== -1 || msg.ruleId.indexOf("no-unsanitized") !== -1)) {
      findings.push({
        rule: msg.ruleId,
        message: msg.message,
        severity: msg.severity === 2 ? "Error" : "Warning",
        file: file.filePath.replace(process.cwd() + "/", ""),
        line: msg.line
      });
    }
  });
});

if (findings.length > 0 && process.env.SYSTEM_PULLREQUEST_PULLREQUESTID) {
  postSastFindingsToPR(
    process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI.split("/")[3],
    process.env.SYSTEM_TEAMPROJECT,
    process.env.SYSTEM_PULLREQUEST_PULLREQUESTID,
    findings,
    process.env.SYSTEM_ACCESSTOKEN
  ).then(function() {
    console.log("Posted " + findings.length + " findings to PR");
  });
}

DAST Results Dashboard Generator

var fs = require("fs");

// Generate an HTML dashboard from DAST results
function generateDastDashboard(reportFile, outputFile) {
  var report = JSON.parse(fs.readFileSync(reportFile, "utf8"));
  var site = report.site ? report.site[0] : { alerts: [] };
  var alerts = site.alerts || [];

  var bySeverity = { high: [], medium: [], low: [], info: [] };
  var severityMap = { "3": "high", "2": "medium", "1": "low", "0": "info" };

  alerts.forEach(function(alert) {
    var sev = severityMap[alert.riskcode] || "info";
    bySeverity[sev].push(alert);
  });

  var html = "<!DOCTYPE html>\n<html>\n<head>\n"
    + "<title>DAST Security Report</title>\n"
    + "<style>\n"
    + "body { font-family: sans-serif; max-width: 900px; margin: 0 auto; padding: 20px; }\n"
    + ".high { color: #dc3545; } .medium { color: #fd7e14; }\n"
    + ".low { color: #ffc107; } .info { color: #17a2b8; }\n"
    + ".alert { border: 1px solid #ddd; padding: 15px; margin: 10px 0; border-radius: 4px; }\n"
    + ".summary { display: flex; gap: 20px; margin: 20px 0; }\n"
    + ".summary-card { padding: 20px; border-radius: 8px; text-align: center; flex: 1; }\n"
    + ".summary-card.high { background: #f8d7da; }\n"
    + ".summary-card.medium { background: #fff3cd; }\n"
    + ".summary-card.low { background: #d4edda; }\n"
    + ".summary-card .count { font-size: 36px; font-weight: bold; }\n"
    + "</style>\n</head>\n<body>\n"
    + "<h1>DAST Security Report</h1>\n"
    + "<p>Target: " + (site["@host"] || "unknown") + " | Date: " + new Date().toISOString() + "</p>\n"
    + "<div class=\"summary\">\n"
    + "<div class=\"summary-card high\"><div class=\"count\">" + bySeverity.high.length + "</div>High</div>\n"
    + "<div class=\"summary-card medium\"><div class=\"count\">" + bySeverity.medium.length + "</div>Medium</div>\n"
    + "<div class=\"summary-card low\"><div class=\"count\">" + bySeverity.low.length + "</div>Low</div>\n"
    + "</div>\n";

  function renderAlerts(severity, items) {
    if (items.length === 0) return "";
    var section = "<h2 class=\"" + severity + "\">" + severity.toUpperCase() + " (" + items.length + ")</h2>\n";
    items.forEach(function(alert) {
      section += "<div class=\"alert\">\n"
        + "<h3>" + alert.name + "</h3>\n"
        + "<p><strong>Risk:</strong> " + alert.riskdesc + "</p>\n"
        + "<p><strong>Description:</strong> " + (alert.desc || "").substring(0, 300) + "</p>\n"
        + "<p><strong>Solution:</strong> " + (alert.solution || "").substring(0, 300) + "</p>\n"
        + "<p><strong>Instances:</strong> " + (alert.instances || []).length + "</p>\n";

      (alert.instances || []).slice(0, 3).forEach(function(instance) {
        section += "<p style=\"font-size:12px;color:#666;\">URL: " + (instance.uri || "") + " | Method: " + (instance.method || "") + "</p>\n";
      });

      section += "</div>\n";
    });
    return section;
  }

  html += renderAlerts("high", bySeverity.high);
  html += renderAlerts("medium", bySeverity.medium);
  html += renderAlerts("low", bySeverity.low);
  html += "</body>\n</html>";

  fs.writeFileSync(outputFile, html);
  console.log("Dashboard written to " + outputFile);
  console.log("High: " + bySeverity.high.length + ", Medium: " + bySeverity.medium.length + ", Low: " + bySeverity.low.length);
}

generateDastDashboard(
  process.argv[2] || "zap-baseline-report.json",
  process.argv[3] || "dast-dashboard.html"
);

Complete Working Example: Full SAST/DAST Pipeline

# azure-pipelines.yml - Complete SAST and DAST pipeline
trigger:
  branches:
    include: [main, develop]

pr:
  branches:
    include: [main]

variables:
  stagingUrl: 'https://staging.myapp.com'

stages:
  # Stage 1: SAST on source code
  - stage: SAST
    displayName: 'Static Analysis'
    jobs:
      - job: ESLintSecurity
        displayName: 'ESLint Security Scan'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'
          - script: npm ci
            displayName: 'Install dependencies'
          - script: |
              npx eslint . --format json --output-file $(Build.ArtifactStagingDirectory)/eslint-security.json \
                --config .eslintrc.security.js --ext .js --ignore-pattern node_modules/ || true
            displayName: 'Run ESLint SAST'
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)/eslint-security.json'
              artifact: 'eslint-sast'

      - job: SonarQube
        displayName: 'SonarQube Analysis'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - task: SonarQubePrepare@5
            inputs:
              SonarQube: 'SonarQube-Connection'
              scannerMode: 'CLI'
              configMode: 'file'
          - script: npm ci && npm test -- --coverage
            displayName: 'Tests with coverage'
          - task: SonarQubeAnalyze@5
          - task: SonarQubePublish@5
            inputs:
              pollingTimeoutSec: '300'

  # Stage 2: Build and deploy to staging
  - stage: DeployStaging
    displayName: 'Deploy to Staging'
    dependsOn: SAST
    jobs:
      - deployment: Deploy
        environment: 'staging'
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to staging..."
                  displayName: 'Deploy application'
                - script: |
                    for i in 1 2 3 4 5; do
                      curl -s -o /dev/null -w "%{http_code}" $(stagingUrl)/api/health | grep 200 && break
                      echo "Waiting for staging to be ready... ($i/5)"
                      sleep 10
                    done
                  displayName: 'Wait for staging readiness'

  # Stage 3: DAST on staging
  - stage: DAST
    displayName: 'Dynamic Analysis'
    dependsOn: DeployStaging
    jobs:
      - job: ZAPBaseline
        displayName: 'ZAP Baseline Scan'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: |
              mkdir -p $(Build.ArtifactStagingDirectory)/zap
              docker run --rm \
                -v $(Build.ArtifactStagingDirectory)/zap:/zap/wrk:rw \
                ghcr.io/zaproxy/zaproxy:stable \
                zap-baseline.py \
                -t $(stagingUrl) \
                -J zap-report.json \
                -r zap-report.html \
                -I
            displayName: 'Run ZAP baseline'
            timeoutInMinutes: 15
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)/zap'
              artifact: 'dast-zap'
            condition: always()

      - job: ZAPAPIScan
        displayName: 'ZAP API Scan'
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - script: |
              mkdir -p $(Build.ArtifactStagingDirectory)/zap-api
              docker run --rm \
                -v $(Build.ArtifactStagingDirectory)/zap-api:/zap/wrk:rw \
                ghcr.io/zaproxy/zaproxy:stable \
                zap-api-scan.py \
                -t $(stagingUrl)/api/docs/openapi.json \
                -f openapi \
                -J zap-api-report.json \
                -I
            displayName: 'Run ZAP API scan'
            timeoutInMinutes: 30
          - task: PublishPipelineArtifact@1
            inputs:
              targetPath: '$(Build.ArtifactStagingDirectory)/zap-api'
              artifact: 'dast-api'
            condition: always()

  # Stage 4: Security gate
  - stage: SecurityGate
    displayName: 'Security Gate'
    dependsOn:
      - SAST
      - DAST
    jobs:
      - job: Evaluate
        pool:
          vmImage: 'ubuntu-latest'
        steps:
          - download: current
          - task: NodeTool@0
            inputs:
              versionSpec: '20.x'
          - script: |
              node $(Build.SourcesDirectory)/scripts/evaluate-security.js \
                --sast=$(Pipeline.Workspace)/eslint-sast/eslint-security.json \
                --dast=$(Pipeline.Workspace)/dast-zap/zap-report.json
            displayName: 'Evaluate security gate'

  # Stage 5: Production deployment
  - stage: DeployProduction
    displayName: 'Deploy to Production'
    dependsOn: SecurityGate
    condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
    jobs:
      - deployment: Deploy
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - script: echo "Deploying to production..."
                  displayName: 'Deploy application'

Common Issues & Troubleshooting

ESLint security/detect-object-injection Floods with False Positives

This rule flags every bracket notation access, which is extremely noisy:

// This triggers the rule even when safe
var items = ["a", "b", "c"];
var first = items[0];  // False positive: detect-object-injection

Fix: Disable for known-safe patterns and use a targeted approach:

// .eslintrc.js
rules: {
  "security/detect-object-injection": "off",  // Too many false positives
  // Use custom rule that only flags req.params/req.query bracket access instead
}

ZAP Scan Times Out in Pipeline

Error: Process exceeded timeout of 15 minutes

ZAP active scans can run for hours on large applications. Solutions:

# Limit scan scope
steps:
  - script: |
      docker run --rm \
        ghcr.io/zaproxy/zaproxy:stable \
        zap-full-scan.py \
        -t https://staging.myapp.com \
        -z "-config spider.maxDuration=2 -config scanner.maxScanDurationInMins=10" \
        -I
    timeoutInMinutes: 30

Or use the automation framework to target specific URLs rather than crawling the entire site.

SonarQube "Not Enough Memory" During Analysis

Error: java.lang.OutOfMemoryError: Java heap space

Increase scanner memory:

steps:
  - task: SonarQubeAnalyze@5
    env:
      SONAR_SCANNER_OPTS: '-Xmx4096m'

ZAP Cannot Reach Staging Environment

Error: Failed to connect to staging.myapp.com:443

When running ZAP in Docker, network access depends on the agent configuration:

# Use host network mode if staging is on an internal network
steps:
  - script: |
      docker run --rm --network host \
        -v $(Build.ArtifactStagingDirectory):/zap/wrk:rw \
        ghcr.io/zaproxy/zaproxy:stable \
        zap-baseline.py -t https://staging.internal:8443 -I

For Azure-hosted agents targeting Azure App Services, ensure the staging environment allows traffic from the agent IP range.

SAST and DAST Results Conflict

SAST reports a potential SQL injection, but DAST does not confirm it. This does not mean SAST is wrong — DAST might not have tested the right input vector. Treat SAST findings as code-level risks that need review regardless of DAST confirmation:

// SAST flags this as potential SQL injection
var query = "SELECT * FROM users WHERE name = '" + req.query.name + "'";
// Even if DAST didn't exploit it, fix it:
var query = "SELECT * FROM users WHERE name = $1";
var params = [req.query.name];

Best Practices

  • Run SAST on every commit, DAST on every deployment — SAST is fast enough for every PR build. DAST requires a running application, so run it after deploying to staging.
  • Start with baseline scans before full active scans — ZAP baseline scans complete in minutes and catch header/cookie issues. Graduate to active scans once you have baseline results clean.
  • Tune rules aggressively to reduce false positives — A scanner that produces 200 findings per build gets ignored. Suppress known false positives, disable overly noisy rules, and keep the signal-to-noise ratio high.
  • Post findings directly on pull requests — Developers fix security issues when they see them in the PR, not when they get a separate report via email three days later. Use the PR comment API to annotate specific lines.
  • Maintain separate SAST configs for security vs code quality — Do not mix style linting with security scanning. Use a dedicated .eslintrc.security.js that only enables security rules so the results are focused.
  • Use authenticated DAST scans — Unauthenticated scans only test the login page and public endpoints. Create a dedicated scan user account and configure ZAP with authentication to reach the real attack surface.
  • Track metrics over time, not just pass/fail — The number of findings per scan, mean time to remediate, and false positive rate tell you whether your security program is improving. Build dashboards from your scan artifacts.
  • Never run active DAST scans against production — Active scans inject payloads that can corrupt data, trigger alerts, or cause outages. Always target staging or a dedicated security testing environment.

References

Powered by Contentful