@nodatachat/guard 2.3.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.
@@ -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.4.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;
@@ -189,7 +204,7 @@ async function main() {
189
204
  if (!ciMode)
190
205
  console.log("");
191
206
  log(ciMode, `Scanned ${files.length} files`);
192
- const piiFields = (0, code_scanner_1.scanPIIFields)(files);
207
+ const piiFields = (0, code_scanner_1.scanPIIFields)(files, projectDir);
193
208
  const routes = (0, code_scanner_1.scanRoutes)(files);
194
209
  const secrets = (0, code_scanner_1.scanSecrets)(files);
195
210
  const stack = (0, code_scanner_1.detectStack)(files);
@@ -361,13 +376,30 @@ async function main() {
361
376
  log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
362
377
  }
363
378
  }
364
- // ── Step 7: Print summary ──
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;
365
388
  if (!ciMode) {
366
389
  console.log("");
367
390
  console.log(" ══════════════════════════════════════");
368
391
  console.log(" GUARD RESULTS");
369
392
  console.log(" ══════════════════════════════════════");
370
- 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
+ }
371
403
  console.log(` PII fields: ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} encrypted (${full.summary.coverage_percent}%)`);
372
404
  if (full.code) {
373
405
  console.log(` Routes: ${full.code.routes.filter(r => r.has_auth).length}/${full.code.routes.length} protected`);
@@ -551,6 +583,85 @@ function loadOrCreateConfig(projectDir, overrides) {
551
583
  },
552
584
  });
553
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
+ }
554
665
  function printHelp() {
555
666
  console.log(`
556
667
  NoData Guard v${VERSION} — Security Scanner + Recommendations
@@ -563,6 +674,17 @@ function printHelp() {
563
674
  npx nodata-guard --license-key NDC-XXXX # Scan + recommend
564
675
  npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
565
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
566
688
 
567
689
  Scan Options:
568
690
  --license-key <key> NoData license key (or set NDC_LICENSE env var)
@@ -618,6 +740,18 @@ function printHelp() {
618
740
  # CI pipeline — fail on critical issues
619
741
  npx nodata-guard --ci --fail-on critical
620
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
+
621
755
  Documentation: https://nodatacapsule.com/guard
622
756
  `);
623
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,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.4.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",