Web Security

Adopting WAFs with no tests means endless security upkeep

Inherited web security is less about ideals than about carrying cost. My position: a QA engineer should resist adopting a full security best-practices program until the riskiest flows are testable, because an untested codebase turns every “best practice” into another source of unexplained breakage, ignored alerts, and political debt.

Your first security control is a characterization suite, not another scanner

A scanner-first rollout feels responsible, but it is often the wrong first move for an untested web application because the team cannot distinguish a real regression from behavior that was already broken. The maintenance burden nobody mentions is not running OWASP ZAP, Semgrep, CodeQL, Trivy, Dependabot, or npm audit; it is explaining their output every week while nobody can prove what the application is supposed to do.

Web Security Best Practices for Modern Software Development is right to treat security as engineering work, but I would delay its broad rollout in an inherited repo because you cannot tell whether a fix improved safety or merely changed the alert count. That disagreement matters for QA: your credibility depends on reproducible evidence, not on the number of controls listed in a slide.

Start with characterization tests around the flows that create the largest blast radius: login, password reset, session renewal, privilege changes, file upload, payment-like state changes, and admin screens. Use Playwright 1.44 or Cypress 13 for browser paths, Jest 29 or pytest 8 for API-level checks, and capture current behavior before arguing about ideal behavior. If the current app accepts a password-reset token twice, write the failing security expectation as a skipped or quarantined test with a ticket link, because hiding the issue in prose guarantees it will be rediscovered as a surprise.

A measured baseline should be boring and specific: count the number of unauthenticated routes, the number of cookie-setting responses, the number of roles, and the number of security tests that run in CI. If that number is 0 today, say so in the QA report because a real zero is more useful than a vague claim that coverage is “low.” A tunable starting target is 10 end-to-end security smoke tests, because a small suite can run on every pull request without becoming the reason developers bypass CI.

I would not begin by rewriting authentication, because authentication rewrites in untested systems break undocumented edge cases and make QA responsible for proving a negative. I would instead pin the existing behavior, add tests for the worst unsafe outcomes, and then change one rule at a time. That sequence is slower for the first week, but it is faster over a month because every fix has a witness.

Scanner findings become debt unless you budget their upkeep

Security tools are not free after installation. Semgrep 1.78.0 with –config p/owasp-top-ten, GitHub CodeQL codeql-action/init@v3, Trivy 0.50 with –severity HIGH,CRITICAL, osv-scanner 1.7.4, Snyk CLI, and npm audit –audit-level=high all need owners, suppressions, version updates, and a rule for when findings block delivery. Without those rules, QA becomes the mailbox for every alert that engineering does not want to interpret.

Here is a small ZAP baseline job that actually runs against a local or deployed target and gives QA an artifact without pretending that the whole application has been assessed:

#!/usr/bin/env bash
set -euo pipefail
TARGET="${TARGET_URL:-http://localhost:3000}"
mkdir -p zap
docker run --rm -t -v "$PWD/zap:/zap/wrk/:rw" ghcr.io/zaproxy/zaproxy:stable \
  zap-baseline.py -t "$TARGET" -m 5 -r zap-baseline.html -J zap.json || code=$?
test "${code:-0}" -le 1
echo "ZAP baseline saved in ./zap"

The 5-minute spider limit in that script is a value to tune, not a magic standard, because inherited apps often have loops, broken redirects, or enormous menus that make an unconstrained scan noisy. OWASP ZAP 2.15.0 is useful for catching missing headers, reflected input, and obvious session problems, but it is poor at understanding business authorization because it cannot know whether “manager can export all users” is intended.

The first hidden cost is triage vocabulary. CVSS v3.1 gives severity language, EPSS gives probability-style exploit likelihood, and CWE identifiers help group root causes, but none of them knows your release risk. A public CVSS score of 9.8 for a dependency vulnerability should get attention, yet it should not automatically outrank a lower-scored access-control bug in your admin panel because exploitability depends on reachability and deployed configuration.

The second hidden cost is suppressions. A Semgrep nosemgrep comment, a CodeQL dismissal, a Dependabot ignore rule, or a Trivy allowlist is a maintenance promise. Every exception needs an expiry date because permanent exceptions become a second, undocumented policy. A practical expiry window is 30 days for high-risk suppressions, chosen as a working agreement rather than a universal benchmark, because it forces re-evaluation without creating daily churn.

Web Security Best Practices for Modern Software Teams belongs in the team handbook only after ownership is explicit, because shared responsibility without named triagers creates permanent QA overflow. The page can inspire standards, but your inherited system needs a queue with names, dates, and reproduction steps more than it needs another principle.

Most teams choose the wrong gate for untested legacy code

The explicit comparison is this: OWASP ZAP baseline scanning wins when you need cheap, repeatable discovery of common web issues across many pages, and it costs time in false positives, authentication setup, and report grooming. Playwright security smoke tests win when you need proof that a specific abuse case stays fixed, and they cost engineering time because someone must model users, roles, cookies, and expected denial states.

For an untested codebase, I would use both, but I would gate on Playwright before gating on ZAP because a deterministic failing test is easier to defend in a release meeting than a scanner report that developers can dismiss as noisy. ZAP can still publish reports on every merge, but it should not block until the team has burned down the initial backlog and agreed which alerts are release-stopping.

A sensible first gate is narrow: “a logged-out user cannot access these 3 protected URLs,” where the three paths are measured from production logs or route configuration rather than guessed. Add checks for Set-Cookie flags such as HttpOnly, Secure, and SameSite=Lax, because these assertions are cheap and stable when the app already uses cookie sessions. Add a Content-Security-Policy-Report-Only header before enforcing CSP, because report-only mode exposes breakage from inline scripts without taking down pages.

Do not let “shift left” become “fail everything immediately.” A hard CodeQL or Semgrep gate on day one is punitive in a legacy repo because the first scan usually reports old patterns that predate the current team. A better gate is ratcheting: fail only on new high-confidence findings, then burn down the old baseline by component. GitHub Advanced Security, GitLab SAST, and SonarQube can all support this style through baseline comparison or quality-gate configuration, but the useful rule is social: new debt is blocked, old debt is scheduled.

Use metrics that QA can defend. Mean time to remediate is useful when measured from accepted finding to merged fix, because measuring from scanner discovery punishes teams for importing old debt. Escaped security defects are useful when counted from incidents and support reports, because they show what the test suite missed. Raw alert count is weak because adding Semgrep rules can make the number rise even when the application gets safer.

A smaller standard beats a heroic checklist

OWASP ASVS 4.0.3 is valuable, but using the full standard as a first checklist is usually performative in an inherited application because the standard contains 286 verification requirements as a published count, and many require architecture knowledge that QA may not have yet. Pick a profile instead: session management, access control, input handling, dependency hygiene, and security logging.

Standards should constrain maintenance, not inflate it. RFC 9110 helps you reason about HTTP semantics such as safe methods and redirects; OpenID Connect Core 1.0 and OAuth 2.1 draft language help you challenge token handling; NIST SP 800-63B gives reauthentication guidance, including 12 hours overall and 15 minutes of inactivity for AAL2 sessions as externally published limits, but those numbers still need product approval because user interruption is a real cost.

Your minimum viable security standard should fit on one page. For example:

  • Every protected route has one negative authorization test because missing denial checks are common in inherited role systems.
  • Every new dependency update from Dependabot or Renovate must show reachability notes for critical advisories because unused vulnerable packages should not consume the same urgency as exposed code paths.
  • Every security bug needs a regression test before closure because QA cannot maintain institutional memory through ticket comments.
  • Every scanner suppression has an owner and expiry date because exceptions without owners outlive the reason they were granted.
  • Every production security header change begins in report-only or staged mode because headers such as CSP and HSTS can break assets or lock users into bad HTTPS assumptions.

I would not enforce HSTS with max-age=31536000; includeSubDomains; preload on an inherited estate during the first pass, because a forgotten subdomain or mixed-content dependency can turn a header improvement into an outage. I would start with a tunable max-age=15552000 only after confirming HTTPS coverage for active hosts, and I would avoid preload until DNS ownership and redirects are audited.

The same caution applies to dependency automation. Dependabot and Renovate are excellent for visibility, but auto-merging security updates in an untested app is risky because transitive changes can alter parsing, serialization, authentication middleware, or template escaping. Auto-merge patch updates only after the characterization suite covers the package’s behavior, because otherwise QA inherits a stream of “security fixes” that nobody can verify.

Start with one protected path and one failing test

Your first concrete action should be to choose one sensitive URL, write one Playwright or Cypress test proving the wrong user is denied, and run it in CI before adding another scanner gate. Then attach ZAP, Semgrep, or CodeQL reports as evidence rather than authority. That order gives QA a maintainable foothold: one behavior, one owner, one repeatable signal.