@nodatachat/guard 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capsule-dir.d.ts +11 -0
- package/dist/capsule-dir.js +218 -1
- package/dist/cli.js +313 -4
- package/dist/code-scanner.d.ts +1 -1
- package/dist/code-scanner.js +25 -2
- package/dist/reporter.js +7 -0
- package/dist/types.d.ts +2 -0
- package/package.json +1 -1
package/dist/capsule-dir.d.ts
CHANGED
|
@@ -91,3 +91,14 @@ export declare function getScoreHistory(projectDir: string): ScoreSnapshot[];
|
|
|
91
91
|
* Print .capsule/ status to terminal.
|
|
92
92
|
*/
|
|
93
93
|
export declare function printCapsuleStatus(projectDir: string, ciMode: boolean): void;
|
|
94
|
+
export declare function attestFinding(projectDir: string, licenseKey: string, findingId: string, status: Override["status"], note: string): void;
|
|
95
|
+
/**
|
|
96
|
+
* Subcommand: guard status
|
|
97
|
+
* Print full .capsule/ status without running a scan.
|
|
98
|
+
*/
|
|
99
|
+
export declare function printFullStatus(projectDir: string): void;
|
|
100
|
+
/**
|
|
101
|
+
* Subcommand: guard diff
|
|
102
|
+
* Show what changed between the two most recent scans.
|
|
103
|
+
*/
|
|
104
|
+
export declare function printDiff(projectDir: string): void;
|
package/dist/capsule-dir.js
CHANGED
|
@@ -30,6 +30,9 @@ exports.readProof = readProof;
|
|
|
30
30
|
exports.readOverrides = readOverrides;
|
|
31
31
|
exports.getScoreHistory = getScoreHistory;
|
|
32
32
|
exports.printCapsuleStatus = printCapsuleStatus;
|
|
33
|
+
exports.attestFinding = attestFinding;
|
|
34
|
+
exports.printFullStatus = printFullStatus;
|
|
35
|
+
exports.printDiff = printDiff;
|
|
33
36
|
const path_1 = require("path");
|
|
34
37
|
const fs_1 = require("fs");
|
|
35
38
|
const crypto_1 = require("crypto");
|
|
@@ -200,7 +203,7 @@ function getScoreHistory(projectDir) {
|
|
|
200
203
|
const scoresDir = (0, path_1.join)(projectDir, DIR_NAME, "scores");
|
|
201
204
|
if (!(0, fs_1.existsSync)(scoresDir))
|
|
202
205
|
return [];
|
|
203
|
-
const files =
|
|
206
|
+
const files = (0, fs_1.readdirSync)(scoresDir)
|
|
204
207
|
.filter((f) => f.endsWith(".json"))
|
|
205
208
|
.sort();
|
|
206
209
|
return files.map((f) => {
|
|
@@ -246,3 +249,217 @@ function printCapsuleStatus(projectDir, ciMode) {
|
|
|
246
249
|
console.log(" \x1b[2mAll evidence stays local. Commit to git for audit trail.\x1b[0m");
|
|
247
250
|
console.log(" ══════════════════════════════════════\n");
|
|
248
251
|
}
|
|
252
|
+
// ═══════════════════════════════════════════════════════════
|
|
253
|
+
// Subcommand: guard attest
|
|
254
|
+
//
|
|
255
|
+
// Allows the user to manually attest that a finding has been
|
|
256
|
+
// addressed. Creates a signed entry in overrides.json.
|
|
257
|
+
// ═══════════════════════════════════════════════════════════
|
|
258
|
+
function attestFinding(projectDir, licenseKey, findingId, status, note) {
|
|
259
|
+
const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
|
|
260
|
+
if (!(0, fs_1.existsSync)(capsuleDir)) {
|
|
261
|
+
initCapsuleDir(projectDir, licenseKey);
|
|
262
|
+
}
|
|
263
|
+
const overridesPath = (0, path_1.join)(capsuleDir, "overrides.json");
|
|
264
|
+
const overrides = (0, fs_1.existsSync)(overridesPath)
|
|
265
|
+
? JSON.parse((0, fs_1.readFileSync)(overridesPath, "utf-8"))
|
|
266
|
+
: { version: "1.0", overrides: [] };
|
|
267
|
+
const deviceHash = (0, crypto_1.createHash)("sha256")
|
|
268
|
+
.update(`capsule:device:${require("os").hostname()}`)
|
|
269
|
+
.digest("hex").slice(0, 12);
|
|
270
|
+
const now = new Date().toISOString();
|
|
271
|
+
const payload = JSON.stringify({ finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now });
|
|
272
|
+
const hmac = signEvidence(payload, licenseKey);
|
|
273
|
+
// Update existing or add new
|
|
274
|
+
const existing = overrides.overrides.findIndex(o => o.finding_id === findingId);
|
|
275
|
+
const entry = { finding_id: findingId, status, note, attested_by: deviceHash, attested_at: now, hmac };
|
|
276
|
+
if (existing >= 0) {
|
|
277
|
+
overrides.overrides[existing] = entry;
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
overrides.overrides.push(entry);
|
|
281
|
+
}
|
|
282
|
+
(0, fs_1.writeFileSync)(overridesPath, JSON.stringify(overrides, null, 2), "utf-8");
|
|
283
|
+
// Also log as evidence
|
|
284
|
+
addEvidence(capsuleDir, licenseKey, {
|
|
285
|
+
date: now,
|
|
286
|
+
action: "override_added",
|
|
287
|
+
category: status === "fixed" ? "remediation" : "risk_management",
|
|
288
|
+
detail: `Attested "${findingId}" as ${status}: ${note}`,
|
|
289
|
+
impact: `Override: ${status}`,
|
|
290
|
+
attested_by: deviceHash,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Subcommand: guard status
|
|
295
|
+
* Print full .capsule/ status without running a scan.
|
|
296
|
+
*/
|
|
297
|
+
function printFullStatus(projectDir) {
|
|
298
|
+
const capsuleDir = (0, path_1.join)(projectDir, DIR_NAME);
|
|
299
|
+
if (!(0, fs_1.existsSync)(capsuleDir)) {
|
|
300
|
+
console.log("\n No .capsule/ directory found. Run a scan first.\n");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const history = getScoreHistory(projectDir);
|
|
304
|
+
const proof = readProof(projectDir);
|
|
305
|
+
const overrides = readOverrides(projectDir);
|
|
306
|
+
console.log("");
|
|
307
|
+
console.log(" ╔══════════════════════════════════════╗");
|
|
308
|
+
console.log(" ║ NoData Guard — Status ║");
|
|
309
|
+
console.log(" ╚══════════════════════════════════════╝");
|
|
310
|
+
console.log("");
|
|
311
|
+
// ── Score history ──
|
|
312
|
+
console.log(" \x1b[33m── SCORE HISTORY ──\x1b[0m");
|
|
313
|
+
if (history.length === 0) {
|
|
314
|
+
console.log(" No scans recorded yet.\n");
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
const last = history[history.length - 1];
|
|
318
|
+
console.log(` Latest score: \x1b[1m${last.score}%\x1b[0m (${last.scan_type} scan, Guard v${last.guard_version})`);
|
|
319
|
+
console.log(` Last scan date: ${last.date}`);
|
|
320
|
+
console.log(` Total scans: ${history.length}`);
|
|
321
|
+
if (last.code_score != null)
|
|
322
|
+
console.log(` Code score: ${last.code_score}%`);
|
|
323
|
+
if (last.db_score != null)
|
|
324
|
+
console.log(` DB score: ${last.db_score}%`);
|
|
325
|
+
console.log(` PII coverage: ${last.pii_encrypted}/${last.pii_total} encrypted (${last.coverage_percent}%)`);
|
|
326
|
+
console.log(` Issues: ${last.critical} critical, ${last.high} high, ${last.medium} medium, ${last.low} low`);
|
|
327
|
+
console.log(` Findings: ${last.findings_count} total`);
|
|
328
|
+
if (history.length >= 2) {
|
|
329
|
+
console.log("");
|
|
330
|
+
console.log(" \x1b[33m── TREND ──\x1b[0m");
|
|
331
|
+
const maxShow = Math.min(history.length, 10);
|
|
332
|
+
for (let i = history.length - maxShow; i < history.length; i++) {
|
|
333
|
+
const h = history[i];
|
|
334
|
+
const prev = i > 0 ? history[i - 1] : null;
|
|
335
|
+
const delta = prev ? h.score - prev.score : 0;
|
|
336
|
+
const arrow = delta > 0 ? `\x1b[32m+${delta}\x1b[0m` : delta < 0 ? `\x1b[31m${delta}\x1b[0m` : " 0";
|
|
337
|
+
const bar = "\x1b[32m" + "█".repeat(Math.round(h.score / 5)) + "\x1b[0m" + "░".repeat(20 - Math.round(h.score / 5));
|
|
338
|
+
console.log(` ${h.date} ${bar} ${h.score}% (${arrow})`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
console.log("");
|
|
342
|
+
}
|
|
343
|
+
// ── Proof ──
|
|
344
|
+
console.log(" \x1b[33m── DB-VERIFIED ENCRYPTION ──\x1b[0m");
|
|
345
|
+
if (!proof || proof.fields.length === 0) {
|
|
346
|
+
console.log(" No DB-verified encryption yet. Run with --db to verify.\n");
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
console.log(` ${proof.fields.length} field(s) verified in database:\n`);
|
|
350
|
+
for (const f of proof.fields) {
|
|
351
|
+
const cov = f.coverage_percent;
|
|
352
|
+
const covColor = cov >= 90 ? "\x1b[32m" : cov >= 50 ? "\x1b[33m" : "\x1b[31m";
|
|
353
|
+
console.log(` ${f.table_hash}:${f.column_hash} ${f.pattern} ${covColor}${cov}%\x1b[0m (${f.encrypted_count}/${f.row_count} rows)`);
|
|
354
|
+
}
|
|
355
|
+
console.log(`\n Last verified: ${proof.updated_at}\n`);
|
|
356
|
+
}
|
|
357
|
+
// ── Overrides ──
|
|
358
|
+
console.log(" \x1b[33m── MANUAL ATTESTATIONS ──\x1b[0m");
|
|
359
|
+
if (!overrides || overrides.overrides.length === 0) {
|
|
360
|
+
console.log(" No overrides. Use \x1b[36mguard attest\x1b[0m to declare fixes.\n");
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
for (const o of overrides.overrides) {
|
|
364
|
+
const statusColor = o.status === "fixed" ? "\x1b[32m" : o.status === "accepted_risk" ? "\x1b[33m" : "\x1b[36m";
|
|
365
|
+
console.log(` ${o.finding_id}`);
|
|
366
|
+
console.log(` Status: ${statusColor}${o.status}\x1b[0m`);
|
|
367
|
+
console.log(` Note: ${o.note}`);
|
|
368
|
+
console.log(` Date: ${o.attested_at}`);
|
|
369
|
+
console.log(` Device: ${o.attested_by}`);
|
|
370
|
+
console.log("");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
// ── Evidence summary ──
|
|
374
|
+
const evidenceDir = (0, path_1.join)(capsuleDir, "evidence");
|
|
375
|
+
if ((0, fs_1.existsSync)(evidenceDir)) {
|
|
376
|
+
const evidenceFiles = (0, fs_1.readdirSync)(evidenceDir).filter(f => f.endsWith(".json"));
|
|
377
|
+
let totalEntries = 0;
|
|
378
|
+
for (const ef of evidenceFiles) {
|
|
379
|
+
try {
|
|
380
|
+
const entries = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(evidenceDir, ef), "utf-8"));
|
|
381
|
+
totalEntries += entries.length;
|
|
382
|
+
}
|
|
383
|
+
catch { /* skip */ }
|
|
384
|
+
}
|
|
385
|
+
console.log(" \x1b[33m── EVIDENCE LOG ──\x1b[0m");
|
|
386
|
+
console.log(` ${totalEntries} entries across ${evidenceFiles.length} day(s)`);
|
|
387
|
+
console.log(" \x1b[2mAll evidence stays local. Commit .capsule/ to git for audit trail.\x1b[0m\n");
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Subcommand: guard diff
|
|
392
|
+
* Show what changed between the two most recent scans.
|
|
393
|
+
*/
|
|
394
|
+
function printDiff(projectDir) {
|
|
395
|
+
const history = getScoreHistory(projectDir);
|
|
396
|
+
if (history.length < 2) {
|
|
397
|
+
console.log("\n Need at least 2 scans to show a diff. Run another scan first.\n");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
const prev = history[history.length - 2];
|
|
401
|
+
const curr = history[history.length - 1];
|
|
402
|
+
console.log("");
|
|
403
|
+
console.log(" ╔══════════════════════════════════════╗");
|
|
404
|
+
console.log(" ║ NoData Guard — Diff ║");
|
|
405
|
+
console.log(" ╚══════════════════════════════════════╝");
|
|
406
|
+
console.log("");
|
|
407
|
+
console.log(` Comparing: ${prev.date} → ${curr.date}`);
|
|
408
|
+
console.log("");
|
|
409
|
+
// Score delta
|
|
410
|
+
const scoreDelta = curr.score - prev.score;
|
|
411
|
+
const scoreArrow = scoreDelta > 0 ? `\x1b[32m↑ +${scoreDelta}%\x1b[0m` : scoreDelta < 0 ? `\x1b[31m↓ ${scoreDelta}%\x1b[0m` : "→ no change";
|
|
412
|
+
console.log(` Overall score: ${prev.score}% → ${curr.score}% ${scoreArrow}`);
|
|
413
|
+
// Code score delta
|
|
414
|
+
if (prev.code_score != null && curr.code_score != null) {
|
|
415
|
+
const cd = curr.code_score - prev.code_score;
|
|
416
|
+
const ca = cd > 0 ? `\x1b[32m+${cd}\x1b[0m` : cd < 0 ? `\x1b[31m${cd}\x1b[0m` : "0";
|
|
417
|
+
console.log(` Code score: ${prev.code_score}% → ${curr.code_score}% (${ca})`);
|
|
418
|
+
}
|
|
419
|
+
// DB score delta
|
|
420
|
+
if (prev.db_score != null && curr.db_score != null) {
|
|
421
|
+
const dd = curr.db_score - prev.db_score;
|
|
422
|
+
const da = dd > 0 ? `\x1b[32m+${dd}\x1b[0m` : dd < 0 ? `\x1b[31m${dd}\x1b[0m` : "0";
|
|
423
|
+
console.log(` DB score: ${prev.db_score}% → ${curr.db_score}% (${da})`);
|
|
424
|
+
}
|
|
425
|
+
console.log("");
|
|
426
|
+
// PII coverage
|
|
427
|
+
const piiDelta = curr.coverage_percent - prev.coverage_percent;
|
|
428
|
+
const piiArrow = piiDelta > 0 ? `\x1b[32m+${piiDelta}%\x1b[0m` : piiDelta < 0 ? `\x1b[31m${piiDelta}%\x1b[0m` : "0";
|
|
429
|
+
console.log(` PII encrypted: ${prev.pii_encrypted}/${prev.pii_total} → ${curr.pii_encrypted}/${curr.pii_total} (${piiArrow})`);
|
|
430
|
+
// Findings delta
|
|
431
|
+
const findDelta = curr.findings_count - prev.findings_count;
|
|
432
|
+
const findArrow = findDelta < 0 ? `\x1b[32m${findDelta} resolved\x1b[0m` : findDelta > 0 ? `\x1b[31m+${findDelta} new\x1b[0m` : "no change";
|
|
433
|
+
console.log(` Findings: ${prev.findings_count} → ${curr.findings_count} (${findArrow})`);
|
|
434
|
+
// Issues breakdown
|
|
435
|
+
console.log("");
|
|
436
|
+
console.log(" \x1b[33m── ISSUES BREAKDOWN ──\x1b[0m");
|
|
437
|
+
const showDelta = (label, p, c) => {
|
|
438
|
+
const d = c - p;
|
|
439
|
+
const color = d < 0 ? "\x1b[32m" : d > 0 ? "\x1b[31m" : "";
|
|
440
|
+
const sign = d > 0 ? "+" : "";
|
|
441
|
+
console.log(` ${label.padEnd(12)} ${p} → ${c} ${color}${d !== 0 ? `(${sign}${d})` : "(=)"}\x1b[0m`);
|
|
442
|
+
};
|
|
443
|
+
showDelta("Critical:", prev.critical, curr.critical);
|
|
444
|
+
showDelta("High:", prev.high, curr.high);
|
|
445
|
+
showDelta("Medium:", prev.medium, curr.medium);
|
|
446
|
+
showDelta("Low:", prev.low, curr.low);
|
|
447
|
+
// Scan type change
|
|
448
|
+
if (prev.scan_type !== curr.scan_type) {
|
|
449
|
+
console.log(`\n \x1b[36mNote:\x1b[0m Scan type changed from "${prev.scan_type}" to "${curr.scan_type}"`);
|
|
450
|
+
}
|
|
451
|
+
// Guard version change
|
|
452
|
+
if (prev.guard_version !== curr.guard_version) {
|
|
453
|
+
console.log(` \x1b[36mNote:\x1b[0m Guard version changed from v${prev.guard_version} to v${curr.guard_version}`);
|
|
454
|
+
}
|
|
455
|
+
console.log("");
|
|
456
|
+
if (scoreDelta > 0) {
|
|
457
|
+
console.log(" \x1b[32m✓ Score improved! Keep going.\x1b[0m\n");
|
|
458
|
+
}
|
|
459
|
+
else if (scoreDelta < 0) {
|
|
460
|
+
console.log(" \x1b[31m⚠ Score dropped. Check the full report for new issues.\x1b[0m\n");
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
console.log(" \x1b[33m→ No score change. Fix open findings to improve.\x1b[0m\n");
|
|
464
|
+
}
|
|
465
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -26,9 +26,24 @@ const reporter_1 = require("./reporter");
|
|
|
26
26
|
const scheduler_1 = require("./fixers/scheduler");
|
|
27
27
|
const vault_crypto_1 = require("./vault-crypto");
|
|
28
28
|
const capsule_dir_1 = require("./capsule-dir");
|
|
29
|
-
const VERSION = "2.
|
|
29
|
+
const VERSION = "2.5.0";
|
|
30
30
|
async function main() {
|
|
31
31
|
const args = process.argv.slice(2);
|
|
32
|
+
// ── Subcommand routing ──
|
|
33
|
+
const subcommand = args[0];
|
|
34
|
+
if (subcommand === "status") {
|
|
35
|
+
const dir = args.includes("--dir") ? (0, path_1.resolve)(args[args.indexOf("--dir") + 1]) : process.cwd();
|
|
36
|
+
(0, capsule_dir_1.printFullStatus)(dir);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
if (subcommand === "diff") {
|
|
40
|
+
const dir = args.includes("--dir") ? (0, path_1.resolve)(args[args.indexOf("--dir") + 1]) : process.cwd();
|
|
41
|
+
(0, capsule_dir_1.printDiff)(dir);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (subcommand === "attest") {
|
|
45
|
+
return handleAttest(args.slice(1));
|
|
46
|
+
}
|
|
32
47
|
// Parse args
|
|
33
48
|
let licenseKey;
|
|
34
49
|
let dbUrl;
|
|
@@ -99,6 +114,49 @@ async function main() {
|
|
|
99
114
|
licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
|
|
100
115
|
if (!dbUrl)
|
|
101
116
|
dbUrl = process.env.DATABASE_URL;
|
|
117
|
+
// ── Auto-detect from .env files ──
|
|
118
|
+
// Guard reads .env files to find DATABASE_URL and license keys.
|
|
119
|
+
// ALL values stay local — never sent anywhere.
|
|
120
|
+
// If DB URL is found, Guard asks for explicit consent before connecting.
|
|
121
|
+
if (!dbUrl || !licenseKey) {
|
|
122
|
+
const envResult = readEnvFiles(projectDir);
|
|
123
|
+
if (!licenseKey && envResult.licenseKey) {
|
|
124
|
+
licenseKey = envResult.licenseKey;
|
|
125
|
+
if (!ciMode)
|
|
126
|
+
console.log(`\n \x1b[32m✓\x1b[0m Found license key in ${envResult.licenseSource}`);
|
|
127
|
+
}
|
|
128
|
+
if (!dbUrl && envResult.dbUrl) {
|
|
129
|
+
// Mask the connection string for display (show host only)
|
|
130
|
+
const masked = maskDbUrl(envResult.dbUrl);
|
|
131
|
+
if (ciMode) {
|
|
132
|
+
// CI mode: auto-consent (operator set up the env)
|
|
133
|
+
dbUrl = envResult.dbUrl;
|
|
134
|
+
console.log(`[nodata-guard] Using database from ${envResult.dbSource}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Interactive: ask for consent
|
|
138
|
+
console.log("");
|
|
139
|
+
console.log(" ╔══════════════════════════════════════════╗");
|
|
140
|
+
console.log(" ║ \x1b[33mDatabase connection found\x1b[0m ║");
|
|
141
|
+
console.log(" ╚══════════════════════════════════════════╝");
|
|
142
|
+
console.log(` Source: ${envResult.dbSource}`);
|
|
143
|
+
console.log(` URL: ${masked}`);
|
|
144
|
+
console.log("");
|
|
145
|
+
console.log(" \x1b[2mGuard will connect to your database to verify encryption.\x1b[0m");
|
|
146
|
+
console.log(" \x1b[2mOnly checks if values START WITH encryption prefixes.\x1b[0m");
|
|
147
|
+
console.log(" \x1b[2mNo data values are read, copied, or sent anywhere.\x1b[0m");
|
|
148
|
+
console.log("");
|
|
149
|
+
const consent = await askConsent(" Connect to database? (y/n): ");
|
|
150
|
+
if (consent) {
|
|
151
|
+
dbUrl = envResult.dbUrl;
|
|
152
|
+
console.log(` \x1b[32m✓\x1b[0m Database scan enabled\n`);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
console.log(" \x1b[33m→\x1b[0m Skipping database scan — code-only mode\n");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
102
160
|
// Shared Protect config fallback (~/.nodata/config.json)
|
|
103
161
|
if (!licenseKey) {
|
|
104
162
|
try {
|
|
@@ -189,7 +247,7 @@ async function main() {
|
|
|
189
247
|
if (!ciMode)
|
|
190
248
|
console.log("");
|
|
191
249
|
log(ciMode, `Scanned ${files.length} files`);
|
|
192
|
-
const piiFields = (0, code_scanner_1.scanPIIFields)(files);
|
|
250
|
+
const piiFields = (0, code_scanner_1.scanPIIFields)(files, projectDir);
|
|
193
251
|
const routes = (0, code_scanner_1.scanRoutes)(files);
|
|
194
252
|
const secrets = (0, code_scanner_1.scanSecrets)(files);
|
|
195
253
|
const stack = (0, code_scanner_1.detectStack)(files);
|
|
@@ -361,13 +419,30 @@ async function main() {
|
|
|
361
419
|
log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
|
|
362
420
|
}
|
|
363
421
|
}
|
|
364
|
-
// ── Step 7: Print summary ──
|
|
422
|
+
// ── Step 7: Print summary with two-score display ──
|
|
423
|
+
const codeScore = full.code ? Math.round((full.code.encryption_coverage_percent * 0.5) +
|
|
424
|
+
(full.code.route_protection_percent * 0.25) +
|
|
425
|
+
(Math.max(0, 100 - (full.summary.critical_issues * 25 + full.summary.high_issues * 10)) * 0.25)) : null;
|
|
426
|
+
const dbScore = full.db ? Math.round((full.db.encryption_coverage_percent * 0.6) +
|
|
427
|
+
(full.db.rls_coverage_percent * 0.4)) : null;
|
|
428
|
+
// Attach to full report for .capsule/ storage
|
|
429
|
+
full.code_score = codeScore;
|
|
430
|
+
full.db_score = dbScore;
|
|
365
431
|
if (!ciMode) {
|
|
366
432
|
console.log("");
|
|
367
433
|
console.log(" ══════════════════════════════════════");
|
|
368
434
|
console.log(" GUARD RESULTS");
|
|
369
435
|
console.log(" ══════════════════════════════════════");
|
|
370
|
-
console.log(`
|
|
436
|
+
console.log(` Overall score: \x1b[1m${full.overall_score}%\x1b[0m${serverResponse.previous_score != null ? ` (was ${serverResponse.previous_score}%)` : ""}`);
|
|
437
|
+
if (codeScore != null) {
|
|
438
|
+
console.log(` Code score: ${codeScore}%`);
|
|
439
|
+
}
|
|
440
|
+
if (dbScore != null) {
|
|
441
|
+
console.log(` DB score: ${dbScore}%`);
|
|
442
|
+
}
|
|
443
|
+
if (codeScore != null && dbScore != null) {
|
|
444
|
+
console.log(" ──────────────────────────────────────");
|
|
445
|
+
}
|
|
371
446
|
console.log(` PII fields: ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} encrypted (${full.summary.coverage_percent}%)`);
|
|
372
447
|
if (full.code) {
|
|
373
448
|
console.log(` Routes: ${full.code.routes.filter(r => r.has_auth).length}/${full.code.routes.length} protected`);
|
|
@@ -551,6 +626,209 @@ function loadOrCreateConfig(projectDir, overrides) {
|
|
|
551
626
|
},
|
|
552
627
|
});
|
|
553
628
|
}
|
|
629
|
+
// ═══════════════════════════════════════════════════════════
|
|
630
|
+
// Consent & display helpers
|
|
631
|
+
// ═══════════════════════════════════════════════════════════
|
|
632
|
+
function maskDbUrl(url) {
|
|
633
|
+
try {
|
|
634
|
+
const parsed = new URL(url);
|
|
635
|
+
const host = parsed.hostname;
|
|
636
|
+
const port = parsed.port || (url.includes("6543") ? "6543" : "5432");
|
|
637
|
+
const db = parsed.pathname.replace("/", "") || "postgres";
|
|
638
|
+
const user = parsed.username ? parsed.username.slice(0, 8) + "..." : "***";
|
|
639
|
+
return `${user}@${host}:${port}/${db}`;
|
|
640
|
+
}
|
|
641
|
+
catch {
|
|
642
|
+
// Fallback: show first 20 chars + mask
|
|
643
|
+
return url.slice(0, 20) + "...****";
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
function askConsent(prompt) {
|
|
647
|
+
return new Promise((resolve) => {
|
|
648
|
+
const readline = require("readline");
|
|
649
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
650
|
+
rl.question(prompt, (answer) => {
|
|
651
|
+
rl.close();
|
|
652
|
+
const a = answer.trim().toLowerCase();
|
|
653
|
+
resolve(a === "y" || a === "yes" || a === "כ" || a === "כן");
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
function readEnvFiles(projectDir) {
|
|
658
|
+
const result = {
|
|
659
|
+
dbUrl: null, dbSource: "",
|
|
660
|
+
licenseKey: null, licenseSource: "",
|
|
661
|
+
supabaseUrl: null, supabaseKey: null,
|
|
662
|
+
};
|
|
663
|
+
// Priority order — more specific files override general ones
|
|
664
|
+
const envFiles = [
|
|
665
|
+
".env",
|
|
666
|
+
".env.local",
|
|
667
|
+
".env.development",
|
|
668
|
+
".env.development.local",
|
|
669
|
+
".env.production",
|
|
670
|
+
".env.production.local",
|
|
671
|
+
];
|
|
672
|
+
// Keys that contain database connection strings
|
|
673
|
+
const DB_KEYS = [
|
|
674
|
+
"DATABASE_URL",
|
|
675
|
+
"DIRECT_URL",
|
|
676
|
+
"POSTGRES_URL",
|
|
677
|
+
"POSTGRES_PRISMA_URL",
|
|
678
|
+
"POSTGRES_URL_NON_POOLING",
|
|
679
|
+
"PG_CONNECTION_STRING",
|
|
680
|
+
"DB_URL",
|
|
681
|
+
"SUPABASE_DB_URL",
|
|
682
|
+
"DATABASE_CONNECTION_STRING",
|
|
683
|
+
];
|
|
684
|
+
// Keys that contain license/API keys
|
|
685
|
+
const LICENSE_KEYS = [
|
|
686
|
+
"NDC_LICENSE",
|
|
687
|
+
"NODATA_LICENSE_KEY",
|
|
688
|
+
"NODATA_API_KEY",
|
|
689
|
+
"NDC_API_KEY",
|
|
690
|
+
"NDC_PROTECT_KEY",
|
|
691
|
+
"CAPSULE_LICENSE_KEY",
|
|
692
|
+
"CAPSULE_API_KEY",
|
|
693
|
+
];
|
|
694
|
+
const SUPABASE_URL_KEYS = [
|
|
695
|
+
"NEXT_PUBLIC_SUPABASE_URL",
|
|
696
|
+
"SUPABASE_URL",
|
|
697
|
+
"VITE_SUPABASE_URL",
|
|
698
|
+
"REACT_APP_SUPABASE_URL",
|
|
699
|
+
"NUXT_PUBLIC_SUPABASE_URL",
|
|
700
|
+
];
|
|
701
|
+
const SUPABASE_KEY_KEYS = [
|
|
702
|
+
"SUPABASE_SERVICE_ROLE_KEY",
|
|
703
|
+
"SUPABASE_SERVICE_KEY",
|
|
704
|
+
];
|
|
705
|
+
for (const envFile of envFiles) {
|
|
706
|
+
const filePath = (0, path_1.resolve)(projectDir, envFile);
|
|
707
|
+
if (!(0, fs_1.existsSync)(filePath))
|
|
708
|
+
continue;
|
|
709
|
+
try {
|
|
710
|
+
const content = (0, fs_1.readFileSync)(filePath, "utf-8");
|
|
711
|
+
const lines = content.split("\n");
|
|
712
|
+
for (const line of lines) {
|
|
713
|
+
const trimmed = line.trim();
|
|
714
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
715
|
+
continue;
|
|
716
|
+
const eqIdx = trimmed.indexOf("=");
|
|
717
|
+
if (eqIdx < 1)
|
|
718
|
+
continue;
|
|
719
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
720
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
721
|
+
// Strip quotes
|
|
722
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
723
|
+
val = val.slice(1, -1);
|
|
724
|
+
}
|
|
725
|
+
if (!val)
|
|
726
|
+
continue;
|
|
727
|
+
// Check DB keys
|
|
728
|
+
if (!result.dbUrl && DB_KEYS.includes(key)) {
|
|
729
|
+
if (val.startsWith("postgres") || val.startsWith("pg://") || val.includes("5432") || val.includes("6543")) {
|
|
730
|
+
result.dbUrl = val;
|
|
731
|
+
result.dbSource = envFile;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
// Check license keys
|
|
735
|
+
if (!result.licenseKey && LICENSE_KEYS.includes(key)) {
|
|
736
|
+
result.licenseKey = val;
|
|
737
|
+
result.licenseSource = envFile;
|
|
738
|
+
}
|
|
739
|
+
// Check Supabase URL (for fallback DB construction)
|
|
740
|
+
if (!result.supabaseUrl && SUPABASE_URL_KEYS.includes(key)) {
|
|
741
|
+
result.supabaseUrl = val;
|
|
742
|
+
}
|
|
743
|
+
// Check Supabase service role key
|
|
744
|
+
if (!result.supabaseKey && SUPABASE_KEY_KEYS.includes(key)) {
|
|
745
|
+
result.supabaseKey = val;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
catch { /* skip unreadable files */ }
|
|
750
|
+
}
|
|
751
|
+
return result;
|
|
752
|
+
}
|
|
753
|
+
function handleAttest(args) {
|
|
754
|
+
let licenseKey;
|
|
755
|
+
let projectDir = process.cwd();
|
|
756
|
+
let findingId;
|
|
757
|
+
let status = "fixed";
|
|
758
|
+
let note = "";
|
|
759
|
+
for (let i = 0; i < args.length; i++) {
|
|
760
|
+
switch (args[i]) {
|
|
761
|
+
case "--license-key":
|
|
762
|
+
licenseKey = args[++i];
|
|
763
|
+
break;
|
|
764
|
+
case "--dir":
|
|
765
|
+
projectDir = (0, path_1.resolve)(args[++i]);
|
|
766
|
+
break;
|
|
767
|
+
case "--finding":
|
|
768
|
+
findingId = args[++i];
|
|
769
|
+
break;
|
|
770
|
+
case "--status":
|
|
771
|
+
status = args[++i];
|
|
772
|
+
break;
|
|
773
|
+
case "--note":
|
|
774
|
+
note = args[++i];
|
|
775
|
+
break;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// Env / config fallbacks for license key
|
|
779
|
+
if (!licenseKey)
|
|
780
|
+
licenseKey = process.env.NDC_LICENSE || process.env.NODATA_LICENSE_KEY || process.env.NODATA_API_KEY || process.env.NDC_API_KEY;
|
|
781
|
+
if (!licenseKey) {
|
|
782
|
+
try {
|
|
783
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
784
|
+
const configPath = require("path").join(home, ".nodata", "config.json");
|
|
785
|
+
if (require("fs").existsSync(configPath)) {
|
|
786
|
+
const config = JSON.parse(require("fs").readFileSync(configPath, "utf-8"));
|
|
787
|
+
if (config.api_key)
|
|
788
|
+
licenseKey = config.api_key;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
catch { /* ignore */ }
|
|
792
|
+
}
|
|
793
|
+
if (!licenseKey) {
|
|
794
|
+
console.error("\n \x1b[31m✗\x1b[0m No API key found. Pass --license-key or set NDC_LICENSE.\n");
|
|
795
|
+
process.exit(1);
|
|
796
|
+
}
|
|
797
|
+
if (!findingId) {
|
|
798
|
+
console.error("\n \x1b[31m✗\x1b[0m Missing --finding <id>.");
|
|
799
|
+
console.error(" Example finding IDs: PII_UNENCRYPTED_email, ROUTE_NO_AUTH, SECRET_POSTGRES_URI");
|
|
800
|
+
console.error("\n Usage:");
|
|
801
|
+
console.error(" npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note \"Encrypted with pgcrypto\"");
|
|
802
|
+
console.error("\n Statuses: fixed | accepted_risk | not_applicable | compensating_control\n");
|
|
803
|
+
process.exit(1);
|
|
804
|
+
}
|
|
805
|
+
if (!note) {
|
|
806
|
+
console.error("\n \x1b[31m✗\x1b[0m Missing --note <description>. Explain what was done.\n");
|
|
807
|
+
process.exit(1);
|
|
808
|
+
}
|
|
809
|
+
const validStatuses = ["fixed", "accepted_risk", "not_applicable", "compensating_control"];
|
|
810
|
+
if (!validStatuses.includes(status)) {
|
|
811
|
+
console.error(`\n \x1b[31m✗\x1b[0m Invalid status "${status}". Use: ${validStatuses.join(" | ")}\n`);
|
|
812
|
+
process.exit(1);
|
|
813
|
+
}
|
|
814
|
+
(0, capsule_dir_1.attestFinding)(projectDir, licenseKey, findingId, status, note);
|
|
815
|
+
const statusColor = status === "fixed" ? "\x1b[32m" : "\x1b[33m";
|
|
816
|
+
console.log("");
|
|
817
|
+
console.log(" ╔══════════════════════════════════════╗");
|
|
818
|
+
console.log(" ║ NoData Guard — Attestation ║");
|
|
819
|
+
console.log(" ╚══════════════════════════════════════╝");
|
|
820
|
+
console.log("");
|
|
821
|
+
console.log(` Finding: ${findingId}`);
|
|
822
|
+
console.log(` Status: ${statusColor}${status}\x1b[0m`);
|
|
823
|
+
console.log(` Note: ${note}`);
|
|
824
|
+
console.log(` Signed: HMAC-SHA256 (license-key-bound)`);
|
|
825
|
+
console.log("");
|
|
826
|
+
console.log(" \x1b[32m✓\x1b[0m Saved to .capsule/overrides.json");
|
|
827
|
+
console.log(" \x1b[32m✓\x1b[0m Evidence logged to .capsule/evidence/");
|
|
828
|
+
console.log("");
|
|
829
|
+
console.log(" \x1b[2mCommit .capsule/ to git for audit trail.\x1b[0m");
|
|
830
|
+
console.log(" \x1b[2mRe-scan to see updated score.\x1b[0m\n");
|
|
831
|
+
}
|
|
554
832
|
function printHelp() {
|
|
555
833
|
console.log(`
|
|
556
834
|
NoData Guard v${VERSION} — Security Scanner + Recommendations
|
|
@@ -563,6 +841,17 @@ function printHelp() {
|
|
|
563
841
|
npx nodata-guard --license-key NDC-XXXX # Scan + recommend
|
|
564
842
|
npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
|
|
565
843
|
npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
|
|
844
|
+
npx nodata-guard status # Show .capsule/ status (no scan)
|
|
845
|
+
npx nodata-guard diff # Compare last 2 scans
|
|
846
|
+
npx nodata-guard attest --finding ID --status fixed --note "..." # Manual attestation
|
|
847
|
+
|
|
848
|
+
Subcommands:
|
|
849
|
+
status Show .capsule/ evidence (scores, proof, overrides) without scanning
|
|
850
|
+
diff Compare the last 2 scans — score delta, issues resolved/new
|
|
851
|
+
attest Manually attest a finding (saved to .capsule/overrides.json)
|
|
852
|
+
--finding <id> Finding ID (e.g., PII_UNENCRYPTED_email, ROUTE_NO_AUTH)
|
|
853
|
+
--status <status> fixed | accepted_risk | not_applicable | compensating_control
|
|
854
|
+
--note <text> Explanation of what was done
|
|
566
855
|
|
|
567
856
|
Scan Options:
|
|
568
857
|
--license-key <key> NoData license key (or set NDC_LICENSE env var)
|
|
@@ -593,9 +882,17 @@ function printHelp() {
|
|
|
593
882
|
✓ Prove — cryptographic proof chain of scan results
|
|
594
883
|
✓ Monitor — track score over time via dashboard
|
|
595
884
|
|
|
885
|
+
Auto-detection:
|
|
886
|
+
Guard automatically reads .env files to find DATABASE_URL
|
|
887
|
+
and license keys. If a database is found, Guard asks for
|
|
888
|
+
your explicit consent before connecting. All values stay
|
|
889
|
+
local — never sent anywhere. In CI mode (--ci), consent
|
|
890
|
+
is automatic (operator configured the environment).
|
|
891
|
+
|
|
596
892
|
What we NEVER do:
|
|
597
893
|
✗ Modify your code, database, or configuration
|
|
598
894
|
✗ Receive data values, source code, or credentials
|
|
895
|
+
✗ Read actual data from your database (only checks prefixes)
|
|
599
896
|
|
|
600
897
|
Important:
|
|
601
898
|
Recommendations require understanding of YOUR system —
|
|
@@ -618,6 +915,18 @@ function printHelp() {
|
|
|
618
915
|
# CI pipeline — fail on critical issues
|
|
619
916
|
npx nodata-guard --ci --fail-on critical
|
|
620
917
|
|
|
918
|
+
# Check .capsule/ status without scanning
|
|
919
|
+
npx nodata-guard status
|
|
920
|
+
|
|
921
|
+
# See what changed between last 2 scans
|
|
922
|
+
npx nodata-guard diff
|
|
923
|
+
|
|
924
|
+
# Attest that you fixed a finding
|
|
925
|
+
npx nodata-guard attest --finding PII_UNENCRYPTED_email --status fixed --note "Encrypted with pgcrypto in migration 042"
|
|
926
|
+
|
|
927
|
+
# Accept risk for a finding
|
|
928
|
+
npx nodata-guard attest --finding ROUTE_NO_AUTH --status accepted_risk --note "Public healthcheck endpoint, no auth needed"
|
|
929
|
+
|
|
621
930
|
Documentation: https://nodatacapsule.com/guard
|
|
622
931
|
`);
|
|
623
932
|
}
|
package/dist/code-scanner.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ interface FileEntry {
|
|
|
4
4
|
content: string;
|
|
5
5
|
}
|
|
6
6
|
export declare function readProjectFiles(projectDir: string, onProgress?: (count: number) => void): FileEntry[];
|
|
7
|
-
export declare function scanPIIFields(files: FileEntry[]): PIIFieldResult[];
|
|
7
|
+
export declare function scanPIIFields(files: FileEntry[], projectDir?: string): PIIFieldResult[];
|
|
8
8
|
export declare function scanRoutes(files: FileEntry[]): RouteResult[];
|
|
9
9
|
export declare function scanSecrets(files: FileEntry[]): SecretResult[];
|
|
10
10
|
export declare function detectStack(files: FileEntry[]): {
|
package/dist/code-scanner.js
CHANGED
|
@@ -18,6 +18,8 @@ exports.scanSecrets = scanSecrets;
|
|
|
18
18
|
exports.detectStack = detectStack;
|
|
19
19
|
const fs_1 = require("fs");
|
|
20
20
|
const path_1 = require("path");
|
|
21
|
+
const crypto_1 = require("crypto");
|
|
22
|
+
const capsule_dir_1 = require("./capsule-dir");
|
|
21
23
|
// ── File reading ──
|
|
22
24
|
const SKIP_DIRS = new Set([
|
|
23
25
|
"node_modules", ".git", ".next", ".nuxt", "dist", "build", "out",
|
|
@@ -97,7 +99,7 @@ function classifyColumn(col) {
|
|
|
97
99
|
}
|
|
98
100
|
return null;
|
|
99
101
|
}
|
|
100
|
-
function scanPIIFields(files) {
|
|
102
|
+
function scanPIIFields(files, projectDir) {
|
|
101
103
|
const fields = [];
|
|
102
104
|
const seen = new Set();
|
|
103
105
|
// Collect all column names to check for _encrypted companions
|
|
@@ -152,7 +154,7 @@ function scanPIIFields(files) {
|
|
|
152
154
|
const encryptFiles = files.filter(f => /createCipheriv|encrypt|decrypt|encryptPII|nodata_encrypt/i.test(f.content));
|
|
153
155
|
const proofFile = files.find(f => f.path.includes("nodata-proof.json"));
|
|
154
156
|
const triggerFiles = files.filter(f => f.path.endsWith(".sql") && /trg_encrypt_/i.test(f.content));
|
|
155
|
-
// Parse proof file
|
|
157
|
+
// Parse legacy proof file (nodata-proof.json)
|
|
156
158
|
const proofFields = new Set();
|
|
157
159
|
if (proofFile) {
|
|
158
160
|
try {
|
|
@@ -167,6 +169,27 @@ function scanPIIFields(files) {
|
|
|
167
169
|
}
|
|
168
170
|
catch { /* ignore */ }
|
|
169
171
|
}
|
|
172
|
+
// Parse .capsule/proof.json (DB-verified encryption from previous scans)
|
|
173
|
+
// This allows code-only scans to recognize encryption verified by a previous --db scan
|
|
174
|
+
if (projectDir) {
|
|
175
|
+
const capsuleProof = (0, capsule_dir_1.readProof)(projectDir);
|
|
176
|
+
if (capsuleProof && capsuleProof.fields.length > 0) {
|
|
177
|
+
// capsule proof uses hashed names — we need to build a lookup by hash
|
|
178
|
+
const fieldHashMap = new Map(); // hash → "table.column"
|
|
179
|
+
for (const field of fields) {
|
|
180
|
+
const tableHash = (0, crypto_1.createHash)("sha256").update(`capsule:name:${field.table}`).digest("hex").slice(0, 16);
|
|
181
|
+
const colHash = (0, crypto_1.createHash)("sha256").update(`capsule:name:${field.column}`).digest("hex").slice(0, 16);
|
|
182
|
+
fieldHashMap.set(`${tableHash}:${colHash}`, `${field.table}.${field.column}`);
|
|
183
|
+
}
|
|
184
|
+
for (const pf of capsuleProof.fields) {
|
|
185
|
+
const key = `${pf.table_hash}:${pf.column_hash}`;
|
|
186
|
+
const realName = fieldHashMap.get(key);
|
|
187
|
+
if (realName && pf.coverage_percent >= 50) {
|
|
188
|
+
proofFields.add(realName);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
170
193
|
// Parse trigger functions
|
|
171
194
|
const triggerTables = new Set();
|
|
172
195
|
for (const f of triggerFiles) {
|
package/dist/reporter.js
CHANGED
|
@@ -80,6 +80,13 @@ function generateReports(input) {
|
|
|
80
80
|
guard_version: GUARD_VERSION,
|
|
81
81
|
scores: {
|
|
82
82
|
overall: overallScore,
|
|
83
|
+
code: input.code ? Math.round((encCoverage * 0.5) + (routeProtection * 0.25) + (secretScore * 0.25)) : null,
|
|
84
|
+
db: input.db ? Math.round(((input.db.piiFields.length > 0
|
|
85
|
+
? Math.round((input.db.piiFields.filter(f => f.encrypted).length / input.db.piiFields.length) * 100)
|
|
86
|
+
: 100) * 0.6) +
|
|
87
|
+
((input.db.rls.length > 0
|
|
88
|
+
? Math.round((input.db.rls.filter(r => r.rls_enabled).length / input.db.rls.length) * 100)
|
|
89
|
+
: 0) * 0.4)) : null,
|
|
83
90
|
previous: input.previousScore,
|
|
84
91
|
improved,
|
|
85
92
|
delta: input.previousScore !== null ? overallScore - input.previousScore : 0,
|
package/dist/types.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodatachat/guard",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.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",
|