Skip to content
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ ignore = [
]

[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "SLF001", "ARG001"] # assert + test data + credentials + private access + unused mock args OK in tests
"**/tests/**/*.py" = ["S101", "S105", "S106", "S107", "S108", "S314", "SLF001", "ARG001"] # assert + test data + credentials + XML parsing + private access + unused mock args OK in tests

[tool.ruff.lint.isort]
known-first-party = ["mac2nix"]
Expand Down
43 changes: 43 additions & 0 deletions src/mac2nix/models/package_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,11 +182,54 @@ class CondaState(BaseModel):
environments: list[CondaEnvironment] = []


class LanguagePackage(BaseModel):
"""A package installed by a language ecosystem package manager."""

name: str
version: str | None = None
binaries: list[str] = []


class PipxState(BaseModel):
present: bool = False
version: str | None = None
packages: list[LanguagePackage] = []


class CargoState(BaseModel):
present: bool = False
version: str | None = None
packages: list[LanguagePackage] = []


class NpmGlobalState(BaseModel):
present: bool = False
version: str | None = None
packages: list[LanguagePackage] = []


class GoState(BaseModel):
present: bool = False
version: str | None = None
packages: list[LanguagePackage] = []


class GemState(BaseModel):
present: bool = False
version: str | None = None
packages: list[LanguagePackage] = []


class PackageManagersResult(BaseModel):
"""Third-party (non-Homebrew, non-Nix) package managers."""

macports: MacPortsState = Field(default_factory=MacPortsState)
conda: CondaState = Field(default_factory=CondaState)
pipx: PipxState = Field(default_factory=PipxState)
cargo: CargoState = Field(default_factory=CargoState)
npm_global: NpmGlobalState = Field(default_factory=NpmGlobalState)
go: GoState = Field(default_factory=GoState)
gem: GemState = Field(default_factory=GemState)


class ContainerRuntimeType(StrEnum):
Expand Down
2 changes: 2 additions & 0 deletions src/mac2nix/models/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class NetworkConfig(BaseModel):
wifi_networks: list[str] = []
vpn_profiles: list[VpnProfile] = []
proxy_bypass_domains: list[str] = []
known_network_services: list[str] = []
locations: list[str] = []
current_location: str | None = None

Expand Down Expand Up @@ -85,6 +86,7 @@ class ICloudState(BaseModel):

class SystemConfig(BaseModel):
hostname: str
computer_name: str | None = None
timezone: str | None = None
locale: str | None = None
power_settings: dict[str, str] = {} # pmset key-value pairs
Expand Down
43 changes: 31 additions & 12 deletions src/mac2nix/scanners/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,22 @@ def parallel_walk_dirs[T](
]


SENSITIVE_KEY_PATTERNS = frozenset({"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"})


def redact_sensitive_keys(data: dict[str, Any]) -> None:
"""Recursively redact values whose key contains a sensitive pattern."""
for key in list(data.keys()):
if any(p in key.upper() for p in SENSITIVE_KEY_PATTERNS):
data[key] = "***REDACTED***"
elif isinstance(data[key], dict):
redact_sensitive_keys(data[key])
elif isinstance(data[key], list):
for item in data[key]:
if isinstance(item, dict):
redact_sensitive_keys(item)


def sanitize_plist_values(obj: Any) -> Any:
"""Recursively convert non-JSON-safe plist values.

Expand Down Expand Up @@ -348,15 +364,12 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None:
else:
logger.warning("Permission denied reading plist: %s", path)
return None
except plistlib.InvalidFileException:
logger.warning("Invalid plist file: %s", path)
return None
except (ValueError, OverflowError):
# plistlib can't handle dates like year 0 (Apple's "no date" sentinel).
# Fall back to plutil XML conversion which preserves dates as strings.
except (plistlib.InvalidFileException, ValueError, OverflowError):
# plistlib can't handle NeXTStep-format plists, some newer binary plist
# variants, or dates like year 0. Fall back to plutil XML conversion.
data = _read_plist_via_plutil(path)
if data is None:
logger.warning("Plist contains unrepresentable data: %s", path)
logger.debug("Skipping unreadable plist: %s", path)
return None
except OSError as exc:
logger.warning("Failed to read plist %s: %s", path, exc)
Expand All @@ -365,7 +378,7 @@ def read_plist_safe(path: Path) -> dict[str, Any] | list[Any] | None:
return sanitize_plist_values(data)


def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None:
def _read_plist_via_plutil(path: Path) -> dict[str, Any] | list[Any] | None:
"""Fallback plist reader using plutil XML conversion.

Parses the XML manually to handle dates that Python's datetime can't
Expand All @@ -381,10 +394,14 @@ def _read_plist_via_plutil(path: Path) -> dict[str, Any] | None:
return None

top_dict = root.find("dict")
if top_dict is None:
return None
if top_dict is not None:
return _parse_xml_dict(top_dict)

top_array = root.find("array")
if top_array is not None:
return [_parse_xml_value(child) for child in top_array]

return _parse_xml_dict(top_dict)
return None


def _parse_xml_dict(element: ElementTree.Element) -> dict[str, Any]:
Expand Down Expand Up @@ -420,7 +437,9 @@ def _parse_xml_value(element: ElementTree.Element) -> Any:
# string, date, data → text; integer/real → parsed; unknown → text
converters: dict[str, Any] = {"integer": int, "real": float}
converter = converters.get(tag)
return converter(text) if converter else text
if converter and text:
return converter(text)
return text


def read_launchd_plists() -> list[tuple[Path, str, dict[str, Any]]]:
Expand Down
15 changes: 3 additions & 12 deletions src/mac2nix/scanners/launch_agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from typing import Any

from mac2nix.models.services import LaunchAgentEntry, LaunchAgentSource, LaunchAgentsResult
from mac2nix.scanners._utils import read_launchd_plists, run_command
from mac2nix.scanners._utils import SENSITIVE_KEY_PATTERNS, read_launchd_plists, redact_sensitive_keys, run_command
from mac2nix.scanners.base import BaseScannerPlugin, register

logger = logging.getLogger(__name__)
Expand All @@ -23,8 +23,6 @@
"daemon": LaunchAgentSource.DAEMON,
}

_SENSITIVE_ENV_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"}


@register("launch_agents")
class LaunchAgentsScanner(BaseScannerPlugin):
Expand Down Expand Up @@ -76,22 +74,15 @@ def _parse_agent_data(

# Deep copy to avoid mutating the shared prefetch data
raw_plist = copy.deepcopy(data)
env_raw = data.get("EnvironmentVariables")
if isinstance(env_raw, dict):
redacted = {
k: "***REDACTED***" if any(p in k.upper() for p in _SENSITIVE_ENV_PATTERNS) else v
for k, v in env_raw.items()
}
if redacted != env_raw:
raw_plist["EnvironmentVariables"] = redacted
redact_sensitive_keys(raw_plist)

# Extract filtered environment variables
env_vars = data.get("EnvironmentVariables")
filtered_env: dict[str, str] | None = None
if isinstance(env_vars, dict):
filtered_env = {}
for key, val in env_vars.items():
if any(p in key.upper() for p in _SENSITIVE_ENV_PATTERNS):
if any(p in key.upper() for p in SENSITIVE_KEY_PATTERNS):
filtered_env[key] = "***REDACTED***"
else:
filtered_env[key] = str(val)
Expand Down
20 changes: 3 additions & 17 deletions src/mac2nix/scanners/library_scanner.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
hash_file,
parallel_walk_dirs,
read_plist_safe,
redact_sensitive_keys,
run_command,
)
from mac2nix.scanners.base import BaseScannerPlugin, register
Expand Down Expand Up @@ -83,8 +84,6 @@
}
)

_SENSITIVE_KEY_PATTERNS = {"_KEY", "_TOKEN", "_SECRET", "_PASSWORD", "_CREDENTIAL", "_AUTH"}

# Redacts values in key=value / key: value lines where the key contains a sensitive word.
# Uses separator-prefixed compound patterns ([_.-]key, [_.-]token, etc.) to avoid false
# positives on words like "monkey", "turkey", "keyboard". Standalone patterns (password,
Expand Down Expand Up @@ -116,19 +115,6 @@
)


def _redact_sensitive_keys(data: dict[str, Any]) -> None:
"""Recursively redact sensitive keys from a plist dict."""
for key in list(data.keys()):
if any(p in key.upper() for p in _SENSITIVE_KEY_PATTERNS):
data[key] = "***REDACTED***"
elif isinstance(data[key], dict):
_redact_sensitive_keys(data[key])
elif isinstance(data[key], list):
for item in data[key]:
if isinstance(item, dict):
_redact_sensitive_keys(item)


@register("library")
class LibraryScanner(BaseScannerPlugin):
@property
Expand Down Expand Up @@ -393,7 +379,7 @@ def _classify_file(self, filepath: Path) -> LibraryFileEntry | None:
raw_plist = read_plist_safe(filepath)
if isinstance(raw_plist, dict):
plist_content = raw_plist
_redact_sensitive_keys(plist_content)
redact_sensitive_keys(plist_content)
content_hash = hash_file(filepath)
strategy = "plist_capture" if plist_content else "hash_only"
elif suffix in {".txt", ".md", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".json", ".xml", ".toml"}:
Expand Down Expand Up @@ -508,7 +494,7 @@ def _parse_workflow(wf_path: Path) -> WorkflowEntry | None:
if doc_plist.is_file():
raw = read_plist_safe(doc_plist)
if isinstance(raw, dict):
_redact_sensitive_keys(raw)
redact_sensitive_keys(raw)
definition = raw

return WorkflowEntry(
Expand Down
19 changes: 18 additions & 1 deletion src/mac2nix/scanners/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def scan(self) -> NetworkConfig:
proxy_bypass_domains = self._get_proxy_bypass_domains(interfaces)
wifi_networks = self._get_wifi_networks(interfaces)
vpn_profiles = self._get_vpn_profiles()
known_network_services = self._get_network_services()
locations, current_location = self._get_locations()

return NetworkConfig(
Expand All @@ -40,6 +41,7 @@ def scan(self) -> NetworkConfig:
wifi_networks=wifi_networks,
vpn_profiles=vpn_profiles,
proxy_bypass_domains=proxy_bypass_domains,
known_network_services=known_network_services,
locations=locations,
current_location=current_location,
)
Expand Down Expand Up @@ -209,7 +211,6 @@ def _get_wifi_networks(self, interfaces: list[NetworkInterface]) -> list[str]:
networks = []
for line in result.stdout.splitlines():
stripped = line.strip()
# Skip header line
if stripped.startswith("Preferred networks"):
continue
if stripped:
Expand Down Expand Up @@ -252,6 +253,22 @@ def _get_vpn_profiles(self) -> list[VpnProfile]:
)
return profiles

def _get_network_services(self) -> list[str]:
"""Get all network service names for networking.knownNetworkServices."""
result = run_command(["networksetup", "-listallnetworkservices"])
if result is None or result.returncode != 0:
return []
services: list[str] = []
for line in result.stdout.splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("An asterisk"):
continue
if stripped.startswith("*"):
stripped = stripped.removeprefix("*").strip()
if stripped:
services.append(stripped)
return services

def _get_locations(self) -> tuple[list[str], str | None]:
"""Get network locations and current location."""
locations: list[str] = []
Expand Down
Loading