ship-safe 4.1.0 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -16
- package/cli/__tests__/agents.test.js +722 -0
- package/cli/agents/api-fuzzer.js +345 -224
- package/cli/agents/auth-bypass-agent.js +348 -326
- package/cli/agents/base-agent.js +262 -253
- package/cli/agents/cicd-scanner.js +201 -200
- package/cli/agents/config-auditor.js +529 -413
- package/cli/agents/git-history-scanner.js +170 -167
- package/cli/agents/html-reporter.js +370 -363
- package/cli/agents/index.js +59 -56
- package/cli/agents/injection-tester.js +455 -401
- package/cli/agents/llm-redteam.js +251 -251
- package/cli/agents/mobile-scanner.js +225 -225
- package/cli/agents/orchestrator.js +263 -157
- package/cli/agents/scoring-engine.js +225 -207
- package/cli/agents/supabase-rls-agent.js +148 -0
- package/cli/agents/supply-chain-agent.js +356 -274
- package/cli/bin/ship-safe.js +29 -1
- package/cli/commands/audit.js +875 -620
- package/cli/commands/baseline.js +192 -0
- package/cli/commands/doctor.js +149 -0
- package/cli/commands/remediate.js +7 -3
- package/cli/index.js +60 -53
- package/cli/providers/llm-provider.js +287 -288
- package/cli/utils/autofix-rules.js +74 -0
- package/cli/utils/cache-manager.js +311 -258
- package/cli/utils/pdf-generator.js +94 -0
- package/package.json +2 -2
|
@@ -1,207 +1,225 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Enhanced Scoring Engine
|
|
3
|
-
* ========================
|
|
4
|
-
*
|
|
5
|
-
* Risk-based scoring with 8 categories, EPSS integration,
|
|
6
|
-
* KEV flagging, and historical trend tracking.
|
|
7
|
-
*
|
|
8
|
-
* Score = 100 - sum(category deductions)
|
|
9
|
-
* Each category has a weight and max deduction cap.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import fs from 'fs';
|
|
13
|
-
import path from 'path';
|
|
14
|
-
|
|
15
|
-
// =============================================================================
|
|
16
|
-
// SCORING CONFIGURATION
|
|
17
|
-
// =============================================================================
|
|
18
|
-
|
|
19
|
-
const CATEGORIES = {
|
|
20
|
-
secrets: { weight: 15, label: 'Secrets', deductions: { critical: 25, high: 15, medium: 5 } },
|
|
21
|
-
injection: { weight: 15, label: 'Code Vulnerabilities', deductions: { critical: 20, high: 10, medium: 3 } },
|
|
22
|
-
deps: { weight: 15, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
|
|
23
|
-
auth: { weight: 15, label: 'Auth & Access Control', deductions: { critical: 20, high: 10, medium: 3 } },
|
|
24
|
-
config: { weight: 10, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
25
|
-
'supply-chain':{ weight: 10, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
26
|
-
api: { weight: 10, label: 'API Security', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
27
|
-
llm: { weight: 10, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
// Fallback categories for findings that don't match a known category
|
|
31
|
-
const FALLBACK_CATEGORY_MAP = {
|
|
32
|
-
'secret': 'secrets',
|
|
33
|
-
'vulnerability': 'injection',
|
|
34
|
-
'ssrf': 'injection',
|
|
35
|
-
'history': 'secrets',
|
|
36
|
-
'cicd': 'config',
|
|
37
|
-
'mobile': 'injection',
|
|
38
|
-
'recon': null, // skip recon findings
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
const GRADES = [
|
|
42
|
-
{ min: 90, letter: 'A', label: 'Ship it!', color: 'green' },
|
|
43
|
-
{ min: 75, letter: 'B', label: 'Minor issues to review', color: 'cyan' },
|
|
44
|
-
{ min: 60, letter: 'C', label: 'Fix before shipping', color: 'yellow' },
|
|
45
|
-
{ min: 40, letter: 'D', label: 'Significant security risks', color: 'red' },
|
|
46
|
-
{ min: 0, letter: 'F', label: 'Not safe to ship', color: 'red' },
|
|
47
|
-
];
|
|
48
|
-
|
|
49
|
-
// =============================================================================
|
|
50
|
-
// SCORING ENGINE
|
|
51
|
-
// =============================================================================
|
|
52
|
-
|
|
53
|
-
export class ScoringEngine {
|
|
54
|
-
/**
|
|
55
|
-
* Compute the security score from agent findings + dependency vulnerabilities.
|
|
56
|
-
*
|
|
57
|
-
* @param {object[]} findings — Array of finding objects from agents
|
|
58
|
-
* @param {object[]} depVulns — Array of dependency CVE objects
|
|
59
|
-
* @returns {object} — { score, grade, categories, breakdown }
|
|
60
|
-
*/
|
|
61
|
-
compute(findings = [], depVulns = []) {
|
|
62
|
-
const categoryResults = {};
|
|
63
|
-
|
|
64
|
-
// Initialize all categories
|
|
65
|
-
for (const [key, config] of Object.entries(CATEGORIES)) {
|
|
66
|
-
categoryResults[key] = {
|
|
67
|
-
label: config.label,
|
|
68
|
-
weight: config.weight,
|
|
69
|
-
counts: { critical: 0, high: 0, medium: 0, low: 0 },
|
|
70
|
-
deduction: 0,
|
|
71
|
-
maxDeduction: config.weight, // Cap at category weight
|
|
72
|
-
findings: [],
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// ── Classify findings into categories ─────────────────────────────────────
|
|
77
|
-
for (const finding of findings) {
|
|
78
|
-
const cat = this.resolveCategory(finding.category);
|
|
79
|
-
if (!cat || !categoryResults[cat]) continue;
|
|
80
|
-
|
|
81
|
-
const sev = finding.severity || 'medium';
|
|
82
|
-
categoryResults[cat].counts[sev] = (categoryResults[cat].counts[sev] || 0) + 1;
|
|
83
|
-
categoryResults[cat].findings.push(finding);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// ── Add dependency vulnerabilities ────────────────────────────────────────
|
|
87
|
-
for (const vuln of depVulns) {
|
|
88
|
-
const sev = vuln.severity || 'medium';
|
|
89
|
-
categoryResults.deps.counts[sev] = (categoryResults.deps.counts[sev] || 0) + 1;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Compute deductions per category
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if (
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Enhanced Scoring Engine
|
|
3
|
+
* ========================
|
|
4
|
+
*
|
|
5
|
+
* Risk-based scoring with 8 categories, EPSS integration,
|
|
6
|
+
* KEV flagging, and historical trend tracking.
|
|
7
|
+
*
|
|
8
|
+
* Score = 100 - sum(category deductions)
|
|
9
|
+
* Each category has a weight and max deduction cap.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import fs from 'fs';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
|
|
15
|
+
// =============================================================================
|
|
16
|
+
// SCORING CONFIGURATION
|
|
17
|
+
// =============================================================================
|
|
18
|
+
|
|
19
|
+
const CATEGORIES = {
|
|
20
|
+
secrets: { weight: 15, label: 'Secrets', deductions: { critical: 25, high: 15, medium: 5 } },
|
|
21
|
+
injection: { weight: 15, label: 'Code Vulnerabilities', deductions: { critical: 20, high: 10, medium: 3 } },
|
|
22
|
+
deps: { weight: 15, label: 'Dependencies', deductions: { critical: 20, high: 10, medium: 5, moderate: 5 } },
|
|
23
|
+
auth: { weight: 15, label: 'Auth & Access Control', deductions: { critical: 20, high: 10, medium: 3 } },
|
|
24
|
+
config: { weight: 10, label: 'Configuration', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
25
|
+
'supply-chain':{ weight: 10, label: 'Supply Chain', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
26
|
+
api: { weight: 10, label: 'API Security', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
27
|
+
llm: { weight: 10, label: 'AI/LLM Security', deductions: { critical: 15, high: 8, medium: 3 } },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Fallback categories for findings that don't match a known category
|
|
31
|
+
const FALLBACK_CATEGORY_MAP = {
|
|
32
|
+
'secret': 'secrets',
|
|
33
|
+
'vulnerability': 'injection',
|
|
34
|
+
'ssrf': 'injection',
|
|
35
|
+
'history': 'secrets',
|
|
36
|
+
'cicd': 'config',
|
|
37
|
+
'mobile': 'injection',
|
|
38
|
+
'recon': null, // skip recon findings
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const GRADES = [
|
|
42
|
+
{ min: 90, letter: 'A', label: 'Ship it!', color: 'green' },
|
|
43
|
+
{ min: 75, letter: 'B', label: 'Minor issues to review', color: 'cyan' },
|
|
44
|
+
{ min: 60, letter: 'C', label: 'Fix before shipping', color: 'yellow' },
|
|
45
|
+
{ min: 40, letter: 'D', label: 'Significant security risks', color: 'red' },
|
|
46
|
+
{ min: 0, letter: 'F', label: 'Not safe to ship', color: 'red' },
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// =============================================================================
|
|
50
|
+
// SCORING ENGINE
|
|
51
|
+
// =============================================================================
|
|
52
|
+
|
|
53
|
+
export class ScoringEngine {
|
|
54
|
+
/**
|
|
55
|
+
* Compute the security score from agent findings + dependency vulnerabilities.
|
|
56
|
+
*
|
|
57
|
+
* @param {object[]} findings — Array of finding objects from agents
|
|
58
|
+
* @param {object[]} depVulns — Array of dependency CVE objects
|
|
59
|
+
* @returns {object} — { score, grade, categories, breakdown }
|
|
60
|
+
*/
|
|
61
|
+
compute(findings = [], depVulns = []) {
|
|
62
|
+
const categoryResults = {};
|
|
63
|
+
|
|
64
|
+
// Initialize all categories
|
|
65
|
+
for (const [key, config] of Object.entries(CATEGORIES)) {
|
|
66
|
+
categoryResults[key] = {
|
|
67
|
+
label: config.label,
|
|
68
|
+
weight: config.weight,
|
|
69
|
+
counts: { critical: 0, high: 0, medium: 0, low: 0 },
|
|
70
|
+
deduction: 0,
|
|
71
|
+
maxDeduction: config.weight, // Cap at category weight
|
|
72
|
+
findings: [],
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Classify findings into categories ─────────────────────────────────────
|
|
77
|
+
for (const finding of findings) {
|
|
78
|
+
const cat = this.resolveCategory(finding.category);
|
|
79
|
+
if (!cat || !categoryResults[cat]) continue;
|
|
80
|
+
|
|
81
|
+
const sev = finding.severity || 'medium';
|
|
82
|
+
categoryResults[cat].counts[sev] = (categoryResults[cat].counts[sev] || 0) + 1;
|
|
83
|
+
categoryResults[cat].findings.push(finding);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Add dependency vulnerabilities ────────────────────────────────────────
|
|
87
|
+
for (const vuln of depVulns) {
|
|
88
|
+
const sev = vuln.severity || 'medium';
|
|
89
|
+
categoryResults.deps.counts[sev] = (categoryResults.deps.counts[sev] || 0) + 1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Compute deductions per category (confidence-weighted) ─────────────────
|
|
93
|
+
const CONFIDENCE_MULTIPLIER = { high: 1.0, medium: 0.6, low: 0.3 };
|
|
94
|
+
|
|
95
|
+
for (const [key, config] of Object.entries(CATEGORIES)) {
|
|
96
|
+
const result = categoryResults[key];
|
|
97
|
+
let deduction = 0;
|
|
98
|
+
|
|
99
|
+
// Count-based deductions for deps (no per-finding confidence)
|
|
100
|
+
for (const [sev, pts] of Object.entries(config.deductions)) {
|
|
101
|
+
if (key === 'deps') {
|
|
102
|
+
deduction += (result.counts[sev] || 0) * pts;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Per-finding confidence-weighted deductions for agent findings
|
|
107
|
+
if (key !== 'deps') {
|
|
108
|
+
for (const finding of result.findings) {
|
|
109
|
+
const sev = finding.severity || 'medium';
|
|
110
|
+
const pts = config.deductions[sev] || 0;
|
|
111
|
+
const confidence = finding.confidence || 'high';
|
|
112
|
+
const multiplier = CONFIDENCE_MULTIPLIER[confidence] || 1.0;
|
|
113
|
+
deduction += pts * multiplier;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
result.deduction = Math.min(deduction, result.maxDeduction);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Compute total score ───────────────────────────────────────────────────
|
|
121
|
+
const totalDeduction = Object.values(categoryResults).reduce(
|
|
122
|
+
(sum, r) => sum + r.deduction, 0
|
|
123
|
+
);
|
|
124
|
+
const score = Math.max(0, 100 - totalDeduction);
|
|
125
|
+
const grade = GRADES.find(g => score >= g.min);
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
score,
|
|
129
|
+
grade,
|
|
130
|
+
categories: categoryResults,
|
|
131
|
+
totalFindings: findings.length,
|
|
132
|
+
totalDepVulns: depVulns.length,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Map a finding category to a scoring category.
|
|
138
|
+
*/
|
|
139
|
+
resolveCategory(findingCategory) {
|
|
140
|
+
if (CATEGORIES[findingCategory]) return findingCategory;
|
|
141
|
+
if (FALLBACK_CATEGORY_MAP[findingCategory] !== undefined) {
|
|
142
|
+
return FALLBACK_CATEGORY_MAP[findingCategory];
|
|
143
|
+
}
|
|
144
|
+
return 'injection'; // default fallback
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Save score to history file for trend tracking.
|
|
149
|
+
*/
|
|
150
|
+
saveToHistory(rootPath, scoreResult, suppressions = null) {
|
|
151
|
+
const historyDir = path.join(rootPath, '.ship-safe');
|
|
152
|
+
const historyFile = path.join(historyDir, 'history.json');
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
if (!fs.existsSync(historyDir)) {
|
|
156
|
+
fs.mkdirSync(historyDir, { recursive: true });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
let history = [];
|
|
160
|
+
if (fs.existsSync(historyFile)) {
|
|
161
|
+
try {
|
|
162
|
+
history = JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
163
|
+
} catch { history = []; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const entry = {
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
score: scoreResult.score,
|
|
169
|
+
grade: scoreResult.grade.letter,
|
|
170
|
+
totalFindings: scoreResult.totalFindings,
|
|
171
|
+
totalDepVulns: scoreResult.totalDepVulns,
|
|
172
|
+
categoryScores: Object.fromEntries(
|
|
173
|
+
Object.entries(scoreResult.categories).map(([k, v]) => [k, {
|
|
174
|
+
deduction: v.deduction,
|
|
175
|
+
counts: v.counts,
|
|
176
|
+
}])
|
|
177
|
+
),
|
|
178
|
+
};
|
|
179
|
+
if (suppressions) entry.suppressions = suppressions;
|
|
180
|
+
history.push(entry);
|
|
181
|
+
|
|
182
|
+
// Keep last 100 entries
|
|
183
|
+
if (history.length > 100) history = history.slice(-100);
|
|
184
|
+
|
|
185
|
+
fs.writeFileSync(historyFile, JSON.stringify(history, null, 2));
|
|
186
|
+
} catch {
|
|
187
|
+
// Don't fail if history save fails
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Load score history for trend display.
|
|
193
|
+
*/
|
|
194
|
+
loadHistory(rootPath) {
|
|
195
|
+
const historyFile = path.join(rootPath, '.ship-safe', 'history.json');
|
|
196
|
+
try {
|
|
197
|
+
if (fs.existsSync(historyFile)) {
|
|
198
|
+
return JSON.parse(fs.readFileSync(historyFile, 'utf-8'));
|
|
199
|
+
}
|
|
200
|
+
} catch { /* ignore */ }
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Get trend summary comparing current to last scan.
|
|
206
|
+
*/
|
|
207
|
+
getTrend(rootPath, currentScore) {
|
|
208
|
+
const history = this.loadHistory(rootPath);
|
|
209
|
+
if (history.length < 2) return null;
|
|
210
|
+
|
|
211
|
+
const previous = history[history.length - 2];
|
|
212
|
+
const diff = currentScore - previous.score;
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
previousScore: previous.score,
|
|
216
|
+
currentScore,
|
|
217
|
+
diff,
|
|
218
|
+
direction: diff > 0 ? 'improved' : diff < 0 ? 'regressed' : 'unchanged',
|
|
219
|
+
previousDate: previous.timestamp,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export { GRADES, CATEGORIES };
|
|
225
|
+
export default ScoringEngine;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SupabaseRLSAgent
|
|
3
|
+
* =================
|
|
4
|
+
*
|
|
5
|
+
* Detects missing or weak Row Level Security (RLS) in Supabase projects.
|
|
6
|
+
* Checks SQL migrations, client-side service_role key usage,
|
|
7
|
+
* unprotected storage operations, and anon-key data mutations.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import { BaseAgent, createFinding } from './base-agent.js';
|
|
13
|
+
|
|
14
|
+
// Patterns for client-side code
|
|
15
|
+
const CLIENT_PATTERNS = [
|
|
16
|
+
{
|
|
17
|
+
rule: 'SUPABASE_SERVICE_KEY_CLIENT',
|
|
18
|
+
title: 'Supabase: Service Role Key in Client Code',
|
|
19
|
+
regex: /SUPABASE_SERVICE_ROLE_KEY|service_role_key|serviceRoleKey|supabaseAdmin/g,
|
|
20
|
+
severity: 'critical',
|
|
21
|
+
cwe: 'CWE-798',
|
|
22
|
+
owasp: 'A07:2021',
|
|
23
|
+
description: 'Service role key bypasses RLS entirely. Never expose it in client-side code.',
|
|
24
|
+
fix: 'Use the anon key on the client. Move service_role operations to a backend/edge function.',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
rule: 'SUPABASE_RLS_DISABLED',
|
|
28
|
+
title: 'Supabase: RLS Bypass via .rpc() or Admin Client',
|
|
29
|
+
regex: /\.rpc\s*\(\s*['"][^'"]+['"]/g,
|
|
30
|
+
severity: 'high',
|
|
31
|
+
cwe: 'CWE-284',
|
|
32
|
+
owasp: 'A01:2021',
|
|
33
|
+
confidence: 'medium',
|
|
34
|
+
description: 'Supabase .rpc() calls execute database functions that may bypass RLS policies.',
|
|
35
|
+
fix: 'Ensure the underlying SQL function uses SECURITY DEFINER carefully, or set search_path.',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
rule: 'SUPABASE_PUBLIC_ANON_INSERT',
|
|
39
|
+
title: 'Supabase: Unguarded Insert/Update/Delete',
|
|
40
|
+
regex: /supabase\s*\.from\s*\(\s*['"][^'"]+['"]\s*\)\s*\.(?:insert|update|delete|upsert)\s*\(/g,
|
|
41
|
+
severity: 'high',
|
|
42
|
+
cwe: 'CWE-284',
|
|
43
|
+
owasp: 'A01:2021',
|
|
44
|
+
confidence: 'medium',
|
|
45
|
+
description: 'Supabase data mutation without visible auth check. Ensure RLS policies protect this table.',
|
|
46
|
+
fix: 'Verify RLS is enabled on the table and policies restrict mutations to authenticated users.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
rule: 'SUPABASE_UNPROTECTED_STORAGE',
|
|
50
|
+
title: 'Supabase: Storage Operation Without Auth',
|
|
51
|
+
regex: /supabase\s*\.storage\s*\.from\s*\(\s*['"][^'"]+['"]\s*\)\s*\.(?:upload|remove|move|createSignedUrl|list)\s*\(/g,
|
|
52
|
+
severity: 'medium',
|
|
53
|
+
cwe: 'CWE-284',
|
|
54
|
+
owasp: 'A01:2021',
|
|
55
|
+
confidence: 'medium',
|
|
56
|
+
description: 'Supabase storage operation detected. Ensure storage policies restrict access.',
|
|
57
|
+
fix: 'Configure storage bucket policies to require authentication.',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Client-side directories (findings here are more severe)
|
|
62
|
+
const CLIENT_DIRS = /(?:^|[/\\])(?:src|pages|app|components|hooks|lib|utils)[/\\]/i;
|
|
63
|
+
|
|
64
|
+
export class SupabaseRLSAgent extends BaseAgent {
|
|
65
|
+
constructor() {
|
|
66
|
+
super('SupabaseRLSAgent', 'Supabase Row Level Security audit', 'auth');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async analyze(context) {
|
|
70
|
+
const { rootPath, files } = context;
|
|
71
|
+
let findings = [];
|
|
72
|
+
|
|
73
|
+
// ── 1. Scan client-side code for Supabase security issues ─────────────────
|
|
74
|
+
const codeFiles = files.filter(f => {
|
|
75
|
+
const ext = path.extname(f).toLowerCase();
|
|
76
|
+
return ['.js', '.jsx', '.ts', '.tsx', '.mjs', '.vue', '.svelte'].includes(ext);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
for (const file of codeFiles) {
|
|
80
|
+
const fileFindings = this.scanFileWithPatterns(file, CLIENT_PATTERNS);
|
|
81
|
+
// Elevate severity for findings in client-side directories
|
|
82
|
+
const relPath = path.relative(rootPath, file).replace(/\\/g, '/');
|
|
83
|
+
if (CLIENT_DIRS.test(relPath)) {
|
|
84
|
+
for (const f of fileFindings) {
|
|
85
|
+
if (f.rule === 'SUPABASE_SERVICE_KEY_CLIENT') {
|
|
86
|
+
f.severity = 'critical';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
findings = findings.concat(fileFindings);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── 2. Scan SQL migrations for missing RLS ────────────────────────────────
|
|
94
|
+
const sqlFiles = files.filter(f => path.extname(f).toLowerCase() === '.sql');
|
|
95
|
+
const tablesWithRLS = new Set();
|
|
96
|
+
const tablesWithoutRLS = [];
|
|
97
|
+
|
|
98
|
+
for (const file of sqlFiles) {
|
|
99
|
+
const content = this.readFile(file);
|
|
100
|
+
if (!content) continue;
|
|
101
|
+
|
|
102
|
+
// Find tables that have RLS enabled
|
|
103
|
+
const rlsMatches = content.matchAll(/ALTER\s+TABLE\s+(?:(?:public|auth|storage)\.)?["']?(\w+)["']?\s+ENABLE\s+ROW\s+LEVEL\s+SECURITY/gi);
|
|
104
|
+
for (const m of rlsMatches) {
|
|
105
|
+
tablesWithRLS.add(m[1].toLowerCase());
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Find CREATE TABLE statements
|
|
109
|
+
const createMatches = content.matchAll(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:(?:public|auth|storage)\.)?["']?(\w+)["']?/gi);
|
|
110
|
+
for (const m of createMatches) {
|
|
111
|
+
const tableName = m[1].toLowerCase();
|
|
112
|
+
// Skip Supabase internal tables
|
|
113
|
+
if (['_prisma_migrations', 'schema_migrations', 'knex_migrations'].includes(tableName)) continue;
|
|
114
|
+
|
|
115
|
+
// Check if RLS is enabled in the same file
|
|
116
|
+
const rlsInFile = new RegExp(
|
|
117
|
+
`ALTER\\s+TABLE\\s+(?:(?:public|auth|storage)\\.)?["']?${tableName}["']?\\s+ENABLE\\s+ROW\\s+LEVEL\\s+SECURITY`,
|
|
118
|
+
'gi'
|
|
119
|
+
).test(content);
|
|
120
|
+
|
|
121
|
+
if (!rlsInFile && !tablesWithRLS.has(tableName)) {
|
|
122
|
+
tablesWithoutRLS.push({ table: tableName, file });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Report tables missing RLS
|
|
128
|
+
for (const { table, file } of tablesWithoutRLS) {
|
|
129
|
+
// Double-check across all SQL files
|
|
130
|
+
if (tablesWithRLS.has(table)) continue;
|
|
131
|
+
findings.push(createFinding({
|
|
132
|
+
file,
|
|
133
|
+
line: 0,
|
|
134
|
+
severity: 'critical',
|
|
135
|
+
category: 'auth',
|
|
136
|
+
rule: 'SUPABASE_NO_RLS_POLICY',
|
|
137
|
+
title: `Supabase: Table "${table}" Missing RLS`,
|
|
138
|
+
description: `Table "${table}" is created without enabling Row Level Security. Any user with the anon key can read/write all rows.`,
|
|
139
|
+
matched: `CREATE TABLE ${table}`,
|
|
140
|
+
fix: `Add: ALTER TABLE ${table} ENABLE ROW LEVEL SECURITY;\nThen create appropriate policies with CREATE POLICY.`,
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default SupabaseRLSAgent;
|