Reference
Output format
Every collection produces a ZIP archive — the evidence container — with a fixed top-level layout. Every collected file is hashed, and the archive is easy to ingest.
Container layout
evidence-container.zip
├── manifest.json # Collection metadata (source, host, plugins run)
├── chain-of-custody.json # Append-only access audit log
├── hashes.json # SHA-256 for every collected file
├── artifacts/ # Original files, copied byte-for-byte
│ └── {category}/{plugin_id}/files/{user}/{filename}
├── records/ # Parsed structured data (JSONL by default, or Parquet)
│ └── {plugin_id}/parsed.jsonl # or {plugin_id}/{artifact_type}.parquet
└── logs/ # Per-plugin run log
└── {plugin_id}/collection.logmanifest.json
The single source of truth for what was collected, when, and from where. The structured source object captures the forensic context (OS, hardware, security state, temporal anchors) you need on day one of analysis. Builds with a license, such as macfor-pro, also add a license object. Every field is defined in the container format spec.
{
"version": "1.4",
"collection_id": "3f0c6a8e-5b7d-4c1e-9a2f-6d8b1e4c7a90",
"tool_version": "0.2.0",
"collected_at": "2026-05-03T11:30:42.104Z",
"completed_at": "2026-05-03T11:34:18.882Z",
"source": {
"type": "live",
"hostname": "host.local",
"os_version": "14.4.1",
"os": { "name": "macOS", "version": "14.4.1", "codename": "Sonoma", "build": "23E224" },
"hardware": { "model": "Mac14,2", "model_name": "MacBook Air (M2, 2022)", "architecture": "arm64" },
"security": { "sip_enabled": true, "filevault_enabled": true, "gatekeeper_enabled": true },
"network": { "hostname": "host.local", "computer_name": "Analyst Mac" },
"temporal": { "timezone": "Europe/Stockholm", "boot_time": "2026-05-01T07:12:08Z",
"collection_time": "2026-05-03T11:30:42Z" },
"collection_env": { "user": "analyst", "uid": 501, "is_root": false, "full_disk_access": true },
"users": [
{ "username": "analyst", "uid": 501, "gid": 20, "name": "Analyst",
"home_dir": "/Users/analyst", "shell": "/bin/zsh" }
]
},
"plugins": [
{
"id": "shell.history", "name": "Shell History", "version": "1.0.0",
"started_at": "2026-05-03T11:30:42.310Z", "completed_at": "2026-05-03T11:30:42.901Z",
"files_collected": 4, "records_created": 5210, "bytes_collected": 389120,
"errors_encountered": 0
},
{
"id": "browser.safari", "name": "Safari Browser", "version": "1.0.0",
"started_at": "2026-05-03T11:30:43.010Z", "completed_at": "2026-05-03T11:30:51.774Z",
"files_collected": 5, "records_created": 9120, "bytes_collected": 41203712,
"errors_encountered": 1,
"errors": [
{ "artifact": "/Users/analyst/Library/Safari/Downloads.plist", "operation": "downloads",
"message": "open …: operation not permitted", "timestamp": "2026-05-03T11:30:50.002Z" }
]
}
],
"statistics": {
"total_files": 9, "total_records": 14330, "total_bytes": 41592832,
"total_errors": 1, "plugins_executed": 2, "users_processed": 1
},
"record_format": "jsonl",
"manifest_provenance": {
"pack_version": "0.2.0",
"digest": "fb3309827e23b347068daea240ee09b54d79d09e2ce6e41295abab13b40fc687",
"entries": [
{ "plugin_id": "browser.safari", "sha256": "9c1f4b2e7a08d35c2b6e1f0a4d8c7b6a..." },
{ "plugin_id": "shell.history", "sha256": "734be7c079aa104d10ac9b67c076bac3..." }
]
}
}The manifest_provenanceblock attests to the artifact- manifest definitions that drove the collection: an overall digest plus the SHA-256 of each applied plugin's artifacts.yaml, which you can check against the collector source (see verifying manifest provenance). In containers written by macfor 0.1.0 (format 1.2), each entry instead hashes a copy the collector re-serialized, which can't be checked against the source; see the format version notes. macfor records it from its own embedded manifests — the plugin is always the source of truth for where and how artifacts are collected — so an analyst can later prove exactly which definitions produced a given evidence set. The same record is logged as a manifest_provenance event in the chain of custody.
source.users (format 1.4) lists the user accounts the source enumerated, whichever plugins ran. It is null if enumeration failed and [] if the source has no accounts, and statistics.users_processed is its length. On a live system it holds human accounts only (UID 500 or higher, with a real login shell). For a --from or --image source it holds every folder under Users/ except Shared and hidden folders. See container format spec §5.1.1.
plugin_options (v0.2.0+) appears when the collection ran with --plugin-opt. It maps each plugin ID to its options, with secret values recorded only as [set]:
"plugin_options": {
"ai.claudedesktop": { "skip_content": "true", "audit_key": "[set]" }
}A collection from --image records "type": "image" in source, with image_path (the image file) and image_format (dmg, raw, or e01). See the CLI reference and container format spec §5.
hashes.json & integrity
Every file under artifacts/, records/, and logs/is hashed with SHA-256 as it's written into the container, and the hash is recorded in hashes.json. To verify a container after the fact:
unzip -p evidence.zip hashes.json \
| jq -r '.files | to_entries[] | "\(.value) \(.key)"' > /tmp/hashes.txt
# Extract & verify
mkdir -p /tmp/check && unzip -o -q evidence.zip -d /tmp/check
(cd /tmp/check && shasum -a 256 -c /tmp/hashes.txt)records/ — parsed records
By default, plugins that parse a structured source emit one JSON record per line into records/<plugin_id>/parsed.jsonl. All of a plugin's record types share that file, and most records carry a type field that tells them apart. They feed straight into Splunk, OpenSearch, DuckDB, or macfor-analyze:
# All Safari history rows
unzip -p evidence.zip records/browser.safari/parsed.jsonl | \
jq 'select(.type == "browser_history") | {visited: .visit_time, url: .url}'
# Count Signal messages per conversation
unzip -p evidence.zip records/messaging.signal/parsed.jsonl | \
jq -r 'select(.type == "incoming" or .type == "outgoing") | .conversation_id' | \
sort | uniq -c | sort -rnsystem.users records
The system.users plugin records every local account in the directory-service node, including system and service accounts such as _www. That makes hidden or unexpected accounts visible. Records are in records/system.users/parsed.jsonl or records/system.users/user_accounts.parquet, depending on record_format. Each record has type set to user_account.
Metadata only. The plugin decodes an allowlist of fields from each account plist and never copies the plist into the container. The raw plist holds credential material: ShadowHashData (the password hash), KerberosKeys, HeimdalSRPKey, and the password hint. None of these reach the evidence container. Each record carries source_sha256, the SHA-256 of the account plist, so you can check a record against the original file.
Requires root on a live system. The plist directory (/private/var/db/dslocal/nodes/Default/users/) is readable only by root. Without root the plugin is reported as not applicable (visible in --dry-run). Against a mounted image or --from tree it works without root.
| Field | Type | Description |
|---|---|---|
type | string | Always user_account. |
username | string | Short (login) name. |
aliases | string[]? | Additional record names, such as an email-style alias. Omitted if none. |
real_name | string | Full name. |
uid, gid | integer? | User and primary group ID. Omitted if missing or non-numeric. |
generated_uid | string | Directory services GUID. |
home_dir, shell | string | Home directory and login shell. |
hidden | boolean | The account is hidden from the login window (IsHidden is set). |
service_account | boolean | UID below 500, no UID, or a non-interactive shell (/usr/bin/false, /bin/false, /sbin/nologin, /usr/sbin/nologin). |
has_password | boolean | The authentication authority contains ;ShadowHash;. Presence only; the hash is never collected. |
secure_token | boolean | The authentication authority contains ;SecureToken;, so the account can unlock FileVault. |
creation_time, password_last_set_time, failed_login_time | timestamp? | RFC 3339 UTC, from the embedded accountPolicyData plist. |
failed_login_count | integer? | From accountPolicyData.failedLoginCount. |
source_file | string | Path of the account plist. |
source_sha256 | string | SHA-256 of the account plist. |
{"type":"user_account","username":"alice","aliases":["alice@example.com"],"real_name":"Alice Smith","uid":501,"gid":20,"generated_uid":"4A1B2C3D-0000-1111-2222-333344445555","home_dir":"/Users/alice","shell":"/bin/zsh","hidden":false,"service_account":false,"has_password":true,"secure_token":true,"creation_time":"2023-11-14T22:13:20.5Z","password_last_set_time":"2023-11-16T02:00:00Z","failed_login_count":3,"failed_login_time":"2023-11-17T05:46:40Z","source_file":"/private/var/db/dslocal/nodes/Default/users/alice.plist","source_sha256":"eec7…"}The plugin doesn't collect Apple ID linkage, group membership or admin status, login history, deleted accounts, or network directory (AD or LDAP) accounts. A plist that fails to parse is recorded as an error, and the other accounts are still collected.
chain-of-custody.json
An ordered log of what happened during the collection: when it started and finished, each plugin's start and end, every file added (with its SHA-256 and source path), every error, and, from v0.2.0, the plugin options and a disk image's attach and detach. Entries are only ever appended. The log isn't itself hashed or chained, so to make it tamper-evident, record a hash of the whole container outside it. The full list of events is in the container format spec.
logs/
Per-plugin log files capture warnings, skipped paths, and errors as the plugin runs, which is more detail than your terminal shows. For the complete list of errors, use plugins[].errors in manifest.json: some errors appear there but not in the plugin log.
Parquet records
Pass --record-format parquet to write records as Apache Parquet instead, or both to get JSONL and Parquet. Parquet writes one file per record type, records/<plugin_id>/<artifact_type>.parquet, with _artifact_type, _plugin_id, _username, and _collected_atcolumns ahead of the plugin's own fields. Choose a codec with --parquet-compression (snappy by default). The record_format field in manifest.json tells you which you have.