@nodatachat/guard 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -18,14 +18,32 @@
18
18
  Object.defineProperty(exports, "__esModule", { value: true });
19
19
  const path_1 = require("path");
20
20
  const fs_1 = require("fs");
21
+ const crypto_1 = require("crypto");
21
22
  const activation_1 = require("./activation");
22
23
  const code_scanner_1 = require("./code-scanner");
23
24
  const db_scanner_1 = require("./db-scanner");
24
25
  const reporter_1 = require("./reporter");
25
26
  const scheduler_1 = require("./fixers/scheduler");
26
- const VERSION = "2.0.0";
27
+ const vault_crypto_1 = require("./vault-crypto");
28
+ const capsule_dir_1 = require("./capsule-dir");
29
+ const VERSION = "2.4.0";
27
30
  async function main() {
28
31
  const args = process.argv.slice(2);
32
+ // ── Subcommand routing ──
33
+ const subcommand = args[0];
34
+ if (subcommand === "status") {
35
+ const dir = args.includes("--dir") ? (0, path_1.resolve)(args[args.indexOf("--dir") + 1]) : process.cwd();
36
+ (0, capsule_dir_1.printFullStatus)(dir);
37
+ return;
38
+ }
39
+ if (subcommand === "diff") {
40
+ const dir = args.includes("--dir") ? (0, path_1.resolve)(args[args.indexOf("--dir") + 1]) : process.cwd();
41
+ (0, capsule_dir_1.printDiff)(dir);
42
+ return;
43
+ }
44
+ if (subcommand === "attest") {
45
+ return handleAttest(args.slice(1));
46
+ }
29
47
  // Parse args
30
48
  let licenseKey;
31
49
  let dbUrl;
@@ -34,6 +52,8 @@ async function main() {
34
52
  let failOn = null;
35
53
  let outputDir = process.cwd();
36
54
  let skipSend = false;
55
+ let vaultPin;
56
+ let vaultDevice;
37
57
  let schedulePreset;
38
58
  let ciProvider;
39
59
  for (let i = 0; i < args.length; i++) {
@@ -59,6 +79,12 @@ async function main() {
59
79
  case "--skip-send":
60
80
  skipSend = true;
61
81
  break;
82
+ case "--vault-pin":
83
+ vaultPin = args[++i];
84
+ break;
85
+ case "--vault-device":
86
+ vaultDevice = args[++i];
87
+ break;
62
88
  case "--fix-plan":
63
89
  console.log("\n ⚠ --fix-plan is deprecated. Capsule provides recommendations only.\n Run without this flag to see recommendations.\n");
64
90
  break;
@@ -166,6 +192,9 @@ async function main() {
166
192
  process.exit(1);
167
193
  }
168
194
  log(ciMode, `Activated. Tier: ${activation.tier} | Scan ID: ${activation.scan_id}`);
195
+ // ── Step 1.5: Initialize .capsule/ directory ──
196
+ const capsuleDir = (0, capsule_dir_1.initCapsuleDir)(projectDir, licenseKey);
197
+ log(ciMode, `.capsule/ directory ready: ${capsuleDir}`);
169
198
  // ── Step 2: Code scan ──
170
199
  log(ciMode, "Scanning code...");
171
200
  const files = (0, code_scanner_1.readProjectFiles)(projectDir, (count) => {
@@ -175,7 +204,7 @@ async function main() {
175
204
  if (!ciMode)
176
205
  console.log("");
177
206
  log(ciMode, `Scanned ${files.length} files`);
178
- const piiFields = (0, code_scanner_1.scanPIIFields)(files);
207
+ const piiFields = (0, code_scanner_1.scanPIIFields)(files, projectDir);
179
208
  const routes = (0, code_scanner_1.scanRoutes)(files);
180
209
  const secrets = (0, code_scanner_1.scanSecrets)(files);
181
210
  const stack = (0, code_scanner_1.detectStack)(files);
@@ -229,6 +258,59 @@ async function main() {
229
258
  (0, fs_1.writeFileSync)(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
230
259
  log(ciMode, `Full report: ${fullPath}`);
231
260
  log(ciMode, `Metadata only: ${metaPath}`);
261
+ // ── Step 5.5: Save to .capsule/ ──
262
+ try {
263
+ // Save score snapshot
264
+ (0, capsule_dir_1.saveScore)(capsuleDir, {
265
+ date: new Date().toISOString().slice(0, 10),
266
+ score: full.overall_score,
267
+ code_score: full.code_score ?? null,
268
+ db_score: full.db_score ?? null,
269
+ pii_total: full.summary.total_pii_fields,
270
+ pii_encrypted: full.summary.encrypted_fields,
271
+ coverage_percent: full.summary.coverage_percent,
272
+ findings_count: metadata.findings?.length || 0,
273
+ critical: full.summary.critical_issues,
274
+ high: full.summary.high_issues,
275
+ medium: full.summary.medium_issues ?? 0,
276
+ low: full.summary.low_issues ?? 0,
277
+ scan_type: dbUrl ? "full" : "code",
278
+ guard_version: VERSION,
279
+ });
280
+ // Update proof.json from DB results (if DB scan was done)
281
+ if (full.db?.pii_fields) {
282
+ const proofFields = full.db.pii_fields
283
+ .filter((f) => f.encrypted && f.encryption_pattern)
284
+ .map((f) => ({
285
+ table: f.table,
286
+ column: f.column,
287
+ pattern: f.encryption_pattern,
288
+ sentinel_prefix: f.sentinel_prefix || f.encryption_pattern,
289
+ row_count: f.row_count || 0,
290
+ encrypted_count: f.encrypted_count || 0,
291
+ }));
292
+ if (proofFields.length > 0) {
293
+ (0, capsule_dir_1.updateProof)(capsuleDir, proofFields);
294
+ log(ciMode, `Updated proof.json: ${proofFields.length} DB-verified encrypted fields`);
295
+ }
296
+ }
297
+ // Log scan as evidence
298
+ const deviceHash = (0, crypto_1.createHash)("sha256")
299
+ .update(`capsule:device:${require("os").hostname()}`)
300
+ .digest("hex").slice(0, 12);
301
+ (0, capsule_dir_1.addEvidence)(capsuleDir, licenseKey, {
302
+ date: new Date().toISOString(),
303
+ action: "scan_completed",
304
+ category: "scan",
305
+ detail: `Guard v${VERSION} scan: score ${full.overall_score}%, ${metadata.findings?.length || 0} findings, ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} PII encrypted`,
306
+ impact: `Score: ${full.overall_score}%`,
307
+ attested_by: deviceHash,
308
+ });
309
+ log(ciMode, "Evidence saved to .capsule/");
310
+ }
311
+ catch (err) {
312
+ log(ciMode, `.capsule/ save failed (non-blocking): ${err instanceof Error ? err.message : err}`);
313
+ }
232
314
  // ── Step 6: Send metadata to NoData ──
233
315
  let serverResponse = {};
234
316
  if (!skipSend) {
@@ -257,13 +339,67 @@ async function main() {
257
339
  log(ciMode, "Could not reach NoData API — report saved locally");
258
340
  }
259
341
  }
260
- // ── Step 7: Print summary ──
342
+ // ── Step 6b: Encrypt & upload to vault (if --vault-pin) ──
343
+ let vaultUploaded = false;
344
+ if (vaultPin && !skipSend) {
345
+ log(ciMode, "Encrypting full report for vault...");
346
+ try {
347
+ const fullJson = JSON.stringify(full);
348
+ const { ciphertext, iv, salt } = (0, vault_crypto_1.encryptForVault)(vaultPin, fullJson);
349
+ const vaultRes = await fetch("https://nodatacapsule.com/api/guard/vault", {
350
+ method: "POST",
351
+ headers: {
352
+ "Content-Type": "application/json",
353
+ "X-License-Key": licenseKey || "",
354
+ },
355
+ body: JSON.stringify({
356
+ encrypted_blob: ciphertext,
357
+ iv,
358
+ salt,
359
+ score: full.overall_score,
360
+ findings_count: metadata.findings?.length || 0,
361
+ scan_id: activation.scan_id,
362
+ device_id: vaultDevice || undefined,
363
+ }),
364
+ signal: AbortSignal.timeout(15000),
365
+ });
366
+ if (vaultRes.ok) {
367
+ vaultUploaded = true;
368
+ log(ciMode, "Report encrypted & saved to vault");
369
+ }
370
+ else {
371
+ const errBody = await vaultRes.text().catch(() => "");
372
+ log(ciMode, `Vault upload failed (${vaultRes.status}): ${errBody}`);
373
+ }
374
+ }
375
+ catch (err) {
376
+ log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
377
+ }
378
+ }
379
+ // ── Step 7: Print summary with two-score display ──
380
+ const codeScore = full.code ? Math.round((full.code.encryption_coverage_percent * 0.5) +
381
+ (full.code.route_protection_percent * 0.25) +
382
+ (Math.max(0, 100 - (full.summary.critical_issues * 25 + full.summary.high_issues * 10)) * 0.25)) : null;
383
+ const dbScore = full.db ? Math.round((full.db.encryption_coverage_percent * 0.6) +
384
+ (full.db.rls_coverage_percent * 0.4)) : null;
385
+ // Attach to full report for .capsule/ storage
386
+ full.code_score = codeScore;
387
+ full.db_score = dbScore;
261
388
  if (!ciMode) {
262
389
  console.log("");
263
390
  console.log(" ══════════════════════════════════════");
264
391
  console.log(" GUARD RESULTS");
265
392
  console.log(" ══════════════════════════════════════");
266
- console.log(` Score: ${full.overall_score}%${serverResponse.previous_score != null ? ` (was ${serverResponse.previous_score}%)` : ""}`);
393
+ console.log(` Overall score: \x1b[1m${full.overall_score}%\x1b[0m${serverResponse.previous_score != null ? ` (was ${serverResponse.previous_score}%)` : ""}`);
394
+ if (codeScore != null) {
395
+ console.log(` Code score: ${codeScore}%`);
396
+ }
397
+ if (dbScore != null) {
398
+ console.log(` DB score: ${dbScore}%`);
399
+ }
400
+ if (codeScore != null && dbScore != null) {
401
+ console.log(" ──────────────────────────────────────");
402
+ }
267
403
  console.log(` PII fields: ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} encrypted (${full.summary.coverage_percent}%)`);
268
404
  if (full.code) {
269
405
  console.log(` Routes: ${full.code.routes.filter(r => r.has_auth).length}/${full.code.routes.length} protected`);
@@ -278,6 +414,9 @@ async function main() {
278
414
  console.log(` Sent to NoData: ${metaPath}`);
279
415
  console.log(` Proof hash: ${full.proof_hash.slice(0, 16)}...`);
280
416
  console.log(" ══════════════════════════════════════");
417
+ if (vaultUploaded) {
418
+ console.log(" Vault: Encrypted & saved to dashboard");
419
+ }
281
420
  console.log(" Your data never left your machine.");
282
421
  console.log(" Diff the two files to verify.\n");
283
422
  }
@@ -325,6 +464,83 @@ async function main() {
325
464
  log(ciMode, `Recommendations: ${recsPath}`);
326
465
  }
327
466
  }
467
+ // ── Step 8.5: Remediation Guide (non-CI) ──
468
+ if (!ciMode && metadata.findings?.length > 0) {
469
+ const fs = metadata.findings;
470
+ const hasPII = fs.some((f) => f.category === "encryption" || f.title?.toLowerCase().includes("pii") || f.title?.toLowerCase().includes("encrypt"));
471
+ const hasRLS = fs.some((f) => f.title?.toLowerCase().includes("rls") || f.title?.toLowerCase().includes("row level") || f.category === "access_control");
472
+ const hasSecrets = fs.some((f) => f.title?.toLowerCase().includes("secret") || f.title?.toLowerCase().includes("hardcoded") || f.category === "secrets");
473
+ const hasHeaders = fs.some((f) => f.title?.toLowerCase().includes("header") || f.category === "headers");
474
+ if (hasPII || hasRLS || hasSecrets || hasHeaders) {
475
+ console.log(" ══════════════════════════════════════");
476
+ console.log(" \x1b[33mREMEDIATION GUIDE\x1b[0m — General commands");
477
+ console.log(" ══════════════════════════════════════");
478
+ console.log(" \x1b[2mThese are general recommendations.");
479
+ console.log(" A qualified technical professional should review");
480
+ console.log(" and adapt them to your specific system.\x1b[0m\n");
481
+ if (hasPII) {
482
+ console.log(" \x1b[33m── ENCRYPT PII FIELDS ──\x1b[0m");
483
+ console.log(" \x1b[36mOption A:\x1b[0m Database-level (PostgreSQL)");
484
+ console.log(" CREATE EXTENSION IF NOT EXISTS pgcrypto;");
485
+ console.log(" ALTER TABLE <table> ADD COLUMN <field>_encrypted BYTEA;");
486
+ console.log(" UPDATE <table> SET <field>_encrypted =");
487
+ console.log(" pgp_sym_encrypt(<field>::TEXT, '<key>');");
488
+ console.log("");
489
+ console.log(" \x1b[36mOption B:\x1b[0m Application-level (Capsule SDK)");
490
+ console.log(" npm install @nodatachat/sdk");
491
+ console.log(" encrypt(value, process.env.FIELD_ENCRYPTION_KEY)");
492
+ console.log("");
493
+ console.log(" \x1b[36mOption C:\x1b[0m Encrypt .env at rest");
494
+ console.log(" npx @nodatachat/protect encrypt .env\n");
495
+ }
496
+ if (hasRLS) {
497
+ console.log(" \x1b[33m── ROW LEVEL SECURITY ──\x1b[0m");
498
+ console.log(" ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;");
499
+ console.log(" ALTER TABLE <table> FORCE ROW LEVEL SECURITY;");
500
+ console.log(" CREATE POLICY \"users_own\" ON <table>");
501
+ console.log(" FOR ALL USING (user_id = auth.uid());\n");
502
+ }
503
+ if (hasSecrets) {
504
+ console.log(" \x1b[33m── REMOVE SECRETS FROM CODE ──\x1b[0m");
505
+ console.log(" 1. Move to .env.local");
506
+ console.log(" 2. Reference: process.env.SECRET_NAME");
507
+ console.log(" 3. echo '.env*.local' >> .gitignore");
508
+ console.log(" 4. npx @nodatachat/protect encrypt .env\n");
509
+ }
510
+ if (hasHeaders) {
511
+ console.log(" \x1b[33m── SECURITY HEADERS ──\x1b[0m");
512
+ console.log(" Strict-Transport-Security: max-age=63072000");
513
+ console.log(" X-Content-Type-Options: nosniff");
514
+ console.log(" X-Frame-Options: DENY");
515
+ console.log(" Referrer-Policy: strict-origin-when-cross-origin\n");
516
+ }
517
+ console.log(" ──────────────────────────────────────\n");
518
+ }
519
+ }
520
+ // ── Step 9: What's next (non-CI) ──
521
+ if (!ciMode) {
522
+ console.log(" ══════════════════════════════════════");
523
+ console.log(" WHAT TO DO NEXT");
524
+ console.log(" ══════════════════════════════════════");
525
+ if (vaultUploaded) {
526
+ console.log(" 1. Open your dashboard: https://nodatacapsule.com/my-capsule");
527
+ console.log(" 2. Go to the \x1b[36mVault\x1b[0m tab");
528
+ console.log(" 3. Enter your PIN to see the full report");
529
+ console.log(" 4. Share the score with your team (no sensitive data)");
530
+ console.log(" 5. Fix findings and re-scan to improve your score\n");
531
+ }
532
+ else {
533
+ console.log(" 1. Review \x1b[36mnodata-full-report.json\x1b[0m (stays local)");
534
+ console.log(" 2. Open your dashboard: https://nodatacapsule.com/my-capsule");
535
+ console.log(" 3. Share the score with your team");
536
+ console.log(" 4. Fix findings and re-scan to improve your score");
537
+ console.log("");
538
+ console.log(" \x1b[33mTip:\x1b[0m Add \x1b[36m--vault-pin 1234\x1b[0m to auto-encrypt");
539
+ console.log(" and upload the full report to your vault.\n");
540
+ }
541
+ }
542
+ // ── Step 10: Print .capsule/ status ──
543
+ (0, capsule_dir_1.printCapsuleStatus)(projectDir, ciMode);
328
544
  // ── CI mode: exit code ──
329
545
  if (ciMode && failOn) {
330
546
  const { critical_issues, high_issues, medium_issues } = full.summary;
@@ -367,6 +583,85 @@ function loadOrCreateConfig(projectDir, overrides) {
367
583
  },
368
584
  });
369
585
  }
586
+ function handleAttest(args) {
587
+ let licenseKey;
588
+ let projectDir = process.cwd();
589
+ let findingId;
590
+ let status = "fixed";
591
+ let note = "";
592
+ for (let i = 0; i < args.length; i++) {
593
+ switch (args[i]) {
594
+ case "--license-key":
595
+ licenseKey = args[++i];
596
+ break;
597
+ case "--dir":
598
+ projectDir = (0, path_1.resolve)(args[++i]);
599
+ break;
600
+ case "--finding":
601
+ findingId = args[++i];
602
+ break;
603
+ case "--status":
604
+ status = args[++i];
605
+ break;
606
+ case "--note":
607
+ note = args[++i];
608
+ break;
609
+ }
610
+ }
611
+ // Env / config fallbacks for license key
612
+ if (!licenseKey)
613
+ licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
614
+ if (!licenseKey) {
615
+ try {
616
+ const home = process.env.HOME || process.env.USERPROFILE || "";
617
+ const configPath = require("path").join(home, ".nodata", "config.json");
618
+ if (require("fs").existsSync(configPath)) {
619
+ const config = JSON.parse(require("fs").readFileSync(configPath, "utf-8"));
620
+ if (config.api_key)
621
+ licenseKey = config.api_key;
622
+ }
623
+ }
624
+ catch { /* ignore */ }
625
+ }
626
+ if (!licenseKey) {
627
+ console.error("\n \x1b[31m✗\x1b[0m No API key found. Pass --license-key or set NDC_LICENSE.\n");
628
+ process.exit(1);
629
+ }
630
+ if (!findingId) {
631
+ console.error("\n \x1b[31m✗\x1b[0m Missing --finding <id>.");
632
+ console.error(" Example finding IDs: PII_UNENCRYPTED_email, ROUTE_NO_AUTH, SECRET_POSTGRES_URI");
633
+ console.error("\n Usage:");
634
+ console.error(" npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note \"Encrypted with pgcrypto\"");
635
+ console.error("\n Statuses: fixed | accepted_risk | not_applicable | compensating_control\n");
636
+ process.exit(1);
637
+ }
638
+ if (!note) {
639
+ console.error("\n \x1b[31m✗\x1b[0m Missing --note <description>. Explain what was done.\n");
640
+ process.exit(1);
641
+ }
642
+ const validStatuses = ["fixed", "accepted_risk", "not_applicable", "compensating_control"];
643
+ if (!validStatuses.includes(status)) {
644
+ console.error(`\n \x1b[31m✗\x1b[0m Invalid status "${status}". Use: ${validStatuses.join(" | ")}\n`);
645
+ process.exit(1);
646
+ }
647
+ (0, capsule_dir_1.attestFinding)(projectDir, licenseKey, findingId, status, note);
648
+ const statusColor = status === "fixed" ? "\x1b[32m" : "\x1b[33m";
649
+ console.log("");
650
+ console.log(" ╔══════════════════════════════════════╗");
651
+ console.log(" ║ NoData Guard — Attestation ║");
652
+ console.log(" ╚══════════════════════════════════════╝");
653
+ console.log("");
654
+ console.log(` Finding: ${findingId}`);
655
+ console.log(` Status: ${statusColor}${status}\x1b[0m`);
656
+ console.log(` Note: ${note}`);
657
+ console.log(` Signed: HMAC-SHA256 (license-key-bound)`);
658
+ console.log("");
659
+ console.log(" \x1b[32m✓\x1b[0m Saved to .capsule/overrides.json");
660
+ console.log(" \x1b[32m✓\x1b[0m Evidence logged to .capsule/evidence/");
661
+ console.log("");
662
+ console.log(" \x1b[2mCommit .capsule/ to git for audit trail.\x1b[0m");
663
+ console.log(" \x1b[2mRe-scan to see updated score.\x1b[0m\n");
664
+ }
370
665
  function printHelp() {
371
666
  console.log(`
372
667
  NoData Guard v${VERSION} — Security Scanner + Recommendations
@@ -379,6 +674,17 @@ function printHelp() {
379
674
  npx nodata-guard --license-key NDC-XXXX # Scan + recommend
380
675
  npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
381
676
  npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
677
+ npx nodata-guard status # Show .capsule/ status (no scan)
678
+ npx nodata-guard diff # Compare last 2 scans
679
+ npx nodata-guard attest --finding ID --status fixed --note "..." # Manual attestation
680
+
681
+ Subcommands:
682
+ status Show .capsule/ evidence (scores, proof, overrides) without scanning
683
+ diff Compare the last 2 scans — score delta, issues resolved/new
684
+ attest Manually attest a finding (saved to .capsule/overrides.json)
685
+ --finding <id> Finding ID (e.g., PII_UNENCRYPTED_email, ROUTE_NO_AUTH)
686
+ --status <status> fixed | accepted_risk | not_applicable | compensating_control
687
+ --note <text> Explanation of what was done
382
688
 
383
689
  Scan Options:
384
690
  --license-key <key> NoData license key (or set NDC_LICENSE env var)
@@ -388,6 +694,8 @@ function printHelp() {
388
694
  --ci CI mode — minimal output, exit codes
389
695
  --fail-on <level> Exit 1 if issues at: critical | high | medium
390
696
  --skip-send Don't send metadata to NoData
697
+ --vault-pin <pin> Encrypt full report & save to vault (AES-256-GCM)
698
+ --vault-device <id> Link vault report to your dashboard device
391
699
 
392
700
  Schedule Options:
393
701
  --schedule <preset> Install CI workflow: daily | weekly | monthly
@@ -420,6 +728,9 @@ function printHelp() {
420
728
  # Scan and get recommendations
421
729
  npx nodata-guard --license-key NDC-XXXX
422
730
 
731
+ # Scan + auto-save encrypted report to vault
732
+ npx nodata-guard --license-key NDC-XXXX --vault-pin 1234
733
+
423
734
  # Full scan with DB probe
424
735
  npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL
425
736
 
@@ -429,6 +740,18 @@ function printHelp() {
429
740
  # CI pipeline — fail on critical issues
430
741
  npx nodata-guard --ci --fail-on critical
431
742
 
743
+ # Check .capsule/ status without scanning
744
+ npx nodata-guard status
745
+
746
+ # See what changed between last 2 scans
747
+ npx nodata-guard diff
748
+
749
+ # Attest that you fixed a finding
750
+ npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note "Encrypted with pgcrypto in migration 042"
751
+
752
+ # Accept risk for a finding
753
+ npx nodata-guard attest --finding ROUTE_NO_AUTH --status accepted_risk --note "Public healthcheck endpoint, no auth needed"
754
+
432
755
  Documentation: https://nodatacapsule.com/guard
433
756
  `);
434
757
  }
@@ -4,7 +4,7 @@ interface FileEntry {
4
4
  content: string;
5
5
  }
6
6
  export declare function readProjectFiles(projectDir: string, onProgress?: (count: number) => void): FileEntry[];
7
- export declare function scanPIIFields(files: FileEntry[]): PIIFieldResult[];
7
+ export declare function scanPIIFields(files: FileEntry[], projectDir?: string): PIIFieldResult[];
8
8
  export declare function scanRoutes(files: FileEntry[]): RouteResult[];
9
9
  export declare function scanSecrets(files: FileEntry[]): SecretResult[];
10
10
  export declare function detectStack(files: FileEntry[]): {
@@ -18,6 +18,8 @@ exports.scanSecrets = scanSecrets;
18
18
  exports.detectStack = detectStack;
19
19
  const fs_1 = require("fs");
20
20
  const path_1 = require("path");
21
+ const crypto_1 = require("crypto");
22
+ const capsule_dir_1 = require("./capsule-dir");
21
23
  // ── File reading ──
22
24
  const SKIP_DIRS = new Set([
23
25
  "node_modules", ".git", ".next", ".nuxt", "dist", "build", "out",
@@ -97,7 +99,7 @@ function classifyColumn(col) {
97
99
  }
98
100
  return null;
99
101
  }
100
- function scanPIIFields(files) {
102
+ function scanPIIFields(files, projectDir) {
101
103
  const fields = [];
102
104
  const seen = new Set();
103
105
  // Collect all column names to check for _encrypted companions
@@ -152,14 +154,14 @@ function scanPIIFields(files) {
152
154
  const encryptFiles = files.filter(f => /createCipheriv|encrypt|decrypt|encryptPII|nodata_encrypt/i.test(f.content));
153
155
  const proofFile = files.find(f => f.path.includes("nodata-proof.json"));
154
156
  const triggerFiles = files.filter(f => f.path.endsWith(".sql") && /trg_encrypt_/i.test(f.content));
155
- // Parse proof file
157
+ // Parse legacy proof file (nodata-proof.json)
156
158
  const proofFields = new Set();
157
159
  if (proofFile) {
158
160
  try {
159
161
  const proof = JSON.parse(proofFile.content);
160
162
  if (proof.version === "1.0" && Array.isArray(proof.fields)) {
161
163
  for (const f of proof.fields) {
162
- if (f.sentinel_encrypted?.startsWith("aes256gcm:v1:")) {
164
+ if (f.sentinel_encrypted?.startsWith("aes256gcm:v1:") || f.sentinel_encrypted?.startsWith("enc:v1:") || f.sentinel_encrypted?.startsWith("ndc_enc_")) {
163
165
  proofFields.add(`${f.table}.${f.column}`);
164
166
  }
165
167
  }
@@ -167,6 +169,27 @@ function scanPIIFields(files) {
167
169
  }
168
170
  catch { /* ignore */ }
169
171
  }
172
+ // Parse .capsule/proof.json (DB-verified encryption from previous scans)
173
+ // This allows code-only scans to recognize encryption verified by a previous --db scan
174
+ if (projectDir) {
175
+ const capsuleProof = (0, capsule_dir_1.readProof)(projectDir);
176
+ if (capsuleProof && capsuleProof.fields.length > 0) {
177
+ // capsule proof uses hashed names — we need to build a lookup by hash
178
+ const fieldHashMap = new Map(); // hash → "table.column"
179
+ for (const field of fields) {
180
+ const tableHash = (0, crypto_1.createHash)("sha256").update(`capsule:name:${field.table}`).digest("hex").slice(0, 16);
181
+ const colHash = (0, crypto_1.createHash)("sha256").update(`capsule:name:${field.column}`).digest("hex").slice(0, 16);
182
+ fieldHashMap.set(`${tableHash}:${colHash}`, `${field.table}.${field.column}`);
183
+ }
184
+ for (const pf of capsuleProof.fields) {
185
+ const key = `${pf.table_hash}:${pf.column_hash}`;
186
+ const realName = fieldHashMap.get(key);
187
+ if (realName && pf.coverage_percent >= 50) {
188
+ proofFields.add(realName);
189
+ }
190
+ }
191
+ }
192
+ }
170
193
  // Parse trigger functions
171
194
  const triggerTables = new Set();
172
195
  for (const f of triggerFiles) {
@@ -201,7 +224,7 @@ function scanPIIFields(files) {
201
224
  for (let i = 0; i < lines.length; i++) {
202
225
  if (lines[i].includes(field.column)) {
203
226
  const context = lines.slice(Math.max(0, i - 10), Math.min(lines.length, i + 10)).join("\n");
204
- if (/createCipheriv|encrypt\(|encryptPII|nodata_encrypt|encryptField/i.test(context)) {
227
+ if (/createCipheriv|encrypt\(|encryptPII|nodata_encrypt|encryptField|NoDataProxy|proxy\.seal|proxy\.unseal|enc:v1:/i.test(context)) {
205
228
  field.encrypted = true;
206
229
  field.encryption_pattern = "code_encrypt";
207
230
  break;
@@ -6,7 +6,10 @@
6
6
  // READS ONLY:
7
7
  // - Schema (table/column names, data types)
8
8
  // - Counts (row counts, encrypted counts)
9
- // - Prefixes (LEFT(value, 13) — detects "aes256gcm:v1:" without reading data)
9
+ // - Prefixes (LEFT(value, N) — detects encryption without reading data):
10
+ // "aes256gcm:v1:" (13 chars) — direct AES-256-GCM
11
+ // "enc:v1:" (7 chars) — Capsule Proxy encryption
12
+ // "ndc_enc_" (8 chars) — @nodatachat/protect format
10
13
  // - System tables (pg_policies, pg_user, pg_settings, pg_extension)
11
14
  //
12
15
  // NEVER READS: actual data values, passwords, tokens, emails, phones
@@ -101,12 +104,18 @@ async function scanDatabase(connectionString, onProgress) {
101
104
  }
102
105
  for (const { col, isCompanion } of columnsToCheck) {
103
106
  try {
104
- // SAFE: LEFT(value, 13) reads only the prefix — never actual data
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)
105
112
  const { rows } = await client.query(`
106
113
  SELECT
107
114
  count(*) as total,
108
115
  count(${quoteIdent(col)}) as non_null,
109
116
  count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 13) = 'aes256gcm:v1:') as aes_gcm,
117
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 7) = 'enc:v1:') as enc_v1,
118
+ count(*) FILTER (WHERE LEFT(${quoteIdent(col)}::text, 8) = 'ndc_enc_') as ndc_enc,
110
119
  count(*) FILTER (WHERE LENGTH(${quoteIdent(col)}::text) > 80
111
120
  AND ${quoteIdent(col)}::text ~ '^[A-Za-z0-9+/=]+$') as base64_long
112
121
  FROM ${quoteIdent(field.table)}
@@ -114,18 +123,26 @@ async function scanDatabase(connectionString, onProgress) {
114
123
  const total = parseInt(rows[0].total);
115
124
  const nonNull = parseInt(rows[0].non_null);
116
125
  const aesGcm = parseInt(rows[0].aes_gcm);
126
+ const encV1 = parseInt(rows[0].enc_v1);
127
+ const ndcEnc = parseInt(rows[0].ndc_enc);
117
128
  const b64 = parseInt(rows[0].base64_long);
118
- const encCount = aesGcm + b64;
129
+ const encCount = aesGcm + encV1 + ndcEnc + b64;
130
+ // Determine pattern name for reporting
131
+ const pattern = aesGcm > 0 ? "aes256gcm:v1"
132
+ : encV1 > 0 ? "enc:v1 (Capsule Proxy)"
133
+ : ndcEnc > 0 ? "ndc_enc (Protect)"
134
+ : b64 > 0 ? "base64_long"
135
+ : "unknown";
119
136
  if (isCompanion && encCount > 0) {
120
137
  field.encrypted = true;
121
- field.encryption_pattern = aesGcm > 0 ? "aes256gcm:v1" : "base64_long";
138
+ field.encryption_pattern = pattern;
122
139
  field.encrypted_count = encCount;
123
140
  }
124
141
  else if (!isCompanion) {
125
142
  field.row_count = total;
126
143
  if (encCount > 0 && encCount >= nonNull * 0.9) {
127
144
  field.encrypted = true;
128
- field.encryption_pattern = aesGcm > 0 ? "aes256gcm:v1" : "base64_long";
145
+ field.encryption_pattern = pattern;
129
146
  field.encrypted_count = encCount;
130
147
  }
131
148
  }