| name | run-juiceshop |
| description | Run the full Clinkz pipeline against OWASP Juice Shop (Node/Angular SPA, JWT auth) and report vulnerability coverage across the 14 categories. |
Apply phase-work skill.
Juice Shop is the SPA/JWT counterpart to DVWA. Where DVWA is PHP + cookie sessions + HTML forms katana can crawl, Juice Shop is an Angular SPA whose real attack surface is /api/* and /rest/* JSON routes invoked by JavaScript — not HTML links — and it authenticates with a Authorization: Bearer <jwt> token, not a cookie session. Treat those two facts as the load-bearing differences when interpreting any miss.
- Verify Juice Shop is running at http://localhost:3000 (check via
Invoke-WebRequest http://localhost:3000 or docker compose -f docker/docker-compose.yml ps). If not running: docker compose -f docker/docker-compose.yml up -d juiceshop
- A healthy shell is a ~75 KB Angular
index.html (HTTP 200) with almost no anchor links — the body is the SPA bootstrap, not the app. Do NOT read "200, large body" as "endpoints discoverable"; the routes are in the JS bundle.
- Juice Shop self-registration is open:
POST /api/Users {email, password, passwordRepeat} then POST /rest/user/login {email, password} returns the JWT at data.authentication.token. This is how tests/test_skills_juiceshop/conftest.py authenticates. The pipeline now does JSON/API auth too: WebAuthenticator.authenticate() falls back to POSTing JSON creds to /rest/user/login (and other common API routes), extracts the token, and propagates it as Authorization: Bearer <jwt> to scan/exploit via the session_headers handoff. The canonical default admin (admin@juice-sh.op / admin123) is seeded, so the orchestrator's default-cred flow can establish a JWT session without self-registering.
- The orchestrator handles clinkz-tools container readiness via its preflight. Just invoke:
python -m clinkz scan --target http://localhost:3000
- Parse the JSON report from
outputs/<engagement_id>/report_<engagement_id>.json and the trace from outputs/<engagement_id>/trace.jsonl (python -m clinkz trace inspect <engagement_id>).
- Report coverage as X/13-applicable using the Juice Shop category map below. Command Injection is the one genuine N/A (Node SPA, no
child_process.exec sink reachable from HTTP input — the /ftp challenge is traversal/LFI, not shell). The other 13 categories have real surface.
- Include: total findings, by severity, engagement ID, runtime.
- If coverage < applicable count, give a single-line root cause per missed category and bucket it as one of: (a) JWT-auth failure (JSON/API auth is now wired — bucket here only if the JWT session genuinely failed to establish), (b) SPA/API endpoint discovery (the
/api+/rest routes never reached planning), (c) JSON-body param handling (params live in a JSON request body, not the query string our extraction reads), or (d) methodology-shape mismatch (the methodology reached the right surface but its assumptions don't fit it). This bucketing is the deliverable — it prioritises the next fix. The upstream pipeline is now proven end-to-end (engagement a07df54b: recon→scan→discovery→JWT delivered ~138 endpoints and 8 methodologies ran), so buckets (a) and (b) are resolved — only flag them if the trace shows a genuine regression (no JSON login, or service_scans: [] / total_endpoints: 0 with no katana invocation). Bucket (c) JSON-body params is now RESOLVED (fix #4): ParamLocation is threaded Endpoint→ExploitTask→PageAnalysis, a shared request builder (_send_probe/_http_post_json) injects into JSON bodies, the form-shaped methodologies iterate a synthesized JSON pseudo-form (_injectable_forms), and discovery recovers the body shape via OpenAPI requestBody parsing OR — since Juice Shop serves no parseable spec (/api-docs is Swagger-UI HTML over a partial /b2b/v2 doc) — a curated _KNOWN_JSON_POST_BODIES fallback. Verified live: brute-force (POST /rest/user/login) and CSRF (POST /api/Feedbacks) now find their JSON-body points; stored-XSS reaches /api/Feedbacks but is captcha-gated (captchaId required ⇒ justified non-finding unless the captcha is solved). So the expected remaining blocker is (d) methodology-shape (e.g. the open-redirect allowlist, the captcha gate on stored-XSS, and the SPA-shape rows below) — flag (c) only if a JSON-body category misses for a param-handling reason, not a target protection.
Juice Shop → 14-category map (canonical surfaces)
| # | Our category | Juice Shop surface | Canonical endpoint | Notes |
|---|
| 1 | SQL Injection | Product search | GET /rest/products/search?q=' | Sequelize/SQLite error-based; q concatenated into raw SQLite. Query-string param — most pipeline-friendly. |
| 2 | XSS Reflected | Search bar | GET /#/search?q=<payload> | Reflected into DOM via Angular binding (not server HTML body). |
| 3 | XSS Stored | Customer feedback | POST /api/Feedbacks {comment,rating} | Rendered into feedback list DOM. JSON body, no HTML form on page — now reached via the synthesized JSON pseudo-form (fix #4). Captcha-gated (captchaId required), so a verified finding needs the captcha solved — expect a justified non-finding otherwise, bucket (d), not (c). |
| 4 | Command Injection | — | — | N/A — no OS-shell sink from HTTP input. The only filesystem surface (/ftp) is LFI-shaped. Expect zero cmdi findings: phase-5 now rejects an echo-canary reflected in an error response (a07df54b emitted 3 phantom "high" RCEs off 500 pages that merely reflected ;echo PWNED; hardened), so any cmdi finding here is a regression, not a catch. |
| 5 | File Inclusion / Path Traversal | Legacy ftp | GET /ftp/<file> (%2e%2e%2f bypass) | Allowlist + .. block defeated by URL-encoding; path segment, not query param. |
| 6 | File Upload | Complaint attachment | POST /file-upload (or /api/Complaints) | Over-permissive upload validation. |
| 7 | CSRF | State-changing JSON APIs | POST /api/Feedbacks, profile change | JWT-bearer is the only CSRF defence → MISSING/COOKIE_ONLY token surface. |
| 8 | Brute Force | Login | |
Applicable = 13 (all except #4 Command Injection).
Tier-1 + Tier-2 primitives beyond the 14-category map (expansion — see docs/ROADMAP.md)
| Primitive | Juice Shop surface | Canonical request | Notes |
|---|
NoSQL Injection (_test_nosqli) | Product reviews (MarsDB) | PATCH /rest/products/reviews {"id":{"$ne":-1},"message":...} | "NoSQL Manipulation" — $ne operator object widens the match set; confirmed by a modified-count jump over the benign baseline. JSON-body operator object (the structured carrier). The $where DoS surface (/rest/track-order/:id, /rest/products/sleep(N)/reviews) is sanitised on recent builds (the lone quote is stripped, ',sleep(2000),' collapses to sleep2000 with no delay) → a verification-honest non-finding there. Note: Juice Shop's login is SQLi, not NoSQL, so the gate is reviews-manipulation, not a login {"$ne":null} bypass. |
SSTI (_test_ssti) | Profile username (Pug) | POST /profile username=#{a*b} → read-back GET /profile | "SSTi" — second-order Pug injection; #{a*b} compiled into views/userProfile.pug renders the product on read-back (the POST 302-redirects). Engine fingerprinted as Pug (#{}). Expected justified non-finding on default Docker: the eval is gated behind isChallengeEnabled(usernameXssChallenge) and challenges.safetyMode=auto + disabledEnv:[Docker] disables it, so #{a*b} renders literally → nothing emits. Confirmed end-to-end against a safetyMode=disabled instance (high-severity expression_eval; the process.mainModule RCE gadget degrades to eval on Node 22). The profile reflects the username twice (eval'd <p> + raw input value=), so detection is baseline-anchored / scaffold-stripped, not literal-presence-gated. |
XXE (_test_xxe) | B2B complaint file upload (libxml) | POST /file-upload multipart with |
What the trace must answer (diagnosis, not just a number)
The point of running against Juice Shop is to test the pipeline, not the isolated smoke tests (which hand each _test_* method a pre-built PageAnalysis at the canonical endpoint). The smoke tests passing proves the methodologies work given the right page; this run proves whether recon→scan→auth→plan actually delivers that page. From clinkz trace inspect <id> confirm:
- Auth: Did a JWT session establish?
WebAuthenticator now tries cookie/form auth first, then falls back to JSON/API auth (POST /rest/user/login, token → Authorization: Bearer via session_headers). Confirm the trace shows a successful JSON login and the bearer reaching scan/exploit; if the session did not establish, this is the break — but it is no longer the expected one (SPA discovery now is).
- Discovery: Did scan surface
/api/* and /rest/* routes, or only the SPA index.html shell? Angular routes aren't anchor links, but scan no longer relies on HTML crawling alone — _scan_http_service runs the _route_discovery.py discoverers (static JS-bundle parsing of main.js/chunks + OpenAPI probing) that extract the real routes with param structure. This is resolved as of a07df54b (recon delivered the :3000 HTTP service, the discoverers ran, ~138 endpoints reached planning). Only treat discovery as the break if the trace regresses to service_scans: [] / total_endpoints: 0 with no katana invocation — otherwise a missed category is a downstream methodology/param-shape issue, not a discovery gap.
- Planning: Did
_llm_plan_exploits receive real parameterized endpoints (/rest/products/search?q=, /rest/basket/:id, /redirect?to=) or just the SPA index?
- Open Redirect specifically: expect it not found, and that is now understood (a07df54b): Juice Shop's
/redirect?to= is gated by a substring allowlist that 406s every generic probe, so phase-1 candidacy correctly returns False — there is no 302 to detect. Confirming it requires defeating the allowlist (harvest the app's own allowlisted outbound links, bypass via substring), which is deferred to a dedicated fix. Do not re-diagnose this as a candidacy-signal bug.
Report the diagnosis as a table: category | found | root cause if missed | bucket (a/b/c/d). Then give the % split of what blocks coverage across the four buckets — that is the fix priority for the next prompt.
Do NOT fix issues found during the run. Measurement and diagnosis only unless the user asks for fixes.