{"updated":"2026-10-21","license":"https://creativecommons.org/licenses/by/4.0/","about":"Check these rules first. Each links to its own page with how to do it and how to check it.","rules":[{"id":"AID-36","name":"Keep secret keys on the server","url":"https://agentwranglers.se/playbook/ai-assisted-development/apps-built-with-ai-builders/server-side-keys","level":"Required","risk":"takeover","rule":"Never ship a secret key in code that runs in the browser. That covers database secret or service role keys, payment secrets and AI provider keys.","check":"Download every script the live app loads and search for key patterns, such as `sb_secret_`, `sk_live_` and `sk-`. Decode each token that starts with `eyJ` and confirm none has the role `service_role`. Only public keys are found."},{"id":"SEC-33","name":"Keep secrets out of code","url":"https://agentwranglers.se/playbook/adversarial-testing/data-and-secrets/secrets-out-of-code","level":"Required","risk":"takeover","rule":"Never write a password, key or token into code, settings files in the repo, or its history.","check":"The agent runs a secret scanner over the whole history, not only the latest commit. It finds no live key."},{"id":"AID-35","name":"Turn on row-level security for every table","url":"https://agentwranglers.se/playbook/ai-assisted-development/apps-built-with-ai-builders/row-level-security","level":"Required","risk":"data","rule":"When the browser talks to the database directly, turn on row-level security for every table and write a policy for each action.","check":"A query lists public tables without row-level security, and it returns no rows. Using only the public key, read each table signed out, then as another user. Both return nothing."},{"id":"SEC-42","name":"Keep storage buckets private","url":"https://agentwranglers.se/playbook/adversarial-testing/data-and-secrets/private-storage-buckets","level":"Required","risk":"data","rule":"Keep cloud storage buckets and file shares private. Hand out short-lived signed links for single files instead of public folders.","check":"The agent lists and downloads from each bucket without a key, and guesses common bucket names for the business. Every request is refused."},{"id":"SEC-17","name":"Check ownership on every record","url":"https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/object-access","level":"Required","risk":"data","rule":"Before returning or changing a record, check on the server that it belongs to the logged-in user or their team.","check":"Log in as two test users. With user A's token, request, change and delete user B's records by ID. Try list, export and search routes, IDs inside request bodies and GraphQL queries too. Every request returns 404 or 403, and lists show only A's records."},{"id":"SEC-18","name":"Check roles on every action","url":"https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/function-access","level":"Required","risk":"takeover","rule":"Check the user's role on the server for every admin or staff action. Hiding a button is not a check.","check":"As a normal user, call every admin address and API action found in the code, the scripts and the sitemap. Every call returns 403 or 404."},{"id":"AID-37","name":"Check every sign-in and permission flow","url":"https://agentwranglers.se/playbook/ai-assisted-development/apps-built-with-ai-builders/check-auth-flows","level":"Required","risk":"takeover","rule":"Test sign-up, sign-in, password reset and every admin page as a stranger and as a normal user before launch.","check":"Signed out, open every page and call every API route found in the code. Only public ones answer. As a normal user, admin routes answer 403 or 404."},{"id":"SEC-21","name":"Store passwords with a slow hash","url":"https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/password-storage","level":"Required","risk":"data","rule":"Store passwords only as a slow, salted hash made for passwords, such as Argon2id or bcrypt.","check":"Look at a test user's row in the database. The password field starts with a marker such as `$argon2id$`, `$scrypt$` or `$2b$`, never the password or a plain hex hash."},{"id":"SEC-20","name":"Slow down password guessing","url":"https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/login-guessing","level":"Required","risk":"takeover","rule":"Limit failed logins per account and per address, and refuse passwords known from breaches.","check":"On the test copy, send 20 wrong passwords to one test account in a minute. Tries after the fifth are slowed or blocked. The error for an unknown email reads the same as for a wrong password. Setting the password `passwordpassword` is refused as breached."},{"id":"SEC-22","name":"Lock down session cookies","url":"https://agentwranglers.se/playbook/adversarial-testing/access-and-accounts/session-cookies","level":"Required","risk":"takeover","rule":"Mark session cookies `HttpOnly`, `Secure` and `SameSite=Lax` or `Strict`. Issue a new session at login and end it at logout, after idle time and after a fixed lifetime.","check":"Read the `Set-Cookie` header after login. The name starts with `__Host-`, it has all three flags, and the session ID differs from the one before login. After logout, the old cookie gets 401. On the test copy, a test admin session left idle for 16 minutes gets 401 too."},{"id":"SEC-9","name":"Use parameterised queries","url":"https://agentwranglers.se/playbook/adversarial-testing/injection/sql-injection","level":"Required","risk":"data","rule":"Send every database query with placeholders, and pass user input as separate values. Never build a query by joining strings.","check":"Send a quote, `' OR '1'='1` and `{\"$ne\": null}` in every search field, filter, sort parameter and login field. The answers match normal input, with no database errors and no extra rows. A search of the code finds no query built with string joins or template strings."},{"id":"SEC-10","name":"Escape everything you show","url":"https://agentwranglers.se/playbook/adversarial-testing/injection/cross-site-scripting","level":"Required","risk":"takeover","rule":"Escape untrusted text for the place it lands in a page, and back it up with a strict Content Security Policy.","check":"Put `<b>PENTEST</b>` and `\"><svg onload=alert('PENTEST')>` in every field that shows up again, such as names, reviews and search terms. Put `javascript:alert('PENTEST')` in every link field, such as a website address. The page shows the text as typed, runs nothing and refuses the link. The page's `Content-Security-Policy` header has a nonce or hashes in `script-src`, plus `'strict-dynamic'`, `object-src 'none'` and `base-uri 'none'` or `'self'`. A list of allowed hosts alone fails, because such lists are easy to bypass."},{"id":"API-51","name":"Sign every webhook","url":"https://agentwranglers.se/playbook/api-design/webhooks-and-events/signed-webhooks","level":"Required","risk":"money","rule":"Sign each webhook body with a secret shared with the receiver, and include a timestamp, so receivers can reject fakes and replays.","check":"Send a test webhook with one byte of the body changed. The receiver rejects it with 401, and a timestamp 10 minutes old is rejected too."},{"id":"PAY-18","name":"Let provider events move the order","url":"https://agentwranglers.se/playbook/payments/payment-events-and-reconciliation/events-drive-fulfilment","level":"Required","risk":"money","rule":"Fulfil, grant access or mark a refund only when a verified provider event or API read says the money moved. Map each event type to one order transition.","check":"Send a signed test event for a payment whose amount differs from the order. The handler refuses to mark the order paid. Every event type enabled on the webhook endpoint appears in the mapping table or in an explicit ignore list."},{"id":"PAY-5","name":"Keep test and live modes apart","url":"https://agentwranglers.se/playbook/payments/checkout-and-card-data/test-and-live-keys","level":"Required","risk":"money","rule":"Use the provider's test mode in every environment except production, and make the app refuse to start with a live key anywhere else.","check":"Start staging with a live key. It exits with an error. Search the repository for `sk_live_` and `rk_live_`. Nothing is found."},{"id":"SEC-36","name":"Close debug and admin doors","url":"https://agentwranglers.se/playbook/adversarial-testing/data-and-secrets/close-debug-doors","level":"Required","risk":"takeover","rule":"Remove debug, test and status tools from production, or put them behind a login and a private network.","check":"The agent requests common debug and admin paths. Each one answers 404 or asks for a login."},{"id":"WEB-139","name":"Make browsers always use HTTPS","url":"https://agentwranglers.se/playbook/website/security/hsts","level":"Required","risk":"takeover","rule":"Send `Strict-Transport-Security` so browsers always use HTTPS. Give it a long `max-age` (at least a year) and `includeSubDomains`, on HTTPS responses only.","check":"`curl -sI https://example.com/` and a missing page both show the header with `max-age` of at least 31536000. `curl -sI http://example.com/` shows no such header."},{"id":"SEC-65","name":"Lock and audit dependencies","url":"https://agentwranglers.se/playbook/adversarial-testing/supply-chain-and-setup/audit-dependencies","level":"Required","risk":"takeover","rule":"Commit a lock file, install from it exactly, and fail the build on known holes in any dependency.","check":"The build runs the locked install command, such as `npm ci`, never a plain install. Add an old package with a known hole on a branch. The audit fails the build."},{"id":"DAT-28","name":"Prove restores work","url":"https://agentwranglers.se/playbook/data/backups-and-recovery/restore-tests","level":"Required","risk":"data","rule":"Restore a backup on a schedule and check the result. An untested backup is a hope, not a backup.","check":"The last automated restore test ran within the past 31 days and passed within the target time. Its row counts and newest timestamp equal those recorded at backup time. The newest backup is younger than the recovery point objective. Delete 1 row from a test restore, and the comparison fails."},{"id":"OPS-45","name":"Back up off the machine","url":"https://agentwranglers.se/playbook/operations/disaster-recovery/off-site-backups","level":"Required","risk":"data","rule":"Copy backups automatically to storage in another place, which an attacker on the server cannot delete.","check":"The newest backup in the off-site store is younger than the backup interval plus one hour, such as 25 hours for daily copies. A delete request sent with the server's own keys is refused."}]}