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:
parent
05b1d81d30
commit
e780fbcd18
2 changed files with 378 additions and 0 deletions
187
docs/PENTEST_SEND_PACKAGE.md
Normal file
187
docs/PENTEST_SEND_PACKAGE.md
Normal 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
|
||||
191
scripts/pentest/seed-test-accounts.sh
Executable file
191
scripts/pentest/seed-test-accounts.sh
Executable 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
|
||||
Loading…
Reference in a new issue