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.jsthat 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.