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>
191 lines
7.6 KiB
Bash
Executable file
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
|