Skip to content
SecHelixv3 alpha
GitHub
DocsContributeSupportWorkbenchGitHub
Back to overview
Review guide · Regression proof

A fix without a failing test is a claim about a fix.

Security regression testing means writing the test against the vulnerable build first, watching it fail for the security reason, then applying the repair. If the test never failed, it proves the harness runs, not that the invariant holds.

Part of the AppSec agent guide.

A security regression test is one you watched fail against the vulnerable build, for the security reason, before the repair existed. If it was written after the fix and has only ever passed, it proves the harness runs. It does not prove the invariant holds, and it cannot tell you when the invariant stops holding, because you have never seen it fail.

That ordering is the whole discipline, and it has two corollaries that are easy to miss: the test belongs at the layer where the invariant actually lives, and it has to assert the legitimate path still works. A test that only proves hostile input is rejected also passes when the feature is broken for everyone.

Fails before the fixPasses after itFails closed in the gate

The order of operations

  1. Reproduce firstEstablish the failing behaviour in an isolated environment with harmless fixture state, and record the exact command and output.
  2. Write the test against the vulnerable buildAssert the security property, not the symptom. Run it and confirm it fails, and confirm it fails for the reason you expect rather than for a setup error.
  3. Repair the canonical invariantFix the boundary that owns the rule, not the call site that exposed it. A repair applied at one caller leaves the siblings intact.
  4. Rerun without changing the testThe same command, the same assertion. Editing the assertion between the failing run and the passing run discards the evidence.
  5. Assert the legitimate path in the same testOtherwise a blanket denial passes as a fix, and the regression suite will happily protect a broken feature.
  6. Record the command and the assertionThe report contract requires both, plus a status. A prose claim that the fix was tested is not a regression record.

Two-sided assertions

The paired evaluation fixtures are built to enforce this habit. Every fixture ships a vulnerable variant and a clean variant of the same file, and the clean variant still has to do its job. Several pairs are deliberately built so the alarming-looking file is the safe one, which means an assertion keyed to surface patterns fails the pair rather than passing it.

One fixture pair makes the point precisely. The vulnerable variant of a URL fetcher has an address allowlist; it validates the destination once and then follows redirects without re-validating, so a permitted host can redirect the fetch to a private address. The clean sibling disables automatic redirects and revalidates every hop in a bounded loop. A test asserting only that an allowlist exists passes both.

The published case study followed the same rule. Ten regression assertions cover the fix, and they are not all denials.

AssertionSide
A catch-all header rule emits all six response headersControl present
Both X-Frame-Options: DENY and CSP frame-ancestors none are setControl present at two layers
javascript:, data:, vbscript:, and protocol-relative URLs are rejectedHostile input refused
Relative paths, https:, and mailto: still resolveLegitimate input preserved
The config normalizer neutralizes hostile upstream values end to endBoundary holds

Put it where the invariant lives

A test that calls the service function directly will pass while the route, the worker, or the database policy remains defective. Choose the layer by asking where the rule is canonically enforced, then test one layer below where you would prefer to test.

Object authorization
At the data-access boundary, with two owned identities, not at the route handler.
Tenant isolation
Against the database with the runtime role the application actually connects as.
Single-use transitions
With two controlled concurrent callers, counting committed effects.
Response headers and framing
Against the built artifact as served, not against the configuration object.
Tool authority
Against the dispatcher call log, with every tool stubbed to record rather than act.

The report contract records the result as a structured object rather than as prose, and each Gold Check Pack names the regression fixture its class is expected to leave behind. The status vocabulary is deliberately small.

The regression record in a finding
"regression": {
  "status": "PASS",
  "command": "pnpm vitest run tests/security.test.ts",
  "assertion": "Every response carries frame-ancestors 'none' and X-Frame-Options: DENY,
                and a relative CTA href still renders."
}
  • NOT_RUN, PASS, FAIL, and NOT_PRACTICAL are the four permitted statuses. There is no state that means "probably fine".
  • command and assertion are both required, so the claim is reproducible by someone who was not there.
  • Scanner output alone cannot close a verified finding. A clean rescan is not a regression proof.

The traps that make a passing test meaningless

  • A stale build cacheIn the published run the first retest appeared to fail: the headers were still missing and the hostile URL was still in the document. The cause was a stale prerender cache plus a server still bound to the old port. Only a clean rebuild proved the fix.
  • A green typecheck read as proofTypes compiling says nothing about what the built application serves. The rule is written into the workflow for exactly this reason.
  • An assertion edited between runsIf the test that failed is not the test that passed, there is no before-and-after. Change the code, never the assertion.
  • A test that never failedWritten after the repair, it has no demonstrated relationship to the defect. It is a smoke test wearing a security label.
  • The symptom instead of the invariantAsserting that one payload is blocked protects against that payload. Asserting the invariant protects against the class.

When the retest is right, the evidence is unambiguous. In the recorded run the browser refused the frame itself rather than the test asserting an absence.

Retest output
Framing 'http://localhost:3009/' violates the following Content Security Policy
directive: "frame-ancestors 'none'". The request has been blocked.

Connect it to a decision

A regression record only matters if something reads it. Generate the canonical report in one step and run the gate in a separate, fail-closed step. Both commands are standard-library Python from the open repository, available once you have installed the skill.

Validate, then gate
python scripts/validate_contract.py report report.json
python scripts/security_gate.py report.json --policy policies/default.json
# exit 0  PASS or PASS_WITH_KNOWN_RISK
# exit 1  BLOCKED
# exit 2  INCOMPLETE or malformed input
  • Treat exit codes 1 and 2 as non-green. A run that could not produce evidence is not a run that found nothing.
  • INCOMPLETE exists so that missing evidence has somewhere to go other than PASS.
  • PASS_WITH_KNOWN_RISK requires an owner, a reason, an approval time, and an expiry. A model may summarize an accepted risk; it may not approve one.
  • Coverage counts travel with the report, including how many integrity-critical hypotheses ended UNKNOWN.
Install

Watch it fail, then make it pass.

npx skills@latest add omarmohelal/SecHelix --skill sechelix