@nodatachat/guard 2.6.1 → 3.1.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/dist/cli.js CHANGED
@@ -26,7 +26,8 @@ const reporter_1 = require("./reporter");
26
26
  const scheduler_1 = require("./fixers/scheduler");
27
27
  const vault_crypto_1 = require("./vault-crypto");
28
28
  const capsule_dir_1 = require("./capsule-dir");
29
- const VERSION = "2.6.1";
29
+ const db_detect_1 = require("./db-detect");
30
+ const VERSION = "3.1.0";
30
31
  async function main() {
31
32
  const args = process.argv.slice(2);
32
33
  // ── Subcommand routing ──
@@ -44,6 +45,12 @@ async function main() {
44
45
  if (subcommand === "attest") {
45
46
  return handleAttest(args.slice(1));
46
47
  }
48
+ if (subcommand === "detect") {
49
+ const dir = args.includes("--dir") ? (0, path_1.resolve)(args[args.indexOf("--dir") + 1]) : process.cwd();
50
+ const result = (0, db_detect_1.detectDatabases)(dir);
51
+ (0, db_detect_1.printDetectionResults)(result);
52
+ return;
53
+ }
47
54
  // Parse args
48
55
  let licenseKey;
49
56
  let dbUrl;
@@ -114,12 +121,21 @@ async function main() {
114
121
  licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
115
122
  if (!dbUrl)
116
123
  dbUrl = process.env.DATABASE_URL;
117
- // ── Auto-detect from .env files ──
118
- // Guard reads .env files to find DATABASE_URL and license keys.
124
+ // ── Auto-detect from .env files + project analysis ──
125
+ // Guard reads .env files AND analyzes the project to find databases.
119
126
  // ALL values stay local — never sent anywhere.
120
- // If DB URL is found, Guard asks for explicit consent before connecting.
127
+ // If a DB is found, Guard asks for explicit consent before connecting.
121
128
  if (!dbUrl || !licenseKey) {
122
129
  const envResult = readEnvFiles(projectDir);
130
+ // Also run full database detection for richer results
131
+ if (!dbUrl) {
132
+ const detection = (0, db_detect_1.detectDatabases)(projectDir);
133
+ const scannableDb = detection.databases.find(d => d.scannable && d.raw_value);
134
+ if (scannableDb && !envResult.dbUrl) {
135
+ envResult.dbUrl = scannableDb.raw_value;
136
+ envResult.dbSource = `${scannableDb.source} (${scannableDb.engine}/${scannableDb.provider})`;
137
+ }
138
+ }
123
139
  if (!licenseKey && envResult.licenseKey) {
124
140
  licenseKey = envResult.licenseKey;
125
141
  if (!ciMode)
@@ -292,6 +308,50 @@ async function main() {
292
308
  const encryptedDb = dbResult.pii_fields.filter(f => f.encrypted).length;
293
309
  log(ciMode, `DB PII: ${dbResult.pii_fields.length} found, ${encryptedDb} encrypted`);
294
310
  log(ciMode, `RLS: ${dbResult.rls.filter(r => r.rls_enabled).length}/${dbResult.rls.length} tables`);
311
+ // Save full DB evidence
312
+ if (dbResult.evidence) {
313
+ const evidencePath = (0, path_1.resolve)(outputDir, "nodata-db-evidence.json");
314
+ (0, fs_1.writeFileSync)(evidencePath, JSON.stringify(dbResult.evidence, null, 2), "utf-8");
315
+ log(ciMode, `DB evidence: ${evidencePath}`);
316
+ // Print evidence summary
317
+ if (!ciMode) {
318
+ const ev = dbResult.evidence;
319
+ console.log("");
320
+ console.log(" ══════════════════════════════════════");
321
+ console.log(" DB EVIDENCE — FORENSIC SUMMARY");
322
+ console.log(" ══════════════════════════════════════");
323
+ console.log(` Connection: ${ev.connection.connection_encrypted ? "\x1b[32mEncrypted\x1b[0m" : "\x1b[31mPlaintext\x1b[0m"}${ev.connection.ssl_version ? ` (${ev.connection.ssl_version})` : ""}`);
324
+ if (ev.connection.ssl_cipher)
325
+ console.log(` Cipher: ${ev.connection.ssl_cipher} (${ev.connection.ssl_bits} bits)`);
326
+ console.log(` Server: ${ev.infrastructure.db_version_short}`);
327
+ console.log(` Password enc: ${ev.security_config.password_encryption}`);
328
+ console.log(" ──────────────────────────────────────");
329
+ console.log(` Tables: ${ev.schema.total_tables} (${ev.schema.pii_columns_found} PII columns)`);
330
+ console.log(` Encrypted: \x1b[32m${ev.encryption.summary.encrypted_fields}/${ev.encryption.summary.total_pii_fields}\x1b[0m fields`);
331
+ console.log(` Rows scanned: ${ev.encryption.summary.total_rows_scanned.toLocaleString()}`);
332
+ console.log(` Rows encrypted: \x1b[32m${ev.encryption.summary.total_rows_encrypted.toLocaleString()}\x1b[0m`);
333
+ console.log(` Rows plaintext: ${ev.encryption.summary.total_rows_plaintext > 0 ? `\x1b[31m${ev.encryption.summary.total_rows_plaintext.toLocaleString()}\x1b[0m` : "\x1b[32m0\x1b[0m"}`);
334
+ console.log(` Rows null: ${ev.encryption.summary.total_rows_null.toLocaleString()}`);
335
+ console.log(` Patterns: ${ev.encryption.summary.encryption_patterns_found.join(", ") || "none"}`);
336
+ console.log(` Coverage: ${ev.encryption.summary.coverage_percent}%`);
337
+ console.log(" ──────────────────────────────────────");
338
+ console.log(` RLS: ${ev.rls.summary.rls_enabled_count}/${ev.rls.summary.total_tables} tables (${ev.rls.summary.coverage_percent}%)`);
339
+ console.log(` Policies: ${ev.rls.summary.total_policies}`);
340
+ if (ev.rls.summary.tables_without_rls.length > 0 && ev.rls.summary.tables_without_rls.length <= 5) {
341
+ console.log(` \x1b[31mNo RLS:\x1b[0m ${ev.rls.summary.tables_without_rls.join(", ")}`);
342
+ }
343
+ console.log(" ──────────────────────────────────────");
344
+ console.log(` Extensions: ${ev.infrastructure.security_extensions.join(", ") || "none security-related"}`);
345
+ console.log(` Enc functions: ${ev.infrastructure.encrypt_functions.length}`);
346
+ console.log(` Enc triggers: ${ev.infrastructure.encrypt_triggers.length}`);
347
+ console.log(` Superusers: ${ev.security_config.roles_with_superuser}`);
348
+ console.log(` Login roles: ${ev.security_config.roles_with_login}`);
349
+ console.log(` Log level: ${ev.security_config.log_statement}`);
350
+ console.log(` Scan time: ${(ev.scan_duration_ms / 1000).toFixed(1)}s`);
351
+ console.log(" ══════════════════════════════════════");
352
+ console.log(` \x1b[2mFull evidence: ${evidencePath}\x1b[0m\n`);
353
+ }
354
+ }
295
355
  }
296
356
  catch (err) {
297
357
  log(ciMode, `DB scan failed: ${err instanceof Error ? err.message : err}`);
@@ -877,11 +937,13 @@ function printHelp() {
877
937
  npx nodata-guard status # Show .capsule/ status (no scan)
878
938
  npx nodata-guard diff # Compare last 2 scans
879
939
  npx nodata-guard attest --finding ID --status fixed --note "..." # Manual attestation
940
+ npx nodata-guard detect # Detect all databases in project
880
941
 
881
942
  Subcommands:
882
943
  status Show .capsule/ evidence (scores, proof, overrides) without scanning
883
944
  diff Compare the last 2 scans — score delta, issues resolved/new
884
945
  attest Manually attest a finding (saved to .capsule/overrides.json)
946
+ detect Detect all databases in your project (env, deps, config)
885
947
  --finding <id> Finding ID (e.g., PII_UNENCRYPTED_email, ROUTE_NO_AUTH)
886
948
  --status <status> fixed | accepted_risk | not_applicable | compensating_control
887
949
  --note <text> Explanation of what was done
@@ -0,0 +1,24 @@
1
+ export type DbEngine = "postgresql" | "mysql" | "mongodb" | "redis" | "sqlite" | "sqlserver" | "cockroachdb" | "turso" | "dynamodb" | "firebase" | "fauna" | "xata" | "convex" | "d1" | "unknown";
2
+ export type DbProvider = "supabase" | "neon" | "planetscale" | "upstash" | "railway" | "render" | "fly" | "vercel-postgres" | "aws-rds" | "azure" | "gcp-cloudsql" | "digitalocean" | "cockroach-cloud" | "turso-cloud" | "mongodb-atlas" | "firebase-google" | "fauna-cloud" | "xata-cloud" | "convex-cloud" | "cloudflare-d1" | "aiven" | "tembo" | "timescale" | "elephantsql" | "local" | "unknown";
3
+ export interface DetectedDatabase {
4
+ engine: DbEngine;
5
+ provider: DbProvider;
6
+ source: string;
7
+ connection_key: string;
8
+ connection_value: string;
9
+ raw_value: string;
10
+ scannable: boolean;
11
+ scan_method: string;
12
+ confidence: number;
13
+ details: string;
14
+ }
15
+ export interface DetectionResult {
16
+ databases: DetectedDatabase[];
17
+ orm_detected: string | null;
18
+ framework_detected: string | null;
19
+ env_files_scanned: string[];
20
+ config_files_found: string[];
21
+ total_signals: number;
22
+ }
23
+ export declare function detectDatabases(projectDir: string): DetectionResult;
24
+ export declare function printDetectionResults(result: DetectionResult): void;
@@ -0,0 +1,443 @@
1
+ "use strict";
2
+ // ═══════════════════════════════════════════════════════════
3
+ // @nodatachat/guard — Universal Database Detector
4
+ //
5
+ // Scans a project to find ALL database connections:
6
+ // 1. .env files — connection strings, URLs, keys
7
+ // 2. package.json — ORM/driver dependencies
8
+ // 3. Config files — prisma.schema, drizzle.config, knexfile, etc.
9
+ // 4. Code patterns — import statements, connection code
10
+ //
11
+ // Supports: PostgreSQL, MySQL, MongoDB, Redis, SQLite,
12
+ // SQL Server, CockroachDB, Turso/LibSQL, DynamoDB,
13
+ // Supabase, Neon, PlanetScale, Upstash, Firebase,
14
+ // Fauna, Xata, Convex, D1 (Cloudflare)
15
+ //
16
+ // ALL detection is LOCAL. No data leaves the machine.
17
+ // ═══════════════════════════════════════════════════════════
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.detectDatabases = detectDatabases;
20
+ exports.printDetectionResults = printDetectionResults;
21
+ const path_1 = require("path");
22
+ const fs_1 = require("fs");
23
+ const ENV_PATTERNS = [
24
+ // Supabase
25
+ { keys: ["NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_URL", "VITE_SUPABASE_URL", "REACT_APP_SUPABASE_URL", "NUXT_PUBLIC_SUPABASE_URL"],
26
+ engine: "postgresql", provider: "supabase", urlPattern: /supabase\.co/ },
27
+ { keys: ["SUPABASE_DB_URL"], engine: "postgresql", provider: "supabase" },
28
+ // Neon
29
+ { keys: ["DATABASE_URL", "POSTGRES_URL", "NEON_DATABASE_URL"],
30
+ engine: "postgresql", provider: "neon", urlPattern: /neon\.tech/ },
31
+ // PlanetScale
32
+ { keys: ["DATABASE_URL"], engine: "mysql", provider: "planetscale", urlPattern: /planetscale|psdb\.cloud/ },
33
+ // Vercel Postgres
34
+ { keys: ["POSTGRES_URL", "POSTGRES_PRISMA_URL", "POSTGRES_URL_NON_POOLING"],
35
+ engine: "postgresql", provider: "vercel-postgres", urlPattern: /vercel-storage\.com/ },
36
+ // Railway
37
+ { keys: ["DATABASE_URL", "RAILWAY_DATABASE_URL"],
38
+ engine: "postgresql", provider: "railway", urlPattern: /railway\.app/ },
39
+ // Render
40
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "render", urlPattern: /render\.com/ },
41
+ // Fly.io
42
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "fly", urlPattern: /fly\.dev|flycast/ },
43
+ // Upstash
44
+ { keys: ["UPSTASH_REDIS_REST_URL", "KV_REST_API_URL"],
45
+ engine: "redis", provider: "upstash" },
46
+ // MongoDB Atlas
47
+ { keys: ["MONGODB_URI", "MONGO_URL", "MONGODB_URL", "DATABASE_URL"],
48
+ engine: "mongodb", provider: "mongodb-atlas", urlPattern: /mongodb\.net|mongodb\+srv/ },
49
+ // AWS RDS
50
+ { keys: ["DATABASE_URL", "RDS_HOSTNAME", "RDS_DB_NAME"],
51
+ engine: "postgresql", provider: "aws-rds", urlPattern: /rds\.amazonaws\.com/ },
52
+ // Azure
53
+ { keys: ["DATABASE_URL", "AZURE_SQL_CONNECTION_STRING"],
54
+ engine: "sqlserver", provider: "azure", urlPattern: /database\.windows\.net|azure/ },
55
+ // GCP Cloud SQL
56
+ { keys: ["DATABASE_URL", "CLOUD_SQL_CONNECTION_NAME"],
57
+ engine: "postgresql", provider: "gcp-cloudsql", urlPattern: /cloudsql|google/ },
58
+ // CockroachDB
59
+ { keys: ["DATABASE_URL"], engine: "cockroachdb", provider: "cockroach-cloud", urlPattern: /cockroachlabs\.cloud/ },
60
+ // Turso
61
+ { keys: ["TURSO_DATABASE_URL", "TURSO_AUTH_TOKEN"],
62
+ engine: "turso", provider: "turso-cloud", urlPattern: /turso\.io|libsql/ },
63
+ // Firebase
64
+ { keys: ["FIREBASE_DATABASE_URL", "NEXT_PUBLIC_FIREBASE_DATABASE_URL"],
65
+ engine: "firebase", provider: "firebase-google", urlPattern: /firebaseio\.com/ },
66
+ // Fauna
67
+ { keys: ["FAUNA_SECRET", "FAUNADB_SECRET"],
68
+ engine: "fauna", provider: "fauna-cloud" },
69
+ // Xata
70
+ { keys: ["XATA_API_KEY", "XATA_DATABASE_URL"],
71
+ engine: "xata", provider: "xata-cloud" },
72
+ // Convex
73
+ { keys: ["CONVEX_DEPLOYMENT", "NEXT_PUBLIC_CONVEX_URL"],
74
+ engine: "convex", provider: "convex-cloud" },
75
+ // Aiven
76
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "aiven", urlPattern: /aivencloud\.com/ },
77
+ // Tembo
78
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "tembo", urlPattern: /tembo\.io/ },
79
+ // Timescale
80
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "timescale", urlPattern: /timescaledb\.io|tsdb\.cloud/ },
81
+ // DigitalOcean
82
+ { keys: ["DATABASE_URL"], engine: "postgresql", provider: "digitalocean", urlPattern: /db\.ondigitalocean\.com/ },
83
+ // Generic PostgreSQL
84
+ { keys: ["DATABASE_URL", "DIRECT_URL", "POSTGRES_URL", "PG_CONNECTION_STRING", "DB_URL", "POSTGRES_PRISMA_URL"],
85
+ engine: "postgresql", provider: "unknown", urlPattern: /^postgres(ql)?:\/\// },
86
+ // Generic MySQL
87
+ { keys: ["DATABASE_URL", "MYSQL_URL", "DB_URL"],
88
+ engine: "mysql", provider: "unknown", urlPattern: /^mysql:\/\// },
89
+ // Generic Redis
90
+ { keys: ["REDIS_URL", "REDIS_TLS_URL"],
91
+ engine: "redis", provider: "unknown", urlPattern: /^redis(s)?:\/\// },
92
+ // Generic SQLite
93
+ { keys: ["DATABASE_URL"], engine: "sqlite", provider: "local", urlPattern: /^file:|\.sqlite|\.db$/ },
94
+ // Generic SQL Server
95
+ { keys: ["DATABASE_URL", "MSSQL_CONNECTION_STRING"],
96
+ engine: "sqlserver", provider: "unknown", urlPattern: /^mssql:\/\/|Server=/ },
97
+ ];
98
+ const DEP_SIGNALS = [
99
+ // ORMs (high confidence — they define the DB)
100
+ { packages: ["@prisma/client", "prisma"], engine: "postgresql", orm: "prisma", confidence: 90 },
101
+ { packages: ["drizzle-orm"], engine: "postgresql", orm: "drizzle", confidence: 85 },
102
+ { packages: ["typeorm"], engine: "postgresql", orm: "typeorm", confidence: 80 },
103
+ { packages: ["knex"], engine: "postgresql", orm: "knex", confidence: 75 },
104
+ { packages: ["sequelize"], engine: "postgresql", orm: "sequelize", confidence: 75 },
105
+ { packages: ["mongoose", "mongodb"], engine: "mongodb", orm: "mongoose", confidence: 90 },
106
+ { packages: ["@libsql/client", "better-sqlite3"], engine: "sqlite", orm: undefined, confidence: 85 },
107
+ // Direct drivers
108
+ { packages: ["pg", "@neondatabase/serverless"], engine: "postgresql", confidence: 95 },
109
+ { packages: ["mysql2", "mysql"], engine: "mysql", confidence: 95 },
110
+ { packages: ["redis", "ioredis", "@upstash/redis"], engine: "redis", confidence: 90 },
111
+ { packages: ["tedious", "mssql"], engine: "sqlserver", confidence: 90 },
112
+ // Managed DB SDKs
113
+ { packages: ["@supabase/supabase-js"], engine: "postgresql", confidence: 85 },
114
+ { packages: ["@planetscale/database"], engine: "mysql", confidence: 90 },
115
+ { packages: ["@vercel/postgres"], engine: "postgresql", confidence: 90 },
116
+ { packages: ["@neondatabase/serverless"], engine: "postgresql", confidence: 90 },
117
+ { packages: ["@libsql/client"], engine: "turso", confidence: 90 },
118
+ { packages: ["firebase", "firebase-admin"], engine: "firebase", confidence: 80 },
119
+ { packages: ["faunadb", "fauna"], engine: "fauna", confidence: 90 },
120
+ { packages: ["@xata.io/client"], engine: "xata", confidence: 90 },
121
+ { packages: ["convex"], engine: "convex", confidence: 85 },
122
+ { packages: ["@aws-sdk/client-dynamodb"], engine: "dynamodb", confidence: 90 },
123
+ ];
124
+ const CONFIG_SIGNALS = [
125
+ {
126
+ files: ["prisma/schema.prisma", "schema.prisma"],
127
+ engine: "postgresql", orm: "prisma",
128
+ parseProvider: (content) => {
129
+ if (content.includes("supabase"))
130
+ return "supabase";
131
+ if (content.includes("neon.tech"))
132
+ return "neon";
133
+ if (content.includes("planetscale"))
134
+ return "planetscale";
135
+ if (content.includes("cockroach"))
136
+ return "cockroach-cloud";
137
+ if (/provider\s*=\s*"mysql"/i.test(content))
138
+ return "unknown"; // engine override
139
+ if (/provider\s*=\s*"sqlite"/i.test(content))
140
+ return "local";
141
+ return "unknown";
142
+ },
143
+ },
144
+ {
145
+ files: ["drizzle.config.ts", "drizzle.config.js", "drizzle.config.mjs"],
146
+ engine: "postgresql", orm: "drizzle",
147
+ },
148
+ {
149
+ files: ["knexfile.js", "knexfile.ts"],
150
+ engine: "postgresql", orm: "knex",
151
+ },
152
+ {
153
+ files: ["ormconfig.json", "ormconfig.ts", "ormconfig.js"],
154
+ engine: "postgresql", orm: "typeorm",
155
+ },
156
+ {
157
+ files: [".sequelizerc", "config/database.js", "config/database.json"],
158
+ engine: "postgresql", orm: "sequelize",
159
+ },
160
+ {
161
+ files: ["firebase.json", ".firebaserc"],
162
+ engine: "firebase",
163
+ },
164
+ {
165
+ files: ["convex/_generated", "convex/schema.ts"],
166
+ engine: "convex",
167
+ },
168
+ {
169
+ files: ["wrangler.toml"], // D1
170
+ engine: "d1",
171
+ parseProvider: (content) => content.includes("d1_databases") ? "cloudflare-d1" : null,
172
+ },
173
+ ];
174
+ // ── Engine scan capabilities ──
175
+ const SCAN_SUPPORT = {
176
+ postgresql: { scannable: true, method: "direct_sql" },
177
+ mysql: { scannable: true, method: "direct_sql" },
178
+ cockroachdb: { scannable: true, method: "direct_sql" },
179
+ sqlserver: { scannable: false, method: "not_yet_supported" },
180
+ mongodb: { scannable: false, method: "not_yet_supported" },
181
+ redis: { scannable: false, method: "not_applicable" },
182
+ sqlite: { scannable: false, method: "not_yet_supported" },
183
+ turso: { scannable: false, method: "not_yet_supported" },
184
+ dynamodb: { scannable: false, method: "not_applicable" },
185
+ firebase: { scannable: false, method: "not_applicable" },
186
+ fauna: { scannable: false, method: "not_applicable" },
187
+ xata: { scannable: false, method: "not_yet_supported" },
188
+ convex: { scannable: false, method: "not_applicable" },
189
+ d1: { scannable: false, method: "not_yet_supported" },
190
+ unknown: { scannable: false, method: "unknown" },
191
+ };
192
+ // ── Mask connection string ──
193
+ function maskValue(val) {
194
+ if (val.includes("://")) {
195
+ try {
196
+ const u = new URL(val);
197
+ return `${u.protocol}//${u.username ? u.username.slice(0, 4) + "***" : "***"}@${u.hostname}:${u.port || "???"}${u.pathname}`;
198
+ }
199
+ catch { /* fallback */ }
200
+ }
201
+ if (val.length > 20)
202
+ return val.slice(0, 8) + "..." + val.slice(-4);
203
+ return val.slice(0, 4) + "***";
204
+ }
205
+ // ── Main detection ──
206
+ function detectDatabases(projectDir) {
207
+ const databases = [];
208
+ const envFilesScanned = [];
209
+ const configFilesFound = [];
210
+ let ormDetected = null;
211
+ let frameworkDetected = null;
212
+ const seen = new Set(); // dedup by connection_key + engine
213
+ // ── Phase 1: Scan .env files ──
214
+ const envFiles = [
215
+ ".env", ".env.local", ".env.development", ".env.development.local",
216
+ ".env.production", ".env.production.local", ".env.staging",
217
+ ];
218
+ const envVars = new Map();
219
+ for (const envFile of envFiles) {
220
+ const filePath = (0, path_1.resolve)(projectDir, envFile);
221
+ if (!(0, fs_1.existsSync)(filePath))
222
+ continue;
223
+ envFilesScanned.push(envFile);
224
+ try {
225
+ const content = (0, fs_1.readFileSync)(filePath, "utf-8");
226
+ for (const line of content.split("\n")) {
227
+ const trimmed = line.trim();
228
+ if (!trimmed || trimmed.startsWith("#"))
229
+ continue;
230
+ const eqIdx = trimmed.indexOf("=");
231
+ if (eqIdx < 1)
232
+ continue;
233
+ const key = trimmed.slice(0, eqIdx).trim();
234
+ let val = trimmed.slice(eqIdx + 1).trim();
235
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
236
+ val = val.slice(1, -1);
237
+ }
238
+ if (val)
239
+ envVars.set(key, { val, source: envFile });
240
+ }
241
+ }
242
+ catch { /* skip */ }
243
+ }
244
+ // Match env vars to patterns
245
+ for (const pattern of ENV_PATTERNS) {
246
+ for (const key of pattern.keys) {
247
+ const entry = envVars.get(key);
248
+ if (!entry)
249
+ continue;
250
+ // Check URL pattern if specified
251
+ if (pattern.urlPattern && !pattern.urlPattern.test(entry.val))
252
+ continue;
253
+ const dedup = `${key}:${pattern.engine}:${pattern.provider}`;
254
+ if (seen.has(dedup))
255
+ continue;
256
+ seen.add(dedup);
257
+ const support = SCAN_SUPPORT[pattern.engine];
258
+ databases.push({
259
+ engine: pattern.engine,
260
+ provider: pattern.provider,
261
+ source: entry.source,
262
+ connection_key: key,
263
+ connection_value: maskValue(entry.val),
264
+ raw_value: entry.val,
265
+ scannable: support.scannable,
266
+ scan_method: support.method,
267
+ confidence: 95,
268
+ details: `Found ${key} in ${entry.source} → ${pattern.engine} (${pattern.provider})`,
269
+ });
270
+ }
271
+ }
272
+ // ── Phase 2: Scan package.json ──
273
+ const pkgPath = (0, path_1.resolve)(projectDir, "package.json");
274
+ if ((0, fs_1.existsSync)(pkgPath)) {
275
+ try {
276
+ const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, "utf-8"));
277
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
278
+ // Framework detection
279
+ if (allDeps.next)
280
+ frameworkDetected = "nextjs";
281
+ else if (allDeps.nuxt)
282
+ frameworkDetected = "nuxt";
283
+ else if (allDeps.express)
284
+ frameworkDetected = "express";
285
+ else if (allDeps.fastify)
286
+ frameworkDetected = "fastify";
287
+ else if (allDeps["@sveltejs/kit"])
288
+ frameworkDetected = "sveltekit";
289
+ else if (allDeps["@angular/core"])
290
+ frameworkDetected = "angular";
291
+ for (const signal of DEP_SIGNALS) {
292
+ const found = signal.packages.find(p => allDeps[p]);
293
+ if (!found)
294
+ continue;
295
+ if (signal.orm && !ormDetected)
296
+ ormDetected = signal.orm;
297
+ const dedup = `dep:${found}:${signal.engine}`;
298
+ if (seen.has(dedup))
299
+ continue;
300
+ seen.add(dedup);
301
+ const support = SCAN_SUPPORT[signal.engine];
302
+ databases.push({
303
+ engine: signal.engine,
304
+ provider: "unknown",
305
+ source: "package.json",
306
+ connection_key: found,
307
+ connection_value: allDeps[found],
308
+ raw_value: "",
309
+ scannable: support.scannable,
310
+ scan_method: support.method,
311
+ confidence: signal.confidence,
312
+ details: `Dependency "${found}" detected → ${signal.engine}${signal.orm ? ` (ORM: ${signal.orm})` : ""}`,
313
+ });
314
+ }
315
+ }
316
+ catch { /* skip */ }
317
+ }
318
+ // Also check apps/*/package.json for monorepos
319
+ for (const subDir of ["apps/web", "apps/api", "apps/server", "packages/db", "packages/database"]) {
320
+ const subPkg = (0, path_1.resolve)(projectDir, subDir, "package.json");
321
+ if (!(0, fs_1.existsSync)(subPkg))
322
+ continue;
323
+ try {
324
+ const pkg = JSON.parse((0, fs_1.readFileSync)(subPkg, "utf-8"));
325
+ const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
326
+ for (const signal of DEP_SIGNALS) {
327
+ const found = signal.packages.find(p => allDeps[p]);
328
+ if (!found)
329
+ continue;
330
+ const dedup = `dep:${subDir}:${found}:${signal.engine}`;
331
+ if (seen.has(dedup))
332
+ continue;
333
+ seen.add(dedup);
334
+ if (signal.orm && !ormDetected)
335
+ ormDetected = signal.orm;
336
+ const support = SCAN_SUPPORT[signal.engine];
337
+ databases.push({
338
+ engine: signal.engine, provider: "unknown",
339
+ source: `${subDir}/package.json`,
340
+ connection_key: found, connection_value: allDeps[found], raw_value: "",
341
+ scannable: support.scannable, scan_method: support.method,
342
+ confidence: signal.confidence,
343
+ details: `Dependency "${found}" in ${subDir} → ${signal.engine}`,
344
+ });
345
+ }
346
+ }
347
+ catch { /* skip */ }
348
+ }
349
+ // ── Phase 3: Scan config files ──
350
+ for (const signal of CONFIG_SIGNALS) {
351
+ for (const file of signal.files) {
352
+ const filePath = (0, path_1.resolve)(projectDir, file);
353
+ if (!(0, fs_1.existsSync)(filePath))
354
+ continue;
355
+ configFilesFound.push(file);
356
+ const dedup = `config:${file}:${signal.engine}`;
357
+ if (seen.has(dedup))
358
+ continue;
359
+ seen.add(dedup);
360
+ let provider = "unknown";
361
+ try {
362
+ const content = (0, fs_1.readFileSync)(filePath, "utf-8");
363
+ if (signal.parseProvider) {
364
+ provider = signal.parseProvider(content) || "unknown";
365
+ }
366
+ }
367
+ catch { /* skip */ }
368
+ if (signal.orm && !ormDetected)
369
+ ormDetected = signal.orm;
370
+ const support = SCAN_SUPPORT[signal.engine];
371
+ databases.push({
372
+ engine: signal.engine, provider,
373
+ source: file, connection_key: file, connection_value: "", raw_value: "",
374
+ scannable: support.scannable, scan_method: support.method,
375
+ confidence: 80,
376
+ details: `Config file "${file}" detected → ${signal.engine}${signal.orm ? ` (ORM: ${signal.orm})` : ""}`,
377
+ });
378
+ }
379
+ }
380
+ // ── Deduplicate: prefer env vars over deps, higher confidence wins ──
381
+ const unique = new Map();
382
+ for (const db of databases) {
383
+ const key = `${db.engine}:${db.provider !== "unknown" ? db.provider : db.connection_key}`;
384
+ const existing = unique.get(key);
385
+ if (!existing || db.confidence > existing.confidence || (db.raw_value && !existing.raw_value)) {
386
+ unique.set(key, db);
387
+ }
388
+ }
389
+ return {
390
+ databases: [...unique.values()].sort((a, b) => b.confidence - a.confidence),
391
+ orm_detected: ormDetected,
392
+ framework_detected: frameworkDetected,
393
+ env_files_scanned: envFilesScanned,
394
+ config_files_found: configFilesFound,
395
+ total_signals: databases.length,
396
+ };
397
+ }
398
+ // ── Pretty print detection results ──
399
+ function printDetectionResults(result) {
400
+ console.log("");
401
+ console.log(" ╔══════════════════════════════════════════╗");
402
+ console.log(" ║ \x1b[33mDatabase Detection Results\x1b[0m ║");
403
+ console.log(" ╚══════════════════════════════════════════╝");
404
+ console.log("");
405
+ if (result.framework_detected) {
406
+ console.log(` Framework: \x1b[36m${result.framework_detected}\x1b[0m`);
407
+ }
408
+ if (result.orm_detected) {
409
+ console.log(` ORM: \x1b[36m${result.orm_detected}\x1b[0m`);
410
+ }
411
+ console.log(` Scanned: ${result.env_files_scanned.length} env files, ${result.config_files_found.length} config files`);
412
+ console.log(` Signals: ${result.total_signals} total`);
413
+ console.log("");
414
+ if (result.databases.length === 0) {
415
+ console.log(" \x1b[33mNo databases detected.\x1b[0m");
416
+ console.log(" Add DATABASE_URL to .env.local or install a database driver.\n");
417
+ return;
418
+ }
419
+ console.log(` Found \x1b[1m${result.databases.length}\x1b[0m database(s):\n`);
420
+ for (let i = 0; i < result.databases.length; i++) {
421
+ const db = result.databases[i];
422
+ const num = ` ${i + 1}.`;
423
+ const engineColor = db.scannable ? "\x1b[32m" : "\x1b[33m";
424
+ const scanLabel = db.scannable ? "\x1b[32m✓ scannable\x1b[0m" : `\x1b[33m○ ${db.scan_method}\x1b[0m`;
425
+ console.log(`${num} ${engineColor}${db.engine.toUpperCase()}\x1b[0m — ${db.provider} ${scanLabel}`);
426
+ console.log(` Source: ${db.source}`);
427
+ if (db.connection_value) {
428
+ console.log(` Connection: ${db.connection_value}`);
429
+ }
430
+ console.log(` Confidence: ${db.confidence}%`);
431
+ console.log(` ${db.details}`);
432
+ console.log("");
433
+ }
434
+ const scannable = result.databases.filter(d => d.scannable);
435
+ if (scannable.length > 0) {
436
+ console.log(` \x1b[32m${scannable.length} database(s) can be scanned for encryption.\x1b[0m`);
437
+ }
438
+ const notScannable = result.databases.filter(d => !d.scannable);
439
+ if (notScannable.length > 0) {
440
+ console.log(` \x1b[33m${notScannable.length} database(s) not yet supported for scanning.\x1b[0m`);
441
+ }
442
+ console.log("");
443
+ }
@@ -1,7 +1,123 @@
1
1
  import type { PIIFieldResult, RLSResult, InfraResult } from "./types";
2
+ export interface DbEvidence {
3
+ connection: ConnectionEvidence;
4
+ schema: SchemaEvidence;
5
+ encryption: EncryptionEvidence;
6
+ rls: RlsEvidence;
7
+ infrastructure: InfraEvidence;
8
+ security_config: SecurityConfigEvidence;
9
+ collected_at: string;
10
+ scan_duration_ms: number;
11
+ }
12
+ interface ConnectionEvidence {
13
+ ssl_in_use: boolean;
14
+ ssl_version: string | null;
15
+ ssl_cipher: string | null;
16
+ ssl_bits: number | null;
17
+ server_host: string;
18
+ server_version: string;
19
+ connection_encrypted: boolean;
20
+ }
21
+ interface SchemaEvidence {
22
+ total_tables: number;
23
+ total_columns: number;
24
+ pii_columns_found: number;
25
+ tables_with_pii: string[];
26
+ companion_columns_found: number;
27
+ }
28
+ interface EncryptionEvidence {
29
+ fields: FieldEncryptionEvidence[];
30
+ summary: {
31
+ total_pii_fields: number;
32
+ encrypted_fields: number;
33
+ plaintext_fields: number;
34
+ total_rows_scanned: number;
35
+ total_rows_encrypted: number;
36
+ total_rows_plaintext: number;
37
+ total_rows_null: number;
38
+ encryption_patterns_found: string[];
39
+ coverage_percent: number;
40
+ };
41
+ }
42
+ interface FieldEncryptionEvidence {
43
+ table: string;
44
+ column: string;
45
+ pii_type: string;
46
+ data_type: string;
47
+ total_rows: number;
48
+ non_null_rows: number;
49
+ null_rows: number;
50
+ encrypted_rows: number;
51
+ plaintext_rows: number;
52
+ coverage_percent: number;
53
+ pattern_counts: {
54
+ aes256gcm_v1: number;
55
+ enc_v1: number;
56
+ enc_any: number;
57
+ ndc_enc: number;
58
+ base64_long: number;
59
+ };
60
+ dominant_pattern: string;
61
+ has_companion: boolean;
62
+ companion_encrypted_rows: number;
63
+ is_encrypted: boolean;
64
+ evidence_source: string;
65
+ }
66
+ interface RlsEvidence {
67
+ tables: RlsTableEvidence[];
68
+ summary: {
69
+ total_tables: number;
70
+ rls_enabled_count: number;
71
+ rls_disabled_count: number;
72
+ total_policies: number;
73
+ coverage_percent: number;
74
+ tables_without_rls: string[];
75
+ };
76
+ }
77
+ interface RlsTableEvidence {
78
+ table: string;
79
+ rls_enabled: boolean;
80
+ force_rls: boolean;
81
+ policy_count: number;
82
+ policies: Array<{
83
+ name: string;
84
+ command: string;
85
+ permissive: string;
86
+ }>;
87
+ }
88
+ interface InfraEvidence {
89
+ db_type: string;
90
+ db_version_full: string;
91
+ db_version_short: string;
92
+ ssl_enabled: boolean;
93
+ extensions: string[];
94
+ security_extensions: string[];
95
+ encrypt_functions: string[];
96
+ encrypt_triggers: string[];
97
+ has_pgcrypto: boolean;
98
+ has_pgsodium: boolean;
99
+ }
100
+ interface SecurityConfigEvidence {
101
+ password_encryption: string;
102
+ ssl_setting: string;
103
+ log_connections: boolean;
104
+ log_disconnections: boolean;
105
+ log_statement: string;
106
+ statement_timeout: string;
107
+ row_security: boolean;
108
+ roles_with_superuser: number;
109
+ roles_with_createdb: number;
110
+ roles_with_login: number;
111
+ search_path: string;
112
+ current_user: string;
113
+ session_user: string;
114
+ is_superuser: boolean;
115
+ }
2
116
  export declare function scanDatabase(connectionString: string, onProgress?: (msg: string) => void): Promise<{
3
117
  tables: number;
4
118
  pii_fields: PIIFieldResult[];
5
119
  rls: RLSResult[];
6
120
  infra: InfraResult;
121
+ evidence?: DbEvidence;
7
122
  }>;
123
+ export {};
@@ -1,37 +1,39 @@
1
1
  "use strict";
2
- // ═══════════════════════════════════════════════════════════
3
- // @nodatachat/guard — Database Scanner (Blind Probe)
2
+ // ═══════════════════════���═══════════════════════════════════
3
+ // @nodatachat/guard — Database Scanner (Forensic Probe)
4
+ //
5
+ // Comprehensive, evidence-based database security analysis.
6
+ // Every finding is backed by a DB query result — zero assumptions.
4
7
  //
5
- // Connects to the customer's DB and checks encryption status.
6
8
  // READS ONLY:
7
- // - Schema (table/column names, data types)
8
- // - Counts (row counts, encrypted counts)
9
- // - Prefixes (LEFT(value, N) detects encryption without reading data):
10
- // "aes256gcm:v1:" (13 chars)direct AES-256-GCM
11
- // "enc:v1:" (7 chars) — Capsule Proxy encryption
12
- // "ndc_enc_" (8 chars) — @nodatachat/protect format
13
- // - System tables (pg_policies, pg_user, pg_settings, pg_extension)
9
+ // - Schema metadata (information_schema, pg_catalog)
10
+ // - Value PREFIXES via LEFT(col, N) — never actual data
11
+ // - System views (pg_settings, pg_policies, pg_roles, pg_stat_ssl)
12
+ // - Counts and aggregates never individual rows
14
13
  //
15
- // NEVER READS: actual data values, passwords, tokens, emails, phones
16
- // ═══════════════════════════════════════════════════════════
14
+ // NEVER READS: actual data values, passwords, tokens, PII content
15
+ // ════════════════════��══════════════════════════���═══════════
17
16
  Object.defineProperty(exports, "__esModule", { value: true });
18
17
  exports.scanDatabase = scanDatabase;
19
18
  const pg_1 = require("pg");
20
- // ── PII patterns (same as code-scanner) ──
19
+ // ── PII column name patterns ──
21
20
  const PII_PATTERNS = [
22
- { pattern: /^(email|e_?mail|email_?address)$/i, type: "email" },
23
- { pattern: /^(phone|mobile|telephone|phone_?number)$/i, type: "phone" },
24
- { pattern: /^(address|street_?address|home_?address)$/i, type: "address" },
25
- { pattern: /^(ip_?address)$/i, type: "tracking" },
26
- { pattern: /^(latitude|longitude|lat|lng)$/i, type: "location" },
21
+ { pattern: /^(email|e_?mail|email_?address|user_?email|contact_?email)$/i, type: "email" },
22
+ { pattern: /^(phone|mobile|telephone|phone_?number|cell_?phone|contact_?phone|sms_?number)$/i, type: "phone" },
23
+ { pattern: /^(address|street_?address|home_?address|mailing_?address|postal_?address)$/i, type: "address" },
24
+ { pattern: /^(ip_?address|client_?ip|remote_?ip|source_?ip|user_?ip)$/i, type: "tracking" },
25
+ { pattern: /^(latitude|longitude|lat|lng|geo_?location|coordinates)$/i, type: "location" },
27
26
  { pattern: /^(signature|digital_?signature)$/i, type: "id" },
28
- { pattern: /^(full_?name|first_?name|last_?name)$/i, type: "name" },
29
- { pattern: /^(id_?number|ssn|national_?id|passport)$/i, type: "id" },
30
- { pattern: /^(bank_?account|credit_?card|iban)$/i, type: "financial" },
31
- { pattern: /^(salary|income)$/i, type: "financial" },
32
- { pattern: /^(date_?of_?birth|dob)$/i, type: "dob" },
27
+ { pattern: /^(full_?name|first_?name|last_?name|spouse_?name|beneficiary_?name|given_?name|family_?name)$/i, type: "name" },
28
+ { pattern: /^(id_?number|ssn|national_?id|passport|driver_?license|identity_?number|teudat_?zehut)$/i, type: "id" },
29
+ { pattern: /^(bank_?account|credit_?card|iban|account_?number|card_?number|routing_?number)$/i, type: "financial" },
30
+ { pattern: /^(salary|income|compensation|wage)$/i, type: "financial" },
31
+ { pattern: /^(date_?of_?birth|dob|birth_?date|birthday)$/i, type: "dob" },
32
+ { pattern: /^(biometric|fingerprint|face_?id|voice_?print)$/i, type: "biometric" },
33
+ { pattern: /^(device_?id|device_?fingerprint|browser_?fingerprint)$/i, type: "tracking" },
34
+ { pattern: /^(user_?agent)$/i, type: "tracking" },
33
35
  ];
34
- 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;
36
+ const SKIP_COLUMNS = /^(id|uuid|created_?at|updated_?at|status|type|description|title|name|display_?name|password_?hash|password|secret|token|key|value|metadata|config|settings|data|content|body|slug|enabled|active|version|role)$/i;
35
37
  function classifyColumn(col) {
36
38
  if (SKIP_COLUMNS.test(col))
37
39
  return null;
@@ -48,31 +50,44 @@ function quoteIdent(name) {
48
50
  }
49
51
  // ── Main DB scan ──
50
52
  async function scanDatabase(connectionString, onProgress) {
53
+ const startTime = Date.now();
51
54
  const client = new pg_1.Client({
52
55
  connectionString,
53
- ssl: connectionString.includes("supabase.co") ? { rejectUnauthorized: false } : undefined,
54
- statement_timeout: 10000,
56
+ ssl: connectionString.includes("supabase.co") || connectionString.includes("pooler")
57
+ ? { rejectUnauthorized: false }
58
+ : undefined,
59
+ statement_timeout: 15000,
55
60
  });
56
61
  try {
57
62
  await client.connect();
58
63
  onProgress?.("Connected to database");
59
- // ── 1. Schema discovery ──
64
+ // ═══════════════════════════════════════════════════════
65
+ // EVIDENCE LAYER 1: Connection Security
66
+ // ═══════════��═══════════════════════════════════════════
67
+ onProgress?.("Verifying connection security...");
68
+ const connectionEvidence = await collectConnectionEvidence(client);
69
+ // ═══════════════════════════════════════════════════════
70
+ // EVIDENCE LAYER 2: Schema Discovery
71
+ // ═════════════════════════════════════════════════��═════
60
72
  onProgress?.("Discovering schema...");
61
73
  const { rows: columns } = await client.query(`
62
- SELECT table_name, column_name, data_type
74
+ SELECT table_name, column_name, data_type, is_nullable
63
75
  FROM information_schema.columns
64
76
  WHERE table_schema = 'public'
65
77
  ORDER BY table_name, ordinal_position
66
78
  `);
67
79
  const tables = new Set();
68
80
  const columnSet = new Set();
81
+ const columnTypes = new Map();
69
82
  for (const row of columns) {
70
83
  tables.add(row.table_name);
71
84
  columnSet.add(`${row.table_name}.${row.column_name}`);
85
+ columnTypes.set(`${row.table_name}.${row.column_name}`, row.data_type);
72
86
  }
73
87
  // Find PII fields
74
88
  const piiFields = [];
75
89
  const seen = new Set();
90
+ const tablesWithPii = new Set();
76
91
  for (const row of columns) {
77
92
  const piiType = classifyColumn(row.column_name);
78
93
  if (!piiType)
@@ -81,6 +96,7 @@ async function scanDatabase(connectionString, onProgress) {
81
96
  if (seen.has(key))
82
97
  continue;
83
98
  seen.add(key);
99
+ tablesWithPii.add(row.table_name);
84
100
  piiFields.push({
85
101
  table: row.table_name,
86
102
  column: row.column_name,
@@ -92,122 +108,357 @@ async function scanDatabase(connectionString, onProgress) {
92
108
  encrypted_count: 0,
93
109
  });
94
110
  }
95
- // ── 2. Encryption detection ──
96
- onProgress?.(`Checking encryption on ${piiFields.length} PII fields...`);
111
+ const companionCount = piiFields.filter(f => f.has_companion_column).length;
112
+ const schemaEvidence = {
113
+ total_tables: tables.size,
114
+ total_columns: columns.length,
115
+ pii_columns_found: piiFields.length,
116
+ tables_with_pii: [...tablesWithPii],
117
+ companion_columns_found: companionCount,
118
+ };
119
+ // ═══════════════════════════════════════════════════════
120
+ // EVIDENCE LAYER 3: Encryption Verification (per-field)
121
+ // ═══════════════════════════════════════════════════════
122
+ onProgress?.(`Verifying encryption on ${piiFields.length} PII fields...`);
123
+ const fieldEvidence = [];
124
+ let totalRowsScanned = 0, totalEncrypted = 0, totalPlaintext = 0, totalNull = 0;
125
+ const patternsFound = new Set();
97
126
  for (const field of piiFields) {
98
- // Check both original and _encrypted companion
99
- const columnsToCheck = [
127
+ const colsToCheck = [
100
128
  { col: field.column, isCompanion: false },
101
129
  ];
102
130
  if (field.has_companion_column) {
103
- columnsToCheck.push({ col: `${field.column}_encrypted`, isCompanion: true });
131
+ colsToCheck.push({ col: `${field.column}_encrypted`, isCompanion: true });
104
132
  }
105
- for (const { col, isCompanion } of columnsToCheck) {
133
+ const fe = {
134
+ table: field.table,
135
+ column: field.column,
136
+ pii_type: field.pii_type,
137
+ data_type: columnTypes.get(`${field.table}.${field.column}`) || "unknown",
138
+ total_rows: 0, non_null_rows: 0, null_rows: 0,
139
+ encrypted_rows: 0, plaintext_rows: 0, coverage_percent: 0,
140
+ pattern_counts: { aes256gcm_v1: 0, enc_v1: 0, enc_any: 0, ndc_enc: 0, base64_long: 0 },
141
+ dominant_pattern: "none",
142
+ has_companion: field.has_companion_column,
143
+ companion_encrypted_rows: 0,
144
+ is_encrypted: false,
145
+ evidence_source: "none",
146
+ };
147
+ for (const { col, isCompanion } of colsToCheck) {
106
148
  try {
107
- // SAFE: LEFT(value, N) reads only the prefix — never actual data
108
- // Detects multiple encryption formats:
109
- // aes256gcm:v1: — legacy direct encryption (13 chars)
110
- // enc:v1: — Capsule Proxy encryption (7 chars)
111
- // ndc_enc_ — @nodatachat/protect format (8 chars)
112
- // enc: — any enc: prefix variant (4 chars)
113
- // Also checks for long base64 strings (>80 chars, alphanumeric)
114
149
  const { rows } = await client.query(`
115
150
  SELECT
116
- count(*) as total,
117
- count(${quoteIdent(col)}) as non_null,
118
- count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 13) = 'aes256gcm:v1:') as aes_gcm,
119
- count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 7) = 'enc:v1:') as enc_v1,
120
- count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 4) = 'enc:') as enc_any,
121
- count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 8) = 'ndc_enc_') as ndc_enc,
151
+ count(*)::int as total,
152
+ count(${quoteIdent(col)})::int as non_null,
153
+ (count(*) - count(${quoteIdent(col)}))::int as null_count,
154
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 13) = 'aes256gcm:v1:')::int as aes_gcm,
155
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 7) = 'enc:v1:')::int as enc_v1,
156
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 4) = 'enc:')::int as enc_any,
157
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 8) = 'ndc_enc_')::int as ndc_enc,
122
158
  count(*) FILTER (WHERE LENGTH(${quoteIdent(col)}::text) > 80
123
- AND ${quoteIdent(col)}::text ~ '^[A-Za-z0-9+/=:_-]+$') as base64_long
159
+ AND ${quoteIdent(col)}::text ~ '^[A-Za-z0-9+/=:_-]+$')::int as base64_long
124
160
  FROM ${quoteIdent(field.table)}
125
161
  `);
126
- const total = parseInt(rows[0].total);
127
- const nonNull = parseInt(rows[0].non_null);
128
- const aesGcm = parseInt(rows[0].aes_gcm);
129
- const encV1 = parseInt(rows[0].enc_v1);
130
- const encAny = parseInt(rows[0].enc_any);
131
- const ndcEnc = parseInt(rows[0].ndc_enc);
132
- const b64 = parseInt(rows[0].base64_long);
133
- // enc_any catches enc:v1:, enc:v2:, enc:aes:, etc.
134
- // Use the most specific match for naming, but count all enc: variants
135
- const encDirectPrefixes = Math.max(aesGcm, encV1, encAny, ndcEnc);
136
- const encCount = encDirectPrefixes + (encDirectPrefixes === 0 ? b64 : 0);
137
- // Determine pattern name for reporting
162
+ const r = rows[0];
163
+ const total = r.total, nonNull = r.non_null, nullCount = r.null_count;
164
+ const aesGcm = r.aes_gcm, encV1 = r.enc_v1, encAny = r.enc_any;
165
+ const ndcEnc = r.ndc_enc, b64 = r.base64_long;
166
+ const encCount = Math.max(aesGcm, encV1, encAny, ndcEnc, b64);
138
167
  const pattern = aesGcm > 0 ? "aes256gcm:v1"
139
- : encV1 > 0 ? "enc:v1 (Capsule Proxy)"
140
- : encAny > 0 ? "enc: (encrypted prefix)"
141
- : ndcEnc > 0 ? "ndc_enc (Protect)"
168
+ : encV1 > 0 ? "enc:v1"
169
+ : encAny > 0 ? "enc:"
170
+ : ndcEnc > 0 ? "ndc_enc"
142
171
  : b64 > 0 ? "base64_long"
143
- : "unknown";
144
- if (isCompanion && encCount > 0) {
145
- field.encrypted = true;
146
- field.encryption_pattern = pattern;
147
- field.encrypted_count = encCount;
172
+ : "none";
173
+ if (isCompanion) {
174
+ fe.companion_encrypted_rows = encCount;
175
+ if (encCount > 0) {
176
+ fe.is_encrypted = true;
177
+ fe.evidence_source = `companion:${pattern}`;
178
+ fe.dominant_pattern = pattern;
179
+ if (pattern !== "none")
180
+ patternsFound.add(pattern);
181
+ }
148
182
  }
149
- else if (!isCompanion) {
183
+ else {
184
+ fe.total_rows = total;
185
+ fe.non_null_rows = nonNull;
186
+ fe.null_rows = nullCount;
187
+ fe.encrypted_rows = encCount;
188
+ fe.plaintext_rows = nonNull - encCount;
189
+ fe.coverage_percent = nonNull > 0 ? Math.round((encCount / nonNull) * 100) : 0;
190
+ fe.pattern_counts = {
191
+ aes256gcm_v1: aesGcm, enc_v1: encV1, enc_any: encAny,
192
+ ndc_enc: ndcEnc, base64_long: b64,
193
+ };
194
+ if (encCount > 0) {
195
+ fe.is_encrypted = true;
196
+ fe.dominant_pattern = pattern;
197
+ fe.evidence_source = `prefix:${pattern}`;
198
+ if (pattern !== "none")
199
+ patternsFound.add(pattern);
200
+ }
201
+ // Update PIIFieldResult for backward compat
150
202
  field.row_count = total;
151
- // If even ONE row has an encryption prefix → encryption is implemented.
152
- // Coverage (what % of rows) is tracked separately for the report.
153
203
  if (encCount > 0) {
154
204
  field.encrypted = true;
155
205
  field.encryption_pattern = pattern;
156
206
  field.encrypted_count = encCount;
157
207
  field.sentinel_prefix = pattern;
158
208
  }
209
+ totalRowsScanned += total;
210
+ totalEncrypted += encCount;
211
+ totalPlaintext += (nonNull - encCount);
212
+ totalNull += nullCount;
159
213
  }
160
214
  }
161
- catch { /* column might not exist */ }
215
+ catch { /* column might not exist or query error */ }
162
216
  }
217
+ fieldEvidence.push(fe);
163
218
  }
164
- // ── 3. RLS ──
165
- onProgress?.("Checking Row-Level Security...");
219
+ const encryptionEvidence = {
220
+ fields: fieldEvidence,
221
+ summary: {
222
+ total_pii_fields: piiFields.length,
223
+ encrypted_fields: piiFields.filter(f => f.encrypted).length,
224
+ plaintext_fields: piiFields.filter(f => !f.encrypted).length,
225
+ total_rows_scanned: totalRowsScanned,
226
+ total_rows_encrypted: totalEncrypted,
227
+ total_rows_plaintext: totalPlaintext,
228
+ total_rows_null: totalNull,
229
+ encryption_patterns_found: [...patternsFound],
230
+ coverage_percent: totalRowsScanned > 0
231
+ ? Math.round((totalEncrypted / (totalEncrypted + totalPlaintext)) * 100) : 0,
232
+ },
233
+ };
234
+ // ═══════════════════════════════════════════════════════
235
+ // EVIDENCE LAYER 4: Row-Level Security (per-table)
236
+ // ═════════════════════════════════════════════��═════════
237
+ onProgress?.("Analyzing Row-Level Security policies...");
166
238
  const { rows: rlsTables } = await client.query(`
167
239
  SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public'
168
240
  `);
241
+ // Force RLS check
242
+ let forceRlsMap = new Map();
243
+ try {
244
+ const { rows: forceRows } = await client.query(`
245
+ SELECT relname, relforcerowsecurity
246
+ FROM pg_class c JOIN pg_namespace n ON c.relnamespace = n.oid
247
+ WHERE n.nspname = 'public' AND c.relkind = 'r'
248
+ `);
249
+ for (const r of forceRows)
250
+ forceRlsMap.set(r.relname, r.relforcerowsecurity);
251
+ }
252
+ catch { /* older PG versions */ }
253
+ // Policy details
169
254
  const { rows: rlsPolicies } = await client.query(`
170
- SELECT tablename, policyname FROM pg_policies WHERE schemaname = 'public'
255
+ SELECT tablename, policyname, cmd, permissive
256
+ FROM pg_policies WHERE schemaname = 'public'
171
257
  `);
172
- const policyCount = new Map();
258
+ const policyMap = new Map();
173
259
  for (const p of rlsPolicies) {
174
- policyCount.set(p.tablename, (policyCount.get(p.tablename) || 0) + 1);
260
+ const list = policyMap.get(p.tablename) || [];
261
+ list.push({ name: p.policyname, command: p.cmd, permissive: p.permissive });
262
+ policyMap.set(p.tablename, list);
175
263
  }
176
- const rls = rlsTables.map((t) => ({
264
+ const rlsTableEvidence = rlsTables.map((t) => ({
177
265
  table: t.tablename,
178
266
  rls_enabled: t.rowsecurity,
179
- policy_count: policyCount.get(t.tablename) || 0,
267
+ force_rls: forceRlsMap.get(t.tablename) ?? false,
268
+ policy_count: policyMap.get(t.tablename)?.length ?? 0,
269
+ policies: policyMap.get(t.tablename) || [],
180
270
  }));
181
- // ── 4. Infrastructure ──
182
- onProgress?.("Checking infrastructure...");
271
+ const tablesWithoutRls = rlsTableEvidence.filter(t => !t.rls_enabled).map(t => t.table);
272
+ const rlsEvidence = {
273
+ tables: rlsTableEvidence,
274
+ summary: {
275
+ total_tables: rlsTableEvidence.length,
276
+ rls_enabled_count: rlsTableEvidence.filter(t => t.rls_enabled).length,
277
+ rls_disabled_count: tablesWithoutRls.length,
278
+ total_policies: rlsPolicies.length,
279
+ coverage_percent: rlsTableEvidence.length > 0
280
+ ? Math.round((rlsTableEvidence.filter(t => t.rls_enabled).length / rlsTableEvidence.length) * 100) : 0,
281
+ tables_without_rls: tablesWithoutRls,
282
+ },
283
+ };
284
+ // Backward compat
285
+ const rls = rlsTableEvidence.map(t => ({
286
+ table: t.table, rls_enabled: t.rls_enabled, policy_count: t.policy_count,
287
+ }));
288
+ // ═══════════════════════════════════════════════════════
289
+ // EVIDENCE LAYER 5: Infrastructure & Extensions
290
+ // ═══════════════════════════════════════════════════════
291
+ onProgress?.("Collecting infrastructure evidence...");
183
292
  const { rows: vRows } = await client.query("SELECT version()");
184
- const dbVersion = vRows[0]?.version?.split(" ").slice(0, 2).join(" ") || "unknown";
293
+ const fullVersion = vRows[0]?.version || "unknown";
294
+ const shortVersion = fullVersion.split(" ").slice(0, 2).join(" ");
185
295
  const { rows: sslRows } = await client.query("SELECT setting FROM pg_settings WHERE name = 'ssl'");
186
- const ssl = sslRows[0]?.setting === "on";
187
- const { rows: extRows } = await client.query("SELECT extname FROM pg_extension");
296
+ const sslEnabled = sslRows[0]?.setting === "on";
297
+ const { rows: extRows } = await client.query("SELECT extname FROM pg_extension ORDER BY extname");
188
298
  const extensions = extRows.map((r) => r.extname);
299
+ const securityExts = extensions.filter((e) => ["pgcrypto", "pgsodium", "pgaudit", "pg_stat_statements", "supabase_vault"].includes(e));
189
300
  const { rows: funcRows } = await client.query(`
190
301
  SELECT routine_name FROM information_schema.routines
191
302
  WHERE routine_schema = 'public'
192
- AND (routine_name LIKE 'nodata_%' OR routine_name LIKE 'trg_encrypt_%')
303
+ AND (routine_name LIKE 'nodata_%' OR routine_name LIKE 'trg_encrypt_%'
304
+ OR routine_name LIKE 'encrypt_%' OR routine_name LIKE 'decrypt_%')
193
305
  `);
194
- const funcNames = funcRows.map((r) => r.routine_name);
306
+ const encFunctions = funcRows.map((r) => r.routine_name);
195
307
  const { rows: trigRows } = await client.query(`
196
- SELECT trigger_name FROM information_schema.triggers
197
- WHERE trigger_schema = 'public' AND trigger_name LIKE 'encrypt_%'
308
+ SELECT trigger_name, event_object_table, action_timing, event_manipulation
309
+ FROM information_schema.triggers
310
+ WHERE trigger_schema = 'public'
198
311
  `);
312
+ const encTriggers = trigRows
313
+ .filter((t) => /encrypt|nodata|capsule/i.test(t.trigger_name))
314
+ .map((t) => `${t.trigger_name} ON ${t.event_object_table} (${t.action_timing} ${t.event_manipulation})`);
315
+ const infraEvidence = {
316
+ db_type: "PostgreSQL",
317
+ db_version_full: fullVersion,
318
+ db_version_short: shortVersion,
319
+ ssl_enabled: sslEnabled,
320
+ extensions,
321
+ security_extensions: securityExts,
322
+ encrypt_functions: encFunctions,
323
+ encrypt_triggers: encTriggers,
324
+ has_pgcrypto: extensions.includes("pgcrypto"),
325
+ has_pgsodium: extensions.includes("pgsodium"),
326
+ };
327
+ // Backward compat
199
328
  const infra = {
200
329
  db_type: "PostgreSQL",
201
- db_version: dbVersion,
202
- ssl,
330
+ db_version: shortVersion,
331
+ ssl: sslEnabled,
203
332
  has_pgcrypto: extensions.includes("pgcrypto"),
204
- encrypt_functions: funcNames.includes("nodata_encrypt"),
205
- trigger_count: trigRows.length,
333
+ encrypt_functions: encFunctions.length > 0,
334
+ trigger_count: encTriggers.length,
206
335
  };
207
- onProgress?.("DB scan complete");
208
- return { tables: tables.size, pii_fields: piiFields, rls, infra };
336
+ // ═══════════════════════════════════════════════════════
337
+ // EVIDENCE LAYER 6: Security Configuration
338
+ // ═════════════════════════════════════════════���═════════
339
+ onProgress?.("Auditing security configuration...");
340
+ const securityConfig = await collectSecurityConfig(client);
341
+ // ═══════════════════════════════════════════════════════
342
+ // Assemble full evidence
343
+ // ══════════════════════════���════════════════════════════
344
+ const evidence = {
345
+ connection: connectionEvidence,
346
+ schema: schemaEvidence,
347
+ encryption: encryptionEvidence,
348
+ rls: rlsEvidence,
349
+ infrastructure: infraEvidence,
350
+ security_config: securityConfig,
351
+ collected_at: new Date().toISOString(),
352
+ scan_duration_ms: Date.now() - startTime,
353
+ };
354
+ onProgress?.("DB scan complete — full evidence collected");
355
+ return { tables: tables.size, pii_fields: piiFields, rls, infra, evidence };
209
356
  }
210
357
  finally {
211
358
  await client.end();
212
359
  }
213
360
  }
361
+ // ── Evidence collectors ──
362
+ async function collectConnectionEvidence(client) {
363
+ let sslVersion = null;
364
+ let sslCipher = null;
365
+ let sslBits = null;
366
+ try {
367
+ const { rows } = await client.query(`
368
+ SELECT ssl, version, cipher, bits
369
+ FROM pg_stat_ssl
370
+ WHERE pid = pg_backend_pid()
371
+ `);
372
+ if (rows[0]) {
373
+ sslVersion = rows[0].version;
374
+ sslCipher = rows[0].cipher;
375
+ sslBits = rows[0].bits ? parseInt(rows[0].bits) : null;
376
+ }
377
+ }
378
+ catch { /* pg_stat_ssl might not be available */ }
379
+ const { rows: vRows } = await client.query("SELECT version()");
380
+ const version = vRows[0]?.version?.split(" ").slice(0, 2).join(" ") || "unknown";
381
+ // Mask the host for privacy
382
+ let host = "unknown";
383
+ try {
384
+ const { rows: hRows } = await client.query("SELECT inet_server_addr()::text as host");
385
+ if (hRows[0]?.host) {
386
+ const parts = hRows[0].host.split(".");
387
+ host = parts.length >= 4
388
+ ? `${parts[0]}.${parts[1]}.***.***.`
389
+ : hRows[0].host.slice(0, 10) + "...";
390
+ }
391
+ }
392
+ catch { /* ok */ }
393
+ return {
394
+ ssl_in_use: sslVersion !== null,
395
+ ssl_version: sslVersion,
396
+ ssl_cipher: sslCipher,
397
+ ssl_bits: sslBits,
398
+ server_host: host,
399
+ server_version: version,
400
+ connection_encrypted: sslVersion !== null,
401
+ };
402
+ }
403
+ async function collectSecurityConfig(client) {
404
+ const settings = {};
405
+ try {
406
+ const { rows } = await client.query(`
407
+ SELECT name, setting FROM pg_settings
408
+ WHERE name IN (
409
+ 'password_encryption', 'ssl', 'log_connections', 'log_disconnections',
410
+ 'log_statement', 'statement_timeout', 'row_security', 'search_path'
411
+ )
412
+ `);
413
+ for (const r of rows)
414
+ settings[r.name] = r.setting;
415
+ }
416
+ catch { /* ok */ }
417
+ // Role counts
418
+ let superuserCount = 0, createdbCount = 0, loginCount = 0;
419
+ try {
420
+ const { rows } = await client.query(`
421
+ SELECT
422
+ count(*) FILTER (WHERE rolsuper)::int as superusers,
423
+ count(*) FILTER (WHERE rolcreatedb)::int as createdb,
424
+ count(*) FILTER (WHERE rolcanlogin)::int as logins
425
+ FROM pg_roles
426
+ `);
427
+ if (rows[0]) {
428
+ superuserCount = rows[0].superusers;
429
+ createdbCount = rows[0].createdb;
430
+ loginCount = rows[0].logins;
431
+ }
432
+ }
433
+ catch { /* ok */ }
434
+ // Current user info
435
+ let currentUser = "unknown", sessionUser = "unknown", isSuperuser = false;
436
+ try {
437
+ const { rows } = await client.query(`
438
+ SELECT current_user, session_user,
439
+ (SELECT rolsuper FROM pg_roles WHERE rolname = current_user) as is_super
440
+ `);
441
+ if (rows[0]) {
442
+ currentUser = rows[0].current_user;
443
+ sessionUser = rows[0].session_user;
444
+ isSuperuser = rows[0].is_super ?? false;
445
+ }
446
+ }
447
+ catch { /* ok */ }
448
+ return {
449
+ password_encryption: settings.password_encryption || "unknown",
450
+ ssl_setting: settings.ssl || "unknown",
451
+ log_connections: settings.log_connections === "on",
452
+ log_disconnections: settings.log_disconnections === "on",
453
+ log_statement: settings.log_statement || "unknown",
454
+ statement_timeout: settings.statement_timeout || "unknown",
455
+ row_security: settings.row_security === "on",
456
+ roles_with_superuser: superuserCount,
457
+ roles_with_createdb: createdbCount,
458
+ roles_with_login: loginCount,
459
+ search_path: settings.search_path || "unknown",
460
+ current_user: currentUser,
461
+ session_user: sessionUser,
462
+ is_superuser: isSuperuser,
463
+ };
464
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodatachat/guard",
3
- "version": "2.6.1",
3
+ "version": "3.1.0",
4
4
  "description": "NoData Guard — continuous security scanner. Runs locally, reports only metadata. Your data never leaves your machine.",
5
5
  "main": "./dist/cli.js",
6
6
  "types": "./dist/cli.d.ts",