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,1818 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access control and authentication auditing tools for Kali Defense MCP Server.
|
|
3
|
+
*
|
|
4
|
+
* Registers 6 tools: access_ssh, access_pam, access_sudo_audit,
|
|
5
|
+
* access_user_audit, access_password_policy, access_restrict_shell.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { executeCommand } from "../core/executor.js";
|
|
9
|
+
import { getConfig, getToolTimeout } from "../core/config.js";
|
|
10
|
+
import { getDistroAdapter } from "../core/distro-adapter.js";
|
|
11
|
+
import { createTextContent, createErrorContent, formatToolOutput, } from "../core/parsers.js";
|
|
12
|
+
import { logChange, createChangeEntry, backupFile, } from "../core/changelog.js";
|
|
13
|
+
const SSH_HARDENING_CHECKS = [
|
|
14
|
+
{
|
|
15
|
+
key: "PermitRootLogin",
|
|
16
|
+
recommended: "no",
|
|
17
|
+
severity: "critical",
|
|
18
|
+
description: "Root login should be disabled",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
key: "PasswordAuthentication",
|
|
22
|
+
recommended: "no",
|
|
23
|
+
severity: "high",
|
|
24
|
+
description: "Password authentication should be disabled in favor of keys",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
key: "X11Forwarding",
|
|
28
|
+
recommended: "no",
|
|
29
|
+
severity: "medium",
|
|
30
|
+
description: "X11 forwarding should be disabled unless needed",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
key: "MaxAuthTries",
|
|
34
|
+
recommended: "4",
|
|
35
|
+
severity: "medium",
|
|
36
|
+
description: "Max authentication attempts should be limited",
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
key: "Protocol",
|
|
40
|
+
recommended: "2",
|
|
41
|
+
severity: "critical",
|
|
42
|
+
description: "Only SSH protocol 2 should be used",
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
key: "PermitEmptyPasswords",
|
|
46
|
+
recommended: "no",
|
|
47
|
+
severity: "critical",
|
|
48
|
+
description: "Empty passwords must not be permitted",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
key: "ClientAliveInterval",
|
|
52
|
+
recommended: "300",
|
|
53
|
+
severity: "low",
|
|
54
|
+
description: "Client alive interval should be set for idle timeout",
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
key: "ClientAliveCountMax",
|
|
58
|
+
recommended: "3",
|
|
59
|
+
severity: "low",
|
|
60
|
+
description: "Client alive count max should be limited",
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
key: "AllowTcpForwarding",
|
|
64
|
+
recommended: "no",
|
|
65
|
+
severity: "medium",
|
|
66
|
+
description: "TCP forwarding should be disabled unless needed",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
key: "Banner",
|
|
70
|
+
recommended: "/etc/issue.net",
|
|
71
|
+
severity: "low",
|
|
72
|
+
description: "A login banner should be displayed",
|
|
73
|
+
},
|
|
74
|
+
// GAP-06: Additional SSH hardening checks
|
|
75
|
+
{
|
|
76
|
+
key: "LoginGraceTime",
|
|
77
|
+
recommended: "60",
|
|
78
|
+
severity: "medium",
|
|
79
|
+
description: "CIS 5.2.16 - Limit login grace time",
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
key: "MaxSessions",
|
|
83
|
+
recommended: "4",
|
|
84
|
+
severity: "medium",
|
|
85
|
+
description: "Limit concurrent sessions per connection",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "AllowAgentForwarding",
|
|
89
|
+
recommended: "no",
|
|
90
|
+
severity: "medium",
|
|
91
|
+
description: "Disable SSH agent forwarding",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "PermitUserEnvironment",
|
|
95
|
+
recommended: "no",
|
|
96
|
+
severity: "medium",
|
|
97
|
+
description: "Prevent user environment variable override",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
key: "UseDNS",
|
|
101
|
+
recommended: "no",
|
|
102
|
+
severity: "low",
|
|
103
|
+
description: "Disable DNS lookups for client verification",
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
key: "Ciphers",
|
|
107
|
+
recommended: "chacha20-poly1305@openssh.com,aes256-gcm@openssh.com,aes128-gcm@openssh.com,aes256-ctr,aes192-ctr,aes128-ctr",
|
|
108
|
+
severity: "high",
|
|
109
|
+
description: "Mozilla Modern cipher suite",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
key: "MACs",
|
|
113
|
+
recommended: "hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com,umac-128-etm@openssh.com,hmac-sha2-512,hmac-sha2-256",
|
|
114
|
+
severity: "high",
|
|
115
|
+
description: "Strong MAC algorithms only",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
key: "KexAlgorithms",
|
|
119
|
+
recommended: "curve25519-sha256,curve25519-sha256@libssh.org,diffie-hellman-group16-sha512,diffie-hellman-group18-sha512,diffie-hellman-group-exchange-sha256",
|
|
120
|
+
severity: "high",
|
|
121
|
+
description: "Strong key exchange algorithms only",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
key: "HostKeyAlgorithms",
|
|
125
|
+
recommended: "ssh-ed25519,rsa-sha2-512,rsa-sha2-256",
|
|
126
|
+
severity: "high",
|
|
127
|
+
description: "Prefer Ed25519 and RSA-SHA2 host keys",
|
|
128
|
+
},
|
|
129
|
+
];
|
|
130
|
+
// ── TOOL-012 remediation: Valid SSH configuration directives ────────────────
|
|
131
|
+
/** Known-good SSH configuration directives (case-sensitive as used in sshd_config) */
|
|
132
|
+
const VALID_SSH_CONFIG_KEYS = new Set([
|
|
133
|
+
"PermitRootLogin", "PasswordAuthentication", "X11Forwarding", "MaxAuthTries",
|
|
134
|
+
"Protocol", "PermitEmptyPasswords", "ClientAliveInterval", "ClientAliveCountMax",
|
|
135
|
+
"AllowTcpForwarding", "Banner", "LoginGraceTime", "MaxSessions",
|
|
136
|
+
"AllowAgentForwarding", "PermitUserEnvironment", "UseDNS",
|
|
137
|
+
"Ciphers", "MACs", "KexAlgorithms", "HostKeyAlgorithms",
|
|
138
|
+
"PubkeyAuthentication", "AuthorizedKeysFile", "HostbasedAuthentication",
|
|
139
|
+
"ChallengeResponseAuthentication", "GSSAPIAuthentication", "UsePAM",
|
|
140
|
+
"AcceptEnv", "AllowUsers", "AllowGroups", "DenyUsers", "DenyGroups",
|
|
141
|
+
"GatewayPorts", "PermitTunnel", "PrintMotd", "PrintLastLog",
|
|
142
|
+
"TCPKeepAlive", "Compression", "MaxStartups", "PermitOpen",
|
|
143
|
+
"AuthenticationMethods", "StrictModes", "SyslogFacility", "LogLevel",
|
|
144
|
+
"ListenAddress", "Port", "AddressFamily", "HostKey",
|
|
145
|
+
"RekeyLimit", "Subsystem",
|
|
146
|
+
]);
|
|
147
|
+
/** Validate an SSH config value — reject shell metacharacters */
|
|
148
|
+
const SSH_VALUE_UNSAFE_RE = /[;|&`$(){}<>!]/;
|
|
149
|
+
function validateSshConfigKey(key) {
|
|
150
|
+
if (!VALID_SSH_CONFIG_KEYS.has(key)) {
|
|
151
|
+
throw new Error(`Invalid SSH configuration directive: '${key}'. ` +
|
|
152
|
+
`Must be a known sshd_config option.`);
|
|
153
|
+
}
|
|
154
|
+
return key;
|
|
155
|
+
}
|
|
156
|
+
function validateSshConfigValue(value) {
|
|
157
|
+
if (SSH_VALUE_UNSAFE_RE.test(value)) {
|
|
158
|
+
throw new Error(`Invalid SSH configuration value: contains shell metacharacters. Value: '${value}'`);
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
// ── Registration entry point ───────────────────────────────────────────────
|
|
163
|
+
export function registerAccessControlTools(server) {
|
|
164
|
+
// ── 1. access_ssh (merged: access_ssh_audit, access_ssh_harden, access_ssh_cipher_audit) ──
|
|
165
|
+
server.tool("access_ssh", "SSH server security. Actions: audit=check config against best practices, harden=apply hardening settings, cipher_audit=audit cryptographic algorithms", {
|
|
166
|
+
action: z
|
|
167
|
+
.enum(["audit", "harden", "cipher_audit"])
|
|
168
|
+
.describe("Action: audit=check config, harden=apply settings, cipher_audit=check algorithms"),
|
|
169
|
+
// shared
|
|
170
|
+
config_path: z
|
|
171
|
+
.string()
|
|
172
|
+
.optional()
|
|
173
|
+
.default("/etc/ssh/sshd_config")
|
|
174
|
+
.describe("Path to sshd_config file"),
|
|
175
|
+
// harden params
|
|
176
|
+
settings: z
|
|
177
|
+
.string()
|
|
178
|
+
.optional()
|
|
179
|
+
.describe("Comma-separated key=value pairs for harden, e.g. 'PermitRootLogin=no,MaxAuthTries=4'"),
|
|
180
|
+
apply_recommended: z
|
|
181
|
+
.boolean()
|
|
182
|
+
.optional()
|
|
183
|
+
.default(false)
|
|
184
|
+
.describe("Apply all recommended hardening settings (for harden)"),
|
|
185
|
+
restart_sshd: z
|
|
186
|
+
.boolean()
|
|
187
|
+
.optional()
|
|
188
|
+
.default(false)
|
|
189
|
+
.describe("Restart sshd after applying changes (for harden)"),
|
|
190
|
+
// shared
|
|
191
|
+
dry_run: z
|
|
192
|
+
.boolean()
|
|
193
|
+
.optional()
|
|
194
|
+
.default(true)
|
|
195
|
+
.describe("Preview changes (for harden)"),
|
|
196
|
+
}, async (params) => {
|
|
197
|
+
const { action } = params;
|
|
198
|
+
switch (action) {
|
|
199
|
+
// ── audit ────────────────────────────────────────────────────
|
|
200
|
+
case "audit": {
|
|
201
|
+
try {
|
|
202
|
+
const config_path = params.config_path ?? "/etc/ssh/sshd_config";
|
|
203
|
+
const result = await executeCommand({
|
|
204
|
+
command: "sudo",
|
|
205
|
+
args: ["cat", config_path],
|
|
206
|
+
toolName: "access_ssh",
|
|
207
|
+
timeout: getToolTimeout("access_ssh"),
|
|
208
|
+
});
|
|
209
|
+
if (result.exitCode !== 0) {
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
createErrorContent(`Cannot read SSH config (exit ${result.exitCode}): ${result.stderr}`),
|
|
213
|
+
],
|
|
214
|
+
isError: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const configContent = result.stdout;
|
|
218
|
+
// Parse the SSH config into key-value pairs
|
|
219
|
+
const configValues = {};
|
|
220
|
+
for (const line of configContent.split("\n")) {
|
|
221
|
+
const trimmed = line.trim();
|
|
222
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
223
|
+
continue;
|
|
224
|
+
const parts = trimmed.split(/\s+/);
|
|
225
|
+
if (parts.length >= 2) {
|
|
226
|
+
configValues[parts[0]] = parts.slice(1).join(" ");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// Check each recommendation
|
|
230
|
+
const findings = [];
|
|
231
|
+
for (const check of SSH_HARDENING_CHECKS) {
|
|
232
|
+
const currentValue = configValues[check.key] ?? null;
|
|
233
|
+
let status;
|
|
234
|
+
if (currentValue === null) {
|
|
235
|
+
if (check.key === "MaxAuthTries" || check.key === "ClientAliveCountMax") {
|
|
236
|
+
status = "warn";
|
|
237
|
+
}
|
|
238
|
+
else if (check.key === "ClientAliveInterval" || check.key === "Banner") {
|
|
239
|
+
status = "warn";
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
status = "warn";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else if (check.key === "MaxAuthTries") {
|
|
246
|
+
status = parseInt(currentValue, 10) <= parseInt(check.recommended, 10) ? "pass" : "fail";
|
|
247
|
+
}
|
|
248
|
+
else if (check.key === "ClientAliveCountMax") {
|
|
249
|
+
status = parseInt(currentValue, 10) <= parseInt(check.recommended, 10) ? "pass" : "fail";
|
|
250
|
+
}
|
|
251
|
+
else if (check.key === "ClientAliveInterval") {
|
|
252
|
+
status = parseInt(currentValue, 10) > 0 ? "pass" : "fail";
|
|
253
|
+
}
|
|
254
|
+
else if (check.key === "Banner") {
|
|
255
|
+
status = currentValue && currentValue !== "none" ? "pass" : "fail";
|
|
256
|
+
}
|
|
257
|
+
else if (check.key === "LoginGraceTime" || check.key === "MaxSessions") {
|
|
258
|
+
status = parseInt(currentValue, 10) <= parseInt(check.recommended, 10) ? "pass" : "fail";
|
|
259
|
+
}
|
|
260
|
+
else if (check.key === "Ciphers" || check.key === "MACs" || check.key === "KexAlgorithms" || check.key === "HostKeyAlgorithms") {
|
|
261
|
+
const configuredAlgs = currentValue.split(",").map(s => s.trim());
|
|
262
|
+
const recommendedAlgs = check.recommended.split(",").map(s => s.trim());
|
|
263
|
+
const hasWeak = configuredAlgs.some(a => !recommendedAlgs.includes(a));
|
|
264
|
+
status = hasWeak ? "fail" : "pass";
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
status = currentValue.toLowerCase() === check.recommended.toLowerCase() ? "pass" : "fail";
|
|
268
|
+
}
|
|
269
|
+
findings.push({
|
|
270
|
+
setting: check.key,
|
|
271
|
+
currentValue,
|
|
272
|
+
recommendedValue: check.recommended,
|
|
273
|
+
status,
|
|
274
|
+
severity: check.severity,
|
|
275
|
+
description: check.description,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
const passed = findings.filter((f) => f.status === "pass").length;
|
|
279
|
+
const failed = findings.filter((f) => f.status === "fail").length;
|
|
280
|
+
const warned = findings.filter((f) => f.status === "warn").length;
|
|
281
|
+
const entry = createChangeEntry({
|
|
282
|
+
tool: "access_ssh",
|
|
283
|
+
action: "SSH configuration audit",
|
|
284
|
+
target: config_path,
|
|
285
|
+
after: `Pass: ${passed}, Fail: ${failed}, Warn: ${warned}`,
|
|
286
|
+
dryRun: false,
|
|
287
|
+
success: true,
|
|
288
|
+
});
|
|
289
|
+
logChange(entry);
|
|
290
|
+
const output = {
|
|
291
|
+
configPath: config_path,
|
|
292
|
+
summary: { passed, failed, warned, total: findings.length },
|
|
293
|
+
findings,
|
|
294
|
+
};
|
|
295
|
+
return { content: [formatToolOutput(output)] };
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
299
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ── harden ───────────────────────────────────────────────────
|
|
303
|
+
case "harden": {
|
|
304
|
+
try {
|
|
305
|
+
const configPath = params.config_path ?? "/etc/ssh/sshd_config";
|
|
306
|
+
// Build the settings to apply
|
|
307
|
+
const settingsToApply = {};
|
|
308
|
+
if (params.apply_recommended) {
|
|
309
|
+
for (const check of SSH_HARDENING_CHECKS) {
|
|
310
|
+
settingsToApply[check.key] = check.recommended;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (params.settings) {
|
|
314
|
+
for (const pair of params.settings.split(",")) {
|
|
315
|
+
const trimmed = pair.trim();
|
|
316
|
+
const eqIdx = trimmed.indexOf("=");
|
|
317
|
+
if (eqIdx > 0) {
|
|
318
|
+
const key = trimmed.substring(0, eqIdx).trim();
|
|
319
|
+
const value = trimmed.substring(eqIdx + 1).trim();
|
|
320
|
+
// TOOL-012: Validate SSH config keys and values
|
|
321
|
+
validateSshConfigKey(key);
|
|
322
|
+
validateSshConfigValue(value);
|
|
323
|
+
settingsToApply[key] = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (Object.keys(settingsToApply).length === 0) {
|
|
328
|
+
return {
|
|
329
|
+
content: [
|
|
330
|
+
createErrorContent("No settings specified. Provide 'settings' or set 'apply_recommended' to true."),
|
|
331
|
+
],
|
|
332
|
+
isError: true,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const sedCommands = [];
|
|
336
|
+
for (const [key, value] of Object.entries(settingsToApply)) {
|
|
337
|
+
sedCommands.push(`sudo sed -i 's|^#*\\s*${key}\\s.*|${key} ${value}|' ${configPath}`);
|
|
338
|
+
}
|
|
339
|
+
const grepCommands = [];
|
|
340
|
+
for (const key of Object.keys(settingsToApply)) {
|
|
341
|
+
grepCommands.push(`grep -q '^#*\\s*${key}\\s' ${configPath} || echo '${key} ${settingsToApply[key]}' | sudo tee -a ${configPath}`);
|
|
342
|
+
}
|
|
343
|
+
if (params.dry_run ?? getConfig().dryRun) {
|
|
344
|
+
const entry = createChangeEntry({
|
|
345
|
+
tool: "access_ssh",
|
|
346
|
+
action: "[DRY-RUN] Apply SSH hardening",
|
|
347
|
+
target: configPath,
|
|
348
|
+
after: JSON.stringify(settingsToApply),
|
|
349
|
+
dryRun: true,
|
|
350
|
+
success: true,
|
|
351
|
+
});
|
|
352
|
+
logChange(entry);
|
|
353
|
+
return {
|
|
354
|
+
content: [
|
|
355
|
+
createTextContent(`[DRY-RUN] Would apply the following SSH hardening to ${configPath}:\n\n` +
|
|
356
|
+
Object.entries(settingsToApply)
|
|
357
|
+
.map(([k, v]) => ` ${k} ${v}`)
|
|
358
|
+
.join("\n") +
|
|
359
|
+
`\n\nSed commands:\n${sedCommands.map((c) => ` ${c}`).join("\n")}` +
|
|
360
|
+
`\n\nAppend commands:\n${grepCommands.map((c) => ` ${c}`).join("\n")}` +
|
|
361
|
+
(params.restart_sshd
|
|
362
|
+
? "\n\nWould also restart sshd."
|
|
363
|
+
: "\n\nsshd will NOT be restarted.")),
|
|
364
|
+
],
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Backup the config file first
|
|
368
|
+
let backupPath;
|
|
369
|
+
try {
|
|
370
|
+
backupPath = backupFile(configPath);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
await executeCommand({
|
|
374
|
+
command: "sudo",
|
|
375
|
+
args: ["cp", configPath, `${configPath}.bak.${Date.now()}`],
|
|
376
|
+
toolName: "access_ssh",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// Apply sed replacements for existing settings
|
|
380
|
+
for (const [key, value] of Object.entries(settingsToApply)) {
|
|
381
|
+
await executeCommand({
|
|
382
|
+
command: "sudo",
|
|
383
|
+
args: [
|
|
384
|
+
"sed",
|
|
385
|
+
"-i",
|
|
386
|
+
`s|^#*\\s*${key}\\s.*|${key} ${value}|`,
|
|
387
|
+
configPath,
|
|
388
|
+
],
|
|
389
|
+
toolName: "access_ssh",
|
|
390
|
+
timeout: getToolTimeout("access_ssh"),
|
|
391
|
+
});
|
|
392
|
+
const grepResult = await executeCommand({
|
|
393
|
+
command: "grep",
|
|
394
|
+
args: ["-q", `^${key}\\s`, configPath],
|
|
395
|
+
toolName: "access_ssh",
|
|
396
|
+
});
|
|
397
|
+
if (grepResult.exitCode !== 0) {
|
|
398
|
+
await executeCommand({
|
|
399
|
+
command: "sudo",
|
|
400
|
+
args: ["tee", "-a", configPath],
|
|
401
|
+
stdin: `${key} ${value}\n`,
|
|
402
|
+
toolName: "access_ssh",
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Validate the config
|
|
407
|
+
const testResult = await executeCommand({
|
|
408
|
+
command: "sudo",
|
|
409
|
+
args: ["sshd", "-t"],
|
|
410
|
+
toolName: "access_ssh",
|
|
411
|
+
timeout: getToolTimeout("access_ssh"),
|
|
412
|
+
});
|
|
413
|
+
if (testResult.exitCode !== 0) {
|
|
414
|
+
const entry = createChangeEntry({
|
|
415
|
+
tool: "access_ssh",
|
|
416
|
+
action: "Apply SSH hardening (config validation FAILED)",
|
|
417
|
+
target: configPath,
|
|
418
|
+
after: JSON.stringify(settingsToApply),
|
|
419
|
+
backupPath,
|
|
420
|
+
dryRun: false,
|
|
421
|
+
success: false,
|
|
422
|
+
error: `sshd -t failed: ${testResult.stderr}`,
|
|
423
|
+
rollbackCommand: backupPath
|
|
424
|
+
? `sudo cp ${backupPath} ${configPath}`
|
|
425
|
+
: undefined,
|
|
426
|
+
});
|
|
427
|
+
logChange(entry);
|
|
428
|
+
return {
|
|
429
|
+
content: [
|
|
430
|
+
createErrorContent(`SSH config validation failed after changes. ` +
|
|
431
|
+
`Config may be invalid.\n${testResult.stderr}\n\n` +
|
|
432
|
+
(backupPath
|
|
433
|
+
? `Rollback: sudo cp ${backupPath} ${configPath}`
|
|
434
|
+
: "Manual rollback may be required.")),
|
|
435
|
+
],
|
|
436
|
+
isError: true,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
// Restart sshd if requested
|
|
440
|
+
let restartOutput = "";
|
|
441
|
+
if (params.restart_sshd) {
|
|
442
|
+
const restartResult = await executeCommand({
|
|
443
|
+
command: "sudo",
|
|
444
|
+
args: ["systemctl", "restart", "sshd"],
|
|
445
|
+
toolName: "access_ssh",
|
|
446
|
+
timeout: getToolTimeout("access_ssh"),
|
|
447
|
+
});
|
|
448
|
+
restartOutput =
|
|
449
|
+
restartResult.exitCode === 0
|
|
450
|
+
? "sshd restarted successfully."
|
|
451
|
+
: `sshd restart failed: ${restartResult.stderr}`;
|
|
452
|
+
}
|
|
453
|
+
const entry = createChangeEntry({
|
|
454
|
+
tool: "access_ssh",
|
|
455
|
+
action: "Apply SSH hardening",
|
|
456
|
+
target: configPath,
|
|
457
|
+
after: JSON.stringify(settingsToApply),
|
|
458
|
+
backupPath,
|
|
459
|
+
dryRun: false,
|
|
460
|
+
success: true,
|
|
461
|
+
rollbackCommand: backupPath
|
|
462
|
+
? `sudo cp ${backupPath} ${configPath} && sudo systemctl restart sshd`
|
|
463
|
+
: undefined,
|
|
464
|
+
});
|
|
465
|
+
logChange(entry);
|
|
466
|
+
return {
|
|
467
|
+
content: [
|
|
468
|
+
createTextContent(`SSH hardening applied to ${configPath}.\n\n` +
|
|
469
|
+
`Settings applied:\n${Object.entries(settingsToApply).map(([k, v]) => ` ${k} = ${v}`).join("\n")}` +
|
|
470
|
+
(backupPath ? `\n\nBackup: ${backupPath}` : "") +
|
|
471
|
+
`\nConfig validation: passed` +
|
|
472
|
+
(restartOutput ? `\n${restartOutput}` : "\nsshd NOT restarted.")),
|
|
473
|
+
],
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
478
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
// ── cipher_audit ─────────────────────────────────────────────
|
|
482
|
+
case "cipher_audit": {
|
|
483
|
+
try {
|
|
484
|
+
const config_path = params.config_path ?? "/etc/ssh/sshd_config";
|
|
485
|
+
// Read the SSH config
|
|
486
|
+
const result = await executeCommand({
|
|
487
|
+
command: "cat",
|
|
488
|
+
args: [config_path],
|
|
489
|
+
timeout: 10000,
|
|
490
|
+
toolName: "access_ssh",
|
|
491
|
+
});
|
|
492
|
+
const config = result.stdout;
|
|
493
|
+
// Also get runtime config if possible
|
|
494
|
+
const runtimeResult = await executeCommand({
|
|
495
|
+
command: "sudo",
|
|
496
|
+
args: ["sshd", "-T"],
|
|
497
|
+
timeout: 10000,
|
|
498
|
+
toolName: "access_ssh",
|
|
499
|
+
});
|
|
500
|
+
const runtimeConfig = runtimeResult.exitCode === 0 ? runtimeResult.stdout : "";
|
|
501
|
+
// Define weak algorithms per Mozilla Modern guidelines
|
|
502
|
+
const WEAK_KEXALGORITHMS = [
|
|
503
|
+
"diffie-hellman-group1-sha1",
|
|
504
|
+
"diffie-hellman-group14-sha1",
|
|
505
|
+
"diffie-hellman-group-exchange-sha1",
|
|
506
|
+
"ecdh-sha2-nistp256",
|
|
507
|
+
"ecdh-sha2-nistp384",
|
|
508
|
+
"ecdh-sha2-nistp521",
|
|
509
|
+
];
|
|
510
|
+
const RECOMMENDED_KEXALGORITHMS = [
|
|
511
|
+
"sntrup761x25519-sha512@openssh.com",
|
|
512
|
+
"curve25519-sha256",
|
|
513
|
+
"curve25519-sha256@libssh.org",
|
|
514
|
+
"diffie-hellman-group16-sha512",
|
|
515
|
+
"diffie-hellman-group18-sha512",
|
|
516
|
+
"diffie-hellman-group-exchange-sha256",
|
|
517
|
+
];
|
|
518
|
+
const WEAK_CIPHERS = [
|
|
519
|
+
"3des-cbc", "aes128-cbc", "aes192-cbc", "aes256-cbc",
|
|
520
|
+
"blowfish-cbc", "cast128-cbc", "arcfour", "arcfour128", "arcfour256",
|
|
521
|
+
"rijndael-cbc@lysator.liu.se",
|
|
522
|
+
];
|
|
523
|
+
const RECOMMENDED_CIPHERS = [
|
|
524
|
+
"chacha20-poly1305@openssh.com",
|
|
525
|
+
"aes256-gcm@openssh.com",
|
|
526
|
+
"aes128-gcm@openssh.com",
|
|
527
|
+
"aes256-ctr",
|
|
528
|
+
"aes192-ctr",
|
|
529
|
+
"aes128-ctr",
|
|
530
|
+
];
|
|
531
|
+
const WEAK_MACS = [
|
|
532
|
+
"hmac-md5", "hmac-md5-96", "hmac-sha1", "hmac-sha1-96",
|
|
533
|
+
"umac-64@openssh.com", "hmac-ripemd160",
|
|
534
|
+
"hmac-sha1-etm@openssh.com", "hmac-md5-etm@openssh.com",
|
|
535
|
+
];
|
|
536
|
+
const RECOMMENDED_MACS = [
|
|
537
|
+
"hmac-sha2-512-etm@openssh.com",
|
|
538
|
+
"hmac-sha2-256-etm@openssh.com",
|
|
539
|
+
"umac-128-etm@openssh.com",
|
|
540
|
+
"hmac-sha2-512",
|
|
541
|
+
"hmac-sha2-256",
|
|
542
|
+
];
|
|
543
|
+
const WEAK_HOSTKEYS = [
|
|
544
|
+
"ssh-dss", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
|
|
545
|
+
];
|
|
546
|
+
const RECOMMENDED_HOSTKEYS = [
|
|
547
|
+
"ssh-ed25519",
|
|
548
|
+
"ssh-ed25519-cert-v01@openssh.com",
|
|
549
|
+
"sk-ssh-ed25519@openssh.com",
|
|
550
|
+
"rsa-sha2-512",
|
|
551
|
+
"rsa-sha2-256",
|
|
552
|
+
];
|
|
553
|
+
function getAlgorithms(key) {
|
|
554
|
+
const runtimeMatch = runtimeConfig.match(new RegExp(`^${key}\\s+(.+)$`, "mi"));
|
|
555
|
+
if (runtimeMatch)
|
|
556
|
+
return runtimeMatch[1].split(",").map(s => s.trim());
|
|
557
|
+
const configMatch = config.match(new RegExp(`^\\s*${key}\\s+(.+)$`, "mi"));
|
|
558
|
+
if (configMatch)
|
|
559
|
+
return configMatch[1].split(",").map(s => s.trim());
|
|
560
|
+
return [];
|
|
561
|
+
}
|
|
562
|
+
const findings = [];
|
|
563
|
+
const checks = [
|
|
564
|
+
{ key: "KexAlgorithms", label: "Key Exchange", weak: WEAK_KEXALGORITHMS, recommended: RECOMMENDED_KEXALGORITHMS },
|
|
565
|
+
{ key: "Ciphers", label: "Ciphers", weak: WEAK_CIPHERS, recommended: RECOMMENDED_CIPHERS },
|
|
566
|
+
{ key: "MACs", label: "MACs", weak: WEAK_MACS, recommended: RECOMMENDED_MACS },
|
|
567
|
+
{ key: "HostKeyAlgorithms", label: "Host Key Algorithms", weak: WEAK_HOSTKEYS, recommended: RECOMMENDED_HOSTKEYS },
|
|
568
|
+
];
|
|
569
|
+
for (const check of checks) {
|
|
570
|
+
const configured = getAlgorithms(check.key);
|
|
571
|
+
const weakFound = configured.filter(a => check.weak.includes(a));
|
|
572
|
+
let status = "PASS";
|
|
573
|
+
let note = "";
|
|
574
|
+
if (configured.length === 0) {
|
|
575
|
+
status = "WARN";
|
|
576
|
+
note = `${check.key} not explicitly set — using system defaults which may include weak algorithms`;
|
|
577
|
+
}
|
|
578
|
+
else if (weakFound.length > 0) {
|
|
579
|
+
status = "FAIL";
|
|
580
|
+
note = `Found ${weakFound.length} weak algorithm(s): ${weakFound.join(", ")}`;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
note = `All ${configured.length} configured algorithms are acceptable`;
|
|
584
|
+
}
|
|
585
|
+
findings.push({
|
|
586
|
+
category: check.label,
|
|
587
|
+
status,
|
|
588
|
+
configured,
|
|
589
|
+
weak_found: weakFound,
|
|
590
|
+
recommended: check.recommended,
|
|
591
|
+
note,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
// Check SSH host key files
|
|
595
|
+
const hostKeyCheck = await executeCommand({
|
|
596
|
+
command: "ls",
|
|
597
|
+
args: ["-la", "/etc/ssh/"],
|
|
598
|
+
timeout: 5000,
|
|
599
|
+
toolName: "access_ssh",
|
|
600
|
+
});
|
|
601
|
+
const hasDSA = hostKeyCheck.stdout.includes("ssh_host_dsa_key");
|
|
602
|
+
const hasECDSA = hostKeyCheck.stdout.includes("ssh_host_ecdsa_key");
|
|
603
|
+
const hasED25519 = hostKeyCheck.stdout.includes("ssh_host_ed25519_key");
|
|
604
|
+
const hasRSA = hostKeyCheck.stdout.includes("ssh_host_rsa_key");
|
|
605
|
+
const hostKeyFindings = [];
|
|
606
|
+
if (hasDSA)
|
|
607
|
+
hostKeyFindings.push({ key: "DSA", status: "FAIL", note: "DSA host key present — should be removed" });
|
|
608
|
+
if (hasECDSA)
|
|
609
|
+
hostKeyFindings.push({ key: "ECDSA", status: "WARN", note: "ECDSA host key present — consider ED25519 only" });
|
|
610
|
+
if (hasED25519)
|
|
611
|
+
hostKeyFindings.push({ key: "ED25519", status: "PASS", note: "ED25519 host key present — recommended" });
|
|
612
|
+
if (hasRSA)
|
|
613
|
+
hostKeyFindings.push({ key: "RSA", status: "PASS", note: "RSA host key present — acceptable with RSA-SHA2" });
|
|
614
|
+
const passCount = findings.filter(f => f.status === "PASS").length;
|
|
615
|
+
const failCount = findings.filter(f => f.status === "FAIL").length;
|
|
616
|
+
const warnCount = findings.filter(f => f.status === "WARN").length;
|
|
617
|
+
return {
|
|
618
|
+
content: [createTextContent(JSON.stringify({
|
|
619
|
+
summary: {
|
|
620
|
+
algorithmChecks: findings.length,
|
|
621
|
+
pass: passCount,
|
|
622
|
+
fail: failCount,
|
|
623
|
+
warn: warnCount,
|
|
624
|
+
hostKeys: hostKeyFindings,
|
|
625
|
+
},
|
|
626
|
+
algorithmAudit: findings,
|
|
627
|
+
hostKeyAudit: hostKeyFindings,
|
|
628
|
+
recommendation: failCount > 0
|
|
629
|
+
? "CRITICAL: Weak SSH algorithms detected. Apply Mozilla Modern SSH configuration immediately."
|
|
630
|
+
: warnCount > 0
|
|
631
|
+
? "WARNING: SSH algorithms not explicitly configured. Set explicit algorithms in sshd_config."
|
|
632
|
+
: "PASS: SSH cryptographic configuration meets modern standards.",
|
|
633
|
+
}, null, 2))],
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
catch (error) {
|
|
637
|
+
return {
|
|
638
|
+
content: [createErrorContent(error instanceof Error ? error.message : String(error))],
|
|
639
|
+
isError: true,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
default:
|
|
644
|
+
return { content: [createErrorContent(`Unknown action: ${action}`)], isError: true };
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
// ── 2. access_pam (merged: access_pam_audit, access_pam_configure) ──
|
|
648
|
+
server.tool("access_pam", "PAM configuration security. Actions: audit=check PAM config for issues, configure=set up pam_pwquality or pam_faillock", {
|
|
649
|
+
action: z
|
|
650
|
+
.enum(["audit", "configure"])
|
|
651
|
+
.describe("Action: audit=check PAM config, configure=set up PAM modules"),
|
|
652
|
+
// audit params
|
|
653
|
+
service: z
|
|
654
|
+
.string()
|
|
655
|
+
.optional()
|
|
656
|
+
.describe("Specific PAM service to audit, e.g. 'sshd', 'login', 'sudo' (for audit)"),
|
|
657
|
+
check_all: z
|
|
658
|
+
.boolean()
|
|
659
|
+
.optional()
|
|
660
|
+
.default(false)
|
|
661
|
+
.describe("Check common-auth, common-password, etc. (for audit)"),
|
|
662
|
+
// configure params
|
|
663
|
+
module: z
|
|
664
|
+
.enum(["pwquality", "faillock"])
|
|
665
|
+
.optional()
|
|
666
|
+
.describe("PAM module to configure (required for configure)"),
|
|
667
|
+
settings: z
|
|
668
|
+
.object({
|
|
669
|
+
// pwquality settings
|
|
670
|
+
minlen: z.number().optional(),
|
|
671
|
+
dcredit: z.number().optional(),
|
|
672
|
+
ucredit: z.number().optional(),
|
|
673
|
+
lcredit: z.number().optional(),
|
|
674
|
+
ocredit: z.number().optional(),
|
|
675
|
+
minclass: z.number().optional(),
|
|
676
|
+
maxrepeat: z.number().optional(),
|
|
677
|
+
reject_username: z.boolean().optional(),
|
|
678
|
+
// faillock settings
|
|
679
|
+
deny: z.number().optional(),
|
|
680
|
+
unlock_time: z.number().optional(),
|
|
681
|
+
fail_interval: z.number().optional(),
|
|
682
|
+
})
|
|
683
|
+
.optional()
|
|
684
|
+
.describe("Module-specific settings (for configure)"),
|
|
685
|
+
// shared
|
|
686
|
+
dry_run: z
|
|
687
|
+
.boolean()
|
|
688
|
+
.optional()
|
|
689
|
+
.default(true)
|
|
690
|
+
.describe("Preview changes (for configure)"),
|
|
691
|
+
}, async (params) => {
|
|
692
|
+
const { action } = params;
|
|
693
|
+
switch (action) {
|
|
694
|
+
// ── audit ────────────────────────────────────────────────────
|
|
695
|
+
case "audit": {
|
|
696
|
+
try {
|
|
697
|
+
const filesToCheck = [];
|
|
698
|
+
if (params.service) {
|
|
699
|
+
filesToCheck.push(`/etc/pam.d/${params.service}`);
|
|
700
|
+
}
|
|
701
|
+
if (params.check_all) {
|
|
702
|
+
const daPam = await getDistroAdapter();
|
|
703
|
+
filesToCheck.push(...daPam.paths.pamAllConfigs);
|
|
704
|
+
}
|
|
705
|
+
if (filesToCheck.length === 0) {
|
|
706
|
+
return {
|
|
707
|
+
content: [
|
|
708
|
+
createErrorContent("Specify a 'service' name or set 'check_all' to true."),
|
|
709
|
+
],
|
|
710
|
+
isError: true,
|
|
711
|
+
};
|
|
712
|
+
}
|
|
713
|
+
const uniqueFiles = [...new Set(filesToCheck)];
|
|
714
|
+
const fileContents = {};
|
|
715
|
+
let unreadableCount = 0;
|
|
716
|
+
for (const filePath of uniqueFiles) {
|
|
717
|
+
const result = await executeCommand({
|
|
718
|
+
command: "sudo",
|
|
719
|
+
args: ["cat", filePath],
|
|
720
|
+
toolName: "access_pam",
|
|
721
|
+
timeout: getToolTimeout("access_pam"),
|
|
722
|
+
});
|
|
723
|
+
if (result.exitCode === 0) {
|
|
724
|
+
fileContents[filePath] = result.stdout;
|
|
725
|
+
}
|
|
726
|
+
else {
|
|
727
|
+
fileContents[filePath] = `[ERROR: ${result.stderr.trim()}]`;
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const findings = [];
|
|
731
|
+
for (const [filePath, content] of Object.entries(fileContents)) {
|
|
732
|
+
if (content.startsWith("[ERROR:")) {
|
|
733
|
+
unreadableCount++;
|
|
734
|
+
const isPermissionDenied = content.toLowerCase().includes("permission denied") ||
|
|
735
|
+
content.toLowerCase().includes("operation not permitted");
|
|
736
|
+
findings.push({
|
|
737
|
+
file: filePath,
|
|
738
|
+
type: "FILE_UNREADABLE",
|
|
739
|
+
severity: isPermissionDenied ? "warning" : "medium",
|
|
740
|
+
detail: isPermissionDenied
|
|
741
|
+
? `Permission denied — could not read ${filePath}. Results may be incomplete.`
|
|
742
|
+
: `Could not read ${filePath}: ${content}. Results may be incomplete.`,
|
|
743
|
+
});
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
// Check for password hashing algorithm
|
|
747
|
+
if (content.includes("pam_unix.so")) {
|
|
748
|
+
if (content.includes("sha512")) {
|
|
749
|
+
findings.push({
|
|
750
|
+
file: filePath,
|
|
751
|
+
type: "HASH_ALGORITHM",
|
|
752
|
+
severity: "info",
|
|
753
|
+
detail: "pam_unix.so is using SHA-512 hashing (good)",
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
else if (content.includes("md5")) {
|
|
757
|
+
findings.push({
|
|
758
|
+
file: filePath,
|
|
759
|
+
type: "HASH_ALGORITHM",
|
|
760
|
+
severity: "critical",
|
|
761
|
+
detail: "pam_unix.so is using MD5 hashing — upgrade to SHA-512",
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
else if (!content.includes("sha256") && !content.includes("sha512")) {
|
|
765
|
+
findings.push({
|
|
766
|
+
file: filePath,
|
|
767
|
+
type: "HASH_ALGORITHM",
|
|
768
|
+
severity: "medium",
|
|
769
|
+
detail: "pam_unix.so password hashing algorithm not explicitly set",
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
// Check for account lockout
|
|
774
|
+
const hasLockout = content.includes("pam_tally2") ||
|
|
775
|
+
content.includes("pam_faillock");
|
|
776
|
+
if (!hasLockout && (filePath.includes("common-auth") || filePath.includes("sshd"))) {
|
|
777
|
+
findings.push({
|
|
778
|
+
file: filePath,
|
|
779
|
+
type: "LOCKOUT_POLICY",
|
|
780
|
+
severity: "high",
|
|
781
|
+
detail: "No account lockout module (pam_tally2/pam_faillock) configured",
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
else if (hasLockout) {
|
|
785
|
+
findings.push({
|
|
786
|
+
file: filePath,
|
|
787
|
+
type: "LOCKOUT_POLICY",
|
|
788
|
+
severity: "info",
|
|
789
|
+
detail: "Account lockout module is present",
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
// Check for password complexity
|
|
793
|
+
const hasComplexity = content.includes("pam_pwquality") ||
|
|
794
|
+
content.includes("pam_cracklib");
|
|
795
|
+
if (!hasComplexity &&
|
|
796
|
+
(filePath.includes("common-password") || filePath.includes("passwd"))) {
|
|
797
|
+
findings.push({
|
|
798
|
+
file: filePath,
|
|
799
|
+
type: "PASSWORD_COMPLEXITY",
|
|
800
|
+
severity: "high",
|
|
801
|
+
detail: "No password complexity module (pam_pwquality/pam_cracklib) configured",
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
else if (hasComplexity) {
|
|
805
|
+
findings.push({
|
|
806
|
+
file: filePath,
|
|
807
|
+
type: "PASSWORD_COMPLEXITY",
|
|
808
|
+
severity: "info",
|
|
809
|
+
detail: "Password complexity module is present",
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
// Check for pam_limits.so
|
|
813
|
+
if (content.includes("pam_limits.so") &&
|
|
814
|
+
filePath.includes("common-session")) {
|
|
815
|
+
findings.push({
|
|
816
|
+
file: filePath,
|
|
817
|
+
type: "RESOURCE_LIMITS",
|
|
818
|
+
severity: "info",
|
|
819
|
+
detail: "pam_limits.so is configured for resource limits",
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
else if (!content.includes("pam_limits.so") &&
|
|
823
|
+
filePath.includes("common-session")) {
|
|
824
|
+
findings.push({
|
|
825
|
+
file: filePath,
|
|
826
|
+
type: "RESOURCE_LIMITS",
|
|
827
|
+
severity: "medium",
|
|
828
|
+
detail: "pam_limits.so is not configured — resource limits not enforced",
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
// Check for ordering issues
|
|
832
|
+
const lines = content
|
|
833
|
+
.split("\n")
|
|
834
|
+
.filter((l) => l.trim() && !l.trim().startsWith("#"));
|
|
835
|
+
let lastType = "";
|
|
836
|
+
for (const line of lines) {
|
|
837
|
+
const parts = line.trim().split(/\s+/);
|
|
838
|
+
const type = parts[0];
|
|
839
|
+
if (type === "account" && lastType === "session") {
|
|
840
|
+
findings.push({
|
|
841
|
+
file: filePath,
|
|
842
|
+
type: "PAM_ORDERING",
|
|
843
|
+
severity: "medium",
|
|
844
|
+
detail: "PAM ordering issue: 'account' entries found after 'session' entries",
|
|
845
|
+
});
|
|
846
|
+
break;
|
|
847
|
+
}
|
|
848
|
+
if (type)
|
|
849
|
+
lastType = type;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const entry = createChangeEntry({
|
|
853
|
+
tool: "access_pam",
|
|
854
|
+
action: `PAM audit${params.service ? ` (${params.service})` : ""}${params.check_all ? " (all common)" : ""}`,
|
|
855
|
+
target: uniqueFiles.join(", "),
|
|
856
|
+
after: `Findings: ${findings.length}`,
|
|
857
|
+
dryRun: false,
|
|
858
|
+
success: true,
|
|
859
|
+
});
|
|
860
|
+
logChange(entry);
|
|
861
|
+
const output = {
|
|
862
|
+
filesChecked: uniqueFiles,
|
|
863
|
+
totalFindings: findings.length,
|
|
864
|
+
unreadableFiles: unreadableCount,
|
|
865
|
+
findings,
|
|
866
|
+
...(unreadableCount > 0
|
|
867
|
+
? {
|
|
868
|
+
warning: `${unreadableCount} file(s) could not be read (insufficient permissions?). Audit results may be incomplete.`,
|
|
869
|
+
}
|
|
870
|
+
: {}),
|
|
871
|
+
fileContents: Object.fromEntries(Object.entries(fileContents).map(([k, v]) => [
|
|
872
|
+
k,
|
|
873
|
+
v.startsWith("[ERROR:") ? v : `${v.split("\n").length} lines`,
|
|
874
|
+
])),
|
|
875
|
+
};
|
|
876
|
+
return { content: [formatToolOutput(output)] };
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
880
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
// ── configure ────────────────────────────────────────────────
|
|
884
|
+
case "configure": {
|
|
885
|
+
try {
|
|
886
|
+
if (!params.module) {
|
|
887
|
+
return { content: [createErrorContent("Error: 'module' is required for configure action (pwquality or faillock)")], isError: true };
|
|
888
|
+
}
|
|
889
|
+
const pamModule = params.module;
|
|
890
|
+
const isDryRun = params.dry_run ?? getConfig().dryRun;
|
|
891
|
+
if (pamModule === "pwquality") {
|
|
892
|
+
const defaults = {
|
|
893
|
+
minlen: 14,
|
|
894
|
+
dcredit: -1,
|
|
895
|
+
ucredit: -1,
|
|
896
|
+
lcredit: -1,
|
|
897
|
+
ocredit: -1,
|
|
898
|
+
minclass: 3,
|
|
899
|
+
maxrepeat: 3,
|
|
900
|
+
reject_username: true,
|
|
901
|
+
};
|
|
902
|
+
const merged = {
|
|
903
|
+
minlen: params.settings?.minlen ?? defaults.minlen,
|
|
904
|
+
dcredit: params.settings?.dcredit ?? defaults.dcredit,
|
|
905
|
+
ucredit: params.settings?.ucredit ?? defaults.ucredit,
|
|
906
|
+
lcredit: params.settings?.lcredit ?? defaults.lcredit,
|
|
907
|
+
ocredit: params.settings?.ocredit ?? defaults.ocredit,
|
|
908
|
+
minclass: params.settings?.minclass ?? defaults.minclass,
|
|
909
|
+
maxrepeat: params.settings?.maxrepeat ?? defaults.maxrepeat,
|
|
910
|
+
reject_username: params.settings?.reject_username ?? defaults.reject_username,
|
|
911
|
+
};
|
|
912
|
+
const targetFile = "/etc/security/pwquality.conf";
|
|
913
|
+
const configLines = [
|
|
914
|
+
`minlen = ${merged.minlen}`,
|
|
915
|
+
`dcredit = ${merged.dcredit}`,
|
|
916
|
+
`ucredit = ${merged.ucredit}`,
|
|
917
|
+
`lcredit = ${merged.lcredit}`,
|
|
918
|
+
`ocredit = ${merged.ocredit}`,
|
|
919
|
+
`minclass = ${merged.minclass}`,
|
|
920
|
+
`maxrepeat = ${merged.maxrepeat}`,
|
|
921
|
+
merged.reject_username ? `reject_username` : `# reject_username`,
|
|
922
|
+
];
|
|
923
|
+
if (isDryRun) {
|
|
924
|
+
const entry = createChangeEntry({
|
|
925
|
+
tool: "access_pam",
|
|
926
|
+
action: "[DRY-RUN] Configure pam_pwquality",
|
|
927
|
+
target: targetFile,
|
|
928
|
+
after: JSON.stringify(merged),
|
|
929
|
+
dryRun: true,
|
|
930
|
+
success: true,
|
|
931
|
+
});
|
|
932
|
+
logChange(entry);
|
|
933
|
+
return {
|
|
934
|
+
content: [
|
|
935
|
+
createTextContent(`[DRY-RUN] Would write the following to ${targetFile}:\n\n` +
|
|
936
|
+
configLines.map((l) => ` ${l}`).join("\n")),
|
|
937
|
+
],
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
// Backup the target file
|
|
941
|
+
await executeCommand({
|
|
942
|
+
command: "sudo",
|
|
943
|
+
args: ["cp", targetFile, `${targetFile}.bak.${Date.now()}`],
|
|
944
|
+
toolName: "access_pam",
|
|
945
|
+
});
|
|
946
|
+
// Read current file content
|
|
947
|
+
const currentResult = await executeCommand({
|
|
948
|
+
command: "sudo",
|
|
949
|
+
args: ["cat", targetFile],
|
|
950
|
+
toolName: "access_pam",
|
|
951
|
+
});
|
|
952
|
+
let currentContent = currentResult.exitCode === 0 ? currentResult.stdout : "";
|
|
953
|
+
// Update each setting
|
|
954
|
+
for (const line of configLines) {
|
|
955
|
+
const key = line.split(/\s*=\s*/)[0].replace(/^#\s*/, "").trim();
|
|
956
|
+
const keyRegex = new RegExp(`^#?\\s*${key}(\\s*=|\\s|$)`, "m");
|
|
957
|
+
if (keyRegex.test(currentContent)) {
|
|
958
|
+
currentContent = currentContent.replace(new RegExp(`^#?\\s*${key}(\\s*=.*|\\s*)$`, "m"), line);
|
|
959
|
+
}
|
|
960
|
+
else {
|
|
961
|
+
currentContent += `\n${line}`;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
await executeCommand({
|
|
965
|
+
command: "sudo",
|
|
966
|
+
args: ["tee", targetFile],
|
|
967
|
+
toolName: "access_pam",
|
|
968
|
+
timeout: getToolTimeout("access_pam"),
|
|
969
|
+
stdin: currentContent,
|
|
970
|
+
});
|
|
971
|
+
const entry = createChangeEntry({
|
|
972
|
+
tool: "access_pam",
|
|
973
|
+
action: "Configure pam_pwquality",
|
|
974
|
+
target: targetFile,
|
|
975
|
+
after: JSON.stringify(merged),
|
|
976
|
+
dryRun: false,
|
|
977
|
+
success: true,
|
|
978
|
+
});
|
|
979
|
+
logChange(entry);
|
|
980
|
+
return {
|
|
981
|
+
content: [
|
|
982
|
+
createTextContent(`pam_pwquality configured in ${targetFile}:\n\n` +
|
|
983
|
+
configLines.map((l) => ` ${l}`).join("\n")),
|
|
984
|
+
],
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
// module === "faillock"
|
|
988
|
+
const defaults = {
|
|
989
|
+
deny: 5,
|
|
990
|
+
unlock_time: 900,
|
|
991
|
+
fail_interval: 900,
|
|
992
|
+
};
|
|
993
|
+
const merged = {
|
|
994
|
+
deny: params.settings?.deny ?? defaults.deny,
|
|
995
|
+
unlock_time: params.settings?.unlock_time ?? defaults.unlock_time,
|
|
996
|
+
fail_interval: params.settings?.fail_interval ?? defaults.fail_interval,
|
|
997
|
+
};
|
|
998
|
+
const targetFile = (await getDistroAdapter()).paths.pamAuth;
|
|
999
|
+
const failArgs = `deny=${merged.deny} unlock_time=${merged.unlock_time} fail_interval=${merged.fail_interval}`;
|
|
1000
|
+
const preLine = `auth required pam_faillock.so preauth silent ${failArgs}`;
|
|
1001
|
+
const authLine = `auth [default=die] pam_faillock.so authfail ${failArgs}`;
|
|
1002
|
+
if (isDryRun) {
|
|
1003
|
+
const entry = createChangeEntry({
|
|
1004
|
+
tool: "access_pam",
|
|
1005
|
+
action: "[DRY-RUN] Configure pam_faillock",
|
|
1006
|
+
target: targetFile,
|
|
1007
|
+
after: JSON.stringify(merged),
|
|
1008
|
+
dryRun: true,
|
|
1009
|
+
success: true,
|
|
1010
|
+
});
|
|
1011
|
+
logChange(entry);
|
|
1012
|
+
return {
|
|
1013
|
+
content: [
|
|
1014
|
+
createTextContent(`[DRY-RUN] Would add/update pam_faillock.so in ${targetFile}:\n\n` +
|
|
1015
|
+
` ${preLine}\n ${authLine}\n\n` +
|
|
1016
|
+
`Settings: ${JSON.stringify(merged)}`),
|
|
1017
|
+
],
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
// Backup the target file
|
|
1021
|
+
await executeCommand({
|
|
1022
|
+
command: "sudo",
|
|
1023
|
+
args: ["cp", targetFile, `${targetFile}.bak.${Date.now()}`],
|
|
1024
|
+
toolName: "access_pam",
|
|
1025
|
+
});
|
|
1026
|
+
// Remove existing pam_faillock lines first
|
|
1027
|
+
await executeCommand({
|
|
1028
|
+
command: "sudo",
|
|
1029
|
+
args: [
|
|
1030
|
+
"sed",
|
|
1031
|
+
"-i",
|
|
1032
|
+
"/pam_faillock\\.so/d",
|
|
1033
|
+
targetFile,
|
|
1034
|
+
],
|
|
1035
|
+
toolName: "access_pam",
|
|
1036
|
+
});
|
|
1037
|
+
// Insert preauth line before pam_unix.so auth line
|
|
1038
|
+
await executeCommand({
|
|
1039
|
+
command: "sudo",
|
|
1040
|
+
args: [
|
|
1041
|
+
"sed",
|
|
1042
|
+
"-i",
|
|
1043
|
+
`0,/pam_unix\\.so/s|.*pam_unix\\.so.*|${preLine}\\n&|`,
|
|
1044
|
+
targetFile,
|
|
1045
|
+
],
|
|
1046
|
+
toolName: "access_pam",
|
|
1047
|
+
});
|
|
1048
|
+
// Insert authfail line after pam_unix.so auth line
|
|
1049
|
+
await executeCommand({
|
|
1050
|
+
command: "sudo",
|
|
1051
|
+
args: [
|
|
1052
|
+
"sed",
|
|
1053
|
+
"-i",
|
|
1054
|
+
`0,/pam_unix\\.so/{/pam_unix\\.so/a\\${authLine}}`,
|
|
1055
|
+
targetFile,
|
|
1056
|
+
],
|
|
1057
|
+
toolName: "access_pam",
|
|
1058
|
+
});
|
|
1059
|
+
const entry = createChangeEntry({
|
|
1060
|
+
tool: "access_pam",
|
|
1061
|
+
action: "Configure pam_faillock",
|
|
1062
|
+
target: targetFile,
|
|
1063
|
+
after: JSON.stringify(merged),
|
|
1064
|
+
dryRun: false,
|
|
1065
|
+
success: true,
|
|
1066
|
+
});
|
|
1067
|
+
logChange(entry);
|
|
1068
|
+
return {
|
|
1069
|
+
content: [
|
|
1070
|
+
createTextContent(`pam_faillock configured in ${targetFile}:\n\n` +
|
|
1071
|
+
` ${preLine}\n ${authLine}\n\n` +
|
|
1072
|
+
`Settings: ${JSON.stringify(merged)}`),
|
|
1073
|
+
],
|
|
1074
|
+
};
|
|
1075
|
+
}
|
|
1076
|
+
catch (err) {
|
|
1077
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1078
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
default:
|
|
1082
|
+
return { content: [createErrorContent(`Unknown action: ${action}`)], isError: true };
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
// ── 3. access_sudo_audit (kept as-is) ─────────────────────────────────────
|
|
1086
|
+
server.tool("access_sudo_audit", "Audit sudoers configuration for security weaknesses", {
|
|
1087
|
+
check_nopasswd: z
|
|
1088
|
+
.boolean()
|
|
1089
|
+
.optional()
|
|
1090
|
+
.default(true)
|
|
1091
|
+
.describe("Check for NOPASSWD entries (default: true)"),
|
|
1092
|
+
check_insecure: z
|
|
1093
|
+
.boolean()
|
|
1094
|
+
.optional()
|
|
1095
|
+
.default(true)
|
|
1096
|
+
.describe("Check for insecure configurations (default: true)"),
|
|
1097
|
+
}, async ({ check_nopasswd, check_insecure }) => {
|
|
1098
|
+
try {
|
|
1099
|
+
// Read main sudoers file
|
|
1100
|
+
const sudoersResult = await executeCommand({
|
|
1101
|
+
command: "sudo",
|
|
1102
|
+
args: ["cat", "/etc/sudoers"],
|
|
1103
|
+
toolName: "access_sudo_audit",
|
|
1104
|
+
timeout: getToolTimeout("access_sudo_audit"),
|
|
1105
|
+
});
|
|
1106
|
+
if (sudoersResult.exitCode !== 0) {
|
|
1107
|
+
return {
|
|
1108
|
+
content: [
|
|
1109
|
+
createErrorContent(`Cannot read /etc/sudoers: ${sudoersResult.stderr}`),
|
|
1110
|
+
],
|
|
1111
|
+
isError: true,
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
// Read sudoers.d directory
|
|
1115
|
+
const sudoersDResult = await executeCommand({
|
|
1116
|
+
command: "sudo",
|
|
1117
|
+
args: ["ls", "/etc/sudoers.d/"],
|
|
1118
|
+
toolName: "access_sudo_audit",
|
|
1119
|
+
});
|
|
1120
|
+
const dropInFiles = sudoersDResult.stdout
|
|
1121
|
+
.split("\n")
|
|
1122
|
+
.map((l) => l.trim())
|
|
1123
|
+
.filter((l) => l.length > 0);
|
|
1124
|
+
// Read all drop-in files
|
|
1125
|
+
let allSudoersContent = sudoersResult.stdout;
|
|
1126
|
+
for (const file of dropInFiles) {
|
|
1127
|
+
const fileResult = await executeCommand({
|
|
1128
|
+
command: "sudo",
|
|
1129
|
+
args: ["cat", `/etc/sudoers.d/${file}`],
|
|
1130
|
+
toolName: "access_sudo_audit",
|
|
1131
|
+
});
|
|
1132
|
+
if (fileResult.exitCode === 0) {
|
|
1133
|
+
allSudoersContent += `\n# --- ${file} ---\n${fileResult.stdout}`;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const findings = [];
|
|
1137
|
+
const lines = allSudoersContent.split("\n");
|
|
1138
|
+
for (const line of lines) {
|
|
1139
|
+
const trimmed = line.trim();
|
|
1140
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
1141
|
+
continue;
|
|
1142
|
+
if (check_nopasswd && trimmed.includes("NOPASSWD")) {
|
|
1143
|
+
findings.push({
|
|
1144
|
+
type: "NOPASSWD",
|
|
1145
|
+
severity: "high",
|
|
1146
|
+
detail: "NOPASSWD allows sudo without password authentication",
|
|
1147
|
+
line: trimmed,
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
if (check_insecure &&
|
|
1151
|
+
trimmed.includes("ALL=(ALL)") &&
|
|
1152
|
+
trimmed.includes("ALL") &&
|
|
1153
|
+
!trimmed.startsWith("root")) {
|
|
1154
|
+
findings.push({
|
|
1155
|
+
type: "BROAD_PRIVILEGE",
|
|
1156
|
+
severity: "high",
|
|
1157
|
+
detail: "Non-root user has full sudo privileges",
|
|
1158
|
+
line: trimmed,
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
if (check_insecure && trimmed.includes("!authenticate")) {
|
|
1162
|
+
findings.push({
|
|
1163
|
+
type: "NO_AUTHENTICATE",
|
|
1164
|
+
severity: "critical",
|
|
1165
|
+
detail: "Authentication bypass in sudoers",
|
|
1166
|
+
line: trimmed,
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
// Check for missing security defaults
|
|
1171
|
+
const defaultChecks = [
|
|
1172
|
+
{ pattern: "env_reset", name: "Defaults env_reset", severity: "medium" },
|
|
1173
|
+
{ pattern: "secure_path", name: "Defaults secure_path", severity: "medium" },
|
|
1174
|
+
{ pattern: "logfile", name: "Defaults logfile", severity: "low" },
|
|
1175
|
+
];
|
|
1176
|
+
if (check_insecure) {
|
|
1177
|
+
for (const check of defaultChecks) {
|
|
1178
|
+
if (!allSudoersContent.includes(check.pattern)) {
|
|
1179
|
+
findings.push({
|
|
1180
|
+
type: "MISSING_DEFAULT",
|
|
1181
|
+
severity: check.severity,
|
|
1182
|
+
detail: `${check.name} is not configured`,
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
// List users with sudo access
|
|
1188
|
+
const sudoUsersResult = await executeCommand({
|
|
1189
|
+
command: "getent",
|
|
1190
|
+
args: ["group", "sudo"],
|
|
1191
|
+
toolName: "access_sudo_audit",
|
|
1192
|
+
});
|
|
1193
|
+
const sudoGroup = sudoUsersResult.stdout.trim();
|
|
1194
|
+
const entry = createChangeEntry({
|
|
1195
|
+
tool: "access_sudo_audit",
|
|
1196
|
+
action: "Sudoers configuration audit",
|
|
1197
|
+
target: "/etc/sudoers",
|
|
1198
|
+
after: `Findings: ${findings.length}`,
|
|
1199
|
+
dryRun: false,
|
|
1200
|
+
success: true,
|
|
1201
|
+
});
|
|
1202
|
+
logChange(entry);
|
|
1203
|
+
const output = {
|
|
1204
|
+
totalFindings: findings.length,
|
|
1205
|
+
dropInFiles,
|
|
1206
|
+
sudoGroup,
|
|
1207
|
+
findings,
|
|
1208
|
+
};
|
|
1209
|
+
return { content: [formatToolOutput(output)] };
|
|
1210
|
+
}
|
|
1211
|
+
catch (err) {
|
|
1212
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1213
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
1214
|
+
}
|
|
1215
|
+
});
|
|
1216
|
+
// ── 4. access_user_audit (kept as-is) ─────────────────────────────────────
|
|
1217
|
+
server.tool("access_user_audit", "Audit user accounts for security issues (privileged, inactive, no password, shells)", {
|
|
1218
|
+
check_type: z
|
|
1219
|
+
.enum(["all", "privileged", "inactive", "no_password", "shell", "locked"])
|
|
1220
|
+
.optional()
|
|
1221
|
+
.default("all")
|
|
1222
|
+
.describe("Type of user audit to perform (default: all)"),
|
|
1223
|
+
}, async ({ check_type }) => {
|
|
1224
|
+
try {
|
|
1225
|
+
const passwdResult = await executeCommand({
|
|
1226
|
+
command: "cat",
|
|
1227
|
+
args: ["/etc/passwd"],
|
|
1228
|
+
toolName: "access_user_audit",
|
|
1229
|
+
timeout: getToolTimeout("access_user_audit"),
|
|
1230
|
+
});
|
|
1231
|
+
const shadowResult = await executeCommand({
|
|
1232
|
+
command: "sudo",
|
|
1233
|
+
args: ["cat", "/etc/shadow"],
|
|
1234
|
+
toolName: "access_user_audit",
|
|
1235
|
+
});
|
|
1236
|
+
const lastlogResult = await executeCommand({
|
|
1237
|
+
command: "lastlog",
|
|
1238
|
+
args: [],
|
|
1239
|
+
toolName: "access_user_audit",
|
|
1240
|
+
});
|
|
1241
|
+
const users = passwdResult.stdout
|
|
1242
|
+
.split("\n")
|
|
1243
|
+
.filter((l) => l.trim().length > 0)
|
|
1244
|
+
.map((line) => {
|
|
1245
|
+
const parts = line.split(":");
|
|
1246
|
+
return {
|
|
1247
|
+
username: parts[0],
|
|
1248
|
+
uid: parseInt(parts[2], 10),
|
|
1249
|
+
gid: parseInt(parts[3], 10),
|
|
1250
|
+
gecos: parts[4] ?? "",
|
|
1251
|
+
home: parts[5] ?? "",
|
|
1252
|
+
shell: parts[6] ?? "",
|
|
1253
|
+
};
|
|
1254
|
+
});
|
|
1255
|
+
const shadowMap = {};
|
|
1256
|
+
if (shadowResult.exitCode === 0) {
|
|
1257
|
+
for (const line of shadowResult.stdout.split("\n")) {
|
|
1258
|
+
const parts = line.split(":");
|
|
1259
|
+
if (parts.length >= 2) {
|
|
1260
|
+
shadowMap[parts[0]] = parts[1];
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
const lastlogMap = {};
|
|
1265
|
+
for (const line of lastlogResult.stdout.split("\n").slice(1)) {
|
|
1266
|
+
const trimmed = line.trim();
|
|
1267
|
+
if (!trimmed)
|
|
1268
|
+
continue;
|
|
1269
|
+
const parts = trimmed.split(/\s+/);
|
|
1270
|
+
if (parts.length >= 1) {
|
|
1271
|
+
const username = parts[0];
|
|
1272
|
+
if (trimmed.includes("Never logged in")) {
|
|
1273
|
+
lastlogMap[username] = "never";
|
|
1274
|
+
}
|
|
1275
|
+
else {
|
|
1276
|
+
lastlogMap[username] = parts.slice(3).join(" ");
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
const nologinShells = [
|
|
1281
|
+
"/usr/sbin/nologin",
|
|
1282
|
+
"/bin/false",
|
|
1283
|
+
"/sbin/nologin",
|
|
1284
|
+
"/bin/nologin",
|
|
1285
|
+
];
|
|
1286
|
+
const loginShells = [
|
|
1287
|
+
"/bin/bash",
|
|
1288
|
+
"/bin/sh",
|
|
1289
|
+
"/bin/zsh",
|
|
1290
|
+
"/bin/fish",
|
|
1291
|
+
"/usr/bin/bash",
|
|
1292
|
+
"/usr/bin/zsh",
|
|
1293
|
+
"/usr/bin/fish",
|
|
1294
|
+
];
|
|
1295
|
+
const results = {};
|
|
1296
|
+
if (check_type === "all" || check_type === "privileged") {
|
|
1297
|
+
results.privileged = users
|
|
1298
|
+
.filter((u) => u.uid === 0)
|
|
1299
|
+
.map((u) => ({
|
|
1300
|
+
username: u.username,
|
|
1301
|
+
uid: u.uid,
|
|
1302
|
+
shell: u.shell,
|
|
1303
|
+
warning: u.username !== "root"
|
|
1304
|
+
? "NON-ROOT USER WITH UID 0!"
|
|
1305
|
+
: null,
|
|
1306
|
+
}));
|
|
1307
|
+
}
|
|
1308
|
+
if (check_type === "all" || check_type === "inactive") {
|
|
1309
|
+
results.inactive = users
|
|
1310
|
+
.filter((u) => {
|
|
1311
|
+
const lastLogin = lastlogMap[u.username];
|
|
1312
|
+
if (!lastLogin || lastLogin === "never")
|
|
1313
|
+
return true;
|
|
1314
|
+
const loginDate = new Date(lastLogin);
|
|
1315
|
+
const daysSince = (Date.now() - loginDate.getTime()) / (1000 * 60 * 60 * 24);
|
|
1316
|
+
return daysSince > 90;
|
|
1317
|
+
})
|
|
1318
|
+
.filter((u) => !nologinShells.includes(u.shell))
|
|
1319
|
+
.map((u) => ({
|
|
1320
|
+
username: u.username,
|
|
1321
|
+
uid: u.uid,
|
|
1322
|
+
lastLogin: lastlogMap[u.username] ?? "unknown",
|
|
1323
|
+
shell: u.shell,
|
|
1324
|
+
}));
|
|
1325
|
+
}
|
|
1326
|
+
if (check_type === "all" || check_type === "no_password") {
|
|
1327
|
+
results.no_password = users
|
|
1328
|
+
.filter((u) => {
|
|
1329
|
+
const hash = shadowMap[u.username];
|
|
1330
|
+
return hash === "" || hash === "!" || hash === "*" || hash === "!!";
|
|
1331
|
+
})
|
|
1332
|
+
.map((u) => ({
|
|
1333
|
+
username: u.username,
|
|
1334
|
+
uid: u.uid,
|
|
1335
|
+
passwordStatus: shadowMap[u.username] || "empty",
|
|
1336
|
+
shell: u.shell,
|
|
1337
|
+
}));
|
|
1338
|
+
}
|
|
1339
|
+
if (check_type === "all" || check_type === "shell") {
|
|
1340
|
+
const systemUsers = users.filter((u) => u.uid < 1000 && u.uid !== 0);
|
|
1341
|
+
results.shell = systemUsers
|
|
1342
|
+
.filter((u) => loginShells.includes(u.shell))
|
|
1343
|
+
.map((u) => ({
|
|
1344
|
+
username: u.username,
|
|
1345
|
+
uid: u.uid,
|
|
1346
|
+
shell: u.shell,
|
|
1347
|
+
warning: "System user has interactive login shell",
|
|
1348
|
+
}));
|
|
1349
|
+
}
|
|
1350
|
+
if (check_type === "all" || check_type === "locked") {
|
|
1351
|
+
results.locked = users
|
|
1352
|
+
.filter((u) => {
|
|
1353
|
+
const hash = shadowMap[u.username];
|
|
1354
|
+
return (hash?.startsWith("!") ||
|
|
1355
|
+
hash?.startsWith("*") ||
|
|
1356
|
+
nologinShells.includes(u.shell));
|
|
1357
|
+
})
|
|
1358
|
+
.map((u) => ({
|
|
1359
|
+
username: u.username,
|
|
1360
|
+
uid: u.uid,
|
|
1361
|
+
shell: u.shell,
|
|
1362
|
+
locked: shadowMap[u.username]?.startsWith("!") ?? false,
|
|
1363
|
+
nologin: nologinShells.includes(u.shell),
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
const totalFindings = Object.values(results).reduce((sum, arr) => sum + arr.length, 0);
|
|
1367
|
+
const entry = createChangeEntry({
|
|
1368
|
+
tool: "access_user_audit",
|
|
1369
|
+
action: `User account audit (${check_type})`,
|
|
1370
|
+
target: "/etc/passwd",
|
|
1371
|
+
after: `Total findings: ${totalFindings}`,
|
|
1372
|
+
dryRun: false,
|
|
1373
|
+
success: true,
|
|
1374
|
+
});
|
|
1375
|
+
logChange(entry);
|
|
1376
|
+
const output = {
|
|
1377
|
+
checkType: check_type,
|
|
1378
|
+
totalUsers: users.length,
|
|
1379
|
+
totalFindings,
|
|
1380
|
+
categories: results,
|
|
1381
|
+
};
|
|
1382
|
+
return { content: [formatToolOutput(output)] };
|
|
1383
|
+
}
|
|
1384
|
+
catch (err) {
|
|
1385
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1386
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
// ── 5. access_password_policy (kept as-is) ────────────────────────────────
|
|
1390
|
+
server.tool("access_password_policy", "Audit or set system password policy in /etc/login.defs and PAM", {
|
|
1391
|
+
action: z
|
|
1392
|
+
.enum(["audit", "set"])
|
|
1393
|
+
.describe("Audit current policy or set new values"),
|
|
1394
|
+
min_days: z
|
|
1395
|
+
.number()
|
|
1396
|
+
.optional()
|
|
1397
|
+
.describe("Minimum days between password changes (PASS_MIN_DAYS)"),
|
|
1398
|
+
max_days: z
|
|
1399
|
+
.number()
|
|
1400
|
+
.optional()
|
|
1401
|
+
.describe("Maximum days before password must be changed (PASS_MAX_DAYS)"),
|
|
1402
|
+
warn_days: z
|
|
1403
|
+
.number()
|
|
1404
|
+
.optional()
|
|
1405
|
+
.describe("Days before expiry to warn user (PASS_WARN_AGE)"),
|
|
1406
|
+
min_length: z
|
|
1407
|
+
.number()
|
|
1408
|
+
.optional()
|
|
1409
|
+
.describe("Minimum password length (PASS_MIN_LEN)"),
|
|
1410
|
+
inactive_days: z
|
|
1411
|
+
.number()
|
|
1412
|
+
.optional()
|
|
1413
|
+
.describe("Days after password expires before account is disabled (INACTIVE)"),
|
|
1414
|
+
encrypt_method: z
|
|
1415
|
+
.enum(["SHA512", "YESCRYPT"])
|
|
1416
|
+
.optional()
|
|
1417
|
+
.describe("Password hashing algorithm (ENCRYPT_METHOD)"),
|
|
1418
|
+
dry_run: z
|
|
1419
|
+
.boolean()
|
|
1420
|
+
.optional()
|
|
1421
|
+
.describe("Preview changes without executing (defaults to KALI_DEFENSE_DRY_RUN env var)"),
|
|
1422
|
+
}, async ({ action, min_days, max_days, warn_days, min_length, inactive_days, encrypt_method, dry_run }) => {
|
|
1423
|
+
try {
|
|
1424
|
+
if (action === "audit") {
|
|
1425
|
+
const loginDefsResult = await executeCommand({
|
|
1426
|
+
command: "cat",
|
|
1427
|
+
args: ["/etc/login.defs"],
|
|
1428
|
+
toolName: "access_password_policy",
|
|
1429
|
+
timeout: getToolTimeout("access_password_policy"),
|
|
1430
|
+
});
|
|
1431
|
+
const pamResult = await executeCommand({
|
|
1432
|
+
command: "cat",
|
|
1433
|
+
args: [(await getDistroAdapter()).paths.pamPassword],
|
|
1434
|
+
toolName: "access_password_policy",
|
|
1435
|
+
});
|
|
1436
|
+
const loginDefs = {};
|
|
1437
|
+
const passwordKeys = [
|
|
1438
|
+
"PASS_MAX_DAYS",
|
|
1439
|
+
"PASS_MIN_DAYS",
|
|
1440
|
+
"PASS_WARN_AGE",
|
|
1441
|
+
"PASS_MIN_LEN",
|
|
1442
|
+
"ENCRYPT_METHOD",
|
|
1443
|
+
"SHA_CRYPT_MIN_ROUNDS",
|
|
1444
|
+
"SHA_CRYPT_MAX_ROUNDS",
|
|
1445
|
+
"INACTIVE",
|
|
1446
|
+
];
|
|
1447
|
+
for (const line of loginDefsResult.stdout.split("\n")) {
|
|
1448
|
+
const trimmed = line.trim();
|
|
1449
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
1450
|
+
continue;
|
|
1451
|
+
const parts = trimmed.split(/\s+/);
|
|
1452
|
+
if (parts.length >= 2 && passwordKeys.includes(parts[0])) {
|
|
1453
|
+
loginDefs[parts[0]] = parts[1];
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
const pamModules = [
|
|
1457
|
+
{ module: "pam_pwquality", present: false },
|
|
1458
|
+
{ module: "pam_cracklib", present: false },
|
|
1459
|
+
{ module: "pam_unix", present: false },
|
|
1460
|
+
];
|
|
1461
|
+
if (pamResult.exitCode === 0) {
|
|
1462
|
+
for (const mod of pamModules) {
|
|
1463
|
+
mod.present = pamResult.stdout.includes(mod.module);
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const useraddResult = await executeCommand({
|
|
1467
|
+
command: "cat",
|
|
1468
|
+
args: ["/etc/default/useradd"],
|
|
1469
|
+
toolName: "access_password_policy",
|
|
1470
|
+
});
|
|
1471
|
+
let inactiveValue = "not set";
|
|
1472
|
+
if (useraddResult.exitCode === 0) {
|
|
1473
|
+
const inactiveMatch = useraddResult.stdout.match(/^INACTIVE=(.*)$/m);
|
|
1474
|
+
if (inactiveMatch) {
|
|
1475
|
+
inactiveValue = inactiveMatch[1].trim();
|
|
1476
|
+
loginDefs["INACTIVE"] = inactiveValue;
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
const recommendations = [];
|
|
1480
|
+
const maxDays = parseInt(loginDefs["PASS_MAX_DAYS"] ?? "99999", 10);
|
|
1481
|
+
const minDays = parseInt(loginDefs["PASS_MIN_DAYS"] ?? "0", 10);
|
|
1482
|
+
const warnAge = parseInt(loginDefs["PASS_WARN_AGE"] ?? "7", 10);
|
|
1483
|
+
if (maxDays > 365) {
|
|
1484
|
+
recommendations.push(`PASS_MAX_DAYS (${maxDays}) should be <= 365. Set to 365 or less (CIS recommends ≤365 for non-privileged, ≤90 for privileged)`);
|
|
1485
|
+
}
|
|
1486
|
+
if (minDays < 1) {
|
|
1487
|
+
recommendations.push(`PASS_MIN_DAYS (${minDays}) should be >= 1`);
|
|
1488
|
+
}
|
|
1489
|
+
if (warnAge < 7) {
|
|
1490
|
+
recommendations.push(`PASS_WARN_AGE (${warnAge}) should be >= 7`);
|
|
1491
|
+
}
|
|
1492
|
+
const encMethod = loginDefs["ENCRYPT_METHOD"] ?? "not set";
|
|
1493
|
+
if (encMethod !== "SHA512" && encMethod !== "YESCRYPT") {
|
|
1494
|
+
recommendations.push(`ENCRYPT_METHOD should be SHA512 or YESCRYPT (current: ${encMethod})`);
|
|
1495
|
+
}
|
|
1496
|
+
if (inactiveValue === "not set" || inactiveValue === "-1") {
|
|
1497
|
+
recommendations.push(`INACTIVE (${inactiveValue}) should be set to 30 or less to disable accounts after password expiry`);
|
|
1498
|
+
}
|
|
1499
|
+
if (!pamModules.find((m) => m.module === "pam_pwquality")?.present) {
|
|
1500
|
+
recommendations.push("pam_pwquality is not configured in PAM - password complexity not enforced");
|
|
1501
|
+
}
|
|
1502
|
+
const entry = createChangeEntry({
|
|
1503
|
+
tool: "access_password_policy",
|
|
1504
|
+
action: "Password policy audit",
|
|
1505
|
+
target: "/etc/login.defs",
|
|
1506
|
+
after: `Recommendations: ${recommendations.length}`,
|
|
1507
|
+
dryRun: false,
|
|
1508
|
+
success: true,
|
|
1509
|
+
});
|
|
1510
|
+
logChange(entry);
|
|
1511
|
+
const output = {
|
|
1512
|
+
loginDefs,
|
|
1513
|
+
pamModules,
|
|
1514
|
+
recommendations,
|
|
1515
|
+
};
|
|
1516
|
+
return { content: [formatToolOutput(output)] };
|
|
1517
|
+
}
|
|
1518
|
+
// action === "set"
|
|
1519
|
+
const settingsToApply = {};
|
|
1520
|
+
if (min_days !== undefined)
|
|
1521
|
+
settingsToApply["PASS_MIN_DAYS"] = String(min_days);
|
|
1522
|
+
if (max_days !== undefined)
|
|
1523
|
+
settingsToApply["PASS_MAX_DAYS"] = String(max_days);
|
|
1524
|
+
if (warn_days !== undefined)
|
|
1525
|
+
settingsToApply["PASS_WARN_AGE"] = String(warn_days);
|
|
1526
|
+
if (min_length !== undefined)
|
|
1527
|
+
settingsToApply["PASS_MIN_LEN"] = String(min_length);
|
|
1528
|
+
if (encrypt_method !== undefined)
|
|
1529
|
+
settingsToApply["ENCRYPT_METHOD"] = encrypt_method;
|
|
1530
|
+
const extraCommands = [];
|
|
1531
|
+
if (inactive_days !== undefined) {
|
|
1532
|
+
extraCommands.push(`sudo useradd -D -f ${inactive_days}`);
|
|
1533
|
+
extraCommands.push(`sudo sed -i 's/^INACTIVE.*/INACTIVE=${inactive_days}/' /etc/default/useradd || echo 'INACTIVE=${inactive_days}' | sudo tee -a /etc/default/useradd`);
|
|
1534
|
+
}
|
|
1535
|
+
if (Object.keys(settingsToApply).length === 0 && extraCommands.length === 0) {
|
|
1536
|
+
return {
|
|
1537
|
+
content: [
|
|
1538
|
+
createErrorContent("No password policy values specified to set."),
|
|
1539
|
+
],
|
|
1540
|
+
isError: true,
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
const sedCommands = [];
|
|
1544
|
+
for (const [key, value] of Object.entries(settingsToApply)) {
|
|
1545
|
+
sedCommands.push(`sudo sed -i 's/^#*\\s*${key}\\s.*/${key}\\t${value}/' /etc/login.defs`);
|
|
1546
|
+
}
|
|
1547
|
+
if (dry_run ?? getConfig().dryRun) {
|
|
1548
|
+
const allSettings = { ...settingsToApply };
|
|
1549
|
+
if (inactive_days !== undefined)
|
|
1550
|
+
allSettings["INACTIVE"] = String(inactive_days);
|
|
1551
|
+
const entry = createChangeEntry({
|
|
1552
|
+
tool: "access_password_policy",
|
|
1553
|
+
action: "[DRY-RUN] Set password policy",
|
|
1554
|
+
target: "/etc/login.defs",
|
|
1555
|
+
after: JSON.stringify(allSettings),
|
|
1556
|
+
dryRun: true,
|
|
1557
|
+
success: true,
|
|
1558
|
+
});
|
|
1559
|
+
logChange(entry);
|
|
1560
|
+
return {
|
|
1561
|
+
content: [
|
|
1562
|
+
createTextContent(`[DRY-RUN] Would apply the following password policy to /etc/login.defs:\n\n` +
|
|
1563
|
+
Object.entries(allSettings)
|
|
1564
|
+
.map(([k, v]) => ` ${k} = ${v}`)
|
|
1565
|
+
.join("\n") +
|
|
1566
|
+
`\n\nSed commands:\n${sedCommands.map((c) => ` ${c}`).join("\n")}` +
|
|
1567
|
+
(extraCommands.length > 0
|
|
1568
|
+
? `\n\nExtra commands:\n${extraCommands.map((c) => ` ${c}`).join("\n")}`
|
|
1569
|
+
: "")),
|
|
1570
|
+
],
|
|
1571
|
+
};
|
|
1572
|
+
}
|
|
1573
|
+
// Backup first
|
|
1574
|
+
let backupPath;
|
|
1575
|
+
try {
|
|
1576
|
+
backupPath = backupFile("/etc/login.defs");
|
|
1577
|
+
}
|
|
1578
|
+
catch {
|
|
1579
|
+
await executeCommand({
|
|
1580
|
+
command: "sudo",
|
|
1581
|
+
args: [
|
|
1582
|
+
"cp",
|
|
1583
|
+
"/etc/login.defs",
|
|
1584
|
+
`/etc/login.defs.bak.${Date.now()}`,
|
|
1585
|
+
],
|
|
1586
|
+
toolName: "access_password_policy",
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
// Apply login.defs changes
|
|
1590
|
+
for (const [key, value] of Object.entries(settingsToApply)) {
|
|
1591
|
+
await executeCommand({
|
|
1592
|
+
command: "sudo",
|
|
1593
|
+
args: [
|
|
1594
|
+
"sed",
|
|
1595
|
+
"-i",
|
|
1596
|
+
`s/^#*\\s*${key}\\s.*/${key}\\t${value}/`,
|
|
1597
|
+
"/etc/login.defs",
|
|
1598
|
+
],
|
|
1599
|
+
toolName: "access_password_policy",
|
|
1600
|
+
timeout: getToolTimeout("access_password_policy"),
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
// Apply INACTIVE setting if provided
|
|
1604
|
+
if (inactive_days !== undefined) {
|
|
1605
|
+
await executeCommand({
|
|
1606
|
+
command: "sudo",
|
|
1607
|
+
args: ["useradd", "-D", "-f", String(inactive_days)],
|
|
1608
|
+
toolName: "access_password_policy",
|
|
1609
|
+
timeout: getToolTimeout("access_password_policy"),
|
|
1610
|
+
});
|
|
1611
|
+
const grepInactive = await executeCommand({
|
|
1612
|
+
command: "grep",
|
|
1613
|
+
args: ["-q", "^INACTIVE", "/etc/default/useradd"],
|
|
1614
|
+
toolName: "access_password_policy",
|
|
1615
|
+
});
|
|
1616
|
+
if (grepInactive.exitCode === 0) {
|
|
1617
|
+
await executeCommand({
|
|
1618
|
+
command: "sudo",
|
|
1619
|
+
args: [
|
|
1620
|
+
"sed",
|
|
1621
|
+
"-i",
|
|
1622
|
+
`s/^INACTIVE.*/INACTIVE=${inactive_days}/`,
|
|
1623
|
+
"/etc/default/useradd",
|
|
1624
|
+
],
|
|
1625
|
+
toolName: "access_password_policy",
|
|
1626
|
+
});
|
|
1627
|
+
}
|
|
1628
|
+
else {
|
|
1629
|
+
await executeCommand({
|
|
1630
|
+
command: "sudo",
|
|
1631
|
+
args: ["tee", "-a", "/etc/default/useradd"],
|
|
1632
|
+
toolName: "access_password_policy",
|
|
1633
|
+
stdin: `INACTIVE=${inactive_days}\n`,
|
|
1634
|
+
});
|
|
1635
|
+
}
|
|
1636
|
+
}
|
|
1637
|
+
const allApplied = { ...settingsToApply };
|
|
1638
|
+
if (inactive_days !== undefined)
|
|
1639
|
+
allApplied["INACTIVE"] = String(inactive_days);
|
|
1640
|
+
const entry = createChangeEntry({
|
|
1641
|
+
tool: "access_password_policy",
|
|
1642
|
+
action: "Set password policy",
|
|
1643
|
+
target: "/etc/login.defs",
|
|
1644
|
+
after: JSON.stringify(allApplied),
|
|
1645
|
+
backupPath,
|
|
1646
|
+
dryRun: false,
|
|
1647
|
+
success: true,
|
|
1648
|
+
rollbackCommand: backupPath
|
|
1649
|
+
? `sudo cp ${backupPath} /etc/login.defs`
|
|
1650
|
+
: undefined,
|
|
1651
|
+
});
|
|
1652
|
+
logChange(entry);
|
|
1653
|
+
return {
|
|
1654
|
+
content: [
|
|
1655
|
+
createTextContent(`Password policy updated in /etc/login.defs:\n\n` +
|
|
1656
|
+
Object.entries(allApplied)
|
|
1657
|
+
.map(([k, v]) => ` ${k} = ${v}`)
|
|
1658
|
+
.join("\n") +
|
|
1659
|
+
(backupPath ? `\n\nBackup: ${backupPath}` : "")),
|
|
1660
|
+
],
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
1663
|
+
catch (err) {
|
|
1664
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1665
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
1666
|
+
}
|
|
1667
|
+
});
|
|
1668
|
+
// ── 6. access_restrict_shell (kept as-is) ─────────────────────────────────
|
|
1669
|
+
server.tool("access_restrict_shell", "Restrict a user's login shell (e.g., set service accounts to /usr/sbin/nologin)", {
|
|
1670
|
+
username: z
|
|
1671
|
+
.string()
|
|
1672
|
+
.describe("The username to restrict"),
|
|
1673
|
+
shell: z
|
|
1674
|
+
.string()
|
|
1675
|
+
.optional()
|
|
1676
|
+
.default("/usr/sbin/nologin")
|
|
1677
|
+
.describe("Shell to set (default: /usr/sbin/nologin)"),
|
|
1678
|
+
dry_run: z
|
|
1679
|
+
.boolean()
|
|
1680
|
+
.optional()
|
|
1681
|
+
.describe("Preview changes without executing (defaults to KALI_DEFENSE_DRY_RUN env var)"),
|
|
1682
|
+
}, async ({ username, shell, dry_run }) => {
|
|
1683
|
+
try {
|
|
1684
|
+
// Validate username
|
|
1685
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(username)) {
|
|
1686
|
+
return {
|
|
1687
|
+
content: [
|
|
1688
|
+
createErrorContent(`Invalid username '${username}'. Must match /^[a-z_][a-z0-9_-]{0,31}$/.`),
|
|
1689
|
+
],
|
|
1690
|
+
isError: true,
|
|
1691
|
+
};
|
|
1692
|
+
}
|
|
1693
|
+
// Validate shell path
|
|
1694
|
+
if (!/^\/[a-z\/]+$/.test(shell)) {
|
|
1695
|
+
return {
|
|
1696
|
+
content: [
|
|
1697
|
+
createErrorContent(`Invalid shell path '${shell}'. Must match /^\\/[a-z\\/]+$/.`),
|
|
1698
|
+
],
|
|
1699
|
+
isError: true,
|
|
1700
|
+
};
|
|
1701
|
+
}
|
|
1702
|
+
// Safety check: refuse to change shell for root
|
|
1703
|
+
if (username === "root") {
|
|
1704
|
+
return {
|
|
1705
|
+
content: [
|
|
1706
|
+
createErrorContent("Refusing to change shell for root user. This is a safety restriction."),
|
|
1707
|
+
],
|
|
1708
|
+
isError: true,
|
|
1709
|
+
};
|
|
1710
|
+
}
|
|
1711
|
+
// Safety check: refuse to change shell for current user
|
|
1712
|
+
const whoamiResult = await executeCommand({
|
|
1713
|
+
command: "whoami",
|
|
1714
|
+
args: [],
|
|
1715
|
+
toolName: "access_restrict_shell",
|
|
1716
|
+
});
|
|
1717
|
+
const currentUser = whoamiResult.stdout.trim();
|
|
1718
|
+
if (username === currentUser) {
|
|
1719
|
+
return {
|
|
1720
|
+
content: [
|
|
1721
|
+
createErrorContent(`Refusing to change shell for the current user '${currentUser}'. This is a safety restriction.`),
|
|
1722
|
+
],
|
|
1723
|
+
isError: true,
|
|
1724
|
+
};
|
|
1725
|
+
}
|
|
1726
|
+
// Check that the user exists
|
|
1727
|
+
const idResult = await executeCommand({
|
|
1728
|
+
command: "id",
|
|
1729
|
+
args: [username],
|
|
1730
|
+
toolName: "access_restrict_shell",
|
|
1731
|
+
});
|
|
1732
|
+
if (idResult.exitCode !== 0) {
|
|
1733
|
+
return {
|
|
1734
|
+
content: [
|
|
1735
|
+
createErrorContent(`User '${username}' does not exist.`),
|
|
1736
|
+
],
|
|
1737
|
+
isError: true,
|
|
1738
|
+
};
|
|
1739
|
+
}
|
|
1740
|
+
// Get current shell
|
|
1741
|
+
const getentResult = await executeCommand({
|
|
1742
|
+
command: "getent",
|
|
1743
|
+
args: ["passwd", username],
|
|
1744
|
+
toolName: "access_restrict_shell",
|
|
1745
|
+
});
|
|
1746
|
+
const currentShell = getentResult.stdout.trim().split(":").pop() ?? "unknown";
|
|
1747
|
+
if (dry_run ?? getConfig().dryRun) {
|
|
1748
|
+
const entry = createChangeEntry({
|
|
1749
|
+
tool: "access_restrict_shell",
|
|
1750
|
+
action: `[DRY-RUN] Restrict shell for ${username}`,
|
|
1751
|
+
target: `/etc/passwd (${username})`,
|
|
1752
|
+
before: `shell=${currentShell}`,
|
|
1753
|
+
after: `shell=${shell}`,
|
|
1754
|
+
dryRun: true,
|
|
1755
|
+
success: true,
|
|
1756
|
+
});
|
|
1757
|
+
logChange(entry);
|
|
1758
|
+
return {
|
|
1759
|
+
content: [
|
|
1760
|
+
createTextContent(`[DRY-RUN] Would change shell for '${username}':\n\n` +
|
|
1761
|
+
` Current shell: ${currentShell}\n` +
|
|
1762
|
+
` New shell: ${shell}\n\n` +
|
|
1763
|
+
` Command: sudo usermod -s ${shell} ${username}`),
|
|
1764
|
+
],
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
// Apply the shell change
|
|
1768
|
+
const result = await executeCommand({
|
|
1769
|
+
command: "sudo",
|
|
1770
|
+
args: ["usermod", "-s", shell, username],
|
|
1771
|
+
toolName: "access_restrict_shell",
|
|
1772
|
+
timeout: getToolTimeout("access_restrict_shell"),
|
|
1773
|
+
});
|
|
1774
|
+
if (result.exitCode !== 0) {
|
|
1775
|
+
const entry = createChangeEntry({
|
|
1776
|
+
tool: "access_restrict_shell",
|
|
1777
|
+
action: `Restrict shell for ${username}`,
|
|
1778
|
+
target: `/etc/passwd (${username})`,
|
|
1779
|
+
before: `shell=${currentShell}`,
|
|
1780
|
+
after: `shell=${shell}`,
|
|
1781
|
+
dryRun: false,
|
|
1782
|
+
success: false,
|
|
1783
|
+
error: result.stderr,
|
|
1784
|
+
});
|
|
1785
|
+
logChange(entry);
|
|
1786
|
+
return {
|
|
1787
|
+
content: [
|
|
1788
|
+
createErrorContent(`Failed to change shell for '${username}': ${result.stderr}`),
|
|
1789
|
+
],
|
|
1790
|
+
isError: true,
|
|
1791
|
+
};
|
|
1792
|
+
}
|
|
1793
|
+
const entry = createChangeEntry({
|
|
1794
|
+
tool: "access_restrict_shell",
|
|
1795
|
+
action: `Restrict shell for ${username}`,
|
|
1796
|
+
target: `/etc/passwd (${username})`,
|
|
1797
|
+
before: `shell=${currentShell}`,
|
|
1798
|
+
after: `shell=${shell}`,
|
|
1799
|
+
dryRun: false,
|
|
1800
|
+
success: true,
|
|
1801
|
+
rollbackCommand: `sudo usermod -s ${currentShell} ${username}`,
|
|
1802
|
+
});
|
|
1803
|
+
logChange(entry);
|
|
1804
|
+
return {
|
|
1805
|
+
content: [
|
|
1806
|
+
createTextContent(`Shell restricted for '${username}':\n\n` +
|
|
1807
|
+
` Previous shell: ${currentShell}\n` +
|
|
1808
|
+
` New shell: ${shell}\n\n` +
|
|
1809
|
+
` Rollback: sudo usermod -s ${currentShell} ${username}`),
|
|
1810
|
+
],
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1813
|
+
catch (err) {
|
|
1814
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1815
|
+
return { content: [createErrorContent(msg)], isError: true };
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
}
|