@nodatachat/guard 2.1.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,13 +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
- const registry_1 = require("./fixers/registry");
26
26
  const scheduler_1 = require("./fixers/scheduler");
27
- 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";
28
30
  async function main() {
29
31
  const args = process.argv.slice(2);
30
32
  // Parse args
@@ -35,10 +37,10 @@ async function main() {
35
37
  let failOn = null;
36
38
  let outputDir = process.cwd();
37
39
  let skipSend = false;
38
- let fixMode = "none";
40
+ let vaultPin;
41
+ let vaultDevice;
39
42
  let schedulePreset;
40
43
  let ciProvider;
41
- let onlyFixers;
42
44
  for (let i = 0; i < args.length; i++) {
43
45
  switch (args[i]) {
44
46
  case "--license-key":
@@ -62,14 +64,21 @@ async function main() {
62
64
  case "--skip-send":
63
65
  skipSend = true;
64
66
  break;
67
+ case "--vault-pin":
68
+ vaultPin = args[++i];
69
+ break;
70
+ case "--vault-device":
71
+ vaultDevice = args[++i];
72
+ break;
65
73
  case "--fix-plan":
66
- fixMode = "plan";
74
+ console.log("\n ⚠ --fix-plan is deprecated. Capsule provides recommendations only.\n Run without this flag to see recommendations.\n");
67
75
  break;
68
76
  case "--fix":
69
- fixMode = "apply";
77
+ console.log("\n ⚠ --fix is deprecated. Capsule does not modify your code.\n Recommendations are provided in the scan output.\n");
70
78
  break;
71
79
  case "--fix-only":
72
- onlyFixers = args[++i]?.split(",");
80
+ i++;
81
+ console.log("\n ⚠ --fix-only is deprecated. Capsule provides recommendations only.\n");
73
82
  break;
74
83
  case "--schedule":
75
84
  schedulePreset = args[++i] || "weekly";
@@ -168,6 +177,9 @@ async function main() {
168
177
  process.exit(1);
169
178
  }
170
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}`);
171
183
  // ── Step 2: Code scan ──
172
184
  log(ciMode, "Scanning code...");
173
185
  const files = (0, code_scanner_1.readProjectFiles)(projectDir, (count) => {
@@ -231,6 +243,59 @@ async function main() {
231
243
  (0, fs_1.writeFileSync)(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
232
244
  log(ciMode, `Full report: ${fullPath}`);
233
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
+ }
234
299
  // ── Step 6: Send metadata to NoData ──
235
300
  let serverResponse = {};
236
301
  if (!skipSend) {
@@ -259,6 +324,43 @@ async function main() {
259
324
  log(ciMode, "Could not reach NoData API — report saved locally");
260
325
  }
261
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
+ }
262
364
  // ── Step 7: Print summary ──
263
365
  if (!ciMode) {
264
366
  console.log("");
@@ -280,107 +382,133 @@ async function main() {
280
382
  console.log(` Sent to NoData: ${metaPath}`);
281
383
  console.log(` Proof hash: ${full.proof_hash.slice(0, 16)}...`);
282
384
  console.log(" ══════════════════════════════════════");
385
+ if (vaultUploaded) {
386
+ console.log(" Vault: Encrypted & saved to dashboard");
387
+ }
283
388
  console.log(" Your data never left your machine.");
284
389
  console.log(" Diff the two files to verify.\n");
285
390
  }
286
- // ── Step 8: Capsule — Fix Plan / Apply ──
287
- if (fixMode !== "none") {
288
- const config = loadOrCreateConfig(projectDir, { dbUrl, failOn });
289
- const fixerContext = {
290
- projectDir,
291
- dbUrl,
292
- config,
293
- scanResults: {
294
- piiFields: piiFields.map(f => ({
295
- table: f.table, column: f.column, pii_type: f.pii_type,
296
- encrypted: f.encrypted, encryption_pattern: f.encryption_pattern,
297
- })),
298
- routes: routes.map(r => ({ path: r.path, has_auth: r.has_auth, auth_type: r.auth_type })),
299
- secrets: secrets.map(s => ({
300
- file: s.file, line: s.line, type: s.type,
301
- severity: s.severity, is_env_interpolated: s.is_env_interpolated,
302
- })),
303
- rls: dbResult?.rls || [],
304
- framework: stack.framework,
305
- database: stack.database,
306
- },
307
- stack: {
308
- framework: stack.framework,
309
- database: stack.database,
310
- language: "typescript",
311
- hosting: "vercel",
312
- },
313
- };
314
- const capsuleResult = await (0, registry_1.runCapsule)(fixerContext, {
315
- mode: fixMode === "plan" ? "plan" : "apply",
316
- fixers: onlyFixers,
317
- dryRun: fixMode === "plan",
318
- }, (msg) => log(ciMode, msg));
319
- if (!ciMode) {
391
+ // ── Step 8: Recommendations ──
392
+ // NOTE: Capsule does NOT auto-fix code. We provide recommendations only.
393
+ // Applying fixes requires understanding of the target system — tables,
394
+ // relationships, business logic — and must be done by the customer's team.
395
+ if (!ciMode) {
396
+ const topFindings = metadata.findings || [];
397
+ if (topFindings.length > 0) {
320
398
  console.log("");
321
399
  console.log(" ══════════════════════════════════════");
322
- console.log(` CAPSULE ${fixMode === "plan" ? "PLAN" : "RESULTS"}`);
400
+ console.log(" RECOMMENDATIONS");
323
401
  console.log(" ══════════════════════════════════════");
324
- console.log(` Total actions: ${capsuleResult.totalActions}`);
325
- if (fixMode === "apply") {
326
- console.log(` Applied: ${capsuleResult.applied}`);
327
- console.log(` Failed: ${capsuleResult.failed}`);
328
- console.log(` Skipped: ${capsuleResult.skipped}`);
402
+ console.log(` ${topFindings.length} issues found. Top priorities:\n`);
403
+ const shown = topFindings.slice(0, 10);
404
+ for (const f of shown) {
405
+ const sev = f.severity === "critical" ? "\x1b[31mCRITICAL\x1b[0m"
406
+ : f.severity === "high" ? "\x1b[33mHIGH\x1b[0m"
407
+ : "\x1b[36mMEDIUM\x1b[0m";
408
+ console.log(` ${sev} ${f.title}`);
409
+ console.log(` \x1b[32m→ ${f.fix_suggestion}\x1b[0m`);
410
+ if (f.soc_control)
411
+ console.log(` SOC: ${f.soc_control}`);
412
+ console.log("");
329
413
  }
330
- console.log(` Manual pending: ${capsuleResult.manualPending}`);
331
- console.log(` Est. impact: +${capsuleResult.estimatedScoreImpact}% score`);
332
- console.log(` Proof hash: ${capsuleResult.proofHash.slice(0, 16)}...`);
333
- console.log(" ──────────────────────────────────────");
334
- // Print per-fixer summary
335
- for (const plan of capsuleResult.plans) {
336
- const icon = plan.actions.every(a => a.status === "applied") ? "✅"
337
- : plan.actions.some(a => a.status === "failed") ? "❌" : "📋";
338
- console.log(` ${icon} ${plan.nameHe}: ${plan.totalActions} actions (${plan.autoFixable} auto, ${plan.manualRequired} manual)`);
414
+ if (topFindings.length > 10) {
415
+ console.log(` ... and ${topFindings.length - 10} more. See full report.\n`);
339
416
  }
417
+ console.log(" ──────────────────────────────────────");
418
+ console.log(" ⚠ Capsule provides recommendations only.");
419
+ console.log(" Fixes require understanding of your system —");
420
+ console.log(" tables, relationships, and business logic.");
421
+ console.log(" Work with your team to apply changes safely.");
340
422
  console.log(" ══════════════════════════════════════\n");
341
- // Write fix plans to output dir
342
- if (capsuleResult.plans.length > 0) {
343
- const plansPath = (0, path_1.resolve)(outputDir, "nodata-fix-plan.json");
344
- (0, fs_1.writeFileSync)(plansPath, JSON.stringify(capsuleResult, null, 2), "utf-8");
345
- log(ciMode, `Fix plan saved: ${plansPath}`);
346
- // Write SQL migrations to files
347
- for (const plan of capsuleResult.plans) {
348
- for (const action of plan.actions) {
349
- if (action.type === "file-create" && action.target.includes("migrations/")) {
350
- const migPath = (0, path_1.resolve)(outputDir, action.target);
351
- const migDir = (0, path_1.resolve)(outputDir, "migrations");
352
- if (!(0, fs_1.existsSync)(migDir)) {
353
- const { mkdirSync } = require("fs");
354
- mkdirSync(migDir, { recursive: true });
355
- }
356
- (0, fs_1.writeFileSync)(migPath, action.content, "utf-8");
357
- log(ciMode, `Migration: ${migPath}`);
358
- }
359
- }
360
- }
423
+ // Save recommendations to file
424
+ const recsPath = (0, path_1.resolve)(outputDir, "nodata-recommendations.json");
425
+ (0, fs_1.writeFileSync)(recsPath, JSON.stringify({
426
+ generated_at: new Date().toISOString(),
427
+ score: full.overall_score,
428
+ total_findings: topFindings.length,
429
+ disclaimer: "These are recommendations only. Capsule does not modify your code. Fixes require understanding of your system architecture, database schema, and business logic. Work with your development team to implement changes safely.",
430
+ recommendations: topFindings,
431
+ }, null, 2), "utf-8");
432
+ log(ciMode, `Recommendations: ${recsPath}`);
433
+ }
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");
361
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");
362
486
  }
363
- // Send notifications if configured
364
- if (config.notify && fixMode === "apply") {
365
- const notifyPayload = {
366
- event: capsuleResult.applied > 0 ? "fix-applied" : "scan-complete",
367
- timestamp: new Date().toISOString(),
368
- projectName: full.project_name || "unknown",
369
- projectHash: full.proof_hash?.slice(0, 16) || "",
370
- score: full.overall_score,
371
- previousScore: serverResponse.previous_score ?? null,
372
- delta: serverResponse.previous_score != null ? full.overall_score - serverResponse.previous_score : 0,
373
- critical: full.summary.critical_issues,
374
- high: full.summary.high_issues,
375
- medium: full.summary.medium_issues,
376
- fixesApplied: capsuleResult.applied,
377
- fixesFailed: capsuleResult.failed,
378
- scanId: activation.scan_id,
379
- proofHash: capsuleResult.proofHash,
380
- };
381
- await (0, registry_1.sendNotifications)(config, notifyPayload.event, notifyPayload, (msg) => log(ciMode, msg));
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");
382
508
  }
383
509
  }
510
+ // ── Step 10: Print .capsule/ status ──
511
+ (0, capsule_dir_1.printCapsuleStatus)(projectDir, ciMode);
384
512
  // ── CI mode: exit code ──
385
513
  if (ciMode && failOn) {
386
514
  const { critical_issues, high_issues, medium_issues } = full.summary;
@@ -425,14 +553,16 @@ function loadOrCreateConfig(projectDir, overrides) {
425
553
  }
426
554
  function printHelp() {
427
555
  console.log(`
428
- NoData Guard v${VERSION} — Security Scanner + Auto-Remediation Capsule
556
+ NoData Guard v${VERSION} — Security Scanner + Recommendations
557
+
558
+ Scans your project locally for security issues and provides
559
+ actionable recommendations. Your code never leaves your machine.
560
+ Capsule does NOT modify your code — only you and your team can.
429
561
 
430
562
  Usage:
431
- npx nodata-guard --license-key NDC-XXXX # Scan only
432
- npx nodata-guard --license-key NDC-XXXX --fix-plan # Scan + show fix plan
433
- npx nodata-guard --license-key NDC-XXXX --fix # Scan + apply fixes
434
- npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
435
- npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL --fix # Full scan + fix
563
+ npx nodata-guard --license-key NDC-XXXX # Scan + recommend
564
+ npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
565
+ npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
436
566
 
437
567
  Scan Options:
438
568
  --license-key <key> NoData license key (or set NDC_LICENSE env var)
@@ -442,13 +572,8 @@ function printHelp() {
442
572
  --ci CI mode — minimal output, exit codes
443
573
  --fail-on <level> Exit 1 if issues at: critical | high | medium
444
574
  --skip-send Don't send metadata to NoData
445
-
446
- Capsule Options:
447
- --fix-plan Generate fix plan (dry-run, no changes)
448
- --fix Apply all auto-fixable remediation
449
- --fix-only <fixers> Only run specific fixers (comma-separated)
450
- Fixers: pii-encrypt, rls, secrets, routes-auth,
451
- headers, csrf, rate-limit, gitignore
575
+ --vault-pin <pin> Encrypt full report & save to vault (AES-256-GCM)
576
+ --vault-device <id> Link vault report to your dashboard device
452
577
 
453
578
  Schedule Options:
454
579
  --schedule <preset> Install CI workflow: daily | weekly | monthly
@@ -458,28 +583,39 @@ function printHelp() {
458
583
  Configure in .nodata-guard.json → notify: { email, webhook, slack, telegram }
459
584
 
460
585
  Output files:
461
- nodata-full-report.json Full report — STAYS LOCAL
462
- nodata-metadata-only.json Metadata only — sent to NoData
463
- nodata-fix-plan.json Fix plan with actions
464
- migrations/*.sql Generated SQL migrations
586
+ nodata-full-report.json Full report — STAYS LOCAL
587
+ nodata-metadata-only.json Metadata only — sent to dashboard
588
+ nodata-recommendations.json Prioritized recommendations
589
+
590
+ What we provide:
591
+ ✓ Scan — find weak points (PII, routes, secrets, encryption)
592
+ ✓ Recommend — prioritized actions with SOC control mapping
593
+ ✓ Prove — cryptographic proof chain of scan results
594
+ ✓ Monitor — track score over time via dashboard
595
+
596
+ What we NEVER do:
597
+ ✗ Modify your code, database, or configuration
598
+ ✗ Receive data values, source code, or credentials
465
599
 
466
- What we NEVER receive:
467
- Data values, emails, phones, passwords, source code, connection strings.
600
+ Important:
601
+ Recommendations require understanding of YOUR system
602
+ tables, relationships, business logic. Work with your team
603
+ to implement changes safely.
468
604
 
469
605
  Examples:
470
- # Quick scan
606
+ # Scan and get recommendations
471
607
  npx nodata-guard --license-key NDC-XXXX
472
608
 
473
- # Full scan + DB probe + fix
474
- npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL --fix
609
+ # Scan + auto-save encrypted report to vault
610
+ npx nodata-guard --license-key NDC-XXXX --vault-pin 1234
475
611
 
476
- # Only fix security headers and CSRF
477
- npx nodata-guard --license-key NDC-XXXX --fix --fix-only headers,csrf
612
+ # Full scan with DB probe
613
+ npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL
478
614
 
479
615
  # Setup weekly CI scan with GitHub Actions
480
616
  npx nodata-guard --license-key NDC-XXXX --schedule weekly
481
617
 
482
- # CI pipeline
618
+ # CI pipeline — fail on critical issues
483
619
  npx nodata-guard --ci --fail-on critical
484
620
 
485
621
  Documentation: https://nodatacapsule.com/guard
@@ -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
- fixes_available: plaintextPII, // each plaintext field has a migration fix
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
  };
@@ -203,10 +205,40 @@ function generateReports(input) {
203
205
  critical_issues: criticalSecrets,
204
206
  high_issues: highSecrets + plaintextPII,
205
207
  medium_issues: medSecrets,
206
- fixes_available: plaintextPII,
208
+ recommendations_available: plaintextPII,
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
@@ -84,10 +84,22 @@ export interface FullReport {
84
84
  critical_issues: number;
85
85
  high_issues: number;
86
86
  medium_issues: number;
87
- fixes_available: number;
87
+ recommendations_available: number;
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: {
@@ -140,7 +150,7 @@ export interface MetadataReport {
140
150
  critical: number;
141
151
  high: number;
142
152
  medium: number;
143
- fixes_available: number;
153
+ recommendations_available: number;
144
154
  };
145
155
  findings: Array<{
146
156
  severity: "critical" | "high" | "medium" | "low";
@@ -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.1.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",