@nodatachat/guard 2.3.0 → 2.5.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.
@@ -91,3 +91,14 @@ export declare function getScoreHistory(projectDir: string): ScoreSnapshot[];
91
91
  * Print .capsule/ status to terminal.
92
92
  */
93
93
  export declare function printCapsuleStatus(projectDir: string, ciMode: boolean): void;
94
+ export declare function attestFinding(projectDir: string, licenseKey: string, findingId: string, status: Override["status"], note: string): void;
95
+ /**
96
+ * Subcommand: guard status
97
+ * Print full .capsule/ status without running a scan.
98
+ */
99
+ export declare function printFullStatus(projectDir: string): void;
100
+ /**
101
+ * Subcommand: guard diff
102
+ * Show what changed between the two most recent scans.
103
+ */
104
+ export declare function printDiff(projectDir: string): void;
@@ -30,6 +30,9 @@ exports.readProof = readProof;
30
30
  exports.readOverrides = readOverrides;
31
31
  exports.getScoreHistory = getScoreHistory;
32
32
  exports.printCapsuleStatus = printCapsuleStatus;
33
+ exports.attestFinding = attestFinding;
34
+ exports.printFullStatus = printFullStatus;
35
+ exports.printDiff = printDiff;
33
36
  const path_1 = require("path");
34
37
  const fs_1 = require("fs");
35
38
  const crypto_1 = require("crypto");
@@ -200,7 +203,7 @@ function getScoreHistory(projectDir) {
200
203
  const scoresDir = (0, path_1.join)(projectDir, DIR_NAME, "scores");
201
204
  if (!(0, fs_1.existsSync)(scoresDir))
202
205
  return [];
203
- const files = require("fs").readdirSync(scoresDir)
206
+ const files = (0, fs_1.readdirSync)(scoresDir)
204
207
  .filter((f) => f.endsWith(".json"))
205
208
  .sort();
206
209
  return files.map((f) => {
@@ -246,3 +249,217 @@ function printCapsuleStatus(projectDir, ciMode) {
246
249
  console.log(" \x1b[2mAll evidence stays local. Commit to git for audit trail.\x1b[0m");
247
250
  console.log(" ══════════════════════════════════════\n");
248
251
  }
252
+ // ═══════════════════════════════════════════════════════════
253
+ // Subcommand: guard attest
254
+ //
255
+ // Allows the user to manually attest that a finding has been
256
+ // addressed. Creates a signed entry in overrides.json.
257
+ // ═══════════════════════════════════════════════════════════
258
+ function attestFinding(projectDir, licenseKey, findingId, status, note) {
259
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
260
+ if (!(0, fs_1.existsSync)(capsuleDir)) {
261
+ initCapsuleDir(projectDir, licenseKey);
262
+ }
263
+ const overridesPath = (0, path_1.join)(capsuleDir, "overrides.json");
264
+ const overrides = (0, fs_1.existsSync)(overridesPath)
265
+ ? JSON.parse((0, fs_1.readFileSync)(overridesPath, "utf-8"))
266
+ : { version: "1.0", overrides: [] };
267
+ const deviceHash = (0, crypto_1.createHash)("sha256")
268
+ .update(`capsule:device:${require("os").hostname()}`)
269
+ .digest("hex").slice(0, 12);
270
+ const now = new Date().toISOString();
271
+ const payload = JSON.stringify({ finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now });
272
+ const hmac = signEvidence(payload, licenseKey);
273
+ // Update existing or add new
274
+ const existing = overrides.overrides.findIndex(o => o.finding_id === findingId);
275
+ const entry = { finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now, hmac };
276
+ if (existing >= 0) {
277
+ overrides.overrides[existing] = entry;
278
+ }
279
+ else {
280
+ overrides.overrides.push(entry);
281
+ }
282
+ (0, fs_1.writeFileSync)(overridesPath, JSON.stringify(overrides, null, 2), "utf-8");
283
+ // Also log as evidence
284
+ addEvidence(capsuleDir, licenseKey, {
285
+ date: now,
286
+ action: "override_added",
287
+ category: status === "fixed" ? "remediation" : "risk_management",
288
+ detail: `Attested "${findingId}" as ${status}: ${note}`,
289
+ impact: `Override: ${status}`,
290
+ attested_by: deviceHash,
291
+ });
292
+ }
293
+ /**
294
+ * Subcommand: guard status
295
+ * Print full .capsule/ status without running a scan.
296
+ */
297
+ function printFullStatus(projectDir) {
298
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
299
+ if (!(0, fs_1.existsSync)(capsuleDir)) {
300
+ console.log("\n No .capsule/ directory found. Run a scan first.\n");
301
+ return;
302
+ }
303
+ const history = getScoreHistory(projectDir);
304
+ const proof = readProof(projectDir);
305
+ const overrides = readOverrides(projectDir);
306
+ console.log("");
307
+ console.log(" ╔══════════════════════════════════════╗");
308
+ console.log(" ║ NoData Guard — Status ║");
309
+ console.log(" ╚══════════════════════════════════════╝");
310
+ console.log("");
311
+ // ── Score history ──
312
+ console.log(" \x1b[33m── SCORE HISTORY ──\x1b[0m");
313
+ if (history.length === 0) {
314
+ console.log(" No scans recorded yet.\n");
315
+ }
316
+ else {
317
+ const last = history[history.length - 1];
318
+ console.log(` Latest score: \x1b[1m${last.score}%\x1b[0m (${last.scan_type} scan, Guard v${last.guard_version})`);
319
+ console.log(` Last scan date: ${last.date}`);
320
+ console.log(` Total scans: ${history.length}`);
321
+ if (last.code_score != null)
322
+ console.log(` Code score: ${last.code_score}%`);
323
+ if (last.db_score != null)
324
+ console.log(` DB score: ${last.db_score}%`);
325
+ console.log(` PII coverage: ${last.pii_encrypted}/${last.pii_total} encrypted (${last.coverage_percent}%)`);
326
+ console.log(` Issues: ${last.critical} critical, ${last.high} high, ${last.medium} medium, ${last.low} low`);
327
+ console.log(` Findings: ${last.findings_count} total`);
328
+ if (history.length >= 2) {
329
+ console.log("");
330
+ console.log(" \x1b[33m── TREND ──\x1b[0m");
331
+ const maxShow = Math.min(history.length, 10);
332
+ for (let i = history.length - maxShow; i < history.length; i++) {
333
+ const h = history[i];
334
+ const prev = i > 0 ? history[i - 1] : null;
335
+ const delta = prev ? h.score - prev.score : 0;
336
+ const arrow = delta > 0 ? `\x1b[32m+${delta}\x1b[0m` : delta < 0 ? `\x1b[31m${delta}\x1b[0m` : " 0";
337
+ const bar = "\x1b[32m" + "█".repeat(Math.round(h.score / 5)) + "\x1b[0m" + "░".repeat(20 - Math.round(h.score / 5));
338
+ console.log(` ${h.date} ${bar} ${h.score}% (${arrow})`);
339
+ }
340
+ }
341
+ console.log("");
342
+ }
343
+ // ── Proof ──
344
+ console.log(" \x1b[33m── DB-VERIFIED ENCRYPTION ──\x1b[0m");
345
+ if (!proof || proof.fields.length === 0) {
346
+ console.log(" No DB-verified encryption yet. Run with --db to verify.\n");
347
+ }
348
+ else {
349
+ console.log(` ${proof.fields.length} field(s) verified in database:\n`);
350
+ for (const f of proof.fields) {
351
+ const cov = f.coverage_percent;
352
+ const covColor = cov >= 90 ? "\x1b[32m" : cov >= 50 ? "\x1b[33m" : "\x1b[31m";
353
+ console.log(` ${f.table_hash}:${f.column_hash} ${f.pattern} ${covColor}${cov}%\x1b[0m (${f.encrypted_count}/${f.row_count} rows)`);
354
+ }
355
+ console.log(`\n Last verified: ${proof.updated_at}\n`);
356
+ }
357
+ // ── Overrides ──
358
+ console.log(" \x1b[33m── MANUAL ATTESTATIONS ──\x1b[0m");
359
+ if (!overrides || overrides.overrides.length === 0) {
360
+ console.log(" No overrides. Use \x1b[36mguard attest\x1b[0m to declare fixes.\n");
361
+ }
362
+ else {
363
+ for (const o of overrides.overrides) {
364
+ const statusColor = o.status === "fixed" ? "\x1b[32m" : o.status === "accepted_risk" ? "\x1b[33m" : "\x1b[36m";
365
+ console.log(` ${o.finding_id}`);
366
+ console.log(` Status: ${statusColor}${o.status}\x1b[0m`);
367
+ console.log(` Note: ${o.note}`);
368
+ console.log(` Date: ${o.attested_at}`);
369
+ console.log(` Device: ${o.attested_by}`);
370
+ console.log("");
371
+ }
372
+ }
373
+ // ── Evidence summary ──
374
+ const evidenceDir = (0, path_1.join)(capsuleDir, "evidence");
375
+ if ((0, fs_1.existsSync)(evidenceDir)) {
376
+ const evidenceFiles = (0, fs_1.readdirSync)(evidenceDir).filter(f => f.endsWith(".json"));
377
+ let totalEntries = 0;
378
+ for (const ef of evidenceFiles) {
379
+ try {
380
+ const entries = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(evidenceDir, ef), "utf-8"));
381
+ totalEntries += entries.length;
382
+ }
383
+ catch { /* skip */ }
384
+ }
385
+ console.log(" \x1b[33m── EVIDENCE LOG ──\x1b[0m");
386
+ console.log(` ${totalEntries} entries across ${evidenceFiles.length} day(s)`);
387
+ console.log(" \x1b[2mAll evidence stays local. Commit .capsule/ to git for audit trail.\x1b[0m\n");
388
+ }
389
+ }
390
+ /**
391
+ * Subcommand: guard diff
392
+ * Show what changed between the two most recent scans.
393
+ */
394
+ function printDiff(projectDir) {
395
+ const history = getScoreHistory(projectDir);
396
+ if (history.length < 2) {
397
+ console.log("\n Need at least 2 scans to show a diff. Run another scan first.\n");
398
+ return;
399
+ }
400
+ const prev = history[history.length - 2];
401
+ const curr = history[history.length - 1];
402
+ console.log("");
403
+ console.log(" ╔══════════════════════════════════════╗");
404
+ console.log(" ║ NoData Guard — Diff ║");
405
+ console.log(" ╚══════════════════════════════════════╝");
406
+ console.log("");
407
+ console.log(` Comparing: ${prev.date} → ${curr.date}`);
408
+ console.log("");
409
+ // Score delta
410
+ const scoreDelta = curr.score - prev.score;
411
+ const scoreArrow = scoreDelta > 0 ? `\x1b[32m↑ +${scoreDelta}%\x1b[0m` : scoreDelta < 0 ? `\x1b[31m↓ ${scoreDelta}%\x1b[0m` : "→ no change";
412
+ console.log(` Overall score: ${prev.score}% → ${curr.score}% ${scoreArrow}`);
413
+ // Code score delta
414
+ if (prev.code_score != null && curr.code_score != null) {
415
+ const cd = curr.code_score - prev.code_score;
416
+ const ca = cd > 0 ? `\x1b[32m+${cd}\x1b[0m` : cd < 0 ? `\x1b[31m${cd}\x1b[0m` : "0";
417
+ console.log(` Code score: ${prev.code_score}% → ${curr.code_score}% (${ca})`);
418
+ }
419
+ // DB score delta
420
+ if (prev.db_score != null && curr.db_score != null) {
421
+ const dd = curr.db_score - prev.db_score;
422
+ const da = dd > 0 ? `\x1b[32m+${dd}\x1b[0m` : dd < 0 ? `\x1b[31m${dd}\x1b[0m` : "0";
423
+ console.log(` DB score: ${prev.db_score}% → ${curr.db_score}% (${da})`);
424
+ }
425
+ console.log("");
426
+ // PII coverage
427
+ const piiDelta = curr.coverage_percent - prev.coverage_percent;
428
+ const piiArrow = piiDelta > 0 ? `\x1b[32m+${piiDelta}%\x1b[0m` : piiDelta < 0 ? `\x1b[31m${piiDelta}%\x1b[0m` : "0";
429
+ console.log(` PII encrypted: ${prev.pii_encrypted}/${prev.pii_total} → ${curr.pii_encrypted}/${curr.pii_total} (${piiArrow})`);
430
+ // Findings delta
431
+ const findDelta = curr.findings_count - prev.findings_count;
432
+ const findArrow = findDelta < 0 ? `\x1b[32m${findDelta} resolved\x1b[0m` : findDelta > 0 ? `\x1b[31m+${findDelta} new\x1b[0m` : "no change";
433
+ console.log(` Findings: ${prev.findings_count} → ${curr.findings_count} (${findArrow})`);
434
+ // Issues breakdown
435
+ console.log("");
436
+ console.log(" \x1b[33m── ISSUES BREAKDOWN ──\x1b[0m");
437
+ const showDelta = (label, p, c) => {
438
+ const d = c - p;
439
+ const color = d < 0 ? "\x1b[32m" : d > 0 ? "\x1b[31m" : "";
440
+ const sign = d > 0 ? "+" : "";
441
+ console.log(` ${label.padEnd(12)} ${p} → ${c} ${color}${d !== 0 ? `(${sign}${d})` : "(=)"}\x1b[0m`);
442
+ };
443
+ showDelta("Critical:", prev.critical, curr.critical);
444
+ showDelta("High:", prev.high, curr.high);
445
+ showDelta("Medium:", prev.medium, curr.medium);
446
+ showDelta("Low:", prev.low, curr.low);
447
+ // Scan type change
448
+ if (prev.scan_type !== curr.scan_type) {
449
+ console.log(`\n \x1b[36mNote:\x1b[0m Scan type changed from "${prev.scan_type}" to "${curr.scan_type}"`);
450
+ }
451
+ // Guard version change
452
+ if (prev.guard_version !== curr.guard_version) {
453
+ console.log(` \x1b[36mNote:\x1b[0m Guard version changed from v${prev.guard_version} to v${curr.guard_version}`);
454
+ }
455
+ console.log("");
456
+ if (scoreDelta > 0) {
457
+ console.log(" \x1b[32m✓ Score improved! Keep going.\x1b[0m\n");
458
+ }
459
+ else if (scoreDelta < 0) {
460
+ console.log(" \x1b[31m⚠ Score dropped. Check the full report for new issues.\x1b[0m\n");
461
+ }
462
+ else {
463
+ console.log(" \x1b[33m→ No score change. Fix open findings to improve.\x1b[0m\n");
464
+ }
465
+ }
package/dist/cli.js CHANGED
@@ -26,9 +26,24 @@ const reporter_1 = require("./reporter");
26
26
  const scheduler_1 = require("./fixers/scheduler");
27
27
  const vault_crypto_1 = require("./vault-crypto");
28
28
  const capsule_dir_1 = require("./capsule-dir");
29
- const VERSION = "2.3.0";
29
+ const VERSION = "2.5.0";
30
30
  async function main() {
31
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
+ }
32
47
  // Parse args
33
48
  let licenseKey;
34
49
  let dbUrl;
@@ -99,6 +114,49 @@ async function main() {
99
114
  licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
100
115
  if (!dbUrl)
101
116
  dbUrl = process.env.DATABASE_URL;
117
+ // ── Auto-detect from .env files ──
118
+ // Guard reads .env files to find DATABASE_URL and license keys.
119
+ // ALL values stay local — never sent anywhere.
120
+ // If DB URL is found, Guard asks for explicit consent before connecting.
121
+ if (!dbUrl || !licenseKey) {
122
+ const envResult = readEnvFiles(projectDir);
123
+ if (!licenseKey && envResult.licenseKey) {
124
+ licenseKey = envResult.licenseKey;
125
+ if (!ciMode)
126
+ console.log(`\n \x1b[32m✓\x1b[0m Found license key in ${envResult.licenseSource}`);
127
+ }
128
+ if (!dbUrl && envResult.dbUrl) {
129
+ // Mask the connection string for display (show host only)
130
+ const masked = maskDbUrl(envResult.dbUrl);
131
+ if (ciMode) {
132
+ // CI mode: auto-consent (operator set up the env)
133
+ dbUrl = envResult.dbUrl;
134
+ console.log(`[nodata-guard] Using database from ${envResult.dbSource}`);
135
+ }
136
+ else {
137
+ // Interactive: ask for consent
138
+ console.log("");
139
+ console.log(" ╔══════════════════════════════════════════╗");
140
+ console.log(" ║ \x1b[33mDatabase connection found\x1b[0m ║");
141
+ console.log(" ╚══════════════════════════════════════════╝");
142
+ console.log(` Source: ${envResult.dbSource}`);
143
+ console.log(` URL: ${masked}`);
144
+ console.log("");
145
+ console.log(" \x1b[2mGuard will connect to your database to verify encryption.\x1b[0m");
146
+ console.log(" \x1b[2mOnly checks if values START WITH encryption prefixes.\x1b[0m");
147
+ console.log(" \x1b[2mNo data values are read, copied, or sent anywhere.\x1b[0m");
148
+ console.log("");
149
+ const consent = await askConsent(" Connect to database? (y/n): ");
150
+ if (consent) {
151
+ dbUrl = envResult.dbUrl;
152
+ console.log(` \x1b[32m✓\x1b[0m Database scan enabled\n`);
153
+ }
154
+ else {
155
+ console.log(" \x1b[33m→\x1b[0m Skipping database scan — code-only mode\n");
156
+ }
157
+ }
158
+ }
159
+ }
102
160
  // Shared Protect config fallback (~/.nodata/config.json)
103
161
  if (!licenseKey) {
104
162
  try {
@@ -189,7 +247,7 @@ async function main() {
189
247
  if (!ciMode)
190
248
  console.log("");
191
249
  log(ciMode, `Scanned ${files.length} files`);
192
- const piiFields = (0, code_scanner_1.scanPIIFields)(files);
250
+ const piiFields = (0, code_scanner_1.scanPIIFields)(files, projectDir);
193
251
  const routes = (0, code_scanner_1.scanRoutes)(files);
194
252
  const secrets = (0, code_scanner_1.scanSecrets)(files);
195
253
  const stack = (0, code_scanner_1.detectStack)(files);
@@ -361,13 +419,30 @@ async function main() {
361
419
  log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
362
420
  }
363
421
  }
364
- // ── Step 7: Print summary ──
422
+ // ── Step 7: Print summary with two-score display ──
423
+ const codeScore = full.code ? Math.round((full.code.encryption_coverage_percent * 0.5) +
424
+ (full.code.route_protection_percent * 0.25) +
425
+ (Math.max(0, 100 - (full.summary.critical_issues * 25 + full.summary.high_issues * 10)) * 0.25)) : null;
426
+ const dbScore = full.db ? Math.round((full.db.encryption_coverage_percent * 0.6) +
427
+ (full.db.rls_coverage_percent * 0.4)) : null;
428
+ // Attach to full report for .capsule/ storage
429
+ full.code_score = codeScore;
430
+ full.db_score = dbScore;
365
431
  if (!ciMode) {
366
432
  console.log("");
367
433
  console.log(" ══════════════════════════════════════");
368
434
  console.log(" GUARD RESULTS");
369
435
  console.log(" ══════════════════════════════════════");
370
- console.log(` Score: ${full.overall_score}%${serverResponse.previous_score != null ? ` (was ${serverResponse.previous_score}%)` : ""}`);
436
+ console.log(` Overall score: \x1b[1m${full.overall_score}%\x1b[0m${serverResponse.previous_score != null ? ` (was ${serverResponse.previous_score}%)` : ""}`);
437
+ if (codeScore != null) {
438
+ console.log(` Code score: ${codeScore}%`);
439
+ }
440
+ if (dbScore != null) {
441
+ console.log(` DB score: ${dbScore}%`);
442
+ }
443
+ if (codeScore != null && dbScore != null) {
444
+ console.log(" ──────────────────────────────────────");
445
+ }
371
446
  console.log(` PII fields: ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} encrypted (${full.summary.coverage_percent}%)`);
372
447
  if (full.code) {
373
448
  console.log(` Routes: ${full.code.routes.filter(r => r.has_auth).length}/${full.code.routes.length} protected`);
@@ -551,6 +626,209 @@ function loadOrCreateConfig(projectDir, overrides) {
551
626
  },
552
627
  });
553
628
  }
629
+ // ═══════════════════════════════════════════════════════════
630
+ // Consent & display helpers
631
+ // ═══════════════════════════════════════════════════════════
632
+ function maskDbUrl(url) {
633
+ try {
634
+ const parsed = new URL(url);
635
+ const host = parsed.hostname;
636
+ const port = parsed.port || (url.includes("6543") ? "6543" : "5432");
637
+ const db = parsed.pathname.replace("/", "") || "postgres";
638
+ const user = parsed.username ? parsed.username.slice(0, 8) + "..." : "***";
639
+ return `${user}@${host}:${port}/${db}`;
640
+ }
641
+ catch {
642
+ // Fallback: show first 20 chars + mask
643
+ return url.slice(0, 20) + "...****";
644
+ }
645
+ }
646
+ function askConsent(prompt) {
647
+ return new Promise((resolve) => {
648
+ const readline = require("readline");
649
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
650
+ rl.question(prompt, (answer) => {
651
+ rl.close();
652
+ const a = answer.trim().toLowerCase();
653
+ resolve(a === "y" || a === "yes" || a === "כ" || a === "כן");
654
+ });
655
+ });
656
+ }
657
+ function readEnvFiles(projectDir) {
658
+ const result = {
659
+ dbUrl: null, dbSource: "",
660
+ licenseKey: null, licenseSource: "",
661
+ supabaseUrl: null, supabaseKey: null,
662
+ };
663
+ // Priority order — more specific files override general ones
664
+ const envFiles = [
665
+ ".env",
666
+ ".env.local",
667
+ ".env.development",
668
+ ".env.development.local",
669
+ ".env.production",
670
+ ".env.production.local",
671
+ ];
672
+ // Keys that contain database connection strings
673
+ const DB_KEYS = [
674
+ "DATABASE_URL",
675
+ "DIRECT_URL",
676
+ "POSTGRES_URL",
677
+ "POSTGRES_PRISMA_URL",
678
+ "POSTGRES_URL_NON_POOLING",
679
+ "PG_CONNECTION_STRING",
680
+ "DB_URL",
681
+ "SUPABASE_DB_URL",
682
+ "DATABASE_CONNECTION_STRING",
683
+ ];
684
+ // Keys that contain license/API keys
685
+ const LICENSE_KEYS = [
686
+ "NDC_LICENSE",
687
+ "NODATA_LICENSE_KEY",
688
+ "NODATA_API_KEY",
689
+ "NDC_API_KEY",
690
+ "NDC_PROTECT_KEY",
691
+ "CAPSULE_LICENSE_KEY",
692
+ "CAPSULE_API_KEY",
693
+ ];
694
+ const SUPABASE_URL_KEYS = [
695
+ "NEXT_PUBLIC_SUPABASE_URL",
696
+ "SUPABASE_URL",
697
+ "VITE_SUPABASE_URL",
698
+ "REACT_APP_SUPABASE_URL",
699
+ "NUXT_PUBLIC_SUPABASE_URL",
700
+ ];
701
+ const SUPABASE_KEY_KEYS = [
702
+ "SUPABASE_SERVICE_ROLE_KEY",
703
+ "SUPABASE_SERVICE_KEY",
704
+ ];
705
+ for (const envFile of envFiles) {
706
+ const filePath = (0, path_1.resolve)(projectDir, envFile);
707
+ if (!(0, fs_1.existsSync)(filePath))
708
+ continue;
709
+ try {
710
+ const content = (0, fs_1.readFileSync)(filePath, "utf-8");
711
+ const lines = content.split("\n");
712
+ for (const line of lines) {
713
+ const trimmed = line.trim();
714
+ if (!trimmed || trimmed.startsWith("#"))
715
+ continue;
716
+ const eqIdx = trimmed.indexOf("=");
717
+ if (eqIdx < 1)
718
+ continue;
719
+ const key = trimmed.slice(0, eqIdx).trim();
720
+ let val = trimmed.slice(eqIdx + 1).trim();
721
+ // Strip quotes
722
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
723
+ val = val.slice(1, -1);
724
+ }
725
+ if (!val)
726
+ continue;
727
+ // Check DB keys
728
+ if (!result.dbUrl && DB_KEYS.includes(key)) {
729
+ if (val.startsWith("postgres") || val.startsWith("pg://") || val.includes("5432") || val.includes("6543")) {
730
+ result.dbUrl = val;
731
+ result.dbSource = envFile;
732
+ }
733
+ }
734
+ // Check license keys
735
+ if (!result.licenseKey && LICENSE_KEYS.includes(key)) {
736
+ result.licenseKey = val;
737
+ result.licenseSource = envFile;
738
+ }
739
+ // Check Supabase URL (for fallback DB construction)
740
+ if (!result.supabaseUrl && SUPABASE_URL_KEYS.includes(key)) {
741
+ result.supabaseUrl = val;
742
+ }
743
+ // Check Supabase service role key
744
+ if (!result.supabaseKey && SUPABASE_KEY_KEYS.includes(key)) {
745
+ result.supabaseKey = val;
746
+ }
747
+ }
748
+ }
749
+ catch { /* skip unreadable files */ }
750
+ }
751
+ return result;
752
+ }
753
+ function handleAttest(args) {
754
+ let licenseKey;
755
+ let projectDir = process.cwd();
756
+ let findingId;
757
+ let status = "fixed";
758
+ let note = "";
759
+ for (let i = 0; i < args.length; i++) {
760
+ switch (args[i]) {
761
+ case "--license-key":
762
+ licenseKey = args[++i];
763
+ break;
764
+ case "--dir":
765
+ projectDir = (0, path_1.resolve)(args[++i]);
766
+ break;
767
+ case "--finding":
768
+ findingId = args[++i];
769
+ break;
770
+ case "--status":
771
+ status = args[++i];
772
+ break;
773
+ case "--note":
774
+ note = args[++i];
775
+ break;
776
+ }
777
+ }
778
+ // Env / config fallbacks for license key
779
+ if (!licenseKey)
780
+ licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
781
+ if (!licenseKey) {
782
+ try {
783
+ const home = process.env.HOME || process.env.USERPROFILE || "";
784
+ const configPath = require("path").join(home, ".nodata", "config.json");
785
+ if (require("fs").existsSync(configPath)) {
786
+ const config = JSON.parse(require("fs").readFileSync(configPath, "utf-8"));
787
+ if (config.api_key)
788
+ licenseKey = config.api_key;
789
+ }
790
+ }
791
+ catch { /* ignore */ }
792
+ }
793
+ if (!licenseKey) {
794
+ console.error("\n \x1b[31m✗\x1b[0m No API key found. Pass --license-key or set NDC_LICENSE.\n");
795
+ process.exit(1);
796
+ }
797
+ if (!findingId) {
798
+ console.error("\n \x1b[31m✗\x1b[0m Missing --finding <id>.");
799
+ console.error(" Example finding IDs: PII_UNENCRYPTED_email, ROUTE_NO_AUTH, SECRET_POSTGRES_URI");
800
+ console.error("\n Usage:");
801
+ console.error(" npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note \"Encrypted with pgcrypto\"");
802
+ console.error("\n Statuses: fixed | accepted_risk | not_applicable | compensating_control\n");
803
+ process.exit(1);
804
+ }
805
+ if (!note) {
806
+ console.error("\n \x1b[31m✗\x1b[0m Missing --note <description>. Explain what was done.\n");
807
+ process.exit(1);
808
+ }
809
+ const validStatuses = ["fixed", "accepted_risk", "not_applicable", "compensating_control"];
810
+ if (!validStatuses.includes(status)) {
811
+ console.error(`\n \x1b[31m✗\x1b[0m Invalid status "${status}". Use: ${validStatuses.join(" | ")}\n`);
812
+ process.exit(1);
813
+ }
814
+ (0, capsule_dir_1.attestFinding)(projectDir, licenseKey, findingId, status, note);
815
+ const statusColor = status === "fixed" ? "\x1b[32m" : "\x1b[33m";
816
+ console.log("");
817
+ console.log(" ╔══════════════════════════════════════╗");
818
+ console.log(" ║ NoData Guard — Attestation ║");
819
+ console.log(" ╚══════════════════════════════════════╝");
820
+ console.log("");
821
+ console.log(` Finding: ${findingId}`);
822
+ console.log(` Status: ${statusColor}${status}\x1b[0m`);
823
+ console.log(` Note: ${note}`);
824
+ console.log(` Signed: HMAC-SHA256 (license-key-bound)`);
825
+ console.log("");
826
+ console.log(" \x1b[32m✓\x1b[0m Saved to .capsule/overrides.json");
827
+ console.log(" \x1b[32m✓\x1b[0m Evidence logged to .capsule/evidence/");
828
+ console.log("");
829
+ console.log(" \x1b[2mCommit .capsule/ to git for audit trail.\x1b[0m");
830
+ console.log(" \x1b[2mRe-scan to see updated score.\x1b[0m\n");
831
+ }
554
832
  function printHelp() {
555
833
  console.log(`
556
834
  NoData Guard v${VERSION} — Security Scanner + Recommendations
@@ -563,6 +841,17 @@ function printHelp() {
563
841
  npx nodata-guard --license-key NDC-XXXX # Scan + recommend
564
842
  npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
565
843
  npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
844
+ npx nodata-guard status # Show .capsule/ status (no scan)
845
+ npx nodata-guard diff # Compare last 2 scans
846
+ npx nodata-guard attest --finding ID --status fixed --note "..." # Manual attestation
847
+
848
+ Subcommands:
849
+ status Show .capsule/ evidence (scores, proof, overrides) without scanning
850
+ diff Compare the last 2 scans — score delta, issues resolved/new
851
+ attest Manually attest a finding (saved to .capsule/overrides.json)
852
+ --finding <id> Finding ID (e.g., PII_UNENCRYPTED_email, ROUTE_NO_AUTH)
853
+ --status <status> fixed | accepted_risk | not_applicable | compensating_control
854
+ --note <text> Explanation of what was done
566
855
 
567
856
  Scan Options:
568
857
  --license-key <key> NoData license key (or set NDC_LICENSE env var)
@@ -593,9 +882,17 @@ function printHelp() {
593
882
  ✓ Prove — cryptographic proof chain of scan results
594
883
  ✓ Monitor — track score over time via dashboard
595
884
 
885
+ Auto-detection:
886
+ Guard automatically reads .env files to find DATABASE_URL
887
+ and license keys. If a database is found, Guard asks for
888
+ your explicit consent before connecting. All values stay
889
+ local — never sent anywhere. In CI mode (--ci), consent
890
+ is automatic (operator configured the environment).
891
+
596
892
  What we NEVER do:
597
893
  ✗ Modify your code, database, or configuration
598
894
  ✗ Receive data values, source code, or credentials
895
+ ✗ Read actual data from your database (only checks prefixes)
599
896
 
600
897
  Important:
601
898
  Recommendations require understanding of YOUR system —
@@ -618,6 +915,18 @@ function printHelp() {
618
915
  # CI pipeline — fail on critical issues
619
916
  npx nodata-guard --ci --fail-on critical
620
917
 
918
+ # Check .capsule/ status without scanning
919
+ npx nodata-guard status
920
+
921
+ # See what changed between last 2 scans
922
+ npx nodata-guard diff
923
+
924
+ # Attest that you fixed a finding
925
+ npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note "Encrypted with pgcrypto in migration 042"
926
+
927
+ # Accept risk for a finding
928
+ npx nodata-guard attest --finding ROUTE_NO_AUTH --status accepted_risk --note "Public healthcheck endpoint, no auth needed"
929
+
621
930
  Documentation: https://nodatacapsule.com/guard
622
931
  `);
623
932
  }
@@ -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,7 +154,7 @@ 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 {
@@ -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) {
package/dist/reporter.js CHANGED
@@ -80,6 +80,13 @@ function generateReports(input) {
80
80
  guard_version: GUARD_VERSION,
81
81
  scores: {
82
82
  overall: overallScore,
83
+ code: input.code ? Math.round((encCoverage * 0.5) + (routeProtection * 0.25) + (secretScore * 0.25)) : null,
84
+ db: input.db ? Math.round(((input.db.piiFields.length > 0
85
+ ? Math.round((input.db.piiFields.filter(f => f.encrypted).length / input.db.piiFields.length) * 100)
86
+ : 100) * 0.6) +
87
+ ((input.db.rls.length > 0
88
+ ? Math.round((input.db.rls.filter(r => r.rls_enabled).length / input.db.rls.length) * 100)
89
+ : 0) * 0.4)) : null,
83
90
  previous: input.previousScore,
84
91
  improved,
85
92
  delta: input.previousScore !== null ? overallScore - input.previousScore : 0,
package/dist/types.d.ts CHANGED
@@ -111,6 +111,8 @@ export interface MetadataReport {
111
111
  guard_version: string;
112
112
  scores: {
113
113
  overall: number;
114
+ code: number | null;
115
+ db: number | null;
114
116
  previous: number | null;
115
117
  improved: boolean;
116
118
  delta: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodatachat/guard",
3
- "version": "2.3.0",
3
+ "version": "2.5.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",