Open Source / SAF #262

A container state parser for the sysdiagnose framework

The framework's container parsers read text logs that a recent iPhone sysdiagnose no longer holds. This pull request adds a parser for the JSON file that holds the same information. It is the sibling of iLEAPP #2509, and the two projects package the same reader differently.

  • Daniel Addai, SectraTEK Lab
  • In review
  • CC BY 4.0

View the pull request on GitHub (opens in a new tab)

Summary

  • The framework's container manager and app installation parsers read text logs that the iOS 27.0 sysdiagnose we studied does not hold. On that archive both return nothing.
  • The pull request adds a parser, containermanager_state, for the JSON file that holds the same information: logs/ContainerManager/state.json.
  • It is the sibling of iLEAPP pull request #2509: both read the same file with almost the same reading code. This post explains how the two projects differ in structure.
  • The pull request is open and in review. Its text reports 4,744 events from the research image, 20 new unit tests, and one test skipped for lack of iOS 27 test data.
  • 4,744events from the research image
  • 7record types in the file
  • 20new unit tests
  • 1,583lines added in 6 files

Cite as

Addai, D., SectraTEK Lab (2026). A container state parser for the sysdiagnose framework. EC-DIGIT-CSIRC/sysdiagnose pull request #262, in review. https://github.com/EC-DIGIT-CSIRC/sysdiagnose/pull/262

Methodology note

Drafted with AI assistance and reviewed by the author before publication.

The issue

The sysdiagnose analysis framework from EC-DIGIT-CSIRC turns a sysdiagnose into events that can be searched and put on a timeline. It has two parsers for what an app container looks like on a phone: containermanager reads the container manager's text log, and appinstallation reads the installer's logs. On the iOS 27.0 archive we studied, the directories those parsers read, logs/MobileContainerManager and logs/appinstallation, are not in the archive. Both parsers return nothing, and an examiner who sees an empty result may conclude that the phone held no app containers.

The records had moved to a JSON file, logs/ContainerManager/state.json, which no parser read. Figure 1 shows the two situations.

Where the container information sits in the iOS 27.0 archive, for the frameworkTwo text-log paths are absent and the containermanager and appinstallation parsers return nothing. The state.json file is present and is read by the new parser.Inside the iOS 27.0 archive we studiedlogs/MobileContainerManager/*Read by the containermanager parserNot in the archivelogs/appinstallation/*Read by the appinstallation parserNot in the archivelogs/ContainerManager/state.jsonJSON file, about 4 MBPresent; no parser read itBefore: two parsers, no inputBoth parsers find nothing to read on an iOS 27.0 archiveand return no events. An empty result reads as nothing found.After: containermanager_stateOne new parser reads the JSON:4,744 events from the research imageWhether iOS 27 introduced the change cannot be told from our collection: the two text-log directories were alreadyabsent in nine iOS 26 archives (LAB-TR-0011, section 4.2). The state file is new relative to all nine.
Figure 1The framework's two older parsers read text logs that the iOS 27.0 archive does not hold; the new parser reads the JSON that does. Source: pull request text; LAB-TR-0011 section 4.2 (diagram, count from the research image).

One caution about the cause. In the nine iOS 26 archives we hold, the two text-log directories were already missing, so our data cannot place the change at iOS 27. The state file, though, appears only in the iOS 27.0 archive.

The essence

The pull request adds one parser, containermanager_state, for iOS 27.0 and later. It reads the state file and emits one event per record: app data, app group, extension and system containers with their directory times, the extension-to-app map, the code-signing registration cache, the recent callers of the daemon, the class paths and the persona tables. The reading is done by a separate module, utils/container_state.py, which never raises, keeps unknown keys in a column named other, and rejects out-of-range times instead of inventing a date.

Record types and events on the research image
Record typeWhat a record isEvents
containerAn app data, app group, extension or system container, with directory times if it has a directory2,157
codesignAn entry of the code-signing registration cache1,799
child_parentAn extension identifier paired with its host app637
managed_pathA managed path of an identity67
callerA recent caller of the container manager daemon58
class_pathA container class and its directory15
personaA persona identity11

The documentation page that ships with the parser separates what the file shows from what is inferred. It shows a container's directory times and its stored generation. It does not show who installed an app: a container's birth time is about when the device created the container, and a record with no directory cannot be told apart as removed, replaced or not yet created.

How this relates to iLEAPP #2509. The two contributions read the same file, and the reading core was written once. Compared line by line, the two copies differ in three places, all from the iLEAPP review: how a numeric time is read, the state word of a registration with no flag set (blank in iLEAPP, "registered" here), and a Python import line. In iLEAPP the core sits in the artifact file and three report functions split it by whether a container has a directory (see the iLEAPP post). In the framework the core sits in a utility module, and the parser turns every record into an event. Figure 4 sets the two structures side by side. Each follows what its project expects.

Our methodology

The method is the one described in the iLEAPP post, applied to a second project. The file's layout was read on one iOS 27.0 archive and each statement tied to a measurement. A container record always has the same eight keys. Its directory name appears in the archive's installation log, while its own identifier does not. A null directory entry goes together with a stored generation of -1 in the records measured. Within the records that have a directory, the birth and modify times are not after the change time, which is not after the last-seen time, in all 1,738.

The parser then follows the framework's own conventions. It looks for the file at a fixed path in the case folder, is gated by ios_version to 27.0 and later, and declares jsonl as its output format (Figure 2). A record with a time becomes an event at that time. A record without one, such as a container with no directory, becomes an event at the sysdiagnose creation time with a description that says the record carries no timestamp, so it cannot be mistaken for a measured time.

From state.json to events in the frameworkThe parser finds the file, hands its bytes to the reader in utils/container_state.py, receives rows with 26 columns, turns each into an event with a time and a message, and writes jsonl.state.jsonlogs/ContainerManager/iOS 27.0 and later onlyget_log_filesPath if the file exists;otherwise a warning, no eventsparse_state_bytesNever raises; bad times becomenotes; unknown keys kept in otherRows26 columns, 8 record typesTimes as UTC text, or blankEvent objectsdatetime, message, module,timestamp_desc, datajsonlOne event per record; a recordwith no time gets creation timeparse_state_bytes lives in utils/container_state.py; the parser file itself is 93 lines.
Figure 2The framework gets one event per record; the reader is a separate module that never raises. Source: src/sysdiagnose/parsers/containermanager_state.py and utils/container_state.py in the pull request (diagram).

Validation is a recount. The tests count the records of each type directly in the file, without the row builder, and compare. On the research image all seven per-type counts equal that independent count and add up to 4,744 events, with no warnings; running the parser twice gives identical output (Figure 3). The 20 new unit tests also cover damaged and truncated files, which return nothing without raising, bad and out-of-range times, very large numbers, unknown keys, and flags that override the state.

Events per record type on the research imageHorizontal bars on a scale from 0 to 2,500: 2,157 container records, 1,799 code-signing registrations, 637 extension-to-app pairs, 67 managed paths, 58 callers, 15 class paths and 11 personas, 4,744 in total.05001,0001,5002,0002,500Container records2,157Code-signing registrations1,799Extension-to-app pairs637Managed paths67Callers of the daemon58Class paths15Personas11Events (count)
Show this figure as a table
Events per record type
Record typeEvents
Container records2,157
Code-signing registrations1,799
Extension-to-app pairs637
Managed paths67
Callers of the daemon58
Class paths15
Personas11
Total4,744
Figure 3Each count equals an independent count of the records of that type in the file; together they make 4,744 events. Source: pull request text (Tested section), counts from the iOS 27.0 research image; real counts, not synthetic.

Test data is a file generated by a script with invented values, so no case data is in the repository. One test is skipped, because the framework's test data repository holds no iOS 27 case. The pull request offers to contribute a small synthetic fixture there if the maintainers want the case-based test to run. The lint check is clean. Eight existing tests error with and without this branch, because the test data submodule was not checked out in the Lab's environment. The pull request records this rather than hiding it.

The value

For users of the framework the value is continuity: an iOS 27 sysdiagnose gives container events where the older parsers give none. The events can be searched with the rest of the sysdiagnose output, and extension containers carry the host app the file pairs with them. Examiners who already work in the framework do not need to leave it to read one file.

The comparison also shows something about method. Two projects with different structures can share one tested reading core, so a fix or a caution found in one place carries to the other. Figure 4 shows where the versions still differ. Numeric times are the point to watch. In the iLEAPP review, the maintainer asked that a numeric time not be turned into a date by guessing its unit, because a count of seconds in the Cocoa epoch falls inside the Unix range and was read as a date in 1994. The framework version as submitted converts a number by guessing the unit and marks it in the notes. In the research image every time is an ISO-8601 string, so this does not change any count. Whether the framework wants the same rule is for its maintainers.

The same file read by two projects, side by sideA grid of six aspects, with iLEAPP on the left and the sysdiagnose framework on the right: code layout, how the file is found, output, records with no time, numeric times and tests.AspectiLEAPP #2509 (merged)SAF #262 (in review)Code layoutOne artifact file holds the readerand three report functionsReader in utils/, a 93-line parser,and a docs pageFinding the fileBy path pattern, in an archive,a folder or on its ownFixed path in the case folder;iOS 27.0 and later onlyOutputThree reports; times first in thefirst one, so it reaches the timelineOne stream of events, one perrecord, in 7 record types observedRecord with no timeReported with a blank time;not on the timelineDated at sysdiagnose creation time,and described as suchNumeric timeLeft blank; raw value in the notes(after maintainer review)Converted, unit guessed; marked inthe notes (as submitted)TestsRegistered cases with baselines,synthetic and sanitized, plus unit tests20 unit tests; the case-based test isskipped (no iOS 27 test data yet)Both read the same file with the same reading code, apart from the three differences named in the text.
Show this figure as a table
Structural comparison
AspectiLEAPP #2509 (merged)SAF #262 (in review)
Code layoutOne artifact file holds the reader and three report functionsReader in utils/, a 93-line parser, and a docs page
Finding the fileBy path pattern, in an archive, a folder or on its ownFixed path in the case folder; iOS 27.0 and later only
OutputThree reports; times first in the first oneOne stream of events, one per record
Record with no timeBlank time; not on the timelineDated at sysdiagnose creation time and described as such
Numeric timeLeft blank, raw value in the notesConverted with a guessed unit, marked in the notes (as submitted)
TestsRegistered cases with baselines plus unit tests20 unit tests; case-based test skipped
Figure 4The projects are built differently, so the same reader is packaged differently; behavior differs mainly for records with no time and for numeric times. Source: diffs of iLEAPP #2509 and SAF #262, and the review thread of #2509.

The limits are those of the iLEAPP contribution. The events are evidence of what the container manager daemon knew at capture. A container with timestamps shows a directory existed; a record without a node shows none existed then. Neither shows use, installation or removal by a person. Only one iOS 27.0 archive was read, so the parser says nothing about other versions or devices.

Where this sits in the Lab's research

This contribution supports LAB-TR-0011, Log Compression and Archive Structure in the iOS 27.0 Sysdiagnose: An Early Observation Tested Against Ten Archives (version 1.2, preprint, not peer reviewed; doi:10.5281/zenodo.23720069). Section 3.4 names this parser as the port of the container-state reader to the framework, and Section 4.5 explains why its container count (2,157) differs from the iLEAPP split (1,738 and 419). The report is on the Lab site at LAB-TR-0011.

LAB-TR-0010, on reading new sysdiagnose files, is in preparation and has no public page. The method above rests on the pull request.

Sources

Every number and claim above, with where it came from. PR means the pull request text, its diff or its review thread.

Sources for the numbers and claims
Number or claimWhere it came from
4,744 events: 2,157 container, 1,799 code-signing, 637 extension-to-app, 58 caller records, 15 class-path, 11 persona, 67 managed-path; 0 warnings; deterministicPR text, section Tested
20 new unit tests; one skipped; lint clean; 8 existing tests error with and without the branchPR text, section Tested; tests in the diff
1,583 lines added in 6 filesGitHub pull request metadata (additions, files)
Parser gated to iOS 27.0 and later, jsonl output, 93-line parser filesrc/sysdiagnose/parsers/containermanager_state.py in the diff
Records with no time dated at sysdiagnose creation timeParser file in the diff
26 columns, 8 record types, unknown keys in otherutils/container_state.py in the diff
Numeric times: unit guessed and marked in the notes (as submitted)parse_timestamp in utils/container_state.py in the diff
iLEAPP review: numeric time left blank; Cocoa-epoch number read as 1994iLEAPP #2509 review thread, maintainer comment
Eight keys per container record; directory name in installation log; generation -1 with a null entry; time ordering in all 1,738 recordsdocs/containermanager_state.md in the diff
Open; no reviews or comments; branch updated with main on 29 October 2026; opened 18 October 2026GitHub pull request metadata (state, reviews, comments, commits, createdAt)
Text-log directories already absent in nine iOS 26 archivesLAB-TR-0011 section 4.2
Container count differs from the iLEAPP split by constructionLAB-TR-0011 section 4.5
The two reading cores differ in three places (numeric time, registration state word, an import line)Line-by-line comparison of the core in the SAF diff and in the iLEAPP #2509 diff
Offer of a small synthetic fixture for the test data repositoryPR text, section Tested