Files
applepy/applepy/checks/deck_export.py
Warezpeddler 3325436017 Initial commit
2026-04-25 23:09:31 +01:00

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"),
)
]