@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.
@@ -0,0 +1,104 @@
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;
94
+ export declare function attestFinding(projectDir: string, licenseKey: string, findingId: string, status: Override["status"], note: string): void;
95
+ /**
96
+ * Subcommand: guard status
97
+ * Print full .capsule/ status without running a scan.
98
+ */
99
+ export declare function printFullStatus(projectDir: string): void;
100
+ /**
101
+ * Subcommand: guard diff
102
+ * Show what changed between the two most recent scans.
103
+ */
104
+ export declare function printDiff(projectDir: string): void;
@@ -0,0 +1,465 @@
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
+ exports.attestFinding = attestFinding;
34
+ exports.printFullStatus = printFullStatus;
35
+ exports.printDiff = printDiff;
36
+ const path_1 = require("path");
37
+ const fs_1 = require("fs");
38
+ const crypto_1 = require("crypto");
39
+ const DIR_NAME = ".capsule";
40
+ const HMAC_KEY_DOMAIN = "capsule:evidence:integrity";
41
+ // ── Helpers ──
42
+ function hashName(name) {
43
+ return (0, crypto_1.createHash)("sha256").update(`capsule:name:${name}`).digest("hex").slice(0, 16);
44
+ }
45
+ function signEvidence(data, licenseKey) {
46
+ return (0, crypto_1.createHmac)("sha256", `${HMAC_KEY_DOMAIN}:${licenseKey}`)
47
+ .update(data)
48
+ .digest("hex")
49
+ .slice(0, 32);
50
+ }
51
+ function ensureDir(dir) {
52
+ if (!(0, fs_1.existsSync)(dir))
53
+ (0, fs_1.mkdirSync)(dir, { recursive: true });
54
+ }
55
+ // ── Public API ──
56
+ /**
57
+ * Initialize .capsule/ directory. Called on every scan.
58
+ * Creates missing dirs/files, never overwrites existing data.
59
+ */
60
+ function initCapsuleDir(projectDir, licenseKey) {
61
+ const capsuleDir = (0, path_1.resolve)(projectDir, DIR_NAME);
62
+ ensureDir(capsuleDir);
63
+ ensureDir((0, path_1.join)(capsuleDir, "scores"));
64
+ ensureDir((0, path_1.join)(capsuleDir, "evidence"));
65
+ // Config — create only if missing
66
+ const configPath = (0, path_1.join)(capsuleDir, "config.json");
67
+ if (!(0, fs_1.existsSync)(configPath)) {
68
+ (0, fs_1.writeFileSync)(configPath, JSON.stringify({
69
+ version: "1.0",
70
+ created_at: new Date().toISOString(),
71
+ license_prefix: licenseKey.slice(0, 12) + "...",
72
+ thresholds: {
73
+ fail_on: "critical",
74
+ target_score: 85,
75
+ encryption_target: 90,
76
+ },
77
+ scan_schedule: "manual",
78
+ notes: "This directory contains local evidence for SOC compliance. Commit to git for audit history. NoData never receives this data.",
79
+ }, null, 2), "utf-8");
80
+ }
81
+ // Proof — create only if missing
82
+ const proofPath = (0, path_1.join)(capsuleDir, "proof.json");
83
+ if (!(0, fs_1.existsSync)(proofPath)) {
84
+ const proof = { version: "1.0", updated_at: new Date().toISOString(), fields: [] };
85
+ (0, fs_1.writeFileSync)(proofPath, JSON.stringify(proof, null, 2), "utf-8");
86
+ }
87
+ // Overrides — create only if missing
88
+ const overridesPath = (0, path_1.join)(capsuleDir, "overrides.json");
89
+ if (!(0, fs_1.existsSync)(overridesPath)) {
90
+ const overrides = { version: "1.0", overrides: [] };
91
+ (0, fs_1.writeFileSync)(overridesPath, JSON.stringify(overrides, null, 2), "utf-8");
92
+ }
93
+ // Ensure .gitignore does NOT exclude .capsule/
94
+ // (It should be committed for audit trail)
95
+ const gitignorePath = (0, path_1.resolve)(projectDir, ".gitignore");
96
+ if ((0, fs_1.existsSync)(gitignorePath)) {
97
+ const content = (0, fs_1.readFileSync)(gitignorePath, "utf-8");
98
+ if (content.includes(".capsule")) {
99
+ // Remove .capsule from gitignore if present — it should be tracked
100
+ const lines = content.split("\n").filter(l => !l.trim().startsWith(".capsule"));
101
+ (0, fs_1.writeFileSync)(gitignorePath, lines.join("\n"), "utf-8");
102
+ }
103
+ }
104
+ return capsuleDir;
105
+ }
106
+ /**
107
+ * Save a score snapshot. Called after every scan.
108
+ */
109
+ function saveScore(capsuleDir, score) {
110
+ const date = new Date().toISOString().slice(0, 10);
111
+ const scoresDir = (0, path_1.join)(capsuleDir, "scores");
112
+ ensureDir(scoresDir);
113
+ (0, fs_1.writeFileSync)((0, path_1.join)(scoresDir, `${date}.json`), JSON.stringify(score, null, 2), "utf-8");
114
+ }
115
+ /**
116
+ * Update proof.json with DB-verified encryption data.
117
+ * Called after DB scan detects encrypted fields.
118
+ */
119
+ function updateProof(capsuleDir, fields) {
120
+ const proofPath = (0, path_1.join)(capsuleDir, "proof.json");
121
+ const proof = (0, fs_1.existsSync)(proofPath)
122
+ ? JSON.parse((0, fs_1.readFileSync)(proofPath, "utf-8"))
123
+ : { version: "1.0", updated_at: "", fields: [] };
124
+ for (const f of fields) {
125
+ const tableHash = hashName(f.table);
126
+ const columnHash = hashName(f.column);
127
+ const coverage = f.row_count > 0 ? Math.round((f.encrypted_count / f.row_count) * 100) : 0;
128
+ // Update existing or add new
129
+ const existing = proof.fields.find(p => p.table_hash === tableHash && p.column_hash === columnHash);
130
+ if (existing) {
131
+ existing.pattern = f.pattern;
132
+ existing.sentinel_prefix = f.sentinel_prefix.slice(0, 12);
133
+ existing.row_count = f.row_count;
134
+ existing.encrypted_count = f.encrypted_count;
135
+ existing.coverage_percent = coverage;
136
+ existing.verified_at = new Date().toISOString();
137
+ }
138
+ else {
139
+ proof.fields.push({
140
+ table_hash: tableHash,
141
+ column_hash: columnHash,
142
+ pattern: f.pattern,
143
+ sentinel_prefix: f.sentinel_prefix.slice(0, 12),
144
+ row_count: f.row_count,
145
+ encrypted_count: f.encrypted_count,
146
+ coverage_percent: coverage,
147
+ verified_at: new Date().toISOString(),
148
+ });
149
+ }
150
+ }
151
+ proof.updated_at = new Date().toISOString();
152
+ (0, fs_1.writeFileSync)(proofPath, JSON.stringify(proof, null, 2), "utf-8");
153
+ }
154
+ /**
155
+ * Add an evidence entry. Called after scan or manual attest.
156
+ */
157
+ function addEvidence(capsuleDir, licenseKey, entry) {
158
+ const date = new Date().toISOString().slice(0, 10);
159
+ const evidenceDir = (0, path_1.join)(capsuleDir, "evidence");
160
+ ensureDir(evidenceDir);
161
+ const filePath = (0, path_1.join)(evidenceDir, `${date}.json`);
162
+ const entries = (0, fs_1.existsSync)(filePath)
163
+ ? JSON.parse((0, fs_1.readFileSync)(filePath, "utf-8"))
164
+ : [];
165
+ // Sign the entry — proves it wasn't tampered with
166
+ const payload = JSON.stringify({ ...entry });
167
+ const hmac = signEvidence(payload, licenseKey);
168
+ entries.push({ ...entry, hmac });
169
+ (0, fs_1.writeFileSync)(filePath, JSON.stringify(entries, null, 2), "utf-8");
170
+ }
171
+ /**
172
+ * Read proof.json — used by code-scanner to check for known encryptions.
173
+ */
174
+ function readProof(projectDir) {
175
+ const proofPath = (0, path_1.join)(projectDir, DIR_NAME, "proof.json");
176
+ if (!(0, fs_1.existsSync)(proofPath))
177
+ return null;
178
+ try {
179
+ return JSON.parse((0, fs_1.readFileSync)(proofPath, "utf-8"));
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Read overrides.json — used by reporter to adjust scores.
187
+ */
188
+ function readOverrides(projectDir) {
189
+ const overridesPath = (0, path_1.join)(projectDir, DIR_NAME, "overrides.json");
190
+ if (!(0, fs_1.existsSync)(overridesPath))
191
+ return null;
192
+ try {
193
+ return JSON.parse((0, fs_1.readFileSync)(overridesPath, "utf-8"));
194
+ }
195
+ catch {
196
+ return null;
197
+ }
198
+ }
199
+ /**
200
+ * Get score history — used for trend display.
201
+ */
202
+ function getScoreHistory(projectDir) {
203
+ const scoresDir = (0, path_1.join)(projectDir, DIR_NAME, "scores");
204
+ if (!(0, fs_1.existsSync)(scoresDir))
205
+ return [];
206
+ const files = (0, fs_1.readdirSync)(scoresDir)
207
+ .filter((f) => f.endsWith(".json"))
208
+ .sort();
209
+ return files.map((f) => {
210
+ try {
211
+ return JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(scoresDir, f), "utf-8"));
212
+ }
213
+ catch {
214
+ return null;
215
+ }
216
+ }).filter(Boolean);
217
+ }
218
+ /**
219
+ * Print .capsule/ status to terminal.
220
+ */
221
+ function printCapsuleStatus(projectDir, ciMode) {
222
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
223
+ if (!(0, fs_1.existsSync)(capsuleDir))
224
+ return;
225
+ if (ciMode)
226
+ return;
227
+ const history = getScoreHistory(projectDir);
228
+ const proof = readProof(projectDir);
229
+ const overrides = readOverrides(projectDir);
230
+ console.log("");
231
+ console.log(" ══════════════════════════════════════");
232
+ console.log(" .capsule/ LOCAL EVIDENCE");
233
+ console.log(" ══════════════════════════════════════");
234
+ console.log(` Score history: ${history.length} scan(s)`);
235
+ if (history.length >= 2) {
236
+ const first = history[0];
237
+ const last = history[history.length - 1];
238
+ const delta = last.score - first.score;
239
+ const arrow = delta > 0 ? `\x1b[32m↑${delta}%\x1b[0m` : delta < 0 ? `\x1b[31m↓${Math.abs(delta)}%\x1b[0m` : "→ no change";
240
+ console.log(` Trend: ${first.score}% → ${last.score}% ${arrow}`);
241
+ }
242
+ if (proof) {
243
+ console.log(` Proven fields: ${proof.fields.length} (DB-verified encryption)`);
244
+ }
245
+ if (overrides && overrides.overrides.length > 0) {
246
+ console.log(` Overrides: ${overrides.overrides.length} manual attestation(s)`);
247
+ }
248
+ console.log(" ──────────────────────────────────────");
249
+ console.log(" \x1b[2mAll evidence stays local. Commit to git for audit trail.\x1b[0m");
250
+ console.log(" ══════════════════════════════════════\n");
251
+ }
252
+ // ═══════════════════════════════════════════════════════════
253
+ // Subcommand: guard attest
254
+ //
255
+ // Allows the user to manually attest that a finding has been
256
+ // addressed. Creates a signed entry in overrides.json.
257
+ // ═══════════════════════════════════════════════════════════
258
+ function attestFinding(projectDir, licenseKey, findingId, status, note) {
259
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
260
+ if (!(0, fs_1.existsSync)(capsuleDir)) {
261
+ initCapsuleDir(projectDir, licenseKey);
262
+ }
263
+ const overridesPath = (0, path_1.join)(capsuleDir, "overrides.json");
264
+ const overrides = (0, fs_1.existsSync)(overridesPath)
265
+ ? JSON.parse((0, fs_1.readFileSync)(overridesPath, "utf-8"))
266
+ : { version: "1.0", overrides: [] };
267
+ const deviceHash = (0, crypto_1.createHash)("sha256")
268
+ .update(`capsule:device:${require("os").hostname()}`)
269
+ .digest("hex").slice(0, 12);
270
+ const now = new Date().toISOString();
271
+ const payload = JSON.stringify({ finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now });
272
+ const hmac = signEvidence(payload, licenseKey);
273
+ // Update existing or add new
274
+ const existing = overrides.overrides.findIndex(o => o.finding_id === findingId);
275
+ const entry = { finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now, hmac };
276
+ if (existing >= 0) {
277
+ overrides.overrides[existing] = entry;
278
+ }
279
+ else {
280
+ overrides.overrides.push(entry);
281
+ }
282
+ (0, fs_1.writeFileSync)(overridesPath, JSON.stringify(overrides, null, 2), "utf-8");
283
+ // Also log as evidence
284
+ addEvidence(capsuleDir, licenseKey, {
285
+ date: now,
286
+ action: "override_added",
287
+ category: status === "fixed" ? "remediation" : "risk_management",
288
+ detail: `Attested "${findingId}" as ${status}: ${note}`,
289
+ impact: `Override: ${status}`,
290
+ attested_by: deviceHash,
291
+ });
292
+ }
293
+ /**
294
+ * Subcommand: guard status
295
+ * Print full .capsule/ status without running a scan.
296
+ */
297
+ function printFullStatus(projectDir) {
298
+ const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
299
+ if (!(0, fs_1.existsSync)(capsuleDir)) {
300
+ console.log("\n No .capsule/ directory found. Run a scan first.\n");
301
+ return;
302
+ }
303
+ const history = getScoreHistory(projectDir);
304
+ const proof = readProof(projectDir);
305
+ const overrides = readOverrides(projectDir);
306
+ console.log("");
307
+ console.log(" ╔══════════════════════════════════════╗");
308
+ console.log(" ║ NoData Guard — Status ║");
309
+ console.log(" ╚══════════════════════════════════════╝");
310
+ console.log("");
311
+ // ── Score history ──
312
+ console.log(" \x1b[33m── SCORE HISTORY ──\x1b[0m");
313
+ if (history.length === 0) {
314
+ console.log(" No scans recorded yet.\n");
315
+ }
316
+ else {
317
+ const last = history[history.length - 1];
318
+ console.log(` Latest score: \x1b[1m${last.score}%\x1b[0m (${last.scan_type} scan, Guard v${last.guard_version})`);
319
+ console.log(` Last scan date: ${last.date}`);
320
+ console.log(` Total scans: ${history.length}`);
321
+ if (last.code_score != null)
322
+ console.log(` Code score: ${last.code_score}%`);
323
+ if (last.db_score != null)
324
+ console.log(` DB score: ${last.db_score}%`);
325
+ console.log(` PII coverage: ${last.pii_encrypted}/${last.pii_total} encrypted (${last.coverage_percent}%)`);
326
+ console.log(` Issues: ${last.critical} critical, ${last.high} high, ${last.medium} medium, ${last.low} low`);
327
+ console.log(` Findings: ${last.findings_count} total`);
328
+ if (history.length >= 2) {
329
+ console.log("");
330
+ console.log(" \x1b[33m── TREND ──\x1b[0m");
331
+ const maxShow = Math.min(history.length, 10);
332
+ for (let i = history.length - maxShow; i < history.length; i++) {
333
+ const h = history[i];
334
+ const prev = i > 0 ? history[i - 1] : null;
335
+ const delta = prev ? h.score - prev.score : 0;
336
+ const arrow = delta > 0 ? `\x1b[32m+${delta}\x1b[0m` : delta < 0 ? `\x1b[31m${delta}\x1b[0m` : " 0";
337
+ const bar = "\x1b[32m" + "█".repeat(Math.round(h.score / 5)) + "\x1b[0m" + "░".repeat(20 - Math.round(h.score / 5));
338
+ console.log(` ${h.date} ${bar} ${h.score}% (${arrow})`);
339
+ }
340
+ }
341
+ console.log("");
342
+ }
343
+ // ── Proof ──
344
+ console.log(" \x1b[33m── DB-VERIFIED ENCRYPTION ──\x1b[0m");
345
+ if (!proof || proof.fields.length === 0) {
346
+ console.log(" No DB-verified encryption yet. Run with --db to verify.\n");
347
+ }
348
+ else {
349
+ console.log(` ${proof.fields.length} field(s) verified in database:\n`);
350
+ for (const f of proof.fields) {
351
+ const cov = f.coverage_percent;
352
+ const covColor = cov >= 90 ? "\x1b[32m" : cov >= 50 ? "\x1b[33m" : "\x1b[31m";
353
+ console.log(` ${f.table_hash}:${f.column_hash} ${f.pattern} ${covColor}${cov}%\x1b[0m (${f.encrypted_count}/${f.row_count} rows)`);
354
+ }
355
+ console.log(`\n Last verified: ${proof.updated_at}\n`);
356
+ }
357
+ // ── Overrides ──
358
+ console.log(" \x1b[33m── MANUAL ATTESTATIONS ──\x1b[0m");
359
+ if (!overrides || overrides.overrides.length === 0) {
360
+ console.log(" No overrides. Use \x1b[36mguard attest\x1b[0m to declare fixes.\n");
361
+ }
362
+ else {
363
+ for (const o of overrides.overrides) {
364
+ const statusColor = o.status === "fixed" ? "\x1b[32m" : o.status === "accepted_risk" ? "\x1b[33m" : "\x1b[36m";
365
+ console.log(` ${o.finding_id}`);
366
+ console.log(` Status: ${statusColor}${o.status}\x1b[0m`);
367
+ console.log(` Note: ${o.note}`);
368
+ console.log(` Date: ${o.attested_at}`);
369
+ console.log(` Device: ${o.attested_by}`);
370
+ console.log("");
371
+ }
372
+ }
373
+ // ── Evidence summary ──
374
+ const evidenceDir = (0, path_1.join)(capsuleDir, "evidence");
375
+ if ((0, fs_1.existsSync)(evidenceDir)) {
376
+ const evidenceFiles = (0, fs_1.readdirSync)(evidenceDir).filter(f => f.endsWith(".json"));
377
+ let totalEntries = 0;
378
+ for (const ef of evidenceFiles) {
379
+ try {
380
+ const entries = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(evidenceDir, ef), "utf-8"));
381
+ totalEntries += entries.length;
382
+ }
383
+ catch { /* skip */ }
384
+ }
385
+ console.log(" \x1b[33m── EVIDENCE LOG ──\x1b[0m");
386
+ console.log(` ${totalEntries} entries across ${evidenceFiles.length} day(s)`);
387
+ console.log(" \x1b[2mAll evidence stays local. Commit .capsule/ to git for audit trail.\x1b[0m\n");
388
+ }
389
+ }
390
+ /**
391
+ * Subcommand: guard diff
392
+ * Show what changed between the two most recent scans.
393
+ */
394
+ function printDiff(projectDir) {
395
+ const history = getScoreHistory(projectDir);
396
+ if (history.length < 2) {
397
+ console.log("\n Need at least 2 scans to show a diff. Run another scan first.\n");
398
+ return;
399
+ }
400
+ const prev = history[history.length - 2];
401
+ const curr = history[history.length - 1];
402
+ console.log("");
403
+ console.log(" ╔══════════════════════════════════════╗");
404
+ console.log(" ║ NoData Guard — Diff ║");
405
+ console.log(" ╚══════════════════════════════════════╝");
406
+ console.log("");
407
+ console.log(` Comparing: ${prev.date} → ${curr.date}`);
408
+ console.log("");
409
+ // Score delta
410
+ const scoreDelta = curr.score - prev.score;
411
+ const scoreArrow = scoreDelta > 0 ? `\x1b[32m↑ +${scoreDelta}%\x1b[0m` : scoreDelta < 0 ? `\x1b[31m↓ ${scoreDelta}%\x1b[0m` : "→ no change";
412
+ console.log(` Overall score: ${prev.score}% → ${curr.score}% ${scoreArrow}`);
413
+ // Code score delta
414
+ if (prev.code_score != null && curr.code_score != null) {
415
+ const cd = curr.code_score - prev.code_score;
416
+ const ca = cd > 0 ? `\x1b[32m+${cd}\x1b[0m` : cd < 0 ? `\x1b[31m${cd}\x1b[0m` : "0";
417
+ console.log(` Code score: ${prev.code_score}% → ${curr.code_score}% (${ca})`);
418
+ }
419
+ // DB score delta
420
+ if (prev.db_score != null && curr.db_score != null) {
421
+ const dd = curr.db_score - prev.db_score;
422
+ const da = dd > 0 ? `\x1b[32m+${dd}\x1b[0m` : dd < 0 ? `\x1b[31m${dd}\x1b[0m` : "0";
423
+ console.log(` DB score: ${prev.db_score}% → ${curr.db_score}% (${da})`);
424
+ }
425
+ console.log("");
426
+ // PII coverage
427
+ const piiDelta = curr.coverage_percent - prev.coverage_percent;
428
+ const piiArrow = piiDelta > 0 ? `\x1b[32m+${piiDelta}%\x1b[0m` : piiDelta < 0 ? `\x1b[31m${piiDelta}%\x1b[0m` : "0";
429
+ console.log(` PII encrypted: ${prev.pii_encrypted}/${prev.pii_total} → ${curr.pii_encrypted}/${curr.pii_total} (${piiArrow})`);
430
+ // Findings delta
431
+ const findDelta = curr.findings_count - prev.findings_count;
432
+ const findArrow = findDelta < 0 ? `\x1b[32m${findDelta} resolved\x1b[0m` : findDelta > 0 ? `\x1b[31m+${findDelta} new\x1b[0m` : "no change";
433
+ console.log(` Findings: ${prev.findings_count} → ${curr.findings_count} (${findArrow})`);
434
+ // Issues breakdown
435
+ console.log("");
436
+ console.log(" \x1b[33m── ISSUES BREAKDOWN ──\x1b[0m");
437
+ const showDelta = (label, p, c) => {
438
+ const d = c - p;
439
+ const color = d < 0 ? "\x1b[32m" : d > 0 ? "\x1b[31m" : "";
440
+ const sign = d > 0 ? "+" : "";
441
+ console.log(` ${label.padEnd(12)} ${p} → ${c} ${color}${d !== 0 ? `(${sign}${d})` : "(=)"}\x1b[0m`);
442
+ };
443
+ showDelta("Critical:", prev.critical, curr.critical);
444
+ showDelta("High:", prev.high, curr.high);
445
+ showDelta("Medium:", prev.medium, curr.medium);
446
+ showDelta("Low:", prev.low, curr.low);
447
+ // Scan type change
448
+ if (prev.scan_type !== curr.scan_type) {
449
+ console.log(`\n \x1b[36mNote:\x1b[0m Scan type changed from "${prev.scan_type}" to "${curr.scan_type}"`);
450
+ }
451
+ // Guard version change
452
+ if (prev.guard_version !== curr.guard_version) {
453
+ console.log(` \x1b[36mNote:\x1b[0m Guard version changed from v${prev.guard_version} to v${curr.guard_version}`);
454
+ }
455
+ console.log("");
456
+ if (scoreDelta > 0) {
457
+ console.log(" \x1b[32m✓ Score improved! Keep going.\x1b[0m\n");
458
+ }
459
+ else if (scoreDelta < 0) {
460
+ console.log(" \x1b[31m⚠ Score dropped. Check the full report for new issues.\x1b[0m\n");
461
+ }
462
+ else {
463
+ console.log(" \x1b[33m→ No score change. Fix open findings to improve.\x1b[0m\n");
464
+ }
465
+ }