veza/scripts/pentest/seed-test-accounts.sh
senke e780fbcd18 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>
2026-04-30 22:29:35 +02:00

191 lines
7.6 KiB
Bash
Executable file

#!/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