@ship-safe/cli 1.1.14 → 1.1.17
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/dist/index.js +1183 -92
- package/package.json +10 -11
package/dist/index.js
CHANGED
|
@@ -7,9 +7,9 @@ var __export = (target, all) => {
|
|
|
7
7
|
|
|
8
8
|
// src/index.ts
|
|
9
9
|
import { Command, InvalidArgumentError, Option } from "commander";
|
|
10
|
-
import { readFileSync as
|
|
10
|
+
import { readFileSync as readFileSync7 } from "fs";
|
|
11
11
|
import { fileURLToPath } from "url";
|
|
12
|
-
import { dirname, join as
|
|
12
|
+
import { dirname, join as join6 } from "path";
|
|
13
13
|
|
|
14
14
|
// src/commands/scan.ts
|
|
15
15
|
import { resolve as resolve2, join as join4 } from "path";
|
|
@@ -4125,6 +4125,65 @@ 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
|
+
);
|
|
4181
|
+
function isPaidTier(tier) {
|
|
4182
|
+
if (!tier) return false;
|
|
4183
|
+
if (tier === "badge") return true;
|
|
4184
|
+
const def = TIERS[tier];
|
|
4185
|
+
return def ? def.priceCents > 0 : false;
|
|
4186
|
+
}
|
|
4128
4187
|
var LANGUAGE_EXTENSIONS = {
|
|
4129
4188
|
".js": "javascript",
|
|
4130
4189
|
".jsx": "javascript",
|
|
@@ -4145,7 +4204,25 @@ var LANGUAGE_EXTENSIONS = {
|
|
|
4145
4204
|
".yml": "yaml",
|
|
4146
4205
|
".yaml": "yaml",
|
|
4147
4206
|
".json": "json",
|
|
4148
|
-
".toml": "toml"
|
|
4207
|
+
".toml": "toml",
|
|
4208
|
+
// Added for AI-agent rules (round 7-vuln):
|
|
4209
|
+
".md": "markdown",
|
|
4210
|
+
".mdc": "markdown",
|
|
4211
|
+
// Cursor 2026 rules format
|
|
4212
|
+
".sql": "sql",
|
|
4213
|
+
".sh": "shell",
|
|
4214
|
+
".bash": "shell",
|
|
4215
|
+
".zsh": "shell"
|
|
4216
|
+
};
|
|
4217
|
+
var LANGUAGE_FILENAMES = {
|
|
4218
|
+
".cursorrules": "markdown",
|
|
4219
|
+
".windsurfrules": "markdown",
|
|
4220
|
+
".clinerules": "markdown",
|
|
4221
|
+
".continuerules": "markdown",
|
|
4222
|
+
".aiderules": "markdown",
|
|
4223
|
+
".roorules": "markdown",
|
|
4224
|
+
Dockerfile: "dockerfile",
|
|
4225
|
+
Containerfile: "dockerfile"
|
|
4149
4226
|
};
|
|
4150
4227
|
var IGNORE_PATTERNS = [
|
|
4151
4228
|
"node_modules",
|
|
@@ -4159,7 +4236,10 @@ var IGNORE_PATTERNS = [
|
|
|
4159
4236
|
".venv",
|
|
4160
4237
|
"vendor",
|
|
4161
4238
|
".idea",
|
|
4162
|
-
|
|
4239
|
+
// NOTE: `.vscode` intentionally removed — GitHub Copilot CVE-2025-53773
|
|
4240
|
+
// modifies `.vscode/settings.json` to bypass guardrails, and we need to
|
|
4241
|
+
// scan that file. Keep `.vscode/launch.json` etc out via specific rules
|
|
4242
|
+
// if false-positive volume becomes an issue.
|
|
4163
4243
|
"*.min.js",
|
|
4164
4244
|
"*.min.css",
|
|
4165
4245
|
"*.map",
|
|
@@ -4244,6 +4324,8 @@ var llmResponseSchema = external_exports.object({
|
|
|
4244
4324
|
import path from "path";
|
|
4245
4325
|
import { createHash } from "crypto";
|
|
4246
4326
|
function detectLanguage(filePath) {
|
|
4327
|
+
const basename = path.basename(filePath);
|
|
4328
|
+
if (LANGUAGE_FILENAMES[basename]) return LANGUAGE_FILENAMES[basename];
|
|
4247
4329
|
const ext = path.extname(filePath).toLowerCase();
|
|
4248
4330
|
return LANGUAGE_EXTENSIONS[ext] ?? "unknown";
|
|
4249
4331
|
}
|
|
@@ -4727,16 +4809,19 @@ var SECRET_RULES = [
|
|
|
4727
4809
|
{
|
|
4728
4810
|
id: "secrets/openai-api-key",
|
|
4729
4811
|
title: "OpenAI API Key Detected",
|
|
4730
|
-
description: "An OpenAI API key was found hardcoded.
|
|
4812
|
+
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
4813
|
severity: "critical",
|
|
4732
4814
|
confidence: "high",
|
|
4733
4815
|
cwe: "CWE-798",
|
|
4816
|
+
owasp: "A07:2021",
|
|
4734
4817
|
languages: ["*"],
|
|
4735
4818
|
patterns: [
|
|
4819
|
+
// Legacy 2023-era keys
|
|
4736
4820
|
{ regex: "sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}", type: "match" },
|
|
4737
|
-
|
|
4821
|
+
// Modern project-scoped, service-account, and sandbox key prefixes (2024+)
|
|
4822
|
+
{ regex: "sk-(?:proj|svcacct|None)-[a-zA-Z0-9_-]{20,}", type: "match" }
|
|
4738
4823
|
],
|
|
4739
|
-
fix: { description: "Move
|
|
4824
|
+
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
4825
|
},
|
|
4741
4826
|
{
|
|
4742
4827
|
id: "secrets/supabase-service-role",
|
|
@@ -4851,6 +4936,230 @@ var SECRET_RULES = [
|
|
|
4851
4936
|
],
|
|
4852
4937
|
excludePatterns: [{ regex: "(?:test|spec|mock|fixture|seed|placeholder|example|\\.test\\.|\\.spec\\.|__test__|jest|vitest)", type: "context_line" }],
|
|
4853
4938
|
fix: { description: "Remove default credentials from code. Use environment variables and generate strong, unique passwords for each deployment." }
|
|
4939
|
+
},
|
|
4940
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
4941
|
+
// Round 7-vuln additions: AI provider keys + vector DB keys + extension
|
|
4942
|
+
// publish tokens. Sourced from tasks/HANDOFF_CLAUDE_CODE.md §I + §4.3.
|
|
4943
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
4944
|
+
{
|
|
4945
|
+
id: "secrets/anthropic-api-key",
|
|
4946
|
+
title: "Anthropic API Key Detected",
|
|
4947
|
+
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.",
|
|
4948
|
+
severity: "critical",
|
|
4949
|
+
confidence: "high",
|
|
4950
|
+
cwe: "CWE-798",
|
|
4951
|
+
owasp: "A07:2021",
|
|
4952
|
+
languages: ["*"],
|
|
4953
|
+
patterns: [{ regex: "sk-ant-[a-zA-Z0-9_-]{40,}", type: "match" }],
|
|
4954
|
+
excludePatterns: [{ regex: "(?:example|test|fake|dummy|placeholder|xxxx|YOUR_KEY)", type: "context_line" }],
|
|
4955
|
+
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" }
|
|
4956
|
+
},
|
|
4957
|
+
{
|
|
4958
|
+
id: "secrets/google-ai-key",
|
|
4959
|
+
title: "Google AI Studio / Gemini API Key Detected",
|
|
4960
|
+
description: "A Google AI Studio API key (AIza...) used for Gemini API and AI Studio. Same scrape risk as other AI keys.",
|
|
4961
|
+
severity: "critical",
|
|
4962
|
+
confidence: "high",
|
|
4963
|
+
cwe: "CWE-798",
|
|
4964
|
+
owasp: "A07:2021",
|
|
4965
|
+
languages: ["*"],
|
|
4966
|
+
patterns: [
|
|
4967
|
+
{ regex: `(?:GEMINI|GOOGLE_AI|GOOGLE_GEN_AI|GENAI)_API_KEY\\s*[:=]\\s*["']?AIza[A-Za-z0-9_-]{35}`, type: "match" },
|
|
4968
|
+
{ regex: `["']AIza[A-Za-z0-9_-]{35}["']`, type: "match" }
|
|
4969
|
+
],
|
|
4970
|
+
excludePatterns: [{ regex: "(?:example|test|fake|placeholder)", type: "context_line" }],
|
|
4971
|
+
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" }
|
|
4972
|
+
},
|
|
4973
|
+
{
|
|
4974
|
+
id: "secrets/grok-xai-key",
|
|
4975
|
+
title: "xAI / Grok API Key Detected",
|
|
4976
|
+
description: "An xAI/Grok API key (xai-...). Newer provider, same blast radius.",
|
|
4977
|
+
severity: "critical",
|
|
4978
|
+
confidence: "high",
|
|
4979
|
+
cwe: "CWE-798",
|
|
4980
|
+
languages: ["*"],
|
|
4981
|
+
patterns: [{ regex: "\\bxai-[a-zA-Z0-9]{60,}\\b", type: "match" }],
|
|
4982
|
+
fix: { description: "Rotate at https://console.x.ai. Server-side only from now on.", suggestion: "process.env.XAI_API_KEY" }
|
|
4983
|
+
},
|
|
4984
|
+
{
|
|
4985
|
+
id: "secrets/huggingface-token",
|
|
4986
|
+
title: "Hugging Face Token Detected",
|
|
4987
|
+
description: "A Hugging Face access token (hf_...). Used for model downloads, inference API, dataset pushes. Has push permissions on repos by default.",
|
|
4988
|
+
severity: "high",
|
|
4989
|
+
confidence: "high",
|
|
4990
|
+
cwe: "CWE-798",
|
|
4991
|
+
languages: ["*"],
|
|
4992
|
+
patterns: [{ regex: "\\bhf_[A-Za-z0-9]{34}\\b", type: "match" }],
|
|
4993
|
+
fix: { description: "Revoke at huggingface.co/settings/tokens. Create a read-only token if that's all you need.", suggestion: "process.env.HUGGINGFACE_TOKEN" }
|
|
4994
|
+
},
|
|
4995
|
+
{
|
|
4996
|
+
id: "secrets/replicate-token",
|
|
4997
|
+
title: "Replicate API Token Detected",
|
|
4998
|
+
description: "A Replicate API token (r8_...). Replicate runs ML models \u2014 your billing balance is directly burnable.",
|
|
4999
|
+
severity: "critical",
|
|
5000
|
+
confidence: "high",
|
|
5001
|
+
cwe: "CWE-798",
|
|
5002
|
+
languages: ["*"],
|
|
5003
|
+
patterns: [{ regex: "\\br8_[A-Za-z0-9]{37,40}\\b", type: "match" }],
|
|
5004
|
+
fix: { description: "Rotate at replicate.com/account/api-tokens immediately.", suggestion: "process.env.REPLICATE_API_TOKEN" }
|
|
5005
|
+
},
|
|
5006
|
+
{
|
|
5007
|
+
id: "secrets/groq-api-key",
|
|
5008
|
+
title: "Groq API Key Detected",
|
|
5009
|
+
description: "A Groq API key (gsk_...). Same blast radius as OpenAI keys \u2014 burnable balance, no scope.",
|
|
5010
|
+
severity: "critical",
|
|
5011
|
+
confidence: "high",
|
|
5012
|
+
cwe: "CWE-798",
|
|
5013
|
+
languages: ["*"],
|
|
5014
|
+
patterns: [{ regex: "\\bgsk_[A-Za-z0-9]{52,56}\\b", type: "match" }],
|
|
5015
|
+
fix: { description: "Rotate at console.groq.com/keys.", suggestion: "process.env.GROQ_API_KEY" }
|
|
5016
|
+
},
|
|
5017
|
+
{
|
|
5018
|
+
id: "secrets/perplexity-api-key",
|
|
5019
|
+
title: "Perplexity API Key Detected",
|
|
5020
|
+
description: "A Perplexity API key (pplx-...).",
|
|
5021
|
+
severity: "high",
|
|
5022
|
+
confidence: "high",
|
|
5023
|
+
cwe: "CWE-798",
|
|
5024
|
+
languages: ["*"],
|
|
5025
|
+
patterns: [{ regex: "\\bpplx-[A-Za-z0-9]{48,56}\\b", type: "match" }],
|
|
5026
|
+
fix: { description: "Rotate at perplexity.ai/settings/api.", suggestion: "process.env.PERPLEXITY_API_KEY" }
|
|
5027
|
+
},
|
|
5028
|
+
{
|
|
5029
|
+
id: "secrets/together-api-key",
|
|
5030
|
+
title: "Together AI API Key Detected",
|
|
5031
|
+
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.",
|
|
5032
|
+
severity: "high",
|
|
5033
|
+
confidence: "medium",
|
|
5034
|
+
cwe: "CWE-798",
|
|
5035
|
+
languages: ["*"],
|
|
5036
|
+
patterns: [{ regex: `(?:TOGETHER_API_KEY|TOGETHERAI_API_KEY)\\s*[:=]\\s*["']?[a-f0-9]{64}["']?`, type: "match" }],
|
|
5037
|
+
fix: { description: "Rotate at api.together.xyz/settings/api-keys.", suggestion: "process.env.TOGETHER_API_KEY" }
|
|
5038
|
+
},
|
|
5039
|
+
{
|
|
5040
|
+
id: "secrets/elevenlabs-api-key",
|
|
5041
|
+
title: "ElevenLabs API Key Detected",
|
|
5042
|
+
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.",
|
|
5043
|
+
severity: "high",
|
|
5044
|
+
confidence: "medium",
|
|
5045
|
+
cwe: "CWE-798",
|
|
5046
|
+
languages: ["*"],
|
|
5047
|
+
// Both patterns must match the file (engine §1.1b context_line filter)
|
|
5048
|
+
// because 32-hex strings appear all over (hashes, UUIDs).
|
|
5049
|
+
patterns: [
|
|
5050
|
+
{ regex: `(?:ELEVENLABS|XI)_API_KEY\\s*[:=]\\s*["']?(?:sk_)?[a-f0-9]{32}`, type: "match" }
|
|
5051
|
+
],
|
|
5052
|
+
fix: { description: "Rotate at elevenlabs.io/app/settings/api-keys.", suggestion: "process.env.ELEVENLABS_API_KEY" }
|
|
5053
|
+
},
|
|
5054
|
+
{
|
|
5055
|
+
id: "secrets/mistral-api-key",
|
|
5056
|
+
title: "Mistral AI API Key Detected",
|
|
5057
|
+
description: "A Mistral AI API key.",
|
|
5058
|
+
severity: "high",
|
|
5059
|
+
confidence: "medium",
|
|
5060
|
+
cwe: "CWE-798",
|
|
5061
|
+
languages: ["*"],
|
|
5062
|
+
patterns: [{ regex: `\\bMISTRAL_API_KEY\\s*[:=]\\s*["']?[A-Za-z0-9]{32,}["']?`, type: "match" }],
|
|
5063
|
+
fix: { description: "Rotate at console.mistral.ai/api-keys.", suggestion: "process.env.MISTRAL_API_KEY" }
|
|
5064
|
+
},
|
|
5065
|
+
{
|
|
5066
|
+
id: "secrets/cohere-api-key",
|
|
5067
|
+
title: "Cohere API Key Detected",
|
|
5068
|
+
description: "A Cohere API key.",
|
|
5069
|
+
severity: "high",
|
|
5070
|
+
confidence: "medium",
|
|
5071
|
+
cwe: "CWE-798",
|
|
5072
|
+
languages: ["*"],
|
|
5073
|
+
patterns: [{ regex: `\\bCOHERE_API_KEY\\s*[:=]\\s*["']?[A-Za-z0-9]{40,}["']?`, type: "match" }],
|
|
5074
|
+
fix: { description: "Rotate at dashboard.cohere.com/api-keys.", suggestion: "process.env.COHERE_API_KEY" }
|
|
5075
|
+
},
|
|
5076
|
+
{
|
|
5077
|
+
id: "secrets/deepseek-api-key",
|
|
5078
|
+
title: "DeepSeek API Key Detected",
|
|
5079
|
+
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.",
|
|
5080
|
+
severity: "high",
|
|
5081
|
+
confidence: "high",
|
|
5082
|
+
cwe: "CWE-798",
|
|
5083
|
+
languages: ["*"],
|
|
5084
|
+
patterns: [
|
|
5085
|
+
{ regex: `(?:DEEPSEEK|DEEP_SEEK)_API_KEY\\s*[:=]\\s*["']?sk-[a-f0-9]{32}["']?`, type: "match" }
|
|
5086
|
+
],
|
|
5087
|
+
fix: { description: "Rotate at platform.deepseek.com/api_keys.", suggestion: "process.env.DEEPSEEK_API_KEY" }
|
|
5088
|
+
},
|
|
5089
|
+
{
|
|
5090
|
+
id: "secrets/pinecone-api-key",
|
|
5091
|
+
title: "Pinecone API Key Detected",
|
|
5092
|
+
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.",
|
|
5093
|
+
severity: "critical",
|
|
5094
|
+
confidence: "high",
|
|
5095
|
+
cwe: "CWE-798",
|
|
5096
|
+
languages: ["*"],
|
|
5097
|
+
patterns: [
|
|
5098
|
+
{ regex: "\\bpcsk_[A-Za-z0-9_]{40,}\\b", type: "match" }
|
|
5099
|
+
],
|
|
5100
|
+
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" }
|
|
5101
|
+
},
|
|
5102
|
+
{
|
|
5103
|
+
id: "secrets/weaviate-api-key",
|
|
5104
|
+
title: "Weaviate API Key Detected",
|
|
5105
|
+
description: "A Weaviate API key.",
|
|
5106
|
+
severity: "high",
|
|
5107
|
+
confidence: "medium",
|
|
5108
|
+
cwe: "CWE-798",
|
|
5109
|
+
languages: ["*"],
|
|
5110
|
+
patterns: [{ regex: `(?:WEAVIATE_API_KEY|WEAVIATE_ADMIN_API_KEY)\\s*[:=]\\s*["']?[A-Za-z0-9-]{32,}["']?`, type: "match" }],
|
|
5111
|
+
fix: { description: "Rotate the key in your Weaviate Cloud console. Prefer read-only keys for client-reachable code.", suggestion: "process.env.WEAVIATE_API_KEY" }
|
|
5112
|
+
},
|
|
5113
|
+
{
|
|
5114
|
+
id: "secrets/vsce-publish-token",
|
|
5115
|
+
title: "VS Code Marketplace Publish Token Detected",
|
|
5116
|
+
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.",
|
|
5117
|
+
severity: "critical",
|
|
5118
|
+
confidence: "high",
|
|
5119
|
+
cwe: "CWE-798",
|
|
5120
|
+
languages: ["*"],
|
|
5121
|
+
patterns: [{ regex: `(?:VSCE_PAT|VSCE_TOKEN|VSCODE_MARKETPLACE_TOKEN)\\s*[:=]\\s*["']?[A-Za-z0-9]{52,}`, type: "match" }],
|
|
5122
|
+
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" }
|
|
5123
|
+
},
|
|
5124
|
+
{
|
|
5125
|
+
id: "secrets/ovsx-publish-token",
|
|
5126
|
+
title: "Open VSX Publish Token Detected",
|
|
5127
|
+
description: "An Open VSX publish token. Same blast radius as VSCE_PAT \u2014 the Clinejection attack stole this token alongside.",
|
|
5128
|
+
severity: "critical",
|
|
5129
|
+
confidence: "high",
|
|
5130
|
+
cwe: "CWE-798",
|
|
5131
|
+
languages: ["*"],
|
|
5132
|
+
patterns: [{ regex: `(?:OVSX_PAT|OVSX_TOKEN|OPEN_VSX_TOKEN)\\s*[:=]\\s*["']?[A-Za-z0-9-]{30,}`, type: "match" }],
|
|
5133
|
+
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" }
|
|
5134
|
+
},
|
|
5135
|
+
{
|
|
5136
|
+
id: "secrets/public-env-secret-exposed",
|
|
5137
|
+
title: "Secret Exposed to the Browser via a Public Variable",
|
|
5138
|
+
description: "A secret is tied to a public, client-bundled variable (VITE_, REACT_APP_, EXPO_PUBLIC_). These are inlined into the JavaScript shipped to every visitor, so anyone can read the key by viewing your site's source. (NEXT_PUBLIC_ is covered by a separate Next.js rule.)",
|
|
5139
|
+
severity: "critical",
|
|
5140
|
+
confidence: "high",
|
|
5141
|
+
cwe: "CWE-200",
|
|
5142
|
+
owasp: "A01:2021",
|
|
5143
|
+
languages: ["*"],
|
|
5144
|
+
patterns: [
|
|
5145
|
+
// A public-prefixed variable whose NAME is unambiguously a server secret.
|
|
5146
|
+
// Scoped to non-Next prefixes (framework/nextjs-public-env-secret owns
|
|
5147
|
+
// NEXT_PUBLIC_) and to names that value-based rules can't catch from a bare
|
|
5148
|
+
// `process.env.X` reference — so this is purely additive, no double-counting
|
|
5149
|
+
// with llm/api-key-client-env. API_KEY/TOKEN are deliberately excluded (those
|
|
5150
|
+
// overlap with value-based detection and legitimate publishable keys).
|
|
5151
|
+
{
|
|
5152
|
+
regex: "(?:VITE_|REACT_APP_|EXPO_PUBLIC_)[A-Z0-9_]*(?:SECRET|SERVICE_ROLE|PRIVATE|PASSWORD)(?!.*(?:anon|public))",
|
|
5153
|
+
type: "match"
|
|
5154
|
+
}
|
|
5155
|
+
],
|
|
5156
|
+
excludePatterns: [
|
|
5157
|
+
{ regex: "ANON_KEY|PUBLISHABLE|PUBLIC_KEY|_URL\\b|_ID\\b", type: "context_line" }
|
|
5158
|
+
],
|
|
5159
|
+
fix: {
|
|
5160
|
+
description: "Never put a secret in a public variable. Use a server-only variable (drop the VITE_/EXPO_PUBLIC_ prefix) and read it from server code, or proxy the request through your backend so the key never reaches the browser.",
|
|
5161
|
+
suggestion: "// server only \u2014 no public prefix:\nconst key = process.env.STRIPE_SECRET_KEY;"
|
|
5162
|
+
}
|
|
4854
5163
|
}
|
|
4855
5164
|
];
|
|
4856
5165
|
var INJECTION_RULES = [
|
|
@@ -4929,6 +5238,56 @@ var INJECTION_RULES = [
|
|
|
4929
5238
|
],
|
|
4930
5239
|
excludePatterns: [{ regex: "(?:__proto__|hasOwnProperty|prototype|constructor|sanitize|safeMerge|lodash\\.merge|deepmerge)", type: "context_line" }],
|
|
4931
5240
|
fix: { description: "Validate that user-provided keys don't include __proto__, constructor, or prototype. Use Object.create(null) for key-value maps from user input.", suggestion: "if (['__proto__', 'constructor', 'prototype'].includes(key)) throw new Error('Invalid key')" }
|
|
5241
|
+
},
|
|
5242
|
+
{
|
|
5243
|
+
id: "injection/python-sql-formatting",
|
|
5244
|
+
title: "SQL Query Built with String Formatting (Python)",
|
|
5245
|
+
description: "A SQL query passed to .execute() is built with % formatting or + concatenation. If any part comes from user input, this is SQL injection. Pass values as parameters instead. (f-string queries are caught separately.)",
|
|
5246
|
+
severity: "critical",
|
|
5247
|
+
confidence: "high",
|
|
5248
|
+
cwe: "CWE-89",
|
|
5249
|
+
owasp: "A03:2021",
|
|
5250
|
+
languages: ["python"],
|
|
5251
|
+
patterns: [
|
|
5252
|
+
// f-strings are owned by framework/django-raw-sql; only the %/+ forms here,
|
|
5253
|
+
// to stay purely additive and avoid double-counting one query.
|
|
5254
|
+
{ regex: `\\.execute(?:many)?\\s*\\(\\s*["'][^"']*["']\\s*%`, type: "match" },
|
|
5255
|
+
{ regex: `\\.execute(?:many)?\\s*\\(\\s*["'][^"']*["']\\s*\\+`, type: "match" }
|
|
5256
|
+
],
|
|
5257
|
+
fix: {
|
|
5258
|
+
description: "Use parameterized queries \u2014 pass values as the second argument to execute(), never format them into the SQL string.",
|
|
5259
|
+
suggestion: 'cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))'
|
|
5260
|
+
}
|
|
5261
|
+
},
|
|
5262
|
+
{
|
|
5263
|
+
id: "injection/python-unsafe-yaml-load",
|
|
5264
|
+
title: "Unsafe YAML Deserialization (Python)",
|
|
5265
|
+
description: "yaml.load() without SafeLoader can construct arbitrary Python objects, so untrusted YAML can execute code on your server. Use yaml.safe_load() instead.",
|
|
5266
|
+
severity: "critical",
|
|
5267
|
+
confidence: "high",
|
|
5268
|
+
cwe: "CWE-502",
|
|
5269
|
+
owasp: "A08:2021",
|
|
5270
|
+
languages: ["python"],
|
|
5271
|
+
patterns: [{ regex: "yaml\\.load\\s*\\((?![^)]{0,200}SafeLoader)", type: "match" }],
|
|
5272
|
+
fix: {
|
|
5273
|
+
description: "Use yaml.safe_load() for any input you don't fully control.",
|
|
5274
|
+
suggestion: "data = yaml.safe_load(user_input)"
|
|
5275
|
+
}
|
|
5276
|
+
},
|
|
5277
|
+
{
|
|
5278
|
+
id: "config/flask-debug-enabled",
|
|
5279
|
+
title: "Flask Debug Mode Enabled",
|
|
5280
|
+
description: "Flask runs with debug=True. The Werkzeug interactive debugger lets anyone who triggers an error run arbitrary code on your server. Never enable debug mode in production.",
|
|
5281
|
+
severity: "critical",
|
|
5282
|
+
confidence: "high",
|
|
5283
|
+
cwe: "CWE-489",
|
|
5284
|
+
owasp: "A05:2021",
|
|
5285
|
+
languages: ["python"],
|
|
5286
|
+
patterns: [{ regex: "\\.run\\s*\\([^)]*debug\\s*=\\s*True", type: "match" }],
|
|
5287
|
+
fix: {
|
|
5288
|
+
description: "Never run Flask with debug=True in production. Drive it from an environment variable that defaults to off.",
|
|
5289
|
+
suggestion: 'app.run(debug=os.environ.get("FLASK_DEBUG") == "1")'
|
|
5290
|
+
}
|
|
4932
5291
|
}
|
|
4933
5292
|
];
|
|
4934
5293
|
var XSS_RULES = [
|
|
@@ -5016,6 +5375,10 @@ var AUTH_RULES = [
|
|
|
5016
5375
|
languages: ["javascript", "typescript"],
|
|
5017
5376
|
patterns: [
|
|
5018
5377
|
{ regex: `(?:Access-Control-Allow-Origin|origin)\\s*[:=]\\s*["']\\*["']`, type: "match" },
|
|
5378
|
+
// Two-argument header form: res.header("Access-Control-Allow-Origin", "*")
|
|
5379
|
+
// / res.setHeader(...) — very common in AI-generated Express code, missed by
|
|
5380
|
+
// the colon/equals pattern above.
|
|
5381
|
+
{ regex: `["']Access-Control-Allow-Origin["']\\s*,\\s*["']\\*["']`, type: "match" },
|
|
5019
5382
|
{ regex: "cors\\(\\s*\\)", type: "match" },
|
|
5020
5383
|
{ regex: `cors\\(\\s*\\{\\s*origin\\s*:\\s*(?:true|\\*|["']\\*["'])`, type: "match" }
|
|
5021
5384
|
],
|
|
@@ -5091,6 +5454,25 @@ var CONFIG_RULES = [
|
|
|
5091
5454
|
patterns: [{ regex: "(?:NextAuth|authOptions|nextauth)\\s*[:=(]", type: "match" }],
|
|
5092
5455
|
excludePatterns: [{ regex: "(?:NEXTAUTH_SECRET|secret\\s*:\\s*process\\.env|AUTH_SECRET|NEXT_AUTH_SECRET)", type: "context_line" }],
|
|
5093
5456
|
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" }
|
|
5457
|
+
},
|
|
5458
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5459
|
+
// Round 7-vuln: agent-workspace config files committed to repo.
|
|
5460
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5461
|
+
{
|
|
5462
|
+
id: "config/agent-config-tracked",
|
|
5463
|
+
title: "Agent Workspace Config Committed to Repo",
|
|
5464
|
+
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.",
|
|
5465
|
+
severity: "medium",
|
|
5466
|
+
confidence: "medium",
|
|
5467
|
+
cwe: "CWE-538",
|
|
5468
|
+
languages: ["json", "yaml"],
|
|
5469
|
+
patterns: [
|
|
5470
|
+
// Fires only on the workspace-config filenames themselves.
|
|
5471
|
+
{ regex: "(?:^|/)(?:\\.cursor/mcp\\.json|\\.claude/settings\\.local\\.json|\\.windsurf/config\\.json|\\.cline/settings\\.json|\\.aider\\.conf\\.yml)$", type: "file_path" },
|
|
5472
|
+
// Marker: any non-empty content. The presence of the file IS the finding.
|
|
5473
|
+
{ regex: "\\S", type: "match" }
|
|
5474
|
+
],
|
|
5475
|
+
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
5476
|
}
|
|
5095
5477
|
];
|
|
5096
5478
|
var PII_RULES = [
|
|
@@ -5353,6 +5735,76 @@ var BAAS_RULES = [
|
|
|
5353
5735
|
],
|
|
5354
5736
|
excludePatterns: [{ regex: `(?:allowedRedirects|validRedirects|safeUrls|startsWith\\s*\\(\\s*["']https://)`, type: "context_line" }],
|
|
5355
5737
|
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`" }
|
|
5738
|
+
},
|
|
5739
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5740
|
+
// Round 7-vuln additions: post-Lovable-CVE Supabase RLS patterns. The
|
|
5741
|
+
// existing `supabase-rls-disabled` rule only flags MISSING RLS. These flag
|
|
5742
|
+
// RLS that LOOKS protective but isn't — `USING (true)`, over-granted anon
|
|
5743
|
+
// role, service-role used in client-reachable code, JWT-claim trust.
|
|
5744
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5745
|
+
{
|
|
5746
|
+
id: "baas/supabase-rls-policy-allows-all",
|
|
5747
|
+
title: "Supabase RLS Policy Allows All Rows",
|
|
5748
|
+
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.",
|
|
5749
|
+
severity: "critical",
|
|
5750
|
+
confidence: "high",
|
|
5751
|
+
cwe: "CWE-285",
|
|
5752
|
+
owasp: "A01:2021",
|
|
5753
|
+
languages: ["sql"],
|
|
5754
|
+
patterns: [
|
|
5755
|
+
{ regex: "create\\s+policy[^;]+(?:using|with\\s+check)\\s*\\(\\s*(?:true|1\\s*=\\s*1)\\s*\\)", type: "match" },
|
|
5756
|
+
{ regex: "create\\s+policy[^;]+using\\s*\\(\\s*auth\\.role\\(\\)\\s*=\\s*'authenticated'\\s*\\)", type: "match" }
|
|
5757
|
+
],
|
|
5758
|
+
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." }
|
|
5759
|
+
},
|
|
5760
|
+
{
|
|
5761
|
+
id: "baas/supabase-anon-overgrant",
|
|
5762
|
+
title: "Postgres Anon Role Granted Broad Privileges",
|
|
5763
|
+
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.",
|
|
5764
|
+
severity: "high",
|
|
5765
|
+
confidence: "high",
|
|
5766
|
+
cwe: "CWE-732",
|
|
5767
|
+
owasp: "A01:2021",
|
|
5768
|
+
languages: ["sql"],
|
|
5769
|
+
patterns: [
|
|
5770
|
+
{ regex: "grant\\s+(?:all|insert|update|delete|truncate)\\s+(?:on[^;]+)?to\\s+anon", type: "match" }
|
|
5771
|
+
],
|
|
5772
|
+
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." }
|
|
5773
|
+
},
|
|
5774
|
+
{
|
|
5775
|
+
id: "baas/supabase-service-role-client-reachable",
|
|
5776
|
+
title: "Service-Role Key Used in Client-Reachable Route",
|
|
5777
|
+
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.",
|
|
5778
|
+
severity: "critical",
|
|
5779
|
+
confidence: "medium",
|
|
5780
|
+
cwe: "CWE-269",
|
|
5781
|
+
owasp: "A01:2021",
|
|
5782
|
+
languages: ["javascript", "typescript"],
|
|
5783
|
+
patterns: [
|
|
5784
|
+
{ regex: "createClient\\s*\\([^,]+,\\s*process\\.env\\.(?:SUPABASE_)?SERVICE_ROLE_KEY", type: "match" }
|
|
5785
|
+
],
|
|
5786
|
+
excludePatterns: [
|
|
5787
|
+
{ regex: "(?:\\.server\\.|/api/internal|cron|webhook|middleware\\.ts|requireAuth|auth\\.uid|getServerSession)", type: "context_line" },
|
|
5788
|
+
{ regex: "(?:\\.server\\.|/api/internal|cron|webhook|middleware\\.ts)", type: "file_path" }
|
|
5789
|
+
],
|
|
5790
|
+
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." }
|
|
5791
|
+
},
|
|
5792
|
+
{
|
|
5793
|
+
id: "baas/supabase-rls-policy-jwt-claim-not-verified",
|
|
5794
|
+
title: "Supabase RLS Policy Trusts a JWT Claim Without Issuing It",
|
|
5795
|
+
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.",
|
|
5796
|
+
severity: "high",
|
|
5797
|
+
confidence: "low",
|
|
5798
|
+
cwe: "CWE-345",
|
|
5799
|
+
owasp: "A01:2021",
|
|
5800
|
+
languages: ["sql"],
|
|
5801
|
+
patterns: [
|
|
5802
|
+
{ regex: "auth\\.jwt\\(\\)\\s*->>?\\s*'(?:role|org_id|tenant|company|workspace_id|account_id)'", type: "match" }
|
|
5803
|
+
],
|
|
5804
|
+
excludePatterns: [
|
|
5805
|
+
{ regex: "(?:auth\\.uid|user_id|owner_id)", type: "context_line" }
|
|
5806
|
+
],
|
|
5807
|
+
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
5808
|
}
|
|
5357
5809
|
];
|
|
5358
5810
|
var LLM_RULES = [
|
|
@@ -5489,6 +5941,158 @@ var LLM_RULES = [
|
|
|
5489
5941
|
{ regex: "(?:query|execute|sql|raw)\\s*\\(\\s*(?:completion|response|result|output|llm|ai|gpt|claude)[.\\[]", type: "match" }
|
|
5490
5942
|
],
|
|
5491
5943
|
fix: { description: "Never execute LLM output as code. Use structured output parsing with validation, or run generated code in a sandboxed environment." }
|
|
5944
|
+
},
|
|
5945
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5946
|
+
// Round 7-vuln additions: MCP tool pinning, LangChain SSRF/path traversal,
|
|
5947
|
+
// Vercel AI SDK input handling, cost-exhaustion (max_tokens / rate limit),
|
|
5948
|
+
// .env-into-prompt, Codex branch-shell, OpenAI Assistants allowlist,
|
|
5949
|
+
// git-hook writes, agent-on-PR-content.
|
|
5950
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
5951
|
+
{
|
|
5952
|
+
id: "llm/mcp-tool-no-pinning",
|
|
5953
|
+
title: "MCP Tool Used Without Version Pinning",
|
|
5954
|
+
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.",
|
|
5955
|
+
severity: "medium",
|
|
5956
|
+
confidence: "low",
|
|
5957
|
+
cwe: "CWE-345",
|
|
5958
|
+
languages: ["javascript", "typescript", "python"],
|
|
5959
|
+
patterns: [
|
|
5960
|
+
{ regex: `(?:mcpClient|mcp_client|MCPClient)\\.(?:callTool|invoke|call)\\s*\\(\\s*["']`, type: "match" }
|
|
5961
|
+
],
|
|
5962
|
+
excludePatterns: [{ regex: "(?:hash|fingerprint|version|pinned|approved)", type: "context_line" }],
|
|
5963
|
+
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." }
|
|
5964
|
+
},
|
|
5965
|
+
{
|
|
5966
|
+
id: "llm/langchain-recursive-url-loader-unsafe",
|
|
5967
|
+
title: "LangChain RecursiveUrlLoader Used Without SSRF Guard",
|
|
5968
|
+
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.",
|
|
5969
|
+
severity: "high",
|
|
5970
|
+
confidence: "medium",
|
|
5971
|
+
cwe: "CWE-918",
|
|
5972
|
+
owasp: "A10:2021",
|
|
5973
|
+
languages: ["python", "javascript", "typescript"],
|
|
5974
|
+
patterns: [
|
|
5975
|
+
{ regex: "(?:RecursiveUrlLoader|recursive_url_loader)\\s*\\(", type: "match" }
|
|
5976
|
+
],
|
|
5977
|
+
excludePatterns: [{ regex: "(?:allowlist|allow_list|allowed_domains|domain_filter|ssrf_guard)", type: "context_line" }],
|
|
5978
|
+
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." }
|
|
5979
|
+
},
|
|
5980
|
+
{
|
|
5981
|
+
id: "llm/langchain-load-prompt-from-path",
|
|
5982
|
+
title: "LangChain Loads Prompt Template from Untrusted Path",
|
|
5983
|
+
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.",
|
|
5984
|
+
severity: "high",
|
|
5985
|
+
confidence: "medium",
|
|
5986
|
+
cwe: "CWE-22",
|
|
5987
|
+
owasp: "A01:2021",
|
|
5988
|
+
languages: ["python", "javascript", "typescript"],
|
|
5989
|
+
patterns: [
|
|
5990
|
+
{ regex: "(?:load_prompt|loadPrompt)\\s*\\([^)]*(?:req\\.|request\\.|input|user|body|query|param)", type: "match" }
|
|
5991
|
+
],
|
|
5992
|
+
fix: { description: "Never pass untrusted strings to load_prompt. Maintain an explicit registry of allowed prompt names and look up the path server-side." }
|
|
5993
|
+
},
|
|
5994
|
+
{
|
|
5995
|
+
id: "llm/ai-sdk-input-as-prompt",
|
|
5996
|
+
title: "Vercel AI SDK generateText/streamText Called with Raw User Input",
|
|
5997
|
+
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.",
|
|
5998
|
+
severity: "high",
|
|
5999
|
+
confidence: "medium",
|
|
6000
|
+
cwe: "CWE-77",
|
|
6001
|
+
languages: ["javascript", "typescript"],
|
|
6002
|
+
patterns: [
|
|
6003
|
+
{ regex: "(?:generateText|streamText|generateObject|streamObject)\\s*\\(\\s*\\{[^}]*prompt\\s*:\\s*(?:req\\.|request\\.|body\\.|input|userInput|message)", type: "match" }
|
|
6004
|
+
],
|
|
6005
|
+
excludePatterns: [{ regex: "(?:sanitize|validate|zod|schema|filter)", type: "context_line" }],
|
|
6006
|
+
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) }] })" }
|
|
6007
|
+
},
|
|
6008
|
+
{
|
|
6009
|
+
id: "llm/agent-runs-on-unsanitized-pr-content",
|
|
6010
|
+
title: "AI Agent Run on PR Title/Body Without Sanitizing",
|
|
6011
|
+
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.",
|
|
6012
|
+
severity: "critical",
|
|
6013
|
+
confidence: "medium",
|
|
6014
|
+
cwe: "CWE-77",
|
|
6015
|
+
owasp: "A03:2021",
|
|
6016
|
+
languages: ["javascript", "typescript", "python"],
|
|
6017
|
+
patterns: [
|
|
6018
|
+
{ regex: "(?:pull_request|pullRequest|pr|issue)\\.(?:title|body|head_ref).{0,200}(?:prompt|messages|systemPrompt|userMessage)", type: "match" },
|
|
6019
|
+
{ regex: "(?:prompt|content)\\s*[:=]\\s*`[^`]*\\$\\{(?:pr|pullRequest|issue|comment)\\.(?:title|body)", type: "match" }
|
|
6020
|
+
],
|
|
6021
|
+
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." }
|
|
6022
|
+
},
|
|
6023
|
+
{
|
|
6024
|
+
id: "llm/no-max-tokens",
|
|
6025
|
+
title: "LLM Call Without max_tokens or Cost Limit",
|
|
6026
|
+
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.",
|
|
6027
|
+
severity: "medium",
|
|
6028
|
+
confidence: "low",
|
|
6029
|
+
cwe: "CWE-770",
|
|
6030
|
+
owasp: "A04:2021",
|
|
6031
|
+
languages: ["javascript", "typescript", "python"],
|
|
6032
|
+
patterns: [
|
|
6033
|
+
{ regex: "(?:openai|anthropic|gemini|generativeai)\\.[a-z_]+\\.(?:create|generate|complete|messages)\\s*\\(\\s*\\{[^}]*\\bmodel\\s*:", type: "match" }
|
|
6034
|
+
],
|
|
6035
|
+
excludePatterns: [{ regex: "(?:max_tokens|max_output_tokens|maxTokens|maxOutputTokens|max_completion_tokens)", type: "context_line" }],
|
|
6036
|
+
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 });" }
|
|
6037
|
+
},
|
|
6038
|
+
{
|
|
6039
|
+
id: "llm/env-file-read-into-prompt",
|
|
6040
|
+
title: ".env or Secrets File Read into LLM Prompt",
|
|
6041
|
+
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.",
|
|
6042
|
+
severity: "critical",
|
|
6043
|
+
confidence: "medium",
|
|
6044
|
+
cwe: "CWE-200",
|
|
6045
|
+
owasp: "A01:2021",
|
|
6046
|
+
languages: ["javascript", "typescript", "python"],
|
|
6047
|
+
patterns: [
|
|
6048
|
+
{ 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" }
|
|
6049
|
+
],
|
|
6050
|
+
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." }
|
|
6051
|
+
},
|
|
6052
|
+
{
|
|
6053
|
+
id: "llm/agent-writes-to-git-hooks",
|
|
6054
|
+
title: "Code Writes to .git/hooks (CVE-2026-26268 vector)",
|
|
6055
|
+
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.",
|
|
6056
|
+
severity: "high",
|
|
6057
|
+
confidence: "high",
|
|
6058
|
+
cwe: "CWE-94",
|
|
6059
|
+
owasp: "A03:2021",
|
|
6060
|
+
languages: ["javascript", "typescript", "python", "shell"],
|
|
6061
|
+
patterns: [
|
|
6062
|
+
{ regex: `(?:writeFile|writeFileSync|open|Path\\([^)]+\\)\\.write)[\\s\\S]{0,80}["']\\.git/hooks/`, type: "match" },
|
|
6063
|
+
{ regex: "chmod\\s+\\+x[\\s\\S]{0,80}\\.git/hooks/", type: "match" }
|
|
6064
|
+
],
|
|
6065
|
+
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." }
|
|
6066
|
+
},
|
|
6067
|
+
{
|
|
6068
|
+
id: "llm/codex-branch-name-shell-injection",
|
|
6069
|
+
title: "Branch Name or Repo Field Passed to Shell Without Sanitizing",
|
|
6070
|
+
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.",
|
|
6071
|
+
severity: "critical",
|
|
6072
|
+
confidence: "medium",
|
|
6073
|
+
cwe: "CWE-78",
|
|
6074
|
+
owasp: "A03:2021",
|
|
6075
|
+
languages: ["javascript", "typescript", "python"],
|
|
6076
|
+
patterns: [
|
|
6077
|
+
{ 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" },
|
|
6078
|
+
{ regex: "`[^`]*\\$\\{(?:branch|ref|head|repo)Name", type: "match" }
|
|
6079
|
+
],
|
|
6080
|
+
excludePatterns: [{ regex: "(?:shellQuote|escapeShellArg|shlex\\.quote|sanitize)", type: "context_line" }],
|
|
6081
|
+
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." }
|
|
6082
|
+
},
|
|
6083
|
+
{
|
|
6084
|
+
id: "llm/openai-assistant-tool-no-allowlist",
|
|
6085
|
+
title: "OpenAI Assistants / Responses API Without Tool Allowlist",
|
|
6086
|
+
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.",
|
|
6087
|
+
severity: "high",
|
|
6088
|
+
confidence: "medium",
|
|
6089
|
+
cwe: "CWE-284",
|
|
6090
|
+
languages: ["javascript", "typescript", "python"],
|
|
6091
|
+
patterns: [
|
|
6092
|
+
{ regex: "required_action[\\s\\S]{0,100}submit_tool_outputs", type: "match" }
|
|
6093
|
+
],
|
|
6094
|
+
excludePatterns: [{ regex: "(?:allowedTools|toolRegistry|ALLOW_LIST|whitelist|permitted)", type: "context_line" }],
|
|
6095
|
+
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
6096
|
}
|
|
5493
6097
|
];
|
|
5494
6098
|
var HEADERS_RULES = [
|
|
@@ -5773,6 +6377,58 @@ var DEPS_RULES = [
|
|
|
5773
6377
|
],
|
|
5774
6378
|
excludePatterns: [{ regex: "(?:integrity|crossorigin)", type: "context_line" }],
|
|
5775
6379
|
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>' }
|
|
6380
|
+
},
|
|
6381
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
6382
|
+
// Round 7-vuln additions: known-malicious packages from the Clinejection
|
|
6383
|
+
// class of supply-chain attacks, extended slopsquatting list, no-lockfile.
|
|
6384
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
6385
|
+
{
|
|
6386
|
+
id: "deps/known-malicious-package",
|
|
6387
|
+
title: "Known-Malicious Package Detected",
|
|
6388
|
+
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.",
|
|
6389
|
+
severity: "critical",
|
|
6390
|
+
confidence: "high",
|
|
6391
|
+
cwe: "CWE-829",
|
|
6392
|
+
owasp: "A06:2021",
|
|
6393
|
+
languages: ["json"],
|
|
6394
|
+
patterns: [
|
|
6395
|
+
{ regex: '"(?:openclaw|postmark-mcp)"\\s*:', type: "match" },
|
|
6396
|
+
{ regex: '"cline"\\s*:\\s*"[^"]*2\\.3\\.0[^"]*"', type: "match" },
|
|
6397
|
+
{ regex: "package(?:-lock)?\\.json$", type: "file_path" }
|
|
6398
|
+
],
|
|
6399
|
+
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." }
|
|
6400
|
+
},
|
|
6401
|
+
{
|
|
6402
|
+
id: "deps/slopsquatting-risk-extended",
|
|
6403
|
+
title: "Likely AI-Hallucinated Package Name (Extended List)",
|
|
6404
|
+
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.",
|
|
6405
|
+
severity: "high",
|
|
6406
|
+
confidence: "low",
|
|
6407
|
+
cwe: "CWE-829",
|
|
6408
|
+
languages: ["json"],
|
|
6409
|
+
patterns: [
|
|
6410
|
+
{ 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" },
|
|
6411
|
+
{ regex: "package\\.json$", type: "file_path" }
|
|
6412
|
+
],
|
|
6413
|
+
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." }
|
|
6414
|
+
},
|
|
6415
|
+
{
|
|
6416
|
+
id: "deps/no-lockfile",
|
|
6417
|
+
title: "No Dependency Lockfile in Project",
|
|
6418
|
+
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.",
|
|
6419
|
+
severity: "medium",
|
|
6420
|
+
confidence: "high",
|
|
6421
|
+
cwe: "CWE-1357",
|
|
6422
|
+
languages: ["json"],
|
|
6423
|
+
patterns: [
|
|
6424
|
+
{ regex: '"name"\\s*:', type: "match" },
|
|
6425
|
+
{ regex: "(?:^|/)package\\.json$", type: "file_path" }
|
|
6426
|
+
],
|
|
6427
|
+
requireSibling: {
|
|
6428
|
+
// Rule fires only if NONE of these lockfiles exist anywhere in the scan set.
|
|
6429
|
+
missing: ["package-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb"]
|
|
6430
|
+
},
|
|
6431
|
+
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
6432
|
}
|
|
5777
6433
|
];
|
|
5778
6434
|
var CLIENT_RULES = [
|
|
@@ -5846,6 +6502,224 @@ var CLIENT_RULES = [
|
|
|
5846
6502
|
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
6503
|
}
|
|
5848
6504
|
];
|
|
6505
|
+
var AI_AGENT_RULES = [
|
|
6506
|
+
{
|
|
6507
|
+
id: "ai-agent/invisible-unicode-in-config",
|
|
6508
|
+
title: "AI Agent Config File Contains Invisible Unicode",
|
|
6509
|
+
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.",
|
|
6510
|
+
severity: "critical",
|
|
6511
|
+
confidence: "high",
|
|
6512
|
+
cwe: "CWE-1007",
|
|
6513
|
+
owasp: "A03:2021",
|
|
6514
|
+
languages: ["markdown"],
|
|
6515
|
+
patterns: [
|
|
6516
|
+
// JS regex Unicode escape uses \u{...} with /u flag (PCRE \x{} doesn't work).
|
|
6517
|
+
// compileRegex auto-adds /u when it sees \u{...} braces in the source.
|
|
6518
|
+
{ regex: "[\\u{200B}-\\u{200F}\\u{2028}-\\u{202F}\\u{2060}-\\u{206F}\\u{FE00}-\\u{FE0F}\\u{E0000}-\\u{E007F}]", type: "match" },
|
|
6519
|
+
{ regex: "(?:\\.cursorrules|\\.windsurfrules|\\.clinerules|CLAUDE\\.md|AGENTS\\.md|copilot-instructions\\.md|\\.mdc|\\.cursor/rules/)", type: "file_path" }
|
|
6520
|
+
],
|
|
6521
|
+
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" }
|
|
6522
|
+
},
|
|
6523
|
+
{
|
|
6524
|
+
id: "ai-agent/mcp-stdio-shell-command",
|
|
6525
|
+
title: "MCP Server Runs an Arbitrary Shell Command",
|
|
6526
|
+
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.",
|
|
6527
|
+
severity: "critical",
|
|
6528
|
+
confidence: "high",
|
|
6529
|
+
cwe: "CWE-78",
|
|
6530
|
+
owasp: "A03:2021",
|
|
6531
|
+
languages: ["json"],
|
|
6532
|
+
patterns: [
|
|
6533
|
+
{ regex: '"command"\\s*:\\s*"(?:sh|bash|zsh|cmd|powershell|pwsh|eval|exec)"', type: "match" },
|
|
6534
|
+
{ regex: '"args"\\s*:\\s*\\[\\s*"-c"', type: "match" },
|
|
6535
|
+
{ regex: "(?:mcp\\.json|mcp_settings\\.json|claude_desktop_config\\.json)$", type: "file_path" }
|
|
6536
|
+
],
|
|
6537
|
+
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.' }
|
|
6538
|
+
},
|
|
6539
|
+
{
|
|
6540
|
+
id: "ai-agent/mcp-public-http-endpoint",
|
|
6541
|
+
title: "MCP Server Points to a Public HTTP URL",
|
|
6542
|
+
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.",
|
|
6543
|
+
severity: "high",
|
|
6544
|
+
confidence: "medium",
|
|
6545
|
+
cwe: "CWE-319",
|
|
6546
|
+
owasp: "A02:2021",
|
|
6547
|
+
languages: ["json"],
|
|
6548
|
+
patterns: [
|
|
6549
|
+
{ regex: '"url"\\s*:\\s*"http://(?!localhost|127\\.|0\\.0\\.0\\.0|::1)', type: "match" },
|
|
6550
|
+
{ regex: "(?:mcp\\.json|claude_desktop_config\\.json)$", type: "file_path" }
|
|
6551
|
+
],
|
|
6552
|
+
excludePatterns: [
|
|
6553
|
+
{ regex: "ngrok\\.io|ngrok-free\\.app|loca\\.lt", type: "context_line" }
|
|
6554
|
+
],
|
|
6555
|
+
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." }
|
|
6556
|
+
},
|
|
6557
|
+
{
|
|
6558
|
+
id: "ai-agent/cursor-auto-run-enabled",
|
|
6559
|
+
title: "Cursor Auto-Run / YOLO Mode Enabled",
|
|
6560
|
+
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.",
|
|
6561
|
+
severity: "high",
|
|
6562
|
+
confidence: "high",
|
|
6563
|
+
cwe: "CWE-269",
|
|
6564
|
+
languages: ["json"],
|
|
6565
|
+
patterns: [
|
|
6566
|
+
{ regex: '"cursor\\.(?:autoRun|yoloMode|composerAutoRun|chat\\.autoExecute|agent\\.autoRun|agent\\.skipApproval)"\\s*:\\s*true', type: "match" },
|
|
6567
|
+
{ regex: '"agent"\\s*:\\s*\\{[^}]*"autoApprove"\\s*:\\s*true', type: "match" }
|
|
6568
|
+
],
|
|
6569
|
+
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." }
|
|
6570
|
+
},
|
|
6571
|
+
{
|
|
6572
|
+
id: "ai-agent/agent-allowlist-disabled",
|
|
6573
|
+
title: "Agent Tool Allowlist Disabled or Empty",
|
|
6574
|
+
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.",
|
|
6575
|
+
severity: "high",
|
|
6576
|
+
confidence: "medium",
|
|
6577
|
+
cwe: "CWE-284",
|
|
6578
|
+
languages: ["json"],
|
|
6579
|
+
patterns: [
|
|
6580
|
+
{ regex: '"allowlist"\\s*:\\s*(?:false|null|\\[\\s*"\\*"\\s*\\])', type: "match" },
|
|
6581
|
+
{ regex: '"toolApproval"\\s*:\\s*"never"', type: "match" }
|
|
6582
|
+
],
|
|
6583
|
+
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." }
|
|
6584
|
+
},
|
|
6585
|
+
{
|
|
6586
|
+
id: "ai-agent/auto-approve-tools",
|
|
6587
|
+
title: "Agent Configured to Auto-Approve All Tool Calls",
|
|
6588
|
+
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.",
|
|
6589
|
+
severity: "high",
|
|
6590
|
+
confidence: "medium",
|
|
6591
|
+
cwe: "CWE-284",
|
|
6592
|
+
languages: ["json", "yaml", "markdown", "shell"],
|
|
6593
|
+
patterns: [
|
|
6594
|
+
{ regex: "(?:auto[_-]?approve|skipConfirmation|autoConfirm|trustedAlways)\\s*[:=]\\s*true", type: "match" },
|
|
6595
|
+
{ regex: "--dangerously-skip-permissions", type: "match" }
|
|
6596
|
+
],
|
|
6597
|
+
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." }
|
|
6598
|
+
},
|
|
6599
|
+
{
|
|
6600
|
+
id: "ai-agent/ci-agent-untrusted-pr-input",
|
|
6601
|
+
title: "CI Agent Reads PR Title or Body Without Sanitizing",
|
|
6602
|
+
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.",
|
|
6603
|
+
severity: "critical",
|
|
6604
|
+
confidence: "medium",
|
|
6605
|
+
cwe: "CWE-77",
|
|
6606
|
+
owasp: "A03:2021",
|
|
6607
|
+
languages: ["yaml"],
|
|
6608
|
+
patterns: [
|
|
6609
|
+
{ regex: "(?:claude-code|copilot|gemini|aider|devin|codex)[\\s\\S]{0,200}\\$\\{\\{\\s*github\\.event\\.pull_request\\.(?:title|body|head_ref)", type: "match" },
|
|
6610
|
+
{ regex: "\\.github/workflows/", type: "file_path" }
|
|
6611
|
+
],
|
|
6612
|
+
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." }
|
|
6613
|
+
},
|
|
6614
|
+
{
|
|
6615
|
+
id: "ai-agent/ci-agent-pull-request-fork",
|
|
6616
|
+
title: "CI Agent Triggers on pull_request from Forks",
|
|
6617
|
+
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.",
|
|
6618
|
+
severity: "high",
|
|
6619
|
+
confidence: "low",
|
|
6620
|
+
cwe: "CWE-269",
|
|
6621
|
+
languages: ["yaml"],
|
|
6622
|
+
patterns: [
|
|
6623
|
+
{ regex: "on\\s*:[\\s\\S]{0,80}pull_request(?:_target)?", type: "match" },
|
|
6624
|
+
{ regex: "(?:claude-code|copilot|gemini|aider|devin)", type: "context_line" },
|
|
6625
|
+
{ regex: "\\.github/workflows/", type: "file_path" }
|
|
6626
|
+
],
|
|
6627
|
+
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." }
|
|
6628
|
+
},
|
|
6629
|
+
{
|
|
6630
|
+
id: "ai-agent/github-action-injection-from-pr",
|
|
6631
|
+
title: "PR Title or Body Interpolated into Shell Step",
|
|
6632
|
+
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.",
|
|
6633
|
+
severity: "critical",
|
|
6634
|
+
confidence: "high",
|
|
6635
|
+
cwe: "CWE-78",
|
|
6636
|
+
owasp: "A03:2021",
|
|
6637
|
+
languages: ["yaml"],
|
|
6638
|
+
patterns: [
|
|
6639
|
+
{ 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" },
|
|
6640
|
+
{ regex: "\\.github/workflows/", type: "file_path" }
|
|
6641
|
+
],
|
|
6642
|
+
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.' }
|
|
6643
|
+
},
|
|
6644
|
+
{
|
|
6645
|
+
id: "ai-agent/agent-config-not-gitignored",
|
|
6646
|
+
title: "Agent Config Path Suggests Workspace File Was Committed",
|
|
6647
|
+
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.)",
|
|
6648
|
+
severity: "low",
|
|
6649
|
+
confidence: "low",
|
|
6650
|
+
cwe: "CWE-538",
|
|
6651
|
+
languages: ["json"],
|
|
6652
|
+
patterns: [
|
|
6653
|
+
{ regex: "(?:^|/)(?:\\.cursor|\\.claude|\\.windsurf|\\.cline|\\.continue|\\.aider)/", type: "file_path" },
|
|
6654
|
+
{ regex: "\\S", type: "match" }
|
|
6655
|
+
],
|
|
6656
|
+
excludePatterns: [
|
|
6657
|
+
{ regex: "\\.gitignore$", type: "file_path" }
|
|
6658
|
+
],
|
|
6659
|
+
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." }
|
|
6660
|
+
},
|
|
6661
|
+
// ── Tier 2 additions (§4 of handoff) ────────────────────────────────────
|
|
6662
|
+
{
|
|
6663
|
+
id: "ai-agent/pwn-request-checkout",
|
|
6664
|
+
title: "GitHub Action Uses pull_request_target + Checks Out PR Code",
|
|
6665
|
+
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.",
|
|
6666
|
+
severity: "critical",
|
|
6667
|
+
confidence: "high",
|
|
6668
|
+
cwe: "CWE-269",
|
|
6669
|
+
owasp: "A01:2021",
|
|
6670
|
+
languages: ["yaml"],
|
|
6671
|
+
patterns: [
|
|
6672
|
+
{ regex: "pull_request_target", type: "match" },
|
|
6673
|
+
{ regex: "actions/checkout@[^\\s]+[\\s\\S]{0,200}ref\\s*:\\s*\\$\\{\\{\\s*github\\.event\\.pull_request\\.head\\.(?:sha|ref)", type: "match" },
|
|
6674
|
+
{ regex: "\\.github/workflows/", type: "file_path" }
|
|
6675
|
+
],
|
|
6676
|
+
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." }
|
|
6677
|
+
},
|
|
6678
|
+
{
|
|
6679
|
+
id: "ai-agent/workflow-write-all-permissions",
|
|
6680
|
+
title: "Workflow Grants Write-All Permissions to an AI-Agent Job",
|
|
6681
|
+
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.",
|
|
6682
|
+
severity: "high",
|
|
6683
|
+
confidence: "medium",
|
|
6684
|
+
cwe: "CWE-269",
|
|
6685
|
+
languages: ["yaml"],
|
|
6686
|
+
patterns: [
|
|
6687
|
+
{ regex: "permissions\\s*:\\s*write-all", type: "match" },
|
|
6688
|
+
{ regex: "(?:claude-code|copilot|codex|gemini|aider)", type: "context_line" },
|
|
6689
|
+
{ regex: "\\.github/workflows/", type: "file_path" }
|
|
6690
|
+
],
|
|
6691
|
+
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 }`." }
|
|
6692
|
+
},
|
|
6693
|
+
{
|
|
6694
|
+
id: "ai-agent/jetbrains-junie-json-schema-remote",
|
|
6695
|
+
title: "JSON Schema Loaded from Untrusted Remote URL",
|
|
6696
|
+
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.",
|
|
6697
|
+
severity: "medium",
|
|
6698
|
+
confidence: "medium",
|
|
6699
|
+
cwe: "CWE-918",
|
|
6700
|
+
languages: ["json"],
|
|
6701
|
+
patterns: [
|
|
6702
|
+
{ regex: '"\\$schema"\\s*:\\s*"http://(?!localhost|127\\.)', type: "match" },
|
|
6703
|
+
{ regex: '"\\$schema"\\s*:\\s*"https?://(?!schemas\\.(?:jetbrains|microsoft|github)\\.|json\\.schemastore\\.org|raw\\.githubusercontent\\.com/SchemaStore/)', type: "match" }
|
|
6704
|
+
],
|
|
6705
|
+
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." }
|
|
6706
|
+
},
|
|
6707
|
+
{
|
|
6708
|
+
id: "ai-agent/cursor-mdc-rules-directory-presence",
|
|
6709
|
+
title: "Cursor .cursor/rules/*.mdc Files Present \u2014 Review Recommended",
|
|
6710
|
+
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.",
|
|
6711
|
+
severity: "low",
|
|
6712
|
+
confidence: "high",
|
|
6713
|
+
cwe: "CWE-1188",
|
|
6714
|
+
languages: ["markdown"],
|
|
6715
|
+
patterns: [
|
|
6716
|
+
{ regex: "\\S", type: "match" },
|
|
6717
|
+
// any non-empty content
|
|
6718
|
+
{ regex: "\\.cursor/rules/.*\\.mdc$", type: "file_path" }
|
|
6719
|
+
],
|
|
6720
|
+
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." }
|
|
6721
|
+
}
|
|
6722
|
+
];
|
|
5849
6723
|
var ALL_RULES = [
|
|
5850
6724
|
...SECRET_RULES,
|
|
5851
6725
|
...INJECTION_RULES,
|
|
@@ -5860,6 +6734,7 @@ var ALL_RULES = [
|
|
|
5860
6734
|
...HEADERS_RULES,
|
|
5861
6735
|
...DEPS_RULES,
|
|
5862
6736
|
...CLIENT_RULES,
|
|
6737
|
+
...AI_AGENT_RULES,
|
|
5863
6738
|
...ALL_FRAMEWORK_RULES
|
|
5864
6739
|
];
|
|
5865
6740
|
var CATEGORY_MAP = {
|
|
@@ -5876,6 +6751,7 @@ var CATEGORY_MAP = {
|
|
|
5876
6751
|
headers: HEADERS_RULES,
|
|
5877
6752
|
deps: DEPS_RULES,
|
|
5878
6753
|
client: CLIENT_RULES,
|
|
6754
|
+
"ai-agent": AI_AGENT_RULES,
|
|
5879
6755
|
nextjs: NEXTJS_RULES,
|
|
5880
6756
|
express: EXPRESS_RULES,
|
|
5881
6757
|
django: DJANGO_RULES,
|
|
@@ -5911,10 +6787,29 @@ function extractSnippet(content, line, contextLines = 5) {
|
|
|
5911
6787
|
return `${marker} ${lineNum} | ${l}`;
|
|
5912
6788
|
}).join("\n");
|
|
5913
6789
|
}
|
|
5914
|
-
function isCommentLine(line) {
|
|
6790
|
+
function isCommentLine(line, language) {
|
|
5915
6791
|
const trimmed = line.trim();
|
|
6792
|
+
if (language === "markdown") {
|
|
6793
|
+
return trimmed.startsWith("<!--");
|
|
6794
|
+
}
|
|
6795
|
+
if (language === "yaml" || language === "shell") {
|
|
6796
|
+
return trimmed.startsWith("#");
|
|
6797
|
+
}
|
|
6798
|
+
if (language === "sql") {
|
|
6799
|
+
return trimmed.startsWith("--") || trimmed.startsWith("/*") || trimmed.startsWith("* ") || trimmed.startsWith("*/") || trimmed === "*";
|
|
6800
|
+
}
|
|
6801
|
+
if (language === "json") {
|
|
6802
|
+
return false;
|
|
6803
|
+
}
|
|
5916
6804
|
return trimmed.startsWith("//") || trimmed.startsWith("#") || trimmed.startsWith("/*") || trimmed.startsWith("* ") || trimmed.startsWith("*/") || trimmed === "*" || trimmed.startsWith("<!--");
|
|
5917
6805
|
}
|
|
6806
|
+
function compileRegex(source, flags) {
|
|
6807
|
+
let finalFlags = flags;
|
|
6808
|
+
if (/\\u\{[0-9A-Fa-f]+\}/.test(source)) {
|
|
6809
|
+
if (!finalFlags.includes("u")) finalFlags += "u";
|
|
6810
|
+
}
|
|
6811
|
+
return new RegExp(source, finalFlags);
|
|
6812
|
+
}
|
|
5918
6813
|
function isInsideStringLiteral(line, pattern) {
|
|
5919
6814
|
const match = pattern.exec(line);
|
|
5920
6815
|
if (!match) return false;
|
|
@@ -5947,21 +6842,35 @@ function matchRule(rule, file) {
|
|
|
5947
6842
|
const contentPaths = /(?:\/docs\/|\/blog\/|\/for\/|\/examples\/|\/fixtures\/|\/tutorials?\/|\/guides?\/|__tests__\/fixtures)/i;
|
|
5948
6843
|
if (contentPaths.test(file.path)) return [];
|
|
5949
6844
|
}
|
|
6845
|
+
const filePathPatterns = rule.patterns.filter((p) => p.type === "file_path");
|
|
6846
|
+
if (filePathPatterns.length > 0) {
|
|
6847
|
+
const allFilePathMatch = filePathPatterns.every(
|
|
6848
|
+
(p) => compileRegex(p.regex, "i").test(file.path)
|
|
6849
|
+
);
|
|
6850
|
+
if (!allFilePathMatch) return [];
|
|
6851
|
+
}
|
|
6852
|
+
const contextLinePatterns = rule.patterns.filter((p) => p.type === "context_line");
|
|
6853
|
+
if (contextLinePatterns.length > 0) {
|
|
6854
|
+
const allContextMatch = contextLinePatterns.every(
|
|
6855
|
+
(p) => compileRegex(p.regex, "i").test(file.content)
|
|
6856
|
+
);
|
|
6857
|
+
if (!allContextMatch) return [];
|
|
6858
|
+
}
|
|
5950
6859
|
const findings = [];
|
|
5951
6860
|
const lines = file.content.split("\n");
|
|
5952
6861
|
for (const pattern of rule.patterns) {
|
|
5953
6862
|
if (pattern.type !== "match") continue;
|
|
5954
|
-
const regex =
|
|
6863
|
+
const regex = compileRegex(pattern.regex, "gi");
|
|
5955
6864
|
for (let i = 0; i < lines.length; i++) {
|
|
5956
6865
|
const line = lines[i];
|
|
5957
6866
|
if (!regex.test(line)) continue;
|
|
5958
6867
|
regex.lastIndex = 0;
|
|
5959
|
-
if (!isSecretRule && isCommentLine(line)) continue;
|
|
5960
|
-
if (!isSecretRule && isInsideStringLiteral(line,
|
|
6868
|
+
if (!isSecretRule && isCommentLine(line, file.language)) continue;
|
|
6869
|
+
if (!isSecretRule && isInsideStringLiteral(line, compileRegex(pattern.regex, "gi"))) continue;
|
|
5961
6870
|
if (!isSecretRule && isJsxTextContent(line)) continue;
|
|
5962
6871
|
if (rule.excludePatterns?.length) {
|
|
5963
6872
|
const excluded = rule.excludePatterns.some((ep) => {
|
|
5964
|
-
const exRegex =
|
|
6873
|
+
const exRegex = compileRegex(ep.regex, "i");
|
|
5965
6874
|
if (ep.type === "context_line") {
|
|
5966
6875
|
return exRegex.test(line);
|
|
5967
6876
|
}
|
|
@@ -5995,8 +6904,21 @@ function matchRule(rule, file) {
|
|
|
5995
6904
|
}
|
|
5996
6905
|
function runRules(rules, files) {
|
|
5997
6906
|
const findings = [];
|
|
6907
|
+
const filePathSet = new Set(files.map((f) => f.path));
|
|
6908
|
+
const suppressedByMissingSibling = /* @__PURE__ */ new Set();
|
|
6909
|
+
for (const rule of rules) {
|
|
6910
|
+
if (!rule.requireSibling?.missing?.length) continue;
|
|
6911
|
+
const anyPresent = rule.requireSibling.missing.some((sibling) => {
|
|
6912
|
+
for (const filePath of filePathSet) {
|
|
6913
|
+
if (filePath === sibling || filePath.endsWith("/" + sibling)) return true;
|
|
6914
|
+
}
|
|
6915
|
+
return false;
|
|
6916
|
+
});
|
|
6917
|
+
if (anyPresent) suppressedByMissingSibling.add(rule.id);
|
|
6918
|
+
}
|
|
5998
6919
|
for (const file of files) {
|
|
5999
6920
|
for (const rule of rules) {
|
|
6921
|
+
if (suppressedByMissingSibling.has(rule.id)) continue;
|
|
6000
6922
|
findings.push(...matchRule(rule, file));
|
|
6001
6923
|
}
|
|
6002
6924
|
}
|
|
@@ -6169,6 +7091,10 @@ function dedupeEntries(entries) {
|
|
|
6169
7091
|
return true;
|
|
6170
7092
|
});
|
|
6171
7093
|
}
|
|
7094
|
+
var OSV_QUERYBATCH_URL = "https://api.osv.dev/v1/querybatch";
|
|
7095
|
+
var OSV_VULN_URL = "https://api.osv.dev/v1/vulns";
|
|
7096
|
+
var MAX_ENRICH = 200;
|
|
7097
|
+
var ENRICH_CONCURRENCY = 8;
|
|
6172
7098
|
async function queryOsv(packages) {
|
|
6173
7099
|
const queries = packages.map((p) => ({
|
|
6174
7100
|
package: { name: p.name, ecosystem: p.ecosystem },
|
|
@@ -6176,7 +7102,7 @@ async function queryOsv(packages) {
|
|
|
6176
7102
|
}));
|
|
6177
7103
|
let response;
|
|
6178
7104
|
try {
|
|
6179
|
-
response = await fetch(
|
|
7105
|
+
response = await fetch(OSV_QUERYBATCH_URL, {
|
|
6180
7106
|
method: "POST",
|
|
6181
7107
|
headers: { "Content-Type": "application/json" },
|
|
6182
7108
|
body: JSON.stringify({ queries })
|
|
@@ -6192,38 +7118,84 @@ async function queryOsv(packages) {
|
|
|
6192
7118
|
);
|
|
6193
7119
|
}
|
|
6194
7120
|
const data = await response.json();
|
|
6195
|
-
const
|
|
7121
|
+
const matches = [];
|
|
7122
|
+
const ids = /* @__PURE__ */ new Set();
|
|
6196
7123
|
for (let i = 0; i < data.results.length; i++) {
|
|
6197
7124
|
const result = data.results[i];
|
|
6198
7125
|
const pkg = packages[i];
|
|
6199
7126
|
if (!result?.vulns || result.vulns.length === 0 || !pkg) continue;
|
|
6200
7127
|
for (const vuln of result.vulns) {
|
|
6201
|
-
|
|
6202
|
-
|
|
6203
|
-
|
|
6204
|
-
const summaryText = vuln.summary ? vuln.summary.replace(/\n/g, " ").trim() : `Known vulnerability in ${pkg.name}`;
|
|
6205
|
-
const humanTitle = summaryText.length > 120 ? summaryText.slice(0, 117) + "..." : summaryText;
|
|
6206
|
-
findings.push({
|
|
6207
|
-
id: `dep-${vuln.id}`,
|
|
6208
|
-
ruleId: vuln.id,
|
|
6209
|
-
title: humanTitle,
|
|
6210
|
-
description: `${pkg.name}@${pkg.version} \u2014 ${vuln.id}${cveAlias ? ` (${cveAlias})` : ""}. ${summaryText}`,
|
|
6211
|
-
plainEnglish: `Your dependency "${pkg.name}" version ${pkg.version} has a known vulnerability (${vuln.id}). ${summaryText}`,
|
|
6212
|
-
severity,
|
|
6213
|
-
confidence: "high",
|
|
6214
|
-
source: "dependency",
|
|
6215
|
-
file: pkg.lockfile,
|
|
6216
|
-
line: 1,
|
|
6217
|
-
snippet: `${pkg.name}@${pkg.version}`,
|
|
6218
|
-
fix: {
|
|
6219
|
-
description: fixedVersion ? `Upgrade ${pkg.name} to version ${fixedVersion} or later.` : `Check ${vuln.id} for remediation steps. Consider upgrading or replacing this dependency.`,
|
|
6220
|
-
suggestion: fixedVersion ? `npm install ${pkg.name}@${fixedVersion}` : void 0
|
|
6221
|
-
},
|
|
6222
|
-
cwe: cveAlias
|
|
6223
|
-
});
|
|
7128
|
+
if (!vuln.id) continue;
|
|
7129
|
+
matches.push({ pkg, id: vuln.id });
|
|
7130
|
+
ids.add(vuln.id);
|
|
6224
7131
|
}
|
|
6225
7132
|
}
|
|
6226
|
-
return
|
|
7133
|
+
if (matches.length === 0) return [];
|
|
7134
|
+
const details = await fetchVulnDetails([...ids].slice(0, MAX_ENRICH));
|
|
7135
|
+
return matches.map(
|
|
7136
|
+
({ pkg, id }) => buildDependencyFinding(pkg, id, details.get(id))
|
|
7137
|
+
);
|
|
7138
|
+
}
|
|
7139
|
+
async function fetchVulnDetails(ids) {
|
|
7140
|
+
const out = /* @__PURE__ */ new Map();
|
|
7141
|
+
let cursor = 0;
|
|
7142
|
+
async function worker() {
|
|
7143
|
+
while (cursor < ids.length) {
|
|
7144
|
+
const id = ids[cursor++];
|
|
7145
|
+
if (!id) continue;
|
|
7146
|
+
try {
|
|
7147
|
+
const res = await fetch(`${OSV_VULN_URL}/${encodeURIComponent(id)}`);
|
|
7148
|
+
if (res.ok) out.set(id, await res.json());
|
|
7149
|
+
} catch {
|
|
7150
|
+
}
|
|
7151
|
+
}
|
|
7152
|
+
}
|
|
7153
|
+
const workers = Array.from(
|
|
7154
|
+
{ length: Math.min(ENRICH_CONCURRENCY, ids.length) },
|
|
7155
|
+
() => worker()
|
|
7156
|
+
);
|
|
7157
|
+
await Promise.all(workers);
|
|
7158
|
+
return out;
|
|
7159
|
+
}
|
|
7160
|
+
function upgradeCommand(pkg, version) {
|
|
7161
|
+
switch (pkg.ecosystem) {
|
|
7162
|
+
case "npm":
|
|
7163
|
+
return `npm install ${pkg.name}@${version}`;
|
|
7164
|
+
case "PyPI":
|
|
7165
|
+
return `pip install --upgrade "${pkg.name}==${version}"`;
|
|
7166
|
+
case "Go":
|
|
7167
|
+
return `go get ${pkg.name}@v${version}`;
|
|
7168
|
+
case "RubyGems":
|
|
7169
|
+
return `bundle update ${pkg.name}`;
|
|
7170
|
+
default:
|
|
7171
|
+
return `Upgrade ${pkg.name} to ${version}`;
|
|
7172
|
+
}
|
|
7173
|
+
}
|
|
7174
|
+
function buildDependencyFinding(pkg, id, vuln) {
|
|
7175
|
+
const cveAlias = vuln?.aliases?.find((a) => a.startsWith("CVE-"));
|
|
7176
|
+
const severity = vuln ? mapOsvSeverity(vuln) : "medium";
|
|
7177
|
+
const fixedVersion = vuln ? getFixedVersion(vuln, pkg.name) : void 0;
|
|
7178
|
+
const ref = cveAlias ?? id;
|
|
7179
|
+
const summaryText = vuln?.summary ? vuln.summary.replace(/\n/g, " ").trim() : `Known vulnerability in ${pkg.name}`;
|
|
7180
|
+
const humanTitle = summaryText.length > 120 ? summaryText.slice(0, 117) + "..." : summaryText;
|
|
7181
|
+
return {
|
|
7182
|
+
id: `dep-${id}`,
|
|
7183
|
+
ruleId: id,
|
|
7184
|
+
title: humanTitle,
|
|
7185
|
+
description: `${pkg.name}@${pkg.version} \u2014 ${id}${cveAlias ? ` (${cveAlias})` : ""}. ${summaryText}`,
|
|
7186
|
+
plainEnglish: `Your dependency "${pkg.name}" version ${pkg.version} has a known vulnerability (${ref}). ${summaryText}`,
|
|
7187
|
+
severity,
|
|
7188
|
+
confidence: "high",
|
|
7189
|
+
source: "dependency",
|
|
7190
|
+
file: pkg.lockfile,
|
|
7191
|
+
line: 1,
|
|
7192
|
+
snippet: `${pkg.name}@${pkg.version}`,
|
|
7193
|
+
fix: {
|
|
7194
|
+
description: fixedVersion ? `Upgrade ${pkg.name} to version ${fixedVersion} or later.` : `Check ${ref} for remediation steps. Consider upgrading or replacing this dependency.`,
|
|
7195
|
+
suggestion: fixedVersion ? upgradeCommand(pkg, fixedVersion) : void 0
|
|
7196
|
+
},
|
|
7197
|
+
cwe: cveAlias
|
|
7198
|
+
};
|
|
6227
7199
|
}
|
|
6228
7200
|
function mapOsvSeverity(vuln) {
|
|
6229
7201
|
if (vuln.severity) {
|
|
@@ -6259,13 +7231,13 @@ function getFixedVersion(vuln, packageName) {
|
|
|
6259
7231
|
}
|
|
6260
7232
|
function deduplicateFindings(findings) {
|
|
6261
7233
|
const seen = /* @__PURE__ */ new Map();
|
|
6262
|
-
for (const
|
|
6263
|
-
const key = `${
|
|
7234
|
+
for (const finding2 of findings) {
|
|
7235
|
+
const key = `${finding2.file}:${finding2.line}:${finding2.ruleId}`;
|
|
6264
7236
|
const existing = seen.get(key);
|
|
6265
7237
|
if (!existing) {
|
|
6266
|
-
seen.set(key,
|
|
6267
|
-
} else if (SEVERITY_ORDER[
|
|
6268
|
-
seen.set(key,
|
|
7238
|
+
seen.set(key, finding2);
|
|
7239
|
+
} else if (SEVERITY_ORDER[finding2.severity] < SEVERITY_ORDER[existing.severity]) {
|
|
7240
|
+
seen.set(key, finding2);
|
|
6269
7241
|
}
|
|
6270
7242
|
}
|
|
6271
7243
|
return Array.from(seen.values()).sort(
|
|
@@ -6751,15 +7723,15 @@ async function translateFindings(findings, apiKey, platform) {
|
|
|
6751
7723
|
for (const { start, translations } of batchResults) {
|
|
6752
7724
|
const translationMap = new Map(translations.map((t) => [t.id, t]));
|
|
6753
7725
|
for (let j = start; j < Math.min(start + batchSize, translated.length); j++) {
|
|
6754
|
-
const
|
|
6755
|
-
if (!
|
|
6756
|
-
const t = translationMap.get(
|
|
7726
|
+
const finding2 = translated[j];
|
|
7727
|
+
if (!finding2) continue;
|
|
7728
|
+
const t = translationMap.get(finding2.id);
|
|
6757
7729
|
if (t) {
|
|
6758
7730
|
translated[j] = {
|
|
6759
|
-
...
|
|
7731
|
+
...finding2,
|
|
6760
7732
|
plainEnglish: t.plainEnglish,
|
|
6761
7733
|
fix: {
|
|
6762
|
-
...
|
|
7734
|
+
...finding2.fix,
|
|
6763
7735
|
description: t.fixDescription
|
|
6764
7736
|
}
|
|
6765
7737
|
};
|
|
@@ -6822,45 +7794,21 @@ function detectPlatform(files) {
|
|
|
6822
7794
|
if (lower.endsWith("package.json") && content.includes("lovable-tagger")) {
|
|
6823
7795
|
signals.lovable.push("lovable-tagger in package.json");
|
|
6824
7796
|
}
|
|
6825
|
-
if (content.includes("lovable") && /\/\*.*lovable.*\*\//i.test(content)) {
|
|
6826
|
-
signals.lovable.push("lovable comment in source");
|
|
6827
|
-
}
|
|
6828
7797
|
if (lower.includes(".lovable") || lower.includes("lovable.config")) {
|
|
6829
7798
|
signals.lovable.push("lovable config file");
|
|
6830
7799
|
}
|
|
6831
7800
|
if (lower.includes(".bolt/") || lower.includes(".bolt\\")) {
|
|
6832
7801
|
signals.bolt.push(".bolt/ directory detected");
|
|
6833
7802
|
}
|
|
6834
|
-
if (lower.endsWith("package.json") && (content.includes("@bolt") || content.includes("bolt-"))) {
|
|
6835
|
-
signals.bolt.push("bolt dependency in package.json");
|
|
6836
|
-
}
|
|
6837
|
-
if (/\/[/*].*bolt\.new/i.test(content)) {
|
|
6838
|
-
signals.bolt.push("bolt.new reference in source");
|
|
6839
|
-
}
|
|
6840
7803
|
if (lower.endsWith(".cursorrules") || lower.endsWith(".cursorignore")) {
|
|
6841
7804
|
signals.cursor.push(".cursorrules file detected");
|
|
6842
7805
|
}
|
|
6843
|
-
if (/\/[/*].*cursor/i.test(content) && lower.endsWith(".ts")) {
|
|
6844
|
-
signals.cursor.push("cursor reference in source");
|
|
6845
|
-
}
|
|
6846
7806
|
if (lower.includes(".v0/") || lower.includes(".v0\\")) {
|
|
6847
7807
|
signals.v0.push(".v0/ directory detected");
|
|
6848
7808
|
}
|
|
6849
|
-
if (content.includes("v0.dev") || content.includes("@v0/")) {
|
|
6850
|
-
signals.v0.push("v0.dev reference in source");
|
|
6851
|
-
}
|
|
6852
|
-
if (lower.endsWith("package.json") && content.includes('"v0"')) {
|
|
6853
|
-
signals.v0.push("v0 dependency in package.json");
|
|
6854
|
-
}
|
|
6855
7809
|
if (lower.includes("base44.config") || lower.includes(".base44")) {
|
|
6856
7810
|
signals.base44.push("base44 config file");
|
|
6857
7811
|
}
|
|
6858
|
-
if (/from\s+['"]@?base44\b/i.test(content) || /require\s*\(\s*['"]@?base44\b/i.test(content)) {
|
|
6859
|
-
signals.base44.push("base44 SDK import in source");
|
|
6860
|
-
}
|
|
6861
|
-
if (/https?:\/\/[^"'\s]*base44\.app/i.test(content)) {
|
|
6862
|
-
signals.base44.push("base44.app URL in source");
|
|
6863
|
-
}
|
|
6864
7812
|
}
|
|
6865
7813
|
const scores = Object.entries(signals).filter(([key]) => key !== "manual").map(([platform, sigs]) => ({
|
|
6866
7814
|
platform,
|
|
@@ -6893,9 +7841,9 @@ function computeSummary(findings, files) {
|
|
|
6893
7841
|
llm: 0,
|
|
6894
7842
|
dependency: 0
|
|
6895
7843
|
};
|
|
6896
|
-
for (const
|
|
6897
|
-
bySeverity[
|
|
6898
|
-
bySource[
|
|
7844
|
+
for (const finding2 of findings) {
|
|
7845
|
+
bySeverity[finding2.severity]++;
|
|
7846
|
+
bySource[finding2.source]++;
|
|
6899
7847
|
}
|
|
6900
7848
|
return {
|
|
6901
7849
|
total: findings.length,
|
|
@@ -7005,6 +7953,95 @@ async function scan(config, onProgress) {
|
|
|
7005
7953
|
...warnings.length > 0 ? { warnings } : {}
|
|
7006
7954
|
};
|
|
7007
7955
|
}
|
|
7956
|
+
async function fetchStatus(fetchImpl, url, init, timeoutMs) {
|
|
7957
|
+
const controller = new AbortController();
|
|
7958
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
7959
|
+
try {
|
|
7960
|
+
const res = await fetchImpl(url, { ...init, signal: controller.signal });
|
|
7961
|
+
return res.status;
|
|
7962
|
+
} catch {
|
|
7963
|
+
return null;
|
|
7964
|
+
} finally {
|
|
7965
|
+
clearTimeout(timer);
|
|
7966
|
+
}
|
|
7967
|
+
}
|
|
7968
|
+
function classify(status) {
|
|
7969
|
+
if (status === null) return "unverifiable";
|
|
7970
|
+
if (status === 200) return "live";
|
|
7971
|
+
if (status === 401 || status === 403) return "revoked";
|
|
7972
|
+
return "unverifiable";
|
|
7973
|
+
}
|
|
7974
|
+
var PROVIDER_VALIDATORS = [
|
|
7975
|
+
{
|
|
7976
|
+
id: "openai",
|
|
7977
|
+
name: "OpenAI",
|
|
7978
|
+
extract: (t) => t.match(/sk-(?:proj-)?[A-Za-z0-9_-]{20,}/)?.[0] ?? null,
|
|
7979
|
+
check: async (value, f, ms) => classify(
|
|
7980
|
+
await fetchStatus(f, "https://api.openai.com/v1/models", { headers: { Authorization: `Bearer ${value}` } }, ms)
|
|
7981
|
+
)
|
|
7982
|
+
},
|
|
7983
|
+
{
|
|
7984
|
+
id: "stripe",
|
|
7985
|
+
name: "Stripe",
|
|
7986
|
+
extract: (t) => t.match(/(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/)?.[0] ?? null,
|
|
7987
|
+
check: async (value, f, ms) => classify(
|
|
7988
|
+
await fetchStatus(f, "https://api.stripe.com/v1/balance", { headers: { Authorization: `Bearer ${value}` } }, ms)
|
|
7989
|
+
)
|
|
7990
|
+
},
|
|
7991
|
+
{
|
|
7992
|
+
id: "github",
|
|
7993
|
+
name: "GitHub",
|
|
7994
|
+
extract: (t) => t.match(/gh[posru]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,}/)?.[0] ?? null,
|
|
7995
|
+
check: async (value, f, ms) => classify(
|
|
7996
|
+
await fetchStatus(
|
|
7997
|
+
f,
|
|
7998
|
+
"https://api.github.com/user",
|
|
7999
|
+
{ headers: { Authorization: `Bearer ${value}`, "User-Agent": "ShipSafe-Validator" } },
|
|
8000
|
+
ms
|
|
8001
|
+
)
|
|
8002
|
+
)
|
|
8003
|
+
},
|
|
8004
|
+
{
|
|
8005
|
+
id: "sendgrid",
|
|
8006
|
+
name: "SendGrid",
|
|
8007
|
+
extract: (t) => t.match(/SG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}/)?.[0] ?? null,
|
|
8008
|
+
check: async (value, f, ms) => classify(
|
|
8009
|
+
await fetchStatus(f, "https://api.sendgrid.com/v3/scopes", { headers: { Authorization: `Bearer ${value}` } }, ms)
|
|
8010
|
+
)
|
|
8011
|
+
},
|
|
8012
|
+
{
|
|
8013
|
+
id: "gitlab",
|
|
8014
|
+
name: "GitLab",
|
|
8015
|
+
extract: (t) => t.match(/glpat-[A-Za-z0-9_-]{20}/)?.[0] ?? null,
|
|
8016
|
+
check: async (value, f, ms) => classify(await fetchStatus(f, "https://gitlab.com/api/v4/user", { headers: { "PRIVATE-TOKEN": value } }, ms))
|
|
8017
|
+
},
|
|
8018
|
+
{
|
|
8019
|
+
id: "slack",
|
|
8020
|
+
name: "Slack",
|
|
8021
|
+
extract: (t) => t.match(/xox[baprs]-[A-Za-z0-9-]{10,}/)?.[0] ?? null,
|
|
8022
|
+
// auth.test is read-only; Slack returns {ok:true|false} regardless of HTTP 200.
|
|
8023
|
+
check: async (value, f, ms) => {
|
|
8024
|
+
const controller = new AbortController();
|
|
8025
|
+
const timer = setTimeout(() => controller.abort(), ms);
|
|
8026
|
+
try {
|
|
8027
|
+
const res = await f("https://slack.com/api/auth.test", {
|
|
8028
|
+
method: "POST",
|
|
8029
|
+
headers: { Authorization: `Bearer ${value}` },
|
|
8030
|
+
signal: controller.signal
|
|
8031
|
+
});
|
|
8032
|
+
const body = await res.json().catch(() => null);
|
|
8033
|
+
if (body?.ok === true) return "live";
|
|
8034
|
+
if (body?.ok === false) return "revoked";
|
|
8035
|
+
return "unverifiable";
|
|
8036
|
+
} catch {
|
|
8037
|
+
return "unverifiable";
|
|
8038
|
+
} finally {
|
|
8039
|
+
clearTimeout(timer);
|
|
8040
|
+
}
|
|
8041
|
+
}
|
|
8042
|
+
}
|
|
8043
|
+
];
|
|
8044
|
+
var VALIDATOR_BY_ID = new Map(PROVIDER_VALIDATORS.map((v) => [v.id, v]));
|
|
7008
8045
|
|
|
7009
8046
|
// src/lib/file-collector.ts
|
|
7010
8047
|
import { readdirSync, readFileSync, statSync, existsSync } from "fs";
|
|
@@ -7424,23 +8461,22 @@ async function loginCommand(options) {
|
|
|
7424
8461
|
const profile = await fetchProfile(apiUrl, pollData.token);
|
|
7425
8462
|
if (profile) {
|
|
7426
8463
|
tokenData.tier = profile.tier;
|
|
7427
|
-
tokenData.cliTier = profile.cliTier;
|
|
7428
8464
|
tokenData.aiQuota = profile.aiQuota;
|
|
7429
8465
|
}
|
|
7430
8466
|
storeToken(tokenData);
|
|
7431
8467
|
pollSpinner.succeed(
|
|
7432
8468
|
chalk3.green(`Logged in as ${chalk3.bold(pollData.email)}`)
|
|
7433
8469
|
);
|
|
7434
|
-
if (profile?.
|
|
8470
|
+
if (profile?.tier && profile.tier !== "free") {
|
|
7435
8471
|
console.log(
|
|
7436
8472
|
chalk3.cyan(
|
|
7437
|
-
`
|
|
8473
|
+
` Plan: ${profile.tier} (${profile.aiQuota.remaining} AI scans remaining)`
|
|
7438
8474
|
)
|
|
7439
8475
|
);
|
|
7440
8476
|
} else {
|
|
7441
8477
|
console.log(
|
|
7442
8478
|
chalk3.dim(
|
|
7443
|
-
"
|
|
8479
|
+
" Free plan. Upgrade at ship-safe.co/pricing for AI-powered scanning."
|
|
7444
8480
|
)
|
|
7445
8481
|
);
|
|
7446
8482
|
}
|
|
@@ -7514,20 +8550,17 @@ async function whoamiCommand(options) {
|
|
|
7514
8550
|
storeToken({
|
|
7515
8551
|
...token,
|
|
7516
8552
|
tier: profile.tier,
|
|
7517
|
-
cliTier: profile.cliTier,
|
|
7518
8553
|
aiQuota: profile.aiQuota
|
|
7519
8554
|
});
|
|
7520
8555
|
spinner.stop();
|
|
7521
8556
|
console.log(chalk3.bold("\nShipSafe Account\n"));
|
|
7522
|
-
console.log(` Email:
|
|
7523
|
-
console.log(`
|
|
7524
|
-
if (profile.
|
|
7525
|
-
console.log(` CLI plan: ${chalk3.green(profile.cliTier)}`);
|
|
8557
|
+
console.log(` Email: ${chalk3.cyan(profile.email)}`);
|
|
8558
|
+
console.log(` Plan: ${chalk3.cyan(profile.tier)}`);
|
|
8559
|
+
if (profile.tier && profile.tier !== "free") {
|
|
7526
8560
|
console.log(
|
|
7527
8561
|
` AI scans: ${chalk3.cyan(`${profile.aiQuota.used}/${profile.aiQuota.limit}`)} used this month (${chalk3.green(`${profile.aiQuota.remaining}`)} remaining)`
|
|
7528
8562
|
);
|
|
7529
8563
|
} else {
|
|
7530
|
-
console.log(` CLI plan: ${chalk3.dim("none")}`);
|
|
7531
8564
|
console.log(
|
|
7532
8565
|
chalk3.dim(" Upgrade at ship-safe.co/pricing for AI-powered scanning.")
|
|
7533
8566
|
);
|
|
@@ -7536,8 +8569,8 @@ async function whoamiCommand(options) {
|
|
|
7536
8569
|
} else {
|
|
7537
8570
|
spinner.stop();
|
|
7538
8571
|
console.log(chalk3.green(`Logged in as ${chalk3.bold(token.email)}`));
|
|
7539
|
-
if (token.
|
|
7540
|
-
console.log(chalk3.cyan(`
|
|
8572
|
+
if (token.tier) {
|
|
8573
|
+
console.log(chalk3.cyan(` Plan: ${token.tier}`));
|
|
7541
8574
|
}
|
|
7542
8575
|
}
|
|
7543
8576
|
}
|
|
@@ -7771,7 +8804,7 @@ async function scanCommand(targetPath, options) {
|
|
|
7771
8804
|
spinner.succeed(
|
|
7772
8805
|
chalk5.green(`Scan complete \u2014 ${result.findings.length} findings in ${result.durationMs}ms`)
|
|
7773
8806
|
);
|
|
7774
|
-
if (tokenData
|
|
8807
|
+
if (tokenData && isPaidTier(tokenData.tier)) {
|
|
7775
8808
|
const aiSpinner = ora2({
|
|
7776
8809
|
text: chalk5.dim("Running AI-powered deep analysis... (this may take 1-3 minutes)"),
|
|
7777
8810
|
color: "yellow",
|
|
@@ -7905,6 +8938,12 @@ async function scanCommand(targetPath, options) {
|
|
|
7905
8938
|
const diff = diffWithPrevious(resolvedPath, result.findings);
|
|
7906
8939
|
saveScanHistory(resolvedPath, result.findings);
|
|
7907
8940
|
const isLoggedIn = !!getStoredToken();
|
|
8941
|
+
if (options.sarifFile) {
|
|
8942
|
+
writeFileSync4(options.sarifFile, formatSarifOutput(result), "utf-8");
|
|
8943
|
+
}
|
|
8944
|
+
if (options.jsonFile) {
|
|
8945
|
+
writeFileSync4(options.jsonFile, formatJsonOutput(result), "utf-8");
|
|
8946
|
+
}
|
|
7908
8947
|
switch (options.output) {
|
|
7909
8948
|
case "json":
|
|
7910
8949
|
console.log(formatJsonOutput(result));
|
|
@@ -8133,21 +9172,67 @@ function initCommand() {
|
|
|
8133
9172
|
console.log(chalk6.green("Created .shipsafe.yml configuration file."));
|
|
8134
9173
|
}
|
|
8135
9174
|
|
|
9175
|
+
// src/commands/false-positive.ts
|
|
9176
|
+
import { readFileSync as readFileSync6, existsSync as existsSync7 } from "fs";
|
|
9177
|
+
import { homedir as homedir4 } from "os";
|
|
9178
|
+
import { join as join5 } from "path";
|
|
9179
|
+
import chalk7 from "chalk";
|
|
9180
|
+
var TOKEN_FILE2 = join5(homedir4(), ".shipsafe", "token.json");
|
|
9181
|
+
async function falsePositiveCommand(ruleId, options) {
|
|
9182
|
+
if (!existsSync7(TOKEN_FILE2)) {
|
|
9183
|
+
console.error(chalk7.red("Not logged in. Run `shipsafe login` first."));
|
|
9184
|
+
process.exitCode = 1;
|
|
9185
|
+
return;
|
|
9186
|
+
}
|
|
9187
|
+
let token;
|
|
9188
|
+
try {
|
|
9189
|
+
token = JSON.parse(readFileSync6(TOKEN_FILE2, "utf-8")).token;
|
|
9190
|
+
if (!token) throw new Error("no token");
|
|
9191
|
+
} catch {
|
|
9192
|
+
console.error(chalk7.red("Couldn't read your login token. Run `shipsafe login` again."));
|
|
9193
|
+
process.exitCode = 1;
|
|
9194
|
+
return;
|
|
9195
|
+
}
|
|
9196
|
+
try {
|
|
9197
|
+
const res = await fetch(`${options.apiUrl}/api/cli/false-positive`, {
|
|
9198
|
+
method: "POST",
|
|
9199
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
9200
|
+
body: JSON.stringify({
|
|
9201
|
+
ruleId,
|
|
9202
|
+
filePath: options.file,
|
|
9203
|
+
lineNumber: options.line ? parseInt(options.line, 10) : void 0,
|
|
9204
|
+
reason: options.reason,
|
|
9205
|
+
surface: "cli"
|
|
9206
|
+
})
|
|
9207
|
+
});
|
|
9208
|
+
if (!res.ok) {
|
|
9209
|
+
const msg = await res.json().then((b) => b?.error).catch(() => null);
|
|
9210
|
+
console.error(chalk7.red(`Couldn't report: ${msg || `HTTP ${res.status}`}`));
|
|
9211
|
+
process.exitCode = 1;
|
|
9212
|
+
return;
|
|
9213
|
+
}
|
|
9214
|
+
console.log(chalk7.green(`\u2713 Reported ${ruleId} as a false positive. Thanks \u2014 this helps ShipSafe tune the rule.`));
|
|
9215
|
+
} catch (e) {
|
|
9216
|
+
console.error(chalk7.red(`Couldn't reach ShipSafe: ${e instanceof Error ? e.message : "network error"}`));
|
|
9217
|
+
process.exitCode = 1;
|
|
9218
|
+
}
|
|
9219
|
+
}
|
|
9220
|
+
|
|
8136
9221
|
// src/index.ts
|
|
8137
9222
|
function getVersion() {
|
|
8138
9223
|
try {
|
|
8139
9224
|
let dir = dirname(fileURLToPath(import.meta.url));
|
|
8140
9225
|
for (let i = 0; i < 5; i++) {
|
|
8141
9226
|
try {
|
|
8142
|
-
const pkg = JSON.parse(
|
|
8143
|
-
if (pkg.
|
|
9227
|
+
const pkg = JSON.parse(readFileSync7(join6(dir, "package.json"), "utf-8"));
|
|
9228
|
+
if (pkg.version) return pkg.version;
|
|
8144
9229
|
} catch {
|
|
8145
9230
|
}
|
|
8146
9231
|
dir = dirname(dir);
|
|
8147
9232
|
}
|
|
8148
9233
|
} catch {
|
|
8149
9234
|
}
|
|
8150
|
-
return "
|
|
9235
|
+
return "0.0.0-dev";
|
|
8151
9236
|
}
|
|
8152
9237
|
function validateApiUrl(value) {
|
|
8153
9238
|
let parsed;
|
|
@@ -8167,7 +9252,7 @@ program.command("scan").description("Scan a directory or file for security vulne
|
|
|
8167
9252
|
new Option("-o, --output <format>", "Output format: table, json, sarif").choices(["table", "json", "sarif"]).default("table")
|
|
8168
9253
|
).addOption(
|
|
8169
9254
|
new Option("-s, --severity <level>", "Minimum severity: critical, high, medium, low").choices(["critical", "high", "medium", "low"]).default("low")
|
|
8170
|
-
).option("--ci", "CI mode: exit code 1 on high/critical findings", false).option("--upload", "Upload results to your ShipSafe dashboard", false).option(
|
|
9255
|
+
).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(
|
|
8171
9256
|
"--api-url <url>",
|
|
8172
9257
|
"API URL for ShipSafe server",
|
|
8173
9258
|
validateApiUrl,
|
|
@@ -8189,4 +9274,10 @@ program.command("whoami").description("Show current login status and plan info")
|
|
|
8189
9274
|
).action(whoamiCommand);
|
|
8190
9275
|
program.command("ignore").description("Suppress a rule in future scans").argument("<rule-id>", "Rule ID to ignore (e.g. secrets/generic-api-key)").option("-r, --reason <reason>", "Why this rule is being suppressed").action(ignoreCommand);
|
|
8191
9276
|
program.command("unignore").description("Re-enable a previously suppressed rule").argument("<rule-id>", "Rule ID to unignore").action(unignoreCommand);
|
|
9277
|
+
program.command("false-positive").description("Report a finding as a false positive to ShipSafe (helps tune the rule)").argument("<rule-id>", "Rule ID to report (e.g. secrets/generic-api-key)").option("-f, --file <path>", "File the finding was reported on").option("-l, --line <number>", "Line number of the finding").option("-r, --reason <reason>", "Why it's a false positive").option(
|
|
9278
|
+
"--api-url <url>",
|
|
9279
|
+
"API URL for ShipSafe server",
|
|
9280
|
+
validateApiUrl,
|
|
9281
|
+
"https://ship-safe.co"
|
|
9282
|
+
).action(falsePositiveCommand);
|
|
8192
9283
|
program.parse();
|