Security Testing
What automated security testing can and cannot find, the scans worth having in every pipeline, and why business-logic flaws remain a human problem.
2 min read · updated 19 September 2026
Automated security testing finds known-shaped problems. That is a genuinely valuable and genuinely bounded thing, and being clear about the boundary is the most useful contribution this page can make.
It will find a vulnerable dependency, a committed AWS key, a missing security header, a SQL string concatenation and an out-of-date base image.
It will not find that your refund endpoint lets a customer refund somebody else's order. That is a business-logic flaw, it is the category most serious breaches fall into, and no scanner understands your domain well enough to see it.
#The four checks every pipeline should have
All four are fast, cheap and catch recurring real problems.
# .github/workflows/security.yml
name: Security
on: [push, pull_request]
jobs:
dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Fail on high and critical only: fail on everything and the team
# learns to bypass the gate, which is worse than no gate.
- run: npm audit --audit-level=high
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # scan history, not just the tip
- uses: gitleaks/gitleaks-action@v2
static-analysis:
runs-on: ubuntu-latest
permissions: { security-events: write }
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/init@v3
with: { languages: javascript-typescript, queries: security-extended }
- uses: github/codeql-action/analyze@v3
container:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: docker build -t app:ci .
- uses: aquasecurity/trivy-action@master
with:
image-ref: app:ci
severity: CRITICAL,HIGH
exit-code: '1'Fail on high and critical only. A gate that fails on every low-severity advisory teaches people to bypass the gate, and a bypassed gate is worse than no gate because it looks like protection.
Scan the full history for secrets. A key removed in a later commit is still in the repository and still valid.
#Tests you write yourself
These are the ones that matter most, because they encode what your application means.
Authorization — the same request as two users, asserting one succeeds and one does not. The single highest-value security test most teams are missing, and it gets its own page.
Input handling at the boundary:
it.each([
["'; DROP TABLE orders; --", 'sql'],
['<img src=x onerror=alert(1)>', 'xss'],
['../../../../etc/passwd', 'traversal'],
['{{7*7}}', 'template'],
['\u0000truncated', 'null byte']
])('stores %s as literal text (%s)', async (payload) => {
const { body } = await request(app)
.post('/orders')
.send({ sku: 'book-1', quantity: 1, note: payload })
.expect(201);
const { body: read } = await request(app).get(`/orders/${body.id}`).expect(200);
expect(read.note).toBe(payload); // round-trips exactly: no mangling, no execution
});Security headers:
it('sets the headers a browser needs to defend itself', async () => {
const response = await request(app).get('/').expect(200);
expect(response.headers['content-security-policy']).toMatch(/default-src 'self'/);
expect(response.headers['strict-transport-security']).toMatch(/max-age=\d{7,}/);
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['referrer-policy']).toBeDefined();
expect(response.headers['x-powered-by']).toBeUndefined();
});Rate limiting and account protections:
it('locks out after five failed sign-ins', async () => {
for (let i = 0; i < 5; i++) {
await request(app).post('/signin').send({ email, password: 'wrong' }).expect(401);
}
// The correct password now fails too — proof the lockout is real.
await request(app).post('/signin').send({ email, password: correct }).expect(429);
});Session and token handling: that a signed-out session is genuinely revoked, that an expired token is rejected even with a valid signature, that changing a password invalidates other sessions.
#DAST
Dynamic scanning attacks a running instance. Worth running on a schedule rather than on every pull request — it is slow and noisy.
- name: OWASP ZAP baseline scan
uses: zaproxy/[email protected]
with:
target: 'https://staging.example.com'
rules_file_name: '.zap/rules.tsv' # tune out the known false positivesRun it against a staging environment, never production, and never against a third party without written authorisation.
#What automation cannot do
The categories that require a person thinking about your domain:
- Broken access control — top of the OWASP list for years, precisely because it is application-specific. Partly addressable by authorization tests you write.
- Business-logic flaws — negative quantities producing a credit, a discount code applied twice, a race between two refund requests.
- Insecure design — the feature is working as specified and the specification is the vulnerability.
- Chained low-severity issues that combine into something serious.
For these: threat modelling at design time, security review at code review time, and periodic penetration testing by people whose job it is. Automation raises the floor; it does not raise the ceiling.
#The honest summary
Put the four scans in the pipeline this afternoon — they are an hour of work and they will find something. Write authorization tests for every endpoint that returns someone's data. Then accept that the remaining risk is a human-judgement problem, and budget for human judgement.
A green security pipeline means no known-shaped problems were found. It does not mean the system is secure, and the gap between those two statements is where the serious incidents live.
Common questions
- Can security testing be fully automated?
- No. Automated tools find known-shaped problems — vulnerable dependencies, missing headers, injection in obvious places, committed secrets. They cannot find business-logic flaws, which is the category most damaging breaches fall into, because those require understanding what the application is supposed to mean.
- What security checks should every pipeline have?
- Dependency scanning, secret scanning, static analysis with security rules, and container image scanning. All four are cheap, run in under a minute, and catch a real and recurring class of problem.
- What is the difference between SAST and DAST?
- SAST reads your source code looking for dangerous patterns; DAST attacks a running instance from the outside. SAST runs early and produces false positives; DAST runs late and finds only what it can reach. They find different things and both are worth having.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/quality/security-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Authorization TestingThe highest-value security testing most teams are not doing — proving that the user who should not be able to do a thing genuinely cannot.
- Testing in GitHub ActionsA complete pipeline — unit tests, integration with containers, sharded Playwright, coverage and artefacts — plus the caching and concurrency settings that make it fast.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
- Database TestingTesting migrations, constraints, queries and transactions — the layer everything else depends on, and the one most suites replace with a mock.