@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 +66 -4
- package/dist/db-detect.d.ts +24 -0
- package/dist/db-detect.js +443 -0
- package/dist/db-scanner.d.ts +116 -0
- package/dist/db-scanner.js +345 -94
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
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
|
+
}
|
package/dist/db-scanner.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/db-scanner.js
CHANGED
|
@@ -1,37 +1,39 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
//
|
|
3
|
-
// @nodatachat/guard — Database Scanner (
|
|
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 (
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
10
|
-
//
|
|
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,
|
|
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
|
|
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")
|
|
54
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
99
|
-
const columnsToCheck = [
|
|
127
|
+
const colsToCheck = [
|
|
100
128
|
{ col: field.column, isCompanion: false },
|
|
101
129
|
];
|
|
102
130
|
if (field.has_companion_column) {
|
|
103
|
-
|
|
131
|
+
colsToCheck.push({ col: `${field.column}_encrypted`, isCompanion: true });
|
|
104
132
|
}
|
|
105
|
-
|
|
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(*)
|
|
119
|
-
count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text,
|
|
120
|
-
count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text,
|
|
121
|
-
count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text,
|
|
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
|
|
127
|
-
const nonNull =
|
|
128
|
-
const aesGcm =
|
|
129
|
-
const
|
|
130
|
-
const
|
|
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
|
|
140
|
-
: encAny > 0 ? "enc:
|
|
141
|
-
: ndcEnc > 0 ? "ndc_enc
|
|
168
|
+
: encV1 > 0 ? "enc:v1"
|
|
169
|
+
: encAny > 0 ? "enc:"
|
|
170
|
+
: ndcEnc > 0 ? "ndc_enc"
|
|
142
171
|
: b64 > 0 ? "base64_long"
|
|
143
|
-
: "
|
|
144
|
-
if (isCompanion
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
165
|
-
|
|
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
|
|
255
|
+
SELECT tablename, policyname, cmd, permissive
|
|
256
|
+
FROM pg_policies WHERE schemaname = 'public'
|
|
171
257
|
`);
|
|
172
|
-
const
|
|
258
|
+
const policyMap = new Map();
|
|
173
259
|
for (const p of rlsPolicies) {
|
|
174
|
-
|
|
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
|
|
264
|
+
const rlsTableEvidence = rlsTables.map((t) => ({
|
|
177
265
|
table: t.tablename,
|
|
178
266
|
rls_enabled: t.rowsecurity,
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
|
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
|
|
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
|
|
306
|
+
const encFunctions = funcRows.map((r) => r.routine_name);
|
|
195
307
|
const { rows: trigRows } = await client.query(`
|
|
196
|
-
SELECT trigger_name
|
|
197
|
-
|
|
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:
|
|
202
|
-
ssl,
|
|
330
|
+
db_version: shortVersion,
|
|
331
|
+
ssl: sslEnabled,
|
|
203
332
|
has_pgcrypto: extensions.includes("pgcrypto"),
|
|
204
|
-
encrypt_functions:
|
|
205
|
-
trigger_count:
|
|
333
|
+
encrypt_functions: encFunctions.length > 0,
|
|
334
|
+
trigger_count: encTriggers.length,
|
|
206
335
|
};
|
|
207
|
-
|
|
208
|
-
|
|
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": "
|
|
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",
|