@ship-safe/cli 1.1.13 → 1.1.16

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.
Files changed (2) hide show
  1. package/dist/index.js +921 -36
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4125,6 +4125,59 @@ var SEVERITY_ORDER = {
4125
4125
  low: 3,
4126
4126
  info: 4
4127
4127
  };
4128
+ var TIERS = {
4129
+ free: {
4130
+ id: "free",
4131
+ displayName: "Free",
4132
+ kind: "free",
4133
+ priceCents: 0,
4134
+ aiScans: 0,
4135
+ aiScansAreLifetime: false,
4136
+ fixPromptsPerMonth: 0
4137
+ },
4138
+ growth: {
4139
+ id: "growth",
4140
+ displayName: "Growth",
4141
+ kind: "subscription",
4142
+ priceCents: 1900,
4143
+ aiScans: 8,
4144
+ aiScansAreLifetime: false,
4145
+ fixPromptsPerMonth: 999
4146
+ },
4147
+ shield: {
4148
+ id: "shield",
4149
+ displayName: "Shield",
4150
+ kind: "subscription",
4151
+ priceCents: 3900,
4152
+ aiScans: 15,
4153
+ aiScansAreLifetime: false,
4154
+ fixPromptsPerMonth: 999
4155
+ },
4156
+ audit: {
4157
+ id: "audit",
4158
+ displayName: "Pro Audit",
4159
+ kind: "one_time",
4160
+ priceCents: 900,
4161
+ aiScans: 3,
4162
+ aiScansAreLifetime: true,
4163
+ fixPromptsPerMonth: 0
4164
+ }
4165
+ };
4166
+ var AI_SCAN_LIMITS = {
4167
+ free: TIERS.free.aiScans,
4168
+ growth: TIERS.growth.aiScans,
4169
+ shield: TIERS.shield.aiScans,
4170
+ audit: TIERS.audit.aiScans
4171
+ };
4172
+ var FIX_PROMPT_LIMITS = {
4173
+ free: TIERS.free.fixPromptsPerMonth,
4174
+ growth: TIERS.growth.fixPromptsPerMonth,
4175
+ shield: TIERS.shield.fixPromptsPerMonth,
4176
+ audit: TIERS.audit.fixPromptsPerMonth
4177
+ };
4178
+ var LIFETIME_TIERS = new Set(
4179
+ Object.values(TIERS).filter((t) => t.aiScansAreLifetime).map((t) => t.id)
4180
+ );
4128
4181
  var LANGUAGE_EXTENSIONS = {
4129
4182
  ".js": "javascript",
4130
4183
  ".jsx": "javascript",
@@ -4145,7 +4198,25 @@ var LANGUAGE_EXTENSIONS = {
4145
4198
  ".yml": "yaml",
4146
4199
  ".yaml": "yaml",
4147
4200
  ".json": "json",
4148
- ".toml": "toml"
4201
+ ".toml": "toml",
4202
+ // Added for AI-agent rules (round 7-vuln):
4203
+ ".md": "markdown",
4204
+ ".mdc": "markdown",
4205
+ // Cursor 2026 rules format
4206
+ ".sql": "sql",
4207
+ ".sh": "shell",
4208
+ ".bash": "shell",
4209
+ ".zsh": "shell"
4210
+ };
4211
+ var LANGUAGE_FILENAMES = {
4212
+ ".cursorrules": "markdown",
4213
+ ".windsurfrules": "markdown",
4214
+ ".clinerules": "markdown",
4215
+ ".continuerules": "markdown",
4216
+ ".aiderules": "markdown",
4217
+ ".roorules": "markdown",
4218
+ Dockerfile: "dockerfile",
4219
+ Containerfile: "dockerfile"
4149
4220
  };
4150
4221
  var IGNORE_PATTERNS = [
4151
4222
  "node_modules",
@@ -4159,7 +4230,10 @@ var IGNORE_PATTERNS = [
4159
4230
  ".venv",
4160
4231
  "vendor",
4161
4232
  ".idea",
4162
- ".vscode",
4233
+ // NOTE: `.vscode` intentionally removed — GitHub Copilot CVE-2025-53773
4234
+ // modifies `.vscode/settings.json` to bypass guardrails, and we need to
4235
+ // scan that file. Keep `.vscode/launch.json` etc out via specific rules
4236
+ // if false-positive volume becomes an issue.
4163
4237
  "*.min.js",
4164
4238
  "*.min.css",
4165
4239
  "*.map",
@@ -4244,6 +4318,8 @@ var llmResponseSchema = external_exports.object({
4244
4318
  import path from "path";
4245
4319
  import { createHash } from "crypto";
4246
4320
  function detectLanguage(filePath) {
4321
+ const basename = path.basename(filePath);
4322
+ if (LANGUAGE_FILENAMES[basename]) return LANGUAGE_FILENAMES[basename];
4247
4323
  const ext = path.extname(filePath).toLowerCase();
4248
4324
  return LANGUAGE_EXTENSIONS[ext] ?? "unknown";
4249
4325
  }
@@ -4727,16 +4803,19 @@ var SECRET_RULES = [
4727
4803
  {
4728
4804
  id: "secrets/openai-api-key",
4729
4805
  title: "OpenAI API Key Detected",
4730
- description: "An OpenAI API key was found hardcoded. This can incur charges on your OpenAI account.",
4806
+ description: "An OpenAI API key was found hardcoded. Anyone with repo access (or anyone running your built bundle) can drain your balance \u2014 and bots scrape GitHub for these continuously, so assume the key is already compromised.",
4731
4807
  severity: "critical",
4732
4808
  confidence: "high",
4733
4809
  cwe: "CWE-798",
4810
+ owasp: "A07:2021",
4734
4811
  languages: ["*"],
4735
4812
  patterns: [
4813
+ // Legacy 2023-era keys
4736
4814
  { regex: "sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}", type: "match" },
4737
- { regex: "sk-proj-[a-zA-Z0-9\\-_]{40,}", type: "match" }
4815
+ // Modern project-scoped, service-account, and sandbox key prefixes (2024+)
4816
+ { regex: "sk-(?:proj|svcacct|None)-[a-zA-Z0-9_-]{20,}", type: "match" }
4738
4817
  ],
4739
- fix: { description: "Move your OpenAI key to an environment variable.", suggestion: "process.env.OPENAI_API_KEY" }
4818
+ fix: { description: "Rotate at platform.openai.com immediately. Move the key to a server-only env var. If this was in a public repo for any window, assume it's drained.", suggestion: "process.env.OPENAI_API_KEY" }
4740
4819
  },
4741
4820
  {
4742
4821
  id: "secrets/supabase-service-role",
@@ -4851,6 +4930,201 @@ var SECRET_RULES = [
4851
4930
  ],
4852
4931
  excludePatterns: [{ regex: "(?:test|spec|mock|fixture|seed|placeholder|example|\\.test\\.|\\.spec\\.|__test__|jest|vitest)", type: "context_line" }],
4853
4932
  fix: { description: "Remove default credentials from code. Use environment variables and generate strong, unique passwords for each deployment." }
4933
+ },
4934
+ // ─────────────────────────────────────────────────────────────────────────
4935
+ // Round 7-vuln additions: AI provider keys + vector DB keys + extension
4936
+ // publish tokens. Sourced from tasks/HANDOFF_CLAUDE_CODE.md §I + §4.3.
4937
+ // ─────────────────────────────────────────────────────────────────────────
4938
+ {
4939
+ id: "secrets/anthropic-api-key",
4940
+ title: "Anthropic API Key Detected",
4941
+ description: "An Anthropic API key (sk-ant-...) is sitting in source. Anyone with repo access can run up your Anthropic bill. Bots scrape GitHub for these continuously.",
4942
+ severity: "critical",
4943
+ confidence: "high",
4944
+ cwe: "CWE-798",
4945
+ owasp: "A07:2021",
4946
+ languages: ["*"],
4947
+ patterns: [{ regex: "sk-ant-[a-zA-Z0-9_-]{40,}", type: "match" }],
4948
+ excludePatterns: [{ regex: "(?:example|test|fake|dummy|placeholder|xxxx|YOUR_KEY)", type: "context_line" }],
4949
+ fix: { description: "Rotate at console.anthropic.com immediately. Move the key to a server-only env var. The key was already scraped \u2014 assume it's compromised, don't just remove the commit.", suggestion: "process.env.ANTHROPIC_API_KEY" }
4950
+ },
4951
+ {
4952
+ id: "secrets/google-ai-key",
4953
+ title: "Google AI Studio / Gemini API Key Detected",
4954
+ description: "A Google AI Studio API key (AIza...) used for Gemini API and AI Studio. Same scrape risk as other AI keys.",
4955
+ severity: "critical",
4956
+ confidence: "high",
4957
+ cwe: "CWE-798",
4958
+ owasp: "A07:2021",
4959
+ languages: ["*"],
4960
+ patterns: [
4961
+ { regex: `(?:GEMINI|GOOGLE_AI|GOOGLE_GEN_AI|GENAI)_API_KEY\\s*[:=]\\s*["']?AIza[A-Za-z0-9_-]{35}`, type: "match" },
4962
+ { regex: `["']AIza[A-Za-z0-9_-]{35}["']`, type: "match" }
4963
+ ],
4964
+ excludePatterns: [{ regex: "(?:example|test|fake|placeholder)", type: "context_line" }],
4965
+ fix: { description: "Revoke in Google AI Studio (https://aistudio.google.com/apikey). Create a new key with API restrictions and move to server-side env vars.", suggestion: "process.env.GEMINI_API_KEY" }
4966
+ },
4967
+ {
4968
+ id: "secrets/grok-xai-key",
4969
+ title: "xAI / Grok API Key Detected",
4970
+ description: "An xAI/Grok API key (xai-...). Newer provider, same blast radius.",
4971
+ severity: "critical",
4972
+ confidence: "high",
4973
+ cwe: "CWE-798",
4974
+ languages: ["*"],
4975
+ patterns: [{ regex: "\\bxai-[a-zA-Z0-9]{60,}\\b", type: "match" }],
4976
+ fix: { description: "Rotate at https://console.x.ai. Server-side only from now on.", suggestion: "process.env.XAI_API_KEY" }
4977
+ },
4978
+ {
4979
+ id: "secrets/huggingface-token",
4980
+ title: "Hugging Face Token Detected",
4981
+ description: "A Hugging Face access token (hf_...). Used for model downloads, inference API, dataset pushes. Has push permissions on repos by default.",
4982
+ severity: "high",
4983
+ confidence: "high",
4984
+ cwe: "CWE-798",
4985
+ languages: ["*"],
4986
+ patterns: [{ regex: "\\bhf_[A-Za-z0-9]{34}\\b", type: "match" }],
4987
+ fix: { description: "Revoke at huggingface.co/settings/tokens. Create a read-only token if that's all you need.", suggestion: "process.env.HUGGINGFACE_TOKEN" }
4988
+ },
4989
+ {
4990
+ id: "secrets/replicate-token",
4991
+ title: "Replicate API Token Detected",
4992
+ description: "A Replicate API token (r8_...). Replicate runs ML models \u2014 your billing balance is directly burnable.",
4993
+ severity: "critical",
4994
+ confidence: "high",
4995
+ cwe: "CWE-798",
4996
+ languages: ["*"],
4997
+ patterns: [{ regex: "\\br8_[A-Za-z0-9]{37,40}\\b", type: "match" }],
4998
+ fix: { description: "Rotate at replicate.com/account/api-tokens immediately.", suggestion: "process.env.REPLICATE_API_TOKEN" }
4999
+ },
5000
+ {
5001
+ id: "secrets/groq-api-key",
5002
+ title: "Groq API Key Detected",
5003
+ description: "A Groq API key (gsk_...). Same blast radius as OpenAI keys \u2014 burnable balance, no scope.",
5004
+ severity: "critical",
5005
+ confidence: "high",
5006
+ cwe: "CWE-798",
5007
+ languages: ["*"],
5008
+ patterns: [{ regex: "\\bgsk_[A-Za-z0-9]{52,56}\\b", type: "match" }],
5009
+ fix: { description: "Rotate at console.groq.com/keys.", suggestion: "process.env.GROQ_API_KEY" }
5010
+ },
5011
+ {
5012
+ id: "secrets/perplexity-api-key",
5013
+ title: "Perplexity API Key Detected",
5014
+ description: "A Perplexity API key (pplx-...).",
5015
+ severity: "high",
5016
+ confidence: "high",
5017
+ cwe: "CWE-798",
5018
+ languages: ["*"],
5019
+ patterns: [{ regex: "\\bpplx-[A-Za-z0-9]{48,56}\\b", type: "match" }],
5020
+ fix: { description: "Rotate at perplexity.ai/settings/api.", suggestion: "process.env.PERPLEXITY_API_KEY" }
5021
+ },
5022
+ {
5023
+ id: "secrets/together-api-key",
5024
+ title: "Together AI API Key Detected",
5025
+ description: "A Together AI API key. 64-hex-char format \u2014 requires the env-var context to fire to avoid false positives on file hashes.",
5026
+ severity: "high",
5027
+ confidence: "medium",
5028
+ cwe: "CWE-798",
5029
+ languages: ["*"],
5030
+ patterns: [{ regex: `(?:TOGETHER_API_KEY|TOGETHERAI_API_KEY)\\s*[:=]\\s*["']?[a-f0-9]{64}["']?`, type: "match" }],
5031
+ fix: { description: "Rotate at api.together.xyz/settings/api-keys.", suggestion: "process.env.TOGETHER_API_KEY" }
5032
+ },
5033
+ {
5034
+ id: "secrets/elevenlabs-api-key",
5035
+ title: "ElevenLabs API Key Detected",
5036
+ description: "An ElevenLabs API key. Voice synthesis charges run fast \u2014 a prompt-injection that generates 30-minute outputs against a leaked key burns the balance in hours.",
5037
+ severity: "high",
5038
+ confidence: "medium",
5039
+ cwe: "CWE-798",
5040
+ languages: ["*"],
5041
+ // Both patterns must match the file (engine §1.1b context_line filter)
5042
+ // because 32-hex strings appear all over (hashes, UUIDs).
5043
+ patterns: [
5044
+ { regex: `(?:ELEVENLABS|XI)_API_KEY\\s*[:=]\\s*["']?(?:sk_)?[a-f0-9]{32}`, type: "match" }
5045
+ ],
5046
+ fix: { description: "Rotate at elevenlabs.io/app/settings/api-keys.", suggestion: "process.env.ELEVENLABS_API_KEY" }
5047
+ },
5048
+ {
5049
+ id: "secrets/mistral-api-key",
5050
+ title: "Mistral AI API Key Detected",
5051
+ description: "A Mistral AI API key.",
5052
+ severity: "high",
5053
+ confidence: "medium",
5054
+ cwe: "CWE-798",
5055
+ languages: ["*"],
5056
+ patterns: [{ regex: `\\bMISTRAL_API_KEY\\s*[:=]\\s*["']?[A-Za-z0-9]{32,}["']?`, type: "match" }],
5057
+ fix: { description: "Rotate at console.mistral.ai/api-keys.", suggestion: "process.env.MISTRAL_API_KEY" }
5058
+ },
5059
+ {
5060
+ id: "secrets/cohere-api-key",
5061
+ title: "Cohere API Key Detected",
5062
+ description: "A Cohere API key.",
5063
+ severity: "high",
5064
+ confidence: "medium",
5065
+ cwe: "CWE-798",
5066
+ languages: ["*"],
5067
+ patterns: [{ regex: `\\bCOHERE_API_KEY\\s*[:=]\\s*["']?[A-Za-z0-9]{40,}["']?`, type: "match" }],
5068
+ fix: { description: "Rotate at dashboard.cohere.com/api-keys.", suggestion: "process.env.COHERE_API_KEY" }
5069
+ },
5070
+ {
5071
+ id: "secrets/deepseek-api-key",
5072
+ title: "DeepSeek API Key Detected",
5073
+ description: "A DeepSeek API key (sk-...). Format collides with OpenAI legacy keys, so detection requires the DEEPSEEK_API_KEY env-var context to avoid false positives.",
5074
+ severity: "high",
5075
+ confidence: "high",
5076
+ cwe: "CWE-798",
5077
+ languages: ["*"],
5078
+ patterns: [
5079
+ { regex: `(?:DEEPSEEK|DEEP_SEEK)_API_KEY\\s*[:=]\\s*["']?sk-[a-f0-9]{32}["']?`, type: "match" }
5080
+ ],
5081
+ fix: { description: "Rotate at platform.deepseek.com/api_keys.", suggestion: "process.env.DEEPSEEK_API_KEY" }
5082
+ },
5083
+ {
5084
+ id: "secrets/pinecone-api-key",
5085
+ title: "Pinecone API Key Detected",
5086
+ description: "A Pinecone vector DB API key. Pinecone keys grant read+write on every index by default \u2014 an attacker with the key can dump or wipe your embeddings.",
5087
+ severity: "critical",
5088
+ confidence: "high",
5089
+ cwe: "CWE-798",
5090
+ languages: ["*"],
5091
+ patterns: [
5092
+ { regex: "\\bpcsk_[A-Za-z0-9_]{40,}\\b", type: "match" }
5093
+ ],
5094
+ fix: { description: "Rotate at app.pinecone.io. Restrict the new key to a specific project and consider creating index-scoped keys.", suggestion: "process.env.PINECONE_API_KEY" }
5095
+ },
5096
+ {
5097
+ id: "secrets/weaviate-api-key",
5098
+ title: "Weaviate API Key Detected",
5099
+ description: "A Weaviate API key.",
5100
+ severity: "high",
5101
+ confidence: "medium",
5102
+ cwe: "CWE-798",
5103
+ languages: ["*"],
5104
+ patterns: [{ regex: `(?:WEAVIATE_API_KEY|WEAVIATE_ADMIN_API_KEY)\\s*[:=]\\s*["']?[A-Za-z0-9-]{32,}["']?`, type: "match" }],
5105
+ fix: { description: "Rotate the key in your Weaviate Cloud console. Prefer read-only keys for client-reachable code.", suggestion: "process.env.WEAVIATE_API_KEY" }
5106
+ },
5107
+ {
5108
+ id: "secrets/vsce-publish-token",
5109
+ title: "VS Code Marketplace Publish Token Detected",
5110
+ description: "A VSCE personal access token publishes extensions to the VS Code Marketplace. The Clinejection attack stole this exact token via CI cache poisoning and published malware to millions of installs. Rotate immediately.",
5111
+ severity: "critical",
5112
+ confidence: "high",
5113
+ cwe: "CWE-798",
5114
+ languages: ["*"],
5115
+ patterns: [{ regex: `(?:VSCE_PAT|VSCE_TOKEN|VSCODE_MARKETPLACE_TOKEN)\\s*[:=]\\s*["']?[A-Za-z0-9]{52,}`, type: "match" }],
5116
+ fix: { description: "Rotate the token in https://dev.azure.com immediately. Move to GitHub Actions OIDC for publishing if possible. Audit the extension's recent versions for tampering.", suggestion: "process.env.VSCE_PAT" }
5117
+ },
5118
+ {
5119
+ id: "secrets/ovsx-publish-token",
5120
+ title: "Open VSX Publish Token Detected",
5121
+ description: "An Open VSX publish token. Same blast radius as VSCE_PAT \u2014 the Clinejection attack stole this token alongside.",
5122
+ severity: "critical",
5123
+ confidence: "high",
5124
+ cwe: "CWE-798",
5125
+ languages: ["*"],
5126
+ patterns: [{ regex: `(?:OVSX_PAT|OVSX_TOKEN|OPEN_VSX_TOKEN)\\s*[:=]\\s*["']?[A-Za-z0-9-]{30,}`, type: "match" }],
5127
+ fix: { description: "Rotate at https://open-vsx.org/user-settings/tokens. Re-publish the latest known-good version of your extension after confirming the source hasn't been tampered with.", suggestion: "process.env.OVSX_PAT" }
4854
5128
  }
4855
5129
  ];
4856
5130
  var INJECTION_RULES = [
@@ -5091,6 +5365,25 @@ var CONFIG_RULES = [
5091
5365
  patterns: [{ regex: "(?:NextAuth|authOptions|nextauth)\\s*[:=(]", type: "match" }],
5092
5366
  excludePatterns: [{ regex: "(?:NEXTAUTH_SECRET|secret\\s*:\\s*process\\.env|AUTH_SECRET|NEXT_AUTH_SECRET)", type: "context_line" }],
5093
5367
  fix: { description: "Set the NEXTAUTH_SECRET environment variable in production. Generate a strong random secret with openssl rand -base64 32.", suggestion: "secret: process.env.NEXTAUTH_SECRET" }
5368
+ },
5369
+ // ─────────────────────────────────────────────────────────────────────────
5370
+ // Round 7-vuln: agent-workspace config files committed to repo.
5371
+ // ─────────────────────────────────────────────────────────────────────────
5372
+ {
5373
+ id: "config/agent-config-tracked",
5374
+ title: "Agent Workspace Config Committed to Repo",
5375
+ description: "A file like .cursor/mcp.json, .claude/settings.local.json, or .windsurf/config.json is in the repo. These are per-machine workspace files that often contain MCP server credentials, API keys, and absolute paths that leak the contributor's home directory. They should never be tracked.",
5376
+ severity: "medium",
5377
+ confidence: "medium",
5378
+ cwe: "CWE-538",
5379
+ languages: ["json", "yaml"],
5380
+ patterns: [
5381
+ // Fires only on the workspace-config filenames themselves.
5382
+ { regex: "(?:^|/)(?:\\.cursor/mcp\\.json|\\.claude/settings\\.local\\.json|\\.windsurf/config\\.json|\\.cline/settings\\.json|\\.aider\\.conf\\.yml)$", type: "file_path" },
5383
+ // Marker: any non-empty content. The presence of the file IS the finding.
5384
+ { regex: "\\S", type: "match" }
5385
+ ],
5386
+ fix: { description: "Add to .gitignore, then `git rm --cached <file>` to untrack. Move secrets in the file to environment variables. Use a `.cursor/mcp.shared.json` (or similar tool convention) for team-shared, secret-free settings." }
5094
5387
  }
5095
5388
  ];
5096
5389
  var PII_RULES = [
@@ -5353,6 +5646,76 @@ var BAAS_RULES = [
5353
5646
  ],
5354
5647
  excludePatterns: [{ regex: `(?:allowedRedirects|validRedirects|safeUrls|startsWith\\s*\\(\\s*["']https://)`, type: "context_line" }],
5355
5648
  fix: { description: "Use a hardcoded or whitelisted redirect URL for OAuth callbacks. Never use raw user input.", suggestion: "redirectTo: `${process.env.NEXT_PUBLIC_URL}/auth/callback`" }
5649
+ },
5650
+ // ─────────────────────────────────────────────────────────────────────────
5651
+ // Round 7-vuln additions: post-Lovable-CVE Supabase RLS patterns. The
5652
+ // existing `supabase-rls-disabled` rule only flags MISSING RLS. These flag
5653
+ // RLS that LOOKS protective but isn't — `USING (true)`, over-granted anon
5654
+ // role, service-role used in client-reachable code, JWT-claim trust.
5655
+ // ─────────────────────────────────────────────────────────────────────────
5656
+ {
5657
+ id: "baas/supabase-rls-policy-allows-all",
5658
+ title: "Supabase RLS Policy Allows All Rows",
5659
+ description: "This row-level-security policy uses USING (true), USING (1=1), or WITH CHECK (true). RLS is technically enabled \u2014 so the table looks protected and security scanners that only check `enable row level security` pass \u2014 but the policy itself authorizes everything. Lovable's 'security scan' missed exactly this pattern. CVE-2025-48757 fallout: 170 apps shipped this.",
5660
+ severity: "critical",
5661
+ confidence: "high",
5662
+ cwe: "CWE-285",
5663
+ owasp: "A01:2021",
5664
+ languages: ["sql"],
5665
+ patterns: [
5666
+ { regex: "create\\s+policy[^;]+(?:using|with\\s+check)\\s*\\(\\s*(?:true|1\\s*=\\s*1)\\s*\\)", type: "match" },
5667
+ { regex: "create\\s+policy[^;]+using\\s*\\(\\s*auth\\.role\\(\\)\\s*=\\s*'authenticated'\\s*\\)", type: "match" }
5668
+ ],
5669
+ fix: { description: "Replace `USING (true)` with a real predicate \u2014 typically `USING (auth.uid() = user_id)`. If the table genuinely allows public read, scope it by operation (`FOR SELECT`) and add a separate restrictive policy for writes." }
5670
+ },
5671
+ {
5672
+ id: "baas/supabase-anon-overgrant",
5673
+ title: "Postgres Anon Role Granted Broad Privileges",
5674
+ description: "GRANT ALL, GRANT INSERT, or GRANT DELETE to the `anon` role gives unauthenticated users database-level write access. Even with RLS, this widens the attack surface \u2014 `anon` should only have SELECT on tables you intend to expose, and EXECUTE on RPC functions you've audited.",
5675
+ severity: "high",
5676
+ confidence: "high",
5677
+ cwe: "CWE-732",
5678
+ owasp: "A01:2021",
5679
+ languages: ["sql"],
5680
+ patterns: [
5681
+ { regex: "grant\\s+(?:all|insert|update|delete|truncate)\\s+(?:on[^;]+)?to\\s+anon", type: "match" }
5682
+ ],
5683
+ fix: { description: "Revoke broad grants. `REVOKE ALL ON <table> FROM anon;` then `GRANT SELECT ON <table> TO anon;` only for tables that should be publicly readable." }
5684
+ },
5685
+ {
5686
+ id: "baas/supabase-service-role-client-reachable",
5687
+ title: "Service-Role Key Used in Client-Reachable Route",
5688
+ description: "This route handler uses the Supabase service-role client but lives in a path the client can call (no auth gate, no internal/server-only marker). Service role bypasses RLS entirely \u2014 a single unauthenticated request reads every table. The April 2026 Lovable mass breach included this pattern across pre-Nov-2025 projects.",
5689
+ severity: "critical",
5690
+ confidence: "medium",
5691
+ cwe: "CWE-269",
5692
+ owasp: "A01:2021",
5693
+ languages: ["javascript", "typescript"],
5694
+ patterns: [
5695
+ { regex: "createClient\\s*\\([^,]+,\\s*process\\.env\\.(?:SUPABASE_)?SERVICE_ROLE_KEY", type: "match" }
5696
+ ],
5697
+ excludePatterns: [
5698
+ { regex: "(?:\\.server\\.|/api/internal|cron|webhook|middleware\\.ts|requireAuth|auth\\.uid|getServerSession)", type: "context_line" },
5699
+ { regex: "(?:\\.server\\.|/api/internal|cron|webhook|middleware\\.ts)", type: "file_path" }
5700
+ ],
5701
+ fix: { description: "Either move this logic behind explicit auth (e.g. requireUser() at the top of the handler), or replace the service-role client with the anon client + RLS. Never use service role in an endpoint a logged-out browser can reach." }
5702
+ },
5703
+ {
5704
+ id: "baas/supabase-rls-policy-jwt-claim-not-verified",
5705
+ title: "Supabase RLS Policy Trusts a JWT Claim Without Issuing It",
5706
+ description: "This policy authorizes based on auth.jwt() ->> 'role' or auth.jwt() ->> 'org_id' from a claim Supabase doesn't issue by default. If the claim comes from a third-party JWT (Clerk, Auth0, Firebase) and you haven't configured Supabase to validate the issuer, an attacker can craft their own JWT and bypass the policy.",
5707
+ severity: "high",
5708
+ confidence: "low",
5709
+ cwe: "CWE-345",
5710
+ owasp: "A01:2021",
5711
+ languages: ["sql"],
5712
+ patterns: [
5713
+ { regex: "auth\\.jwt\\(\\)\\s*->>?\\s*'(?:role|org_id|tenant|company|workspace_id|account_id)'", type: "match" }
5714
+ ],
5715
+ excludePatterns: [
5716
+ { regex: "(?:auth\\.uid|user_id|owner_id)", type: "context_line" }
5717
+ ],
5718
+ fix: { description: "Either issue these claims via Supabase Auth Hooks (so Supabase is the JWT issuer), or configure third-party JWT verification with a strict issuer + audience check. Don't read claims you don't issue." }
5356
5719
  }
5357
5720
  ];
5358
5721
  var LLM_RULES = [
@@ -5489,6 +5852,158 @@ var LLM_RULES = [
5489
5852
  { regex: "(?:query|execute|sql|raw)\\s*\\(\\s*(?:completion|response|result|output|llm|ai|gpt|claude)[.\\[]", type: "match" }
5490
5853
  ],
5491
5854
  fix: { description: "Never execute LLM output as code. Use structured output parsing with validation, or run generated code in a sandboxed environment." }
5855
+ },
5856
+ // ─────────────────────────────────────────────────────────────────────────
5857
+ // Round 7-vuln additions: MCP tool pinning, LangChain SSRF/path traversal,
5858
+ // Vercel AI SDK input handling, cost-exhaustion (max_tokens / rate limit),
5859
+ // .env-into-prompt, Codex branch-shell, OpenAI Assistants allowlist,
5860
+ // git-hook writes, agent-on-PR-content.
5861
+ // ─────────────────────────────────────────────────────────────────────────
5862
+ {
5863
+ id: "llm/mcp-tool-no-pinning",
5864
+ title: "MCP Tool Used Without Version Pinning",
5865
+ description: "This code calls an MCP tool by name without checking the server's identity hash or pinning the tool description. A 'rug pull' (CVE-2025-54136) \u2014 server updates its tool description after you approved it \u2014 silently changes what the tool does. Your code keeps calling `add` and ends up exfiltrating data.",
5866
+ severity: "medium",
5867
+ confidence: "low",
5868
+ cwe: "CWE-345",
5869
+ languages: ["javascript", "typescript", "python"],
5870
+ patterns: [
5871
+ { regex: `(?:mcpClient|mcp_client|MCPClient)\\.(?:callTool|invoke|call)\\s*\\(\\s*["']`, type: "match" }
5872
+ ],
5873
+ excludePatterns: [{ regex: "(?:hash|fingerprint|version|pinned|approved)", type: "context_line" }],
5874
+ fix: { description: "Record a hash of the tool description the first time you approve a server. Re-verify on every call. Reject calls whose description changed since approval." }
5875
+ },
5876
+ {
5877
+ id: "llm/langchain-recursive-url-loader-unsafe",
5878
+ title: "LangChain RecursiveUrlLoader Used Without SSRF Guard",
5879
+ description: "RecursiveUrlLoader follows HTTP redirects in a way that bypasses URL validators \u2014 CVE-2026-27795 lets an attacker hand you a benign-looking URL that 302s to cloud metadata. If you load attacker-supplied URLs into a RAG pipeline, you leak 169.254.169.254 and friends.",
5880
+ severity: "high",
5881
+ confidence: "medium",
5882
+ cwe: "CWE-918",
5883
+ owasp: "A10:2021",
5884
+ languages: ["python", "javascript", "typescript"],
5885
+ patterns: [
5886
+ { regex: "(?:RecursiveUrlLoader|recursive_url_loader)\\s*\\(", type: "match" }
5887
+ ],
5888
+ excludePatterns: [{ regex: "(?:allowlist|allow_list|allowed_domains|domain_filter|ssrf_guard)", type: "context_line" }],
5889
+ fix: { description: "Upgrade @langchain/community to 1.1.14+ (Node) or langchain-community to the patched release (Python). Wrap loader calls in an SSRF-safe HTTP client that blocks RFC 1918, link-local, and cloud-metadata ranges before AND after redirects." }
5890
+ },
5891
+ {
5892
+ id: "llm/langchain-load-prompt-from-path",
5893
+ title: "LangChain Loads Prompt Template from Untrusted Path",
5894
+ description: "load_prompt(...) (CVE-2026-34070) traverses any path you give it. If the path comes from request input, an attacker reads arbitrary files \u2014 /etc/passwd, .env, SSH keys.",
5895
+ severity: "high",
5896
+ confidence: "medium",
5897
+ cwe: "CWE-22",
5898
+ owasp: "A01:2021",
5899
+ languages: ["python", "javascript", "typescript"],
5900
+ patterns: [
5901
+ { regex: "(?:load_prompt|loadPrompt)\\s*\\([^)]*(?:req\\.|request\\.|input|user|body|query|param)", type: "match" }
5902
+ ],
5903
+ fix: { description: "Never pass untrusted strings to load_prompt. Maintain an explicit registry of allowed prompt names and look up the path server-side." }
5904
+ },
5905
+ {
5906
+ id: "llm/ai-sdk-input-as-prompt",
5907
+ title: "Vercel AI SDK generateText/streamText Called with Raw User Input",
5908
+ description: "generateText({ prompt: userInput }) and streamText({ messages: [...] }) with raw user input is the canonical Vercel AI SDK prompt-injection point. Users can override system instructions, extract the system prompt, or trigger arbitrary tool calls.",
5909
+ severity: "high",
5910
+ confidence: "medium",
5911
+ cwe: "CWE-77",
5912
+ languages: ["javascript", "typescript"],
5913
+ patterns: [
5914
+ { regex: "(?:generateText|streamText|generateObject|streamObject)\\s*\\(\\s*\\{[^}]*prompt\\s*:\\s*(?:req\\.|request\\.|body\\.|input|userInput|message)", type: "match" }
5915
+ ],
5916
+ excludePatterns: [{ regex: "(?:sanitize|validate|zod|schema|filter)", type: "context_line" }],
5917
+ fix: { description: "Always pass user input as a separate `user` message, never as the prompt string and never interpolated into the system message. Validate with Zod before sending.", suggestion: "streamText({ system: SYSTEM_PROMPT, messages: [{ role: 'user', content: sanitize(input) }] })" }
5918
+ },
5919
+ {
5920
+ id: "llm/agent-runs-on-unsanitized-pr-content",
5921
+ title: "AI Agent Run on PR Title/Body Without Sanitizing",
5922
+ description: "This code passes PR titles, bodies, or comments straight into an agent prompt. The 'Comment and Control' attack chain has confirmed exfil of ANTHROPIC_API_KEY and GITHUB_TOKEN via PR titles in Claude Code, fake 'Trusted Content Section' blocks in Gemini CLI, and HTML comments in Copilot Agent.",
5923
+ severity: "critical",
5924
+ confidence: "medium",
5925
+ cwe: "CWE-77",
5926
+ owasp: "A03:2021",
5927
+ languages: ["javascript", "typescript", "python"],
5928
+ patterns: [
5929
+ { regex: "(?:pull_request|pullRequest|pr|issue)\\.(?:title|body|head_ref).{0,200}(?:prompt|messages|systemPrompt|userMessage)", type: "match" },
5930
+ { regex: "(?:prompt|content)\\s*[:=]\\s*`[^`]*\\$\\{(?:pr|pullRequest|issue|comment)\\.(?:title|body)", type: "match" }
5931
+ ],
5932
+ fix: { description: "Treat PR content as untrusted data, never as instructions. Strip prompt-injection markers (<system>, </system>, 'ignore previous'). Move the actual instructions to a server-side system prompt and pass PR content as a quoted block inside a user message." }
5933
+ },
5934
+ {
5935
+ id: "llm/no-max-tokens",
5936
+ title: "LLM Call Without max_tokens or Cost Limit",
5937
+ description: "This LLM call has no max_tokens (OpenAI/Anthropic), max_output_tokens (Gemini), or equivalent ceiling. A prompt-injection or a single buggy loop can rack up thousands of dollars on a free-tier app. Uber exhausted its 2026 AI budget months into the year \u2014 this is the pattern.",
5938
+ severity: "medium",
5939
+ confidence: "low",
5940
+ cwe: "CWE-770",
5941
+ owasp: "A04:2021",
5942
+ languages: ["javascript", "typescript", "python"],
5943
+ patterns: [
5944
+ { regex: "(?:openai|anthropic|gemini|generativeai)\\.[a-z_]+\\.(?:create|generate|complete|messages)\\s*\\(\\s*\\{[^}]*\\bmodel\\s*:", type: "match" }
5945
+ ],
5946
+ excludePatterns: [{ regex: "(?:max_tokens|max_output_tokens|maxTokens|maxOutputTokens|max_completion_tokens)", type: "context_line" }],
5947
+ fix: { description: "Always set a max_tokens ceiling. Add a per-user / per-IP rate limiter at the API edge. Track cost per request via the response usage object and refuse new requests when the user passes a daily threshold.", suggestion: "const completion = await openai.chat.completions.create({ model: 'gpt-4o', messages, max_tokens: 1024 });" }
5948
+ },
5949
+ {
5950
+ id: "llm/env-file-read-into-prompt",
5951
+ title: ".env or Secrets File Read into LLM Prompt",
5952
+ description: "This code reads a secrets file (.env, .aws/credentials, ~/.ssh/) and passes it into an LLM prompt or message. The LLM provider now has your secrets in their logs; a prompt injection in the response can exfiltrate them; and any output filtering you do is best-effort.",
5953
+ severity: "critical",
5954
+ confidence: "medium",
5955
+ cwe: "CWE-200",
5956
+ owasp: "A01:2021",
5957
+ languages: ["javascript", "typescript", "python"],
5958
+ patterns: [
5959
+ { regex: "(?:readFile|readFileSync|fs\\.read|open|Path\\([^)]+\\)\\.read)[\\s\\S]{0,80}(?:\\.env|credentials|\\.ssh|secret|\\.pem|id_rsa)[\\s\\S]{0,300}(?:prompt|messages|content|completion|generateText|streamText)", type: "match" }
5960
+ ],
5961
+ fix: { description: "Never put secrets in a prompt. If the agent needs to act on credentials, give it a tool that uses them internally and never returns or echoes them. Redact env files before passing any config snippet to an LLM." }
5962
+ },
5963
+ {
5964
+ id: "llm/agent-writes-to-git-hooks",
5965
+ title: "Code Writes to .git/hooks (CVE-2026-26268 vector)",
5966
+ description: "This code writes to .git/hooks/. Cursor IDE CVE-2026-26268 used exactly this \u2014 an agent cloning a repo into a workspace triggers pre-commit hooks attackers placed there. If your own code writes hooks based on agent output or external input, you've recreated the bug.",
5967
+ severity: "high",
5968
+ confidence: "high",
5969
+ cwe: "CWE-94",
5970
+ owasp: "A03:2021",
5971
+ languages: ["javascript", "typescript", "python", "shell"],
5972
+ patterns: [
5973
+ { regex: `(?:writeFile|writeFileSync|open|Path\\([^)]+\\)\\.write)[\\s\\S]{0,80}["']\\.git/hooks/`, type: "match" },
5974
+ { regex: "chmod\\s+\\+x[\\s\\S]{0,80}\\.git/hooks/", type: "match" }
5975
+ ],
5976
+ fix: { description: "Don't write Git hooks from application code. If you need pre-commit checks, install them via husky or pre-commit at setup time with the user's explicit consent." }
5977
+ },
5978
+ {
5979
+ id: "llm/codex-branch-name-shell-injection",
5980
+ title: "Branch Name or Repo Field Passed to Shell Without Sanitizing",
5981
+ description: "OpenAI Codex CVE (reported Dec 2025, patched Feb 2026) shipped because branch names were passed unsanitized into shell commands inside the agent container, letting any user with branch-create permission run arbitrary commands and exfiltrate the GitHub token. Same shape applies to any agent that runs shell with attacker-controllable repo metadata.",
5982
+ severity: "critical",
5983
+ confidence: "medium",
5984
+ cwe: "CWE-78",
5985
+ owasp: "A03:2021",
5986
+ languages: ["javascript", "typescript", "python"],
5987
+ patterns: [
5988
+ { regex: "(?:exec|spawn|execSync|child_process|subprocess\\.(?:run|call|Popen))[\\s\\S]{0,100}(?:branch|ref|head_ref|base_ref|repo_name|pr_title)", type: "match" },
5989
+ { regex: "`[^`]*\\$\\{(?:branch|ref|head|repo)Name", type: "match" }
5990
+ ],
5991
+ excludePatterns: [{ regex: "(?:shellQuote|escapeShellArg|shlex\\.quote|sanitize)", type: "context_line" }],
5992
+ fix: { description: "Never interpolate any field that could be attacker-controlled into a shell command. Use execFile with argv array (no shell) or quote the input with shell-quote/shlex.quote." }
5993
+ },
5994
+ {
5995
+ id: "llm/openai-assistant-tool-no-allowlist",
5996
+ title: "OpenAI Assistants / Responses API Without Tool Allowlist",
5997
+ description: "This code uses the OpenAI Assistants API or Responses API with tools enabled and no allowlist check before executing the returned tool call. The Codex command-injection chain (CVE patched Feb 2026) and the ChatGPT data-exfil chain (Feb 2026) both relied on this \u2014 the agent decides what to call, the code does it.",
5998
+ severity: "high",
5999
+ confidence: "medium",
6000
+ cwe: "CWE-284",
6001
+ languages: ["javascript", "typescript", "python"],
6002
+ patterns: [
6003
+ { regex: "required_action[\\s\\S]{0,100}submit_tool_outputs", type: "match" }
6004
+ ],
6005
+ excludePatterns: [{ regex: "(?:allowedTools|toolRegistry|ALLOW_LIST|whitelist|permitted)", type: "context_line" }],
6006
+ fix: { description: "Maintain an explicit Set<string> of permitted tool names. Reject any tool call whose name isn't in the set. Log and alert on rejections." }
5492
6007
  }
5493
6008
  ];
5494
6009
  var HEADERS_RULES = [
@@ -5773,6 +6288,58 @@ var DEPS_RULES = [
5773
6288
  ],
5774
6289
  excludePatterns: [{ regex: "(?:integrity|crossorigin)", type: "context_line" }],
5775
6290
  fix: { description: "Add integrity and crossorigin attributes to all CDN-loaded scripts.", suggestion: '<script src="https://cdn.example.com/lib.js" integrity="sha384-..." crossorigin="anonymous"></script>' }
6291
+ },
6292
+ // ─────────────────────────────────────────────────────────────────────────
6293
+ // Round 7-vuln additions: known-malicious packages from the Clinejection
6294
+ // class of supply-chain attacks, extended slopsquatting list, no-lockfile.
6295
+ // ─────────────────────────────────────────────────────────────────────────
6296
+ {
6297
+ id: "deps/known-malicious-package",
6298
+ title: "Known-Malicious Package Detected",
6299
+ description: "This package was used in a confirmed supply-chain attack. Remove it immediately and rotate any credentials that were on the machine when it was installed.",
6300
+ severity: "critical",
6301
+ confidence: "high",
6302
+ cwe: "CWE-829",
6303
+ owasp: "A06:2021",
6304
+ languages: ["json"],
6305
+ patterns: [
6306
+ { regex: '"(?:openclaw|postmark-mcp)"\\s*:', type: "match" },
6307
+ { regex: '"cline"\\s*:\\s*"[^"]*2\\.3\\.0[^"]*"', type: "match" },
6308
+ { regex: "package(?:-lock)?\\.json$", type: "file_path" }
6309
+ ],
6310
+ fix: { description: "Remove the package. Rotate any tokens (npm publish, GitHub PAT, AI provider keys) that were active on the machine since install. Audit your shell history and ~/.npm cache. The Cline 2.3.0 incident shipped openclaw as a postinstall payload between 3:26am and 11:30am PT on 2026-02-17." }
6311
+ },
6312
+ {
6313
+ id: "deps/slopsquatting-risk-extended",
6314
+ title: "Likely AI-Hallucinated Package Name (Extended List)",
6315
+ description: "Extension of `deps/slopsquatting-risk` with names observed in the past 6 months of AI-generated code (Cursor, Lovable, Bolt, v0 traces). These names sound plausible \u2014 'react-query-helpers', 'nextjs-auth-utils' \u2014 but they didn't exist when the model trained. Squatters now own these names.",
6316
+ severity: "high",
6317
+ confidence: "low",
6318
+ cwe: "CWE-829",
6319
+ languages: ["json"],
6320
+ patterns: [
6321
+ { regex: '"(?:react-query-helpers|nextjs-auth-utils|supabase-edge-utils|shadcn-ui-helpers|drizzle-helpers|hono-helpers|tanstack-router-utils|effect-helpers|zustand-helpers|tailwind-typography-extended|framer-motion-utils|radix-ui-helpers|trpc-helpers|prisma-edge-helpers|vercel-blob-utils|cloudflare-d1-helpers|nuxt-auth-utils|svelte-kit-helpers|astro-edge-helpers|solid-js-helpers)"\\s*:', type: "match" },
6322
+ { regex: "package\\.json$", type: "file_path" }
6323
+ ],
6324
+ fix: { description: "Verify on npmjs.com. Run `npm view <pkg>` \u2014 if the package is <30 days old, has one maintainer, and zero stars, do not install." }
6325
+ },
6326
+ {
6327
+ id: "deps/no-lockfile",
6328
+ title: "No Dependency Lockfile in Project",
6329
+ description: "This project's package.json has no companion package-lock.json / pnpm-lock.yaml / yarn.lock. Every install re-resolves versions, so a malicious update to any transitive dep ships on your next `npm install`. The Cline supply-chain attack window was 8 hours \u2014 without a lockfile, every install during that window pulled malware.",
6330
+ severity: "medium",
6331
+ confidence: "high",
6332
+ cwe: "CWE-1357",
6333
+ languages: ["json"],
6334
+ patterns: [
6335
+ { regex: '"name"\\s*:', type: "match" },
6336
+ { regex: "(?:^|/)package\\.json$", type: "file_path" }
6337
+ ],
6338
+ requireSibling: {
6339
+ // Rule fires only if NONE of these lockfiles exist anywhere in the scan set.
6340
+ missing: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"]
6341
+ },
6342
+ fix: { description: "Commit a lockfile. `npm install --package-lock-only` or `pnpm install --lockfile-only`. Re-run on every dependency change. Set engine-strict and pin the package manager via `packageManager` in package.json." }
5776
6343
  }
5777
6344
  ];
5778
6345
  var CLIENT_RULES = [
@@ -5846,6 +6413,224 @@ var CLIENT_RULES = [
5846
6413
  fix: { description: "Add CSRF protection: use SameSite=Strict cookies, verify a CSRF token header, or use a framework that handles this automatically (e.g. Next.js Server Actions have built-in CSRF protection)." }
5847
6414
  }
5848
6415
  ];
6416
+ var AI_AGENT_RULES = [
6417
+ {
6418
+ id: "ai-agent/invisible-unicode-in-config",
6419
+ title: "AI Agent Config File Contains Invisible Unicode",
6420
+ description: "This agent config file contains zero-width, tag, or variation-selector Unicode characters that humans can't see but LLMs read as normal text. This is the standard technique for hiding prompt injection payloads inside .cursorrules / .windsurfrules / CLAUDE.md / AGENTS.md / copilot-instructions.md. If you didn't put them there, an attacker did.",
6421
+ severity: "critical",
6422
+ confidence: "high",
6423
+ cwe: "CWE-1007",
6424
+ owasp: "A03:2021",
6425
+ languages: ["markdown"],
6426
+ patterns: [
6427
+ // JS regex Unicode escape uses \u{...} with /u flag (PCRE \x{} doesn't work).
6428
+ // compileRegex auto-adds /u when it sees \u{...} braces in the source.
6429
+ { regex: "[\\u{200B}-\\u{200F}\\u{2028}-\\u{202F}\\u{2060}-\\u{206F}\\u{FE00}-\\u{FE0F}\\u{E0000}-\\u{E007F}]", type: "match" },
6430
+ { regex: "(?:\\.cursorrules|\\.windsurfrules|\\.clinerules|CLAUDE\\.md|AGENTS\\.md|copilot-instructions\\.md|\\.mdc|\\.cursor/rules/)", type: "file_path" }
6431
+ ],
6432
+ fix: { description: "Open the file in an editor that visualizes invisible characters (VS Code with 'Render Whitespace' + the 'Gremlins' extension, or `cat -v`). Strip any character outside printable ASCII unless you intentionally need it. Add a pre-commit hook that rejects these ranges.", suggestion: "perl -CSDA -pe 's/[\\x{200B}-\\x{200F}\\x{2060}-\\x{206F}\\x{E0000}-\\x{E007F}]//g' -i .cursorrules" }
6433
+ },
6434
+ {
6435
+ id: "ai-agent/mcp-stdio-shell-command",
6436
+ title: "MCP Server Runs an Arbitrary Shell Command",
6437
+ description: "This MCP server config launches a STDIO process via a shell wrapper (sh -c, bash -c, eval, exec) or `npx -y` (downloads and runs arbitrary npm code). A poisoned tool description or a rug-pull update can inject extra arguments and run anything on your machine. The Windsurf zero-click RCE (CVE-2026-30615) used exactly this shape.",
6438
+ severity: "critical",
6439
+ confidence: "high",
6440
+ cwe: "CWE-78",
6441
+ owasp: "A03:2021",
6442
+ languages: ["json"],
6443
+ patterns: [
6444
+ { regex: '"command"\\s*:\\s*"(?:sh|bash|zsh|cmd|powershell|pwsh|eval|exec)"', type: "match" },
6445
+ { regex: '"args"\\s*:\\s*\\[\\s*"-c"', type: "match" },
6446
+ { regex: "(?:mcp\\.json|mcp_settings\\.json|claude_desktop_config\\.json)$", type: "file_path" }
6447
+ ],
6448
+ fix: { description: 'MCP servers should be invoked with their executable directly \u2014 `"command": "node"`, `"args": ["server.js"]` \u2014 never wrapped in a shell. If the server requires shell expansion, the server itself is wrong.' }
6449
+ },
6450
+ {
6451
+ id: "ai-agent/mcp-public-http-endpoint",
6452
+ title: "MCP Server Points to a Public HTTP URL",
6453
+ description: "This MCP server is configured to talk to an HTTP endpoint, not a local STDIO process, and the URL is on the public internet without TLS. Anything the server returns becomes context the agent acts on, so a network attacker can serve poisoned tool descriptions and trigger a confused-deputy attack.",
6454
+ severity: "high",
6455
+ confidence: "medium",
6456
+ cwe: "CWE-319",
6457
+ owasp: "A02:2021",
6458
+ languages: ["json"],
6459
+ patterns: [
6460
+ { regex: '"url"\\s*:\\s*"http://(?!localhost|127\\.|0\\.0\\.0\\.0|::1)', type: "match" },
6461
+ { regex: "(?:mcp\\.json|claude_desktop_config\\.json)$", type: "file_path" }
6462
+ ],
6463
+ excludePatterns: [
6464
+ { regex: "ngrok\\.io|ngrok-free\\.app|loca\\.lt", type: "context_line" }
6465
+ ],
6466
+ fix: { description: "Require HTTPS for any MCP server you don't run locally. Pin the server's full URL including version path. Prefer STDIO with a locally installed binary for anything that handles credentials." }
6467
+ },
6468
+ {
6469
+ id: "ai-agent/cursor-auto-run-enabled",
6470
+ title: "Cursor Auto-Run / YOLO Mode Enabled",
6471
+ description: "Cursor's Auto-Run Mode (formerly YOLO Mode) lets the agent execute commands without per-call approval. Combined with prompt injection from an MCP server, a rules file, or a PR comment, this turns the IDE into a one-shot RCE. CVE-2026-22708 specifically bypasses the allowlist via shell built-ins when this mode is on.",
6472
+ severity: "high",
6473
+ confidence: "high",
6474
+ cwe: "CWE-269",
6475
+ languages: ["json"],
6476
+ patterns: [
6477
+ { regex: '"cursor\\.(?:autoRun|yoloMode|composerAutoRun|chat\\.autoExecute|agent\\.autoRun|agent\\.skipApproval)"\\s*:\\s*true', type: "match" },
6478
+ { regex: '"agent"\\s*:\\s*\\{[^}]*"autoApprove"\\s*:\\s*true', type: "match" }
6479
+ ],
6480
+ fix: { description: "Disable Auto-Run for any repo that contains code you didn't write yourself. If you must keep it on, also disable shell built-ins in the allowlist and review every MCP server in .cursor/mcp.json first." }
6481
+ },
6482
+ {
6483
+ id: "ai-agent/agent-allowlist-disabled",
6484
+ title: "Agent Tool Allowlist Disabled or Empty",
6485
+ description: "This agent config disables the tool allowlist or sets it to allow everything. The allowlist is the last layer between a prompt injection and shell access \u2014 turning it off means any malicious instruction (in a file, an MCP tool description, a PR comment) becomes arbitrary code execution.",
6486
+ severity: "high",
6487
+ confidence: "medium",
6488
+ cwe: "CWE-284",
6489
+ languages: ["json"],
6490
+ patterns: [
6491
+ { regex: '"allowlist"\\s*:\\s*(?:false|null|\\[\\s*"\\*"\\s*\\])', type: "match" },
6492
+ { regex: '"toolApproval"\\s*:\\s*"never"', type: "match" }
6493
+ ],
6494
+ fix: { description: "Keep the allowlist on. Add specific tool names you genuinely need (read_file, edit_file) and leave shell, web fetch, and MCP-write tools off until you have a reason to enable them per session." }
6495
+ },
6496
+ {
6497
+ id: "ai-agent/auto-approve-tools",
6498
+ title: "Agent Configured to Auto-Approve All Tool Calls",
6499
+ description: "This config tells the agent to approve every tool call automatically. That removes the human-in-the-loop check that catches prompt injection mid-attack. The 'Comment and Control' PR-injection family relies on this \u2014 without auto-approve, the user sees the exfil command and stops it.",
6500
+ severity: "high",
6501
+ confidence: "medium",
6502
+ cwe: "CWE-284",
6503
+ languages: ["json", "yaml", "markdown", "shell"],
6504
+ patterns: [
6505
+ { regex: "(?:auto[_-]?approve|skipConfirmation|autoConfirm|trustedAlways)\\s*[:=]\\s*true", type: "match" },
6506
+ { regex: "--dangerously-skip-permissions", type: "match" }
6507
+ ],
6508
+ fix: { description: "Remove the auto-approve flag for any agent that touches a CI environment, untrusted repo, or anything with real credentials. The 5 seconds of friction is the entire point." }
6509
+ },
6510
+ {
6511
+ id: "ai-agent/ci-agent-untrusted-pr-input",
6512
+ title: "CI Agent Reads PR Title or Body Without Sanitizing",
6513
+ description: "This GitHub Action passes github.event.pull_request.title, .body, or .head_ref directly to an AI agent (Claude Code, Copilot Agent, Gemini CLI, Codex). An attacker opens a PR with prompt-injection in the title and exfiltrates ANTHROPIC_API_KEY / GITHUB_TOKEN. This is the 'Comment and Control' / CVE-2026-35020 family.",
6514
+ severity: "critical",
6515
+ confidence: "medium",
6516
+ cwe: "CWE-77",
6517
+ owasp: "A03:2021",
6518
+ languages: ["yaml"],
6519
+ patterns: [
6520
+ { regex: "(?:claude-code|copilot|gemini|aider|devin|codex)[\\s\\S]{0,200}\\$\\{\\{\\s*github\\.event\\.pull_request\\.(?:title|body|head_ref)", type: "match" },
6521
+ { regex: "\\.github/workflows/", type: "file_path" }
6522
+ ],
6523
+ fix: { description: "Never interpolate PR-controlled fields directly into an agent prompt or shell line. Pass them through an env var, then have the agent treat that env var as untrusted data (not instructions). Block any prompt-injection-shaped string before invoking the agent." }
6524
+ },
6525
+ {
6526
+ id: "ai-agent/ci-agent-pull-request-fork",
6527
+ title: "CI Agent Triggers on pull_request from Forks",
6528
+ description: "This workflow uses pull_request and runs an AI agent. Forked PRs run in your workflow context with read access to secrets if the action requests them \u2014 combined with PR-body prompt injection, this is the documented exfiltration vector.",
6529
+ severity: "high",
6530
+ confidence: "low",
6531
+ cwe: "CWE-269",
6532
+ languages: ["yaml"],
6533
+ patterns: [
6534
+ { regex: "on\\s*:[\\s\\S]{0,80}pull_request(?:_target)?", type: "match" },
6535
+ { regex: "(?:claude-code|copilot|gemini|aider|devin)", type: "context_line" },
6536
+ { regex: "\\.github/workflows/", type: "file_path" }
6537
+ ],
6538
+ fix: { description: "Gate AI-agent jobs on `if: github.event.pull_request.head.repo.full_name == github.repository` so forks can't trigger them with secrets attached. Or move the agent to a manually-triggered workflow_dispatch after a maintainer reviews the PR." }
6539
+ },
6540
+ {
6541
+ id: "ai-agent/github-action-injection-from-pr",
6542
+ title: "PR Title or Body Interpolated into Shell Step",
6543
+ description: "This workflow uses ${{ github.event.pull_request.title }} or similar inside a `run:` block. GitHub interpolates that into the shell before any escaping \u2014 an attacker with a PR title containing backticks runs commands on your runner. Independent of AI agents but urgent because AI-agent workflows tend to ship in this shape.",
6544
+ severity: "critical",
6545
+ confidence: "high",
6546
+ cwe: "CWE-78",
6547
+ owasp: "A03:2021",
6548
+ languages: ["yaml"],
6549
+ patterns: [
6550
+ { regex: "run\\s*:[\\s\\S]{0,400}\\$\\{\\{\\s*github\\.event\\.(?:pull_request|issue|comment|review|release|discussion|head_commit|commits)\\.[a-z_]+(?:\\.[a-z_]+)?", type: "match" },
6551
+ { regex: "\\.github/workflows/", type: "file_path" }
6552
+ ],
6553
+ fix: { description: 'Move the untrusted value into an env var (env: PR_TITLE: ${{ github.event.pull_request.title }}), then reference "$PR_TITLE" from the shell. The runner expands env vars after the shell parses the script, so injection becomes inert.' }
6554
+ },
6555
+ {
6556
+ id: "ai-agent/agent-config-not-gitignored",
6557
+ title: "Agent Config Path Suggests Workspace File Was Committed",
6558
+ description: "Files like .cursor/mcp.json, .claude/settings.json, or .windsurf/config.json often contain MCP credentials, API keys, and machine-specific paths. If they're tracked by git, those secrets ship to anyone with repo access. (This rule signals a path-level concern; combine with `config/agent-config-tracked` for a stronger signal.)",
6559
+ severity: "low",
6560
+ confidence: "low",
6561
+ cwe: "CWE-538",
6562
+ languages: ["json"],
6563
+ patterns: [
6564
+ { regex: "(?:^|/)(?:\\.cursor|\\.claude|\\.windsurf|\\.cline|\\.continue|\\.aider)/", type: "file_path" },
6565
+ { regex: "\\S", type: "match" }
6566
+ ],
6567
+ excludePatterns: [
6568
+ { regex: "\\.gitignore$", type: "file_path" }
6569
+ ],
6570
+ fix: { description: "Add .cursor/, .claude/, .windsurf/, .cline/, .continue/, .aider* to .gitignore. Move anything secret out of the tracked config and into a local-only override." }
6571
+ },
6572
+ // ── Tier 2 additions (§4 of handoff) ────────────────────────────────────
6573
+ {
6574
+ id: "ai-agent/pwn-request-checkout",
6575
+ title: "GitHub Action Uses pull_request_target + Checks Out PR Code",
6576
+ description: "This workflow uses pull_request_target (which runs in the base repo's context with secrets) AND checks out the PR's head ref. Attacker opens a PR, the workflow runs their code with your secrets in env, attacker exfiltrates. This is the 'Pwn Request' attack \u2014 researchers have compromised Microsoft, Google, and Nvidia repos with exactly this pattern.",
6577
+ severity: "critical",
6578
+ confidence: "high",
6579
+ cwe: "CWE-269",
6580
+ owasp: "A01:2021",
6581
+ languages: ["yaml"],
6582
+ patterns: [
6583
+ { regex: "pull_request_target", type: "match" },
6584
+ { regex: "actions/checkout@[^\\s]+[\\s\\S]{0,200}ref\\s*:\\s*\\$\\{\\{\\s*github\\.event\\.pull_request\\.head\\.(?:sha|ref)", type: "match" },
6585
+ { regex: "\\.github/workflows/", type: "file_path" }
6586
+ ],
6587
+ fix: { description: "Either switch to pull_request (loses secrets \u2014 that's the point) or split into two workflows. The privileged workflow runs only on the base branch (commenting, labeling); the build workflow runs on pull_request without secrets and reports back via artifact." }
6588
+ },
6589
+ {
6590
+ id: "ai-agent/workflow-write-all-permissions",
6591
+ title: "Workflow Grants Write-All Permissions to an AI-Agent Job",
6592
+ description: "This workflow runs an AI agent (Claude Code, Copilot Agent, Codex) and grants `permissions: write-all` or doesn't set permissions: (which defaults to write on classic-token repos). A prompt-injection from PR content turns into write access to the repo, releases, and packages.",
6593
+ severity: "high",
6594
+ confidence: "medium",
6595
+ cwe: "CWE-269",
6596
+ languages: ["yaml"],
6597
+ patterns: [
6598
+ { regex: "permissions\\s*:\\s*write-all", type: "match" },
6599
+ { regex: "(?:claude-code|copilot|codex|gemini|aider)", type: "context_line" },
6600
+ { regex: "\\.github/workflows/", type: "file_path" }
6601
+ ],
6602
+ fix: { description: "Set explicit permissions: block listing only what the job needs. For an agent that comments on PRs: `permissions: { contents: read, pull-requests: write }`." }
6603
+ },
6604
+ {
6605
+ id: "ai-agent/jetbrains-junie-json-schema-remote",
6606
+ title: "JSON Schema Loaded from Untrusted Remote URL",
6607
+ description: "JetBrains IDEs auto-load $schema URLs in JSON files. JetBrains Junie CVE-2025-58335 abused this \u2014 a malicious JSON in your repo points $schema at an attacker-controlled host, the IDE fetches it, and follow-on behavior (settings overwrite, prompt context contamination) opens the agent up.",
6608
+ severity: "medium",
6609
+ confidence: "medium",
6610
+ cwe: "CWE-918",
6611
+ languages: ["json"],
6612
+ patterns: [
6613
+ { regex: '"\\$schema"\\s*:\\s*"http://(?!localhost|127\\.)', type: "match" },
6614
+ { regex: '"\\$schema"\\s*:\\s*"https?://(?!schemas\\.(?:jetbrains|microsoft|github)\\.|json\\.schemastore\\.org|raw\\.githubusercontent\\.com/SchemaStore/)', type: "match" }
6615
+ ],
6616
+ fix: { description: "Only reference $schema URLs from trusted hosts (schemastore.org, jetbrains.com, github.com). Audit any custom schema URL in your repo \u2014 if it's not yours, remove it." }
6617
+ },
6618
+ {
6619
+ id: "ai-agent/cursor-mdc-rules-directory-presence",
6620
+ title: "Cursor .cursor/rules/*.mdc Files Present \u2014 Review Recommended",
6621
+ description: "Cursor's 2026 rules format is .cursor/rules/<name>.mdc (replacing the single .cursorrules file). Each .mdc carries frontmatter + instructions the agent reads. These files are prime targets for prompt injection and invisible Unicode payloads. This rule is an awareness flag \u2014 every .mdc file in a freshly cloned repo deserves a human read before opening Cursor.",
6622
+ severity: "low",
6623
+ confidence: "high",
6624
+ cwe: "CWE-1188",
6625
+ languages: ["markdown"],
6626
+ patterns: [
6627
+ { regex: "\\S", type: "match" },
6628
+ // any non-empty content
6629
+ { regex: "\\.cursor/rules/.*\\.mdc$", type: "file_path" }
6630
+ ],
6631
+ fix: { description: "Read every .mdc rule file before running Cursor on a new repo. If you didn't write the rule, treat it as untrusted instructions until you've verified it. Combine with `ai-agent/invisible-unicode-in-config` to catch the hidden-payload variant." }
6632
+ }
6633
+ ];
5849
6634
  var ALL_RULES = [
5850
6635
  ...SECRET_RULES,
5851
6636
  ...INJECTION_RULES,
@@ -5860,6 +6645,7 @@ var ALL_RULES = [
5860
6645
  ...HEADERS_RULES,
5861
6646
  ...DEPS_RULES,
5862
6647
  ...CLIENT_RULES,
6648
+ ...AI_AGENT_RULES,
5863
6649
  ...ALL_FRAMEWORK_RULES
5864
6650
  ];
5865
6651
  var CATEGORY_MAP = {
@@ -5876,6 +6662,7 @@ var CATEGORY_MAP = {
5876
6662
  headers: HEADERS_RULES,
5877
6663
  deps: DEPS_RULES,
5878
6664
  client: CLIENT_RULES,
6665
+ "ai-agent": AI_AGENT_RULES,
5879
6666
  nextjs: NEXTJS_RULES,
5880
6667
  express: EXPRESS_RULES,
5881
6668
  django: DJANGO_RULES,
@@ -5911,10 +6698,29 @@ function extractSnippet(content, line, contextLines = 5) {
5911
6698
  return `${marker} ${lineNum} | ${l}`;
5912
6699
  }).join("\n");
5913
6700
  }
5914
- function isCommentLine(line) {
6701
+ function isCommentLine(line, language) {
5915
6702
  const trimmed = line.trim();
6703
+ if (language === "markdown") {
6704
+ return trimmed.startsWith("<!--");
6705
+ }
6706
+ if (language === "yaml" || language === "shell") {
6707
+ return trimmed.startsWith("#");
6708
+ }
6709
+ if (language === "sql") {
6710
+ return trimmed.startsWith("--") || trimmed.startsWith("/*") || trimmed.startsWith("* ") || trimmed.startsWith("*/") || trimmed === "*";
6711
+ }
6712
+ if (language === "json") {
6713
+ return false;
6714
+ }
5916
6715
  return trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("/*") || trimmed.startsWith("* ") || trimmed.startsWith("*/") || trimmed === "*" || trimmed.startsWith("<!--");
5917
6716
  }
6717
+ function compileRegex(source, flags) {
6718
+ let finalFlags = flags;
6719
+ if (/\\u\{[0-9A-Fa-f]+\}/.test(source)) {
6720
+ if (!finalFlags.includes("u")) finalFlags += "u";
6721
+ }
6722
+ return new RegExp(source, finalFlags);
6723
+ }
5918
6724
  function isInsideStringLiteral(line, pattern) {
5919
6725
  const match = pattern.exec(line);
5920
6726
  if (!match) return false;
@@ -5926,29 +6732,56 @@ function isInsideStringLiteral(line, pattern) {
5926
6732
  }
5927
6733
  return backtickCount % 2 === 1;
5928
6734
  }
6735
+ function isJsxTextContent(line) {
6736
+ const trimmed = line.trim();
6737
+ if (/^\w+\s*[:=]\s*["'`]/.test(trimmed) && trimmed.length > 80) {
6738
+ const nonCodeChars = trimmed.replace(/[a-zA-Z\s.,!?;:'"()\-]/g, "").length;
6739
+ if (nonCodeChars / trimmed.length < 0.15) return true;
6740
+ }
6741
+ if (/>[^<]{40,}</.test(trimmed)) return true;
6742
+ if (/["'`][A-Z][^"'`]{60,}["'`]/.test(trimmed)) {
6743
+ return true;
6744
+ }
6745
+ return false;
6746
+ }
5929
6747
  function matchRule(rule, file) {
5930
6748
  if (!rule.languages.includes("*") && !rule.languages.includes(file.language)) {
5931
6749
  return [];
5932
6750
  }
5933
6751
  const isSecretRule = rule.id.startsWith("secrets/");
5934
6752
  if (!isSecretRule) {
5935
- const docPaths = /(?:\/docs\/|\/examples\/|\/fixtures\/|__tests__\/fixtures)/i;
5936
- if (docPaths.test(file.path)) return [];
6753
+ const contentPaths = /(?:\/docs\/|\/blog\/|\/for\/|\/examples\/|\/fixtures\/|\/tutorials?\/|\/guides?\/|__tests__\/fixtures)/i;
6754
+ if (contentPaths.test(file.path)) return [];
6755
+ }
6756
+ const filePathPatterns = rule.patterns.filter((p) => p.type === "file_path");
6757
+ if (filePathPatterns.length > 0) {
6758
+ const allFilePathMatch = filePathPatterns.every(
6759
+ (p) => compileRegex(p.regex, "i").test(file.path)
6760
+ );
6761
+ if (!allFilePathMatch) return [];
6762
+ }
6763
+ const contextLinePatterns = rule.patterns.filter((p) => p.type === "context_line");
6764
+ if (contextLinePatterns.length > 0) {
6765
+ const allContextMatch = contextLinePatterns.every(
6766
+ (p) => compileRegex(p.regex, "i").test(file.content)
6767
+ );
6768
+ if (!allContextMatch) return [];
5937
6769
  }
5938
6770
  const findings = [];
5939
6771
  const lines = file.content.split("\n");
5940
6772
  for (const pattern of rule.patterns) {
5941
6773
  if (pattern.type !== "match") continue;
5942
- const regex = new RegExp(pattern.regex, "gi");
6774
+ const regex = compileRegex(pattern.regex, "gi");
5943
6775
  for (let i = 0; i < lines.length; i++) {
5944
6776
  const line = lines[i];
5945
6777
  if (!regex.test(line)) continue;
5946
6778
  regex.lastIndex = 0;
5947
- if (!isSecretRule && isCommentLine(line)) continue;
5948
- if (!isSecretRule && isInsideStringLiteral(line, new RegExp(pattern.regex, "gi"))) continue;
6779
+ if (!isSecretRule && isCommentLine(line, file.language)) continue;
6780
+ if (!isSecretRule && isInsideStringLiteral(line, compileRegex(pattern.regex, "gi"))) continue;
6781
+ if (!isSecretRule && isJsxTextContent(line)) continue;
5949
6782
  if (rule.excludePatterns?.length) {
5950
6783
  const excluded = rule.excludePatterns.some((ep) => {
5951
- const exRegex = new RegExp(ep.regex, "i");
6784
+ const exRegex = compileRegex(ep.regex, "i");
5952
6785
  if (ep.type === "context_line") {
5953
6786
  return exRegex.test(line);
5954
6787
  }
@@ -5982,8 +6815,21 @@ function matchRule(rule, file) {
5982
6815
  }
5983
6816
  function runRules(rules, files) {
5984
6817
  const findings = [];
6818
+ const filePathSet = new Set(files.map((f) => f.path));
6819
+ const suppressedByMissingSibling = /* @__PURE__ */ new Set();
6820
+ for (const rule of rules) {
6821
+ if (!rule.requireSibling?.missing?.length) continue;
6822
+ const anyPresent = rule.requireSibling.missing.some((sibling) => {
6823
+ for (const filePath of filePathSet) {
6824
+ if (filePath === sibling || filePath.endsWith("/" + sibling)) return true;
6825
+ }
6826
+ return false;
6827
+ });
6828
+ if (anyPresent) suppressedByMissingSibling.add(rule.id);
6829
+ }
5985
6830
  for (const file of files) {
5986
6831
  for (const rule of rules) {
6832
+ if (suppressedByMissingSibling.has(rule.id)) continue;
5987
6833
  findings.push(...matchRule(rule, file));
5988
6834
  }
5989
6835
  }
@@ -6839,12 +7685,15 @@ function detectPlatform(files) {
6839
7685
  if (lower.endsWith("package.json") && content.includes('"v0"')) {
6840
7686
  signals.v0.push("v0 dependency in package.json");
6841
7687
  }
6842
- if (content.includes("base44") || content.includes("Base44")) {
6843
- signals.base44.push("base44 reference in source");
6844
- }
6845
7688
  if (lower.includes("base44.config") || lower.includes(".base44")) {
6846
7689
  signals.base44.push("base44 config file");
6847
7690
  }
7691
+ if (/from\s+['"]@?base44\b/i.test(content) || /require\s*\(\s*['"]@?base44\b/i.test(content)) {
7692
+ signals.base44.push("base44 SDK import in source");
7693
+ }
7694
+ if (/https?:\/\/[^"'\s]*base44\.app/i.test(content)) {
7695
+ signals.base44.push("base44.app URL in source");
7696
+ }
6848
7697
  }
6849
7698
  const scores = Object.entries(signals).filter(([key]) => key !== "manual").map(([platform, sigs]) => ({
6850
7699
  platform,
@@ -7408,23 +8257,22 @@ async function loginCommand(options) {
7408
8257
  const profile = await fetchProfile(apiUrl, pollData.token);
7409
8258
  if (profile) {
7410
8259
  tokenData.tier = profile.tier;
7411
- tokenData.cliTier = profile.cliTier;
7412
8260
  tokenData.aiQuota = profile.aiQuota;
7413
8261
  }
7414
8262
  storeToken(tokenData);
7415
8263
  pollSpinner.succeed(
7416
8264
  chalk3.green(`Logged in as ${chalk3.bold(pollData.email)}`)
7417
8265
  );
7418
- if (profile?.cliTier) {
8266
+ if (profile?.tier && profile.tier !== "free") {
7419
8267
  console.log(
7420
8268
  chalk3.cyan(
7421
- ` CLI plan: ${profile.cliTier} (${profile.aiQuota.remaining} AI scans remaining)`
8269
+ ` Plan: ${profile.tier} (${profile.aiQuota.remaining} AI scans remaining)`
7422
8270
  )
7423
8271
  );
7424
8272
  } else {
7425
8273
  console.log(
7426
8274
  chalk3.dim(
7427
- " No CLI plan. Upgrade at ship-safe.co/pricing for AI-powered scanning."
8275
+ " Free plan. Upgrade at ship-safe.co/pricing for AI-powered scanning."
7428
8276
  )
7429
8277
  );
7430
8278
  }
@@ -7498,20 +8346,17 @@ async function whoamiCommand(options) {
7498
8346
  storeToken({
7499
8347
  ...token,
7500
8348
  tier: profile.tier,
7501
- cliTier: profile.cliTier,
7502
8349
  aiQuota: profile.aiQuota
7503
8350
  });
7504
8351
  spinner.stop();
7505
8352
  console.log(chalk3.bold("\nShipSafe Account\n"));
7506
- console.log(` Email: ${chalk3.cyan(profile.email)}`);
7507
- console.log(` Web tier: ${chalk3.cyan(profile.tier)}`);
7508
- if (profile.cliTier) {
7509
- console.log(` CLI plan: ${chalk3.green(profile.cliTier)}`);
8353
+ console.log(` Email: ${chalk3.cyan(profile.email)}`);
8354
+ console.log(` Plan: ${chalk3.cyan(profile.tier)}`);
8355
+ if (profile.tier && profile.tier !== "free") {
7510
8356
  console.log(
7511
8357
  ` AI scans: ${chalk3.cyan(`${profile.aiQuota.used}/${profile.aiQuota.limit}`)} used this month (${chalk3.green(`${profile.aiQuota.remaining}`)} remaining)`
7512
8358
  );
7513
8359
  } else {
7514
- console.log(` CLI plan: ${chalk3.dim("none")}`);
7515
8360
  console.log(
7516
8361
  chalk3.dim(" Upgrade at ship-safe.co/pricing for AI-powered scanning.")
7517
8362
  );
@@ -7520,8 +8365,8 @@ async function whoamiCommand(options) {
7520
8365
  } else {
7521
8366
  spinner.stop();
7522
8367
  console.log(chalk3.green(`Logged in as ${chalk3.bold(token.email)}`));
7523
- if (token.cliTier) {
7524
- console.log(chalk3.cyan(` CLI plan: ${token.cliTier}`));
8368
+ if (token.tier) {
8369
+ console.log(chalk3.cyan(` Plan: ${token.tier}`));
7525
8370
  }
7526
8371
  }
7527
8372
  }
@@ -7755,21 +8600,50 @@ async function scanCommand(targetPath, options) {
7755
8600
  spinner.succeed(
7756
8601
  chalk5.green(`Scan complete \u2014 ${result.findings.length} findings in ${result.durationMs}ms`)
7757
8602
  );
7758
- if (tokenData?.cliTier) {
8603
+ if (tokenData?.tier === "growth" || tokenData?.tier === "badge" || tokenData?.tier === "shield") {
7759
8604
  const aiSpinner = ora2({
7760
- text: chalk5.dim("Running AI-powered deep analysis..."),
8605
+ text: chalk5.dim("Running AI-powered deep analysis... (this may take 1-3 minutes)"),
7761
8606
  color: "yellow",
7762
8607
  spinner: "dots12"
7763
8608
  }).start();
8609
+ const stages = [
8610
+ "Uploading files to AI scanner...",
8611
+ "Analyzing code with Claude AI...",
8612
+ "Detecting auth & logic vulnerabilities...",
8613
+ "Checking business logic flaws...",
8614
+ "Generating plain-English report..."
8615
+ ];
8616
+ let stageIdx = 0;
8617
+ const progressInterval = setInterval(() => {
8618
+ stageIdx = Math.min(stageIdx + 1, stages.length - 1);
8619
+ aiSpinner.text = chalk5.dim(`${stages[stageIdx]} (${(stageIdx + 1) * 20}%)`);
8620
+ }, 15e3);
7764
8621
  try {
8622
+ const MAX_AI_FILES = 100;
8623
+ const MAX_FILE_BYTES = 1e5;
8624
+ const MAX_TOTAL_BYTES = 4 * 1024 * 1024;
8625
+ let totalBytes = 0;
8626
+ const aiFiles = [];
8627
+ for (const f of files) {
8628
+ const size = new TextEncoder().encode(f.content).byteLength;
8629
+ if (size > MAX_FILE_BYTES) continue;
8630
+ if (totalBytes + size > MAX_TOTAL_BYTES) break;
8631
+ totalBytes += size;
8632
+ aiFiles.push(f);
8633
+ if (aiFiles.length >= MAX_AI_FILES) break;
8634
+ }
8635
+ aiSpinner.text = chalk5.dim(`Uploading ${aiFiles.length} files to AI scanner...`);
7765
8636
  const aiRes = await fetch(`${options.apiUrl}/api/cli/ai-scan`, {
7766
8637
  method: "POST",
7767
8638
  headers: {
7768
8639
  "Content-Type": "application/json",
7769
8640
  Authorization: `Bearer ${tokenData.token}`
7770
8641
  },
7771
- body: JSON.stringify({ files })
8642
+ body: JSON.stringify({ files: aiFiles }),
8643
+ signal: AbortSignal.timeout(3e5)
8644
+ // 5 minute timeout
7772
8645
  });
8646
+ clearInterval(progressInterval);
7773
8647
  if (aiRes.ok) {
7774
8648
  const aiData = await aiRes.json();
7775
8649
  const aiFindings = aiData.findings.map((f) => ({
@@ -7805,8 +8679,9 @@ async function scanCommand(targetPath, options) {
7805
8679
  `AI analysis: ${aiFindings.length} additional findings`
7806
8680
  )
7807
8681
  );
8682
+ const isUnlimited = !aiData.quota.limit || aiData.quota.limit < 0 || aiData.quota.limit >= 999999;
7808
8683
  printInfo(
7809
- `AI scans: ${aiData.quota.used}/${aiData.quota.limit} used this month (${aiData.quota.remaining} remaining)`
8684
+ isUnlimited ? `AI scans: ${aiData.quota.used} used this month (unlimited plan)` : `AI scans: ${aiData.quota.used}/${aiData.quota.limit} used this month (${aiData.quota.remaining} remaining)`
7810
8685
  );
7811
8686
  } else if (aiRes.status === 429) {
7812
8687
  aiSpinner.warn(
@@ -7826,10 +8701,14 @@ async function scanCommand(targetPath, options) {
7826
8701
  chalk5.yellow(`AI analysis unavailable (HTTP ${aiRes.status}). ${errBody || "Showing rule-based results only."}`)
7827
8702
  );
7828
8703
  }
7829
- } catch {
8704
+ } catch (fetchErr) {
8705
+ clearInterval(progressInterval);
8706
+ const errMsg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
7830
8707
  aiSpinner.warn(
7831
- chalk5.yellow("Could not reach AI scanning service. Showing rule-based results only.")
8708
+ chalk5.yellow(`Could not reach AI scanning service: ${errMsg}`)
7832
8709
  );
8710
+ } finally {
8711
+ clearInterval(progressInterval);
7833
8712
  }
7834
8713
  }
7835
8714
  const ignoredRules = loadIgnoredRules(resolvedPath);
@@ -7855,6 +8734,12 @@ async function scanCommand(targetPath, options) {
7855
8734
  const diff = diffWithPrevious(resolvedPath, result.findings);
7856
8735
  saveScanHistory(resolvedPath, result.findings);
7857
8736
  const isLoggedIn = !!getStoredToken();
8737
+ if (options.sarifFile) {
8738
+ writeFileSync4(options.sarifFile, formatSarifOutput(result), "utf-8");
8739
+ }
8740
+ if (options.jsonFile) {
8741
+ writeFileSync4(options.jsonFile, formatJsonOutput(result), "utf-8");
8742
+ }
7858
8743
  switch (options.output) {
7859
8744
  case "json":
7860
8745
  console.log(formatJsonOutput(result));
@@ -8090,14 +8975,14 @@ function getVersion() {
8090
8975
  for (let i = 0; i < 5; i++) {
8091
8976
  try {
8092
8977
  const pkg = JSON.parse(readFileSync6(join5(dir, "package.json"), "utf-8"));
8093
- if (pkg.name === "@ship-safe/cli") return pkg.version;
8978
+ if (pkg.version) return pkg.version;
8094
8979
  } catch {
8095
8980
  }
8096
8981
  dir = dirname(dir);
8097
8982
  }
8098
8983
  } catch {
8099
8984
  }
8100
- return "1.1.11";
8985
+ return "0.0.0-dev";
8101
8986
  }
8102
8987
  function validateApiUrl(value) {
8103
8988
  let parsed;
@@ -8117,7 +9002,7 @@ program.command("scan").description("Scan a directory or file for security vulne
8117
9002
  new Option("-o, --output <format>", "Output format: table, json, sarif").choices(["table", "json", "sarif"]).default("table")
8118
9003
  ).addOption(
8119
9004
  new Option("-s, --severity <level>", "Minimum severity: critical, high, medium, low").choices(["critical", "high", "medium", "low"]).default("low")
8120
- ).option("--ci", "CI mode: exit code 1 on high/critical findings", false).option("--upload", "Upload results to your ShipSafe dashboard", false).option(
9005
+ ).option("--ci", "CI mode: exit code 1 on high/critical findings", false).option("--upload", "Upload results to your ShipSafe dashboard", false).option("--sarif-file <path>", "Also write SARIF output to a file (for CI / GitHub Action)").option("--json-file <path>", "Also write JSON output to a file (for CI / GitHub Action)").option(
8121
9006
  "--api-url <url>",
8122
9007
  "API URL for ShipSafe server",
8123
9008
  validateApiUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ship-safe/cli",
3
- "version": "1.1.13",
3
+ "version": "1.1.16",
4
4
  "description": "Security scanner for AI-generated code — find vulnerabilities before you ship",
5
5
  "type": "module",
6
6
  "license": "MIT",