cleartoship 0.4.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -58,7 +58,7 @@ npx cleartoship
58
58
  | **CTS021** | high | Dependency registered days ago with near-zero downloads (slopsquat shape) |
59
59
  | **CTS022** | low | Runtime dependency with almost no users |
60
60
  | **CTS023** | high/medium | Name is one edit from a popular package (`expres` → `express`) |
61
- | **CTS024** | by CVSS | Dependency version has a **published advisory**, resolved live from [OSV.dev](https://osv.dev) |
61
+ | **CTS024** | by CVSS | Dependency version has a **published advisory**, resolved live from [OSV.dev](https://osv.dev). Vulns in `devDependencies` are labeled and held below the gate — a linter CVE never blocks a deploy the way a shipping one does. |
62
62
  | **CTS025** | low | Dependency deprecated upstream |
63
63
  | **CTS026** | critical | Registry serves HTTP 451 — the package was pulled for malware |
64
64
  | **CTS027** | critical | Package was unpublished but still has installs; the name is open to takeover |
@@ -140,7 +140,7 @@ jobs:
140
140
  runs-on: ubuntu-latest
141
141
  steps:
142
142
  - uses: actions/checkout@v4
143
- - uses: murtazaozdemir/cleartoship@v0.3.0
143
+ - uses: murtazaozdemir/cleartoship@v0.7.0
144
144
  with:
145
145
  fail-on: critical
146
146
  comment: true
@@ -89,7 +89,8 @@ function collectFromPyproject(source, relPath) {
89
89
  }
90
90
  /** Valid npm package name, optionally scoped. */
91
91
  const NPM_NAME = String.raw `(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*`;
92
- const INSTALL_COMMAND = new RegExp(String.raw `\b(?:npm\s+(?:i|install|add)|yarn\s+add|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add)|npx|pnpm\s+dlx|bunx)\s+([^\n\`|;&>]+)`, 'gi');
92
+ const INSTALL_COMMAND = new RegExp(String.raw `\b(?:npm\s+(?:i|install|add)|yarn\s+add|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add))\s+([^\n\`|;&>]+)`, 'gi');
93
+ const RUNNER_COMMAND = new RegExp(String.raw `\b(?:npx|pnpm\s+dlx|bunx)\s+([^\n\`|;&>]+)`, 'gi');
93
94
  const PIP_COMMAND = /\b(?:pip3?\s+install|uv\s+pip\s+install|poetry\s+add|uv\s+add)\s+([^\n`|;&>]+)/gi;
94
95
  /**
95
96
  * Pulls package names out of install commands written in prose. Agent
@@ -115,7 +116,9 @@ function collectFromProse(source, relPath) {
115
116
  if (/^[.~/]|:/.test(bare))
116
117
  continue;
117
118
  const ok = ecosystem === 'npm' ? npmName.test(bare) : /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(bare);
118
- if (!ok)
119
+ // A real package name contains letters; reject list bullets, ports and
120
+ // version-ish tokens ("3003", "1.", "2") that show up in prose.
121
+ if (!ok || !/[a-z]/i.test(bare) || bare.length < 2)
119
122
  continue;
120
123
  out.push({ name: bare, range: '', ecosystem, file: relPath, line, dev: false, fromProse: true });
121
124
  if (firstArgOnly)
@@ -123,6 +126,10 @@ function collectFromProse(source, relPath) {
123
126
  }
124
127
  }
125
128
  };
129
+ // For `npx pkg <args>` / `pnpm dlx` / `bunx`, only the first token is a
130
+ // package — the rest are that command's arguments (so `wrangler d1 create
131
+ // my-db` must not read `d1`, `create`, `my-db` as packages).
132
+ harvest(RUNNER_COMMAND, 'npm', true);
126
133
  harvest(INSTALL_COMMAND, 'npm', false);
127
134
  harvest(PIP_COMMAND, 'pypi', false);
128
135
  return out;
@@ -498,15 +505,28 @@ export const dependencyScanner = {
498
505
  const worst = vulns.reduce((a, b) => ((b.cvss ?? 0) > (a.cvss ?? 0) ? b : a));
499
506
  const cve = worst.aliases.find((a) => a.startsWith('CVE-')) ?? worst.id;
500
507
  const others = vulns.length - 1;
508
+ // A devDependency's vulnerability lives in your build/CI toolchain, not
509
+ // in what your users run — a real concern, but not the same class as a
510
+ // CVE in a package that ships. Label it and hold its severity below the
511
+ // gate so a linter CVE never blocks a deploy the way a runtime one does.
512
+ const shipsToProd = !d.dev;
513
+ const severity = shipsToProd
514
+ ? severityFromCvss(worst.cvss)
515
+ : 'low';
501
516
  result.findings.push({
502
517
  id: 'CTS024',
503
- severity: severityFromCvss(worst.cvss),
504
- title: `Dependency has a known vulnerability (${cve})`,
518
+ severity,
519
+ title: shipsToProd
520
+ ? `Dependency has a known vulnerability (${cve})`
521
+ : `Dev dependency has a known vulnerability (${cve})`,
505
522
  detail: `\`${d.name}@${q.version}\` is affected by ${cve}: ${worst.summary}` +
506
523
  (worst.cvss !== null ? ` CVSS ${worst.cvss}.` : '') +
507
524
  (others > 0
508
525
  ? ` ${others} further advisor${others === 1 ? 'y' : 'ies'} also affect this version.`
509
- : ''),
526
+ : '') +
527
+ (shipsToProd
528
+ ? ''
529
+ : ' It is a dev/build dependency, so it does not ship to production — fix it, but it does not gate a deploy.'),
510
530
  fix: worst.fixedIn
511
531
  ? `Upgrade \`${d.name}\` to ${worst.fixedIn} or later.`
512
532
  : `No fixed version is published yet. Check https://osv.dev/vulnerability/${worst.id} for mitigations.`,
@@ -517,6 +537,7 @@ export const dependencyScanner = {
517
537
  meta: {
518
538
  package: d.name,
519
539
  version: q.version,
540
+ production: shipsToProd,
520
541
  resolvedFrom: versions.has(d.name) ? 'lockfile-or-range' : 'range',
521
542
  advisories: vulns.map((v) => ({
522
543
  id: v.id,
@@ -528,11 +549,16 @@ export const dependencyScanner = {
528
549
  });
529
550
  });
530
551
  if (osvChecked > 0) {
531
- const vulnerable = result.findings.filter((f) => f.id === 'CTS024').length;
552
+ const cts024 = result.findings.filter((f) => f.id === 'CTS024');
553
+ const shipping = cts024.filter((f) => f.meta?.production === true).length;
554
+ const devOnly = cts024.length - shipping;
532
555
  result.checks.push({
533
556
  label: `Known vulnerabilities (${osvChecked} resolved versions checked against OSV.dev)`,
534
- passed: vulnerable === 0,
535
- note: vulnerable > 0 ? `${vulnerable} affected` : undefined,
557
+ // Only shipping vulnerabilities fail the check; dev/build ones are noted.
558
+ passed: shipping === 0,
559
+ note: cts024.length === 0
560
+ ? undefined
561
+ : `${shipping} shipping` + (devOnly > 0 ? `, ${devOnly} dev/build (non-blocking)` : ''),
536
562
  });
537
563
  }
538
564
  }
@@ -56,7 +56,25 @@ function parseColumns(body) {
56
56
  export const rlsScanner = {
57
57
  name: 'Supabase / PostgreSQL Row Level Security',
58
58
  applies(ctx) {
59
- return ctx.files.some(isSql);
59
+ if (!ctx.files.some(isSql))
60
+ return false;
61
+ // Row Level Security is a PostgreSQL feature that Supabase builds on. This
62
+ // scanner must not fire on SQLite / Cloudflare D1 / Prisma-sqlite schemas,
63
+ // which have no RLS concept at all — doing so turns every CREATE TABLE into
64
+ // a false "RLS disabled" critical. Require a genuine Postgres/Supabase
65
+ // signal: the dependency set, a supabase/ directory, or RLS/auth idioms in
66
+ // the SQL itself.
67
+ if (ctx.framework.supabase)
68
+ return true;
69
+ for (const file of ctx.files) {
70
+ if (!isSql(file))
71
+ continue;
72
+ const src = read(file);
73
+ if (src && /\brow\s+level\s+security\b|\bauth\.(uid|jwt|role)\s*\(|\bto\s+(anon|authenticated)\b|\bcreate\s+policy\b/i.test(src)) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
60
78
  },
61
79
  async run(ctx) {
62
80
  const result = emptyResult();
@@ -82,7 +82,7 @@ const HEX_DIGEST = /^[a-f0-9]{40}$|^[a-f0-9]{64}$/i;
82
82
  */
83
83
  const NON_PRODUCTION_PATH = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$|(^|\/)(README|CHANGELOG|CONTRIBUTING)/i;
84
84
  /** Env var names that are meant to be public even though they read like secrets. */
85
- const PUBLIC_BY_DESIGN = /(ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|CLIENT_ID|MEASUREMENT_ID|PROJECT_ID|APP_ID|SENDER_ID|FIREBASE_API_KEY|MAPBOX_TOKEN|POSTHOG_KEY|SENTRY_DSN)$/;
85
+ const PUBLIC_BY_DESIGN = /(ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|CLIENT_ID|MEASUREMENT_ID|PROJECT_ID|APP_ID|SENDER_ID|FIREBASE_API_KEY|MAPBOX_TOKEN|POSTHOG_KEY|SENTRY_DSN|SHOPIFY_API_KEY)$/;
86
86
  const SECRETY_NAME = /(SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|PASSWD|_TOKEN|API_KEY|ACCESS_KEY|CREDENTIAL)/;
87
87
  const LOCKFILES = new Set([
88
88
  'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
@@ -153,6 +153,8 @@ export const secretsScanner = {
153
153
  if (pattern.id === 'supabase-anon')
154
154
  continue; // informational only, not reported
155
155
  const line = lineAt(source, m.index);
156
+ if (isCommentedOut(source, m.index))
157
+ continue; // documented example, not a live secret
156
158
  if (suppress.suppressed(line, 'CTS030'))
157
159
  continue;
158
160
  const key = `${relPath}:${line}:${pattern.id}`;
@@ -40,7 +40,18 @@ const SIGNATURE_CHECKS = [
40
40
  'webhooks.constructEvent', 'constructEvent', 'constructEventAsync', 'verifyHeader',
41
41
  'verify', 'verifySignature', 'createHmac', 'timingSafeEqual', 'Webhook', 'validateRequest',
42
42
  'verifyWebhook', 'verifyWebhookSignature',
43
+ // Framework webhook handlers that verify the signature internally, so the
44
+ // route body delegates rather than calling an hmac primitive directly.
45
+ 'authenticate.webhook', 'webhooks.process', 'webhooks.validate', 'processWebhook',
46
+ 'handleWebhook', 'validateWebhook', 'wh.verify', 'svix.verify',
43
47
  ];
48
+ /**
49
+ * Request headers that only exist to carry a webhook signature. A handler that
50
+ * reads one is participating in signature verification — a route that genuinely
51
+ * forgot it would not reference the header at all — so reading one clears the
52
+ * "unverified webhook" check.
53
+ */
54
+ const SIGNATURE_HEADERS = /(x-shopify-hmac-sha256|x-hub-signature(-256)?|stripe-signature|svix-signature|svix-id|x-signature|x-webhook-signature|x-slack-signature|x-line-signature|paypal-transmission-sig)/i;
44
55
  /** Schema escape hatches that make validation decorative. */
45
56
  const LOOSE_SCHEMA = /\.passthrough\s*\(|z\s*\.\s*(any|unknown)\s*\(|\.catchall\s*\(/g;
46
57
  const SERVICE_ROLE_HINTS = [
@@ -121,6 +132,13 @@ function analyseFunction(path, name) {
121
132
  if (tail === 'from' || tail === 'select' || tail === 'findMany' || tail === 'findUnique' || tail === 'findFirst') {
122
133
  info.hasRead = true;
123
134
  }
135
+ // Reading a webhook-signature header counts as participating in
136
+ // verification (see SIGNATURE_HEADERS).
137
+ for (const arg of inner.node.arguments ?? []) {
138
+ if (arg?.type === 'StringLiteral' && SIGNATURE_HEADERS.test(arg.value)) {
139
+ info.hasSignatureCheck = true;
140
+ }
141
+ }
124
142
  // Raw SQL: db.query(`DELETE FROM ...`) / sql`UPDATE ...`
125
143
  if (tail === 'query' || tail === 'execute' || tail === 'unsafe' || tail === 'raw') {
126
144
  for (const arg of inner.node.arguments ?? []) {
@@ -249,13 +267,24 @@ export const serverActionsScanner = {
249
267
  const writes = info.hasMutation || (isRoute && HTTP_MUTATION_METHODS.has(httpMethod));
250
268
  const isWebhook = isRoute && httpMethod === 'POST' && /webhook|\bhooks?\b|stripe|clerk|svix/i.test(relPath);
251
269
  const isCron = isRoute && /(^|\/)(cron|scheduled|jobs?)(\/|$)/i.test(relPath);
270
+ // Some endpoints are unauthenticated by design — the sign-in and
271
+ // account-recovery flow (you have no session yet), and public intake
272
+ // forms. Flagging "missing auth" there is a false positive, so it is
273
+ // reported at low rather than as a blocking critical.
274
+ const PUBLIC_BY_DESIGN_ROUTE = /(^|\/|-)(login|signin|sign-in|register|signup|sign-up|forgot-password|reset-password|verify-email|resend-verification|magic-link|contact|lead|leads|waitlist|subscribe|unsubscribe|newsletter)(\/|-|\.|$)/i;
275
+ const intentionallyPublic = isRoute && PUBLIC_BY_DESIGN_ROUTE.test(relPath);
252
276
  if (writes && !info.hasAuth && info.getSessionLine === null) {
253
277
  push({
254
278
  id: 'CTS001',
255
- severity: 'critical',
256
- title: `Missing ${kind} authorization`,
279
+ severity: intentionallyPublic ? 'low' : 'critical',
280
+ title: intentionallyPublic
281
+ ? `${kind} is unauthenticated (appears public by design)`
282
+ : `Missing ${kind} authorization`,
257
283
  detail: `${kind} \`${name}\` performs a database mutation without verifying the caller. ` +
258
- exposure,
284
+ exposure +
285
+ (intentionallyPublic
286
+ ? ' This route name suggests a sign-in / account-recovery or public-intake endpoint, which is unauthenticated by design — confirm it has rate limiting and does not trust caller-supplied identifiers.'
287
+ : ''),
259
288
  fix: 'Resolve and check the session before touching the database, e.g.\n' +
260
289
  ' const { data: { user } } = await supabase.auth.getUser()\n' +
261
290
  " if (!user) throw new Error('Unauthorized')\n" +
@@ -3,8 +3,9 @@ import { join, relative, sep } from 'node:path';
3
3
  const SKIP_DIRS = new Set([
4
4
  'node_modules', '.git', '.next', '.turbo', '.vercel', '.wrangler',
5
5
  'dist', 'build', 'out', 'coverage', '.venv', 'venv', '__pycache__',
6
+ '.open-next', '.sst', '.vercel',
6
7
  '.cache', '.pnpm-store', 'vendor', 'target', '.svelte-kit', '.nuxt',
7
- '.cts-cache', '_reference',
8
+ '.cts-cache', '_reference', 'site',
8
9
  ]);
9
10
  const SCAN_EXTS = new Set([
10
11
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.sql',
@@ -24,6 +25,29 @@ const AGENT_FILES = new Set([
24
25
  ]);
25
26
  /** Files bigger than this are almost certainly bundles or fixtures, not source. */
26
27
  const MAX_FILE_BYTES = 2_000_000;
28
+ /**
29
+ * Precise generated-code detection — deliberately NOT a blanket skip of any
30
+ * `generated/` directory, which can legitimately hold hand-written code. Matches
31
+ * the codegen outputs that produce false positives (a Prisma client ships its
32
+ * own credentials, queryRawUnsafe and dynamic requires that are safe in vendor
33
+ * code but read as findings), and files carrying a generated banner.
34
+ */
35
+ const GENERATED_PATH = /(^|\/)(generated|__generated__)\/(prisma|graphql|gql|client)(\/|$)|(^|\/)\.prisma(\/|$)|\.(generated|gen)\.[cm]?[jt]sx?$/i;
36
+ const GENERATED_BANNER = /(^|\n).{0,4}(@generated\b|this (file|code) (is|was) (auto[- ]?)?generated|code generated by|do not edit|prisma client js|autogenerated)/i;
37
+ function looksGenerated(fullPath, size) {
38
+ if (GENERATED_PATH.test(fullPath))
39
+ return true;
40
+ // Cheap banner check on the first chunk for the ambiguous cases only.
41
+ if (!/(^|\/)(generated|gen|__generated__|codegen)(\/|$)/i.test(fullPath))
42
+ return false;
43
+ try {
44
+ const head = readFileSync(fullPath, 'utf8').slice(0, 400);
45
+ return GENERATED_BANNER.test(head);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
27
51
  export function walk(root) {
28
52
  const found = [];
29
53
  const stack = [root];
@@ -56,12 +80,13 @@ export function walk(root) {
56
80
  const dot = entry.lastIndexOf('.');
57
81
  const ext = dot === -1 ? '' : entry.slice(dot);
58
82
  // .env, .env.local, requirements.txt and friends have no useful extension.
59
- if (SCAN_EXTS.has(ext) ||
83
+ const scannable = SCAN_EXTS.has(ext) ||
60
84
  AGENT_FILES.has(entry) ||
61
85
  NAMED_FILES.has(entry) ||
62
86
  entry.startsWith('Dockerfile') ||
63
87
  entry.startsWith('.env') ||
64
- entry === 'requirements.txt') {
88
+ entry === 'requirements.txt';
89
+ if (scannable && !looksGenerated(full, st.size)) {
65
90
  found.push(full);
66
91
  }
67
92
  }
@@ -16,7 +16,7 @@ jobs:
16
16
  runs-on: ubuntu-latest
17
17
  steps:
18
18
  - uses: actions/checkout@v4
19
- - uses: murtazaozdemir/cleartoship@v0.3.0
19
+ - uses: murtazaozdemir/cleartoship@v0.7.0
20
20
  with:
21
21
  fail-on: critical # block the PR only on criticals
22
22
  comment: true # post a summary comment on the PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cleartoship",
3
- "version": "0.4.0",
3
+ "version": "0.7.0",
4
4
  "description": "The 30-second pre-launch security clearance for AI-built & vibe-coded apps. Catches missing Server Action auth, Supabase RLS holes, hallucinated npm packages and leaked keys.",
5
5
  "keywords": [
6
6
  "security",