@nodatachat/guard 2.6.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +45 -1
- 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,7 @@ 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 = "
|
|
29
|
+
const VERSION = "3.0.0";
|
|
30
30
|
async function main() {
|
|
31
31
|
const args = process.argv.slice(2);
|
|
32
32
|
// ── Subcommand routing ──
|
|
@@ -292,6 +292,50 @@ async function main() {
|
|
|
292
292
|
const encryptedDb = dbResult.pii_fields.filter(f => f.encrypted).length;
|
|
293
293
|
log(ciMode, `DB PII: ${dbResult.pii_fields.length} found, ${encryptedDb} encrypted`);
|
|
294
294
|
log(ciMode, `RLS: ${dbResult.rls.filter(r => r.rls_enabled).length}/${dbResult.rls.length} tables`);
|
|
295
|
+
// Save full DB evidence
|
|
296
|
+
if (dbResult.evidence) {
|
|
297
|
+
const evidencePath = (0, path_1.resolve)(outputDir, "nodata-db-evidence.json");
|
|
298
|
+
(0, fs_1.writeFileSync)(evidencePath, JSON.stringify(dbResult.evidence, null, 2), "utf-8");
|
|
299
|
+
log(ciMode, `DB evidence: ${evidencePath}`);
|
|
300
|
+
// Print evidence summary
|
|
301
|
+
if (!ciMode) {
|
|
302
|
+
const ev = dbResult.evidence;
|
|
303
|
+
console.log("");
|
|
304
|
+
console.log(" ══════════════════════════════════════");
|
|
305
|
+
console.log(" DB EVIDENCE — FORENSIC SUMMARY");
|
|
306
|
+
console.log(" ══════════════════════════════════════");
|
|
307
|
+
console.log(` Connection: ${ev.connection.connection_encrypted ? "\x1b[32mEncrypted\x1b[0m" : "\x1b[31mPlaintext\x1b[0m"}${ev.connection.ssl_version ? ` (${ev.connection.ssl_version})` : ""}`);
|
|
308
|
+
if (ev.connection.ssl_cipher)
|
|
309
|
+
console.log(` Cipher: ${ev.connection.ssl_cipher} (${ev.connection.ssl_bits} bits)`);
|
|
310
|
+
console.log(` Server: ${ev.infrastructure.db_version_short}`);
|
|
311
|
+
console.log(` Password enc: ${ev.security_config.password_encryption}`);
|
|
312
|
+
console.log(" ──────────────────────────────────────");
|
|
313
|
+
console.log(` Tables: ${ev.schema.total_tables} (${ev.schema.pii_columns_found} PII columns)`);
|
|
314
|
+
console.log(` Encrypted: \x1b[32m${ev.encryption.summary.encrypted_fields}/${ev.encryption.summary.total_pii_fields}\x1b[0m fields`);
|
|
315
|
+
console.log(` Rows scanned: ${ev.encryption.summary.total_rows_scanned.toLocaleString()}`);
|
|
316
|
+
console.log(` Rows encrypted: \x1b[32m${ev.encryption.summary.total_rows_encrypted.toLocaleString()}\x1b[0m`);
|
|
317
|
+
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"}`);
|
|
318
|
+
console.log(` Rows null: ${ev.encryption.summary.total_rows_null.toLocaleString()}`);
|
|
319
|
+
console.log(` Patterns: ${ev.encryption.summary.encryption_patterns_found.join(", ") || "none"}`);
|
|
320
|
+
console.log(` Coverage: ${ev.encryption.summary.coverage_percent}%`);
|
|
321
|
+
console.log(" ──────────────────────────────────────");
|
|
322
|
+
console.log(` RLS: ${ev.rls.summary.rls_enabled_count}/${ev.rls.summary.total_tables} tables (${ev.rls.summary.coverage_percent}%)`);
|
|
323
|
+
console.log(` Policies: ${ev.rls.summary.total_policies}`);
|
|
324
|
+
if (ev.rls.summary.tables_without_rls.length > 0 && ev.rls.summary.tables_without_rls.length <= 5) {
|
|
325
|
+
console.log(` \x1b[31mNo RLS:\x1b[0m ${ev.rls.summary.tables_without_rls.join(", ")}`);
|
|
326
|
+
}
|
|
327
|
+
console.log(" ──────────────────────────────────────");
|
|
328
|
+
console.log(` Extensions: ${ev.infrastructure.security_extensions.join(", ") || "none security-related"}`);
|
|
329
|
+
console.log(` Enc functions: ${ev.infrastructure.encrypt_functions.length}`);
|
|
330
|
+
console.log(` Enc triggers: ${ev.infrastructure.encrypt_triggers.length}`);
|
|
331
|
+
console.log(` Superusers: ${ev.security_config.roles_with_superuser}`);
|
|
332
|
+
console.log(` Login roles: ${ev.security_config.roles_with_login}`);
|
|
333
|
+
console.log(` Log level: ${ev.security_config.log_statement}`);
|
|
334
|
+
console.log(` Scan time: ${(ev.scan_duration_ms / 1000).toFixed(1)}s`);
|
|
335
|
+
console.log(" ══════════════════════════════════════");
|
|
336
|
+
console.log(` \x1b[2mFull evidence: ${evidencePath}\x1b[0m\n`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
295
339
|
}
|
|
296
340
|
catch (err) {
|
|
297
341
|
log(ciMode, `DB scan failed: ${err instanceof Error ? err.message : err}`);
|
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.0.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",
|