Initial commit

This commit is contained in:
Warezpeddler
2026-04-25 23:09:31 +01:00
commit 3325436017
92 changed files with 18397 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Python
.venv/
__pycache__/
*.py[cod]
*.egg-info/
# Test / lint caches
.pytest_cache/
.ruff_cache/
# Build output
dist/
build/
# Tool output
applepy-out/
out-priv/
# macOS
.DS_Store
# Editor / IDE
.claude/
# Reference material (large PDFs + nested repos, not needed to run the tool)
pdfs/
# Personal / session notes
progress.txt
resume.txt

3
.semgrepignore Normal file
View File

@@ -0,0 +1,3 @@
# Third-party trees vendored for PyInstaller (not ApplePY source)
applepy/data/macos_security/
applepy/data/lynis/

60
CREDITS.md Normal file
View File

@@ -0,0 +1,60 @@
# Credits and acknowledgements
All rights to original authors. ApplePY is for **authorised** security assessments only.
---
[CIS macOS Benchmark](https://www.cisecurity.org/benchmark/apple_os)
Informs `checks/cis_extended.py`. Over 80 Level 1 and Level 2 controls are implemented as individual checks; each section number, evidence format, and remediation text maps directly to the benchmark.
[usnistgov/macos_security](https://github.com/usnistgov/macos_security)
Informs `checks/compliance.py`. The corpus is vendored into `applepy/data/macos_security` at build time. `generate_guidance.py` runs per scan to produce a compliance shell script; the resulting audit plist is parsed into per-rule findings.
[cisofy/lynis](https://github.com/cisofy/lynis)
Informs `checks/compliance.py`. Runs as `lynis audit system --quick --no-colors`; the hardening index and any warnings are surfaced as findings. Vendored into `applepy/data/lynis`. GPL-3.0.
[cedowens/SwiftBelt](https://github.com/cedowens/SwiftBelt)
Informs `checks/common_paths.py` and `checks/extended_surface.py`. The breadth-first approach to attack surface enumeration (mounts, keychains, cron, shell startup files, SSH directories, browser extensions, MDM hints) mirrors SwiftBelt's style.
[cedowens/EntitlementCheck](https://github.com/cedowens/EntitlementCheck)
Informs `checks/surface.py`. The `codesign -d --entitlements -` invocation pattern for sampling entitlements from installed `.app` bundles originates here.
[SpecterOps SO-CON 2025 — Modern macOS Red Teaming](https://github.com/SpecterOps/presentations/)
Informs `checks/deck_export.py`. The eight check themes (SystemConfiguration preferences, Time Machine plist, zsh sessions, kubeconfig, Docker, Parallels, Homebrew, OpenVPN) are structured around this presentation's red-team framework. The `APPLEPY_DECK_EXPORT_TXT` environment variable loads a reference export from the talk.
[SpecterOps/JamfHound](https://github.com/SpecterOps/JamfHound)
Informs `reporters/graph_json.py` and `graph_validate.py`. The JamfHound v1.1.2 OpenGraph schema is reproduced so that `graph_findings.json` can be ingested directly into BloodHound CE with the Jamf extension installed.
[ReversecLabs/Jamf-Attack-Toolkit](https://github.com/ReversecLabs/Jamf-Attack-Toolkit)
Informs `checks/mdm/jamf.py`. The credential pattern set (`_CRED_PATTERNS`) and extension attribute cache enumeration are drawn from this research. The toolkit itself is not bundled; an informational finding notes its scope when the Jamf agent is detected.
[kandji-inc/security-toolkit](https://github.com/kandji-inc/security-toolkit)
Informs `checks/mdm/kandji.py`. Application Support paths, LaunchAgent names, preference keys, and helper binary paths used in the Kandji local posture checks are sourced from this project.
[RedFoxSec — macOS Privilege Escalation](https://www.redfoxsec.com/blog/macos-security-privilege-escalation)
Informs `checks/privesc.py`. All five checks (sudoers NOPASSWD, unexpected SUID/SGID binaries, writable LaunchDaemon binaries, writable PATH directories, and high-risk TCC permissions) implement techniques documented in this article.
[Maldev-Academy/ElectronVulnScanner](https://github.com/Maldev-Academy/ElectronVulnScanner)
Informs `checks/electron.py`. ASAR archive detection and writable parent directory inspection for Electron-based `.app` bundles follow the attack pattern documented here.
[LOOBins](https://www.loobins.io/)
Informs `checks/catalogues.py`. The live `loobins.json` feed is fetched with a disk cache and a bundled JSON fallback. Entries are cross-referenced against binaries on `PATH` to produce per-binary findings.
[GTFOBins](https://gtfobins.org/)
Informs `checks/catalogues.py`. The live `api.json` feed is fetched with a disk cache. macOS-platform entries are matched against on-host binaries and summarised in a capped findings row. CC BY-SA 4.0.
[lolapps-project](https://lolapps-project.github.io/)
Informs `checks/catalogues.py`. Application names from the public list are checked for presence under `/Applications` and `~/Applications`.
[lottunnels](https://lottunnels.github.io/)
Informs `checks/catalogues.py`. Tunnelling tool names from the public list are checked for presence on `PATH` and under `/Applications`.
[MITRE ATT&CK](https://attack.mitre.org/)
Informs `Finding.mitre_techniques` and `checks/mitre.py`. Technique IDs are attached to every finding where applicable and expanded into a dedicated MITRE worksheet in the XLSX report with links to each technique page.
[PyObjC](https://github.com/ronaldoussoren/pyobjc)
Informs `checks/pyobjc_surface.py`. `NSWorkspace.sharedWorkspace().runningApplications()` is used for running process enumeration. Required on macOS. MIT licence.
---
This project does not bundle proprietary Jamf, Kandji, or Apple software.

363
README.md Normal file
View File

@@ -0,0 +1,363 @@
# ApplePY
A macOS security review and attack-surface scanner. It can be run on a Mac to produce a Markdown report** and a multi-worksheet XLSX spreadsheet covering CIS benchmark controls, privilege escalation paths, MDM posture, NIST mSCP compliance, living-off-the-land catalogues, and much more. Optionally exports a BloodHound CEcompatible graph JSON file for visual attack-path analysis (courtesy of JAMF Hound).
### Disclaimer
For **authorised** security assessments only. Results are indicative; confirm material findings in your environment before acting on them. Third-party sources and licences are listed in [CREDITS.md](CREDITS.md). All rights to original authors.
---
## Quick start
```bash
# Unprivileged scan (no root required), outputs ./applepy-out/report.md and findings.xlsx
./dist/applepy/applepy
# Full scan (unprivileged + privileged checks)
sudo ./dist/applepy/applepy
# Choose a custom output folder
sudo ./dist/applepy/applepy --output-dir ~/Desktop/assessment
```
See [Getting the binary](#getting-the-binary) below if you do not yet have `dist/applepy/applepy`.
---
## What it produces
| File | Description |
|------|-------------|
| `report.md` | Narrative summary grouped into themes (e.g. Hardening, MDM, Compliance). Each finding shows severity, risk, impact, and remediation steps. |
| `findings.xlsx` | Multi-worksheet spreadsheet. The **Contents** tab links to every category sheet. Each sheet is colour-coded by severity (critical → informational) and includes a MITRE ATT&CK mapping worksheet. |
| `graph_findings.json` | *(Optional — `--export-graph-json`)* JamfHound-compatible BloodHound CE OpenGraph JSON for visual attack-path analysis. |
---
## Privilege model
ApplePY splits its work into two phases so that you do not need root for a partial scan:
| How you run | What runs |
|-------------|-----------|
| `applepy` (no `sudo`) | **Unprivileged phase only.** Checks that do not require root. A warning is printed reminding you that the privileged phase was skipped. |
| `sudo applepy` | **Both phases.** Unprivileged checks first, then privileged checks (sudoers, SUID/SGID binaries, TCC permissions, world-writable LaunchDaemon binaries, mSCP compliance generation, and so on). |
Optional overrides:
| Flag | Effect |
|------|--------|
| `--unprivileged-only` | Run the unprivileged phase only, even when invoked with `sudo`. |
| `--privileged-only` | Run the privileged phase only (combine with `sudo`; without root, no checks run). |
---
## Getting the binary
### Option A — Build the PyInstaller bundle (recommended)
The **primary deployment method** is a self-contained one-folder bundle built with PyInstaller. It can then transferred to the target host as a single self extracting archive. The bundle embeds the Python runtime, dependencies and optionally the NIST mSCP compliance corpus and Lynis.
**Requirements (build machine only):** a macOS workstation, Python ≥ 3.11 and git.
```bash
cd /path/to/security-review
chmod +x scripts/build_bundle.sh scripts/vendor_compliance_assets.sh
./scripts/build_bundle.sh
```
This will:
1. Shallow-clone **NIST mSCP** and **Lynis** into `applepy/data/`.
2. Install ApplePY with the `bundle` extra (PyInstaller, PyYAML, xlwt).
3. Run PyInstaller using `applepy.spec`.
Output:
- **`dist/applepy/applepy`** — the binary you run on the target.
- **`dist/applepy/_internal/`** — bundled libraries, data, and Python runtime (keep alongside the binary).
> **Do not run `build/applepy/applepy`.** The `build/` tree is PyInstaller's working directory — it is missing the `_internal/` folder and will fail. Always use `dist/applepy/`.
**Rebuilding without re-cloning** (e.g. offline):
```bash
SKIP_VENDOR_COMPLIANCE=1 ./scripts/build_bundle.sh
```
**If the build fails with a `PermissionError` removing `dist/applepy`:** a previous `sudo applepy` run left root-owned files in `_internal/.../macos_security/build/`. Clear them, then rebuild:
```bash
sudo rm -rf dist/applepy
./scripts/build_bundle.sh
```
### Option B — Install from source (development / library)
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
applepy --help
```
To include compliance reporting in this workflow, run `applepy --bootstrap-compliance` (see [Compliance](#compliance-mscp-and-lynis)).
---
## Running on the target Mac
### Clear the quarantine flag
When you transfer `dist/applepy/` by browser download, email, or certain archive tools, macOS applies a quarantine attribute. Clear it before running:
```bash
xattr -dr com.apple.quarantine /path/to/dist/applepy
```
Verify nothing remains:
```bash
xattr -lr /path/to/dist/applepy
```
### Run the scan
```bash
# Show all available options
/path/to/dist/applepy/applepy --help
# Unprivileged scan (safe to run without sudo)
/path/to/dist/applepy/applepy --output-dir ./out-user
# Full scan (both phases)
sudo /path/to/dist/applepy/applepy --output-dir ./out-full
# Full scan + graph JSON for BloodHound CE
sudo /path/to/dist/applepy/applepy --output-dir ./out-full --export-graph-json
```
### Unsigned binary on a managed Mac
If macOS shows a security warning on first launch, **Control-click → Open** in Finder, or go to **System Settings → Privacy & Security** and click **Open Anyway**. This is expected for programs that have not been signed with an Apple Developer ID.
---
## What it checks
ApplePY runs registered checks in the unprivileged and privileged phases. Below is a capability overview grouped by area.
### Core platform
Host summary, **SIP** status (`csrutil`), **Gatekeeper**, **Application Firewall**, system extensions listing, LaunchAgent/LaunchDaemon inventories, common **interpreters** on `PATH`.
### CIS benchmark controls
Over 80 CIS macOS Benchmark Level 1 and Level 2 checks covering: FileVault, screen saver lock, password policy, guest account, remote login, Bluetooth, mDNS, IPv6, Safari settings, Handoff, iCloud services, Siri, AirDrop, Time Machine, printer sharing, wake settings, and many more. Each check emits PASS, FAIL, or WARN with evidence.
### Privilege escalation
Read-only detection of common privilege escalation paths:
- **Sudoers NOPASSWD** — `/etc/sudoers` and `/etc/sudoers.d/` entries that allow password-free `sudo`.
- **Unexpected SUID/SGID binaries** — files with setuid or setgid bits outside the expected macOS baseline.
- **Writable LaunchDaemon binaries** — LaunchDaemon plists that reference binaries writable by non-root users.
- **Writable PATH directories** — directories on `PATH` (from `/etc/paths`) that are writable by non-root users.
- **High-risk TCC permissions** — applications granted full-disk access, camera, microphone, and similar sensitive TCC services.
### Dylib hijacking
RPATH ordering and writable-dylib-directory checks for installed applications.
### Filesystem posture
World-writable files under LaunchAgent and LaunchDaemon directories (caps configurable via environment variables).
### Living-off-the-land catalogues
**LOOBins** (live with cache; fallback data), **GTFOBins** (live API with cache), **lolapps**, **lottunnels**, cloud CLI presence on `PATH`, credential path hints (presence only — no secret reads).
### Compliance
**CIS worksheet** pointer, FileVault, pwpolicy, boot/uptime, Time Machine destinations, admin group, default umask, **NIST mSCP** status and (privileged) baseline generation + compliance script execution with per-rule worksheet output, **Lynis** quick audit when available.
### Common paths
Mounts, guest login, remote login / ARD plists, MDM enrolment hints, browser extension directories, local users (`dscl`), SSH public key filenames, cron/periodic/at surface, Automator workflow directories, user Keychains filenames.
### Extended surface
Routing table, `/etc/hosts`, DNS settings, per-service HTTP proxies, AirDrop/sharing defaults, shell startup file metadata, kernel extension list, USB/Bluetooth `system_profiler` snapshots, `last` logins, Wi-Fi preferences, NFS exports, `pmset`, CUPS/printing surface, configuration profiles, Spotlight, `launchctl print-disabled`, `sysctl kern` snapshot.
### Network listeners
TCP LISTEN and UDP sockets via `lsof`, excluding loopback-only and OS ephemeral port ranges; correlated with process names.
### Application surface
Entitlements sample (`codesign`), SSH directory, TCC / login items / background task store paths, Electron app ASAR and writable parent-directory checks.
### MDM posture (read-only)
- **Jamf** — local plists, receipts, LaunchAgents, helpers, extension attribute cache, listener hints, configuration profile stores, filesystem caps.
- **Kandji** — Application Support tree, LaunchAgents, receipts, preferences, helpers, listeners, configuration profile store.
### AI tools and IDEs
AI CLI tools on `PATH` (e.g. Claude Code, Cursor, GitHub Copilot CLI), AI desktop apps, local model storage, and IDE surface.
### Interpreters and package managers
Python, Node.js, Ruby, Go, Rust, and other runtimes found on `PATH` or in standard locations, plus Homebrew, pip, npm, and conda distributions.
---
## CLI reference
```text
applepy [options] # from pip install / editable install
python -m applepy [options] # from source tree (without install)
./dist/applepy/applepy [options] # PyInstaller bundle
```
| Short | Long | Description |
|-------|------|-------------|
| | `--version` | Print version and exit. |
| `-h` | `--help` | Show help and exit. |
| `-o PATH` | `--output-dir PATH` | Directory for `report.md`, `findings.xlsx`, and optional JSON (default: `./applepy-out`). |
| `-v` | `--verbose` | Increase log verbosity. Use `-vv` for debug output. |
| `-q` | `--quiet` | Suppress all output except errors on stderr (disables progress reporting). |
| | `--unprivileged-only` | Run the unprivileged phase only, even with `sudo`. |
| | `--privileged-only` | Run the privileged phase only (use with `sudo`). |
| | `--dry-run` | Run all checks but do not write any output files. |
| | `--export-graph-json` | Write `graph_findings.json` in JamfHound-compatible BloodHound CE OpenGraph format. Requires the SpecterOps Jamf extension in BloodHound CE. |
| | `--sequential` | Disable parallel check execution within each phase (useful for debugging). |
| | `--bootstrap-compliance` | Clone NIST mSCP and Lynis into `./vendor`, print hints, then exit (requires `git` and internet access). |
| `HEX` | `--heading-color HEX` | XLSX heading background colour as a 6-digit hex code (default: `871727`). |
### Progress output
By default, ApplePY prints a running progress display to stderr:
- **Each check** shows a progress bar, percentage, check name, and either `running` or the elapsed time and finding count once complete.
- **At the end of each phase**, a severity breakdown is shown (critical / high / medium / low / informational counts).
- **`-q` / `--quiet`** suppresses all of this.
- **`NO_COLOR=1`** disables ANSI colour codes. **`FORCE_COLOR=1`** forces colour even when stderr is not a terminal.
---
## Environment variables
| Variable | Description |
|----------|-------------|
| `APPLEPY_MACOS_SECURITY_ROOT` | Override the path to the `macos_security` clone (takes precedence over the bundled copy). |
| `APPLEPY_MSCP_BASELINE` | Baseline YAML stem or filename under `baselines/` (e.g. `cis_level1`). |
| `APPLEPY_MSCP_SKIP_GENERATE=1` | Skip `generate_guidance.py`; only run an existing `*_compliance.sh --check`. |
| `APPLEPY_MSCP_COMPLIANCE_LOG_MAX` | Maximum characters retained per stream in compliance evidence (default: 524288). |
| `APPLEPY_MSCP_FORCE_SUBPROCESS=1` | *(Frozen bundle only)* Use a system `python3` instead of re-invoking the bundle for `generate_guidance.py`. |
| `APPLEPY_PYTHON` | Python 3 interpreter to use for `generate_guidance.py` (when not frozen, or with `APPLEPY_MSCP_FORCE_SUBPROCESS=1`). |
| `APPLEPY_DECK_EXPORT_TXT` | Path to an optional SpecterOps-style deck reference text file. |
| `APPLEPY_KANDJI_MAX_FILES` | Cap for Kandji Application Support file listing. |
| `APPLEPY_FS_MAX_SCAN` / `APPLEPY_FS_MAX_HITS` | Caps for world-writable filesystem walks. |
| `APPLEPY_CATALOG_INCLUDE_NOISE=1` | Emit per-binary LOOBins rows for common UI binaries (Dock, Finder, etc.) that are normally summarised only. |
| `APPLEPY_GTFO_DETAILED=1` | One finding per on-host GTFOBins match (default: a single summary row). |
| `APPLEPY_GTFO_LIST_ABSENT=1` | Add a finding listing every GTFOBins name not found on the host (large; off by default). |
| `APPLEPY_GTFO_ROLLUP_MAX_LINES` | Maximum lines in the default GTFOBins summary row (default: 120). |
| `SKIP_VENDOR_COMPLIANCE=1` | Skip compliance cloning in `build_bundle.sh` (use when `applepy/data/` is already populated). |
| `NO_COLOR` | Disable ANSI colours in progress output (see [no-color.org](https://no-color.org/)). |
| `FORCE_COLOR` | Force ANSI colours even when stderr is not a TTY. |
---
## Compliance — NIST mSCP and Lynis
### NIST mSCP (macOS Security Compliance Project)
The NIST mSCP corpus lives at [usnistgov/macos_security](https://github.com/usnistgov/macos_security). ApplePY does **not** commit it; instead it resolves the corpus in this order:
1. `APPLEPY_MACOS_SECURITY_ROOT` (environment variable).
2. Bundled `applepy/data/macos_security` (populated by `scripts/vendor_compliance_assets.sh` at build time).
3. `./vendor/macos_security`, `./macos_security`, then common locations under the user's home directory.
When found, ApplePY runs `generate_guidance.py` against the selected baseline YAML (auto-selected, or set via `APPLEPY_MSCP_BASELINE`), then executes the generated `*_compliance.sh --check` script and parses the resulting audit plist into findings. Temporary artefacts (`build/` and the audit plist) are cleaned up after each scan.
### Lynis
Lynis is used from `PATH` if available, then from the bundled `applepy/data/lynis/lynis`, then from `./vendor/lynis/lynis`. Lynis is GPL-licensed — see [CREDITS.md](CREDITS.md).
### Bootstrap workflow (source install without bundled data)
```bash
# Clone mSCP and Lynis into ./vendor, print export hints, then exit
applepy --bootstrap-compliance
# Set paths for subsequent scans
export APPLEPY_MACOS_SECURITY_ROOT="$PWD/vendor/macos_security"
export PATH="$PWD/vendor/lynis:$PATH"
# Install mSCP Python dependencies
pip install pyyaml xlwt
# Run the full scan
sudo applepy --output-dir ./out
```
---
## Graph JSON (BloodHound CE)
With `--export-graph-json`, ApplePY writes `graph_findings.json` to the output directory. This file is in the **JamfHound-compatible BloodHound CE OpenGraph format** (v1.1.2), suitable for import into BloodHound CE with the SpecterOps Jamf extension installed.
To ingest: open BloodHound CE → **File Ingest** → upload `graph_findings.json`.
See [JamfHound on GitHub](https://github.com/SpecterOps/JamfHound) for extension installation instructions.
---
## Output files in detail
### `report.md`
A narrative summary organised into **overarching themes** (Hardening, Compliance, MDM, Catalogues, etc.). Each theme section lists findings from critical down to informational. Every finding includes:
- **Title** and **severity** (CRITICAL / HIGH / MEDIUM / LOW / INFORMATIONAL / WARN)
- **Description** of what was found
- **Risk** — what an attacker could do with this
- **Impact** — what remediation affects
- **Remediation** steps
- **MITRE ATT&CK technique IDs** where applicable
- **Evidence** — raw command output or parsed data supporting the finding
### `findings.xlsx`
A multi-worksheet spreadsheet:
- **Contents** — index tab with hyperlinks to each category sheet and a finding count.
- **Findings list** — all non-informational findings sorted by severity, then ID, with a link to the relevant category sheet.
- **Category sheets** — one sheet per check category, all findings including informational.
- **MITRE** — all findings with MITRE technique IDs, for import into threat-modelling tools.
- **CIS index** — CIS benchmark section cross-reference.
Cells are colour-coded by severity (red for critical through grey for informational). Empty optional fields show an em dash (—) rather than a blank cell.
---
## Developer reference
### Running the test suite
```bash
pip install -e ".[dev]"
./scripts/verify.sh # Ruff, pytest, ty (if installed), Semgrep (if installed)
```
Or individually:
```bash
ruff check applepy/
pytest
```
### Portable `PYTHONPATH` layout (no venv, no PyInstaller)
```bash
mkdir -p vendor && pip install -t vendor 'openpyxl>=3.1'
PYTHONPATH=vendor:. python -m applepy --output-dir ./out
```
Note: PyObjC is required on macOS for the full check set and is not available on Linux or Windows.
### PyObjC dependency
ApplePY depends on `pyobjc-core` and `pyobjc-framework-Cocoa` on macOS for native platform introspection. These packages are macOS-only; `pip install` on Linux or Windows simply skips them. Run the full scan on a Mac.
---
## Licence
See [CREDITS.md](CREDITS.md) for third-party references and licences.

121
applepy.spec Normal file
View File

@@ -0,0 +1,121 @@
# PyInstaller spec: `pip install -e ".[bundle]"` then `pyinstaller applepy.spec`
# Produces dist/applepy (one-folder) with bundled applepy data (JSON, optional compliance trees).
# Run scripts/vendor_compliance_assets.sh before building to embed mSCP + Lynis (not in git).
from pathlib import Path
from PyInstaller.utils.hooks import collect_data_files
# Root of the repo when building; PyInstaller defines SPECPATH (not __file__) for .spec evaluation.
_SPEC_DIR = Path(SPECPATH)
def _mscp_data_files_excluding_generated(mscp: Path) -> list[tuple[str, str]]:
"""
Per-file datas for mSCP: omit ``build/`` (output from generate_guidance on the host) and ``.git``.
Shipping ``build/`` bloats the bundle and, after ``sudo dist/.../applepy``, can leave root-owned
trees that break the next PyInstaller clean of ``dist/applepy``.
"""
prefix = Path("applepy/data/macos_security")
out: list[tuple[str, str]] = []
for p in mscp.rglob("*"):
if not p.is_file():
continue
try:
rel = p.relative_to(mscp)
except ValueError:
continue
parts = rel.parts
if not parts:
continue
if parts[0] == "build" or parts[0] == ".git":
continue
if ".git" in parts:
continue
dest_dir = str(prefix / rel.parent)
out.append((str(p), dest_dir))
return out
def _applepy_datas_excluding_mscp_tree() -> list[tuple[str, str]]:
"""Package data from ``collect_data_files`` minus ``macos_security`` (re-added below, with filters)."""
return [(s, d) for s, d in collect_data_files("applepy") if "macos_security" not in Path(s).parts]
def _optional_mscp_datas() -> list[tuple[str, str]]:
mscp = _SPEC_DIR / "applepy" / "data" / "macos_security"
if not (mscp / "scripts" / "generate_guidance.py").is_file():
return []
return _mscp_data_files_excluding_generated(mscp)
# Lynis and other ``applepy/data`` trees stay on the collect_data_files side; mSCP is merged once here
# without ``.git`` or ``build/`` (avoids bloated bundles and root-owned build dirs after sudo runs).
datas = _applepy_datas_excluding_mscp_tree() + _optional_mscp_datas()
a = Analysis(
["applepy/__main__.py"],
pathex=[],
binaries=[],
datas=datas,
hiddenimports=[
"openpyxl",
"yaml",
"_yaml",
"xlwt",
# mSCP generate_guidance.py (run via runpy in frozen binary): explicit stdlib names as well as the hook module.
"uuid",
"zipfile",
"applepy.pyi_mscp_stdlib",
"applepy.check_progress",
"applepy.checks",
"applepy.checks.mdm.jamf",
"applepy.checks.mdm.kandji",
"applepy.catalog_cache",
"applepy.mscp_audit_parse",
"applepy.checks.fs_posture",
"applepy.checks.plan_extras",
"applepy.checks.privesc",
"applepy.checks.pyobjc_surface",
"applepy.checks.listening_ports",
"plistlib",
"sqlite3",
"objc",
"Foundation",
"AppKit",
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="applepy",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="applepy",
)

3
applepy/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""ApplePY — macOS security review scanner."""
__version__ = "0.1.0"

4
applepy/__main__.py Normal file
View File

@@ -0,0 +1,4 @@
from applepy.cli import main
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,175 @@
"""Clone NIST macos_security and Lynis into ./vendor for library / override workflows.
For **bundled** installs and PyInstaller builds, upstream trees live under `applepy/data/` (populated by
`scripts/vendor_compliance_assets.sh` at build time). See README.
"""
from __future__ import annotations
import logging
import os
import subprocess
import sys
from pathlib import Path
logger = logging.getLogger(__name__)
MACOS_SECURITY_REPO = "https://github.com/usnistgov/macos_security.git"
LYNIS_REPO = "https://github.com/cisofy/lynis.git"
def _run_git(args: list[str], *, cwd: Path | None = None) -> tuple[int, str, str]:
try:
p = subprocess.run(
["git", *args],
cwd=str(cwd) if cwd else None,
stdin=subprocess.DEVNULL,
capture_output=True,
text=True,
timeout=600,
check=False,
errors="replace",
)
return p.returncode, (p.stdout or "").strip(), (p.stderr or "").strip()
except FileNotFoundError:
return 127, "", "git executable not found on PATH"
except subprocess.TimeoutExpired:
return 124, "", "git command timed out"
except OSError as e:
return 1, "", str(e)
def vendor_root(project_root: Path) -> Path:
return (project_root / "vendor").resolve()
def macos_security_clone_path(project_root: Path) -> Path:
return vendor_root(project_root) / "macos_security"
def lynis_clone_path(project_root: Path) -> Path:
return vendor_root(project_root) / "lynis"
def lynis_bundled_executable() -> Path | None:
"""Lynis script shipped under applepy/data/lynis (wheel / editable / PyInstaller)."""
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
p = Path(sys._MEIPASS) / "applepy" / "data" / "lynis" / "lynis"
if p.is_file():
return p
try:
import applepy
p = Path(applepy.__file__).resolve().parent / "data" / "lynis" / "lynis"
if p.is_file():
return p
except (ImportError, OSError):
pass
return None
def bootstrap_macos_security(project_root: Path, *, shallow: bool = True) -> tuple[int, str]:
dest = macos_security_clone_path(project_root)
if (dest / "baselines").is_dir() and (dest / "scripts" / "generate_guidance.py").is_file():
return 0, f"Already present: {dest}"
vendor_root(project_root).mkdir(parents=True, exist_ok=True)
args = ["clone"]
if shallow:
args.extend(["--depth", "1"])
args.extend([MACOS_SECURITY_REPO, str(dest)])
code, out, err = _run_git(args)
msg = "\n".join(x for x in (out, err) if x)
if code != 0:
return code, msg or f"git clone failed with exit {code}"
return 0, f"Cloned macos_security to {dest}\n{msg}".strip()
def bootstrap_lynis(project_root: Path, *, shallow: bool = True) -> tuple[int, str]:
dest = lynis_clone_path(project_root)
lynis_bin = dest / "lynis"
if lynis_bin.is_file():
return 0, f"Already present: {dest}"
vendor_root(project_root).mkdir(parents=True, exist_ok=True)
args = ["clone"]
if shallow:
args.extend(["--depth", "1"])
args.extend([LYNIS_REPO, str(dest)])
code, out, err = _run_git(args)
msg = "\n".join(x for x in (out, err) if x)
if code != 0:
return code, msg or f"git clone failed with exit {code}"
return 0, f"Cloned Lynis to {dest}\n{msg}".strip()
def pip_hint_mscp() -> str:
return (
"Install mSCP script dependencies in the same Python you use for ApplePY, for example: "
"`pip install pyyaml xlwt` (see upstream macos_security README for the current list)."
)
def run_full_bootstrap(project_root: Path) -> tuple[int, list[str]]:
lines: list[str] = []
code1, m1 = bootstrap_macos_security(project_root)
lines.append(f"macos_security: {m1}")
if code1 != 0:
return code1, lines
code2, m2 = bootstrap_lynis(project_root)
lines.append(f"lynis: {m2}")
if code2 != 0:
return code2, lines
root = macos_security_clone_path(project_root)
lines.append("")
lines.append("Next steps:")
lines.append(f" export APPLEPY_MACOS_SECURITY_ROOT={root}")
lines.append(f" export PATH={lynis_clone_path(project_root)}:$PATH")
lines.append(f" {pip_hint_mscp()}")
return 0, lines
def default_project_root() -> Path:
env = os.environ.get("APPLEPY_PROJECT_ROOT", "").strip()
if env:
return Path(env).expanduser().resolve()
return Path.cwd().resolve()
def main_bootstrap(argv: list[str] | None = None) -> int:
"""Entry point for `python -m applepy.bootstrap_compliance` (optional)."""
import argparse
p = argparse.ArgumentParser(description="Clone macos_security and Lynis into ./vendor for ApplePY.")
p.add_argument(
"--project-root",
type=Path,
default=None,
help="Project directory containing vendor/ (default: cwd or APPLEPY_PROJECT_ROOT).",
)
p.add_argument(
"--macos-security-only",
action="store_true",
help="Clone only usnistgov/macos_security.",
)
p.add_argument(
"--lynis-only",
action="store_true",
help="Clone only cisofy/lynis.",
)
args = p.parse_args(argv)
root = (args.project_root or default_project_root()).resolve()
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
if args.lynis_only:
code, msg = bootstrap_lynis(root)
print(msg)
return 0 if code == 0 else 1
if args.macos_security_only:
code, msg = bootstrap_macos_security(root)
print(msg)
return 0 if code == 0 else 1
code, lines = run_full_bootstrap(root)
print("\n".join(lines))
return 0 if code == 0 else 1
if __name__ == "__main__":
sys.exit(main_bootstrap())

153
applepy/catalog_cache.py Normal file
View File

@@ -0,0 +1,153 @@
"""Fetch and cache LOOBins and GTFOBins JSON APIs (stdlib only)."""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
logger = logging.getLogger(__name__)
LOOBINS_JSON_URL = "https://www.loobins.io/loobins.json"
GTFOBINS_API_URL = "https://gtfobins.org/api.json"
DEFAULT_MAX_AGE_HOURS = 24
def _cache_dir() -> Path:
base = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
d = base / "applepy"
d.mkdir(parents=True, exist_ok=True)
return d
def _cache_path(url: str) -> Path:
h = hashlib.sha256(url.encode()).hexdigest()[:16]
return _cache_dir() / f"catalog_{h}.json"
def _read_cache(path: Path, max_age_s: float) -> bytes | None:
try:
st = path.stat()
except OSError:
return None
if time.time() - st.st_mtime > max_age_s:
return None
try:
return path.read_bytes()
except OSError:
return None
def fetch_json_url(
url: str,
*,
max_age_hours: float | None = None,
offline: bool = False,
) -> tuple[dict | list | None, str]:
"""
Returns (parsed_json, status_message).
offline=True or APPLEPY_CATALOG_OFFLINE=1: cache only, no network.
"""
max_age = max_age_hours
if max_age is None:
try:
max_age = float(os.environ.get("APPLEPY_CATALOG_MAX_AGE_HOURS", DEFAULT_MAX_AGE_HOURS))
except ValueError:
max_age = float(DEFAULT_MAX_AGE_HOURS)
max_age_s = max_age * 3600.0
offline = offline or os.environ.get("APPLEPY_CATALOG_OFFLINE", "").strip() in ("1", "true", "yes")
path = _cache_path(url)
raw = _read_cache(path, max_age_s)
if raw is None and not offline:
req = Request(
url,
headers={"User-Agent": "ApplePY/0.1 (security audit; +https://github.com/usnistgov/macos_security)"},
)
try:
with urlopen(req, timeout=90) as resp:
raw = resp.read()
path.write_bytes(raw)
except (HTTPError, URLError, TimeoutError, OSError) as e:
stale = path.read_bytes() if path.is_file() else None
if stale:
raw = stale
logger.debug("Catalog fetch failed for %s; using stale cache: %s", url, e)
return json.loads(stale.decode("utf-8")), f"network_error_using_stale_cache:{e}"
logger.debug("Catalog fetch failed for %s (no cache): %s", url, e)
return None, f"fetch_failed:{e}"
if raw is None:
stale = path.read_bytes() if path.is_file() else None
if stale:
raw = stale
return json.loads(stale.decode("utf-8")), "offline_mode_using_stale_cache"
return None, "offline_no_cache"
try:
return json.loads(raw.decode("utf-8")), "ok"
except json.JSONDecodeError as e:
return None, f"json_decode_error:{e}"
def load_loobins_entries(data: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalise LOOBins array entries."""
out: list[dict[str, Any]] = []
for row in data:
if not isinstance(row, dict):
continue
name = row.get("name")
if not name:
continue
paths = row.get("paths") or []
if isinstance(paths, str):
paths = [paths]
out.append(
{
"name": str(name),
"paths": [str(p) for p in paths if p],
"short_description": str(row.get("short_description", "")),
"source": "loobins.io",
}
)
return out
def iter_gtfo_binaries(data: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]:
"""
Yield (binary_name, entry) for GTFOBins api.json.
Current upstream shape uses top-level `executables`; older snapshots used flat
binary keys alongside a `functions` metadata object.
"""
out: list[tuple[str, dict[str, Any]]] = []
if isinstance(data.get("executables"), dict):
bin_map: dict[str, Any] = data["executables"]
else:
skip = {"functions", "contexts", "executables"}
bin_map = {k: v for k, v in data.items() if k not in skip}
for key, val in bin_map.items():
if not isinstance(val, dict):
continue
if set(val.keys()) == {"alias"}:
continue
if "functions" not in val:
continue
out.append((key, val))
out.sort(key=lambda x: x[0].lower())
return out
def gtfo_technique_summary(entry: dict[str, Any]) -> str:
fn = entry.get("functions")
if not isinstance(fn, dict):
return ""
keys = sorted(str(k) for k in fn.keys())
return ", ".join(keys)

27
applepy/catalog_policy.py Normal file
View File

@@ -0,0 +1,27 @@
"""Filter catalogue entries that add noise to client-facing reports (ubiquitous macOS UI)."""
from __future__ import annotations
import os
# LOOBins names (normalised lower case) omitted from per-binary findings unless
# APPLEPY_CATALOG_INCLUDE_NOISE=1 — they are expected on every workstation.
_LOOBINS_NOISE_NAMES: frozenset[str] = frozenset(
{
"dock",
"finder",
"loginwindow",
"system settings",
"systemsettings",
"control centre",
"control center",
"controlcenter",
}
)
def loobins_entry_is_report_noise(name: str) -> bool:
"""True if this LOOBins display name should not generate its own worksheet row."""
if os.environ.get("APPLEPY_CATALOG_INCLUDE_NOISE", "").strip() in ("1", "true", "yes"):
return False
return name.strip().casefold() in _LOOBINS_NOISE_NAMES

162
applepy/check_progress.py Normal file
View File

@@ -0,0 +1,162 @@
"""Coloured stderr lines for per-check progress (TTY-aware, no emojis or banners).
Disabled from the CLI when ``-q`` / ``--quiet`` is set (no progress output).
"""
from __future__ import annotations
import os
import sys
import threading
from collections import Counter
from typing import TextIO
from applepy.context import PrivilegePhase
from applepy.findings import SEVERITY_BREAKDOWN_ORDER, Finding, Severity, severity_breakdown_line
def _use_colour(stream: TextIO) -> bool:
if os.environ.get("NO_COLOR", "").strip():
return False
if os.environ.get("FORCE_COLOR", "").strip():
return True
return stream.isatty()
def check_run_failed(findings: list[Finding]) -> bool:
return any(f.id.startswith("run-") for f in findings)
def _completion_percent(done: int, total: int) -> int:
"""Whole percent 0100 for ``done`` completed steps of ``total``."""
if total <= 0:
return 100
d = max(0, min(int(done), total))
return min(100, max(0, round(100 * d / total)))
def _ascii_progress_bar(done: int, total: int, width: int = 12) -> str:
"""TTY-safe ASCII bar: completed checks ``done`` of ``total`` (hash = done segment)."""
if total <= 0:
return "[" + ("?" * width) + "]"
d = max(0, min(int(done), total))
filled = round(width * d / total) if total else 0
filled = max(0, min(width, filled))
return f"[{'#' * filled}{'-' * (width - filled)}]"
class CheckProgressReporter:
"""Writes human-readable progress to stderr; suppress colour when not a TTY or NO_COLOR is set."""
__slots__ = ("_stream", "_c", "_lock")
def __init__(self, stream: TextIO | None = None) -> None:
self._stream = stream or sys.stderr
use = _use_colour(self._stream)
self._c = {
"dim": "\033[2m" if use else "",
"bold": "\033[1m" if use else "",
"green": "\033[32m" if use else "",
"red": "\033[31m" if use else "",
"yellow": "\033[33m" if use else "",
"magenta": "\033[35m" if use else "",
"cyan": "\033[36m" if use else "",
"reset": "\033[0m" if use else "",
}
self._lock = threading.Lock()
def _write(self, s: str) -> None:
self._stream.write(s)
self._stream.flush()
def phase_begin(self, phase: PrivilegePhase, check_count: int) -> None:
label = "Unprivileged" if phase == "unprivileged" else "Privileged"
c = self._c
self._write(
f"{c['bold']}{label} phase{c['reset']} {c['dim']}({check_count} checks){c['reset']}\n"
)
def check_start(self, index: int, total: int, name: str) -> None:
c = self._c
pct = _completion_percent(index - 1, total)
bar = _ascii_progress_bar(index - 1, total)
self._write(
f" {c['bold']}{pct:>3}%{c['reset']} {bar} {_pad_name(name)} {c['cyan']}running{c['reset']}\n"
)
def check_done(
self,
slot: int,
total: int,
name: str,
elapsed_s: float,
n_findings: int,
failed: bool,
) -> None:
c = self._c
time_s = _fmt_elapsed(elapsed_s)
stat = f"{n_findings} finding" + ("s" if n_findings != 1 else "")
tag = f"{c['red']}failed{c['reset']}" if failed else f"{c['green']}ok{c['reset']}"
pct = _completion_percent(slot, total)
bar = _ascii_progress_bar(slot, total)
self._write(
f" {c['bold']}{pct:>3}%{c['reset']} {bar} {_pad_name(name)} {c['dim']}{time_s:>8}{c['reset']} "
f"{stat:>14} {tag}\n"
)
def phase_end(self, phase: PrivilegePhase, findings: list[Finding]) -> None:
label = "Unprivileged" if phase == "unprivileged" else "Privileged"
c = self._c
n = len(findings)
br = self._severity_breakdown_tty(findings)
self._write(
f"{c['dim']}{label} phase complete{c['reset']} - {c['bold']}{n}{c['reset']} "
f"finding{'s' if n != 1 else ''} this phase\n{br}\n"
)
def scan_complete(self, findings: list[Finding], output_dir: str) -> None:
c = self._c
n = len(findings)
br = self._severity_breakdown_tty(findings)
self._write(
f"{c['bold']}Scan complete{c['reset']} - {c['bold']}{n}{c['reset']} "
f"finding{'s' if n != 1 else ''} - {c['dim']}{output_dir}{c['reset']}\n"
f"{br}\n"
)
def _severity_breakdown_tty(self, findings: list[Finding]) -> str:
"""Plain counts when colour off; per-severity tinted counts when colour on."""
c = self._c
ctr = Counter(f.severity for f in findings)
if not c["reset"]:
return f" {severity_breakdown_line(findings)}"
colour_for: dict[Severity, str] = {
Severity.CRITICAL: c["red"],
Severity.HIGH: c["yellow"],
Severity.MEDIUM: c["magenta"],
Severity.LOW: c["cyan"],
Severity.INFORMATIONAL: "",
}
parts: list[str] = []
for sev in SEVERITY_BREAKDOWN_ORDER:
n = ctr[sev]
col = colour_for[sev]
if n == 0:
parts.append(f"{c['dim']}{sev.value}: 0{c['reset']}")
elif col:
parts.append(f"{c['dim']}{sev.value}:{c['reset']} {col}{n}{c['reset']}")
else:
parts.append(f"{c['dim']}{sev.value}:{c['reset']} {c['dim']}{n}{c['reset']}")
return " " + " · ".join(parts)
def _fmt_elapsed(s: float) -> str:
if s < 1.0:
return f"{int(s * 1000)}ms"
return f"{s:.2f}s"
def _pad_name(name: str, width: int = 30) -> str:
if len(name) <= width:
return name + " " * (width - len(name))
return name[: width - 1] + "..."

View File

@@ -0,0 +1,49 @@
"""Check modules and registry builder."""
from __future__ import annotations
from applepy.checks import (
ai_tools,
catalogues,
cis_extended,
common_paths,
compliance,
core,
deck_export,
dylib_hijack,
electron,
extended_surface,
fs_posture,
interpreters,
listening_ports,
plan_extras,
privesc,
pyobjc_surface,
surface,
)
from applepy.checks.mdm import jamf, kandji
from applepy.registry import CheckRegistry
def build_registry() -> CheckRegistry:
r = CheckRegistry()
core.register(r)
fs_posture.register(r)
catalogues.register(r)
compliance.register(r)
cis_extended.register(r)
common_paths.register(r)
extended_surface.register(r)
listening_ports.register(r)
surface.register(r)
electron.register(r)
pyobjc_surface.register(r)
plan_extras.register(r)
deck_export.register(r)
jamf.register(r)
kandji.register(r)
dylib_hijack.register(r)
privesc.register(r)
ai_tools.register(r)
interpreters.register(r)
return r

716
applepy/checks/ai_tools.py Normal file
View File

@@ -0,0 +1,716 @@
"""AI agent and LLM tool detection: CLI tools, desktop apps, and local model storage."""
from __future__ import annotations
import platform
import shutil
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
def register(registry: CheckRegistry) -> None:
registry.register("ai_cli_tools", check_ai_cli_tools, phases=("unprivileged",))
registry.register("ai_apps", check_ai_apps, phases=("unprivileged",))
registry.register("ai_model_storage", check_ai_model_storage, phases=("unprivileged",))
registry.register("ides", check_ides, phases=("unprivileged",))
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
# Vendor → list of CLI tool names to probe via shutil.which().
_CLI_TOOLS_BY_VENDOR: dict[str, list[str]] = {
"Anthropic (Claude)": ["claude", "claude-code"],
"Cursor IDE": ["cursor"],
"Codeium / Windsurf": ["windsurf", "codeium"],
"aider-chat": ["aider"],
"Ollama": ["ollama"],
"LM Studio": ["lm-studio", "lms"],
"Google Gemini": ["gemini"],
"Open Interpreter": ["interpreter"],
"Amazon Kiro": ["kiro"],
"GitHub Copilot": ["copilot", "gh-copilot"],
"Continue.dev": ["continue"],
"Jan.ai": ["jan"],
"Open WebUI": ["open-webui"],
"GPT4All": ["gpt4all"],
"llama.cpp": ["llama", "llama-cli", "llama-cpp"],
"KoboldCpp": ["koboldcpp"],
"Hugging Face": ["huggingface-cli", "huggingface_hub"],
"DeepSeek": ["deepseek"],
"Qwen": ["qwen"],
"Antigravity": ["antigravity"],
"Claw": ["claw"],
}
# Additional specific filesystem paths to check for CLIs not on PATH.
# Each entry: (description, path_string, is_dir)
_EXTRA_CLI_PATHS: list[tuple[str, str, bool]] = [
("Anthropic Claude Code (user local bin)", "~/.local/bin/claude", False),
("Anthropic Claude Code (user home local)", "~/.claude/local/claude", False),
("Cursor IDE (user config bin)", "~/.cursor/bin/cursor", False),
("Ollama model data (config dir)", "~/.config/ollama/models", True),
("Ollama model data (Library/Application Support)", "~/Library/Application Support/ollama", True),
]
# Known app bundle names to look for under /Applications and ~/Applications.
_KNOWN_APP_BUNDLES: list[str] = [
"Claude.app",
"Claude Code.app",
"Claude Code URL Handler.app",
"Cursor.app",
"Windsurf.app",
"ChatGPT.app",
"Gemini.app",
"Copilot.app",
"GitHub Copilot.app",
"Ollama.app",
"LM Studio.app",
"LMStudio.app",
"Jan.app",
"GPT4All.app",
"Kiro.app",
"Tabnine.app",
"Codeium.app",
"Perplexity.app",
"DeepSeek.app",
"Qwen.app",
"Poe.app",
"Open Interpreter.app",
]
# Keywords for fuzzy scan of /Applications and ~/Applications (case-insensitive).
_APP_KEYWORDS: tuple[str, ...] = (
"ai",
"llm",
"gpt",
"claude",
"copilot",
"cursor",
"ollama",
"gemini",
"deepseek",
"qwen",
"mistral",
"llama",
"kiro",
"codeium",
"windsurf",
"antigravity",
"claw",
)
# Local model storage directories to check, with human labels.
_MODEL_DIRS: list[tuple[str, str]] = [
("Ollama (default store)", "~/.ollama/models"),
("Hugging Face cache", "~/.cache/huggingface/hub"),
("LM Studio cache", "~/.cache/lm-studio/models"),
("LM Studio (Library/Application Support)", "~/Library/Application Support/LM Studio/models"),
("Jan.ai models", "~/Library/Application Support/Jan/models"),
("GPT4All data", "~/Library/Application Support/GPT4All"),
("LM Studio (dot-lmstudio)", "~/.lmstudio/models"),
("Ollama (local share)", "~/.local/share/ollama/models"),
("Ollama (Homebrew var)", "/opt/homebrew/var/ollama/models"),
]
def _get_app_version(app_path: Path) -> str:
"""Return the CFBundleShortVersionString from an app's Info.plist, or empty string."""
plist = app_path / "Contents" / "Info.plist"
if not plist.is_file():
return ""
try:
code, out, _err = run_text(
["/usr/bin/plutil", "-extract", "CFBundleShortVersionString", "raw", str(plist)],
timeout=10,
)
if code == 0 and out.strip():
return out.strip()
except Exception: # noqa: BLE001
pass
return ""
def _du_sh(path: Path) -> str:
"""Return human-readable size via `du -sh`, or a note on failure."""
try:
code, out, err = run_text(["/usr/bin/du", "-sh", str(path)], timeout=30)
if code == 0 and out.strip():
# du output: "<size>\t<path>"
return out.strip().split("\t")[0]
return f"(du failed: {(err or '').strip()[:80]})"
except Exception: # noqa: BLE001
return "(size unavailable)"
def _app_matches_keyword(name: str) -> bool:
"""Return True if the app bundle stem matches any AI-related keyword."""
lower = name.lower().removesuffix(".app")
return any(kw in lower for kw in _APP_KEYWORDS)
# ---------------------------------------------------------------------------
# Check 1 — ai_cli_tools (ai-001)
# ---------------------------------------------------------------------------
def check_ai_cli_tools(ctx: RunContext) -> list[Finding]:
"""Detect AI coding assistant CLI tools on PATH and in common install locations."""
vendor_hits: dict[str, list[str]] = {}
# Probe via shutil.which for each vendor's tools.
for vendor, tools in _CLI_TOOLS_BY_VENDOR.items():
for tool in tools:
try:
found = shutil.which(tool)
except Exception: # noqa: BLE001
found = None
if found:
vendor_hits.setdefault(vendor, []).append(f"{tool}: {found}")
# Probe extra filesystem paths.
extra_hits: list[str] = []
for description, raw_path, is_dir in _EXTRA_CLI_PATHS:
try:
p = Path(raw_path).expanduser()
if is_dir:
exists = p.is_dir()
else:
exists = p.is_file()
if exists:
extra_hits.append(f"{description}: {p}")
except OSError:
pass
all_found = bool(vendor_hits or extra_hits)
if not all_found:
return [
Finding(
id="ai-001",
title="AI CLI tools: none detected",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"No known AI coding assistant CLI tools were found on PATH or in common install "
"locations. The scan checked shutil.which() for each tool and probed specific "
"filesystem paths."
),
evidence="(none found)",
worksheet="Attack surface",
mitre_techniques=("T1059", "T1106"),
)
]
evidence_lines: list[str] = []
for vendor, hits in sorted(vendor_hits.items()):
evidence_lines.append(f"[{vendor}]")
evidence_lines.extend(f" {h}" for h in sorted(hits))
if extra_hits:
evidence_lines.append("[Additional paths (not on PATH)]")
evidence_lines.extend(f" {h}" for h in extra_hits)
total = sum(len(v) for v in vendor_hits.values()) + len(extra_hits)
vendor_count = len(vendor_hits) + (1 if extra_hits else 0)
return [
Finding(
id="ai-001",
title=f"AI CLI tools detected ({total} tool(s) across {vendor_count} vendor group(s))",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"One or more AI coding assistant or LLM CLI tools were found on PATH or in common "
"install locations. These tools may communicate with remote AI services, store "
"credentials, or access source code. Presence is expected on developer machines; "
"this finding is for attack-surface awareness."
),
evidence="\n".join(evidence_lines),
worksheet="Attack surface",
mitre_techniques=("T1059", "T1106"),
risk=(
"AI CLI tools may transmit code, prompts, or context to remote services. Compromised "
"credentials or misconfigured tools could expose intellectual property or secrets."
),
impact=(
"Potential for unintended data disclosure to third-party AI providers; supply-chain "
"risk if the tool's own update mechanism is compromised."
),
remediation=(
"Audit each tool's API-key storage, network egress policy, and update source. "
"Ensure sensitive repositories are excluded from AI context ingestion where required "
"by data-handling policy."
),
)
]
# ---------------------------------------------------------------------------
# Check 2 — ai_apps (ai-002)
# ---------------------------------------------------------------------------
def check_ai_apps(ctx: RunContext) -> list[Finding]:
"""Detect AI-related desktop applications in /Applications and ~/Applications."""
app_dirs: list[tuple[Path, str]] = [
(Path("/Applications"), ""),
(ctx.home / "Applications", "~/Applications/"),
]
found_apps: list[str] = []
seen_paths: set[Path] = set()
for apps_dir, label in app_dirs:
if not apps_dir.is_dir():
continue
# First pass: check known app bundle names.
for bundle_name in _KNOWN_APP_BUNDLES:
candidate = apps_dir / bundle_name
try:
if candidate.is_dir() and candidate not in seen_paths:
seen_paths.add(candidate)
version = _get_app_version(candidate)
ver_str = f" v{version}" if version else ""
found_apps.append(f"{bundle_name}{ver_str} ({label}{candidate})")
except OSError:
pass
# Second pass: fuzzy keyword scan over all .app bundles in the directory.
try:
for app_path in sorted(apps_dir.iterdir()):
if app_path.suffix != ".app":
continue
if app_path in seen_paths:
continue
if not _app_matches_keyword(app_path.name):
continue
try:
if app_path.is_dir():
seen_paths.add(app_path)
version = _get_app_version(app_path)
ver_str = f" v{version}" if version else ""
found_apps.append(f"{app_path.name}{ver_str} ({label}{app_path})")
except OSError:
pass
except OSError:
pass
if not found_apps:
return [
Finding(
id="ai-002",
title="AI desktop applications: none detected",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"No known AI-related desktop applications were found under /Applications or "
"~/Applications. Both an exact-name lookup and a keyword-based scan were performed."
),
evidence="(none found)",
worksheet="Attack surface",
mitre_techniques=("T1106",),
)
]
return [
Finding(
id="ai-002",
title=f"AI desktop applications detected ({len(found_apps)} app(s))",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"One or more AI-related desktop applications were found installed on this system. "
"Versions are extracted from each bundle's Info.plist (CFBundleShortVersionString) "
"where available. Presence is expected on developer machines; this finding is for "
"attack-surface and data-handling awareness."
),
evidence="\n".join(sorted(found_apps)),
worksheet="Attack surface",
mitre_techniques=("T1106",),
risk=(
"Desktop AI applications may access local files, credentials, and clipboard content, "
"and transmit context to remote AI provider infrastructure."
),
impact=(
"Data leakage via AI context ingestion; persistence or abuse of TCC entitlements "
"granted to AI applications (e.g., Full Disk Access, Accessibility)."
),
remediation=(
"Review TCC permissions granted to each application (System Settings > Privacy & "
"Security). Ensure vendor data-handling policies align with organisational requirements. "
"Keep applications updated to receive security patches."
),
)
]
# ---------------------------------------------------------------------------
# Check 4 — ides (dev-001)
# ---------------------------------------------------------------------------
# (vendor_label, [app bundle names], [CLI shutil.which names])
_IDE_DEFS: list[tuple[str, list[str], list[str]]] = [
(
"Visual Studio Code",
["Visual Studio Code.app", "Visual Studio Code - Insiders.app", "VSCodium.app"],
["code", "code-insiders", "codium"],
),
(
"JetBrains IntelliJ IDEA",
["IntelliJ IDEA.app", "IntelliJ IDEA CE.app", "IntelliJ IDEA Ultimate.app"],
["idea"],
),
(
"JetBrains PyCharm",
["PyCharm.app", "PyCharm CE.app", "PyCharm Professional Edition.app"],
["pycharm", "charm"],
),
(
"JetBrains WebStorm",
["WebStorm.app"],
["webstorm"],
),
(
"JetBrains GoLand",
["GoLand.app"],
["goland"],
),
(
"JetBrains CLion",
["CLion.app", "CLion Nova.app"],
["clion"],
),
(
"JetBrains Rider",
["Rider.app"],
["rider"],
),
(
"JetBrains DataGrip",
["DataGrip.app"],
["datagrip"],
),
(
"JetBrains RubyMine",
["RubyMine.app"],
["rubymine", "mine"],
),
(
"JetBrains PhpStorm",
["PhpStorm.app"],
["phpstorm"],
),
(
"JetBrains DataSpell",
["DataSpell.app"],
["dataspell"],
),
(
"JetBrains Fleet",
["Fleet.app"],
["fleet"],
),
(
"JetBrains Aqua",
["Aqua.app"],
["aqua"],
),
(
"JetBrains RustRover",
["RustRover.app"],
["rustrover"],
),
(
"JetBrains MPS",
["MPS.app"],
["mps"],
),
(
"JetBrains Writerside",
["Writerside.app"],
["writerside"],
),
(
"JetBrains AppCode",
["AppCode.app"],
[],
),
(
"Xcode",
["Xcode.app", "Xcode-beta.app"],
["xcodebuild", "xcode-select", "xcrun"],
),
(
"Android Studio",
["Android Studio.app", "Android Studio Preview.app"],
["studio"],
),
(
"Eclipse",
["Eclipse.app", "Eclipse IDE.app", "Spring Tool Suite 4.app"],
["eclipse"],
),
(
"NetBeans",
["NetBeans.app", "Apache NetBeans.app"],
["netbeans"],
),
(
"Sublime Text",
["Sublime Text.app", "Sublime Text 4.app"],
["subl", "sublime_text"],
),
(
"BBEdit",
["BBEdit.app"],
["bbdiff", "bbedit"],
),
(
"TextMate",
["TextMate.app"],
["mate"],
),
(
"Nova (Panic)",
["Nova.app"],
[],
),
(
"Zed",
["Zed.app", "Zed Preview.app"],
["zed"],
),
(
"Helix",
[],
["hx"],
),
(
"Neovim",
["Neovim.app", "VimR.app"],
["nvim"],
),
(
"MacVim",
["MacVim.app"],
["mvim", "gvim"],
),
(
"Emacs",
["Emacs.app", "GNU Emacs.app"],
["emacs"],
),
(
"Vim",
[],
["vim", "vi"],
),
(
"Atom",
["Atom.app"],
["atom"],
),
(
"CodeRunner",
["CodeRunner.app"],
[],
),
(
"RStudio / Positron",
["RStudio.app", "Positron.app"],
["rstudio"],
),
(
"Brackets",
["Brackets.app"],
[],
),
(
"Spyder",
["Spyder.app", "Spyder-6.app", "Spyder-5.app"],
["spyder"],
),
]
# JetBrains Toolbox installs IDEs into per-version subdirectories here
_JETBRAINS_TOOLBOX_DIR = Path.home() / "Library" / "Application Support" / "JetBrains" / "Toolbox" / "apps"
def check_ides(ctx: RunContext) -> list[Finding]:
"""dev-001 — Detect installed IDEs (app bundles and CLI launchers)."""
app_dirs = [Path("/Applications"), ctx.home / "Applications"]
results: dict[str, list[str]] = {} # vendor → list of found items
seen_paths: set[Path] = set()
for vendor, bundles, clis in _IDE_DEFS:
hits: list[str] = []
# Check app bundles
for bundle_name in bundles:
for app_dir in app_dirs:
candidate = app_dir / bundle_name
try:
if candidate.is_dir() and candidate not in seen_paths:
seen_paths.add(candidate)
version = _get_app_version(candidate)
ver_str = f" v{version}" if version else ""
hits.append(f" {bundle_name}{ver_str} ({candidate})")
except OSError:
pass
# Check CLI launchers
for cli in clis:
try:
loc = shutil.which(cli)
except Exception:
loc = None
if loc:
hits.append(f" {cli} CLI: {loc}")
if hits:
results[vendor] = hits
# Extra: JetBrains Toolbox managed installs
toolbox_hits: list[str] = []
try:
if _JETBRAINS_TOOLBOX_DIR.is_dir():
for app_dir in sorted(_JETBRAINS_TOOLBOX_DIR.iterdir()):
if app_dir.is_dir():
toolbox_hits.append(f" JetBrains Toolbox managed: {app_dir.name}")
except OSError:
pass
if toolbox_hits:
results["JetBrains Toolbox"] = toolbox_hits
if not results:
return [
Finding(
id="dev-001",
title="IDEs: none detected",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="No known IDEs found in /Applications, ~/Applications, or on PATH.",
evidence="",
worksheet="Attack surface",
mitre_techniques=["T1059", "T1106"],
)
]
evidence_lines: list[str] = []
for vendor, hits in sorted(results.items()):
evidence_lines.append(f"[{vendor}]")
evidence_lines.extend(hits)
return [
Finding(
id="dev-001",
title=f"IDEs detected ({len(results)} product(s))",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"One or more integrated development environments are installed. IDEs execute "
"arbitrary extension code, hold developer credentials, and have broad filesystem "
"access. Extension marketplaces represent a supply-chain attack vector."
),
evidence="\n".join(evidence_lines),
worksheet="Attack surface",
mitre_techniques=["T1059", "T1106", "T1195"],
risk=(
"Malicious IDE extensions can silently exfiltrate source code, API keys, and SSH "
"credentials, or establish persistence via build hooks and task runners."
),
impact=(
"Extension-based credential theft, source code exfiltration, or arbitrary code "
"execution within the developer's session."
),
remediation=(
"Audit installed extensions against an approved list. Enable extension signature "
"verification where supported. Restrict IDE network egress to approved registries. "
"Use separate profiles for untrusted project work."
),
)
]
# ---------------------------------------------------------------------------
# Check 3 — ai_model_storage (ai-003)
# ---------------------------------------------------------------------------
def check_ai_model_storage(ctx: RunContext) -> list[Finding]:
"""Check for local AI model storage directories and report their disk usage."""
present: list[str] = []
absent: list[str] = []
for label, raw_path in _MODEL_DIRS:
# Some paths are Darwin-specific (Library/Application Support); skip on other platforms.
if "Library/Application Support" in raw_path and platform.system() != "Darwin":
continue
if "/opt/homebrew" in raw_path and platform.system() != "Darwin":
continue
try:
p = Path(raw_path).expanduser()
if p.is_dir():
size = _du_sh(p)
present.append(f"{label}: {p} [{size}]")
else:
absent.append(f"{label}: {p} (not present)")
except OSError as e:
present.append(f"{label}: {raw_path} (OSError: {e})")
if not present:
return [
Finding(
id="ai-003",
title="AI model storage directories: none found",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"None of the common local AI model storage directories were found on this system. "
"Checked paths: " + "; ".join(raw for _, raw in _MODEL_DIRS)
),
evidence="(none present)\n\nAbsent paths:\n" + "\n".join(absent),
worksheet="Attack surface",
mitre_techniques=("T1005", "T1074"),
)
]
evidence = "Present directories (with approximate size):\n" + "\n".join(present)
if absent:
evidence += "\n\nAbsent directories:\n" + "\n".join(absent)
return [
Finding(
id="ai-003",
title=f"Local AI model storage detected ({len(present)} director{'y' if len(present) == 1 else 'ies'})",
category="AI Tools",
severity=Severity.INFORMATIONAL,
description=(
"One or more local AI model storage directories are present. These directories may "
"contain large proprietary or open-weight model files (GGUF, safetensors, PyTorch "
"checkpoints, etc.). Sizes are obtained via `du -sh` where readable."
),
evidence=evidence,
worksheet="Attack surface",
mitre_techniques=("T1005", "T1074"),
risk=(
"Proprietary model weights represent high-value intellectual property. Local model "
"files may be exfiltrated by malware or insider threat. Very large stores may also "
"indicate unrestricted downloading of third-party model weights without licence review."
),
impact=(
"Data exfiltration of proprietary or licensed model weights; potential licence "
"compliance issues; disk exhaustion on managed endpoints."
),
remediation=(
"Inventory model weights against a licence and data-classification register. "
"Ensure model directories are not world-readable and are excluded from unencrypted "
"backups or sync services unless explicitly approved. Consider MDM controls to "
"restrict installation of unapproved local inference tools."
),
)
]

View File

@@ -0,0 +1,554 @@
"""LOLBins / GTFO catalogues (live APIs with cache + bundled fallback), lolapps, cloud paths."""
from __future__ import annotations
import json
import os
from importlib import resources
from pathlib import Path
from typing import Any
from applepy.catalog_cache import (
GTFOBINS_API_URL,
LOOBINS_JSON_URL,
fetch_json_url,
gtfo_technique_summary,
iter_gtfo_binaries,
load_loobins_entries,
)
from applepy.catalog_policy import loobins_entry_is_report_noise
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
def _load_json(name: str) -> list | dict:
pkg = resources.files("applepy.data")
with (pkg / name).open(encoding="utf-8") as f:
return json.load(f)
def _load_json_list_dicts(name: str) -> list[dict[str, Any]]:
raw = _load_json(name)
if isinstance(raw, list):
return [x for x in raw if isinstance(x, dict)]
return []
def _which(name: str, path_env: str) -> str | None:
for d in path_env.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 _resolve_binary_path(name: str, path_env: str) -> str | None:
for d in (
"/usr/bin",
"/bin",
"/sbin",
"/usr/sbin",
"/usr/local/bin",
"/opt/homebrew/bin",
):
p = Path(d) / name
if p.is_file():
return str(p.resolve())
return _which(name, path_env)
def _skip_catalog_fetch() -> bool:
return os.environ.get("APPLEPY_SKIP_CATALOG_FETCH", "").strip() in ("1", "true", "yes")
def _gtfo_max() -> int | None:
raw = os.environ.get("APPLEPY_GTFO_MAX", "").strip()
if not raw:
return None
try:
n = int(raw)
return n if n > 0 else None
except ValueError:
return None
def _gtfo_detailed() -> bool:
return os.environ.get("APPLEPY_GTFO_DETAILED", "").strip() in ("1", "true", "yes")
def _gtfo_list_absent() -> bool:
return os.environ.get("APPLEPY_GTFO_LIST_ABSENT", "").strip() in ("1", "true", "yes")
def _gtfo_rollup_max_lines() -> int:
raw = os.environ.get("APPLEPY_GTFO_ROLLUP_MAX_LINES", "").strip()
if not raw:
return 120
try:
n = int(raw)
return max(20, min(n, 5000))
except ValueError:
return 120
def register(registry: CheckRegistry) -> None:
registry.register("cat_lolbins", check_lolbins, phases=("unprivileged",))
registry.register("cat_gtfo", check_gtfo, phases=("unprivileged",))
registry.register("cat_lolapps", check_lolapps, phases=("unprivileged",))
registry.register("cat_lottunnels", check_lottunnels, phases=("unprivileged",))
registry.register("cat_cloud_paths", check_cloud_credential_paths, phases=("unprivileged",))
def check_lolbins(ctx: RunContext) -> list[Finding]:
findings: list[Finding] = []
source_note = ""
rows: list[dict] = []
if _skip_catalog_fetch():
rows = load_loobins_entries(_load_json_list_dicts("lolbins_macos.json"))
source_note = "bundled lolbins_macos.json (APPLEPY_SKIP_CATALOG_FETCH)"
else:
parsed, status = fetch_json_url(LOOBINS_JSON_URL)
if isinstance(parsed, list):
rows = load_loobins_entries(parsed)
source_note = f"https://www.loobins.io/loobins.json ({status})"
else:
rows = load_loobins_entries(_load_json_list_dicts("lolbins_macos.json"))
source_note = f"fetch_unavailable_using_bundled ({status})"
path_env = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
present_lines: list[str] = []
missing_lines: list[str] = []
per_entry: list[Finding] = []
noise_omitted: list[str] = []
for row in rows:
name = row["name"]
skip_detail = loobins_entry_is_report_noise(name)
if skip_detail:
noise_omitted.append(name)
doc_paths = row.get("paths") or []
note = str(row.get("short_description", "") or "")
hit = None
for p in doc_paths:
if Path(p).is_file():
hit = p
break
if not hit:
hit = _resolve_binary_path(name, path_env)
if hit:
present_lines.append(f"{name} -> {hit}")
if not skip_detail:
per_entry.append(
Finding(
id=f"loob-api-{name.replace(' ', '_').replace('/', '_')}",
title=f"LOOBins: {name} present on host",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"LOOBins documents this binary as a macOS living-off-the-land candidate. "
"Presence alone is not a vulnerability; prioritise entries that sit outside "
"your standard build or that support execution or download in sensitive contexts."
),
evidence=f"path={hit}\nnotes={note}\nsource={source_note}",
worksheet="LOLBins",
mitre_techniques=("T1105", "T1059.002", "T1059.007"),
risk="Living-off-the-land primitives may support download or execution chains.",
impact="Attackers can avoid dropping custom tools for early-stage actions.",
remediation="Restrict script execution and outbound paths per policy; monitor ancestry.",
references=("https://www.loobins.io/", "https://www.loobins.io/loobins.json"),
)
)
else:
miss = f"{name}: documented on LOOBins; not at listed paths or common macOS prefixes — {note}"
missing_lines.append(miss)
if not skip_detail:
per_entry.append(
Finding(
id=f"loob-miss-{name.replace(' ', '_').replace('/', '_')}",
title=f"LOOBins: {name} not at canonical paths",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Listed on LOOBins but not resolved under documented paths, standard prefixes, or PATH. "
"This often reflects a relocated binary, a bundle-only install, or a catalogue path skew — "
"confirm with `which` or your software inventory before treating as absent."
),
evidence=f"{miss}\nsource={source_note}",
worksheet="LOLBins",
mitre_techniques=("T1059",),
remediation="Validate with `which`, mdfind, or inventory tooling.",
references=("https://www.loobins.io/", "https://www.loobins.io/loobins.json"),
)
)
noise_note = ""
if noise_omitted:
uniq = ", ".join(sorted({n for n in noise_omitted}))
noise_note = (
f"\nOmitted per-binary rows for ubiquitous UI entries ({uniq}); "
"they remain in counts above. Set APPLEPY_CATALOG_INCLUDE_NOISE=1 to emit those rows."
)
findings.append(
Finding(
id="lol-001",
title="LOLBins — LOOBins.io catalogue coverage",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Cross-check of the LOOBins macOS catalogue against this host (live JSON with cache under "
"~/.cache/applepy, or bundled fallback). The summary row establishes scope; substantive binaries "
"appear as individual findings below."
),
evidence=(
f"Source: {source_note}\n"
f"entries={len(rows)} present_count={len(present_lines)} missing_count={len(missing_lines)}\n"
"Set APPLEPY_CATALOG_OFFLINE=1 to use cache only; APPLEPY_SKIP_CATALOG_FETCH=1 for bundled only."
f"{noise_note}"
),
worksheet="LOLBins",
mitre_techniques=("T1105", "T1059.002", "T1059.007"),
risk="Living-off-the-land execution and download primitives aid stealthy operations.",
impact="Attackers may avoid dropping custom malware for initial actions.",
remediation="Restrict script execution and outbound connectivity per policy; monitor process ancestry.",
references=("https://www.loobins.io/", "https://www.loobins.io/docs/api/pyloobins/"),
)
)
findings.extend(per_entry)
return findings
def check_gtfo(ctx: RunContext) -> list[Finding]:
findings: list[Finding] = []
path_env = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
if _skip_catalog_fetch():
return _check_gtfo_bundled(path_env)
parsed, status = fetch_json_url(GTFOBINS_API_URL)
if not isinstance(parsed, dict):
findings.append(
Finding(
id="lol-007",
title="GTFOBins live catalogue unavailable — bundled macOS subset used",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"The scanner could not obtain a fresh GTFOBins `api.json` (network, timeout, or first-run "
"without cache). ApplePY fell back to the packaged `gtfo_macos.json` shortlist. "
"That is an expected resilience path, not a host failure."
),
evidence=(
f"fetch_status={status}\n"
"Re-try when outbound HTTPS is available, or rely on the bundled list for this workstation. "
"Catalogue traffic uses ~/.cache/applepy; see APPLEPY_CATALOG_OFFLINE and "
"APPLEPY_SKIP_CATALOG_FETCH in README."
),
worksheet="LOLBins",
references=("https://gtfobins.org/api.json",),
)
)
findings.extend(_check_gtfo_bundled(path_env))
return findings
binaries = iter_gtfo_binaries(parsed)
gmax = _gtfo_max()
if gmax is not None:
binaries = binaries[:gmax]
lines_hit: list[str] = []
lines_miss: list[str] = []
per_hit: list[Finding] = []
detailed = _gtfo_detailed()
for binary, entry in binaries:
tech = gtfo_technique_summary(entry)
hit = _resolve_binary_path(binary, path_env)
if hit:
lines_hit.append(f"{binary}: {hit}{tech}")
if detailed:
per_hit.append(
Finding(
id=f"gtfo-api-{binary.replace('/', '_').replace(' ', '_')}",
title=f"GTFOBins catalogue: {binary} present on host",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"This executable name appears in the GTFOBins reference set and resolved on disk. "
"GTFOBins is Linux- and Unix-centric; treat hits as **context** for dual-use tooling, "
"not as proof of misuse."
),
evidence=f"path={hit}\nfunctions={tech}\napi_load={status}",
worksheet="LOLBins",
mitre_techniques=("T1059",),
references=(
"https://gtfobins.org/",
"https://gtfobins.org/api.json",
"https://mitre-attack.github.io/attack-navigator/"
"#layerURL=https://gtfobins.org/mitre.json",
),
)
)
else:
lines_miss.append(binary)
if lines_miss and _gtfo_list_absent():
findings.append(
Finding(
id="gtfo-absent-all",
title="GTFOBins API — catalogue names not resolved on this host (full list)",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Optional exhaustive list: every `executables` entry from the GTFOBins API that did not "
"resolve under standard macOS prefixes or PATH for this run. Most misses are normal on "
"macOS because the catalogue is Linux-heavy. Enable only when you explicitly need the "
"complete negative set (APPLEPY_GTFO_LIST_ABSENT=1)."
),
evidence="\n".join(lines_miss) + f"\n\napi_load={status} total_missing={len(lines_miss)}",
worksheet="LOLBins",
references=("https://gtfobins.org/api.json",),
)
)
summary_bits: list[str] = [
f"api_status={status}",
f"executables_checked={len(binaries)}",
f"on_host={len(lines_hit)} not_found={len(lines_miss)}",
]
if detailed:
summary_bits.append("APPLEPY_GTFO_DETAILED=1: per-binary worksheet rows follow this summary.")
summary_bits.append("Optional: APPLEPY_GTFO_MAX=N to cap catalogue size for testing.")
else:
cap = _gtfo_rollup_max_lines()
summary_bits.append("On-host matches (capped for workbook readability):")
summary_bits.extend(lines_hit[:cap])
if len(lines_hit) > cap:
summary_bits.append(
f"... and {len(lines_hit) - cap} further on-host matches omitted "
"(raise APPLEPY_GTFO_ROLLUP_MAX_LINES or set APPLEPY_GTFO_DETAILED=1)."
)
summary_bits.append(
"Absent catalogue names are omitted by default (most are Linux-only). "
"Set APPLEPY_GTFO_LIST_ABSENT=1 for a dedicated finding listing every absent name."
)
summary_bits.append("Optional: APPLEPY_GTFO_MAX=N to cap checks for testing.")
findings.insert(
0,
Finding(
id="lol-002",
title="GTFOBins — API catalogue versus this host",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Comparison of the GTFOBins `api.json` executable set (cached under ~/.cache/applepy when "
"fetched successfully) against standard macOS binary locations and PATH. Default output rolls "
"matches into this row so spreadsheets stay usable; use APPLEPY_GTFO_DETAILED=1 for one row per "
"on-host binary."
),
evidence="\n".join(summary_bits),
worksheet="LOLBins",
mitre_techniques=("T1059",),
references=(
"https://gtfobins.org/",
"https://gtfobins.org/api.json",
"https://mitre-attack.github.io/attack-navigator/#layerURL=https://gtfobins.org/mitre.json",
),
),
)
findings.extend(per_hit)
return findings
def _check_gtfo_bundled(path_env: str) -> list[Finding]:
data = _load_json_list_dicts("gtfo_macos.json")
lines_hit: list[str] = []
lines_miss: list[str] = []
for row in data:
binary = row["binary"]
paths = row.get("paths") or []
tech = row.get("technique", "")
hit = next((p for p in paths if Path(p).is_file()), None) or _resolve_binary_path(
binary, path_env
)
if hit:
lines_hit.append(f"{binary}: {hit}{tech}")
else:
lines_miss.append(f"{binary}: not at bundled path — {tech}")
return [
Finding(
id="lol-002b",
title="GTFOBins — bundled macOS-oriented executable shortlist",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Curated dual-use Unix utilities from packaged `gtfo_macos.json`, used when live catalogue "
"fetch is disabled or unreachable. Use this row to anchor discussion; it is narrower than the "
"full GTFOBins API."
),
evidence="\n".join(lines_hit) if lines_hit else "(none at resolved paths)",
worksheet="LOLBins",
mitre_techniques=("T1059",),
references=("https://gtfobins.org/", "https://gtfobins.org/api.json"),
),
*(
[
Finding(
id="lol-006",
title="GTFO-style entries — bundled path miss",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description="Technique documented in bundled list; path not found on this host.",
evidence="\n".join(lines_miss),
worksheet="LOLBins",
remediation="Confirm binary location with `which` and vendor documentation.",
references=("https://gtfobins.org/",),
)
]
if lines_miss
else []
),
]
def _apps_dir_list() -> list[str]:
apps = Path("/Applications")
if not apps.is_dir():
return []
return sorted(p.name for p in apps.iterdir() if p.is_dir() and p.suffix == ".app")
def check_lolapps(ctx: RunContext) -> list[Finding]:
raw = _load_json("lolapps.json")
targets = [str(t) for t in raw] if isinstance(raw, list) else []
installed = _apps_dir_list()
hits = []
for t in targets:
for app in installed:
if t.lower() in app.lower():
hits.append(app)
break
ev = "Matches in /Applications:\n" + ("\n".join(hits) if hits else "(none from curated list)")
return [
Finding(
id="lol-003",
title="Potentially unnecessary software (remote / virtualisation) — informational",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description="Presence-only match against a lolapps-style list; validate business need.",
evidence=ev,
worksheet="LOLBins",
mitre_techniques=("T1219", "T1021.001"),
remediation="Remove unused remote-access tools or enforce conditional access and logging.",
references=("https://lolapps-project.github.io/",),
)
]
def check_lottunnels(ctx: RunContext) -> list[Finding]:
names = _load_json("lottunnels.json")
home = ctx.home
path_env = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
hits: list[str] = []
for n in names:
w = _which(n, path_env)
if w:
hits.append(f"{n} -> {w}")
continue
for cand in (Path("/usr/local/bin") / n, Path("/opt/homebrew/bin") / n, home / "bin" / n):
if cand.is_file():
hits.append(str(cand))
break
ev = "Tunnelling-related binaries:\n" + ("\n".join(hits) if hits else "(none found via PATH and common prefixes)")
return [
Finding(
id="lol-004",
title="Tunnelling applications identified",
category="LOLBins",
severity=Severity.INFORMATIONAL,
description=(
"Informational: searches PATH and common prefixes for names from a lottunnels-style list."
),
evidence=ev,
worksheet="LOLBins",
mitre_techniques=("T1090", "T1572"),
remediation="Inventory authorised tunnelling tools; block or alert on unknown egress paths.",
references=("https://lottunnels.github.io/",),
)
]
def check_cloud_credential_paths(ctx: RunContext) -> list[Finding]:
"""Presence-only checks for common cloud-credential path hints (no secret reads)."""
home = ctx.home
checks: list[tuple[str, Path]] = [
("AWS credentials", home / ".aws" / "credentials"),
("AWS config", home / ".aws" / "config"),
("Azure CLI", home / ".azure"),
("gcloud config", home / ".config" / "gcloud"),
("GCP application default credentials", home / ".config" / "gcloud" / "application_default_credentials.json"),
("GitHub CLI hosts", home / ".config" / "gh" / "hosts.yml"),
("GitHub CLI hosts (yaml)", home / ".config" / "gh" / "hosts.yaml"),
("Docker config", home / ".docker" / "config.json"),
("kubectl config", home / ".kube" / "config"),
(".netrc", home / ".netrc"),
(".git-credentials", home / ".git-credentials"),
("npmrc", home / ".npmrc"),
("SSH directory", home / ".ssh"),
(".env in home", home / ".env"),
("Shell history (zsh)", home / ".zsh_history"),
("Shell history (bash)", home / ".bash_history"),
("Safari binary cookies", home / "Library" / "Cookies" / "Cookies.binarycookies"),
("Firefox profiles dir", home / "Library" / "Application Support" / "Firefox" / "Profiles"),
("Chrome user data dir", home / "Library" / "Application Support" / "Google" / "Chrome"),
]
lines: list[str] = []
for label, path in checks:
if path.is_dir():
lines.append(f"{label}: directory exists {path}")
elif path.is_file():
try:
lines.append(f"{label}: file exists {path} ({path.stat().st_size} bytes)")
except OSError as e:
lines.append(f"{label}: {path} ({e})")
else:
lines.append(f"{label}: not present at {path}")
gac = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS", "").strip()
if gac:
gp = Path(gac).expanduser()
try:
if gp.is_file():
lines.append(
f"GOOGLE_APPLICATION_CREDENTIALS: file exists {gp} ({gp.stat().st_size} bytes)"
)
else:
lines.append(f"GOOGLE_APPLICATION_CREDENTIALS: set but path not a file ({gp})")
except OSError as e:
lines.append(f"GOOGLE_APPLICATION_CREDENTIALS: {gp} ({e})")
return [
Finding(
id="cloud-001",
title="Cloud and developer credential locations (presence only)",
category="Credentials",
severity=Severity.INFORMATIONAL,
description=(
"Files and directories commonly targeted for cloud, browser, VCS credentials, and shell "
"history (presence and size only); ApplePY does not read file contents."
),
evidence="\n".join(lines),
worksheet="Credentials",
mitre_techniques=("T1552.001", "T1552.003", "T1528", "T1539"),
risk="Stored credentials enable lateral movement and cloud resource abuse.",
impact="Account takeover, data exfiltration, or supply-chain access via tokens.",
remediation="Use short-lived credentials, hardware-backed storage, and MDM-managed keychain policies.",
references=(
"https://attack.mitre.org/matrices/enterprise/macos/",
),
)
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,482 @@
"""Common paths (cop_paths) and SwiftBelt-style macOS reconnaissance (read-only).
Host paths and artefacts frequently reviewed during authorised macOS assessments (persistence,
credentials, account surface). Register names use the ``cop_paths_*`` prefix; implementation module
is ``common_paths``.
"""
from __future__ import annotations
import os
import platform
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
def register(registry: CheckRegistry) -> None:
registry.register("cop_paths_mounted", check_mounted_volumes, phases=("unprivileged",))
registry.register("cop_paths_loginwindow", check_loginwindow_guest, phases=("unprivileged",))
registry.register("cop_paths_remote_login", check_remote_login, phases=("privileged",))
registry.register("cop_paths_remote_mgmt", check_remote_management_plist, phases=("privileged",))
registry.register("cop_paths_mdm_enrol", check_mdm_enrolment, phases=("unprivileged",))
registry.register("cop_paths_browser_ext", check_browser_extension_dirs, phases=("unprivileged",))
registry.register("cop_paths_cloud_clis", check_cloud_clis_on_path, phases=("unprivileged",))
registry.register("cop_paths_dscl_users", check_dscl_local_users, phases=("unprivileged",))
registry.register("cop_paths_ssh_pub", check_ssh_public_key_files, phases=("unprivileged",))
registry.register("cop_paths_cron_periodic", check_cron_periodic_surface, phases=("unprivileged",))
registry.register("cop_paths_automator", check_automator_workflow_dirs, phases=("unprivileged",))
registry.register("cop_paths_keychains", check_user_keychain_directory, phases=("unprivileged",))
def check_mounted_volumes(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/sbin/mount"])
text = (out + err).strip()
return [
Finding(
id="soc-001",
title="Mounted file systems",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Lists mounted volumes — useful for identifying network shares and removable media.",
evidence=text,
worksheet="Attack surface",
mitre_techniques=("T1039", "T1560.001"),
risk="Unexpected mounts may indicate data staging or lateral movement paths.",
impact="Sensitive data may be copied to removable or network locations.",
remediation="Review mount points; restrict unauthorised network shares and removable media if policy requires.",
)
]
def check_loginwindow_guest(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(
["/usr/bin/defaults", "read", "/Library/Preferences/com.apple.loginwindow", "GuestEnabled"],
timeout=10,
)
text = (out + err).strip()
sev = Severity.INFORMATIONAL
if "1" in text.split() or text == "1":
sev = Severity.MEDIUM
return [
Finding(
id="soc-002",
title="Guest login (loginwindow preference)",
category="Attack surface",
severity=sev,
description="Reads GuestEnabled from com.apple.loginwindow — guest access weakens accountability.",
evidence=text or f"exit={code}",
worksheet="Attack surface",
mitre_techniques=("T1078",),
risk="Guest sessions reduce attribution and may bypass some controls.",
impact="Local opportunistic access without a named account.",
remediation="Disable the guest account via MDM or System Settings.",
references=("https://support.apple.com/guide/mac-help/allow-guests-to-log-in-to-your-mac-mchlp2275/mac",),
)
]
def check_remote_login(ctx: RunContext) -> list[Finding]:
if not ctx.is_root():
return []
code, out, err = run_text(
["/usr/sbin/systemsetup", "-getremotelogin"],
timeout=15,
)
text = (out + err).strip()
sev = Severity.INFORMATIONAL
if "On" in text or "on" in text:
sev = Severity.MEDIUM
return [
Finding(
id="soc-003",
title="Remote Login (SSH) status",
category="Attack surface",
severity=sev,
description="systemsetup reports whether Remote Login (sshd) is enabled.",
evidence=text,
worksheet="Attack surface",
mitre_techniques=("T1021.004",),
risk="SSH expands remote attack surface if exposed and poorly authenticated.",
impact="Credential attacks and lateral movement over the network.",
remediation="Disable Remote Login if not required; enforce keys, firewall rules, and MDM restrictions.",
)
]
def check_remote_management_plist(ctx: RunContext) -> list[Finding]:
"""Apple Remote Desktop / Remote Management preference plist (read-only plutil)."""
if not ctx.is_root():
return []
plist = Path("/Library/Preferences/com.apple.RemoteManagement.plist")
if not plist.is_file():
return [
Finding(
id="soc-007",
title="Remote Management (ARD) preferences plist absent",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="com.apple.RemoteManagement.plist not present; Remote Management may be off or managed elsewhere.",
evidence=str(plist),
worksheet="Attack surface",
mitre_techniques=("T1021.001",),
remediation="If ARD is required, enforce strong local passwords and network segmentation; otherwise leave disabled.",
)
]
code, out, err = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=15)
blob = (out + err).strip()
sev = Severity.INFORMATIONAL
lowered = blob.lower()
if code != 0 or "error" in lowered:
sev = Severity.LOW
return [
Finding(
id="soc-008",
title="Remote Management (ARD) preferences (plutil)",
category="Attack surface",
severity=sev,
description=(
"Structured view of Remote Management settings (Screen Sharing / ARD). Review with "
"organisational policy on interactive remote control."
),
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1021.001", "T1078"),
risk="Enabled remote management increases interactive access and credential exposure if misconfigured.",
impact="Operators or attackers with suitable credentials may control the desktop session.",
remediation="Restrict ARD to management VLANs; use MDM-derived policies; disable if unused.",
references=("https://support.apple.com/guide/remote-desktop/",),
)
]
def check_mdm_enrolment(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/profiles", "status", "-type", "enrollment"], timeout=15)
text = (out + err).strip()
return [
Finding(
id="soc-004",
title="MDM enrolment status (profiles)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Apple `profiles status -type enrollment` output for device management context.",
evidence=text or f"exit={code}",
worksheet="Attack surface",
mitre_techniques=("T1078", "T1553.004"),
risk="Misconfigured MDM or missing supervision affects enterprise control narratives.",
impact="Policy enforcement and visibility depend on correct enrolment.",
remediation="Validate enrolment with your MDM console; investigate anomalies with IT.",
)
]
def check_browser_extension_dirs(ctx: RunContext) -> list[Finding]:
home = ctx.home
bases = [
home / "Library" / "Application Support" / "Google" / "Chrome" / "External Extensions",
home / "Library" / "Application Support" / "Google" / "Chrome" / "Default" / "Extensions",
home / "Library" / "Application Support" / "Microsoft Edge" / "Default" / "Extensions",
home / "Library" / "Application Support" / "Mozilla" / "Firefox",
]
lines: list[str] = []
for b in bases:
if b.is_dir():
try:
n = sum(1 for _ in b.iterdir())
except OSError as e:
lines.append(f"{b}: error {e}")
else:
lines.append(f"{b}: {n} entries")
else:
lines.append(f"{b}: absent")
return [
Finding(
id="soc-005",
title="Browser extension / profile directories (presence)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="SwiftBelt-style interest paths; counts only — no extension contents read.",
evidence="\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1176",),
risk="Malicious extensions persist in user profiles.",
impact="Credential access, ad injection, or data exfiltration via the browser.",
remediation="Review extensions via browser UI; use enterprise browser policies where available.",
)
]
def _resolve_cli(name: str) -> str | None:
path = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
for d in path.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())
for extra in ("/opt/homebrew/bin", "/usr/local/bin"):
p = Path(extra) / name
if p.is_file() and os.access(p, os.X_OK):
return str(p.resolve())
return None
def check_cloud_clis_on_path(ctx: RunContext) -> list[Finding]:
names = (
"aws",
"az",
"gcloud",
"kubectl",
"terraform",
"pulumi",
"doctl",
"openstack",
"ibmcloud",
"oci",
"flyctl",
"heroku",
"linode-cli",
"vultr-cli",
)
hits: list[str] = []
for name in names:
hit = _resolve_cli(name)
if hit:
hits.append(f"{name}: {hit}")
listed = ", ".join(names)
ev = "\n".join(hits) if hits else f"(none of: {listed})"
return [
Finding(
id="soc-006",
title="Cloud and infrastructure CLIs on PATH",
category="Credentials",
severity=Severity.INFORMATIONAL,
description=(
"Presence of common cloud and infrastructure CLIs (including Homebrew prefixes when PATH is "
"minimal) — correlate with credential path findings."
),
evidence=ev,
worksheet="Credentials",
mitre_techniques=("T1580", "T1528"),
remediation="Ensure CLIs use short-lived credentials; review ~/.aws, ~/.azure, ~/.config/gcloud.",
)
]
def check_dscl_local_users(_ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return []
code, out, err = run_text(["/usr/bin/dscl", ".", "list", "/Users"], timeout=25)
text = (out + err).strip()
return [
Finding(
id="soc-009",
title="Local user accounts (dscl list /Users)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Directory Service listing of local user records (SwiftBelt / common_paths account surface). "
"Read-only; does not modify accounts."
),
evidence=text if text else f"exit={code} (no stdout/stderr)",
worksheet="Attack surface",
mitre_techniques=("T1087",),
risk="Unexpected local accounts weaken attribution and may bypass some enterprise controls.",
impact="Attackers target dormant or shared accounts for persistence.",
remediation="Align with IdP and MDM records; remove obsolete accounts.",
)
]
def check_ssh_public_key_files(ctx: RunContext) -> list[Finding]:
d = ctx.home / ".ssh"
if not d.is_dir():
return [
Finding(
id="soc-010",
title="SSH public key files (~/.ssh)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="~/.ssh absent or not a directory.",
evidence=str(d),
worksheet="Attack surface",
mitre_techniques=("T1552.004",),
)
]
pubs = sorted(p.name for p in d.iterdir() if p.is_file() and p.suffix == ".pub")
priv_hint = sorted(
p.name
for p in d.iterdir()
if p.is_file()
and not p.name.endswith(".pub")
and p.name not in ("config", "known_hosts", "authorized_keys", "known_hosts.old")
and not p.name.startswith(".")
)
lines = [
f"public_keys ({len(pubs)}):",
"\n".join(pubs) if pubs else "(none)",
f"other_non_pub_files_listed ({len(priv_hint)}):",
"\n".join(priv_hint) if priv_hint else "(none)",
]
return [
Finding(
id="soc-010",
title="SSH directory — public keys and other filenames (no secret reads)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Lists `*.pub` and other non-hidden filenames under ~/.ssh. Private key material is never read."
),
evidence="\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1552.004", "T1021.004"),
risk="Loose SSH keys enable lateral movement if combined with trust relationships.",
impact="Stolen or duplicated keys may grant remote shell access.",
remediation="Use hardware-backed or short-lived keys; audit authorized_keys on servers.",
)
]
def check_cron_periodic_surface(_ctx: RunContext) -> list[Finding]:
lines: list[str] = []
cr = Path("/etc/crontab")
if cr.is_file():
lines.append("=== /etc/crontab ===")
try:
lines.extend(cr.read_text(encoding="utf-8", errors="replace").splitlines())
except OSError as e:
lines.append(f"(read failed: {e})")
else:
lines.append(f"/etc/crontab: not a regular file ({cr})")
for label in ("daily", "weekly", "monthly"):
p = Path("/etc/periodic") / label
if p.is_dir():
try:
names = sorted(x.name for x in p.iterdir() if x.is_file())
lines.append(f"=== /etc/periodic/{label} ({len(names)} files) ===")
lines.extend(names)
except OSError as e:
lines.append(f"/etc/periodic/{label}: {e}")
else:
lines.append(f"/etc/periodic/{label}: not a directory")
at_tabs = Path("/var/at/tabs")
if at_tabs.is_dir():
try:
entries = sorted(at_tabs.iterdir())
lines.append(f"=== /var/at/tabs ({len(entries)} entries) ===")
lines.extend(e.name for e in entries)
except OSError as e:
lines.append(f"/var/at/tabs: {e}")
else:
lines.append("/var/at/tabs: not a directory (or not visible to this user)")
return [
Finding(
id="soc-011",
title="Cron, periodic jobs, and at(1) tab paths (read-only)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Full /etc/crontab text when readable; full filenames under /etc/periodic/*; "
"`at` spool entry names under /var/at/tabs when visible. Aligns with scheduled task "
"interest in macOS red-team tradecraft."
),
evidence="\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1053.003", "T1053.004"),
risk="Unexpected cron or periodic scripts may indicate persistence.",
impact="Jobs run with elevated or user context depending on configuration.",
remediation="Review scripts with change control; restrict write access to job directories.",
)
]
def check_automator_workflow_dirs(ctx: RunContext) -> list[Finding]:
paths = [
("User Workflows", ctx.home / "Library" / "Workflows"),
("User Services", ctx.home / "Library" / "Services"),
("System Workflows", Path("/Library/Workflows")),
]
blocks: list[str] = []
for label, p in paths:
if p.is_dir():
try:
names = sorted(x.name for x in p.iterdir())
blocks.append(f"{label} ({p}): {len(names)} entries\n" + "\n".join(names))
except OSError as e:
blocks.append(f"{label} ({p}): list failed ({e})")
else:
blocks.append(f"{label} ({p}): absent or not a directory")
return [
Finding(
id="soc-012",
title="Automator workflows and Services (directory listings)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"SwiftBelt-style interest in Automator and Quick Action surfaces; filenames only, no workflow "
"content ingestion."
),
evidence="\n\n".join(blocks),
worksheet="Attack surface",
mitre_techniques=("T1059.002", "T1547"),
risk="Malicious workflows can chain osascript and shell execution.",
impact="Users may be tricked into running crafted Automator payloads.",
remediation="Review unusual workflows; use MDM to restrict automation where policy allows.",
)
]
def check_user_keychain_directory(ctx: RunContext) -> list[Finding]:
kd = ctx.home / "Library" / "Keychains"
if not kd.is_dir():
return [
Finding(
id="soc-013",
title="User keychains directory",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="~/Library/Keychains not present or not a directory.",
evidence=str(kd),
worksheet="Attack surface",
mitre_techniques=("T1555",),
)
]
try:
names = sorted(p.name for p in kd.iterdir())
except OSError as e:
return [
Finding(
id="soc-013",
title="User keychains directory",
category="Attack surface",
severity=Severity.LOW,
description="Could not list ~/Library/Keychains.",
evidence=str(e),
worksheet="Attack surface",
mitre_techniques=("T1555",),
)
]
return [
Finding(
id="soc-013",
title="User keychain files (names only)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Complete filename list under ~/Library/Keychains — no keychain unlock or password reads."
),
evidence=f"count={len(names)}\n" + "\n".join(names),
worksheet="Attack surface",
mitre_techniques=("T1555",),
risk="Keychain files are high-value targets after local compromise.",
impact="Credential access may follow disk or memory attacks.",
remediation="Enforce FileVault; monitor access to keychain paths during engagements.",
)
]

View File

@@ -0,0 +1,787 @@
"""NIST mSCP (macos_security) integration, CIS worksheet signals, Lynis execution."""
from __future__ import annotations
import logging
import os
import platform
import re
import shutil
import time
from pathlib import Path
from applepy.bootstrap_compliance import lynis_bundled_executable, lynis_clone_path
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.mscp import (
APPLEPY_MSCP_AUDIT_NAME,
find_generated_compliance_script,
pick_baseline_yaml,
resolve_mscp_root,
run_compliance_script,
run_generate_guidance,
)
from applepy.mscp_audit_parse import audit_plist_path, parse_audit_plist
from applepy.registry import CheckRegistry
from applepy.subproc import run_text
_log = logging.getLogger(__name__)
# macOS version → marketing name mapping for CIS/mSCP baseline selection.
_MACOS_VERSION_NAMES: dict[int, str] = {
26: "Tahoe",
15: "Sequoia",
14: "Sonoma",
13: "Ventura",
12: "Monterey",
11: "Big Sur",
}
# Bundled mSCP data targets this major version (matches applepy/data/macos_security/VERSION.yaml).
_BUNDLED_MSCP_MACOS_MAJOR = 26
def _macos_major_version() -> int | None:
"""Return the macOS major version number (e.g. 15 for Sequoia, 26 for Tahoe).
``platform.mac_ver()`` is tried first. On some Python builds (< 3.8) running
on macOS 11+ it returns the compatibility string ``"10.16"`` instead of
``"11.x"``, yielding major version 10 which is wrong. When the result looks
like a pre-Big-Sur compatibility alias (``"10.16"``), or when the call
returns nothing, we fall back to ``sw_vers -productVersion`` which always
reports the real marketing version.
"""
try:
ver = platform.mac_ver()[0]
if ver:
major = int(ver.split(".")[0])
# "10.16" is the compatibility alias some Pythons report for macOS 11.
if major != 10:
return major
except (ValueError, IndexError, OSError):
pass
# Fallback: ask the OS directly.
try:
import subprocess # noqa: PLC0415
result = subprocess.run(
["/usr/bin/sw_vers", "-productVersion"],
capture_output=True, text=True, timeout=5
)
ver = result.stdout.strip()
if ver:
return int(ver.split(".")[0])
except (ValueError, IndexError, OSError, subprocess.TimeoutExpired):
pass
return None
def _macos_name() -> str:
"""Return the marketing name of the running macOS, or the version string if unknown."""
major = _macos_major_version()
if major is None:
return "Unknown"
return _MACOS_VERSION_NAMES.get(major, f"macOS {major}")
_LYNIS_EVIDENCE_MAX_CHARS = 18_000
# mSCP generate_guidance / compliance --check transcripts (nist-004 / nist-006 evidence).
_MSCP_LOG_CAP_DEFAULT = 524_288
def _mscp_log_cap_chars() -> int:
raw = os.environ.get("APPLEPY_MSCP_COMPLIANCE_LOG_MAX", "").strip()
if not raw:
return _MSCP_LOG_CAP_DEFAULT
try:
n = int(raw, 10)
except ValueError:
return _MSCP_LOG_CAP_DEFAULT
return max(8_192, min(n, 20_000_000))
def _cap_mscp_transcript(label: str, text: str) -> str:
cap = _mscp_log_cap_chars()
if len(text) <= cap:
return text
omitted = len(text) - cap
return (
f"{text[:cap]}\n--- ApplePY: {label} truncated, {omitted} characters omitted "
f"(APPLEPY_MSCP_COMPLIANCE_LOG_MAX; cap {cap}) ---\n"
)
def _truncate_lynis_evidence(blob: str, max_chars: int = _LYNIS_EVIDENCE_MAX_CHARS) -> str:
if len(blob) <= max_chars:
return blob
head = (max_chars * 2) // 3
tail = max_chars // 5
omitted = len(blob) - head - tail
return (
blob[:head]
+ "\n\n[… "
f"{omitted} characters omitted for workbook readability; run Lynis interactively on the host for the "
"complete transcript …]\n\n"
+ blob[-tail:]
)
def register(registry: CheckRegistry) -> None:
registry.register("comp_macos_version", check_macos_version, phases=("unprivileged",))
registry.register("comp_cis_pointer", check_cis_worksheet_pointer, phases=("unprivileged",))
registry.register("comp_nist_status", check_nist_mscp_status, phases=("unprivileged",))
registry.register("comp_mscp_audit", check_mscp_audit_privileged, phases=("privileged",))
registry.register("comp_filevault", check_filevault, phases=("unprivileged", "privileged"))
registry.register("comp_pwpolicy", check_pwpolicy, phases=("privileged",))
registry.register("comp_boot_uptime", check_boot_uptime, phases=("unprivileged",))
registry.register("comp_timemachine", check_timemachine_destinations, phases=("unprivileged",))
registry.register("comp_admin_group", check_admin_group_membership, phases=("unprivileged",))
registry.register("comp_umask", check_default_interactive_umask, phases=("unprivileged",))
registry.register("comp_lynis_run", check_lynis_run, phases=("unprivileged",))
def check_macos_version(ctx: RunContext) -> list[Finding]:
"""Detect the running macOS version and report CIS/mSCP baseline compatibility."""
code, out, err = run_text(["/usr/bin/sw_vers"], timeout=10)
sw_vers_output = (out + err).strip() or f"exit={code}, no output"
major = _macos_major_version()
name = _macos_name()
supported = major in _MACOS_VERSION_NAMES
bundled_match = major == _BUNDLED_MSCP_MACOS_MAJOR
supported_versions = ", ".join(
f"{k} ({v})" for k, v in sorted(_MACOS_VERSION_NAMES.items(), reverse=True)
)
if major is None:
sev = Severity.MEDIUM
description = (
"Could not determine the macOS version. CIS and mSCP baseline selection may be incorrect. "
"Manual version verification is required."
)
remediation = "Confirm macOS version via System Information or `sw_vers` and re-run."
elif not supported:
sev = Severity.MEDIUM
description = (
f"Running macOS {major} which is not in the known-supported version list "
f"({supported_versions}). CIS checks and the bundled mSCP data may not apply accurately."
)
remediation = (
"Verify this macOS version is covered by the CIS benchmark and mSCP project, "
"or update to a supported release."
)
elif not bundled_match:
sev = Severity.LOW
description = (
f"Running {name} (macOS {major}), but the bundled mSCP data targets macOS "
f"{_BUNDLED_MSCP_MACOS_MAJOR} ({_MACOS_VERSION_NAMES.get(_BUNDLED_MSCP_MACOS_MAJOR, 'unknown')}). "
"CIS manual checks still apply; the mSCP compliance script may produce inaccurate results. "
"Set APPLEPY_MACOS_SECURITY_ROOT to a version-matched mSCP clone for accurate results."
)
remediation = (
"Clone the usnistgov/macos_security branch that matches your macOS version "
"(e.g., `sequoia` for macOS 15, `ventura` for macOS 13), then set "
"APPLEPY_MACOS_SECURITY_ROOT to that path and re-run."
)
else:
sev = Severity.INFORMATIONAL
description = (
f"Running {name} (macOS {major}). The bundled mSCP data matches this version. "
"CIS and NIST mSCP checks will use the appropriate baseline."
)
remediation = "No action required."
evidence_lines = [f"sw_vers output:\n{sw_vers_output}"]
if major is not None:
evidence_lines.append(f"\nDetected: macOS {major} ({name})")
evidence_lines.append(f"Bundled mSCP targets: macOS {_BUNDLED_MSCP_MACOS_MAJOR}")
evidence_lines.append(f"Version match: {'yes' if bundled_match else 'no'}")
return [
Finding(
id="cis-ver-001",
title=f"macOS version: {name} (macOS {major})" if major else "macOS version: unknown",
category="Compliance",
severity=sev,
description=description,
evidence="\n".join(evidence_lines),
worksheet="CIS",
mitre_techniques=("T1082",),
risk="Version mismatch between running OS and CIS/mSCP baseline reduces assessment accuracy.",
impact="CIS and mSCP findings may not accurately reflect applicable controls for this OS version.",
remediation=remediation,
references=(
"https://www.cisecurity.org/benchmark/apple_os",
"https://github.com/usnistgov/macos_security",
),
)
]
def parse_kern_boottime_sec(text: str) -> int | None:
"""Parse `sysctl kern.boottime` output; returns Unix seconds at boot or None."""
m = re.search(r"sec\s*=\s*(\d+)", text)
if not m:
return None
try:
return int(m.group(1))
except ValueError:
return None
def check_cis_worksheet_pointer(ctx: RunContext) -> list[Finding]:
return [
Finding(
id="cis-000",
title="CIS macOS — see worksheet for row-level results",
category="Compliance",
severity=Severity.INFORMATIONAL,
description=(
"CIS-aligned indicators collected by ApplePY are written to the **CIS** worksheet in "
"`findings.xlsx`. Use that tab for sortable, filterable control signals (for example "
"FileVault and password policy)."
),
evidence="Worksheet name: CIS",
worksheet="CIS",
mitre_techniques=(),
risk="Low — this item is an index pointer, not a control failure.",
impact="None directly; ensures reviewers open the correct worksheet.",
remediation="Open `findings.xlsx`, select the CIS tab, and triage rows by severity.",
references=("https://www.cisecurity.org/",),
)
]
def check_nist_mscp_status(ctx: RunContext) -> list[Finding]:
root = resolve_mscp_root(ctx.home, Path.cwd())
if not root:
return [
Finding(
id="nist-002",
title="NIST mSCP repository not found locally",
category="Compliance",
severity=Severity.INFORMATIONAL,
description=(
"ApplePY did not find a `macos_security` clone with `baselines/` and "
"`scripts/generate_guidance.py`. Without it, automated mSCP compliance scripts "
"cannot be generated or executed from this host."
),
evidence=(
"Checked APPLEPY_MACOS_SECURITY_ROOT, bundled applepy/data/macos_security (if populated "
"at build time), ./vendor/macos_security, ./macos_security, ~/src/macos_security, "
"~/Projects/macos_security. For library use, run `applepy --bootstrap-compliance` to "
"clone into ./vendor (requires git), or run scripts/vendor_compliance_assets.sh before "
"packaging."
),
worksheet="Compliance",
mitre_techniques=(),
risk="Assessment coverage gap for NIST SP 800-219 style baselines.",
impact="mSCP rule checks are not run automatically until a clone is available.",
remediation=(
"Clone https://github.com/usnistgov/macos_security (prefer the OS-matching branch), "
"set APPLEPY_MACOS_SECURITY_ROOT to that path, optionally APPLEPY_MSCP_BASELINE to a "
"YAML name under baselines/, then re-run with sudo so the privileged phase can "
"generate (unless skipped) and run the compliance script."
),
references=(
"https://github.com/usnistgov/macos_security",
"https://pages.nist.gov/macos_security/",
),
)
]
baseline = pick_baseline_yaml(root)
major = _macos_major_version()
name = _macos_name()
lines = [f"mSCP root: {root.resolve()}"]
lines.append(f"Running: {name} (macOS {major})" if major else "Running: macOS version unknown")
if major and major != _BUNDLED_MSCP_MACOS_MAJOR:
lines.append(
f"WARNING: bundled data targets macOS {_BUNDLED_MSCP_MACOS_MAJOR}; "
f"set APPLEPY_MACOS_SECURITY_ROOT to a version-matched clone for accurate results."
)
if baseline:
lines.append(f"Selected baseline: {baseline}")
else:
lines.append("No baseline YAML found under baselines/.")
script = find_generated_compliance_script(root, baseline.stem) if baseline else None
if script:
lines.append(f"Existing compliance script: {script}")
return [
Finding(
id="nist-001",
title="NIST mSCP repository detected",
category="Compliance",
severity=Severity.INFORMATIONAL,
description=(
"A macOS Security Compliance Project layout is present. The privileged phase regenerates "
"the compliance shell script on **this host** via `scripts/generate_guidance.py -s` "
"(unless APPLEPY_MSCP_SKIP_GENERATE=1), then runs `*_compliance.sh --check`. "
"**PyInstaller builds** re-invoke the same `applepy` binary with bundled PyYAML/xlwt so a "
"separate system Python install is not required; library installs use your venv interpreter "
"or APPLEPY_PYTHON when APPLEPY_MSCP_FORCE_SUBPROCESS=1."
),
evidence="\n".join(lines),
worksheet="Compliance",
mitre_techniques=(),
risk="Depends on baseline; non-compliance highlights configuration drift.",
impact="Failed or partial runs reduce assurance until dependencies and branch match the host OS.",
remediation=(
"Install mSCP script prerequisites (see upstream README), match the repository branch "
"to your macOS version, then run `sudo applepy` from a directory that can resolve the clone."
),
references=(
"https://github.com/usnistgov/macos_security/wiki/Compliance-Script",
"https://csrc.nist.gov/pubs/sp/800/219/r1/final",
),
)
]
def check_mscp_audit_privileged(ctx: RunContext) -> list[Finding]:
if not ctx.is_root():
return []
root = resolve_mscp_root(ctx.home, Path.cwd())
if not root:
return []
findings: list[Finding] = []
baseline = pick_baseline_yaml(root)
if not baseline:
findings.append(
Finding(
id="nist-003",
title="mSCP baselines directory empty or unreadable",
category="Compliance",
severity=Severity.MEDIUM,
description="Cannot select a baseline YAML under baselines/.",
evidence=str(root / "baselines"),
worksheet="Compliance",
mitre_techniques=("T1012", "T1562.001"),
remediation="Add baseline YAML files from the mSCP repository or set APPLEPY_MSCP_BASELINE.",
)
)
return findings
stem = baseline.stem
skip_gen = os.environ.get("APPLEPY_MSCP_SKIP_GENERATE", "").strip() in ("1", "true", "yes")
if not skip_gen:
code, out, err = run_generate_guidance(root, baseline)
out_c = _cap_mscp_transcript("stdout", out)
err_c = _cap_mscp_transcript("stderr", err)
blob = f"exit={code}\n--- stdout ---\n{out_c}\n--- stderr ---\n{err_c}"
sev = Severity.INFORMATIONAL if code == 0 else Severity.HIGH
findings.append(
Finding(
id="nist-004",
title="mSCP generate_guidance.py (-s compliance script)",
category="Compliance",
severity=sev,
description="Regenerated the compliance shell script from the selected baseline.",
evidence=blob,
worksheet="Compliance",
risk="Generation failure prevents an automated audit run.",
impact="No fresh compliance script; you may still run a pre-built script if present.",
remediation=(
"Confirm the embedded generator path: frozen ApplePY bundles mSCP script dependencies; "
"if you forced subprocess mode, install PyYAML and xlwt into APPLEPY_PYTHON. "
"Match the macos_security branch to the host OS per NIST guidance."
),
references=("https://github.com/usnistgov/macos_security",),
)
)
script = find_generated_compliance_script(root, stem)
if not script:
findings.append(
Finding(
id="nist-005",
title="mSCP compliance script not found after generation",
category="Compliance",
severity=Severity.HIGH,
description="Expected `build/<baseline>/<baseline>_compliance.sh` missing.",
evidence=f"Baseline stem: {stem}",
worksheet="Compliance",
mitre_techniques=("T1012", "T1562.001"),
remediation="Fix generate_guidance errors (nist-004) or run it manually upstream.",
)
)
return findings
code, out, err = run_compliance_script(script)
out_c = _cap_mscp_transcript("stdout", out)
err_c = _cap_mscp_transcript("stderr", err)
blob = (
f"script={script}\nexit={code}\n--- stdout ---\n{out_c}\n--- stderr ---\n{err_c}"
)
sev = Severity.INFORMATIONAL if code == 0 else Severity.MEDIUM
findings.append(
Finding(
id="nist-006",
title="mSCP compliance script audit (--check)",
category="Compliance",
severity=sev,
description=(
"Executed the generated mSCP compliance script with `--check`. Raw console output is "
"retained for traceability; per-rule results are also parsed from the audit plist when present."
),
evidence=blob,
worksheet="Compliance",
mitre_techniques=("T1012", "T1562.001"),
risk="Non-zero exit or FAIL lines indicate control drift versus the chosen baseline.",
impact="Organisation policy may be out of alignment with the selected mSCP profile.",
remediation="Remediate failing checks per mSCP guidance or adjust the baseline to match policy.",
references=("https://github.com/usnistgov/macos_security/wiki/Compliance-Script",),
)
)
plist_path = audit_plist_path(APPLEPY_MSCP_AUDIT_NAME)
findings.extend(parse_audit_plist(plist_path))
# Clean up artefacts so no root-owned files persist outside the output directory.
_cleanup_mscp_artefacts(root, plist_path)
return findings
def _cleanup_mscp_artefacts(mscp_root: Path, audit_plist: Path) -> None:
"""Remove the mSCP build directory and audit plist created during this run."""
build_dir = mscp_root / "build"
if build_dir.is_dir():
try:
shutil.rmtree(build_dir)
_log.debug("Removed mSCP build artefacts: %s", build_dir)
except OSError as e:
_log.warning("Could not remove mSCP build dir %s: %s", build_dir, e)
if audit_plist.is_file():
try:
audit_plist.unlink()
_log.debug("Removed mSCP audit plist: %s", audit_plist)
except OSError as e:
_log.warning("Could not remove audit plist %s: %s", audit_plist, e)
def check_boot_uptime(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/sbin/sysctl", "-n", "kern.boottime"], timeout=10)
raw = (out + err).strip()
boot_sec = parse_kern_boottime_sec(raw)
if boot_sec is None:
return [
Finding(
id="cis-005",
title="System boot time (kern.boottime) unavailable",
category="Compliance",
severity=Severity.LOW,
description="Could not parse kern.boottime from sysctl; uptime metrics are unavailable.",
evidence=f"exit={code}\n{raw}",
worksheet="CIS",
mitre_techniques=("T1082",),
remediation="Confirm sysctl access on this host; review uptime via System Information if needed.",
)
]
now = int(time.time())
age_s = max(0, now - boot_sec)
days = age_s / 86400.0
hours = age_s / 3600.0
ev = f"boot_unix={boot_sec}\napprox_uptime_days={days:.2f}\napprox_uptime_hours={hours:.1f}\nraw={raw}"
sev = Severity.INFORMATIONAL
if days >= 90:
sev = Severity.LOW
return [
Finding(
id="cis-005",
title="System uptime since last boot",
category="Compliance",
severity=sev,
description=(
"Derived from `sysctl kern.boottime`. Long continuous uptime may delay kernel and security "
"updates that require a restart; correlate with organisational patch and reboot policy."
),
evidence=ev,
worksheet="CIS",
mitre_techniques=("T1082",),
risk="Extended uptime can defer reboot-dependent fixes.",
impact="Known vulnerabilities addressed only after restart may remain exploitable longer.",
remediation="Schedule maintenance reboots per policy after critical updates.",
references=(
"https://support.kandji.io/kb/restart-after-x-number-of-days-of-continuous-uptime",
),
)
]
def check_timemachine_destinations(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/tmutil", "destinationinfo"], timeout=20)
blob = (out + err).strip()
lowered = blob.lower()
if code != 0 and not blob:
return [
Finding(
id="cis-006",
title="Time Machine destinations (tmutil)",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="tmutil did not return destination information (Time Machine may be off or unconfigured).",
evidence=f"exit={code}\n{blob or '(no output)'}",
worksheet="CIS",
mitre_techniques=("T1082",),
remediation="If backups are required, configure Time Machine or enterprise backup per policy.",
references=(
"https://support.kandji.io/kb/monitor-encryption-status-of-time-machine-volumes",
),
)
]
enc_hint = "encrypted" in lowered or "encryption" in lowered
sev = Severity.INFORMATIONAL
if code == 0 and blob and not enc_hint and "name" in lowered:
sev = Severity.LOW
return [
Finding(
id="cis-006",
title="Time Machine destinations (tmutil destinationinfo)",
category="Compliance",
severity=sev,
description=(
"Time Machine target summary from `tmutil destinationinfo`. Review whether backup volumes "
"should be encrypted at rest per organisational standard."
),
evidence=f"exit={code}\n{blob}",
worksheet="CIS",
mitre_techniques=("T1082", "T1565"),
risk="Unencrypted backup media can expand impact if physical media is lost.",
impact="Historical file content may be recoverable from backup targets.",
remediation="Use encrypted backup destinations and protect backup credentials.",
references=(
"https://support.kandji.io/kb/monitor-encryption-status-of-time-machine-volumes",
),
)
]
def check_admin_group_membership(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(
["/usr/bin/dscl", ".", "-read", "/Groups/admin", "GroupMembership"],
timeout=15,
)
text = (out + err).strip()
if code != 0 or "GroupMembership" not in text:
return [
Finding(
id="cis-007",
title="Local admin group membership (dscl)",
category="Compliance",
severity=Severity.LOW,
description="Could not read /Groups/admin GroupMembership via dscl.",
evidence=f"exit={code}\n{text}",
worksheet="CIS",
mitre_techniques=("T1087",),
remediation="Re-run on macOS with Directory Service available; review local accounts manually.",
)
]
members: list[str] = []
for line in text.splitlines():
line = line.strip()
if line.startswith("GroupMembership:"):
rest = line.split(":", 1)[1].strip()
if rest:
members.extend(rest.split())
uniq = sorted(set(m for m in members if m))
ev = "Admin group members: " + (", ".join(uniq) if uniq else "(none parsed)")
sev = Severity.INFORMATIONAL
if len(uniq) > 3:
sev = Severity.LOW
return [
Finding(
id="cis-007",
title="Local administrator accounts (admin group)",
category="Compliance",
severity=sev,
description=(
"Accounts in the local `admin` group can authorise privileged actions. Excess membership "
"increases insider and credential-theft risk."
),
evidence=ev,
worksheet="CIS",
mitre_techniques=("T1078", "T1087"),
risk="Too many admins weaken least-privilege and expand lateral movement impact.",
impact="Compromise of any admin account may lead to full system control.",
remediation="Demote users to standard accounts where possible; use MDM for tasks requiring elevation.",
references=(
"https://support.kandji.io/kb/demote-user-accounts-to-standard",
"https://support.kandji.io/kb/create-user-accounts",
),
)
]
def check_default_interactive_umask(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/bin/sh", "-c", "umask"], timeout=5)
text = (out + err).strip() or "(no output)"
sev = Severity.INFORMATIONAL
digits = "".join(c for c in text if c.isdigit())
if digits in ("000", "0000") or text.strip() in ("0", "000"):
sev = Severity.LOW
return [
Finding(
id="cis-008",
title="Default shell umask (non-login sh)",
category="Compliance",
severity=sev,
description=(
"Reports `umask` from `/bin/sh -c` for a quick POSIX default (Kandji umask policy alignment). "
"Does not reflect every user shell profile."
),
evidence=f"exit={code}\n{text}",
worksheet="CIS",
mitre_techniques=("T1222",),
risk="Weak umask values yield group- or world-readable new files.",
impact="Sensitive artefacts written by scripts may become readable by other local users.",
remediation="Set umask in launchd, profiles, or shell RC files per organisational standard.",
references=("https://support.kandji.io/kb/setting-umask-for-all-users",),
)
]
def check_filevault(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/fdesetup", "status"])
fv = (out + err).strip()
return [
Finding(
id="cis-001",
title="FileVault status (fdesetup)",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="CIS-relevant full-disk encryption posture from `fdesetup status`.",
evidence=fv,
worksheet="CIS",
mitre_techniques=("T1012",),
risk="Disabled FileVault increases impact of physical loss or offline disk access.",
impact="Data at rest may be readable without the user password.",
remediation="Enable FileVault via System Settings or MDM; escrow recovery keys per policy.",
references=("https://support.apple.com/en-gb/guide/deployment/dep82064ec40/web",),
)
]
def check_pwpolicy(ctx: RunContext) -> list[Finding]:
if not ctx.is_root():
return []
pw_policy = Path("/etc/pwpolicy.plist")
if pw_policy.is_file():
try:
sz = pw_policy.stat().st_size
return [
Finding(
id="cis-002",
title="Password policy plist present",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="/etc/pwpolicy.plist exists; compare contents to organisation standards.",
evidence=f"File size {sz} bytes at {pw_policy}",
worksheet="CIS",
mitre_techniques=("T1201",),
remediation="Validate complexity, history, and lockout settings against CIS/NIST policy.",
)
]
except OSError as e:
return [
Finding(
id="cis-003",
title="Password policy plist unreadable",
category="Compliance",
severity=Severity.MEDIUM,
description="Could not stat /etc/pwpolicy.plist.",
evidence=str(e),
worksheet="CIS",
remediation="Re-run with appropriate permissions or investigate file system issues.",
)
]
return [
Finding(
id="cis-004",
title="No /etc/pwpolicy.plist",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="Password policy file absent; policy may be delivered via MDM or directory services.",
evidence="Not found",
worksheet="CIS",
remediation="Confirm password requirements via MDM baselines if local plist is absent.",
)
]
def _resolve_lynis_executable() -> str | None:
code, wh_out, _ = run_text(["/usr/bin/which", "lynis"], timeout=5)
path = (wh_out.strip().splitlines()[-1] if wh_out.strip() else "") or ""
if path and "not found" not in path:
return path
bundled = lynis_bundled_executable()
if bundled is not None:
return str(bundled.resolve())
vend = lynis_clone_path(Path.cwd()) / "lynis"
if vend.is_file():
return str(vend.resolve())
return None
def check_lynis_run(ctx: RunContext) -> list[Finding]:
path = _resolve_lynis_executable()
if not path:
return [
Finding(
id="lynis-001",
title="Lynis not installed",
category="Compliance",
severity=Severity.INFORMATIONAL,
description=(
"Lynis was not found on PATH, no bundled copy under applepy/data/lynis, and "
"`./vendor/lynis/lynis` is absent. For packaged builds run "
"`scripts/vendor_compliance_assets.sh` before PyInstaller; for library use run "
"`applepy --bootstrap-compliance` or install Lynis via your package manager "
"(upstream is GPL)."
),
evidence="which lynis: not found; bundled and vendor/lynis/lynis absent",
worksheet="Compliance",
remediation=(
"Vendor Lynis at build time, run `applepy --bootstrap-compliance`, or install Lynis, "
"then re-run."
),
references=("https://github.com/cisofy/lynis",),
)
]
lynis_root = str(Path(path).resolve().parent)
code, out, err = run_text(
[path, "audit", "system", "--quick", "--no-colors"],
timeout=400,
cwd=lynis_root,
)
blob = (out + err).strip()
sev = Severity.INFORMATIONAL if code == 0 else Severity.LOW
ev_body = _truncate_lynis_evidence(blob) if blob else f"exit={code}, no output"
return [
Finding(
id="lynis-002",
title="Lynis quick system audit — captured output",
category="Compliance",
severity=sev,
description=(
"CISOFY Lynis was executed in **quick** mode (`audit system --quick --no-colors`) to sample "
"local hardening posture. Use the excerpt under Evidence as a triage hook: correlate `[WARNING]` "
"and suggestion IDs with your baseline, then validate material items outside this automated pass."
),
evidence=f"lynis_binary={path}\nexit_code={code}\n\n{ev_body}",
worksheet="Compliance",
risk="Undocumented deviations from Lynis recommendations may leave exploitable configuration drift.",
impact="Severity follows Lynis own hints; unreviewed warnings can mask missing controls or unsafe defaults.",
remediation=(
"Work through Lynis suggestions with change control, or record formal risk acceptance where "
"deviation is intentional."
),
references=("https://github.com/cisofy/lynis",),
)
]

269
applepy/checks/core.py Normal file
View File

@@ -0,0 +1,269 @@
"""Core macOS posture and enumeration (common-paths-aligned baseline)."""
from __future__ import annotations
import os
import platform
import time
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
def register(registry: CheckRegistry) -> None:
registry.register("core_platform", check_platform, phases=("unprivileged",))
registry.register("core_csrutil", check_csrutil, phases=("unprivileged",))
registry.register("core_gatekeeper", check_gatekeeper, phases=("unprivileged",))
registry.register("core_launchd_user", check_launchd_user, phases=("unprivileged",))
registry.register("core_launchd_system", check_launchd_system, phases=("privileged",))
registry.register("core_interpreters", check_interpreters, phases=("unprivileged",))
registry.register("core_firewall", check_application_firewall, phases=("unprivileged",))
registry.register("core_sys_extensions", check_system_extensions, phases=("unprivileged",))
def check_platform(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="core-001",
title="Not running on macOS",
category="Core",
severity=Severity.HIGH,
description="ApplePY targets Darwin; results may be meaningless on this platform.",
evidence=f"platform.system()={platform.system()!r}",
worksheet="Core",
mitre_techniques=("T1082",),
)
]
ver = platform.mac_ver()
evidence = f"mac_ver={ver}, machine={platform.machine()}, python={platform.python_version()}"
return [
Finding(
id="core-002",
title="Platform identification",
category="Core",
severity=Severity.INFORMATIONAL,
description="Host software identification for scoping the engagement.",
evidence=evidence,
worksheet="Core",
mitre_techniques=("T1082",),
)
]
def check_csrutil(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/csrutil", "status"])
text = (out + err).strip() or "(no output)"
sev = Severity.INFORMATIONAL
if "disabled" in text.lower():
sev = Severity.HIGH
elif "unknown" in text.lower():
sev = Severity.MEDIUM
return [
Finding(
id="core-003",
title="System Integrity Protection (SIP) status",
category="Core",
severity=sev,
description="csrutil reports whether SIP is enabled — disabled SIP increases persistence and tampering risk.",
evidence=text,
worksheet="Core",
mitre_techniques=("T1548.001", "T1562.001"),
)
]
def check_gatekeeper(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/sbin/spctl", "--status"])
text = (out + err).strip() or "(no output)"
return [
Finding(
id="core-004",
title="Gatekeeper (assessment mode)",
category="Core",
severity=Severity.INFORMATIONAL,
description="spctl --status indicates developer ID / notarisation enforcement posture.",
evidence=f"exit={code}, {text}",
worksheet="Core",
mitre_techniques=("T1553.001",),
)
]
def _parse_alf_globalstate(stdout_err: str, exit_code: int) -> tuple[int | None, str]:
"""Return (globalstate int or None, human note)."""
text = stdout_err.strip()
low = text.lower()
if exit_code != 0 and ("does not exist" in low or "could not find" in low or "no value" in low):
return None, "preference_missing_or_unreadable"
for line in text.splitlines():
s = line.strip()
if s.isdigit():
return int(s), "ok"
if text.isdigit():
return int(text), "ok"
return None, "unparsed_output"
def check_application_firewall(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(
["/usr/bin/defaults", "read", "/Library/Preferences/com.apple.alf", "globalstate"],
timeout=10,
)
blob = (out + err).strip()
state, note = _parse_alf_globalstate(out + err, code)
# 0 = off, 1 = block incoming except signed, 2 = stealth / stricter (Apple docs vary by OS).
sev = Severity.INFORMATIONAL
if state == 0:
sev = Severity.MEDIUM
elif state is None and note == "preference_missing_or_unreadable":
sev = Severity.LOW
interp = (
"Application Firewall globalstate from com.apple.alf (0 typically off; non-zero indicates "
"some blocking mode — confirm with organisational baseline)."
)
return [
Finding(
id="core-008",
title="Application Firewall (com.apple.alf globalstate)",
category="Core",
severity=sev,
description=interp,
evidence=f"exit={code}\nparsed_state={state!r}\nparse_note={note}\nraw=\n{blob}",
worksheet="Core",
mitre_techniques=("T1562.004", "T1012"),
risk="A disabled firewall increases exposure on untrusted networks.",
impact="Unwanted inbound connections may succeed if other controls are absent.",
remediation="Enable Application Firewall via System Settings or MDM; document exceptions.",
references=("https://support.apple.com/guide/mac-help/",),
)
]
def check_system_extensions(ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/systemextensionsctl", "list"], timeout=30)
blob = (out + err).strip()
sev = Severity.INFORMATIONAL if code == 0 else Severity.LOW
return [
Finding(
id="core-009",
title="System extensions (systemextensionsctl list)",
category="Core",
severity=sev,
description=(
"Enumerates approved system extensions and driver extensions (read-only). "
"Unexpected vendors warrant review alongside MDM allow-lists."
),
evidence=f"exit={code}\n{blob}" if blob else f"exit={code}, (no output)",
worksheet="Core",
mitre_techniques=("T1012", "T1547"),
risk="Malicious or vulnerable extensions undermine kernel/trust boundaries.",
impact="Persistence, monitoring bypass, or instability depending on extension type.",
remediation="Compare to MDM-approved lists; remove unknown or deprecated extensions.",
references=("https://support.apple.com/guide/security/",),
)
]
def _list_plists(directory: Path) -> str:
if not directory.is_dir():
return f"(missing or not a directory: {directory})"
names = sorted(p.name for p in directory.glob("*.plist") if p.is_file())
return "\n".join(names) if names else "(no plist files)"
def check_launchd_user(ctx: RunContext) -> list[Finding]:
la = ctx.home / "Library" / "LaunchAgents"
da = ctx.home / "Library" / "LaunchDaemons"
ev = (
f"LaunchAgents ({la}):\n{_list_plists(la)}\n\n"
f"LaunchDaemons user ({da}):\n{_list_plists(da)}"
)
return [
Finding(
id="core-005",
title="User LaunchAgents / LaunchDaemons plists",
category="Core",
severity=Severity.INFORMATIONAL,
description="Per-user persistence locations commonly abused for login items and agents.",
evidence=ev,
worksheet="Persistence",
mitre_techniques=("T1543.001", "T1543.004"),
)
]
def check_launchd_system(ctx: RunContext) -> list[Finding]:
if not ctx.is_root():
return []
system = Path("/Library/LaunchDaemons")
agents = Path("/Library/LaunchAgents")
ev = f"/Library/LaunchDaemons:\n{_list_plists(system)}\n\n/Library/LaunchAgents:\n{_list_plists(agents)}"
return [
Finding(
id="core-006",
title="System LaunchDaemons / LaunchAgents plists",
category="Core",
severity=Severity.INFORMATIONAL,
description="System-wide persistence; review unfamiliar labels and ProgramArguments.",
evidence=ev,
worksheet="Persistence",
mitre_techniques=("T1543.001", "T1543.004"),
)
]
def _which(name: str) -> str | None:
path_env = os.environ.get("PATH", "/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/homebrew/bin")
for d in path_env.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())
for extra in ("/opt/homebrew/bin", "/usr/local/bin"):
p = Path(extra) / name
if p.is_file() and os.access(p, os.X_OK):
return str(p.resolve())
return None
def check_interpreters(ctx: RunContext) -> list[Finding]:
names = ["python3", "python", "ruby", "perl", "node"]
lines: list[str] = []
now = time.time()
for n in names:
p = _which(n)
if not p:
continue
if n.startswith("python"):
try:
st = Path(p).stat()
if (now - st.st_mtime) / 3600.0 < 48:
lines.append(
f"{n}: {p} (mtime within 48h — excluded from interpreter tally per policy)"
)
continue
except OSError as e:
lines.append(f"{n}: {p} (could not read mtime for 48h rule: {e})")
continue
lines.append(f"{n}: {p}")
if not lines:
lines.append("(no common interpreters found on PATH)")
return [
Finding(
id="core-007",
title="Scripting interpreters present",
category="Core",
severity=Severity.INFORMATIONAL,
description="Common scripting runtimes on PATH; Python entries within 48h filesystem mtime are excluded from reporting.",
evidence="\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1059.004", "T1059.006", "T1059.007"),
)
]

View File

@@ -0,0 +1,390 @@
"""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"),
)
]

View File

@@ -0,0 +1,709 @@
"""Dylib hijacking detection checks for macOS."""
from __future__ import annotations
import os
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
_MITRE_DYLIB = ("T1574.006",)
_MITRE_DYLIB_RPATH = ("T1574.006", "T1574.007")
_WORKSHEET = "Hardening"
_CATEGORY = "Hardening"
def register(registry: CheckRegistry) -> None:
registry.register("dylib_writable_dirs", dylib_writable_dirs, phases=("unprivileged",))
registry.register("dylib_dyld_env_vars", dylib_dyld_env_vars, phases=("unprivileged",))
registry.register("dylib_world_writable", dylib_world_writable, phases=("unprivileged",))
registry.register("dylib_rpath_hijack", dylib_rpath_hijack, phases=("unprivileged",))
registry.register("dylib_missing_dylibs", dylib_missing_dylibs, phases=("unprivileged",))
# ---------------------------------------------------------------------------
# dylib-001: Writable dylib search directories
# ---------------------------------------------------------------------------
def _is_root_owned(p: Path) -> bool:
try:
return p.stat().st_uid == 0
except OSError:
return False
def dylib_writable_dirs(ctx: RunContext) -> list[Finding]:
"""Check if any common dylib search directories are user-writable."""
running_as_root = os.geteuid() == 0
candidate_paths = [
Path("/usr/local/lib"),
Path("/opt/homebrew/lib"),
Path("/opt/local/lib"),
Path("/Library/Frameworks"),
ctx.home / "Library" / "Frameworks",
ctx.home / "lib",
]
writable: list[str] = []
checked: list[str] = []
for p in candidate_paths:
try:
exists = p.exists()
except OSError as e:
checked.append(f"{p}: error checking existence: {e}")
continue
if not exists:
checked.append(f"{p}: does not exist")
continue
try:
w = os.access(p, os.W_OK)
except OSError as e:
checked.append(f"{p}: error checking writability: {e}")
continue
if w:
if running_as_root and _is_root_owned(p):
# Root-owned dirs appear writable when running as root — not a hijack risk
checked.append(f"{p}: writable (root-owned; expected when running as sudo)")
else:
writable.append(str(p))
checked.append(f"{p}: WRITABLE")
else:
checked.append(f"{p}: not writable")
sev = Severity.HIGH if writable else Severity.INFORMATIONAL
title = (
f"Writable dylib search directories found ({len(writable)})"
if writable
else "No user-writable dylib search directories found"
)
desc = (
"Common dynamic library search directories were checked for write access by the current "
"process. A user-writable directory in the dylib search path is hijacking-ready: an "
"attacker (or malware) can plant a malicious library that gets loaded in preference to "
"the legitimate one."
)
evidence = "\n".join(checked) if checked else "(no paths checked)"
return [
Finding(
id="dylib-001",
title=title,
category=_CATEGORY,
severity=sev,
description=desc,
evidence=evidence,
worksheet=_WORKSHEET,
mitre_techniques=_MITRE_DYLIB,
risk=(
"A writable dylib search path allows a local attacker to load arbitrary code into "
"processes that search that directory."
),
impact=(
"Privilege escalation, persistence, or credential theft depending on which processes "
"load libraries from the affected path."
),
remediation=(
"Ensure /usr/local/lib and Homebrew/MacPorts library roots are owned by root and not "
"group- or world-writable. Review install scripts that relax permissions."
),
references=(
"https://attack.mitre.org/techniques/T1574/006/",
"https://www.synacktiv.com/en/publications/macos-dylib-hijacking",
),
)
]
# ---------------------------------------------------------------------------
# dylib-002: DYLD environment variables in shell startup files
# ---------------------------------------------------------------------------
_DYLD_VARS = (
"DYLD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_FALLBACK_LIBRARY_PATH",
)
_SHELL_STARTUP_FILES = (
"~/.zshrc",
"~/.zshenv",
"~/.bash_profile",
"~/.bashrc",
"~/.profile",
"/etc/profile",
"/etc/zshrc",
"/etc/zprofile",
)
_MAX_FILE_BYTES = 65536
def dylib_dyld_env_vars(ctx: RunContext) -> list[Finding]:
"""Scan shell startup files for dangerous DYLD_* environment variable assignments."""
hits: list[str] = []
scanned: list[str] = []
for raw_path in _SHELL_STARTUP_FILES:
p = Path(os.path.expanduser(raw_path))
try:
if not p.is_file():
scanned.append(f"{raw_path}: not present")
continue
content = p.read_bytes()[:_MAX_FILE_BYTES].decode("utf-8", errors="replace")
except OSError as e:
scanned.append(f"{raw_path}: read error: {e}")
continue
scanned.append(f"{raw_path}: scanned ({len(content)} chars)")
for lineno, line in enumerate(content.splitlines(), start=1):
stripped = line.strip()
if not stripped or stripped.startswith("#"):
continue
for var in _DYLD_VARS:
if var in line:
hits.append(f"{raw_path}:{lineno}: {stripped}")
break # one hit per line is enough
sev = Severity.MEDIUM if hits else Severity.INFORMATIONAL
title = (
f"DYLD environment variable(s) found in shell startup files ({len(hits)} hit(s))"
if hits
else "No DYLD_* environment variables found in shell startup files"
)
desc = (
"Shell startup files were scanned for DYLD_LIBRARY_PATH, DYLD_INSERT_LIBRARIES, and "
"DYLD_FALLBACK_LIBRARY_PATH. These variables influence the dynamic linker search order "
"and can be leveraged to inject arbitrary dylibs into every process launched from the "
"affected shell. While macOS strips them for hardened-runtime binaries, they remain "
"effective against non-hardened executables."
)
evidence_parts = ["--- Hits ---"]
evidence_parts.extend(hits if hits else ["(none)"])
evidence_parts.append("\n--- Files scanned ---")
evidence_parts.extend(scanned)
evidence = "\n".join(evidence_parts)
return [
Finding(
id="dylib-002",
title=title,
category=_CATEGORY,
severity=sev,
description=desc,
evidence=evidence,
worksheet=_WORKSHEET,
mitre_techniques=_MITRE_DYLIB_RPATH,
risk=(
"DYLD injection variables affect all non-hardened processes spawned from that shell, "
"enabling code injection without modifying any binary."
),
impact=(
"An attacker with local access can achieve persistent code injection by setting these "
"variables in startup files."
),
remediation=(
"Remove DYLD_* variable assignments from shell startup files unless absolutely required "
"for a specific, documented development workflow. Never set them system-wide in /etc."
),
references=(
"https://attack.mitre.org/techniques/T1574/006/",
"https://attack.mitre.org/techniques/T1574/007/",
"https://developer.apple.com/library/archive/documentation/DeveloperTools/Conceptual/"
"DynamicLibraries/100-Articles/UsingDynamicLibraries.html",
),
)
]
# ---------------------------------------------------------------------------
# dylib-003: World-writable .dylib / .so files
# ---------------------------------------------------------------------------
_DYLIB_SCAN_ROOTS = (
Path("/usr/local/lib"),
Path("/opt/homebrew/lib"),
Path("/Library/Frameworks"),
Path("/System/Library"),
)
_FILE_CAP = 500
def dylib_world_writable(ctx: RunContext) -> list[Finding]:
"""Find world-writable .dylib and .so files in standard library locations."""
world_writable: list[str] = []
scanned_count = 0
for root in _DYLIB_SCAN_ROOTS:
if not root.is_dir():
continue
# /System/Library is huge — limit depth to 2 levels
is_system = root == Path("/System/Library")
for ext in ("*.dylib", "*.so"):
if scanned_count >= _FILE_CAP:
break
if is_system:
# Only one level of glob (no recursion)
try:
candidates = list(root.glob(ext))
for sub in root.iterdir():
if sub.is_dir():
try:
candidates.extend(sub.glob(ext))
except OSError:
pass
except OSError:
candidates = []
else:
try:
candidates = list(root.glob(f"**/{ext}"))
except OSError:
candidates = []
for p in candidates:
if scanned_count >= _FILE_CAP:
break
scanned_count += 1
try:
mode = p.stat().st_mode
if mode & 0o002:
world_writable.append(str(p))
except OSError:
pass
sev = Severity.HIGH if world_writable else Severity.INFORMATIONAL
title = (
f"World-writable dylib/so files found ({len(world_writable)})"
if world_writable
else "No world-writable dylib/so files found"
)
desc = (
"Dynamic library files (.dylib, .so) in standard library directories were checked for "
"world-writable permissions (mode & 0o002). Any world-writable library can be overwritten "
"by any local user, leading to immediate code execution in the context of any process that "
"loads that library."
)
evidence = (
"\n".join(world_writable)
if world_writable
else f"(none found; scanned up to {_FILE_CAP} files across {len(_DYLIB_SCAN_ROOTS)} roots)"
)
evidence += f"\n\nTotal files scanned: {scanned_count}"
return [
Finding(
id="dylib-003",
title=title,
category=_CATEGORY,
severity=sev,
description=desc,
evidence=evidence,
worksheet=_WORKSHEET,
mitre_techniques=_MITRE_DYLIB,
risk=(
"A world-writable library is an immediate privilege-escalation primitive: any local "
"user can inject code that runs in any process (including privileged ones) that loads it."
),
impact="Arbitrary code execution in the security context of any process loading the library.",
remediation=(
"Run `chmod o-w <path>` on each identified file and audit the process that created it. "
"Libraries should be owned root:wheel with mode 0755 or stricter."
),
references=(
"https://attack.mitre.org/techniques/T1574/006/",
"https://ss64.com/osx/chmod.html",
),
)
]
# ---------------------------------------------------------------------------
# dylib-004: Rpath-based hijack risk
# ---------------------------------------------------------------------------
_RPATH_SCAN_DIRS = (
Path("/usr/local/bin"),
Path("/opt/homebrew/bin"),
)
_MAX_BINARIES = 30
_MAX_OTOOL_INVOCATIONS = 200
_SYSTEM_RPATH_PREFIXES = ("/usr/lib", "/System", "/Library/Apple", "/usr/AppleInternal")
def _is_system_rpath(rpath: str) -> bool:
return any(rpath.startswith(prefix) for prefix in _SYSTEM_RPATH_PREFIXES)
def _parse_rpaths(otool_output: str) -> list[str]:
"""Extract rpath values from `otool -l` output."""
rpaths: list[str] = []
in_lc_rpath = False
for line in otool_output.splitlines():
stripped = line.strip()
if "LC_RPATH" in stripped:
in_lc_rpath = True
continue
if in_lc_rpath:
if stripped.startswith("path "):
# e.g. "path /usr/local/lib (offset 12)"
parts = stripped.split()
if len(parts) >= 2:
rpaths.append(parts[1])
in_lc_rpath = False
elif stripped.startswith("cmd ") or stripped.startswith("Load command"):
in_lc_rpath = False
return rpaths
def _parse_rpath_dylibs(otool_l_output: str) -> list[str]:
"""Extract @rpath/-prefixed dylib names from `otool -L` output."""
refs: list[str] = []
for line in otool_l_output.splitlines():
stripped = line.strip()
if stripped.startswith("@rpath/"):
# Strip trailing " (compatibility version ...)"
name = stripped.split("(")[0].strip()
refs.append(name)
return refs
def dylib_rpath_hijack(ctx: RunContext) -> list[Finding]:
"""Sample rpath-based dylib hijack risk in /usr/local/bin and /opt/homebrew/bin."""
otool_count = 0
findings_data: list[tuple[str, str, list[str]]] = [] # (binary, rpath, writable_dylibs)
writable_rpath_no_refs: list[str] = []
for scan_dir in _RPATH_SCAN_DIRS:
if not scan_dir.is_dir():
continue
binaries_checked = 0
try:
candidates = sorted(p for p in scan_dir.iterdir() if p.is_file())
except OSError:
continue
for binary in candidates:
if binaries_checked >= _MAX_BINARIES:
break
if otool_count >= _MAX_OTOOL_INVOCATIONS:
break
# Skip symlinks that resolve outside the scan dir
if binary.is_symlink():
try:
resolved = binary.resolve()
if not str(resolved).startswith(str(scan_dir)):
continue
except OSError:
continue
# Get rpaths via otool -l
otool_count += 1
code_l, out_l, _ = run_text(["/usr/bin/otool", "-l", str(binary)], timeout=15)
rpaths = _parse_rpaths(out_l) if code_l == 0 else []
if not rpaths:
binaries_checked += 1
continue
# Get @rpath dylib references via otool -L
otool_count += 1
code_L, out_L, _ = run_text(["/usr/bin/otool", "-L", str(binary)], timeout=15)
rpath_dylib_refs = _parse_rpath_dylibs(out_L) if code_L == 0 else []
for rpath in rpaths:
if _is_system_rpath(rpath):
continue
# Expand @executable_path and @loader_path conservatively
if rpath.startswith("@"):
# Non-system relative rpath — check the binary's directory
rpath_resolved = str(binary.parent)
else:
rpath_resolved = rpath
try:
writable = os.access(rpath_resolved, os.W_OK)
except OSError:
writable = False
if writable:
if rpath_dylib_refs:
findings_data.append((str(binary), rpath, rpath_dylib_refs))
else:
writable_rpath_no_refs.append(f"{binary}: rpath={rpath}")
binaries_checked += 1
if findings_data:
sev = Severity.HIGH
title = f"Rpath hijack risk: writable rpath directories with @rpath dylib references ({len(findings_data)} case(s))"
lines: list[str] = []
for binary, rpath, dylibs in findings_data:
lines.append(f"Binary: {binary}")
lines.append(f" Writable rpath: {rpath}")
lines.append(f" @rpath dylibs: {', '.join(dylibs)}")
evidence = "\n".join(lines)
elif writable_rpath_no_refs:
sev = Severity.LOW
title = f"Rpath directories are user-writable but no @rpath dylib references found ({len(writable_rpath_no_refs)} binary/rpath pair(s))"
evidence = "\n".join(writable_rpath_no_refs)
else:
sev = Severity.INFORMATIONAL
title = "No rpath-based hijack risk found in sampled binaries"
evidence = (
f"Scanned up to {_MAX_BINARIES} binaries per directory in "
f"{', '.join(str(d) for d in _RPATH_SCAN_DIRS)}; "
f"{otool_count} otool invocations used (cap: {_MAX_OTOOL_INVOCATIONS})."
)
return [
Finding(
id="dylib-004",
title=title,
category=_CATEGORY,
severity=sev,
description=(
"Sampled binaries in /usr/local/bin and /opt/homebrew/bin for LC_RPATH entries. "
"For each non-system rpath, writability was tested. Binaries with both a writable "
"rpath directory and @rpath-prefixed dylib references represent a concrete hijack "
"opportunity: planting a dylib with the expected name in the writable rpath directory "
"causes it to be loaded at runtime."
),
evidence=evidence,
worksheet=_WORKSHEET,
mitre_techniques=_MITRE_DYLIB_RPATH,
risk=(
"A writable rpath directory combined with @rpath dylib usage lets any local user inject "
"arbitrary code into the affected process with no binary modification required."
),
impact=(
"Code execution in the security context of the binary; may include TCC entitlements, "
"network privileges, or other grants held by the binary."
),
remediation=(
"Ensure rpath directories are root-owned and not world-writable. For Homebrew and "
"user-installed tools, prefer system-wide installs or sign binaries with hardened runtime. "
"Review LC_RPATH entries with `otool -l <binary> | grep -A2 LC_RPATH`."
),
references=(
"https://attack.mitre.org/techniques/T1574/006/",
"https://attack.mitre.org/techniques/T1574/007/",
"https://www.mandiant.com/resources/blog/macos-dylib-hijacking",
),
)
]
# ---------------------------------------------------------------------------
# dylib-005: Missing dylibs in known hijacking targets
# ---------------------------------------------------------------------------
_TARGET_BINARIES = (
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
"/usr/local/bin/node",
"/opt/homebrew/bin/node",
"/usr/local/bin/ruby",
"/opt/homebrew/bin/ruby",
)
_MAX_BINARIES_MISSING = 10
_MAX_DYLIBS_PER_BINARY = 50
_NON_SYSTEM_PREFIXES = ("@rpath/", "/usr/local/", "/opt/homebrew/", "/opt/local/")
def _resolve_dylib_path(dep: str, binary_path: Path) -> Path | None:
"""Attempt to resolve a dylib dependency path to an absolute path."""
if dep.startswith("@rpath/"):
# Without knowing the actual rpath, we can't resolve @rpath; return None
return None
if dep.startswith("@executable_path/"):
rel = dep[len("@executable_path/"):]
return (binary_path.parent / rel).resolve()
if dep.startswith("@loader_path/"):
rel = dep[len("@loader_path/"):]
return (binary_path.parent / rel).resolve()
if dep.startswith("/"):
return Path(dep)
return None
def _is_non_system_dep(dep: str) -> bool:
return any(dep.startswith(prefix) for prefix in _NON_SYSTEM_PREFIXES)
def dylib_missing_dylibs(ctx: RunContext) -> list[Finding]:
"""Check for missing dylib dependencies in commonly-installed interpreter binaries."""
missing_and_writable: list[str] = []
missing_not_writable: list[str] = []
checked_summary: list[str] = []
seen_missing: set[tuple[str, str, str]] = set() # (binary, dep, rpath_abs)
binaries_checked = 0
for raw_binary in _TARGET_BINARIES:
if binaries_checked >= _MAX_BINARIES_MISSING:
break
binary_path = Path(raw_binary)
if not binary_path.is_file():
continue
binaries_checked += 1
code, out, _ = run_text(["/usr/bin/otool", "-L", str(binary_path)], timeout=15)
if code != 0:
checked_summary.append(f"{raw_binary}: otool failed (exit {code})")
continue
deps = []
for line in out.splitlines()[1:]: # skip first line (the binary itself)
stripped = line.strip()
if not stripped:
continue
dep_name = stripped.split("(")[0].strip()
if dep_name:
deps.append(dep_name)
deps = deps[:_MAX_DYLIBS_PER_BINARY]
checked_summary.append(f"{raw_binary}: {len(deps)} dep(s) examined")
for dep in deps:
if not _is_non_system_dep(dep):
continue
if dep.startswith("@rpath/"):
# Try to resolve by looking up rpaths for this binary
code_l, out_l, _ = run_text(["/usr/bin/otool", "-l", str(binary_path)], timeout=15)
rpaths = _parse_rpaths(out_l) if code_l == 0 else []
dylib_name = dep[len("@rpath/"):]
resolved_any = False
for rpath in rpaths:
if rpath.startswith("@"):
rpath_abs = str(binary_path.parent)
else:
rpath_abs = rpath
candidate = Path(rpath_abs) / dylib_name
if candidate.exists():
resolved_any = True
break
dedup_key = (raw_binary, dep, rpath_abs)
if dedup_key in seen_missing:
continue
seen_missing.add(dedup_key)
# Missing — check if containing dir is writable
try:
dir_writable = os.access(rpath_abs, os.W_OK)
except OSError:
dir_writable = False
if dir_writable:
missing_and_writable.append(
f"{raw_binary}: @rpath dep '{dep}' not found in rpath '{rpath_abs}' "
f"(directory is writable)"
)
else:
missing_not_writable.append(
f"{raw_binary}: @rpath dep '{dep}' not found in rpath '{rpath_abs}'"
)
if not rpaths and not resolved_any:
missing_not_writable.append(
f"{raw_binary}: @rpath dep '{dep}' — no rpaths found to resolve against"
)
continue
resolved = _resolve_dylib_path(dep, binary_path)
if resolved is None:
continue
try:
if resolved.exists():
continue
except OSError:
continue
# Missing — check if containing dir is writable
try:
dir_writable = os.access(str(resolved.parent), os.W_OK)
except OSError:
dir_writable = False
entry = f"{raw_binary}: dep '{dep}' resolved to '{resolved}' (missing)"
if dir_writable:
missing_and_writable.append(entry + " — parent dir writable")
else:
missing_not_writable.append(entry)
if missing_and_writable:
sev = Severity.HIGH
title = (
f"Missing dylib dependencies in writable locations ({len(missing_and_writable)} case(s))"
)
elif missing_not_writable:
sev = Severity.LOW
title = f"Missing dylib dependencies found (directories not writable; {len(missing_not_writable)} case(s))"
else:
sev = Severity.INFORMATIONAL
title = "No missing dylib dependencies found in sampled interpreter binaries"
desc = (
"Common interpreter binaries were scanned with `otool -L` to identify non-system dylib "
"dependencies. For each dependency with a non-system path (@rpath/, /usr/local/, "
"/opt/homebrew/), existence was verified. A missing dylib whose containing directory is "
"user-writable is an immediately exploitable hijacking opportunity."
)
evidence_parts: list[str] = []
if missing_and_writable:
evidence_parts.append("=== Missing + writable (HIGH risk) ===")
evidence_parts.extend(missing_and_writable)
if missing_not_writable:
evidence_parts.append("=== Missing (directory not writable) ===")
evidence_parts.extend(missing_not_writable)
evidence_parts.append("\n=== Binaries checked ===")
evidence_parts.extend(checked_summary if checked_summary else ["(none present)"])
evidence = "\n".join(evidence_parts)
return [
Finding(
id="dylib-005",
title=title,
category=_CATEGORY,
severity=sev,
description=desc,
evidence=evidence,
worksheet=_WORKSHEET,
mitre_techniques=_MITRE_DYLIB,
risk=(
"A missing dylib in a user-writable location is an immediately exploitable "
"hijacking vector: planting a file with the expected name causes it to be loaded "
"by the target binary with no further access required."
),
impact=(
"Code injection into high-value interpreter processes (Python, Node, Ruby) that may "
"hold TCC permissions or be used in privileged automation pipelines."
),
remediation=(
"Reinstall the affected package to restore missing libraries. Ensure library "
"directories are root-owned. Audit package manager state with `brew doctor` or "
"equivalent."
),
references=(
"https://attack.mitre.org/techniques/T1574/006/",
"https://www.synacktiv.com/en/publications/macos-dylib-hijacking",
"https://theevilbit.github.io/posts/dylib_hijacking_on_macos/",
),
)
]

126
applepy/checks/electron.py Normal file
View File

@@ -0,0 +1,126 @@
"""Electron desktop application surface: ASAR detection and install-directory write posture (no Node dependency)."""
from __future__ import annotations
import os
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
def register(registry: CheckRegistry) -> None:
registry.register("elec_scan", check_electron_apps, phases=("unprivileged",))
def _scan_app_dir(apps_dir: Path, label: str) -> tuple[list[str], list[str]]:
hits: list[str] = []
hijack_hints: list[str] = []
if not apps_dir.is_dir():
return hits, hijack_hints
for app in sorted(p for p in apps_dir.iterdir() if p.suffix == ".app"):
res = app / "Contents" / "Resources"
plist = app / "Contents" / "Info.plist"
if not res.is_dir():
continue
asar = res / "app.asar"
if asar.is_file():
try:
sz = asar.stat().st_size
except OSError:
sz = -1
bid = ""
if plist.is_file():
code, po, pe = run_text(["/usr/bin/plutil", "-extract", "CFBundleIdentifier", "raw", str(plist)])
if code == 0:
bid = (po or "").strip()
hits.append(f"{label}{app.name}: app.asar ({sz} bytes) bundle_id={bid or 'unknown'}")
parent = app.parent
try:
if os.access(parent, os.W_OK):
hijack_hints.append(
f"{label}{app.name}: parent directory writable by this user — "
"helpers or libraries alongside the bundle may be replaceable (hijack-style risk)"
)
except OSError:
pass
continue
for g in res.glob("*.asar"):
try:
hits.append(f"{label}{app.name}: {g.name} ({g.stat().st_size} bytes)")
except OSError:
hits.append(f"{label}{app.name}: {g.name}")
break
return hits, hijack_hints
def check_electron_apps(ctx: RunContext) -> list[Finding]:
hits_sys, hijack_sys = _scan_app_dir(Path("/Applications"), "")
hits_user, hijack_user = _scan_app_dir(ctx.home / "Applications", "~/Applications/")
hits = hits_sys + hits_user
hijack = hijack_sys + hijack_user
findings: list[Finding] = []
if not hits:
findings.append(
Finding(
id="elec-001",
title="No Electron ASAR bundles detected under /Applications or ~/Applications",
category="Electron",
severity=Severity.INFORMATIONAL,
description=(
"Scan for `app.asar` or `*.asar` under each bundles `Contents/Resources`. "
"Some Electron apps use different layouts or custom packers."
),
evidence="(none)",
worksheet="Electron",
mitre_techniques=("T1195.001",),
remediation="Extend review to other install roots (for example `/opt`) if the engagement requires.",
references=("https://www.electronjs.org/docs/latest/tutorial/security",),
)
)
else:
findings.append(
Finding(
id="elec-002",
title="Electron applications located (ASAR in bundle)",
category="Electron",
severity=Severity.INFORMATIONAL,
description=(
"These `.app` bundles contain ASAR archives under `Contents/Resources`, which is typical of "
"Electron packaging. Bundle identifiers are read with `plutil` for inventory. "
"Review code signing, update mechanism, and whether install locations are user-writable."
),
evidence="\n".join(hits),
worksheet="Electron",
mitre_techniques=("T1195.001", "T1554"),
risk="Weak update channels or writable install trees enable persistence or supply-chain abuse.",
impact="Tampered application content may run with the same user-facing trust and TCC context as the vendor app.",
remediation="Prefer vendor-signed updates, root-owned system-wide installs where policy allows, and monitor unusual writes beside application bundles.",
references=("https://www.electronjs.org/docs/latest/tutorial/security",),
)
)
if hijack:
findings.append(
Finding(
id="elec-003",
title="Electron app parent directory writable",
category="Electron",
severity=Severity.MEDIUM,
description=(
"The directory that contains the `.app` bundle allows writes by the user running the scan. "
"That layout can allow **hijack-style** abuse: replacing or planting libraries, helpers, or "
"sidecar binaries that the application or its updater loads from predictable paths next to the bundle."
),
evidence="\n".join(hijack),
worksheet="Electron",
mitre_techniques=("T1546", "T1554"),
risk="A writable parent path weakens filesystem boundaries around the application package.",
impact="Malware or a local attacker with that users privileges may substitute components loaded from the install tree.",
remediation="Install under root-owned `/Applications` where possible; avoid installing to user-writable sync or share folders.",
)
)
return findings

View File

@@ -0,0 +1,745 @@
"""Additional read-only host surface (SwiftBelt-style breadth, network, MDM profiles)."""
from __future__ import annotations
import platform
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
def register(registry: CheckRegistry) -> None:
registry.register("ext_route_table", check_route_table, phases=("unprivileged",))
registry.register("ext_hosts_file", check_hosts_file, phases=("unprivileged",))
registry.register("ext_scutil_dns", check_scutil_dns, phases=("unprivileged",))
registry.register("ext_network_proxies", check_network_proxies, phases=("unprivileged",))
registry.register("ext_sharing_airdrop", check_sharing_airdrop_defaults, phases=("unprivileged",))
registry.register("ext_emond_rules", check_emond_rules, phases=("privileged",))
registry.register("ext_shell_startup", check_shell_startup_files, phases=("unprivileged",))
registry.register("ext_kmutil", check_kmutil_showloaded, phases=("unprivileged",))
registry.register("ext_sp_usb", check_system_profiler_usb, phases=("unprivileged",))
registry.register("ext_sp_bluetooth", check_system_profiler_bluetooth, phases=("unprivileged",))
registry.register("ext_session_users", check_session_users, phases=("unprivileged",))
registry.register("ext_last_logins", check_last_logins, phases=("unprivileged",))
registry.register("ext_admin_group", check_admin_group, phases=("unprivileged",))
registry.register("ext_security_keychains", check_security_list_keychains, phases=("unprivileged",))
registry.register("ext_mrt_version", check_mrt_bundle_version, phases=("unprivileged",))
registry.register("ext_airport_prefs", check_airport_preferences, phases=("unprivileged",))
registry.register("ext_resolv_conf", check_resolv_conf, phases=("unprivileged",))
registry.register("ext_nfs_exports", check_etc_exports, phases=("privileged",))
registry.register("ext_pmset", check_pmset, phases=("unprivileged",))
registry.register("ext_printing", check_printing_surface, phases=("unprivileged",))
registry.register("ext_profiles_configuration", check_profiles_configuration, phases=("unprivileged",))
registry.register("ext_mdfind_apps", check_mdfind_application_count, phases=("unprivileged",))
registry.register("ext_launchctl_disabled", check_launchctl_disabled, phases=("unprivileged",))
registry.register("ext_clipboard_helpers", check_clipboard_manager_paths, phases=("unprivileged",))
registry.register("ext_sysctl_kern", check_sysctl_kern_snapshot, phases=("unprivileged",))
def _darwin_only() -> bool:
return platform.system() == "Darwin"
def check_route_table(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/sbin/netstat", "-rn"], timeout=25)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-101",
title="Routing table (netstat -rn)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="IPv4/IPv6 routing table for default routes, VPN interfaces, and unexpected gateways.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1016", "T1016.001", "T1049", "T1071"),
risk="Static routes or VPN split tunnelling may affect data exfiltration paths.",
impact="Reviewers use this to correlate with listener and proxy findings.",
remediation="Compare against architectural diagrams and MDM VPN profiles.",
)
]
def check_hosts_file(_ctx: RunContext) -> list[Finding]:
path = Path("/etc/hosts")
if not path.is_file():
return [
Finding(
id="ext-102",
title="/etc/hosts",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="/etc/hosts not present as a regular file.",
evidence=str(path),
worksheet="Attack surface",
mitre_techniques=("T1565.001", "T1071.004"),
)
]
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return [
Finding(
id="ext-102",
title="/etc/hosts",
category="Attack surface",
severity=Severity.LOW,
description="Could not read /etc/hosts.",
evidence=str(e),
worksheet="Attack surface",
mitre_techniques=("T1565.001", "T1071.004"),
)
]
return [
Finding(
id="ext-102",
title="/etc/hosts (full text)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Static host entries can redirect or block resolution for credential phishing or C2.",
evidence=text,
worksheet="Attack surface",
mitre_techniques=("T1565.001", "T1071.004", "T1014"),
risk="Malicious entries may blind defenders to real services.",
impact="Applications resolve names to attacker-controlled addresses.",
remediation="Baseline /etc/hosts; restrict write access; monitor with EDR file integrity tools.",
)
]
def check_scutil_dns(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/sbin/scutil", "--dns"], timeout=25)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-103",
title="Resolver configuration (scutil --dns)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="macOS DNS configuration and search domains — correlate with proxy and /etc/hosts findings.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1071.004", "T1016", "T1090"),
)
]
def check_network_proxies(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/sbin/networksetup", "-listallnetworkservices"], timeout=15)
services_raw = (out + err).strip()
if code != 0 or not services_raw:
return [
Finding(
id="ext-104",
title="Network proxy settings (networksetup)",
category="Attack surface",
severity=Severity.LOW,
description="Could not list network services for per-interface proxy enumeration.",
evidence=f"exit={code}\n{services_raw}",
worksheet="Attack surface",
mitre_techniques=("T1090", "T1016"),
)
]
lines_out: list[str] = []
for line in services_raw.splitlines():
s = line.strip()
if not s or s.startswith("*") or s.lower() == "an asterisk (*) denotes that a network service is disabled.":
continue
code_w, ow, ew = run_text(
["/usr/sbin/networksetup", "-getwebproxy", s],
timeout=10,
)
code_s, os_, es = run_text(
["/usr/sbin/networksetup", "-getsecurewebproxy", s],
timeout=10,
)
lines_out.append(f"=== {s} ===\nweb:\n{(ow + ew).strip()}\nsecureweb:\n{(os_ + es).strip()}")
blob = "\n\n".join(lines_out) if lines_out else services_raw
return [
Finding(
id="ext-104",
title="Per-interface HTTP/HTTPS proxy settings",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="networksetup proxy flags per service — abused for traffic forwarding and inspection bypass.",
evidence=blob,
worksheet="Attack surface",
mitre_techniques=("T1090", "T1016", "T1071"),
risk="Enabled proxies may chain outbound traffic through unexpected infrastructure.",
impact="Data loss or MITM if proxy settings are attacker-controlled.",
remediation="Baseline proxy settings against MDM; investigate unauthorised changes.",
)
]
def check_sharing_airdrop_defaults(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
rows: list[str] = []
keys = [
("/Library/Preferences/com.apple.NetworkBrowser", "DisableAirDrop"),
("/Library/Preferences/com.apple.NetworkBrowser", "BrowseAllInterfaces"),
("/Library/Preferences/SystemConfiguration/com.apple.RemoteAccess.plist", None),
]
for domain, key in keys:
if key:
code, o, e = run_text(["/usr/bin/defaults", "read", domain, key], timeout=10)
rows.append(f"{domain} {key}: exit={code}\n{(o + e).strip()}")
else:
p = Path(domain)
rows.append(f"{p}: {'exists' if p.is_file() else 'absent'}")
return [
Finding(
id="ext-105",
title="Sharing-related defaults (AirDrop / browse interfaces)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Read-only defaults for common sharing surface knobs; correlate with user training and MDM.",
evidence="\n\n".join(rows),
worksheet="Attack surface",
mitre_techniques=("T1537", "T1021.005", "T1016"),
)
]
def check_emond_rules(_ctx: RunContext) -> list[Finding]:
if not _darwin_only() or not _ctx.is_root():
return []
rules = Path("/etc/emond.d/rules")
if not rules.is_dir():
return [
Finding(
id="ext-106",
title="emond rules directory",
category="Persistence",
severity=Severity.INFORMATIONAL,
description="/etc/emond.d/rules absent or not a directory.",
evidence=str(rules),
worksheet="Persistence",
mitre_techniques=("T1546.014",),
)
]
try:
names = sorted(p.name for p in rules.iterdir() if p.is_file())
except OSError as e:
return [
Finding(
id="ext-106",
title="emond rules directory",
category="Persistence",
severity=Severity.LOW,
description="Could not list /etc/emond.d/rules.",
evidence=str(e),
worksheet="Persistence",
mitre_techniques=("T1546.014",),
)
]
return [
Finding(
id="ext-106",
title="emond event rules (filenames)",
category="Persistence",
severity=Severity.INFORMATIONAL,
description="Event Monitor daemon rules — filenames only; content not parsed.",
evidence="\n".join(names) if names else "(no regular files)",
worksheet="Persistence",
mitre_techniques=("T1546.014", "T1546"),
risk="emond can trigger execution on system events.",
impact="Persistence or privilege chains if rules are attacker-controlled.",
remediation="Review rule plist contents with change control; restrict directory permissions.",
)
]
def check_shell_startup_files(ctx: RunContext) -> list[Finding]:
home = ctx.home
candidates = [
home / ".zprofile",
home / ".zshrc",
home / ".zshenv",
home / ".bash_profile",
home / ".bashrc",
home / ".profile",
home / ".bash_login",
Path("/etc/zprofile"),
Path("/etc/zshrc"),
Path("/etc/profile"),
]
rows: list[str] = []
for p in candidates:
try:
if p.is_file():
st = p.stat()
rows.append(f"{p}: file, {st.st_size} bytes")
else:
rows.append(f"{p}: absent or not a file")
except OSError as e:
rows.append(f"{p}: {e}")
return [
Finding(
id="ext-107",
title="Shell startup file presence (paths and sizes)",
category="Persistence",
severity=Severity.INFORMATIONAL,
description="Login and interactive shell files commonly modified for persistence; contents not read.",
evidence="\n".join(rows),
worksheet="Persistence",
mitre_techniques=("T1546", "T1546.004", "T1059.004"),
)
]
def check_kmutil_showloaded(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/bin/kmutil", "showloaded"], timeout=45)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-108",
title="Loaded kernel extensions and drivers (kmutil showloaded)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Driver and kext inventory for comparison with MDM allow lists.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1014", "T1547.006", "T1082"),
)
]
def check_system_profiler_usb(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/sbin/system_profiler", "SPUSBDataType"], timeout=60)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-109",
title="USB devices (system_profiler)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Attached USB device tree — useful for rogue-device and exfiltration context.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1091", "T1200", "T1580"),
)
]
def check_system_profiler_bluetooth(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/sbin/system_profiler", "SPBluetoothDataType"], timeout=45)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-110",
title="Bluetooth state (system_profiler)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Bluetooth controllers and paired devices summary.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1200", "T1016"),
)
]
def check_session_users(_ctx: RunContext) -> list[Finding]:
code_w, ow, ew = run_text(["/usr/bin/who"], timeout=10)
code_u, ou, eu = run_text(["/usr/bin/uptime"], timeout=10)
ev = f"=== who (exit={code_w}) ===\n{(ow + ew).strip()}\n\n=== uptime (exit={code_u}) ===\n{(ou + eu).strip()}"
return [
Finding(
id="ext-111",
title="Interactive sessions (who, uptime)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Current login sessions and load averages.",
evidence=ev,
worksheet="Attack surface",
mitre_techniques=("T1033", "T1082"),
)
]
def check_last_logins(_ctx: RunContext) -> list[Finding]:
code, out, err = run_text(["/usr/bin/last", "-n", "25"], timeout=15)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-112",
title="Recent login history (last -n 25)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Last lines of wtmp-style login history when readable.",
evidence=blob,
worksheet="Attack surface",
mitre_techniques=("T1078", "T1033"),
)
]
def check_admin_group(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/bin/dscl", ".", "-read", "/Groups/admin"], timeout=20)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-113",
title="Local admin group (dscl read /Groups/admin)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="GroupMembership for admin — correlate unexpected accounts with IdP and MDM.",
evidence=blob,
worksheet="Attack surface",
mitre_techniques=("T1069", "T1087", "T1078"),
)
]
def check_security_list_keychains(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/bin/security", "list-keychains"], timeout=15)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-114",
title="Keychain search list (security list-keychains)",
category="Credentials",
severity=Severity.INFORMATIONAL,
description="Keychains in the user search path — no unlock or password reads.",
evidence=f"exit={code}\n{blob}",
worksheet="Credentials",
mitre_techniques=("T1555", "T1552.001"),
)
]
def check_mrt_bundle_version(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
plist = Path("/Library/Apple/System/Library/CoreServices/MRT.app/Contents/Info.plist")
if not plist.is_file():
return [
Finding(
id="ext-115",
title="Malware Removal Tool (MRT) bundle",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="MRT Info.plist not at expected path on this OS build.",
evidence=str(plist),
worksheet="Attack surface",
mitre_techniques=("T1518.001", "T1562.001"),
)
]
code, out, err = run_text(["/usr/bin/plutil", "-extract", "CFBundleShortVersionString", "raw", str(plist)], timeout=10)
ver = (out + err).strip()
if code != 0:
code, out, err = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=10)
ver = (out + err).strip()
return [
Finding(
id="ext-115",
title="Malware Removal Tool (MRT) version metadata",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Apple MRT bundle version from Info.plist (read-only).",
evidence=ver or f"exit={code}",
worksheet="Attack surface",
mitre_techniques=("T1518.001", "T1562.001"),
)
]
def check_airport_preferences(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
plist = Path("/Library/Preferences/SystemConfiguration/com.apple.airport.preferences.plist")
if not plist.is_file():
return [
Finding(
id="ext-116",
title="AirPort preferences plist",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="AirPort preferences plist absent — Wi-Fi history not available via this path.",
evidence=str(plist),
worksheet="Attack surface",
mitre_techniques=("T1016", "T1040"),
)
]
code, out, err = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=20)
blob = (out + err).strip()
return [
Finding(
id="ext-116",
title="AirPort / Wi-Fi preferences (plutil)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Known networks and Wi-Fi policy hints; may be large on well-travelled laptops.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1016", "T1040", "T1580"),
)
]
def check_resolv_conf(_ctx: RunContext) -> list[Finding]:
path = Path("/etc/resolv.conf")
if not path.is_file():
return [
Finding(
id="ext-117",
title="/etc/resolv.conf",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="/etc/resolv.conf absent on this host (common on macOS where resolver is dynamic).",
evidence=str(path),
worksheet="Attack surface",
mitre_techniques=("T1071.004",),
)
]
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return [
Finding(
id="ext-117",
title="/etc/resolv.conf",
category="Attack surface",
severity=Severity.LOW,
description="Could not read /etc/resolv.conf.",
evidence=str(e),
worksheet="Attack surface",
mitre_techniques=("T1071.004",),
)
]
return [
Finding(
id="ext-117",
title="/etc/resolv.conf (full text)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Resolver configuration when this file is materialised.",
evidence=text,
worksheet="Attack surface",
mitre_techniques=("T1071.004", "T1090"),
)
]
def check_etc_exports(_ctx: RunContext) -> list[Finding]:
if not _ctx.is_root():
return []
path = Path("/etc/exports")
if not path.is_file():
return [
Finding(
id="ext-118",
title="/etc/exports (NFS)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="/etc/exports not present — NFS exports may be inactive.",
evidence=str(path),
worksheet="Attack surface",
mitre_techniques=("T1039", "T1021.002"),
)
]
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError as e:
return [
Finding(
id="ext-118",
title="/etc/exports (NFS)",
category="Attack surface",
severity=Severity.LOW,
description="Could not read /etc/exports.",
evidence=str(e),
worksheet="Attack surface",
mitre_techniques=("T1039", "T1021.002"),
)
]
return [
Finding(
id="ext-118",
title="/etc/exports (NFS export table)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="NFS export definitions when file sharing exports are configured.",
evidence=text,
worksheet="Attack surface",
mitre_techniques=("T1039", "T1021.002", "T1565.001"),
)
]
def check_pmset(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/bin/pmset", "-g"], timeout=15)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-119",
title="Power management settings (pmset -g)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Sleep, display, and UPS settings — correlate with disk encryption and unattended session risk.",
evidence=blob,
worksheet="Attack surface",
mitre_techniques=("T1529", "T1498", "T1082"),
)
]
def check_printing_surface(_ctx: RunContext) -> list[Finding]:
code_p, op, ep = run_text(["/usr/bin/lpstat", "-p"], timeout=15)
lines = [f"=== lpstat -p (exit={code_p}) ===\n{(op + ep).strip()}"]
cups = Path("/etc/cups")
if cups.is_dir():
try:
names = sorted(p.name for p in cups.iterdir() if p.is_file())
lines.append(f"=== /etc/cups files ({len(names)}) ===\n" + "\n".join(names))
except OSError as e:
lines.append(f"/etc/cups list failed: {e}")
else:
lines.append(f"/etc/cups: not a directory ({cups})")
return [
Finding(
id="ext-120",
title="Printing subsystem (lpstat, CUPS config filenames)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Printer queues and CUPS configuration filenames only.",
evidence="\n\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1547", "T1091"),
)
]
def check_profiles_configuration(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/usr/bin/profiles", "show", "-type", "configuration"], timeout=45)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-121",
title="Installed configuration profiles (profiles show)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Full `profiles show -type configuration` output for MDM-delivered settings review.",
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1012", "T1547", "T1553.004", "T1078"),
risk="Profiles encode trust, VPN, restrictions, and certificates.",
impact="Mis-issued profiles expand attack surface or weaken user consent boundaries.",
remediation="Map payloads to MDM console; rotate suspicious identity or SCEP payloads.",
)
]
def check_mdfind_application_count(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(
[
"/usr/bin/mdfind",
"-count",
"kMDItemContentTypeTree == 'com.apple.application-bundle'",
],
timeout=120,
)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-122",
title="Spotlight index: application bundle count (mdfind -count)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="SwiftBelt-style Spotlight signal without enumerating every bundle path.",
evidence=blob,
worksheet="Attack surface",
mitre_techniques=("T1082", "T1518.001", "T1580"),
)
]
def check_launchctl_disabled(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
code, out, err = run_text(["/bin/launchctl", "print-disabled"], timeout=20)
blob = (out + err).strip() or f"(no output, exit={code})"
return [
Finding(
id="ext-123",
title="Disabled launchd jobs (launchctl print-disabled)",
category="Persistence",
severity=Severity.INFORMATIONAL,
description="System and user services marked disabled — defenders sometimes disable noisy agents.",
evidence=f"exit={code}\n{blob}",
worksheet="Persistence",
mitre_techniques=("T1562.001", "T1543.001"),
)
]
def check_clipboard_manager_paths(ctx: RunContext) -> list[Finding]:
home = ctx.home
paths = [
home / "Library" / "Application Support" / "Paste",
home / "Library" / "Application Support" / "Maccy",
home / "Library" / "Application Support" / "CopyClip",
Path("/Applications/Paste.app"),
Path("/Applications/Maccy.app"),
]
rows = [f"{p}: {'exists' if p.exists() else 'absent'}" for p in paths]
return [
Finding(
id="ext-124",
title="Clipboard manager applications (presence)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Common third-party clipboard tools that retain history (presence only).",
evidence="\n".join(rows),
worksheet="Attack surface",
mitre_techniques=("T1115", "T1580"),
)
]
def check_sysctl_kern_snapshot(_ctx: RunContext) -> list[Finding]:
if not _darwin_only():
return []
keys = ("kern.osversion", "kern.osrelease", "kern.boottime", "kern.hv_support")
lines: list[str] = []
for k in keys:
code, o, e = run_text(["/usr/sbin/sysctl", "-n", k], timeout=10)
lines.append(f"{k}: exit={code} {(o + e).strip()}")
return [
Finding(
id="ext-125",
title="Kernel sysctl snapshot (selected keys)",
category="Core",
severity=Severity.INFORMATIONAL,
description="OS build, boot time, and hypervisor support flags for virtualisation context.",
evidence="\n".join(lines),
worksheet="Core",
mitre_techniques=("T1082", "T1564.006", "T1497.001"),
)
]

View File

@@ -0,0 +1,164 @@
"""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",
),
)
]

View File

@@ -0,0 +1,514 @@
"""Interpreter and package-manager detection for macOS attack surface mapping.
Detects runtime interpreters and package managers that may expand attack
surface (code execution paths, dependency supply-chain, script injection).
"""
from __future__ import annotations
import shutil
from pathlib import Path
from typing import NamedTuple
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
from applepy.subproc import run_text
# ---------------------------------------------------------------------------
# Ecosystem definitions
# ---------------------------------------------------------------------------
class _Ecosystem(NamedTuple):
label: str
runtimes: list[str] # CLI tool names for shutil.which()
package_managers: list[str] # CLI tool names for shutil.which()
extra_paths: list[str] # Absolute or ~ paths to check
_ECOSYSTEMS: list[_Ecosystem] = [
_Ecosystem(
label="Python",
runtimes=["python3", "python", "python3.9", "python3.10", "python3.11", "python3.12",
"python3.13", "pypy3", "pypy", "micropython"],
package_managers=["pip3", "pip", "uv", "pipx", "poetry", "pdm", "hatch",
"conda", "mamba", "micromamba", "pipenv", "easy_install"],
extra_paths=["~/.pyenv/shims/python3", "~/.pyenv/bin/pyenv",
"/opt/homebrew/bin/python3", "/usr/local/bin/python3"],
),
_Ecosystem(
label="Ruby",
runtimes=["ruby", "ruby2", "ruby3", "jruby", "rbenv", "rvm", "chruby"],
package_managers=["gem", "bundle", "bundler"],
extra_paths=["~/.rbenv/shims/ruby", "/opt/homebrew/bin/ruby"],
),
_Ecosystem(
label="Perl",
runtimes=["perl", "perl5"],
package_managers=["cpan", "cpanm", "cpanminus", "cpm"],
extra_paths=["/usr/bin/perl"],
),
_Ecosystem(
label="Node.js / JavaScript",
runtimes=["node", "nodejs", "ts-node", "tsx", "esno", "deno"],
package_managers=["npm", "npx", "yarn", "pnpm", "corepack"],
extra_paths=["/usr/local/bin/node", "/opt/homebrew/bin/node",
"~/.nvm/versions/node", "~/.volta/bin/node"],
),
_Ecosystem(
label="Bun",
runtimes=["bun"],
package_managers=["bunx"],
extra_paths=["~/.bun/bin/bun"],
),
_Ecosystem(
label="TypeScript (compiler)",
runtimes=["tsc"],
package_managers=["npx"], # tsc typically installed via npm
extra_paths=[],
),
_Ecosystem(
label="Go",
runtimes=["go", "gofmt"],
package_managers=["go"], # go install acts as package manager
extra_paths=["/usr/local/go/bin/go", "/opt/homebrew/bin/go",
"~/go/bin/go"],
),
_Ecosystem(
label="Java / JVM",
runtimes=["java", "javac", "javap", "jar", "jshell",
"kotlin", "kotlinc", "scala", "scalac", "groovy", "groovyc",
"clojure"],
package_managers=["mvn", "gradle", "sbt", "leiningen", "lein",
"coursier", "cs"],
extra_paths=["/usr/bin/java", "/opt/homebrew/bin/java",
"/Library/Java/JavaVirtualMachines"],
),
_Ecosystem(
label="Lua",
runtimes=["lua", "lua5.4", "lua5.3", "lua5.2", "lua5.1", "luajit",
"love"],
package_managers=["luarocks"],
extra_paths=["/opt/homebrew/bin/lua", "/usr/local/bin/lua"],
),
_Ecosystem(
label="Rust",
runtimes=["rustc", "rustup"],
package_managers=["cargo"],
extra_paths=["~/.cargo/bin/rustc", "~/.cargo/bin/cargo"],
),
_Ecosystem(
label="PHP",
runtimes=["php", "php8", "php8.0", "php8.1", "php8.2", "php8.3",
"php-fpm"],
package_managers=["composer", "pear", "pecl"],
extra_paths=["/usr/bin/php", "/opt/homebrew/bin/php"],
),
_Ecosystem(
label="Swift / Objective-C",
runtimes=["swift", "swiftc", "clang", "clang++", "gcc", "g++"],
package_managers=["swift"], # swift package manager
extra_paths=["/usr/bin/swift", "/usr/bin/swiftc"],
),
_Ecosystem(
label=".NET / Mono",
runtimes=["dotnet", "mono", "mcs", "csc"],
package_managers=["nuget"],
extra_paths=["/usr/local/share/dotnet/dotnet", "/opt/homebrew/bin/mono"],
),
_Ecosystem(
label="Elixir / Erlang / BEAM",
runtimes=["elixir", "elixirc", "erl", "erlc", "escript"],
package_managers=["mix", "rebar3", "hex"],
extra_paths=["/opt/homebrew/bin/elixir", "/opt/homebrew/bin/erl"],
),
_Ecosystem(
label="Haskell / GHC",
runtimes=["ghc", "ghci", "runhaskell", "runghc"],
package_managers=["cabal", "stack"],
extra_paths=["/opt/homebrew/bin/ghc", "~/.ghcup/bin/ghc"],
),
_Ecosystem(
label="Julia",
runtimes=["julia"],
package_managers=[],
extra_paths=["~/.juliaup/bin/julia", "/Applications/Julia.app",
"/usr/local/bin/julia"],
),
_Ecosystem(
label="R",
runtimes=["R", "Rscript"],
package_managers=[],
extra_paths=["/usr/local/bin/R", "/opt/homebrew/bin/R",
"/Library/Frameworks/R.framework"],
),
_Ecosystem(
label="Zig",
runtimes=["zig"],
package_managers=[],
extra_paths=["~/.zvm/bin/zig", "/opt/homebrew/bin/zig"],
),
_Ecosystem(
label="Nim",
runtimes=["nim", "nimble"],
package_managers=["nimble"],
extra_paths=["~/.nimble/bin/nim", "/opt/homebrew/bin/nim"],
),
_Ecosystem(
label="Dart / Flutter",
runtimes=["dart", "flutter"],
package_managers=["pub", "dart"],
extra_paths=["~/flutter/bin/flutter", "/opt/homebrew/bin/dart"],
),
_Ecosystem(
label="Crystal",
runtimes=["crystal"],
package_managers=["shards"],
extra_paths=["/opt/homebrew/bin/crystal"],
),
_Ecosystem(
label="V / Vlang",
runtimes=["v"],
package_managers=["vpm"],
extra_paths=["~/v/v"],
),
_Ecosystem(
label="Assembly",
runtimes=["nasm", "yasm", "as"],
package_managers=[],
extra_paths=["/opt/homebrew/bin/nasm"],
),
]
# Conda/Miniconda/Anaconda distribution installation roots to probe.
# Each entry: (label, path_string) — ~ is expanded against ctx.home
_CONDA_ROOTS: list[tuple[str, str]] = [
("Miniconda3 (home)", "~/miniconda3"),
("Miniconda3 (opt)", "/opt/miniconda3"),
("Miniconda3 (usr/local)", "/usr/local/miniconda3"),
("Miniconda (home, legacy)", "~/miniconda"),
("Anaconda3 (home)", "~/anaconda3"),
("Anaconda3 (opt)", "/opt/anaconda3"),
("Anaconda (home, legacy)", "~/anaconda"),
("Homebrew Cask miniconda", "/opt/homebrew/Caskroom/miniconda"),
("Homebrew Cask anaconda", "/opt/homebrew/Caskroom/anaconda"),
("Mambaforge (home)", "~/mambaforge"),
("Miniforge3 (home)", "~/miniforge3"),
("Miniforge3 (opt)", "/opt/miniforge3"),
]
# Standalone system package managers and build tools not tied to one ecosystem
_SYSTEM_PKG_MANAGERS: list[tuple[str, str]] = [
("brew", "Homebrew (macOS)"),
("port", "MacPorts"),
("fink", "Fink"),
("nix", "Nix"),
("nix-env", "Nix (nix-env)"),
("guix", "GNU Guix"),
("pkgin", "pkgin"),
("pkg", "FreeBSD pkg (Rosetta)"),
("snap", "Snap"),
("flatpak", "Flatpak"),
("make", "GNU Make / BSD Make"),
("cmake", "CMake"),
("ninja", "Ninja build"),
("bazel", "Bazel / Blaze"),
("buck2", "Buck2 (Meta)"),
("meson", "Meson build"),
("conan", "Conan (C/C++ pkgs)"),
("vcpkg", "vcpkg (C/C++ pkgs)"),
("xcodebuild", "Xcode command-line tools"),
]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _which(name: str) -> str | None:
try:
return shutil.which(name)
except Exception:
return None
def _path_exists(raw: str, home: Path) -> str | None:
"""Return the expanded path string if the path exists, else None."""
try:
p = Path(raw.replace("~", str(home)))
return str(p) if p.exists() else None
except Exception:
return None
def _version_of(binary: str) -> str:
"""Return a short version string for a detected binary, or empty string."""
for flag in ("--version", "version", "-version", "-V"):
try:
out = run_text([binary, flag], timeout=4)
if out:
line = out.strip().splitlines()[0][:80]
return line
except Exception:
continue
return ""
# ---------------------------------------------------------------------------
# Check 1 — Interpreter runtimes
# ---------------------------------------------------------------------------
def check_interpreters(ctx: RunContext) -> list[Finding]:
"""int-001 — Detect programming language runtimes present on the system."""
hits: list[str] = []
hit_count = 0
for eco in _ECOSYSTEMS:
eco_hits: list[str] = []
for rt in eco.runtimes:
loc = _which(rt)
if loc:
eco_hits.append(f" {rt}: {loc}")
hit_count += 1
# Extra paths (not necessarily on PATH)
for ep in eco.extra_paths:
resolved = _path_exists(ep, ctx.home)
if resolved:
label = ep.replace("~", str(ctx.home))
if label not in [h.split(": ", 1)[-1] for h in eco_hits]:
eco_hits.append(f" {label} [extra path]")
hit_count += 1
if eco_hits:
hits.append(f"[{eco.label}]")
hits.extend(eco_hits)
if not hits:
return [
Finding(
id="int-001",
title="Language runtimes: none detected",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="No third-party language runtime interpreters found.",
evidence="",
worksheet="Attack surface",
mitre_techniques=["T1059"],
)
]
return [
Finding(
id="int-001",
title=f"Language runtimes detected ({hit_count})",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Language runtimes present additional code execution paths. "
"Each installed runtime can be leveraged by malware or malicious scripts "
"to execute arbitrary code outside of Gatekeeper-signed binaries."
),
evidence="\n".join(hits),
worksheet="Attack surface",
mitre_techniques=["T1059", "T1106"],
risk="Additional code execution paths available to attackers.",
impact="Malicious scripts can run without triggering Gatekeeper checks on signed binaries.",
remediation="Remove or version-pin interpreters that are not required for production workloads.",
)
]
# ---------------------------------------------------------------------------
# Check 2 — Package managers
# ---------------------------------------------------------------------------
def check_package_managers(ctx: RunContext) -> list[Finding]:
"""int-002 — Detect package managers (supply-chain attack surface)."""
eco_hits: list[str] = []
sys_hits: list[str] = []
total = 0
# Ecosystem-specific package managers
for eco in _ECOSYSTEMS:
found: list[str] = []
for pm in eco.package_managers:
loc = _which(pm)
if loc and loc not in [h.split(": ", 1)[-1] for h in found]:
found.append(f" {pm}: {loc}")
total += 1
if found:
eco_hits.append(f"[{eco.label}]")
eco_hits.extend(found)
# System-level package managers
for binary, label in _SYSTEM_PKG_MANAGERS:
loc = _which(binary)
if loc:
sys_hits.append(f" {binary} ({label}): {loc}")
total += 1
if not eco_hits and not sys_hits:
return [
Finding(
id="int-002",
title="Package managers: none detected",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="No package managers detected on PATH.",
evidence="",
worksheet="Attack surface",
mitre_techniques=["T1195"],
)
]
sections: list[str] = []
if sys_hits:
sections.append("[System package managers]")
sections.extend(sys_hits)
if eco_hits:
sections.append("[Ecosystem package managers]")
sections.extend(eco_hits)
return [
Finding(
id="int-002",
title=f"Package managers detected ({total})",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Package managers expand the dependency supply-chain attack surface. "
"Compromised packages from any of these registries can silently install "
"malicious code with developer-level privileges."
),
evidence="\n".join(sections),
worksheet="Attack surface",
mitre_techniques=["T1195", "T1072"],
risk="Supply-chain compromise via malicious package publication or dependency confusion.",
impact="Arbitrary code execution with developer privileges via install/build hooks.",
remediation=(
"Pin dependency versions, use lock files, audit installs with integrity checks. "
"Remove package managers not required on production systems."
),
)
]
# ---------------------------------------------------------------------------
# Check 3 — Conda / Miniconda / Anaconda distributions
# ---------------------------------------------------------------------------
def check_conda_distributions(ctx: RunContext) -> list[Finding]:
"""int-003 — Detect Conda, Miniconda, and Anaconda distribution installs."""
found: list[str] = []
for label, raw in _CONDA_ROOTS:
try:
p = Path(raw.replace("~", str(ctx.home)))
if not p.exists():
continue
except OSError:
continue
# Read the conda version from conda-meta/history or the conda binary
version = ""
conda_bin = p / "bin" / "conda"
try:
if conda_bin.is_file():
code, out, _err = run_text([str(conda_bin), "--version"], timeout=8)
if code == 0 and out.strip():
version = out.strip().splitlines()[0][:60]
except Exception:
pass
# Count active environments
envs_dir = p / "envs"
env_count = 0
try:
if envs_dir.is_dir():
env_count = sum(1 for e in envs_dir.iterdir() if e.is_dir())
except OSError:
pass
env_note = f" {env_count} environment(s) under {envs_dir}" if env_count else ""
ver_note = f" version: {version}" if version else ""
lines = [f"[{label}]", f" path: {p}"]
if ver_note:
lines.append(ver_note)
if env_note:
lines.append(env_note)
found.extend(lines)
# Also check conda CLI on PATH (may be shim outside a root above)
conda_on_path = _which("conda")
mamba_on_path = _which("mamba")
micromamba_on_path = _which("micromamba")
path_hits = []
if conda_on_path:
path_hits.append(f" conda: {conda_on_path}")
if mamba_on_path:
path_hits.append(f" mamba: {mamba_on_path}")
if micromamba_on_path:
path_hits.append(f" micromamba: {micromamba_on_path}")
if path_hits:
found.append("[Conda CLI on PATH]")
found.extend(path_hits)
if not found:
return [
Finding(
id="int-003",
title="Conda distributions: none detected",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"No Conda, Miniconda, Anaconda, Mambaforge, or Miniforge installations found. "
"Checked standard install roots and PATH."
),
evidence="",
worksheet="Attack surface",
mitre_techniques=["T1195"],
)
]
return [
Finding(
id="int-003",
title="Conda distribution(s) detected",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"One or more Conda-based Python distribution installs (Miniconda, Anaconda, "
"Mambaforge, Miniforge) were found. Each install may manage multiple isolated "
"environments, each with their own Python and package set."
),
evidence="\n".join(found),
worksheet="Attack surface",
mitre_techniques=["T1195", "T1059"],
risk=(
"Conda environments may contain packages from conda-forge or PyPI with weaker "
"supply-chain controls. Compromised packages in any environment can execute code "
"with developer privileges."
),
impact=(
"Lateral movement between environments; supply-chain compromise via malicious "
"conda or pip packages; large number of environments increases review burden."
),
remediation=(
"Audit active environments and remove unused ones. Pin package versions and use "
"environment.yml lock files. Prefer conda-forge or verified channels over defaults. "
"Consider using mamba for faster, auditable dependency resolution."
),
)
]
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
def register(registry: CheckRegistry) -> None:
registry.register("interpreters", check_interpreters, phases=("unprivileged",))
registry.register("package_managers", check_package_managers, phases=("unprivileged",))
registry.register("conda_distributions", check_conda_distributions, phases=("unprivileged",))

View File

@@ -0,0 +1,361 @@
"""
Non-ephemeral listeners bound beyond loopback (network-reachable attack surface).
Uses `lsof` for socket ownership and optional `ps` for process command lines. Ephemeral bounds
prefer macOS `sysctl net.inet.ip.portrange.hi*`, else IANA 4915265535.
"""
from __future__ import annotations
import platform
import re
import shutil
import socket
from collections import defaultdict
from dataclasses import dataclass
from typing import Final
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
from applepy.subproc import run_text
_IANA_EPHEMERAL_LOW: Final[int] = 49152
_IANA_EPHEMERAL_HIGH: Final[int] = 65535
# lsof separates the NAME column with whitespace (often tab) before the protocol token.
_TCP_LISTEN_RE = re.compile(r"\sTCP\s+(.+)\s+\(LISTEN\)\s*$")
_UDP_SUFFIX_RE = re.compile(r"\sUDP\s+(.+)\s*$")
_PS_BATCH: Final[int] = 24
@dataclass(frozen=True, slots=True)
class _ListenerRow:
proto: str
bind_spec: str
port: int
command: str
pid: int
user: str
args_hint: str
def register(registry: CheckRegistry) -> None:
registry.register("net_listen_ports", check_network_listeners_non_ephemeral, phases=("unprivileged",))
def _ephemeral_port_bounds() -> tuple[int, int]:
"""Return (low, high) inclusive ephemeral range for client-style dynamic ports."""
if platform.system() == "Darwin":
c1, lo_s, _ = run_text(["/usr/sbin/sysctl", "-n", "net.inet.ip.portrange.hifirst"], timeout=5)
c2, hi_s, _ = run_text(["/usr/sbin/sysctl", "-n", "net.inet.ip.portrange.hilast"], timeout=5)
if c1 == 0 and c2 == 0:
try:
lo = int(lo_s.strip())
hi = int(hi_s.strip())
if 1024 <= lo <= hi <= 65535:
return lo, hi
except ValueError:
pass
return _IANA_EPHEMERAL_LOW, _IANA_EPHEMERAL_HIGH
def _is_ephemeral_port(port: int, el: int, eh: int) -> bool:
return el <= port <= eh
def _split_host_port(name: str) -> tuple[str, int] | None:
name = name.strip()
if not name or name == "*:*":
return None
if name.startswith("[") and "]:" in name:
brk = name.rindex("]:")
host = name[: brk + 1]
port_s = name[brk + 2 :]
else:
if ":" not in name:
return None
host, port_s = name.rsplit(":", 1)
try:
return host, int(port_s)
except ValueError:
return None
def _registered_service_name(port: int, proto: str) -> str:
"""Best-effort IANA / services(5) name from the OS; empty if unmapped."""
key = "tcp" if proto.upper() == "TCP" else "udp"
try:
return socket.getservbyport(port, key)
except OSError:
return ""
def _is_loopback_only_host(host: str) -> bool:
h = host.strip().lower()
if h in ("127.0.0.1", "localhost", "::1", "[::1]"):
return True
if h.startswith("127."):
return True
return False
def _parse_lsof_line(line: str) -> tuple[str, str, int, str, int, str] | None:
"""
Return (proto, bind_spec, port, command, pid, user) or None.
bind_spec is the raw host:port or [*]:port fragment from lsof.
"""
parts = line.split(None, 8)
if len(parts) < 9:
return None
command, pid_s, user, _fd, _typ, _dev, _size, _node, _tail = parts[:9]
try:
pid = int(pid_s)
except ValueError:
return None
m = _TCP_LISTEN_RE.search(line)
if m:
spec = m.group(1).strip()
parsed = _split_host_port(spec)
if parsed is None:
return None
_host, port = parsed
return "TCP", spec, port, command, pid, user
m = _UDP_SUFFIX_RE.search(line)
if m:
spec = m.group(1).strip()
parsed = _split_host_port(spec)
if parsed is None:
return None
_host, port = parsed
return "UDP", spec, port, command, pid, user
return None
def _gather_ps_args(pids: set[int]) -> dict[int, str]:
if not pids or shutil.which("ps") is None:
return {}
out_map: dict[int, str] = {}
sorted_pids = sorted(pids)
ps_bin = shutil.which("ps") or "/bin/ps"
for i in range(0, len(sorted_pids), _PS_BATCH):
chunk = sorted_pids[i : i + _PS_BATCH]
pid_arg = ",".join(str(p) for p in chunk)
code, out, err = run_text(
[ps_bin, "-p", pid_arg, "-o", "pid=", "-o", "args="],
timeout=20,
)
text = (out or "") + (err or "")
if code != 0 and not text.strip():
continue
for raw_line in text.splitlines():
line = raw_line.strip()
if not line:
continue
# First field is pid, remainder is args (may contain spaces)
toks = line.split(None, 1)
if not toks:
continue
try:
pr = int(toks[0])
except ValueError:
continue
arg = toks[1].strip() if len(toks) > 1 else ""
if pr in chunk:
out_map[pr] = arg
return out_map
def _lsof_binary() -> str | None:
return shutil.which("lsof")
def check_network_listeners_non_ephemeral(_ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="net-001",
title="Listening socket inventory — unsupported platform",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"This check uses `lsof` and is intended for macOS. "
"No listener audit was run."
),
evidence=f"platform.system()={platform.system()!r}",
worksheet="Network listeners",
mitre_techniques=("T1046",),
remediation="Run ApplePY on macOS.",
)
]
lsof_path = _lsof_binary()
if not lsof_path:
return [
Finding(
id="net-002",
title="Listening socket inventory — lsof not available",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"`lsof` was not found on PATH; listener names and processes could not be enumerated."
),
evidence="which(lsof)=None",
worksheet="Network listeners",
mitre_techniques=("T1046",),
remediation="Install `lsof` (included with macOS by default).",
)
]
eph_lo, eph_hi = _ephemeral_port_bounds()
tcp_code, tcp_out, tcp_err = run_text(
[lsof_path, "-nP", "-iTCP", "-sTCP:LISTEN"],
timeout=45,
)
udp_code, udp_out, udp_err = run_text(
[lsof_path, "-nP", "-iUDP"],
timeout=45,
)
rows: list[_ListenerRow] = []
errors: list[str] = []
if tcp_code != 0:
errors.append(
f"TCP lsof exit={tcp_code}:\n--- stderr ---\n{tcp_err}\n--- stdout ---\n{tcp_out}"
)
if udp_code != 0:
errors.append(
f"UDP lsof exit={udp_code}:\n--- stderr ---\n{udp_err}\n--- stdout ---\n{udp_out}"
)
def consume_lsof_block(text: str) -> None:
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("COMMAND"):
continue
parsed = _parse_lsof_line(line)
if parsed is None:
continue
proto, spec, port, command, pid, user = parsed
host_for_loopback, _p = _split_host_port(spec) or ("", 0)
if _is_loopback_only_host(host_for_loopback):
continue
if _is_ephemeral_port(port, eph_lo, eph_hi):
continue
rows.append(
_ListenerRow(
proto=proto,
bind_spec=spec,
port=port,
command=command,
pid=pid,
user=user,
args_hint="",
)
)
consume_lsof_block(tcp_out)
consume_lsof_block(udp_out)
if not rows and (tcp_code != 0 and udp_code != 0):
return [
Finding(
id="net-003",
title="Listening socket inventory — lsof failed",
category="Attack surface",
severity=Severity.MEDIUM,
description=(
"Neither TCP nor UDP `lsof` invocations returned successfully; "
"listener data may be incomplete (permissions, sandbox, or policy)."
),
evidence="\n".join(errors),
worksheet="Network listeners",
mitre_techniques=("T1046",),
risk="Blind spot for exposed services and unexpected daemons.",
impact="Attack surface may be understated in the report.",
remediation="Re-run with appropriate permissions; confirm `lsof -nP -iTCP -sTCP:LISTEN` works in a shell.",
)
]
unique_pids = {r.pid for r in rows}
pid_args = _gather_ps_args(unique_pids)
merged: dict[tuple[str, int, int, str, str], set[str]] = defaultdict(set)
for r in rows:
key = (r.proto, r.port, r.pid, r.command, r.user)
merged[key].add(r.bind_spec)
deduped: list[_ListenerRow] = []
for (proto, port, pid, command, user), specs in merged.items():
specs_u = sorted(specs)
bind_joined = "; ".join(specs_u)
deduped.append(
_ListenerRow(
proto=proto,
bind_spec=bind_joined,
port=port,
command=command,
pid=pid,
user=user,
args_hint=pid_args.get(pid, ""),
)
)
deduped.sort(key=lambda x: (x.port, x.proto, x.command.lower()))
lines_out = [
f"ephemeral_range_inclusive={eph_lo}-{eph_hi} (sysctl net.inet.ip.portrange.*, else IANA fallback)",
"filters: exclude loopback-only binds; exclude ports in ephemeral range",
"proto\tbind\tport\tiana_service_name\tcommand\tpid\tuser\targs_snippet",
]
for r in deduped:
args_col = (r.args_hint or "").replace("\n", " ").replace("\t", " ")
svc = _registered_service_name(r.port, r.proto)
lines_out.append(
f"{r.proto}\t{r.bind_spec}\t{r.port}\t{svc}\t{r.command}\t{r.pid}\t{r.user}\t{args_col}"
)
lines_out.append(f"total_listener_rows_listed={len(deduped)}")
note_vis = (
"Visibility note: without elevated privileges, `lsof` may omit other users' processes; "
"compare with a privileged run when authorised."
)
ev_parts = ["\n".join(lines_out)]
if errors:
ev_parts.append("warnings:\n" + "\n".join(errors))
ev_parts.append(note_vis)
evidence = "\n\n".join(ev_parts)
empty_msg = (
"No non-ephemeral TCP listeners or fixed UDP ports were observed outside loopback "
"under current filters and lsof visibility."
)
return [
Finding(
id="net-004",
title="Network-exposed listeners (non-ephemeral, non-loopback)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Inventory of **TCP LISTEN** and **UDP** sockets that are **not** loopback-only and whose "
"ports fall **outside** the host ephemeral range (typically upper dynamic ports). "
"Bindings such as `*`, `0.0.0.0`, `::`, and interface addresses beyond 127.0.0.0/8 are included. "
"Process names come from `lsof`; an `args` snippet from `ps` clarifies the binary when available. "
"The `iana_service_name` column uses the OS services database (`getservbyport`) when a registered name exists."
),
evidence=evidence if deduped else f"{empty_msg}\n\n{note_vis}\n\n" + "\n".join(errors),
worksheet="Network listeners",
mitre_techniques=("T1046", "T1048", "T1021"),
risk="Unexpected listeners increase remote exploitation and lateral movement opportunities.",
impact="Services bound broadly may be reachable from adjacent networks or VPNs.",
remediation=(
"Validate each listener against build standard; restrict binds to localhost where possible; "
"use host firewalls and segmentation; remove obsolete agents."
),
references=(
"https://developer.apple.com/library/archive/documentation/Darwin/Reference/ManPages/man8/lsof.8.html",
"https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml",
),
)
]

View File

@@ -0,0 +1 @@
"""MDM posture modules."""

920
applepy/checks/mdm/jamf.py Normal file
View File

@@ -0,0 +1,920 @@
"""Jamf local posture (read-only; no API CRUD).
Attack surface checks informed by:
- ReversecLabs/Jamf-Attack-Toolkit (JamfDumper, JamfExplorer credential-in-process technique)
- RobotOperator/Eve (extension attribute and policy abuse paths)
- SpecterOps/JamfHound (attack path nodes: Computer, Account, APIClient, Group)
"""
from __future__ import annotations
import platform
import plistlib
import re
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
# Credential patterns for local script cache scanning (Jamf-Attack-Toolkit JamfDumper research:
# policies frequently embed credentials in script arguments visible to unprivileged users via ps).
_CRED_PATTERNS: list[re.Pattern[str]] = [
re.compile(r'(?i)(password|passwd|pwd|secret|token|apikey|api_key|bearer)\s*[=:]\s*\S+'),
re.compile(r'(?i)(bearer\s+[A-Za-z0-9\-_\.]+)'),
re.compile(r'(?i)(Authorization:\s*\S+)'),
re.compile(r'(?i)--password\s+\S+'),
re.compile(r'(?i)-p\s+\S{6,}'),
re.compile(r'[A-Za-z0-9+/]{40,}={0,2}'), # base64-ish blobs (API keys, tokens)
]
_CRED_MAX_BYTES = 65_536 # cap file reads for script scans
def register(registry: CheckRegistry) -> None:
registry.register("jamf_local", check_jamf_local, phases=("unprivileged", "privileged"))
registry.register("jamf_script_cache", check_jamf_script_cache, phases=("unprivileged", "privileged"))
registry.register("jamf_ps_creds", check_jamf_process_credential_exposure, phases=("unprivileged",))
registry.register("jamf_login_hooks", check_jamf_login_hooks, phases=("unprivileged", "privileged"))
registry.register("jamf_ea_cache", check_jamf_extension_attribute_cache, phases=("unprivileged", "privileged"))
registry.register("jamf_keychain", check_jamf_keychain_entries, phases=("unprivileged",))
registry.register("jamf_jss_url", check_jss_url_and_api_surface, phases=("unprivileged",))
def check_jamf_local(ctx: RunContext) -> list[Finding]:
findings: list[Finding] = []
jamf_bin = Path("/usr/local/bin/jamf")
if jamf_bin.is_file():
code, out, err = run_text([str(jamf_bin), "version"], timeout=15)
findings.append(
Finding(
id="jamf-001",
title="Jamf binary present",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description=(
"Jamf Pro agent detected. Enumerate local policy, profiles, and logs using read-only "
"commands; published research covers abuse of Jamf capabilities during authorised tests."
),
evidence=f"{jamf_bin}\n{(out + err).strip()}",
worksheet="MDM Jamf",
mitre_techniques=("T1078", "T1106"),
risk="Compromised Jamf context can scale actions across enrolled Mac endpoints.",
impact="Policy manipulation or credential exposure in enterprise Mac estates.",
remediation="Harden Jamf Pro roles, API clients, and logging; validate script payloads from the server.",
references=(
"https://github.com/jamf/jamfprotect",
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit",
"https://github.com/SpecterOps/JamfHound",
"https://developer.jamf.com/jamf-pro/reference/classic-api",
"https://i.blackhat.com/BH-USA-25/Presentations/USA-25-Cain-Mayer-Leveraging-Jamf-for-Red-Teaming.pdf",
),
)
)
findings.append(
Finding(
id="jamf-019",
title="Jamf Attack Toolkit — scope note (not avoided)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description=(
"ApplePY does **not** bundle or invoke [ReversecLabs/Jamf-Attack-Toolkit](https://github.com/ReversecLabs/Jamf-Attack-Toolkit) "
"by design: that project targets **Jamf Pro API** workflows (credentials, policies, scripts) and is "
"appropriate as a **separate** step when you hold explicit authorisation and API access. This scanner "
"implements **local read-only** enumeration (agent binary, plists, LaunchDaemons, logs, listeners) "
"that overlaps defensive reconnaissance themes from the same body of research — not a substitute for "
"the toolkits server-side actions."
),
evidence="Jamf agent present; see references for toolkit vs local posture split.",
worksheet="MDM Jamf",
mitre_techniques=(),
references=(
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit",
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit#readme",
),
)
)
else:
findings.append(
Finding(
id="jamf-002",
title="Jamf binary not at /usr/local/bin/jamf",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Jamf may be absent, renamed, or installed via a non-standard path.",
evidence="Not found at default location",
worksheet="MDM Jamf",
mitre_techniques=(),
remediation="Search with `mdfind kMDItemDisplayName == 'jamf'` if Jamf is expected.",
)
)
plist = Path("/Library/Preferences/com.jamfsoftware.jamf.plist")
if plist.is_file():
try:
code, po, pe = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=15)
blob = ((po or "") + (pe or "")).strip()
findings.append(
Finding(
id="jamf-003",
title="Jamf preferences plist (plutil)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Structured view of com.jamfsoftware.jamf preferences (keys only, read-only).",
evidence=blob or f"(plutil exit {code})",
worksheet="MDM Jamf",
mitre_techniques=("T1012",),
)
)
except OSError as e:
findings.append(
Finding(
id="jamf-004",
title="Jamf plist unreadable",
category="MDM: Jamf",
severity=Severity.MEDIUM,
description="Could not read Jamf preference plist.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
jamf_support = Path("/Library/Application Support/JAMF")
if jamf_support.is_dir():
try:
sub = sorted(p.name for p in jamf_support.iterdir())
findings.append(
Finding(
id="jamf-006",
title="Jamf Application Support directory",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Top-level entries under /Library/Application Support/JAMF.",
evidence="\n".join(sub),
worksheet="MDM Jamf",
mitre_techniques=("T1083",),
)
)
except OSError as e:
findings.append(
Finding(
id="jamf-007",
title="Jamf Application Support listing failed",
category="MDM: Jamf",
severity=Severity.LOW,
description="Directory exists but could not be listed.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
log = Path("/var/log/jamf.log")
try:
if log.is_file():
sz = log.stat().st_size
findings.append(
Finding(
id="jamf-005",
title="Jamf log file present",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="jamf.log may contain policy and inventory breadcrumbs; contents not ingested here.",
evidence=f"{log} size={sz}",
worksheet="MDM Jamf",
mitre_techniques=("T1070",),
)
)
except OSError as e:
findings.append(
Finding(
id="jamf-009",
title="Jamf log file present but not readable",
category="MDM: Jamf",
severity=Severity.LOW,
description="jamf.log exists but stat or size check failed.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
code, out, err = run_text(["/usr/bin/profiles", "-C"], timeout=20)
blob = (out + err).strip()
if "com.jamfsoftware" in blob or "jamf" in blob.lower():
findings.append(
Finding(
id="jamf-008",
title="Configuration profiles mentioning Jamf",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="profiles -C output excerpt containing Jamf-related identifiers.",
evidence=blob,
worksheet="MDM Jamf",
mitre_techniques=("T1012",),
)
)
ld = Path("/Library/LaunchDaemons")
if ld.is_dir():
try:
jamf_plists = sorted(
p.name for p in ld.iterdir() if p.is_file() and p.suffix == ".plist" and "jamf" in p.name.lower()
)
except OSError as e:
findings.append(
Finding(
id="jamf-010",
title="Jamf LaunchDaemon plist listing failed",
category="MDM: Jamf",
severity=Severity.LOW,
description="Could not enumerate /Library/LaunchDaemons for Jamf-related plists.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
else:
if jamf_plists:
findings.append(
Finding(
id="jamf-010",
title="Jamf-related LaunchDaemon plists (filenames)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description=(
"Complete list of `.plist` filenames in /Library/LaunchDaemons whose names contain "
"`jamf` (case-insensitive). Contents are not parsed here."
),
evidence="\n".join(jamf_plists),
worksheet="MDM Jamf",
mitre_techniques=("T1543.001", "T1012"),
references=("https://github.com/jamf/jamfprotect",),
)
)
pht = Path("/Library/PrivilegedHelperTools")
if pht.is_dir():
try:
jamf_helpers = sorted(p.name for p in pht.iterdir() if p.is_file() and "jamf" in p.name.lower())
except OSError as e:
findings.append(
Finding(
id="jamf-013",
title="Jamf PrivilegedHelperTools listing failed",
category="MDM: Jamf",
severity=Severity.LOW,
description="Could not list /Library/PrivilegedHelperTools.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
else:
if jamf_helpers:
findings.append(
Finding(
id="jamf-013",
title="Jamf-related Privileged Helper Tools (filenames)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Elevated helper binaries whose names reference Jamf (filenames only).",
evidence="\n".join(jamf_helpers),
worksheet="MDM Jamf",
mitre_techniques=("T1543.001", "T1553.004"),
references=("https://github.com/palantir/jamf-pro-scripts",),
)
)
u_pref = ctx.home / "Library" / "Preferences"
if u_pref.is_dir():
try:
u_jamf_plists = sorted(u_pref.glob("com.jamfsoftware*.plist"))
except OSError as e:
findings.append(
Finding(
id="jamf-014",
title="User Jamf preference plists glob failed",
category="MDM: Jamf",
severity=Severity.LOW,
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
description="Could not glob user Preferences for com.jamfsoftware*.plist.",
)
)
else:
if u_jamf_plists:
rows: list[str] = []
for pl in u_jamf_plists:
try:
st = pl.stat()
rows.append(f"{pl.name}\t{st.st_size} bytes")
except OSError as e:
rows.append(f"{pl.name}\t{e}")
findings.append(
Finding(
id="jamf-014",
title="User Jamf preference plists (metadata)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Per-user com.jamfsoftware*.plist files — sizes only; contents not parsed.",
evidence="\n".join(rows),
worksheet="MDM Jamf",
mitre_techniques=("T1012", "T1078"),
)
)
la = Path("/Library/LaunchAgents")
if la.is_dir():
try:
jamf_la = sorted(
p.name for p in la.iterdir() if p.is_file() and p.suffix == ".plist" and "jamf" in p.name.lower()
)
except OSError as e:
findings.append(Finding(
id="jamf-016",
title="Jamf LaunchAgents enumeration failed",
category="MDM: Jamf",
severity=Severity.LOW,
description="Could not enumerate /Library/LaunchAgents for Jamf-related plists.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=("T1543.001",),
))
jamf_la = []
if jamf_la:
findings.append(
Finding(
id="jamf-015",
title="Jamf-related LaunchAgent plists (system Library, filenames)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Filenames under /Library/LaunchAgents containing `jamf` (case-insensitive).",
evidence="\n".join(jamf_la),
worksheet="MDM Jamf",
mitre_techniques=("T1543.001",),
)
)
logs_dir = Path("/Library/Logs")
if logs_dir.is_dir():
try:
extra_logs = sorted(
p.name for p in logs_dir.iterdir() if p.is_file() and "jamf" in p.name.lower()
)
except OSError:
extra_logs = []
if extra_logs:
lines = []
for name in extra_logs:
lp = logs_dir / name
try:
lines.append(f"{name}\t{lp.stat().st_size} bytes")
except OSError as e:
lines.append(f"{name}\t{e}")
findings.append(
Finding(
id="jamf-016",
title="Jamf-related log files under /Library/Logs",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Filenames and sizes only — contents not ingested.",
evidence="\n".join(lines),
worksheet="MDM Jamf",
mitre_techniques=("T1070",),
)
)
if platform.system() == "Darwin":
code, out, err = run_text(["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], timeout=30)
blob = (out or "") + (err or "")
jamf_listen = [ln for ln in blob.splitlines() if "jamf" in ln.lower()]
if jamf_listen:
findings.append(
Finding(
id="jamf-017",
title="Listening TCP sockets involving Jamf (lsof excerpt)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description=(
"Lines from `lsof -nP -iTCP -sTCP:LISTEN` containing `jamf` (local agent or helper "
"listener surface — read-only enumeration)."
),
evidence="\n".join(jamf_listen),
worksheet="MDM Jamf",
mitre_techniques=("T1046", "T1071"),
references=("https://github.com/MacJediWizard/JAMF-Ecosystem-Analyzer-and-Report",),
)
)
for alt_bin in (Path("/usr/local/jamf/bin/jamf"), Path("/opt/jamf/bin/jamf")):
if alt_bin.is_file() and (not jamf_bin.is_file() or alt_bin.resolve() != jamf_bin.resolve()):
findings.append(
Finding(
id="jamf-018",
title="Additional Jamf binary path",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Alternate Jamf agent install prefix observed (compare to /usr/local/bin/jamf).",
evidence=str(alt_bin.resolve()),
worksheet="MDM Jamf",
mitre_techniques=("T1106",),
)
)
break
cp_store = Path("/private/var/db/ConfigurationProfiles/Store")
if ctx.is_root() and cp_store.is_dir():
try:
entries = sorted(p.name for p in cp_store.iterdir())
findings.append(
Finding(
id="jamf-011",
title="ConfigurationProfiles Store (MDM payload store, filenames)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description=(
"Apple MDM configuration profile payload store on disk (read-only directory listing). "
"Relevant to Jamf and other MDM vendors that deliver profiles via the same subsystem."
),
evidence="\n".join(entries) if entries else "(empty)",
worksheet="MDM Jamf",
mitre_techniques=("T1012", "T1553.004"),
references=("https://github.com/jamf/API_Scripts",),
)
)
except OSError as e:
findings.append(
Finding(
id="jamf-012",
title="ConfigurationProfiles Store listing failed",
category="MDM: Jamf",
severity=Severity.LOW,
description="Store path exists but could not be listed.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=(),
)
)
return findings
# ---------------------------------------------------------------------------
# New checks: Jamf credential exposure and attack surface (JamfHound / Eve / Jamf-Attack-Toolkit)
# ---------------------------------------------------------------------------
def _redact_cred_match(text: str) -> str:
"""Replace the secret value in a credential match, keeping the key/prefix."""
redacted = re.sub(r'(?i)(\s*[=:]\s*|\s+)\S+$', r'\1[REDACTED]', text)
if redacted == text:
return text[:12] + "[REDACTED]"
return redacted
def _scan_file_for_creds(path: Path) -> list[str]:
"""Return a list of redacted match excerpts from a file, capped at _CRED_MAX_BYTES."""
try:
raw = path.read_bytes()[:_CRED_MAX_BYTES]
text = raw.decode("utf-8", errors="replace")
except OSError as e:
return [f"{path.name}: (unreadable — {e})"]
hits: list[str] = []
for pat in _CRED_PATTERNS:
for m in pat.finditer(text):
excerpt = _redact_cred_match(m.group(0)[:120])
hits.append(f"{path.name}: {excerpt}")
return hits
def check_jamf_script_cache(ctx: RunContext) -> list[Finding]:
"""Scan Jamf local script/package cache directories for embedded credentials.
JamfDumper research (ReversecLabs/Jamf-Attack-Toolkit) shows that Jamf policies
frequently contain hardcoded credentials in script arguments. These are downloaded
to local cache directories and may be readable by unprivileged or low-privilege users.
"""
scan_dirs = [
Path("/Library/Application Support/JAMF/Downloads"),
Path("/Library/Application Support/JAMF/Scripts"),
Path("/Library/Application Support/JAMF/Offline Policies"),
]
findings: list[Finding] = []
for scan_dir in scan_dirs:
if not scan_dir.is_dir():
continue
try:
files = [p for p in scan_dir.rglob("*") if p.is_file()]
except OSError as e:
findings.append(Finding(
id="jamf-cred-001",
title=f"Jamf cache scan failed: {scan_dir.name}",
category="Credentials",
severity=Severity.LOW,
description=f"Could not enumerate {scan_dir}.",
evidence=str(e),
worksheet="Credentials",
mitre_techniques=("T1552",),
))
continue
cred_hits: list[str] = []
script_exts = {".sh", ".py", ".rb", ".pl", ".bash", ".zsh", ".js", ".xml", ".plist", ""}
for f in files:
if f.suffix.lower() in script_exts or not f.suffix:
cred_hits.extend(_scan_file_for_creds(f))
dir_label = scan_dir.name
if cred_hits:
findings.append(Finding(
id=f"jamf-cred-002-{dir_label.lower().replace(' ', '_')}",
title=f"Potential credentials in Jamf cache: {dir_label}",
category="Credentials",
severity=Severity.HIGH,
description=(
f"Credential-pattern matches found in files under `{scan_dir}`. "
"Jamf policies frequently embed API keys, passwords, or tokens in script "
"arguments (per ReversecLabs/Jamf-Attack-Toolkit research). These are "
"written to local cache and may be readable by low-privilege users."
),
evidence="\n".join(cred_hits[:50]),
worksheet="Credentials",
mitre_techniques=("T1552", "T1552.001", "T1078"),
risk="Hardcoded credentials in policy scripts may expose service accounts or API tokens.",
impact="An attacker with local access could harvest credentials for lateral movement or Jamf API abuse.",
remediation=(
"Replace hardcoded credentials with Jamf-managed parameters or an external secrets manager. "
"Audit policy scripts via the Jamf Pro console (`/JSSResource/scripts`). "
"Restrict cache directory permissions."
),
references=(
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit",
"https://labs.withsecure.com/publications/jamfing-for-joy-attacking-macos-in-enterprise",
),
))
else:
findings.append(Finding(
id=f"jamf-cred-003-{dir_label.lower().replace(' ', '_')}",
title=f"Jamf cache scanned — no credential patterns: {dir_label}",
category="Credentials",
severity=Severity.INFORMATIONAL,
description=f"No common credential patterns found in {scan_dir} ({len(files)} files examined).",
evidence=f"Files examined: {len(files)}\nDirectory: {scan_dir}",
worksheet="Credentials",
mitre_techniques=("T1552",),
))
return findings
def check_jamf_process_credential_exposure(_ctx: RunContext) -> list[Finding]:
"""Check running processes for Jamf policy executions with embedded credentials in arguments.
JamfExplorer.py (ReversecLabs/Jamf-Attack-Toolkit) demonstrates that Jamf policy
script arguments are visible in `ps` output even to unprivileged users, exposing
any credentials passed as script parameters at execution time.
"""
if platform.system() != "Darwin":
return []
code, out, err = run_text(["/bin/ps", "auxww"], timeout=15)
text = (out + err)
jamf_procs = [ln for ln in text.splitlines() if "jamf" in ln.lower() or "JamfDaemon" in ln]
cred_hits: list[str] = []
for line in jamf_procs:
for pat in _CRED_PATTERNS:
m = pat.search(line)
if m:
redacted_line = line[:m.start()] + _redact_cred_match(m.group(0)) + line[m.end():]
cred_hits.append(redacted_line[:300])
break
if cred_hits:
return [Finding(
id="jamf-cred-004",
title="Jamf process arguments contain credential patterns",
category="Credentials",
severity=Severity.CRITICAL,
description=(
"Running Jamf-related processes have credential-pattern matches in their argument list. "
"These are visible to all users via `ps`. This is the attack vector demonstrated by "
"JamfExplorer.py in the Jamf-Attack-Toolkit."
),
evidence="\n".join(cred_hits[:20]),
worksheet="Credentials",
mitre_techniques=("T1552", "T1057", "T1078"),
risk="Any local user can read process arguments via ps, harvesting credentials in real time.",
impact="Live credential exposure enables immediate lateral movement or Jamf API takeover.",
remediation=(
"Pass credentials via environment variables set from a secrets manager, not as script arguments. "
"Use Jamf parameter labels (Parameters 411) with values injected server-side and not logged."
),
references=(
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit",
"https://learn.jamf.com/en-US/bundle/jamf-pro-documentation-current/page/Script_Parameters.html",
),
)]
ev = f"Jamf-related processes found: {len(jamf_procs)}\nNo credential patterns detected in ps output."
if not jamf_procs:
ev = "No active Jamf processes observed in ps output (policies may not be currently executing)."
return [Finding(
id="jamf-cred-004",
title="Jamf process argument credential exposure (ps scan)",
category="Credentials",
severity=Severity.INFORMATIONAL,
description=(
"Scanned `ps auxww` for Jamf-related processes and credential patterns in arguments. "
"No live credential exposure detected at scan time — this is a point-in-time check. "
"The risk exists whenever a Jamf policy with hardcoded script arguments executes."
),
evidence=ev,
worksheet="Credentials",
mitre_techniques=("T1552", "T1057"),
remediation="Audit policy script parameters in Jamf Pro and replace hardcoded values.",
references=("https://github.com/ReversecLabs/Jamf-Attack-Toolkit",),
)]
def check_jamf_login_hooks(ctx: RunContext) -> list[Finding]:
"""Check for Jamf-deployed login/logout hooks in the loginwindow plist.
Login hooks execute as root at login; attackers and red teamers use Jamf policies
to deploy them for persistence. Eve (RobotOperator/Eve) documents this technique.
Also checks HiddenUsersList for Jamf management accounts.
"""
plist_paths = [
Path("/private/var/root/Library/Preferences/com.apple.loginwindow.plist"),
Path("/Library/Preferences/com.apple.loginwindow.plist"),
]
findings: list[Finding] = []
for plist_path in plist_paths:
if not plist_path.is_file():
continue
try:
data = plistlib.loads(plist_path.read_bytes())
except (OSError, plistlib.InvalidFileException, ValueError) as e:
findings.append(Finding(
id="jamf-cred-005",
title=f"loginwindow plist unreadable: {plist_path.name}",
category="MDM: Jamf",
severity=Severity.LOW,
description=f"Could not parse {plist_path}.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=("T1546.011",),
))
continue
hook_keys = ("LoginHook", "LogoutHook", "LoginWindowScript")
hooks_found = {k: str(data[k]) for k in hook_keys if k in data}
hidden = data.get("HiddenUsersList", [])
if hooks_found:
ev_lines = [f"{k}: {v}" for k, v in hooks_found.items()]
if hidden:
ev_lines.append(f"HiddenUsersList: {', '.join(str(u) for u in hidden)}")
findings.append(Finding(
id="jamf-cred-006",
title=f"Login hook(s) configured in {plist_path.name}",
category="Credentials",
severity=Severity.HIGH,
description=(
"LoginHook or LogoutHook entries found in the loginwindow plist. These execute "
"as root at login/logout and are a known Jamf persistence and privilege escalation "
"vector (documented in RobotOperator/Eve and Eve SpecterOps research)."
),
evidence="\n".join(ev_lines),
worksheet="Credentials",
mitre_techniques=("T1546.011", "T1078.003", "T1543"),
risk="Login hooks run as root; a compromised Jamf policy can install persistent backdoors.",
impact="Full local root code execution persisting across reboots.",
remediation=(
"Remove unauthorized login hooks. Audit via `defaults read /Library/Preferences/com.apple.loginwindow`. "
"Restrict Jamf policy creation permissions; monitor LoginHook changes with EDR."
),
references=(
"https://github.com/RobotOperator/Eve",
"https://support.apple.com/en-gb/101990",
),
))
elif hidden:
findings.append(Finding(
id="jamf-cred-007",
title=f"Hidden users in loginwindow plist: {plist_path.name}",
category="MDM: Jamf",
severity=Severity.LOW,
description=(
"HiddenUsersList entries found — Jamf may create a hidden management account. "
"Verify these are authorised MDM-managed accounts."
),
evidence=f"HiddenUsersList: {', '.join(str(u) for u in hidden)}",
worksheet="MDM Jamf",
mitre_techniques=("T1078", "T1564.002"),
remediation="Confirm hidden accounts are authorised management accounts; remove any orphaned entries.",
references=("https://github.com/RobotOperator/Eve",),
))
else:
findings.append(Finding(
id="jamf-cred-005-clean",
title=f"No login hooks in {plist_path.name}",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="loginwindow plist has no LoginHook, LogoutHook, or HiddenUsersList entries.",
evidence=f"Keys present: {sorted(data.keys())}",
worksheet="MDM Jamf",
mitre_techniques=("T1546.011",),
))
return findings
def check_jamf_extension_attribute_cache(ctx: RunContext) -> list[Finding]:
"""Enumerate Jamf extension attribute cache for sensitive data exposure.
Eve (RobotOperator/Eve) demonstrates reading and writing extension attributes as an
attack vector: EAs can store and exfiltrate data, or be used for C2 communication.
Local EA cache files may expose data collected from the host.
"""
ea_dirs = [
Path("/Library/Application Support/JAMF/tmp"),
Path("/Library/Application Support/JAMF"),
]
ea_files: list[Path] = []
for d in ea_dirs:
if not d.is_dir():
continue
try:
ea_files.extend(p for p in d.glob("*.xml") if p.is_file())
ea_files.extend(p for p in d.glob("*ea*") if p.is_file())
ea_files.extend(p for p in d.glob("*extension*") if p.is_file())
except OSError:
pass
if not ea_files:
return [Finding(
id="jamf-cred-008",
title="Jamf extension attribute cache files not found",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="No extension attribute cache files found in expected Jamf directories.",
evidence="\n".join(str(d) for d in ea_dirs),
worksheet="MDM Jamf",
mitre_techniques=("T1005",),
references=("https://github.com/RobotOperator/Eve",),
)]
rows = []
for f in ea_files[:30]:
try:
rows.append(f"{f.name}\t{f.stat().st_size} bytes")
except OSError:
rows.append(f"{f.name}\t(unreadable)")
return [Finding(
id="jamf-cred-008",
title="Jamf extension attribute cache files present",
category="MDM: Jamf",
severity=Severity.LOW,
description=(
"Extension attribute cache files found in Jamf directories. "
"Per Eve (RobotOperator/Eve) research, EAs can be used for data exfiltration "
"and C2 staging — review contents for sensitive data."
),
evidence="\n".join(rows),
worksheet="MDM Jamf",
mitre_techniques=("T1005", "T1132", "T1041"),
risk="EAs written by Jamf scripts may cache collected host data; attackers can read or tamper with them.",
impact="Data collected by EAs (e.g., installed software, config values) may leak sensitive information.",
remediation="Audit EA definitions in Jamf Pro; restrict EA creation permissions to administrators only.",
references=(
"https://github.com/RobotOperator/Eve",
"https://developer.jamf.com/jamf-pro/reference/get_v1-computer-extension-attributes",
),
)]
def check_jamf_keychain_entries(_ctx: RunContext) -> list[Finding]:
"""Check for Jamf-related keychain entries that may store MDM credentials or tokens."""
services = ("jamf", "com.jamfsoftware", "JSS", "jamfcloud")
hits: list[str] = []
for svc in services:
code, out, err = run_text(
["/usr/bin/security", "find-generic-password", "-s", svc],
timeout=10,
)
blob = (out + err).strip()
if code == 0 and blob:
# Redact any acct/password fields before including in evidence
safe = re.sub(r'(?i)(password|"acct"|"svce")\s*.*', r'\1: [REDACTED]', blob)
hits.append(f"service={svc}\n{safe}")
if hits:
return [Finding(
id="jamf-cred-009",
title="Jamf-related keychain entries found",
category="Credentials",
severity=Severity.MEDIUM,
description=(
"Keychain entries referencing Jamf service names were found. These may store "
"MDM enrollment credentials, management account passwords, or API tokens. "
"Contents are not extracted here — manual review required."
),
evidence="\n---\n".join(hits),
worksheet="Credentials",
mitre_techniques=("T1555.001", "T1078"),
risk="Keychain entries accessible to the current user may expose MDM credentials.",
impact="Harvesting these credentials could enable Jamf API access or lateral movement.",
remediation=(
"Audit keychain access controls with `security dump-keychain -a`. "
"Ensure management credentials are stored only in system keychain items "
"accessible exclusively to root."
),
references=(
"https://github.com/SpecterOps/JamfHound",
"https://developer.jamf.com/jamf-pro/docs/jamf-pro-api-developer-resources",
),
)]
return [Finding(
id="jamf-cred-009",
title="No Jamf-related keychain entries found",
category="Credentials",
severity=Severity.INFORMATIONAL,
description="security find-generic-password found no entries for known Jamf service names.",
evidence=f"Services checked: {', '.join(services)}",
worksheet="Credentials",
mitre_techniques=("T1555.001",),
)]
def check_jss_url_and_api_surface(_ctx: RunContext) -> list[Finding]:
"""Extract the JSS URL and enumerate the Jamf API attack surface from local config.
The JSS URL is needed to build JamfHound attack paths; it also indicates
whether the host is enrolled in a cloud vs on-premise Jamf Pro instance.
API surface data from JamfHound node: jamf_Tenant.
"""
plist = Path("/Library/Preferences/com.jamfsoftware.jamf.plist")
if not plist.is_file():
return [Finding(
id="jamf-cred-010",
title="JSS URL not found (Jamf plist absent)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="com.jamfsoftware.jamf.plist not found; cannot determine JSS URL or enrollment state.",
evidence="Not found",
worksheet="MDM Jamf",
mitre_techniques=("T1012",),
)]
try:
data = plistlib.loads(plist.read_bytes())
except (OSError, plistlib.InvalidFileException, ValueError) as e:
return [Finding(
id="jamf-cred-010",
title="JSS plist unreadable",
category="MDM: Jamf",
severity=Severity.LOW,
description="Could not parse com.jamfsoftware.jamf.plist.",
evidence=str(e),
worksheet="MDM Jamf",
mitre_techniques=("T1012",),
)]
jss_url = str(data.get("jss_url") or data.get("jamf_url") or "(not found)")
is_cloud = "jamfcloud.com" in jss_url.lower()
cloud_label = "Jamf Cloud (jamfcloud.com)" if is_cloud else "On-premise Jamf Pro"
sev = Severity.INFORMATIONAL
ev_lines = [
f"JSS URL: {jss_url}",
f"Deployment type: {cloud_label}",
f"Plist keys: {sorted(data.keys())}",
]
return [Finding(
id="jamf-cred-010",
title=f"JSS URL identified: {cloud_label}",
category="MDM: Jamf",
severity=sev,
description=(
f"The Jamf Pro Server URL is `{jss_url}` ({cloud_label}). "
"This is the JamfHound `jamf_Tenant` node for BloodHound attack path mapping. "
"API endpoints reachable at this URL include `/api/v2/computers`, `/api/v2/scripts`, "
"and `/api/v2/computerextensionattributes` (require valid credentials)."
),
evidence="\n".join(ev_lines),
worksheet="MDM Jamf",
mitre_techniques=("T1012", "T1590"),
risk="Knowing the JSS URL enables targeted API enumeration and credential stuffing (JamfSniper technique).",
impact="API access with valid credentials grants visibility and control over all enrolled Macs.",
remediation=(
"Enable Jamf Pro API rate limiting and alerting. "
"Use short-lived API client credentials (OAuth2) rather than username/password. "
"Restrict API client scope to least-privilege roles."
),
references=(
"https://github.com/SpecterOps/JamfHound",
"https://github.com/ReversecLabs/Jamf-Attack-Toolkit",
"https://developer.jamf.com/jamf-pro/docs/jamf-pro-api-developer-resources",
),
)]

View File

@@ -0,0 +1,435 @@
"""Kandji local posture (read-only; documentation-aligned heuristics)."""
from __future__ import annotations
import os
import platform
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
def register(registry: CheckRegistry) -> None:
registry.register("kandji_local", check_kandji_local, phases=("unprivileged", "privileged"))
def _list_dir_names(path: Path) -> str:
if not path.is_dir():
return f"(not a directory: {path})"
try:
names = sorted(p.name for p in path.iterdir())
return "\n".join(names) if names else "(empty directory)"
except OSError as e:
return str(e)
def check_kandji_local(ctx: RunContext) -> list[Finding]:
findings: list[Finding] = []
candidates = [
Path("/usr/local/bin/kandji"),
Path("/Library/Application Support/Kandji/Kandji Agent.app"),
ctx.home / "Library" / "Application Support" / "Kandji",
Path("/Library/Application Support/Kandji"),
]
hits = [str(p) for p in candidates if p.exists()]
if hits:
evidence = "\n".join(hits)
findings.append(
Finding(
id="kandji-001",
title="Kandji artefacts detected",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Kandji agent or support paths present. Review library items, blueprints, and assignment "
"maps in the tenant; ApplePY performs only local, read-only inspection."
),
evidence=evidence,
worksheet="MDM Kandji",
mitre_techniques=("T1078", "T1012"),
risk="MDM agents are high-value targets for policy and credential abuse during engagements.",
impact="Misconfiguration may weaken enforcement of updates, encryption, or login controls.",
remediation="Validate Kandji API access scopes, script payloads, and device attestation posture.",
references=(
"https://github.com/kandji-inc/security-toolkit",
"https://support.kandji.io/kb/macos-check-in",
"https://api-docs.kandji.io/",
"https://support.kandji.io/kb/kandji-api",
),
)
)
else:
findings.append(
Finding(
id="kandji-002",
title="No common Kandji paths observed",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Absence is heuristic only; agents may use non-standard locations.",
evidence="Checked kandji binary, Kandji Application Support paths, user Library.",
worksheet="MDM Kandji",
mitre_techniques=(),
)
)
kb = Path("/usr/local/bin/kandji")
if kb.is_file():
code, out, err = run_text([str(kb), "--version"], timeout=10)
if code != 0:
code, out, err = run_text([str(kb), "version"], timeout=10)
blob = (out + err).strip()
if blob:
findings.append(
Finding(
id="kandji-003",
title="Kandji CLI version string",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Version output from local binary when supported.",
evidence=blob,
worksheet="MDM Kandji",
mitre_techniques=(),
)
)
managed = Path("/Library/Managed Preferences")
if managed.is_dir():
findings.append(
Finding(
id="kandji-004",
title="Managed Preferences directory (MDM-delivered settings)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Lists plist names commonly pushed by MDM vendors including Kandji-style library items. "
"Filenames only."
),
evidence=_list_dir_names(managed),
worksheet="MDM Kandji",
mitre_techniques=("T1012",),
remediation="Map plist domains to your MDM console and CIS/NIST expectations.",
)
)
user_managed = ctx.home / "Library" / "Managed Preferences"
if user_managed.is_dir():
findings.append(
Finding(
id="kandji-006",
title="User Managed Preferences (per-user MDM payloads)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Per-user MDM preference domain files (filenames only). Useful for PPPC, login window, "
"and screen-recording policy hints without opening payloads."
),
evidence=_list_dir_names(user_managed),
worksheet="MDM Kandji",
mitre_techniques=("T1012", "T1547"),
remediation="Correlate domains with Kandji library items (PPPC, passcode, screen recording).",
references=("https://support.kandji.io/kb/create-a-privacy-preferences-policy-control-pppc-library-item",),
)
)
logs_dir = Path("/Library/Logs")
if logs_dir.is_dir():
klogs = sorted(logs_dir.glob("kandji*.log"))
if klogs:
lines = []
for lp in klogs:
try:
lines.append(f"{lp} ({lp.stat().st_size} bytes)")
except OSError as e:
lines.append(f"{lp}: {e}")
findings.append(
Finding(
id="kandji-005",
title="Kandji log files under /Library/Logs",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Log presence only — contents not ingested.",
evidence="\n".join(lines),
worksheet="MDM Kandji",
mitre_techniques=("T1070",),
)
)
ld = Path("/Library/LaunchDaemons")
if ld.is_dir():
try:
k_plists = sorted(
p.name
for p in ld.iterdir()
if p.is_file() and p.suffix == ".plist" and "kandji" in p.name.lower()
)
except OSError as e:
findings.append(
Finding(
id="kandji-007",
title="Kandji LaunchDaemon plist listing failed",
category="MDM: Kandji",
severity=Severity.LOW,
description="Could not enumerate /Library/LaunchDaemons for Kandji-related plists.",
evidence=str(e),
worksheet="MDM Kandji",
mitre_techniques=(),
)
)
else:
if k_plists:
findings.append(
Finding(
id="kandji-007",
title="Kandji-related LaunchDaemon plists (filenames)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Complete list of `.plist` filenames in /Library/LaunchDaemons whose names contain "
"`kandji` (case-insensitive)."
),
evidence="\n".join(k_plists),
worksheet="MDM Kandji",
mitre_techniques=("T1543.001", "T1012"),
references=("https://support.kandji.io/kb/macos-check-in",),
)
)
prefs_dir = Path("/Library/Preferences")
if prefs_dir.is_dir():
try:
k_prefs = sorted(prefs_dir.glob("com.kandji*.plist"))
except OSError as e:
findings.append(
Finding(
id="kandji-008",
title="Kandji Library Preferences glob failed",
category="MDM: Kandji",
severity=Severity.LOW,
description="Could not glob /Library/Preferences for com.kandji*.plist.",
evidence=str(e),
worksheet="MDM Kandji",
mitre_techniques=(),
)
)
else:
if k_prefs:
lines: list[str] = []
for plist in k_prefs:
code, po, pe = run_text(["/usr/bin/plutil", "-p", str(plist)], timeout=15)
blob = ((po or "") + (pe or "")).strip()
lines.append(f"=== {plist.name} (plutil exit={code}) ===\n{blob}")
findings.append(
Finding(
id="kandji-008",
title="Kandji preference plists (plutil)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Structured dump of `/Library/Preferences/com.kandji*.plist` when present — "
"agent URL hints and local identifiers may appear (read-only)."
),
evidence="\n\n".join(lines),
worksheet="MDM Kandji",
mitre_techniques=("T1012", "T1078"),
references=("https://support.kandji.io/kb/kandji-agent-settings-profile",),
)
)
k_support = Path("/Library/Application Support/Kandji")
if k_support.is_dir():
max_files = 800
try:
max_files = max(50, int(os.environ.get("APPLEPY_KANDJI_MAX_FILES", "800")))
except ValueError:
max_files = 800
rows_k: list[str] = []
capped = False
n = 0
try:
for p in sorted(k_support.rglob("*")):
if n >= max_files:
capped = True
break
if not p.is_file():
continue
try:
rel = p.relative_to(k_support)
rows_k.append(f"{rel}\t{p.stat().st_size}")
except OSError as e:
rows_k.append(f"{p}\t{e}")
n += 1
except OSError as e:
rows_k.append(str(e))
if rows_k:
ev = "\n".join(rows_k)
if capped:
ev += f"\n\n(listing capped at {max_files} files; raise APPLEPY_KANDJI_MAX_FILES if needed)"
findings.append(
Finding(
id="kandji-009",
title="Kandji Application Support tree (files, capped walk)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Recursive file paths under /Library/Application Support/Kandji with sizes — "
"contents not read. Cap avoids enormous reports on busy agents."
),
evidence=ev,
worksheet="MDM Kandji",
mitre_techniques=("T1083", "T1012"),
references=("https://support.kandji.io/kb/kandji-agent-settings-profile",),
)
)
la = Path("/Library/LaunchAgents")
if la.is_dir():
try:
k_la = sorted(
p.name for p in la.iterdir() if p.is_file() and p.suffix == ".plist" and "kandji" in p.name.lower()
)
except OSError as e:
findings.append(Finding(
id="kandji-011",
title="Kandji LaunchAgents enumeration failed",
category="MDM: Kandji",
severity=Severity.LOW,
description="Could not enumerate /Library/LaunchAgents for Kandji-related plists.",
evidence=str(e),
worksheet="MDM Kandji",
mitre_techniques=("T1543.001",),
))
k_la = []
if k_la:
findings.append(
Finding(
id="kandji-010",
title="Kandji-related LaunchAgent plists (/Library/LaunchAgents)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="System LaunchAgents whose filenames reference Kandji.",
evidence="\n".join(k_la),
worksheet="MDM Kandji",
mitre_techniques=("T1543.001",),
)
)
receipts = Path("/var/db/receipts")
if receipts.is_dir():
try:
rk = sorted(p.name for p in receipts.iterdir() if p.is_file() and "kandji" in p.name.lower())
except OSError:
rk = []
if rk:
findings.append(
Finding(
id="kandji-011",
title="Installer receipts mentioning Kandji (/var/db/receipts)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Package receipt filenames (install footprint).",
evidence="\n".join(rk),
worksheet="MDM Kandji",
mitre_techniques=("T1083",),
)
)
u_pref = ctx.home / "Library" / "Preferences"
if u_pref.is_dir():
try:
uk = sorted(p.name for p in u_pref.glob("*kandji*") if p.is_file())
except OSError:
uk = []
if uk:
findings.append(
Finding(
id="kandji-012",
title="User Preferences files matching *kandji*",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Filenames only under ~/Library/Preferences.",
evidence="\n".join(uk),
worksheet="MDM Kandji",
mitre_techniques=("T1012",),
)
)
pht = Path("/Library/PrivilegedHelperTools")
if pht.is_dir():
try:
kh = sorted(p.name for p in pht.iterdir() if p.is_file() and "kandji" in p.name.lower())
except OSError:
kh = []
if kh:
findings.append(
Finding(
id="kandji-013",
title="Kandji-related Privileged Helper Tools",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description="Elevated helpers whose names reference Kandji.",
evidence="\n".join(kh),
worksheet="MDM Kandji",
mitre_techniques=("T1543.001", "T1553.004"),
)
)
if platform.system() == "Darwin":
code, out, err = run_text(["/usr/sbin/lsof", "-nP", "-iTCP", "-sTCP:LISTEN"], timeout=30)
blob = (out or "") + (err or "")
k_listen = [ln for ln in blob.splitlines() if "kandji" in ln.lower()]
if k_listen:
findings.append(
Finding(
id="kandji-014",
title="Listening TCP sockets involving Kandji (lsof excerpt)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Local listener lines from `lsof -nP -iTCP -sTCP:LISTEN` containing `kandji` — "
"possible agent API or helper surface (read-only)."
),
evidence="\n".join(k_listen),
worksheet="MDM Kandji",
mitre_techniques=("T1046", "T1071", "T1102"),
references=("https://support.kandji.io/kb/using-kandji-on-enterprise-networks",),
)
)
cp_store = Path("/private/var/db/ConfigurationProfiles/Store")
if ctx.is_root() and cp_store.is_dir():
try:
cpe = sorted(p.name for p in cp_store.iterdir())
except OSError as e:
findings.append(
Finding(
id="kandji-015",
title="ConfigurationProfiles Store listing failed (Kandji worksheet)",
category="MDM: Kandji",
severity=Severity.LOW,
description="Could not list MDM payload store (shared with other MDM vendors).",
evidence=str(e),
worksheet="MDM Kandji",
mitre_techniques=(),
)
)
else:
findings.append(
Finding(
id="kandji-015",
title="ConfigurationProfiles Store (MDM payloads, filenames)",
category="MDM: Kandji",
severity=Severity.INFORMATIONAL,
description=(
"Same on-disk store as other Apple MDM clients; duplicated here for Kandji worksheet "
"self-containment during review."
),
evidence="\n".join(cpe) if cpe else "(empty)",
worksheet="MDM Kandji",
mitre_techniques=("T1012", "T1553.004"),
)
)
return findings

190
applepy/checks/mitre.py Normal file
View File

@@ -0,0 +1,190 @@
"""Post-scan MITRE ATT&CK mapping worksheet rows and gap notes."""
from __future__ import annotations
import json
from collections import defaultdict
from importlib import resources
from typing import Final
from applepy.findings import Finding, Severity
# Fallback if bundled CTI extract is missing (subset).
_REFERENCE_FALLBACK: Final[frozenset[str]] = frozenset(
{
"T1059",
"T1059.002",
"T1059.004",
"T1059.006",
"T1059.007",
"T1082",
"T1012",
"T1201",
"T1543.001",
"T1543.004",
"T1548.001",
"T1552.001",
"T1552.002",
"T1552.004",
"T1553.001",
"T1555",
"T1562.001",
"T1562.004",
"T1547",
"T1539",
"T1528",
"T1090",
"T1572",
"T1105",
"T1046",
"T1048",
"T1219",
"T1021.001",
"T1626",
"T1222",
"T1021.004",
"T1087",
"T1565",
"T1548.003",
"T1566.001",
"T1580",
}
)
_DATA_NAME: Final[str] = "mitre_enterprise_macos_techniques.json"
def _load_macos_technique_reference() -> frozenset[str]:
"""Technique IDs from MITRE ATT&CK Enterprise whose objects list macOS as a platform."""
try:
pkg = resources.files("applepy.data")
data_path = pkg / _DATA_NAME
with data_path.open(encoding="utf-8") as f:
blob = json.load(f)
raw = blob.get("technique_ids")
if isinstance(raw, list):
ids = frozenset(str(x) for x in raw if isinstance(x, str) and x.startswith("T"))
if ids:
return ids
except (OSError, TypeError, ValueError, json.JSONDecodeError):
pass
return _REFERENCE_FALLBACK
_REFERENCE_MACOS_TECHNIQUES: frozenset[str] = _load_macos_technique_reference()
def _attack_technique_url(technique_id: str) -> str:
"""Stable ATT&CK technique URL for Enterprise (handles sub-techniques e.g. T1548.001)."""
tid = technique_id.strip().upper()
if not tid.startswith("T"):
return "https://attack.mitre.org/matrices/enterprise/macos/"
if "." in tid:
main, sub = tid.split(".", 1)
return f"https://attack.mitre.org/techniques/{main}/{sub}/"
return f"https://attack.mitre.org/techniques/{tid}/"
_DEFER_CHUNK_SIZE: Final[int] = 40
_DEFER_DESCRIPTION: Final[str] = (
"ATT&CK Enterprise techniques that list macOS as a platform (bundled MITRE CTI extract) but are **not** "
"linked to any **host-observable** ApplePY check output on this run. These `map-defer-*` rows exist so the "
"MITRE Mapping worksheet enumerates the **full** matrix scope for planning — they do **not** assert "
"detection, absence of adversary behaviour, or control effectiveness. Assess each ID via ATT&CK, telemetry, "
"and threat intelligence."
)
def augment_mitre_worksheet(findings: list[Finding]) -> None:
"""Append synthetic findings for worksheet `MITRE Mapping` (per-technique links + honest deferral chunks)."""
by_tech: dict[str, list[str]] = defaultdict(list)
for f in findings:
if f.id.startswith("map-"):
continue
for t in f.mitre_techniques:
by_tech[t].append(f.id)
for tech in sorted(by_tech.keys()):
ids = ", ".join(sorted(set(by_tech[tech])))
page = _attack_technique_url(tech)
findings.append(
Finding(
id=f"map-{tech}",
title=f"ATT&CK {tech} — linked from this assessment",
category="MITRE",
severity=Severity.INFORMATIONAL,
description=(
f"At least one ApplePY observation on this run referenced ATT&CK **{tech}**. "
"Use the technique page for adversary context, data sources, and mitigations; the finding "
"IDs under Evidence tie this workbook back to concrete host artefacts."
),
evidence=(
f"attack_technique_page={page}\n"
f"applepy_finding_ids={ids}\n"
"Interpretation: linkage expresses thematic coverage in this scan, not confirmation of "
"adversary activity or control effectiveness."
),
worksheet="MITRE Mapping",
mitre_techniques=(tech,),
references=(page, "https://attack.mitre.org/matrices/enterprise/macos/"),
)
)
ref = _REFERENCE_MACOS_TECHNIQUES
uncovered = sorted(ref - set(by_tech.keys()))
defer_chunks = 0
if uncovered:
for i in range(0, len(uncovered), _DEFER_CHUNK_SIZE):
chunk = tuple(uncovered[i : i + _DEFER_CHUNK_SIZE])
defer_chunks += 1
n = defer_chunks
label = f"{n:02d}"
findings.append(
Finding(
id=f"map-defer-{label}",
title=f"ATT&CK macOS matrix — deferred techniques (chunk {label})",
category="MITRE",
severity=Severity.INFORMATIONAL,
description=_DEFER_DESCRIPTION,
evidence=(
f"chunk_index={n}\n"
f"technique_ids_in_chunk={len(chunk)}\n"
"Each line: technique_id ATT&CK_URL\n"
+ "\n".join(f"{tid} {_attack_technique_url(tid)}" for tid in chunk)
),
worksheet="MITRE Mapping",
mitre_techniques=chunk,
references=(
"https://attack.mitre.org/matrices/enterprise/macos/",
"https://github.com/mitre/cti",
),
)
)
findings.append(
Finding(
id="map-summary",
title="MITRE Mapping worksheet — coverage summary",
category="MITRE",
severity=Severity.INFORMATIONAL,
description=(
"Counts for this run against the bundled Enterprise macOS technique list. "
"`linked` means at least one non-synthetic finding carried the technique ID. "
"`deferred` rows (`map-defer-*`) list matrix IDs without a dedicated local observable check — "
"not a failure state."
),
evidence=(
f"bundled_reference_size={len(ref)}\n"
f"linked_technique_count={len(by_tech)}\n"
f"deferred_technique_count={len(uncovered)}\n"
f"defer_chunk_findings={defer_chunks}\n"
f"reference_data_file=applepy/data/{_DATA_NAME}"
),
worksheet="MITRE Mapping",
mitre_techniques=(),
references=(
"https://attack.mitre.org/matrices/enterprise/macos/",
"https://github.com/mitre/cti",
),
)
)

View File

@@ -0,0 +1,334 @@
"""Plan.md coverage: quarantine sampling, XProtect, Gatekeeper listings, sudoers posture, JXA, app inventory."""
from __future__ import annotations
import os
import platform
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
_QUARANTINE = "com.apple.quarantine"
_XPROTECT_PLISTS = (
Path("/Library/Apple/System/Library/CoreServices/XProtect.app/Contents/Info.plist"),
Path("/Library/Apple/System/Library/CoreServices/XProtect.bundle/Contents/Info.plist"),
)
def _xattr_has_quarantine(path: Path) -> bool:
listxattr = getattr(os, "listxattr", None)
if listxattr is None:
return False
try:
names = listxattr(path, follow_symlinks=False)
except OSError:
return False
return any(n == _QUARANTINE for n in names)
def register(registry: CheckRegistry) -> None:
registry.register("plan_quarantine", check_downloads_quarantine_sample, phases=("unprivileged",))
registry.register("plan_xprotect", check_xprotect_bundle_version, phases=("unprivileged",))
registry.register("plan_spctl_list", check_spctl_assessment_list, phases=("unprivileged",))
registry.register("plan_sudoers", check_sudoers_posture, phases=("privileged",))
registry.register("plan_jxa", check_osascript_javascript, phases=("unprivileged",))
registry.register("plan_app_inventory", check_application_folder_counts, phases=("unprivileged",))
def check_downloads_quarantine_sample(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="plan-001",
title="Downloads quarantine attribute sample (macOS only)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Extended attribute scan applies to macOS; skipped on other platforms.",
evidence=f"platform={platform.system()}",
worksheet="Attack surface",
mitre_techniques=(),
)
]
dl = ctx.home / "Downloads"
if not dl.is_dir():
return [
Finding(
id="plan-001",
title="Downloads folder quarantine sample",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="~/Downloads not present or not a directory; no quarantine sampling performed.",
evidence=str(dl),
worksheet="Attack surface",
mitre_techniques=("T1553.001",),
)
]
scanned = 0
with_q = 0
quarantine_files: list[str] = []
try:
for p in sorted(dl.iterdir(), key=lambda x: x.name.lower()):
if not p.is_file():
continue
scanned += 1
if _xattr_has_quarantine(p):
with_q += 1
try:
quarantine_files.append(f"{p.name} ({p.stat().st_size} bytes)")
except OSError:
quarantine_files.append(p.name)
except OSError as e:
return [
Finding(
id="plan-001",
title="Downloads folder quarantine sample",
category="Attack surface",
severity=Severity.LOW,
description="Could not enumerate ~/Downloads for quarantine extended attributes.",
evidence=str(e),
worksheet="Attack surface",
mitre_techniques=(),
)
]
q_block = "\n".join(quarantine_files) if quarantine_files else "(no regular files carried com.apple.quarantine)"
ev = (
f"regular_files_scanned={scanned} with_com_apple_quarantine={with_q}\n"
f"complete_list_files_with_quarantine:\n{q_block}"
)
return [
Finding(
id="plan-001",
title="Downloads quarantine extended attributes (full directory pass)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Every regular file directly under ~/Downloads checked for `com.apple.quarantine` via listxattr "
"(no content reads). Evidence lists all files that carry the attribute. Aligns with "
"quarantine and Gatekeeper context."
),
evidence=ev,
worksheet="Attack surface",
mitre_techniques=("T1553.001", "T1566.001"),
risk="Files without quarantine may have been created locally or had attributes stripped.",
impact="Downloaded malware may bypass Gatekeeper prompts if quarantine metadata is missing.",
remediation="Investigate unusual downloads; enforce managed browser and attachment policies.",
)
]
def check_xprotect_bundle_version(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="plan-002",
title="XProtect bundle version (macOS only)",
category="Core",
severity=Severity.INFORMATIONAL,
description="Apple XProtect metadata is only meaningful on macOS.",
evidence=platform.system(),
worksheet="Core",
mitre_techniques=(),
)
]
lines: list[str] = []
for plist in _XPROTECT_PLISTS:
if not plist.is_file():
lines.append(f"absent: {plist}")
continue
code, out, err = run_text(
["/usr/bin/plutil", "-extract", "CFBundleShortVersionString", "raw", str(plist)],
timeout=10,
)
ver = (out or "").strip()
lines.append(f"{plist}\n exit={code} CFBundleShortVersionString={ver or err.strip()}")
return [
Finding(
id="plan-002",
title="XProtect bundle version (Apple malware definitions tooling)",
category="Core",
severity=Severity.INFORMATIONAL,
description=(
"Reads CFBundleShortVersionString from Apple-shipped XProtect Info.plist when present. "
"Use for baseline drift versus known good builds; does not query update servers."
),
evidence="\n\n".join(lines),
worksheet="Core",
mitre_techniques=("T1012", "T1562.001"),
references=("https://support.apple.com/guide/security/",),
)
]
def check_spctl_assessment_list(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="plan-003",
title="Gatekeeper assessment database listing (spctl --list)",
category="Core",
severity=Severity.INFORMATIONAL,
description="spctl is macOS-specific; not run on this platform.",
evidence=platform.system(),
worksheet="Core",
mitre_techniques=(),
)
]
code, out, err = run_text(["/usr/sbin/spctl", "--list"], timeout=25)
blob = (out + err).strip()
sev = Severity.INFORMATIONAL if code == 0 else Severity.LOW
return [
Finding(
id="plan-003",
title="Gatekeeper assessment database listing (spctl --list)",
category="Core",
severity=sev,
description=(
"Lists team IDs and assessment modes as reported by spctl. May be empty or error on "
"some macOS builds without Full Disk Access; capture stderr in evidence."
),
evidence=f"exit={code}\n{blob}" if blob else f"exit={code}, (no output)",
worksheet="Core",
mitre_techniques=("T1553.001", "T1012"),
remediation="Compare team IDs to organisational allow-lists; investigate unexpected developers.",
)
]
def check_sudoers_posture(ctx: RunContext) -> list[Finding]:
if not ctx.is_root():
return []
lines: list[str] = []
sudoers = Path("/etc/sudoers")
if sudoers.is_file():
try:
st = sudoers.stat()
lines.append(f"/etc/sudoers mode={oct(st.st_mode & 0o777)} uid={st.st_uid} gid={st.st_gid}")
except OSError as e:
lines.append(f"/etc/sudoers: {e}")
else:
lines.append("/etc/sudoers: not a regular file")
drop = Path("/etc/sudoers.d")
if drop.is_dir():
try:
names = sorted(p.name for p in drop.iterdir() if p.is_file())
lines.append(f"/etc/sudoers.d file_count={len(names)}")
lines.append("names:\n" + "\n".join(names))
except OSError as e:
lines.append(f"/etc/sudoers.d: {e}")
else:
lines.append("/etc/sudoers.d: not a directory")
return [
Finding(
id="plan-004",
title="Sudoers file metadata and sudoers.d inventory (no content read)",
category="Hardening",
severity=Severity.INFORMATIONAL,
description=(
"Privileged stat of /etc/sudoers and filenames under /etc/sudoers.d only — contents are "
"not parsed (avoids leaking rules into reports). Review unexpected drop-in files manually."
),
evidence="\n".join(lines),
worksheet="Hardening",
mitre_techniques=("T1548.003", "T1012"),
risk="Overly permissive sudoers enables privilege escalation.",
impact="Attackers with editor access can grant persistent root execution.",
remediation="Audit sudoers.d with visudo discipline; remove obsolete entries.",
)
]
def check_osascript_javascript(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="plan-005",
title="JavaScript for Automation (JXA) via osascript",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="osascript is macOS-specific; not evaluated on this platform.",
evidence=platform.system(),
worksheet="Attack surface",
mitre_techniques=(),
)
]
code, out, err = run_text(
["/usr/bin/osascript", "-l", "JavaScript", "-e", "2+2"],
timeout=15,
)
blob = (out + err).strip()
ok = code == 0 and any(x.strip() == "4" for x in blob.splitlines() if x.strip())
sev = Severity.INFORMATIONAL if ok else Severity.LOW
return [
Finding(
id="plan-005",
title="JavaScript for Automation (JXA) execution availability",
category="Attack surface",
severity=sev,
description=(
"Minimal `osascript -l JavaScript` probe for SwiftBelt-JXA automation surface. "
"Does not run user scripts or touch MDM APIs."
),
evidence=f"exit={code}\n{blob}",
worksheet="Attack surface",
mitre_techniques=("T1059.002", "T1059.007"),
risk="JXA is a living-off-the-land execution vector on managed Macs.",
impact="Policy gaps may allow unapproved automation or phishing chains.",
remediation="Restrict osascript via PPPC / MDM where policy allows; monitor suspicious JXA.",
references=(
"https://github.com/cedowens/SwiftBelt-JXA",
"https://github.com/cedowens/SwiftBelt",
),
)
]
def _count_apps(d: Path) -> tuple[int, str]:
if not d.is_dir():
return 0, f"(not a directory: {d})"
try:
n = sum(1 for p in d.iterdir() if p.suffix == ".app" and p.is_dir())
return n, "ok"
except OSError as e:
return 0, str(e)
def check_application_folder_counts(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="plan-006",
title="Installed application bundle counts",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="/Applications inventory is intended for macOS; counts may be zero elsewhere.",
evidence=platform.system(),
worksheet="Attack surface",
mitre_techniques=("T1082",),
)
]
sys_n, sys_m = _count_apps(Path("/Applications"))
usr_n, usr_m = _count_apps(ctx.home / "Applications")
ev = (
f"/Applications: count={sys_n} note={sys_m}\n"
f"~/Applications: count={usr_n} note={usr_m}"
)
return [
Finding(
id="plan-006",
title="Installed application bundle counts (SwiftBelt-style inventory)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Counts *.app bundles one level deep under /Applications and ~/Applications for common-paths-style "
"situational awareness (not a full mdfind inventory)."
),
evidence=ev,
worksheet="Attack surface",
mitre_techniques=("T1082", "T1580"),
remediation="Correlate with MDM application block/allow lists and software centre records.",
)
]

569
applepy/checks/privesc.py Normal file
View File

@@ -0,0 +1,569 @@
"""macOS privilege escalation surface checks (read-only detection).
Covers techniques documented in standard macOS security research:
- Sudoers NOPASSWD entries
- Unexpected SUID/SGID binaries outside the SIP-sealed system volume
- Writable binaries referenced by LaunchDaemon/LaunchAgent plists
- World-/group-writable directories on the system PATH
- TCC Full Disk Access and high-risk privacy permissions
"""
from __future__ import annotations
import contextlib
import logging
import plistlib
import sqlite3
import stat
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
_log = logging.getLogger(__name__)
_CATEGORY = "Privilege Escalation"
_WORKSHEET = "Privesc"
def register(registry: CheckRegistry) -> None:
registry.register("privesc_sudoers_nopasswd", check_sudoers_nopasswd, phases=("privileged",))
registry.register("privesc_suid_sgid_unexpected", check_suid_sgid_unexpected, phases=("privileged",))
registry.register("privesc_ld_binary_writable", check_launchdaemon_bin_writable, phases=("unprivileged",))
registry.register("privesc_path_writable_dirs", check_path_writable_dirs, phases=("unprivileged",))
registry.register("privesc_tcc_high_risk", check_tcc_high_risk_permissions, phases=("privileged",))
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _ev_privesc(check_id: str, status: str, expected: str, found: str, raw: str) -> str:
icon = {"PASS": "", "FAIL": "", "WARN": ""}.get(status, "?")
return (
f"Privesc check: {check_id}\n"
f"Status: {icon} {status}\n"
f"Expected: {expected}\n"
f"Found: {found}\n"
f"--- Detail ---\n"
f"{raw}"
)
def _is_writable_by_non_root(path: Path) -> bool:
"""Return True if the file/dir has write bits set for group or others."""
try:
mode = path.stat().st_mode
return bool(mode & (stat.S_IWGRP | stat.S_IWOTH))
except OSError:
return False
def _owner_uid(path: Path) -> int:
try:
return path.stat().st_uid
except OSError:
return -1
# ---------------------------------------------------------------------------
# 1. Sudoers NOPASSWD entries
# ---------------------------------------------------------------------------
def check_sudoers_nopasswd(ctx: RunContext) -> list[Finding]:
"""Detect NOPASSWD entries in sudoers that allow passwordless privilege escalation."""
if not ctx.is_root():
return []
hits: list[str] = []
sudoers_files: list[Path] = [Path("/etc/sudoers")]
sudoers_d = Path("/etc/sudoers.d")
if sudoers_d.is_dir():
sudoers_files.extend(sorted(sudoers_d.iterdir()))
for sudoers_path in sudoers_files:
try:
text = sudoers_path.read_text(errors="replace")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("#") or not stripped:
continue
if "NOPASSWD" in stripped:
hits.append(f"{sudoers_path}:{lineno}: {stripped}")
raw = "\n".join(hits) if hits else "(none found)"
if hits:
status, sev = "FAIL", Severity.HIGH
found_desc = f"{len(hits)} NOPASSWD entry/entries found"
else:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = "No NOPASSWD entries in sudoers"
return [Finding(
id="privesc-sudo-nopasswd",
title="Privilege Escalation — Sudoers NOPASSWD Entries",
category=_CATEGORY,
severity=sev,
description=(
"NOPASSWD entries in /etc/sudoers or /etc/sudoers.d allow the specified users "
"to execute commands as root without entering a password, removing a critical "
"authentication barrier."
),
evidence=_ev_privesc(
"sudoers-nopasswd",
status,
"No NOPASSWD entries in sudoers",
found_desc,
raw,
),
worksheet=_WORKSHEET,
mitre_techniques=("T1548.003",),
risk="An attacker with local access who compromises a user account with NOPASSWD sudo can escalate to root without knowing any password.",
impact="Full root compromise of the endpoint from any process running as the affected user.",
remediation=(
"Remove or restrict NOPASSWD entries:\n"
" sudo visudo\n"
"Replace NOPASSWD: ALL with the minimum specific commands required."
),
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
)]
# ---------------------------------------------------------------------------
# 2. Unexpected SUID/SGID binaries (outside SIP-sealed /System)
# ---------------------------------------------------------------------------
# Standard SUID/SGID binaries shipped and expected on macOS outside /System.
# /System itself is SIP-sealed (read-only authenticated root volume) so binaries
# there cannot be modified and are excluded from the scan entirely.
_EXPECTED_SUID_SGID: frozenset[str] = frozenset({
"/usr/bin/at",
"/usr/bin/atq",
"/usr/bin/atrm",
"/usr/bin/batch",
"/usr/bin/crontab",
"/usr/bin/login",
"/usr/bin/newgrp",
"/usr/bin/passwd",
"/usr/bin/rsh",
"/usr/bin/su",
"/usr/bin/wall",
"/usr/sbin/mkpassdb",
"/usr/sbin/pppd",
"/usr/libexec/authopen",
"/usr/libexec/security_authtrampoline",
"/bin/ps",
"/sbin/mount_nfs",
"/sbin/umount",
})
# Scan only paths outside the SIP-sealed /System volume.
_SUID_SCAN_ROOTS = (
"/usr/local",
"/opt",
"/Applications",
"/Library/Application Support",
"/Library/PreferencePanes",
)
def check_suid_sgid_unexpected(ctx: RunContext) -> list[Finding]:
"""Find SUID/SGID binaries outside the SIP-sealed system volume that are not on the allowlist."""
if not ctx.is_root():
return []
code, out, _err = run_text(
["/usr/bin/find", *_SUID_SCAN_ROOTS,
"-type", "f", "(", "-perm", "-4000", "-o", "-perm", "-2000", ")"],
timeout=60,
)
found_paths = [p for p in out.splitlines() if p.strip()]
unexpected = [p for p in found_paths if p not in _EXPECTED_SUID_SGID]
if unexpected:
status, sev = "FAIL", Severity.HIGH
found_desc = f"{len(unexpected)} unexpected SUID/SGID binary/binaries found outside /System"
detail = "\n".join(unexpected)
elif found_paths:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = f"{len(found_paths)} SUID/SGID binaries found, all on the allowlist"
detail = "\n".join(found_paths)
else:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = "No SUID/SGID binaries found in scanned paths"
detail = "(none found)"
return [Finding(
id="privesc-suid-sgid",
title="Privilege Escalation — Unexpected SUID/SGID Binaries",
category=_CATEGORY,
severity=sev,
description=(
"SUID (set-user-ID) and SGID (set-group-ID) bits cause a binary to run with "
"the owner's privileges regardless of who executes it. Unexpected SUID root "
"binaries outside the SIP-sealed /System volume represent an exploitation "
"or persistence risk."
),
evidence=_ev_privesc(
"suid-sgid",
status,
"No unexpected SUID/SGID binaries outside the SIP-protected system volume",
found_desc,
detail,
),
worksheet=_WORKSHEET,
mitre_techniques=("T1548.001",),
risk="A misconfigured or malicious SUID binary allows any local user to execute code as root.",
impact="Trivial local privilege escalation to root without any authentication.",
remediation=(
"Remove the SUID/SGID bit from unexpected binaries:\n"
" sudo chmod -s /path/to/binary\n"
"Investigate each finding — third-party software may legitimately require elevated bits, "
"but each should be documented and justified."
),
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
)]
# ---------------------------------------------------------------------------
# 3. Writable binaries referenced by LaunchDaemon/LaunchAgent plists
# ---------------------------------------------------------------------------
_LAUNCHD_ROOTS: tuple[Path, ...] = (
Path("/Library/LaunchDaemons"),
Path("/Library/LaunchAgents"),
)
def _parse_launchd_program(plist_path: Path) -> str | None:
"""Return the executable path from a launchd plist, or None if unparseable."""
try:
raw_bytes = plist_path.read_bytes()
except OSError as e:
_log.warning("Cannot read plist %s: %s", plist_path, e)
return None
try:
data = plistlib.loads(raw_bytes)
except (plistlib.InvalidFileException, ValueError, OSError) as e:
_log.warning("Cannot parse plist %s: %s", plist_path, e)
return None
if not isinstance(data, dict):
return None
prog = data.get("Program")
if prog:
return str(prog)
args = data.get("ProgramArguments")
if isinstance(args, list) and args:
return str(args[0])
return None
def check_launchdaemon_bin_writable(ctx: RunContext) -> list[Finding]:
"""Detect LaunchDaemon/LaunchAgent plists whose referenced binary is world- or group-writable."""
hits: list[str] = []
for root in _LAUNCHD_ROOTS:
if not root.is_dir():
continue
for plist_path in sorted(root.glob("*.plist")):
prog = _parse_launchd_program(plist_path)
if not prog:
continue
bin_path = Path(prog)
if not bin_path.is_file():
continue
if _is_writable_by_non_root(bin_path):
try:
mode = oct(bin_path.stat().st_mode)
except OSError:
mode = "unknown"
hits.append(f"{plist_path.name}{prog} (mode {mode})")
raw = "\n".join(hits) if hits else "(none found)"
if hits:
status, sev = "FAIL", Severity.CRITICAL
found_desc = f"{len(hits)} LaunchDaemon/LaunchAgent binary/binaries are writable by non-root"
else:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = "All LaunchDaemon/LaunchAgent binaries are root-owned and not world/group-writable"
return [Finding(
id="privesc-ld-binary-writable",
title="Privilege Escalation — Writable LaunchDaemon/LaunchAgent Binary",
category=_CATEGORY,
severity=sev,
description=(
"LaunchDaemons run as root at boot. If the binary a LaunchDaemon references is "
"writable by an unprivileged user, that user can replace the binary and have their "
"code executed as root on the next boot or service restart."
),
evidence=_ev_privesc(
"ld-binary-writable",
status,
"All daemon binaries are root-owned and non-writable by unprivileged users",
found_desc,
raw,
),
worksheet=_WORKSHEET,
mitre_techniques=("T1543.004",),
risk="A writable daemon binary provides persistent, root-level code execution that survives reboots.",
impact="Complete privilege escalation to root with persistence. The attacker's code runs on every boot.",
remediation=(
"Fix permissions on affected binaries:\n"
" sudo chown root:wheel /path/to/binary\n"
" sudo chmod 755 /path/to/binary\n"
"Investigate the owning application — this may indicate a software packaging defect."
),
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
)]
# ---------------------------------------------------------------------------
# 4. Writable directories on the system PATH
# ---------------------------------------------------------------------------
def _read_system_path_dirs() -> list[str]:
"""Return the ordered list of directories from /etc/paths and /etc/paths.d/."""
dirs: list[str] = []
for p in [Path("/etc/paths")] + sorted(Path("/etc/paths.d").glob("*") if Path("/etc/paths.d").is_dir() else []):
try:
for line in p.read_text().splitlines():
d = line.strip()
if d:
dirs.append(d)
except OSError as e:
_log.warning("Cannot read PATH file %s: %s", p, e)
return dirs
def check_path_writable_dirs(ctx: RunContext) -> list[Finding]:
"""Detect world- or group-writable directories on the system PATH."""
path_dirs = _read_system_path_dirs()
hits: list[str] = []
for d in path_dirs:
p = Path(d)
if not p.is_dir():
continue
if _is_writable_by_non_root(p):
try:
mode = oct(p.stat().st_mode)
except OSError:
mode = "unknown"
hits.append(f"{d} (mode {mode})")
raw_dirs = "\n".join(path_dirs) if path_dirs else "(could not read /etc/paths)"
raw = f"System PATH directories:\n{raw_dirs}\n\nWritable by non-root:\n" + (
"\n".join(hits) if hits else "(none)"
)
if hits:
status, sev = "FAIL", Severity.HIGH
found_desc = f"{len(hits)} PATH directory/directories are writable by non-root users"
else:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = "All system PATH directories are root-owned and not world/group-writable"
return [Finding(
id="privesc-path-writable",
title="Privilege Escalation — Writable Directory on System PATH",
category=_CATEGORY,
severity=sev,
description=(
"If a directory on the system PATH is writable by an unprivileged user, that user "
"can place a malicious binary with the same name as a standard system utility. "
"Any root-executed script that invokes the utility by name without an absolute "
"path will execute the attacker's binary instead."
),
evidence=_ev_privesc(
"path-writable",
status,
"All system PATH directories owned by root and not world/group-writable",
found_desc,
raw,
),
worksheet=_WORKSHEET,
mitre_techniques=("T1574.007",),
risk="A writable PATH directory enables PATH hijacking — a root-executing script can be tricked into running attacker-controlled code.",
impact="Privilege escalation to root whenever any privileged process invokes an unqualified command that resolves through the writable directory.",
remediation=(
"Correct permissions on the affected directory:\n"
" sudo chmod 755 /affected/path\n"
" sudo chown root:wheel /affected/path"
),
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
)]
# ---------------------------------------------------------------------------
# 5. TCC — high-risk privacy permissions
# ---------------------------------------------------------------------------
# Services where a granted permission represents a significant attack surface.
_HIGH_RISK_TCC_SERVICES: dict[str, str] = {
"kTCCServiceSystemPolicyAllFiles": "Full Disk Access",
"kTCCServiceScreenCapture": "Screen Recording",
"kTCCServiceAccessibility": "Accessibility (input control)",
"kTCCServiceAddressBook": "Contacts",
"kTCCServiceCalendar": "Calendar",
"kTCCServiceReminders": "Reminders",
"kTCCServiceMicrophone": "Microphone",
"kTCCServiceCamera": "Camera",
"kTCCServiceLocation": "Location",
"kTCCServiceSystemPolicyDesktopFolder": "Desktop Folder",
"kTCCServiceSystemPolicyDocumentsFolder": "Documents Folder",
"kTCCServiceSystemPolicyDownloadsFolder": "Downloads Folder",
}
_TCC_AUTH_ALLOWED = 2 # auth_value=2 means "Allow"
def _query_tcc_db(db_path: Path) -> list[tuple[str, str, str]] | None:
"""
Return (service, client, auth_value_str) rows for allowed high-risk services.
Returns None if the DB cannot be opened or queried (schema error, locked, corrupt).
"""
try:
with contextlib.closing(
sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5)
) as con:
con.row_factory = sqlite3.Row
rows = con.execute(
"SELECT service, client, auth_value FROM access WHERE auth_value = ?",
(_TCC_AUTH_ALLOWED,),
).fetchall()
results: list[tuple[str, str, str]] = []
for row in rows:
svc = row["service"] or ""
if svc in _HIGH_RISK_TCC_SERVICES:
results.append((svc, row["client"] or "", str(row["auth_value"])))
return results
except (sqlite3.OperationalError, sqlite3.DatabaseError) as e:
_log.warning("TCC.db query failed for %s: %s", db_path, e)
return None
except OSError as e:
_log.warning("TCC.db open failed for %s: %s", db_path, e)
return None
def check_tcc_high_risk_permissions(ctx: RunContext) -> list[Finding]:
"""Enumerate applications with high-risk TCC privacy permissions (Full Disk Access, Screen Recording, etc.)."""
if not ctx.is_root():
return []
db_paths = [
Path("/Library/Application Support/com.apple.TCC/TCC.db"),
ctx.home / "Library" / "Application Support" / "com.apple.TCC" / "TCC.db",
]
all_rows: list[tuple[str, str, str]] = []
read_dbs: list[str] = []
failed_dbs: list[str] = []
for db in db_paths:
if db.is_file():
rows = _query_tcc_db(db)
if rows is None:
failed_dbs.append(str(db))
else:
all_rows.extend(rows)
read_dbs.append(str(db))
if not read_dbs and not failed_dbs:
return [Finding(
id="privesc-tcc-high-risk",
title="Privilege Escalation — TCC High-Risk Permissions",
category=_CATEGORY,
severity=Severity.INFORMATIONAL,
description=(
"Could not read any TCC.db to enumerate privacy permissions. "
"Run with sudo to access the system-level TCC database."
),
evidence=_ev_privesc(
"tcc-high-risk", "WARN",
"TCC.db accessible",
"No TCC.db files could be read",
"Requires root to read /Library/Application Support/com.apple.TCC/TCC.db",
),
worksheet=_WORKSHEET,
mitre_techniques=("T1548",),
)]
if failed_dbs and not read_dbs:
return [Finding(
id="privesc-tcc-high-risk",
title="Privilege Escalation — TCC Database Unreadable",
category=_CATEGORY,
severity=Severity.LOW,
description=(
"TCC.db file(s) exist but could not be queried — schema mismatch, locked, or corrupt. "
"This may indicate an OS upgrade changed the TCC schema. Check logs for details."
),
evidence=_ev_privesc(
"tcc-high-risk", "WARN",
"TCC.db queryable",
f"Query failed for: {', '.join(failed_dbs)}",
"See applepy log output for the specific sqlite3 error.",
),
worksheet=_WORKSHEET,
mitre_techniques=("T1548",),
)]
# Group by service for reporting
by_service: dict[str, list[str]] = {}
for svc, client, _ in all_rows:
by_service.setdefault(svc, []).append(client)
lines: list[str] = [f"TCC databases read: {', '.join(read_dbs)}"]
if failed_dbs:
lines.append(f"TCC databases unreadable (schema/lock/corrupt): {', '.join(failed_dbs)}")
lines.append("")
for svc, clients in sorted(by_service.items(), key=lambda kv: kv[0]):
label = _HIGH_RISK_TCC_SERVICES.get(svc, svc)
lines.append(f"{label} ({svc}):")
for c in sorted(set(clients)):
lines.append(f" {c}")
raw = "\n".join(lines)
# Flag as WARN (not FAIL) — having legitimate apps with FDA/screen recording is expected;
# the report surfaces *what* has access so the operator can review.
if all_rows:
status, sev = "WARN", Severity.MEDIUM
found_desc = f"{len(all_rows)} high-risk TCC permission(s) granted across {len(by_service)} service type(s)"
else:
status, sev = "PASS", Severity.INFORMATIONAL
found_desc = "No high-risk TCC permissions granted"
return [Finding(
id="privesc-tcc-high-risk",
title="Privilege Escalation — TCC High-Risk Privacy Permissions",
category=_CATEGORY,
severity=sev,
description=(
"The Transparency, Consent, and Control (TCC) subsystem controls access to "
"sensitive OS resources. Applications granted Full Disk Access, Accessibility, "
"or Screen Recording have significant capability to read data or inject input, "
"making them high-value targets for exploitation or abuse."
),
evidence=_ev_privesc(
"tcc-high-risk",
status,
"Only explicitly authorised, necessary applications hold high-risk TCC permissions",
found_desc,
raw,
),
worksheet=_WORKSHEET,
mitre_techniques=("T1548", "T1552"),
risk="An application with Full Disk Access or Accessibility can exfiltrate all data or inject keystrokes/commands, bypassing user-level controls.",
impact="Data exfiltration, credential theft, or arbitrary command execution without user interaction if the privileged application is compromised.",
remediation=(
"Review and revoke unnecessary TCC permissions:\n"
" System Settings → Privacy & Security → [service]\n"
"Remove any application that does not need the listed permission."
),
references=("https://www.redfoxsec.com/blog/macos-security-privilege-escalation",),
)]

View File

@@ -0,0 +1,130 @@
"""
Native macOS enumeration via PyObjC (AppKit / Foundation).
PyObjC is a **required** dependency on Darwin (see pyproject.toml). On other platforms
the Objective-C runtime is absent, so this module returns an explicit informational finding.
"""
from __future__ import annotations
import importlib
import platform
from typing import Any
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
def register(registry: CheckRegistry) -> None:
registry.register("objc_running_apps", check_running_applications_native, phases=("unprivileged",))
def _running_apps_via_workspace() -> tuple[list[str], str | None]:
"""
Return (lines, error_message). lines are 'bundle_id<TAB>name' for running GUI/capable apps.
"""
try:
appkit = importlib.import_module("AppKit")
NSWorkspace = getattr(appkit, "NSWorkspace", None)
if NSWorkspace is None:
return [], "AppKit module loaded but NSWorkspace attribute missing"
except ImportError as e:
return [], f"PyObjC AppKit import failed: {e}"
try:
ws = NSWorkspace.sharedWorkspace()
apps = ws.runningApplications()
except Exception as e: # noqa: BLE001 — PyObjC bridge can raise NSException proxies
return [], f"NSWorkspace.runningApplications failed: {type(e).__name__}: {e}"
lines: list[str] = []
seen: set[str] = set()
for app in apps:
try:
bid_obj: Any = app.bundleIdentifier()
bid = str(bid_obj) if bid_obj is not None else ""
except Exception:
bid = ""
if not bid or bid in seen:
continue
seen.add(bid)
try:
name_obj: Any = app.localizedName()
name = str(name_obj) if name_obj is not None else ""
except Exception:
name = ""
lines.append(f"{bid}\t{name}")
lines.sort(key=lambda x: x.lower())
return lines, None
def check_running_applications_native(ctx: RunContext) -> list[Finding]:
if platform.system() != "Darwin":
return [
Finding(
id="objc-001",
title="PyObjC native enumeration — not applicable off macOS",
category="Native (PyObjC)",
severity=Severity.INFORMATIONAL,
description=(
"ApplePY with PyObjC targets **macOS only** for native bridge calls. "
"This host is not Darwin; no AppKit inventory was collected."
),
evidence=f"platform.system()={platform.system()!r}",
worksheet="Native (PyObjC)",
mitre_techniques=("T1082",),
remediation="Run ApplePY on macOS to populate this worksheet with NSWorkspace data.",
)
]
lines, err = _running_apps_via_workspace()
if err:
return [
Finding(
id="objc-002",
title="PyObjC / AppKit unavailable or failed at runtime",
category="Native (PyObjC)",
severity=Severity.MEDIUM,
description=(
"PyObjC is listed as a required dependency on macOS. Import or NSWorkspace failed; "
"repair the environment (pip install, architecture match, virtualenv on same machine)."
),
evidence=err,
worksheet="Native (PyObjC)",
mitre_techniques=(),
risk="Without the bridge, native enumeration and future Security.framework checks cannot run.",
impact="Assessment gap versus expected PyObjC-backed automation coverage.",
remediation="Reinstall: pip install -e . && verify AppKit loads: python -c \"from AppKit import NSWorkspace\".",
references=("https://pyobjc.readthedocs.io/en/latest/",),
)
]
ev = (
f"source=NSWorkspace.runningApplications (PyObjC)\n"
f"unique_bundle_ids={len(lines)}\n"
"bundle_id\tlocalized_name\n" + "\n".join(lines)
)
return [
Finding(
id="objc-003",
title="Running applications (NSWorkspace via PyObjC)",
category="Native (PyObjC)",
severity=Severity.INFORMATIONAL,
description=(
"Living process surface from AppKit **NSWorkspace**, complementing shell and file-based "
"checks. Bundle IDs aid correlation with MDM allow lists and LOLBin-style tooling."
),
evidence=ev,
worksheet="Native (PyObjC)",
mitre_techniques=("T1082", "T1106", "T1580"),
risk="Unexpected long-running agents may indicate persistence or unwanted remote tooling.",
impact="Helps prioritise TCC, firewall, and network rules around sensitive binaries.",
remediation="Compare against gold images; investigate unknown bundle IDs with codesign and vendor context.",
references=(
"https://pyobjc.readthedocs.io/en/latest/",
"https://github.com/cedowens/SwiftBelt",
),
)
]

185
applepy/checks/surface.py Normal file
View File

@@ -0,0 +1,185 @@
"""Entitlement-style inspection, Lynis hook, SwiftBelt-inspired paths."""
from __future__ import annotations
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
def register(registry: CheckRegistry) -> None:
registry.register("surf_entitlements", check_entitlements_sample, phases=("unprivileged",))
registry.register("surf_swiftbeltish", check_collaboration_paths, phases=("unprivileged",))
registry.register("surf_ssh", check_ssh_dir, phases=("unprivileged",))
registry.register("surf_tcc_loginitems", check_tcc_and_loginitem_stores, phases=("unprivileged",))
def check_entitlements_sample(ctx: RunContext) -> list[Finding]:
apps = Path("/Applications")
if not apps.is_dir():
return [
Finding(
id="ent-001",
title="Applications folder not readable",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="Cannot enumerate /Applications for codesign entitlements sampling.",
evidence="/Applications missing or not a directory",
worksheet="Entitlements",
mitre_techniques=("T1552",),
)
]
app_bundles = sorted(p for p in apps.iterdir() if p.suffix == ".app")
lines: list[str] = []
for app in app_bundles:
exe = app / "Contents" / "MacOS" / app.stem
if not exe.is_file():
exes = list((app / "Contents" / "MacOS").glob("*")) if (app / "Contents" / "MacOS").is_dir() else []
exe = exes[0] if exes else None
if not exe or not exe.is_file():
lines.append(f"{app.name}: (no Mach-O sampled)")
continue
code, out, err = run_text(["/usr/bin/codesign", "-d", "--entitlements", ":-", str(exe)], timeout=20)
blob = (out + err).strip()
lines.append(f"{app.name} ({exe.name}): exit={code}\n{blob}")
return [
Finding(
id="ent-002",
title="Sample application entitlements (codesign)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Every `*.app` bundle directly under `/Applications` — EntitlementCheck-style `codesign` "
f"entitlements dump per sampled Mach-O (total bundles enumerated: {len(app_bundles)})."
),
evidence="\n\n".join(lines),
worksheet="Entitlements",
mitre_techniques=("T1552.002", "T1626"),
risk="Over-privileged entitlements weaken sandbox and TCC expectations.",
impact="Malware or RATs may abuse accessibility, Apple Events, or debugging entitlements.",
remediation="Compare entitlements to vendor documentation; remove unnecessary hardened runtime exceptions.",
references=("https://github.com/cedowens/EntitlementCheck",),
)
]
def check_collaboration_paths(ctx: RunContext) -> list[Finding]:
home = ctx.home
paths = [
home / "Library" / "Application Support" / "Slack",
home / "Library" / "Application Support" / "Microsoft" / "Teams",
home / "Library" / "Containers" / "com.tinyspeck.slackmacgap",
home / "Library" / "Keychains",
]
lines = [f"{p}: {'exists' if p.exists() else 'absent'}" for p in paths]
return [
Finding(
id="surf-001",
title="Collaboration and keychain-adjacent paths",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="SwiftBelt-style interest paths for credential and cache artefacts (presence only).",
evidence="\n".join(lines),
worksheet="Attack surface",
mitre_techniques=("T1539", "T1555"),
)
]
def check_tcc_and_loginitem_stores(ctx: RunContext) -> list[Finding]:
"""Presence-only paths for TCC, classic login items, and background task management (no DB reads)."""
home = ctx.home
rows: list[str] = []
checks: list[tuple[str, Path, str]] = [
(
"User TCC database",
home / "Library" / "Application Support" / "com.apple.TCC" / "tcc.db",
"file",
),
(
"System TCC database",
Path("/Library/Application Support/com.apple.TCC/tcc.db"),
"file",
),
(
"Login Items plist",
home / "Library" / "Preferences" / "com.apple.loginitems.plist",
"file",
),
(
"Background Task Management support",
home / "Library" / "Application Support" / "com.apple.backgroundtaskmanagementagent",
"dir",
),
]
for label, path, kind in checks:
try:
if kind == "file":
if path.is_file():
st = path.stat()
rows.append(f"{label}: present file {path} ({st.st_size} bytes)")
else:
rows.append(f"{label}: absent or not a file ({path})")
elif path.is_dir():
rows.append(f"{label}: directory exists {path}")
else:
rows.append(f"{label}: absent ({path})")
except OSError as e:
rows.append(f"{label}: {path} ({e})")
return [
Finding(
id="surf-004",
title="TCC, login items, and background task stores (presence only)",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description=(
"Standard paths for Transparency Consent and Control (user), system TCC store, legacy "
"login-items plist, and Background Task Management support data. Sizes and presence only — "
"databases are not opened."
),
evidence="\n".join(rows),
worksheet="Attack surface",
mitre_techniques=("T1012", "T1543.001"),
risk="These stores govern privacy prompts and login-time execution; tampering affects consent UX.",
impact="Malware may target TCC or login persistence; reviewers should correlate with MDM telemetry.",
remediation="Review login items in System Settings; validate TCC decisions against policy.",
references=(
"https://support.kandji.io/kb/configure-the-login-background-items-library-item",
),
)
]
def check_ssh_dir(ctx: RunContext) -> list[Finding]:
d = ctx.home / ".ssh"
if not d.is_dir():
return [
Finding(
id="surf-002",
title="SSH directory",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="~/.ssh not present or not a directory.",
evidence=str(d),
worksheet="Attack surface",
mitre_techniques=("T1552.004",),
)
]
names = sorted(p.name for p in d.iterdir() if p.is_file())
return [
Finding(
id="surf-003",
title="SSH directory file names",
category="Attack surface",
severity=Severity.INFORMATIONAL,
description="File names only — private key material is not read.",
evidence="\n".join(names),
worksheet="Attack surface",
mitre_techniques=("T1552.004",),
)
]

252
applepy/cli.py Normal file
View File

@@ -0,0 +1,252 @@
"""Command-line interface for ApplePY."""
from __future__ import annotations
import argparse
import logging
import os
import runpy
import socket
import sys
import traceback
from pathlib import Path
from applepy import __version__
from applepy import (
pyi_mscp_stdlib as _pyi_mscp_stdlib, # noqa: F401 — PyInstaller: mSCP generate_guidance (runpy) deps
)
from applepy.checks import build_registry
from applepy.checks.mitre import augment_mitre_worksheet
from applepy.context import RunContext
from applepy.dedupe import dedupe_by_id
from applepy.findings import severity_breakdown_line
from applepy.graph_validate import GraphExportError
from applepy.mscp import APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG
from applepy.reporters.graph_json import write_graph_json
from applepy.reporters.markdown import write_markdown_report
from applepy.reporters.xlsx import write_xlsx_report
from applepy.runner import run_all
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="applepy",
description=(
"macOS security review and attack-surface scanner (Markdown + XLSX). "
"By default: unprivileged checks always run; privileged checks run only when effective UID is 0 (use sudo)."
),
epilog=(
"Privilege\n"
" Without sudo Only non-privileged checks (unprivileged phase). No root required.\n"
" With sudo Full scan: unprivileged phase, then privileged phase (root-only checks).\n"
"\n"
"Use --unprivileged-only or --privileged-only to force a single phase."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
)
p.add_argument(
"--version",
action="version",
version=f"%(prog)s {__version__}",
)
p.add_argument(
"-o",
"--output-dir",
type=Path,
default=Path("applepy-out"),
help="Directory for report.md, findings.xlsx, and optional JSON (default: ./applepy-out).",
)
p.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Increase log verbosity (-v, -vv).",
)
p.add_argument(
"-q",
"--quiet",
action="store_true",
help="No console output except errors on stderr (disables per-check progress and informational logs).",
)
p.add_argument(
"--unprivileged-only",
action="store_true",
help="Run only the unprivileged phase (even if invoked with sudo).",
)
p.add_argument(
"--privileged-only",
action="store_true",
help="Run only the privileged phase (no effect without root; use with sudo).",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Collect checks but skip writing output files.",
)
p.add_argument(
"--export-graph-json",
action="store_true",
help=(
"Write macOS security posture as graph_findings.json in JamfHound-compatible "
"BloodHound CE OpenGraph format (ingest via BloodHound CE → File Ingest with "
"the SpecterOps Jamf extension installed). "
"See: https://github.com/SpecterOps/JamfHound"
),
)
p.add_argument(
"--sequential",
action="store_true",
help="Run checks one at a time (default: parallel execution within each phase).",
)
p.add_argument(
"--bootstrap-compliance",
action="store_true",
help=(
"Clone usnistgov/macos_security and cisofy/lynis into ./vendor (shallow git clone), print "
"export hints, then exit. Requires git and network access."
),
)
p.add_argument(
"--heading-color",
default="871727",
metavar="HEX",
help="XLSX heading background colour as a 6-digit hex code (default: 871727). Leading '#' is stripped.",
)
return p
def _internal_mscp_script_entry(parts: list[str]) -> int:
"""
PyInstaller-only side entry: run NIST generate_guidance.py with this binary's embedded stdlib
and bundled PyYAML/xlwt (no separate host python3 required).
"""
if len(parts) < 2:
print(
"internal mSCP runner: expected <workdir> <script.py> [script args...]",
file=sys.stderr,
)
return 2
workdir, script_path, *script_args = parts
os.chdir(workdir)
sys.argv = [Path(script_path).name] + script_args
try:
runpy.run_path(script_path, run_name="__main__")
return 0
except SystemExit as e:
if e.code is None:
return 0
if isinstance(e.code, int):
return e.code
return 1
except BaseException:
traceback.print_exc()
return 1
def main(argv: list[str] | None = None) -> int:
try:
av = sys.argv[1:] if argv is None else argv
if getattr(sys, "frozen", False) and len(av) >= 4 and av[0] == APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG:
return _internal_mscp_script_entry(av[1:])
return _main_run(argv)
except KeyboardInterrupt:
print("Interrupted.", file=sys.stderr)
return 130
def _main_run(argv: list[str] | None = None) -> int:
args = _build_parser().parse_args(argv)
if args.bootstrap_compliance:
from applepy.bootstrap_compliance import run_full_bootstrap
root = Path.cwd().resolve()
code, lines = run_full_bootstrap(root)
if not args.quiet:
print("\n".join(lines))
return 0 if code == 0 else 1
if args.unprivileged_only and args.privileged_only:
print("Cannot combine --unprivileged-only and --privileged-only.", file=sys.stderr)
return 2
if args.quiet:
level = logging.ERROR
elif args.verbose >= 2:
level = logging.DEBUG
elif args.verbose >= 1:
level = logging.INFO
else:
level = logging.WARNING
logging.basicConfig(level=level, format="%(levelname)s: %(message)s")
out = args.output_dir.resolve()
ctx = RunContext(home=RunContext.default_home(), output_dir=out, phase="unprivileged", dry_run=args.dry_run)
progress_rpt = None
if not args.quiet:
from applepy.check_progress import CheckProgressReporter
progress_rpt = CheckProgressReporter()
registry = build_registry()
findings, warnings = run_all(
registry,
ctx,
unprivileged_only=args.unprivileged_only,
privileged_only=args.privileged_only,
parallel=not args.sequential,
progress=progress_rpt,
)
findings = dedupe_by_id(findings)
augment_mitre_worksheet(findings)
if args.dry_run:
if progress_rpt:
progress_rpt.scan_complete(findings, "dry run (no files written)")
logging.info(
"Dry run: %d findings — %s (not writing files)",
len(findings),
severity_breakdown_line(findings),
)
return 0
try:
out.mkdir(parents=True, exist_ok=True)
except OSError as e:
logging.error("Cannot create output directory %s: %s", out, e)
return 1
try:
write_markdown_report(out / "report.md", findings, warnings)
write_xlsx_report(out / "findings.xlsx", findings, heading_bg=args.heading_color)
except OSError as e:
logging.error("Failed to write report or spreadsheet under %s: %s", out, e)
return 1
if args.export_graph_json:
gj_path = out / "graph_findings.json"
try:
write_graph_json(gj_path, findings, socket.gethostname())
except GraphExportError as e:
logging.error("Graph JSON failed validation: %s", e)
return 1
except OSError as e:
logging.error("Failed to write %s: %s", gj_path, e)
return 1
if warnings:
for w in warnings:
logging.warning("%s", w)
if progress_rpt:
progress_rpt.scan_complete(findings, str(out))
logging.info(
"Wrote %s and %s%s",
out / "report.md",
out / "findings.xlsx",
severity_breakdown_line(findings),
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

30
applepy/context.py Normal file
View File

@@ -0,0 +1,30 @@
"""Execution context for checks (privilege phase, paths)."""
from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
PrivilegePhase = Literal["unprivileged", "privileged"]
@dataclass(frozen=True, slots=True)
class RunContext:
"""Per-run environment passed into every check."""
home: Path
output_dir: Path
phase: PrivilegePhase
dry_run: bool = False
@classmethod
def default_home(cls) -> Path:
return Path(os.path.expanduser(os.environ.get("HOME", "~"))).resolve()
def is_root(self) -> bool:
try:
return os.geteuid() == 0
except AttributeError:
return False

1
applepy/data/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Bundled JSON catalogues (LOLBins-style subsets, informational lists)."""

View File

@@ -0,0 +1,16 @@
[
{"binary": "vim", "paths": ["/usr/bin/vim"], "technique": "Editor escape / shell (if misconfigured sudo)"},
{"binary": "nano", "paths": ["/usr/bin/nano"], "technique": "Limited vs Linux GTFO"},
{"binary": "awk", "paths": ["/usr/bin/awk"], "technique": "Script execution"},
{"binary": "sed", "paths": ["/usr/bin/sed"], "technique": "E expression execution on some platforms"},
{"binary": "find", "paths": ["/usr/bin/find"], "technique": "-exec / -okexec"},
{"binary": "perl", "paths": ["/usr/bin/perl"], "technique": "-e inline code"},
{"binary": "ruby", "paths": ["/usr/bin/ruby"], "technique": "-e inline code"},
{"binary": "python3", "paths": ["/usr/bin/python3"], "technique": "-c one-liners"},
{"binary": "less", "paths": ["/usr/bin/less"], "technique": "! shell escape"},
{"binary": "more", "paths": ["/usr/bin/more"], "technique": "Pager escapes (legacy)"},
{"binary": "emacs", "paths": ["/usr/bin/emacs", "/opt/homebrew/bin/emacs"], "technique": "M-x shell"},
{"binary": "git", "paths": ["/usr/bin/git", "/opt/homebrew/bin/git"], "technique": "Hooks / exec via config"},
{"binary": "make", "paths": ["/usr/bin/make"], "technique": "Makefile command execution"},
{"binary": "env", "paths": ["/usr/bin/env"], "technique": "Invoke interpreter by name"}
]

10
applepy/data/lolapps.json Normal file
View File

@@ -0,0 +1,10 @@
[
"TeamViewer",
"AnyDesk",
"LogMeIn",
"Chrome Remote Desktop",
"Splashtop",
"Parallels Desktop",
"VMware Fusion",
"VirtualBox"
]

View File

@@ -0,0 +1,50 @@
[
{"name": "osascript", "paths": ["/usr/bin/osascript"], "note": "AppleScript / JXA"},
{"name": "curl", "paths": ["/usr/bin/curl"], "note": "Download and upload"},
{"name": "nc", "paths": ["/usr/bin/nc", "/bin/nc"], "note": "Netcat"},
{"name": "openssl", "paths": ["/usr/bin/openssl"], "note": "Crypto, reverse shells"},
{"name": "mktemp", "paths": ["/usr/bin/mktemp"], "note": "Staging"},
{"name": "sqlite3", "paths": ["/usr/bin/sqlite3"], "note": "Browser DBs"},
{"name": "plutil", "paths": ["/usr/bin/plutil"], "note": "Plist read/write"},
{"name": "launchctl", "paths": ["/bin/launchctl"], "note": "Launchd control"},
{"name": "ditto", "paths": ["/usr/bin/ditto"], "note": "Bundle copy"},
{"name": "sfltool", "paths": ["/usr/bin/sfltool"], "note": "Shortcuts"},
{"name": "bash", "paths": ["/bin/bash", "/usr/local/bin/bash"], "note": "Shell"},
{"name": "zsh", "paths": ["/bin/zsh", "/usr/bin/zsh"], "note": "Shell"},
{"name": "sh", "paths": ["/bin/sh"], "note": "POSIX shell"},
{"name": "csh", "paths": ["/bin/csh"], "note": "C shell"},
{"name": "tclsh", "paths": ["/usr/bin/tclsh"], "note": "Tcl interpreter"},
{"name": "awk", "paths": ["/usr/bin/awk"], "note": "Text processing"},
{"name": "sed", "paths": ["/usr/bin/sed"], "note": "Stream editor"},
{"name": "grep", "paths": ["/usr/bin/grep"], "note": "Search"},
{"name": "find", "paths": ["/usr/bin/find"], "note": "File search -exec"},
{"name": "chmod", "paths": ["/bin/chmod"], "note": "Permissions"},
{"name": "chflags", "paths": ["/usr/bin/chflags"], "note": "File flags"},
{"name": "xattr", "paths": ["/usr/bin/xattr"], "note": "Extended attributes, quarantine"},
{"name": "pbpaste", "paths": ["/usr/bin/pbpaste"], "note": "Clipboard read"},
{"name": "pbcopy", "paths": ["/usr/bin/pbcopy"], "note": "Clipboard write"},
{"name": "open", "paths": ["/usr/bin/open"], "note": "Open files / apps"},
{"name": "defaults", "paths": ["/usr/bin/defaults"], "note": "Preferences read/write"},
{"name": "security", "paths": ["/usr/bin/security"], "note": "Keychain CLI"},
{"name": "dscl", "paths": ["/usr/bin/dscl"], "note": "Directory service"},
{"name": "diskutil", "paths": ["/usr/sbin/diskutil"], "note": "Disk management"},
{"name": "screencapture", "paths": ["/usr/sbin/screencapture"], "note": "Screenshots"},
{"name": "textutil", "paths": ["/usr/bin/textutil"], "note": "Document conversion"},
{"name": "uuencode", "paths": ["/usr/bin/uuencode"], "note": "Encoding"},
{"name": "uudecode", "paths": ["/usr/bin/uudecode"], "note": "Decoding"},
{"name": "base64", "paths": ["/usr/bin/base64"], "note": "Encoding"},
{"name": "dd", "paths": ["/bin/dd"], "note": "Block copy"},
{"name": "scp", "paths": ["/usr/bin/scp"], "note": "Remote copy over SSH"},
{"name": "sftp", "paths": ["/usr/bin/sftp"], "note": "SFTP client"},
{"name": "ssh", "paths": ["/usr/bin/ssh"], "note": "SSH client"},
{"name": "rsync", "paths": ["/usr/bin/rsync"], "note": "Sync / copy"},
{"name": "zip", "paths": ["/usr/bin/zip"], "note": "Archive"},
{"name": "unzip", "paths": ["/usr/bin/unzip"], "note": "Extract"},
{"name": "tar", "paths": ["/usr/bin/tar"], "note": "Archive"},
{"name": "hdiutil", "paths": ["/usr/bin/hdiutil"], "note": "Disk images"},
{"name": "softwareupdate", "paths": ["/usr/sbin/softwareupdate"], "note": "OS updates"},
{"name": "profiles", "paths": ["/usr/bin/profiles"], "note": "Configuration profiles"},
{"name": "system_profiler", "paths": ["/usr/sbin/system_profiler"], "note": "Hardware / software inventory"},
{"name": "log", "paths": ["/usr/bin/log"], "note": "Unified log stream"},
{"name": "syslog", "paths": ["/usr/sbin/syslog"], "note": "Legacy syslog"}
]

View File

@@ -0,0 +1,12 @@
[
"cloudflared",
"ngrok",
"localtunnel",
"frp",
"chisel",
"ssh",
"autossh",
"ltunnel",
"bore",
"rathole"
]

1
applepy/data/lynis Submodule

Submodule applepy/data/lynis added at 52ed89ce35

Submodule applepy/data/macos_security added at aaf6970248

View File

@@ -0,0 +1,436 @@
{
"source": "https://raw.githubusercontent.com/mitre/cti/master/enterprise-attack/enterprise-attack.json",
"description": "attack-pattern objects whose x_mitre_platforms includes macOS",
"technique_ids": [
"T1001",
"T1001.001",
"T1001.002",
"T1001.003",
"T1002",
"T1003",
"T1005",
"T1007",
"T1008",
"T1009",
"T1010",
"T1011",
"T1011.001",
"T1014",
"T1016",
"T1016.001",
"T1016.002",
"T1017",
"T1018",
"T1020",
"T1021",
"T1021.004",
"T1021.005",
"T1022",
"T1024",
"T1025",
"T1026",
"T1027",
"T1027.001",
"T1027.002",
"T1027.003",
"T1027.004",
"T1027.005",
"T1027.006",
"T1027.008",
"T1027.009",
"T1027.010",
"T1027.013",
"T1027.014",
"T1027.015",
"T1027.016",
"T1027.017",
"T1029",
"T1030",
"T1032",
"T1033",
"T1036",
"T1036.001",
"T1036.002",
"T1036.003",
"T1036.004",
"T1036.005",
"T1036.006",
"T1036.008",
"T1036.009",
"T1036.010",
"T1036.012",
"T1037",
"T1037.002",
"T1037.004",
"T1037.005",
"T1039",
"T1040",
"T1041",
"T1043",
"T1044",
"T1045",
"T1046",
"T1048",
"T1048.001",
"T1048.002",
"T1048.003",
"T1049",
"T1052",
"T1052.001",
"T1053",
"T1053.002",
"T1053.003",
"T1053.004",
"T1055",
"T1056",
"T1056.001",
"T1056.002",
"T1056.003",
"T1056.004",
"T1057",
"T1059",
"T1059.002",
"T1059.004",
"T1059.005",
"T1059.006",
"T1059.007",
"T1059.011",
"T1061",
"T1063",
"T1064",
"T1065",
"T1066",
"T1068",
"T1069",
"T1069.001",
"T1069.002",
"T1070",
"T1070.002",
"T1070.003",
"T1070.004",
"T1070.006",
"T1070.007",
"T1070.008",
"T1070.009",
"T1070.010",
"T1071",
"T1071.001",
"T1071.002",
"T1071.003",
"T1071.004",
"T1071.005",
"T1072",
"T1074",
"T1074.001",
"T1074.002",
"T1078",
"T1078.001",
"T1078.002",
"T1078.003",
"T1079",
"T1080",
"T1081",
"T1082",
"T1083",
"T1087",
"T1087.001",
"T1087.002",
"T1089",
"T1090",
"T1090.001",
"T1090.002",
"T1090.003",
"T1090.004",
"T1092",
"T1094",
"T1095",
"T1098",
"T1098.004",
"T1098.007",
"T1099",
"T1100",
"T1102",
"T1102.001",
"T1102.002",
"T1102.003",
"T1104",
"T1105",
"T1106",
"T1107",
"T1108",
"T1110",
"T1110.001",
"T1110.002",
"T1110.003",
"T1110.004",
"T1111",
"T1113",
"T1114",
"T1114.003",
"T1115",
"T1116",
"T1119",
"T1120",
"T1123",
"T1124",
"T1125",
"T1129",
"T1130",
"T1132",
"T1132.001",
"T1132.002",
"T1133",
"T1135",
"T1136",
"T1136.001",
"T1136.002",
"T1139",
"T1140",
"T1141",
"T1142",
"T1143",
"T1144",
"T1145",
"T1146",
"T1147",
"T1148",
"T1149",
"T1150",
"T1151",
"T1152",
"T1153",
"T1154",
"T1155",
"T1156",
"T1157",
"T1158",
"T1159",
"T1160",
"T1161",
"T1162",
"T1163",
"T1164",
"T1165",
"T1166",
"T1167",
"T1168",
"T1169",
"T1172",
"T1176",
"T1176.001",
"T1176.002",
"T1184",
"T1188",
"T1189",
"T1190",
"T1192",
"T1193",
"T1194",
"T1195",
"T1195.001",
"T1195.002",
"T1195.003",
"T1199",
"T1200",
"T1201",
"T1203",
"T1204",
"T1204.001",
"T1204.002",
"T1204.004",
"T1204.005",
"T1205",
"T1205.001",
"T1205.002",
"T1206",
"T1210",
"T1211",
"T1212",
"T1213",
"T1213.006",
"T1215",
"T1217",
"T1218",
"T1218.015",
"T1219",
"T1219.001",
"T1219.002",
"T1219.003",
"T1222",
"T1222.002",
"T1480",
"T1480.001",
"T1480.002",
"T1483",
"T1485",
"T1486",
"T1487",
"T1488",
"T1489",
"T1490",
"T1491",
"T1491.001",
"T1491.002",
"T1492",
"T1493",
"T1494",
"T1495",
"T1496",
"T1496.001",
"T1496.002",
"T1497",
"T1497.001",
"T1497.002",
"T1497.003",
"T1498",
"T1498.001",
"T1498.002",
"T1499",
"T1499.001",
"T1499.002",
"T1499.003",
"T1499.004",
"T1500",
"T1503",
"T1505",
"T1505.003",
"T1514",
"T1518",
"T1518.001",
"T1518.002",
"T1519",
"T1529",
"T1531",
"T1534",
"T1539",
"T1542",
"T1542.002",
"T1543",
"T1543.001",
"T1543.004",
"T1546",
"T1546.004",
"T1546.005",
"T1546.006",
"T1546.014",
"T1546.016",
"T1546.018",
"T1547",
"T1547.006",
"T1547.007",
"T1547.011",
"T1547.015",
"T1548",
"T1548.001",
"T1548.003",
"T1548.004",
"T1548.006",
"T1552",
"T1552.001",
"T1552.003",
"T1552.004",
"T1553",
"T1553.001",
"T1553.002",
"T1553.004",
"T1553.006",
"T1554",
"T1555",
"T1555.001",
"T1555.002",
"T1555.003",
"T1555.005",
"T1556",
"T1556.003",
"T1556.006",
"T1557",
"T1557.002",
"T1557.003",
"T1558",
"T1558.005",
"T1559",
"T1559.003",
"T1560",
"T1560.001",
"T1560.002",
"T1560.003",
"T1561",
"T1561.001",
"T1561.002",
"T1562",
"T1562.001",
"T1562.003",
"T1562.004",
"T1562.006",
"T1562.010",
"T1562.011",
"T1563",
"T1563.001",
"T1564",
"T1564.001",
"T1564.002",
"T1564.003",
"T1564.005",
"T1564.006",
"T1564.007",
"T1564.008",
"T1564.009",
"T1564.011",
"T1564.012",
"T1564.014",
"T1565",
"T1565.001",
"T1565.002",
"T1565.003",
"T1566",
"T1566.001",
"T1566.002",
"T1566.003",
"T1566.004",
"T1567",
"T1567.001",
"T1567.002",
"T1567.003",
"T1567.004",
"T1568",
"T1568.001",
"T1568.002",
"T1568.003",
"T1569",
"T1569.001",
"T1570",
"T1571",
"T1572",
"T1573",
"T1573.001",
"T1573.002",
"T1574",
"T1574.004",
"T1574.006",
"T1574.007",
"T1606",
"T1606.001",
"T1614",
"T1614.001",
"T1620",
"T1621",
"T1622",
"T1647",
"T1649",
"T1652",
"T1653",
"T1654",
"T1656",
"T1657",
"T1659",
"T1665",
"T1667",
"T1668",
"T1669",
"T1672",
"T1673",
"T1674",
"T1678",
"T1680"
]
}

16
applepy/dedupe.py Normal file
View File

@@ -0,0 +1,16 @@
"""Deduplicate findings when the same logical check runs in multiple phases."""
from __future__ import annotations
from applepy.findings import Finding
def dedupe_by_id(findings: list[Finding]) -> list[Finding]:
seen: set[str] = set()
out: list[Finding] = []
for f in findings:
if f.id in seen:
continue
seen.add(f.id)
out.append(f)
return out

74
applepy/findings.py Normal file
View File

@@ -0,0 +1,74 @@
"""Structured security-review findings (not LLM output)."""
from __future__ import annotations
from collections import Counter
from collections.abc import Iterable
from dataclasses import dataclass, field
from enum import StrEnum
from typing import Any
class Severity(StrEnum):
CRITICAL = "critical"
HIGH = "high"
MEDIUM = "medium"
LOW = "low"
INFORMATIONAL = "informational"
# Descending risk: critical first, then high, medium, low, informational (for reports and spreadsheets).
_SEVERITY_RANK: dict[Severity, int] = {
Severity.CRITICAL: 0,
Severity.HIGH: 1,
Severity.MEDIUM: 2,
Severity.LOW: 3,
Severity.INFORMATIONAL: 4,
}
def severity_rank(severity: Severity) -> int:
return _SEVERITY_RANK[severity]
SEVERITY_BREAKDOWN_ORDER: tuple[Severity, ...] = (
Severity.CRITICAL,
Severity.HIGH,
Severity.MEDIUM,
Severity.LOW,
Severity.INFORMATIONAL,
)
def severity_breakdown_line(findings: Iterable[Finding]) -> str:
"""Single-line counts for console progress (always includes all severities, including zeros)."""
c = Counter(f.severity for f in findings)
return " · ".join(f"{s.value}: {c[s]}" for s in SEVERITY_BREAKDOWN_ORDER)
@dataclass(frozen=True, slots=True)
class Finding:
"""One observation from a check (configuration, presence, posture, etc.)."""
id: str
title: str
category: str
severity: Severity
description: str
evidence: str
worksheet: str
mitre_techniques: tuple[str, ...] = ()
raw_details: dict[str, Any] = field(default_factory=dict)
risk: str = ""
impact: str = ""
remediation: str = ""
references: tuple[str, ...] = ()
cis_section: str = ""
cis_level: int = 0
def mitre_display(self) -> str:
return ", ".join(self.mitre_techniques) if self.mitre_techniques else ""
def references_display(self) -> str:
return "\n".join(self.references) if self.references else ""

141
applepy/graph_validate.py Normal file
View File

@@ -0,0 +1,141 @@
"""Structural validation for JamfHound-compatible BloodHound CE OpenGraph JSON."""
from __future__ import annotations
from typing import Any
_VALID_NODE_KINDS = frozenset({
"jamf_Computer", "jamf_Account", "jamf_Group", "jamf_Tenant",
"jamf_Site", "jamf_ComputerUser", "jamf_ApiClient",
"jamf_DisabledAccount", "jamf_DisabledApiClient", "jamf_SSOIntegration",
})
_VALID_EDGE_KINDS = frozenset({
# Traversable
"jamf_AdminTo", "jamf_AdminToSite", "jamf_AssignedUser", "jamf_Contains",
"jamf_CreateAPIClientAndAssignRole", "jamf_CreateAPIClientAndCreateRole",
"jamf_CreateAPIClientAndUpdateRole", "jamf_CreateAccounts",
"jamf_CreateComputerExtensions", "jamf_CreatePolicies",
"jamf_MatchedEmail", "jamf_MatchedName", "jamf_MemberOf",
"jamf_OktaSameDevice", "jamf_SSOLogin", "jamf_UpdateRecurringScripts",
"jamf_UpdateRolesAssignedToSelf",
# Non-traversable
"jamf_AZMatchedEmail", "jamf_CreateAPIRoles", "jamf_ScriptsNonTraversable",
"jamf_UpdateAPIClientAndAssignRole", "jamf_UpdateAPIClientAndCreateRoles",
"jamf_UpdateAPIClientAndUpdateRoles", "jamf_UpdateAPIRoles",
})
class GraphExportError(ValueError):
"""Raised when the exported graph JSON fails structural validation."""
def validate_graph_document(doc: dict[str, Any]) -> None:
"""Raise GraphExportError if the document does not conform to the OpenGraph schema.
Expected top-level structure (JamfHound v1.1.2 / BloodHound CE Jamf extension)::
{
"graph": {
"nodes": [ { "id": "...", "kinds": [...], "properties": { "name": "...", "Tier": int } } ],
"edges": [ { "kind": "...", "start": {"value": "...", "match_by": "id"},
"end": {"value": "...", "match_by": "id"},
"properties": { "description": "...", "traversable": bool } } ]
},
"metadata": {
"ingest_version": "v1",
"source_kind": "jamf",
"collector": { "name": "...", "version": "...", "properties": {...} }
}
}
"""
if not isinstance(doc, dict):
raise GraphExportError("document must be a JSON object")
# ------------------------------------------------------------------
# metadata
# ------------------------------------------------------------------
meta = doc.get("metadata")
if not isinstance(meta, dict):
raise GraphExportError("metadata must be an object")
for required_meta_key in ("ingest_version", "source_kind", "collector"):
if required_meta_key not in meta:
raise GraphExportError(f"metadata.{required_meta_key} is required")
collector = meta.get("collector")
if not isinstance(collector, dict) or "name" not in collector:
raise GraphExportError("metadata.collector must be an object with 'name'")
# ------------------------------------------------------------------
# graph envelope
# ------------------------------------------------------------------
graph = doc.get("graph")
if not isinstance(graph, dict):
raise GraphExportError("graph must be an object")
# ------------------------------------------------------------------
# nodes
# ------------------------------------------------------------------
nodes = graph.get("nodes")
if not isinstance(nodes, list):
raise GraphExportError("graph.nodes must be an array")
for i, node in enumerate(nodes):
if not isinstance(node, dict):
raise GraphExportError(f"graph.nodes[{i}] must be an object")
if "id" not in node:
raise GraphExportError(f"graph.nodes[{i}] missing id")
kinds = node.get("kinds")
if not isinstance(kinds, list) or not kinds:
raise GraphExportError(f"graph.nodes[{i}] kinds must be a non-empty array")
for k in kinds:
if k not in _VALID_NODE_KINDS:
raise GraphExportError(
f"graph.nodes[{i}] unknown kind '{k}'; "
f"valid kinds: {sorted(_VALID_NODE_KINDS)}"
)
props = node.get("properties")
if not isinstance(props, dict):
raise GraphExportError(f"graph.nodes[{i}] properties must be an object")
if "name" not in props:
raise GraphExportError(f"graph.nodes[{i}] properties.name is required")
if "Tier" not in props or not isinstance(props["Tier"], int):
raise GraphExportError(f"graph.nodes[{i}] properties.Tier must be an integer")
# Build a set of valid node ids for edge validation
node_ids = {node["id"] for node in nodes}
# ------------------------------------------------------------------
# edges
# ------------------------------------------------------------------
edges = graph.get("edges")
if not isinstance(edges, list):
raise GraphExportError("graph.edges must be an array")
for i, edge in enumerate(edges):
if not isinstance(edge, dict):
raise GraphExportError(f"graph.edges[{i}] must be an object")
kind = edge.get("kind")
if kind not in _VALID_EDGE_KINDS:
raise GraphExportError(
f"graph.edges[{i}] unknown kind '{kind}'; "
f"valid kinds: {sorted(_VALID_EDGE_KINDS)}"
)
for endpoint in ("start", "end"):
if endpoint not in edge:
raise GraphExportError(f"graph.edges[{i}] missing {endpoint}")
ep_ref = edge[endpoint]
if not isinstance(ep_ref, dict) or "value" not in ep_ref:
raise GraphExportError(
f"graph.edges[{i}] {endpoint} must be an object with 'value'"
)
if ep_ref["value"] not in node_ids:
raise GraphExportError(
f"graph.edges[{i}] {endpoint}.value '{ep_ref['value']}' references unknown node"
)
ep = edge.get("properties")
if not isinstance(ep, dict):
raise GraphExportError(f"graph.edges[{i}] properties must be an object")
if "traversable" not in ep or not isinstance(ep["traversable"], bool):
raise GraphExportError(
f"graph.edges[{i}] properties.traversable must be a boolean"
)

245
applepy/mscp.py Normal file
View File

@@ -0,0 +1,245 @@
"""Integrate with a local NIST usnistgov/macos_security (mSCP) clone."""
from __future__ import annotations
import logging
import os
import platform
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
logger = logging.getLogger(__name__)
# Audit name passed to generate_guidance.py `-a`; plist is org.<name>.audit.plist
APPLEPY_MSCP_AUDIT_NAME = "applepy_mscp"
# Frozen builds re-exec the same binary with this flag so generate_guidance runs with bundled PyYAML/xlwt.
APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG = "--internal-mscp-run-script"
# Subprocess interpreter for generate_guidance.py. macOS PyInstaller bundles expose `python` as a symlink
# to libPython.dylib (not exec()able — errno 8); use Homebrew/system python3 instead.
_SYSTEM_PYTHON3_CANDIDATES: tuple[str, ...] = (
"/opt/homebrew/bin/python3",
"/usr/local/bin/python3",
"/usr/bin/python3",
)
def _valid_mscp_root(p: Path) -> bool:
if not p.is_dir():
return False
if (p / "scripts" / "generate_guidance.py").is_file() and (p / "baselines").is_dir():
return True
return (p / "rules").is_dir() and (p / "baselines").is_dir()
def _bundled_mscp_root() -> Path | None:
"""Wheel / editable install / PyInstaller: `applepy/data/macos_security` populated at build time."""
roots: list[Path] = []
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
roots.append(Path(sys._MEIPASS) / "applepy" / "data" / "macos_security")
try:
import applepy
pkg = Path(applepy.__file__).resolve().parent
roots.append(pkg / "data" / "macos_security")
except (ImportError, OSError):
pass
for base in roots:
if _valid_mscp_root(base):
return base
return None
def resolve_mscp_root(home: Path, cwd: Path) -> Path | None:
env_root = os.environ.get("APPLEPY_MACOS_SECURITY_ROOT", "").strip()
candidates: list[Path] = []
if env_root:
candidates.append(Path(env_root).expanduser().resolve())
bundled = _bundled_mscp_root()
if bundled is not None:
candidates.append(bundled)
candidates.extend(
[
cwd / "vendor" / "macos_security",
cwd / "macos_security",
home / "src" / "macos_security",
home / "Projects" / "macos_security",
]
)
for p in candidates:
if _valid_mscp_root(p):
return p
return None
def pick_baseline_yaml(root: Path) -> Path | None:
bdir = root / "baselines"
if not bdir.is_dir():
return None
explicit = os.environ.get("APPLEPY_MSCP_BASELINE", "").strip()
if explicit:
cand = bdir / explicit
if cand.is_file():
return cand
if not explicit.endswith((".yaml", ".yml")):
for ext in (".yaml", ".yml"):
c2 = bdir / f"{explicit}{ext}"
if c2.is_file():
return c2
seen: set[Path] = set()
yamls: list[Path] = []
for p in sorted(bdir.glob("*.yaml")) + sorted(bdir.glob("*.yml")):
rp = p.resolve()
if rp not in seen:
seen.add(rp)
yamls.append(p)
if not yamls:
return None
for pref in ("cis_level1", "cis_level2", "cis"):
for y in yamls:
if pref in y.name.lower():
return y
return yamls[0]
def find_generated_compliance_script(root: Path, baseline_stem: str) -> Path | None:
"""Expected path after generate_guidance.py -s: build/<stem>/<stem>_compliance.sh"""
p = root / "build" / baseline_stem / f"{baseline_stem}_compliance.sh"
if p.is_file():
return p
build = root / "build"
if build.is_dir():
for path in sorted(build.rglob("*_compliance.sh")):
if path.is_file():
return path
return None
def _python_for_mscp_subprocess() -> str:
"""Interpreter to run generate_guidance.py. PyInstaller's ``sys.executable`` is the app binary, not CPython."""
override = os.environ.get("APPLEPY_PYTHON", "").strip()
if override:
p = Path(override).expanduser()
if p.is_file() and os.access(p, os.X_OK):
return str(p)
logger.warning("APPLEPY_PYTHON is set but not an executable file: %s", override)
if not getattr(sys, "frozen", False):
return sys.executable
# Linux/Windows one-folder bundles may include a runnable python* next to the app; macOS does not.
if platform.system() != "Darwin":
meipass = getattr(sys, "_MEIPASS", None)
if meipass:
base = Path(meipass)
for name in (
"python3.14",
"python3.13",
"python3.12",
"python3.11",
"python3.10",
"python3",
"python",
):
cand = base / name
if cand.is_file() and os.access(cand, os.X_OK):
return str(cand)
for alt in _SYSTEM_PYTHON3_CANDIDATES:
p = Path(alt)
if p.is_file() and os.access(p, os.X_OK):
return str(p)
path_py3 = shutil.which("python3")
if path_py3:
p = Path(path_py3)
if p.is_file() and os.access(p, os.X_OK):
return str(p)
logger.warning(
"Frozen ApplePY: no usable system python3 for mSCP (macOS bundle has no exec()able interpreter). "
"Set APPLEPY_PYTHON to a Python 3 with PyYAML, xlwt, and other mSCP script dependencies."
)
return sys.executable
def _run_subprocess_via_logfiles(
cmd: list[str],
*,
cwd: str | None,
timeout: float,
) -> tuple[int, str, str]:
"""
Run with stdin closed; stdout and stderr go to disk-backed files in a temp directory.
Prevents pipe buffer deadlocks when a compliance shell script prints heavily or spawns
children that inherit the same pipe as the parent process.
"""
try:
with tempfile.TemporaryDirectory(prefix="applepy_mscp_") as td:
tdir = Path(td)
out_p = tdir / "stdout.log"
err_p = tdir / "stderr.log"
with open(out_p, "w", encoding="utf-8", newline="", errors="replace") as fo, open(
err_p, "w", encoding="utf-8", newline="", errors="replace"
) as fe:
try:
p = subprocess.run(
cmd,
cwd=cwd,
stdin=subprocess.DEVNULL,
stdout=fo,
stderr=fe,
timeout=timeout,
check=False,
)
except subprocess.TimeoutExpired:
return 124, "", "command timed out"
out_text = out_p.read_text(encoding="utf-8", errors="replace")
err_text = err_p.read_text(encoding="utf-8", errors="replace")
return p.returncode, out_text, err_text
except OSError as e:
return 1, "", str(e)
def run_generate_guidance(root: Path, baseline: Path) -> tuple[int, str, str]:
gen = root / "scripts" / "generate_guidance.py"
if not gen.is_file():
return 1, "", "generate_guidance.py not found"
bl = baseline.resolve()
force_sub = os.environ.get("APPLEPY_MSCP_FORCE_SUBPROCESS", "").strip() in ("1", "true", "yes")
if getattr(sys, "frozen", False) and not force_sub:
cmd = [
sys.executable,
APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG,
str(root.resolve()),
str(gen.resolve()),
str(bl),
"-s",
"-a",
APPLEPY_MSCP_AUDIT_NAME,
]
else:
py = _python_for_mscp_subprocess()
cmd = [py, str(gen), str(bl), "-s", "-a", APPLEPY_MSCP_AUDIT_NAME]
return _run_subprocess_via_logfiles(cmd, cwd=str(root), timeout=900.0)
def run_compliance_script(script: Path) -> tuple[int, str, str]:
"""Execute generated zsh compliance script in check-only mode."""
if not script.is_file():
return 1, "", "compliance script path is not a file"
if not os.access(script, os.X_OK):
try:
script.chmod(0o755)
except OSError as e:
logger.warning("Could not chmod +x %s: %s", script, e)
shell = "/bin/zsh"
if not Path(shell).is_file():
shell = "/bin/bash"
return _run_subprocess_via_logfiles(
[shell, str(script), "--check"],
cwd=None,
timeout=1800.0,
)

View File

@@ -0,0 +1,89 @@
"""Parse mSCP compliance audit plist into per-rule results."""
from __future__ import annotations
import plistlib
from pathlib import Path
from applepy.findings import Finding, Severity
def parse_audit_plist(plist_path: Path) -> list[Finding]:
"""
mSCP stores org.<audit>.audit.plist with per-rule dicts.
finding == false means compliant (passed); true means non-compliant (failed).
"""
if not plist_path.is_file():
return []
try:
raw = plist_path.read_bytes()
root = plistlib.loads(raw)
except (OSError, plistlib.InvalidFileException, ValueError):
return []
if not isinstance(root, dict):
return []
findings: list[Finding] = []
for rule_id, payload in root.items():
if rule_id in ("lastComplianceCheck",) or not isinstance(payload, dict):
continue
finding_val = payload.get("finding")
if finding_val is None:
continue
exempt = payload.get("exempt") in (1, True, "1", "true")
# false -> passed in upstream zsh logic
passed = finding_val in (False, 0, "false", "0")
if passed:
sev = Severity.INFORMATIONAL
status = "compliant"
elif exempt:
sev = Severity.INFORMATIONAL
status = "non_compliant_exempt"
else:
sev = Severity.MEDIUM
status = "non_compliant"
rid = str(rule_id).replace("/", "_").replace(" ", "_")
if passed:
desc = (
f"Rule `{rule_id}` is compliant in the mSCP audit plist (post `--check`). "
"No baseline drift for this control."
)
impact = ""
elif exempt:
desc = (
f"Rule `{rule_id}` is non-compliant in the audit plist but marked exempt. "
"Confirm the exception is authorised."
)
impact = "Exempt rules are excluded from fail counts; validate exception records."
else:
desc = (
f"Rule `{rule_id}` is non-compliant in the mSCP audit plist (post `--check`). "
"Review remediation or baseline alignment."
)
impact = "Policy or hardening expectations may not be met for this control."
findings.append(
Finding(
id=f"mscp-{rid}",
title=f"mSCP rule {rule_id}: {status.replace('_', ' ')}",
category="Compliance",
severity=sev,
description=desc,
evidence=f"plist={plist_path}\nfinding={finding_val!r}\nexempt={exempt}\nraw={payload!r}",
worksheet="mSCP rules",
mitre_techniques=(),
risk="Non-compliant rules indicate drift from the selected baseline." if not passed else "",
impact=impact,
remediation="Run the mSCP compliance script with `--fix` only under change control, or adjust MDM.",
references=("https://github.com/usnistgov/macos_security/wiki/Compliance-Script",),
)
)
return findings
def audit_plist_path(audit_name: str) -> Path:
"""Default mSCP location for org.<audit>.audit.plist (root-owned after compliance run)."""
return Path("/Library/Preferences") / f"org.{audit_name}.audit.plist"

View File

@@ -0,0 +1,28 @@
"""Stdlib (and layout) imports for NIST ``generate_guidance.py`` under frozen ``runpy.run_path``.
PyInstaller traces static imports from the application entry graph only. The mSCP script is executed
at runtime via ``runpy`` and is not analysed, so modules such as ``uuid`` would otherwise be omitted
from the bundle and fail with ``ModuleNotFoundError``. Importing them here pulls them into the frozen
build. See ``applepy.spec`` hiddenimports.
"""
from __future__ import annotations
import argparse # noqa: F401
import base64 # noqa: F401
import glob # noqa: F401
import hashlib # noqa: F401
import json # noqa: F401
import logging # noqa: F401
import os # noqa: F401
import plistlib # noqa: F401
import re # noqa: F401
import shutil # noqa: F401
import subprocess # noqa: F401
import sys # noqa: F401
import tempfile # noqa: F401
import zipfile # noqa: F401
from datetime import date # noqa: F401
from itertools import groupby # noqa: F401
from string import Template # noqa: F401
from uuid import uuid4 # noqa: F401

29
applepy/registry.py Normal file
View File

@@ -0,0 +1,29 @@
"""Register and collect check callables."""
from __future__ import annotations
from collections.abc import Callable, Iterator
from applepy.context import PrivilegePhase, RunContext
from applepy.findings import Finding
CheckFn = Callable[[RunContext], list[Finding]]
class CheckRegistry:
def __init__(self) -> None:
self._checks: list[tuple[str, tuple[PrivilegePhase, ...], CheckFn]] = []
def register(
self,
name: str,
fn: CheckFn,
*,
phases: tuple[PrivilegePhase, ...] = ("unprivileged",),
) -> None:
self._checks.append((name, phases, fn))
def checks_for(self, phase: PrivilegePhase) -> Iterator[tuple[str, CheckFn]]:
for name, phs, fn in self._checks:
if phase in phs:
yield name, fn

View File

@@ -0,0 +1,4 @@
from applepy.reporters.markdown import write_markdown_report
from applepy.reporters.xlsx import write_xlsx_report
__all__ = ["write_markdown_report", "write_xlsx_report"]

View File

@@ -0,0 +1,216 @@
"""JamfHound-compatible BloodHound CE OpenGraph export of ApplePY findings.
Produces the JamfHound OpenGraph format:
{ "graph": { "nodes": [...], "edges": [...] }, "metadata": { ... } }
Node kinds: jamf_Computer, jamf_Account, jamf_Group, jamf_Tenant
Edge kinds: jamf_AdminTo, jamf_MemberOf, jamf_AssignedUser, jamf_Contains
"""
from __future__ import annotations
import json
import re
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from applepy import __version__
from applepy.findings import Finding
from applepy.graph_validate import GraphExportError, validate_graph_document
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _make_node(
node_id: str,
kinds: list[str],
name: str,
tier: int,
extra: dict[str, Any] | None = None,
) -> dict[str, Any]:
# objectid is a protected attribute in BloodHound CE v1.1.1+ — omit from properties
props: dict[str, Any] = {"name": name, "Tier": tier}
if extra:
props.update(extra)
return {
"id": node_id,
"kinds": kinds,
"properties": props,
}
def _make_edge(
kind: str,
start: str,
end: str,
traversable: bool,
description: str = "",
) -> dict[str, Any]:
return {
"kind": kind,
"start": {"value": start, "match_by": "id"},
"end": {"value": end, "match_by": "id"},
"properties": {"description": description, "traversable": traversable},
}
def _extract_jss_url(jamf_findings: list[Finding]) -> str | None:
"""Try to pull a JSS URL from the jamf-003 plist evidence blob."""
for f in jamf_findings:
if f.id == "jamf-003":
# plutil -p output: "jss_url" => "https://example.jamfcloud.com/"
m = re.search(r'"jss_url"\s*=>\s*"([^"]+)"', f.evidence)
if m:
return m.group(1).rstrip("/")
# Plain-key format: jss_url = https://...
m = re.search(r'jss_url\s*[=:]\s*"?([^\s"]+)"?', f.evidence, re.IGNORECASE)
if m:
return m.group(1).rstrip("/")
return None
def _parse_admin_members(findings: list[Finding]) -> list[str]:
"""Return a sorted list of admin-group member names from cis-007 evidence."""
for f in findings:
if f.id == "cis-007" and f.evidence.startswith("Admin group members:"):
rest = f.evidence.split(":", 1)[1].strip()
if rest and rest != "(none parsed)":
return sorted(m.strip() for m in rest.split(",") if m.strip())
return []
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def write_graph_json(path: Path, findings: list[Finding], hostname: str) -> None:
"""Write a JamfHound-compatible BloodHound CE OpenGraph JSON file.
Args:
path: Destination file path (parent dirs are created if needed).
findings: Full list of Finding objects from the run.
hostname: The machine hostname, used as the Computer node display name.
"""
path.parent.mkdir(parents=True, exist_ok=True)
jamf_findings = [f for f in findings if f.category == "MDM: Jamf"]
jamf_enrolled = any(f.id == "jamf-001" for f in jamf_findings)
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
# ------------------------------------------------------------------
# Computer node (always present)
# ------------------------------------------------------------------
host_upper = hostname.upper()
host_id = f"COMPUTER:{host_upper}"
# Gather OS / posture evidence from findings where available.
# These fields are best-effort; blanks are acceptable.
os_name: str = "macOS"
os_version: str = ""
sip_status: str = ""
gatekeeper_status: str = ""
for f in findings:
if not os_version and f.id in ("cis-001", "mscp-001") and f.evidence:
# Evidence often starts with the OS version string
first_line = f.evidence.splitlines()[0]
m = re.search(r"(\d+\.\d+[\.\d]*)", first_line)
if m:
os_version = m.group(1)
if not sip_status and "sip" in f.title.lower() and f.evidence:
sip_status = f.evidence.splitlines()[0][:120]
if not gatekeeper_status and "gatekeeper" in f.title.lower() and f.evidence:
gatekeeper_status = f.evidence.splitlines()[0][:120]
host_node = _make_node(
node_id=host_id,
kinds=["jamf_Computer"],
name=host_upper,
tier=1,
extra={
"platform": "macOS",
"os_name": os_name,
"os_version": os_version,
"sip_status": sip_status,
"gatekeeper_status": gatekeeper_status,
"jamf_enrolled": jamf_enrolled,
},
)
nodes.append(host_node)
# ------------------------------------------------------------------
# Tenant node (only if we can find a JSS URL)
# ------------------------------------------------------------------
tenant_id: str | None = None
if jamf_findings:
jss_url = _extract_jss_url(jamf_findings)
if jss_url:
tenant_id = f"TENANT:{jss_url}"
else:
# Fall back to hostname as a stand-in tenant identifier
tenant_id = f"TENANT:{host_upper}"
tenant_name = jss_url.upper() if jss_url else host_upper
tenant_node = _make_node(
node_id=tenant_id,
kinds=["jamf_Tenant"],
name=tenant_name,
tier=0,
extra={"jss_url": jss_url or ""},
)
nodes.append(tenant_node)
edges.append(_make_edge("jamf_Contains", tenant_id, host_id, traversable=False))
# ------------------------------------------------------------------
# Account nodes from cis-007 admin-group membership
# ------------------------------------------------------------------
admin_members = _parse_admin_members(findings)
for member in admin_members:
account_id = f"ACCOUNT:{member.upper()}@{host_upper}"
account_node = _make_node(
node_id=account_id,
kinds=["jamf_Account"],
name=member.upper(),
tier=0,
extra={"account_name": member, "is_admin": True},
)
nodes.append(account_node)
# Admin account → computer
edges.append(_make_edge("jamf_AdminTo", account_id, host_id, traversable=True))
# Tenant contains the account (if we have a tenant)
if tenant_id is not None:
edges.append(_make_edge("jamf_Contains", tenant_id, account_id, traversable=False))
# ------------------------------------------------------------------
# Build the document
# ------------------------------------------------------------------
timestamp = datetime.now(tz=UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
doc: dict[str, Any] = {
"graph": {
"nodes": nodes,
"edges": edges,
},
"metadata": {
"ingest_version": "v1",
"source_kind": "jamf",
"collector": {
"name": "ApplePY",
"version": f"v{__version__}",
"properties": {
"collection_methods": ["ApplePY macOS security review"],
"collection_timestamp": timestamp,
"windows_server_version": "n/a",
},
},
},
}
try:
validate_graph_document(doc)
except GraphExportError as e:
raise GraphExportError(f"Graph document failed validation: {e}") from e
path.write_text(json.dumps(doc, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,252 @@
"""Write report.md from findings (structured like a technical assessment write-up)."""
from __future__ import annotations
import re
from collections import Counter, defaultdict
from datetime import UTC, datetime
from pathlib import Path
from applepy.findings import Finding, severity_rank
from applepy.reporters.report_themes import theme_for_category, themes_present_in_order
_MD_CTRL = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F]")
def _md_text(s: str) -> str:
return _MD_CTRL.sub(" ", s)
def _md_anchor(heading: str) -> str:
"""GitHub-style fragment for a ## heading (lowercase, hyphenated)."""
s = heading.strip().lower()
parts: list[str] = []
for ch in s:
if "a" <= ch <= "z" or "0" <= ch <= "9":
parts.append(ch)
else:
parts.append("-")
a = "".join(parts).strip("-")
while "--" in a:
a = a.replace("--", "-")
return a or "section"
def write_markdown_report(path: Path, findings: list[Finding], warnings: list[str]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
by_theme: dict[str, list[Finding]] = defaultdict(list)
for f in findings:
by_theme[theme_for_category(f.category)].append(f)
themes_ordered = themes_present_in_order(set(by_theme.keys()))
lines = [
"# ApplePY security review report",
"",
f"Generated (UTC): {datetime.now(UTC).isoformat(timespec='seconds')}",
"",
"This report was produced by ApplePY for authorised security assessment use. "
"Narrative sections below are grouped into **overarching themes** to limit volume; each finding "
"still states its granular **Detail category** (matching `findings.xlsx` columns). "
"Use the **Contents** worksheet in `findings.xlsx` for tab-level navigation.",
"",
"## Contents",
"",
"- [Executive summary](#executive-summary)",
]
if warnings:
lines.append("- [Warnings](#warnings)")
if any(f.worksheet == "CIS" for f in findings):
lines.append("- [CIS macOS worksheet index](#cis-macos-worksheet-index)")
for theme in themes_ordered:
lines.append(f"- [{_md_text(theme)}](#{_md_anchor(theme)})")
lines.extend(
[
"- [Appendix: external baselines and methodology](#appendix-external-baselines-and-methodology)",
"",
]
)
if warnings:
lines.append("## Warnings")
lines.append("")
for w in warnings:
lines.append(f"- {w}")
lines.append("")
lines.extend(
[
"## Executive summary",
"",
f"- **Total findings:** {len(findings)}",
f"- **By severity:** {_severity_summary(findings)}",
"",
"### CIS macOS baseline",
"",
"CIS-aligned control signals are recorded on the **CIS** worksheet in `findings.xlsx` "
"(for example FileVault, password policy, boot uptime, Time Machine destinations, local "
"`admin` group membership, and default shell umask). Row-level mSCP rule outcomes from "
"the audit plist appear on the **mSCP rules** worksheet; raw compliance script output remains "
"on the **Compliance** worksheet when a local `macos_security` clone is configured and "
"privileged execution is enabled.",
"",
]
)
lines.extend(_cis_worksheet_index_lines(findings))
for theme in themes_ordered:
rows = by_theme[theme]
lines.append(f"## {theme}")
lines.append("")
granular = sorted({f.category for f in rows})
if granular:
lines.append(
f"*Granular categories covered here: {', '.join(_md_text(c) for c in granular)}.*"
)
lines.append("")
for f in sorted(rows, key=lambda x: (severity_rank(x.severity), x.id)):
lines.extend(_format_finding_section(f))
lines.append("## Appendix: external baselines and methodology")
lines.append("")
lines.append(
"- NIST / mSCP: [usnistgov/macos_security](https://github.com/usnistgov/macos_security), "
"[project pages](https://pages.nist.gov/macos_security/). "
"Set `APPLEPY_MACOS_SECURITY_ROOT` to your clone; optional `APPLEPY_MSCP_BASELINE` for the "
"YAML file name under `baselines/`. Set `APPLEPY_MSCP_SKIP_GENERATE=1` to only run an "
"existing `build/*/*_compliance.sh` without regenerating."
)
lines.append(
"- Reporting references: [ZSEC LTR101](https://blog.zsec.uk/ltr101-pentest-reporting/), "
"Hack The Box reporting guidance, PlexTrac blueprint articles, Black Hills Infosec reporting "
"guidance (see project `README.md`)."
)
lines.append("- Third-party tool credits: see `CREDITS.md`.")
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
def _cis_sort_key(section: str) -> tuple[int, ...]:
"""Numeric sort key for CIS section strings like '2.3.3.4'."""
try:
return tuple(int(x) for x in section.split("."))
except ValueError:
return (999,)
def _cis_status_from_evidence(evidence: str) -> str:
"""Extract PASS/FAIL/WARN/UNKNOWN token from a structured CIS evidence block."""
for line in evidence.splitlines():
m = re.match(r"Status:\s+[✓✗⚠?]\s+(PASS|FAIL|WARN|UNKNOWN)", line)
if m:
return m.group(1)
return ""
_STATUS_ICON: dict[str, str] = {"PASS": "", "FAIL": "", "WARN": "", "UNKNOWN": "?"}
def _cis_worksheet_index_lines(findings: list[Finding]) -> list[str]:
"""Structured CIS table with PASS/FAIL status, grouped by section number."""
cis_rows = [f for f in findings if f.worksheet == "CIS"]
if not cis_rows:
return []
new_style = [f for f in cis_rows if f.cis_section]
legacy = [f for f in cis_rows if not f.cis_section]
out = [
"## CIS macOS worksheet index",
"",
"Row-level CIS-tab results from this run (sort and filter in `findings.xlsx` → **CIS**):",
"",
]
if new_style:
statuses = {f.id: _cis_status_from_evidence(f.evidence) for f in new_style}
status_counts: Counter[str] = Counter(s for s in statuses.values() if s)
summary_parts = [
f"{_STATUS_ICON.get(label, '?')} **{label}:** {status_counts[label]}"
for label in ("PASS", "FAIL", "WARN", "UNKNOWN")
if status_counts[label]
]
out.append(f"**{len(new_style)} automated checks** — " + " · ".join(summary_parts))
out.append("")
out.append("| CIS Ref | Lvl | Status | Title | Check ID |")
out.append("|:--------|:---:|:------:|-------|:---------|")
for f in sorted(new_style, key=lambda x: _cis_sort_key(x.cis_section)):
status = statuses.get(f.id, "")
icon = _STATUS_ICON.get(status, "")
lvl = str(f.cis_level) if f.cis_level else ""
out.append(
f"| {f.cis_section} | {lvl} | {icon} {status} | {_md_text(f.title)} | `{f.id}` |"
)
out.append("")
if legacy:
out.append("**Legacy CIS checks** (pre-dating section metadata):")
out.append("")
for f in sorted(legacy, key=lambda x: (severity_rank(x.severity), x.id)):
out.append(f"- **`{f.id}`** — {_md_text(f.title)} *(severity: {f.severity.value})*")
out.append("")
out.append(
"The narrative finding **cis-000** under Compliance in this document is the worksheet pointer; "
"all items above are the substantive CIS-tab rows for this scan."
)
out.append("")
return out
def _format_finding_section(f: Finding) -> list[str]:
out = [
f"### {_md_text(f.title)} (`{f.id}`)",
"",
f"- **Severity:** {f.severity.value}",
f"- **Detail category:** {_md_text(f.category)}",
f"- **Worksheet:** `{f.worksheet}`",
]
if f.mitre_techniques:
out.append(f"- **MITRE ATT&CK:** {f.mitre_display()}")
out.append("")
out.append("#### Description")
out.append("")
out.append(_md_text(f.description))
out.append("")
if f.risk:
out.append("#### Risk")
out.append("")
out.append(_md_text(f.risk))
out.append("")
if f.impact:
out.append("#### Potential impact")
out.append("")
out.append(_md_text(f.impact))
out.append("")
out.append("#### Evidence")
out.append("")
out.append("```")
ev = f.evidence.strip() if f.evidence.strip() else "(none)"
out.append(_md_text(ev))
out.append("```")
out.append("")
if f.remediation:
out.append("#### Remediation recommendations")
out.append("")
out.append(_md_text(f.remediation))
out.append("")
if f.references:
out.append("#### References")
out.append("")
for ref in f.references:
out.append(f"- {ref}")
out.append("")
return out
def _severity_summary(findings: list[Finding]) -> str:
counts: dict[str, int] = defaultdict(int)
for f in findings:
counts[f.severity.value] += 1
order = ("critical", "high", "medium", "low", "informational")
return ", ".join(f"{k}: {counts[k]}" for k in order if counts.get(k))

View File

@@ -0,0 +1,47 @@
"""Map granular finding categories to overarching report themes (markdown narrative only)."""
from __future__ import annotations
# Order of ## sections in report.md (highest-level narrative grouping).
REPORT_THEME_ORDER: tuple[str, ...] = (
"Core platform and controls",
"Compliance and configuration baselines",
"Catalogues, credentials, and living-off-the-land",
"Attack surface, applications, and persistence",
"Hardening and filesystem posture",
"MDM local posture",
"Threat mapping and scanner meta",
"Other findings",
)
_CATEGORY_TO_THEME: dict[str, str] = {
"Core": "Core platform and controls",
"Native (PyObjC)": "Core platform and controls",
"Compliance": "Compliance and configuration baselines",
"LOLBins": "Catalogues, credentials, and living-off-the-land",
"Credentials": "Catalogues, credentials, and living-off-the-land",
"Attack surface": "Attack surface, applications, and persistence",
"Electron": "Attack surface, applications, and persistence",
"Persistence": "Attack surface, applications, and persistence",
"Hardening": "Hardening and filesystem posture",
"MDM: Jamf": "MDM local posture",
"MDM: Kandji": "MDM local posture",
"MITRE": "Threat mapping and scanner meta",
"Scanner reliability": "Threat mapping and scanner meta",
}
def theme_for_category(category: str) -> str:
return _CATEGORY_TO_THEME.get(category, "Other findings")
def themes_present_in_order(themes: set[str]) -> list[str]:
"""Stable section order: known themes first, then any extras."""
out: list[str] = []
for t in REPORT_THEME_ORDER:
if t in themes:
out.append(t)
for t in sorted(themes):
if t not in out:
out.append(t)
return out

513
applepy/reporters/xlsx.py Normal file
View File

@@ -0,0 +1,513 @@
"""Write multi-worksheet XLSX with formatting (freeze, filters, wrap, widths)."""
from __future__ import annotations
import re
from collections import defaultdict
from pathlib import Path
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from applepy.findings import Finding, Severity, severity_rank
# XML 1.0 disallows these control characters; openpyxl/Excel reject or corrupt cells if present.
_ILLEGAL_XML_CHARS = re.compile(r"[\x00-\x08\x0B\x0C\x0E-\x1F]")
# Excel per-cell character limit (~32k); split into continuation rows instead of truncating silently.
_EXCEL_CELL_CHAR_LIMIT = 32767
_EXCEL_CHUNK = 32700
_HEADER_FONT = Font(bold=True, color="FFFFFF")
_HYPERLINK_FONT = Font(color="0563C1", underline="single")
_WRAP = Alignment(wrap_text=True, vertical="top")
_THIN = Side(style="thin", color="B4B4B4")
_BORDER_ALL = Border(top=_THIN, left=_THIN, right=_THIN, bottom=_THIN)
# Row background by severity (pastel, WCAG-friendly text remains default black).
_SEVERITY_ROW_FILL: dict[Severity, PatternFill] = {
Severity.CRITICAL: PatternFill(start_color="F8D7DA", end_color="F8D7DA", fill_type="solid"),
Severity.HIGH: PatternFill(start_color="FCE1C9", end_color="FCE1C9", fill_type="solid"),
Severity.MEDIUM: PatternFill(start_color="FFF3CD", end_color="FFF3CD", fill_type="solid"),
Severity.LOW: PatternFill(start_color="D1ECF1", end_color="D1ECF1", fill_type="solid"),
Severity.INFORMATIONAL: PatternFill(start_color="E9ECEF", end_color="E9ECEF", fill_type="solid"),
}
_SEVERITY_LABEL_FONT: dict[Severity, Font] = {
Severity.CRITICAL: Font(bold=True, color="842029"),
Severity.HIGH: Font(bold=True, color="B45309"),
Severity.MEDIUM: Font(bold=True, color="664D03"),
Severity.LOW: Font(bold=True, color="055160"),
Severity.INFORMATIONAL: Font(bold=True, color="495057"),
}
_SEVERITY_COL_INDEX = 3
_COLS = (
"ID",
"Title",
"Severity",
"Category",
"Description",
"Risk",
"Impact",
"Evidence",
"Remediation",
"References",
"MITRE ATT&CK",
)
_FINDINGS_LIST_COLS = (
"ID",
"Title",
"Severity",
"Category",
"Detail worksheet",
"Description",
"Risk",
"Impact",
"Evidence",
"Remediation",
"References",
"MITRE ATT&CK",
)
def _allocate_sheet_title(base: str, used_titles: set[str]) -> str:
base_safe = _safe_sheet_title(base)
safe = base_safe
n = 2
while safe in used_titles:
suffix = str(n)
trim = max(1, 31 - len(suffix) - 1)
safe = _safe_sheet_title(f"{base_safe[:trim]}_{suffix}")
n += 1
return safe
def write_xlsx_report(path: Path, findings: list[Finding], *, heading_bg: str = "871727") -> None:
# Normalise heading_bg: strip any leading '#'.
heading_bg = heading_bg.lstrip("#")
path.parent.mkdir(parents=True, exist_ok=True)
by_sheet: dict[str, list[Finding]] = defaultdict(list)
for f in findings:
by_sheet[f.worksheet].append(f)
wb = Workbook()
# The active sheet will become the Summary sheet; rename it now.
summary_ws = wb.active
summary_ws.title = "Summary"
sorted_names = sorted(by_sheet.keys())
sheet_info: list[tuple[str, str, list[Finding]]] = []
used_titles: set[str] = {"Summary"}
for name in sorted_names:
safe = _allocate_sheet_title(name, used_titles)
used_titles.add(safe)
rows = sorted(by_sheet[name], key=lambda f: (severity_rank(f.severity), f.id))
sheet_info.append((name, safe, rows))
actionable = [f for f in findings if f.severity != Severity.INFORMATIONAL]
actionable.sort(key=lambda f: (severity_rank(f.severity), f.id))
findings_list_title = _allocate_sheet_title("Findings list", used_titles)
used_titles.add(findings_list_title)
if not sheet_info:
# Minimal summary when there are no findings.
_write_summary_sheet(summary_ws, findings, sheet_info, findings_list_title, heading_bg)
wb.save(path)
return
_write_summary_sheet(summary_ws, findings, sheet_info, findings_list_title, heading_bg)
fl_ws = wb.create_sheet(title=findings_list_title)
_write_findings_list_sheet(fl_ws, actionable, heading_bg)
for _orig, safe, rows in sheet_info:
ws = wb.create_sheet(title=safe)
_write_sheet(ws, rows, heading_bg)
wb.save(path)
# ---------------------------------------------------------------------------
# Summary sheet
# ---------------------------------------------------------------------------
def _write_summary_sheet(
ws,
findings: list[Finding],
sheet_info: list[tuple[str, str, list[Finding]]],
findings_list_title: str,
heading_bg: str,
) -> None:
"""Populate the Summary worksheet with four sections."""
heading_fill = PatternFill(start_color=heading_bg, end_color=heading_bg, fill_type="solid")
heading_font = Font(bold=True, color="FFFFFF")
center_bold = Alignment(horizontal="center", vertical="center", wrap_text=True)
center_wrap = Alignment(horizontal="center", vertical="top")
left_wrap = Alignment(wrap_text=True, vertical="top")
# ------------------------------------------------------------------
# Row 1: Title (merged A1:E1)
# ------------------------------------------------------------------
ws.merge_cells("A1:E1")
title_cell = ws["A1"]
title_cell.value = "Security Review Summary"
title_cell.font = Font(bold=True, size=14, color="FFFFFF")
title_cell.fill = heading_fill
title_cell.alignment = center_bold
current_row = 2 # blank gap row
# ------------------------------------------------------------------
# Helper: write a section header row (merged A:E to cover widest section)
# ------------------------------------------------------------------
def _section_header(label: str) -> int:
nonlocal current_row
current_row += 1 # gap before section
ws.merge_cells(f"A{current_row}:E{current_row}")
cell = ws[f"A{current_row}"]
cell.value = label
cell.font = heading_font
cell.fill = heading_fill
cell.alignment = center_bold
r = current_row
current_row += 1
return r
def _data_row(*values, hyperlink_col: int | None = None, hyperlink_target: str | None = None):
nonlocal current_row
r = current_row
for col_idx, val in enumerate(values, start=1):
c = ws.cell(row=r, column=col_idx, value=val)
c.border = _BORDER_ALL
c.alignment = center_wrap if col_idx > 1 else left_wrap
if col_idx == hyperlink_col and hyperlink_target:
c.hyperlink = hyperlink_target
c.font = _HYPERLINK_FONT
current_row += 1
return r
def _col_header_row(*labels):
nonlocal current_row
r = current_row
for col_idx, label in enumerate(labels, start=1):
c = ws.cell(row=r, column=col_idx, value=label)
c.font = heading_font
c.fill = heading_fill
c.border = _BORDER_ALL
c.alignment = center_bold
current_row += 1
return r
# ------------------------------------------------------------------
# Section 1: Vulnerability Summary
# ------------------------------------------------------------------
_section_header("Vulnerability Summary")
_col_header_row("Severity", "Count", "% of Total")
sev_order = [
Severity.CRITICAL,
Severity.HIGH,
Severity.MEDIUM,
Severity.LOW,
Severity.INFORMATIONAL,
]
sev_labels = {
Severity.CRITICAL: "Critical",
Severity.HIGH: "High",
Severity.MEDIUM: "Medium",
Severity.LOW: "Low",
Severity.INFORMATIONAL: "Informational",
}
sev_counts: dict[Severity, int] = {s: 0 for s in sev_order}
for f in findings:
if f.severity in sev_counts:
sev_counts[f.severity] += 1
total = len(findings)
for sev in sev_order:
count = sev_counts[sev]
pct = f"{count / total * 100:.1f}%" if total else "0.0%"
_data_row(sev_labels[sev], count, pct)
# Total row
_data_row("Total", total, "100.0%" if total else "0.0%")
# ------------------------------------------------------------------
# Section 2: CIS Compliance
# ------------------------------------------------------------------
_section_header("CIS Compliance Summary")
_col_header_row("Pass", "Fail", "Count", "Pass %")
cis_findings: list[Finding] = []
cis_sheet_title: str | None = None
for orig, safe, rows in sheet_info:
if orig == "CIS":
cis_findings = rows
cis_sheet_title = safe
break
cis_pass = sum(
1 for f in cis_findings
if f.severity == Severity.INFORMATIONAL and f.id != "cis-000"
)
cis_fail = sum(1 for f in cis_findings if f.severity != Severity.INFORMATIONAL and f.id != "cis-000")
# Exclude the cis-000 index pointer from the denominator so pass + fail == count.
cis_count = sum(1 for f in cis_findings if f.id != "cis-000")
cis_pct = f"{cis_pass / cis_count * 100:.1f}%" if cis_count else "N/A"
if cis_sheet_title:
esc = cis_sheet_title.replace("'", "''")
_data_row(cis_pass, cis_fail, cis_count, cis_pct,
hyperlink_col=4, hyperlink_target=f"#'{esc}'!A1")
else:
_data_row(cis_pass, cis_fail, cis_count, cis_pct)
# ------------------------------------------------------------------
# Section 3: mSCP / NIST Compliance
# ------------------------------------------------------------------
_section_header("mSCP / NIST Compliance Summary")
_col_header_row("Pass", "Fail", "Exempt", "Total", "Pass %")
mscp_findings: list[Finding] = []
for orig, _safe, rows in sheet_info:
if orig == "mSCP rules":
mscp_findings = rows
break
def _title_lc(f: Finding) -> str:
return f.title.lower()
def _is_non_compliant(t: str) -> bool:
# mSCP titles may use either a space or underscore as the word separator.
return "non compliant" in t or "non_compliant" in t
mscp_pass = sum(
1 for f in mscp_findings
if "compliant" in _title_lc(f) and not _is_non_compliant(_title_lc(f))
)
mscp_fail = sum(
1 for f in mscp_findings
if _is_non_compliant(_title_lc(f)) and "exempt" not in _title_lc(f)
)
mscp_exempt = sum(1 for f in mscp_findings if "exempt" in _title_lc(f))
mscp_total = len(mscp_findings)
mscp_pct = f"{mscp_pass / mscp_total * 100:.1f}%" if mscp_total else "N/A"
# 5-column data row
r = current_row
for col_idx, val in enumerate(
[mscp_pass, mscp_fail, mscp_exempt, mscp_total, mscp_pct], start=1
):
c = ws.cell(row=r, column=col_idx, value=val)
c.border = _BORDER_ALL
c.alignment = center_wrap
current_row += 1
# ------------------------------------------------------------------
# Section 4: Worksheet Index
# ------------------------------------------------------------------
_section_header("Worksheet Index")
_col_header_row("Worksheet", "Findings", "", "")
# Findings list row
fl_esc = findings_list_title.replace("'", "''")
r = current_row
c1 = ws.cell(row=r, column=1, value="Findings list (non-informational)")
c1.hyperlink = f"#'{fl_esc}'!A1"
c1.font = _HYPERLINK_FONT
c1.border = _BORDER_ALL
c1.alignment = left_wrap
actionable_count = sum(1 for f in findings if f.severity != Severity.INFORMATIONAL)
for col_idx in range(2, 5):
c = ws.cell(row=r, column=col_idx,
value=actionable_count if col_idx == 2 else "")
c.border = _BORDER_ALL
c.alignment = center_wrap
current_row += 1
for orig, safe, rows in sheet_info:
r = current_row
esc = safe.replace("'", "''")
c1 = ws.cell(row=r, column=1, value=orig)
c1.hyperlink = f"#'{esc}'!A1"
c1.font = _HYPERLINK_FONT
c1.border = _BORDER_ALL
c1.alignment = left_wrap
for col_idx in range(2, 5):
c = ws.cell(row=r, column=col_idx,
value=len(rows) if col_idx == 2 else "")
c.border = _BORDER_ALL
c.alignment = center_wrap
current_row += 1
# ------------------------------------------------------------------
# Sheet-level formatting
# ------------------------------------------------------------------
ws.freeze_panes = "A2"
ws.column_dimensions["A"].width = 28
ws.column_dimensions["B"].width = 20
ws.column_dimensions["C"].width = 15
ws.column_dimensions["D"].width = 15
ws.column_dimensions["E"].width = 15
# ---------------------------------------------------------------------------
# Existing helpers
# ---------------------------------------------------------------------------
# Characters that make Excel/LibreOffice treat a cell as a formula when they appear first.
_FORMULA_FIRST_CHARS = frozenset("=+-@")
def _xlsx_cell_text(value: object) -> str:
s = value if isinstance(value, str) else str(value)
s = _ILLEGAL_XML_CHARS.sub(" ", s)
# Prefix formula-triggering first characters with a space so the cell is stored as plain text.
if s and s[0] in _FORMULA_FIRST_CHARS:
s = " " + s
return s
def _display_cell(value: object) -> str:
"""Use an em dash for empty optional text so columns do not look accidentally blank."""
s = value if isinstance(value, str) else str(value)
t = s.strip()
return _xlsx_cell_text(t) if t else ""
def _evidence_chunks_for_excel(evidence: str) -> list[str]:
"""Split sanitised evidence so each chunk stays within Excel's cell limit."""
text = _xlsx_cell_text(evidence)
if not text.strip():
return [""]
if len(text) <= _EXCEL_CELL_CHAR_LIMIT:
return [text]
chunks: list[str] = []
for i in range(0, len(text), _EXCEL_CHUNK):
chunks.append(text[i : i + _EXCEL_CHUNK])
return chunks
def _apply_severity_row_style(ws, row_idx: int, severity: Severity, ncols: int) -> None:
fill = _SEVERITY_ROW_FILL.get(severity)
# Always apply borders regardless of whether we have a fill for this severity.
for col in range(1, ncols + 1):
cell = ws.cell(row=row_idx, column=col)
if fill:
cell.fill = fill
cell.border = _BORDER_ALL
sev_cell = ws.cell(row=row_idx, column=_SEVERITY_COL_INDEX)
sev_cell.font = _SEVERITY_LABEL_FONT.get(severity, Font(bold=True))
def _safe_sheet_title(name: str) -> str:
cleaned = "".join(c for c in name if c not in "[]:*?/\\")[:31]
return cleaned or "Sheet"
def _make_header_fill(heading_bg: str) -> PatternFill:
return PatternFill(start_color=heading_bg, end_color=heading_bg, fill_type="solid")
def _write_sheet(ws, rows: list[Finding], heading_bg: str = "871727") -> None:
header_fill = _make_header_fill(heading_bg)
header_font = Font(bold=True, color="FFFFFF")
ws.append(list(_COLS))
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = _BORDER_ALL
for f in rows:
ev_chunks = _evidence_chunks_for_excel(f.evidence)
n_ev = len(ev_chunks)
for part_idx, ev_part in enumerate(ev_chunks):
title = f.title
if n_ev > 1:
title = f"{f.title} [evidence segment {part_idx + 1} of {n_ev}; Excel cell limit]"
ws.append(
[
_display_cell(f.id),
_display_cell(title),
_display_cell(f.severity.value),
_display_cell(f.category),
_display_cell(f.description),
_display_cell(f.risk),
_display_cell(f.impact),
ev_part,
_display_cell(f.remediation),
_display_cell(f.references_display()),
_display_cell(f.mitre_display()),
]
)
_apply_severity_row_style(ws, ws.max_row, f.severity, len(_COLS))
for row in ws.iter_rows(min_row=2, max_row=ws.max_row, min_col=1, max_col=len(_COLS)):
for cell in row:
cell.alignment = _WRAP
ws.freeze_panes = "A2"
if ws.max_row >= 1:
ws.auto_filter.ref = f"A1:{get_column_letter(len(_COLS))}{max(1, ws.max_row)}"
widths = (14, 32, 10, 18, 40, 28, 28, 44, 36, 32, 22)
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w
def _write_findings_list_sheet(ws, rows: list[Finding], heading_bg: str = "871727") -> None:
header_fill = _make_header_fill(heading_bg)
header_font = Font(bold=True, color="FFFFFF")
ws.append(list(_FINDINGS_LIST_COLS))
for cell in ws[1]:
cell.font = header_font
cell.fill = header_fill
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = _BORDER_ALL
for f in rows:
ev_chunks = _evidence_chunks_for_excel(f.evidence)
n_ev = len(ev_chunks)
for part_idx, ev_part in enumerate(ev_chunks):
title = f.title
if n_ev > 1:
title = f"{f.title} [evidence segment {part_idx + 1} of {n_ev}; Excel cell limit]"
ws.append(
[
_display_cell(f.id),
_display_cell(title),
_display_cell(f.severity.value),
_display_cell(f.category),
_display_cell(f.worksheet),
_display_cell(f.description),
_display_cell(f.risk),
_display_cell(f.impact),
ev_part,
_display_cell(f.remediation),
_display_cell(f.references_display()),
_display_cell(f.mitre_display()),
]
)
_apply_severity_row_style(ws, ws.max_row, f.severity, len(_FINDINGS_LIST_COLS))
for row in ws.iter_rows(
min_row=2, max_row=ws.max_row, min_col=1, max_col=len(_FINDINGS_LIST_COLS)
):
for cell in row:
cell.alignment = _WRAP
ws.freeze_panes = "A2"
if ws.max_row >= 1:
ws.auto_filter.ref = (
f"A1:{get_column_letter(len(_FINDINGS_LIST_COLS))}{max(1, ws.max_row)}"
)
widths = (14, 32, 10, 18, 22, 40, 28, 28, 44, 36, 32, 22)
for i, w in enumerate(widths, start=1):
ws.column_dimensions[get_column_letter(i)].width = w

181
applepy/runner.py Normal file
View File

@@ -0,0 +1,181 @@
"""Execute registered checks and merge findings."""
from __future__ import annotations
import hashlib
import logging
import subprocess
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import TYPE_CHECKING
from applepy.check_progress import check_run_failed
from applepy.context import PrivilegePhase, RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
if TYPE_CHECKING:
from applepy.check_progress import CheckProgressReporter
logger = logging.getLogger(__name__)
def _failure_finding(check_name: str, exc: BaseException) -> Finding:
"""Surface check crashes in the report instead of silent omission."""
hid = hashlib.sha256(check_name.encode("utf-8")).hexdigest()[:14]
msg = f"{type(exc).__name__}: {exc}"
return Finding(
id=f"run-{hid}",
title=f"Check failed to complete: {check_name}",
category="Scanner reliability",
severity=Severity.LOW,
description=(
"This check raised an exception before returning structured findings. Other checks continued. "
"Re-run with `--sequential` and `-v` to isolate; confirm the host Python and filesystem are healthy."
),
evidence=msg,
worksheet="Scanner reliability",
mitre_techniques=(),
remediation=(
"Review stderr logs above; if the fault reproduces, capture the check name and exception type "
"for triage."
),
)
def _run_single_check(name: str, fn, ctx: RunContext) -> list[Finding]:
try:
return fn(ctx)
except OSError as e:
logger.warning("Check %s failed (OS): %s", name, e)
return [_failure_finding(name, e)]
except subprocess.SubprocessError as e:
logger.warning("Check %s failed (subprocess): %s", name, e)
return [_failure_finding(name, e)]
except Exception as e: # noqa: BLE001
logger.exception("Check %s raised unexpectedly: %s", name, e)
return [_failure_finding(name, e)]
def run_phase(
registry: CheckRegistry,
phase: PrivilegePhase,
ctx_base: RunContext,
*,
parallel: bool,
progress: CheckProgressReporter | None = None,
) -> list[Finding]:
ctx = RunContext(
home=ctx_base.home,
output_dir=ctx_base.output_dir,
phase=phase,
dry_run=ctx_base.dry_run,
)
checks = list(registry.checks_for(phase))
if not checks:
return []
if progress:
progress.phase_begin(phase, len(checks))
if not parallel:
out: list[Finding] = []
total = len(checks)
for i, (name, fn) in enumerate(checks, start=1):
if progress:
progress.check_start(i, total, name)
t0 = time.perf_counter()
found = _run_single_check(name, fn, ctx)
elapsed = time.perf_counter() - t0
if progress:
progress.check_done(i, total, name, elapsed, len(found), check_run_failed(found))
out.extend(found)
if progress:
progress.phase_end(phase, out)
return out
max_workers = min(12, max(4, len(checks)))
results: list[tuple[int, list[Finding]]] = []
slot_lock = threading.Lock()
done_slot = [0]
def _wrapped(i: int, name: str, fn):
t0 = time.perf_counter()
found = _run_single_check(name, fn, ctx)
elapsed = time.perf_counter() - t0
if progress:
with slot_lock:
done_slot[0] += 1
slot = done_slot[0]
progress.check_done(slot, len(checks), name, elapsed, len(found), check_run_failed(found))
return i, found
with ThreadPoolExecutor(max_workers=max_workers) as pool:
future_to_meta = {
pool.submit(_wrapped, i, name, fn): (i, name) for i, (name, fn) in enumerate(checks)
}
for fut in as_completed(future_to_meta):
i, name = future_to_meta[fut]
try:
i2, found = fut.result()
except Exception as e: # noqa: BLE001
logger.exception("Check %s future failed: %s", name, e)
found = [_failure_finding(name, e)]
if progress:
with slot_lock:
done_slot[0] += 1
slot = done_slot[0]
progress.check_done(slot, len(checks), name, 0.0, len(found), True)
i2 = i
results.append((i2, found))
results.sort(key=lambda x: x[0])
merged: list[Finding] = []
for _, found in results:
merged.extend(found)
if progress:
progress.phase_end(phase, merged)
return merged
def run_all(
registry: CheckRegistry,
ctx_base: RunContext,
*,
unprivileged_only: bool,
privileged_only: bool,
parallel: bool = True,
progress: CheckProgressReporter | None = None,
) -> tuple[list[Finding], list[str]]:
warnings: list[str] = []
findings: list[Finding] = []
if privileged_only:
if not ctx_base.is_root():
warnings.append(
"Privileged-only mode requested but effective UID is not 0; no privileged checks ran."
)
return findings, warnings
findings.extend(
run_phase(registry, "privileged", ctx_base, parallel=parallel, progress=progress)
)
return findings, warnings
findings.extend(
run_phase(registry, "unprivileged", ctx_base, parallel=parallel, progress=progress)
)
if unprivileged_only:
return findings, warnings
if not ctx_base.is_root():
warnings.append(
"Privileged checks skipped: re-run with sudo for a full scan (privileged phase requires root)."
)
return findings, warnings
findings.extend(
run_phase(registry, "privileged", ctx_base, parallel=parallel, progress=progress)
)
return findings, warnings

36
applepy/subproc.py Normal file
View File

@@ -0,0 +1,36 @@
"""Constrained subprocess helpers."""
from __future__ import annotations
import os
import subprocess
from typing import Final
_DEFAULT_TIMEOUT: Final[float] = 45.0
def run_text(
cmd: list[str],
*,
timeout: float = _DEFAULT_TIMEOUT,
cwd: str | os.PathLike[str] | None = None,
) -> tuple[int, str, str]:
"""Run command; return (code, stdout, stderr) as UTF-8 text with errors replaced."""
cwd_arg = os.fspath(cwd) if cwd is not None else None
try:
p = subprocess.run(
cmd,
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=timeout,
check=False,
text=True,
encoding="utf-8",
errors="replace",
cwd=cwd_arg,
)
return p.returncode, p.stdout or "", p.stderr or ""
except subprocess.TimeoutExpired:
return 124, "", f"timeout after {timeout}s: {' '.join(cmd)}"
except OSError as e:
return 1, "", str(e)

47
pyproject.toml Normal file
View File

@@ -0,0 +1,47 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "applepy"
version = "0.1.0"
description = "macOS security review and attack-surface scanner"
readme = "README.md"
requires-python = ">=3.11"
# PyObjC ships macOS wheels only; it is required for supported (Darwin) installs — see README.
dependencies = [
"openpyxl>=3.1.0",
"pyobjc-core>=10.3.1; platform_system == 'Darwin'",
"pyobjc-framework-Cocoa>=10.3.1; platform_system == 'Darwin'",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"ruff>=0.6",
"ty>=0.0.29",
]
bundle = [
"pyinstaller>=6.0",
"pyyaml>=6.0",
"xlwt>=1.3.0",
]
[project.scripts]
applepy = "applepy.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["applepy"]
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["applepy", "tests"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]

View File

@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# Clone NIST macos_security and Lynis into ./vendor (shallow). Requires git and network.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
exec python3 -m applepy.bootstrap_compliance "$@"

31
scripts/build_bundle.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Build one-folder PyInstaller distribution (see applepy.spec). Requires: pip install -e ".[bundle]"
# By default fetches NIST macos_security + Lynis into applepy/data/ (git + network). Offline:
# SKIP_VENDOR_COMPLIANCE=1 ./scripts/build_bundle.sh
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ -f .venv/bin/activate ]]; then
# shellcheck source=/dev/null
source .venv/bin/activate
fi
if [[ "${SKIP_VENDOR_COMPLIANCE:-0}" != "1" ]]; then
"${ROOT}/scripts/vendor_compliance_assets.sh" all
else
echo "SKIP_VENDOR_COMPLIANCE=1: skipping scripts/vendor_compliance_assets.sh"
fi
python -m pip install -q -e ".[bundle]"
DIST_OUT="${ROOT}/dist/applepy"
if [[ -e "${DIST_OUT}" ]]; then
echo "Removing previous bundle: ${DIST_OUT}"
if ! rm -rf "${DIST_OUT}"; then
echo "ERROR: Could not remove ${DIST_OUT}." >&2
echo "This usually means root-owned files under .../macos_security/build from a prior sudo run of the bundle." >&2
echo "Fix: sudo rm -rf \"${DIST_OUT}\"" >&2
echo "Then re-run this script. The spec omits mSCP build/ from the bundle to avoid shipping host output." >&2
exit 1
fi
fi
python -m PyInstaller --noconfirm "${ROOT}/applepy.spec"
echo "Output: ${ROOT}/dist/applepy/ → run: dist/applepy/applepy --help"
echo "Note: build/applepy/ is PyInstallers work dir only (no _internal/). Do not run that copy."

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# Shallow-clone NIST macos_security and Lynis into applepy/data/ for bundled / PyInstaller builds.
# Preserves applepy/data/{macos_security,lynis}/README.md and .gitignore (excluded from rsync).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
MACP="${ROOT}/applepy/data/macos_security"
LYNP="${ROOT}/applepy/data/lynis"
TMP="${TMPDIR:-/tmp}/applepy-vendor-$$"
cleanup() { rm -rf "${TMP}"; }
trap cleanup EXIT
mkdir -p "${TMP}"
refresh="${REFRESH:-0}"
clone_mscp() {
if [[ -f "${MACP}/scripts/generate_guidance.py" ]] && [[ "${refresh}" != "1" ]]; then
echo "macos_security already present under applepy/data/macos_security (set REFRESH=1 to re-fetch)"
return 0
fi
mkdir -p "${MACP}"
git clone --depth 1 "https://github.com/usnistgov/macos_security.git" "${TMP}/macos_security"
rsync -a --delete \
--exclude README.md --exclude .gitignore \
"${TMP}/macos_security/" "${MACP}/"
echo "Vendored macos_security -> ${MACP}"
}
clone_lynis() {
if [[ -f "${LYNP}/lynis" ]] && [[ "${refresh}" != "1" ]]; then
echo "Lynis already present under applepy/data/lynis (set REFRESH=1 to re-fetch)"
return 0
fi
mkdir -p "${LYNP}"
git clone --depth 1 "https://github.com/cisofy/lynis.git" "${TMP}/lynis"
rsync -a --delete \
--exclude README.md --exclude .gitignore \
"${TMP}/lynis/" "${LYNP}/"
echo "Vendored Lynis -> ${LYNP}"
}
case "${1:-all}" in
mscp|macos_security) clone_mscp ;;
lynis) clone_lynis ;;
all)
clone_mscp
clone_lynis
;;
*)
echo "Usage: $0 [all|mscp|lynis]" >&2
exit 2
;;
esac

25
scripts/verify.sh Executable file
View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
if [[ -f .venv/bin/activate ]]; then
# shellcheck source=/dev/null
source .venv/bin/activate
fi
ruff check applepy tests
pytest -q
if command -v ty >/dev/null 2>&1; then
ty check applepy
fi
if command -v semgrep >/dev/null 2>&1; then
# Exclude vendored upstream trees under applepy/data/ (not project-owned source).
_applepy_py=()
while IFS= read -r _f; do
_applepy_py+=("$_f")
done < <(
find "${ROOT}/applepy" \( -path "${ROOT}/applepy/data/macos_security" -o -path "${ROOT}/applepy/data/lynis" \) \
-prune -o -name "*.py" -print
)
semgrep --config="${ROOT}/semgrep.yml" --error "${_applepy_py[@]}"
semgrep --config=p/python --error "${_applepy_py[@]}"
fi

12
semgrep.yml Normal file
View File

@@ -0,0 +1,12 @@
# Run: semgrep --config semgrep.yml applepy
# Or: semgrep --config=p/python applepy
rules:
- id: subprocess-with-shell-true
languages: [python]
severity: ERROR
message: Avoid subprocess with shell=True (injection risk).
pattern-either:
- pattern: subprocess.run(..., shell=True, ...)
- pattern: subprocess.Popen(..., shell=True, ...)
- pattern: subprocess.call(..., shell=True, ...)

345
tests/test_bloodhound.py Normal file
View File

@@ -0,0 +1,345 @@
"""Tests for the JamfHound-compatible BloodHound CE OpenGraph export."""
import json
from pathlib import Path
import pytest
from applepy.graph_validate import GraphExportError, validate_graph_document
from applepy.findings import Finding, Severity
from applepy.reporters.graph_json import write_graph_json
# ---------------------------------------------------------------------------
# Fixtures / helpers
# ---------------------------------------------------------------------------
def _jamf_enrolled_finding() -> Finding:
return Finding(
id="jamf-001",
title="Jamf binary present",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Jamf Pro agent detected.",
evidence="/usr/local/bin/jamf\njamf version 10.50",
worksheet="MDM Jamf",
)
def _admin_group_finding(members: list[str]) -> Finding:
ev = "Admin group members: " + ", ".join(members) if members else "Admin group members: (none parsed)"
return Finding(
id="cis-007",
title="Local administrator accounts (admin group)",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="Accounts in the local admin group.",
evidence=ev,
worksheet="CIS",
)
def _jss_plist_finding(jss_url: str) -> Finding:
"""Simulate jamf-003 plist evidence containing a jss_url key."""
return Finding(
id="jamf-003",
title="Jamf preferences plist (plutil)",
category="MDM: Jamf",
severity=Severity.INFORMATIONAL,
description="Structured view of com.jamfsoftware.jamf preferences.",
evidence=f'"jss_url" => "{jss_url}"',
worksheet="MDM Jamf",
)
# ---------------------------------------------------------------------------
# Structural / format tests
# ---------------------------------------------------------------------------
def test_graph_json_opengraph_structure(tmp_path: Path) -> None:
"""Written file has the top-level graph/metadata OpenGraph envelope."""
p = tmp_path / "graph.json"
write_graph_json(p, [_jamf_enrolled_finding()], "testhost")
data = json.loads(p.read_text(encoding="utf-8"))
assert "graph" in data, "top-level 'graph' key missing"
assert "metadata" in data, "top-level 'metadata' key missing"
assert "nodes" in data["graph"], "graph.nodes missing"
assert "edges" in data["graph"], "graph.edges missing"
meta = data["metadata"]
assert meta["tool"] == "ApplePY"
assert meta["schema_version"] == "1.1.1"
assert "collection_timestamp" in meta
def test_graph_json_computer_node(tmp_path: Path) -> None:
"""A jamf_Computer node is always generated for the scanned host."""
p = tmp_path / "graph.json"
write_graph_json(p, [_jamf_enrolled_finding()], "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
computer_nodes = [n for n in nodes if "jamf_Computer" in n["kinds"]]
assert len(computer_nodes) == 1, "expected exactly one jamf_Computer node"
cn = computer_nodes[0]
assert cn["properties"]["name"] == "MYHOST"
assert cn["properties"]["objectid"] == cn["id"]
assert cn["properties"]["Tier"] == 1
assert cn["properties"]["platform"] == "macOS"
def test_graph_json_tenant_node_from_jss_url(tmp_path: Path) -> None:
"""A jamf_Tenant node is created when a JSS URL is present in evidence."""
findings = [
_jamf_enrolled_finding(),
_jss_plist_finding("https://acme.jamfcloud.com/"),
]
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
tenant_nodes = [n for n in nodes if "jamf_Tenant" in n["kinds"]]
assert len(tenant_nodes) == 1
tn = tenant_nodes[0]
assert "acme.jamfcloud.com" in tn["id"].upper() or "ACME.JAMFCLOUD.COM" in tn["properties"]["name"]
assert tn["properties"]["Tier"] == 0
def test_graph_json_tenant_fallback_when_no_jss_url(tmp_path: Path) -> None:
"""When no JSS URL is found, a tenant node is still created using hostname."""
findings = [_jamf_enrolled_finding()]
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
tenant_nodes = [n for n in nodes if "jamf_Tenant" in n["kinds"]]
assert len(tenant_nodes) == 1, "fallback tenant node should be generated when Jamf is enrolled"
def test_graph_json_no_tenant_when_not_enrolled(tmp_path: Path) -> None:
"""No tenant node is created when Jamf is not enrolled (no jamf-001 finding)."""
findings: list[Finding] = [] # no Jamf findings at all
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
tenant_nodes = [n for n in nodes if "jamf_Tenant" in n["kinds"]]
assert len(tenant_nodes) == 0, "no tenant node expected when Jamf not enrolled"
def test_graph_json_admin_account_nodes_and_edges(tmp_path: Path) -> None:
"""jamf_Account nodes and jamf_AdminTo edges are built from cis-007 evidence."""
findings = [
_jamf_enrolled_finding(),
_admin_group_finding(["alice", "bob"]),
]
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
edges = data["graph"]["edges"]
account_nodes = [n for n in nodes if "jamf_Account" in n["kinds"]]
assert len(account_nodes) == 2
names = {n["properties"]["name"] for n in account_nodes}
assert "ALICE" in names and "BOB" in names
admin_edges = [e for e in edges if e["kind"] == "jamf_AdminTo"]
assert len(admin_edges) == 2
for e in admin_edges:
assert e["properties"]["traversable"] is True
computer_id = next(n["id"] for n in nodes if "jamf_Computer" in n["kinds"])
targets = {e["end"] for e in admin_edges}
assert computer_id in targets
def test_graph_json_contains_edges(tmp_path: Path) -> None:
"""jamf_Contains edges link the tenant to the computer and each account."""
findings = [
_jamf_enrolled_finding(),
_jss_plist_finding("https://acme.jamfcloud.com/"),
_admin_group_finding(["alice"]),
]
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
edges = data["graph"]["edges"]
contains = [e for e in edges if e["kind"] == "jamf_Contains"]
# tenant -> computer + tenant -> alice account = 2
assert len(contains) == 2
for e in contains:
assert e["properties"]["traversable"] is False
def test_graph_json_passes_validation(tmp_path: Path) -> None:
"""validate_graph_document accepts a fully-populated export."""
findings = [
_jamf_enrolled_finding(),
_jss_plist_finding("https://acme.jamfcloud.com/"),
_admin_group_finding(["alice", "bob"]),
]
p = tmp_path / "graph.json"
write_graph_json(p, findings, "myhost")
data = json.loads(p.read_text(encoding="utf-8"))
validate_graph_document(data) # must not raise
def test_graph_json_hostname_uppercased(tmp_path: Path) -> None:
"""Computer node name and id use the uppercased hostname."""
p = tmp_path / "graph.json"
write_graph_json(p, [_jamf_enrolled_finding()], "MyLaptop")
data = json.loads(p.read_text(encoding="utf-8"))
nodes = data["graph"]["nodes"]
computer = next(n for n in nodes if "jamf_Computer" in n["kinds"])
assert computer["properties"]["name"] == "MYLAPTOP"
assert "MYLAPTOP" in computer["id"]
# ---------------------------------------------------------------------------
# validate_graph_document unit tests
# ---------------------------------------------------------------------------
def _minimal_valid_doc() -> dict:
return {
"graph": {
"nodes": [
{
"id": "COMPUTER:HOST",
"kinds": ["jamf_Computer"],
"properties": {
"objectid": "COMPUTER:HOST",
"name": "HOST",
"Tier": 1,
"platform": "macOS",
"os_name": "macOS",
"os_version": "",
"sip_status": "",
"gatekeeper_status": "",
"jamf_enrolled": False,
},
}
],
"edges": [],
},
"metadata": {
"tool": "ApplePY",
"schema_version": "1.1.1",
"collection_timestamp": "2026-01-01T00:00:00Z",
},
}
def test_validate_accepts_minimal_valid() -> None:
validate_graph_document(_minimal_valid_doc())
def test_validate_rejects_missing_graph_key() -> None:
doc = _minimal_valid_doc()
del doc["graph"]
with pytest.raises(GraphExportError, match="graph"):
validate_graph_document(doc)
def test_validate_rejects_missing_metadata() -> None:
doc = _minimal_valid_doc()
del doc["metadata"]
with pytest.raises(GraphExportError, match="metadata"):
validate_graph_document(doc)
def test_validate_rejects_missing_metadata_fields() -> None:
for key in ("tool", "schema_version", "collection_timestamp"):
doc = _minimal_valid_doc()
del doc["metadata"][key]
with pytest.raises(GraphExportError, match=key):
validate_graph_document(doc)
def test_validate_rejects_unknown_node_kind() -> None:
doc = _minimal_valid_doc()
doc["graph"]["nodes"][0]["kinds"] = ["BloodHound_Computer"]
with pytest.raises(GraphExportError, match="unknown kind"):
validate_graph_document(doc)
def test_validate_rejects_mismatched_objectid() -> None:
doc = _minimal_valid_doc()
doc["graph"]["nodes"][0]["properties"]["objectid"] = "WRONG"
with pytest.raises(GraphExportError, match="objectid"):
validate_graph_document(doc)
def test_validate_rejects_missing_tier() -> None:
doc = _minimal_valid_doc()
del doc["graph"]["nodes"][0]["properties"]["Tier"]
with pytest.raises(GraphExportError, match="Tier"):
validate_graph_document(doc)
def test_validate_rejects_edge_with_unknown_node_reference() -> None:
doc = _minimal_valid_doc()
doc["graph"]["edges"].append(
{
"kind": "jamf_AdminTo",
"start": "ACCOUNT:NOBODY",
"end": "COMPUTER:HOST",
"properties": {"traversable": True},
}
)
with pytest.raises(GraphExportError, match="unknown node"):
validate_graph_document(doc)
def test_validate_rejects_edge_missing_traversable() -> None:
doc = _minimal_valid_doc()
# Add a second node so we can form a valid-looking edge
doc["graph"]["nodes"].append(
{
"id": "ACCOUNT:ALICE@HOST",
"kinds": ["jamf_Account"],
"properties": {
"objectid": "ACCOUNT:ALICE@HOST",
"name": "ALICE",
"Tier": 0,
},
}
)
doc["graph"]["edges"].append(
{
"kind": "jamf_AdminTo",
"start": "ACCOUNT:ALICE@HOST",
"end": "COMPUTER:HOST",
"properties": {}, # missing traversable
}
)
with pytest.raises(GraphExportError, match="traversable"):
validate_graph_document(doc)
def test_validate_rejects_unknown_edge_kind() -> None:
doc = _minimal_valid_doc()
doc["graph"]["nodes"].append(
{
"id": "ACCOUNT:BOB@HOST",
"kinds": ["jamf_Account"],
"properties": {"objectid": "ACCOUNT:BOB@HOST", "name": "BOB", "Tier": 0},
}
)
doc["graph"]["edges"].append(
{
"kind": "HasJamfObservation", # old format kind, should be rejected
"start": "ACCOUNT:BOB@HOST",
"end": "COMPUTER:HOST",
"properties": {"traversable": False},
}
)
with pytest.raises(GraphExportError, match="unknown kind"):
validate_graph_document(doc)

View File

@@ -0,0 +1,16 @@
"""Tests for vendor clone helpers (no network)."""
from pathlib import Path
from applepy.bootstrap_compliance import bootstrap_macos_security, macos_security_clone_path
def test_bootstrap_macos_security_skips_when_layout_present(tmp_path: Path) -> None:
dest = macos_security_clone_path(tmp_path)
dest.mkdir(parents=True)
(dest / "baselines").mkdir()
(dest / "scripts").mkdir()
(dest / "scripts" / "generate_guidance.py").write_text("# stub\n", encoding="utf-8")
code, msg = bootstrap_macos_security(tmp_path)
assert code == 0
assert "Already present" in msg

View File

@@ -0,0 +1,36 @@
from applepy import catalog_cache
def test_load_loobins_entries_filters() -> None:
rows = catalog_cache.load_loobins_entries(
[
{"name": "curl", "paths": ["/usr/bin/curl"], "short_description": "x"},
{"bad": 1},
{"name": "", "paths": []},
]
)
assert len(rows) == 1
assert rows[0]["name"] == "curl"
def test_iter_gtfo_modern_api_shape() -> None:
data = {
"functions": {},
"contexts": {},
"executables": {
"7z": {"functions": {"Shell": {}}},
"apt": {"alias": "apt-get"},
},
}
out = catalog_cache.iter_gtfo_binaries(data)
assert out == [("7z", {"functions": {"Shell": {}}})]
def test_fetch_json_offline_uses_stale(tmp_path, monkeypatch) -> None:
monkeypatch.setattr(catalog_cache, "_cache_dir", lambda: tmp_path)
url = "https://example.test/x.json"
p = catalog_cache._cache_path(url)
p.write_text("[1]", encoding="utf-8")
parsed, status = catalog_cache.fetch_json_url(url, offline=True, max_age_hours=0)
assert parsed == [1]
assert "offline" in status

View File

@@ -0,0 +1,19 @@
"""Catalogue noise filtering for client-facing worksheets."""
from applepy.catalog_policy import loobins_entry_is_report_noise
def test_loobins_noise_dock_and_finder(monkeypatch) -> None:
monkeypatch.delenv("APPLEPY_CATALOG_INCLUDE_NOISE", raising=False)
assert loobins_entry_is_report_noise("Dock") is True
assert loobins_entry_is_report_noise("Finder") is True
def test_loobins_noise_overridden_by_env(monkeypatch) -> None:
monkeypatch.setenv("APPLEPY_CATALOG_INCLUDE_NOISE", "1")
assert loobins_entry_is_report_noise("Dock") is False
def test_loobins_non_noise_tool(monkeypatch) -> None:
monkeypatch.delenv("APPLEPY_CATALOG_INCLUDE_NOISE", raising=False)
assert loobins_entry_is_report_noise("curl") is False

View File

@@ -0,0 +1,6 @@
from applepy.checks.catalogues import _load_json
def test_bundled_json_loads() -> None:
assert isinstance(_load_json("lolbins_macos.json"), list)
assert isinstance(_load_json("lolapps.json"), list)

View File

@@ -0,0 +1,59 @@
"""GTFOBins check behaviour (rollup vs detailed, absent list toggle)."""
from __future__ import annotations
from pathlib import Path
from applepy.checks import catalogues
from applepy.context import RunContext
def _minimal_ctx(tmp_path: Path) -> RunContext:
return RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged", dry_run=False)
def test_check_gtfo_no_absent_finding_by_default(monkeypatch, tmp_path: Path) -> None:
monkeypatch.delenv("APPLEPY_GTFO_LIST_ABSENT", raising=False)
monkeypatch.delenv("APPLEPY_GTFO_DETAILED", raising=False)
monkeypatch.delenv("APPLEPY_SKIP_CATALOG_FETCH", raising=False)
data = {
"executables": {
"true": {"functions": {"Shell": {}}},
"missingbin_xyz": {"functions": {"Shell": {}}},
}
}
monkeypatch.setattr(catalogues, "fetch_json_url", lambda _url: (data, "ok"))
def resolve(name: str, _pe: str) -> str | None:
return "/bin/true" if name == "true" else None
monkeypatch.setattr(catalogues, "_resolve_binary_path", resolve)
out = catalogues.check_gtfo(_minimal_ctx(tmp_path))
assert not any(f.id == "gtfo-absent-all" for f in out)
assert not any(f.id.startswith("gtfo-api-") for f in out)
lol2 = next(f for f in out if f.id == "lol-002")
assert "true" in lol2.evidence
assert "not_found=1" in lol2.evidence
def test_check_gtfo_absent_when_env_set(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("APPLEPY_GTFO_LIST_ABSENT", "1")
monkeypatch.delenv("APPLEPY_GTFO_DETAILED", raising=False)
monkeypatch.delenv("APPLEPY_SKIP_CATALOG_FETCH", raising=False)
data = {"executables": {"onlymissing": {"functions": {"Shell": {}}}}}
monkeypatch.setattr(catalogues, "fetch_json_url", lambda _url: (data, "ok"))
monkeypatch.setattr(catalogues, "_resolve_binary_path", lambda _n, _pe: None)
out = catalogues.check_gtfo(_minimal_ctx(tmp_path))
absent = next(f for f in out if f.id == "gtfo-absent-all")
assert "onlymissing" in absent.evidence
def test_check_gtfo_detailed_emits_per_binary(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setenv("APPLEPY_GTFO_DETAILED", "1")
monkeypatch.delenv("APPLEPY_GTFO_LIST_ABSENT", raising=False)
monkeypatch.delenv("APPLEPY_SKIP_CATALOG_FETCH", raising=False)
data = {"executables": {"true": {"functions": {"Shell": {}}}}}
monkeypatch.setattr(catalogues, "fetch_json_url", lambda _url: (data, "ok"))
monkeypatch.setattr(catalogues, "_resolve_binary_path", lambda _n, _pe: "/bin/true")
out = catalogues.check_gtfo(_minimal_ctx(tmp_path))
assert any(f.id == "gtfo-api-true" for f in out)

View File

@@ -0,0 +1,123 @@
"""Check progress reporter (TTY and NO_COLOR behaviour)."""
from __future__ import annotations
import io
from pathlib import Path
import pytest
from applepy.check_progress import (
CheckProgressReporter,
_ascii_progress_bar,
_completion_percent,
check_run_failed,
)
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
from applepy.runner import run_phase
def test_ascii_progress_bar_edges() -> None:
assert _ascii_progress_bar(0, 4, 8) == "[--------]"
assert _ascii_progress_bar(4, 4, 8) == "[########]"
assert "?" in _ascii_progress_bar(0, 0, 6)
def test_completion_percent_saturates() -> None:
assert _completion_percent(0, 10) == 0
assert _completion_percent(5, 10) == 50
assert _completion_percent(10, 10) == 100
assert _completion_percent(0, 0) == 100
def test_check_done_includes_percent_and_bar(monkeypatch: pytest.MonkeyPatch) -> None:
buf = io.StringIO()
monkeypatch.setenv("NO_COLOR", "1")
r = CheckProgressReporter(stream=buf)
r.check_done(3, 10, "short_name", 0.05, 0, False)
line = buf.getvalue()
assert " 30%" in line
assert "[###" in line or "[##" in line
def test_phase_end_colours_nonzero_counts_when_force_colour(
monkeypatch: pytest.MonkeyPatch,
) -> None:
buf = io.StringIO()
monkeypatch.delenv("NO_COLOR", raising=False)
monkeypatch.setenv("FORCE_COLOR", "1")
monkeypatch.setattr(buf, "isatty", lambda: True)
r = CheckProgressReporter(stream=buf)
f = Finding(
id="h1",
title="t",
category="c",
severity=Severity.HIGH,
description="d",
evidence="e",
worksheet="W",
)
r.phase_end("unprivileged", [f])
text = buf.getvalue()
assert "\033[33m" in text
assert "high:" in text
def test_phase_lines_contain_no_escapes_when_colour_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
buf = io.StringIO()
monkeypatch.setenv("NO_COLOR", "1")
r = CheckProgressReporter(stream=buf)
r.phase_begin("unprivileged", 2)
r.check_start(1, 2, "a")
r.check_done(1, 2, "a", 0.1, 1, False)
f = Finding(
id="x",
title="t",
category="c",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="W",
)
r.phase_end("unprivileged", [f])
r.scan_complete([f], "/tmp/out")
text = buf.getvalue()
assert "\033" not in text
assert "Unprivileged phase" in text
assert "Scan complete" in text
assert "critical: 0" in text
assert "informational: 1" in text
def test_check_run_failed_detects_run_prefix() -> None:
f = Finding(
id="run-deadbeef01",
title="Check failed",
category="Scanner reliability",
severity=Severity.LOW,
description="d",
evidence="e",
worksheet="Scanner reliability",
)
assert check_run_failed([f]) is True
assert check_run_failed([]) is False
def test_run_phase_invokes_progress_sequential(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
buf = io.StringIO()
monkeypatch.setenv("NO_COLOR", "1")
r = CheckProgressReporter(stream=buf)
reg = CheckRegistry()
def _one(_ctx: RunContext) -> list[Finding]:
return []
reg.register("z_check", _one, phases=("unprivileged",))
base = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
run_phase(reg, "unprivileged", base, parallel=False, progress=r)
out = buf.getvalue()
assert "z_check" in out
assert "running" in out
assert "ok" in out

40
tests/test_cli.py Normal file
View File

@@ -0,0 +1,40 @@
import pytest
from applepy.cli import _build_parser, main
def test_help_text_documents_privilege_and_sudo() -> None:
text = _build_parser().format_help()
assert "Without sudo" in text
assert "With sudo" in text
assert "unprivileged" in text.lower()
def test_help_exits_zero() -> None:
with pytest.raises(SystemExit) as e:
main(["--help"])
assert e.value.code == 0
with pytest.raises(SystemExit) as e2:
main(["-h"])
assert e2.value.code == 0
def test_version() -> None:
with pytest.raises(SystemExit) as e:
main(["--version"])
assert e.value.code == 0
def test_conflicting_flags() -> None:
assert main(["--unprivileged-only", "--privileged-only"]) == 2
def test_bootstrap_compliance_flag(monkeypatch: pytest.MonkeyPatch) -> None:
from pathlib import Path
def fake_run(root: Path) -> tuple[int, list[str]]:
assert isinstance(root, Path)
return 0, ["bootstrap_ok"]
monkeypatch.setattr("applepy.bootstrap_compliance.run_full_bootstrap", fake_run)
assert main(["--bootstrap-compliance"]) == 0

View File

@@ -0,0 +1,12 @@
from pathlib import Path
from applepy.checks.common_paths import check_cron_periodic_surface
from applepy.context import RunContext
def test_cron_periodic_returns_finding(tmp_path: Path) -> None:
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_cron_periodic_surface(ctx)
assert len(out) == 1
assert out[0].id == "soc-011"
assert "/etc/crontab" in out[0].evidence or "crontab" in out[0].evidence.lower()

View File

@@ -0,0 +1,11 @@
from applepy.checks.compliance import parse_kern_boottime_sec
def test_parse_kern_boottime_sec_typical() -> None:
text = "{ sec = 1700000000, usec = 0 }"
assert parse_kern_boottime_sec(text) == 1700000000
def test_parse_kern_boottime_sec_no_match() -> None:
assert parse_kern_boottime_sec("") is None
assert parse_kern_boottime_sec("nonsense") is None

View File

@@ -0,0 +1,148 @@
"""Bundled mSCP / Lynis resolution (no upstream clones)."""
from __future__ import annotations
from pathlib import Path
from applepy.bootstrap_compliance import lynis_bundled_executable
from applepy.checks import compliance
from applepy.context import RunContext
from applepy.mscp import resolve_mscp_root
def _fake_pkg_root(tmp_path: Path) -> Path:
pkg = tmp_path / "pkg"
pkg.mkdir()
(pkg / "__init__.py").write_text("", encoding="utf-8")
return pkg
def test_resolve_mscp_root_uses_bundled_layout(tmp_path: Path, monkeypatch) -> None:
import applepy
pkg = _fake_pkg_root(tmp_path)
mroot = pkg / "data" / "macos_security"
(mroot / "baselines").mkdir(parents=True)
(mroot / "scripts").mkdir()
(mroot / "scripts" / "generate_guidance.py").write_text("#\n", encoding="utf-8")
monkeypatch.setattr(applepy, "__file__", str(pkg / "__init__.py"))
monkeypatch.delenv("APPLEPY_MACOS_SECURITY_ROOT", raising=False)
got = resolve_mscp_root(tmp_path / "home", tmp_path / "cwd")
assert got is not None
assert got.resolve() == mroot.resolve()
def test_resolve_mscp_root_env_beats_bundled(tmp_path: Path, monkeypatch) -> None:
import applepy
pkg = _fake_pkg_root(tmp_path)
bundled = pkg / "data" / "macos_security"
(bundled / "baselines").mkdir(parents=True)
(bundled / "scripts").mkdir()
(bundled / "scripts" / "generate_guidance.py").write_text("#\n", encoding="utf-8")
env_root = tmp_path / "from_env"
(env_root / "baselines").mkdir(parents=True)
(env_root / "scripts").mkdir()
(env_root / "scripts" / "generate_guidance.py").write_text("#\n", encoding="utf-8")
monkeypatch.setattr(applepy, "__file__", str(pkg / "__init__.py"))
monkeypatch.setenv("APPLEPY_MACOS_SECURITY_ROOT", str(env_root))
got = resolve_mscp_root(tmp_path / "home", tmp_path / "cwd")
assert got is not None
assert got.resolve() == env_root.resolve()
def test_lynis_bundled_executable(tmp_path: Path, monkeypatch) -> None:
import applepy
pkg = _fake_pkg_root(tmp_path)
ldir = pkg / "data" / "lynis"
ldir.mkdir(parents=True)
script = ldir / "lynis"
script.write_text("#!/bin/sh\necho\n", encoding="utf-8")
monkeypatch.setattr(applepy, "__file__", str(pkg / "__init__.py"))
got = lynis_bundled_executable()
assert got is not None
assert got.resolve() == script.resolve()
def test_resolve_lynis_prefers_path_after_which(tmp_path: Path, monkeypatch) -> None:
import applepy
pkg = _fake_pkg_root(tmp_path)
ldir = pkg / "data" / "lynis"
ldir.mkdir(parents=True)
bundled_script = ldir / "lynis"
bundled_script.write_text("#\n", encoding="utf-8")
monkeypatch.setattr(applepy, "__file__", str(pkg / "__init__.py"))
def fake_run_text(cmd: list[str], timeout: int = 30, cwd=None):
if cmd[:2] == ["/usr/bin/which", "lynis"]:
return 0, "/usr/local/bin/lynis\n", ""
raise AssertionError(f"unexpected cmd: {cmd}")
monkeypatch.setattr(compliance, "run_text", fake_run_text)
assert compliance._resolve_lynis_executable() == "/usr/local/bin/lynis"
def test_resolve_lynis_falls_back_to_bundled(tmp_path: Path, monkeypatch) -> None:
import applepy
pkg = _fake_pkg_root(tmp_path)
ldir = pkg / "data" / "lynis"
ldir.mkdir(parents=True)
bundled_script = ldir / "lynis"
bundled_script.write_text("#\n", encoding="utf-8")
monkeypatch.setattr(applepy, "__file__", str(pkg / "__init__.py"))
def fake_run_text(cmd: list[str], timeout: int = 30, cwd=None):
if cmd[:2] == ["/usr/bin/which", "lynis"]:
return 1, "", ""
raise AssertionError(f"unexpected cmd: {cmd}")
monkeypatch.setattr(compliance, "run_text", fake_run_text)
assert compliance._resolve_lynis_executable() == str(bundled_script.resolve())
def test_truncate_lynis_evidence_short_unchanged() -> None:
s = "a" * 100
assert compliance._truncate_lynis_evidence(s) == s
def test_check_lynis_run_uses_lynis_install_root_as_cwd(tmp_path: Path, monkeypatch) -> None:
"""Lynis discovers ./include from pwd; cwd must be the directory that contains the script."""
nest = tmp_path / "opt" / "lynis"
nest.mkdir(parents=True)
script = nest / "lynis"
script.write_text("#!/bin/sh\necho ok\n", encoding="utf-8")
script.chmod(0o755)
monkeypatch.setattr(compliance, "_resolve_lynis_executable", lambda: str(script))
captured: dict[str, object] = {}
def capture_run(cmd: list[str], timeout: float = 400, cwd=None):
captured["cwd"] = cwd
captured["cmd"] = cmd
return 0, "ok", ""
monkeypatch.setattr(compliance, "run_text", capture_run)
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged", dry_run=False)
compliance.check_lynis_run(ctx)
assert captured["cwd"] == str(nest.resolve())
assert captured["cmd"][0] == str(script)
def test_truncate_lynis_evidence_long_inserts_ellipsis() -> None:
s = "x" * 50_000
out = compliance._truncate_lynis_evidence(s, max_chars=1000)
assert "omitted" in out
assert len(out) < len(s)
def test_cap_mscp_transcript_truncates(monkeypatch) -> None:
monkeypatch.setenv("APPLEPY_MSCP_COMPLIANCE_LOG_MAX", "8192")
long = "Z" * 20_000
out = compliance._cap_mscp_transcript("stdout", long)
assert "truncated" in out
assert len(out) < len(long)

View File

@@ -0,0 +1,22 @@
from applepy.checks.core import _parse_alf_globalstate
def test_parse_alf_globalstate_digit() -> None:
state, note = _parse_alf_globalstate("0\n", 0)
assert state == 0
assert note == "ok"
def test_parse_alf_globalstate_multiline() -> None:
state, note = _parse_alf_globalstate("foo\n2\n", 0)
assert state == 2
assert note == "ok"
def test_parse_alf_globalstate_missing() -> None:
state, note = _parse_alf_globalstate(
"2025-01-01 12:00:00.000 defaults[123:456] Could not find domain com.apple.alf\n",
1,
)
assert state is None
assert note == "preference_missing_or_unreadable"

24
tests/test_deck_export.py Normal file
View File

@@ -0,0 +1,24 @@
"""Presentation exportaligned checks (reference path and portable pieces)."""
from pathlib import Path
import pytest
from applepy.checks.deck_export import check_deck_export_reference, check_kube_config_presence
from applepy.context import RunContext
def test_deck_reference_finds_export(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
name = "APPLEPY_DECK_REFERENCE.txt"
(tmp_path / name).write_text("stub export\n", encoding="utf-8")
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_deck_export_reference(ctx)
assert len(out) == 1
assert out[0].id == "deck-000"
assert "located" in out[0].title.lower()
def test_kube_config_absent(tmp_path: Path) -> None:
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_kube_config_presence(ctx)
assert out[0].id == "deck-104"

View File

@@ -0,0 +1,53 @@
from applepy.checks.mitre import _attack_technique_url, augment_mitre_worksheet
from applepy.dedupe import dedupe_by_id
from applepy.findings import Finding, Severity
def test_dedupe_by_id() -> None:
a = Finding(
id="x",
title="t",
category="c",
severity=Severity.LOW,
description="d",
evidence="e",
worksheet="W",
)
b = Finding(
id="x",
title="other",
category="c",
severity=Severity.HIGH,
description="d",
evidence="e",
worksheet="W",
)
out = dedupe_by_id([a, b])
assert len(out) == 1
assert out[0].title == "t"
def test_attack_technique_url_subtechnique() -> None:
assert _attack_technique_url("T1548.001") == "https://attack.mitre.org/techniques/T1548/001/"
def test_attack_technique_url_parent() -> None:
assert _attack_technique_url("T1059") == "https://attack.mitre.org/techniques/T1059/"
def test_augment_mitre_adds_rows() -> None:
f = Finding(
id="f1",
title="t",
category="c",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="Core",
mitre_techniques=("T1082",),
)
findings: list[Finding] = [f]
augment_mitre_worksheet(findings)
assert any(x.id == "map-T1082" for x in findings)
assert any(x.id == "map-summary" for x in findings)
assert any(x.id.startswith("map-defer-") for x in findings)

View File

@@ -0,0 +1,22 @@
"""Tests for extended_surface checks (cross-platform where possible)."""
from pathlib import Path
from applepy.checks.extended_surface import check_hosts_file, check_shell_startup_files
from applepy.context import RunContext
def test_check_hosts_file_returns_finding(tmp_path: Path) -> None:
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_hosts_file(ctx)
assert len(out) == 1
assert out[0].id == "ext-102"
assert out[0].worksheet == "Attack surface"
def test_check_shell_startup_lists_paths(tmp_path: Path) -> None:
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_shell_startup_files(ctx)
assert len(out) == 1
assert out[0].id == "ext-107"
assert ".zshrc" in out[0].evidence or "zshrc" in out[0].evidence

View File

@@ -0,0 +1,48 @@
"""Severity breakdown helper for console output."""
from __future__ import annotations
from applepy.findings import Finding, Severity, severity_breakdown_line
def test_severity_breakdown_line_all_levels() -> None:
findings = [
Finding(
id="1",
title="a",
category="c",
severity=Severity.CRITICAL,
description="d",
evidence="e",
worksheet="W",
),
Finding(
id="2",
title="b",
category="c",
severity=Severity.HIGH,
description="d",
evidence="e",
worksheet="W",
),
Finding(
id="3",
title="c",
category="c",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="W",
),
]
line = severity_breakdown_line(findings)
assert "critical: 1" in line
assert "high: 1" in line
assert "medium: 0" in line
assert "low: 0" in line
assert "informational: 1" in line
def test_severity_breakdown_line_empty() -> None:
line = severity_breakdown_line([])
assert line == "critical: 0 · high: 0 · medium: 0 · low: 0 · informational: 0"

23
tests/test_fs_posture.py Normal file
View File

@@ -0,0 +1,23 @@
from pathlib import Path
from applepy.checks.fs_posture import collect_world_writable_files
def test_collect_world_writable_detects_regular_file(tmp_path: Path) -> None:
launch = tmp_path / "LaunchAgents"
launch.mkdir()
f = launch / "evil.plist"
f.write_text("<?xml version='1.0'?><plist></plist>")
f.chmod(0o666)
hits, notes = collect_world_writable_files([launch], max_scan=500, max_hits=20)
assert hits
assert any("evil.plist" in h for h in hits)
assert not any("cap reached" in n for n in notes)
def test_collect_world_writable_empty_dir(tmp_path: Path) -> None:
empty = tmp_path / "empty"
empty.mkdir()
hits, notes = collect_world_writable_files([empty])
assert not hits
assert not notes

View File

@@ -0,0 +1,117 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from applepy.checks.listening_ports import (
_registered_service_name,
check_network_listeners_non_ephemeral,
)
from applepy.context import RunContext
def test_registered_service_name_does_not_raise() -> None:
assert isinstance(_registered_service_name(22, "TCP"), str)
assert isinstance(_registered_service_name(53, "UDP"), str)
def _lsof_line(command: str, pid: int, user: str, tail: str) -> str:
return (
f"{command}\t{pid}\t{user}\t9u\tIPv4\t0x0\t0t0\t0\t{tail}"
)
@patch("applepy.checks.listening_ports.shutil.which")
@patch("applepy.checks.listening_ports.platform.system", return_value="Darwin")
@patch("applepy.checks.listening_ports.run_text")
@patch(
"applepy.checks.listening_ports._ephemeral_port_bounds",
return_value=(49152, 65535),
)
def test_includes_wildcard_non_ephemeral_excludes_loopback_ephemeral(
_eb: object,
mock_run: object,
_plat: object,
mock_which: object,
) -> None:
mock_which.side_effect = lambda n: "/x/lsof" if n == "lsof" else "/x/ps"
tcp = "\n".join(
[
"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME",
_lsof_line("Svc7000", 700, "u1", "TCP *:7000 (LISTEN)"),
_lsof_line("Lo443", 701, "u1", "TCP 127.0.0.1:443 (LISTEN)"),
_lsof_line("Ephem", 702, "u1", "TCP *:50000 (LISTEN)"),
]
)
udp = "\n".join(
[
"COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME",
_lsof_line("dnsmasq", 3, "root", "UDP *:53"),
]
)
def run_side_effect(cmd: list[str], **kwargs: object) -> tuple[int, str, str]:
exe = cmd[0] if cmd else ""
joined = " ".join(cmd)
if exe.endswith("lsof") and "-iTCP" in joined:
return 0, tcp, ""
if exe.endswith("lsof") and "-iUDP" in joined:
return 0, udp, ""
if exe.endswith("ps"):
return (
0,
"700 /Applications/Svc7000.app/Contents/MacOS/Svc7000\n"
"3 /usr/sbin/dnsmasq\n",
"",
)
return 1, "", "unexpected command"
mock_run.side_effect = run_side_effect
ctx = RunContext(home=Path("/tmp"), output_dir=Path("/tmp"), phase="unprivileged")
out = check_network_listeners_non_ephemeral(ctx)
assert len(out) == 1
assert out[0].id == "net-004"
ev = out[0].evidence
assert "*:7000" in ev or "*:7000" in ev.replace("\t", " ")
assert "UDP" in ev and "53" in ev
assert "127.0.0.1:443" not in ev
assert "50000" not in ev
@patch("applepy.checks.listening_ports.shutil.which", return_value=None)
@patch("applepy.checks.listening_ports.platform.system", return_value="Darwin")
def test_missing_lsof(_plat: object, _mock_which: object) -> None:
ctx = RunContext(home=Path("/tmp"), output_dir=Path("/tmp"), phase="unprivileged")
out = check_network_listeners_non_ephemeral(ctx)
assert len(out) == 1
assert out[0].id == "net-002"
@patch("applepy.checks.listening_ports.shutil.which", return_value="/lsof")
@patch("applepy.checks.listening_ports.platform.system", return_value="Darwin")
@patch("applepy.checks.listening_ports.run_text")
@patch(
"applepy.checks.listening_ports._ephemeral_port_bounds",
return_value=(49152, 65535),
)
def test_both_lsof_fail_returns_net003(
_eb: object,
mock_run: object,
_plat: object,
_which: object,
) -> None:
mock_run.return_value = (1, "", "permission denied")
ctx = RunContext(home=Path("/tmp"), output_dir=Path("/tmp"), phase="unprivileged")
out = check_network_listeners_non_ephemeral(ctx)
assert len(out) == 1
assert out[0].id == "net-003"
@patch("applepy.checks.listening_ports.platform.system", return_value="Windows")
def test_unsupported_platform(_mock_sys: object) -> None:
ctx = RunContext(home=Path("/tmp"), output_dir=Path("/tmp"), phase="unprivileged")
out = check_network_listeners_non_ephemeral(ctx)
assert out[0].id == "net-001"

View File

@@ -0,0 +1,31 @@
import plistlib
from pathlib import Path
from applepy.mscp_audit_parse import parse_audit_plist
def test_parse_audit_plist_per_rule(tmp_path: Path) -> None:
plist_path = tmp_path / "org.applepy_mscp.audit.plist"
payload = {
"lastComplianceCheck": "2026-01-01",
"rule_a": {"finding": False},
"rule_b": {"finding": True, "exempt": 0},
"rule_c": {"finding": True, "exempt": 1},
}
plist_path.write_bytes(plistlib.dumps(payload))
findings = parse_audit_plist(plist_path)
ids = {f.id for f in findings}
assert "mscp-rule_a" in ids
assert "mscp-rule_b" in ids
assert "mscp-rule_c" in ids
assert len(findings) == 3
by_id = {f.id: f for f in findings}
assert "rule_a" in by_id["mscp-rule_a"].description
assert "compliant" in by_id["mscp-rule_a"].description.lower()
assert "rule_b" in by_id["mscp-rule_b"].description
assert "non-compliant" in by_id["mscp-rule_b"].description.lower()
assert "exempt" in by_id["mscp-rule_c"].description.lower()
def test_parse_missing_plist(tmp_path: Path) -> None:
assert parse_audit_plist(tmp_path / "nope.plist") == []

View File

@@ -0,0 +1,55 @@
"""Frozen-style mSCP script re-exec (generate_guidance) entrypoint."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from applepy import cli
from applepy.mscp import APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG, run_generate_guidance
def test_internal_mscp_script_entry_runs_script(tmp_path: Path) -> None:
script = tmp_path / "x.py"
script.write_text(
"import sys\n"
"assert 'hello' in sys.argv\n"
"raise SystemExit(0)\n",
encoding="utf-8",
)
code = cli._internal_mscp_script_entry([str(tmp_path), str(script), "hello"])
assert code == 0
def test_run_generate_guidance_frozen_uses_applepy_executable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
root = tmp_path / "macos_security"
(root / "scripts").mkdir(parents=True)
(root / "scripts" / "generate_guidance.py").write_text("# stub\n", encoding="utf-8")
bl = root / "baselines" / "cis.yaml"
bl.parent.mkdir(parents=True)
bl.write_text("{}", encoding="utf-8")
captured: list[list[str]] = []
def fake_run(cmd, **kwargs):
captured.append(cmd)
class R:
returncode = 0
stdout = ""
stderr = ""
return R()
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "executable", "/fake/applepy", raising=False)
monkeypatch.setattr("applepy.mscp.subprocess.run", fake_run)
monkeypatch.delenv("APPLEPY_MSCP_FORCE_SUBPROCESS", raising=False)
code, out, err = run_generate_guidance(root, bl)
assert code == 0
assert captured
assert captured[0][0] == "/fake/applepy"
assert captured[0][1] == APPLEPY_INTERNAL_MSCP_SCRIPT_FLAG
assert captured[0][2] == str(root.resolve())
assert captured[0][3] == str((root / "scripts" / "generate_guidance.py").resolve())

View File

@@ -0,0 +1,73 @@
"""mSCP subprocess uses a real Python when frozen (not the PyInstaller binary)."""
from __future__ import annotations
import platform
import sys
from pathlib import Path
import pytest
from applepy import mscp
def test_python_for_mscp_respects_applepy_python(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
fake = tmp_path / "mypython"
fake.write_text("#!/bin/sh\necho ok\n", encoding="utf-8")
fake.chmod(0o755)
monkeypatch.setenv("APPLEPY_PYTHON", str(fake))
monkeypatch.delenv("APPLEPY_MACOS_SECURITY_ROOT", raising=False)
got = mscp._python_for_mscp_subprocess()
assert got == str(fake)
def test_python_for_mscp_frozen_non_darwin_uses_meipass_python(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("APPLEPY_PYTHON", raising=False)
monkeypatch.setattr(sys, "frozen", True, raising=False)
monkeypatch.setattr(sys, "_MEIPASS", str(tmp_path), raising=False)
monkeypatch.setattr(platform, "system", lambda: "Linux")
py = tmp_path / "python3"
py.write_text("#!\n", encoding="utf-8")
py.chmod(0o755)
got = mscp._python_for_mscp_subprocess()
assert got == str(py)
def test_python_for_mscp_frozen_darwin_skips_meipass_python(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""macOS bundle `python` is libPython, not an interpreter — use system or PATH."""
monkeypatch.delenv("APPLEPY_PYTHON", raising=False)
monkeypatch.setattr(sys, "frozen", True, raising=False)
me = tmp_path / "meipass"
me.mkdir()
fake_internal = me / "python3"
fake_internal.write_text("# would be dylib on real bundle\n", encoding="utf-8")
fake_internal.chmod(0o755)
monkeypatch.setattr(sys, "_MEIPASS", str(me), raising=False)
monkeypatch.setattr(platform, "system", lambda: "Darwin")
monkeypatch.setattr(mscp, "_SYSTEM_PYTHON3_CANDIDATES", ())
good = tmp_path / "path_python3"
good.write_text("#!/bin/sh\necho\n", encoding="utf-8")
good.chmod(0o755)
monkeypatch.setattr(mscp.shutil, "which", lambda _cmd: str(good))
got = mscp._python_for_mscp_subprocess()
assert got == str(good)
def test_python_for_mscp_non_frozen_uses_sys_executable(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("APPLEPY_PYTHON", raising=False)
monkeypatch.setattr(sys, "frozen", False, raising=False)
assert mscp._python_for_mscp_subprocess() == sys.executable
def test_run_subprocess_via_logfiles_captures_streams() -> None:
code, out, err = mscp._run_subprocess_via_logfiles(
[sys.executable, "-c", "import sys; print('hello'); print('warn', file=sys.stderr)"],
cwd=None,
timeout=30.0,
)
assert code == 0
assert "hello" in out
assert "warn" in err

20
tests/test_plan_extras.py Normal file
View File

@@ -0,0 +1,20 @@
from pathlib import Path
from applepy.checks.plan_extras import _count_apps
def test_count_apps_finds_dot_app(tmp_path: Path) -> None:
apps = tmp_path / "Applications"
apps.mkdir()
bundle = apps / "Test.app"
bundle.mkdir()
(bundle / "Contents").mkdir()
n, msg = _count_apps(apps)
assert n == 1
assert msg == "ok"
def test_count_apps_missing_dir(tmp_path: Path) -> None:
n, msg = _count_apps(tmp_path / "nope")
assert n == 0
assert "not a directory" in msg

View File

@@ -0,0 +1,27 @@
import platform
from pathlib import Path
import pytest
from applepy.checks.pyobjc_surface import check_running_applications_native
from applepy.context import RunContext
def test_non_macos_finding(tmp_path: Path) -> None:
if platform.system() == "Darwin":
pytest.skip("macOS uses objc-003/objc-002 path")
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_running_applications_native(ctx)
assert len(out) == 1
assert out[0].id == "objc-001"
assert "Darwin" in out[0].description or "macOS" in out[0].description
@pytest.mark.skipif(platform.system() != "Darwin", reason="PyObjC AppKit only on macOS")
def test_macos_returns_workspace_or_import_finding(tmp_path: Path) -> None:
ctx = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = check_running_applications_native(ctx)
assert len(out) == 1
assert out[0].id in ("objc-002", "objc-003")
if out[0].id == "objc-003":
assert "NSWorkspace" in out[0].evidence
assert "\t" in out[0].evidence or "bundle_id" in out[0].evidence

25
tests/test_registry.py Normal file
View File

@@ -0,0 +1,25 @@
from applepy.checks import build_registry
def test_build_registry_has_phases() -> None:
r = build_registry()
unpriv = list(r.checks_for("unprivileged"))
priv = list(r.checks_for("privileged"))
assert len(unpriv) >= 60
assert len(priv) >= 7
names_u = {n for n, _ in unpriv}
names_p = {n for n, _ in priv}
assert "comp_boot_uptime" in names_u
assert "core_firewall" in names_u
assert "surf_tcc_loginitems" in names_u
assert "net_listen_ports" in names_u
assert "objc_running_apps" in names_u
assert "plan_quarantine" in names_u
assert "cop_paths_dscl_users" in names_u
assert "cop_paths_cron_periodic" in names_u
assert "comp_umask" in names_u
assert "fs_world_writable_system" in names_p
assert "plan_sudoers" in names_p
assert "ext_hosts_file" in names_u
assert "ext_emond_rules" in names_p
assert "deck_reference" in names_u

View File

@@ -0,0 +1,19 @@
"""Report theme mapping for markdown grouping."""
from applepy.reporters.report_themes import theme_for_category, themes_present_in_order
def test_theme_for_known_categories() -> None:
assert theme_for_category("Core") == "Core platform and controls"
assert theme_for_category("Compliance") == "Compliance and configuration baselines"
assert theme_for_category("MDM: Jamf") == "MDM local posture"
def test_theme_for_unknown_goes_to_other() -> None:
assert theme_for_category("CustomVendor") == "Other findings"
def test_themes_present_in_order_sorts() -> None:
got = themes_present_in_order({"Other findings", "Core platform and controls"})
assert got[0] == "Core platform and controls"
assert got[1] == "Other findings"

197
tests/test_reporters.py Normal file
View File

@@ -0,0 +1,197 @@
from pathlib import Path
from applepy.findings import Finding, Severity
from applepy.reporters.markdown import write_markdown_report
from applepy.reporters.xlsx import (
_evidence_chunks_for_excel,
_xlsx_cell_text,
write_xlsx_report,
)
def test_markdown_and_xlsx(tmp_path: Path) -> None:
f = Finding(
id="t-1",
title="Test finding",
category="Test",
severity=Severity.INFORMATIONAL,
description="Desc",
evidence="Ev",
worksheet="Test checks",
mitre_techniques=("T1082",),
)
cis = Finding(
id="cis-099",
title="CIS tab sample",
category="Compliance",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="CIS",
)
md = tmp_path / "r.md"
xl = tmp_path / "f.xlsx"
write_markdown_report(md, [f, cis], ["warn1"])
write_xlsx_report(xl, [f, cis])
md_text = md.read_text(encoding="utf-8")
assert md_text.startswith("# ApplePY")
assert "## Contents" in md_text
assert "[Executive summary](#executive-summary)" in md_text
assert "overarching themes" in md_text.lower()
assert "## Other findings" in md_text or "## Core platform and controls" in md_text
assert "Detail category:" in md_text
assert "Granular categories covered here:" in md_text
assert "#### Evidence" in md_text
assert "```" in md_text
assert "CIS macOS worksheet index" in md_text
assert "cis-099" in md_text
assert xl.is_file() and xl.stat().st_size > 200
from openpyxl import load_workbook
wb = load_workbook(xl)
assert wb.sheetnames[0] == "Summary"
assert wb.sheetnames[1] == "Findings list"
assert "CIS" in wb.sheetnames
assert wb["CIS"]["A2"].value == "cis-099"
fl = wb["Findings list"]
assert fl["A1"].value == "ID"
assert fl["E1"].value == "Detail worksheet"
assert fl.max_row == 1
# Summary sheet must contain the worksheet index section
summary_ws = wb["Summary"]
col_a_vals = [summary_ws.cell(row=r, column=1).value for r in range(1, summary_ws.max_row + 1)]
assert "Findings list (non-informational)" in col_a_vals
assert "CIS" in col_a_vals
def test_xlsx_cell_text_strips_control_chars() -> None:
raw = "a\x00b\x08c"
assert _xlsx_cell_text(raw) == "a b c"
def test_evidence_chunks_split_for_excel_limit() -> None:
long_ev = "X" * 40000
chunks = _evidence_chunks_for_excel(long_ev)
assert len(chunks) >= 2
assert all(len(c) <= 32767 for c in chunks)
assert "".join(chunks) == _xlsx_cell_text(long_ev)
def test_xlsx_sorts_by_severity_descending(tmp_path: Path) -> None:
from openpyxl import load_workbook
lo = Finding(
id="a-low",
title="Later",
category="Test",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="Zebra",
)
hi = Finding(
id="z-high",
title="First",
category="Test",
severity=Severity.HIGH,
description="d",
evidence="e",
worksheet="Zebra",
)
xl = tmp_path / "sort.xlsx"
write_xlsx_report(xl, [lo, hi])
wb = load_workbook(xl)
assert wb.sheetnames[:2] == ["Summary", "Findings list"]
fl = wb["Findings list"]
assert fl.max_row == 2
assert fl["A2"].value == "z-high"
assert fl["C2"].value == "high"
ws = wb["Zebra"]
assert ws["A2"].value == "z-high"
assert ws["A3"].value == "a-low"
def test_xlsx_findings_list_includes_non_informational_only(tmp_path: Path) -> None:
from openpyxl import load_workbook
info = Finding(
id="i-1",
title="Noise",
category="Test",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="Alpha",
)
med = Finding(
id="m-1",
title="Material",
category="Test",
severity=Severity.MEDIUM,
description="d",
evidence="e",
worksheet="Alpha",
)
xl = tmp_path / "fl.xlsx"
write_xlsx_report(xl, [info, med])
wb = load_workbook(xl)
fl = wb["Findings list"]
assert fl.max_row == 2
assert fl["A2"].value == "m-1"
assert fl["E2"].value == "Alpha"
def test_xlsx_severity_row_has_distinct_fill(tmp_path: Path) -> None:
from openpyxl import load_workbook
high_f = Finding(
id="h-sev",
title="High item",
category="Test",
severity=Severity.HIGH,
description="d",
evidence="e",
worksheet="Alpha",
)
low_f = Finding(
id="l-sev",
title="Low item",
category="Test",
severity=Severity.LOW,
description="d",
evidence="e",
worksheet="Alpha",
)
xl = tmp_path / "sevfill.xlsx"
write_xlsx_report(xl, [high_f, low_f])
wb = load_workbook(xl)
ws = wb["Alpha"]
hi_fill = ws["A2"].fill
lo_fill = ws["A3"].fill
assert hi_fill.fill_type == "solid"
assert lo_fill.fill_type == "solid"
assert hi_fill.start_color.rgb != lo_fill.start_color.rgb
fl = wb["Findings list"]
assert fl["A2"].fill.start_color.rgb == hi_fill.start_color.rgb
def test_xlsx_long_evidence_writes_continuation_rows(tmp_path: Path) -> None:
from openpyxl import load_workbook
long_ev = "Y" * 35000
f = Finding(
id="long-1",
title="Long evidence",
category="Test",
severity=Severity.INFORMATIONAL,
description="d",
evidence=long_ev,
worksheet="Evidence sheet",
)
xl = tmp_path / "big.xlsx"
write_xlsx_report(xl, [f])
wb = load_workbook(xl)
assert wb.sheetnames[1] == "Findings list"
assert wb["Findings list"].max_row == 1
ws = wb["Evidence sheet"]
assert ws.max_row >= 3

View File

@@ -0,0 +1,38 @@
from pathlib import Path
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
from applepy.runner import run_phase
def _ok(_ctx: RunContext) -> list[Finding]:
return [
Finding(
id="ok-1",
title="OK check",
category="Test",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="Test",
)
]
def _boom(_ctx: RunContext) -> list[Finding]:
raise RuntimeError("deliberate test failure")
def test_run_phase_records_check_exception_as_finding(tmp_path: Path) -> None:
reg = CheckRegistry()
reg.register("boom", _boom, phases=("unprivileged",))
reg.register("ok", _ok, phases=("unprivileged",))
base = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
out = run_phase(reg, "unprivileged", base, parallel=False)
assert len(out) == 2
assert any(f.id == "ok-1" for f in out)
failed = [f for f in out if f.category == "Scanner reliability"]
assert len(failed) == 1
assert "boom" in failed[0].title
assert "RuntimeError" in failed[0].evidence

View File

@@ -0,0 +1,44 @@
from pathlib import Path
from applepy.context import RunContext
from applepy.findings import Finding, Severity
from applepy.registry import CheckRegistry
from applepy.runner import run_phase
def test_run_phase_parallel_returns_all_findings(tmp_path: Path) -> None:
r = CheckRegistry()
def check_a(ctx: RunContext) -> list[Finding]:
return [
Finding(
id="p-a",
title="A",
category="T",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="S",
)
]
def check_b(ctx: RunContext) -> list[Finding]:
return [
Finding(
id="p-b",
title="B",
category="T",
severity=Severity.INFORMATIONAL,
description="d",
evidence="e",
worksheet="S",
)
]
r.register("a", check_a, phases=("unprivileged",))
r.register("b", check_b, phases=("unprivileged",))
base = RunContext(home=tmp_path, output_dir=tmp_path, phase="unprivileged")
seq = run_phase(r, "unprivileged", base, parallel=False)
par = run_phase(r, "unprivileged", base, parallel=True)
assert {f.id for f in seq} == {"p-a", "p-b"}
assert {f.id for f in par} == {"p-a", "p-b"}

23
tests/test_subproc.py Normal file
View File

@@ -0,0 +1,23 @@
import subprocess
from unittest.mock import patch
from applepy.subproc import run_text
@patch("applepy.subproc.subprocess.run")
def test_run_text_passes_cwd(mock_run: object, tmp_path) -> None:
mock_run.return_value = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
d = tmp_path / "work"
d.mkdir()
run_text(["/bin/true"], cwd=d)
mock_run.assert_called_once()
assert mock_run.call_args.kwargs.get("cwd") == str(d)
@patch("applepy.subproc.subprocess.run")
def test_run_text_timeout_message_includes_duration(mock_run: object) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd=["/bin/echo", "a"], timeout=99)
code, out, err = run_text(["/bin/echo", "a"], timeout=12.5)
assert code == 124
assert "12.5" in err
assert "timeout" in err.lower()