How to turn a working exploit into a repeatable GitHub Actions test with h5i
Press enter or click to view image in full size
AI has made it much easier to find potential vulnerabilities, inspect application code, mutate HTTP requests, and assemble proof-of-concept attacks.
However, finding a vulnerability is only half the job. A bug may be fixed today and quietly reintroduced during a refactor six months later (it often happenes to me when I use Claude or Codex). A new endpoint may repeat the same authorization mistake.
Thus, it is significantly important to implement the attack as a regression test that runs in CI/CD, rather than just fixing a bug.
In this article, I will show how to encode a confirmed attack flow with h5i and replay it in GitHub Actions.
Example Scenario
Suppose an application exposes this endpoint:
GET /api/documents/{documentId}During testing, we discover an IDOR vulnerability: after logging in as Alice, changing the document ID in the URL may return a document owned by Bob. Here, the security property that we want to satisfy is “Alice must not be able to read Bob’s document.”
Hence, we want CI to perform the following steps on every pull request:
- Log in as Alice.
- Request a document that belongs to Bob.
- Verify that the application denies access.
- Verify that Bob’s data does not appear in the response body.
You could express this in an application-specific integration test. But preserving the actual HTTP attack flow gives us a useful black-box property: we test what an external attacker can observe, rather than only the internal function that was patched.
What h5i does in CI
h5i is a lightweight browser and HTTP testing tool for red-team. While h5i is quite useful for interactive red-teaming, we can also easily integrate it with CI/CD pipeline like GitHub Actions.
repository-owned attack flow
↓
structured evidence
↓
application-owned oracleh5i replays the requests and records the responses. A repository-owned program, called an oracle, decides whether the evidence satisfies the application’s security property.
Repository layout
For this example, we add four files:
.
├── .github/
│ └── workflows/
│ └── security.yml
├── .h5i-tests/
│ ├── oracles/
│ │ └── document-isolation.sh
│ └── tests/
│ └── document-isolation.yaml
└── openapi.yamldocument-isolation.yamldescribes the HTTP requests and their order.document-isolation.shdecides whether the observed responses are secure.security.ymlstarts the application and runs the test in GitHub Actions.openapi.yamlis optional and provides a denominator for API-operation coverage.
1. Describe the attack flow
Create .h5i-tests/tests/document-isolation.yaml:
version: h5i.test/v1
id: document-isolation
name: Alice cannot read Bob's documentactors:
alice: {}
requests:
login:
method: POST
path: /api/session
headers:
content-type: application/json
json:
username: alice
password: "${env.H5I_ALICE_PASSWORD}"
other-users-document:
method: GET
path: "/api/documents/${env.H5I_OTHER_DOCUMENT_ID}"
headers:
accept: application/json
flow:
- send: login
as: alice
save: login_result
- send: other-users-document
as: alice
save: attack
covers:
operation: getDocument
mutation: identifier-substitution
oracle:
command: [../oracles/document-isolation.sh]
inputs: [login_result, attack]
timeout_seconds: 10
An actor owns an isolated cookie jar, so the second request uses the session established by the first request.
Values such as the password and Bob’s document ID come from environment variables through ${env.NAME}. The CI job should use seeded, non-production fixtures. Production credentials and production customer data do not belong in this test.
The covers block records that this flow exercises the OpenAPI operation getDocument using an identifier-substitution attack. We will use that metadata later for coverage reporting.
2. Define what “the attack failed” means
Now create .h5i-tests/oracles/document-isolation.sh:
#!/bin/sh
set -eulogin_status="$(jq -r '.responses.login_result.status' "$H5I_TEST_RESULT")"
attack_status="$(jq -r '.responses.attack.status' "$H5I_TEST_RESULT")"
attack_body="$(jq -r '.responses.attack.body' "$H5I_TEST_RESULT")"
forbidden_marker="${H5I_FORBIDDEN_MARKER:?set H5I_FORBIDDEN_MARKER}"
# If login failed, the security property was not actually tested.
case "$login_status" in
200|204) ;;
*) exit 2 ;;
esac
# Alice's request for Bob's document must be denied.
case "$attack_status" in
403|404) ;;
*) exit 1 ;;
esac
# Bob's data must not appear in the response body either.
if grep -Fq "$forbidden_marker" "$H5I_TEST_ARTIFACTS/$attack_body"; then
exit 1
fi
h5i provides the oracle with three relevant environment variables:
H5I_TEST_RESULTpoints to a JSON evidence bundle containing response metadata.H5I_TEST_ARTIFACTSpoints to the owner-only directory containing response bodies and related evidence.H5I_TEST_INPUTScontains the saved response names declared inoracle.inputs.
In h5i, the oracle contract is intentionally small:
- Exit
0: the security property held. - Exit
1: the security property was violated. - Any other exit code or a timeout: the test could not reach a conclusion.
3. Replay it in GitHub Actions
Add .github/workflows/security.yml:
name: Security regression testson:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
security-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Start application
run: docker compose up -d --wait
- name: Replay security regression tests
uses: h5i-dev/h5i@v1
env:
# Seeded, non-production CI fixtures
H5I_ALICE_PASSWORD: alice-ci-password
H5I_OTHER_DOCUMENT_ID: "2"
H5I_FORBIDDEN_MARKER: bob@example.test
with:
target: http://localhost:3000
tests: .h5i-tests/tests
openapi: openapi.yaml
output: h5i-test-results
- name: Upload h5i evidence
if: always()
uses: actions/upload-artifact@v4
with:
name: h5i-test-results
path: h5i-test-results/
if-no-files-found: warn
This example assumes that docker compose up -d --wait starts the application at http://localhost:3000. Replace that command with however your project starts its integration-test environment.
The h5i action writes:
result.jsonfor structured processing,junit.xmlfor CI test reporting, and- per-response artifacts for investigation when a test fails.
Response evidence may contain sensitive application data. Treat the uploaded directory accordingly: restrict access, choose an appropriate retention period, and avoid using production data.
For stricter supply-chain controls, pin h5i-dev/h5i@v1 and other third-party actions to reviewed commit SHAs rather than moving tags.
4. Measure attack coverage
As the suite grows, a simple test count becomes less informative. Ten passing attacks might cover ten different API operations, or the same operation ten times.
h5i can compare each test’s covers.operation field with the operationId values in an OpenAPI document. It then reports the proportion of API operations for which an attack flow reached a conclusive oracle result.
A minimal openapi.yaml could look like this:
openapi: 3.0.0
info:
title: Example document API
version: 1.0.0
paths:
/api/documents/{documentId}:
get:
operationId: getDocument
responses:
"200":
description: Document
"403":
description: Forbidden
/api/profile:
get:
operationId: getProfile
responses:
"200":
description: Current user profileThis metric is not code coverage, and it is certainly not the probability that the application is vulnerability-free. It answers how many declared API operations do we have at least one attack flow whose evidence was checked by an oracle.
Summary
AI can accelerate the exploratory side of security testing: reading code, identifying suspicious flows, enumerating endpoints, mutating requests, and proposing exploit chains.
But asking an agent to rediscover every vulnerability from scratch on every pull request is neither deterministic nor efficient. Even a capable model may take a different path, overlook a previously known bug, or stop before reproducing the exact condition that matters.
A better division of labor is:
- Explore: A human or AI discovers an unknown vulnerability.
- Freeze: The confirmed request sequence and expected defense become reviewable repository files.
- Regress: CI replays the attack deterministically on every relevant change.
As vulnerability discovery gets cheaper, retaining what we learn becomes more important. The final step after fixing a security bug should be to make the exploit fail forever.