defense-mcp-server 0.7.2 → 0.7.3
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/build/core/command-allowlist.d.ts.map +1 -1
- package/build/core/command-allowlist.js +1 -0
- package/build/core/distro-adapter.js +1 -1
- package/build/core/sudo-guard.js +3 -3
- package/build/core/tool-dependencies.d.ts.map +1 -1
- package/build/core/tool-dependencies.js +2 -1
- package/build/index.js +1 -1
- package/build/tools/container-security.d.ts +1 -0
- package/build/tools/container-security.d.ts.map +1 -1
- package/build/tools/container-security.js +82 -9
- package/build/tools/firewall.d.ts.map +1 -1
- package/build/tools/firewall.js +28 -6
- package/build/tools/hardening.d.ts.map +1 -1
- package/build/tools/hardening.js +13 -3
- package/build/tools/meta.d.ts.map +1 -1
- package/build/tools/meta.js +42 -8
- package/build/tools/sudo-management.d.ts.map +1 -1
- package/build/tools/sudo-management.js +123 -147
- package/package.json +8 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"command-allowlist.d.ts","sourceRoot":"","sources":["../../src/core/command-allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,sDAAsD;AACtD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;
|
|
1
|
+
{"version":3,"file":"command-allowlist.d.ts","sourceRoot":"","sources":["../../src/core/command-allowlist.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAeH,MAAM,WAAW,cAAc;IAC7B,wCAAwC;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,gDAAgD;IAChD,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,uEAAuE;IACvE,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,sDAAsD;AACtD,MAAM,WAAW,wBAAwB;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;CACjB;AA+WD;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,IAAI,IAAI,CAkD1C;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAsDtD;AAED;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CActD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;CACpB,CA6CA;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,IAAI,aAAa,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAC,CAE7E;AAED;;GAEG;AACH,wBAAgB,aAAa,IAAI,OAAO,CAEvC;AAED;;GAEG;AACH,wBAAgB,gCAAgC,IAAI,OAAO,CAE1D;AAED;;;GAGG;AACH,wBAAgB,0BAA0B,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAEjE;AA8CD;;GAEG;AACH,wBAAgB,yBAAyB,IAAI,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAAC,CAE9E;AAeD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,UAAU,EAAE,MAAM,EAClB,eAAe,CAAC,EAAE,MAAM,GACvB,wBAAwB,CA6G1B;AAiHD;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,IAAI,OAAO,CAAC,wBAAwB,EAAE,CAAC,CA6C7E"}
|
|
@@ -241,6 +241,7 @@ const ALLOWLIST_DEFINITIONS = [
|
|
|
241
241
|
// ── GUI askpass helpers (sudo-management.ts) ───────────────────────────
|
|
242
242
|
{ binary: "zenity", candidates: ["/usr/bin/zenity"] },
|
|
243
243
|
{ binary: "kdialog", candidates: ["/usr/bin/kdialog"] },
|
|
244
|
+
{ binary: "setsid", candidates: ["/usr/bin/setsid", "/usr/sbin/setsid"] },
|
|
244
245
|
{ binary: "ksshaskpass", candidates: ["/usr/bin/ksshaskpass"] },
|
|
245
246
|
{ binary: "lxqt-sudo", candidates: ["/usr/bin/lxqt-sudo"] },
|
|
246
247
|
// ── DNS security ───────────────────────────────────────────────────────
|
|
@@ -396,7 +396,7 @@ function buildFirewallPersistenceConfig(distro) {
|
|
|
396
396
|
return {
|
|
397
397
|
packageName: "iptables-persistent",
|
|
398
398
|
checkInstalledCmd: ["dpkg", "-l", "iptables-persistent"],
|
|
399
|
-
installCmd: ["
|
|
399
|
+
installCmd: ["apt-get", "install", "-y", "iptables-persistent"],
|
|
400
400
|
serviceName: "netfilter-persistent",
|
|
401
401
|
enableCmd: ["systemctl", "enable", "netfilter-persistent"],
|
|
402
402
|
saveCmd: ["netfilter-persistent", "save"],
|
package/build/core/sudo-guard.js
CHANGED
|
@@ -135,12 +135,12 @@ export class SudoGuard {
|
|
|
135
135
|
if (nopasswdPattern.test(content)) {
|
|
136
136
|
logger.security("sudo-guard", "nopasswd_detected", "SECURITY CRITICAL: NOPASSWD:ALL detected in sudoers configuration. " +
|
|
137
137
|
"Authentication via sudo_elevate is NON-FUNCTIONAL — any password will be accepted. " +
|
|
138
|
-
"Remove NOPASSWD from the sudoers file and set a real password for
|
|
138
|
+
"Remove NOPASSWD from the sudoers file and set a real password for the user. " +
|
|
139
139
|
"See docs/SUDO-SESSION-DESIGN.md for remediation steps.", {
|
|
140
140
|
severity: "CRITICAL",
|
|
141
141
|
location: filePath,
|
|
142
|
-
remediation: "
|
|
143
|
-
"
|
|
142
|
+
remediation: "Update the sudoers configuration to use a scoped allowlist " +
|
|
143
|
+
"with no NOPASSWD. See docs/SUDO-SESSION-DESIGN.md for details.",
|
|
144
144
|
});
|
|
145
145
|
return { nopasswdDetected: true, location: filePath };
|
|
146
146
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-dependencies.d.ts","sourceRoot":"","sources":["../../src/core/tool-dependencies.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIvE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAaD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GACb,eAAe,GAAG,SAAS,CAE7B;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,cAAc,
|
|
1
|
+
{"version":3,"file":"tool-dependencies.d.ts","sourceRoot":"","sources":["../../src/core/tool-dependencies.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAmB,KAAK,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAIvE;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,kDAAkD;IAClD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,gEAAgE;IAChE,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAC;IAC5B,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAaD;;GAEG;AACH,wBAAgB,2BAA2B,CACzC,MAAM,EAAE,MAAM,GACb,eAAe,GAAG,SAAS,CAE7B;AAID;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB,EAAE,cAAc,EAwL7C,CAAC;AAUF;;GAEG;AACH,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,MAAM,GACf,cAAc,GAAG,SAAS,CAE5B;AAED;;GAEG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,EAAE,CAQjD;AAED;;GAEG;AACH,wBAAgB,cAAc,IAAI;IAAE,QAAQ,EAAE,MAAM,EAAE,CAAC;IAAC,QAAQ,EAAE,MAAM,EAAE,CAAA;CAAE,CAiB3E;AAED;;GAEG;AACH,wBAAgB,uBAAuB,IAAI,cAAc,EAAE,CAE1D"}
|
|
@@ -57,7 +57,8 @@ export const TOOL_DEPENDENCIES = [
|
|
|
57
57
|
// ── Access Control (1) ────────────────────────────────────────────────────
|
|
58
58
|
{
|
|
59
59
|
toolName: "access_control",
|
|
60
|
-
requiredBinaries: ["
|
|
60
|
+
requiredBinaries: ["pam-auth-update"],
|
|
61
|
+
optionalBinaries: ["ssh", "sshd"],
|
|
61
62
|
critical: true,
|
|
62
63
|
},
|
|
63
64
|
// ── Compliance (1) ────────────────────────────────────────────────────────
|
package/build/index.js
CHANGED
|
@@ -187,7 +187,7 @@ async function main() {
|
|
|
187
187
|
if (nopasswdCheck.nopasswdDetected) {
|
|
188
188
|
console.error(`[startup] ⚠️ SECURITY CRITICAL: NOPASSWD:ALL detected in sudoers ` +
|
|
189
189
|
`(${nopasswdCheck.location}). sudo_elevate credential validation is NON-FUNCTIONAL. ` +
|
|
190
|
-
`
|
|
190
|
+
`Remove the NOPASSWD entry from sudoers and set a real password for the MCP user.`);
|
|
191
191
|
}
|
|
192
192
|
else {
|
|
193
193
|
console.error('[startup] ✅ Sudoers check: NOPASSWD:ALL not detected — credential validation active');
|
|
@@ -9,5 +9,6 @@
|
|
|
9
9
|
* namespace_check, seccomp_profile, rootless_setup)
|
|
10
10
|
*/
|
|
11
11
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
12
|
+
export declare const DESKTOP_BREAKING_PROFILES: Set<string>;
|
|
12
13
|
export declare function registerContainerSecurityTools(server: McpServer): void;
|
|
13
14
|
//# sourceMappingURL=container-security.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"container-security.d.ts","sourceRoot":"","sources":["../../src/tools/container-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"container-security.d.ts","sourceRoot":"","sources":["../../src/tools/container-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAwBpE,eAAO,MAAM,yBAAyB,aAyBpC,CAAC;AAIH,wBAAgB,8BAA8B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAuvBtE"}
|
|
@@ -20,6 +20,36 @@ import { resolve } from "node:path";
|
|
|
20
20
|
import { secureWriteFileSync } from "../core/secure-fs.js";
|
|
21
21
|
// ── TOOL-011 remediation: safe directory for seccomp profiles ──────────────
|
|
22
22
|
const SECCOMP_PROFILE_DIR = "/tmp/defense-mcp/seccomp";
|
|
23
|
+
// ── AppArmor profiles known to break desktop applications ─────────────────
|
|
24
|
+
// These profiles ship in apparmor-profiles-extra and use ABI 4.0 default-deny
|
|
25
|
+
// with only `userns` granted. In enforce mode they block shared library loading
|
|
26
|
+
// for flatpak, chromium, and other GUI apps via the dynamic linker.
|
|
27
|
+
export const DESKTOP_BREAKING_PROFILES = new Set([
|
|
28
|
+
"flatpak",
|
|
29
|
+
"chromium",
|
|
30
|
+
"unprivileged_userns",
|
|
31
|
+
"chrome",
|
|
32
|
+
"brave",
|
|
33
|
+
"Discord",
|
|
34
|
+
"element-desktop",
|
|
35
|
+
"firefox",
|
|
36
|
+
"signal-desktop",
|
|
37
|
+
"slack",
|
|
38
|
+
"vivaldi-bin",
|
|
39
|
+
"opera",
|
|
40
|
+
"msedge",
|
|
41
|
+
"obsidian",
|
|
42
|
+
"steam",
|
|
43
|
+
"code",
|
|
44
|
+
"epiphany",
|
|
45
|
+
"github-desktop",
|
|
46
|
+
"polypane",
|
|
47
|
+
"qutebrowser",
|
|
48
|
+
// Container runtimes — same ABI 4.0 default-deny issue breaks Docker
|
|
49
|
+
"runc",
|
|
50
|
+
"crun",
|
|
51
|
+
"rootlesskit",
|
|
52
|
+
]);
|
|
23
53
|
// ── Registration entry point ───────────────────────────────────────────────
|
|
24
54
|
export function registerContainerSecurityTools(server) {
|
|
25
55
|
// ── 1. container_docker (audit + bench + seccomp + daemon + image_scan) ─
|
|
@@ -449,11 +479,17 @@ export function registerContainerSecurityTools(server) {
|
|
|
449
479
|
try {
|
|
450
480
|
const sections = ["🛡️ AppArmor System Status", "=".repeat(40)];
|
|
451
481
|
const enabledResult = await executeCommand({ command: "aa-enabled", args: [], toolName: "container_isolation", timeout: 5000 });
|
|
452
|
-
const
|
|
453
|
-
sections.push(`\n AppArmor enabled: ${aaEnabled ? "✅ Yes" : "❌ No"}`);
|
|
482
|
+
const aaEnabledBin = enabledResult.exitCode === 0 && enabledResult.stdout.trim() === "Yes";
|
|
454
483
|
const moduleResult = await executeCommand({ command: "cat", args: ["/sys/module/apparmor/parameters/enabled"], toolName: "container_isolation", timeout: 5000 });
|
|
484
|
+
const kernelModuleLoaded = moduleResult.exitCode === 0 && moduleResult.stdout.trim() === "Y";
|
|
485
|
+
// Also check if apparmor service is active and profiles directory is populated
|
|
486
|
+
const svcResult = await executeCommand({ command: "systemctl", args: ["is-active", "apparmor"], toolName: "container_isolation", timeout: 5000 });
|
|
487
|
+
const svcActive = svcResult.exitCode === 0 && svcResult.stdout.trim() === "active";
|
|
488
|
+
// AppArmor is considered enabled if aa-enabled says "Yes", OR if kernel module is loaded AND service is active
|
|
489
|
+
const aaEnabled = aaEnabledBin || (kernelModuleLoaded && svcActive);
|
|
490
|
+
sections.push(`\n AppArmor enabled: ${aaEnabled ? "✅ Yes" : "❌ No"}`);
|
|
455
491
|
if (moduleResult.exitCode === 0)
|
|
456
|
-
sections.push(` Kernel module: ${
|
|
492
|
+
sections.push(` Kernel module: ${kernelModuleLoaded ? "✅ Loaded" : "❌ Not loaded"}`);
|
|
457
493
|
const pkgChecks = ["apparmor-profiles", "apparmor-profiles-extra", "apparmor-utils"];
|
|
458
494
|
sections.push("\n Profile Packages:");
|
|
459
495
|
for (const pkg of pkgChecks) {
|
|
@@ -517,14 +553,25 @@ export function registerContainerSecurityTools(server) {
|
|
|
517
553
|
const baseAction = action.replace("apparmor_", "");
|
|
518
554
|
const cmdMap = { enforce: "aa-enforce", complain: "aa-complain", disable: "aa-disable" };
|
|
519
555
|
const cmd = cmdMap[baseAction];
|
|
556
|
+
// Extract just the profile name from a potential path
|
|
557
|
+
const profileBaseName = profile.replace(/.*\//, "");
|
|
558
|
+
const isDesktopProfile = DESKTOP_BREAKING_PROFILES.has(profileBaseName);
|
|
559
|
+
// SAFETY: Warn when enforcing profiles known to break desktop apps
|
|
560
|
+
const desktopWarning = (baseAction === "enforce" && isDesktopProfile)
|
|
561
|
+
? `\n\n⚠️ WARNING: Profile '${profileBaseName}' is known to break desktop applications ` +
|
|
562
|
+
`(flatpak, browsers, GUI apps) when enforced.\n` +
|
|
563
|
+
`These profiles use ABI 4.0 default-deny and block shared library loading.\n` +
|
|
564
|
+
`This may prevent Chromium, Firefox, Flatpak apps, and similar from launching.\n` +
|
|
565
|
+
`Consider using complain mode instead, or test thoroughly before enforcing.`
|
|
566
|
+
: "";
|
|
520
567
|
if (dry_run ?? getConfig().dryRun) {
|
|
521
|
-
return { content: [createTextContent(`[DRY RUN] Would set profile '${profile}' to ${baseAction} mode.\n Command: sudo ${cmd} ${profile}`)] };
|
|
568
|
+
return { content: [createTextContent(`[DRY RUN] Would set profile '${profile}' to ${baseAction} mode.\n Command: sudo ${cmd} ${profile}${desktopWarning}`)] };
|
|
522
569
|
}
|
|
523
570
|
const result = await executeCommand({ command: "sudo", args: [cmd, profile], toolName: "container_isolation", timeout: getToolTimeout("container_apparmor_manage") });
|
|
524
571
|
if (result.exitCode !== 0)
|
|
525
572
|
return { content: [createErrorContent(`Failed to ${baseAction} profile '${profile}': ${result.stderr}`)], isError: true };
|
|
526
|
-
logChange(createChangeEntry({ tool: "container_isolation", action: baseAction, target: profile, after: `${baseAction} mode`, dryRun: false, success: true, rollbackCommand: baseAction === "disable" ? `sudo aa-enforce ${profile}` : undefined }));
|
|
527
|
-
return { content: [createTextContent(`✅ Profile '${profile}' set to ${baseAction} mode.\n${result.stdout || result.stderr}`)] };
|
|
573
|
+
logChange(createChangeEntry({ tool: "container_isolation", action: baseAction, target: profile, after: `${baseAction} mode`, dryRun: false, success: true, rollbackCommand: baseAction === "disable" ? `sudo aa-enforce ${profile}` : baseAction === "enforce" ? `sudo aa-complain ${profile}` : undefined }));
|
|
574
|
+
return { content: [createTextContent(`✅ Profile '${profile}' set to ${baseAction} mode.\n${result.stdout || result.stderr}${desktopWarning}`)] };
|
|
528
575
|
}
|
|
529
576
|
catch (err) {
|
|
530
577
|
return { content: [createErrorContent(err instanceof Error ? err.message : String(err))], isError: true };
|
|
@@ -537,13 +584,39 @@ export function registerContainerSecurityTools(server) {
|
|
|
537
584
|
const isDryRun = dry_run ?? getConfig().dryRun;
|
|
538
585
|
const packages = ["apparmor-profiles", "apparmor-profiles-extra"];
|
|
539
586
|
if (isDryRun) {
|
|
540
|
-
return { content: [createTextContent(`[DRY RUN] Would install: ${packages.join(", ")}\n
|
|
587
|
+
return { content: [createTextContent(`[DRY RUN] Would install: ${packages.join(", ")}\n` +
|
|
588
|
+
` Command: sudo apt-get install -y ${packages.join(" ")}\n\n` +
|
|
589
|
+
`⚠️ After installation, the following profiles will be set to COMPLAIN mode\n` +
|
|
590
|
+
`to prevent breaking desktop applications (flatpak, browsers, etc.):\n` +
|
|
591
|
+
` ${[...DESKTOP_BREAKING_PROFILES].join(", ")}\n\n` +
|
|
592
|
+
`Use apparmor_enforce to selectively enforce profiles after testing.`)] };
|
|
541
593
|
}
|
|
542
594
|
const installResult = await executeCommand({ command: "sudo", args: ["apt-get", "install", "-y", ...packages], toolName: "container_isolation", timeout: 120000 });
|
|
543
595
|
if (installResult.exitCode !== 0)
|
|
544
596
|
return { content: [createErrorContent(`Failed to install: ${installResult.stderr}`)], isError: true };
|
|
545
|
-
|
|
546
|
-
|
|
597
|
+
// SAFETY: Set desktop-breaking profiles to complain mode to prevent
|
|
598
|
+
// breaking flatpak, chromium, and other GUI applications.
|
|
599
|
+
// These profiles use ABI 4.0 default-deny with only `userns` granted,
|
|
600
|
+
// which blocks the dynamic linker from loading shared libraries.
|
|
601
|
+
const complainResults = [];
|
|
602
|
+
for (const profileName of DESKTOP_BREAKING_PROFILES) {
|
|
603
|
+
const profilePath = `/etc/apparmor.d/${profileName}`;
|
|
604
|
+
const checkResult = await executeCommand({ command: "test", args: ["-f", profilePath], toolName: "container_isolation", timeout: 5000 });
|
|
605
|
+
if (checkResult.exitCode === 0) {
|
|
606
|
+
const complainResult = await executeCommand({ command: "sudo", args: ["aa-complain", profilePath], toolName: "container_isolation", timeout: 10000 });
|
|
607
|
+
if (complainResult.exitCode === 0) {
|
|
608
|
+
complainResults.push(` ✓ ${profileName} → complain mode`);
|
|
609
|
+
}
|
|
610
|
+
else {
|
|
611
|
+
complainResults.push(` ✗ ${profileName} → failed: ${complainResult.stderr.trim()}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
const complainSection = complainResults.length > 0
|
|
616
|
+
? `\n\n⚠️ Desktop-safe profiles set to complain mode (prevents breaking GUI apps):\n${complainResults.join("\n")}\n\nUse apparmor_enforce to selectively enforce profiles after testing.`
|
|
617
|
+
: "";
|
|
618
|
+
logChange(createChangeEntry({ tool: "container_isolation", action: "install_profiles", target: packages.join(", "), after: `installed; ${complainResults.length} profiles set to complain`, dryRun: false, success: true, rollbackCommand: `sudo apt-get remove -y ${packages.join(" ")}` }));
|
|
619
|
+
return { content: [createTextContent(`✅ Successfully installed: ${packages.join(", ")}${complainSection}`)] };
|
|
547
620
|
}
|
|
548
621
|
catch (err) {
|
|
549
622
|
return { content: [createErrorContent(err instanceof Error ? err.message : String(err))], isError: true };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"firewall.d.ts","sourceRoot":"","sources":["../../src/tools/firewall.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6EpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"firewall.d.ts","sourceRoot":"","sources":["../../src/tools/firewall.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6EpE,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAigD7D"}
|
package/build/tools/firewall.js
CHANGED
|
@@ -1315,14 +1315,36 @@ export function registerFirewallTools(server) {
|
|
|
1315
1315
|
const ruleCount = (output.match(/^[A-Z]+\s/gm) || []).length;
|
|
1316
1316
|
findings.push({ check: "iptables_rule_count", status: ruleCount > 0 ? "INFO" : "WARN", value: String(ruleCount), description: "Total iptables rules" });
|
|
1317
1317
|
}
|
|
1318
|
-
// Check UFW status
|
|
1319
|
-
const
|
|
1320
|
-
if (
|
|
1321
|
-
|
|
1322
|
-
|
|
1318
|
+
// Check UFW status — distinguish "not installed" from "command failed"
|
|
1319
|
+
const ufwWhich = await executeCommand({ command: "which", args: ["ufw"], timeout: 5000, toolName: "firewall" });
|
|
1320
|
+
if (ufwWhich.exitCode === 0) {
|
|
1321
|
+
// UFW binary exists — try to get status
|
|
1322
|
+
const ufwResult = await executeCommand({ command: "sudo", args: ["ufw", "status"], timeout: 10000, toolName: "firewall" });
|
|
1323
|
+
if (ufwResult.exitCode === 0) {
|
|
1324
|
+
const active = ufwResult.stdout.includes("Status: active");
|
|
1325
|
+
findings.push({ check: "ufw_active", status: active ? "PASS" : "FAIL", value: active ? "active" : "inactive", description: "UFW firewall status" });
|
|
1326
|
+
}
|
|
1327
|
+
else {
|
|
1328
|
+
// UFW exists but status command failed — check nftables for UFW chains as fallback
|
|
1329
|
+
const nftFallback = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000, toolName: "firewall" });
|
|
1330
|
+
const hasUfwChains = nftFallback.exitCode === 0 && nftFallback.stdout.includes("ufw-");
|
|
1331
|
+
if (hasUfwChains) {
|
|
1332
|
+
findings.push({ check: "ufw_active", status: "PASS", value: "active (nftables backend)", description: "UFW firewall status (detected via nftables ruleset)" });
|
|
1333
|
+
}
|
|
1334
|
+
else {
|
|
1335
|
+
findings.push({ check: "ufw_active", status: "WARN", value: "installed but status check failed", description: "UFW installed but 'ufw status' failed — may need sudo or service restart" });
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1323
1338
|
}
|
|
1324
1339
|
else {
|
|
1325
|
-
|
|
1340
|
+
// UFW binary not found — check if nftables has rules directly
|
|
1341
|
+
const nftDirect = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000, toolName: "firewall" });
|
|
1342
|
+
if (nftDirect.exitCode === 0 && nftDirect.stdout.trim().length > 50) {
|
|
1343
|
+
findings.push({ check: "nftables_active", status: "PASS", value: "active (nftables native)", description: "Firewall active via nftables (no UFW)" });
|
|
1344
|
+
}
|
|
1345
|
+
else {
|
|
1346
|
+
findings.push({ check: "ufw_installed", status: "FAIL", value: "not installed", description: "No firewall detected (UFW not found, nftables empty)" });
|
|
1347
|
+
}
|
|
1326
1348
|
}
|
|
1327
1349
|
// Check ip6tables
|
|
1328
1350
|
const ip6Result = await executeCommand({ command: "sudo", args: ["ip6tables", "-L", "-n"], timeout: 10000, toolName: "firewall" });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/tools/hardening.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"hardening.d.ts","sourceRoot":"","sources":["../../src/tools/hardening.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+TpE,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAwiF9D"}
|
package/build/tools/hardening.js
CHANGED
|
@@ -11,6 +11,7 @@ import { createTextContent, createErrorContent, parseSysctlOutput, parseSystemct
|
|
|
11
11
|
import { logChange, createChangeEntry, backupFile, } from "../core/changelog.js";
|
|
12
12
|
import { validateServiceName, sanitizeArgs, validateSysctlKey, } from "../core/sanitizer.js";
|
|
13
13
|
import { SafeguardRegistry } from "../core/safeguards.js";
|
|
14
|
+
import { SudoSession } from "../core/sudo-session.js";
|
|
14
15
|
import { readFileSync, existsSync } from "node:fs";
|
|
15
16
|
import { execSync } from "node:child_process";
|
|
16
17
|
import { spawnSafe } from "../core/spawn-safe.js";
|
|
@@ -26,7 +27,16 @@ function checkPrivileges() {
|
|
|
26
27
|
if (typeof process.getuid === "function" && process.getuid() === 0) {
|
|
27
28
|
return { ok: true };
|
|
28
29
|
}
|
|
29
|
-
// Check if sudo is
|
|
30
|
+
// Check if MCP sudo session is elevated (sudo -S with cached password)
|
|
31
|
+
try {
|
|
32
|
+
if (SudoSession.getInstance().isElevated()) {
|
|
33
|
+
return { ok: true };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// SudoSession not available — fall through to sudo -n check
|
|
38
|
+
}
|
|
39
|
+
// Fallback: Check if sudo is available without password (NOPASSWD)
|
|
30
40
|
try {
|
|
31
41
|
execSync("sudo -n true 2>/dev/null", { timeout: 3000, stdio: "ignore" });
|
|
32
42
|
return { ok: true };
|
|
@@ -34,8 +44,8 @@ function checkPrivileges() {
|
|
|
34
44
|
catch {
|
|
35
45
|
return {
|
|
36
46
|
ok: false,
|
|
37
|
-
message: "Insufficient privileges. This operation requires root access or sudo. " +
|
|
38
|
-
"
|
|
47
|
+
message: "Insufficient privileges. This operation requires root access or an active sudo session. " +
|
|
48
|
+
"Use sudo_session action=elevate_gui to authenticate first.",
|
|
39
49
|
};
|
|
40
50
|
}
|
|
41
51
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"meta.d.ts","sourceRoot":"","sources":["../../src/tools/meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAq5BpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,
|
|
1
|
+
{"version":3,"file":"meta.d.ts","sourceRoot":"","sources":["../../src/tools/meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAq5BpE,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAohCzD"}
|
package/build/tools/meta.js
CHANGED
|
@@ -1123,12 +1123,46 @@ export function registerMetaTools(server) {
|
|
|
1123
1123
|
const kernelScore = assessableKernelCount > 0 ? Math.round((kernelPassed / assessableKernelCount) * 100) : -1;
|
|
1124
1124
|
domains.push({ domain: "kernel-hardening", score: kernelScore, maxScore: 100, checks: kernelResults.map((r) => ({ name: r.name, passed: r.passed, detail: r.detail })) });
|
|
1125
1125
|
const fwChecks = [];
|
|
1126
|
-
|
|
1127
|
-
const
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1126
|
+
// Check iptables/nftables rules (with sudo for accurate results)
|
|
1127
|
+
const iptResult = await executeCommand({ command: "sudo", args: ["iptables", "-L", "-n"], timeout: 10000 });
|
|
1128
|
+
const hasIptRules = iptResult.exitCode === 0 && iptResult.stdout.split("\n").length > 8;
|
|
1129
|
+
fwChecks.push({ name: "iptables rules present", passed: hasIptRules, detail: `${iptResult.exitCode === 0 ? iptResult.stdout.split("\n").length : 0} lines` });
|
|
1130
|
+
// Multi-layer firewall detection: UFW → nftables fallback
|
|
1131
|
+
let fwDetected = false;
|
|
1132
|
+
const ufwResult = await executeCommand({ command: "sudo", args: ["ufw", "status"], timeout: 5000 });
|
|
1133
|
+
if (ufwResult.exitCode === 0) {
|
|
1134
|
+
const ufwActive = ufwResult.stdout.includes("active");
|
|
1135
|
+
fwChecks.push({ name: "UFW active", passed: ufwActive, detail: ufwResult.stdout.slice(0, 100) });
|
|
1136
|
+
fwDetected = ufwActive;
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
// UFW command failed — check if nftables has active rules (UFW chains or native)
|
|
1140
|
+
const nftResult = await executeCommand({ command: "sudo", args: ["nft", "list", "ruleset"], timeout: 10000 });
|
|
1141
|
+
if (nftResult.exitCode === 0) {
|
|
1142
|
+
const hasUfwChains = nftResult.stdout.includes("ufw-");
|
|
1143
|
+
const hasNftRules = nftResult.stdout.trim().length > 50;
|
|
1144
|
+
if (hasUfwChains) {
|
|
1145
|
+
fwChecks.push({ name: "UFW active", passed: true, detail: "Active via nftables backend (ufw chains detected)" });
|
|
1146
|
+
fwDetected = true;
|
|
1147
|
+
}
|
|
1148
|
+
else if (hasNftRules) {
|
|
1149
|
+
fwChecks.push({ name: "nftables active", passed: true, detail: "Native nftables ruleset loaded" });
|
|
1150
|
+
fwDetected = true;
|
|
1151
|
+
}
|
|
1152
|
+
else {
|
|
1153
|
+
fwChecks.push({ name: "UFW active", passed: false, detail: "No firewall rules detected" });
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
else {
|
|
1157
|
+
// Check if binary exists to distinguish "not installed" from "error"
|
|
1158
|
+
const whichUfw = await executeCommand({ command: "which", args: ["ufw"], timeout: 3000 });
|
|
1159
|
+
const whichNft = await executeCommand({ command: "which", args: ["nft"], timeout: 3000 });
|
|
1160
|
+
const detail = whichUfw.exitCode === 0 ? "UFW installed but status check failed"
|
|
1161
|
+
: whichNft.exitCode === 0 ? "nftables installed but ruleset check failed"
|
|
1162
|
+
: "No firewall installed";
|
|
1163
|
+
fwChecks.push({ name: "firewall detected", passed: false, detail });
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1132
1166
|
const fwPassed = fwChecks.filter((c) => c.passed).length;
|
|
1133
1167
|
domains.push({ domain: "firewall", score: Math.round((fwPassed / fwChecks.length) * 100), maxScore: 100, checks: fwChecks });
|
|
1134
1168
|
const dangerousServices = ["telnet.socket", "rsh.socket", "rlogin.socket", "tftp.socket", "xinetd.service"];
|
|
@@ -1141,10 +1175,10 @@ export function registerMetaTools(server) {
|
|
|
1141
1175
|
const svcPassed = svcChecks.filter((c) => c.passed).length;
|
|
1142
1176
|
domains.push({ domain: "services", score: Math.round((svcPassed / svcChecks.length) * 100), maxScore: 100, checks: svcChecks });
|
|
1143
1177
|
const userChecks = [];
|
|
1144
|
-
const rootLogin = await executeCommand({ command: "
|
|
1178
|
+
const rootLogin = await executeCommand({ command: "sudo", args: ["passwd", "-S", "root"], timeout: 5000, toolName: "defense_mgmt" });
|
|
1145
1179
|
const rootLocked = rootLogin.stdout.includes(" L ") || rootLogin.stdout.includes(" LK ");
|
|
1146
1180
|
userChecks.push({ name: "Root account locked", passed: rootLocked, detail: rootLogin.stdout.trim().slice(0, 100) });
|
|
1147
|
-
const noPasswd = await executeCommand({ command: "
|
|
1181
|
+
const noPasswd = await executeCommand({ command: "sudo", args: ["awk", "-F:", '($2 == "" ) { print $1 }', "/etc/shadow"], timeout: 5000, toolName: "defense_mgmt" });
|
|
1148
1182
|
const noEmptyPasswd = noPasswd.stdout.trim().length === 0;
|
|
1149
1183
|
userChecks.push({ name: "No empty passwords", passed: noEmptyPasswd, detail: noPasswd.stdout.trim() || "none" });
|
|
1150
1184
|
const uidZero = await executeCommand({ command: "awk", args: ["-F:", '($3 == 0) { print $1 }', "/etc/passwd"], timeout: 5000 });
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sudo-management.d.ts","sourceRoot":"","sources":["../../src/tools/sudo-management.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;
|
|
1
|
+
{"version":3,"file":"sudo-management.d.ts","sourceRoot":"","sources":["../../src/tools/sudo-management.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAgBpE,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAirBnE"}
|
|
@@ -16,6 +16,7 @@ import { z } from "zod";
|
|
|
16
16
|
import { spawnSafe } from "../core/spawn-safe.js";
|
|
17
17
|
import { SudoSession } from "../core/sudo-session.js";
|
|
18
18
|
import { getConfig } from "../core/config.js";
|
|
19
|
+
import { resolveCommand } from "../core/command-allowlist.js";
|
|
19
20
|
import { createTextContent, createErrorContent, } from "../core/parsers.js";
|
|
20
21
|
import { invalidatePreflightCaches } from '../core/tool-wrapper.js';
|
|
21
22
|
import { PreflightEngine } from '../core/preflight.js';
|
|
@@ -129,7 +130,7 @@ export function registerSudoManagementTools(server) {
|
|
|
129
130
|
` Auth method: password (sudo -S)`,
|
|
130
131
|
];
|
|
131
132
|
if (isHeadless) {
|
|
132
|
-
lines.push(` Environment: headless
|
|
133
|
+
lines.push(` Environment: headless (no display server)`);
|
|
133
134
|
}
|
|
134
135
|
lines.push(``);
|
|
135
136
|
lines.push(`All tools that require sudo will now work automatically.`);
|
|
@@ -156,10 +157,8 @@ export function registerSudoManagementTools(server) {
|
|
|
156
157
|
? `Rate limit reached — locked out until ${rlAfter.resetAt ? new Date(rlAfter.resetAt).toLocaleTimeString() : "unknown"}.`
|
|
157
158
|
: `Attempts remaining before lockout: ${rlAfter.attemptsRemaining}`;
|
|
158
159
|
const passwordSource = isHeadless
|
|
159
|
-
? `\n\nNote: Running in headless
|
|
160
|
-
`
|
|
161
|
-
` • Docker secret: mcpuser-password (recommended)\n` +
|
|
162
|
-
` • Environment variable: MCPUSER_PASSWORD (less secure)`
|
|
160
|
+
? `\n\nNote: Running in a headless environment (no display server).\n` +
|
|
161
|
+
`Use sudo_session action=elevate with your system password.`
|
|
163
162
|
: "";
|
|
164
163
|
return {
|
|
165
164
|
content: [
|
|
@@ -184,39 +183,17 @@ export function registerSudoManagementTools(server) {
|
|
|
184
183
|
}
|
|
185
184
|
// ── elevate_gui ────────────────────────────────────────────────────
|
|
186
185
|
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
186
|
+
// Single-step secure GUI elevation:
|
|
187
|
+
// 1. Detect graphical session environment (even if MCP server lacks DISPLAY)
|
|
188
|
+
// 2. Spawn a native password dialog (zenity/kdialog/ssh-askpass)
|
|
189
|
+
// 3. Capture password via secure temp file (never visible to AI)
|
|
190
|
+
// 4. Elevate and wipe the password
|
|
191
191
|
//
|
|
192
192
|
// The password is NEVER visible to the LLM at any point.
|
|
193
193
|
case "elevate_gui": {
|
|
194
194
|
const { timeout_minutes } = params;
|
|
195
195
|
try {
|
|
196
|
-
// ── Phase 4: Headless environment detection ───────────────────────
|
|
197
|
-
// GUI dialogs (zenity, kdialog, ssh-askpass) require a display server.
|
|
198
|
-
// In headless Docker containers, none of these are available.
|
|
199
|
-
const hasDisplay = Boolean(process.env.DISPLAY) || Boolean(process.env.WAYLAND_DISPLAY);
|
|
200
|
-
if (!hasDisplay) {
|
|
201
|
-
return {
|
|
202
|
-
content: [
|
|
203
|
-
createErrorContent(`❌ GUI elevation is not available in headless environments.\n\n` +
|
|
204
|
-
`The Defense MCP Server is running in a Docker container without\n` +
|
|
205
|
-
`a display server (no DISPLAY or WAYLAND_DISPLAY is set).\n` +
|
|
206
|
-
`Use sudo_session action=elevate instead:\n\n` +
|
|
207
|
-
` Action: elevate\n` +
|
|
208
|
-
` Parameter: password = <your sudo password>\n\n` +
|
|
209
|
-
`The mcpuser password is set at container startup via:\n` +
|
|
210
|
-
` • Docker secret (recommended): docker run --secret mcpuser-password ...\n` +
|
|
211
|
-
` • Environment variable (less secure): docker run -e MCPUSER_PASSWORD='...' ...`),
|
|
212
|
-
],
|
|
213
|
-
isError: true,
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
const fs = await import("node:fs");
|
|
217
|
-
const crypto = await import("node:crypto");
|
|
218
196
|
const session = SudoSession.getInstance();
|
|
219
|
-
const SUDO_PW_FILE = "/tmp/.defense-sudo-pw";
|
|
220
197
|
// Check if already elevated
|
|
221
198
|
if (session.isElevated()) {
|
|
222
199
|
const status = session.getStatus();
|
|
@@ -229,117 +206,102 @@ export function registerSudoManagementTools(server) {
|
|
|
229
206
|
],
|
|
230
207
|
};
|
|
231
208
|
}
|
|
232
|
-
//
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (mode !== "600") {
|
|
239
|
-
// Wipe insecure file
|
|
240
|
-
try {
|
|
241
|
-
const size = stat.size || 64;
|
|
242
|
-
fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(size));
|
|
243
|
-
fs.unlinkSync(SUDO_PW_FILE);
|
|
244
|
-
}
|
|
245
|
-
catch {
|
|
246
|
-
console.error("[sudo-gui] Failed to wipe insecure password file");
|
|
247
|
-
}
|
|
248
|
-
return {
|
|
249
|
-
content: [
|
|
250
|
-
createErrorContent(`❌ Security error: Password file has insecure permissions (${mode}).\n` +
|
|
251
|
-
`Expected 600. File has been securely wiped.\n` +
|
|
252
|
-
`Please run the zenity command again.`),
|
|
253
|
-
],
|
|
254
|
-
isError: true,
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
// Read password from file
|
|
258
|
-
let password;
|
|
259
|
-
try {
|
|
260
|
-
password = fs.readFileSync(SUDO_PW_FILE, "utf-8").trim();
|
|
261
|
-
}
|
|
262
|
-
catch (err) {
|
|
263
|
-
return {
|
|
264
|
-
content: [
|
|
265
|
-
createErrorContent(`❌ Could not read password file: ${err instanceof Error ? err.message : String(err)}`),
|
|
266
|
-
],
|
|
267
|
-
isError: true,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
// Securely wipe the file IMMEDIATELY (before elevation attempt)
|
|
271
|
-
try {
|
|
272
|
-
const fileSize = Buffer.byteLength(password, "utf-8") + 16;
|
|
273
|
-
fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(fileSize));
|
|
274
|
-
fs.writeFileSync(SUDO_PW_FILE, crypto.randomBytes(fileSize)); // Double overwrite
|
|
275
|
-
fs.unlinkSync(SUDO_PW_FILE);
|
|
276
|
-
console.error("[sudo-gui] Password file securely wiped (2x random overwrite + unlink)");
|
|
277
|
-
}
|
|
278
|
-
catch {
|
|
279
|
-
try {
|
|
280
|
-
fs.unlinkSync(SUDO_PW_FILE);
|
|
281
|
-
}
|
|
282
|
-
catch {
|
|
283
|
-
console.error("[sudo-gui] Failed to unlink password file during wipe fallback");
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
if (!password || password.length === 0) {
|
|
287
|
-
return {
|
|
288
|
-
content: [
|
|
289
|
-
createErrorContent(`❌ Empty password file. Please run the zenity command again and enter your password.`),
|
|
290
|
-
],
|
|
291
|
-
isError: true,
|
|
292
|
-
};
|
|
293
|
-
}
|
|
294
|
-
// Elevate using the captured password
|
|
295
|
-
const timeoutMs = timeout_minutes * 60 * 1000;
|
|
296
|
-
const config = getConfig();
|
|
297
|
-
if (config.sudoSessionTimeout) {
|
|
298
|
-
session.setDefaultTimeout(config.sudoSessionTimeout);
|
|
299
|
-
}
|
|
300
|
-
const result = await session.elevate(password, timeoutMs);
|
|
301
|
-
if (result.success) {
|
|
302
|
-
invalidatePreflightCaches();
|
|
303
|
-
const status = session.getStatus();
|
|
304
|
-
return {
|
|
305
|
-
content: [
|
|
306
|
-
createTextContent(`🔓 Privileges elevated successfully!\n\n` +
|
|
307
|
-
` User: ${status.username}\n` +
|
|
308
|
-
` Expires: ${status.expiresAt ?? "never (running as root)"}\n` +
|
|
309
|
-
` Timeout: ${timeout_minutes} minutes\n` +
|
|
310
|
-
` Method: Secure GUI dialog (password never visible to AI)\n\n` +
|
|
311
|
-
`All tools that require sudo will now work automatically.\n` +
|
|
312
|
-
`Use sudo_session action=status to check session state, or sudo_session action=drop to end early.`),
|
|
313
|
-
],
|
|
314
|
-
};
|
|
315
|
-
}
|
|
209
|
+
// ── Pre-flight rate-limit check ──────────────────────────────────
|
|
210
|
+
const rlStatus = session.getRateLimitStatus();
|
|
211
|
+
if (rlStatus.limited) {
|
|
212
|
+
const resetAt = rlStatus.resetAt
|
|
213
|
+
? new Date(rlStatus.resetAt).toLocaleTimeString()
|
|
214
|
+
: "unknown";
|
|
316
215
|
return {
|
|
317
216
|
content: [
|
|
318
|
-
createErrorContent(`❌
|
|
319
|
-
`
|
|
217
|
+
createErrorContent(`❌ Authentication rate limit exceeded.\n\n` +
|
|
218
|
+
`Too many failed attempts. Please wait until ${resetAt} before retrying.`),
|
|
320
219
|
],
|
|
321
220
|
isError: true,
|
|
322
221
|
};
|
|
323
222
|
}
|
|
324
|
-
//
|
|
325
|
-
//
|
|
223
|
+
// ── Detect graphical session ──────────────────────────────────────
|
|
224
|
+
// The MCP server process may not inherit DISPLAY/WAYLAND_DISPLAY,
|
|
225
|
+
// so we probe the user's desktop session processes via /proc.
|
|
226
|
+
const sessionEnv = await getGraphicalSessionEnv();
|
|
227
|
+
const hasDisplay = Boolean(sessionEnv.DISPLAY) || Boolean(sessionEnv.WAYLAND_DISPLAY);
|
|
228
|
+
if (!hasDisplay) {
|
|
229
|
+
return {
|
|
230
|
+
content: [
|
|
231
|
+
createErrorContent(`❌ GUI elevation is not available — no graphical session detected.\n\n` +
|
|
232
|
+
`Could not find DISPLAY or WAYLAND_DISPLAY in the current process\n` +
|
|
233
|
+
`or any desktop session process (gnome-shell, plasmashell, etc.).\n\n` +
|
|
234
|
+
`Use sudo_session action=elevate with your password instead.`),
|
|
235
|
+
],
|
|
236
|
+
isError: true,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
// ── Detect available GUI dialog tool ─────────────────────────────
|
|
326
240
|
const guiTool = await detectGuiPasswordTool();
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
241
|
+
if (!guiTool) {
|
|
242
|
+
return {
|
|
243
|
+
content: [
|
|
244
|
+
createErrorContent(`❌ No GUI password dialog tool found.\n\n` +
|
|
245
|
+
`Install one of: zenity, kdialog, or ssh-askpass\n` +
|
|
246
|
+
` sudo apt install zenity # GNOME/GTK\n` +
|
|
247
|
+
` sudo apt install kdialog # KDE/Qt\n\n` +
|
|
248
|
+
`Or use sudo_session action=elevate with your password instead.`),
|
|
249
|
+
],
|
|
250
|
+
isError: true,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
console.error(`[sudo-gui] Launching ${guiTool.name} password dialog...`);
|
|
254
|
+
// ── Launch GUI dialog and capture password ────────────────────────
|
|
255
|
+
// openGuiPasswordDialog spawns the dialog with the correct graphical
|
|
256
|
+
// session environment, captures the password via a secure temp file,
|
|
257
|
+
// and wipes it immediately after reading.
|
|
258
|
+
const password = await openGuiPasswordDialog(guiTool);
|
|
259
|
+
if (!password) {
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
createErrorContent(`❌ Password dialog was cancelled or timed out.\n\n` +
|
|
263
|
+
`No password was entered. Try again with:\n` +
|
|
264
|
+
` sudo_session action=elevate_gui\n\n` +
|
|
265
|
+
`Or provide your password directly with:\n` +
|
|
266
|
+
` sudo_session action=elevate`),
|
|
267
|
+
],
|
|
268
|
+
isError: true,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
// ── Elevate using captured password ──────────────────────────────
|
|
272
|
+
const timeoutMs = timeout_minutes * 60 * 1000;
|
|
273
|
+
const config = getConfig();
|
|
274
|
+
if (config.sudoSessionTimeout) {
|
|
275
|
+
session.setDefaultTimeout(config.sudoSessionTimeout);
|
|
276
|
+
}
|
|
277
|
+
const result = await session.elevate(password, timeoutMs);
|
|
278
|
+
if (result.success) {
|
|
279
|
+
invalidatePreflightCaches();
|
|
280
|
+
const status = session.getStatus();
|
|
281
|
+
return {
|
|
282
|
+
content: [
|
|
283
|
+
createTextContent(`🔓 Privileges elevated successfully!\n\n` +
|
|
284
|
+
` User: ${status.username}\n` +
|
|
285
|
+
` Expires: ${status.expiresAt ?? "never (running as root)"}\n` +
|
|
286
|
+
` Timeout: ${timeout_minutes} minutes\n` +
|
|
287
|
+
` Method: Secure GUI dialog (${guiTool.name}) — password never visible to AI\n\n` +
|
|
288
|
+
`All tools that require sudo will now work automatically.\n` +
|
|
289
|
+
`Use sudo_session action=status to check session state, or sudo_session action=drop to end early.`),
|
|
290
|
+
],
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
// ── Elevation failed ─────────────────────────────────────────────
|
|
294
|
+
const rlAfter = session.getRateLimitStatus();
|
|
295
|
+
const attemptsLine = rlAfter.limited
|
|
296
|
+
? `Rate limit reached — locked out until ${rlAfter.resetAt ? new Date(rlAfter.resetAt).toLocaleTimeString() : "unknown"}.`
|
|
297
|
+
: `Attempts remaining before lockout: ${rlAfter.attemptsRemaining}`;
|
|
330
298
|
return {
|
|
331
299
|
content: [
|
|
332
|
-
|
|
333
|
-
`${
|
|
334
|
-
`
|
|
335
|
-
` ${toolCmd} > /tmp/.defense-sudo-pw 2>/dev/null && chmod 600 /tmp/.defense-sudo-pw && echo "READY" || echo "CANCELLED"\n\n` +
|
|
336
|
-
`This opens a password dialog on the user's screen.\n` +
|
|
337
|
-
`The password goes DIRECTLY to a secure temp file — it\n` +
|
|
338
|
-
`NEVER appears in terminal output or the AI chat.\n\n` +
|
|
339
|
-
`After the user enters their password (output shows "READY"),\n` +
|
|
340
|
-
`call sudo_session action=elevate_gui again to complete elevation.\n` +
|
|
341
|
-
`The tool will read the file, elevate, and securely wipe it.`),
|
|
300
|
+
createErrorContent(`❌ Authentication failed: ${result.error}\n\n` +
|
|
301
|
+
`${attemptsLine}\n\n` +
|
|
302
|
+
`The password was securely wiped. Please try again.`),
|
|
342
303
|
],
|
|
304
|
+
isError: true,
|
|
343
305
|
};
|
|
344
306
|
}
|
|
345
307
|
catch (err) {
|
|
@@ -631,6 +593,7 @@ export function registerSudoManagementTools(server) {
|
|
|
631
593
|
}
|
|
632
594
|
/**
|
|
633
595
|
* Detect which GUI password dialog tool is available.
|
|
596
|
+
* Uses the command allowlist for resolution (no spawning `which`).
|
|
634
597
|
* Preference order: zenity > kdialog > ssh-askpass
|
|
635
598
|
*/
|
|
636
599
|
async function detectGuiPasswordTool() {
|
|
@@ -663,17 +626,15 @@ async function detectGuiPasswordTool() {
|
|
|
663
626
|
];
|
|
664
627
|
for (const tool of candidates) {
|
|
665
628
|
try {
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
});
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
});
|
|
673
|
-
if (result)
|
|
674
|
-
return tool;
|
|
629
|
+
// resolveCommand checks the allowlist and verifies the binary exists on disk
|
|
630
|
+
const resolved = resolveCommand(tool.command);
|
|
631
|
+
if (resolved) {
|
|
632
|
+
console.error(`[sudo-gui] Found GUI tool: ${tool.name} at ${resolved}`);
|
|
633
|
+
return { ...tool, command: resolved };
|
|
634
|
+
}
|
|
675
635
|
}
|
|
676
636
|
catch {
|
|
637
|
+
// Binary not found or not in allowlist — try next candidate
|
|
677
638
|
continue;
|
|
678
639
|
}
|
|
679
640
|
}
|
|
@@ -688,11 +649,28 @@ async function getGraphicalSessionEnv() {
|
|
|
688
649
|
try {
|
|
689
650
|
const { readFile } = await import("node:fs/promises");
|
|
690
651
|
const { execFileSafe } = await import("../core/spawn-safe.js");
|
|
691
|
-
// Find a PID from the user's graphical session
|
|
652
|
+
// Find a PID from the user's graphical session.
|
|
653
|
+
// We need a process that INHERITS display vars from the compositor.
|
|
654
|
+
// The compositor itself (gnome-shell, kwin_wayland) often doesn't have
|
|
655
|
+
// DISPLAY/WAYLAND_DISPLAY in its own /proc/environ — so we prefer child
|
|
656
|
+
// processes like gjs, nautilus, plasmashell that do inherit them.
|
|
692
657
|
const uid = process.getuid?.() ?? 1000;
|
|
693
|
-
// Get a graphical session process PID owned by the current user
|
|
694
658
|
let pid = null;
|
|
695
|
-
const candidates = [
|
|
659
|
+
const candidates = [
|
|
660
|
+
"gjs", // GNOME shell extensions (always has display vars)
|
|
661
|
+
"nautilus", // GNOME file manager
|
|
662
|
+
"plasmashell", // KDE
|
|
663
|
+
"kwin_wayland", // KDE compositor
|
|
664
|
+
"xfce4-panel", // XFCE
|
|
665
|
+
"xfce4-session", // XFCE
|
|
666
|
+
"cinnamon", // Cinnamon
|
|
667
|
+
"budgie-panel", // Budgie
|
|
668
|
+
"lxqt-panel", // LXQt
|
|
669
|
+
"sway", // Sway
|
|
670
|
+
"hyprland", // Hyprland
|
|
671
|
+
"Xwayland", // X11-on-Wayland bridge
|
|
672
|
+
"gnome-shell", // GNOME compositor (may lack display vars)
|
|
673
|
+
];
|
|
696
674
|
for (const proc of candidates) {
|
|
697
675
|
try {
|
|
698
676
|
const result = execFileSafe("pgrep", ["-u", String(uid), "-o", proc], { encoding: "utf-8", stdio: "pipe" }).trim();
|
|
@@ -850,5 +828,3 @@ async function openGuiPasswordDialog(tool) {
|
|
|
850
828
|
catch { /* best-effort cleanup */ }
|
|
851
829
|
}
|
|
852
830
|
}
|
|
853
|
-
// Suppress unused warning — openGuiPasswordDialog is kept for potential future use
|
|
854
|
-
void openGuiPasswordDialog;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "defense-mcp-server",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Defense MCP Server — 31 domain-grouped defensive security tools for system hardening and threat detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "build/index.js",
|
|
@@ -28,7 +28,13 @@
|
|
|
28
28
|
"changelog:check": "git diff HEAD~1 --name-only | grep -q CHANGELOG.md || echo 'Warning: CHANGELOG.md not updated'",
|
|
29
29
|
"license:check": "license-checker --production --failOn 'GPL-3.0;AGPL-3.0'",
|
|
30
30
|
"prepare": "husky",
|
|
31
|
-
"prepublishOnly": "npm run build"
|
|
31
|
+
"prepublishOnly": "npm run build",
|
|
32
|
+
"release": "./scripts/release.sh patch",
|
|
33
|
+
"release:minor": "./scripts/release.sh minor",
|
|
34
|
+
"release:major": "./scripts/release.sh major",
|
|
35
|
+
"release:git": "./scripts/release.sh patch --git-only",
|
|
36
|
+
"release:npm": "./scripts/release.sh --npm-only",
|
|
37
|
+
"push": "git add -A && git commit -m 'chore: update' && git push origin $(git rev-parse --abbrev-ref HEAD)"
|
|
32
38
|
},
|
|
33
39
|
"keywords": [
|
|
34
40
|
"mcp",
|