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