/proc/PID/mountinfo carries two option sets with different meaning: fields[5]
are the per-mount (VFS) flags, the field after the "-" separator belongs to the
superblock. A mount is writable only if both allow it.
parse_mountinfo() merged both into one list and set
is_read_write = "rw" in options, so a "rw" VFS mount over a read-only
superblock was reported as writable. On stock Xiaomi-family builds that made
every read-only mi_ext customisation overlay a HIGH "system partition is
mounted as read-write".
Require "rw" in both layers, and let "rw" count as a suspicious mount option
only when the mount is actually writable; remount, noatime and nodiratime keep
their current meaning in either layer. mount_options and options_list still
carry both layers, so nothing downstream loses data.
Measured on 50 bug reports carrying mountinfo - the 28 where the rule changes
the output plus 22 controls, 14 brands, Android 10-16: HIGH 94 -> 0,
MEDIUM 119 -> 25, the 22 controls identical, and 36233 mount entries parsed
either way. Every removed alert is a read-only superblock under a "rw" VFS
mount; no report gains an alert. A partition that really is writable still
raises the HIGH, which the new test asserts explicitly.
Fixes#936
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The tests read the settings file of whoever runs them, and importing
mvt.common.config writes it back. On a machine whose config.yaml sets
NETWORK_ACCESS_ALLOWED to false, the plugin update and URL batch tests
fail before their mocked requests are reached, and every Command run in
the suite parses the indicators downloaded on that machine, which made
the suite take minutes instead of seconds.
MVT_CONFIG_FOLDER and MVT_DATA_FOLDER in the environment now relocate
the settings file and the downloaded indicators with their update-check
state. The test conftest points both at a throwaway folder before any
mvt module is imported, and removes it at exit. Subprocesses started by
the tests inherit the variables; the isolated interpreter helper already
gives them a temporary home.
On the machine that prompted this the suite goes from 6 failures in six
minutes to none in seven seconds, and config.yaml is left alone.
Device-generated sysdiagnose archives carry a ._name entry beside every
file that has extended attributes, an ACL or Finder info; one iOS 26
archive held 1234 of them among 3648 members, and the count grows with
each release. bsdtar folds them back into the file on extraction and
hides them from listings, but tarfile returns them as regular members,
so check-sysdiagnose extracted them and handed them to every module.
A module that globs for plists or logs then tries to parse AppleDouble
headers and logs one warning per sidecar.
Leave them out of the file list, both for archives and for folders
extracted on a system that keeps them as files.
* Defer indicator loading until first use
* Load indicators once at the start of a run
The lazy property loads indicators wherever they are first read, which
in Command.run() is inside the module loop, after init(). For
check-androidqf that means the whole acquisition is walked before a
missing --iocs file is reported, and the STIX parsing lines land between
the module list and the first module.
Read the indicators once after the module list is settled, so that a bad
--module name still loads nothing, and hand the same object to every
module. check-iocs reads them once too, so that a wrong --iocs path is
reported even when no stored result matches a module.
* Drop two test assertions the change does not need
Retrying after a failed indicator load is incidental to the property
rather than a requirement, so nothing should pin it. The exact wording
of the check-backup rejection belongs to that command's own tests; exit
code 1 already shows the path was rejected before indicators were
loaded.
* Create the output folder and command.log when a run starts
Command.__init__ created the --output folder and attached the
command.log handler, so `--list-modules -o out` or a rejected target
path left an empty folder behind for a run that never happened.
Do both at the top of run(), before the module list is resolved so that
its warnings still reach the log. The nested commands run by
check-androidqf re-attach the handler at the start of their runs, as
they did at construction. Lines the CLI logs between construction and
run(), such as the target path being checked, no longer reach
command.log; info.json records the target path.
This is the remaining part of #888.
* Cache the indicators with functools.cached_property
A property with a setter and a backing attribute does by hand what
cached_property does: compute on first read, store the result on the
instance, and accept assignment, which is how nested commands receive
their parent's indicators. A failed load is still not cached.
* Announce the target from the command so that it reaches command.log
The CLI logged which backup, filesystem or acquisition it was about to
check just before run(), which is now before command.log exists, so the
line only reached the console. Each command logs it from init() instead,
which runs once the log is attached, and run() logs the module list
after init() so that the target still comes first. Nested commands
without a target path log nothing, as before.
* Log the Android backup path from the typed local
mypy cannot determine the type of target_path in this command, which the
surrounding lines already silence, so read the announced path from the
local that carries the type instead of adding another ignore.
---------
Co-authored-by: bitmeta69 <206962233+bitmeta69@users.noreply.github.com>
Co-authored-by: besendorf <janik@besendorf.org>
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
A bugreport zip stores each entry's time as the device's wall clock, and
an unpacked bugreport's mtimes are whatever the extraction left. The
bugreport modules read both as naive local datetimes, so a tombstone's
file_timestamp moved with the analysing machine's timezone and sat three
hours from the crash time the tombstone itself records for a device in
Nairobi.
check-bugreport now resolves the device's timezone once, from --timezone
or from persist.sys.timezone in the dumpstate's SYSTEM PROPERTIES, and
hands it to the modules as device_timezone the way check-androidqf does;
a zone already known, as when androidqf drives the bugreport inside its
own archive, is kept. Zip entry times are read in that zone and the
mtimes of an unpacked bugreport as UTC instants, so convert_datetime_to_iso
writes both in UTC. Without a zone the wall clock is kept naive and a
warning says so, and an unpacked bugreport gets a warning that its file
timestamps are the extraction's, not the device's. BugReportTimestamps
uses the same reading instead of parsing the properties itself.
A sysdiagnose tarball whose download stopped halfway ends in an EOFError
from gzip while check-sysdiagnose extracts it. Click turns EOFError into
click.Abort, so the command printed nothing but "Aborted!", even with
-v. The extraction now catches the read errors an archive can raise,
names the file and the reason at critical level, says the file may be
truncated or not a gzip tarball, and exits 1. A plain .tar given to the
gzip reader gets the same message rather than a traceback.
* Add a SysdiagnoseInfo module to check-sysdiagnose
check-sysdiagnose had no module of its own: it prepared the archive for
plugin modules and refused to run without one. SysdiagnoseInfo is the
first built-in module. It writes sysdiagnose_info.json with details
about the device and the archive: product type and model, iOS version
and build, serial number, IMEI, MEID and UDID from remotectl_dumpstate.txt
and the mobile activation request, the Apple account name and email from
the App Store daemon database, and the archive's original file name and
creation time from sysdiagnose.log. The build is checked against the
known iOS versions the way BackupInfo does.
The App Store database is copied out of the archive together with its
-wal and -shm sidecars before it is opened, so rows still in the
write-ahead log are read.
With a built-in module the command's list is never empty, so the "no
custom modules" error and its test go. The module joins
IOS_CHECK_IOCS_MODULES like every other module that writes a results
file.
* Note that newer sysdiagnoses lack the App Store daemon database
* Keep refusing check-sysdiagnose runs without a custom module
* Warn instead of refusing when no forensic sysdiagnose module is loaded
MVTSettings.initialise() constructs the settings once with load_env=False
and writes the result to config.yaml, so that values taken from MVT_*
environment variables are never persisted, then constructs them again
with the environment applied.
Since #716 settings_customise_sources() has added env_settings
unconditionally, making load_env dead code. Any MVT_* variable set for a
single run, including MVT_IOS_BACKUP_PASSWORD, MVT_ANDROID_BACKUP_PASSWORD
and MVT_VT_API_KEY, was written in plaintext to config.yaml and read back
on every later run.
Only add env_settings when load_env is true, and add a regression test.
Fixes#915
dumpsys prints a blank line after every namespace block and after a
change history, and the generation registry and any vendor dumps follow
the last block before the section trailer. A record ran until the next
`_id:` line, heading or trailer, so the last row of the section
absorbed those dumps into whichever field came last.
A blank line now closes the record being read; lines that follow it
without an `_id:` are skipped until the next row or heading. The fixture
carries a generation registry after its last block, modelled on the
AOSP dump, and the last row is pinned to exactly its own fields.
A dumpsys settings row can end in `tag:` and, on some vendor builds, in
`isValuePreservedInRestore:` or a bare `notPreservedInRestore` token.
The parser only peeled `default:` and `defaultSystemSet:` off the end
of a record, so on a row without a default those tokens stayed inside
the value, and on a row with one they landed in `defaultSystemSet`. A
value of `0 tag:null` is not the safe value `0`, so a setting at its
safe value was reported as dangerous; these are the false positives
measured in #912.
The metadata keys are ordered fields like the rest of the record, so
`_split_fields` reads them now, which also replaces the separate
handling of the default. The bare token has no `key:` shape and is
printed last, so it is stripped first and recorded as
`isValuePreservedInRestore: false`.
The bugreport fixture gains the three row shapes from #912: a restore
flag after `defaultSystemSet:`, and a `tag:` or a bare token directly
after the value. Two of them sit on dangerous settings at their safe
value, so the unchanged alert count of one is the false-positive check.
The coverage comment never posted: the per-file table with a link on
every file and missing line range exceeds GitHub's 65536-character
comment limit, so PRs got only a badge. Every matrix job also raced to
post the same comment, and the action was unpinned at @main.
Post from one job only, limit the table to files changed in the PR,
drop per-line links, and pin the action. Write the full coverage table
to the job summary as well, which also works for PRs from forks where
the token is read-only.
Set UV_PYTHON from the matrix. Without it, .python-version pins 3.10
and `uv run` rebuilt the venv with 3.10 after `uv sync --python X`, so
all five matrix jobs were testing on Python 3.10.
The `dumpsys settings` parser matched a single regex per line, which
truncated every value that spans more than one line and, when
`defaultSystemSet:` did not fall on the first line, left the trailing
`default:` metadata inside the value. It also keyed results by setting
name within a namespace, so a name recorded twice kept only the last row
and a row without a `pkg:` field was dropped entirely.
Replace it with a line loop that accumulates one record at a time and
splits the `key:value` fields once the whole record has been read.
Results become a list of records carrying the fields dumpsys prints:
namespace, user, _id, name, value, pkg, default and defaultSystemSet,
plus the per-setting change history. History timestamps are printed
without a year, so they are resolved against the "ending at:" time of
the section and serialized into the timeline. This shows which package
changed a security-relevant setting, and when.
The androidqf settings module shares this artifact, so it now emits the
same record shape.
The package version now comes from the latest v* git tag, so a release
is a tag and nothing else. A scheduled workflow tags main on the first
of the month when something was merged since the last release, creates
the GitHub release with generated notes, and publishes to PyPI and the
container registry. Pushing a v* tag by hand goes through the same path.
Each console script imports its CLI module before Click can answer a
shell completion request, and the completion scripts run the program on
every keystroke. Importing mvt.ios.cli or mvt.android.cli took ~230 ms,
of which building the command tree needed almost nothing: cli_plugins
imported one constant from module_loader, which pulled in MVTModule, the
indicators, the pydantic settings, requests and rich; the command
implementations pulled in the same, and the iOS CLI imported iOSbackup
(pycryptodome) for decrypt-backup.
The two platform CLI modules now only build the command tree: each
command imports what it runs when it is invoked. cli_plugins owns the
custom command prefix instead of importing it from module_loader, and
exec_or_profile() loads the settings when it runs.
Completion of mvt-ios and mvt-android drops from ~260 ms to ~85 ms per
keystroke on a clean install, and every command starts that much
sooner. A test fails as soon as a CLI module imports the module
machinery again.
A module is part of check-iocs for a platform when it declares the
check-iocs pair, as before. It is now also part of check-iocs when it
overrides check_indicators() and supports at least one command of that
platform.
The rule lives in module_supports_command(). CmdCheckIOCS already uses
that function to pick its modules, so --list-modules and --module follow
it too. Built-in modules are unaffected. check-iocs takes them from
IOS_CHECK_IOCS_MODULES and ANDROID_CHECK_IOCS_MODULES.
A custom module which replaces a built-in module is covered by the same
rule. If it subclasses the module it replaces, it inherits its
check_indicators() and takes over the re-check of the results file. The
"Replacing a built-in module" section of the plugin documentation is
updated to say that.
A custom module which extends a built-in one ran alongside it, and both
wrote to the same results file when they shared a slug, with the run
order deciding the surviving content.
Custom modules can now name the module class they supersede in a
`replaces` attribute. When both are available to a command, the named
module is dropped from the run and the substitution is logged with the
origin of the replacement, so it is recorded in command.log. Only the
replacements which are applied are reported: a declaration from a
disabled module is ignored, so that replacing a module cannot silently
disable it, and modules which replace each other in a cycle all keep
running and replace nothing, with a warning naming every one of them.
Dependencies are remapped along with the modules themselves: a module
depending on a replaced class is ordered against, and receives the
results of, the module which took its place. Replacing a module which
others depend on therefore does not make that dependency unavailable.
The remapping applies wherever dependencies are read, so a replacement
which cannot run is skipped like any other module with an unavailable
dependency, and takes the modules depending on the module it replaced
with it. Those warnings name the dependency the author declared as well
as the module which replaces it.
A replacement does not have to keep the class name of the module it
replaces, so `--module` now falls back to the name of a replaced module
and runs its replacement. A name which matches no module at all stops
the run with a warning instead of silently analyzing nothing, as does a
selection left with nothing to run once skipped modules are dropped.
Sharing a slug outside a replacement stays possible and is now reported.
Two modules writing to the same results file is a forensic-integrity
problem rather than an error, so both still run and a warning names them,
where each came from, and the file the later one overwrites. Taking over
the slug of a replaced module is not reported, because that module is no
longer part of the run.
* Add the mvt.plugin import surface
mvt.plugin re-exports the names a plugin needs from MVT under one import
path. It holds the module base classes and Command, the alert and result
types, the database errors a module raises, the timestamp converters, the
plugin settings API, MVT's settings, get_plugin_logger() and MVT_VERSION.
The names it exports are kept working on a best-effort basis. Changes to
them are announced in the release notes. Anything else in mvt can still be
imported, and may change between releases without notice.
get_plugin_logger(__name__) returns a logger under mvt.ext for plugin code
outside a module class. Its records then reach the console and the
command.log file of a run. A file loaded with --load-module or
--load-command is named after the file.
* Document how to write MVT plugins
The custom modules page now leads with plugin packages. Loading module
files with --load-module and MVT_CUSTOM_MODULES moves to a section on
developing a module locally.
A new "Writing a module" section shows a module which subclasses
IOSExtraction. It lists each base class, the command pair it serves and the
helpers it provides. "Depending on a built-in module" says to import a
built-in class from its family package.
"Importing from MVT" says what mvt.plugin exports and what importing from
it means. The custom commands page shows a Command subclass which lists its
own modules. The sysdiagnose and plugin configuration pages import from
mvt.plugin.
* Share the check-iocs module lists between the CLI and the code
check-iocs re-checks the results a previous run stored, so its module
list is every module of the platform that could have written one. Each
platform's CLI composed that list inline, concatenating the families by
hand, so the list existed only inside the click callback: anything else
needing to know what check-iocs runs had to build its own copy, and the
two could drift apart without a test noticing.
Give each platform a command_modules.py holding the one list, and have
its CLI assign it. The modules check-iocs runs are unchanged, and a
test pins each list to the families it is composed of.
* Pin that check-iocs re-checks the results of custom modules
check-iocs takes its custom modules from load_custom_modules() like every
check-* command and matches result files to modules by slug, so a plugin
module's stored results are re-checked whenever it declares the
check-iocs pair of its platform; nothing asserted it.
* Find the console log handler by type when changing verbosity
set_verbose_logging() adjusted the first handler on the "mvt" logger,
whichever handler that happened to be. Anything else attaching a
handler to that logger - an embedding application, a plugin, a test
harness - had it mistaken for the console and raised or lowered behind
its back, and the same slot can hold the file handler a command
attaches to its output folder, whose level a --verbose flag should
never decide: command.log records the whole run either way.
Walk the handlers instead and adjust only MVT's own console handler,
found by its MVTLogHandler type. Every other handler on the logger is
left alone. Behaviour is otherwise unchanged.
* Add a --verbose option to the mvt, mvt-ios and mvt-android commands
Verbosity was an option of each module-running command, so
"mvt-ios --verbose check-backup" was a usage error, a plugin command had to
define a flag of its own, and there was no way to get debug output from mvt at
all.
The option now sits on the three commands themselves and sets the level of
MVT's console handler for the run, through set_verbose_logging(), before any
command runs. Plugin commands registered on any of the three CLIs get the
option for free and need none of their own.
The per-command --verbose of the check-* commands is kept for backward
compatibility. It only ever raises the level, so the CLI's choice is never
undone by a command's default, and its help text says it is kept for
compatibility. It is to be removed in a later release.
* Add plugin update checking
Report available updates to the installed MVT plugin packages in the
startup banner, for plugins installed from a package index and for
plugins installed directly from a repository. Repository installs pinned
to a commit or a tag are never reported as outdated.
MVT only prints the command which upgrades a plugin. Installing the
update stays a deliberate choice of the analyst. The check runs at most
once every twelve hours, and in between prints the findings of the
latest check which still apply to what is installed.
Nothing about the check can interrupt a running command: the parts of
the suggested command come from package metadata and are quoted for the
shell, the repository query refuses to prompt for credentials and never
passes metadata as a git option, and a corrupt or stale cache is
discarded rather than trusted.
* Add a plugins command to list installed plugins and check updates
Add a "plugins" command to the platform-neutral mvt command. "plugins
list" shows every installed plugin package with its version, where it was
installed from, how many forensic modules it contributes and which
commands it adds. "plugins check-updates" checks for updates immediately,
without waiting for the automatic check, and prints the command which
upgrades a plugin instead of installing anything.
It lives on mvt only. The packages it lists extend mvt-ios and mvt-android
too, but auditing them is not the job of a command which analyses one
platform, and the two platform CLIs should not carry commands which are
not about an acquisition.
The command is registered as a built-in, before any external command, so
that an installed package cannot replace this audit surface.