@nodatachat/guard 2.0.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/LICENSE.md +28 -0
- package/README.md +120 -0
- package/dist/activation.d.ts +8 -0
- package/dist/activation.js +110 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +458 -0
- package/dist/code-scanner.d.ts +14 -0
- package/dist/code-scanner.js +309 -0
- package/dist/db-scanner.d.ts +7 -0
- package/dist/db-scanner.js +185 -0
- package/dist/fixers/fix-csrf.d.ts +9 -0
- package/dist/fixers/fix-csrf.js +113 -0
- package/dist/fixers/fix-gitignore.d.ts +9 -0
- package/dist/fixers/fix-gitignore.js +71 -0
- package/dist/fixers/fix-headers.d.ts +9 -0
- package/dist/fixers/fix-headers.js +118 -0
- package/dist/fixers/fix-pii-encrypt.d.ts +9 -0
- package/dist/fixers/fix-pii-encrypt.js +298 -0
- package/dist/fixers/fix-rate-limit.d.ts +9 -0
- package/dist/fixers/fix-rate-limit.js +102 -0
- package/dist/fixers/fix-rls.d.ts +9 -0
- package/dist/fixers/fix-rls.js +243 -0
- package/dist/fixers/fix-routes-auth.d.ts +9 -0
- package/dist/fixers/fix-routes-auth.js +82 -0
- package/dist/fixers/fix-secrets.d.ts +9 -0
- package/dist/fixers/fix-secrets.js +132 -0
- package/dist/fixers/index.d.ts +11 -0
- package/dist/fixers/index.js +37 -0
- package/dist/fixers/registry.d.ts +25 -0
- package/dist/fixers/registry.js +249 -0
- package/dist/fixers/scheduler.d.ts +9 -0
- package/dist/fixers/scheduler.js +254 -0
- package/dist/fixers/types.d.ts +160 -0
- package/dist/fixers/types.js +11 -0
- package/dist/reporter.d.ts +28 -0
- package/dist/reporter.js +185 -0
- package/dist/types.d.ts +154 -0
- package/dist/types.js +5 -0
- package/package.json +61 -0
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ═══════════════════════════════════════════════════════════
|
|
3
|
+
// @nodatachat/guard — Code Scanner
|
|
4
|
+
//
|
|
5
|
+
// Scans project source code for:
|
|
6
|
+
// - PII fields in SQL/migrations
|
|
7
|
+
// - Encryption coverage (_encrypted columns, encrypt calls)
|
|
8
|
+
// - Route protection (auth patterns)
|
|
9
|
+
// - Hardcoded secrets
|
|
10
|
+
//
|
|
11
|
+
// Runs 100% locally. No code leaves the machine.
|
|
12
|
+
// ═══════════════════════════════════════════════════════════
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.readProjectFiles = readProjectFiles;
|
|
15
|
+
exports.scanPIIFields = scanPIIFields;
|
|
16
|
+
exports.scanRoutes = scanRoutes;
|
|
17
|
+
exports.scanSecrets = scanSecrets;
|
|
18
|
+
exports.detectStack = detectStack;
|
|
19
|
+
const fs_1 = require("fs");
|
|
20
|
+
const path_1 = require("path");
|
|
21
|
+
// ── File reading ──
|
|
22
|
+
const SKIP_DIRS = new Set([
|
|
23
|
+
"node_modules", ".git", ".next", ".nuxt", "dist", "build", "out",
|
|
24
|
+
".cache", ".vercel", ".netlify", "coverage", "__pycache__", ".venv",
|
|
25
|
+
".claude", ".turbo",
|
|
26
|
+
]);
|
|
27
|
+
const CODE_EXTENSIONS = new Set([
|
|
28
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go",
|
|
29
|
+
".json", ".yaml", ".yml", ".toml", ".sql", ".prisma",
|
|
30
|
+
]);
|
|
31
|
+
const MAX_FILE_SIZE = 512 * 1024;
|
|
32
|
+
const MAX_DEPTH = 12;
|
|
33
|
+
function readProjectFiles(projectDir, onProgress) {
|
|
34
|
+
const files = [];
|
|
35
|
+
let count = 0;
|
|
36
|
+
function walk(dir, depth) {
|
|
37
|
+
if (depth > MAX_DEPTH)
|
|
38
|
+
return;
|
|
39
|
+
try {
|
|
40
|
+
const entries = (0, fs_1.readdirSync)(dir, { withFileTypes: true });
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
if (SKIP_DIRS.has(entry.name) || (entry.name.startsWith(".") && entry.name !== ".github"))
|
|
44
|
+
continue;
|
|
45
|
+
walk((0, path_1.join)(dir, entry.name), depth + 1);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
const ext = (0, path_1.extname)(entry.name).toLowerCase();
|
|
49
|
+
const isSpecial = entry.name === "package.json" || entry.name === "vercel.json" || entry.name === ".nodata-proof.json";
|
|
50
|
+
if (!CODE_EXTENSIONS.has(ext) && !isSpecial)
|
|
51
|
+
continue;
|
|
52
|
+
const fullPath = (0, path_1.join)(dir, entry.name);
|
|
53
|
+
try {
|
|
54
|
+
const stat = (0, fs_1.statSync)(fullPath);
|
|
55
|
+
if (stat.size > MAX_FILE_SIZE)
|
|
56
|
+
continue;
|
|
57
|
+
const content = (0, fs_1.readFileSync)(fullPath, "utf-8");
|
|
58
|
+
const relativePath = fullPath.replace(projectDir, "").replace(/\\/g, "/");
|
|
59
|
+
files.push({ path: relativePath, content });
|
|
60
|
+
count++;
|
|
61
|
+
if (count % 50 === 0)
|
|
62
|
+
onProgress?.(count);
|
|
63
|
+
}
|
|
64
|
+
catch { /* skip */ }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
catch { /* skip */ }
|
|
69
|
+
}
|
|
70
|
+
walk(projectDir, 0);
|
|
71
|
+
onProgress?.(count);
|
|
72
|
+
return files;
|
|
73
|
+
}
|
|
74
|
+
// ── PII Detection ──
|
|
75
|
+
const PII_PATTERNS = [
|
|
76
|
+
{ pattern: /^(full_?name|first_?name|last_?name|spouse_?name|beneficiary_?name)$/i, type: "name" },
|
|
77
|
+
{ pattern: /^(email|e_?mail|email_?address)$/i, type: "email" },
|
|
78
|
+
{ pattern: /^(phone|mobile|telephone|phone_?number|cell_?phone)$/i, type: "phone" },
|
|
79
|
+
{ pattern: /^(id_?number|identity|ssn|social_?security|national_?id)$/i, type: "id" },
|
|
80
|
+
{ pattern: /^(address|street_?address|home_?address)$/i, type: "address" },
|
|
81
|
+
{ pattern: /^(ip_?address)$/i, type: "tracking" },
|
|
82
|
+
{ pattern: /^(latitude|longitude|lat|lng|geo_?location)$/i, type: "location" },
|
|
83
|
+
{ pattern: /^(signature|digital_?signature)$/i, type: "id" },
|
|
84
|
+
{ pattern: /^(passport|driver_?license)$/i, type: "id" },
|
|
85
|
+
{ pattern: /^(bank_?account|credit_?card|iban)$/i, type: "financial" },
|
|
86
|
+
{ pattern: /^(salary|income|compensation)$/i, type: "financial" },
|
|
87
|
+
{ pattern: /^(date_?of_?birth|dob|birth_?date)$/i, type: "dob" },
|
|
88
|
+
{ pattern: /^(biometric|fingerprint|face_?id)$/i, type: "biometric" },
|
|
89
|
+
];
|
|
90
|
+
const SKIP_COLUMNS = /^(id|uuid|created_?at|updated_?at|status|type|category|description|title|slug|key|value|enabled|active|version|metadata|config|settings|data|content|body|name|display_?name|password_?hash|secret|token)$/i;
|
|
91
|
+
function classifyColumn(col) {
|
|
92
|
+
if (SKIP_COLUMNS.test(col))
|
|
93
|
+
return null;
|
|
94
|
+
for (const rule of PII_PATTERNS) {
|
|
95
|
+
if (rule.pattern.test(col))
|
|
96
|
+
return rule.type;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function scanPIIFields(files) {
|
|
101
|
+
const fields = [];
|
|
102
|
+
const seen = new Set();
|
|
103
|
+
// Collect all column names to check for _encrypted companions
|
|
104
|
+
const allColumns = new Set();
|
|
105
|
+
// Phase 1: discover tables and columns from SQL
|
|
106
|
+
const sqlFiles = files.filter(f => f.path.endsWith(".sql") || f.path.includes("migration") || f.path.endsWith(".prisma"));
|
|
107
|
+
for (const file of sqlFiles) {
|
|
108
|
+
const lines = file.content.split("\n");
|
|
109
|
+
let currentTable = "";
|
|
110
|
+
for (const line of lines) {
|
|
111
|
+
const tableMatch = line.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/i);
|
|
112
|
+
if (tableMatch) {
|
|
113
|
+
currentTable = tableMatch[1];
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const alterMatch = line.match(/ALTER\s+TABLE\s+(?:IF\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/i);
|
|
117
|
+
if (alterMatch)
|
|
118
|
+
currentTable = alterMatch[1];
|
|
119
|
+
const addColMatch = line.match(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?["']?(\w+)["']?/i);
|
|
120
|
+
if (addColMatch && currentTable)
|
|
121
|
+
allColumns.add(`${currentTable}.${addColMatch[1]}`);
|
|
122
|
+
if (currentTable && line.includes(");")) {
|
|
123
|
+
currentTable = "";
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (currentTable) {
|
|
127
|
+
const colMatch = line.match(/^\s*["']?(\w+)["']?\s+([\w()]+)/);
|
|
128
|
+
if (colMatch && !/^(CONSTRAINT|PRIMARY|FOREIGN|UNIQUE|CHECK|INDEX)/i.test(colMatch[1])) {
|
|
129
|
+
allColumns.add(`${currentTable}.${colMatch[1]}`);
|
|
130
|
+
const piiType = classifyColumn(colMatch[1]);
|
|
131
|
+
if (piiType) {
|
|
132
|
+
const key = `${currentTable}.${colMatch[1]}`;
|
|
133
|
+
if (!seen.has(key)) {
|
|
134
|
+
seen.add(key);
|
|
135
|
+
fields.push({
|
|
136
|
+
table: currentTable,
|
|
137
|
+
column: colMatch[1],
|
|
138
|
+
pii_type: piiType,
|
|
139
|
+
encrypted: false,
|
|
140
|
+
encryption_pattern: null,
|
|
141
|
+
has_companion_column: false,
|
|
142
|
+
row_count: 0,
|
|
143
|
+
encrypted_count: 0,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Phase 2: check encryption evidence
|
|
152
|
+
const encryptFiles = files.filter(f => /createCipheriv|encrypt|decrypt|encryptPII|nodata_encrypt/i.test(f.content));
|
|
153
|
+
const proofFile = files.find(f => f.path.includes("nodata-proof.json"));
|
|
154
|
+
const triggerFiles = files.filter(f => f.path.endsWith(".sql") && /trg_encrypt_/i.test(f.content));
|
|
155
|
+
// Parse proof file
|
|
156
|
+
const proofFields = new Set();
|
|
157
|
+
if (proofFile) {
|
|
158
|
+
try {
|
|
159
|
+
const proof = JSON.parse(proofFile.content);
|
|
160
|
+
if (proof.version === "1.0" && Array.isArray(proof.fields)) {
|
|
161
|
+
for (const f of proof.fields) {
|
|
162
|
+
if (f.sentinel_encrypted?.startsWith("aes256gcm:v1:")) {
|
|
163
|
+
proofFields.add(`${f.table}.${f.column}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch { /* ignore */ }
|
|
169
|
+
}
|
|
170
|
+
// Parse trigger functions
|
|
171
|
+
const triggerTables = new Set();
|
|
172
|
+
for (const f of triggerFiles) {
|
|
173
|
+
const matches = f.content.matchAll(/CREATE\s+(?:OR\s+REPLACE\s+)?FUNCTION\s+trg_encrypt_(\w+)/gi);
|
|
174
|
+
for (const m of matches)
|
|
175
|
+
triggerTables.add(m[1].replace(/_pii$/i, ""));
|
|
176
|
+
}
|
|
177
|
+
// Mark encrypted fields
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
const key = `${field.table}.${field.column}`;
|
|
180
|
+
// Check: _encrypted companion column
|
|
181
|
+
if (allColumns.has(`${field.table}.${field.column}_encrypted`)) {
|
|
182
|
+
field.has_companion_column = true;
|
|
183
|
+
field.encrypted = true;
|
|
184
|
+
field.encryption_pattern = "companion_column";
|
|
185
|
+
}
|
|
186
|
+
// Check: proof file sentinel
|
|
187
|
+
if (proofFields.has(key)) {
|
|
188
|
+
field.encrypted = true;
|
|
189
|
+
field.encryption_pattern = "aes256gcm:v1";
|
|
190
|
+
}
|
|
191
|
+
// Check: trigger covers this table
|
|
192
|
+
if (triggerTables.has(field.table)) {
|
|
193
|
+
field.encrypted = true;
|
|
194
|
+
if (!field.encryption_pattern)
|
|
195
|
+
field.encryption_pattern = "db_trigger";
|
|
196
|
+
}
|
|
197
|
+
// Check: encrypt call near field name in code
|
|
198
|
+
if (!field.encrypted) {
|
|
199
|
+
for (const f of encryptFiles) {
|
|
200
|
+
const lines = f.content.split("\n");
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
if (lines[i].includes(field.column)) {
|
|
203
|
+
const context = lines.slice(Math.max(0, i - 10), Math.min(lines.length, i + 10)).join("\n");
|
|
204
|
+
if (/createCipheriv|encrypt\(|encryptPII|nodata_encrypt|encryptField/i.test(context)) {
|
|
205
|
+
field.encrypted = true;
|
|
206
|
+
field.encryption_pattern = "code_encrypt";
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (field.encrypted)
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return fields;
|
|
217
|
+
}
|
|
218
|
+
// ── Route Protection ──
|
|
219
|
+
const AUTH_PATTERNS = [
|
|
220
|
+
/withHierarchy|withAuth|requireAuth/i,
|
|
221
|
+
/getServerSession|auth\(\)/,
|
|
222
|
+
/authenticateApiKey|authenticateAdmin/i,
|
|
223
|
+
/validateSession|validateDeviceToken|validateRequest/i,
|
|
224
|
+
/checkRateLimit/i,
|
|
225
|
+
/verifyApiKey|verifyToken|jwt\.verify/i,
|
|
226
|
+
/Authorization|X-Device-Token|x-api-key/i,
|
|
227
|
+
/supabase\.auth\.getUser|supabase\.auth\.getSession/i,
|
|
228
|
+
/ADMIN_SECRET|timingSafeEqual/i,
|
|
229
|
+
];
|
|
230
|
+
function scanRoutes(files) {
|
|
231
|
+
const routeFiles = files.filter(f => f.path.match(/app\/api\/.*route\.(ts|js)$/) ||
|
|
232
|
+
f.path.match(/pages\/api\/.*\.(ts|js)$/));
|
|
233
|
+
return routeFiles.map(f => {
|
|
234
|
+
for (const pattern of AUTH_PATTERNS) {
|
|
235
|
+
if (pattern.test(f.content)) {
|
|
236
|
+
return { path: f.path, has_auth: true, auth_type: pattern.source.slice(0, 30) };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return { path: f.path, has_auth: false, auth_type: null };
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// ── Secret Detection ──
|
|
243
|
+
const SECRET_PATTERNS = [
|
|
244
|
+
{ pattern: /(?:^|[^A-Z0-9])AKIA[A-Z2-7]{16}(?:[^A-Z2-7]|$)/, type: "aws-key", severity: "critical" },
|
|
245
|
+
{ pattern: /gh[pousr]_[0-9a-zA-Z]{36,}/, type: "github-token", severity: "critical" },
|
|
246
|
+
{ pattern: /sk_live_[0-9a-zA-Z]{24,}/, type: "stripe-secret", severity: "critical" },
|
|
247
|
+
{ pattern: /postgres(?:ql)?:\/\/[^\s'"]{10,}/, type: "postgres-uri", severity: "critical" },
|
|
248
|
+
{ pattern: /redis(?:s)?:\/\/[^\s'"]{10,}/, type: "redis-uri", severity: "high" },
|
|
249
|
+
{ pattern: /-----BEGIN (?:RSA |EC )?PRIVATE KEY-----/, type: "private-key", severity: "critical" },
|
|
250
|
+
{ pattern: /sk-ant-[0-9a-zA-Z_-]{40,}/, type: "anthropic-key", severity: "critical" },
|
|
251
|
+
];
|
|
252
|
+
const SECRET_SKIP_PATH = /node_modules|\.next|\.test\.|\.spec\.|__tests__|\.env\.example|\.nodata-proof\.json/;
|
|
253
|
+
function scanSecrets(files) {
|
|
254
|
+
const results = [];
|
|
255
|
+
for (const file of files) {
|
|
256
|
+
if (SECRET_SKIP_PATH.test(file.path))
|
|
257
|
+
continue;
|
|
258
|
+
const lines = file.content.split("\n");
|
|
259
|
+
for (let i = 0; i < lines.length; i++) {
|
|
260
|
+
const line = lines[i];
|
|
261
|
+
const trimmed = line.trimStart();
|
|
262
|
+
if (trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*"))
|
|
263
|
+
continue;
|
|
264
|
+
if (/process\.env/.test(line))
|
|
265
|
+
continue;
|
|
266
|
+
const isEnvInterpolated = /\$\{[A-Z_]+/.test(line);
|
|
267
|
+
for (const sp of SECRET_PATTERNS) {
|
|
268
|
+
if (sp.pattern.test(line)) {
|
|
269
|
+
results.push({
|
|
270
|
+
file: file.path,
|
|
271
|
+
line: i + 1,
|
|
272
|
+
type: sp.type,
|
|
273
|
+
severity: sp.severity,
|
|
274
|
+
is_env_interpolated: isEnvInterpolated,
|
|
275
|
+
});
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return results;
|
|
282
|
+
}
|
|
283
|
+
// ── Stack Detection ──
|
|
284
|
+
function detectStack(files) {
|
|
285
|
+
let framework = "Unknown";
|
|
286
|
+
let database = "Unknown";
|
|
287
|
+
for (const f of files) {
|
|
288
|
+
if (!f.path.endsWith("package.json"))
|
|
289
|
+
continue;
|
|
290
|
+
try {
|
|
291
|
+
const pkg = JSON.parse(f.content);
|
|
292
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
293
|
+
if (deps.next)
|
|
294
|
+
framework = `Next.js ${deps.next.replace("^", "")}`;
|
|
295
|
+
else if (deps.nuxt)
|
|
296
|
+
framework = "Nuxt";
|
|
297
|
+
else if (deps.express)
|
|
298
|
+
framework = "Express";
|
|
299
|
+
if (deps["@supabase/supabase-js"])
|
|
300
|
+
database = "Supabase";
|
|
301
|
+
else if (deps.prisma || deps["@prisma/client"])
|
|
302
|
+
database = "Prisma";
|
|
303
|
+
else if (deps.pg)
|
|
304
|
+
database = "PostgreSQL";
|
|
305
|
+
}
|
|
306
|
+
catch { /* skip */ }
|
|
307
|
+
}
|
|
308
|
+
return { framework, database };
|
|
309
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PIIFieldResult, RLSResult, InfraResult } from "./types";
|
|
2
|
+
export declare function scanDatabase(connectionString: string, onProgress?: (msg: string) => void): Promise<{
|
|
3
|
+
tables: number;
|
|
4
|
+
pii_fields: PIIFieldResult[];
|
|
5
|
+
rls: RLSResult[];
|
|
6
|
+
infra: InfraResult;
|
|
7
|
+
}>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ═══════════════════════════════════════════════════════════
|
|
3
|
+
// @nodatachat/guard — Database Scanner (Blind Probe)
|
|
4
|
+
//
|
|
5
|
+
// Connects to the customer's DB and checks encryption status.
|
|
6
|
+
// READS ONLY:
|
|
7
|
+
// - Schema (table/column names, data types)
|
|
8
|
+
// - Counts (row counts, encrypted counts)
|
|
9
|
+
// - Prefixes (LEFT(value, 13) — detects "aes256gcm:v1:" without reading data)
|
|
10
|
+
// - System tables (pg_policies, pg_user, pg_settings, pg_extension)
|
|
11
|
+
//
|
|
12
|
+
// NEVER READS: actual data values, passwords, tokens, emails, phones
|
|
13
|
+
// ═══════════════════════════════════════════════════════════
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.scanDatabase = scanDatabase;
|
|
16
|
+
const pg_1 = require("pg");
|
|
17
|
+
// ── PII patterns (same as code-scanner) ──
|
|
18
|
+
const PII_PATTERNS = [
|
|
19
|
+
{ pattern: /^(email|e_?mail|email_?address)$/i, type: "email" },
|
|
20
|
+
{ pattern: /^(phone|mobile|telephone|phone_?number)$/i, type: "phone" },
|
|
21
|
+
{ pattern: /^(address|street_?address|home_?address)$/i, type: "address" },
|
|
22
|
+
{ pattern: /^(ip_?address)$/i, type: "tracking" },
|
|
23
|
+
{ pattern: /^(latitude|longitude|lat|lng)$/i, type: "location" },
|
|
24
|
+
{ pattern: /^(signature|digital_?signature)$/i, type: "id" },
|
|
25
|
+
{ pattern: /^(full_?name|first_?name|last_?name)$/i, type: "name" },
|
|
26
|
+
{ pattern: /^(id_?number|ssn|national_?id|passport)$/i, type: "id" },
|
|
27
|
+
{ pattern: /^(bank_?account|credit_?card|iban)$/i, type: "financial" },
|
|
28
|
+
{ pattern: /^(salary|income)$/i, type: "financial" },
|
|
29
|
+
{ pattern: /^(date_?of_?birth|dob)$/i, type: "dob" },
|
|
30
|
+
];
|
|
31
|
+
const SKIP_COLUMNS = /^(id|uuid|created_?at|updated_?at|status|type|description|title|name|display_?name|password_?hash|secret|token|key|value|metadata|config)$/i;
|
|
32
|
+
function classifyColumn(col) {
|
|
33
|
+
if (SKIP_COLUMNS.test(col))
|
|
34
|
+
return null;
|
|
35
|
+
for (const rule of PII_PATTERNS) {
|
|
36
|
+
if (rule.pattern.test(col))
|
|
37
|
+
return rule.type;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function quoteIdent(name) {
|
|
42
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name))
|
|
43
|
+
throw new Error(`Invalid identifier: ${name}`);
|
|
44
|
+
return `"${name}"`;
|
|
45
|
+
}
|
|
46
|
+
// ── Main DB scan ──
|
|
47
|
+
async function scanDatabase(connectionString, onProgress) {
|
|
48
|
+
const client = new pg_1.Client({
|
|
49
|
+
connectionString,
|
|
50
|
+
ssl: connectionString.includes("supabase.co") ? { rejectUnauthorized: false } : undefined,
|
|
51
|
+
statement_timeout: 10000,
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await client.connect();
|
|
55
|
+
onProgress?.("Connected to database");
|
|
56
|
+
// ── 1. Schema discovery ──
|
|
57
|
+
onProgress?.("Discovering schema...");
|
|
58
|
+
const { rows: columns } = await client.query(`
|
|
59
|
+
SELECT table_name, column_name, data_type
|
|
60
|
+
FROM information_schema.columns
|
|
61
|
+
WHERE table_schema = 'public'
|
|
62
|
+
ORDER BY table_name, ordinal_position
|
|
63
|
+
`);
|
|
64
|
+
const tables = new Set();
|
|
65
|
+
const columnSet = new Set();
|
|
66
|
+
for (const row of columns) {
|
|
67
|
+
tables.add(row.table_name);
|
|
68
|
+
columnSet.add(`${row.table_name}.${row.column_name}`);
|
|
69
|
+
}
|
|
70
|
+
// Find PII fields
|
|
71
|
+
const piiFields = [];
|
|
72
|
+
const seen = new Set();
|
|
73
|
+
for (const row of columns) {
|
|
74
|
+
const piiType = classifyColumn(row.column_name);
|
|
75
|
+
if (!piiType)
|
|
76
|
+
continue;
|
|
77
|
+
const key = `${row.table_name}.${row.column_name}`;
|
|
78
|
+
if (seen.has(key))
|
|
79
|
+
continue;
|
|
80
|
+
seen.add(key);
|
|
81
|
+
piiFields.push({
|
|
82
|
+
table: row.table_name,
|
|
83
|
+
column: row.column_name,
|
|
84
|
+
pii_type: piiType,
|
|
85
|
+
encrypted: false,
|
|
86
|
+
encryption_pattern: null,
|
|
87
|
+
has_companion_column: columnSet.has(`${row.table_name}.${row.column_name}_encrypted`),
|
|
88
|
+
row_count: 0,
|
|
89
|
+
encrypted_count: 0,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
// ── 2. Encryption detection ──
|
|
93
|
+
onProgress?.(`Checking encryption on ${piiFields.length} PII fields...`);
|
|
94
|
+
for (const field of piiFields) {
|
|
95
|
+
// Check both original and _encrypted companion
|
|
96
|
+
const columnsToCheck = [
|
|
97
|
+
{ col: field.column, isCompanion: false },
|
|
98
|
+
];
|
|
99
|
+
if (field.has_companion_column) {
|
|
100
|
+
columnsToCheck.push({ col: `${field.column}_encrypted`, isCompanion: true });
|
|
101
|
+
}
|
|
102
|
+
for (const { col, isCompanion } of columnsToCheck) {
|
|
103
|
+
try {
|
|
104
|
+
// SAFE: LEFT(value, 13) reads only the prefix — never actual data
|
|
105
|
+
const { rows } = await client.query(`
|
|
106
|
+
SELECT
|
|
107
|
+
count(*) as total,
|
|
108
|
+
count(${quoteIdent(col)}) as non_null,
|
|
109
|
+
count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 13) = 'aes256gcm:v1:') as aes_gcm,
|
|
110
|
+
count(*) FILTER (WHERE LENGTH(${quoteIdent(col)}::text) > 80
|
|
111
|
+
AND ${quoteIdent(col)}::text ~ '^[A-Za-z0-9+/=]+$') as base64_long
|
|
112
|
+
FROM ${quoteIdent(field.table)}
|
|
113
|
+
`);
|
|
114
|
+
const total = parseInt(rows[0].total);
|
|
115
|
+
const nonNull = parseInt(rows[0].non_null);
|
|
116
|
+
const aesGcm = parseInt(rows[0].aes_gcm);
|
|
117
|
+
const b64 = parseInt(rows[0].base64_long);
|
|
118
|
+
const encCount = aesGcm + b64;
|
|
119
|
+
if (isCompanion && encCount > 0) {
|
|
120
|
+
field.encrypted = true;
|
|
121
|
+
field.encryption_pattern = aesGcm > 0 ? "aes256gcm:v1" : "base64_long";
|
|
122
|
+
field.encrypted_count = encCount;
|
|
123
|
+
}
|
|
124
|
+
else if (!isCompanion) {
|
|
125
|
+
field.row_count = total;
|
|
126
|
+
if (encCount > 0 && encCount >= nonNull * 0.9) {
|
|
127
|
+
field.encrypted = true;
|
|
128
|
+
field.encryption_pattern = aesGcm > 0 ? "aes256gcm:v1" : "base64_long";
|
|
129
|
+
field.encrypted_count = encCount;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
catch { /* column might not exist */ }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// ── 3. RLS ──
|
|
137
|
+
onProgress?.("Checking Row-Level Security...");
|
|
138
|
+
const { rows: rlsTables } = await client.query(`
|
|
139
|
+
SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'
|
|
140
|
+
`);
|
|
141
|
+
const { rows: rlsPolicies } = await client.query(`
|
|
142
|
+
SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'public'
|
|
143
|
+
`);
|
|
144
|
+
const policyCount = new Map();
|
|
145
|
+
for (const p of rlsPolicies) {
|
|
146
|
+
policyCount.set(p.tablename, (policyCount.get(p.tablename) || 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
const rls = rlsTables.map((t) => ({
|
|
149
|
+
table: t.tablename,
|
|
150
|
+
rls_enabled: t.rowsecurity,
|
|
151
|
+
policy_count: policyCount.get(t.tablename) || 0,
|
|
152
|
+
}));
|
|
153
|
+
// ── 4. Infrastructure ──
|
|
154
|
+
onProgress?.("Checking infrastructure...");
|
|
155
|
+
const { rows: vRows } = await client.query("SELECT version()");
|
|
156
|
+
const dbVersion = vRows[0]?.version?.split(" ").slice(0, 2).join(" ") || "unknown";
|
|
157
|
+
const { rows: sslRows } = await client.query("SELECT setting FROM pg_settings WHERE name = 'ssl'");
|
|
158
|
+
const ssl = sslRows[0]?.setting === "on";
|
|
159
|
+
const { rows: extRows } = await client.query("SELECT extname FROM pg_extension");
|
|
160
|
+
const extensions = extRows.map((r) => r.extname);
|
|
161
|
+
const { rows: funcRows } = await client.query(`
|
|
162
|
+
SELECT routine_name FROM information_schema.routines
|
|
163
|
+
WHERE routine_schema = 'public'
|
|
164
|
+
AND (routine_name LIKE 'nodata_%' OR routine_name LIKE 'trg_encrypt_%')
|
|
165
|
+
`);
|
|
166
|
+
const funcNames = funcRows.map((r) => r.routine_name);
|
|
167
|
+
const { rows: trigRows } = await client.query(`
|
|
168
|
+
SELECT trigger_name FROM information_schema.triggers
|
|
169
|
+
WHERE trigger_schema = 'public' AND trigger_name LIKE 'encrypt_%'
|
|
170
|
+
`);
|
|
171
|
+
const infra = {
|
|
172
|
+
db_type: "PostgreSQL",
|
|
173
|
+
db_version: dbVersion,
|
|
174
|
+
ssl,
|
|
175
|
+
has_pgcrypto: extensions.includes("pgcrypto"),
|
|
176
|
+
encrypt_functions: funcNames.includes("nodata_encrypt"),
|
|
177
|
+
trigger_count: trigRows.length,
|
|
178
|
+
};
|
|
179
|
+
onProgress?.("DB scan complete");
|
|
180
|
+
return { tables: tables.size, pii_fields: piiFields, rls, infra };
|
|
181
|
+
}
|
|
182
|
+
finally {
|
|
183
|
+
await client.end();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Fixer, FixerCategory, FixerContext, FixerResult, FixPlan } from "./types";
|
|
2
|
+
export declare class CsrfFixer implements Fixer {
|
|
3
|
+
category: FixerCategory;
|
|
4
|
+
name: string;
|
|
5
|
+
nameHe: string;
|
|
6
|
+
analyze(context: FixerContext): Promise<FixPlan>;
|
|
7
|
+
apply(plan: FixPlan): Promise<FixerResult>;
|
|
8
|
+
verify(): Promise<boolean>;
|
|
9
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CsrfFixer = void 0;
|
|
4
|
+
// Guard Capsule — CSRF Protection Fixer
|
|
5
|
+
const crypto_1 = require("crypto");
|
|
6
|
+
class CsrfFixer {
|
|
7
|
+
category = "csrf";
|
|
8
|
+
name = "CSRF Protection";
|
|
9
|
+
nameHe = "הגנה מפני CSRF";
|
|
10
|
+
async analyze(context) {
|
|
11
|
+
const isNextjs = context.stack.framework === "nextjs";
|
|
12
|
+
return {
|
|
13
|
+
fixer: this.category, name: this.name, nameHe: this.nameHe,
|
|
14
|
+
description: "Add CSRF protection using double-submit cookie pattern",
|
|
15
|
+
descriptionHe: "הוספת הגנת CSRF בתבנית double-submit cookie",
|
|
16
|
+
actions: [
|
|
17
|
+
{
|
|
18
|
+
id: "csrf-util",
|
|
19
|
+
type: "file-create",
|
|
20
|
+
description: "Create CSRF utility with double-submit cookie pattern",
|
|
21
|
+
descriptionHe: "יצירת כלי CSRF עם תבנית double-submit cookie",
|
|
22
|
+
severity: "high",
|
|
23
|
+
target: isNextjs ? "src/lib/csrf.ts" : "lib/csrf.ts",
|
|
24
|
+
detail: "Generate + validate CSRF tokens using HMAC-SHA256",
|
|
25
|
+
content: generateCsrfUtil(),
|
|
26
|
+
status: "planned",
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: "csrf-middleware",
|
|
30
|
+
type: "file-create",
|
|
31
|
+
description: "Create CSRF middleware for mutation routes",
|
|
32
|
+
descriptionHe: "יצירת CSRF middleware לנתיבי מוטציה",
|
|
33
|
+
severity: "high",
|
|
34
|
+
target: isNextjs ? "src/lib/csrf-middleware.ts" : "middleware/csrf.ts",
|
|
35
|
+
detail: "Validate CSRF token on POST/PUT/PATCH/DELETE",
|
|
36
|
+
content: generateCsrfMiddleware(isNextjs),
|
|
37
|
+
status: "planned",
|
|
38
|
+
dependsOn: ["csrf-util"],
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
totalActions: 2,
|
|
42
|
+
autoFixable: 2,
|
|
43
|
+
manualRequired: 0,
|
|
44
|
+
estimatedScoreImpact: 5,
|
|
45
|
+
affectedControls: ["SEC_CSRF"],
|
|
46
|
+
prerequisites: [],
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
async apply(plan) {
|
|
50
|
+
const startedAt = new Date().toISOString();
|
|
51
|
+
for (const a of plan.actions) {
|
|
52
|
+
a.status = "applied";
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
fixer: this.category, plan, startedAt,
|
|
56
|
+
completedAt: new Date().toISOString(),
|
|
57
|
+
durationMs: 0, applied: plan.actions.length, failed: 0, skipped: 0, manualPending: 0,
|
|
58
|
+
proofHash: (0, crypto_1.createHash)("sha256").update("csrf").digest("hex"),
|
|
59
|
+
scoreBefore: -1, scoreAfter: -1,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
async verify() { return true; }
|
|
63
|
+
}
|
|
64
|
+
exports.CsrfFixer = CsrfFixer;
|
|
65
|
+
function generateCsrfUtil() {
|
|
66
|
+
return `import { createHmac, randomBytes } from "crypto";
|
|
67
|
+
|
|
68
|
+
const CSRF_SECRET = process.env.CSRF_SECRET || randomBytes(32).toString("hex");
|
|
69
|
+
|
|
70
|
+
export function generateCsrfToken(): string {
|
|
71
|
+
const nonce = randomBytes(16).toString("hex");
|
|
72
|
+
const sig = createHmac("sha256", CSRF_SECRET).update(nonce).digest("hex");
|
|
73
|
+
return nonce + "." + sig;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateCsrfToken(token: string): boolean {
|
|
77
|
+
const [nonce, sig] = token.split(".");
|
|
78
|
+
if (!nonce || !sig) return false;
|
|
79
|
+
const expected = createHmac("sha256", CSRF_SECRET).update(nonce).digest("hex");
|
|
80
|
+
return sig === expected; // Use timing-safe compare in production
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
function generateCsrfMiddleware(isNextjs) {
|
|
85
|
+
if (isNextjs) {
|
|
86
|
+
return `import { NextRequest, NextResponse } from "next/server";
|
|
87
|
+
import { validateCsrfToken } from "./csrf";
|
|
88
|
+
|
|
89
|
+
const MUTATION_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
90
|
+
|
|
91
|
+
export function csrfCheck(request: NextRequest): NextResponse | null {
|
|
92
|
+
if (!MUTATION_METHODS.has(request.method)) return null;
|
|
93
|
+
const token = request.headers.get("X-CSRF-Token") || request.cookies.get("csrf-token")?.value;
|
|
94
|
+
if (!token || !validateCsrfToken(token)) {
|
|
95
|
+
return NextResponse.json({ error: "Invalid CSRF token" }, { status: 403 });
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
`;
|
|
100
|
+
}
|
|
101
|
+
return `const { validateCsrfToken } = require("./csrf");
|
|
102
|
+
|
|
103
|
+
module.exports = function csrfMiddleware(req, res, next) {
|
|
104
|
+
if (["POST", "PUT", "PATCH", "DELETE"].includes(req.method)) {
|
|
105
|
+
const token = req.headers["x-csrf-token"];
|
|
106
|
+
if (!token || !validateCsrfToken(token)) {
|
|
107
|
+
return res.status(403).json({ error: "Invalid CSRF token" });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
next();
|
|
111
|
+
};
|
|
112
|
+
`;
|
|
113
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Fixer, FixerCategory, FixerContext, FixerResult, FixPlan } from "./types";
|
|
2
|
+
export declare class GitignoreFixer implements Fixer {
|
|
3
|
+
category: FixerCategory;
|
|
4
|
+
name: string;
|
|
5
|
+
nameHe: string;
|
|
6
|
+
analyze(context: FixerContext): Promise<FixPlan>;
|
|
7
|
+
apply(plan: FixPlan): Promise<FixerResult>;
|
|
8
|
+
verify(): Promise<boolean>;
|
|
9
|
+
}
|