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:3000ordocker 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}thenPOST /rest/user/login{email, password}returns the JWT atdata.authentication.token. This is howtests/test_skills_juiceshop/conftest.pyauthenticates. 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 asAuthorization: Bearer <jwt>to scan/exploit via thesession_headershandoff. 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.
- A healthy shell is a ~75 KB Angular
- 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>.jsonand the trace fromoutputs/<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.execsink reachable from HTTP input — the/ftpchallenge 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+/restroutes 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, orservice_scans: []/total_endpoints: 0with no katana invocation). Bucket (c) JSON-body params is now RESOLVED (fix #4):ParamLocationis threadedEndpoint→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 OpenAPIrequestBodyparsing OR — since Juice Shop serves no parseable spec (/api-docsis Swagger-UI HTML over a partial/b2b/v2doc) — a curated_KNOWN_JSON_POST_BODIESfallback. Verified live: brute-force (POST /rest/user/login) and CSRF (POST /api/Feedbacks) now find their JSON-body points; stored-XSS reaches/api/Feedbacksbut is captcha-gated (captchaIdrequired ⇒ 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 | POST /rest/user/login |
No lockout / captcha / rate-limit by design. JSON body creds. |
| 9 | Weak Session IDs | JWT token | token from /rest/user/login |
JWT, not a cookie session ID. Our entropy/flag methodology is cookie-shaped — shape mismatch expected, not a true negative. |
| 10 | DOM XSS | SPA fragment routes | GET /#/track-order?id=<payload> |
Client-side Angular binding; strength=likely (no JS interpreter). |
| 11 | JavaScript Attacks | Score-board gate | GET /#/score-board |
Client-side gate. Methodology is form-bound; SPA route serves no server-rendered form → expect skip/empty, not a contract break. |
| 12 | Authorization Bypass (IDOR) | Basket / users | GET /rest/basket/:id, GET /api/Users/:id |
Horizontal IDOR — auth'd but :id not owner-checked. Strong surface. Reference in path segment. |
| 13 | Open HTTP Redirect | Redirector | GET /redirect?to=<url> |
Substring-allowlist gate — to 302s only if it contains an allowlisted URL (e.g. https://github.com/bkimminich/juice-shop); every generic attacker URL (//evil, @evil, appended) 406s. So _test_open_redirect phase-1 candidacy correctly returns False (no 302 to detect) — bucket (d) methodology-shape, NOT a candidacy-signal bug. Confirming it needs an allowlist-bypass (harvest the app's own allowlisted outbound links → phase-4 synthesis). Deferred (a07df54b diagnosis). |
| 14 | CSP Bypass / Security Headers | Root response | GET / headers |
Missing/weak CSP, Referrer-Policy, Permissions-Policy on most builds (build-dependent). |
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 .xml with <!ENTITY xxe SYSTEM "file:///etc/passwd"> |
"XXE Data Access" / "XXE DoS" — the XML parse + entity expansion is gated by deprecatedInterfaceChallenge (no disabledEnv → runs in Docker); the two XXE challenges are disabledEnv:[Docker,Heroku,Gitpod], which suppresses only the scoreboard banner. On expansion the server reflects the file content in-band in an HTTP 410 (…deprecated for security reasons: <…>root:x:0:0:…), so file disclosure confirms on the file-content signature — and the 4xx is the legitimate channel, NOT a reject signal (the prior trio's "reject 4xx" guard does not apply). Endpoint-scoped (the whole XML doc is the payload; carrier = multipart upload, no injectable param). May emit a real finding even on default Docker if the container's libxml resolves the external entity (documented-unreliable → the reason the challenges are Docker-disabled); else a verification-honest non-finding — the smoke gate is skip-tolerant. DoS is bounded (parse-delay / 503), never a real billion-laughs (segfaults the parser). |
JWT attacks (_test_jwt) — Tier-2 |
Bearer-gated API routes (/rest/basket/:id, /rest/user/whoami) |
GET /rest/basket/1 with a forged Authorization: Bearer |
"Unsigned JWT" (alg:none) / "Forged Signed JWT" — the first Tier-2 primitive (cryptographic token manipulation, not injection). Endpoint-scoped (the token is the payload; carrier authorization_bearer, no injectable param). Phase 1 carries the captured JWT as a bearer and baseline-anchors the route (valid token accepted, broken-sig rejected); the new _jwt_send_with_token carrier drops the cookie jar so only the token is under test (Juice Shop's token cookie would otherwise mask acceptance). Fully in-band — confirmed when a forged token (alg:none strip via PyJWT, weak/known secret re-sign, RS256→HS256 confusion via a JWKS public key) is accepted like the valid baseline. Skip-tolerant: alg:none acceptance is build-dependent → a critical finding when accepted, a verification-honest non-finding when hardened. Tokens are redacted in evidence (header + claim names + <sig elided>, no secret). Distinct from #9 Weak Session IDs (which scores the JWT's entropy/flags; JWT attacks forge/tamper it). |
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?
WebAuthenticatornow tries cookie/form auth first, then falls back to JSON/API auth (POST /rest/user/login, token →Authorization: Bearerviasession_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 SPAindex.htmlshell? Angular routes aren't anchor links, but scan no longer relies on HTML crawling alone —_scan_http_serviceruns the_route_discovery.pydiscoverers (static JS-bundle parsing ofmain.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 toservice_scans: []/total_endpoints: 0with no katana invocation — otherwise a missed category is a downstream methodology/param-shape issue, not a discovery gap. - Planning: Did
_llm_plan_exploitsreceive 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.