165 lines
6.4 KiB
Python
165 lines
6.4 KiB
Python
"""Filesystem hardening signals (world-writable files in hook directories)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import stat
|
|
from pathlib import Path
|
|
|
|
from applepy.context import RunContext
|
|
from applepy.findings import Finding, Severity
|
|
from applepy.registry import CheckRegistry
|
|
|
|
|
|
def _env_positive_int(name: str, default: int) -> int:
|
|
raw = os.environ.get(name, "").strip()
|
|
if not raw:
|
|
return default
|
|
try:
|
|
v = int(raw)
|
|
return v if v > 0 else default
|
|
except ValueError:
|
|
return default
|
|
|
|
|
|
# Override with APPLEPY_FS_MAX_SCAN / APPLEPY_FS_MAX_HITS if a host is very large.
|
|
_DEFAULT_MAX_SCAN = _env_positive_int("APPLEPY_FS_MAX_SCAN", 500_000)
|
|
_DEFAULT_MAX_HITS = _env_positive_int("APPLEPY_FS_MAX_HITS", 100_000)
|
|
|
|
|
|
def collect_world_writable_files(
|
|
roots: list[Path],
|
|
*,
|
|
max_scan: int = _DEFAULT_MAX_SCAN,
|
|
max_hits: int = _DEFAULT_MAX_HITS,
|
|
) -> tuple[list[str], list[str]]:
|
|
"""
|
|
Return (hit_lines, notes). Each hit is 'path mode=0o....'.
|
|
Stops after max_scan file stats or max_hits world-writable regular files (see env vars above).
|
|
"""
|
|
hits: list[str] = []
|
|
notes: list[str] = []
|
|
scanned = 0
|
|
|
|
for root in roots:
|
|
if not root.is_dir():
|
|
continue
|
|
try:
|
|
for dirpath, _dirnames, filenames in os.walk(
|
|
root,
|
|
topdown=True,
|
|
followlinks=False,
|
|
):
|
|
for fn in filenames:
|
|
if scanned >= max_scan or len(hits) >= max_hits:
|
|
if len(hits) >= max_hits:
|
|
notes.append(
|
|
f"Hit cap reached ({max_hits} world-writable regular files) "
|
|
f"(APPLEPY_FS_MAX_HITS); scanned {scanned} entries — increase the variable if more "
|
|
"matches must be listed."
|
|
)
|
|
if scanned >= max_scan:
|
|
notes.append(
|
|
f"Scan stopped after examining {max_scan} file system entries "
|
|
f"(APPLEPY_FS_MAX_SCAN); {len(hits)} world-writable regular files recorded — "
|
|
"increase the variable for a complete pass on very large trees."
|
|
)
|
|
return hits, notes
|
|
path = Path(dirpath) / fn
|
|
try:
|
|
st = path.lstat()
|
|
except OSError:
|
|
continue
|
|
scanned += 1
|
|
if not stat.S_ISREG(st.st_mode):
|
|
continue
|
|
if st.st_mode & stat.S_IWOTH:
|
|
mode_oct = oct(st.st_mode & 0o777)
|
|
hits.append(f"{path} mode={mode_oct}")
|
|
except OSError as e:
|
|
notes.append(f"{root}: walk failed ({e})")
|
|
|
|
return hits, notes
|
|
|
|
|
|
def register(registry: CheckRegistry) -> None:
|
|
registry.register(
|
|
"fs_world_writable_user",
|
|
check_world_writable_user_launch_dirs,
|
|
phases=("unprivileged",),
|
|
)
|
|
registry.register(
|
|
"fs_world_writable_system",
|
|
check_world_writable_system_launch_dirs,
|
|
phases=("privileged",),
|
|
)
|
|
|
|
|
|
def check_world_writable_user_launch_dirs(ctx: RunContext) -> list[Finding]:
|
|
roots = [
|
|
ctx.home / "Library" / "LaunchAgents",
|
|
ctx.home / "Library" / "LaunchDaemons",
|
|
]
|
|
hits, notes = collect_world_writable_files(roots)
|
|
ev_parts = list(hits)
|
|
ev_parts.extend(notes)
|
|
evidence = "\n".join(ev_parts) if ev_parts else "(no world-writable regular files under user Launch* dirs)"
|
|
sev = Severity.HIGH if hits else Severity.INFORMATIONAL
|
|
return [
|
|
Finding(
|
|
id="fs-001",
|
|
title="World-writable files under user LaunchAgents / LaunchDaemons",
|
|
category="Hardening",
|
|
severity=sev,
|
|
description=(
|
|
"Regular files writable by others under per-user launch directories can allow local "
|
|
"privilege abuse or persistence tampering; aligns with library/system folder reviews "
|
|
"recommended in enterprise macOS baselines."
|
|
),
|
|
evidence=evidence,
|
|
worksheet="Hardening",
|
|
mitre_techniques=("T1222", "T1543.001", "T1543.004"),
|
|
risk="World-writable launch artefacts weaken integrity expectations for login-time execution.",
|
|
impact="Low-privilege users or malware may alter plist payloads or helper scripts.",
|
|
remediation="Remove world-writable bits; ensure ownership is the user and group is staff (or equivalent).",
|
|
references=(
|
|
"https://support.kandji.io/kb/checking-library-and-system-folders-for-world-writable-files",
|
|
),
|
|
)
|
|
]
|
|
|
|
|
|
def check_world_writable_system_launch_dirs(ctx: RunContext) -> list[Finding]:
|
|
if not ctx.is_root():
|
|
return []
|
|
roots = [
|
|
Path("/Library/LaunchDaemons"),
|
|
Path("/Library/LaunchAgents"),
|
|
]
|
|
hits, notes = collect_world_writable_files(roots)
|
|
ev_parts = list(hits)
|
|
ev_parts.extend(notes)
|
|
evidence = "\n".join(ev_parts) if ev_parts else "(no world-writable regular files under /Library Launch* dirs)"
|
|
sev = Severity.CRITICAL if hits else Severity.INFORMATIONAL
|
|
return [
|
|
Finding(
|
|
id="fs-002",
|
|
title="World-writable files under system LaunchDaemons / LaunchAgents",
|
|
category="Hardening",
|
|
severity=sev,
|
|
description=(
|
|
"World-writable plists or binaries under /Library/Launch* are a serious integrity failure "
|
|
"on macOS; scan is depth-first with configurable file and hit caps (see APPLEPY_FS_MAX_*)."
|
|
),
|
|
evidence=evidence,
|
|
worksheet="Hardening",
|
|
mitre_techniques=("T1222", "T1543.001", "T1543.004"),
|
|
risk="Any local process may alter launch configuration consumed at boot or login.",
|
|
impact="Persistence, privilege escalation, or denial of service across all users.",
|
|
remediation="Immediately audit ownership and modes; restore vendor defaults and investigate root cause.",
|
|
references=(
|
|
"https://support.kandji.io/kb/checking-library-and-system-folders-for-world-writable-files",
|
|
),
|
|
)
|
|
]
|