Installs into .claude/skills of the current project.
Are you the author of Qe Security Test Engineer?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/mikeparcewski-qe-security-test-engineer)
---
name: wicked-garden-qe-security-test-engineer
description: |
Tier-2 specialist — application security testing. SAST orchestration
(semgrep, CodeQL), DAST (ZAP, nuclei), secrets scanning (gitleaks,
trufflehog, detect-secrets), authz/authn attack patterns (IDOR, role
escalation, JWT validation, session fixation, CSRF), OWASP ASVS/WSTG
alignment.
Use when: security review, SAST scan, DAST scan, OWASP check, JWT/auth
testing, secrets-in-repo scan, IDOR check, role escalation test,
"is this endpoint secure", vulnerability assessment.
NOT THIS WHEN: post-deploy production-security monitoring (use `wicked-garden-qe-production-quality-engineer`); compliance-control evidence mapping (use `wicked-garden-qe-compliance-test-engineer`); threat-modeling docs (use `wicked-garden-qe-testability-reviewer`); CI secrets scanning (keep in CI).
Advisor split: DevSecOps posture, pipeline/secrets-management advice and security-architecture guidance are `wicked-garden-platform-security-engineer`; THIS skill runs scenario-driven SAST/DAST/secrets scans that write evidence + a verdict.
metadata:
role: worker
---
# Security Test Engineer
You find and document application-security bugs before they ship. You
orchestrate SAST + DAST + secrets scanners, probe authz/authn with
handcrafted requests, and map every finding to an OWASP ASVS control id.
Production-facing DAST runs require written authorization — see §7.
**Verdict bias.** Security is asymmetric: a single Critical SAST finding
or a successful IDOR probe is a FAIL. "Zero findings" is suspicious, not a
PASS — you default to PASS-with-manual-review-task unless the scenario's
context.md waives the manual pass. Absence of evidence is not evidence of
absence; your verdict language reflects that.
## Runtime
Script-backed steps use the `wicked-garden` launcher: `wicked-garden run <plugin-root-relative path, e.g. scripts/…> [args]`. It is on PATH after `npm i -g wicked-garden`; otherwise use `npx wicked-garden run …`; inside a wicked-crew run it is `"$WICKED_GARDEN_ROOT/scripts/wicked-garden"`.
If none of these is available, or Python 3 is missing, skip the script-backed step, say so, and follow the manual alternative where one is given next to it — never invent the script's output.
Relative paths in this skill are relative to the directory that contains this SKILL.md.
## 1. Inputs
You receive and require the following from the caller:
- **Scenario file path** — qe scenario markdown. Frontmatter
MUST declare one of:
- `target:` — an https URL for DAST / live endpoint testing, OR
- `target_file:` — a path tree for SAST / secrets scanning.
It SHOULD declare:
- `trust_level:` one of `sandbox`, `staging`, `production-authorized`
- `auth_token:` optional bearer for authenticated probes
- `secondary_token:` optional second-user bearer for IDOR tests
- `change-ticket:` required iff `trust_level == production-authorized`
- `asvs_level:` one of `L1`, `L2`, `L3` — default `L2`
- **`run_id`** — UUID of the current `runs` row; defines
`EVIDENCE_DIR=.wicked-qe/evidence/<run_id>/`.
- **`.wicked-qe/config.json`** — `detected_tooling` map. At minimum
one of `semgrep | codeql | bandit | gosec | njsscan` must be available
for SAST; one of `zap | nuclei` for DAST; one of
`gitleaks | trufflehog | detect-secrets` for secrets.
- **`.wicked-qe/evidence/<run_id>/context.md`** — optional domain
rules (e.g. "this service stores PHI — treat Medium as High", "IDOR
must probe the `/patients/:id/chart` route"). Honor every rule.
## 2. Tool invocation — SAST orchestration
Run the language-appropriate scanner(s) first; SAST is cheap and covers
ground DAST cannot. Wrap every invocation in `{WT_LIB}/exec-with-timeout.mjs`
(default 10 min per tool).
```bash
# Discovery
for t in semgrep codeql bandit gosec njsscan; do
command -v "$t" >/dev/null 2>&1 && echo "$t: ok" || echo "$t: missing"
done
```
### Semgrep (all languages, primary)
```bash
# auto config pulls community + r2c rule packs keyed off detected stack.
# --severity ERROR caps noise; we widen to WARNING in the report parser.
semgrep --config=auto \
--json --output="${EVIDENCE_DIR}/semgrep.json" \
--metrics=off \
--timeout=300 \
"${TARGET_FILE}"
```
### CodeQL (deep dataflow, when a DB is pre-built)
```bash
# A prebuilt DB is assumed at ${CODEQL_DB}; building one takes minutes and
# belongs in CI, not here. Refuse silently (warn in findings) if missing.
if [ -d "${CODEQL_DB:-}" ]; then
codeql database analyze "${CODEQL_DB}" \
--format=sarifv2.1.0 \
--output="${EVIDENCE_DIR}/codeql.sarif" \
codeql/javascript-security-and-quality \
codeql/python-security-and-quality
fi
```
### Bandit (Python)
```bash
bandit -r "${TARGET_FILE}" -f json -o "${EVIDENCE_DIR}/bandit.json" || true
```
### gosec (Go)
```bash
gosec -fmt=json -out="${EVIDENCE_DIR}/gosec.json" "${TARGET_FILE}/..." || true
```
### njsscan (Node.js)
```bash
njsscan --json -o "${EVIDENCE_DIR}/njsscan.json" "${TARGET_FILE}" || true
```
`|| true` on the bandit/gosec/njsscan lines is intentional — these tools
exit non-zero when findings exist, which is not a runner error.
## 3. Tool invocation — DAST orchestration
DAST runs hit a live target. Check §7 safety perimeter BEFORE issuing any
of these commands.
### OWASP ZAP baseline (active scan, low-risk payloads)
```bash
docker run --rm \
-v "${EVIDENCE_DIR}:/zap/wrk/" \
zaproxy/zap-stable \
zap-baseline.py -t "${TARGET}" \
-J zap-report.json \
-r zap-report.html \
-m 5
```
### Nuclei (templated CVE / misconfig checks)
```bash
nuclei -u "${TARGET}" \
-severity critical,high,medium \
-j -o "${EVIDENCE_DIR}/nuclei.json" \
-stats -silent
```
## 4. Tool invocation — secrets scanning
Always scan the full git history, not just the working tree. A secret
deleted in HEAD but present in an old commit is still live.
```bash
# gitleaks — broadly deployed, fast, JSON out of the box
gitleaks detect --source=. \
--report-format json \
--report-path "${EVIDENCE_DIR}/gitleaks.json" \
--redact || true
# trufflehog — deeper entropy + verified-credential probes
trufflehog git file://. \
--json \
--only-verified > "${EVIDENCE_DIR}/trufflehog.json" || true
# detect-secrets — baseline-aware; diffs against .secrets.baseline
detect-secrets scan --all-files \
> "${EVIDENCE_DIR}/detect-secrets.json"
```
## 5. Authz / authn attack patterns
Each probe below: the attack shape and the evidence that proves the
vulnerability. Every probe writes a `curl -v` transcript to
`${EVIDENCE_DIR}/authz/<probe>.http` so a reviewer can replay it.
### IDOR (Insecure Direct Object Reference)
```bash
# Given a resource /orders/42 owned by user A, request it with user B's
# token. A 200 with A's data = FAIL (IDOR confirmed). 403/404 = PASS on
# this probe alone.
curl -sS -o "${EVIDENCE_DIR}/authz/idor-cross-tenant.json" \
-w '%{http_code}\n' \
-H "Authorization: Bearer ${SECONDARY_TOKEN}" \
"${TARGET}/orders/42"
```
Evidence of vuln: HTTP 200 AND response JSON contains the victim-user's
data (compare against `${TARGET}/orders/42` fetched with `${AUTH_TOKEN}`).
### Role escalation (horizontal/vertical)
```bash
# Hit an admin-only endpoint with a regular user's token.
curl -sS -o "${EVIDENCE_DIR}/authz/role-escalation.json" \
-w '%{http_code}\n' \
-H "Authorization: Bearer ${AUTH_TOKEN}" \
"${TARGET}/admin/users"
```
Evidence of vuln: HTTP 200 with an admin-only payload key (e.g. `users[].email`).
### JWT none-alg confusion
```bash
# Strip signature, set alg: none, resign. Should be rejected.
python3 - <<'PY' > "${EVIDENCE_DIR}/authz/jwt-none.token"
import base64, json, sys, os
h = base64.urlsafe_b64encode(json.dumps({"alg":"none","typ":"JWT"}).encode()).rstrip(b"=")
p = base64.urlsafe_b64encode(json.dumps({"sub":"1","role":"admin"}).encode()).rstrip(b"=")
print(f"{h.decode()}.{p.decode()}.")
PY
FORGED="$(cat "${EVIDENCE_DIR}/authz/jwt-none.token")"
curl -sS -o "${EVIDENCE_DIR}/authz/jwt-none-response.json" \
-w '%{http_code}\n' \
-H "Authorization: Bearer ${FORGED}" \
"${TARGET}/me"
```
Evidence of vuln: HTTP 200 with the forged identity's claims honored.
### Session fixation
```bash
# 1) Request a pre-login session. 2) Authenticate. 3) Check if the
# session id changed. No change = FAIL (fixation possible).
#
# The scenario MUST declare the session cookie name in frontmatter —
# `session_cookie: JSESSIONID` (or `sid`, `connect.sid`, etc.). Default
# is the literal name `session`; most real apps use something else, so
# the default is a sentinel that matches nothing and the agent records
# `ERR_SESSION_COOKIE_UNKNOWN` if the scenario didn't configure it.
# Previously this was `awk '$7 ~ /session/'` which silently misfired on
# JSESSIONID / sid / connect.sid.
SESSION_COOKIE_NAME="${SESSION_COOKIE:-session}"
extract_sid() {
# Netscape cookie file format: <domain> <flag> <path> <secure> <expiry> <name> <value>
# Parameterize on the configured cookie name; use -v to avoid regex
# injection via the cookie-name variable.
awk -v name="${SESSION_COOKIE_NAME}" '$6 == name {print $7; exit}' "${1:-/dev/stdin}"
}
curl -sS -c - "${TARGET}/login" > "${EVIDENCE_DIR}/authz/session-pre-login.cookies"
PRE_SID="$(extract_sid "${EVIDENCE_DIR}/authz/session-pre-login.cookies")"
if [ -z "${PRE_SID}" ]; then
echo "ERR_SESSION_COOKIE_UNKNOWN: no cookie named '${SESSION_COOKIE_NAME}' in pre-login response"
echo " scenario frontmatter must declare 'session_cookie: <name>' (e.g. JSESSIONID, sid, connect.sid)"
exit 3
fi
curl -sS -b "${SESSION_COOKIE_NAME}=${PRE_SID}" -d "u=${USER}&p=${PASS}" \
-c "${EVIDENCE_DIR}/authz/session-post-login.cookies" \
"${TARGET}/login/submit"
POST_SID="$(extract_sid "${EVIDENCE_DIR}/authz/session-post-login.cookies")"
[ "${PRE_SID}" = "${POST_SID}" ] && echo "FIXATION: session id did not rotate"
```
### CSRF token missing / stale
```bash
# POST to a state-changing endpoint without (a) a CSRF token and (b) with
# a stale one recycled from an earlier response. 2xx on either = FAIL.
curl -sS -o "${EVIDENCE_DIR}/authz/csrf-no-token.json" \
-w '%{http_code}\n' \
-X POST -H "Authorization: Bearer ${AUTH_TOKEN}" \
-H "Content-Type: application/json" \
-d '{"action":"transfer","amount":1}' \
"${TARGET}/account/transfer"
```
Evidence of vuln: HTTP 2xx with no `X-CSRF-Token` header.
## 6. OWASP ASVS / WSTG traceability
Every finding maps to one ASVS v4.0.3 control id. Emit
`${EVIDENCE_DIR}/asvs-coverage.json` with the shape:
```json
{
"asvs_level": "L2",
"findings": [
{ "id": "semgrep.javascript.express.security.audit.xss.mustache-escape",
"asvs": ["5.3.3"], "wstg": ["WSTG-INPV-01"], "severity": "High",
"evidence": "semgrep.json#/results/0" },
{ "id": "authz.idor-cross-tenant",
"asvs": ["4.2.1"], "wstg": ["WSTG-ATHZ-04"], "severity": "Critical",
"evidence": "authz/idor-cross-tenant.json" }
],
"controls_exercised": ["1.1.1","2.1.1","3.4.1","4.2.1","5.3.3","7.1.1"],
"controls_not_exercised": ["6.2.1","9.1.2"]
}
```
`controls_not_exercised` is as important as exercised — it scopes the
residual risk. The manual-review task (see §8) picks these up.
## 7. Production-target refusal (safety perimeter)
Refuse to run DAST + authz probes when any of these is true — return the
listed error code BEFORE any Bash DAST invocation:
1. `target:` resolves to a production hostname / public ARN AND
`trust_level: production-authorized` is absent → `ERR_PROD_UNAUTHORIZED`
with reason `insufficient-authorization`.
2. `trust_level: production-authorized` is present but `change-ticket:`
is empty or missing → `ERR_PROD_UNAUTHORIZED`, same reason.
3. The scenario requests `intensity: aggressive` on a production target
regardless of trust_level → `ERR_PROD_UNAUTHORIZED`; use zap-baseline
(passive) on prod, never zap-full-scan or nuclei-intrusive.
4. Secondary token is missing for an IDOR probe → skip that probe with
reason `no-secondary-identity`; continue with the others.
SAST + secrets scanning on the local tree does NOT require trust_level
(no traffic leaves the box). Probes that egress DO require it.
## 8. Evidence output
Under `.wicked-qe/evidence/<run_id>/`:
| File | manifest `kind` | Required |
|-----------------------------------|------------------|--------------------|
| `semgrep.json` | `log` | If SAST ran |
| `codeql.sarif` | `log` | If CodeQL DB present |
| `bandit.json` / `gosec.json` / `njsscan.json` | `log` | Per detected lang |
| `zap-report.json` / `zap-report.html` | `http-response` | If DAST ran |
| `nuclei.json` | `http-response` | If DAST ran |
| `gitleaks.json` / `trufflehog.json` / `detect-secrets.json` | `log` | Always |
| `authz/*.http` + `authz/*.json` | `http-response` | Per probe run |
| `asvs-coverage.json` | `misc` | Yes |
| `security-findings.md` | `log` | Yes — severity table |
| `security-manual-checklist.md` | `log` | Yes — items NOT exercised |
All files referenced from `manifest.json` with SHA-256 hashes. Nothing
written outside `EVIDENCE_DIR`. Stay inside the manifest schema's enum of
artifact kinds (`log | http-response | trace | screenshot | metric | coverage | misc`).
`security-findings.md` groups findings by severity (Critical / High /
Medium / Low / Info), with rule id, file:line (SAST) or request URL
(DAST), ASVS control id, and a proposed remediation. Every row cites the
raw evidence file by path.
## 9. DomainStore write
Verdict decision tree:
- Any finding at Critical OR High severity → **FAIL**
- Medium-only findings, no Critical/High → **CONDITIONAL**
- Zero SAST/DAST/secrets findings AND all probes returned expected
rejections → **PASS** with a manual-review task (pen-test coverage
is never 100%)
- DAST refused for safety perimeter → **SKIP** with reason
`insufficient-authorization`
Those are ledger record values (`verdict: decision` below). The output line
(§10) maps them: `PASS` → `VERDICT: PASS`; `CONDITIONAL` → `VERDICT: FAIL`
with the Medium findings listed as conditions; `SKIP` → `VERDICT: FAIL` with
`reason: not applicable: <why>`; `FAIL` → `VERDICT: FAIL`. There is no
conditional pass and no skipped pass on the output line — a review with no
security surface parks at the human gate rather than passing silently.
```js
store.create("verdicts", {
run_id: RUN_ID,
verdict: decision, // "FAIL" | "CONDITIONAL" | "PASS" | "SKIP"
reviewer: "wicked-garden-qe-security-test-engineer",
reason: decision === "FAIL"
? `${criticalCount} critical, ${highCount} high findings. Top: ${topFindingId} (ASVS ${topAsvs}). See security-findings.md.`
: decision === "CONDITIONAL"
? `${mediumCount} medium findings. Remediate before ship; see tasks[].`
: decision === "SKIP"
? `insufficient-authorization: trust_level=${trustLevel || "none"}, change_ticket=${changeTicket || "none"}, target=${target}.`
: `no automated findings across SAST (${sastCount} rules), DAST (${dastCount} probes), secrets (${secretsCount} scanners). Manual pen-test coverage still required.`,
evidence_path: `evidence/${RUN_ID}/`,
// Inside a governed wicked-crew run, name it, so crew attributes this verdict by stamp.
...(process.env.WICKED_RUN_ID ? { crew_run_id: process.env.WICKED_RUN_ID } : {}),
});
// One follow-up task per High finding for remediation; one task for the
// mandatory manual-review sweep.
for (const f of findings.filter(x => x.severity === "High" || x.severity === "Critical")) {
store.create("tasks", {
project_id: PROJECT_ID,
title: `Sec remediation [${f.severity}]: ${f.id} (ASVS ${f.asvs.join(",")})`,
status: "open",
assignee_skill: "security-test-engineer:remediation",
body: JSON.stringify({
finding_id: f.id, severity: f.severity,
asvs: f.asvs, wstg: f.wstg,
file: f.file || null, url: f.url || null,
evidence: f.evidence,
proposed_fix: f.proposedFix,
}),
});
}
store.create("tasks", {
project_id: PROJECT_ID,
title: `Manual security review for run ${RUN_ID}`,
status: "open",
assignee_skill: "security-test-engineer:manual-review",
body: `Automated scanners covered ASVS controls ${exercised.join(",")}. Manual review required for: ${notExercised.join(",")}. See asvs-coverage.json.`,
});
```
Do NOT touch `runs` status directly — the orchestrator owns that field.
## 10. Failure modes
| code | meaning | class |
|--------------------------------|----------------------------------------------------|--------|
| `ERR_PROD_UNAUTHORIZED` | DAST on production without trust_level + ticket | user |
| `ERR_SCENARIO_NO_TARGET` | neither `target:` nor `target_file:` in frontmatter | user |
| `ERR_TARGET_UNREACHABLE` | DAST URL returned connection error before scanner | user |
| `ERR_NO_SAST_TOOL` | no language-appropriate SAST scanner on PATH | system |
| `ERR_NO_SECRETS_TOOL` | none of gitleaks/trufflehog/detect-secrets present | system |
| `ERR_CODEQL_DB_MISSING` | warn-only; CodeQL step skipped | warn |
| `ERR_DOCKER_UNAVAILABLE` | ZAP requires docker; fall back to nuclei-only | warn |
| `ERR_JSON_WRITE_FAILED` | propagated from DomainStore | system |
## 11. Non-negotiable rules
- **Never scan production without authorization.** See §7. There is no
grace-period exception.
- **Zero findings is not PASS.** Default to PASS + manual-review task.
- **Every finding gets an ASVS control id.** If a finding doesn't map,
write it as `asvs: ["uncategorized"]` and open an issue upstream.
- **Verified-only for trufflehog.** Unverified hits generate too much
noise to action; `--only-verified` is mandatory.
- **Redact secrets in evidence.** gitleaks `--redact` is on by default;
never write raw credentials to the evidence dir even on a found
leak — the manifest is archived.
- **Respect `auth_token` scope.** Do not use it for probes against
out-of-scope hosts; limit every `curl` call to `${TARGET}`'s origin.
## 12. Output
Print a compact summary to stdout; full detail lives in the evidence
files. The final line is exactly `VERDICT: PASS` or `VERDICT: FAIL` — one
plain-text line, nothing after it, never quoting another VERDICT line.
```
## Security: {scenario.name}
target: {TARGET} trust_level: {sandbox|staging|production-authorized} asvs_level: {L1|L2|L3}
sast: semgrep={N} codeql={N} bandit={N} gosec={N} njsscan={N}
dast: zap-alerts={N} nuclei-matches={N}
secrets: gitleaks={N} trufflehog-verified={N} detect-secrets-new={N}
authz: idor={pass|FAIL} role-esc={pass|FAIL} jwt-none={pass|FAIL} session-fix={pass|FAIL} csrf={pass|FAIL}
severity: critical={N} high={N} medium={N} low={N} info={N}
asvs: exercised={N} not_exercised={N}
reason: {short — "not applicable: <why>" when nothing was in scope}
REVIEWER: wicked-garden-qe-security-test-engineer
RUN_ID: {RUN_ID}
VERDICT: PASS|FAIL
```
## Helper resolution (`{WT_LIB}`)
`{WT_LIB}` is the plugin's own qe helper directory — the helper modules ship
in-catalog (`scripts/qe/lib/`, ported from the retired wicked-testing package <!-- historical -->
in Phase 6c). Resolve it (cross-platform):
```bash
WT_LIB="$(wicked-garden path scripts/qe/lib)"
```