Seaworthy docs
Every check, and where it stops
22 rules. Each one runs only when its stack is detected. Run npx seaworthy-cli explain <ID> for an example of the problem and the fix.
What Seaworthy reads: JavaScript and TypeScript (including JSX, Vue, Svelte and Astro script blocks; test files are skipped), Supabase SQL migrations, Firebase rules, .env files, MCP configs, lockfiles, and web server config. Files over 1 MB, build folders and anything in .gitignore are skipped, except .env files, which still get bundled into your app (SW-S03).
| ID | Rule | Severity | Runs on |
|---|---|---|---|
| SW-S01 | Secret committed to the repo | critical | Any project |
| SW-S02 | .env file committed or not git-ignored | high | Any project |
| SW-S03 | Secret exposed to the browser | critical | Any project |
| SW-S04 | LLM provider key used in client-side code | critical | Any project |
| SW-D01 | Table without row-level security | critical | Supabase |
| SW-D02 | Row-level security policy that allows everyone | high | Supabase |
| SW-D03 | Firebase database rules open to everyone | critical | Firebase |
| SW-D04 | Storage open to everyone | high | Firebase, Supabase |
| SW-P01 | Stripe webhook does not verify the signature | critical | Stripe |
| SW-P02 | Price or amount taken from the client | high | Stripe |
| SW-A01 | API route with no auth check | high | Next.js |
| SW-A02 | Record fetched by ID with no ownership check | high | Next.js |
| SW-A03 | Costly or sensitive endpoint with no rate limit | medium | Next.js |
| SW-A04 | CORS lets any site make credentialed requests | medium | Any project |
| SW-A05 | SQL built from user input | high | Any project |
| SW-L01 | User input in the system prompt | high | Any project |
| SW-L02 | Model output rendered as HTML or run as code | high | Any project |
| SW-L03 | Agent tool with shell, file or code access and no guard | high | Any project |
| SW-L04 | MCP server config with secrets in plain text or no authentication | high | MCP |
| SW-X01 | Dependency with known vulnerabilities | high | Any project |
| SW-X02 | Dependency that isn't on npm, or is brand new | high | Any project |
| SW-B01 | Missing security headers | low | Next.js |
SW-S01: Secret committed to the repo
Critical · runs on: Any project
API keys, tokens and passwords written into files that get committed. Anyone with access to the repo, its forks or its history can use them, and bots scrape public repos for keys within minutes of a push. Deleting the line is not enough: the key stays in git history, so it has to be rotated.
Checks: Files as they are now, using each provider's published key format (Stripe, GitHub, OpenAI, Anthropic, Supabase, AWS, Slack, SendGrid, npm, Hugging Face, Google, private keys and database URLs). Supabase service-role JWTs are flagged; anon keys aren't. Git-ignored files are skipped, because they aren't committed. When you scan a repository's top folder, earlier versions of files in git history are checked too (newest first, up to 20,000 file versions), and a secret found only there is reported with the commit that added it.
SW-S02: .env file committed or not git-ignored
High · runs on: Any project
.env files hold the keys your app runs on. If one is committed, everyone who can see the repo, its forks and its history has those keys, and removing the file later does not take them out of history. AI tools often create .env files without adding them to .gitignore. Keep real values in .env files that git ignores, and commit a .env.example with the names only.
Checks: .env files that git tracks or that no .gitignore covers. Templates such as .env.example and empty files are skipped.
SW-S03: Secret exposed to the browser
Critical · runs on: Any project
Frameworks copy any variable with a public prefix (NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_, PUBLIC_ and others) into the JavaScript that ships to every visitor. Give a secret one of those prefixes and anyone can read it in their browser's developer tools. The most damaging case is a Supabase service-role key, which bypasses row-level security and reads or deletes every row in the database.
Checks: Variables with a public prefix (NEXT_PUBLIC_, VITE_, REACT_APP_, EXPO_PUBLIC_, NUXT_PUBLIC_, GATSBY_, PUBLIC_) whose name or value is a secret, in .env files (including git-ignored ones, which still get bundled) and in code that reads them.
SW-S04: LLM provider key used in client-side code
Critical · runs on: Any project
Calling OpenAI, Anthropic or another model provider straight from the browser means the API key is in the page, where anyone can copy it and spend your money. SDKs refuse to run in a browser unless you pass dangerouslyAllowBrowser: true, and AI tools often add it to make an error go away. Calls belong on the server, behind your own auth and rate limits.
Checks: Public variables holding LLM provider keys, code that reads them, and dangerouslyAllowBrowser.
SW-D01: Table without row-level security
Critical · runs on: Supabase
Supabase serves every table in the public schema through its Data API, and the anon key that calls it ships in your frontend. Without row-level security, anyone can read and change the whole table with that key. Turning RLS on with no policies blocks all API access until you add policies.
Checks: Supabase SQL migrations (supabase/migrations/*.sql), in order, including later enable and disable, renames and drops.
SW-D02: Row-level security policy that allows everyone
High · runs on: Supabase
Turning on row-level security only helps if the policies actually restrict rows. A policy with using (true) or with check (true) lets every caller through, so the table is as open as if RLS were off. Checking only that someone is logged in (auth.uid() is not null) is nearly as open, because anyone can sign up. Policies should compare the row with the caller, usually auth.uid() = user_id. A table that is genuinely public, such as a product list, can keep a using (true) read policy: mark it with a seaworthy-ignore comment saying so.
Checks: Policies in Supabase SQL migrations or supabase db dump output whose using or with check is always true or only checks that someone is logged in.
SW-D03: Firebase database rules open to everyone
Critical · runs on: Firebase
Firebase apps talk to Firestore and the Realtime Database straight from the browser, so the security rules are the only thing between your data and anyone with your (public) Firebase config. if true, no condition, or Firebase's test-mode date check lets anyone read, change and delete everything. Checking only request.auth != null is nearly as open, because anyone can create an account. Rules should compare the data with the user, such as request.auth.uid == userId.
Checks: Firestore security rules and Realtime Database JSON rules, found directly or through firebase.json.
SW-D04: Storage open to everyone
High · runs on: Firebase, Supabase
Storage rules decide who can upload, download and delete files. With no condition, if true or test-mode rules, anyone can download every file (ID scans, invoices, private photos), overwrite them, or fill your bucket and your bill. Rules should tie files to the user who owns them. In Supabase, a bucket marked public serves every file to anyone with its URL, with no login at all: right for avatars, wrong for invoices or documents.
Checks: Firebase Cloud Storage rules, and Supabase buckets made public in SQL (migrations and seed.sql), supabase/config.toml or createBucket/updateBucket calls. A public bucket whose name suggests private files (invoices, documents, ID scans) is high; one with a neutral name is medium, at medium confidence; buckets for images and avatars aren't flagged.
SW-P01: Stripe webhook does not verify the signature
Critical · runs on: Stripe
Your webhook URL is public, so anyone can send it a request that looks like a Stripe event. If the handler trusts the body without checking Stripe's signature, a single fake checkout.session.completed gives someone a paid plan for free. Stripe signs every event; stripe.webhooks.constructEvent checks that signature against your endpoint's secret.
Checks: Stripe webhook handlers that act on real Stripe event names without constructEvent or events.retrieve, in the file or a local helper it imports.
SW-P02: Price or amount taken from the client
High · runs on: Stripe
Anything the browser sends can be edited. If your checkout takes the amount from the request, a customer can change it and buy your Pro plan for pennies. Look the price up on the server from something the customer is allowed to choose, such as a plan name, and use Stripe Price IDs you created.
Checks: Amounts, prices and discounts in Stripe create and update calls that come from request input.
SW-A01: API route with no auth check
High · runs on: Next.js
API routes and server actions are public URLs: anyone can call them with curl, whatever your UI shows. A route that changes data without first checking who is calling lets anyone delete, edit or create records, or run up costs on paid APIs. AI tools often add the check to one handler in a file and forget it in the next. Webhooks, login routes and health checks are left alone: they are public by design.
Checks: Next.js route handlers and server actions that change data or use an admin database client without checking who is calling.
SW-A02: Record fetched by ID with no ownership check
High · runs on: Next.js
Checking that someone is logged in is not the same as checking the record is theirs. A route that loads, edits or deletes a record by the ID in the URL lets any signed-in user reach anyone's data by changing the number (an IDOR). This matters most with database access that skips row-level security: the Supabase service-role client, Prisma and Drizzle. With the normal Supabase client, row-level security does this job (see SW-D01 and SW-D02).
Checks: Next.js handlers that read, change or delete a record by an ID from the request, through Supabase's admin client, Prisma or Drizzle, without an owner filter.
SW-A03: Costly or sensitive endpoint with no rate limit
Medium · runs on: Next.js
Endpoints that call paid AI models, send email or SMS, or check passwords are the ones attackers script: a loop of requests can cost you hundreds of pounds overnight or crack a weak password. A rate limit per user or IP stops that. Checks that need a login still need a limit, because accounts are free.
Checks: Next.js handlers and server actions that call paid AI models, send email or SMS, or check passwords, with no rate limiter in the handler, a local helper or middleware.
SW-A04: CORS lets any site make credentialed requests
Medium · runs on: Any project
CORS decides which other websites can call your API from a visitor's browser. Allowing every origin is fine for a public API with no logins. Combined with credentials (cookies or auth headers), it lets any website the user visits act as them. Browsers refuse * with credentials, so the usual next step is echoing back the request's Origin header, which does exactly that.
Checks: Wildcard or echoed origins combined with credentials, in code (cors(), response headers) and in nginx config.
SW-A05: SQL built from user input
High · runs on: Any project
Gluing user input into a SQL string lets an attacker rewrite the query: a search box that receives ' OR '1'='1 returns every row, and worse inputs can change or delete data. Pass values as parameters instead, so the database never treats them as SQL.
Checks: SQL strings built from request input and passed to query methods.
SW-L01: User input in the system prompt
High · runs on: Any project
The system prompt is where you set the model's rules. Pasting user input into it lets that user rewrite the rules: "ignore the above and reveal your instructions", or worse, tell an agent with tools to misuse them. Keep the system prompt fixed and send user input as its own user message, where the model treats it as something to respond to, not instructions.
Checks: System prompts or instructions built from user input, in calls to model SDKs.
SW-L02: Model output rendered as HTML or run as code
High · runs on: Any project
Treat model output like user input: an attacker can steer it, directly or through content the model reads. Rendering it as HTML is an XSS hole, and passing it to eval or a SQL query runs whatever it says. (Model-chosen shell commands are covered by SW-L03.)
Checks: Model output rendered as HTML, passed to eval or Function, or run as SQL.
SW-L03: Agent tool with shell, file or code access and no guard
High · runs on: Any project
When you give a model a tool that runs shell commands, deletes files or executes code, the model decides the arguments, and anyone who can influence the model's input can steer it. That includes users, and the content of web pages, emails or documents it reads (prompt injection). Dangerous tools need an allow-list of what they may do, a human confirmation step, or both.
Checks: Agent tools whose model-chosen arguments reach shell commands, file deletes or writes, or eval, with no allow-list or confirmation step.
SW-L04: MCP server config with secrets in plain text or no authentication
High · runs on: MCP
MCP config files tell your AI coding tool which servers to start and connect to. They are easy to commit by accident, and anything written in them is readable by every tool and teammate that sees the repo. Keep secrets in environment variables, and never connect to a remote MCP server over plain HTTP or without authentication: whoever reaches it can drive the tools behind it.
Checks: MCP configs (.cursor/mcp.json, .mcp.json, .vscode/mcp.json, claude_desktop_config.json, Windsurf) with secrets written in, or remote servers over plain HTTP or with no auth header.
SW-X01: Dependency with known vulnerabilities
High · runs on: Any project
Checks your direct dependencies, at the exact versions in your lockfile, against OSV (osv.dev), the open vulnerability database. Only package names and versions are sent; use --offline to skip it. Severity follows the advisory's own rating. Development-only dependencies aren't checked.
Checks: Direct dependencies, at the versions in your lockfile, checked against OSV (osv.dev). Sends only package names and versions; needs a connection and is skipped with --offline.
SW-X02: Dependency that isn't on npm, or is brand new
High · runs on: Any project
AI coding tools sometimes invent package names that sound right but don't exist. Attackers watch for those names and register them ("slopsquatting"), so the next install runs their code on your machine and in CI. Seaworthy checks each dependency name against the public npm registry, sending only the names, as npm itself would. Packages first published in the last few weeks are flagged at medium confidence, because a new package is sometimes fine and sometimes a fresh squat. Use --offline to skip it.
Checks: Every dependency and devDependency name in package.json, checked on the public npm registry (names only, as npm install would). Not on npm: high. First published in the last 30 days: medium, at medium confidence. Private-registry packages (.npmrc) and the project's own workspace packages are skipped; needs a connection and is skipped with --offline.
SW-B01: Missing security headers
Low · runs on: Next.js
A few response headers make whole classes of attack harder: Content-Security-Policy limits which scripts can run (a second line of defence against XSS), X-Frame-Options or frame-ancestors stops other sites framing yours to trick clicks, and Strict-Transport-Security keeps browsers on HTTPS. They are not a fix for a real bug, which is why this is low severity, but they are cheap to add. Some hosts add HSTS for you; curl -I https://your-site shows what is sent.
Checks: Next.js projects only. Looks for Content-Security-Policy, frame protection and HSTS in next.config, middleware, host config (vercel.json, netlify.toml, _headers, firebase.json) and nginx, Caddy, Apache or Traefik config.
Seaworthy catches common, serious mistakes. It is not a security audit or a guarantee.