-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathintegrity.ts
More file actions
137 lines (123 loc) · 4.57 KB
/
Copy pathintegrity.ts
File metadata and controls
137 lines (123 loc) · 4.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
/**
* Plugin Integrity Verification — detects post-install tampering.
*
* SECURITY MODEL:
* On first run, compute SHA256 of all dist/ files and store a baseline in
* ~/.claude/zc-ctx/integrity.json. On every subsequent startup, recompute
* and compare. Any mismatch means a file was modified after installation.
*
* STRICT MODE (ZC_STRICT_INTEGRITY=1):
* By default, integrity failures are logged as warnings and the server continues.
* In strict mode, a tampered file causes the server to refuse to start.
* Enable for production / shared machine deployments:
* ZC_STRICT_INTEGRITY=1 node dist/server.js
*
* This does NOT prevent a sophisticated attacker who also updates the baseline,
* but it defends against the most common tampering: supply chain attacks that
* modify plugin files on disk without triggering reinstall.
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { Config } from "./config.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIST_DIR = __dirname;
const INTEGRITY_FILE = join(Config.GLOBAL_DIR, "integrity.json");
interface IntegrityBaseline {
version: string;
computed_at: string;
strict_mode: boolean;
files: Record<string, string>; // filename → sha256
}
export interface IntegrityResult {
ok: boolean;
firstRun: boolean;
warnings: string[];
strictMode: boolean;
}
function sha256File(filePath: string): string {
const buf = readFileSync(filePath);
return createHash("sha256").update(buf).digest("hex");
}
function getDistFiles(): string[] {
if (!existsSync(DIST_DIR)) return [];
return readdirSync(DIST_DIR)
.filter((f) => f.endsWith(".js") && !f.endsWith(".test.js"))
.sort();
}
function computeHashes(): Record<string, string> {
const hashes: Record<string, string> = {};
for (const file of getDistFiles()) {
const fp = join(DIST_DIR, file);
if (statSync(fp).isFile()) {
hashes[file] = sha256File(fp);
}
}
return hashes;
}
/**
* Run integrity check on startup.
* First run: establish baseline.
* Subsequent runs: compare against baseline.
*
* In STRICT MODE (ZC_STRICT_INTEGRITY=1):
* - Returns ok:false with warnings for any mismatch
* - Caller (server.ts) should exit(1) when ok is false in strict mode
*/
export function checkIntegrity(version: string): IntegrityResult {
const warnings: string[] = [];
const strictMode: boolean = Config.STRICT_INTEGRITY;
try {
const current = computeHashes();
if (!existsSync(INTEGRITY_FILE)) {
// First run — establish baseline
mkdirSync(Config.GLOBAL_DIR, { recursive: true });
const baseline: IntegrityBaseline = {
version,
computed_at: new Date().toISOString(),
strict_mode: strictMode,
files: current,
};
writeFileSync(INTEGRITY_FILE, JSON.stringify(baseline, null, 2), "utf8");
return { ok: true, firstRun: true, warnings: [], strictMode };
}
const stored: IntegrityBaseline = JSON.parse(readFileSync(INTEGRITY_FILE, "utf8"));
if (stored.version !== version) {
// Version changed → re-baseline (legitimate update or upgrade)
const baseline: IntegrityBaseline = {
version,
computed_at: new Date().toISOString(),
strict_mode: strictMode,
files: current,
};
writeFileSync(INTEGRITY_FILE, JSON.stringify(baseline, null, 2), "utf8");
return { ok: true, firstRun: false, warnings: [], strictMode };
}
// Same version — check for file modifications
for (const [file, hash] of Object.entries(current)) {
if (!(file in stored.files)) {
warnings.push(`New file added to dist/: ${file}`);
} else if (stored.files[file] !== hash) {
warnings.push(
`TAMPERED: dist/${file} hash mismatch ` +
`(stored: ${stored.files[file]!.slice(0, 8)}…, current: ${hash.slice(0, 8)}…)`
);
}
}
for (const file of Object.keys(stored.files)) {
if (!(file in current)) {
warnings.push(`File removed from dist/: ${file}`);
}
}
return { ok: warnings.length === 0, firstRun: false, warnings, strictMode };
} catch {
// Never crash the plugin due to an integrity check failure (unless strict)
return {
ok: !strictMode, // in strict mode, a broken check is itself a failure
firstRun: false,
warnings: ["Integrity check could not run — skipping"],
strictMode,
};
}
}