defense-mcp-server 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -230,4 +230,127 @@ export declare function backupPamFile(filePath: string): Promise<BackupEntry>;
230
230
  * @throws If backup file is missing, invalid, or restore fails
231
231
  */
232
232
  export declare function restorePamFile(backupEntry: BackupEntry): Promise<void>;
233
+ /** A single finding from PAM policy sanity validation. */
234
+ export interface PamSanityFinding {
235
+ /** warning = proceed with caution; critical = blocks operation unless forced */
236
+ severity: "warning" | "critical";
237
+ /** Which module the finding relates to */
238
+ module: "pam_faillock.so" | "pam_pwquality.so" | "general";
239
+ /** The specific parameter that triggered the finding, if applicable */
240
+ parameter?: string;
241
+ /** The problematic value */
242
+ value?: string | number;
243
+ /** Human-readable description of the problem */
244
+ message: string;
245
+ /** What the user should do instead */
246
+ recommendation: string;
247
+ }
248
+ /** Result of PAM policy sanity validation. */
249
+ export interface PamSanityResult {
250
+ /** true if no critical findings exist */
251
+ safe: boolean;
252
+ /** All findings, ordered by severity then module */
253
+ findings: PamSanityFinding[];
254
+ /** Count of critical-severity findings */
255
+ criticalCount: number;
256
+ /** Count of warning-severity findings */
257
+ warningCount: number;
258
+ }
259
+ /**
260
+ * Thresholds for PAM policy sanity checks.
261
+ * These define what constitutes "sane" vs "dangerous" PAM policy values.
262
+ * Tuned to prevent lockouts while allowing reasonable security hardening.
263
+ */
264
+ export declare const PAM_SANITY_THRESHOLDS: {
265
+ readonly faillock: {
266
+ /** deny below this triggers critical — too few attempts before lockout */
267
+ readonly minDeny: 3;
268
+ /** unlock_time above this triggers warning — extended lockout */
269
+ readonly maxUnlockTimeWarn: 1800;
270
+ /** unlock_time above this triggers critical — extreme lockout */
271
+ readonly maxUnlockTimeCritical: 86400;
272
+ /** fail_interval below this triggers warning — unusually short window */
273
+ readonly minFailInterval: 60;
274
+ };
275
+ readonly pwquality: {
276
+ /** minlen above this triggers warning — unusually long */
277
+ readonly maxMinlenWarn: 24;
278
+ /** minlen above this triggers critical — unreasonably long */
279
+ readonly maxMinlenCritical: 64;
280
+ /** retry below this triggers critical — no second chance */
281
+ readonly minRetry: 2;
282
+ /** Combined credit threshold: all credits at this or below with high minlen */
283
+ readonly restrictiveCreditThreshold: -2;
284
+ };
285
+ };
286
+ /**
287
+ * Validate faillock parameters for policy sanity.
288
+ *
289
+ * Checks for overly restrictive settings that could cause lockouts:
290
+ * - deny too low (typos cause lockout)
291
+ * - unlock_time too high or zero (extended/permanent lockout)
292
+ * - deny + unlock_time=0 combination (permanent lock on typos)
293
+ * - fail_interval too short
294
+ *
295
+ * @param params - Faillock parameters to validate
296
+ * @returns Array of sanity findings (empty = all sane)
297
+ */
298
+ export declare function validateFaillockParams(params: {
299
+ deny?: number;
300
+ unlock_time?: number;
301
+ fail_interval?: number;
302
+ }): PamSanityFinding[];
303
+ /**
304
+ * Validate pwquality parameters for policy sanity.
305
+ *
306
+ * Checks for overly restrictive settings that prevent password creation:
307
+ * - minlen too high
308
+ * - retry too low (no second chance)
309
+ * - All character class requirements simultaneously very strict
310
+ *
311
+ * @param params - Pwquality parameters to validate
312
+ * @returns Array of sanity findings (empty = all sane)
313
+ */
314
+ export declare function validatePwqualityParams(params: {
315
+ minlen?: number;
316
+ dcredit?: number;
317
+ ucredit?: number;
318
+ lcredit?: number;
319
+ ocredit?: number;
320
+ minclass?: number;
321
+ maxrepeat?: number;
322
+ retry?: number;
323
+ }): PamSanityFinding[];
324
+ /**
325
+ * Validate a PAM config structure for dangerous patterns.
326
+ *
327
+ * Checks the resulting PamLine[] after manipulation for patterns
328
+ * that would break authentication:
329
+ * - pam_deny.so as first auth rule (blocks all auth)
330
+ * - Missing pam_unix.so in auth stack
331
+ * - Incomplete faillock setup (preauth without authfail or vice versa)
332
+ * - Missing pam_permit.so in session stack
333
+ *
334
+ * @param lines - Parsed PAM config lines (after manipulation)
335
+ * @returns Array of sanity findings
336
+ */
337
+ export declare function validatePamConfigSanity(lines: PamLine[]): PamSanityFinding[];
338
+ /**
339
+ * Validate PAM policy sanity — combined parameter + config check.
340
+ *
341
+ * This is the main entry point for sanity validation. It runs:
342
+ * 1. Module-specific parameter checks (if module + params provided)
343
+ * 2. Config structure checks (if lines provided)
344
+ *
345
+ * @param options - What to validate
346
+ * @returns Combined sanity result with safe flag and all findings
347
+ */
348
+ export declare function validatePamPolicySanity(options: {
349
+ /** Which PAM module is being configured */
350
+ module?: "faillock" | "pwquality";
351
+ /** Module parameters being applied */
352
+ params?: Record<string, unknown>;
353
+ /** Resulting PAM config lines (after manipulation) */
354
+ lines?: PamLine[];
355
+ }): PamSanityResult;
233
356
  //# sourceMappingURL=pam-utils.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"pam-utils.d.ts","sourceRoot":"","sources":["../../src/core/pam-utils.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"pam-utils.d.ts","sourceRoot":"","sources":["../../src/core/pam-utils.ts"],"names":[],"mappings":"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"}
@@ -624,3 +624,312 @@ export async function restorePamFile(backupEntry) {
624
624
  }
625
625
  console.error(`[pam-utils] Restored ${backupEntry.backupPath} → ${backupEntry.originalPath}`);
626
626
  }
627
+ // ── PAM Sanity Thresholds ───────────────────────────────────────────────────
628
+ /**
629
+ * Thresholds for PAM policy sanity checks.
630
+ * These define what constitutes "sane" vs "dangerous" PAM policy values.
631
+ * Tuned to prevent lockouts while allowing reasonable security hardening.
632
+ */
633
+ export const PAM_SANITY_THRESHOLDS = {
634
+ faillock: {
635
+ /** deny below this triggers critical — too few attempts before lockout */
636
+ minDeny: 3,
637
+ /** unlock_time above this triggers warning — extended lockout */
638
+ maxUnlockTimeWarn: 1800, // 30 minutes
639
+ /** unlock_time above this triggers critical — extreme lockout */
640
+ maxUnlockTimeCritical: 86400, // 24 hours
641
+ /** fail_interval below this triggers warning — unusually short window */
642
+ minFailInterval: 60, // 1 minute
643
+ },
644
+ pwquality: {
645
+ /** minlen above this triggers warning — unusually long */
646
+ maxMinlenWarn: 24,
647
+ /** minlen above this triggers critical — unreasonably long */
648
+ maxMinlenCritical: 64,
649
+ /** retry below this triggers critical — no second chance */
650
+ minRetry: 2,
651
+ /** Combined credit threshold: all credits at this or below with high minlen */
652
+ restrictiveCreditThreshold: -2,
653
+ },
654
+ };
655
+ // ── Faillock Parameter Validation ───────────────────────────────────────────
656
+ /**
657
+ * Validate faillock parameters for policy sanity.
658
+ *
659
+ * Checks for overly restrictive settings that could cause lockouts:
660
+ * - deny too low (typos cause lockout)
661
+ * - unlock_time too high or zero (extended/permanent lockout)
662
+ * - deny + unlock_time=0 combination (permanent lock on typos)
663
+ * - fail_interval too short
664
+ *
665
+ * @param params - Faillock parameters to validate
666
+ * @returns Array of sanity findings (empty = all sane)
667
+ */
668
+ export function validateFaillockParams(params) {
669
+ const findings = [];
670
+ const T = PAM_SANITY_THRESHOLDS.faillock;
671
+ // Check deny
672
+ if (params.deny !== undefined && params.deny < T.minDeny) {
673
+ findings.push({
674
+ severity: "critical",
675
+ module: "pam_faillock.so",
676
+ parameter: "deny",
677
+ value: params.deny,
678
+ message: params.deny === 1
679
+ ? `deny=${params.deny}: A single failed attempt locks the account — typos cause immediate lockout`
680
+ : `deny=${params.deny}: Only ${params.deny} attempts before lockout — insufficient margin for typos`,
681
+ recommendation: `Set deny >= ${T.minDeny} (CIS Benchmark recommends 3-5)`,
682
+ });
683
+ }
684
+ // Check unlock_time
685
+ if (params.unlock_time !== undefined) {
686
+ if (params.unlock_time === 0) {
687
+ findings.push({
688
+ severity: "critical",
689
+ module: "pam_faillock.so",
690
+ parameter: "unlock_time",
691
+ value: 0,
692
+ message: "unlock_time=0: Permanent lock until admin runs 'faillock --reset' — no automatic recovery",
693
+ recommendation: "Set unlock_time to a positive value (e.g., 900 for 15 minutes)",
694
+ });
695
+ }
696
+ else if (params.unlock_time > T.maxUnlockTimeCritical) {
697
+ findings.push({
698
+ severity: "critical",
699
+ module: "pam_faillock.so",
700
+ parameter: "unlock_time",
701
+ value: params.unlock_time,
702
+ message: `unlock_time=${params.unlock_time}: Lockout exceeds 24 hours — effectively permanent for most users`,
703
+ recommendation: `Set unlock_time <= ${T.maxUnlockTimeWarn} (30 minutes, per CIS Benchmark)`,
704
+ });
705
+ }
706
+ else if (params.unlock_time > T.maxUnlockTimeWarn) {
707
+ findings.push({
708
+ severity: "warning",
709
+ module: "pam_faillock.so",
710
+ parameter: "unlock_time",
711
+ value: params.unlock_time,
712
+ message: `unlock_time=${params.unlock_time}: Lockout exceeds 30 minutes — consider a shorter unlock time`,
713
+ recommendation: `Set unlock_time <= ${T.maxUnlockTimeWarn} (30 minutes, per CIS Benchmark)`,
714
+ });
715
+ }
716
+ }
717
+ // Check fail_interval
718
+ if (params.fail_interval !== undefined && params.fail_interval < T.minFailInterval) {
719
+ findings.push({
720
+ severity: "warning",
721
+ module: "pam_faillock.so",
722
+ parameter: "fail_interval",
723
+ value: params.fail_interval,
724
+ message: `fail_interval=${params.fail_interval}: Very short failure tracking window (< 60s)`,
725
+ recommendation: `Set fail_interval >= ${T.minFailInterval} (60 seconds or more)`,
726
+ });
727
+ }
728
+ return findings;
729
+ }
730
+ // ── Pwquality Parameter Validation ──────────────────────────────────────────
731
+ /**
732
+ * Validate pwquality parameters for policy sanity.
733
+ *
734
+ * Checks for overly restrictive settings that prevent password creation:
735
+ * - minlen too high
736
+ * - retry too low (no second chance)
737
+ * - All character class requirements simultaneously very strict
738
+ *
739
+ * @param params - Pwquality parameters to validate
740
+ * @returns Array of sanity findings (empty = all sane)
741
+ */
742
+ export function validatePwqualityParams(params) {
743
+ const findings = [];
744
+ const T = PAM_SANITY_THRESHOLDS.pwquality;
745
+ // Check minlen
746
+ if (params.minlen !== undefined) {
747
+ if (params.minlen > T.maxMinlenCritical) {
748
+ findings.push({
749
+ severity: "critical",
750
+ module: "pam_pwquality.so",
751
+ parameter: "minlen",
752
+ value: params.minlen,
753
+ message: `minlen=${params.minlen}: Minimum password length exceeds ${T.maxMinlenCritical} — users cannot create compliant passwords`,
754
+ recommendation: `Set minlen <= ${T.maxMinlenWarn} (NIST SP 800-63B recommends 8-64 characters)`,
755
+ });
756
+ }
757
+ else if (params.minlen > T.maxMinlenWarn) {
758
+ findings.push({
759
+ severity: "warning",
760
+ module: "pam_pwquality.so",
761
+ parameter: "minlen",
762
+ value: params.minlen,
763
+ message: `minlen=${params.minlen}: Minimum password length exceeds ${T.maxMinlenWarn} — may be difficult for users`,
764
+ recommendation: `Set minlen <= ${T.maxMinlenWarn} for usability (14-16 is a good balance)`,
765
+ });
766
+ }
767
+ }
768
+ // Check retry
769
+ if (params.retry !== undefined && params.retry < T.minRetry) {
770
+ findings.push({
771
+ severity: "critical",
772
+ module: "pam_pwquality.so",
773
+ parameter: "retry",
774
+ value: params.retry,
775
+ message: params.retry === 0
776
+ ? "retry=0: Zero retries — password rejected on first attempt with no recovery"
777
+ : `retry=${params.retry}: Only ${params.retry} retry — insufficient for correcting typos`,
778
+ recommendation: `Set retry >= ${T.minRetry}`,
779
+ });
780
+ }
781
+ // Check combined restrictive credit requirements with high minlen
782
+ const minlen = params.minlen ?? 0;
783
+ const credits = [params.dcredit, params.ucredit, params.lcredit, params.ocredit];
784
+ const definedCredits = credits.filter((c) => c !== undefined);
785
+ if (definedCredits.length === 4 && minlen > 16) {
786
+ const allVeryRestrictive = definedCredits.every((c) => c <= T.restrictiveCreditThreshold);
787
+ if (allVeryRestrictive) {
788
+ findings.push({
789
+ severity: "warning",
790
+ module: "pam_pwquality.so",
791
+ parameter: "dcredit+ucredit+lcredit+ocredit",
792
+ message: `All character classes require ${Math.abs(T.restrictiveCreditThreshold)}+ characters with minlen=${minlen} — very restrictive combined requirements`,
793
+ recommendation: "Relax either the character class requirements or the minimum length",
794
+ });
795
+ }
796
+ }
797
+ // Check minclass=4 with high minlen
798
+ if (params.minclass !== undefined && params.minclass >= 4 && minlen > 16) {
799
+ findings.push({
800
+ severity: "warning",
801
+ module: "pam_pwquality.so",
802
+ parameter: "minclass",
803
+ value: params.minclass,
804
+ message: `minclass=${params.minclass} with minlen=${minlen}: All ${params.minclass} character classes required with long minimum — very restrictive`,
805
+ recommendation: "Consider minclass=3 or reducing minlen when requiring all character classes",
806
+ });
807
+ }
808
+ return findings;
809
+ }
810
+ // ── PAM Config Structure Validation ─────────────────────────────────────────
811
+ /**
812
+ * Validate a PAM config structure for dangerous patterns.
813
+ *
814
+ * Checks the resulting PamLine[] after manipulation for patterns
815
+ * that would break authentication:
816
+ * - pam_deny.so as first auth rule (blocks all auth)
817
+ * - Missing pam_unix.so in auth stack
818
+ * - Incomplete faillock setup (preauth without authfail or vice versa)
819
+ * - Missing pam_permit.so in session stack
820
+ *
821
+ * @param lines - Parsed PAM config lines (after manipulation)
822
+ * @returns Array of sanity findings
823
+ */
824
+ export function validatePamConfigSanity(lines) {
825
+ const findings = [];
826
+ const authRules = lines.filter((l) => l.kind === "rule" && (l.pamType === "auth" || l.pamType === "-auth"));
827
+ const sessionRules = lines.filter((l) => l.kind === "rule" && (l.pamType === "session" || l.pamType === "-session"));
828
+ // Check: pam_deny.so as first auth rule
829
+ if (authRules.length > 0 && authRules[0].module === "pam_deny.so") {
830
+ findings.push({
831
+ severity: "critical",
832
+ module: "general",
833
+ message: "pam_deny.so is the first auth rule — this blocks ALL authentication",
834
+ recommendation: "Ensure pam_deny.so is not the first rule in the auth stack",
835
+ });
836
+ }
837
+ // Check: no pam_unix.so in auth stack
838
+ const hasUnixAuth = authRules.some((r) => r.module === "pam_unix.so");
839
+ if (authRules.length > 0 && !hasUnixAuth) {
840
+ findings.push({
841
+ severity: "critical",
842
+ module: "general",
843
+ message: "No pam_unix.so in auth stack — basic password authentication is broken",
844
+ recommendation: "Ensure pam_unix.so is present in the auth stack",
845
+ });
846
+ }
847
+ // Check: incomplete faillock setup
848
+ const faillockRules = authRules.filter((r) => r.module === "pam_faillock.so");
849
+ if (faillockRules.length > 0) {
850
+ const hasPreauth = faillockRules.some((r) => r.args.includes("preauth"));
851
+ const hasAuthfail = faillockRules.some((r) => r.args.includes("authfail"));
852
+ if (hasPreauth && !hasAuthfail) {
853
+ findings.push({
854
+ severity: "warning",
855
+ module: "pam_faillock.so",
856
+ message: "Incomplete faillock setup: preauth rule present but authfail rule missing — failed attempts may not be tracked",
857
+ recommendation: "Add a pam_faillock.so authfail rule after pam_unix.so",
858
+ });
859
+ }
860
+ if (hasAuthfail && !hasPreauth) {
861
+ findings.push({
862
+ severity: "warning",
863
+ module: "pam_faillock.so",
864
+ message: "Incomplete faillock setup: authfail rule present but preauth rule missing — locked accounts may not be checked before authentication",
865
+ recommendation: "Add a pam_faillock.so preauth rule before pam_unix.so",
866
+ });
867
+ }
868
+ }
869
+ // Check: missing pam_permit.so in session stack
870
+ const hasPermitSession = sessionRules.some((r) => r.module === "pam_permit.so");
871
+ if (sessionRules.length > 0 && !hasPermitSession) {
872
+ findings.push({
873
+ severity: "warning",
874
+ module: "general",
875
+ message: "No pam_permit.so in session stack — sessions may fail to initialize",
876
+ recommendation: "Add 'session required pam_permit.so' to the session stack",
877
+ });
878
+ }
879
+ return findings;
880
+ }
881
+ // ── Combined Entry Point ────────────────────────────────────────────────────
882
+ /**
883
+ * Validate PAM policy sanity — combined parameter + config check.
884
+ *
885
+ * This is the main entry point for sanity validation. It runs:
886
+ * 1. Module-specific parameter checks (if module + params provided)
887
+ * 2. Config structure checks (if lines provided)
888
+ *
889
+ * @param options - What to validate
890
+ * @returns Combined sanity result with safe flag and all findings
891
+ */
892
+ export function validatePamPolicySanity(options) {
893
+ const findings = [];
894
+ // 1. Module-specific parameter checks
895
+ if (options.module && options.params) {
896
+ if (options.module === "faillock") {
897
+ findings.push(...validateFaillockParams({
898
+ deny: typeof options.params.deny === "number" ? options.params.deny : undefined,
899
+ unlock_time: typeof options.params.unlock_time === "number" ? options.params.unlock_time : undefined,
900
+ fail_interval: typeof options.params.fail_interval === "number" ? options.params.fail_interval : undefined,
901
+ }));
902
+ }
903
+ else if (options.module === "pwquality") {
904
+ findings.push(...validatePwqualityParams({
905
+ minlen: typeof options.params.minlen === "number" ? options.params.minlen : undefined,
906
+ dcredit: typeof options.params.dcredit === "number" ? options.params.dcredit : undefined,
907
+ ucredit: typeof options.params.ucredit === "number" ? options.params.ucredit : undefined,
908
+ lcredit: typeof options.params.lcredit === "number" ? options.params.lcredit : undefined,
909
+ ocredit: typeof options.params.ocredit === "number" ? options.params.ocredit : undefined,
910
+ minclass: typeof options.params.minclass === "number" ? options.params.minclass : undefined,
911
+ maxrepeat: typeof options.params.maxrepeat === "number" ? options.params.maxrepeat : undefined,
912
+ retry: typeof options.params.retry === "number" ? options.params.retry : undefined,
913
+ }));
914
+ }
915
+ }
916
+ // 2. Config structure checks
917
+ if (options.lines) {
918
+ findings.push(...validatePamConfigSanity(options.lines));
919
+ }
920
+ // Sort: critical first, then warning; within same severity, by module
921
+ findings.sort((a, b) => {
922
+ if (a.severity !== b.severity) {
923
+ return a.severity === "critical" ? -1 : 1;
924
+ }
925
+ return a.module.localeCompare(b.module);
926
+ });
927
+ const criticalCount = findings.filter((f) => f.severity === "critical").length;
928
+ const warningCount = findings.filter((f) => f.severity === "warning").length;
929
+ return {
930
+ safe: criticalCount === 0,
931
+ findings,
932
+ criticalCount,
933
+ warningCount,
934
+ };
935
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"access-control.d.ts","sourceRoot":"","sources":["../../src/tools/access-control.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA0MpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAu4DlE"}
1
+ {"version":3,"file":"access-control.d.ts","sourceRoot":"","sources":["../../src/tools/access-control.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA6PpE,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA0jElE"}
@@ -12,7 +12,40 @@ import { getConfig, getToolTimeout } from "../core/config.js";
12
12
  import { getDistroAdapter } from "../core/distro-adapter.js";
13
13
  import { createTextContent, createErrorContent, formatToolOutput, } from "../core/parsers.js";
14
14
  import { logChange, createChangeEntry, backupFile, } from "../core/changelog.js";
15
- import { parsePamConfig, serializePamConfig, validatePamConfig, createPamRule, removeModuleRules, insertBeforeModule, insertAfterModule, adjustJumpCounts, readPamFile, writePamFile, backupPamFile, restorePamFile, PamWriteError, } from "../core/pam-utils.js";
15
+ import { parsePamConfig, serializePamConfig, validatePamConfig, createPamRule, removeModuleRules, insertBeforeModule, insertAfterModule, adjustJumpCounts, readPamFile, writePamFile, backupPamFile, restorePamFile, PamWriteError, validatePamPolicySanity, } from "../core/pam-utils.js";
16
+ /**
17
+ * Downgrade a severity level by one step.
18
+ * critical → high, high → medium, medium → low, low → low
19
+ */
20
+ function downgradeSeverity(severity) {
21
+ switch (severity) {
22
+ case "critical": return "high";
23
+ case "high": return "medium";
24
+ case "medium": return "low";
25
+ default: return severity;
26
+ }
27
+ }
28
+ /**
29
+ * Adjust finding severity and description based on SSH service state.
30
+ */
31
+ function adjustFindingForServiceState(finding, serviceState) {
32
+ switch (serviceState) {
33
+ case "active":
34
+ return finding;
35
+ case "installed_inactive":
36
+ return {
37
+ severity: downgradeSeverity(finding.severity),
38
+ description: `[SERVICE STOPPED] ${finding.description}`,
39
+ };
40
+ case "removed_residual":
41
+ return {
42
+ severity: "info",
43
+ description: `[RESIDUAL CONFIG] ${finding.description}`,
44
+ };
45
+ case "not_installed":
46
+ return finding; // Should not reach here — not_installed short-circuits
47
+ }
48
+ }
16
49
  const SSH_HARDENING_CHECKS = [
17
50
  {
18
51
  key: "PermitRootLogin",
@@ -285,6 +318,12 @@ export function registerAccessControlTools(server) {
285
318
  .default("/usr/sbin/nologin")
286
319
  .describe("Shell to set (restrict_shell, default: /usr/sbin/nologin)"),
287
320
  // ── shared ──
321
+ force: z
322
+ .boolean()
323
+ .optional()
324
+ .default(false)
325
+ .describe("Force operation even if sanity checks report critical findings (pam_configure). " +
326
+ "Use with extreme caution — may cause lockouts."),
288
327
  dry_run: z
289
328
  .boolean()
290
329
  .optional()
@@ -296,13 +335,90 @@ export function registerAccessControlTools(server) {
296
335
  case "ssh_audit": {
297
336
  try {
298
337
  const config_path = params.config_path ?? "/etc/ssh/sshd_config";
338
+ // ── Step 1: Detect SSH service state ──────────────────────
339
+ const whichResult = await executeCommand({
340
+ command: "which",
341
+ args: ["sshd"],
342
+ toolName: "access_control",
343
+ timeout: 5000,
344
+ });
345
+ const sshdBinaryExists = whichResult.exitCode === 0;
346
+ const serviceResult = await executeCommand({
347
+ command: "systemctl",
348
+ args: ["is-active", "ssh", "sshd"],
349
+ toolName: "access_control",
350
+ timeout: 5000,
351
+ });
352
+ const serviceRunning = serviceResult.exitCode === 0;
353
+ // ── Step 2: Try to read the config file ──────────────────
299
354
  const result = await executeCommand({
300
355
  command: "sudo",
301
356
  args: ["cat", config_path],
302
357
  toolName: "access_control",
303
358
  timeout: getToolTimeout("access_control"),
304
359
  });
305
- if (result.exitCode !== 0) {
360
+ const configExists = result.exitCode === 0;
361
+ // ── Step 3: Classify service state ───────────────────────
362
+ let serviceStatus;
363
+ if (sshdBinaryExists && serviceRunning) {
364
+ serviceStatus = {
365
+ state: "active",
366
+ label: "ACTIVE (sshd running, accepting connections)",
367
+ sshdBinaryExists: true,
368
+ serviceRunning: true,
369
+ configExists,
370
+ };
371
+ }
372
+ else if (sshdBinaryExists && !serviceRunning) {
373
+ serviceStatus = {
374
+ state: "installed_inactive",
375
+ label: "INSTALLED BUT INACTIVE (sshd binary found, service not running)",
376
+ sshdBinaryExists: true,
377
+ serviceRunning: false,
378
+ configExists,
379
+ };
380
+ }
381
+ else if (!sshdBinaryExists && configExists) {
382
+ serviceStatus = {
383
+ state: "removed_residual",
384
+ label: "NOT INSTALLED (residual config file detected)",
385
+ sshdBinaryExists: false,
386
+ serviceRunning: false,
387
+ configExists: true,
388
+ };
389
+ }
390
+ else {
391
+ serviceStatus = {
392
+ state: "not_installed",
393
+ label: "NOT INSTALLED",
394
+ sshdBinaryExists: false,
395
+ serviceRunning: false,
396
+ configExists: false,
397
+ };
398
+ }
399
+ // ── Step 4: Short-circuit for not_installed ──────────────
400
+ if (serviceStatus.state === "not_installed") {
401
+ const entry = createChangeEntry({
402
+ tool: "access_control",
403
+ action: "SSH configuration audit",
404
+ target: config_path,
405
+ after: "SSH server not installed — audit skipped",
406
+ dryRun: false,
407
+ success: true,
408
+ });
409
+ logChange(entry);
410
+ const output = {
411
+ configPath: config_path,
412
+ serviceStatus: serviceStatus.label,
413
+ serviceState: serviceStatus.state,
414
+ summary: { passed: 0, failed: 0, warned: 0, total: 0 },
415
+ findings: [],
416
+ note: "SSH server is not installed on this system. No audit required.",
417
+ };
418
+ return { content: [formatToolOutput(output)] };
419
+ }
420
+ // ── Step 5: Config read failed but binary exists → error ─
421
+ if (!configExists) {
306
422
  return {
307
423
  content: [
308
424
  createErrorContent(`Cannot read SSH config (exit ${result.exitCode}): ${result.stderr}`),
@@ -362,13 +478,15 @@ export function registerAccessControlTools(server) {
362
478
  else {
363
479
  status = currentValue.toLowerCase() === check.recommended.toLowerCase() ? "pass" : "fail";
364
480
  }
481
+ // ── Step 6: Adjust severity based on service state ─────
482
+ const adjusted = adjustFindingForServiceState({ severity: check.severity, description: check.description }, serviceStatus.state);
365
483
  findings.push({
366
484
  setting: check.key,
367
485
  currentValue,
368
486
  recommendedValue: check.recommended,
369
487
  status,
370
- severity: check.severity,
371
- description: check.description,
488
+ severity: adjusted.severity,
489
+ description: adjusted.description,
372
490
  });
373
491
  }
374
492
  const passed = findings.filter((f) => f.status === "pass").length;
@@ -378,16 +496,31 @@ export function registerAccessControlTools(server) {
378
496
  tool: "access_control",
379
497
  action: "SSH configuration audit",
380
498
  target: config_path,
381
- after: `Pass: ${passed}, Fail: ${failed}, Warn: ${warned}`,
499
+ after: `Pass: ${passed}, Fail: ${failed}, Warn: ${warned}, Service: ${serviceStatus.state}`,
382
500
  dryRun: false,
383
501
  success: true,
384
502
  });
385
503
  logChange(entry);
504
+ // ── Step 7: Build output with service status context ─────
386
505
  const output = {
387
506
  configPath: config_path,
507
+ serviceStatus: serviceStatus.label,
508
+ serviceState: serviceStatus.state,
388
509
  summary: { passed, failed, warned, total: findings.length },
389
510
  findings,
390
511
  };
512
+ // Add contextual notes for non-active states
513
+ if (serviceStatus.state === "removed_residual") {
514
+ output.note =
515
+ "openssh-server has been removed but " + config_path + " remains. " +
516
+ "All findings below are INFORMATIONAL — no SSH server is running. " +
517
+ "Recommendation: sudo dpkg --purge openssh-server";
518
+ }
519
+ else if (serviceStatus.state === "installed_inactive") {
520
+ output.note =
521
+ "sshd is installed but the service is not currently running. " +
522
+ "Findings have been downgraded by one severity level.";
523
+ }
391
524
  return { content: [formatToolOutput(output)] };
392
525
  }
393
526
  catch (err) {
@@ -954,6 +1087,27 @@ export function registerAccessControlTools(server) {
954
1087
  maxrepeat: params.pam_settings?.maxrepeat ?? defaults.maxrepeat,
955
1088
  reject_username: params.pam_settings?.reject_username ?? defaults.reject_username,
956
1089
  };
1090
+ // ── Sanity check: detect dangerous pwquality parameters ──
1091
+ const pwqSanityResult = validatePamPolicySanity({
1092
+ module: "pwquality",
1093
+ params: merged,
1094
+ });
1095
+ if (pwqSanityResult.criticalCount > 0 && !(params.force)) {
1096
+ const findingsText = pwqSanityResult.findings
1097
+ .map(f => ` [${f.severity.toUpperCase()}] ${f.message}\n → ${f.recommendation}`)
1098
+ .join("\n");
1099
+ return {
1100
+ content: [
1101
+ createErrorContent(`PAM policy sanity check FAILED — ${pwqSanityResult.criticalCount} critical finding(s):\n\n` +
1102
+ findingsText +
1103
+ `\n\nTo override, set force=true. This may cause lockouts.`),
1104
+ ],
1105
+ isError: true,
1106
+ };
1107
+ }
1108
+ const pwqSanityWarnings = pwqSanityResult.findings
1109
+ .map(f => `[${f.severity.toUpperCase()}] ${f.message}`)
1110
+ .join("\n");
957
1111
  const targetFile = "/etc/security/pwquality.conf";
958
1112
  const configLines = [
959
1113
  `minlen = ${merged.minlen}`,
@@ -978,7 +1132,8 @@ export function registerAccessControlTools(server) {
978
1132
  return {
979
1133
  content: [
980
1134
  createTextContent(`[DRY-RUN] Would write the following to ${targetFile}:\n\n` +
981
- configLines.map((l) => ` ${l}`).join("\n")),
1135
+ configLines.map((l) => ` ${l}`).join("\n") +
1136
+ (pwqSanityWarnings ? `\n\n⚠️ Sanity warnings:\n${pwqSanityWarnings}` : "")),
982
1137
  ],
983
1138
  };
984
1139
  }
@@ -1023,7 +1178,8 @@ export function registerAccessControlTools(server) {
1023
1178
  return {
1024
1179
  content: [
1025
1180
  createTextContent(`pam_pwquality configured in ${targetFile}:\n\n` +
1026
- configLines.map((l) => ` ${l}`).join("\n")),
1181
+ configLines.map((l) => ` ${l}`).join("\n") +
1182
+ (pwqSanityWarnings ? `\n\n⚠️ Sanity warnings:\n${pwqSanityWarnings}` : "")),
1027
1183
  ],
1028
1184
  };
1029
1185
  }
@@ -1054,6 +1210,27 @@ export function registerAccessControlTools(server) {
1054
1210
  unlock_time: params.pam_settings?.unlock_time ?? defaults.unlock_time,
1055
1211
  fail_interval: params.pam_settings?.fail_interval ?? defaults.fail_interval,
1056
1212
  };
1213
+ // ── Sanity check: detect dangerous faillock parameters ──
1214
+ const flSanityResult = validatePamPolicySanity({
1215
+ module: "faillock",
1216
+ params: merged,
1217
+ });
1218
+ if (flSanityResult.criticalCount > 0 && !(params.force)) {
1219
+ const findingsText = flSanityResult.findings
1220
+ .map(f => ` [${f.severity.toUpperCase()}] ${f.message}\n → ${f.recommendation}`)
1221
+ .join("\n");
1222
+ return {
1223
+ content: [
1224
+ createErrorContent(`PAM policy sanity check FAILED — ${flSanityResult.criticalCount} critical finding(s):\n\n` +
1225
+ findingsText +
1226
+ `\n\nTo override, set force=true. This may cause lockouts.`),
1227
+ ],
1228
+ isError: true,
1229
+ };
1230
+ }
1231
+ const flSanityWarnings = flSanityResult.findings
1232
+ .map(f => `[${f.severity.toUpperCase()}] ${f.message}`)
1233
+ .join("\n");
1057
1234
  const targetFile = (await getDistroAdapter()).paths.pamAuth;
1058
1235
  const failArgsList = [
1059
1236
  `deny=${merged.deny}`,
@@ -1077,7 +1254,8 @@ export function registerAccessControlTools(server) {
1077
1254
  content: [
1078
1255
  createTextContent(`[DRY-RUN] Would add/update pam_faillock.so in ${targetFile}:\n\n` +
1079
1256
  ` ${preLine}\n ${authLine}\n\n` +
1080
- `Settings: ${JSON.stringify(merged)}`),
1257
+ `Settings: ${JSON.stringify(merged)}` +
1258
+ (flSanityWarnings ? `\n\n⚠️ Sanity warnings:\n${flSanityWarnings}` : "")),
1081
1259
  ],
1082
1260
  };
1083
1261
  }
@@ -1110,6 +1288,11 @@ export function registerAccessControlTools(server) {
1110
1288
  if (!recheckValidation.valid) {
1111
1289
  throw new PamWriteError(`Generated PAM config failed validation: ${recheckValidation.errors.join("; ")}`, targetFile, backupEntry.id);
1112
1290
  }
1291
+ // 7b. Config-level sanity check on the resulting PAM structure
1292
+ const configSanity = validatePamPolicySanity({ lines: recheckLines });
1293
+ if (configSanity.criticalCount > 0 && !(params.force)) {
1294
+ throw new PamWriteError(`PAM config sanity check failed: ${configSanity.findings.map(f => f.message).join("; ")}`, targetFile, backupEntry.id);
1295
+ }
1113
1296
  // 8. Write (validates pre and post-write internally)
1114
1297
  await writePamFile(targetFile, newContent);
1115
1298
  const entry = createChangeEntry({
@@ -1127,7 +1310,8 @@ export function registerAccessControlTools(server) {
1127
1310
  createTextContent(`pam_faillock configured in ${targetFile}:\n\n` +
1128
1311
  ` ${preLine}\n ${authLine}\n\n` +
1129
1312
  `Settings: ${JSON.stringify(merged)}\n` +
1130
- `Backup: ${backupEntry.backupPath}`),
1313
+ `Backup: ${backupEntry.backupPath}` +
1314
+ (flSanityWarnings ? `\n\n⚠️ Sanity warnings:\n${flSanityWarnings}` : "")),
1131
1315
  ],
1132
1316
  };
1133
1317
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "defense-mcp-server",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Defense MCP Server — 31 defensive security tools across 29 modules for system hardening and threat detection",
5
5
  "type": "module",
6
6
  "main": "build/index.js",