diff --git a/docs/PENTEST_SEND_PACKAGE.md b/docs/PENTEST_SEND_PACKAGE.md new file mode 100644 index 000000000..e71a565c5 --- /dev/null +++ b/docs/PENTEST_SEND_PACKAGE.md @@ -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 , + +Per the signed scope letter dated , the Veza v1.0.9 +pre-launch pentest engagement starts on . 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 + has been allow-listed as of .) + • 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 window : + + • Start : + • End : (~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 : , 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, + +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 diff --git a/scripts/pentest/seed-test-accounts.sh b/scripts/pentest/seed-test-accounts.sh new file mode 100755 index 000000000..c99ecdd70 --- /dev/null +++ b/scripts/pentest/seed-test-accounts.sh @@ -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 < + 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 + # 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 <&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 - < " >&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