diff --git a/packages/device-agent/src/checks/macos/password-policy.spec.ts b/packages/device-agent/src/checks/macos/password-policy.spec.ts new file mode 100644 index 0000000000..2d61eb17bf --- /dev/null +++ b/packages/device-agent/src/checks/macos/password-policy.spec.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execSyncMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + execSync: (command: string) => execSyncMock(command), +})); + +import { MacOSPasswordPolicyCheck } from './password-policy'; + +/** + * Captured `pwpolicy getaccountpolicies` output of a stock, unmanaged Mac (macOS 26.2), + * with the non-English `policyContentDescription` localizations trimmed. + */ +const ACCOUNT_POLICIES_MIN_4 = `Getting global account policies + + + + + policyCategoryPasswordContent + + + policyContent + policyAttributePassword matches '.{4,}+' + policyContentDescription + + en + Enter a password that is four characters or more. + + policyIdentifier + com.apple.defaultpasswordpolicy.fde + + + + +`; + +/** The same output with the content policy's quantifier raised to 8 characters. */ +const ACCOUNT_POLICIES_MIN_8 = ACCOUNT_POLICIES_MIN_4.replace("'.{4,}+'", "'.{8,}+'"); + +/** `pwpolicy getaccountpolicies` when no account policy is set at all. */ +const NO_ACCOUNT_POLICIES = `Getting global account policies + + + + + +`; + +/** Representative `system_profiler SPConfigurationProfileDataType` output of a managed Mac. */ +const MDM_PROFILE_MIN_8 = `Configuration Profiles: + + ProfileItems: + + PayloadType: com.apple.mobiledevice.passwordpolicy + minLength: 8 +`; + +function mockCommands({ + accountPolicies, + globalPolicy = '', + configurationProfiles = '', +}: { + accountPolicies: string; + globalPolicy?: string; + configurationProfiles?: string; +}) { + execSyncMock.mockImplementation((command: string) => { + if (command.includes('getaccountpolicies')) return accountPolicies; + if (command.includes('getglobalpolicy')) return globalPolicy; + if (command.includes('system_profiler')) return configurationProfiles; + throw new Error(`unexpected command: ${command}`); + }); +} + +describe('MacOSPasswordPolicyCheck', () => { + beforeEach(() => { + execSyncMock.mockReset(); + }); + + it('fails when the effective account policy only requires 4 characters', async () => { + // `minChars` in the deprecated global store is what our own remediation used to write; + // macOS does not enforce it, so it must not make this check pass. + mockCommands({ accountPolicies: ACCOUNT_POLICIES_MIN_4, globalPolicy: 'minChars=8\n' }); + + const result = await new MacOSPasswordPolicyCheck().run(); + + expect(result.passed).toBe(false); + expect(result.details.message).toContain('only 4 characters'); + expect(execSyncMock.mock.calls.flat().join('\n')).not.toContain('getglobalpolicy'); + }); + + it('fails when nothing constrains the password length', async () => { + mockCommands({ accountPolicies: NO_ACCOUNT_POLICIES }); + + const result = await new MacOSPasswordPolicyCheck().run(); + + expect(result.passed).toBe(false); + expect(result.details.message).toContain('No minimum password length policy detected'); + }); + + it('passes when the account policy content requires 8 characters', async () => { + mockCommands({ accountPolicies: ACCOUNT_POLICIES_MIN_8 }); + + const result = await new MacOSPasswordPolicyCheck().run(); + + expect(result.passed).toBe(true); + expect(result.details.message).toContain('minimum 8 characters'); + }); + + it('passes when an MDM passcode payload raises the minimum above the account policy', async () => { + // A managed Mac keeps the built-in 4-character policy; the profile is enforced on top of it. + mockCommands({ + accountPolicies: ACCOUNT_POLICIES_MIN_4, + configurationProfiles: MDM_PROFILE_MIN_8, + }); + + const result = await new MacOSPasswordPolicyCheck().run(); + + expect(result.passed).toBe(true); + expect(result.details.message).toContain('minimum 8 characters'); + }); +}); diff --git a/packages/device-agent/src/checks/macos/password-policy.ts b/packages/device-agent/src/checks/macos/password-policy.ts index d9b1774604..78c0a66bd0 100644 --- a/packages/device-agent/src/checks/macos/password-policy.ts +++ b/packages/device-agent/src/checks/macos/password-policy.ts @@ -3,16 +3,25 @@ import type { CheckResult } from '../../shared/types'; import type { ComplianceCheck } from '../types'; const REQUIRED_MIN_LENGTH = 8; +const METHOD = 'pwpolicy getaccountpolicies + configuration-profiles'; /** * Checks if a minimum password length policy (>= 8 characters) is enforced on macOS. * - * Uses `pwpolicy getaccountpolicies` and `pwpolicy -getglobalpolicy` to read password policies. - * The account policies output is XML that may contain `policyAttributePassword` constraints - * with a `minChars` or `policyAttributeMinimumLength` attribute. - * The global policy output is a key=value string that may contain `minChars`. + * `pwpolicy getaccountpolicies` (no user) prints the global account policies as an XML + * plist — the store macOS evaluates when a password is set. A minimum length is expressed + * as a password content regex, e.g. `policyAttributePassword matches '.{4,}+'` (the + * 4-character `com.apple.defaultpasswordpolicy.fde` policy a stock Mac ships with). Every + * content policy has to be satisfied, so the effective minimum is the largest quantifier. * - * Also checks for MDM-enforced profiles via `system_profiler SPConfigurationProfileDataType`. + * An MDM-imposed minimum comes from a Passcode payload and is read from + * `system_profiler SPConfigurationProfileDataType`. That payload is enforced too, so the + * effective minimum is the largest value across both sources. + * + * The deprecated `pwpolicy -getglobalpolicy` store is deliberately not consulted: it is + * empty on modern macOS even while an account policy is in effect, so the `minChars` value + * our own remediation used to write there made this check pass on devices whose password + * was still 4 characters. */ export class MacOSPasswordPolicyCheck implements ComplianceCheck { checkType = 'password_policy' as const; @@ -20,26 +29,42 @@ export class MacOSPasswordPolicyCheck implements ComplianceCheck { async run(): Promise { try { - // Try pwpolicy first - const pwpolicyResult = this.checkPwpolicy(); - if (pwpolicyResult !== null) { - return pwpolicyResult; - } + const accountPolicies = execSync('pwpolicy getaccountpolicies 2>/dev/null', { + encoding: 'utf-8', + timeout: 10000, + }); - // Fall back to checking configuration profiles (MDM-enforced) - const profileResult = this.checkConfigurationProfiles(); - if (profileResult !== null) { - return profileResult; + const minLengths = [ + this.getContentPolicyMinLength(accountPolicies), + this.getProfileMinLength(), + ].filter((minLength): minLength is number => minLength !== null); + + // Nothing constrains the length, so macOS enforces no minimum. + if (minLengths.length === 0) { + return { + checkType: this.checkType, + passed: false, + details: { + method: METHOD, + raw: accountPolicies.substring(0, 500), + message: `No minimum password length policy detected. A minimum of ${REQUIRED_MIN_LENGTH} characters is required.`, + }, + checkedAt: new Date().toISOString(), + }; } - // If no explicit policy is found, macOS doesn't enforce minimum password length by default + const minLength = Math.max(...minLengths); + const passed = minLength >= REQUIRED_MIN_LENGTH; + return { checkType: this.checkType, - passed: false, + passed, details: { - method: 'pwpolicy + configuration-profiles', - raw: 'No password policy found', - message: `No minimum password length policy detected. A minimum of ${REQUIRED_MIN_LENGTH} characters is required.`, + method: METHOD, + raw: accountPolicies.substring(0, 500), + message: passed + ? `Password policy enforces minimum ${minLength} characters` + : `Password policy requires only ${minLength} characters (minimum ${REQUIRED_MIN_LENGTH} required)`, }, checkedAt: new Date().toISOString(), }; @@ -62,118 +87,35 @@ export class MacOSPasswordPolicyCheck implements ComplianceCheck { } } - private checkPwpolicy(): CheckResult | null { - // Check getaccountpolicies first - const accountResult = this.checkAccountPolicies(); - if (accountResult !== null) { - return accountResult; - } - - // Fall back to getglobalpolicy - return this.checkGlobalPolicy(); - } - - private checkAccountPolicies(): CheckResult | null { - try { - const output = execSync('pwpolicy getaccountpolicies 2>/dev/null', { - encoding: 'utf-8', - timeout: 10000, - }); - - // Look for minimum password length in the XML output - const minCharsMatch = output.match(/policyAttributePasswordMinimumLength\s*=\s*(\d+)/i); - const minCharsMatch2 = output.match(/minChars\s*[=:]\s*(\d+)/i); - const minLengthMatch = output.match(/minimumLength\s*[=:>\s]*(\d+)/i); - - const matches = [minCharsMatch, minCharsMatch2, minLengthMatch].filter(Boolean); - - if (matches.length > 0) { - const minLength = Math.max(...matches.map((m) => parseInt(m![1], 10))); - const passed = minLength >= REQUIRED_MIN_LENGTH; - - return { - checkType: this.checkType, - passed, - details: { - method: 'pwpolicy getaccountpolicies', - raw: output.substring(0, 500), - message: passed - ? `Password policy enforces minimum ${minLength} characters` - : `Password policy requires only ${minLength} characters (minimum ${REQUIRED_MIN_LENGTH} required)`, - }, - checkedAt: new Date().toISOString(), - }; - } + /** + * Largest minimum length required by the password content policies, + * or null when none of them constrains the length. + */ + private getContentPolicyMinLength(accountPolicies: string): number | null { + const constraints = [ + ...accountPolicies.matchAll(/policyAttributePassword\s+matches\s+'[^']*\{(\d+),/g), + ]; - return null; - } catch { + if (constraints.length === 0) { return null; } - } - - private checkGlobalPolicy(): CheckResult | null { - try { - const globalOutput = execSync('pwpolicy -getglobalpolicy 2>/dev/null', { - encoding: 'utf-8', - timeout: 10000, - }); - - const globalMinCharsMatch = globalOutput.match(/minChars\s*[=:]\s*(\d+)/i); - - if (globalMinCharsMatch) { - const minLength = parseInt(globalMinCharsMatch[1], 10); - const passed = minLength >= REQUIRED_MIN_LENGTH; - - return { - checkType: this.checkType, - passed, - details: { - method: 'pwpolicy -getglobalpolicy', - raw: globalOutput.substring(0, 500), - message: passed - ? `Password policy enforces minimum ${minLength} characters` - : `Password policy requires only ${minLength} characters (minimum ${REQUIRED_MIN_LENGTH} required)`, - }, - checkedAt: new Date().toISOString(), - }; - } - return null; - } catch { - return null; - } + return Math.max(...constraints.map((match) => parseInt(match[1], 10))); } - private checkConfigurationProfiles(): CheckResult | null { + /** + * Minimum length imposed by an MDM Passcode payload, or null when no profile declares one. + */ + private getProfileMinLength(): number | null { try { const output = execSync('system_profiler SPConfigurationProfileDataType 2>/dev/null', { encoding: 'utf-8', timeout: 15000, }); - // Look for password policy in MDM profiles const minLengthMatch = output.match(/minLength\s*[=:]\s*(\d+)/i); - const minComplexCharsMatch = output.match(/minComplexChars\s*[=:]\s*(\d+)/i); - - if (minLengthMatch) { - const minLength = parseInt(minLengthMatch[1], 10); - const passed = minLength >= REQUIRED_MIN_LENGTH; - - return { - checkType: this.checkType, - passed, - details: { - method: 'system_profiler SPConfigurationProfileDataType', - raw: `MDM Profile: minLength=${minLength}${minComplexCharsMatch ? `, minComplexChars=${minComplexCharsMatch[1]}` : ''}`, - message: passed - ? `MDM profile enforces minimum ${minLength} character password` - : `MDM profile requires only ${minLength} characters (minimum ${REQUIRED_MIN_LENGTH} required)`, - }, - checkedAt: new Date().toISOString(), - }; - } - return null; + return minLengthMatch ? parseInt(minLengthMatch[1], 10) : null; } catch { return null; } diff --git a/packages/device-agent/src/remediations/instructions.ts b/packages/device-agent/src/remediations/instructions.ts index b1ac60d1b6..c5bc8805e4 100644 --- a/packages/device-agent/src/remediations/instructions.ts +++ b/packages/device-agent/src/remediations/instructions.ts @@ -21,13 +21,14 @@ const MACOS_INSTRUCTIONS: Record = { ], }, password_policy: { - description: 'Set a minimum password length of 8 characters', + description: 'Enforce a minimum password length of 8 characters', steps: [ - 'An administrator password will be required to apply this setting', - 'Click "Fix" to set the policy, or apply it manually:', - 'Open Terminal', - 'Run: sudo pwpolicy -setglobalpolicy "minChars=8"', - 'Enter your administrator password when prompted', + 'macOS only enforces the minimum length set in its global account policies — System Settings cannot set it', + 'Managed devices: ask your IT administrator to deploy a Passcode payload with a minimum length of 8 via your MDM (Jamf, Kandji, Intune, …)', + 'Unmanaged devices: an administrator has to set the global account policies with: sudo pwpolicy -setaccountpolicies ', + 'That command replaces every existing policy, so the file must also keep the built-in FileVault policy', + "Verify with: pwpolicy getaccountpolicies — it should show policyAttributePassword matches '.{8,}+'", + 'Note: sudo pwpolicy -setglobalpolicy "minChars=8" is deprecated and is not enforced by macOS', ], }, disk_encryption: { diff --git a/packages/device-agent/src/remediations/macos/password-policy.spec.ts b/packages/device-agent/src/remediations/macos/password-policy.spec.ts new file mode 100644 index 0000000000..ed43addf61 --- /dev/null +++ b/packages/device-agent/src/remediations/macos/password-policy.spec.ts @@ -0,0 +1,39 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const execSyncMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + execSync: (command: string) => execSyncMock(command), +})); + +import { MacOSPasswordPolicyRemediation } from './password-policy'; + +describe('MacOSPasswordPolicyRemediation', () => { + beforeEach(() => { + execSyncMock.mockReset(); + }); + + it('is guidance only, because macOS enforces no policy this agent can safely write', () => { + const info = new MacOSPasswordPolicyRemediation().getInfo(); + + expect(info.type).toBe('guide_only'); + expect(info.requiresAdmin).toBe(false); + }); + + it('never recommends the deprecated global policy store as the way to fix it', () => { + const info = new MacOSPasswordPolicyRemediation().getInfo(); + const instructions = info.instructions.join('\n'); + + expect(instructions).toContain('pwpolicy -setaccountpolicies'); + expect(instructions).toContain('is deprecated and is not enforced by macOS'); + }); + + it('does not run any command and does not report success', async () => { + const result = await new MacOSPasswordPolicyRemediation().remediate(); + + // Reporting success re-ran the checks and showed a "policy set" toast while the check + // kept failing, because `pwpolicy -setglobalpolicy` writes a store macOS ignores. + expect(result.success).toBe(false); + expect(execSyncMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/device-agent/src/remediations/macos/password-policy.ts b/packages/device-agent/src/remediations/macos/password-policy.ts index bf691a5e6c..c2fd0dc1ef 100644 --- a/packages/device-agent/src/remediations/macos/password-policy.ts +++ b/packages/device-agent/src/remediations/macos/password-policy.ts @@ -1,14 +1,16 @@ -import { execSync } from 'node:child_process'; import type { RemediationInfo, RemediationResult } from '../../shared/types'; import { getInstructions } from '../instructions'; import type { ComplianceRemediation } from '../types'; -const REQUIRED_MIN_LENGTH = 8; - /** * macOS password policy remediation. - * Uses osascript to run pwpolicy with administrator privileges, - * which shows the native macOS password dialog. + * + * There is nothing safe to automate: the minimum length macOS enforces lives in the global + * account policies, and `pwpolicy -setaccountpolicies` *replaces* that whole set, which + * would drop the built-in FileVault policy and any MDM-imposed ones. The previous + * `pwpolicy -setglobalpolicy "minChars=8"` wrote the deprecated legacy store instead, which + * macOS does not enforce — it reported success while the check kept failing. + * This remediation provides guided instructions only. */ export class MacOSPasswordPolicyRemediation implements ComplianceRemediation { checkType = 'password_policy' as const; @@ -18,45 +20,19 @@ export class MacOSPasswordPolicyRemediation implements ComplianceRemediation { return { checkType: this.checkType, available: true, - type: 'admin_fix', - requiresAdmin: true, + type: 'guide_only', + requiresAdmin: false, description, instructions: steps, }; } async remediate(): Promise { - try { - // Use osascript to elevate privileges — shows native macOS admin password dialog - // Escaped quotes: osascript uses single-quoted AppleScript, inner shell command uses escaped double quotes - const command = `pwpolicy -setglobalpolicy \\\"minChars=${REQUIRED_MIN_LENGTH}\\\"`; - execSync( - `osascript -e 'do shell script "${command}" with administrator privileges'`, - { encoding: 'utf-8', timeout: 60000 }, // 60s timeout to allow for password entry - ); - - return { - checkType: this.checkType, - success: true, - message: `Password policy set: minimum ${REQUIRED_MIN_LENGTH} characters required`, - }; - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error); - - // User cancelled the admin dialog - if (errorMessage.includes('User canceled') || errorMessage.includes('-128')) { - return { - checkType: this.checkType, - success: false, - message: 'Administrator authentication was cancelled', - }; - } - - return { - checkType: this.checkType, - success: false, - message: `Failed to set password policy: ${errorMessage}`, - }; - } + return { + checkType: this.checkType, + success: false, + message: + 'A minimum password length has to be enforced by your MDM, or by an administrator setting the global account policies. Please follow the guided instructions.', + }; } } diff --git a/packages/docs/device-agent.mdx b/packages/docs/device-agent.mdx index e71c492c80..59d7275d9b 100644 --- a/packages/docs/device-agent.mdx +++ b/packages/docs/device-agent.mdx @@ -245,10 +245,11 @@ For users who cannot install the agent on their device, manual evidence of devic **Minimum Password Length** - 1. Native macOS UI doesn't enforce this; requires **Terminal** or **MDM**. - 2. `pwpolicy -setglobalpolicy "minChars=8"` - 3. If set via Terminal, take a screenshot of the command output confirming the policy. - 4. If enforced by MDM (Jamf, Intune, etc.), screenshot the compliance screen from the MDM portal. + 1. Native macOS UI doesn't enforce this; requires **MDM** or **Terminal**. + 2. If enforced by MDM (Jamf, Intune, etc.), deploy a **Passcode** payload with a minimum length of 8, then screenshot the compliance screen from the MDM portal. + 3. Without MDM, an administrator sets the global account policies: `sudo pwpolicy -setaccountpolicies `. This **replaces** all existing policies, so keep the built-in FileVault policy in the file. + 4. Verify with `pwpolicy getaccountpolicies` — it should show `policyAttributePassword matches '.{8,}+'`. Take a screenshot of that output. + 5. `pwpolicy -setglobalpolicy "minChars=8"` is deprecated and **not** enforced by macOS, so it is not accepted as evidence. **Automatic Security Updates**