391 lines
15 KiB
Python
391 lines
15 KiB
Python
"""Read-only checks aligned with a local text export of SpecterOps' macOS red-teaming presentation.
|
|
|
|
Themes: Homebrew, containers, scripting, egress, situational awareness, lateral-movement artefacts. No API abuse
|
|
— local filesystem and PATH posture only.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import platform
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from applepy.context import RunContext
|
|
from applepy.findings import Finding, Severity
|
|
from applepy.registry import CheckRegistry
|
|
from applepy.subproc import run_text
|
|
|
|
# Optional cwd filename if you copy or symlink a text export (e.g. from SpecterOps presentations).
|
|
_DECK_EXPORT_TXT_CANDIDATES: tuple[str, ...] = ("APPLEPY_DECK_REFERENCE.txt",)
|
|
|
|
|
|
def _darwin() -> bool:
|
|
return platform.system() == "Darwin"
|
|
|
|
|
|
def _find_deck_export_txt(cwd: Path) -> Path | None:
|
|
for key in ("APPLEPY_DECK_EXPORT_TXT", "APPLEPY_SOCON_TXT"):
|
|
env = os.environ.get(key, "").strip()
|
|
if env:
|
|
p = Path(env).expanduser()
|
|
if p.is_file():
|
|
return p.resolve()
|
|
for name in _DECK_EXPORT_TXT_CANDIDATES:
|
|
p = cwd / name
|
|
if p.is_file():
|
|
return p.resolve()
|
|
return None
|
|
|
|
|
|
def register(registry: CheckRegistry) -> None:
|
|
registry.register("deck_reference", check_deck_export_reference, phases=("unprivileged",))
|
|
registry.register("deck_sysconfig_prefs", check_systemconfiguration_preferences, phases=("unprivileged",))
|
|
registry.register("deck_timemachine_plist", check_timemachine_plist, phases=("unprivileged",))
|
|
registry.register("deck_zsh_sessions", check_zsh_sessions_dir, phases=("unprivileged",))
|
|
registry.register("deck_kube_config", check_kube_config_presence, phases=("unprivileged",))
|
|
registry.register("deck_docker_surface", check_docker_surface, phases=("unprivileged",))
|
|
registry.register("deck_parallels", check_parallels_presence, phases=("unprivileged",))
|
|
registry.register("deck_homebrew_surface", check_homebrew_surface, phases=("unprivileged",))
|
|
registry.register("deck_openvpn", check_openvpn_on_path, phases=("unprivileged",))
|
|
registry.register("deck_pyobjc_import", check_pyobjc_import_probe, phases=("unprivileged",))
|
|
|
|
|
|
def check_deck_export_reference(_ctx: RunContext) -> list[Finding]:
|
|
cwd = Path.cwd()
|
|
hit = _find_deck_export_txt(cwd)
|
|
if not hit:
|
|
return [
|
|
Finding(
|
|
id="deck-000",
|
|
title="Presentation text export (reference file)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description=(
|
|
"Optional local text export of the SpecterOps macOS red-teaming presentation was not found. "
|
|
"Checks in this module still apply aligned themes. Set APPLEPY_DECK_EXPORT_TXT (legacy alias "
|
|
"APPLEPY_SOCON_TXT), or place `APPLEPY_DECK_REFERENCE.txt` in the working directory."
|
|
),
|
|
evidence=f"cwd={cwd}",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=(),
|
|
references=(
|
|
"https://github.com/SpecterOps/presentations/",
|
|
),
|
|
)
|
|
]
|
|
try:
|
|
st = hit.stat()
|
|
ev = f"path={hit}\nsize_bytes={st.st_size}"
|
|
except OSError as e:
|
|
ev = f"{hit}: {e}"
|
|
return [
|
|
Finding(
|
|
id="deck-000",
|
|
title="Presentation text export located",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Local export found; ApplePY maps deck topics to read-only checks in this module.",
|
|
evidence=ev,
|
|
worksheet="Attack surface",
|
|
mitre_techniques=(),
|
|
references=("https://github.com/SpecterOps/presentations/",),
|
|
)
|
|
]
|
|
|
|
|
|
def check_systemconfiguration_preferences(_ctx: RunContext) -> list[Finding]:
|
|
if not _darwin():
|
|
return []
|
|
plist = Path("/Library/Preferences/SystemConfiguration/preferences.plist")
|
|
if not plist.is_file():
|
|
return [
|
|
Finding(
|
|
id="deck-101",
|
|
title="SystemConfiguration preferences.plist",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Network preference blob absent at canonical path.",
|
|
evidence=str(plist),
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1016", "T1082"),
|
|
)
|
|
]
|
|
code, out, err = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=45)
|
|
blob = (out + err).strip()
|
|
return [
|
|
Finding(
|
|
id="deck-101",
|
|
title="SystemConfiguration preferences (plutil)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description=(
|
|
"Deck-referenced network identity and interface context (may be large). Read-only structured dump."
|
|
),
|
|
evidence=f"exit={code}\n{blob}",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1016", "T1082", "T1046"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_timemachine_plist(_ctx: RunContext) -> list[Finding]:
|
|
if not _darwin():
|
|
return []
|
|
plist = Path("/Library/Preferences/com.apple.TimeMachine.plist")
|
|
if not plist.is_file():
|
|
return [
|
|
Finding(
|
|
id="deck-102",
|
|
title="TimeMachine preferences plist",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="com.apple.TimeMachine.plist absent — FDA visibility check from deck not applicable.",
|
|
evidence=str(plist),
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1005",),
|
|
)
|
|
]
|
|
code, out, err = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=20)
|
|
return [
|
|
Finding(
|
|
id="deck-102",
|
|
title="TimeMachine preferences (plutil)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Deck technique for inferring backup/FDA-related paths without TCC prompts.",
|
|
evidence=f"exit={code}\n{(out + err).strip()}",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1005", "T1565"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_zsh_sessions_dir(ctx: RunContext) -> list[Finding]:
|
|
d = ctx.home / ".zsh_sessions"
|
|
if not d.is_dir():
|
|
return [
|
|
Finding(
|
|
id="deck-103",
|
|
title="Zsh sessions directory (~/.zsh_sessions)",
|
|
category="Credentials",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Directory absent — no session history files to enumerate.",
|
|
evidence=str(d),
|
|
worksheet="Credentials",
|
|
mitre_techniques=("T1552.003",),
|
|
)
|
|
]
|
|
rows: list[str] = []
|
|
try:
|
|
for p in sorted(d.iterdir(), key=lambda x: x.name):
|
|
try:
|
|
st = p.stat()
|
|
rows.append(f"{p.name}\t{type(p).__name__}\t{st.st_size}")
|
|
except OSError as e:
|
|
rows.append(f"{p.name}\t(error {e})")
|
|
except OSError as e:
|
|
return [
|
|
Finding(
|
|
id="deck-103",
|
|
title="Zsh sessions directory (~/.zsh_sessions)",
|
|
category="Credentials",
|
|
severity=Severity.LOW,
|
|
description="Could not list ~/.zsh_sessions.",
|
|
evidence=str(e),
|
|
worksheet="Credentials",
|
|
mitre_techniques=("T1552.003",),
|
|
)
|
|
]
|
|
return [
|
|
Finding(
|
|
id="deck-103",
|
|
title="Zsh sessions metadata (filenames and sizes, no content)",
|
|
category="Credentials",
|
|
severity=Severity.INFORMATIONAL,
|
|
description=(
|
|
"Common red-team notes highlight command history in .zsh_sessions; contents are not read into "
|
|
"the report."
|
|
),
|
|
evidence="\n".join(rows) if rows else "(empty)",
|
|
worksheet="Credentials",
|
|
mitre_techniques=("T1552.003", "T1078"),
|
|
risk="Session files may contain credentials or operational commands.",
|
|
impact="Historical shell activity may be recoverable from disk.",
|
|
remediation="Rotate secrets found in history; enforce shorter retention or centralised logging policy.",
|
|
)
|
|
]
|
|
|
|
|
|
def check_kube_config_presence(ctx: RunContext) -> list[Finding]:
|
|
kc = ctx.home / ".kube" / "config"
|
|
if not kc.is_file():
|
|
return [
|
|
Finding(
|
|
id="deck-104",
|
|
title="Kubernetes client configuration (~/.kube/config)",
|
|
category="Credentials",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Default kubeconfig absent.",
|
|
evidence=str(kc),
|
|
worksheet="Credentials",
|
|
mitre_techniques=("T1552.001", "T1528"),
|
|
)
|
|
]
|
|
try:
|
|
st = kc.stat()
|
|
ev = f"{kc}\nsize_bytes={st.st_size}\n(mode: contents not read)"
|
|
except OSError as e:
|
|
ev = str(e)
|
|
return [
|
|
Finding(
|
|
id="deck-104",
|
|
title="Kubernetes client configuration present (~/.kube/config)",
|
|
category="Credentials",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Deck-referenced kubeconfig; metadata only — no credential extraction.",
|
|
evidence=ev,
|
|
worksheet="Credentials",
|
|
mitre_techniques=("T1552.001", "T1528", "T1580"),
|
|
risk="Embedded credentials or tokens may grant cluster access.",
|
|
impact="Cluster lateral movement if kube-apiserver is reachable.",
|
|
remediation="Use short-lived credentials; restrict file permissions; audit RBAC.",
|
|
)
|
|
]
|
|
|
|
|
|
def _which(name: str) -> str | None:
|
|
pe = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
|
|
for d in pe.split(os.pathsep):
|
|
if not d:
|
|
continue
|
|
p = Path(d) / name
|
|
if p.is_file() and os.access(p, os.X_OK):
|
|
return str(p.resolve())
|
|
return None
|
|
|
|
|
|
def check_docker_surface(ctx: RunContext) -> list[Finding]:
|
|
docker = _which("docker") or _which("docker-compose")
|
|
sock = Path("/var/run/docker.sock")
|
|
home_d = ctx.home / ".docker"
|
|
lines = [
|
|
f"docker_binary_on_path={docker or '(none)'}",
|
|
f"{sock}: {'exists' if sock.exists() else 'absent'}",
|
|
f"{home_d}: {'exists' if home_d.exists() else 'absent'}",
|
|
]
|
|
return [
|
|
Finding(
|
|
id="deck-105",
|
|
title="Docker surface (binary, socket, ~/.docker)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Container-oriented macOS tradecraft — local Docker signals only.",
|
|
evidence="\n".join(lines),
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1614", "T1059", "T1580"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_parallels_presence(_ctx: RunContext) -> list[Finding]:
|
|
if not _darwin():
|
|
return []
|
|
candidates = [
|
|
Path("/Applications/Parallels Desktop.app"),
|
|
Path("/Applications/Parallels Desktop.app/Contents/MacOS/prl_client_app"),
|
|
]
|
|
hits = [str(p) for p in candidates if p.exists()]
|
|
return [
|
|
Finding(
|
|
id="deck-106",
|
|
title="Parallels Desktop artefacts",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Virtualisation surface often discussed for host file access scenarios.",
|
|
evidence="\n".join(hits) if hits else "(no default Parallels paths found)",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1564.006", "T1580"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_homebrew_surface(_ctx: RunContext) -> list[Finding]:
|
|
if not _darwin():
|
|
return []
|
|
brew = _which("brew")
|
|
paths = [
|
|
Path("/opt/homebrew"),
|
|
Path("/usr/local/Homebrew"),
|
|
Path("/usr/local/bin/brew"),
|
|
]
|
|
lines = [f"brew_on_path={brew or '(none)'}"] + [f"{p}: {'yes' if p.exists() else 'no'}" for p in paths]
|
|
return [
|
|
Finding(
|
|
id="deck-107",
|
|
title="Homebrew surface (PATH and common prefixes)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Third-party taps and formulae supply-chain themes from the deck.",
|
|
evidence="\n".join(lines),
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1195.003", "T1105", "T1580"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_openvpn_on_path(_ctx: RunContext) -> list[Finding]:
|
|
hit = _which("openvpn")
|
|
return [
|
|
Finding(
|
|
id="deck-108",
|
|
title="OpenVPN binary on PATH",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Egress / tunnel blending; presence only.",
|
|
evidence=hit or "(openvpn not found on PATH)",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1090", "T1572"),
|
|
)
|
|
]
|
|
|
|
|
|
def check_pyobjc_import_probe(_ctx: RunContext) -> list[Finding]:
|
|
if not _darwin():
|
|
return []
|
|
# When frozen by PyInstaller sys.executable is the applepy binary, not python3.
|
|
# Use shutil.which("python3") so the probe runs under the host Python interpreter.
|
|
import shutil
|
|
python = shutil.which("python3") or shutil.which("python")
|
|
if not python or getattr(sys, "frozen", False) and not python:
|
|
return [
|
|
Finding(
|
|
id="deck-109",
|
|
title="PyObjC import probe — skipped (no host python3 found)",
|
|
category="Attack surface",
|
|
severity=Severity.INFORMATIONAL,
|
|
description="Verifies Python can import PyObjC (native bridge tradecraft).",
|
|
evidence="python3 not found on PATH; probe skipped in frozen binary context.",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1059.006", "T1620"),
|
|
)
|
|
]
|
|
code, out, err = run_text(
|
|
[python, "-c", "import objc, Foundation; print('pyobjc_ok')"],
|
|
timeout=15,
|
|
)
|
|
blob = (out + err).strip()
|
|
sev = Severity.INFORMATIONAL if "pyobjc_ok" in blob else Severity.LOW
|
|
return [
|
|
Finding(
|
|
id="deck-109",
|
|
title="PyObjC import probe (native API theme)",
|
|
category="Attack surface",
|
|
severity=sev,
|
|
description="Verifies Python can import PyObjC (native bridge tradecraft).",
|
|
evidence=f"exit={code}\n{blob}",
|
|
worksheet="Attack surface",
|
|
mitre_techniques=("T1059.006", "T1620"),
|
|
)
|
|
]
|