@nodatachat/guard 2.2.0 → 2.3.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.
@@ -0,0 +1,93 @@
1
+ export interface CapsuleProof {
2
+ version: "1.0";
3
+ updated_at: string;
4
+ fields: ProofField[];
5
+ }
6
+ export interface ProofField {
7
+ table_hash: string;
8
+ column_hash: string;
9
+ pattern: string;
10
+ sentinel_prefix: string;
11
+ row_count: number;
12
+ encrypted_count: number;
13
+ coverage_percent: number;
14
+ verified_at: string;
15
+ }
16
+ export interface CapsuleOverride {
17
+ version: "1.0";
18
+ overrides: Override[];
19
+ }
20
+ export interface Override {
21
+ finding_id: string;
22
+ status: "fixed" | "accepted_risk" | "not_applicable" | "compensating_control";
23
+ note: string;
24
+ attested_by: string;
25
+ attested_at: string;
26
+ hmac: string;
27
+ }
28
+ export interface ScoreSnapshot {
29
+ date: string;
30
+ score: number;
31
+ code_score: number | null;
32
+ db_score: number | null;
33
+ pii_total: number;
34
+ pii_encrypted: number;
35
+ coverage_percent: number;
36
+ findings_count: number;
37
+ critical: number;
38
+ high: number;
39
+ medium: number;
40
+ low: number;
41
+ scan_type: "code" | "full";
42
+ guard_version: string;
43
+ }
44
+ export interface EvidenceEntry {
45
+ date: string;
46
+ action: string;
47
+ category: string;
48
+ detail: string;
49
+ impact: string;
50
+ attested_by: string;
51
+ hmac: string;
52
+ }
53
+ /**
54
+ * Initialize .capsule/ directory. Called on every scan.
55
+ * Creates missing dirs/files, never overwrites existing data.
56
+ */
57
+ export declare function initCapsuleDir(projectDir: string, licenseKey: string): string;
58
+ /**
59
+ * Save a score snapshot. Called after every scan.
60
+ */
61
+ export declare function saveScore(capsuleDir: string, score: ScoreSnapshot): void;
62
+ /**
63
+ * Update proof.json with DB-verified encryption data.
64
+ * Called after DB scan detects encrypted fields.
65
+ */
66
+ export declare function updateProof(capsuleDir: string, fields: Array<{
67
+ table: string;
68
+ column: string;
69
+ pattern: string;
70
+ sentinel_prefix: string;
71
+ row_count: number;
72
+ encrypted_count: number;
73
+ }>): void;
74
+ /**
75
+ * Add an evidence entry. Called after scan or manual attest.
76
+ */
77
+ export declare function addEvidence(capsuleDir: string, licenseKey: string, entry: Omit<EvidenceEntry, "hmac">): void;
78
+ /**
79
+ * Read proof.json — used by code-scanner to check for known encryptions.
80
+ */
81
+ export declare function readProof(projectDir: string): CapsuleProof | null;
82
+ /**
83
+ * Read overrides.json — used by reporter to adjust scores.
84
+ */
85
+ export declare function readOverrides(projectDir: string): CapsuleOverride | null;
86
+ /**
87
+ * Get score history — used for trend display.
88
+ */
89
+ export declare function getScoreHistory(projectDir: string): ScoreSnapshot[];
90
+ /**
91
+ * Print .capsule/ status to terminal.
92
+ */
93
+ export declare function printCapsuleStatus(projectDir: string, ciMode: boolean): void;
@@ -0,0 +1,248 @@
1
+ "use strict";
2
+ // ═══════════════════════════════════════════════════════════
3
+ // .capsule/ Directory Manager
4
+ //
5
+ // Creates and maintains a local evidence directory in the customer's project.
6
+ // Everything stays local. NoData receives ONLY metadata (scores, counts).
7
+ //
8
+ // Structure:
9
+ // .capsule/
10
+ // ├── config.json ← Scanner settings, license, thresholds
11
+ // ├── proof.json ← Encryption sentinels, verified patterns
12
+ // ├── overrides.json ← Manual attestations ("I fixed this")
13
+ // ├── scores/
14
+ // │ └── YYYY-MM-DD.json ← Score snapshots per scan
15
+ // └── evidence/
16
+ // └── YYYY-MM-DD.json ← What was fixed, by whom, when
17
+ //
18
+ // Privacy:
19
+ // - No PII, table names, column names, or data values are stored
20
+ // - Sentinels are prefix-only (first 12 chars) — never full encrypted values
21
+ // - HMAC signature prevents tampering (proves the evidence is authentic)
22
+ // - NoData receives: score, date, finding_count, coverage_percent — NOTHING else
23
+ // ═══════════════════════════════════════════════════════════
24
+ Object.defineProperty(exports, "__esModule", { value: true });
25
+ exports.initCapsuleDir = initCapsuleDir;
26
+ exports.saveScore = saveScore;
27
+ exports.updateProof = updateProof;
28
+ exports.addEvidence = addEvidence;
29
+ exports.readProof = readProof;
30
+ exports.readOverrides = readOverrides;
31
+ exports.getScoreHistory = getScoreHistory;
32
+ exports.printCapsuleStatus = printCapsuleStatus;
33
+ const path_1 = require("path");
34
+ const fs_1 = require("fs");
35
+ const crypto_1 = require("crypto");
36
+ const DIR_NAME = ".capsule";
37
+ const HMAC_KEY_DOMAIN = "capsule:evidence:integrity";
38
+ // ── Helpers ──
39
+ function hashName(name) {
40
+ return (0, crypto_1.createHash)("sha256").update(`capsule:name:${name}`).digest("hex").slice(0, 16);
41
+ }
42
+ function signEvidence(data, licenseKey) {
43
+ return (0, crypto_1.createHmac)("sha256", `${HMAC_KEY_DOMAIN}:${licenseKey}`)
44
+ .update(data)
45
+ .digest("hex")
46
+ .slice(0, 32);
47
+ }
48
+ function ensureDir(dir) {
49
+ if (!(0, fs_1.existsSync)(dir))
50
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
51
+ }
52
+ // ── Public API ──
53
+ /**
54
+ * Initialize .capsule/ directory. Called on every scan.
55
+ * Creates missing dirs/files, never overwrites existing data.
56
+ */
57
+ function initCapsuleDir(projectDir, licenseKey) {
58
+ const capsuleDir = (0, path_1.resolve)(projectDir, DIR_NAME);
59
+ ensureDir(capsuleDir);
60
+ ensureDir((0, path_1.join)(capsuleDir, "scores"));
61
+ ensureDir((0, path_1.join)(capsuleDir, "evidence"));
62
+ // Config — create only if missing
63
+ const configPath = (0, path_1.join)(capsuleDir, "config.json");
64
+ if (!(0, fs_1.existsSync)(configPath)) {
65
+ (0, fs_1.writeFileSync)(configPath, JSON.stringify({
66
+ version: "1.0",
67
+ created_at: new Date().toISOString(),
68
+ license_prefix: licenseKey.slice(0, 12) + "...",
69
+ thresholds: {
70
+ fail_on: "critical",
71
+ target_score: 85,
72
+ encryption_target: 90,
73
+ },
74
+ scan_schedule: "manual",
75
+ notes: "This directory contains local evidence for SOC compliance. Commit to git for audit history. NoData never receives this data.",
76
+ }, null, 2), "utf-8");
77
+ }
78
+ // Proof — create only if missing
79
+ const proofPath = (0, path_1.join)(capsuleDir, "proof.json");
80
+ if (!(0, fs_1.existsSync)(proofPath)) {
81
+ const proof = { version: "1.0", updated_at: new Date().toISOString(), fields: [] };
82
+ (0, fs_1.writeFileSync)(proofPath, JSON.stringify(proof, null, 2), "utf-8");
83
+ }
84
+ // Overrides — create only if missing
85
+ const overridesPath = (0, path_1.join)(capsuleDir, "overrides.json");
86
+ if (!(0, fs_1.existsSync)(overridesPath)) {
87
+ const overrides = { version: "1.0", overrides: [] };
88
+ (0, fs_1.writeFileSync)(overridesPath, JSON.stringify(overrides, null, 2), "utf-8");
89
+ }
90
+ // Ensure .gitignore does NOT exclude .capsule/
91
+ // (It should be committed for audit trail)
92
+ const gitignorePath = (0, path_1.resolve)(projectDir, ".gitignore");
93
+ if ((0, fs_1.existsSync)(gitignorePath)) {
94
+ const content = (0, fs_1.readFileSync)(gitignorePath, "utf-8");
95
+ if (content.includes(".capsule")) {
96
+ // Remove .capsule from gitignore if present — it should be tracked
97
+ const lines = content.split("\n").filter(l => !l.trim().startsWith(".capsule"));
98
+ (0, fs_1.writeFileSync)(gitignorePath, lines.join("\n"), "utf-8");
99
+ }
100
+ }
101
+ return capsuleDir;
102
+ }
103
+ /**
104
+ * Save a score snapshot. Called after every scan.
105
+ */
106
+ function saveScore(capsuleDir, score) {
107
+ const date = new Date().toISOString().slice(0, 10);
108
+ const scoresDir = (0, path_1.join)(capsuleDir, "scores");
109
+ ensureDir(scoresDir);
110
+ (0, fs_1.writeFileSync)((0, path_1.join)(scoresDir, `${date}.json`), JSON.stringify(score, null, 2), "utf-8");
111
+ }
112
+ /**
113
+ * Update proof.json with DB-verified encryption data.
114
+ * Called after DB scan detects encrypted fields.
115
+ */
116
+ function updateProof(capsuleDir, fields) {
117
+ const proofPath = (0, path_1.join)(capsuleDir, "proof.json");
118
+ const proof = (0, fs_1.existsSync)(proofPath)
119
+ ? JSON.parse((0, fs_1.readFileSync)(proofPath, "utf-8"))
120
+ : { version: "1.0", updated_at: "", fields: [] };
121
+ for (const f of fields) {
122
+ const tableHash = hashName(f.table);
123
+ const columnHash = hashName(f.column);
124
+ const coverage = f.row_count > 0 ? Math.round((f.encrypted_count / f.row_count) * 100) : 0;
125
+ // Update existing or add new
126
+ const existing = proof.fields.find(p => p.table_hash === tableHash && p.column_hash === columnHash);
127
+ if (existing) {
128
+ existing.pattern = f.pattern;
129
+ existing.sentinel_prefix = f.sentinel_prefix.slice(0, 12);
130
+ existing.row_count = f.row_count;
131
+ existing.encrypted_count = f.encrypted_count;
132
+ existing.coverage_percent = coverage;
133
+ existing.verified_at = new Date().toISOString();
134
+ }
135
+ else {
136
+ proof.fields.push({
137
+ table_hash: tableHash,
138
+ column_hash: columnHash,
139
+ pattern: f.pattern,
140
+ sentinel_prefix: f.sentinel_prefix.slice(0, 12),
141
+ row_count: f.row_count,
142
+ encrypted_count: f.encrypted_count,
143
+ coverage_percent: coverage,
144
+ verified_at: new Date().toISOString(),
145
+ });
146
+ }
147
+ }
148
+ proof.updated_at = new Date().toISOString();
149
+ (0, fs_1.writeFileSync)(proofPath, JSON.stringify(proof, null, 2), "utf-8");
150
+ }
151
+ /**
152
+ * Add an evidence entry. Called after scan or manual attest.
153
+ */
154
+ function addEvidence(capsuleDir, licenseKey, entry) {
155
+ const date = new Date().toISOString().slice(0, 10);
156
+ const evidenceDir = (0, path_1.join)(capsuleDir, "evidence");
157
+ ensureDir(evidenceDir);
158
+ const filePath = (0, path_1.join)(evidenceDir, `${date}.json`);
159
+ const entries = (0, fs_1.existsSync)(filePath)
160
+ ? JSON.parse((0, fs_1.readFileSync)(filePath, "utf-8"))
161
+ : [];
162
+ // Sign the entry — proves it wasn't tampered with
163
+ const payload = JSON.stringify({ ...entry });
164
+ const hmac = signEvidence(payload, licenseKey);
165
+ entries.push({ ...entry, hmac });
166
+ (0, fs_1.writeFileSync)(filePath, JSON.stringify(entries, null, 2), "utf-8");
167
+ }
168
+ /**
169
+ * Read proof.json — used by code-scanner to check for known encryptions.
170
+ */
171
+ function readProof(projectDir) {
172
+ const proofPath = (0, path_1.join)(projectDir, DIR_NAME, "proof.json");
173
+ if (!(0, fs_1.existsSync)(proofPath))
174
+ return null;
175
+ try {
176
+ return JSON.parse((0, fs_1.readFileSync)(proofPath, "utf-8"));
177
+ }
178
+ catch {
179
+ return null;
180
+ }
181
+ }
182
+ /**
183
+ * Read overrides.json — used by reporter to adjust scores.
184
+ */
185
+ function readOverrides(projectDir) {
186
+ const overridesPath = (0, path_1.join)(projectDir, DIR_NAME, "overrides.json");
187
+ if (!(0, fs_1.existsSync)(overridesPath))
188
+ return null;
189
+ try {
190
+ return JSON.parse((0, fs_1.readFileSync)(overridesPath, "utf-8"));
191
+ }
192
+ catch {
193
+ return null;
194
+ }
195
+ }
196
+ /**
197
+ * Get score history — used for trend display.
198
+ */
199
+ function getScoreHistory(projectDir) {
200
+ const scoresDir = (0, path_1.join)(projectDir, DIR_NAME, "scores");
201
+ if (!(0, fs_1.existsSync)(scoresDir))
202
+ return [];
203
+ const files = require("fs").readdirSync(scoresDir)
204
+ .filter((f) => f.endsWith(".json"))
205
+ .sort();
206
+ return files.map((f) => {
207
+ try {
208
+ return JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(scoresDir, f), "utf-8"));
209
+ }
210
+ catch {
211
+ return null;
212
+ }
213
+ }).filter(Boolean);
214
+ }
215
+ /**
216
+ * Print .capsule/ status to terminal.
217
+ */
218
+ function printCapsuleStatus(projectDir, ciMode) {
219
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
220
+ if (!(0, fs_1.existsSync)(capsuleDir))
221
+ return;
222
+ if (ciMode)
223
+ return;
224
+ const history = getScoreHistory(projectDir);
225
+ const proof = readProof(projectDir);
226
+ const overrides = readOverrides(projectDir);
227
+ console.log("");
228
+ console.log(" ══════════════════════════════════════");
229
+ console.log(" .capsule/ LOCAL EVIDENCE");
230
+ console.log(" ══════════════════════════════════════");
231
+ console.log(` Score history: ${history.length} scan(s)`);
232
+ if (history.length >= 2) {
233
+ const first = history[0];
234
+ const last = history[history.length - 1];
235
+ const delta = last.score - first.score;
236
+ const arrow = delta > 0 ? `\x1b[32m↑${delta}%\x1b[0m` : delta < 0 ? `\x1b[31m↓${Math.abs(delta)}%\x1b[0m` : "→ no change";
237
+ console.log(` Trend: ${first.score}% → ${last.score}% ${arrow}`);
238
+ }
239
+ if (proof) {
240
+ console.log(` Proven fields: ${proof.fields.length} (DB-verified encryption)`);
241
+ }
242
+ if (overrides && overrides.overrides.length > 0) {
243
+ console.log(` Overrides: ${overrides.overrides.length} manual attestation(s)`);
244
+ }
245
+ console.log(" ──────────────────────────────────────");
246
+ console.log(" \x1b[2mAll evidence stays local. Commit to git for audit trail.\x1b[0m");
247
+ console.log(" ══════════════════════════════════════\n");
248
+ }
package/dist/cli.js CHANGED
@@ -18,12 +18,15 @@
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.3.0";
27
30
  async function main() {
28
31
  const args = process.argv.slice(2);
29
32
  // Parse args
@@ -34,6 +37,8 @@ async function main() {
34
37
  let failOn = null;
35
38
  let outputDir = process.cwd();
36
39
  let skipSend = false;
40
+ let vaultPin;
41
+ let vaultDevice;
37
42
  let schedulePreset;
38
43
  let ciProvider;
39
44
  for (let i = 0; i < args.length; i++) {
@@ -59,6 +64,12 @@ async function main() {
59
64
  case "--skip-send":
60
65
  skipSend = true;
61
66
  break;
67
+ case "--vault-pin":
68
+ vaultPin = args[++i];
69
+ break;
70
+ case "--vault-device":
71
+ vaultDevice = args[++i];
72
+ break;
62
73
  case "--fix-plan":
63
74
  console.log("\n ⚠ --fix-plan is deprecated. Capsule provides recommendations only.\n Run without this flag to see recommendations.\n");
64
75
  break;
@@ -166,6 +177,9 @@ async function main() {
166
177
  process.exit(1);
167
178
  }
168
179
  log(ciMode, `Activated. Tier: ${activation.tier} | Scan ID: ${activation.scan_id}`);
180
+ // ── Step 1.5: Initialize .capsule/ directory ──
181
+ const capsuleDir = (0, capsule_dir_1.initCapsuleDir)(projectDir, licenseKey);
182
+ log(ciMode, `.capsule/ directory ready: ${capsuleDir}`);
169
183
  // ── Step 2: Code scan ──
170
184
  log(ciMode, "Scanning code...");
171
185
  const files = (0, code_scanner_1.readProjectFiles)(projectDir, (count) => {
@@ -229,6 +243,59 @@ async function main() {
229
243
  (0, fs_1.writeFileSync)(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
230
244
  log(ciMode, `Full report: ${fullPath}`);
231
245
  log(ciMode, `Metadata only: ${metaPath}`);
246
+ // ── Step 5.5: Save to .capsule/ ──
247
+ try {
248
+ // Save score snapshot
249
+ (0, capsule_dir_1.saveScore)(capsuleDir, {
250
+ date: new Date().toISOString().slice(0, 10),
251
+ score: full.overall_score,
252
+ code_score: full.code_score ?? null,
253
+ db_score: full.db_score ?? null,
254
+ pii_total: full.summary.total_pii_fields,
255
+ pii_encrypted: full.summary.encrypted_fields,
256
+ coverage_percent: full.summary.coverage_percent,
257
+ findings_count: metadata.findings?.length || 0,
258
+ critical: full.summary.critical_issues,
259
+ high: full.summary.high_issues,
260
+ medium: full.summary.medium_issues ?? 0,
261
+ low: full.summary.low_issues ?? 0,
262
+ scan_type: dbUrl ? "full" : "code",
263
+ guard_version: VERSION,
264
+ });
265
+ // Update proof.json from DB results (if DB scan was done)
266
+ if (full.db?.pii_fields) {
267
+ const proofFields = full.db.pii_fields
268
+ .filter((f) => f.encrypted && f.encryption_pattern)
269
+ .map((f) => ({
270
+ table: f.table,
271
+ column: f.column,
272
+ pattern: f.encryption_pattern,
273
+ sentinel_prefix: f.sentinel_prefix || f.encryption_pattern,
274
+ row_count: f.row_count || 0,
275
+ encrypted_count: f.encrypted_count || 0,
276
+ }));
277
+ if (proofFields.length > 0) {
278
+ (0, capsule_dir_1.updateProof)(capsuleDir, proofFields);
279
+ log(ciMode, `Updated proof.json: ${proofFields.length} DB-verified encrypted fields`);
280
+ }
281
+ }
282
+ // Log scan as evidence
283
+ const deviceHash = (0, crypto_1.createHash)("sha256")
284
+ .update(`capsule:device:${require("os").hostname()}`)
285
+ .digest("hex").slice(0, 12);
286
+ (0, capsule_dir_1.addEvidence)(capsuleDir, licenseKey, {
287
+ date: new Date().toISOString(),
288
+ action: "scan_completed",
289
+ category: "scan",
290
+ 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`,
291
+ impact: `Score: ${full.overall_score}%`,
292
+ attested_by: deviceHash,
293
+ });
294
+ log(ciMode, "Evidence saved to .capsule/");
295
+ }
296
+ catch (err) {
297
+ log(ciMode, `.capsule/ save failed (non-blocking): ${err instanceof Error ? err.message : err}`);
298
+ }
232
299
  // ── Step 6: Send metadata to NoData ──
233
300
  let serverResponse = {};
234
301
  if (!skipSend) {
@@ -257,6 +324,43 @@ async function main() {
257
324
  log(ciMode, "Could not reach NoData API — report saved locally");
258
325
  }
259
326
  }
327
+ // ── Step 6b: Encrypt & upload to vault (if --vault-pin) ──
328
+ let vaultUploaded = false;
329
+ if (vaultPin && !skipSend) {
330
+ log(ciMode, "Encrypting full report for vault...");
331
+ try {
332
+ const fullJson = JSON.stringify(full);
333
+ const { ciphertext, iv, salt } = (0, vault_crypto_1.encryptForVault)(vaultPin, fullJson);
334
+ const vaultRes = await fetch("https://nodatacapsule.com/api/guard/vault", {
335
+ method: "POST",
336
+ headers: {
337
+ "Content-Type": "application/json",
338
+ "X-License-Key": licenseKey || "",
339
+ },
340
+ body: JSON.stringify({
341
+ encrypted_blob: ciphertext,
342
+ iv,
343
+ salt,
344
+ score: full.overall_score,
345
+ findings_count: metadata.findings?.length || 0,
346
+ scan_id: activation.scan_id,
347
+ device_id: vaultDevice || undefined,
348
+ }),
349
+ signal: AbortSignal.timeout(15000),
350
+ });
351
+ if (vaultRes.ok) {
352
+ vaultUploaded = true;
353
+ log(ciMode, "Report encrypted & saved to vault");
354
+ }
355
+ else {
356
+ const errBody = await vaultRes.text().catch(() => "");
357
+ log(ciMode, `Vault upload failed (${vaultRes.status}): ${errBody}`);
358
+ }
359
+ }
360
+ catch (err) {
361
+ log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
362
+ }
363
+ }
260
364
  // ── Step 7: Print summary ──
261
365
  if (!ciMode) {
262
366
  console.log("");
@@ -278,6 +382,9 @@ async function main() {
278
382
  console.log(` Sent to NoData: ${metaPath}`);
279
383
  console.log(` Proof hash: ${full.proof_hash.slice(0, 16)}...`);
280
384
  console.log(" ══════════════════════════════════════");
385
+ if (vaultUploaded) {
386
+ console.log(" Vault: Encrypted & saved to dashboard");
387
+ }
281
388
  console.log(" Your data never left your machine.");
282
389
  console.log(" Diff the two files to verify.\n");
283
390
  }
@@ -325,6 +432,83 @@ async function main() {
325
432
  log(ciMode, `Recommendations: ${recsPath}`);
326
433
  }
327
434
  }
435
+ // ── Step 8.5: Remediation Guide (non-CI) ──
436
+ if (!ciMode && metadata.findings?.length > 0) {
437
+ const fs = metadata.findings;
438
+ const hasPII = fs.some((f) => f.category === "encryption" || f.title?.toLowerCase().includes("pii") || f.title?.toLowerCase().includes("encrypt"));
439
+ const hasRLS = fs.some((f) => f.title?.toLowerCase().includes("rls") || f.title?.toLowerCase().includes("row level") || f.category === "access_control");
440
+ const hasSecrets = fs.some((f) => f.title?.toLowerCase().includes("secret") || f.title?.toLowerCase().includes("hardcoded") || f.category === "secrets");
441
+ const hasHeaders = fs.some((f) => f.title?.toLowerCase().includes("header") || f.category === "headers");
442
+ if (hasPII || hasRLS || hasSecrets || hasHeaders) {
443
+ console.log(" ══════════════════════════════════════");
444
+ console.log(" \x1b[33mREMEDIATION GUIDE\x1b[0m — General commands");
445
+ console.log(" ══════════════════════════════════════");
446
+ console.log(" \x1b[2mThese are general recommendations.");
447
+ console.log(" A qualified technical professional should review");
448
+ console.log(" and adapt them to your specific system.\x1b[0m\n");
449
+ if (hasPII) {
450
+ console.log(" \x1b[33m── ENCRYPT PII FIELDS ──\x1b[0m");
451
+ console.log(" \x1b[36mOption A:\x1b[0m Database-level (PostgreSQL)");
452
+ console.log(" CREATE EXTENSION IF NOT EXISTS pgcrypto;");
453
+ console.log(" ALTER TABLE <table> ADD COLUMN <field>_encrypted BYTEA;");
454
+ console.log(" UPDATE <table> SET <field>_encrypted =");
455
+ console.log(" pgp_sym_encrypt(<field>::TEXT, '<key>');");
456
+ console.log("");
457
+ console.log(" \x1b[36mOption B:\x1b[0m Application-level (Capsule SDK)");
458
+ console.log(" npm install @nodatachat/sdk");
459
+ console.log(" encrypt(value, process.env.FIELD_ENCRYPTION_KEY)");
460
+ console.log("");
461
+ console.log(" \x1b[36mOption C:\x1b[0m Encrypt .env at rest");
462
+ console.log(" npx @nodatachat/protect encrypt .env\n");
463
+ }
464
+ if (hasRLS) {
465
+ console.log(" \x1b[33m── ROW LEVEL SECURITY ──\x1b[0m");
466
+ console.log(" ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;");
467
+ console.log(" ALTER TABLE <table> FORCE ROW LEVEL SECURITY;");
468
+ console.log(" CREATE POLICY \"users_own\" ON <table>");
469
+ console.log(" FOR ALL USING (user_id = auth.uid());\n");
470
+ }
471
+ if (hasSecrets) {
472
+ console.log(" \x1b[33m── REMOVE SECRETS FROM CODE ──\x1b[0m");
473
+ console.log(" 1. Move to .env.local");
474
+ console.log(" 2. Reference: process.env.SECRET_NAME");
475
+ console.log(" 3. echo '.env*.local' >> .gitignore");
476
+ console.log(" 4. npx @nodatachat/protect encrypt .env\n");
477
+ }
478
+ if (hasHeaders) {
479
+ console.log(" \x1b[33m── SECURITY HEADERS ──\x1b[0m");
480
+ console.log(" Strict-Transport-Security: max-age=63072000");
481
+ console.log(" X-Content-Type-Options: nosniff");
482
+ console.log(" X-Frame-Options: DENY");
483
+ console.log(" Referrer-Policy: strict-origin-when-cross-origin\n");
484
+ }
485
+ console.log(" ──────────────────────────────────────\n");
486
+ }
487
+ }
488
+ // ── Step 9: What's next (non-CI) ──
489
+ if (!ciMode) {
490
+ console.log(" ══════════════════════════════════════");
491
+ console.log(" WHAT TO DO NEXT");
492
+ console.log(" ══════════════════════════════════════");
493
+ if (vaultUploaded) {
494
+ console.log(" 1. Open your dashboard: https://nodatacapsule.com/my-capsule");
495
+ console.log(" 2. Go to the \x1b[36mVault\x1b[0m tab");
496
+ console.log(" 3. Enter your PIN to see the full report");
497
+ console.log(" 4. Share the score with your team (no sensitive data)");
498
+ console.log(" 5. Fix findings and re-scan to improve your score\n");
499
+ }
500
+ else {
501
+ console.log(" 1. Review \x1b[36mnodata-full-report.json\x1b[0m (stays local)");
502
+ console.log(" 2. Open your dashboard: https://nodatacapsule.com/my-capsule");
503
+ console.log(" 3. Share the score with your team");
504
+ console.log(" 4. Fix findings and re-scan to improve your score");
505
+ console.log("");
506
+ console.log(" \x1b[33mTip:\x1b[0m Add \x1b[36m--vault-pin 1234\x1b[0m to auto-encrypt");
507
+ console.log(" and upload the full report to your vault.\n");
508
+ }
509
+ }
510
+ // ── Step 10: Print .capsule/ status ──
511
+ (0, capsule_dir_1.printCapsuleStatus)(projectDir, ciMode);
328
512
  // ── CI mode: exit code ──
329
513
  if (ciMode && failOn) {
330
514
  const { critical_issues, high_issues, medium_issues } = full.summary;
@@ -388,6 +572,8 @@ function printHelp() {
388
572
  --ci CI mode — minimal output, exit codes
389
573
  --fail-on <level> Exit 1 if issues at: critical | high | medium
390
574
  --skip-send Don't send metadata to NoData
575
+ --vault-pin <pin> Encrypt full report & save to vault (AES-256-GCM)
576
+ --vault-device <id> Link vault report to your dashboard device
391
577
 
392
578
  Schedule Options:
393
579
  --schedule <preset> Install CI workflow: daily | weekly | monthly
@@ -420,6 +606,9 @@ function printHelp() {
420
606
  # Scan and get recommendations
421
607
  npx nodata-guard --license-key NDC-XXXX
422
608
 
609
+ # Scan + auto-save encrypted report to vault
610
+ npx nodata-guard --license-key NDC-XXXX --vault-pin 1234
611
+
423
612
  # Full scan with DB probe
424
613
  npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL
425
614
 
@@ -159,7 +159,7 @@ function scanPIIFields(files) {
159
159
  const proof = JSON.parse(proofFile.content);
160
160
  if (proof.version === "1.0" && Array.isArray(proof.fields)) {
161
161
  for (const f of proof.fields) {
162
- if (f.sentinel_encrypted?.startsWith("aes256gcm:v1:")) {
162
+ if (f.sentinel_encrypted?.startsWith("aes256gcm:v1:") || f.sentinel_encrypted?.startsWith("enc:v1:") || f.sentinel_encrypted?.startsWith("ndc_enc_")) {
163
163
  proofFields.add(`${f.table}.${f.column}`);
164
164
  }
165
165
  }
@@ -201,7 +201,7 @@ function scanPIIFields(files) {
201
201
  for (let i = 0; i < lines.length; i++) {
202
202
  if (lines[i].includes(field.column)) {
203
203
  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)) {
204
+ if (/createCipheriv|encrypt\(|encryptPII|nodata_encrypt|encryptField|NoDataProxy|proxy\.seal|proxy\.unseal|enc:v1:/i.test(context)) {
205
205
  field.encrypted = true;
206
206
  field.encryption_pattern = "code_encrypt";
207
207
  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
  }
package/dist/reporter.js CHANGED
@@ -67,6 +67,9 @@ function generateReports(input) {
67
67
  const proofData = allPII.map(f => `${f.table}.${f.column}:${f.encrypted}`).join("|") + `|${overallScore}|${now}`;
68
68
  const proofHash = (0, crypto_1.createHash)("sha256").update(proofData).digest("hex");
69
69
  // ── Build metadata report (what we send) ──
70
+ // PRIVACY DOCTRINE: No structural metadata (table/column/file names) ever leaves the machine.
71
+ // We send ONLY: counts, scores, severity, generic category labels, SOC control IDs.
72
+ // The customer keeps the full report locally and can verify the diff.
70
73
  const metadata = {
71
74
  version: "1.0",
72
75
  scan_id: input.scanId,
@@ -93,13 +96,11 @@ function generateReports(input) {
93
96
  route_protection_percent: routeProtection,
94
97
  secrets_found: (input.code.secrets.filter(s => !s.is_env_interpolated)).length,
95
98
  secrets_critical: criticalSecrets,
96
- // Per-field summary NO VALUES, just table.column + status
99
+ // Per-field: hashed identifier + type + status NO real names
97
100
  fields: input.code.piiFields.map(f => ({
98
- table: f.table,
99
- column: f.column,
101
+ field_hash: (0, crypto_1.createHash)("sha256").update(`${f.table}.${f.column}`).digest("hex").slice(0, 16),
100
102
  pii_type: f.pii_type,
101
103
  encrypted: f.encrypted,
102
- pattern: f.encryption_pattern,
103
104
  })),
104
105
  } : null,
105
106
  db_summary: input.db ? {
@@ -123,32 +124,32 @@ function generateReports(input) {
123
124
  critical: criticalSecrets,
124
125
  high: highSecrets + plaintextPII,
125
126
  medium: medSecrets,
126
- recommendations_available: plaintextPII, // each plaintext field has a recommendation
127
+ recommendations_available: plaintextPII,
127
128
  },
128
- // Fix suggestionssafe to send (just recommendation text, no code/values)
129
+ // REDACTED findingsgeneric titles only, no structural metadata
129
130
  findings: [
130
131
  ...allPII.filter(f => !f.encrypted).map(f => ({
131
132
  severity: "high",
132
133
  category: "pii",
133
134
  rule_id: `PII_${f.pii_type.toUpperCase()}`,
134
- title: `${f.table}.${f.column} ${f.pii_type} stored in plaintext`,
135
- fix_suggestion: `Add AES-256-GCM encryption to ${f.table}.${f.column} via Capsule field permissions`,
135
+ title: `PII field (${f.pii_type}) stored unencrypted`,
136
+ fix_suggestion: "Add AES-256-GCM column encryption via Capsule Protect",
136
137
  soc_control: "CC6.7",
137
138
  })),
138
139
  ...(input.code?.secrets.filter(s => !s.is_env_interpolated) || []).map(s => ({
139
140
  severity: s.severity,
140
141
  category: "secrets",
141
142
  rule_id: `SECRET_${s.type.toUpperCase().replace(/-/g, "_")}`,
142
- title: `Hardcoded ${s.type} in ${s.file}:${s.line}`,
143
- fix_suggestion: `Move to .env and encrypt with @nodatachat/protect`,
143
+ title: `Hardcoded secret (${s.type}) in source code`,
144
+ fix_suggestion: "Move to .env and encrypt with @nodatachat/protect",
144
145
  soc_control: "CC6.1",
145
146
  })),
146
- ...(input.code?.routes.filter(r => !r.has_auth) || []).slice(0, 20).map(r => ({
147
+ ...(input.code?.routes.filter(r => !r.has_auth) || []).slice(0, 20).map(() => ({
147
148
  severity: "high",
148
149
  category: "auth",
149
150
  rule_id: "ROUTE_NO_AUTH",
150
- title: `Unprotected route: ${r.path}`,
151
- fix_suggestion: `Add authentication middleware (withAuth or API key check)`,
151
+ title: "API route missing authentication",
152
+ fix_suggestion: "Add authentication middleware (withAuth or API key check)",
152
153
  soc_control: "CC6.3",
153
154
  })),
154
155
  ],
@@ -159,6 +160,7 @@ function generateReports(input) {
159
160
  contains_credentials: false,
160
161
  contains_source_code: false,
161
162
  contains_file_contents: false,
163
+ contains_structural_metadata: false,
162
164
  customer_can_verify: true,
163
165
  },
164
166
  };
@@ -207,6 +209,36 @@ function generateReports(input) {
207
209
  },
208
210
  proof_hash: proofHash,
209
211
  metadata_preview: metadata,
212
+ // ── HOW TO FIX ──
213
+ // This section stays in the LOCAL report only. Never sent to NoData.
214
+ how_to_fix: {
215
+ summary: `This scan found ${plaintextPII} unencrypted PII fields, ${criticalSecrets} hardcoded secrets, and ${totalRoutes - protectedRoutes} unprotected routes.`,
216
+ steps: [
217
+ ...(plaintextPII > 0 ? [{
218
+ priority: 1,
219
+ action: "Encrypt PII fields",
220
+ detail: `${plaintextPII} fields contain personal data without encryption. Each field listed below needs AES-256-GCM column-level encryption.`,
221
+ fields: allPII.filter(f => !f.encrypted).map(f => `${f.table}.${f.column} (${f.pii_type})`),
222
+ impact: `+${Math.min(Math.round(plaintextPII * 1.5), 30)}% score improvement`,
223
+ }] : []),
224
+ ...(criticalSecrets > 0 ? [{
225
+ priority: 2,
226
+ action: "Remove hardcoded secrets",
227
+ detail: `${criticalSecrets} secrets are hardcoded in source files. Move them to .env and encrypt with: npx @nodatachat/protect init`,
228
+ fields: (input.code?.secrets.filter(s => !s.is_env_interpolated && s.severity === "critical") || []).map(s => `${s.file}:${s.line} (${s.type})`),
229
+ impact: `+${Math.min(criticalSecrets * 5, 25)}% score improvement`,
230
+ }] : []),
231
+ ...((totalRoutes - protectedRoutes > 0) ? [{
232
+ priority: 3,
233
+ action: "Add route authentication",
234
+ detail: `${totalRoutes - protectedRoutes} API routes have no auth middleware. Add withAuth, API key validation, or rate limiting.`,
235
+ fields: (input.code?.routes.filter(r => !r.has_auth) || []).map(r => r.path),
236
+ impact: `+${Math.min(Math.round((totalRoutes - protectedRoutes) * 0.5), 15)}% score improvement`,
237
+ }] : []),
238
+ ],
239
+ workflow: "Give this report to your development team or security engineer. They can review each finding and implement the fix suggestions. After fixing, re-run Guard to verify your score improved.",
240
+ re_scan: `npx @nodatachat/guard@latest --license-key YOUR_KEY`,
241
+ },
210
242
  };
211
243
  return { full, metadata };
212
244
  }
package/dist/types.d.ts CHANGED
@@ -88,6 +88,18 @@ export interface FullReport {
88
88
  };
89
89
  proof_hash: string;
90
90
  metadata_preview: MetadataReport;
91
+ how_to_fix: {
92
+ summary: string;
93
+ steps: Array<{
94
+ priority: number;
95
+ action: string;
96
+ detail: string;
97
+ fields: string[];
98
+ impact: string;
99
+ }>;
100
+ workflow: string;
101
+ re_scan: string;
102
+ };
91
103
  }
92
104
  export interface MetadataReport {
93
105
  version: "1.0";
@@ -116,11 +128,9 @@ export interface MetadataReport {
116
128
  secrets_found: number;
117
129
  secrets_critical: number;
118
130
  fields: Array<{
119
- table: string;
120
- column: string;
131
+ field_hash: string;
121
132
  pii_type: string;
122
133
  encrypted: boolean;
123
- pattern: string | null;
124
134
  }>;
125
135
  } | null;
126
136
  db_summary: {
@@ -157,6 +167,7 @@ export interface MetadataReport {
157
167
  contains_credentials: false;
158
168
  contains_source_code: false;
159
169
  contains_file_contents: false;
170
+ contains_structural_metadata: false;
160
171
  customer_can_verify: true;
161
172
  };
162
173
  }
@@ -0,0 +1,6 @@
1
+ /** Encrypt plaintext string → base64 blob + hex IV + hex salt */
2
+ export declare function encryptForVault(pin: string, plaintext: string): {
3
+ ciphertext: string;
4
+ iv: string;
5
+ salt: string;
6
+ };
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ // ══════════════════════════════════════════════════════════════
3
+ // vault-crypto.ts — Node.js AES-256-GCM encryption for Guard CLI
4
+ //
5
+ // Mirrors the browser vault-crypto.ts exactly:
6
+ // PIN → PBKDF2 (100k iterations, SHA-256) → AES-256-GCM key
7
+ //
8
+ // The encrypted blob produced here can be decrypted by the
9
+ // browser VaultReportViewer using the same PIN.
10
+ // ══════════════════════════════════════════════════════════════
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.encryptForVault = encryptForVault;
13
+ const crypto_1 = require("crypto");
14
+ const ITERATIONS = 100_000;
15
+ const KEY_LENGTH = 32; // 256 bits
16
+ const SALT_LENGTH = 16; // bytes
17
+ const IV_LENGTH = 12; // bytes (standard for AES-GCM)
18
+ /** Encrypt plaintext string → base64 blob + hex IV + hex salt */
19
+ function encryptForVault(pin, plaintext) {
20
+ const salt = (0, crypto_1.randomBytes)(SALT_LENGTH);
21
+ const iv = (0, crypto_1.randomBytes)(IV_LENGTH);
22
+ const key = (0, crypto_1.pbkdf2Sync)(pin, salt, ITERATIONS, KEY_LENGTH, "sha256");
23
+ const cipher = (0, crypto_1.createCipheriv)("aes-256-gcm", key, iv);
24
+ const encrypted = Buffer.concat([
25
+ cipher.update(plaintext, "utf8"),
26
+ cipher.final(),
27
+ ]);
28
+ const authTag = cipher.getAuthTag();
29
+ // Combine ciphertext + authTag (browser Web Crypto expects them concatenated)
30
+ const combined = Buffer.concat([encrypted, authTag]);
31
+ return {
32
+ ciphertext: combined.toString("base64"),
33
+ iv: iv.toString("hex"),
34
+ salt: salt.toString("hex"),
35
+ };
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodatachat/guard",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",