Application Security Testing in Agile Development
Rapid releases don’t have to mean brittle security — weaving verification into everyday engineering keeps features shipping and risks contained.

I’ve shipped features on two-week sprints and watched pipelines crumble under late-stage penetration testing reports. The workable approach I keep returning to is simple: treat security testing like any other quality gate in the delivery path. If you can unit-test behavior, you can test for common vulnerabilities with roughly the same cadence and tooling. The difference is the mindset. Security testing is not a final audit; it’s a continuous feedback loop that begins with your first git commit and lives in your pull request checks, infrastructure definitions, and runtime monitoring.
The reason this matters now is straightforward. Modern applications are assemblies of libraries, containers, APIs, and cloud services. The modern version catalog is a software supply chain, and vulnerabilities arrive through dependencies, misconfigurations, and API misuse as often as through custom code. Agile’s speed is a force multiplier: it can either accelerate risk or amplify good hygiene. The teams I work with prefer the latter, using lightweight, developer-friendly tools that slot into existing CI and local workflows. You can too.
Context: Where security testing fits in today’s Agile landscape
In most product organizations, engineering velocity is measured in deployments per day, lead time, and change failure rate. Security rarely shows up on those dashboards, but it should. Agile development is about short feedback loops; security testing is about shortening the time from a flawed pattern to its discovery. The industry has converged on a practical set of test types that map well to Agile cadence:
- SAST (Static Application Security Testing): Scans source code and builds for insecure patterns, dependency issues, and misconfigurations before deployment.
- SCA (Software Composition Analysis): Identifies vulnerable libraries and licenses in your dependency tree.
- DAST (Dynamic Application Security Testing): Tests a running application for OWASP-style issues via crawling and probes.
- IAST (Interactive Application Security Testing): Agents inside the runtime emit signals that correlate with code-level vulnerabilities.
- IaC Scanning: Security checks for Terraform, CloudFormation, Kubernetes manifests, and container images.
- Secret Scanning: Detects hardcoded credentials and tokens in code and commits.
This stack is used by developers and platform engineers in sprint cycles. Security teams typically own policies, thresholds, and triage, but developers run the tools in CI and on their machines. Compared to a monolithic, after-the-fact pentest, this approach catches issues early and reduces remediation cost. At the same time, you still want targeted manual testing and periodic deep dives; automation cannot find every class of vulnerability, especially logic flaws or multi-step business process issues.
A practical context: most mature Agile teams weave SAST and SCA into pull request checks, run IaC scans in build pipelines, schedule DAST against staging, and reserve manual testing for sensitive workflows or new features. The mix depends on risk profile, regulatory requirements, and stack. A fintech API will take a different posture than a content marketing site.
Core concepts and practical patterns
Before adopting any tool, define your security “quality bar.” A good starting point is a policy that states:
- Every PR triggers SAST and SCA.
- No PR may introduce new critical or high severity issues unless accompanied by a documented risk acceptance.
- Container images are scanned before deployment.
- Secrets are blocked pre-commit via hooks.
- DAST runs on nightly builds of the staging environment.
Now, let’s see what this looks like in practice.
Setting up a developer-friendly SAST workflow
SAST works best when developers see results quickly and can act without context switching. A typical flow:
- Local pre-commit hooks scan the changed files.
- CI runs deeper scans on the full codebase.
- Results are reported as PR comments or a check summary.
- Only explicit risk acceptances allow merging critical findings.
Example project structure for a Node.js API service with SAST and SCA integrated:
/my-api
├── .github
│ └── workflows
│ └── security.yml
├── .husky
│ └── pre-commit
├── src
│ ├── routes
│ │ └── user.js
│ └── index.js
├── test
│ └── user.test.js
├── package.json
├── package-lock.json
├── .sast.yml
├── .secretsignore
├── Dockerfile
└── README.md
In this repository, we’ll use a lightweight open-source SAST tool for local speed, and an SCA tool for dependency issues. Below is a pre-commit hook that runs SAST only on changed files to keep feedback fast.
#!/bin/bash
# .husky/pre-commit
# List staged JS/TS files and run a quick SAST rule set
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep -E '\.(js|ts|jsx|tsx)$' | xargs)
if [ -n "$FILES" ]; then
echo "Running lightweight SAST on staged files..."
# Example using a community rule set. Replace with your preferred tool.
# This runs a subset of rules focused on injection and unsafe usage.
semgrep --config=p/javascript ./src
fi
# Always run secret scan on the entire repo (fast)
gitleaks detect --verbose --redact --source . --config-path .gitleaks.toml
exit 0
In CI, we run deeper scans and policy checks. The following GitHub Actions workflow is intentionally simple: it runs SAST across the full repo, SCA, and fails on new critical issues unless a risk acceptance file is present.
# .github/workflows/security.yml
name: Security Checks
on:
pull_request:
push:
branches: [ main ]
jobs:
security:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: npm ci
- name: SAST scan
run: |
# Scan the whole repo for SAST findings
# Using a community rule set; replace with your preferred engine
semgrep --config=p/javascript --error --sarif --output=semgrep.sarif ./src
- name: SCA scan
run: |
# Example using npm audit to surface vulnerable dependencies
npm audit --audit-level=moderate --json > npm-audit.json || true
- name: Policy check
run: |
# Fail if there are any new critical/high SAST issues not explicitly accepted
node scripts/check-sast-policy.js semgrep.sarif .risk-acceptance.json
- name: Upload SARIF
if: always()
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
SCA and dependency health
Dependency vulnerabilities are the most common source of exploitable issues. In Agile, you want continuous visibility rather than occasional audits. In the Node.js example, we use npm audit and a policy script to enforce a baseline. A more robust approach integrates an SCA service like OSS-Findings (e.g., GitHub Dependabot) to open PRs automatically.
Below is a simple Node script that checks for critical or high findings from semgrep.sarif and npm-audit.json, and compares them with a risk acceptance file.
// scripts/check-sast-policy.js
const fs = require('fs');
const path = require('path');
function loadJson(file) {
if (!fs.existsSync(file)) return null;
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function countCriticalHigh(sarifFile, acceptanceFile) {
const sarif = loadJson(sarifFile);
const accept = loadJson(acceptanceFile) || { accepted: [] };
if (!sarif || !sarif.runs || sarif.runs.length === 0) {
console.log('No SARIF results found.');
return 0;
}
let criticalHighCount = 0;
sarif.runs.forEach(run => {
const results = run.results || [];
results.forEach(r => {
const level = (r.level || 'none').toLowerCase();
const ruleId = r.ruleId || 'unknown';
const location = r.locations && r.locations[0] && r.locations[0].physicalLocation
? `${r.locations[0].physicalLocation.artifactLocation.uri}:${r.locations[0].physicalLocation.region.startLine}`
: 'unknown';
if (level === 'error' || level === 'high') {
const isAccepted = accept.accepted.some(a => a.ruleId === ruleId && a.location === location);
if (!isAccepted) {
criticalHighCount++;
console.error(`NEW CRITICAL/HIGH: [${ruleId}] at ${location}`);
}
}
});
});
return criticalHighCount;
}
function countVulnSeverities(npmAuditFile, acceptanceFile) {
const audit = loadJson(npmAuditFile);
const accept = loadJson(acceptanceFile) || { accepted: [] };
if (!audit || !audit.vulnerabilities) {
console.log('No npm audit results found.');
return 0;
}
let criticalHighCount = 0;
Object.entries(audit.vulnerabilities).forEach(([pkg, vuln]) => {
const severity = vuln.severity;
if (severity === 'critical' || severity === 'high') {
const id = vuln.id || vuln.source || pkg;
const isAccepted = accept.accepted.some(a => a.id === id);
if (!isAccepted) {
criticalHighCount++;
console.error(`NEW DEP VULN: [${id}] ${pkg} severity=${severity}`);
}
}
});
return criticalHighCount;
}
function main() {
const sarifFile = process.argv[2] || 'semgrep.sarif';
const acceptanceFile = process.argv[3] || '.risk-acceptance.json';
const sastCount = countCriticalHigh(sarifFile, acceptanceFile);
const scaCount = countVulnSeverities('npm-audit.json', acceptanceFile);
if (sastCount + scaCount > 0) {
console.error(`Policy violation: ${sastCount + scaCount} new critical/high issues detected.`);
process.exit(1);
} else {
console.log('Policy check passed: No new critical/high issues.');
}
}
main();
Risk acceptance files should be explicit and ephemeral. Here’s a minimal example that documents a temporary tolerance for a finding with an owner and expiration date.
// .risk-acceptance.json
{
"accepted": [
{
"type": "sast",
"ruleId": "javascript.express.security.exposed-dir-listing",
"location": "src/routes/user.js:14",
"reason": "Feature flagged behind admin role; required for audit UI.",
"owner": "team-platform",
"expires": "2025-03-01"
},
{
"type": "sca",
"id": "GHSA-9c3h-7qgf-x4jq",
"reason": "No fix available; mitigation via WAF rule applied.",
"owner": "team-security",
"expires": "2025-02-15"
}
]
}
Container and IaC scanning in the pipeline
Dockerfiles and infrastructure-as-code often introduce misconfigurations that are easier to prevent than fix. Scanning images and manifests should be as routine as linting.
A minimal Dockerfile example for a Node API:
# Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
FROM node:20-alpine
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
WORKDIR /app
COPY --from=builder --chown=nextjs:nodejs /app/node_modules ./node_modules
COPY --from=builder --chown=nextjs:nodejs /app/dist ./dist
USER nextjs
EXPOSE 3000
CMD ["node", "dist/index.js"]
In CI, you can scan the image with a tool like Trivy and check Kubernetes manifests with Checkov. The following workflow step is illustrative:
# Build, tag, and scan image (inside CI job)
docker build -t my-api:${{ github.sha }} .
trivy image --exit-code 1 --severity CRITICAL,HIGH my-api:${{ github.sha }}
# Example of scanning Kubernetes manifests
checkov -d ./k8s --framework kubernetes --skip-check CKV_K8S_42
For Kubernetes, a simple Deployment manifest might look like:
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-api
namespace: prod
spec:
replicas: 3
selector:
matchLabels:
app: my-api
template:
metadata:
labels:
app: my-api
spec:
containers:
- name: my-api
image: my-api:latest
ports:
- containerPort: 3000
resources:
requests:
cpu: "100m"
memory: "128Mi"
limits:
cpu: "500m"
memory: "256Mi"
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
readOnlyRootFilesystem: true
If you want a quick local validation, you can write a small Node script that checks container fields for risky settings like runAsRoot or allowPrivilegeEscalation. This kind of check is straightforward to extend and suits Agile’s incremental style.
// scripts/check-k8s-security.js
const fs = require('fs');
const yaml = require('js-yaml');
function loadManifests(dir) {
const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
return files.map(f => yaml.load(fs.readFileSync(`${dir}/${f}`, 'utf8')));
}
function validatePodSecurity(podSpec) {
const issues = [];
const ctx = podSpec.securityContext || {};
if (!ctx.runAsNonRoot) issues.push('runAsNonRoot should be true');
if (ctx.allowPrivilegeEscalation === true) issues.push('allowPrivilegeEscalation should be false');
return issues;
}
function validateContainers(containers) {
const issues = [];
containers.forEach(c => {
const ctx = c.securityContext || {};
if (!ctx.readOnlyRootFilesystem) issues.push(`Container ${c.name} should use readOnlyRootFilesystem`);
if (ctx.privileged) issues.push(`Container ${c.name} must not be privileged`);
});
return issues;
}
function main() {
const manifests = loadManifests('./k8s');
let totalIssues = 0;
manifests.forEach(m => {
if (m.kind === 'Deployment' && m.spec?.template?.spec) {
const podIssues = validatePodSecurity(m.spec.template.spec);
const containerIssues = validateContainers(m.spec.template.spec.containers || []);
const allIssues = [...podIssues, ...containerIssues];
if (allIssues.length) {
console.error(`Issues in ${m.metadata.name}:`, allIssues);
totalIssues += allIssues.length;
}
}
});
if (totalIssues > 0) {
console.error(`Found ${totalIssues} Kubernetes security issues.`);
process.exit(1);
} else {
console.log('Kubernetes manifests passed security checks.');
}
}
main();
DAST in staging
Dynamic testing exercises the running application and is invaluable for finding issues SAST might miss, such as misconfigured authentication or exposed debug endpoints. In Agile, schedule DAST against staging nightly or on demand before release. A simple approach uses OWASP ZAP’s baseline scan to spider the app and scan for common issues.
# Example: baseline DAST scan using OWASP ZAP
# Run this in CI or a scheduled job targeting the staging environment.
docker run --rm -t owasp/zap2docker-stable zap-baseline.py \
-t https://staging.my-api.example.com \
-g gen.conf \
-r zap-report.html
Only fail the pipeline based on DAST if you have stable baselines and triage. DAST scans can be noisy, and false positives are more common when applications change frequently. Use them to inform tests and security reviews rather than as a hard gate unless your risk model demands it.
Secret scanning as a developer habit
Secrets in code are a common breach vector. Make scanning non-negotiable and fast. Tools like Gitleaks can run pre-commit and in CI. In the pre-commit hook shown earlier, we used a .gitleaks.toml file to allowlist test patterns and ignore low-risk files like package-lock.json.
# .gitleaks.toml
title = "My API Gitleaks Config"
[allowlist]
paths = ['''package-lock\.json''', '''node_modules/''', '''dist/''']
commits = ['''(?i)example''']
[[rules]]
id = "test-api-key"
description = "Ignore test fixtures with example keys"
regex = '''(?i)example[0-9a-zA-Z]{16}'''
stopword = "example"
Strengths, weaknesses, and tradeoffs
Security testing in Agile is about pacing. The strengths of a well-integrated approach are clear:
- Early detection: Issues are flagged when code changes are small and context is fresh.
- Developer ownership: Engineers fix what they build, improving security literacy.
- Measurable policy: Policy-as-code provides a clear, automated bar.
- Reduced blast radius: Preventing a vulnerability from entering main is cheaper than a hotfix after an incident.
There are also tradeoffs and limitations:
- Noise and alert fatigue: Without tuned rules, SAST and DAST will overwhelm teams. Start with a small, high-impact set of rules and expand as you mature.
- Coverage gaps: SAST misses runtime and configuration issues; DAST misses code paths unreachable via crawling. Combine them and supplement with targeted manual testing.
- Performance overhead: Deep scans on every PR can slow velocity. Balance with local lightweight scans and nightly full scans.
- False positives: Policy checks should be strict, but require an easy path for risk acceptance with accountability and expiration.
- Supply chain complexity: SCA alone won’t catch typosquatting or compromised transitive dependencies. Consider provenance and sigstore for stronger guarantees.
When is this approach a poor fit? If your team rarely ships changes or runs mostly third-party SaaS with no code, a periodic security review might suffice. If you operate in a regulated environment with strict audit requirements, you may need additional controls (SBOM generation, attestations, formal pentests). But even in those cases, Agile-integrated testing remains a valuable baseline.
Personal experience: learning curves, mistakes, and moments that mattered
The biggest learning curve for teams is not tooling but cadence. Running a full SAST suite on every PR and blocking on every warning looks robust on paper and exhausting in practice. I’ve seen teams stall because the rule set was too strict from day one. What works better is to start with a small set of rules that catch the most common issues: injection risks, exposed secrets, unsafe deserialization, and insecure header configurations. Expand rules gradually as you automate triage and add context to findings.
One common mistake is treating security tool output as truth. A SAST warning is a hypothesis. The developer should be able to quickly verify the finding or dismiss it with evidence. Another pitfall is skipping local checks. If developers only see security issues in CI, context switching and delays creep in. The pre-commit hook in the earlier example is not a gate; it’s a fast filter. If you make it too heavy, people will bypass it. Keep local scans under a minute.
I also learned that ownership matters. If a security finding is assigned to a team without documentation, it becomes noise. A risk acceptance file, even a simple JSON, makes decisions explicit. Expiration dates force re-evaluation. During one release, a high-severity finding was accepted temporarily to ship an audit feature. The expiration date reminded the team to revisit it, and we fixed the underlying issue two sprints later. Without that reminder, the exception would have lingered.
Moments where this approach proved especially valuable are predictable but satisfying. In one project, SCA auto-fix PRs prevented a dependency vulnerability from entering the build, saving us from a weekend incident. In another, IaC scanning caught a Kubernetes securityContext misconfiguration that would have allowed privilege escalation in production. A later pentest found the same issue in a different microservice, but the pipeline had already blocked it months prior. These are low-drama wins, which is the point.
Getting started: workflow and mental model
Adopting security testing in Agile is more about workflow than tool choices. The mental model is straightforward: local → PR → pipeline → staging → production → feedback.
Local:
- Fast SAST on changed files.
- Secret scanning pre-commit.
- SCA checks on
npm installor equivalent.
PR:
- Full SAST and SCA with policy enforcement.
- IaC scanning if manifests changed.
- Risk acceptance workflow for exceptions.
Pipeline:
- Container image scanning.
- Build artifact checks.
- Dependency update automation (SCA).
Staging:
- DAST baseline scans.
- Smoke tests and security smoke tests (e.g., verify auth headers).
Production:
- Runtime monitoring and telemetry.
- Incident response drills tied to security findings.
In a practical setup, your CI will run the checks in parallel to keep feedback fast. Tools should output machine-readable formats (SARIF, JSON) for aggregation and reporting. Focus on reproducible results: scanning the same commit twice should yield the same outcome.
Folder structure guidance:
/.github/workflows # CI jobs for security checks
/scripts # Policy checks, K8s validators, triage helpers
/src # Application code
/k8s # Kubernetes manifests
/container # Dockerfiles and related build context
/docs/security # Security runbooks and triage guidelines
Standout features and ecosystem strengths
What makes this approach fit Agile so well is developer experience. Tools that produce SARIF integrate directly into GitHub’s code scanning UI. That means findings appear where code lives, not in an external dashboard. Similarly, SCA integrations can open PRs with patches, letting engineers evaluate changes without hunting for advisories.
Ecosystem strengths include:
- Open-source tooling: Semgrep, Trivy, Checkov, ZAP, and Gitleaks are mature, configurable, and easy to script.
- Language coverage: You can use the same workflow for Node, Python, Go, Java, and more. Rule sets are language-specific, but the pipeline structure stays consistent.
- Composable formats: SARIF and JSON outputs make it easy to write policy scripts and dashboards.
Developers also benefit from quick local runs. If a SAST scan takes 5 seconds on changed files and 30 seconds on the full repo, it encourages frequent use. Over time, that speed translates into fewer surprises at release and fewer hotfixes.
Free learning resources
- OWASP Top 10: The canonical starting point for understanding common vulnerabilities and how to test for them. https://owasp.org/www-project-top-ten/
- OWASP Application Security Verification Standard (ASVS): A framework for defining security requirements and tests. https://owasp.org/www-project-application-security-verification-standard/
- Semgrep Rules Registry: A practical set of community rules to tailor SAST to your stack. https://semgrep.dev/r
- OWASP ZAP Documentation: Tutorials and recipes for DAST, including baseline scans and authentication handling. https://www.zaproxy.org/docs/
- Checkov Policy Library: IaC scanning rules for Kubernetes, Terraform, and more. https://www.checkov.io/5.Policy%20Index/
- Trivy Documentation: Container and dependency scanning guide. https://aquasecurity.github.io/trivy/latest/
- GitHub CodeQL: Advanced static analysis with deep language support. https://codeql.github.com/
Summary: Who should use this approach and when to skip it
Teams that ship frequently, own their code, and want to reduce security risk without sacrificing velocity benefit most from integrated security testing in Agile. If you write code, maintain APIs, or manage infrastructure-as-code, this approach will provide immediate value. It is particularly useful for startups and mid-size organizations building a security culture from the ground up.
You might skip or defer this approach if:
- You run a mostly third-party SaaS stack with no custom code or build artifacts.
- Your release cadence is extremely low and periodic audits already meet compliance.
- Your organization lacks the engineering capacity to triage and act on findings (in which case, start small with SCA and secret scanning first).
The takeaway is simple. Security testing in Agile is not about perfection; it’s about introducing feedback loops where they matter most. Start with fast, local checks. Add policy gates that are explicit and time-bound. Use staging for DAST and keep a human in the loop for logic flaws. Over time, you’ll ship faster and sleep better.
Sources and references
- OWASP Top 10: https://owasp.org/www-project-top-ten/
- OWASP ASVS: https://owasp.org/www-project-application-security-verification-standard/
- OWASP ZAP: https://www.zaproxy.org/docs/
- Semgrep: https://semgrep.dev/
- Trivy: https://aquasecurity.github.io/trivy/latest/
- Checkov: https://www.checkov.io/
- GitHub CodeQL: https://codeql.github.com/
- Gitleaks: https://github.com/gitleaks/gitleaks
- npm audit: https://docs.npmjs.com/cli/commands/npm-audit




