@clawops/cli 1.6.0 → 1.7.1
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/README.md +63 -0
- package/dist/{apply-SI3JKS4Z.js → apply-BTZFFGAD.js} +2 -2
- package/dist/{chunk-C5NDLOK2.js → chunk-CG3Y7Y2R.js} +39 -70
- package/dist/{chunk-72FF7IK2.js → chunk-FQ4JXUCR.js} +2 -2
- package/dist/{chunk-QHGFENYR.js → chunk-ISFHLA4G.js} +3 -3
- package/dist/chunk-Q7NQY5HV.js +37 -0
- package/dist/cli.js +273 -37
- package/dist/{context-KPKBOWOP.js → context-ALSJMTHE.js} +1 -1
- package/dist/{generate-YV7226Q7.js → generate-SPT7PJKR.js} +2 -2
- package/dist/harden-BDVYW567.js +748 -0
- package/dist/human-Z44CW7TR.js +21 -0
- package/dist/{package-EW3FS257.js → package-OMPI6RMV.js} +5 -1
- package/dist/{server-3DEZHUWB.js → server-RGLKKCCU.js} +7 -6
- package/package.json +5 -1
- /package/dist/{chunk-QI75OVJK.js → chunk-3MFZ7E74.js} +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
acquireSession,
|
|
4
|
+
drainPool
|
|
5
|
+
} from "./chunk-LCKD7L7X.js";
|
|
6
|
+
import "./chunk-GJEF6UQA.js";
|
|
7
|
+
import "./chunk-KGXPLI7W.js";
|
|
8
|
+
|
|
9
|
+
// src/harden/types.ts
|
|
10
|
+
var SENTINEL_DIR = "/etc/clawops/hardening";
|
|
11
|
+
|
|
12
|
+
// src/harden/runner.ts
|
|
13
|
+
async function runHardening(conn, opts) {
|
|
14
|
+
const { session, release } = await acquireSession({
|
|
15
|
+
host: conn.host,
|
|
16
|
+
port: conn.port,
|
|
17
|
+
user: conn.user,
|
|
18
|
+
privateKeyPath: conn.privateKeyPath,
|
|
19
|
+
knownHostsPath: conn.knownHostsPath,
|
|
20
|
+
signal: opts.signal
|
|
21
|
+
});
|
|
22
|
+
const exec = (command, execOpts) => session.exec(command, execOpts?.signal ?? opts.signal);
|
|
23
|
+
const results = [];
|
|
24
|
+
try {
|
|
25
|
+
for (const mod of opts.modules) {
|
|
26
|
+
if (opts.signal?.aborted) break;
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
let checkResult;
|
|
29
|
+
let applyResult;
|
|
30
|
+
let error;
|
|
31
|
+
try {
|
|
32
|
+
checkResult = await mod.check(exec);
|
|
33
|
+
if (!opts.dryRun && checkResult.status !== "applied") {
|
|
34
|
+
applyResult = await mod.apply(exec);
|
|
35
|
+
}
|
|
36
|
+
} catch (err) {
|
|
37
|
+
error = err instanceof Error ? err.message : String(err);
|
|
38
|
+
checkResult ??= { status: "drifted", detail: error };
|
|
39
|
+
}
|
|
40
|
+
const result = {
|
|
41
|
+
module: mod,
|
|
42
|
+
checkResult,
|
|
43
|
+
applyResult,
|
|
44
|
+
durationMs: Date.now() - start,
|
|
45
|
+
error
|
|
46
|
+
};
|
|
47
|
+
results.push(result);
|
|
48
|
+
opts.onProgress?.(result);
|
|
49
|
+
}
|
|
50
|
+
} finally {
|
|
51
|
+
release();
|
|
52
|
+
drainPool();
|
|
53
|
+
}
|
|
54
|
+
return results;
|
|
55
|
+
}
|
|
56
|
+
async function withRemoteExec(conn, signal, fn) {
|
|
57
|
+
const { session, release } = await acquireSession({
|
|
58
|
+
host: conn.host,
|
|
59
|
+
port: conn.port,
|
|
60
|
+
user: conn.user,
|
|
61
|
+
privateKeyPath: conn.privateKeyPath,
|
|
62
|
+
knownHostsPath: conn.knownHostsPath,
|
|
63
|
+
signal
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
return await fn((cmd, opts) => session.exec(cmd, opts?.signal ?? signal));
|
|
67
|
+
} finally {
|
|
68
|
+
release();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function formatHardenSummary(results) {
|
|
72
|
+
const rows = results.map((r) => {
|
|
73
|
+
const icon = r.error ? "\u2717" : r.applyResult?.changed ? "\u2713" : r.checkResult.status === "applied" ? "\xB7" : "\u26A0";
|
|
74
|
+
const action = r.error ? "error" : r.applyResult?.changed ? "applied" : r.checkResult.status === "applied" ? "already ok" : "skipped (dry-run)";
|
|
75
|
+
const detail = r.error ?? r.applyResult?.detail ?? r.checkResult.detail;
|
|
76
|
+
return ` ${icon} ${r.module.label.padEnd(30)} ${action.padEnd(16)} ${detail}`;
|
|
77
|
+
});
|
|
78
|
+
const changed = results.filter((r) => r.applyResult?.changed).length;
|
|
79
|
+
const errors = results.filter((r) => r.error).length;
|
|
80
|
+
const already = results.filter(
|
|
81
|
+
(r) => !r.error && !r.applyResult?.changed && r.checkResult.status === "applied"
|
|
82
|
+
).length;
|
|
83
|
+
const summary = `
|
|
84
|
+
${changed} applied ${already} already ok ${errors} errors`;
|
|
85
|
+
return rows.join("\n") + "\n" + summary + "\n";
|
|
86
|
+
}
|
|
87
|
+
function resolveModules(catalog, options, provider) {
|
|
88
|
+
const providerFiltered = catalog.filter(
|
|
89
|
+
(m) => m.providers === "all" || m.providers.includes(provider)
|
|
90
|
+
);
|
|
91
|
+
if (!options?.trim()) {
|
|
92
|
+
return providerFiltered.filter((m) => m.defaultOn);
|
|
93
|
+
}
|
|
94
|
+
const ids = new Set(options.split(",").map((s) => s.trim()).filter(Boolean));
|
|
95
|
+
return providerFiltered.filter((m) => ids.has(m.id));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// src/harden/modules/ssh.ts
|
|
99
|
+
var SENTINEL = `${SENTINEL_DIR}/ssh.applied`;
|
|
100
|
+
var SSHD_SETTINGS = [
|
|
101
|
+
"PermitRootLogin no",
|
|
102
|
+
"PasswordAuthentication no",
|
|
103
|
+
"MaxAuthTries 3",
|
|
104
|
+
"LoginGraceTime 30"
|
|
105
|
+
];
|
|
106
|
+
var sshModule = {
|
|
107
|
+
id: "ssh",
|
|
108
|
+
label: "SSH hardening",
|
|
109
|
+
defaultOn: true,
|
|
110
|
+
providers: "all",
|
|
111
|
+
async check(exec) {
|
|
112
|
+
const { stdout, code } = await exec(`test -f ${SENTINEL} && echo yes || echo no`);
|
|
113
|
+
if (code === 0 && stdout.trim() === "yes") {
|
|
114
|
+
return { status: "applied", detail: "sshd_config hardened (sentinel present)" };
|
|
115
|
+
}
|
|
116
|
+
const { stdout: cfg } = await exec(
|
|
117
|
+
`sshd -T 2>/dev/null | grep -E '^(permitrootlogin|passwordauthentication|maxauthtries|logingracetime)' || true`
|
|
118
|
+
);
|
|
119
|
+
const lines = cfg.toLowerCase();
|
|
120
|
+
const allApplied = lines.includes("permitrootlogin no") && lines.includes("passwordauthentication no") && lines.includes("maxauthtries 3") && lines.includes("logingracetime 30");
|
|
121
|
+
if (allApplied) {
|
|
122
|
+
return { status: "applied", detail: "sshd settings already hardened" };
|
|
123
|
+
}
|
|
124
|
+
return { status: "missing", detail: "sshd_config has insecure defaults" };
|
|
125
|
+
},
|
|
126
|
+
async apply(exec) {
|
|
127
|
+
const { stdout: authKeys } = await exec(
|
|
128
|
+
`cat /home/clawops/.ssh/authorized_keys 2>/dev/null || true`
|
|
129
|
+
);
|
|
130
|
+
if (!authKeys.trim()) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
"SSH hardening aborted: /home/clawops/.ssh/authorized_keys is empty. Applying PasswordAuthentication=no without a key would lock out access."
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const settings = SSHD_SETTINGS.map((s) => {
|
|
136
|
+
const [key] = s.split(" ");
|
|
137
|
+
return `sed -i "/^${key}/Id" /etc/ssh/sshd_config && echo "${s}" >> /etc/ssh/sshd_config`;
|
|
138
|
+
}).join(" && ");
|
|
139
|
+
const script = [
|
|
140
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
141
|
+
settings,
|
|
142
|
+
"sshd -t",
|
|
143
|
+
// validate config before restarting
|
|
144
|
+
"systemctl restart sshd || systemctl restart ssh",
|
|
145
|
+
`touch ${SENTINEL}`
|
|
146
|
+
].join(" && ");
|
|
147
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
148
|
+
if (code !== 0) {
|
|
149
|
+
throw new Error(`SSH hardening failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
150
|
+
}
|
|
151
|
+
return { changed: true, detail: "sshd_config hardened and sshd restarted" };
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// src/harden/modules/ufw.ts
|
|
156
|
+
var SENTINEL2 = `${SENTINEL_DIR}/ufw.applied`;
|
|
157
|
+
var GATEWAY_PORT = 18789;
|
|
158
|
+
function makeUfwModule(sshPort = 22) {
|
|
159
|
+
return {
|
|
160
|
+
id: "ufw",
|
|
161
|
+
label: "UFW firewall",
|
|
162
|
+
defaultOn: true,
|
|
163
|
+
providers: "all",
|
|
164
|
+
async check(exec) {
|
|
165
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL2} && echo yes || echo no`);
|
|
166
|
+
if (sentinel.trim() === "yes") {
|
|
167
|
+
return { status: "applied", detail: "UFW configured (sentinel present)" };
|
|
168
|
+
}
|
|
169
|
+
const { stdout } = await exec(`ufw status 2>/dev/null || echo 'not installed'`);
|
|
170
|
+
if (stdout.includes("Status: active")) {
|
|
171
|
+
return { status: "applied", detail: "UFW already active" };
|
|
172
|
+
}
|
|
173
|
+
if (stdout.includes("not installed")) {
|
|
174
|
+
return { status: "missing", detail: "UFW not installed" };
|
|
175
|
+
}
|
|
176
|
+
return { status: "missing", detail: "UFW installed but not active" };
|
|
177
|
+
},
|
|
178
|
+
async apply(exec) {
|
|
179
|
+
const script = [
|
|
180
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
181
|
+
"apt-get install -y -q ufw",
|
|
182
|
+
"ufw --force reset",
|
|
183
|
+
"ufw default deny incoming",
|
|
184
|
+
"ufw default allow outgoing",
|
|
185
|
+
`ufw allow ${sshPort}/tcp comment "clawops SSH"`,
|
|
186
|
+
`ufw allow ${GATEWAY_PORT}/tcp comment "OpenClaw gateway"`,
|
|
187
|
+
"ufw --force enable",
|
|
188
|
+
`touch ${SENTINEL2}`
|
|
189
|
+
].join(" && ");
|
|
190
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
191
|
+
if (code !== 0) {
|
|
192
|
+
throw new Error(`UFW setup failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
193
|
+
}
|
|
194
|
+
return { changed: true, detail: `UFW enabled: deny-all in, allow ${sshPort}/tcp + ${GATEWAY_PORT}/tcp` };
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
var ufwModule = makeUfwModule();
|
|
199
|
+
|
|
200
|
+
// src/harden/modules/fail2ban.ts
|
|
201
|
+
var SENTINEL3 = `${SENTINEL_DIR}/fail2ban.applied`;
|
|
202
|
+
var JAIL_CONF = `[sshd]
|
|
203
|
+
enabled = true
|
|
204
|
+
port = ssh
|
|
205
|
+
maxretry = 5
|
|
206
|
+
bantime = 600
|
|
207
|
+
findtime = 600
|
|
208
|
+
`;
|
|
209
|
+
var fail2banModule = {
|
|
210
|
+
id: "fail2ban",
|
|
211
|
+
label: "Fail2ban (SSH jail)",
|
|
212
|
+
defaultOn: true,
|
|
213
|
+
providers: "all",
|
|
214
|
+
async check(exec) {
|
|
215
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL3} && echo yes || echo no`);
|
|
216
|
+
if (sentinel.trim() === "yes") {
|
|
217
|
+
return { status: "applied", detail: "fail2ban configured (sentinel present)" };
|
|
218
|
+
}
|
|
219
|
+
const { stdout: active } = await exec(
|
|
220
|
+
`systemctl is-active fail2ban 2>/dev/null || echo inactive`
|
|
221
|
+
);
|
|
222
|
+
if (active.trim() === "active") {
|
|
223
|
+
return { status: "applied", detail: "fail2ban already running" };
|
|
224
|
+
}
|
|
225
|
+
const { stdout: installed } = await exec(
|
|
226
|
+
`dpkg -l fail2ban 2>/dev/null | grep -c '^ii' || echo 0`
|
|
227
|
+
);
|
|
228
|
+
if (parseInt(installed.trim(), 10) > 0) {
|
|
229
|
+
return { status: "drifted", detail: "fail2ban installed but not running" };
|
|
230
|
+
}
|
|
231
|
+
return { status: "missing", detail: "fail2ban not installed" };
|
|
232
|
+
},
|
|
233
|
+
async apply(exec) {
|
|
234
|
+
const script = [
|
|
235
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
236
|
+
"apt-get install -y -q fail2ban",
|
|
237
|
+
`printf '%s' ${shellQuote(JAIL_CONF)} > /etc/fail2ban/jail.local`,
|
|
238
|
+
"systemctl enable --now fail2ban",
|
|
239
|
+
"systemctl restart fail2ban",
|
|
240
|
+
`touch ${SENTINEL3}`
|
|
241
|
+
].join(" && ");
|
|
242
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
243
|
+
if (code !== 0) {
|
|
244
|
+
throw new Error(`fail2ban setup failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
245
|
+
}
|
|
246
|
+
return { changed: true, detail: "fail2ban installed with SSH jail (maxretry=5, bantime=600s)" };
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
function shellQuote(s) {
|
|
250
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/harden/modules/unattended-upgrades.ts
|
|
254
|
+
var SENTINEL4 = `${SENTINEL_DIR}/unattended-upgrades.applied`;
|
|
255
|
+
var AUTO_UPGRADES_CONF = `APT::Periodic::Update-Package-Lists "1";
|
|
256
|
+
APT::Periodic::Unattended-Upgrade "1";
|
|
257
|
+
APT::Periodic::AutocleanInterval "7";
|
|
258
|
+
`;
|
|
259
|
+
var unattendedUpgradesModule = {
|
|
260
|
+
id: "unattended-upgrades",
|
|
261
|
+
label: "Automatic security updates",
|
|
262
|
+
defaultOn: true,
|
|
263
|
+
providers: "all",
|
|
264
|
+
async check(exec) {
|
|
265
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL4} && echo yes || echo no`);
|
|
266
|
+
if (sentinel.trim() === "yes") {
|
|
267
|
+
return { status: "applied", detail: "unattended-upgrades configured (sentinel present)" };
|
|
268
|
+
}
|
|
269
|
+
const { stdout } = await exec(
|
|
270
|
+
`test -f /etc/apt/apt.conf.d/20auto-upgrades && cat /etc/apt/apt.conf.d/20auto-upgrades || echo ''`
|
|
271
|
+
);
|
|
272
|
+
if (stdout.includes('Unattended-Upgrade "1"')) {
|
|
273
|
+
return { status: "applied", detail: "unattended-upgrades already enabled" };
|
|
274
|
+
}
|
|
275
|
+
return { status: "missing", detail: "unattended-upgrades not configured" };
|
|
276
|
+
},
|
|
277
|
+
async apply(exec) {
|
|
278
|
+
const confEscaped = AUTO_UPGRADES_CONF.replace(/'/g, "'\\''");
|
|
279
|
+
const script = [
|
|
280
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
281
|
+
"apt-get install -y -q unattended-upgrades",
|
|
282
|
+
`printf '%s' '${confEscaped}' > /etc/apt/apt.conf.d/20auto-upgrades`,
|
|
283
|
+
"systemctl enable --now unattended-upgrades",
|
|
284
|
+
`touch ${SENTINEL4}`
|
|
285
|
+
].join(" && ");
|
|
286
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
287
|
+
if (code !== 0) {
|
|
288
|
+
throw new Error(`unattended-upgrades setup failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
289
|
+
}
|
|
290
|
+
return { changed: true, detail: "unattended-upgrades enabled for security updates" };
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// src/harden/modules/docker-socket.ts
|
|
295
|
+
var SENTINEL5 = `${SENTINEL_DIR}/docker-socket.applied`;
|
|
296
|
+
var dockerSocketModule = {
|
|
297
|
+
id: "docker-socket",
|
|
298
|
+
label: "Docker socket permissions",
|
|
299
|
+
defaultOn: true,
|
|
300
|
+
providers: "all",
|
|
301
|
+
async check(exec) {
|
|
302
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL5} && echo yes || echo no`);
|
|
303
|
+
if (sentinel.trim() === "yes") {
|
|
304
|
+
}
|
|
305
|
+
const { stdout, code } = await exec(
|
|
306
|
+
`stat -c '%U %G %a' /var/run/docker.sock 2>/dev/null || echo 'not found'`
|
|
307
|
+
);
|
|
308
|
+
const trimmed = stdout.trim();
|
|
309
|
+
if (trimmed === "not found" || code !== 0) {
|
|
310
|
+
return { status: "skipped", detail: "Docker socket not present (Docker not running?)" };
|
|
311
|
+
}
|
|
312
|
+
const [owner, group, perms] = trimmed.split(" ");
|
|
313
|
+
if (owner === "root" && group === "docker" && perms === "660") {
|
|
314
|
+
return { status: "applied", detail: "docker.sock is root:docker 660" };
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
status: "drifted",
|
|
318
|
+
detail: `docker.sock is ${owner}:${group} ${perms} (expected root:docker 660)`
|
|
319
|
+
};
|
|
320
|
+
},
|
|
321
|
+
async apply(exec) {
|
|
322
|
+
const script = [
|
|
323
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
324
|
+
"chown root:docker /var/run/docker.sock",
|
|
325
|
+
"chmod 660 /var/run/docker.sock",
|
|
326
|
+
`touch ${SENTINEL5}`
|
|
327
|
+
].join(" && ");
|
|
328
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
329
|
+
if (code !== 0) {
|
|
330
|
+
throw new Error(`docker socket fix failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
331
|
+
}
|
|
332
|
+
return { changed: true, detail: "docker.sock set to root:docker 660" };
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// src/harden/modules/auditd.ts
|
|
337
|
+
var SENTINEL6 = `${SENTINEL_DIR}/auditd.applied`;
|
|
338
|
+
var AUDIT_RULES = `-a always,exit -F arch=b64 -S execve -F euid=0 -k privileged
|
|
339
|
+
-a always,exit -F arch=b64 -S open,openat -F dir=/etc/ssh -F perm=wa -k sshd_config
|
|
340
|
+
-a always,exit -F arch=b64 -S open,openat -F dir=/etc/clawops -F perm=wa -k clawops_config
|
|
341
|
+
-e 2
|
|
342
|
+
`;
|
|
343
|
+
var auditdModule = {
|
|
344
|
+
id: "auditd",
|
|
345
|
+
label: "auditd (kernel audit logging)",
|
|
346
|
+
defaultOn: false,
|
|
347
|
+
providers: "all",
|
|
348
|
+
async check(exec) {
|
|
349
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL6} && echo yes || echo no`);
|
|
350
|
+
if (sentinel.trim() === "yes") {
|
|
351
|
+
return { status: "applied", detail: "auditd configured (sentinel present)" };
|
|
352
|
+
}
|
|
353
|
+
const { stdout: active } = await exec(
|
|
354
|
+
`systemctl is-active auditd 2>/dev/null || echo inactive`
|
|
355
|
+
);
|
|
356
|
+
if (active.trim() === "active") {
|
|
357
|
+
return { status: "applied", detail: "auditd already running" };
|
|
358
|
+
}
|
|
359
|
+
return { status: "missing", detail: "auditd not installed or not running" };
|
|
360
|
+
},
|
|
361
|
+
async apply(exec) {
|
|
362
|
+
const rulesEscaped = AUDIT_RULES.replace(/'/g, "'\\''");
|
|
363
|
+
const script = [
|
|
364
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
365
|
+
"apt-get install -y -q auditd",
|
|
366
|
+
`printf '%s' '${rulesEscaped}' > /etc/audit/rules.d/clawops.rules`,
|
|
367
|
+
"augenrules --load || auditctl -R /etc/audit/rules.d/clawops.rules",
|
|
368
|
+
"systemctl enable --now auditd",
|
|
369
|
+
`touch ${SENTINEL6}`
|
|
370
|
+
].join(" && ");
|
|
371
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
372
|
+
if (code !== 0) {
|
|
373
|
+
throw new Error(`auditd setup failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
374
|
+
}
|
|
375
|
+
return { changed: true, detail: "auditd installed with CIS-aligned rules" };
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
// src/harden/modules/lynis.ts
|
|
380
|
+
import path from "path";
|
|
381
|
+
import os from "os";
|
|
382
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
383
|
+
var SENTINEL7 = `${SENTINEL_DIR}/lynis.applied`;
|
|
384
|
+
var lynisModule = {
|
|
385
|
+
id: "lynis",
|
|
386
|
+
label: "CIS Level 1 report (lynis)",
|
|
387
|
+
defaultOn: false,
|
|
388
|
+
providers: "all",
|
|
389
|
+
async check(exec) {
|
|
390
|
+
const { stdout: installed } = await exec(`which lynis 2>/dev/null || echo ''`);
|
|
391
|
+
if (!installed.trim()) {
|
|
392
|
+
return { status: "missing", detail: "lynis not installed (will install on apply)" };
|
|
393
|
+
}
|
|
394
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL7} && echo yes || echo no`);
|
|
395
|
+
if (sentinel.trim() === "yes") {
|
|
396
|
+
return { status: "applied", detail: "lynis audit has been run (sentinel present)" };
|
|
397
|
+
}
|
|
398
|
+
return { status: "missing", detail: "lynis installed but audit not yet run" };
|
|
399
|
+
},
|
|
400
|
+
async apply(exec, stackName) {
|
|
401
|
+
const script = [
|
|
402
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
403
|
+
"apt-get install -y -q lynis",
|
|
404
|
+
// Run non-interactively; pipe stdout + stderr to a tmp file
|
|
405
|
+
"lynis audit system --no-colors --quick 2>&1 | tee /tmp/lynis-report.txt || true",
|
|
406
|
+
`touch ${SENTINEL7}`
|
|
407
|
+
].join(" && ");
|
|
408
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
409
|
+
if (code !== 0) {
|
|
410
|
+
throw new Error(`lynis run failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
411
|
+
}
|
|
412
|
+
const { stdout: report } = await exec('cat /tmp/lynis-report.txt 2>/dev/null || echo ""');
|
|
413
|
+
const scoreMatch = report.match(/Hardening index\s*:\s*(\d+)/i);
|
|
414
|
+
const score = scoreMatch ? scoreMatch[1] : "unknown";
|
|
415
|
+
const suggestions = report.split("\n").filter((l) => l.includes("Suggestion") || l.includes("[suggestion]")).slice(0, 5).map((l) => l.trim());
|
|
416
|
+
const reportsDir = path.join(os.homedir(), ".clawops", "reports");
|
|
417
|
+
mkdirSync(reportsDir, { recursive: true });
|
|
418
|
+
const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
419
|
+
const name = stackName ?? "stack";
|
|
420
|
+
const reportPath = path.join(reportsDir, `${name}-lynis-${date}.txt`);
|
|
421
|
+
writeFileSync(reportPath, report, "utf-8");
|
|
422
|
+
const detail = `Hardening index: ${score}/100. Full report: ${reportPath}` + (suggestions.length ? `
|
|
423
|
+
Top suggestions:
|
|
424
|
+
${suggestions.join("\n ")}` : "");
|
|
425
|
+
return { changed: true, detail };
|
|
426
|
+
}
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// src/harden/modules/sysctl.ts
|
|
430
|
+
var SENTINEL8 = `${SENTINEL_DIR}/sysctl.applied`;
|
|
431
|
+
var SYSCTL_CONF = `# clawops kernel hardening
|
|
432
|
+
net.ipv4.ip_forward = 0
|
|
433
|
+
net.ipv6.conf.all.forwarding = 0
|
|
434
|
+
net.ipv4.tcp_syncookies = 1
|
|
435
|
+
net.ipv4.conf.all.accept_redirects = 0
|
|
436
|
+
net.ipv6.conf.all.accept_redirects = 0
|
|
437
|
+
net.ipv4.conf.all.send_redirects = 0
|
|
438
|
+
net.ipv4.conf.all.accept_source_route = 0
|
|
439
|
+
net.ipv4.conf.default.rp_filter = 1
|
|
440
|
+
`;
|
|
441
|
+
var sysctlModule = {
|
|
442
|
+
id: "sysctl",
|
|
443
|
+
label: "Kernel sysctl hardening",
|
|
444
|
+
defaultOn: false,
|
|
445
|
+
providers: "all",
|
|
446
|
+
async check(exec) {
|
|
447
|
+
const { stdout: sentinel } = await exec(`test -f ${SENTINEL8} && echo yes || echo no`);
|
|
448
|
+
if (sentinel.trim() === "yes") {
|
|
449
|
+
return { status: "applied", detail: "sysctl hardening applied (sentinel present)" };
|
|
450
|
+
}
|
|
451
|
+
const { stdout } = await exec(
|
|
452
|
+
`test -f /etc/sysctl.d/99-clawops-hardening.conf && echo yes || echo no`
|
|
453
|
+
);
|
|
454
|
+
if (stdout.trim() === "yes") {
|
|
455
|
+
return { status: "applied", detail: "clawops sysctl config already present" };
|
|
456
|
+
}
|
|
457
|
+
return { status: "missing", detail: "hardened sysctl settings not configured" };
|
|
458
|
+
},
|
|
459
|
+
async apply(exec) {
|
|
460
|
+
const confEscaped = SYSCTL_CONF.replace(/'/g, "'\\''");
|
|
461
|
+
const script = [
|
|
462
|
+
`mkdir -p ${SENTINEL_DIR}`,
|
|
463
|
+
`printf '%s' '${confEscaped}' > /etc/sysctl.d/99-clawops-hardening.conf`,
|
|
464
|
+
"sysctl --system",
|
|
465
|
+
`touch ${SENTINEL8}`
|
|
466
|
+
].join(" && ");
|
|
467
|
+
const { code, stderr } = await exec(`sudo sh -c '${script.replace(/'/g, "'\\''")}'`);
|
|
468
|
+
if (code !== 0) {
|
|
469
|
+
throw new Error(`sysctl hardening failed (exit ${code}): ${stderr.slice(0, 200)}`);
|
|
470
|
+
}
|
|
471
|
+
return { changed: true, detail: "kernel hardening applied (ip_forward=0, syncookies=1, no redirects)" };
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// src/harden/modules/aws-sg-audit.ts
|
|
476
|
+
var EXPECTED_OPEN_PORTS = /* @__PURE__ */ new Set([22, 18789]);
|
|
477
|
+
var awsSgAuditModule = {
|
|
478
|
+
id: "aws-sg-audit",
|
|
479
|
+
label: "AWS SG audit (check-only)",
|
|
480
|
+
defaultOn: true,
|
|
481
|
+
providers: ["aws"],
|
|
482
|
+
async check(_exec) {
|
|
483
|
+
try {
|
|
484
|
+
const { EC2Client, DescribeSecurityGroupsCommand } = await import("@aws-sdk/client-ec2");
|
|
485
|
+
const client = new EC2Client({});
|
|
486
|
+
const resp = await client.send(new DescribeSecurityGroupsCommand({
|
|
487
|
+
Filters: [{ Name: "tag:Name", Values: ["clawops"] }]
|
|
488
|
+
}));
|
|
489
|
+
const groups = resp.SecurityGroups ?? [];
|
|
490
|
+
const openRules = [];
|
|
491
|
+
for (const sg of groups) {
|
|
492
|
+
for (const rule of sg.IpPermissions ?? []) {
|
|
493
|
+
const fromPort = rule.FromPort ?? 0;
|
|
494
|
+
const toPort = rule.ToPort ?? 65535;
|
|
495
|
+
for (const range of rule.IpRanges ?? []) {
|
|
496
|
+
if (range.CidrIp === "0.0.0.0/0" && !EXPECTED_OPEN_PORTS.has(fromPort)) {
|
|
497
|
+
openRules.push(`port ${fromPort}\u2013${toPort} open to 0.0.0.0/0 in ${sg.GroupId}`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (openRules.length === 0) {
|
|
503
|
+
return { status: "applied", detail: "No unexpected open ingress rules found" };
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
status: "drifted",
|
|
507
|
+
detail: `Open ingress rules detected: ${openRules.join("; ")}`
|
|
508
|
+
};
|
|
509
|
+
} catch (err) {
|
|
510
|
+
return {
|
|
511
|
+
status: "skipped",
|
|
512
|
+
detail: `AWS SDK unavailable: ${err.message}`
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
},
|
|
516
|
+
// SG audit is check-only — apply() surfaces the finding but makes no changes.
|
|
517
|
+
async apply(_exec) {
|
|
518
|
+
return {
|
|
519
|
+
changed: false,
|
|
520
|
+
detail: "SG audit is check-only. Review and tighten rules manually or re-run clawops up."
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// src/harden/modules/aws-ssm-check.ts
|
|
526
|
+
var SSM_POLICY_ARN = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore";
|
|
527
|
+
var awsSsmCheckModule = {
|
|
528
|
+
id: "aws-ssm-check",
|
|
529
|
+
label: "AWS SSM access (check-only)",
|
|
530
|
+
defaultOn: true,
|
|
531
|
+
providers: ["aws"],
|
|
532
|
+
async check(_exec) {
|
|
533
|
+
try {
|
|
534
|
+
const { EC2Client, DescribeInstancesCommand } = await import("@aws-sdk/client-ec2");
|
|
535
|
+
const { IAMClient, ListAttachedRolePoliciesCommand } = await import("@aws-sdk/client-iam");
|
|
536
|
+
const ec2 = new EC2Client({});
|
|
537
|
+
const resp = await ec2.send(new DescribeInstancesCommand({
|
|
538
|
+
Filters: [{ Name: "tag:Name", Values: ["clawops"] }, { Name: "instance-state-name", Values: ["running"] }]
|
|
539
|
+
}));
|
|
540
|
+
const instance = resp.Reservations?.[0]?.Instances?.[0];
|
|
541
|
+
if (!instance) {
|
|
542
|
+
return { status: "skipped", detail: "No running clawops instance found in this region" };
|
|
543
|
+
}
|
|
544
|
+
const profileArn = instance.IamInstanceProfile?.Arn;
|
|
545
|
+
if (!profileArn) {
|
|
546
|
+
return { status: "drifted", detail: "Instance has no IAM instance profile attached" };
|
|
547
|
+
}
|
|
548
|
+
const roleName = profileArn.split("/").pop() ?? "";
|
|
549
|
+
const iam = new IAMClient({});
|
|
550
|
+
const policies = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName: roleName }));
|
|
551
|
+
const hasSSM = (policies.AttachedPolicies ?? []).some(
|
|
552
|
+
(p) => p.PolicyArn === SSM_POLICY_ARN
|
|
553
|
+
);
|
|
554
|
+
if (hasSSM) {
|
|
555
|
+
return { status: "applied", detail: `${roleName} has AmazonSSMManagedInstanceCore` };
|
|
556
|
+
}
|
|
557
|
+
return {
|
|
558
|
+
status: "drifted",
|
|
559
|
+
detail: `${roleName} missing AmazonSSMManagedInstanceCore \u2014 SSM shell access unavailable`
|
|
560
|
+
};
|
|
561
|
+
} catch (err) {
|
|
562
|
+
return {
|
|
563
|
+
status: "skipped",
|
|
564
|
+
detail: `AWS SDK unavailable: ${err.message}`
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
async apply(_exec) {
|
|
569
|
+
return {
|
|
570
|
+
changed: false,
|
|
571
|
+
detail: "SSM check is read-only. Attach AmazonSSMManagedInstanceCore to the instance role to remediate."
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
// src/harden/modules/aws-flow-logs.ts
|
|
577
|
+
var LOG_GROUP = "/clawops/vpc-flow-logs";
|
|
578
|
+
var awsFlowLogsModule = {
|
|
579
|
+
id: "aws-flow-logs",
|
|
580
|
+
label: "AWS VPC Flow Logs (opt-in, billed)",
|
|
581
|
+
defaultOn: false,
|
|
582
|
+
providers: ["aws"],
|
|
583
|
+
async check(_exec) {
|
|
584
|
+
try {
|
|
585
|
+
const { EC2Client, DescribeFlowLogsCommand } = await import("@aws-sdk/client-ec2");
|
|
586
|
+
const { EC2Client: EC2, DescribeVpcsCommand } = await import("@aws-sdk/client-ec2");
|
|
587
|
+
const ec2 = new EC2({});
|
|
588
|
+
const vpcs = await ec2.send(new DescribeVpcsCommand({
|
|
589
|
+
Filters: [{ Name: "tag:Name", Values: ["clawops"] }]
|
|
590
|
+
}));
|
|
591
|
+
const vpcId = vpcs.Vpcs?.[0]?.VpcId;
|
|
592
|
+
if (!vpcId) {
|
|
593
|
+
return { status: "skipped", detail: "No clawops VPC found" };
|
|
594
|
+
}
|
|
595
|
+
const flowClient = new EC2Client({});
|
|
596
|
+
const logs = await flowClient.send(new DescribeFlowLogsCommand({
|
|
597
|
+
Filter: [{ Name: "resource-id", Values: [vpcId] }]
|
|
598
|
+
}));
|
|
599
|
+
if ((logs.FlowLogs ?? []).length > 0) {
|
|
600
|
+
return { status: "applied", detail: `VPC Flow Logs active on ${vpcId}` };
|
|
601
|
+
}
|
|
602
|
+
return { status: "missing", detail: `VPC Flow Logs not enabled on ${vpcId}` };
|
|
603
|
+
} catch (err) {
|
|
604
|
+
return { status: "skipped", detail: `AWS SDK unavailable: ${err.message}` };
|
|
605
|
+
}
|
|
606
|
+
},
|
|
607
|
+
async apply(_exec) {
|
|
608
|
+
try {
|
|
609
|
+
const {
|
|
610
|
+
EC2Client,
|
|
611
|
+
DescribeVpcsCommand,
|
|
612
|
+
CreateFlowLogsCommand
|
|
613
|
+
} = await import("@aws-sdk/client-ec2");
|
|
614
|
+
const {
|
|
615
|
+
CloudWatchLogsClient,
|
|
616
|
+
CreateLogGroupCommand
|
|
617
|
+
} = await import("@aws-sdk/client-cloudwatch-logs");
|
|
618
|
+
const { IAMClient, CreateRoleCommand, AttachRolePolicyCommand } = await import("@aws-sdk/client-iam");
|
|
619
|
+
const ec2 = new EC2Client({});
|
|
620
|
+
const vpcs = await ec2.send(new DescribeVpcsCommand({
|
|
621
|
+
Filters: [{ Name: "tag:Name", Values: ["clawops"] }]
|
|
622
|
+
}));
|
|
623
|
+
const vpcId = vpcs.Vpcs?.[0]?.VpcId;
|
|
624
|
+
if (!vpcId) throw new Error("No clawops VPC found");
|
|
625
|
+
const cwl = new CloudWatchLogsClient({});
|
|
626
|
+
try {
|
|
627
|
+
await cwl.send(new CreateLogGroupCommand({ logGroupName: LOG_GROUP }));
|
|
628
|
+
} catch (e) {
|
|
629
|
+
if (e.name !== "ResourceAlreadyExistsException") throw e;
|
|
630
|
+
}
|
|
631
|
+
const iam = new IAMClient({});
|
|
632
|
+
const roleName = "clawops-flow-logs-role";
|
|
633
|
+
let roleArn;
|
|
634
|
+
try {
|
|
635
|
+
const role = await iam.send(new CreateRoleCommand({
|
|
636
|
+
RoleName: roleName,
|
|
637
|
+
AssumeRolePolicyDocument: JSON.stringify({
|
|
638
|
+
Version: "2012-10-17",
|
|
639
|
+
Statement: [{ Effect: "Allow", Principal: { Service: "vpc-flow-logs.amazonaws.com" }, Action: "sts:AssumeRole" }]
|
|
640
|
+
})
|
|
641
|
+
}));
|
|
642
|
+
roleArn = role.Role.Arn;
|
|
643
|
+
await iam.send(new AttachRolePolicyCommand({
|
|
644
|
+
RoleName: roleName,
|
|
645
|
+
PolicyArn: "arn:aws:iam::aws:policy/CloudWatchLogsFullAccess"
|
|
646
|
+
}));
|
|
647
|
+
} catch (e) {
|
|
648
|
+
if (e.name !== "EntityAlreadyExists") throw e;
|
|
649
|
+
const { IAMClient: IAM2, GetRoleCommand } = await import("@aws-sdk/client-iam");
|
|
650
|
+
const existing = await new IAM2({}).send(new GetRoleCommand({ RoleName: roleName }));
|
|
651
|
+
roleArn = existing.Role.Arn;
|
|
652
|
+
}
|
|
653
|
+
await ec2.send(new CreateFlowLogsCommand({
|
|
654
|
+
ResourceIds: [vpcId],
|
|
655
|
+
ResourceType: "VPC",
|
|
656
|
+
TrafficType: "ALL",
|
|
657
|
+
LogDestinationType: "cloud-watch-logs",
|
|
658
|
+
LogGroupName: LOG_GROUP,
|
|
659
|
+
DeliverLogsPermissionArn: roleArn
|
|
660
|
+
}));
|
|
661
|
+
return { changed: true, detail: `VPC Flow Logs enabled \u2192 CloudWatch ${LOG_GROUP}` };
|
|
662
|
+
} catch (err) {
|
|
663
|
+
throw new Error(`VPC Flow Logs setup failed: ${err.message}`);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// src/harden/modules/aws-guardduty.ts
|
|
669
|
+
var awsGuardDutyModule = {
|
|
670
|
+
id: "aws-guardduty",
|
|
671
|
+
label: "AWS GuardDuty (opt-in, ~$4/mo)",
|
|
672
|
+
defaultOn: false,
|
|
673
|
+
providers: ["aws"],
|
|
674
|
+
async check(_exec) {
|
|
675
|
+
try {
|
|
676
|
+
const { GuardDutyClient, ListDetectorsCommand } = await import("@aws-sdk/client-guardduty");
|
|
677
|
+
const client = new GuardDutyClient({});
|
|
678
|
+
const resp = await client.send(new ListDetectorsCommand({}));
|
|
679
|
+
const detectors = resp.DetectorIds ?? [];
|
|
680
|
+
if (detectors.length === 0) {
|
|
681
|
+
return { status: "missing", detail: "GuardDuty not enabled in this region" };
|
|
682
|
+
}
|
|
683
|
+
const { GetDetectorCommand } = await import("@aws-sdk/client-guardduty");
|
|
684
|
+
for (const id of detectors) {
|
|
685
|
+
const det = await client.send(new GetDetectorCommand({ DetectorId: id }));
|
|
686
|
+
if (det.Status === "ENABLED") {
|
|
687
|
+
return { status: "applied", detail: `GuardDuty enabled (detector: ${id})` };
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return { status: "drifted", detail: "GuardDuty detector exists but is not ENABLED" };
|
|
691
|
+
} catch (err) {
|
|
692
|
+
return { status: "skipped", detail: `AWS SDK unavailable: ${err.message}` };
|
|
693
|
+
}
|
|
694
|
+
},
|
|
695
|
+
async apply(_exec) {
|
|
696
|
+
try {
|
|
697
|
+
const { GuardDutyClient, CreateDetectorCommand } = await import("@aws-sdk/client-guardduty");
|
|
698
|
+
const client = new GuardDutyClient({});
|
|
699
|
+
const resp = await client.send(new CreateDetectorCommand({ Enable: true }));
|
|
700
|
+
return {
|
|
701
|
+
changed: true,
|
|
702
|
+
detail: `GuardDuty enabled (detector: ${resp.DetectorId}). Note: billed ~$4/mo per account.`
|
|
703
|
+
};
|
|
704
|
+
} catch (err) {
|
|
705
|
+
throw new Error(`GuardDuty enable failed: ${err.message}`);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
|
|
710
|
+
// src/harden/index.ts
|
|
711
|
+
var MODULE_CATALOG = [
|
|
712
|
+
// ON by default — common
|
|
713
|
+
sshModule,
|
|
714
|
+
ufwModule,
|
|
715
|
+
fail2banModule,
|
|
716
|
+
unattendedUpgradesModule,
|
|
717
|
+
dockerSocketModule,
|
|
718
|
+
// OFF by default — common
|
|
719
|
+
auditdModule,
|
|
720
|
+
lynisModule,
|
|
721
|
+
sysctlModule,
|
|
722
|
+
// AWS-specific
|
|
723
|
+
awsSgAuditModule,
|
|
724
|
+
awsSsmCheckModule,
|
|
725
|
+
awsFlowLogsModule,
|
|
726
|
+
awsGuardDutyModule
|
|
727
|
+
];
|
|
728
|
+
export {
|
|
729
|
+
MODULE_CATALOG,
|
|
730
|
+
SENTINEL_DIR,
|
|
731
|
+
auditdModule,
|
|
732
|
+
awsFlowLogsModule,
|
|
733
|
+
awsGuardDutyModule,
|
|
734
|
+
awsSgAuditModule,
|
|
735
|
+
awsSsmCheckModule,
|
|
736
|
+
dockerSocketModule,
|
|
737
|
+
fail2banModule,
|
|
738
|
+
formatHardenSummary,
|
|
739
|
+
lynisModule,
|
|
740
|
+
makeUfwModule,
|
|
741
|
+
resolveModules,
|
|
742
|
+
runHardening,
|
|
743
|
+
sshModule,
|
|
744
|
+
sysctlModule,
|
|
745
|
+
ufwModule,
|
|
746
|
+
unattendedUpgradesModule,
|
|
747
|
+
withRemoteExec
|
|
748
|
+
};
|