@nodatachat/guard 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capsule-dir.d.ts +93 -0
- package/dist/capsule-dir.js +248 -0
- package/dist/cli.js +256 -120
- package/dist/code-scanner.js +2 -2
- package/dist/db-scanner.js +22 -5
- package/dist/reporter.js +46 -14
- package/dist/types.d.ts +16 -5
- 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,13 +18,15 @@
|
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
19
|
const path_1 = require("path");
|
|
20
20
|
const fs_1 = require("fs");
|
|
21
|
+
const crypto_1 = require("crypto");
|
|
21
22
|
const activation_1 = require("./activation");
|
|
22
23
|
const code_scanner_1 = require("./code-scanner");
|
|
23
24
|
const db_scanner_1 = require("./db-scanner");
|
|
24
25
|
const reporter_1 = require("./reporter");
|
|
25
|
-
const registry_1 = require("./fixers/registry");
|
|
26
26
|
const scheduler_1 = require("./fixers/scheduler");
|
|
27
|
-
const
|
|
27
|
+
const vault_crypto_1 = require("./vault-crypto");
|
|
28
|
+
const capsule_dir_1 = require("./capsule-dir");
|
|
29
|
+
const VERSION = "2.3.0";
|
|
28
30
|
async function main() {
|
|
29
31
|
const args = process.argv.slice(2);
|
|
30
32
|
// Parse args
|
|
@@ -35,10 +37,10 @@ async function main() {
|
|
|
35
37
|
let failOn = null;
|
|
36
38
|
let outputDir = process.cwd();
|
|
37
39
|
let skipSend = false;
|
|
38
|
-
let
|
|
40
|
+
let vaultPin;
|
|
41
|
+
let vaultDevice;
|
|
39
42
|
let schedulePreset;
|
|
40
43
|
let ciProvider;
|
|
41
|
-
let onlyFixers;
|
|
42
44
|
for (let i = 0; i < args.length; i++) {
|
|
43
45
|
switch (args[i]) {
|
|
44
46
|
case "--license-key":
|
|
@@ -62,14 +64,21 @@ async function main() {
|
|
|
62
64
|
case "--skip-send":
|
|
63
65
|
skipSend = true;
|
|
64
66
|
break;
|
|
67
|
+
case "--vault-pin":
|
|
68
|
+
vaultPin = args[++i];
|
|
69
|
+
break;
|
|
70
|
+
case "--vault-device":
|
|
71
|
+
vaultDevice = args[++i];
|
|
72
|
+
break;
|
|
65
73
|
case "--fix-plan":
|
|
66
|
-
|
|
74
|
+
console.log("\n ⚠ --fix-plan is deprecated. Capsule provides recommendations only.\n Run without this flag to see recommendations.\n");
|
|
67
75
|
break;
|
|
68
76
|
case "--fix":
|
|
69
|
-
|
|
77
|
+
console.log("\n ⚠ --fix is deprecated. Capsule does not modify your code.\n Recommendations are provided in the scan output.\n");
|
|
70
78
|
break;
|
|
71
79
|
case "--fix-only":
|
|
72
|
-
|
|
80
|
+
i++;
|
|
81
|
+
console.log("\n ⚠ --fix-only is deprecated. Capsule provides recommendations only.\n");
|
|
73
82
|
break;
|
|
74
83
|
case "--schedule":
|
|
75
84
|
schedulePreset = args[++i] || "weekly";
|
|
@@ -168,6 +177,9 @@ async function main() {
|
|
|
168
177
|
process.exit(1);
|
|
169
178
|
}
|
|
170
179
|
log(ciMode, `Activated. Tier: ${activation.tier} | Scan ID: ${activation.scan_id}`);
|
|
180
|
+
// ── Step 1.5: Initialize .capsule/ directory ──
|
|
181
|
+
const capsuleDir = (0, capsule_dir_1.initCapsuleDir)(projectDir, licenseKey);
|
|
182
|
+
log(ciMode, `.capsule/ directory ready: ${capsuleDir}`);
|
|
171
183
|
// ── Step 2: Code scan ──
|
|
172
184
|
log(ciMode, "Scanning code...");
|
|
173
185
|
const files = (0, code_scanner_1.readProjectFiles)(projectDir, (count) => {
|
|
@@ -231,6 +243,59 @@ async function main() {
|
|
|
231
243
|
(0, fs_1.writeFileSync)(metaPath, JSON.stringify(metadata, null, 2), "utf-8");
|
|
232
244
|
log(ciMode, `Full report: ${fullPath}`);
|
|
233
245
|
log(ciMode, `Metadata only: ${metaPath}`);
|
|
246
|
+
// ── Step 5.5: Save to .capsule/ ──
|
|
247
|
+
try {
|
|
248
|
+
// Save score snapshot
|
|
249
|
+
(0, capsule_dir_1.saveScore)(capsuleDir, {
|
|
250
|
+
date: new Date().toISOString().slice(0, 10),
|
|
251
|
+
score: full.overall_score,
|
|
252
|
+
code_score: full.code_score ?? null,
|
|
253
|
+
db_score: full.db_score ?? null,
|
|
254
|
+
pii_total: full.summary.total_pii_fields,
|
|
255
|
+
pii_encrypted: full.summary.encrypted_fields,
|
|
256
|
+
coverage_percent: full.summary.coverage_percent,
|
|
257
|
+
findings_count: metadata.findings?.length || 0,
|
|
258
|
+
critical: full.summary.critical_issues,
|
|
259
|
+
high: full.summary.high_issues,
|
|
260
|
+
medium: full.summary.medium_issues ?? 0,
|
|
261
|
+
low: full.summary.low_issues ?? 0,
|
|
262
|
+
scan_type: dbUrl ? "full" : "code",
|
|
263
|
+
guard_version: VERSION,
|
|
264
|
+
});
|
|
265
|
+
// Update proof.json from DB results (if DB scan was done)
|
|
266
|
+
if (full.db?.pii_fields) {
|
|
267
|
+
const proofFields = full.db.pii_fields
|
|
268
|
+
.filter((f) => f.encrypted && f.encryption_pattern)
|
|
269
|
+
.map((f) => ({
|
|
270
|
+
table: f.table,
|
|
271
|
+
column: f.column,
|
|
272
|
+
pattern: f.encryption_pattern,
|
|
273
|
+
sentinel_prefix: f.sentinel_prefix || f.encryption_pattern,
|
|
274
|
+
row_count: f.row_count || 0,
|
|
275
|
+
encrypted_count: f.encrypted_count || 0,
|
|
276
|
+
}));
|
|
277
|
+
if (proofFields.length > 0) {
|
|
278
|
+
(0, capsule_dir_1.updateProof)(capsuleDir, proofFields);
|
|
279
|
+
log(ciMode, `Updated proof.json: ${proofFields.length} DB-verified encrypted fields`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// Log scan as evidence
|
|
283
|
+
const deviceHash = (0, crypto_1.createHash)("sha256")
|
|
284
|
+
.update(`capsule:device:${require("os").hostname()}`)
|
|
285
|
+
.digest("hex").slice(0, 12);
|
|
286
|
+
(0, capsule_dir_1.addEvidence)(capsuleDir, licenseKey, {
|
|
287
|
+
date: new Date().toISOString(),
|
|
288
|
+
action: "scan_completed",
|
|
289
|
+
category: "scan",
|
|
290
|
+
detail: `Guard v${VERSION} scan: score ${full.overall_score}%, ${metadata.findings?.length || 0} findings, ${full.summary.encrypted_fields}/${full.summary.total_pii_fields} PII encrypted`,
|
|
291
|
+
impact: `Score: ${full.overall_score}%`,
|
|
292
|
+
attested_by: deviceHash,
|
|
293
|
+
});
|
|
294
|
+
log(ciMode, "Evidence saved to .capsule/");
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
log(ciMode, `.capsule/ save failed (non-blocking): ${err instanceof Error ? err.message : err}`);
|
|
298
|
+
}
|
|
234
299
|
// ── Step 6: Send metadata to NoData ──
|
|
235
300
|
let serverResponse = {};
|
|
236
301
|
if (!skipSend) {
|
|
@@ -259,6 +324,43 @@ async function main() {
|
|
|
259
324
|
log(ciMode, "Could not reach NoData API — report saved locally");
|
|
260
325
|
}
|
|
261
326
|
}
|
|
327
|
+
// ── Step 6b: Encrypt & upload to vault (if --vault-pin) ──
|
|
328
|
+
let vaultUploaded = false;
|
|
329
|
+
if (vaultPin && !skipSend) {
|
|
330
|
+
log(ciMode, "Encrypting full report for vault...");
|
|
331
|
+
try {
|
|
332
|
+
const fullJson = JSON.stringify(full);
|
|
333
|
+
const { ciphertext, iv, salt } = (0, vault_crypto_1.encryptForVault)(vaultPin, fullJson);
|
|
334
|
+
const vaultRes = await fetch("https://nodatacapsule.com/api/guard/vault", {
|
|
335
|
+
method: "POST",
|
|
336
|
+
headers: {
|
|
337
|
+
"Content-Type": "application/json",
|
|
338
|
+
"X-License-Key": licenseKey || "",
|
|
339
|
+
},
|
|
340
|
+
body: JSON.stringify({
|
|
341
|
+
encrypted_blob: ciphertext,
|
|
342
|
+
iv,
|
|
343
|
+
salt,
|
|
344
|
+
score: full.overall_score,
|
|
345
|
+
findings_count: metadata.findings?.length || 0,
|
|
346
|
+
scan_id: activation.scan_id,
|
|
347
|
+
device_id: vaultDevice || undefined,
|
|
348
|
+
}),
|
|
349
|
+
signal: AbortSignal.timeout(15000),
|
|
350
|
+
});
|
|
351
|
+
if (vaultRes.ok) {
|
|
352
|
+
vaultUploaded = true;
|
|
353
|
+
log(ciMode, "Report encrypted & saved to vault");
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
const errBody = await vaultRes.text().catch(() => "");
|
|
357
|
+
log(ciMode, `Vault upload failed (${vaultRes.status}): ${errBody}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
catch (err) {
|
|
361
|
+
log(ciMode, `Vault upload error: ${err instanceof Error ? err.message : err}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
262
364
|
// ── Step 7: Print summary ──
|
|
263
365
|
if (!ciMode) {
|
|
264
366
|
console.log("");
|
|
@@ -280,107 +382,133 @@ async function main() {
|
|
|
280
382
|
console.log(` Sent to NoData: ${metaPath}`);
|
|
281
383
|
console.log(` Proof hash: ${full.proof_hash.slice(0, 16)}...`);
|
|
282
384
|
console.log(" ══════════════════════════════════════");
|
|
385
|
+
if (vaultUploaded) {
|
|
386
|
+
console.log(" Vault: Encrypted & saved to dashboard");
|
|
387
|
+
}
|
|
283
388
|
console.log(" Your data never left your machine.");
|
|
284
389
|
console.log(" Diff the two files to verify.\n");
|
|
285
390
|
}
|
|
286
|
-
// ── Step 8:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
scanResults: {
|
|
294
|
-
piiFields: piiFields.map(f => ({
|
|
295
|
-
table: f.table, column: f.column, pii_type: f.pii_type,
|
|
296
|
-
encrypted: f.encrypted, encryption_pattern: f.encryption_pattern,
|
|
297
|
-
})),
|
|
298
|
-
routes: routes.map(r => ({ path: r.path, has_auth: r.has_auth, auth_type: r.auth_type })),
|
|
299
|
-
secrets: secrets.map(s => ({
|
|
300
|
-
file: s.file, line: s.line, type: s.type,
|
|
301
|
-
severity: s.severity, is_env_interpolated: s.is_env_interpolated,
|
|
302
|
-
})),
|
|
303
|
-
rls: dbResult?.rls || [],
|
|
304
|
-
framework: stack.framework,
|
|
305
|
-
database: stack.database,
|
|
306
|
-
},
|
|
307
|
-
stack: {
|
|
308
|
-
framework: stack.framework,
|
|
309
|
-
database: stack.database,
|
|
310
|
-
language: "typescript",
|
|
311
|
-
hosting: "vercel",
|
|
312
|
-
},
|
|
313
|
-
};
|
|
314
|
-
const capsuleResult = await (0, registry_1.runCapsule)(fixerContext, {
|
|
315
|
-
mode: fixMode === "plan" ? "plan" : "apply",
|
|
316
|
-
fixers: onlyFixers,
|
|
317
|
-
dryRun: fixMode === "plan",
|
|
318
|
-
}, (msg) => log(ciMode, msg));
|
|
319
|
-
if (!ciMode) {
|
|
391
|
+
// ── Step 8: Recommendations ──
|
|
392
|
+
// NOTE: Capsule does NOT auto-fix code. We provide recommendations only.
|
|
393
|
+
// Applying fixes requires understanding of the target system — tables,
|
|
394
|
+
// relationships, business logic — and must be done by the customer's team.
|
|
395
|
+
if (!ciMode) {
|
|
396
|
+
const topFindings = metadata.findings || [];
|
|
397
|
+
if (topFindings.length > 0) {
|
|
320
398
|
console.log("");
|
|
321
399
|
console.log(" ══════════════════════════════════════");
|
|
322
|
-
console.log(
|
|
400
|
+
console.log(" RECOMMENDATIONS");
|
|
323
401
|
console.log(" ══════════════════════════════════════");
|
|
324
|
-
console.log(`
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
402
|
+
console.log(` ${topFindings.length} issues found. Top priorities:\n`);
|
|
403
|
+
const shown = topFindings.slice(0, 10);
|
|
404
|
+
for (const f of shown) {
|
|
405
|
+
const sev = f.severity === "critical" ? "\x1b[31mCRITICAL\x1b[0m"
|
|
406
|
+
: f.severity === "high" ? "\x1b[33mHIGH\x1b[0m"
|
|
407
|
+
: "\x1b[36mMEDIUM\x1b[0m";
|
|
408
|
+
console.log(` ${sev} ${f.title}`);
|
|
409
|
+
console.log(` \x1b[32m→ ${f.fix_suggestion}\x1b[0m`);
|
|
410
|
+
if (f.soc_control)
|
|
411
|
+
console.log(` SOC: ${f.soc_control}`);
|
|
412
|
+
console.log("");
|
|
329
413
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
console.log(` Proof hash: ${capsuleResult.proofHash.slice(0, 16)}...`);
|
|
333
|
-
console.log(" ──────────────────────────────────────");
|
|
334
|
-
// Print per-fixer summary
|
|
335
|
-
for (const plan of capsuleResult.plans) {
|
|
336
|
-
const icon = plan.actions.every(a => a.status === "applied") ? "✅"
|
|
337
|
-
: plan.actions.some(a => a.status === "failed") ? "❌" : "📋";
|
|
338
|
-
console.log(` ${icon} ${plan.nameHe}: ${plan.totalActions} actions (${plan.autoFixable} auto, ${plan.manualRequired} manual)`);
|
|
414
|
+
if (topFindings.length > 10) {
|
|
415
|
+
console.log(` ... and ${topFindings.length - 10} more. See full report.\n`);
|
|
339
416
|
}
|
|
417
|
+
console.log(" ──────────────────────────────────────");
|
|
418
|
+
console.log(" ⚠ Capsule provides recommendations only.");
|
|
419
|
+
console.log(" Fixes require understanding of your system —");
|
|
420
|
+
console.log(" tables, relationships, and business logic.");
|
|
421
|
+
console.log(" Work with your team to apply changes safely.");
|
|
340
422
|
console.log(" ══════════════════════════════════════\n");
|
|
341
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
(
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
423
|
+
// Save recommendations to file
|
|
424
|
+
const recsPath = (0, path_1.resolve)(outputDir, "nodata-recommendations.json");
|
|
425
|
+
(0, fs_1.writeFileSync)(recsPath, JSON.stringify({
|
|
426
|
+
generated_at: new Date().toISOString(),
|
|
427
|
+
score: full.overall_score,
|
|
428
|
+
total_findings: topFindings.length,
|
|
429
|
+
disclaimer: "These are recommendations only. Capsule does not modify your code. Fixes require understanding of your system architecture, database schema, and business logic. Work with your development team to implement changes safely.",
|
|
430
|
+
recommendations: topFindings,
|
|
431
|
+
}, null, 2), "utf-8");
|
|
432
|
+
log(ciMode, `Recommendations: ${recsPath}`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// ── Step 8.5: Remediation Guide (non-CI) ──
|
|
436
|
+
if (!ciMode && metadata.findings?.length > 0) {
|
|
437
|
+
const fs = metadata.findings;
|
|
438
|
+
const hasPII = fs.some((f) => f.category === "encryption" || f.title?.toLowerCase().includes("pii") || f.title?.toLowerCase().includes("encrypt"));
|
|
439
|
+
const hasRLS = fs.some((f) => f.title?.toLowerCase().includes("rls") || f.title?.toLowerCase().includes("row level") || f.category === "access_control");
|
|
440
|
+
const hasSecrets = fs.some((f) => f.title?.toLowerCase().includes("secret") || f.title?.toLowerCase().includes("hardcoded") || f.category === "secrets");
|
|
441
|
+
const hasHeaders = fs.some((f) => f.title?.toLowerCase().includes("header") || f.category === "headers");
|
|
442
|
+
if (hasPII || hasRLS || hasSecrets || hasHeaders) {
|
|
443
|
+
console.log(" ══════════════════════════════════════");
|
|
444
|
+
console.log(" \x1b[33mREMEDIATION GUIDE\x1b[0m — General commands");
|
|
445
|
+
console.log(" ══════════════════════════════════════");
|
|
446
|
+
console.log(" \x1b[2mThese are general recommendations.");
|
|
447
|
+
console.log(" A qualified technical professional should review");
|
|
448
|
+
console.log(" and adapt them to your specific system.\x1b[0m\n");
|
|
449
|
+
if (hasPII) {
|
|
450
|
+
console.log(" \x1b[33m── ENCRYPT PII FIELDS ──\x1b[0m");
|
|
451
|
+
console.log(" \x1b[36mOption A:\x1b[0m Database-level (PostgreSQL)");
|
|
452
|
+
console.log(" CREATE EXTENSION IF NOT EXISTS pgcrypto;");
|
|
453
|
+
console.log(" ALTER TABLE <table> ADD COLUMN <field>_encrypted BYTEA;");
|
|
454
|
+
console.log(" UPDATE <table> SET <field>_encrypted =");
|
|
455
|
+
console.log(" pgp_sym_encrypt(<field>::TEXT, '<key>');");
|
|
456
|
+
console.log("");
|
|
457
|
+
console.log(" \x1b[36mOption B:\x1b[0m Application-level (Capsule SDK)");
|
|
458
|
+
console.log(" npm install @nodatachat/sdk");
|
|
459
|
+
console.log(" encrypt(value, process.env.FIELD_ENCRYPTION_KEY)");
|
|
460
|
+
console.log("");
|
|
461
|
+
console.log(" \x1b[36mOption C:\x1b[0m Encrypt .env at rest");
|
|
462
|
+
console.log(" npx @nodatachat/protect encrypt .env\n");
|
|
463
|
+
}
|
|
464
|
+
if (hasRLS) {
|
|
465
|
+
console.log(" \x1b[33m── ROW LEVEL SECURITY ──\x1b[0m");
|
|
466
|
+
console.log(" ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;");
|
|
467
|
+
console.log(" ALTER TABLE <table> FORCE ROW LEVEL SECURITY;");
|
|
468
|
+
console.log(" CREATE POLICY \"users_own\" ON <table>");
|
|
469
|
+
console.log(" FOR ALL USING (user_id = auth.uid());\n");
|
|
361
470
|
}
|
|
471
|
+
if (hasSecrets) {
|
|
472
|
+
console.log(" \x1b[33m── REMOVE SECRETS FROM CODE ──\x1b[0m");
|
|
473
|
+
console.log(" 1. Move to .env.local");
|
|
474
|
+
console.log(" 2. Reference: process.env.SECRET_NAME");
|
|
475
|
+
console.log(" 3. echo '.env*.local' >> .gitignore");
|
|
476
|
+
console.log(" 4. npx @nodatachat/protect encrypt .env\n");
|
|
477
|
+
}
|
|
478
|
+
if (hasHeaders) {
|
|
479
|
+
console.log(" \x1b[33m── SECURITY HEADERS ──\x1b[0m");
|
|
480
|
+
console.log(" Strict-Transport-Security: max-age=63072000");
|
|
481
|
+
console.log(" X-Content-Type-Options: nosniff");
|
|
482
|
+
console.log(" X-Frame-Options: DENY");
|
|
483
|
+
console.log(" Referrer-Policy: strict-origin-when-cross-origin\n");
|
|
484
|
+
}
|
|
485
|
+
console.log(" ──────────────────────────────────────\n");
|
|
362
486
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
487
|
+
}
|
|
488
|
+
// ── Step 9: What's next (non-CI) ──
|
|
489
|
+
if (!ciMode) {
|
|
490
|
+
console.log(" ══════════════════════════════════════");
|
|
491
|
+
console.log(" WHAT TO DO NEXT");
|
|
492
|
+
console.log(" ══════════════════════════════════════");
|
|
493
|
+
if (vaultUploaded) {
|
|
494
|
+
console.log(" 1. Open your dashboard: https://nodatacapsule.com/my-capsule");
|
|
495
|
+
console.log(" 2. Go to the \x1b[36mVault\x1b[0m tab");
|
|
496
|
+
console.log(" 3. Enter your PIN to see the full report");
|
|
497
|
+
console.log(" 4. Share the score with your team (no sensitive data)");
|
|
498
|
+
console.log(" 5. Fix findings and re-scan to improve your score\n");
|
|
499
|
+
}
|
|
500
|
+
else {
|
|
501
|
+
console.log(" 1. Review \x1b[36mnodata-full-report.json\x1b[0m (stays local)");
|
|
502
|
+
console.log(" 2. Open your dashboard: https://nodatacapsule.com/my-capsule");
|
|
503
|
+
console.log(" 3. Share the score with your team");
|
|
504
|
+
console.log(" 4. Fix findings and re-scan to improve your score");
|
|
505
|
+
console.log("");
|
|
506
|
+
console.log(" \x1b[33mTip:\x1b[0m Add \x1b[36m--vault-pin 1234\x1b[0m to auto-encrypt");
|
|
507
|
+
console.log(" and upload the full report to your vault.\n");
|
|
382
508
|
}
|
|
383
509
|
}
|
|
510
|
+
// ── Step 10: Print .capsule/ status ──
|
|
511
|
+
(0, capsule_dir_1.printCapsuleStatus)(projectDir, ciMode);
|
|
384
512
|
// ── CI mode: exit code ──
|
|
385
513
|
if (ciMode && failOn) {
|
|
386
514
|
const { critical_issues, high_issues, medium_issues } = full.summary;
|
|
@@ -425,14 +553,16 @@ function loadOrCreateConfig(projectDir, overrides) {
|
|
|
425
553
|
}
|
|
426
554
|
function printHelp() {
|
|
427
555
|
console.log(`
|
|
428
|
-
NoData Guard v${VERSION} — Security Scanner +
|
|
556
|
+
NoData Guard v${VERSION} — Security Scanner + Recommendations
|
|
557
|
+
|
|
558
|
+
Scans your project locally for security issues and provides
|
|
559
|
+
actionable recommendations. Your code never leaves your machine.
|
|
560
|
+
Capsule does NOT modify your code — only you and your team can.
|
|
429
561
|
|
|
430
562
|
Usage:
|
|
431
|
-
npx nodata-guard --license-key NDC-XXXX
|
|
432
|
-
npx nodata-guard --license-key NDC-XXXX --
|
|
433
|
-
npx nodata-guard --license-key NDC-XXXX --
|
|
434
|
-
npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
|
|
435
|
-
npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL --fix # Full scan + fix
|
|
563
|
+
npx nodata-guard --license-key NDC-XXXX # Scan + recommend
|
|
564
|
+
npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL # Full scan (code + DB)
|
|
565
|
+
npx nodata-guard --license-key NDC-XXXX --schedule weekly # Setup CI schedule
|
|
436
566
|
|
|
437
567
|
Scan Options:
|
|
438
568
|
--license-key <key> NoData license key (or set NDC_LICENSE env var)
|
|
@@ -442,13 +572,8 @@ function printHelp() {
|
|
|
442
572
|
--ci CI mode — minimal output, exit codes
|
|
443
573
|
--fail-on <level> Exit 1 if issues at: critical | high | medium
|
|
444
574
|
--skip-send Don't send metadata to NoData
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
--fix-plan Generate fix plan (dry-run, no changes)
|
|
448
|
-
--fix Apply all auto-fixable remediation
|
|
449
|
-
--fix-only <fixers> Only run specific fixers (comma-separated)
|
|
450
|
-
Fixers: pii-encrypt, rls, secrets, routes-auth,
|
|
451
|
-
headers, csrf, rate-limit, gitignore
|
|
575
|
+
--vault-pin <pin> Encrypt full report & save to vault (AES-256-GCM)
|
|
576
|
+
--vault-device <id> Link vault report to your dashboard device
|
|
452
577
|
|
|
453
578
|
Schedule Options:
|
|
454
579
|
--schedule <preset> Install CI workflow: daily | weekly | monthly
|
|
@@ -458,28 +583,39 @@ function printHelp() {
|
|
|
458
583
|
Configure in .nodata-guard.json → notify: { email, webhook, slack, telegram }
|
|
459
584
|
|
|
460
585
|
Output files:
|
|
461
|
-
nodata-full-report.json
|
|
462
|
-
nodata-metadata-only.json
|
|
463
|
-
nodata-
|
|
464
|
-
|
|
586
|
+
nodata-full-report.json Full report — STAYS LOCAL
|
|
587
|
+
nodata-metadata-only.json Metadata only — sent to dashboard
|
|
588
|
+
nodata-recommendations.json Prioritized recommendations
|
|
589
|
+
|
|
590
|
+
What we provide:
|
|
591
|
+
✓ Scan — find weak points (PII, routes, secrets, encryption)
|
|
592
|
+
✓ Recommend — prioritized actions with SOC control mapping
|
|
593
|
+
✓ Prove — cryptographic proof chain of scan results
|
|
594
|
+
✓ Monitor — track score over time via dashboard
|
|
595
|
+
|
|
596
|
+
What we NEVER do:
|
|
597
|
+
✗ Modify your code, database, or configuration
|
|
598
|
+
✗ Receive data values, source code, or credentials
|
|
465
599
|
|
|
466
|
-
|
|
467
|
-
|
|
600
|
+
Important:
|
|
601
|
+
Recommendations require understanding of YOUR system —
|
|
602
|
+
tables, relationships, business logic. Work with your team
|
|
603
|
+
to implement changes safely.
|
|
468
604
|
|
|
469
605
|
Examples:
|
|
470
|
-
#
|
|
606
|
+
# Scan and get recommendations
|
|
471
607
|
npx nodata-guard --license-key NDC-XXXX
|
|
472
608
|
|
|
473
|
-
#
|
|
474
|
-
npx nodata-guard --license-key NDC-XXXX --
|
|
609
|
+
# Scan + auto-save encrypted report to vault
|
|
610
|
+
npx nodata-guard --license-key NDC-XXXX --vault-pin 1234
|
|
475
611
|
|
|
476
|
-
#
|
|
477
|
-
npx nodata-guard --license-key NDC-XXXX --
|
|
612
|
+
# Full scan with DB probe
|
|
613
|
+
npx nodata-guard --license-key NDC-XXXX --db $DATABASE_URL
|
|
478
614
|
|
|
479
615
|
# Setup weekly CI scan with GitHub Actions
|
|
480
616
|
npx nodata-guard --license-key NDC-XXXX --schedule weekly
|
|
481
617
|
|
|
482
|
-
# CI pipeline
|
|
618
|
+
# CI pipeline — fail on critical issues
|
|
483
619
|
npx nodata-guard --ci --fail-on critical
|
|
484
620
|
|
|
485
621
|
Documentation: https://nodatacapsule.com/guard
|
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
|
-
|
|
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
|
};
|
|
@@ -203,10 +205,40 @@ function generateReports(input) {
|
|
|
203
205
|
critical_issues: criticalSecrets,
|
|
204
206
|
high_issues: highSecrets + plaintextPII,
|
|
205
207
|
medium_issues: medSecrets,
|
|
206
|
-
|
|
208
|
+
recommendations_available: plaintextPII,
|
|
207
209
|
},
|
|
208
210
|
proof_hash: proofHash,
|
|
209
211
|
metadata_preview: metadata,
|
|
212
|
+
// ── HOW TO FIX ──
|
|
213
|
+
// This section stays in the LOCAL report only. Never sent to NoData.
|
|
214
|
+
how_to_fix: {
|
|
215
|
+
summary: `This scan found ${plaintextPII} unencrypted PII fields, ${criticalSecrets} hardcoded secrets, and ${totalRoutes - protectedRoutes} unprotected routes.`,
|
|
216
|
+
steps: [
|
|
217
|
+
...(plaintextPII > 0 ? [{
|
|
218
|
+
priority: 1,
|
|
219
|
+
action: "Encrypt PII fields",
|
|
220
|
+
detail: `${plaintextPII} fields contain personal data without encryption. Each field listed below needs AES-256-GCM column-level encryption.`,
|
|
221
|
+
fields: allPII.filter(f => !f.encrypted).map(f => `${f.table}.${f.column} (${f.pii_type})`),
|
|
222
|
+
impact: `+${Math.min(Math.round(plaintextPII * 1.5), 30)}% score improvement`,
|
|
223
|
+
}] : []),
|
|
224
|
+
...(criticalSecrets > 0 ? [{
|
|
225
|
+
priority: 2,
|
|
226
|
+
action: "Remove hardcoded secrets",
|
|
227
|
+
detail: `${criticalSecrets} secrets are hardcoded in source files. Move them to .env and encrypt with: npx @nodatachat/protect init`,
|
|
228
|
+
fields: (input.code?.secrets.filter(s => !s.is_env_interpolated && s.severity === "critical") || []).map(s => `${s.file}:${s.line} (${s.type})`),
|
|
229
|
+
impact: `+${Math.min(criticalSecrets * 5, 25)}% score improvement`,
|
|
230
|
+
}] : []),
|
|
231
|
+
...((totalRoutes - protectedRoutes > 0) ? [{
|
|
232
|
+
priority: 3,
|
|
233
|
+
action: "Add route authentication",
|
|
234
|
+
detail: `${totalRoutes - protectedRoutes} API routes have no auth middleware. Add withAuth, API key validation, or rate limiting.`,
|
|
235
|
+
fields: (input.code?.routes.filter(r => !r.has_auth) || []).map(r => r.path),
|
|
236
|
+
impact: `+${Math.min(Math.round((totalRoutes - protectedRoutes) * 0.5), 15)}% score improvement`,
|
|
237
|
+
}] : []),
|
|
238
|
+
],
|
|
239
|
+
workflow: "Give this report to your development team or security engineer. They can review each finding and implement the fix suggestions. After fixing, re-run Guard to verify your score improved.",
|
|
240
|
+
re_scan: `npx @nodatachat/guard@latest --license-key YOUR_KEY`,
|
|
241
|
+
},
|
|
210
242
|
};
|
|
211
243
|
return { full, metadata };
|
|
212
244
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -84,10 +84,22 @@ export interface FullReport {
|
|
|
84
84
|
critical_issues: number;
|
|
85
85
|
high_issues: number;
|
|
86
86
|
medium_issues: number;
|
|
87
|
-
|
|
87
|
+
recommendations_available: number;
|
|
88
88
|
};
|
|
89
89
|
proof_hash: string;
|
|
90
90
|
metadata_preview: MetadataReport;
|
|
91
|
+
how_to_fix: {
|
|
92
|
+
summary: string;
|
|
93
|
+
steps: Array<{
|
|
94
|
+
priority: number;
|
|
95
|
+
action: string;
|
|
96
|
+
detail: string;
|
|
97
|
+
fields: string[];
|
|
98
|
+
impact: string;
|
|
99
|
+
}>;
|
|
100
|
+
workflow: string;
|
|
101
|
+
re_scan: string;
|
|
102
|
+
};
|
|
91
103
|
}
|
|
92
104
|
export interface MetadataReport {
|
|
93
105
|
version: "1.0";
|
|
@@ -116,11 +128,9 @@ export interface MetadataReport {
|
|
|
116
128
|
secrets_found: number;
|
|
117
129
|
secrets_critical: number;
|
|
118
130
|
fields: Array<{
|
|
119
|
-
|
|
120
|
-
column: string;
|
|
131
|
+
field_hash: string;
|
|
121
132
|
pii_type: string;
|
|
122
133
|
encrypted: boolean;
|
|
123
|
-
pattern: string | null;
|
|
124
134
|
}>;
|
|
125
135
|
} | null;
|
|
126
136
|
db_summary: {
|
|
@@ -140,7 +150,7 @@ export interface MetadataReport {
|
|
|
140
150
|
critical: number;
|
|
141
151
|
high: number;
|
|
142
152
|
medium: number;
|
|
143
|
-
|
|
153
|
+
recommendations_available: number;
|
|
144
154
|
};
|
|
145
155
|
findings: Array<{
|
|
146
156
|
severity: "critical" | "high" | "medium" | "low";
|
|
@@ -157,6 +167,7 @@ export interface MetadataReport {
|
|
|
157
167
|
contains_credentials: false;
|
|
158
168
|
contains_source_code: false;
|
|
159
169
|
contains_file_contents: false;
|
|
170
|
+
contains_structural_metadata: false;
|
|
160
171
|
customer_can_verify: true;
|
|
161
172
|
};
|
|
162
173
|
}
|
|
@@ -0,0 +1,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",
|