Pentest regression scripts for every deployment
Five open-source shell probes check, after each deployment, that five fixed web findings have not come back.
Only on systems you own or may test
Run these scripts only against systems you own, or that you are authorised in writing to test, within the scope and the time window of that authorisation. Sending requests to someone else's system without permission is an offence in most countries (for example the UK Computer Misuse Act 1990 and the US Computer Fraud and Abuse Act, 18 U.S.C. 1030). The probes are light, but they are still requests that a security team may see as an attack: tell the people who watch the target's logs, and identify the probes with the PROBE_UA user agent.
Your systems, or written permission
It is an offence elsewhere
Tell whoever watches the logs
A fixed finding can come back without a sound
A finding fixed after a penetration test can come back weeks later without anyone noticing: a deployment from a branch that predates the fix, a web server or proxy rule rolled back, a plugin update that resets a hook, a new endpoint added without the guard of the others. Nobody re-tests until the next penetration test finds the same thing again. A probe that runs after every deployment turns that silent return into a failed pipeline. Testing executable code as part of delivery is a practice of the NIST Secure Software Development Framework, NIST SP 800-218, Secure Software Development Framework (SSDF) Version 1.1 (2022-02).
A deployment from an old branch
A rule rolled back
An update that resets a hook
A new route without the guard
Five probes, one runner, one self-test
Five read-mostly HTTP probes, one per finding, plus a runner. Each probe sends one request, prints one line and exits 0 (the fix holds), 1 (it regressed) or 2 (usage error or target unreachable, never counted as a pass). Needs bash, curl (and python3 for the self-test only). Every route is yours to set: the package names no site.
The five probes, one by one
Click a probe for what it checks, when it passes, why it matters, and the script itself.
What it checks
Whether the answer allows that foreign origin: an Access-Control-Allow-Origin header that echoes it back, or that says null.
The request
One GET to a path you choose (default /) with the header Origin: https://pca-regression-probe.invalid.
It passes when
The foreign origin is not allowed (no header, or a header naming only your own origins).
Why it matters
A server that echoes any origin lets a page on any other site read the answers a logged-in visitor's browser gets, tokens and personal data included.
It usually comes back when
A CORS rule in the web server, the proxy or the application middleware is reset to « allow the caller's origin ».
Usage
bash probe-a1-cors-reflection.sh <base_url> [path]The script (probe-a1-cors-reflection.sh, MIT)
#!/usr/bin/env bash
# probe-a1-cors-reflection.sh — CORS origin reflection regression check (CWE-942)
# Sends ONE GET with a foreign Origin header and fails if the response allows that origin.
# Usage: bash probe-a1-cors-reflection.sh <base_url> [path] (path default: /)
# PROBE_ORIGIN=https://other.invalid bash probe-a1-cors-reflection.sh <base_url> /api/me
# Exit: 0 = foreign origin not allowed · 1 = regression · 2 = usage error or unreachable
# Only run against systems you own or are authorised in writing to test.
set -uo pipefail
. "$(dirname "${0}")/_common.sh"
U="${0} <base_url> [path]"
BASE_URL="${1:-}"; PROBE_PATH="${2:-/}"
[ -n "$BASE_URL" ] || pr_usage "$U"; pr_check_base "$BASE_URL" "$U"
ORIGIN="${PROBE_ORIGIN:-https://pca-regression-probe.invalid}" # .invalid can never be a real site (RFC 2606)
hdrs=$(curl -s -o /dev/null -D - -A "$UA" -H "Origin: $ORIGIN" --max-time "$MAXT" "$BASE_URL$PROBE_PATH" 2>/dev/null | tr -d '\r')
code=$(printf '%s\n' "$hdrs" | sed -nE 's|^HTTP/[0-9.]+ ([0-9]{3}).*|\1|p' | tail -1)
[ -n "$code" ] || pr_unreachable A1 "$BASE_URL$PROBE_PATH"
acao=$(printf '%s\n' "$hdrs" | grep -i '^access-control-allow-origin:' | tail -1 | cut -d: -f2- | sed 's/^ *//')
acac=$(printf '%s\n' "$hdrs" | grep -i '^access-control-allow-credentials:' | tail -1 | cut -d: -f2- | sed 's/^ *//')
if [ "$acao" = "$ORIGIN" ] || [ "$acao" = "null" ]; then
echo "🔴 A1/CWE-942 REGRESSED — $PROBE_PATH allows the foreign origin: Access-Control-Allow-Origin: $acao${acac:+ · Allow-Credentials: $acac}"
exit 1
fi
echo "✅ A1/CWE-942 OK — $PROBE_PATH (HTTP $code) does not allow a foreign origin${acao:+ (Access-Control-Allow-Origin: $acao)}"
exit 0Sources: MITRE CWE-942, Permissive Cross-domain Security Policy with Untrusted Domains, CWE list 5.0 (2026-10-28) · OWASP Web Security Testing Guide v4.2 (tests 4.11.7 CORS, 4.3.4 account enumeration, 4.8 error handling, 4.1.2 fingerprinting) (2020-12-03)
What it checks
Whether the answer redirects to /author/<login>/, which discloses an account name.
The request
One GET to /?author=N (default N=1), redirects not followed.
It passes when
No redirect to an author page (a 403, a 404 or the home page are all fine).
Why it matters
Known login names make password spraying and brute force much cheaper.
It usually comes back when
A security plugin is replaced or a theme update resets the hook or the rewrite rule that blocked ?author=.
Usage
bash probe-a2-user-enumeration.sh <base_url> [author_id]The script (probe-a2-user-enumeration.sh, MIT)
#!/usr/bin/env bash
# probe-a2-user-enumeration.sh — WordPress ?author=N login enumeration regression check (CWE-200)
# Sends ONE GET to /?author=N, does not follow redirects, fails if the answer points to /author/<login>/.
# Usage: bash probe-a2-user-enumeration.sh <base_url> [author_id] (author_id default: 1)
# Exit: 0 = no login disclosed · 1 = regression · 2 = usage error or unreachable
# Only run against systems you own or are authorised in writing to test.
set -uo pipefail
. "$(dirname "${0}")/_common.sh"
U="${0} <base_url> [author_id]"
BASE_URL="${1:-}"; AUTHOR_ID="${2:-1}"
[ -n "$BASE_URL" ] || pr_usage "$U"; pr_check_base "$BASE_URL" "$U"
case "$AUTHOR_ID" in ''|*[!0-9]*) pr_usage "$U" ;; esac
hdrs=$(curl -s -o /dev/null -D - -A "$UA" --max-time "$MAXT" "$BASE_URL/?author=$AUTHOR_ID" 2>/dev/null | tr -d '\r')
code=$(printf '%s\n' "$hdrs" | sed -nE 's|^HTTP/[0-9.]+ ([0-9]{3}).*|\1|p' | tail -1)
[ -n "$code" ] || pr_unreachable A2 "$BASE_URL/?author=$AUTHOR_ID"
loc=$(printf '%s\n' "$hdrs" | grep -i '^location:' | tail -1 | cut -d: -f2- | sed 's/^ *//')
if printf '%s' "$loc" | grep -qi '/author/'; then
login=$(printf '%s' "$loc" | sed -nE 's|.*/author/([^/?#]+).*|\1|p')
echo "🔴 A2/CWE-200 REGRESSED — ?author=$AUTHOR_ID redirects to an author page and discloses the login « $login »"
exit 1
fi
echo "✅ A2/CWE-200 OK — ?author=$AUTHOR_ID answers HTTP $code without disclosing a login"
exit 0Sources: MITRE CWE-200, Exposure of Sensitive Information to an Unauthorized Actor, CWE list 5.0 (2026-10-28) · OWASP Web Security Testing Guide v4.2 (tests 4.11.7 CORS, 4.3.4 account enumeration, 4.8 error handling, 4.1.2 fingerprinting) (2020-12-03)
What it checks
Whether an anonymous call gets the expected answer (default 404, the route looks absent) instead of an error that names the integration behind it, a 5xx, or a 200.
The request
One empty POST (no body, no credentials) to a webhook route you name.
It passes when
The status code is one of the expected codes you pass (default 404).
Why it matters
An error that names the system behind a webhook tells an attacker which integration, and which team, to target.
It usually comes back when
A route is registered again without its guard, or an error handler starts returning detailed messages.
Note
The empty POST must be rejected before any handler runs. Do not use this probe on a route that acts on an empty POST.
Usage
bash probe-a3-webhook-info-leak.sh <base_url> <route> [expected_codes]The script (probe-a3-webhook-info-leak.sh, MIT)
#!/usr/bin/env bash
# probe-a3-webhook-info-leak.sh — hidden webhook route regression check (CWE-209)
# Sends ONE empty POST (no body, no credentials) to a webhook route that must stay invisible to anonymous callers,
# and fails unless the answer is one of the expected codes (default 404: the route looks like it does not exist).
# Usage: bash probe-a3-webhook-info-leak.sh <base_url> <route> [expected_codes]
# bash probe-a3-webhook-info-leak.sh https://your-site.test /wp-json/your-plugin/v1/webhook/ 404
# Exit: 0 = route hidden · 1 = regression (an error page that names the integration, a 5xx, a 200…) · 2 = usage or unreachable
# The empty POST must be rejected before any handler runs. If your route does work on an empty POST, do not use this probe on it.
# Only run against systems you own or are authorised in writing to test.
set -uo pipefail
. "$(dirname "${0}")/_common.sh"
U="${0} <base_url> <route> [expected_codes]"
BASE_URL="${1:-}"; ROUTE="${2:-}"; EXPECTED="${3:-404}"
[ -n "$BASE_URL" ] && [ -n "$ROUTE" ] || pr_usage "$U"; pr_check_base "$BASE_URL" "$U"
code=$(curl -s -o /dev/null -w '%{http_code}' -A "$UA" -X POST -H 'Content-Length: 0' --max-time "$MAXT" "$BASE_URL$ROUTE" 2>/dev/null)
[ "$code" != "000" ] && [ -n "$code" ] || pr_unreachable A3 "$BASE_URL$ROUTE"
if pr_in_list "$code" "$EXPECTED"; then
echo "✅ A3/CWE-209 OK — POST $ROUTE answers HTTP $code (expected $EXPECTED: route hidden)"
exit 0
fi
echo "🔴 A3/CWE-209 REGRESSED — POST $ROUTE answers HTTP $code, expected $EXPECTED"
exit 1Sources: MITRE CWE-209, Generation of Error Message Containing Sensitive Information, CWE list 5.0 (2026-10-28) · OWASP Web Security Testing Guide v4.2 (tests 4.11.7 CORS, 4.3.4 account enumeration, 4.8 error handling, 4.1.2 fingerprinting) (2020-12-03)
What it checks
Whether the route refuses an anonymous caller (default 401 or 403), and whether the body, even of a refusal, names the stack: database, cache, runtime version, memory limit, stack trace (LEAK_REGEX, which you can extend).
The request
One anonymous GET to a health or diagnostic route you name.
It passes when
An expected refusal code and none of the LEAK_REGEX words in the body.
Why it matters
Versions of the database, the cache and the runtime let an attacker pick known vulnerabilities that match them.
It usually comes back when
A « make the health check public for monitoring » change, or a plugin update that drops the permission check.
Usage
bash probe-a4-health-endpoint-recon.sh <base_url> <route> [expected_codes]The script (probe-a4-health-endpoint-recon.sh, MIT)
#!/usr/bin/env bash
# probe-a4-health-endpoint-recon.sh — health / diagnostic endpoint auth regression check (CWE-200, CWE-209)
# Sends ONE anonymous GET to a health or diagnostic route. Fails if the code is not an expected refusal (default 401,403)
# or if the body, even a refusal, contains infrastructure details (database, cache, runtime versions…).
# Usage: bash probe-a4-health-endpoint-recon.sh <base_url> <route> [expected_codes]
# LEAK_REGEX='redis|mysql' bash probe-a4-health-endpoint-recon.sh https://your-site.test /health/deep 401,403
# Exit: 0 = anonymous caller refused, nothing leaked · 1 = regression · 2 = usage or unreachable
# Only run against systems you own or are authorised in writing to test.
set -uo pipefail
. "$(dirname "${0}")/_common.sh"
U="${0} <base_url> <route> [expected_codes]"
BASE_URL="${1:-}"; ROUTE="${2:-}"; EXPECTED="${3:-401,403}"
LEAK_REGEX="${LEAK_REGEX:-redis|mysql|mariadb|postgres|opcache|php_version|phpversion|git_commit|db_host|memory_limit|stack trace}"
[ -n "$BASE_URL" ] && [ -n "$ROUTE" ] || pr_usage "$U"; pr_check_base "$BASE_URL" "$U"
body=$(mktemp "${TMPDIR:-/tmp}/pca-a4.XXXXXX"); trap 'rm -f "$body"' EXIT
code=$(curl -s -o "$body" -w '%{http_code}' -A "$UA" --max-time "$MAXT" "$BASE_URL$ROUTE" 2>/dev/null)
[ "$code" != "000" ] && [ -n "$code" ] || pr_unreachable A4 "$BASE_URL$ROUTE"
if ! pr_in_list "$code" "$EXPECTED"; then
echo "🔴 A4/CWE-209 REGRESSED — GET $ROUTE answers HTTP $code to an anonymous caller, expected $EXPECTED"
echo " ↳ check that the route still requires authentication"
exit 1
fi
if hit=$(grep -oiE "$LEAK_REGEX" "$body" | head -3 | tr '\n' ' ') && [ -n "$hit" ]; then
echo "🔴 A4/CWE-209 REGRESSED — GET $ROUTE is refused (HTTP $code) but the body names: $hit"
exit 1
fi
echo "✅ A4/CWE-209 OK — GET $ROUTE answers HTTP $code to an anonymous caller, no infrastructure detail in the body"
exit 0Sources: MITRE CWE-209, Generation of Error Message Containing Sensitive Information, CWE list 5.0 (2026-10-28) · MITRE CWE-200, Exposure of Sensitive Information to an Unauthorized Actor, CWE list 5.0 (2026-10-28) · OWASP Web Security Testing Guide v4.2 (tests 4.11.7 CORS, 4.3.4 account enumeration, 4.8 error handling, 4.1.2 fingerprinting) (2020-12-03)
What it checks
Whether the route refuses an anonymous caller (default 401 or 403), and whether the body carries a 40-character commit hash or a commit, revision or build id key (SHA_REGEX).
The request
One anonymous GET to a route that reports the deployed build (version, commit, deploy time).
It passes when
An expected refusal code and no commit or build id in the body.
Why it matters
The exact commit or version deployed tells an attacker which published vulnerabilities are still open.
It usually comes back when
A new « build info » route is added for an operations dashboard and left open.
Usage
bash probe-a5-build-commit-recon.sh <base_url> <route> [expected_codes]The script (probe-a5-build-commit-recon.sh, MIT)
#!/usr/bin/env bash
# probe-a5-build-commit-recon.sh — build / commit / version endpoint auth regression check (CWE-200)
# Sends ONE anonymous GET to a route that reports the deployed build (commit, version, deploy time). Fails if the code
# is not an expected refusal (default 401,403) or if the body carries something that looks like a commit hash.
# Usage: bash probe-a5-build-commit-recon.sh <base_url> <route> [expected_codes]
# for r in /version /build /buildinfo; do bash probe-a5-build-commit-recon.sh https://your-site.test "$r" 401,403,404; done
# Exit: 0 = anonymous caller refused, no commit leaked · 1 = regression · 2 = usage or unreachable
# Only run against systems you own or are authorised in writing to test.
set -uo pipefail
. "$(dirname "${0}")/_common.sh"
U="${0} <base_url> <route> [expected_codes]"
BASE_URL="${1:-}"; ROUTE="${2:-}"; EXPECTED="${3:-401,403}"
SHA_DEFAULT='(^|[^0-9a-f])[0-9a-f]{40}([^0-9a-f]|$)|"(commit|git_sha|revision|build_id)"' # a 40-hex commit, or a commit-like key
SHA_REGEX="${SHA_REGEX:-$SHA_DEFAULT}"
[ -n "$BASE_URL" ] && [ -n "$ROUTE" ] || pr_usage "$U"; pr_check_base "$BASE_URL" "$U"
body=$(mktemp "${TMPDIR:-/tmp}/pca-a5.XXXXXX"); trap 'rm -f "$body"' EXIT
code=$(curl -s -o "$body" -w '%{http_code}' -A "$UA" --max-time "$MAXT" "$BASE_URL$ROUTE" 2>/dev/null)
[ "$code" != "000" ] && [ -n "$code" ] || pr_unreachable A5 "$BASE_URL$ROUTE"
if ! pr_in_list "$code" "$EXPECTED"; then
echo "🔴 A5/CWE-200 REGRESSED — GET $ROUTE answers HTTP $code to an anonymous caller, expected $EXPECTED"
exit 1
fi
if grep -qiE "$SHA_REGEX" "$body"; then
echo "🔴 A5/CWE-200 REGRESSED — GET $ROUTE is refused (HTTP $code) but the body carries a commit or build id"
exit 1
fi
echo "✅ A5/CWE-200 OK — GET $ROUTE answers HTTP $code to an anonymous caller, no commit or build id in the body"
exit 0Sources: MITRE CWE-200, Exposure of Sensitive Information to an Unauthorized Actor, CWE list 5.0 (2026-10-28) · OWASP Web Security Testing Guide v4.2 (tests 4.11.7 CORS, 4.3.4 account enumeration, 4.8 error handling, 4.1.2 fingerprinting) (2020-12-03)
Run them after every deployment
Click an example. Adapt the routes to your own application; leave out a probe that does not apply.
Unzip the package, run the self-test once, then point the runner at a system you own. A probe whose route you do not set is skipped, and the runner says so.
# 1. prove the probes work, on your machine (127.0.0.1 only)
bash tests/selftest.sh
# 2. run them against YOUR site, with YOUR routes
A3_ROUTE=/wp-json/your-plugin/v1/webhook/ \
A4_ROUTE=/health/deep \
A5_ROUTE=/version \
bash run-all.sh https://www.your-site.test
echo "exit code: $?" # 0 holds · 1 regressed · 2 usage error or unreachableCopy the scripts into your repository (here tools/security/pentest-regression/) and add this step to a workflow that runs when your own deploy workflow succeeds: it fails if one probe regresses. STRICT=1 also fails it when a probe is skipped.
- uses: actions/checkout@v4
- name: Five post-deployment regression probes
env:
BASE_URL: ${{ vars.PROD_BASE_URL }} # e.g. https://www.your-site.test
A3_ROUTE: /wp-json/your-plugin/v1/webhook/ # a webhook that must stay hidden
A4_ROUTE: /health/deep # a diagnostic route that must refuse anonymous callers
A5_ROUTE: /version # a build / commit route that must refuse anonymous callers
STRICT: "1" # a skipped probe fails the job
run: bash tools/security/pentest-regression/run-all.sh "$BASE_URL"The complete workflow, with its trigger on your deployment, is ci/github-actions.yml in the zip.
The same, for GitLab: the job needs your production deployment job and installs bash and curl on a small Alpine image.
# Add to .gitlab-ci.yml: a job that runs after the deploy stage. Adapt the routes to your own application.
# Only point it at a system you own or are authorised in writing to test.
post-deploy-regression-probes:
stage: .post
image: alpine:3.20
needs: ["deploy-production"] # the name of your own deployment job
variables:
A3_ROUTE: /wp-json/your-plugin/v1/webhook/
A4_ROUTE: /health/deep
A5_ROUTE: /version
STRICT: "1"
script:
- apk add --no-cache bash curl
- bash tools/security/pentest-regression/run-all.sh "$PROD_BASE_URL"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHThe contract is the exit code. Anything that stops on a non-zero exit can use it:
bash run-all.sh "$BASE_URL" || exit 1Exit codes: 0 = every probe that ran holds · 1 = at least one regression · 2 = usage error, unreachable target, or a skip with STRICT=1. Variables: A1_PATH (default /) · A2_AUTHOR (default 1) · A3_ROUTE, A4_ROUTE, A5_ROUTE (a probe whose route is not set is skipped, and said so) · A3_EXPECTED (default 404) · A4_EXPECTED, A5_EXPECTED (default 401,403) · STRICT=1 makes a skip fail · MAXT (seconds per request, default 15) · PROBE_UA (the user agent the probes send).
What a pass does not tell you
Read these before you rely on a green pipeline.
Limit 1
Limit 2
Limit 3
Limit 4
Limit 5
Limit 6
Limit 7
Download the scripts
Version 1.0, published 11 November 2026. Generic: no client, site or target named. Tested with the included self-test (13 cases: hardened, regressed, leaking refusals, unreachable).
Pentest regression scripts: questions
Click a question to read the answer.
No. They re-check five findings that a penetration test already found and that were fixed. They find nothing new, see only what an anonymous caller sees, and a pass does not mean the site is secure. Keep testing with people.
No. Run them only against systems you own or are authorised in writing to test, within that scope and time window. Sending requests to someone else's system without permission is an offence in most countries.
One request per probe: four GET requests and one empty POST, with a user agent you can set. No payload, no exploit, no login, nothing stored on the target. The empty POST of A3 must be refused before any handler runs.
Every CI runner has them, the scripts can be read in a few minutes before you trust them, and there is no dependency to keep up to date. Python is needed only for the local self-test.
MIT: copy, change and ship them, including in commercial work, as long as the copyright notice and the licence stay with the files.
Copy one probe, keep the exit code contract (0 holds, 1 regressed, 2 usage error or unreachable), add it to run-all.sh, and add a hardened and a regressed case to the test server so the self-test proves it.
Related pages
Would your pipeline notice a fix coming undone?
Twelve questions, about two minutes, for the engineer who runs deployments, accounts and network: it runs in the browser and sends nothing.
Test your security engineer
