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**