defense-mcp-server 0.6.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/CHANGELOG.md +471 -0
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/build/core/auto-installer.d.ts +102 -0
- package/build/core/auto-installer.d.ts.map +1 -0
- package/build/core/auto-installer.js +833 -0
- package/build/core/backup-manager.d.ts +63 -0
- package/build/core/backup-manager.d.ts.map +1 -0
- package/build/core/backup-manager.js +189 -0
- package/build/core/changelog.d.ts +75 -0
- package/build/core/changelog.d.ts.map +1 -0
- package/build/core/changelog.js +123 -0
- package/build/core/command-allowlist.d.ts +129 -0
- package/build/core/command-allowlist.d.ts.map +1 -0
- package/build/core/command-allowlist.js +849 -0
- package/build/core/config.d.ts +79 -0
- package/build/core/config.d.ts.map +1 -0
- package/build/core/config.js +193 -0
- package/build/core/dependency-validator.d.ts +106 -0
- package/build/core/dependency-validator.d.ts.map +1 -0
- package/build/core/dependency-validator.js +405 -0
- package/build/core/distro-adapter.d.ts +177 -0
- package/build/core/distro-adapter.d.ts.map +1 -0
- package/build/core/distro-adapter.js +481 -0
- package/build/core/distro.d.ts +68 -0
- package/build/core/distro.d.ts.map +1 -0
- package/build/core/distro.js +457 -0
- package/build/core/encrypted-state.d.ts +76 -0
- package/build/core/encrypted-state.d.ts.map +1 -0
- package/build/core/encrypted-state.js +209 -0
- package/build/core/executor.d.ts +56 -0
- package/build/core/executor.d.ts.map +1 -0
- package/build/core/executor.js +350 -0
- package/build/core/installer.d.ts +92 -0
- package/build/core/installer.d.ts.map +1 -0
- package/build/core/installer.js +1072 -0
- package/build/core/logger.d.ts +102 -0
- package/build/core/logger.d.ts.map +1 -0
- package/build/core/logger.js +132 -0
- package/build/core/parsers.d.ts +151 -0
- package/build/core/parsers.d.ts.map +1 -0
- package/build/core/parsers.js +479 -0
- package/build/core/policy-engine.d.ts +170 -0
- package/build/core/policy-engine.d.ts.map +1 -0
- package/build/core/policy-engine.js +656 -0
- package/build/core/preflight.d.ts +157 -0
- package/build/core/preflight.d.ts.map +1 -0
- package/build/core/preflight.js +638 -0
- package/build/core/privilege-manager.d.ts +108 -0
- package/build/core/privilege-manager.d.ts.map +1 -0
- package/build/core/privilege-manager.js +363 -0
- package/build/core/rate-limiter.d.ts +67 -0
- package/build/core/rate-limiter.d.ts.map +1 -0
- package/build/core/rate-limiter.js +129 -0
- package/build/core/rollback.d.ts +73 -0
- package/build/core/rollback.d.ts.map +1 -0
- package/build/core/rollback.js +278 -0
- package/build/core/safeguards.d.ts +58 -0
- package/build/core/safeguards.d.ts.map +1 -0
- package/build/core/safeguards.js +448 -0
- package/build/core/sanitizer.d.ts +118 -0
- package/build/core/sanitizer.d.ts.map +1 -0
- package/build/core/sanitizer.js +459 -0
- package/build/core/secure-fs.d.ts +67 -0
- package/build/core/secure-fs.d.ts.map +1 -0
- package/build/core/secure-fs.js +143 -0
- package/build/core/spawn-safe.d.ts +55 -0
- package/build/core/spawn-safe.d.ts.map +1 -0
- package/build/core/spawn-safe.js +146 -0
- package/build/core/sudo-guard.d.ts +145 -0
- package/build/core/sudo-guard.d.ts.map +1 -0
- package/build/core/sudo-guard.js +349 -0
- package/build/core/sudo-session.d.ts +100 -0
- package/build/core/sudo-session.d.ts.map +1 -0
- package/build/core/sudo-session.js +319 -0
- package/build/core/tool-dependencies.d.ts +61 -0
- package/build/core/tool-dependencies.d.ts.map +1 -0
- package/build/core/tool-dependencies.js +571 -0
- package/build/core/tool-registry.d.ts +111 -0
- package/build/core/tool-registry.d.ts.map +1 -0
- package/build/core/tool-registry.js +656 -0
- package/build/core/tool-wrapper.d.ts +73 -0
- package/build/core/tool-wrapper.d.ts.map +1 -0
- package/build/core/tool-wrapper.js +296 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +247 -0
- package/build/tools/access-control.d.ts +9 -0
- package/build/tools/access-control.d.ts.map +1 -0
- package/build/tools/access-control.js +1818 -0
- package/build/tools/api-security.d.ts +12 -0
- package/build/tools/api-security.d.ts.map +1 -0
- package/build/tools/api-security.js +901 -0
- package/build/tools/app-hardening.d.ts +11 -0
- package/build/tools/app-hardening.d.ts.map +1 -0
- package/build/tools/app-hardening.js +768 -0
- package/build/tools/backup.d.ts +8 -0
- package/build/tools/backup.d.ts.map +1 -0
- package/build/tools/backup.js +381 -0
- package/build/tools/cloud-security.d.ts +17 -0
- package/build/tools/cloud-security.d.ts.map +1 -0
- package/build/tools/cloud-security.js +739 -0
- package/build/tools/compliance.d.ts +10 -0
- package/build/tools/compliance.d.ts.map +1 -0
- package/build/tools/compliance.js +1225 -0
- package/build/tools/container-security.d.ts +14 -0
- package/build/tools/container-security.d.ts.map +1 -0
- package/build/tools/container-security.js +788 -0
- package/build/tools/deception.d.ts +13 -0
- package/build/tools/deception.d.ts.map +1 -0
- package/build/tools/deception.js +763 -0
- package/build/tools/dns-security.d.ts +93 -0
- package/build/tools/dns-security.d.ts.map +1 -0
- package/build/tools/dns-security.js +745 -0
- package/build/tools/drift-detection.d.ts +8 -0
- package/build/tools/drift-detection.d.ts.map +1 -0
- package/build/tools/drift-detection.js +326 -0
- package/build/tools/ebpf-security.d.ts +15 -0
- package/build/tools/ebpf-security.d.ts.map +1 -0
- package/build/tools/ebpf-security.js +294 -0
- package/build/tools/encryption.d.ts +9 -0
- package/build/tools/encryption.d.ts.map +1 -0
- package/build/tools/encryption.js +1667 -0
- package/build/tools/firewall.d.ts +9 -0
- package/build/tools/firewall.d.ts.map +1 -0
- package/build/tools/firewall.js +1398 -0
- package/build/tools/hardening.d.ts +10 -0
- package/build/tools/hardening.d.ts.map +1 -0
- package/build/tools/hardening.js +2654 -0
- package/build/tools/ids.d.ts +9 -0
- package/build/tools/ids.d.ts.map +1 -0
- package/build/tools/ids.js +624 -0
- package/build/tools/incident-response.d.ts +10 -0
- package/build/tools/incident-response.d.ts.map +1 -0
- package/build/tools/incident-response.js +1180 -0
- package/build/tools/logging.d.ts +12 -0
- package/build/tools/logging.d.ts.map +1 -0
- package/build/tools/logging.js +454 -0
- package/build/tools/malware.d.ts +10 -0
- package/build/tools/malware.d.ts.map +1 -0
- package/build/tools/malware.js +532 -0
- package/build/tools/meta.d.ts +11 -0
- package/build/tools/meta.d.ts.map +1 -0
- package/build/tools/meta.js +2278 -0
- package/build/tools/network-defense.d.ts +12 -0
- package/build/tools/network-defense.d.ts.map +1 -0
- package/build/tools/network-defense.js +760 -0
- package/build/tools/patch-management.d.ts +3 -0
- package/build/tools/patch-management.d.ts.map +1 -0
- package/build/tools/patch-management.js +708 -0
- package/build/tools/process-security.d.ts +12 -0
- package/build/tools/process-security.d.ts.map +1 -0
- package/build/tools/process-security.js +784 -0
- package/build/tools/reporting.d.ts +11 -0
- package/build/tools/reporting.d.ts.map +1 -0
- package/build/tools/reporting.js +559 -0
- package/build/tools/secrets.d.ts +9 -0
- package/build/tools/secrets.d.ts.map +1 -0
- package/build/tools/secrets.js +596 -0
- package/build/tools/siem-integration.d.ts +18 -0
- package/build/tools/siem-integration.d.ts.map +1 -0
- package/build/tools/siem-integration.js +754 -0
- package/build/tools/sudo-management.d.ts +18 -0
- package/build/tools/sudo-management.d.ts.map +1 -0
- package/build/tools/sudo-management.js +737 -0
- package/build/tools/supply-chain-security.d.ts +8 -0
- package/build/tools/supply-chain-security.d.ts.map +1 -0
- package/build/tools/supply-chain-security.js +256 -0
- package/build/tools/threat-intel.d.ts +22 -0
- package/build/tools/threat-intel.d.ts.map +1 -0
- package/build/tools/threat-intel.js +749 -0
- package/build/tools/vulnerability-management.d.ts +11 -0
- package/build/tools/vulnerability-management.d.ts.map +1 -0
- package/build/tools/vulnerability-management.js +667 -0
- package/build/tools/waf.d.ts +12 -0
- package/build/tools/waf.d.ts.map +1 -0
- package/build/tools/waf.js +843 -0
- package/build/tools/wireless-security.d.ts +19 -0
- package/build/tools/wireless-security.d.ts.map +1 -0
- package/build/tools/wireless-security.js +826 -0
- package/build/tools/zero-trust-network.d.ts +8 -0
- package/build/tools/zero-trust-network.d.ts.map +1 -0
- package/build/tools/zero-trust-network.js +367 -0
- package/docs/SAFEGUARDS.md +518 -0
- package/docs/TOOLS-REFERENCE.md +665 -0
- package/package.json +87 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { resolve, normalize } from "node:path";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
/**
|
|
4
|
+
* Regex matching dangerous shell metacharacters.
|
|
5
|
+
* These characters could enable command injection if passed unsanitized.
|
|
6
|
+
*/
|
|
7
|
+
const SHELL_METACHAR_RE = /[;|&$`(){}<>!\\\n\r]/;
|
|
8
|
+
/**
|
|
9
|
+
* Regex matching control characters (excluding tab, newline, carriage return
|
|
10
|
+
* which are handled by SHELL_METACHAR_RE where dangerous).
|
|
11
|
+
*/
|
|
12
|
+
const CONTROL_CHAR_RE = /[\x00-\x08\x0e-\x1f\x7f]/;
|
|
13
|
+
/**
|
|
14
|
+
* Regex matching path traversal components (`..` as a directory segment).
|
|
15
|
+
*/
|
|
16
|
+
const PATH_TRAVERSAL_RE = /(^|[\/\\])\.\.([\/\\]|$)/;
|
|
17
|
+
/**
|
|
18
|
+
* Validates a target string as hostname, IPv4, IPv6, or CIDR notation.
|
|
19
|
+
* Throws on invalid input.
|
|
20
|
+
*/
|
|
21
|
+
export function validateTarget(target, config) {
|
|
22
|
+
if (!target || typeof target !== "string") {
|
|
23
|
+
throw new Error("Target must be a non-empty string");
|
|
24
|
+
}
|
|
25
|
+
const trimmed = target.trim();
|
|
26
|
+
if (SHELL_METACHAR_RE.test(trimmed)) {
|
|
27
|
+
throw new Error(`Target contains forbidden shell metacharacters: ${trimmed}`);
|
|
28
|
+
}
|
|
29
|
+
if (CONTROL_CHAR_RE.test(trimmed)) {
|
|
30
|
+
throw new Error(`Target contains control characters: ${trimmed}`);
|
|
31
|
+
}
|
|
32
|
+
// IPv4 with optional CIDR
|
|
33
|
+
const ipv4Re = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
|
|
34
|
+
// IPv6 (simplified)
|
|
35
|
+
const ipv6Re = /^[0-9a-fA-F:]+(%[a-zA-Z0-9]+)?(\/\d{1,3})?$/;
|
|
36
|
+
// Hostname
|
|
37
|
+
const hostnameRe = /^[a-zA-Z0-9]([a-zA-Z0-9._-]{0,253}[a-zA-Z0-9])?$/;
|
|
38
|
+
if (!ipv4Re.test(trimmed) &&
|
|
39
|
+
!ipv6Re.test(trimmed) &&
|
|
40
|
+
!hostnameRe.test(trimmed)) {
|
|
41
|
+
throw new Error(`Invalid target format: ${trimmed}`);
|
|
42
|
+
}
|
|
43
|
+
// Validate IPv4 octets if it looks like IPv4
|
|
44
|
+
if (ipv4Re.test(trimmed)) {
|
|
45
|
+
const ipPart = trimmed.split("/")[0];
|
|
46
|
+
const octets = ipPart.split(".").map(Number);
|
|
47
|
+
if (octets.some((o) => o < 0 || o > 255)) {
|
|
48
|
+
throw new Error(`Invalid IPv4 address: ${trimmed}`);
|
|
49
|
+
}
|
|
50
|
+
// Validate CIDR prefix
|
|
51
|
+
if (trimmed.includes("/")) {
|
|
52
|
+
const prefix = parseInt(trimmed.split("/")[1], 10);
|
|
53
|
+
if (prefix < 0 || prefix > 32) {
|
|
54
|
+
throw new Error(`Invalid CIDR prefix: ${trimmed}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return trimmed;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Validates a single port number (1-65535).
|
|
62
|
+
* Throws on invalid input.
|
|
63
|
+
*/
|
|
64
|
+
export function validatePort(port) {
|
|
65
|
+
const num = typeof port === "string" ? parseInt(port, 10) : port;
|
|
66
|
+
if (isNaN(num) || num < 1 || num > 65535 || !Number.isInteger(num)) {
|
|
67
|
+
throw new Error(`Invalid port number: ${port}. Must be 1-65535`);
|
|
68
|
+
}
|
|
69
|
+
return num;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validates a port range specification (e.g., "80,443,1-1024").
|
|
73
|
+
* Throws on invalid input.
|
|
74
|
+
*/
|
|
75
|
+
export function validatePortRange(range) {
|
|
76
|
+
if (!range || typeof range !== "string") {
|
|
77
|
+
throw new Error("Port range must be a non-empty string");
|
|
78
|
+
}
|
|
79
|
+
const trimmed = range.trim();
|
|
80
|
+
if (SHELL_METACHAR_RE.test(trimmed)) {
|
|
81
|
+
throw new Error(`Port range contains forbidden shell metacharacters: ${trimmed}`);
|
|
82
|
+
}
|
|
83
|
+
const portRangeRe = /^(\d{1,5}(-\d{1,5})?,)*\d{1,5}(-\d{1,5})?$/;
|
|
84
|
+
if (!portRangeRe.test(trimmed)) {
|
|
85
|
+
throw new Error(`Invalid port range format: ${trimmed}`);
|
|
86
|
+
}
|
|
87
|
+
// Validate individual port numbers and ranges
|
|
88
|
+
const parts = trimmed.split(",");
|
|
89
|
+
for (const part of parts) {
|
|
90
|
+
if (part.includes("-")) {
|
|
91
|
+
const [startStr, endStr] = part.split("-");
|
|
92
|
+
const start = parseInt(startStr, 10);
|
|
93
|
+
const end = parseInt(endStr, 10);
|
|
94
|
+
if (start < 1 || start > 65535 || end < 1 || end > 65535) {
|
|
95
|
+
throw new Error(`Port out of range in: ${part}`);
|
|
96
|
+
}
|
|
97
|
+
if (start > end) {
|
|
98
|
+
throw new Error(`Invalid port range (start > end): ${part}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
const p = parseInt(part, 10);
|
|
103
|
+
if (p < 1 || p > 65535) {
|
|
104
|
+
throw new Error(`Port out of range: ${part}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return trimmed;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Validates a file path is within allowed directories,
|
|
112
|
+
* contains no traversal attacks, no null bytes, no shell metacharacters.
|
|
113
|
+
* Throws on invalid input.
|
|
114
|
+
*/
|
|
115
|
+
export function validateFilePath(filePath, config) {
|
|
116
|
+
if (!filePath || typeof filePath !== "string") {
|
|
117
|
+
throw new Error("File path must be a non-empty string");
|
|
118
|
+
}
|
|
119
|
+
// Check for null bytes
|
|
120
|
+
if (filePath.includes("\0")) {
|
|
121
|
+
throw new Error("File path contains null bytes");
|
|
122
|
+
}
|
|
123
|
+
// Check for path traversal
|
|
124
|
+
if (PATH_TRAVERSAL_RE.test(filePath)) {
|
|
125
|
+
throw new Error("Path contains forbidden directory traversal (..)");
|
|
126
|
+
}
|
|
127
|
+
// Check for shell metacharacters
|
|
128
|
+
if (SHELL_METACHAR_RE.test(filePath)) {
|
|
129
|
+
throw new Error(`File path contains forbidden shell metacharacters: ${filePath}`);
|
|
130
|
+
}
|
|
131
|
+
// Check for control characters
|
|
132
|
+
if (CONTROL_CHAR_RE.test(filePath)) {
|
|
133
|
+
throw new Error(`File path contains control characters: ${filePath}`);
|
|
134
|
+
}
|
|
135
|
+
const normalized = normalize(resolve(filePath));
|
|
136
|
+
const cfg = config ?? getConfig();
|
|
137
|
+
// Check path traversal - ensure resolved path doesn't escape allowed dirs
|
|
138
|
+
const isAllowed = cfg.allowedDirs.some((dir) => {
|
|
139
|
+
const normalizedDir = normalize(resolve(dir));
|
|
140
|
+
return (normalized === normalizedDir || normalized.startsWith(normalizedDir + "/"));
|
|
141
|
+
});
|
|
142
|
+
if (!isAllowed) {
|
|
143
|
+
throw new Error(`File path is not within allowed directories: ${filePath} (allowed: ${cfg.allowedDirs.join(", ")})`);
|
|
144
|
+
}
|
|
145
|
+
// Check against protected paths
|
|
146
|
+
const isProtected = cfg.protectedPaths.some((protectedPath) => {
|
|
147
|
+
const normalizedProtected = normalize(resolve(protectedPath));
|
|
148
|
+
return (normalized === normalizedProtected ||
|
|
149
|
+
normalized.startsWith(normalizedProtected + "/"));
|
|
150
|
+
});
|
|
151
|
+
if (isProtected) {
|
|
152
|
+
throw new Error(`File path is in a protected location: ${filePath}`);
|
|
153
|
+
}
|
|
154
|
+
return normalized;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Validates an array of arguments for shell metacharacters.
|
|
158
|
+
* Throws on invalid input.
|
|
159
|
+
*/
|
|
160
|
+
export function sanitizeArgs(args) {
|
|
161
|
+
if (!Array.isArray(args)) {
|
|
162
|
+
throw new Error("Arguments must be an array of strings");
|
|
163
|
+
}
|
|
164
|
+
for (let i = 0; i < args.length; i++) {
|
|
165
|
+
const arg = args[i];
|
|
166
|
+
if (typeof arg !== "string") {
|
|
167
|
+
throw new Error(`Argument at index ${i} is not a string`);
|
|
168
|
+
}
|
|
169
|
+
if (SHELL_METACHAR_RE.test(arg)) {
|
|
170
|
+
throw new Error(`Argument at index ${i} contains forbidden shell metacharacters: ${arg}`);
|
|
171
|
+
}
|
|
172
|
+
if (CONTROL_CHAR_RE.test(arg)) {
|
|
173
|
+
throw new Error(`Argument at index ${i} contains control characters: ${arg}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return args;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Validates a systemd service name.
|
|
180
|
+
* Only allows `[a-zA-Z0-9._@-]+`.
|
|
181
|
+
* Throws on invalid input.
|
|
182
|
+
*/
|
|
183
|
+
export function validateServiceName(name) {
|
|
184
|
+
if (!name || typeof name !== "string") {
|
|
185
|
+
throw new Error("Service name must be a non-empty string");
|
|
186
|
+
}
|
|
187
|
+
const trimmed = name.trim();
|
|
188
|
+
const serviceRe = /^[a-zA-Z0-9._@-]+$/;
|
|
189
|
+
if (!serviceRe.test(trimmed)) {
|
|
190
|
+
throw new Error(`Invalid service name: ${trimmed}. Only [a-zA-Z0-9._@-] allowed`);
|
|
191
|
+
}
|
|
192
|
+
return trimmed;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Validates a sysctl key (must be word.word.word... pattern).
|
|
196
|
+
* Throws on invalid input.
|
|
197
|
+
*/
|
|
198
|
+
export function validateSysctlKey(key) {
|
|
199
|
+
if (!key || typeof key !== "string") {
|
|
200
|
+
throw new Error("Sysctl key must be a non-empty string");
|
|
201
|
+
}
|
|
202
|
+
const trimmed = key.trim();
|
|
203
|
+
const sysctlRe = /^[a-zA-Z0-9_]+(\.[a-zA-Z0-9_]+)+$/;
|
|
204
|
+
if (!sysctlRe.test(trimmed)) {
|
|
205
|
+
throw new Error(`Invalid sysctl key: ${trimmed}. Must match word.word.word pattern`);
|
|
206
|
+
}
|
|
207
|
+
return trimmed;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Validates a configuration key.
|
|
211
|
+
* Only allows `[a-zA-Z0-9._-]+`.
|
|
212
|
+
* Throws on invalid input.
|
|
213
|
+
*/
|
|
214
|
+
export function validateConfigKey(key) {
|
|
215
|
+
if (!key || typeof key !== "string") {
|
|
216
|
+
throw new Error("Config key must be a non-empty string");
|
|
217
|
+
}
|
|
218
|
+
const trimmed = key.trim();
|
|
219
|
+
const configRe = /^[a-zA-Z0-9._-]+$/;
|
|
220
|
+
if (!configRe.test(trimmed)) {
|
|
221
|
+
throw new Error(`Invalid config key: ${trimmed}. Only [a-zA-Z0-9._-] allowed`);
|
|
222
|
+
}
|
|
223
|
+
return trimmed;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Validates a package name.
|
|
227
|
+
* Only allows `[a-zA-Z0-9._+:-]+`.
|
|
228
|
+
* Throws on invalid input.
|
|
229
|
+
*/
|
|
230
|
+
export function validatePackageName(name) {
|
|
231
|
+
if (!name || typeof name !== "string") {
|
|
232
|
+
throw new Error("Package name must be a non-empty string");
|
|
233
|
+
}
|
|
234
|
+
const trimmed = name.trim();
|
|
235
|
+
const pkgRe = /^[a-zA-Z0-9._+:-]+$/;
|
|
236
|
+
if (!pkgRe.test(trimmed)) {
|
|
237
|
+
throw new Error(`Invalid package name: ${trimmed}. Only [a-zA-Z0-9._+:-] allowed`);
|
|
238
|
+
}
|
|
239
|
+
return trimmed;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Validates an iptables chain name.
|
|
243
|
+
* Allows built-in chains `[A-Z_]+` (e.g., INPUT, OUTPUT, FORWARD)
|
|
244
|
+
* and custom chains matching `[A-Za-z_][A-Za-z0-9_-]{0,28}`.
|
|
245
|
+
* Throws on invalid input.
|
|
246
|
+
*/
|
|
247
|
+
export function validateIptablesChain(chain) {
|
|
248
|
+
if (!chain || typeof chain !== "string") {
|
|
249
|
+
throw new Error("Iptables chain must be a non-empty string");
|
|
250
|
+
}
|
|
251
|
+
const trimmed = chain.trim();
|
|
252
|
+
// Allow both built-in (e.g. INPUT) and custom chains (e.g. syn_flood)
|
|
253
|
+
const chainRe = /^[A-Za-z_][A-Za-z0-9_-]{0,28}$/;
|
|
254
|
+
if (!chainRe.test(trimmed)) {
|
|
255
|
+
throw new Error(`Invalid iptables chain: ${trimmed}. Must match [A-Za-z_][A-Za-z0-9_-]{0,28}`);
|
|
256
|
+
}
|
|
257
|
+
return trimmed;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Validates a network interface name.
|
|
261
|
+
* Only allows `[a-zA-Z0-9._-]+`, max 16 characters.
|
|
262
|
+
* Throws on invalid input.
|
|
263
|
+
*/
|
|
264
|
+
export function validateInterface(iface) {
|
|
265
|
+
if (!iface || typeof iface !== "string") {
|
|
266
|
+
throw new Error("Interface name must be a non-empty string");
|
|
267
|
+
}
|
|
268
|
+
const trimmed = iface.trim();
|
|
269
|
+
if (trimmed.length > 16) {
|
|
270
|
+
throw new Error(`Interface name too long: ${trimmed}. Maximum 16 characters`);
|
|
271
|
+
}
|
|
272
|
+
const ifaceRe = /^[a-zA-Z0-9._-]+$/;
|
|
273
|
+
if (!ifaceRe.test(trimmed)) {
|
|
274
|
+
throw new Error(`Invalid interface name: ${trimmed}. Only [a-zA-Z0-9._-] allowed`);
|
|
275
|
+
}
|
|
276
|
+
return trimmed;
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Validates a Unix username.
|
|
280
|
+
* Only allows `[a-zA-Z0-9._-]+`, max 32 characters.
|
|
281
|
+
* Throws on invalid input.
|
|
282
|
+
*/
|
|
283
|
+
export function validateUsername(name) {
|
|
284
|
+
if (!name || typeof name !== "string") {
|
|
285
|
+
throw new Error("Username must be a non-empty string");
|
|
286
|
+
}
|
|
287
|
+
const trimmed = name.trim();
|
|
288
|
+
if (trimmed.length > 32) {
|
|
289
|
+
throw new Error(`Username too long: ${trimmed}. Maximum 32 characters`);
|
|
290
|
+
}
|
|
291
|
+
const usernameRe = /^[a-zA-Z0-9._-]+$/;
|
|
292
|
+
if (!usernameRe.test(trimmed)) {
|
|
293
|
+
throw new Error(`Invalid username: ${trimmed}. Only [a-zA-Z0-9._-] allowed`);
|
|
294
|
+
}
|
|
295
|
+
return trimmed;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Validates a YARA rule file path (must end in .yar or .yara).
|
|
299
|
+
* Throws on invalid input.
|
|
300
|
+
*/
|
|
301
|
+
export function validateYaraRule(path) {
|
|
302
|
+
if (!path || typeof path !== "string") {
|
|
303
|
+
throw new Error("YARA rule path must be a non-empty string");
|
|
304
|
+
}
|
|
305
|
+
const trimmed = path.trim();
|
|
306
|
+
if (PATH_TRAVERSAL_RE.test(trimmed)) {
|
|
307
|
+
throw new Error("Path contains forbidden directory traversal (..)");
|
|
308
|
+
}
|
|
309
|
+
if (SHELL_METACHAR_RE.test(trimmed)) {
|
|
310
|
+
throw new Error(`YARA rule path contains forbidden shell metacharacters: ${trimmed}`);
|
|
311
|
+
}
|
|
312
|
+
if (CONTROL_CHAR_RE.test(trimmed)) {
|
|
313
|
+
throw new Error(`YARA rule path contains control characters: ${trimmed}`);
|
|
314
|
+
}
|
|
315
|
+
if (!trimmed.endsWith(".yar") && !trimmed.endsWith(".yara")) {
|
|
316
|
+
throw new Error(`Invalid YARA rule file: ${trimmed}. Must end in .yar or .yara`);
|
|
317
|
+
}
|
|
318
|
+
return trimmed;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Validates a certificate file path (must end in .pem, .crt, .key, .p12, or .pfx).
|
|
322
|
+
* Throws on invalid input.
|
|
323
|
+
*/
|
|
324
|
+
export function validateCertPath(path) {
|
|
325
|
+
if (!path || typeof path !== "string") {
|
|
326
|
+
throw new Error("Certificate path must be a non-empty string");
|
|
327
|
+
}
|
|
328
|
+
const trimmed = path.trim();
|
|
329
|
+
if (PATH_TRAVERSAL_RE.test(trimmed)) {
|
|
330
|
+
throw new Error("Path contains forbidden directory traversal (..)");
|
|
331
|
+
}
|
|
332
|
+
if (SHELL_METACHAR_RE.test(trimmed)) {
|
|
333
|
+
throw new Error(`Certificate path contains forbidden shell metacharacters: ${trimmed}`);
|
|
334
|
+
}
|
|
335
|
+
if (CONTROL_CHAR_RE.test(trimmed)) {
|
|
336
|
+
throw new Error(`Certificate path contains control characters: ${trimmed}`);
|
|
337
|
+
}
|
|
338
|
+
const validExtensions = [".pem", ".crt", ".key", ".p12", ".pfx"];
|
|
339
|
+
const hasValidExt = validExtensions.some((ext) => trimmed.toLowerCase().endsWith(ext));
|
|
340
|
+
if (!hasValidExt) {
|
|
341
|
+
throw new Error(`Invalid certificate file: ${trimmed}. Must end in ${validExtensions.join(", ")}`);
|
|
342
|
+
}
|
|
343
|
+
return trimmed;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Validates a firewalld zone name.
|
|
347
|
+
* Only allows `[a-zA-Z0-9_-]+`.
|
|
348
|
+
* Throws on invalid input.
|
|
349
|
+
*/
|
|
350
|
+
export function validateFirewallZone(zone) {
|
|
351
|
+
if (!zone || typeof zone !== "string") {
|
|
352
|
+
throw new Error("Firewall zone must be a non-empty string");
|
|
353
|
+
}
|
|
354
|
+
const trimmed = zone.trim();
|
|
355
|
+
const zoneRe = /^[a-zA-Z0-9_-]+$/;
|
|
356
|
+
if (!zoneRe.test(trimmed)) {
|
|
357
|
+
throw new Error(`Invalid firewall zone: ${trimmed}. Only [a-zA-Z0-9_-] allowed`);
|
|
358
|
+
}
|
|
359
|
+
return trimmed;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Validates an auditd key name.
|
|
363
|
+
* Only allows `[a-zA-Z0-9_-]+`.
|
|
364
|
+
* Throws on invalid input.
|
|
365
|
+
*/
|
|
366
|
+
export function validateAuditdKey(key) {
|
|
367
|
+
if (!key || typeof key !== "string") {
|
|
368
|
+
throw new Error("Auditd key must be a non-empty string");
|
|
369
|
+
}
|
|
370
|
+
const trimmed = key.trim();
|
|
371
|
+
const keyRe = /^[a-zA-Z0-9_-]+$/;
|
|
372
|
+
if (!keyRe.test(trimmed)) {
|
|
373
|
+
throw new Error(`Invalid auditd key: ${trimmed}. Only [a-zA-Z0-9_-] allowed`);
|
|
374
|
+
}
|
|
375
|
+
return trimmed;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Validates a tool-supplied file path against traversal attacks and an explicit
|
|
379
|
+
* list of allowed root directories.
|
|
380
|
+
*
|
|
381
|
+
* 1. Rejects paths containing `..`
|
|
382
|
+
* 2. Uses `path.resolve()` to normalize
|
|
383
|
+
* 3. Verifies resolved path is within one of the allowed directories
|
|
384
|
+
*
|
|
385
|
+
* @param inputPath The user-supplied path
|
|
386
|
+
* @param allowedDirs Array of allowed root directories (e.g. ["/var/log", "/etc"])
|
|
387
|
+
* @param label Human-readable label for error messages (default: "Path")
|
|
388
|
+
* @returns The resolved, validated path
|
|
389
|
+
*/
|
|
390
|
+
export function validateToolPath(inputPath, allowedDirs, label = "Path") {
|
|
391
|
+
if (!inputPath || typeof inputPath !== "string") {
|
|
392
|
+
throw new Error(`${label} must be a non-empty string`);
|
|
393
|
+
}
|
|
394
|
+
if (inputPath.includes("\0")) {
|
|
395
|
+
throw new Error(`${label} contains null bytes`);
|
|
396
|
+
}
|
|
397
|
+
// Defense-in-depth: reject any path containing `..` sequences
|
|
398
|
+
if (PATH_TRAVERSAL_RE.test(inputPath)) {
|
|
399
|
+
throw new Error(`${label} contains forbidden directory traversal (..): '${inputPath}'`);
|
|
400
|
+
}
|
|
401
|
+
if (SHELL_METACHAR_RE.test(inputPath)) {
|
|
402
|
+
throw new Error(`${label} contains forbidden shell metacharacters: '${inputPath}'`);
|
|
403
|
+
}
|
|
404
|
+
if (CONTROL_CHAR_RE.test(inputPath)) {
|
|
405
|
+
throw new Error(`${label} contains control characters: '${inputPath}'`);
|
|
406
|
+
}
|
|
407
|
+
const resolved = normalize(resolve(inputPath));
|
|
408
|
+
const isAllowed = allowedDirs.some((dir) => {
|
|
409
|
+
const normalizedDir = normalize(resolve(dir));
|
|
410
|
+
return (resolved === normalizedDir || resolved.startsWith(normalizedDir + "/"));
|
|
411
|
+
});
|
|
412
|
+
if (!isAllowed) {
|
|
413
|
+
throw new Error(`${label} '${resolved}' is not within allowed directories: ${allowedDirs.join(", ")}`);
|
|
414
|
+
}
|
|
415
|
+
return resolved;
|
|
416
|
+
}
|
|
417
|
+
// ── Error Sanitization ───────────────────────────────────────────────────────
|
|
418
|
+
/**
|
|
419
|
+
* SECURITY (TOOL-029): Regex to match absolute paths in error messages.
|
|
420
|
+
* Matches paths like /home/user/... or /var/lib/...
|
|
421
|
+
*/
|
|
422
|
+
const ABS_PATH_RE = /\/(?:home|root|tmp|var|etc|usr|opt|srv|run|mnt|media)\/\S*/gi;
|
|
423
|
+
/**
|
|
424
|
+
* SECURITY (TOOL-029): Regex to match stack traces in error messages.
|
|
425
|
+
* Matches lines starting with " at " (Node.js stack trace format).
|
|
426
|
+
*/
|
|
427
|
+
const STACK_TRACE_RE = /\n\s+at .+/g;
|
|
428
|
+
/**
|
|
429
|
+
* Sanitize error messages before returning them to MCP clients.
|
|
430
|
+
*
|
|
431
|
+
* Strips:
|
|
432
|
+
* 1. Absolute file paths (replaced with `[path]`)
|
|
433
|
+
* 2. Stack traces (removed entirely)
|
|
434
|
+
* 3. Overly long messages (truncated to 500 chars)
|
|
435
|
+
*
|
|
436
|
+
* @param error - The caught error (unknown type)
|
|
437
|
+
* @returns A sanitized error message string safe for external exposure
|
|
438
|
+
*/
|
|
439
|
+
export function sanitizeToolError(error) {
|
|
440
|
+
let message;
|
|
441
|
+
if (error instanceof Error) {
|
|
442
|
+
message = error.message;
|
|
443
|
+
}
|
|
444
|
+
else if (typeof error === "string") {
|
|
445
|
+
message = error;
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
message = String(error);
|
|
449
|
+
}
|
|
450
|
+
// Strip stack traces
|
|
451
|
+
message = message.replace(STACK_TRACE_RE, "");
|
|
452
|
+
// Strip absolute paths
|
|
453
|
+
message = message.replace(ABS_PATH_RE, "[path]");
|
|
454
|
+
// Truncate overly long messages
|
|
455
|
+
if (message.length > 500) {
|
|
456
|
+
message = message.substring(0, 497) + "...";
|
|
457
|
+
}
|
|
458
|
+
return message;
|
|
459
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure filesystem utilities for the defense-mcp-server.
|
|
3
|
+
* All state files (changelog, rollback, backups) must use these helpers
|
|
4
|
+
* to ensure restrictive permissions (owner-only read/write).
|
|
5
|
+
*/
|
|
6
|
+
/** Options for secureWriteFileSync. */
|
|
7
|
+
export interface SecureWriteOptions {
|
|
8
|
+
/** Character encoding for string data. Defaults to `"utf-8"`. */
|
|
9
|
+
encoding?: BufferEncoding;
|
|
10
|
+
/** Use atomic write (write to temp file, then rename). Defaults to `false`. */
|
|
11
|
+
atomic?: boolean;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Write a file with owner-only permissions (0o600).
|
|
15
|
+
* Creates parent directories with 0o700 if they don't exist.
|
|
16
|
+
*
|
|
17
|
+
* @param filePath - Destination file path
|
|
18
|
+
* @param data - Content to write
|
|
19
|
+
* @param encodingOrOptions - Either a BufferEncoding string (legacy) or a SecureWriteOptions object
|
|
20
|
+
*/
|
|
21
|
+
export declare function secureWriteFileSync(filePath: string, data: string | Buffer, encodingOrOptions?: BufferEncoding | SecureWriteOptions): void;
|
|
22
|
+
/**
|
|
23
|
+
* Create a directory with owner-only permissions (0o700).
|
|
24
|
+
*/
|
|
25
|
+
export declare function secureMkdirSync(dirPath: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* Copy a file and set owner-only permissions on the destination (0o600).
|
|
28
|
+
*/
|
|
29
|
+
export declare function secureCopyFileSync(src: string, dest: string): void;
|
|
30
|
+
/**
|
|
31
|
+
* Verify that a state file has secure permissions.
|
|
32
|
+
* Returns true if the file is owner-only (no group/other read/write/execute).
|
|
33
|
+
* Returns false if permissions are too open or file doesn't exist.
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifySecurePermissions(filePath: string): boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Fix permissions on an existing file to be owner-only.
|
|
38
|
+
*/
|
|
39
|
+
export declare function hardenFilePermissions(filePath: string): void;
|
|
40
|
+
/**
|
|
41
|
+
* Fix permissions on an existing directory to be owner-only.
|
|
42
|
+
*/
|
|
43
|
+
export declare function hardenDirPermissions(dirPath: string): void;
|
|
44
|
+
/** Options for atomicWriteFileSync. */
|
|
45
|
+
export interface AtomicWriteOptions {
|
|
46
|
+
/** File permissions mode. Defaults to `0o600`. */
|
|
47
|
+
mode?: number;
|
|
48
|
+
/** Character encoding for string data. Defaults to `"utf-8"`. */
|
|
49
|
+
encoding?: BufferEncoding;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Write a file atomically using a write-to-temp-then-rename strategy.
|
|
53
|
+
*
|
|
54
|
+
* Steps:
|
|
55
|
+
* 1. Write data to a temporary file in the same directory (`.tmp` suffix)
|
|
56
|
+
* 2. Set file permissions on the temp file
|
|
57
|
+
* 3. Rename (atomic on POSIX) from temp to target path
|
|
58
|
+
* 4. If rename fails, clean up the temp file
|
|
59
|
+
*
|
|
60
|
+
* This prevents file corruption from interrupted writes (crash, signal, etc.).
|
|
61
|
+
*
|
|
62
|
+
* @param filePath - Destination file path
|
|
63
|
+
* @param data - Content to write (string or Buffer)
|
|
64
|
+
* @param options - Write options (mode, encoding)
|
|
65
|
+
*/
|
|
66
|
+
export declare function atomicWriteFileSync(filePath: string, data: string | Buffer, options?: AtomicWriteOptions): void;
|
|
67
|
+
//# sourceMappingURL=secure-fs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"secure-fs.d.ts","sourceRoot":"","sources":["../../src/core/secure-fs.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAYH,uCAAuC;AACvC,MAAM,WAAW,kBAAkB;IAC/B,iEAAiE;IACjE,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,+EAA+E;IAC/E,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GAAG,MAAM,EACrB,iBAAiB,CAAC,EAAE,cAAc,GAAG,kBAAkB,GACxD,IAAI,CA0BN;AAED;;GAEG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAMrD;AAED;;GAEG;AACH,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAOlE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMjE;AAED;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI,CAI5D;AAED;;GAEG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,CAI1D;AAID,uCAAuC;AACvC,MAAM,WAAW,kBAAkB;IAC/B,kDAAkD;IAClD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,iEAAiE;IACjE,QAAQ,CAAC,EAAE,cAAc,CAAC;CAC7B;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,mBAAmB,CAC/B,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,MAAM,GAAG,MAAM,EACrB,OAAO,CAAC,EAAE,kBAAkB,GAC7B,IAAI,CAkCN"}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Secure filesystem utilities for the defense-mcp-server.
|
|
3
|
+
* All state files (changelog, rollback, backups) must use these helpers
|
|
4
|
+
* to ensure restrictive permissions (owner-only read/write).
|
|
5
|
+
*/
|
|
6
|
+
import { writeFileSync, mkdirSync, copyFileSync, chmodSync, existsSync, statSync, renameSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
/** File permission: owner read/write only (0o600) */
|
|
10
|
+
const SECURE_FILE_MODE = 0o600;
|
|
11
|
+
/** Directory permission: owner read/write/execute only (0o700) */
|
|
12
|
+
const SECURE_DIR_MODE = 0o700;
|
|
13
|
+
/**
|
|
14
|
+
* Write a file with owner-only permissions (0o600).
|
|
15
|
+
* Creates parent directories with 0o700 if they don't exist.
|
|
16
|
+
*
|
|
17
|
+
* @param filePath - Destination file path
|
|
18
|
+
* @param data - Content to write
|
|
19
|
+
* @param encodingOrOptions - Either a BufferEncoding string (legacy) or a SecureWriteOptions object
|
|
20
|
+
*/
|
|
21
|
+
export function secureWriteFileSync(filePath, data, encodingOrOptions) {
|
|
22
|
+
// Normalize options
|
|
23
|
+
let encoding = "utf-8";
|
|
24
|
+
let atomic = false;
|
|
25
|
+
if (typeof encodingOrOptions === "string") {
|
|
26
|
+
encoding = encodingOrOptions;
|
|
27
|
+
}
|
|
28
|
+
else if (encodingOrOptions !== undefined) {
|
|
29
|
+
encoding = encodingOrOptions.encoding ?? "utf-8";
|
|
30
|
+
atomic = encodingOrOptions.atomic ?? false;
|
|
31
|
+
}
|
|
32
|
+
// Ensure parent directory exists with secure permissions
|
|
33
|
+
const dir = dirname(filePath);
|
|
34
|
+
if (!existsSync(dir)) {
|
|
35
|
+
mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
|
|
36
|
+
}
|
|
37
|
+
if (atomic) {
|
|
38
|
+
atomicWriteFileSync(filePath, data, { mode: SECURE_FILE_MODE, encoding });
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
// Write the file
|
|
42
|
+
writeFileSync(filePath, data, { encoding, mode: SECURE_FILE_MODE });
|
|
43
|
+
// Explicitly chmod in case umask interfered
|
|
44
|
+
chmodSync(filePath, SECURE_FILE_MODE);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Create a directory with owner-only permissions (0o700).
|
|
49
|
+
*/
|
|
50
|
+
export function secureMkdirSync(dirPath) {
|
|
51
|
+
if (!existsSync(dirPath)) {
|
|
52
|
+
mkdirSync(dirPath, { recursive: true, mode: SECURE_DIR_MODE });
|
|
53
|
+
}
|
|
54
|
+
// Explicitly chmod in case umask interfered
|
|
55
|
+
chmodSync(dirPath, SECURE_DIR_MODE);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Copy a file and set owner-only permissions on the destination (0o600).
|
|
59
|
+
*/
|
|
60
|
+
export function secureCopyFileSync(src, dest) {
|
|
61
|
+
const dir = dirname(dest);
|
|
62
|
+
if (!existsSync(dir)) {
|
|
63
|
+
mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
|
|
64
|
+
}
|
|
65
|
+
copyFileSync(src, dest);
|
|
66
|
+
chmodSync(dest, SECURE_FILE_MODE);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Verify that a state file has secure permissions.
|
|
70
|
+
* Returns true if the file is owner-only (no group/other read/write/execute).
|
|
71
|
+
* Returns false if permissions are too open or file doesn't exist.
|
|
72
|
+
*/
|
|
73
|
+
export function verifySecurePermissions(filePath) {
|
|
74
|
+
if (!existsSync(filePath))
|
|
75
|
+
return false;
|
|
76
|
+
const stats = statSync(filePath);
|
|
77
|
+
// Check that group and other have no permissions
|
|
78
|
+
// mode & 0o077 should be 0 (no group/other bits set)
|
|
79
|
+
return (stats.mode & 0o077) === 0;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Fix permissions on an existing file to be owner-only.
|
|
83
|
+
*/
|
|
84
|
+
export function hardenFilePermissions(filePath) {
|
|
85
|
+
if (existsSync(filePath)) {
|
|
86
|
+
chmodSync(filePath, SECURE_FILE_MODE);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Fix permissions on an existing directory to be owner-only.
|
|
91
|
+
*/
|
|
92
|
+
export function hardenDirPermissions(dirPath) {
|
|
93
|
+
if (existsSync(dirPath)) {
|
|
94
|
+
chmodSync(dirPath, SECURE_DIR_MODE);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Write a file atomically using a write-to-temp-then-rename strategy.
|
|
99
|
+
*
|
|
100
|
+
* Steps:
|
|
101
|
+
* 1. Write data to a temporary file in the same directory (`.tmp` suffix)
|
|
102
|
+
* 2. Set file permissions on the temp file
|
|
103
|
+
* 3. Rename (atomic on POSIX) from temp to target path
|
|
104
|
+
* 4. If rename fails, clean up the temp file
|
|
105
|
+
*
|
|
106
|
+
* This prevents file corruption from interrupted writes (crash, signal, etc.).
|
|
107
|
+
*
|
|
108
|
+
* @param filePath - Destination file path
|
|
109
|
+
* @param data - Content to write (string or Buffer)
|
|
110
|
+
* @param options - Write options (mode, encoding)
|
|
111
|
+
*/
|
|
112
|
+
export function atomicWriteFileSync(filePath, data, options) {
|
|
113
|
+
const mode = options?.mode ?? SECURE_FILE_MODE;
|
|
114
|
+
const encoding = options?.encoding ?? "utf-8";
|
|
115
|
+
// Ensure parent directory exists with secure permissions
|
|
116
|
+
const dir = dirname(filePath);
|
|
117
|
+
if (!existsSync(dir)) {
|
|
118
|
+
mkdirSync(dir, { recursive: true, mode: SECURE_DIR_MODE });
|
|
119
|
+
}
|
|
120
|
+
// Generate a unique temp file path in the same directory
|
|
121
|
+
const tmpSuffix = `.tmp.${randomBytes(6).toString("hex")}`;
|
|
122
|
+
const tmpPath = join(dir, `${filePath.split("/").pop()}${tmpSuffix}`);
|
|
123
|
+
try {
|
|
124
|
+
// Step 1: Write to temp file
|
|
125
|
+
writeFileSync(tmpPath, data, { encoding, mode });
|
|
126
|
+
// Step 2: Explicitly set permissions (in case umask interfered)
|
|
127
|
+
chmodSync(tmpPath, mode);
|
|
128
|
+
// Step 3: Atomic rename
|
|
129
|
+
renameSync(tmpPath, filePath);
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
// Step 4: Clean up temp file on failure
|
|
133
|
+
try {
|
|
134
|
+
if (existsSync(tmpPath)) {
|
|
135
|
+
unlinkSync(tmpPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Best-effort cleanup — ignore errors
|
|
140
|
+
}
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|