docs(pentest): add send-package SOP + seed-test-accounts helper

The pentest scope doc (PENTEST_SCOPE_2026.md) is the technical brief —
what's testable, what's out, what to focus on. But it doesn't tell
the operator HOW to send the engagement off : credentials delivery
plan, IP allow-list step, kick-off email template, alert-tuning
during the engagement window. So historically each engagement has
been a one-off that depends on whoever was on duty remembering the
last time.

Added :

  * docs/PENTEST_SEND_PACKAGE.md — 5-step send sequence (NDA →
    credentials → IP allow-list → kick-off email → alert tuning),
    reception checklist, and post-engagement housekeeping. Email
    template inline so it's grep-able and version-controlled.

  * scripts/pentest/seed-test-accounts.sh — provisions the 3 staging
    accounts (listener/creator/admin) referenced by §"Authentication
    context" of the scope doc. Generates 32-char random passwords,
    probes each by login, emits 1Password import JSON to stdout
    (passwords NEVER printed to the screen). Refuses to run against
    any env that isn't "staging".

The send-package doc references one helper that doesn't exist yet :
  * infra/ansible/playbooks/pentest_allowlist_ip.yml — Forgejo IP
    allow-list automation. Punted to a follow-up because the manual
    SSH path is fine for once-per-engagement use and Ansible
    formalisation deserves its own commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-30 22:29:35 +02:00
parent 05b1d81d30
commit e780fbcd18
2 changed files with 378 additions and 0 deletions

View file

@ -0,0 +1,187 @@
# Pentest send package — v2026 engagement
> Operational checklist for handing off the v1.0.9 pre-launch pentest
> brief to the external team. Companion to `docs/PENTEST_SCOPE_2026.md`
> (the technical scope) — this doc is purely "what you send, in what
> order, via which channel."
The scope doc is technical and reusable across engagements. This file
is the per-engagement "send package" that wraps it: the email template,
the credentials-delivery plan, the IP allow-list step, and the kick-off
checklist.
## The 5-step send sequence
Run these in order. Each step has a check (✓) the operator ticks before
moving to the next — out-of-order steps cause the engagement to stall.
### Step 1 — counter-sign the NDA + authorisation letter
- [ ] NDA template signed by the pentester firm and counter-signed by us.
- [ ] Authorisation-to-test letter signed by Veza tech lead (limits the
scope to what's in `PENTEST_SCOPE_2026.md` §"In-scope assets" — the
letter MUST list the staging URL explicitly so a reviewer can map
pentester traffic to authorised activity).
- [ ] Both PDFs uploaded to the shared 1Password vault (entry name :
`pentest-2026-legal`). Do **not** email PDFs.
### Step 2 — provision pentester credentials
- [ ] Run `bash scripts/pentest/seed-test-accounts.sh staging` (creates
the 3 accounts from `PENTEST_SCOPE_2026.md` §"Authentication
context", outputs random passwords).
- [ ] Output passwords land in three 1Password entries :
`pentest-2026-listener`, `pentest-2026-creator`, `pentest-2026-admin`.
Each entry's "Notes" field includes the role and the MFA bypass
token if applicable.
- [ ] Share each entry **read-only** with the pentester's 1Password
account using the firm's billing email. Do **not** put passwords
in chat, email, or shell history.
- [ ] Set entry expiration to engagement-end + 7 days (so cleanup is
automatic if the team forgets to revoke).
### Step 3 — allow-list the pentester's IP
The Forgejo source-code mirror at `https://10.0.20.105:3000/senke/veza`
is grey-box read-only access. The pentester needs their static
egress IP allow-listed before they can `git clone`.
- [ ] Pentester sends their static egress IP (PGP-signed mail, or
1Password Notes field).
- [ ] SSH to `srv-102v` (Forgejo container) and add the IP to
`/etc/forgejo/allowlist.conf`.
- [ ] `systemctl reload forgejo`.
- [ ] Verify : `curl -I https://10.0.20.105:3000/senke/veza` from the
pentester IP returns 200 ; from any other IP, 403.
(A future iteration could turn this into an Ansible playbook
`infra/ansible/playbooks/pentest_allowlist_ip.yml`. For now the manual
SSH path is fine — this happens once per engagement.)
### Step 4 — send the kick-off email
Use the template below. Replace the placeholders inside `<…>`. Send
PGP-encrypted (the pentester's key is in their security.txt) to
**both** their lead pentester and their project manager so the chain
of responsibility is recorded.
```text
Subject : [PENTEST] Veza v1.0.9 pre-launch engagement — kick-off
Hi <lead pentester first name>,
Per the signed scope letter dated <YYYY-MM-DD>, the Veza v1.0.9
pre-launch pentest engagement starts on <YYYY-MM-DD>. The brief is
attached as PENTEST_SCOPE_2026.md (see also the rendered HTML at
https://staging.veza.fr/legal/pentest-scope-2026.html).
Quick links :
• Staging URL : https://staging.veza.fr
• Source code : https://10.0.20.105:3000/senke/veza
(grey-box, read-only ; your egress IP <PENTESTER_IP>
has been allow-listed as of <YYYY-MM-DD HH:MM UTC>.)
• Status page : https://status.veza.fr (we'll lower the alert
threshold during your engagement so the SOC isn't
paged on every benign 401).
• Test accounts: shared with your firm's 1Password — entries
pentest-2026-{listener,creator,admin}. Passwords
expire <engagement_end + 7d>.
Engagement window :
• Start : <YYYY-MM-DD>
• End : <YYYY-MM-DD> (~10 business days)
• Re-test: 1 round, after our team's fix pass (typically 2 weeks
after the initial report)
Communications :
• Async : security@veza.fr (PGP fingerprint at
https://veza.fr/.well-known/security.txt)
• Weekly sync : <weekday HH:MM TZ>, video link in the calendar invite
• Critical findings : phone the on-call number in the contract
(HIGH severity = phone, not email)
Expected deliverables :
• Initial findings report (markdown or PDF) at engagement end
• Re-test report after our fix pass
• Optional : exec-level summary slide deck
Reach out if anything in PENTEST_SCOPE_2026.md is unclear before
day 1. Otherwise — good hunting.
Best,
<Tech lead name>
Veza
```
- [ ] Email PGP-signed and sent.
- [ ] Calendar invite sent for the weekly sync.
- [ ] Slack/Signal channel created for HIGH-severity escalation
(channel naming : `#pentest-2026-veza`).
### Step 5 — lower the SOC alerting threshold
During the engagement, automated scanners and authentication
brute-force attempts WILL fire alerts. Tune them down so the on-call
isn't paged on every legitimate pentester action.
- [ ] In `config/prometheus/alert_rules.yml``HighErrorRate`,
`HighLatencyP99` : add a `for: 30m` override OR mute via
Alertmanager silence (recommended: silence rather than edit
rules so the change auto-expires at engagement end).
- [ ] Silence URL : `https://prometheus.veza.fr/alertmanager/#/silences/new`
→ matchers: `severity=warning`, comment: `pentest-2026 active`,
duration: `engagement_end + 24h`.
- [ ] Subscribe the engagement Slack channel to the silence's
auto-removal so the SOC knows when the heightened alerting
resumes.
## Reception checklist (after pentester confirms receipt)
- [ ] Pentester replied to the kick-off email within 1 business day.
- [ ] Pentester confirmed they can `git clone` the source repo.
- [ ] Pentester confirmed they can log in as each of the 3 test
accounts.
- [ ] Pentester confirmed the staging URL responds (`/api/v1/health`
returns 200).
- [ ] First findings — even informational — start landing in the
shared report by end of engagement day 3 (a complete silence
until the final report is a process smell).
If any reception checklist item fails after 24h, the engagement
hasn't really started. Phone the firm's PM, don't email.
## Post-engagement housekeeping
- [ ] Findings report received → import into the issue tracker as
separate tickets, severity preserved, attribution
`external-pentest-2026`.
- [ ] Fix pass scheduled and timeboxed (HIGH within 1 week, MEDIUM
within 4 weeks, LOW best-effort).
- [ ] Re-test scheduled 2 weeks after fix-pass start.
- [ ] Re-test report received → update the ticket statuses ; any
remaining unresolved finding above LOW blocks v2.0.0-public.
- [ ] Test accounts' passwords manually rotated **the day the
engagement ends** (don't wait for 1Password's auto-expiry).
- [ ] Pentester IP removed from Forgejo allow-list.
- [ ] Alertmanager silence removed (should auto-remove, but verify).
- [ ] Engagement folder zipped and stored at
`docs/archive/pentest-2026/` (kept 5 years for audit trail).
- [ ] Public summary blog post drafted (no findings details, just the
"we did this, here's what we learned" framing). Reviewed by
legal before publish.
## Linked artefacts
- `docs/PENTEST_SCOPE_2026.md` — the technical scope (what's testable)
- `docs/SECURITY_PRELAUNCH_AUDIT.md` — internal Day 21 audit (what we
already cleared)
- `docs/archive/PENTEST_REPORT_VEZA_v0.12.6.md` — last engagement's
report, format reference for what to expect back
- `scripts/pentest/seed-test-accounts.sh` — credential provisioning
helper (creates the 3 staging accounts referenced in the scope)
- `docs/GO_NO_GO_CHECKLIST_v2.0.0_PUBLIC.md` — the row this engagement
unblocks

View file

@ -0,0 +1,191 @@
#!/usr/bin/env bash
# seed-test-accounts.sh — provision the 3 pentester accounts on a target
# environment (staging only ; refuses to run against prod).
#
# Per docs/PENTEST_SCOPE_2026.md §"Authentication context", an external
# pentest engagement needs three pre-seeded accounts (listener, creator,
# admin). This script :
#
# 1. Generates a 32-char random password for each role.
# 2. Calls the staging admin API to create / reset each account.
# 3. Promotes creator to creator, admin to admin (via direct DB UPDATE
# because the public API doesn't expose role changes — operator
# runs that step from a maintenance shell).
# 4. Writes a 1Password import JSON to stdout so the operator can
# `op item template` it into the shared vault. NEVER prints
# passwords to the screen.
#
# Usage :
# bash scripts/pentest/seed-test-accounts.sh staging
#
# Output :
# 1Password JSON on stdout (3 entries). Pipe into a file, then
# `op item create --vault Pentest-2026 - < file.json`.
#
# Exit codes :
# 0 — three accounts provisioned, JSON emitted
# 1 — API call failed (account creation or login probe)
# 2 — wrong target environment (e.g. operator passed "prod")
# 3 — required env var or tool missing
set -euo pipefail
ENV_NAME=${1:-}
if [ -z "$ENV_NAME" ]; then
cat >&2 <<EOF
usage : bash scripts/pentest/seed-test-accounts.sh <env>
env : staging (the only accepted value — prod is refused)
Required env vars :
STAGING_URL base URL (e.g. https://staging.veza.fr)
STAGING_ADMIN_EMAIL admin who creates the accounts
STAGING_ADMIN_PASSWORD admin password (provisioning cred only)
Output :
1Password import JSON for vault Pentest-2026, on stdout.
Passwords are NEVER printed to the operator's screen.
EOF
exit 3
fi
if [ "$ENV_NAME" != "staging" ]; then
echo "ERROR: this script refuses to run against any env other than 'staging'." >&2
echo " Pentest accounts on production violate the engagement scope." >&2
exit 2
fi
STAGING_URL=${STAGING_URL:-?}
STAGING_ADMIN_EMAIL=${STAGING_ADMIN_EMAIL:-?}
STAGING_ADMIN_PASSWORD=${STAGING_ADMIN_PASSWORD:-?}
[ "$STAGING_URL" = "?" ] && { echo "STAGING_URL required" >&2; exit 3; }
[ "$STAGING_ADMIN_EMAIL" = "?" ] && { echo "STAGING_ADMIN_EMAIL required" >&2; exit 3; }
[ "$STAGING_ADMIN_PASSWORD" = "?" ] && { echo "STAGING_ADMIN_PASSWORD required" >&2; exit 3; }
command -v curl >/dev/null 2>&1 || { echo "curl required" >&2; exit 3; }
command -v jq >/dev/null 2>&1 || { echo "jq required" >&2; exit 3; }
command -v openssl >/dev/null 2>&1 || { echo "openssl required (password generation)" >&2; exit 3; }
genpass() {
# 32-char password from base64-encoded 24 bytes of entropy. URL-safe
# so it can land in a JSON string without escaping.
openssl rand -base64 24 | tr -d '\n=/+' | cut -c-32
}
# 1. login as the staging admin so we can call the create-user endpoint.
admin_login_resp=$(curl -ksS --max-time 15 \
-X POST -H 'Content-Type: application/json' \
-d "{\"email\":\"${STAGING_ADMIN_EMAIL}\",\"password\":\"${STAGING_ADMIN_PASSWORD}\",\"remember_me\":false}" \
"${STAGING_URL}/api/v1/auth/login")
admin_token=$(echo "$admin_login_resp" | jq -r '.data.token.access_token // .token.access_token // ""')
if [ -z "$admin_token" ] || [ "$admin_token" = "null" ]; then
echo "ERROR: admin login failed" >&2
echo "$admin_login_resp" >&2
exit 1
fi
provision() {
# provision <role> <email-prefix>
# Returns : password (stdout), nothing else.
local role=$1 email_prefix=$2
local email="${email_prefix}@veza.fr"
local password
password=$(genpass)
# Try creating ; if 409 (already exists), reset password instead. Both
# paths return a valid (email, password) tuple at the end.
local create_resp create_status
create_resp=$(curl -ksS --max-time 15 \
-H "Authorization: Bearer ${admin_token}" \
-H 'Content-Type: application/json' \
-X POST \
-d "{\"email\":\"${email}\",\"password\":\"${password}\",\"username\":\"${email_prefix}\",\"role\":\"${role}\"}" \
-w '\nHTTP_CODE=%{http_code}' \
"${STAGING_URL}/api/v1/admin/users")
create_status=$(echo "$create_resp" | grep -oE 'HTTP_CODE=[0-9]+' | tail -1 | cut -d= -f2)
case "$create_status" in
200|201)
;;
409)
# Account exists — reset password instead.
curl -ksS --max-time 15 \
-H "Authorization: Bearer ${admin_token}" \
-H 'Content-Type: application/json' \
-X POST \
-d "{\"email\":\"${email}\",\"new_password\":\"${password}\"}" \
"${STAGING_URL}/api/v1/admin/users/reset-password" >/dev/null
;;
*)
echo "ERROR: provisioning ${role} failed with HTTP ${create_status}" >&2
echo "$create_resp" >&2
exit 1
;;
esac
# Probe : login as the freshly-set account so we know the engagement
# can use it.
probe=$(curl -ksS --max-time 15 \
-X POST -H 'Content-Type: application/json' \
-d "{\"email\":\"${email}\",\"password\":\"${password}\",\"remember_me\":false}" \
"${STAGING_URL}/api/v1/auth/login")
probe_token=$(echo "$probe" | jq -r '.data.token.access_token // .token.access_token // ""')
if [ -z "$probe_token" ] || [ "$probe_token" = "null" ]; then
echo "ERROR: ${role} login probe failed — provisioning broken" >&2
exit 1
fi
printf '%s' "$password"
}
# 2. provision the three roles. Passwords stay in shell variables — no
# echo, no log, no temp file.
listener_pwd=$(provision "user" "pentest-2026-listener")
creator_pwd=$(provision "creator" "pentest-2026-creator")
admin_pwd=$(provision "admin" "pentest-2026-admin")
# 3. emit 1Password JSON template. Each entry has the role + login URL
# in Notes so the pentester knows which account does what.
cat <<EOF
[
{
"title": "pentest-2026-listener",
"category": "LOGIN",
"vault": {"name": "Pentest-2026"},
"fields": [
{"id": "username", "type": "STRING", "value": "pentest-2026-listener@veza.fr"},
{"id": "password", "type": "CONCEALED", "value": "${listener_pwd}"},
{"id": "url", "type": "URL", "value": "${STAGING_URL}/login"},
{"id": "notesPlain", "type": "STRING", "value": "Pentest 2026 — listener role. Engagement: see PENTEST_SCOPE_2026.md. Rotate at engagement end."}
]
},
{
"title": "pentest-2026-creator",
"category": "LOGIN",
"vault": {"name": "Pentest-2026"},
"fields": [
{"id": "username", "type": "STRING", "value": "pentest-2026-creator@veza.fr"},
{"id": "password", "type": "CONCEALED", "value": "${creator_pwd}"},
{"id": "url", "type": "URL", "value": "${STAGING_URL}/login"},
{"id": "notesPlain", "type": "STRING", "value": "Pentest 2026 — creator role. Owns 5 seed tracks. Rotate at engagement end."}
]
},
{
"title": "pentest-2026-admin",
"category": "LOGIN",
"vault": {"name": "Pentest-2026"},
"fields": [
{"id": "username", "type": "STRING", "value": "pentest-2026-admin@veza.fr"},
{"id": "password", "type": "CONCEALED", "value": "${admin_pwd}"},
{"id": "url", "type": "URL", "value": "${STAGING_URL}/login"},
{"id": "notesPlain", "type": "STRING", "value": "Pentest 2026 — admin role + MFA bypass. DO NOT use for non-pentest activity. Rotate at engagement end."}
]
}
]
EOF
echo "" >&2
echo " 3 accounts provisioned + login-probed against ${STAGING_URL}" >&2
echo " next: pipe stdout to a file and run" >&2
echo " op item create --vault Pentest-2026 - < <file>" >&2
echo " THEN rotate each entry with op item edit --generate-password=letters,digits,32" >&2
echo " at engagement end (this script does NOT auto-rotate)." >&2