Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/device-agent/src/checks/macos/password-policy.spec.ts
Original file line number Diff line number Diff line change
@@ -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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>policyCategoryPasswordContent</key>
<array>
<dict>
<key>policyContent</key>
<string>policyAttributePassword matches '.{4,}+'</string>
<key>policyContentDescription</key>
<dict>
<key>en</key>
<string>Enter a password that is four characters or more.</string>
</dict>
<key>policyIdentifier</key>
<string>com.apple.defaultpasswordpolicy.fde</string>
</dict>
</array>
</dict>
</plist>
`;

/** 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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
</plist>
`;

/** 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');
});
});
176 changes: 59 additions & 117 deletions packages/device-agent/src/checks/macos/password-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,43 +3,68 @@ 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;
displayName = 'Password Policy (Min 8 Characters)';

async run(): Promise<CheckResult> {
try {
// Try pwpolicy first
const pwpolicyResult = this.checkPwpolicy();
if (pwpolicyResult !== null) {
return pwpolicyResult;
}
const accountPolicies = execSync('pwpolicy getaccountpolicies 2>/dev/null', {

@cubic-dev-ai cubic-dev-ai Bot Jul 29, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: MDM-only Macs can be reported as non-compliant without checking their Passcode profile: if pwpolicy getaccountpolicies throws, the outer catch returns before getProfileMinLength() runs. Keeping the account-policy and profile reads independently guarded would preserve the existing fallback and allow a readable MDM policy to pass even when the account-policy store is inaccessible.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/device-agent/src/checks/macos/password-policy.ts, line 32:

<comment>MDM-only Macs can be reported as non-compliant without checking their Passcode profile: if `pwpolicy getaccountpolicies` throws, the outer catch returns before `getProfileMinLength()` runs. Keeping the account-policy and profile reads independently guarded would preserve the existing fallback and allow a readable MDM policy to pass even when the account-policy store is inaccessible.</comment>

<file context>
@@ -3,43 +3,68 @@ import type { CheckResult } from '../../shared/types';
-      if (pwpolicyResult !== null) {
-        return pwpolicyResult;
-      }
+      const accountPolicies = execSync('pwpolicy getaccountpolicies 2>/dev/null', {
+        encoding: 'utf-8',
+        timeout: 10000,
</file context>
Fix with cubic

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(),
};
Expand All @@ -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;
}
Expand Down
13 changes: 7 additions & 6 deletions packages/device-agent/src/remediations/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ const MACOS_INSTRUCTIONS: Record<DeviceCheckType, InstructionSet> = {
],
},
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 <file>',
'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: {
Expand Down
Loading
Loading