Specification

Container format

The macfor evidence container is a single ZIP archive that holds collected files, parsed records, and the metadata needed to prove what was collected, from where, and that it hasn't changed since. This page is the specification for format version 1.4.

For a task-oriented tour of the same archive, see Output format. This page defines the format precisely enough to write an independent reader or verifier.

1. Conventions

The key words MUST, MUST NOT, SHOULD, and MAY are to be interpreted as described in RFC 2119.

  • Container: the ZIP file written by macfor collect --output.
  • Entry: a single file inside the container, identified by its ZIP entry name.
  • Writer: the macfor collector. Reader: any tool that consumes a container, for example macfor-analyze.
  • Timestamps in JSON documents are RFC 3339 strings with up to nanosecond precision (trailing zeros dropped) and an explicit UTC offset. The offset may be Zor the collection host's local offset, for example 2026-05-03T11:30:42.123456789Z or 2026-05-03T13:30:42.1+02:00. Readers MUST honour the offset.
  • Hashes are lowercase hexadecimal SHA-256 digests (64 characters).

2. Format version

The format version is recorded in the version field of manifest.json. This specification describes version 1.4, which macfor 0.2.0 writes. The collector's release version is recorded separately, in tool_version.

Version 1.4 (macfor 0.2.0) adds source.users, the list of user accounts the source enumerated (§5.1.1). It changes nothing else, so a reader that ignores unknown fields reads a 1.4 container as it would a 1.3 one.

Version 1.3, also introduced in macfor 0.2.0 (no release writes 1.3), changed one thing from 1.2 (written by macfor 0.1.0): what manifest_provenance.entries[].sha256 hashes (§5.3). In 1.3it is the hash of the plugin's artifacts.yaml file, so it can be checked against the collector source. In 1.2it is the hash of a copy the collector re-serialized, which can't be checked against the source.

macfor 0.2.0 also added fields and events that need no format change, because readers ignore what they don't recognize: plugin_options (§5), the image source type with image_format (§5.1), the plugin_options and image_* custody actions (§6.1), and counter-suffixed entry names for colliding files (§8.1.1).

Readers SHOULD check version before parsing the container, and SHOULDignore JSON fields they don't recognize, so that additions within a major version don't break them.

3. Archive encoding

  • The container MUST be a standard ZIP archive (PKWARE APPNOTE). Writers MUST use ZIP64 extensions when sizes or counts require them.
  • Every entry MUST use the Storemethod (method 0, no compression). The stored bytes are therefore identical to the original content, and an entry's SHA-256 can be checked without re-encoding it.
  • Entry names MUST be relative paths that use / as the separator, with no leading slash. Entry names SHOULD be unique, but readers MUST NOT assume they are (see §8.1.1).
  • The container is not encrypted. If the evidence needs protection at rest, encrypt the container file as a whole.

3.1 Entry modification times

For collected evidence under artifacts/, the ZIP modified time of each entry is set to the source file's modification time, so extracted files keep their original mtime. If the source mtime is unknown, the entry is left undated. For all other entries, the modified time is the time the entry was written.

ZIP headers store modification times only to whole seconds. The exact source mtime is recorded in the source_modified detail of the entry's file_added custody event (see §6.1). Readers that need sub-second precision MUST take it from there.

4. Entry layout

<container>.zip
├── manifest.json                                    # §5   (required)
├── chain-of-custody.json                            # §6   (required)
├── hashes.json                                      # §7   (required)
├── artifacts/                                       # §8.1
│   └── {category}/{plugin_id}/files/{user}/{filename}
├── records/                                         # §8.2
│   └── {plugin_id}/
│       ├── parsed.jsonl                             # record_format jsonl | both
│       └── {artifact_type}.parquet                  # record_format parquet | both
└── logs/                                            # §8.3
    └── {plugin_id}/collection.log

manifest.json, chain-of-custody.json, and hashes.json MUST be present in every complete container. Each of the three directories MAY be absent if no plugin produced output of that kind.

4.1 Write order

The writer streams evidence and per-plugin output first, and writes the three metadata documents last, in this order:

  1. For each plugin, in execution order: its artifacts/ entries as they are collected, then its records/ entries, then its logs/ entry.
  2. manifest.json
  3. chain-of-custody.json
  4. hashes.json

Readers MUST NOT rely on entry order. Locate entries through the ZIP central directory.

4.2 Incomplete containers

If an I/O error occurs while writing, or the collection is cancelled between plugins, the writer deletes the partial file rather than leave a truncated container. One case isn't covered: a collection cancelled before any plugin runs can leave an empty archive at the output path. Readers MUST treat a container that lacks any of manifest.json, chain-of-custody.json, or hashes.json as incomplete, and MUST NOT use it as evidence.

Plugin-level errors don't make a container incomplete: they are recorded in the manifest, the custody log, and the plugin log, and the collector exits with code 2 (see CLI reference).

5. manifest.json

The manifest describes the collection: what ran, against which system, when, and with what result. It is UTF-8 JSON, pretty-printed with two-space indentation.

{
  "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": { … },                        // §5.1
  "plugins": [ … ],                       // §5.2
  "statistics": {
    "total_files": 214,
    "total_records": 18342,
    "total_bytes": 93812234,
    "total_errors": 1,
    "plugins_executed": 2,
    "users_processed": 1
  },
  "record_format": "jsonl",
  "manifest_provenance": { … },           // §5.3, optional
  "license": { … },                       // §5.4, optional
  "plugin_options": {                     // optional
    "ai.claudedesktop": { "skip_content": "true", "audit_key": "[set]" }
  }
}
FieldTypeDescription
versionstringContainer format version. "1.4".
collection_idstringUnique collection identifier (a UUID unless supplied by the caller). Matches collection_id in chain-of-custody.json.
tool_versionstringVersion of the macfor binary that wrote the container.
collected_attimestampWhen collection started.
completed_attimestampWhen the container was finalized.
sourceobjectThe source system. See §5.1.
pluginsarrayOne summary per plugin that ran, in execution order. See §5.2.
statisticsobjectTotals across all plugins. Each field except plugins_executed and users_processed is the sum of the matching per-plugin field.
record_formatstringjsonl, parquet, or both. Determines which files appear under records/.
manifest_provenanceobject?Attestation of the artifact definitions used. Omitted if not recorded. See §5.3.
licenseobject?The license the collecting build ran under. Omitted for unlicensed builds, including the community build. See §5.4.
plugin_optionsobject?The options the collection ran with (--plugin-opt, macfor 0.2.0+), as { plugin_id: { key: value } }. Omitted when no options were set. Values read from env: or file:, and values of keys that look secret (containing key, token, secret, password, passwd, credential, or auth, case-insensitive), are recorded as "[set]". Other values are recorded verbatim, so the collection can be reproduced.

5.1 source

source has a set of flat fields, kept for backward compatibility, plus structured sub-objects that are present when the collector could gather full system information. Readers SHOULD prefer the structured sub-objects when they are present.

"source": {
  "type": "live",                  // "live" | "directory" | "image"
  "hostname": "host.local",
  "os_version": "14.4.1",
  "os_name": "macOS",              // optional
  "serial_number": "C02XXXXXXXXX", // optional
  "image_path": "/cases/disk.dmg", // optional: the image file ("image") or
                                   //   the tree root ("directory")
  "image_format": "dmg",           // optional, "image" only: "dmg" | "raw" | "e01"

  "os":       { "name": "macOS", "version": "14.4.1", "build": "23E224",
                "codename": "Sonoma", "kernel_version": "23.4.0" },
  "hardware": { "model": "Mac14,2", "model_name": "MacBook Air (M2, 2022)",
                "serial_number": "C02XXXXXXXXX", "architecture": "arm64",
                "cpu_type": "Apple M2", "cpu_cores": 8, "memory_gb": 16 },
  "storage":  { "boot_volume_name": "Macintosh HD", "boot_volume_uuid": "…",
                "boot_volume_fs": "apfs", "boot_volume_encrypted": true },
  "security": { "sip_enabled": true, "filevault_enabled": true,
                "secure_boot_level": "full", "gatekeeper_enabled": true },
  "network":  { "hostname": "host.local", "local_hostname": "host",
                "computer_name": "Analyst Mac" },
  "temporal": { "timezone": "Europe/Stockholm", "timezone_offset": "+02:00",
                "boot_time": "2026-05-01T07:12:08Z", "uptime_seconds": 188314,
                "collection_time": "2026-05-03T11:30:42Z" },
  "collection_env": { "user": "root", "uid": 0, "is_root": true,
                      "full_disk_access": true },

  "users": [                       // null if enumeration failed; [] if none
    { "username": "alice", "uid": 501, "gid": 20, "name": "Alice Smith",
      "home_dir": "/Users/alice", "shell": "/bin/zsh" }
  ]
}

collection_env.full_disk_accessapplies to live collections and is omitted when it couldn't be determined. When it is false, gaps in user-protected data are expected and aren't evidence of deletion.

An image source (--image, macfor 0.2.0+) is a disk image file the collector attached read-only itself. Its fields are read from files on the image, as for a directory source, over a merged view of the System and Data volumes of a macOS 10.15+ volume group. Fields that describe runtime state, such as the security flags, serial_number, and cpu_type, can't be known from a directory or image and are "unknown", empty, zero, or false; such a value means "not derivable from this source", not "absent on the subject Mac". hardware.architecture is arm64 or amd64 (Intel), on every source type.

5.1.1 source.users

Added in format 1.4. source.userslists every user account the source enumerated, which are the accounts whose home directories plugins collected from. The collector writes it on every collection. It doesn't depend on any plugin, on running as root, or on --plugin and --category filters.

FieldTypeDescription
usernamestringShort (login) name.
uidintegerUser ID.
gidintegerPrimary group ID.
namestring?Full name. Omitted if empty.
home_dirstring?Home directory path. Omitted if empty.
shellstring?Login shell. Omitted if empty.
  • users is always present in a 1.4 manifest. null means enumeration failed, so the account list is unknown. [] means enumeration succeeded and found no accounts. Readers MUST NOT treat the two as the same.
  • statistics.users_processed equals the length of source.users.
  • Live system. The collector reads the local directory-service plists (root only), then falls back to dscl . -readall /Users (no root needed), then /etc/passwd. It keeps human accounts only: UID 500 or higher, with a login shell other than /usr/bin/false, /bin/false, or /sbin/nologin. The nobody account is excluded.
  • Directory or image source (--from, --image). The collector lists every folder under Users/ except Shared and hidden folders. Where a matching directory-service plist exists, it fills in the UID, GID, name, and shell. No UID or shell filter applies, so service accounts that have a home folder appear.
  • source.users is not the full account inventory. For every local account, including system and service accounts, use the system.users plugin (see system.users records).

5.2 plugins[]

{
  "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": [                       // omitted when empty
    {
      "artifact": "/Users/alice/Library/Safari/Downloads.plist",
      "operation": "downloads",
      "message": "open …: operation not permitted",
      "timestamp": "2026-05-03T11:30:50.002Z"
    }
  ]
}

errors_encountered MUST equal the length of errors. artifactis the source path involved, and is omitted when an error isn't tied to one file. operation is the artifact type, or collect for errors the plugin reported as a whole.

5.3 manifest_provenance

Records which artifact definitions (the plugins' artifacts.yaml manifests, as built into the binary) drove the collection, so an analyst can later prove which definitions produced the evidence. Only plugins that applied to the source are listed.

"manifest_provenance": {
  "pack_version": "0.2.0",
  "digest": "fb3309827e23b347068daea240ee09b54d79d09e2ce6e41295abab13b40fc687",
  "entries": [                                   // sorted by plugin_id
    { "plugin_id": "browser.safari", "sha256": "9c1f4b2e…" },
    { "plugin_id": "shell.history",  "sha256": "734be7c0…" }
  ]
}
  • pack_version is the collector's tool_version.
  • entries[].sha256is the SHA-256 of the exact bytes of that plugin's artifacts.yaml, as built into the binary. It equals shasum -a 256 artifacts.yaml run on the file in the collector source at the release that wrote the container (see §9.3). Any change to the file, including to a comment or whitespace, changes the hash.
  • digest is the SHA-256 of the canonical payload below, where entries are sorted by plugin_id (byte order), \n is LF, and \0 is a NUL byte. Signed manifest packs (macfor manifest pack) hash their files and build their digest the same way. A pack entry for the same artifacts.yaml therefore has the same sha256as the container entry. The digests match only if the pack's version and set of plugins match too.
<pack_version>\n
<plugin_id>\0<sha256>\n      # repeated for each entry

5.4 license

Present when the container was written by a licensed build, such as macfor-pro. It identifies the license the collection ran under.

"license": {
  "license_id": "lic_01J9…",
  "customer": "Example Forensics Ltd",     // optional
  "tier": "pro",                           // optional
  "issued_at": "2026-01-01T00:00:00Z",     // optional
  "expires_at": "2027-01-01T00:00:00Z",    // optional
  "key_id": "…"                            // optional, identifies the signing key
}

A development build of macfor-pro without a license records license_id as unlicensed-dev-build. Containers carrying that value weren't produced by a released build.

6. chain-of-custody.json

An ordered log of every significant event during the collection. Entries are appended in chronological order and are never rewritten.

{
  "collection_id": "3f0c6a8e-5b7d-4c1e-9a2f-6d8b1e4c7a90",
  "created_at": "2026-05-03T11:30:42.104Z",
  "entries": [
    {
      "timestamp": "2026-05-03T11:30:43.221Z",
      "action": "file_added",
      "actor": "macfor",
      "target": "artifacts/browser/browser.safari/files/alice/History.db",
      "hash": "a3b1…",
      "details": {
        "plugin": "browser.safari",
        "source_path": "/Users/alice/Library/Safari/History.db",
        "username": "alice",
        "source_modified": "2026-05-02T19:04:11.837201Z"
      }
    }
  ]
}
FieldTypeDescription
timestamptimestampWhen the event occurred.
actionstringEvent type. See §6.1.
actorstringWho performed the action. macfor for every event the collector writes.
targetstring?Subject of the event: an entry name, plugin ID, or source path.
hashstring?SHA-256 of the target entry, for file_added.
detailsobject?Action-specific fields.

6.1 Actions

ActionTargetDetails
collection_started—tool_version, source_type, hostname
manifest_provenance—pack_version, digest, manifest_count
image_attachedimage fileimage_path, image_format, mount_point, roots, read_only, image_size_bytes and image_modified (if known), warnings (if any). image sources only; its timestamp is when the image was attached.
plugin_options—options, masked as in manifest.json. Only when options were set.
license—license_id, customer, tier, key_id, expires_at (if set)
plugin_startedplugin IDplugin_name, plugin_version
file_addedentry nameplugin, source_path, username, source_modified (if known), and for a renamed entry requested_path and renamed_for_collision(§8.1.1); the entry's hash is in hash
errorsource path or plugin IDplugin, operation, error
plugin_completedplugin IDfiles_collected, records_created, errors_encountered
image_detached, image_detach_failedimage fileimage_path; error on failure. image sources only, before collection_completed.
collection_completed—total_files, total_records, total_errors

A complete log starts with collection_started and ends with collection_completed. Each plugin's events sit between its plugin_started and plugin_completed. Readers SHOULD tolerate action values they don't recognize.

A file_added event is written only for collected evidence under artifacts/. Records and logs are covered by hashes.json but have no custody event of their own.

7. hashes.json

{
  "algorithm": "sha256",
  "generated_at": "2026-05-03T11:34:18.882Z",
  "files": {
    "artifacts/browser/browser.safari/files/alice/History.db": "a3b1…",
    "records/browser.safari/parsed.jsonl": "5e0d…",
    "logs/browser.safari/collection.log": "c71f…"
  }
}
  • algorithm MUST be sha256.
  • files is an object that maps each entry name to the SHA-256 of its content. The hash is computed while the entry is written, from the same bytes that are stored.
  • files covers every entry under artifacts/, records/, and logs/. It does not cover manifest.json, chain-of-custody.json, or hashes.json itself. To protect those, hash the container file as a whole (§9.2).

8. Payload directories

8.1 artifacts/ — original evidence

artifacts/{category}/{plugin_id}/files/{user}/{filename}
  • category: a broad category set by the plugin, such as browser. It isn't necessarily one of the categories listed in the plugin's manifest.
  • plugin_id: for example browser.safari.
  • user: the macOS account name the artifact belongs to, or systemfor artifacts that aren't tied to a user.
  • filename: the base name of the source path, or the artifact type if there is no source path.

Content is copied byte for byte from the source. The full source path is recorded in the matching file_added custody event, not in the entry name.

8.1.1 Duplicate entry names

Entry names are built from the source file's base name only, so two source files with the same name, user, and plugin map to the same entry name. This happens in practice, for example with Firefox profiles that each contain places.sqlite.

From macfor 0.2.0, every entry name is unique. The first file keeps the name; each later one gets a counter before its extension, starting at 2: places.sqlite, then places~2.sqlite, places~3.sqlite, and so on. A name without an extension takes the counter at the end (History~2), and a dotfile keeps its leading dot (.zsh_history~2). Each copy has its own hashes.json entry and its own file_added event, whose target is the name actually written. A renamed entry's event also carries requested_path (the name it would otherwise have had) and "renamed_for_collision": true. Use source_path in that event, not the entry name, to tell the copies apart.

Containers written by macfor 0.1.0 can instead hold several entries with one name. Each has its own file_added event with its own hash and source_path, but hashes.json keeps only the last one. Readers MUST read such entries from the central directory in order, rather than by name, if they need every copy.

8.2 records/ — parsed records

Which files appear depends on record_format, chosen with --record-format (default jsonl):

record_formatEntries per plugin
jsonlrecords/{plugin_id}/parsed.jsonl
parquetrecords/{plugin_id}/{artifact_type}.parquet, one per artifact type
bothBoth of the above, with identical records.

JSONL.UTF-8, one compact JSON object per line, each line terminated by LF. All of a plugin's artifact types share a single file, and each plugin defines its own record shape. The artifacts each plugin collects are listed in the plugin reference.

Parquet. One Apache Parquet file per artifact type. Records with no artifact type are written to parsed.parquet. The compression codec is set with --parquet-compression: snappy (default), zstd, gzip, or none. Every file begins with four common columns, followed by the plugin's own fields:

ColumnTypeDescription
_artifact_typestringArtifact type, matching the file name.
_plugin_idstringPlugin that produced the record.
_usernamestringOwning account, or empty for system artifacts.
_collected_attimestamp (µs)When the record was written (UTC).

8.3 logs/ — plugin logs

logs/{plugin_id}/collection.log is a UTF-8 text log, written only if the plugin logged anything. Each line has this form:

[<RFC 3339 timestamp>] [<LEVEL>] [<plugin_id>] <message>

The log is for people to read. Its message text isn't a stable interface, so use manifest.json and the custody log for anything you process automatically.

9. Verification

9.1 Content integrity

A reader verifies a container by checking that:

  1. The archive opens, and manifest.json, chain-of-custody.json, and hashes.json are present.
  2. collection_id is the same in the manifest and the custody log.
  3. Every key in hashes.json files exists as an entry, and its SHA-256 matches. Where an entry name occurs more than once, hashes.json holds the hash of the last copy; check the earlier copies against their file_added events instead.
  4. Every entry under artifacts/, records/, and logs/ is listed in hashes.json. An unlisted entry indicates the archive was modified after it was written.
  5. For each file_added custody event, hash equals the hashes.json value for its target.

A shell implementation of check 3:

unzip -p evidence.zip hashes.json \
  | jq -r '.files | to_entries[] | "\(.value)  \(.key)"' > /tmp/hashes.txt

mkdir -p /tmp/check && unzip -o -q evidence.zip -d /tmp/check
(cd /tmp/check && shasum -a 256 -c /tmp/hashes.txt)

9.2 Sealing the container

hashes.jsonproves the payload matches what the collector wrote. It can't prove the metadata documents are unchanged, because someone who edits the archive could also rewrite hashes.json. To make the whole container tamper-evident, hash the container file as soon as it is written and record that hash outside the container, in your case notes or evidence management system:

shasum -a 256 evidence.zip

9.3 Manifest provenance

To check which artifact definitions drove a collection, compare manifest_provenance with the collector source at the release that wrote the container. That release is tagged v<tool_version>. For a macfor-pro container, hash the manifests from both the macfor-community and macfor-pro sources that release was built from. A reader verifies provenance by checking that:

  1. Every entries[].sha256 equals the SHA-256 of an artifacts.yaml file in that source.
  2. digest equals the SHA-256 of the canonical payload rebuilt from pack_version and entries (§5.3).

Check 2 shows that the record is internally consistent, not that it is authentic. Like hashes.json, it can be rewritten by anyone who edits the archive, so rely on the container hash you recorded when sealing it (§9.2). Containers of format 1.2fail check 1, because their entry hashes aren't of the source files.

git clone --depth 1 --branch v<tool_version> \
  https://github.com/macfor-io/macfor-community.git

# Check 1: prints any container hash with no matching source file
shasum -a 256 macfor-community/plugins/*/artifacts.yaml \
  | cut -d ' ' -f 1 | sort > /tmp/source-hashes.txt
unzip -p evidence.zip manifest.json \
  | jq -r '.manifest_provenance.entries[].sha256' \
  | sort | comm -23 - /tmp/source-hashes.txt

# Check 2: the two lines must match
unzip -p evidence.zip manifest.json | jq -j '.manifest_provenance
  | .pack_version + "\n"
  + ([.entries | sort_by(.plugin_id)[] | .plugin_id + "\u0000" + .sha256 + "\n"]
     | join(""))' | shasum -a 256
unzip -p evidence.zip manifest.json | jq -r '.manifest_provenance.digest'