@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/capsule-dir.d.ts +104 -0
- package/dist/capsule-dir.js +465 -0
- package/dist/cli.js +327 -4
- package/dist/code-scanner.d.ts +1 -1
- package/dist/code-scanner.js +27 -4
- package/dist/db-scanner.js +22 -5
- package/dist/reporter.js +52 -13
- package/dist/types.d.ts +16 -3
- package/dist/vault-crypto.d.ts +6 -0
- package/dist/vault-crypto.js +36 -0
- package/package.json +1 -1
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
|
|
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
|
|
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(`
|
|
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
|
}
|
package/dist/code-scanner.d.ts
CHANGED
|
@@ -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[]): {
|
package/dist/code-scanner.js
CHANGED
|
@@ -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
|
|
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;
|
package/dist/db-scanner.js
CHANGED
|
@@ -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,
|
|
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,
|
|
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 =
|
|
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 =
|
|
145
|
+
field.encryption_pattern = pattern;
|
|
129
146
|
field.encrypted_count = encCount;
|
|
130
147
|
}
|
|
131
148
|
}
|