Compare commits

..
Author SHA1 Message Date
Janik Besendorf d58abbb568 Check stable results in parallel module tests 2026-10-11 14:40:26 +02:00
Janik Besendorf d52e9fcc74 Merge main and resolve upstream integration conflicts 2026-10-11 14:27:54 +02:00
Janik Besendorf 59c3f5d60c Expose completed modules during initialization 2026-10-11 09:49:44 +02:00
besendorf b3aa443819 Merge pull request #970 from WingManOO7/fix/settings-leap-day
Keep settings history timestamps on 29 February
2026-10-09 12:52:44 +02:00
besendorf aac41ec8c2 Test settings leap-day resolution across a skipped century
Ensure a February 29 history entry in a January 2104 dump resolves to 2096, since 2100 is not a leap year.
2026-10-09 03:49:54 -07:00
besendorf 1e05f438d7 Include the eighth prior year when resolving settings leap days
Adjust the year range for leap year calculation to account for century years.
2026-10-09 03:48:37 -07:00
Muhammad Mohid Shafiq 10f739d669 Keep settings history timestamps on 29 February
dumpsys prints the settings change history without a year, so
_resolve_timestamp() parsed the value with strptime("%m-%d ...") and only
then replaced the year. strptime() parses a value without a year in 1900,
which has no 29 February, so every change made on a leap day raised
ValueError and lost its timestamp, dropping it from the timeline and from
the alert of a dangerous setting.

Parse the value together with a year instead, starting from the year the
section was dumped and going back until the date exists and is not later
than the dump. Eight years always reach a leap year.

This also removes the DeprecationWarning Python 3.13 raises for parsing a
day of month without a year (python/cpython#70647).
2026-10-08 19:35:29 +05:00
besendorf 94e9cd3712 Merge pull request #968 from va-resident/fix/adb-state-closing-brace
Fix spurious ADB state parse error on devices without stored keys
2026-10-07 17:27:15 +02:00
besendorf 300cc26112 Merge branch 'main' into fix/adb-state-closing-brace 2026-10-07 17:25:29 +02:00
besendorf 3c12e1a019 Merge pull request #925 from mvt-project/docs/recommend-pymobiledevice3
Recommend pymobiledevice3 for iOS acquisition
2026-10-07 16:12:55 +02:00
besendorf c86bc93852 Merge branch 'main' into docs/recommend-pymobiledevice3 2026-10-07 16:11:14 +02:00
va@resident 3a462e1e2c Fix spurious ADB state parse error on devices without stored keys
Since #957 the parsed body keeps the closing brace of debugging_manager
and the blank lines before it. The brace popped two levels, and a blank
line after system_keys raised IndexError. Don't pop when the
indentation drops on a closing brace, and skip blank lines outside
multiline values.

Fixes #967.
2026-10-06 15:29:41 +05:00
besendorf c95eeed825 Merge pull request #964 from forest-savage1234/docs/python-minimum-q093-20260929
Correct the Python minimum in installation documentation
2026-09-30 10:53:41 +02:00
Forest Savage 60d1b414fe Correct the Python minimum in installation documentation 2026-09-29 21:13:11 -08:00
besendorf 563fe44bf3 Merge pull request #959 from mvt-project/auto/add-new-ios-releases
[auto] Update iOS releases and versions
2026-09-29 20:44:00 +02:00
besendorf 85fca1c825 Merge branch 'main' into auto/add-new-ios-releases 2026-09-29 20:42:15 +02:00
besendorf 3704bf26c0 Merge pull request #960 from Yi-111-a/fix/manifest-flag-missing-backup-files
fix(manifest): flag backup files listed in the manifest but not stored
2026-09-29 20:41:50 +02:00
Janik Besendorf a80b0fff2e fix(manifest): validate backup inventory before marking files missing 2026-09-29 18:40:02 +02:00
Yi-111-a f98f4f6cd2 fix(manifest): flag backup files listed in the manifest but not stored
An incomplete iTunes backup still lists in its Manifest.db the files it
failed to acquire, so a module which found nothing for one of them looks
exactly like a module which found nothing on a device that never had the
artifact. The Manifest module now records a "missing" flag on those
records and reports the total, so a gap in the acquisition is visible in
manifest.json and in the command output.

The stored file IDs are collected by walking the backup folder once, which
keeps the check off the per-entry filesystem lookup path: on a 20k entry
manifest the module runs in the same time as before the change.
2026-09-29 23:59:15 +08:00
DonnchaC 3e99412c32 Add new iOS versions and build numbers 2026-09-28 18:17:44 +00:00
besendorf fd37c4d8d9 Merge pull request #957 from itzzdev09/fix/adb-state-bounds-main
Bound the ADB manager state by matching braces
2026-09-28 16:16:03 +02:00
Janik Besendorf c615d5e304 Merge main into PR #957 and preserve ADB state bounds 2026-09-28 16:12:15 +02:00
besendorf 9bdc25f57d Merge pull request #949 from NeuroSaki987/codex/fix-windows-dumpsys-crlf
fix: parse dumpsys ADB output with CRLF line endings
2026-09-28 16:06:05 +02:00
besendorf 8238d80035 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-28 16:04:49 +02:00
besendorf 22d97f0a88 Merge pull request #954 from SomeoneUnlicensed/fix/windows-tests
Make the test suite pass on Windows
2026-09-28 16:01:08 +02:00
besendorf d224db5a78 Merge branch 'main' into fix/windows-tests 2026-09-28 15:54:55 +02:00
Janik Besendorf 658c7aa327 Test Windows path normalization and preserve backup safety coverage 2026-09-28 15:47:15 +02:00
itzzdev09 9316ac7fb0 Bound the ADB manager state by matching braces 2026-09-28 02:07:03 +05:30
besendorf 6ddb2c9671 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 22:25:21 +02:00
besendorf 165d87b97f Merge pull request #956 from itzzdev09/fix/battery-daily-malformed-update
Skip a malformed battery daily Update line instead of aborting
2026-09-27 20:44:35 +02:00
itzzdev09 d2e992e40e Skip a malformed battery daily Update line instead of aborting 2026-09-28 00:07:17 +05:30
besendorf aa3ae511ad Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 19:24:54 +02:00
besendorf 7700749cfa Merge pull request #950 from itzzdev09/fix/url-domain-www-prefix
Strip only a whole www. prefix from parsed domains
2026-09-27 19:24:23 +02:00
besendorf 6eb1a53873 Merge branch 'main' into fix/url-domain-www-prefix 2026-09-27 19:20:51 +02:00
Neruo Saki 02aa23cb81 Merge branch 'main' into codex/fix-windows-dumpsys-crlf 2026-09-27 17:54:12 +08:00
besendorf 9c3db579ee Merge pull request #946 from va-resident/fix/accessibility-installed-service-count
Carry the accessibility service count the dump states
2026-09-25 19:38:10 +02:00
besendorf 983e970443 Merge branch 'main' into fix/accessibility-installed-service-count 2026-09-25 19:36:53 +02:00
besendorf cfaeb99bf7 Merge pull request #944 from va-resident/fix/bugreport-oem-wrapper-archive
Descend into an OEM wrapper archive in check-bugreport
2026-09-25 19:35:42 +02:00
besendorf 00a5f11124 Merge branch 'main' into fix/accessibility-installed-service-count 2026-09-25 19:30:09 +02:00
va-resident b1b9958f4d Descend into an OEM wrapper archive in check-bugreport
MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump captures
with the real bugreport-<device>-<timestamp>.zip nested inside. The outer
archive carries none of the entry points the bug report modules read, so every
module reported it found no files and check-bugreport still exited 0 with an
empty result — an empty analysis that looks like a finished one.

Name the entry points once in modules/bugreport/base.py, next to the
_get_dumpstate_file() that tries them, and when an archive has none of them,
open its zip members in memory and use the first one that does.

Measured over 44 bug report collections: the 14 wrapper collections go from 0
artifacts to 260 artifacts and 586638 records; the 30 normal collections are
unchanged, the descent being unreachable for them.

Fixes #935
2026-09-25 21:21:40 +04:00
va-resident ad6caa155f Track accessibility state sections and unnamed services per user
Two issues from review:

* The set of printed state sections was global, so a section printed for
  one user decided the flags of another. A user with an installed list and
  no enabled section was marked enabled=False and got a LOW "installed, not
  enabled" alert. Sections are now tracked per user.

* A count-only record was added only when a user had no named component at
  all. A dump stating installedServiceCount=2 and naming one service read
  as complete. The count-only record is now added whenever the stated count
  exceeds the distinct named components for that user, and carries the
  difference in a new unnamed_service_count field. The module summary adds
  up that remainder.
2026-09-25 20:55:01 +04:00
besendorf c2030e6e84 Merge pull request #945 from va-resident/fix/bugreport-section-duration-line
Do not treat a section's timing line as a section boundary
2026-09-25 18:36:28 +02:00
SomeoneUnlicensed 99b3b5f014 Make the test suite pass on Windows
The Filesystem module stored the paths of an iOS dump with the separator
of the system checking it, so on Windows the process and file path
indicators, which split paths on "/", never matched. It now stores them
as POSIX paths.

The rest are test fixes:
- the completion install tests also redirect USERPROFILE, which
  Path.home() reads on Windows; they wrote to the real home folder;
- the plugin table helpers accept the light header Rich draws on
  consoles that cannot show the heavy one;
- the completion test quotes the command path, whose backslashes were
  dropped when COMP_WORDS was split;
- tests that need symbolic links or the sqlite3 binary are skipped when
  those are not available;
- two assertions no longer depend on the path separator or on the line
  ending text mode writes.
2026-09-24 21:29:45 +03:00
besendorf 9ca4254649 Fix formatting in test_artifact_dumpsys_adb.py 2026-09-24 09:38:18 -07:00
besendorf f1e52b297a test: cover mixed ADB state line endings
Regression for CRLF after the manager brace and LF after the outer brace.
2026-09-24 04:40:48 -07:00
besendorf 8c5278f540 Refactor JSON extraction logic in dumpsys_adb.py 2026-09-24 04:37:25 -07:00
besendorf df3b130ca8 Test w-prefixed subdomain indicator matching
Add test for URL matching with prefixed subdomain.
2026-09-24 04:34:52 -07:00
besendorf 3bb652f2d2 Test w-prefixed shortener detection 2026-09-24 04:29:33 -07:00
besendorf c399608345 Merge branch 'main' into fix/url-domain-www-prefix 2026-09-24 13:24:47 +02:00
itzzdev09 7f19d96297 Strip only a whole www. prefix from parsed domains 2026-09-23 19:27:08 +05:30
StarRailHub ff5ebf73cc fix: parse dumpsys ADB output with CRLF line endings 2026-09-23 12:11:58 +08:00
va-resident bdbc07a3b3 Carry the accessibility service count the dump states
Most builds never print the `installed services: {...}` block. They state
installedServiceCount=N in the user's attributes line and list nothing, so the
parser recorded no service at all and MVT logged "Identified a total of 0
accessibility services" — an artifact that says "no accessibility services"
about a dump that said there are five. The dump pasted in #744 is itself an
example: it states installedServiceCount=6 and names none.

Parse the count per user and, for a user whose services the dump did not list,
keep one record carrying it, raised as LOW: a count is a coverage statement,
not a running service.

Name the service state in the alert and split its severity, as asked on #744:
a service the dump states is switched off is LOW, enabled or bound is MEDIUM,
and a dump that does not state the enabled state stays MEDIUM, because "not
stated" is not "not enabled". A state section the dump never printed now reads
None rather than False. IOC matching is unaffected by the state: a disabled
service still returns CRITICAL on a match.

Measured over 163 bug reports carrying an accessibility section: MEDIUM alerts
305 -> 4, LOW 0 -> 407. The four that stay MEDIUM are the only records in the
set with enabled=True.

Fixes #744
2026-09-22 18:13:57 +03:00
va-resident 6f65eee85a Do not treat a section's timing line as a section boundary
extract_command_section() ends a section at any line starting with "------".
dumpstate prints a section's timing line when THAT section finishes, and it can
land in the middle of the section currently being written, so the section is
truncated at an arbitrary point and the rest is silently dropped.

Skip the timing line instead of returning it: it never reaches a parser as
content, and the real "------ <NAME> ------" boundary still ends the section.

Measured over 40 bug reports carrying a dumpstate: SYSTEM PROPERTIES was
truncated on 6 of them, losing 6559 properties, and 3 parsed no property at
all. On one Samsung archive getprop goes from 418 to 1259 properties and from
0 to 473 ro.* ones, bringing back ro.product.model,
ro.build.version.security_patch and ro.boot.verifiedbootstate. The other 34
archives are byte-identical.

Fixes #938
2026-09-22 18:11:42 +03:00
besendorf dbbcdd52df Merge branch 'main' into docs/recommend-pymobiledevice3 2026-09-22 16:11:02 +02:00
besendorf 777db67291 Merge pull request #926 from mvt-project/besendorf-patch-5
Remove adb-shell dependency from pyproject.toml
2026-09-22 15:36:20 +02:00
besendorf a0bc233eec Merge branch 'main' into besendorf-patch-5 2026-09-22 15:34:36 +02:00
Janik Besendorf 1db5d239d0 Merge main into PR 926 2026-09-22 15:33:17 +02:00
Donncha Ó Cearbhaill 2426178baa Enable automated PyPI deployments for tagged releases (#943)
* Limit PyPi deploys to tag relaeses

* Remove unnecessary tests and docs from sdist build
2026-09-22 13:02:49 +02:00
besendorf e0697a58e9 Merge pull request #937 from va-resident/fix/mountinfo-rw-both-layers
Fix mountinfo read-write detection across both option layers
2026-09-18 17:48:33 +02:00
va-residentandClaude Opus 5 d0fc0379b9 Fix mountinfo read-write detection across both option layers
/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>
2026-09-18 18:43:06 +03:00
besendorf 7973e90589 Merge pull request #934 from mvt-project/chore/update-iphone-backup-decrypt-0.10.0
Bump iphone_backup_decrypt to 0.10.0
2026-09-18 10:36:59 +02:00
Janik Besendorf 935747519f Bump iphone_backup_decrypt to 0.10.0 2026-09-18 10:22:21 +02:00
besendorf 178ac52aab Merge pull request #927 from mvt-project/auto/add-new-ios-releases
[auto] Update iOS releases and versions
2026-09-15 03:37:45 +02:00
DonnchaC f20fd14075 Add new iOS versions and build numbers 2026-09-15 00:37:07 +00:00
Janik Besendorf a9d787c446 Remove unused libusb1 and refresh dependency lockfile 2026-09-14 00:30:48 +02:00
besendorf 518becb825 Remove adb-shell dependency from pyproject.toml
not used anymore
2026-09-13 20:06:15 +02:00
Janik Besendorf d0cf303aa2 Replace libimobiledevice with pymobiledevice3 in Docker images 2026-09-11 11:21:57 +02:00
Janik Besendorf 86b217a926 Recommend pymobiledevice3 for iOS acquisition 2026-09-11 11:14:53 +02:00
besendorf a3d289c709 Merge pull request #924 from mvt-project/auto/add-new-ios-releases
[auto] Update iOS releases and versions
2026-09-08 19:52:51 +01:00
DonnchaC b4b0a06470 Add new iOS versions and build numbers 2026-09-08 18:16:19 +00:00
Donncha Ó Cearbhaill eceafdc785 Keep the test suite out of the user's MVT folders (#923)
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.
2026-09-08 01:04:43 +01:00
Donncha Ó Cearbhaill ffae240355 Skip AppleDouble sidecars when listing a sysdiagnose (#922)
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.
2026-09-08 01:03:05 +01:00
3623e430f4 Defer indicator loading until first use (#919)
* 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>
2026-09-08 01:01:02 +01:00
Donncha Ó Cearbhaill 60e652bafc Read bugreport file timestamps in the device's timezone (#921)
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.
2026-09-08 00:08:30 +01:00
Donncha Ó Cearbhaill 92f780df5a Report an unreadable sysdiagnose archive instead of aborting silently (#920)
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.
2026-09-07 23:58:43 +01:00
Donncha Ó Cearbhaill 2eb40b85cf Add a SysdiagnoseInfo module to check-sysdiagnose (#917)
* 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
2026-09-05 23:45:14 +02:00
Donncha Ó Cearbhaill b9055e365c Merge pull request #916 from mvt-project/fix/settings-env-persist
Stop persisting environment variables to config.yaml
2026-09-05 16:44:40 +02:00
Donncha Ó Cearbhaill fc217e9608 Merge branch 'main' into fix/settings-env-persist 2026-09-05 16:43:43 +02:00
Donncha Ó Cearbhaill d7148c03b4 Stop persisting environment variables to config.yaml
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
2026-09-05 16:41:50 +02:00
Donncha Ó Cearbhaill 0ddcab904d Merge pull request #914 from DonnchaC/feature/bugreport-settings-records
Parse dumpsys settings as per-record results
2026-09-05 16:32:02 +02:00
Donncha Ó Cearbhaill 858496e60b End a settings record at a blank line
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.
2026-09-05 16:17:43 +02:00
Donncha Ó Cearbhaill e5fced2a08 Peel tag and restore flags off settings rows
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.
2026-09-05 15:56:55 +02:00
Donncha Ó Cearbhaill fe505c63a6 Merge branch 'main' into feature/bugreport-settings-records 2026-09-05 00:59:11 +02:00
Donncha Ó Cearbhaill 25a571c462 Merge pull request #913 from mvt-project/fix/coverage-logging
Fix CI coverage reports and fix matrix testing each Python version
2026-09-04 20:34:18 +02:00
Donncha Ó Cearbhaill 03012d2c2d Fix CI coverage comment and test each matrix Python version
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.
2026-09-04 20:25:07 +02:00
Donncha Ó Cearbhaill 0e231eefaf Parse dumpsys settings as per-record results
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.
2026-09-04 17:44:51 +02:00
besendorf a463e8509c Merge pull request #774 from mvt-project/backup_extraction
Replace iOSbackup with iphone_backup_decrypt
2026-09-02 19:29:12 +02:00
Janik Besendorf ca063a2ddc Merge current main into backup decryption 2026-09-02 19:27:52 +02:00
Donncha Ó Cearbhaill ece44a7e7f Merge pull request #910 from mvt-project/feature/monthly-releases
Release monthly from a scheduled workflow
2026-09-01 19:41:01 +02:00
Donncha Ó Cearbhaill 27cfe7f1e7 Release weekly instead of monthly 2026-09-01 19:31:23 +02:00
Donncha Ó Cearbhaill 531d63ab07 Release monthly from a scheduled workflow
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.
2026-09-01 15:49:25 +02:00
Donncha Ó Cearbhaill b18afb4a48 Merge pull request #871 from mvt-project/fix/bugreport-parser-coverage
Fix Android bugreport parser coverage
2026-08-30 14:50:39 +02:00
Donncha Ó Cearbhaill 32c88822a0 Merge branch 'main' into fix/bugreport-parser-coverage 2026-08-30 14:11:54 +02:00
Donncha Ó Cearbhaill 23565af4c1 Merge pull request #907 from mvt-project/feature/fast-cli-startup
Keep the start of mvt-ios and mvt-android cheap for shell completion
2026-08-28 01:03:56 +02:00
Donncha Ó Cearbhaill 7d41d0646e Keep the start of mvt-ios and mvt-android cheap for shell completion
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.
2026-08-28 00:42:08 +02:00
Donncha Ó Cearbhaill 24c65859ee Merge branch 'main' into fix/bugreport-parser-coverage 2026-08-27 21:06:30 +02:00
Donncha Ó Cearbhaill a11319dcc5 Merge pull request #905 from mvt-project/feature/cli-plugin-enhancements 2026-08-27 16:14:28 +02:00
Donncha Ó Cearbhaill c9a57f5d10 Do not pin the wording of the mvt help text in the test 2026-08-27 15:34:53 +02:00
Donncha Ó Cearbhaill ee24121a73 Fix redundent docs and CLI help text 2026-08-27 15:28:52 +02:00
Donncha Ó Cearbhaill b06b5c36ac Merge branch 'main' into feature/cli-plugin-enhancements 2026-08-27 15:18:23 +02:00
Donncha Ó Cearbhaill 00d892d354 Run check-iocs on every module which implements check_indicators() (#903)
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.
2026-08-27 14:47:17 +02:00
Donncha Ó Cearbhaill 47ac8a5a85 Allow custom modules to replace built-in modules (#902)
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.
2026-08-27 14:47:17 +02:00
Donncha Ó Cearbhaill 0b5b3f2d7c Add the mvt.plugin import surface (#901)
* 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.
2026-08-27 14:47:16 +02:00
Donncha Ó Cearbhaill 85adb02eb9 Share the check-iocs module lists between the CLI and the code (#900)
* 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.
2026-08-27 14:47:16 +02:00
Donncha Ó Cearbhaill 91da901741 Find the console log handler by type when changing verbosity (#899)
* 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.
2026-08-27 14:47:16 +02:00
Donncha Ó Cearbhaill dcfd500112 Add plugin update checking and a plugins command (#898)
* 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.
2026-08-27 14:47:15 +02:00
Donncha Ó Cearbhaill a78894aaa5 Add a platform-neutral mvt command (#897)
* Add a platform-neutral mvt command

Several MVT commands have nothing to do with the acquisition of one
platform, yet they were reachable only through mvt-ios and mvt-android.
Asking which version is installed or downloading the public indicators
meant picking one of the two platform commands arbitrarily, and each of
those tasks had to be written, documented and maintained twice.

Add a third console script, mvt, hosting the commands which belong to no
platform: version and download-iocs for now, with completion following
in a later commit.

Commands installed in the new mvt.cli_plugins entry-point group are
registered on mvt, and on mvt only, so that a command package chooses
the CLI each of its commands is added to: mvt.ios.cli_plugins for
mvt-ios, mvt.android.cli_plugins for mvt-android and mvt.cli_plugins for
mvt. A command wanted on both platform CLIs is registered in both
platform groups; no group adds a command to every CLI. The
MVT_CUSTOM_COMMANDS variable loads command files and folders into mvt
the way the platform variables already do for mvt-ios and mvt-android.

Run on its own, mvt prints the banner and its help instead of a usage
error. The help text reminds that the forensic analysis of an
acquisition runs through mvt-ios and mvt-android, so that the command
which knows nothing about acquisitions says where they are analysed.

version and download-iocs stay on mvt-ios and mvt-android for now, so
that no documented invocation stops working. They are to be dropped from
the platform CLIs in a later release, once mvt has been available long
enough for the change to be announced.

Unlike mvt-ios and mvt-android, which point at their subpackages, the
console script points at mvt.cli:main and the mvt package re-exports
nothing of it. Importing mvt has to stay cheap and free of side effects:
it is the package plugins import from, and pulling in Click, the CLI and
everything the commands import merely because something imported mvt
would work against that.

While here, give the version command of both platform CLIs the context
settings every other command already has, so that "mvt-ios version -h"
prints its help instead of failing on an unknown option.

* Reduce indent for MVT CLI header

* Move shell completion to the mvt command and generate one script for every MVT command

Setting up shell completion had nothing to do with the acquisition of one
platform, yet it was a command of mvt-ios and mvt-android, each
generating the script of the program it ran under only. Completion now
leaves the platform CLIs for mvt, which emits one script covering mvt,
mvt-ios and mvt-android, installed as a single file loaded when the shell
starts. Click names the completion function of each program after the
program, so the three scripts concatenate without colliding, and fish
takes the file in conf.d rather than one named after a single command.

With one script there is nothing left for the platform commands to
generate, so their completion command goes, and with it the banner
suppression which was keyed on the command name: mvt-ios and mvt-android
now print the banner for every command they have.

The long help line says which commands the completion covers, so a
short_help keeps "mvt --help" from truncating it.

* Point the README and the command docs at plugin packages

Loading a command by path is for local development; a package is how a
command is distributed. The README's summary of what extends MVT now
names plugin packages and nothing else, and the section on loading a
command file says what it is for: keeping a command being written
loadable without reinstalling its package after every change.
2026-08-27 14:47:15 +02:00
Donncha Ó Cearbhaill 24645cb718 Register installed CLI plugins at program start (#896)
register_cli_plugins() ran while mvt.ios and mvt.android were being
imported, so importing any part of MVT executed the entry points of every
installed command package. That made plugin loading depend on import
order: a plugin importing from MVT while MVT was still initializing got an
ImportError and was quietly demoted to a broken command, and the same
plugin worked when MVT happened to be imported first.

Move the call into a main() function in each CLI module and point the
console scripts at it, so registration happens once when the program
starts and importing MVT no longer runs third-party code. For packagers:
mvt.ios:cli and mvt.android:cli stay importable, but a wrapper invoking
cli() directly no longer registers the installed plugin commands and
should call main() instead.
2026-08-27 14:47:14 +02:00
Donncha Ó Cearbhaill 104ffb167f Skip modules with unavailable dependencies instead of aborting the run (#895)
A module declaring a dependency its command does not provide made
_ordered_modules() give up on the whole run, so a single wrong declaration
in a module scoped to several commands turned a forensic analysis into
zero executed modules with one warning to explain it.

Drop only the modules that cannot run: the one with the unavailable
dependency, and anything depending on it. Each gets its own warning naming
the module missing a dependency and the dependency it is missing, and the
remaining modules run in the same stable topological order as before. A
cycle in the dependency graph is still a programming error and still stops
the run.
2026-08-27 14:47:14 +02:00
Donncha Ó CearbhaillandDonncha Ó Cearbhaill 097766a63b Add namespaced plugin configuration support (#894)
* Add namespaced plugin configuration support

Plugin packages need somewhere to keep their own settings, but MVT
rewrites its config.yaml with only the fields it knows about, so any
foreign section is dropped. Add MVTPluginSettings, a pydantic-settings
base class that gives each plugin its own file under the MVT config
folder and its own MVT_PLUGIN_<NAME>_ environment variable namespace.

Settings resolve from constructor arguments, then the environment, then
the plugin file, then the field defaults. Saving skips the values the
environment currently supplies, so credentials passed as environment
variables are not copied to disk, and writes through a private temporary
file so a settings file is never partially written or briefly readable
by other users.

* Add per-plugin data folders

Plugins had no sanctioned place to keep the data they persist, so the
plugin configuration documentation suggested a CACHE_FOLDER setting
defaulting to ~/.cache/example-plugin. That is a Linux convention which
is wrong on macOS, nothing expands or creates it, and it turns a path
into a setting a user can be asked to configure.

Add plugin_data_folder(), which returns the folder a plugin should use
for caches, downloaded artifacts, synchronization state or anything
else it writes to disk, and creates it if it is missing. The plugin
name is validated before anything is created, so a name holding a path
separator raises an error and leaves no folder behind, and calling the
function again returns the same folder with its contents untouched.

The folder sits under plugin-data rather than under the plugins folder
which holds the settings files. On macOS the configuration folder and
the data folder are the same directory, so reusing the plugins name
would leave each plugin's data folder in among the settings files.

Both the plugin-data folder and the folder of each plugin are created
with 0700 permissions. MVT is a forensic tool, and what a plugin keeps
there, such as API responses or sample metadata, is private by default.
The path is resolved on every call, as the configuration folder already
is, so it follows the current environment rather than whatever it was
when MVT was imported.

The documentation now points plugins at the helper, and the example
settings class carries a plain integer setting in place of its cache
folder.

* Derive the data folder of a plugin from its settings class

A plugin with a settings class already names itself in `plugin_name`;
passing the name again to plugin_data_folder() repeats it and can drift.
Add a `data_folder()` class method on MVTPluginSettings which returns
plugin_data_folder() for the class's validated plugin name (works on the
class and on an instance); plugin_data_folder(name) stays as the function
underneath for plugins without a settings class.

---------

Co-authored-by: Donncha Ó Cearbhaill <google@donncha.is>
2026-08-27 14:47:13 +02:00
Donncha Ó Cearbhaill 65b8114469 Move development documentation under docs/development (#893)
The Development section holds more than one page and will grow further,
so give it its own directory. development.md becomes development/index.md,
which keeps its published URL, and the custom CLI command page moves
alongside it.

Update the nav, the cross-link between the two pages, and the README
link to the custom command documentation, which is now published at
/development/custom_commands/.
2026-08-27 14:47:13 +02:00
va@resident 874f75bb4c Do not discard the whole tombstone on a "Caused by:" line (#892)
Keys are matched as bare prefixes, so `Caused by:` inside an abort message
reaches the `Cause` key, fails the key comparison and raises — and the
per-line loop turns that into an error that drops the entire text
tombstone, stack trace included.

A key mismatch means "this line is not that key", not "this file is
broken": decline the line and let the remaining keys have their turn. A
line with no colon is declined the same way instead of raising on the
unpack. The same trap has a second form in the field, HiSilicon/Huawei
tombstones printing `code around pc:` against the `code` key.
2026-08-25 21:35:33 +02:00
va@resident 3c8a581fd0 Do not end the whole androidqf run on an encrypted backup.ab (#891)
from_ab() raises InvalidAndroidBackup instead of exiting when it runs as a
sub-command, which check-androidqf catches to skip the backup modules. The
two password branches still called sys.exit(1) unconditionally, and since
run_backup_cmd() runs inside finish(), that ended the parent run before the
intrusion-logs command and before the timeline, alerts, urls, info and run
manifest were stored — leaving an output directory that looks complete but
has no alerts.json.

Also drop "as backup.ab is malformed" from the skip warning: it covers a
missing or wrong password too.
2026-08-25 20:18:36 +02:00
Janik Besendorf 65df483258 Parse multiline Android properties 2026-08-25 19:19:47 +02:00
Janik Besendorf fd27fe3378 Fix module registry type annotations 2026-08-22 14:21:51 +02:00
Janik Besendorf 7ef26779bf Register added bugreport parsers 2026-08-22 14:21:51 +02:00
Janik Besendorf 3a43f6fcc7 Merge plaintext and protobuf tombstones 2026-08-22 14:21:51 +02:00
Janik Besendorf 45fdf85790 Add bugreport mountinfo parser 2026-08-22 14:21:51 +02:00
Janik Besendorf 627ab32d42 Add bugreport settings parser 2026-08-22 14:21:51 +02:00
Janik Besendorf 8b85972cc3 Add bugreport process table parser 2026-08-22 14:21:51 +02:00
Janik Besendorf f747751b09 Add timezone provenance to file timestamps 2026-08-22 14:21:51 +02:00
Janik Besendorf 832e46604d Parse all platform compatibility overrides 2026-08-22 14:21:51 +02:00
Janik Besendorf a3c8b56102 Parse typed multi-user package details 2026-08-22 14:21:51 +02:00
Janik Besendorf e9bf197ec6 Parse all database operation details 2026-08-22 14:20:59 +02:00
Janik Besendorf 288b313649 Parse complete battery history events 2026-08-22 14:20:59 +02:00
Janik Besendorf 87a27f6e37 Normalize battery daily update records 2026-08-22 14:20:59 +02:00
Janik Besendorf 1bf2f9b35a Retain AppOps UID and event details 2026-08-22 14:20:59 +02:00
Janik Besendorf b904414bb0 Parse ADB binary XML keys 2026-08-22 14:20:59 +02:00
Janik Besendorf 008844b440 Parse accessibility service states per user 2026-08-22 14:20:59 +02:00
Janik Besendorf ab879bb23b Parse all package resolver categories 2026-08-22 14:20:59 +02:00
Janik Besendorf dc0650bf76 Fix bugreport command section extraction 2026-08-22 14:20:59 +02:00
Donncha Ó Cearbhaill dac4acb180 Load installed module packages via entry points (#883)
* Load installed module packages via entry points

Python packages can already register custom CLI commands which load
automatically, but custom modules still require --load-module or the
MVT_CUSTOM_MODULES environment variable on every invocation.

Add an mvt.modules entry-point group so installed packages can register
forensic modules which load automatically into every module-running
check-* command. An entry point resolves to an iterable of MVTModule
subclasses, or a callable returning one. Broken entry points are
skipped with a warning so a faulty package cannot break MVT.

* Record the source of loaded modules for auditability

Now that installed module packages load automatically, record where every
module came from:

- --list-modules groups the available modules by source, one line per
  source with the modules comma-separated: MVT itself with its version,
  each installed package with its version and VCS commit when recorded
  (PEP 610 direct_url.json), and each --load-module/MVT_CUSTOM_MODULES
  file with its SHA-256 hash.
- Commands log one line per module source with its version or hash and
  the modules loaded from it, so command.log records exactly which
  modules ran and where they came from.
- Make init_logging() idempotent: a loaded module package importing an
  MVT CLI module would previously add a second console handler and
  duplicate every console log line.

* Route loaded module logging under the mvt.ext namespace

Modules loaded from installed packages or file paths live outside the
mvt logger hierarchy, so their log records never reach MVT's console
and file handlers and instead fall through to logging.lastResort:
alerts print as bare unformatted lines and INFO messages are dropped
entirely.

Add get_module_logger() and use it everywhere module loggers are
created. Built-in mvt.* modules keep their existing logger names, and
everything external is parented under a dedicated mvt.ext namespace so
records reach the handlers and external names can never collide with
MVT's internal logger tree. File-path modules are named after their
file (mvt.ext.<stem>) instead of the mangled internal import name.

Document a naming convention for community module packages:
distribute as mvt-plugin-<name> with import package mvt_plugin_<name>,
including the publishing organization in the name. The prefix is
advisory (loading is by entry point, and it is no mark of
authenticity), but conforming packages get a cleaner logger namespace:
the mvt_plugin_ prefix is stripped, so mvt_plugin_amnesty_custom logs
as mvt.ext.amnesty_custom.
2026-08-19 23:15:48 +02:00
besendorfandDonncha Ó Cearbhaill dd8bd2cb01 Group custom docs under development (#878)
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-08-19 16:46:31 +02:00
Donncha Ó Cearbhaill 30c11f68c7 Add WhatsApp contacts module and fix InteractionC contact resolution (#882)
* Add WhatsappContacts module to extract WhatsApp disappearing messages state

WhatsApp on iOS stores the disappearing messages timer for 1:1 chats on
the contact records in ContactsV2.sqlite, not in ChatStorage.sqlite. Add
a new WhatsappContacts module which extracts contact records from this
database, including phone numbers, WhatsApp and LID identifiers, and the
per-contact disappearing messages duration, and emits a timeline event
when a disappearing messages timer was set.

The database is often missing from incremental backups, so the module
logs a clear warning and returns no results instead of failing. Columns
are selected based on the actual table schema to tolerate changes across
WhatsApp versions, and if the disappearing messages column is absent the
state is reported as unknown rather than off.

The test fixture is a synthetic ContactsV2.sqlite with fictional
contacts, stored under the backup file ID derived from the WhatsApp
shared app group domain.

* Fix InteractionC contact resolution and resolve WhatsApp LIDs to contacts

The two primary InteractionC queries contained a SQL syntax error in
their direction CASE expression (a double column alias), so they always
failed and the module silently fell back to a reduced query without the
recipient join. As a result outgoing messages were serialized with no
counterpart at all ("from None (None)"). Fix the syntax so recipient
names and identifiers are extracted again, and normalize the raw 0/1
direction values from the fallback queries to INCOMING/OUTGOING.

WhatsApp identifies chat peers in interactionC.db by LID and stores the
peer LID in the domain identifier, which InteractionC could not map to a
person. Declare a dependency on the WhatsappContacts module and resolve
sender, recipient and domain identifiers (LID, JID or phone number)
against the WhatsApp contacts database, adding resolved phone number and
name fields to WhatsApp records.

Rewrite the timeline serialization to use the resolved values, fall back
to the chat peer from the domain identifier when no recipient was
recorded, label the local user instead of printing None, and include the
message direction and group name.

* Add timeline events for all WhatsApp contact timestamps

Extract ZABOUTEXPIRATIONTIMESTAMP and emit a timeline event for each
timestamp stored on a WhatsApp contact record: disappearing messages
timer changes, "about" text changes and scheduled expiry, and contact
record updates. ContactsV2.sqlite stores no other date attributes in
any released schema version.

* Add first and last interaction timeline events for WhatsApp chats

Extract one record per ZWACHATSESSION with the first and last stored
message dates, the session's own last-message date, the group creation
date and message counts. Each chat produces chat_first_message and
chat_last_message timeline events, and groups a group_created event.
The session last-message date is preferred over the newest stored
message because it survives message deletion.

* Resolve WhatsApp LID chat identifiers via the LID pair table

Recent WhatsApp versions key 1:1 chat sessions by an opaque LID rather
than the contact's phone number. Extract the ZWAPHONENUMBERLIDPAIR
table from the dedicated LID.sqlite database (or from ChatStorage
itself in versions that store it there) and use it to populate
partner_resolved_phone_number on chat session records and in timeline
events, without requiring the often-missing ContactsV2.sqlite. Each
pair is also extracted as a record and produces a lid_pair_recorded
timeline event marking when the association was learned.

* Reduce duplicate InteractionC timeline events

The interaction record's creation date normally trails its start date
by milliseconds, so serializing both nearly doubled the timeline with
duplicate entries. Only emit the creation date when it diverges from
the start date by more than an hour, with explicit wording, since a
record created long after its event indicates backfill by sync,
restore or tampering.

Per-contact aggregate dates from ZCONTACTS repeat on every interaction
row of the same contact and carried that row's message text. Serialize
them with contact-centric data strings instead, so timeline
de-duplication collapses them into one first/last-seen event per
contact.
2026-08-19 14:07:27 +02:00
besendorfandDonncha Ó Cearbhaill a92ec7f963 Build Docker images for ARM64 (#880)
Co-authored-by: Donncha Ó Cearbhaill <donncha.ocearbhaill@amnesty.org>
2026-08-19 11:46:48 +02:00
Donncha Ó Cearbhaill 10a5bccbe0 Fix usability nits in the Docker images (#881)
* Fix error in ADB key generation commmand

* Add useful forensic analysis tools
2026-08-19 11:43:40 +02:00
github-actions[bot]andDonnchaC 3b21c9347c Add new iOS versions and build numbers (#879)
Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com>
2026-08-18 07:04:35 +02:00
besendorf 0fdc2c34b0 Document iOS device access from Docker (#875)
* Document iOS device access from Docker

* Simplify docker.md instructions

Removed unnecessary note about mounting the entire '/var/run' directory.
2026-08-17 13:42:22 +02:00
Janik Besendorf 545ac19158 Restore concurrent backup decryption 2026-08-17 13:24:30 +02:00
Codex 2de8312349 Merge main and fix encrypted backup extraction 2026-08-16 22:39:08 +05:30
besendorf 0ee25edf0a Fix dumpsys package system flag parsing (#874) 2026-08-14 15:58:05 +02:00
besendorf fa24b5465b Scope package fields to the primary user (#872) 2026-08-14 14:33:28 +02:00
besendorf 683b8ba133 Parse package installer from bugreports (#868) 2026-08-14 09:40:34 +02:00
besendorf 7b64463727 Update GitHub Actions (#866) 2026-08-12 15:42:15 +02:00
github-actions[bot]andDonnchaC 47fd771b2a Add new iOS versions and build numbers (#865)
Co-authored-by: DonnchaC <3081375+DonnchaC@users.noreply.github.com>
2026-08-11 10:07:52 +02:00
besendorf 8123db26d9 Fix iOS update workflow and refresh dependencies (#864) 2026-08-10 22:06:15 +02:00
besendorf d92a60c9be Preserve tombstone crash causes (#863) 2026-08-10 20:59:56 +02:00
besendorf 0b48d9fe1d Speed up compressed sysdiagnose analysis (#861)
* Speed up compressed sysdiagnose analysis

* ci: retrigger Ruff check
2026-08-07 09:07:33 +02:00
besendorf 067f053627 Add extensible CLI commands (#853)
* Add extensible CLI commands

* Handle plugin SystemExit failures
2026-08-05 23:30:28 +02:00
besendorf 93b7fb5232 Store message URLs in analysis output (#856) 2026-08-05 23:21:14 +02:00
besendorf 8617e0bf54 Alert on AndroidQF trusted ADB keys (#860) 2026-08-05 17:36:49 +02:00
Rory Flynn 53fb12aee8 Correct version.py (#855) 2026-07-29 16:08:42 +02:00
Rory Flynn 8140e350f7 Update version.py (#854) 2026-07-29 13:57:34 +02:00
besendorf f483223e23 Add iOS sysdiagnose checking (#832)
* Add iOS sysdiagnose checking

* Clarify documentation navigation
2026-07-28 18:59:58 +02:00
besendorf 2dfe3cbcb1 Fix module audit findings (#850)
* Fix module audit findings

* Always parse paired tombstones
2026-07-28 18:58:46 +02:00
besendorf 3eff0c550d Handle mis-indented dumpsys receiver actions (#852) 2026-07-28 18:34:13 +02:00
besendorf 797411e1e5 Fix text tombstone crashing thread parsing (#848) 2026-07-27 17:58:34 +02:00
84df51c518 Scan Safari profile databases for history and browser state (#846)
* fix(ios): scan Safari profile databases for history and browser state

Safari profiles (iOS 17 and later) keep their own databases under
Library/Safari/Profiles/<UUID>/, but SafariHistory and SafariBrowserState
only ever looked at the default profile's Library/Safari/History.db and
Library/Safari/BrowserState.db.

On a device where browsing happens inside a profile, MVT silently skipped
that history and still reported no detections, so an indicator only ever
visited within a profile went unnoticed.

Both modules now also match Library/Safari/Profiles/*/ in backups and in
filesystem dumps. No helper changes were needed: the Manifest.db lookup
already translates "*" into a SQL LIKE wildcard, and the filesystem lookup
already globs.

Found while examining an encrypted iOS 26.5.2 backup that contained 14
per-profile History.db files under
AppDomain-com.apple.mobilesafari::Library/Safari/Profiles/<UUID>/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(ios): scope Safari redirects to history database

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-27 15:06:44 +02:00
besendorf a6c3a805d8 Parallelize URL indicator checks (#844) 2026-07-27 15:03:02 +02:00
besendorf 123c9081ed Skip resolving Google Maps short URLs (#843) 2026-07-19 17:13:57 +02:00
FelixandJanik Besendorf 5ed8b3c1a5 fix: terminate dumpsys adb multiline values at structural lines (#842)
* fix: terminate dumpsys adb multiline values at structural lines

* fix dumpsys ADB multiline boundaries

---------

Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-17 18:25:47 +02:00
Janik Besendorf 2fe7f4e261 Make parallel log grouping test order-independent 2026-07-16 22:38:55 +02:00
Janik Besendorf 361bc72a4e Cache Android bugreport dumpstate sections 2026-07-15 23:51:53 +02:00
Janik Besendorf 9d71661f55 Run extraction modules in parallel 2026-07-15 09:40:18 +02:00
besendorf afcfda4720 Deduplicate AndroidQF SMS analysis (#837) 2026-07-15 09:09:04 +02:00
besendorf 911115b0c8 Match receiver indicators by package ID (#836) 2026-07-15 08:32:13 +02:00
besendorf c806fd8d61 Support rotated iOS shutdown logs (#834) 2026-07-14 23:27:44 +02:00
besendorf 516ba06cf7 Suppress benign PinStorage key generation warning (#835) 2026-07-14 23:20:53 +02:00
besendorf 1532578b39 Validate iOS backup path before checks (#825)
* Validate iOS backup path before checks

* Fix iOS backup path mypy typing

* Fix custom module test backup fixture
2026-07-14 21:58:46 +02:00
Nimrod B.andJanik Besendorf f5b0a3cd91 WIP: Addition of a timer to virustotal checks (#593)
* Add delay option to virustotal checks (#408)

* Fix missing delay argument

* Fix VirusTotal delay handling

* Fix mypy type for VirusTotal package map

---------

Co-authored-by: Janik Besendorf <janik@besendorf.org>
2026-07-01 12:36:56 +02:00
besendorf a18e632ec8 Add shell completion command (#817) 2026-07-01 12:35:21 +02:00
besendorf 2689176c0e Add custom module loading (#816) 2026-07-01 12:33:38 +02:00
besendorf 8b195d2696 Merge branch 'main' into backup_extraction 2026-04-27 15:03:42 +02:00
Janik Besendorf 39a26d0f0b Replace iOSbackup with iphone_backup_decrypt
Replace the unmaintained iOSbackup dependency with iphone_backup_decrypt
(MIT licensed, actively maintained). This fixes file corruption caused by
iOSbackup truncating files to inaccurate sizes from backup metadata.

The extract-key command and --key-file option are preserved via an
MVTEncryptedBackup subclass that patches the keybag unlock to
capture/reuse the derived PBKDF2 key.

Closes #669
2026-04-12 10:51:56 +02:00
216 changed files with 17910 additions and 2904 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ jobs:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v0.5.0
- uses: actions/add-to-project@v2
with:
# You can target a project in a different organization
# to the issue
+3 -3
View File
@@ -11,13 +11,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Install Dependencies
+11 -6
View File
@@ -36,10 +36,12 @@ jobs:
tag-suffix: "-android"
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # the package version comes from the git tags
# Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here.
- name: Log in to the Container registry
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -47,7 +49,7 @@ jobs:
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels.
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
flavor: |
@@ -59,21 +61,24 @@ jobs:
type=raw,enable=${{ github.ref_type == 'tag' }},value=${{ github.ref_name }},suffix=${{ matrix.platform.tag-suffix }}
type=sha,suffix=${{ matrix.platform.tag-suffix }}
type=sha,format=long,suffix=${{ matrix.platform.tag-suffix }}
# Register emulators so Buildx can build the ARM64 images on the AMD64 runner.
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
# This step sets up some additional capabilities to generate the provenance and sbom attestations
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages.
# It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository.
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
- name: Build and push Docker image
id: push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
file: ${{ matrix.platform.dockerfile }}
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: mode=max
sbom: true
+45
View File
@@ -0,0 +1,45 @@
name: Release
on:
schedule:
- cron: "0 9 * * 1" # Mondays, 09:00 UTC
workflow_dispatch:
push:
tags: ["v*"]
jobs:
tag:
if: github.ref_type != 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
actions: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- run: |
last=$(git describe --tags --abbrev=0 --match 'v*')
if [ "$(git rev-list "$last..HEAD" --count)" = 0 ]; then
echo "Nothing merged since $last"; exit 0
fi
tag=v$(date -u +%Y.%-m.%-d)
gh release create "$tag" --target "$GITHUB_SHA" --generate-notes
# Events made with GITHUB_TOKEN don't start other workflows; run them by hand.
gh workflow run release.yml --ref "$tag"
gh workflow run publish-release-docker.yml --ref "$tag"
publish:
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
environment: pypi # only tag refs may deploy; the PyPI trusted publisher requires it
permissions:
id-token: write # PyPI trusted publishing
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: astral-sh/setup-uv@v10.0.0
- run: uv build && uv publish
+3 -3
View File
@@ -12,13 +12,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Setup Python
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: "3.10"
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Install Dependencies
+22 -7
View File
@@ -5,6 +5,10 @@ on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: write # coverage comment
jobs:
build:
name: Run Python Tests
@@ -13,29 +17,40 @@ jobs:
fail-fast: false
matrix:
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
env:
# Takes precedence over .python-version, which otherwise makes `uv run`
# rebuild the venv with 3.10 and test every matrix entry on 3.10.
UV_PYTHON: ${{ matrix.python-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@v10.0.0
with:
enable-cache: true
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v6
uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- name: Install Python dependencies
run: |
uv sync --locked --group dev --python ${{ matrix.python-version }}
uv sync --locked --group dev
- name: Test with pytest
run: |
set -o pipefail
make test-ci | tee pytest-coverage.txt
- name: Coverage job summary
run: uv run coverage report --format=markdown --show-missing --skip-covered >> "$GITHUB_STEP_SUMMARY"
- name: Pytest coverage comment
continue-on-error: true # Workflows running on a fork can't post comments
uses: MishaKav/pytest-coverage-comment@main
if: github.event_name == 'pull_request'
# One comment per PR, not one per matrix entry. PRs from forks get a
# read-only token and can't post; the job summary above still works.
if: github.event_name == 'pull_request' && matrix.python-version == '3.13'
continue-on-error: true
uses: MishaKav/pytest-coverage-comment@v1.12.2
with:
pytest-coverage-path: ./pytest-coverage.txt
junitxml-path: ./pytest.xml
# The full table with per-line links exceeds GitHub's 65536-char comment limit.
report-only-changed-files: true
remove-links-to-lines: true
+5 -3
View File
@@ -11,10 +11,12 @@ jobs:
update-ios-version:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
- name: Install script dependencies
run: python -m pip install "packaging==26.3"
- name: Run script to fetch latest iOS releases from Apple RSS feed.
run: python3 .github/workflows/scripts/update-ios-releases.py
run: python .github/workflows/scripts/update-ios-releases.py
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
+26 -117
View File
@@ -1,107 +1,3 @@
# Base image for building libraries
# ---------------------------------
FROM ubuntu:22.04 AS build-base
ARG DEBIAN_FRONTEND=noninteractive
# Install build tools and dependencies
RUN apt-get update \
&& apt-get install -y \
build-essential \
git \
autoconf \
automake \
libtool-bin \
pkg-config \
libcurl4-openssl-dev \
libusb-1.0-0-dev \
libssl-dev \
udev \
&& rm -rf /var/lib/apt/lists/*
# libplist
# --------
FROM build-base AS build-libplist
# Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libplist
# libimobiledevice-glue
# ---------------------
FROM build-base AS build-libimobiledevice-glue
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice-glue
# libtatsu
# --------
FROM build-base AS build-libtatsu
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libtatsu
# libusbmuxd
# ----------
FROM build-base AS build-libusbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
# Build
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libusbmuxd
# libimobiledevice
# ----------------
FROM build-base AS build-libimobiledevice
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice
# usbmuxd
# -------
FROM build-base AS build-usbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
# Build
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
# Create main image
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
@@ -112,30 +8,43 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:22.04
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
# Install runtime dependencies
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y \
adb \
binutils \
default-jre-headless \
libcurl4 \
file \
git \
jq \
less \
libimage-exiftool-perl \
libssl3 \
libusb-1.0-0 \
moreutils \
p7zip-full \
python3 \
sqlite3
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
COPY --from=build-usbmuxd /build /
ripgrep \
sqlite3 \
tree \
unzip \
xxd \
&& rm -rf /var/lib/apt/lists/*
# Install mvt using the locally checked out source
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
ARG PYMOBILEDEVICE3_VERSION=11.12.4
COPY . mvt/
RUN uv pip install --system --break-system-packages --no-cache ./mvt \
&& rm -rf mvt
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
&& rm -rf mvt /var/lib/apt/lists/* \
&& pymobiledevice3 --help > /dev/null \
&& pymobiledevice3 backup2 --help > /dev/null \
&& mvt-ios --help > /dev/null
# Installing ABE
ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2e11632 \
@@ -144,7 +53,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
RUN mkdir /root/.android
# Setup investigations environment
+1 -1
View File
@@ -31,7 +31,7 @@ ADD --checksum=sha256:a20e07f8b2ea47620aff0267f230c3f1f495f097081fd709eec51cf2a2
RUN echo 'alias abe="java -jar /opt/abe/abe.jar"' >> ~/.bashrc
# Generate adb key folder
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey 2&>1 > /dev/null; fi' >> ~/.bashrc
RUN echo 'if [ ! -f /root/.android/adbkey ]; then adb keygen /root/.android/adbkey > /dev/null 2>&1; fi' >> ~/.bashrc
RUN mkdir /root/.android
ENTRYPOINT [ "/usr/local/bin/mvt-android" ]
+22 -122
View File
@@ -1,110 +1,6 @@
# Base image for building libraries
# ---------------------------------
FROM ubuntu:22.04 AS build-base
ARG DEBIAN_FRONTEND=noninteractive
# Install build tools and dependencies
RUN apt-get update \
&& apt-get install -y \
build-essential \
git \
autoconf \
automake \
libtool-bin \
pkg-config \
libcurl4-openssl-dev \
libusb-1.0-0-dev \
libssl-dev \
udev \
&& rm -rf /var/lib/apt/lists/*
# libplist
# --------
FROM build-base AS build-libplist
# Build
RUN git clone https://github.com/libimobiledevice/libplist && cd libplist \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libplist
# libimobiledevice-glue
# ---------------------
FROM build-base AS build-libimobiledevice-glue
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice-glue && cd libimobiledevice-glue \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice-glue
# libtatsu
# --------
FROM build-base AS build-libtatsu
# Install dependencies
COPY --from=build-libplist /build /
# Build
RUN git clone https://github.com/libimobiledevice/libtatsu && cd libtatsu \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libtatsu
# libusbmuxd
# ----------
FROM build-base AS build-libusbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
# Build
RUN git clone https://github.com/libimobiledevice/libusbmuxd && cd libusbmuxd \
&& ./autogen.sh && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libusbmuxd
# libimobiledevice
# ----------------
FROM build-base AS build-libimobiledevice
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
# Build
RUN git clone https://github.com/libimobiledevice/libimobiledevice && cd libimobiledevice \
&& ./autogen.sh --enable-debug && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf libimobiledevice
# usbmuxd
# -------
FROM build-base AS build-usbmuxd
# Install dependencies
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
# Build
RUN git clone https://github.com/libimobiledevice/usbmuxd && cd usbmuxd \
&& ./autogen.sh --sysconfdir=/etc --localstatedir=/var --runstatedir=/run && make -j "$(nproc)" && make install DESTDIR=/build \
&& cd .. && rm -rf usbmuxd && mv /build/lib /build/usr/lib
# Main image
# ----------
FROM python:3.10.14-alpine3.20 AS main
FROM ubuntu:24.04 AS main
COPY --from=ghcr.io/astral-sh/uv:0.11.8 /uv /uvx /usr/local/bin/
LABEL org.opencontainers.image.url="https://mvt.re"
@@ -113,26 +9,30 @@ LABEL org.opencontainers.image.source="https://github.com/mvt-project/mvt"
LABEL org.opencontainers.image.title="Mobile Verification Toolkit (iOS)"
LABEL org.opencontainers.image.description="MVT is a forensic tool to look for signs of infection in smartphone devices."
LABEL org.opencontainers.image.licenses="MVT License 1.1"
LABEL org.opencontainers.image.base.name=docker.io/library/python:3.10.14-alpine3.20
LABEL org.opencontainers.image.base.name=docker.io/library/ubuntu:24.04
# Install runtime dependencies
RUN apk add --no-cache \
gcompat \
libcurl \
ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
git \
libssl3 \
libusb \
sqlite
COPY --from=build-libplist /build /
COPY --from=build-libimobiledevice-glue /build /
COPY --from=build-libtatsu /build /
COPY --from=build-libusbmuxd /build /
COPY --from=build-libimobiledevice /build /
COPY --from=build-usbmuxd /build /
libusb-1.0-0 \
python3 \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Install mvt using the locally checked out source
COPY ./ mvt
RUN apk add --no-cache --virtual .build-deps git gcc musl-dev \
&& uv pip install --system --no-cache ./mvt \
&& apk del .build-deps git gcc musl-dev && rm -rf ./mvt
# Install MVT and pymobiledevice3. Native build dependencies are needed on ARM64.
ARG PYMOBILEDEVICE3_VERSION=11.12.4
COPY . mvt/
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential python3-dev libssl-dev \
&& uv pip install --system --break-system-packages --no-cache ./mvt "pymobiledevice3==${PYMOBILEDEVICE3_VERSION}" \
&& apt-get purge -y --auto-remove build-essential python3-dev libssl-dev \
&& rm -rf mvt /var/lib/apt/lists/* \
&& pymobiledevice3 --help > /dev/null \
&& pymobiledevice3 backup2 --help > /dev/null \
&& mvt-ios --help > /dev/null
ENTRYPOINT [ "/usr/local/bin/mvt-ios" ]
+4
View File
@@ -0,0 +1,4 @@
prune tests
prune docs
prune .github
exclude uv.lock Dockerfile* mkdocs.yml .readthedocs.yaml .safety-policy.yml .python-version .gitignore Makefile
-6
View File
@@ -31,9 +31,3 @@ clean:
dist:
$(UV) build
upload:
$(UV) tool run twine upload dist/*
test-upload:
$(UV) tool run twine upload --repository testpypi dist/*
+30 -4
View File
@@ -58,12 +58,38 @@ For alternative installation options and known issues, please refer to the [docu
## Usage
MVT provides two commands `mvt-ios` and `mvt-android`. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
MVT provides three commands: `mvt-ios` and `mvt-android` analyse acquisitions from devices of that platform, and `mvt` hosts what belongs to neither: `version`, `completion`, `plugins` and `download-iocs` (`version` and `download-iocs` remain available on the platform commands for now). Running `mvt` on its own shows the installed version, update notices and the available commands. [Check out the documentation to learn how to use them!](https://docs.mvt.re/)
Module-running `check-*` commands can load custom Python modules with
`--load-module PATH` or from a folder set in `MVT_CUSTOM_MODULES`. See the
Pass `--verbose` to any of the three commands, before the command name (`mvt-ios --verbose check-backup ...`), for debug output. The `--verbose` option the `check-*` commands accept after their name still works but is kept for compatibility only and will be removed in a future release.
### Shell completion
MVT can generate a shell completion script for Bash, Zsh, and Fish which covers `mvt`, `mvt-ios` and `mvt-android`:
```bash
mvt completion
```
The command prints setup instructions by default. To generate the completion script directly, pass the shell name:
```bash
mvt completion bash
```
MVT only writes completion files or shell configuration when `--install` is passed. See the [command completion documentation](https://docs.mvt.re/en/latest/command_completion/) for details.
Plugin packages extend MVT with additional forensic modules, which run inside
the `check-*` commands, and with top-level commands on `mvt`, `mvt-ios` and
`mvt-android`. See the
[development documentation](https://docs.mvt.re/en/latest/development/) for
details.
writing and installing them, and the
[custom CLI command documentation](https://docs.mvt.re/en/latest/development/custom_commands/)
for the entry points a package registers commands in.
Extraction commands run independent modules concurrently with four workers by
default. Use `--jobs 1` for sequential execution or another positive value to
set the worker limit. Dependencies retain their declared ordering, and parallel
console logs are printed as grouped module blocks.
## License
+6
View File
@@ -57,3 +57,9 @@ If the backup is encrypted, ABE will prompt you to enter the password.
Alternatively, [ab-decrypt](https://github.com/joernheissler/ab-decrypt) can be used for that purpose.
You can then extract SMSs with MVT by passing the folder path as parameter instead of the `.ab` file: `mvt-android check-backup --output /path/to/results/ /path/to/backup/` (the path to backup given should be the folder containing the `apps` folder).
When an output folder is specified, URLs extracted from SMS and MMS messages
are also written to `urls.json`. Each entry contains the URL, its expanded
destination when MVT resolved a shortened URL during indicator checking, the
message timestamp, and the `sms` source. The same file is created by
`check-androidqf` when its nested Android backup contains messages with URLs.
+1 -1
View File
@@ -58,7 +58,7 @@ mvt-android check-intrusion-logs --output /path/to/results/ /path/to/intrusion-l
| `-l, --list-modules` | List the available intrusion-log modules and exit. |
| `-m, --module NAME` | Run a single module (e.g. `DnsEvent`) instead of all of them. |
| `-t, --timezone TZ` | IANA timezone name for the device (e.g. `Europe/Paris`). When set, event timestamps are converted to the device's local time instead of UTC. |
| `-v, --verbose` | Verbose logging. |
| `-v, --verbose` | Verbose logging. Kept for compatibility and to be removed in a future release: pass `--verbose` to `mvt-android` itself instead. |
## Modules
+16
View File
@@ -38,6 +38,22 @@ By separating artifact collection from forensic analysis, this approach ensures
For more information, refer to the [AndroidQF project documentation](https://github.com/mvt-project/androidqf).
### VirusTotal package lookups
AndroidQF records APK file hashes in `packages.json`. MVT can optionally look up non-system APK hashes on VirusTotal while checking an AndroidQF acquisition:
```bash
MVT_VT_API_KEY=<key> mvt-android check-androidqf --virustotal /path/to/androidqf-output
```
The `--virustotal` option is disabled by default because it sends APK hashes to VirusTotal and requires network access. It uses the `VT_API_KEY` MVT configuration value, which can also be provided through the `MVT_VT_API_KEY` environment variable.
To avoid exhausting free VirusTotal API quotas, MVT waits 16 seconds between package hash requests by default. Use `--delay` to change the delay, or `--delay 0` to disable throttling:
```bash
mvt-android check-androidqf --virustotal --delay 30 /path/to/androidqf-output
```
## Android Intrusion Logs
On devices where the user has opted into Android's [**Advanced Protection Mode**](https://support.google.com/android/answer/16339980) and turned on the optional Intrusion Logging featrue, Android can create and archive structured *Intrusion Logs* in an encrypted format. These logs record DNS queries, outbound network connections, process starts, ADB activity and other security-relevant events, and are a high-fidelity complement to the rest of an AndroidQF acquisition. The logs are generated on-device and encrypted before being stored in the Google account associated with the device. The encryption key is protected by the user device PIN. The intrusion log data is not accessible to Google.
+39 -16
View File
@@ -1,43 +1,66 @@
# Command Completion
# Command Completion
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
MVT utilizes the [Click](https://click.palletsprojects.com/en/stable/) library for creating its command line interface.
Click provides tab completion support for Bash (version 4.4 and up), Zsh, and Fish.
To enable it, you need to manually register a special function with your shell, which varies depending on the shell you are using.
To enable it, you need to register a completion script with your shell, which varies depending on the shell you are using.
The following describes how to generate the command completion scripts and add them to your shell configuration.
`mvt completion` generates one script which covers `mvt`, `mvt-ios` and `mvt-android`. The following describes how to generate that script and add it to your shell configuration.
> **Note: You will need to start a new shell for the changes to take effect.**
### For Bash
```bash
# Generates bash completion scripts
echo "$(_MVT_IOS_COMPLETE=bash_source mvt-ios)" > ~/.mvt-ios-complete.bash &&
echo "$(_MVT_ANDROID_COMPLETE=bash_source mvt-android)" > ~/.mvt-android-complete.bash
# Generate the bash completion script
mvt completion bash > ~/.mvt-complete.bash
```
Add the following to `~/.bashrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.bash && . ~/.mvt-android-complete.bash
# source the mvt completion script
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
```
### For Zsh
```bash
# Generates zsh completion scripts
echo "$(_MVT_IOS_COMPLETE=zsh_source mvt-ios)" > ~/.mvt-ios-complete.zsh &&
echo "$(_MVT_ANDROID_COMPLETE=zsh_source mvt-android)" > ~/.mvt-android-complete.zsh
# Generate the zsh completion script
mvt completion zsh > ~/.mvt-complete.zsh
```
Add the following to `~/.zshrc`:
```bash
# source mvt completion scripts
. ~/.mvt-ios-complete.zsh && . ~/.mvt-android-complete.zsh
# source the mvt completion script
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh
```
### For Fish
```bash
# Generate the fish completion script
mkdir -p ~/.config/fish/conf.d
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
```
Fish loads the files in `~/.config/fish/conf.d` automatically.
### Automatic Installation
MVT can write the completion file and update the relevant shell configuration for Bash and Zsh when you pass `--install`:
```bash
mvt completion bash --install
```
Replace `bash` with `zsh` or `fish` as needed. For Fish, `--install` writes the completion file into `~/.config/fish/conf.d` and changes no shell configuration.
!!! note
Earlier versions generated one script per command, with `mvt-ios completion`
and `mvt-android completion`. Files written by them keep working. When you
switch to the single script, remove the old files and the lines which load
them from your shell configuration.
For more information, visit the official [Click Docs](https://click.palletsprojects.com/en/stable/shell-completion/#enabling-completion).
-129
View File
@@ -1,129 +0,0 @@
# Development
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
## Local environment
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
```bash
make install
```
## Testing
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
be run automatically with:
```bash
make check
```
Run these tests before making new commits or opening pull requests.
## Module dependencies
Modules can require other modules to run first by declaring their classes in
`dependencies`. The command runner uses a stable topological ordering, so the
existing module list order is preserved wherever dependency constraints allow.
```python
class DependentModule(MVTModule):
dependencies = (PrerequisiteModule,)
def run(self):
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
```
Selecting a single module also runs its transitive dependencies. If a dependency
is unavailable or the dependency graph contains a cycle, the command logs a
warning and does not run any modules.
## Custom modules
Module-running `check-*` commands can load custom modules from Python files that
are not installed as part of MVT. Load one file with:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
You can also load a folder. MVT loads non-hidden top-level `*.py` files in
sorted order and skips `__init__.py`:
```bash
mvt-ios check-fs --load-module ./custom_modules ./filesystem-dump
```
Set `MVT_CUSTOM_MODULES` to load a folder for every module-running command. This
folder is loaded before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Custom modules are normal `MVTModule` subclasses:
```python
from mvt.common.module import MVTModule
class ExampleCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"), ("ios", "check-fs"))
slug = "example_custom_module"
def run(self):
self.results = [{"message": "custom module ran"}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
Use `supported_commands` to restrict a module to specific platform/command
pairs. Missing or empty `supported_commands` means the module is available to
all commands, which keeps older modules compatible. Supported pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
Custom modules can depend on existing MVT module classes. Dependencies are
resolved with the same ordering logic as built-in modules, and custom modules
are appended after built-ins before ordering:
```python
from mvt.common.module import MVTModule
from mvt.ios.modules.backup.manifest import Manifest
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
take to avoid inefficient code paths as we add new modules.
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable.
```bash
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
```
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
+202
View File
@@ -0,0 +1,202 @@
# Custom CLI Commands
MVT can load additional top-level commands into `mvt`, `mvt-ios` and
`mvt-android`. A command package chooses which of the three each of its
commands is added to.
Custom commands are different from [custom forensic modules](index.md#custom-modules):
commands add new CLI operations, while modules add analysis steps to existing
`check-*` commands.
!!! warning
Custom commands run as trusted Python code inside the MVT process. Install
or load commands only from sources you trust. MVT does not sandbox
third-party commands, and the MVT maintainers do not maintain them.
## Install a Command Package
Python packages can register a Click command or group on one or more of the MVT CLIs.
A minimal package can expose this command from `my_mvt_plugin.py`:
```python
import click
@click.command()
@click.argument("path", type=click.Path(exists=True))
def summarize(path):
"""Summarize an acquisition."""
click.echo(f"Summarizing {path}")
```
Log through `get_plugin_logger(__name__)` from `mvt.plugin`. Records logged
through `logging.getLogger(__name__)` do not reach `command.log`, and MVT's
console handler does not show them.
Register the object in the package's `pyproject.toml`. The entry-point name is
the command users invoke:
```toml
[project.entry-points."mvt.ios.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
[project.entry-points."mvt.android.cli_plugins"]
summarize = "my_mvt_plugin:summarize"
```
Each entry-point group adds the command to one CLI: `mvt.ios.cli_plugins` to
`mvt-ios`, `mvt.android.cli_plugins` to `mvt-android` and `mvt.cli_plugins` to
`mvt`. Register the command in the group of every CLI which should offer it: a
platform-specific command belongs in one platform group, and a command which
handles acquisitions of both platforms, as above, in both. After installing the
package in the same environment as MVT, the command appears directly in those
CLIs:
```bash
mvt-ios summarize ./ios-backup
mvt-android summarize ./androidqf-output
```
For a `pipx` installation of MVT, inject the plugin into MVT's environment:
```bash
pipx inject mvt my-mvt-plugin
```
When MVT is installed in an active virtual environment, install the plugin with
`pip` in that environment. `mvt plugins list` shows the installed packages and
the commands they add, see [Managing Plugins](plugins.md).
Command packages that need their own settings, such as an API key, should store
them in a namespaced [plugin configuration file](plugin_configuration.md)
rather than in MVT's own `config.yaml`.
### Commands on `mvt`
A MVT plugin command can also add sub-commands to the base `mvt` command. This can be used for commands which are not tied to a particular forensic platform:
```toml
[project.entry-points."mvt.cli_plugins"]
my-plugin = "my_mvt_plugin:my_plugin"
```
Commands in this group are added to `mvt` only, so this one is invoked as
`mvt my-plugin`. A command on `mvt` has nothing but its name to say which
plugin it belongs to, so name it after the plugin, and make it a Click group
when the plugin has several operations to offer, such as
`mvt my-plugin configure`.
## Developing a Command Locally
A package is how a command is distributed. While a command is being written,
MVT can load it straight from its file instead, so the package need not be
reinstalled after every change; an editable install of the package does the
same through its entry points. Create a Python file that exports one Click
command or group named `cli`:
```python
import click
@click.command("case-summary")
@click.argument("path", type=click.Path(exists=True))
def cli(path):
"""Summarize a case directory."""
click.echo(f"Summarizing {path}")
```
Pass the file before the custom command name:
```bash
mvt-ios --load-command ./case_summary.py case-summary ./ios-backup
```
`--load-command` can be repeated and also accepts a folder. MVT loads
non-hidden top-level `*.py` files in sorted order and skips `__init__.py`.
Every loaded file must export one `cli` object.
To load a file or folder on every invocation, set the environment variable of
the CLI the commands belong on. Like the entry-point groups, each variable adds
its commands to one CLI only:
```bash
export MVT_CUSTOM_COMMANDS=./commands
export MVT_IOS_CUSTOM_COMMANDS=./ios_commands
export MVT_ANDROID_CUSTOM_COMMANDS=./android_commands
```
## Building a Module-Running Command
A command which runs forensic modules over an acquisition subclasses `Command`.
`Command` creates the output folder and writes `command.log`. It orders the
modules, resolves their dependencies and runs them. It writes the result files,
`alerts.json` and `info.json`. The subclass sets `platform`, `name` and
`modules`:
```python
from mvt.plugin import Command, MVTModule, convert_unix_to_iso, get_plugin_logger
log = get_plugin_logger(__name__)
class APKManifest(MVTModule):
supported_commands = (("android", "check-apks"),)
def run(self):
self.results = [{"checked_at": convert_unix_to_iso(0)}]
class CmdCheckAPKs(Command):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.platform = "android"
self.name = "check-apks"
self.modules = [APKManifest]
```
`platform` and `name` are the pair the modules declare in
`supported_commands`. `modules` lists the module classes the command runs,
imported directly. A command which runs modules of another plugin depends on
that package in `pyproject.toml` and imports them the same way. `init()`,
`module_init(module)` and `finish()` are optional hooks. `run()` calls them
before the run, before each module and after the run.
Wrap the command in a Click command with an `--output` option. Run it, then
print the alert summary:
```python
import click
@click.command("check-apks")
@click.option("--output", "-o", type=click.Path(exists=False))
@click.argument("TARGET_PATH", type=click.Path(exists=True))
def cli(output, target_path):
cmd = CmdCheckAPKs(target_path=target_path, results_path=output)
log.info("Checking APK files at path: %s", target_path)
cmd.run()
cmd.show_alerts_brief()
```
The `--verbose` option of `mvt`, `mvt-ios` and `mvt-android` applies to the
command. The command defines no `--verbose` option of its own. The pair a
plugin command adds is not listed anywhere in MVT. Name it in the plugin's
README.
## Naming and Errors
Built-in MVT commands cannot be replaced. External command names must also be
unique on each CLI; when installed packages or environment paths collide, MVT
keeps the first command and logs a warning. The environment path of a CLI is
registered before its installed packages, so a command loaded from there wins a
collision with a package. A collision from an explicit `--load-command` is a
usage error.
A package entry point or environment command that cannot be imported appears
as a marked broken command without preventing other MVT commands from working.
Invoke that command to see its package or file source and the underlying error.
An invalid command supplied explicitly with `--load-command` fails immediately
with a usage error.
Installed command packages use the entry-point name as the CLI command name.
The entry point must resolve to a `click.Command` or `click.Group`.
+419
View File
@@ -0,0 +1,419 @@
# Development
The Mobile Verification Toolkit team welcomes contributions of new forensic modules or other contributions which help improve the software.
## Local environment
MVT uses `uv` for dependency management. To install the project and development dependencies from the locked environment, run:
```bash
make install
```
## Testing
MVT uses `pytest` for unit and integration tests. Code style consistency is maintained with `ruff` and `mypy`. All can
be run automatically with:
```bash
make check
```
Run these tests before making new commits or opening pull requests.
## Module dependencies
Modules can require other modules to run first by declaring their classes in
`dependencies`. The command runner uses a stable topological ordering, so the
existing module list order is preserved wherever dependency constraints allow.
```python
class DependentModule(MVTModule):
dependencies = (PrerequisiteModule,)
def run(self):
prerequisite_results = self.get_dependency_results(PrerequisiteModule)
```
Selecting a single module also runs its transitive dependencies.
A module can only depend on modules the command it runs in also has. When a
declared dependency is not among them, the command logs a warning naming the
module and the missing dependency, skips that module and everything depending
on it, and runs the rest of the analysis. Selecting such a module with
`--module` therefore leaves nothing to run, which the warning explains.
A cycle in the dependency graph is a programming error rather than a
configuration problem: the command logs a warning and runs no modules at all.
## Parallel module execution
Extraction modules run concurrently, using four worker threads by default. Use
`--jobs INTEGER` to change the worker limit, or `--jobs 1` for sequential
execution and live per-line logging:
```bash
mvt-ios check-backup --jobs 8 --output ./out ./backup
```
The scheduler starts a module only after all of its declared dependencies have
completed. Results, alerts, and timeline entries are still aggregated in stable
topological order. During parallel execution, console records from each module
are buffered and printed together as a labeled block when that module finishes;
`command.log` is written immediately and retains the complete log stream.
Parallel-safe modules must log through `self.log`, must not write directly to
stdout or stderr, and must not mutate shared global state. A module that uses a
non-thread-safe resource or direct terminal output can opt out:
```python
class TerminalModule(MVTModule):
parallel_safe = False
```
Such modules run synchronously and exclusively after active workers finish.
## Custom modules
MVT's module-running `check-*` commands can run forensic modules which are not
part of MVT. Custom modules are distributed as plugin packages. A Python
package installed next to MVT registers its modules through an entry point.
The modules then load automatically in every command they support, see
[Installed module packages](#installed-module-packages). `mvt plugins list`
shows the installed packages and where each one was installed from.
MVT can also load module files by path, with `--load-module` and
`MVT_CUSTOM_MODULES`, see
[Developing modules locally](#developing-modules-locally). This can be used
while writing a module. Use a Python package to distribute one.
A custom module declares in `supported_commands` the platform and command
pairs it runs in. A module with empty `supported_commands` does not run and
MVT logs a warning. The nine pairs are:
```python
("ios", "check-backup")
("ios", "check-fs")
("ios", "check-iocs")
("ios", "check-sysdiagnose")
("android", "check-backup")
("android", "check-bugreport")
("android", "check-androidqf")
("android", "check-intrusion-logs")
("android", "check-iocs")
```
`check-iocs` re-checks stored results rather than an acquisition. It matches
every `<slug>.json` file in the results folder to the module with that slug.
It then runs that module's `check_indicators()` again. A module which
implements `check_indicators()` is included in `check-iocs` for its platform.
It does not need to declare the `check-iocs` pair.
### Writing a module
A module subclasses `MVTModule` or one of the base classes below and
implements `run()`. `check_indicators()` and `serialize()` are optional. The
first matches results against IOCs or detections. The second returns timeline
records.
```python
from mvt.plugin import IOSExtraction, convert_unix_to_iso
class ExampleCustomModule(IOSExtraction):
supported_commands = (
("ios", "check-backup"),
("ios", "check-fs"),
)
slug = "example_custom_module"
def run(self):
self.results = [{"checked_at": convert_unix_to_iso(0)}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
The base classes are:
- `MVTModule`: the base of every module. It provides `self.results`,
`self.alertstore`, `self.log`, `self.indicators` and
`get_dependency_results()`. Subclass it directly for a module which reads
only the results of other modules.
- `IOSExtraction`: `("ios", "check-backup")` and `("ios", "check-fs")`. Adds
`_find_ios_database()`, which locates a module's database in a backup or in
a filesystem dump and repairs it if it is malformed. Adds
`_get_backup_files_from_manifest()`, `_get_backup_file_from_id()` and
`_get_fs_files_from_patterns()`. Adds `_open_sqlite_db()`, which opens a
database read-only.
- `SysdiagnoseExtraction`: `("ios", "check-sysdiagnose")`. MVT extracts the
archive and calls `from_sysdiagnose_folder()` before `run()`. The module
reads files with `_get_files_by_pattern()` and `_get_file_content()`.
`ips_files` lists the crash reports. See
[Check an iOS Sysdiagnose](../ios/sysdiagnose.md).
- `AndroidQFModule`: `("android", "check-androidqf")`. MVT calls `from_dir()`
or `from_zip()` with the file list of the acquisition. The module reads
files with `_get_files_by_pattern()` and `_get_file_content()`.
`_get_device_timezone()` returns the device timezone.
- `AndroidBackupModule`: `("android", "check-backup")`. MVT calls `from_dir()`
or `from_ab()`. The module reads files with `_get_files_by_pattern()` and
`_get_file_content()`.
- `BugReportModule`: `("android", "check-bugreport")`. MVT calls `from_dir()`
or `from_zip()`. The module reads files with `_get_files_by_pattern()`,
`_get_files_by_patterns()` and `_get_file_content()`.
`_get_dumpstate_file()` returns the dumpstate file, and
`_get_file_modification_time()` the modification time of a file.
The underscore-named helpers are internal to the base classes. Plugin modules
can call them. Their names and signatures can change between releases. Read
the base class in `src/mvt/ios/modules` or `src/mvt/android/modules` before
relying on one.
### Depending on a built-in module
A module which post-processes records generated by one or more built-in MVT
modules must declare the source modules in `dependencies`. It reads their
results with `get_dependency_results()`. Import the class from its family
package: `mvt.ios.modules.backup`, `mvt.ios.modules.fs`,
`mvt.ios.modules.mixed`, `mvt.android.modules.androidqf`,
`mvt.android.modules.backup`, `mvt.android.modules.bugreport` or
`mvt.android.modules.intrusion_logs`.
```python
from mvt.ios.modules.backup import Manifest
from mvt.plugin import MVTModule
class DependentCustomModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
dependencies = (Manifest,)
def run(self):
manifest_results = self.get_dependency_results(Manifest)
self.results = [{"manifest_entries": len(manifest_results)}]
```
Dependencies are ordered as for the built-in modules, with custom modules
appended after the built-ins, see [Module dependencies](#module-dependencies).
A dependency has to run in every command the module supports. Where it does
not, MVT skips the module with a warning.
`get_dependency_results()` returns the plain dictionaries the module produced.
They are the same records it writes to `<slug>.json`. Typed results per module
are planned.
### Replacing a built-in module
A custom module which extends a built-in one runs alongside it, and when the
two share a slug they write to the same results file. MVT warns about that
collision, naming both modules, where each came from and the file the later one
overwrites, but it runs them both. Set `replaces` to the class the module
supersedes to take that module's place instead: when both are available to a
command, the named module is dropped from the run.
```python
from mvt.ios.modules.backup import Manifest as BuiltinManifest
class Manifest(BuiltinManifest):
supported_commands = (("ios", "check-backup"),)
replaces = BuiltinManifest
```
Keeping the class name of the replaced module, as above, keeps the name
`--module` selects the module by and the slug its results file is named after.
A replacement with a different class name writes to a file named after its own
slug, unless it sets `slug` to the slug of the module it replaces, and
`--module` still selects it by the name of the replaced module, which MVT logs.
Taking over the slug of a replaced module is not a collision and is not warned
about, because that module is no longer part of the run.
Modules which depend on the replaced module receive the replacement instead, so
`get_dependency_results(BuiltinManifest)` returns the replacement's results. A
module must not depend on the module it replaces: that module does not run, so
the replacement has to produce the data itself, and MVT logs a warning when a
module declares both.
A replacement takes on the obligations of the module it replaces. If it
declares a dependency the command does not provide, it is skipped like any
other module with an unavailable dependency, and the module it replaced stays
out of the run: neither of them produces results, and the modules depending on
the replaced module are skipped as well.
Subclassing the replaced module is not required, but a replacement which is not
a subclass is logged with a warning, because its results may not be what the
modules depending on the replaced module expect. Naming a module the command
does not run has no effect, and modules which replace each other in a cycle all
keep running and replace nothing. Every applied substitution is logged, so it
is recorded in `command.log` when the command runs with an `--output` folder.
Every command resolves replacements on its own. `check-iocs` matches stored
results files against the slugs of the modules available for that command. A
replacement which subclasses the module it replaces inherits its
`check_indicators()`. It is then part of `check-iocs` for its platform and
re-checks the results file named after its slug. A replacement with no
`check_indicators()` is not part of `check-iocs`. The built-in module it
replaced re-checks the file. `check-iocs` matches `--module` on the class name
only. Pass a differently named replacement's own name there, not the name of
the module it replaces.
### Importing from MVT
Import from `mvt.plugin` if it has what you need. The names it exports are
kept working on a best-effort basis. A change to one of them is announced in
the release notes. Anything else in `mvt` can be imported too, but may change
between releases without notice. The plugin interface is best effort.
`mvt.plugin` exports the base classes above and `Command`, `Alert` and
`AlertLevel`, the result types, `DatabaseNotFoundError` and
`DatabaseCorruptedError`, the timestamp converters, the settings API of
[Plugin Configuration](plugin_configuration.md), MVT's own `settings`,
`get_plugin_logger()` and `MVT_VERSION`. `src/mvt/plugin.py` holds the list.
Read MVT's `settings` for values such as `NETWORK_ACCESS_ALLOWED` and
`NETWORK_TIMEOUT`. Plugin values go in the plugin's own settings file.
## Installed module packages
Python packages can register modules so they load automatically in every
module-running `check-*` command, without `--load-module` or
`MVT_CUSTOM_MODULES`. Register an entry point in the `mvt.modules` group in
the package's `pyproject.toml`:
```toml
[project.entry-points."mvt.modules"]
mvt-plugin-example-org = "mvt_plugin_example_org:get_modules"
```
The entry point must resolve to an iterable of `MVTModule` subclasses, or to
a callable returning one:
```python
from mvt.plugin import MVTModule
class PackagedModule(MVTModule):
supported_commands = (("ios", "check-backup"),)
def run(self):
self.results = [{"message": "packaged module ran"}]
def get_modules() -> list[type[MVTModule]]:
return [PackagedModule]
```
`get_modules()` is the package's module list, written by hand. A package which
keeps its modules in separate files imports each class there and lists it. A
module missing from the list does not load.
Installed modules follow the same rules as other custom modules: each module
must declare `supported_commands`, and dependencies are resolved with the
standard ordering logic. A broken entry point is skipped with a warning and
does not prevent MVT from running. As with custom commands, installed module
packages run as trusted code inside the MVT process, so install only packages
from sources you trust.
For a `pipx` installation of MVT, inject the package into MVT's environment:
```bash
pipx inject mvt mvt-plugin-example-org
```
Module packages that need their own settings, such as an API key, should store
them in a namespaced [plugin configuration file](plugin_configuration.md)
rather than in MVT's own `config.yaml`.
### Naming module packages
Name module packages `mvt-plugin-<name>` (import package `mvt_plugin_<name>`),
and include the name of the publishing organization or author so packages from
different groups do not collide: for example, an organisation's custom modules
would be distributed as `mvt-plugin-example-org` with the import package
`mvt_plugin_example_org`.
The prefix makes module packages easy to find on PyPI and keeps their import
names from clashing with unrelated Python packages. It is a convention, not a
technical requirement: modules load through the `mvt.modules` entry point
regardless of what the package is called, and existing packages with other
names keep working. Note that the prefix is also not a mark of authenticity —
anyone can publish a package with any available name, so vet a module package
and its publisher before installing it, whatever it is called.
### Module logging
Modules log through `self.log`, and MVT names the logger for where the module
came from. MVT's own modules log under their dotted path (for example
`mvt.ios.modules.mixed.whatsapp`). Everything external is namespaced under
`mvt.ext` to keep it visually distinct from built-in modules and isolated from
MVT's internal logger tree:
- Installed packages log under `mvt.ext.<package>`, with the `mvt_plugin_`
prefix stripped: modules in `mvt_plugin_example_org` log as
`mvt.ext.example_org.*`.
- Files loaded with `--load-module` or `MVT_CUSTOM_MODULES` log as
`mvt.ext.<file name>`.
Outside a module class, for example in a custom command line handler, log
through `get_plugin_logger(__name__)`. It returns a logger in the same
namespace.
## Developing modules locally
While a module is being written, load it from its file. `--load-module` takes a
Python file, or a folder of them, on every module-running command, and can be
repeated:
```bash
mvt-ios check-backup --load-module ./example_module.py --output ./out ./backup
```
For a folder, MVT loads its non-hidden top-level `*.py` files in sorted order
and skips `__init__.py`. `MVT_CUSTOM_MODULES` names a folder to load on every
module-running command, before any `--load-module` path:
```bash
MVT_CUSTOM_MODULES=./custom_modules mvt-android check-bugreport ./bugreport.zip
```
Files loaded this way follow the same rules as packaged modules.
`--list-modules` reports them with the SHA-256 hash of the file in place of a
version. An editable install of the package (`pip install -e .`) also works:
the modules load through the entry point, and `mvt plugins list` shows the
package with the `local` origin.
Loading by path is for development. Move a module into a package once it
works.
## Auditing loaded modules
Because installed module packages load automatically, MVT records where every
module came from:
- `--list-modules` groups the available modules by source: MVT itself
(with its version), each installed package (with its version and, when
installed directly from a repository, the commit), and each file loaded
with `--load-module` or `MVT_CUSTOM_MODULES` (with the SHA-256 hash of the
file).
- When a command runs with an `--output` folder, the `command.log` file
records one line per module source with the source's version or hash and
the list of modules loaded from it.
- `mvt plugins list` lists the installed packages, where each of them
was installed from and how many modules it contributes, see
[Managing Plugins](plugins.md).
## Profiling
Some MVT modules extract and process significant amounts of data during the analysis process or while checking results against known indicators. Care must be
take to avoid inefficient code paths as we add new modules.
MVT modules can be profiled with Python built-in `cProfile` by setting the `MVT_PROFILE` environment variable. Profiling forces sequential execution even
when `--jobs` is greater than one.
```bash
MVT_PROFILE=1 dev/mvt-ios check-backup test_backup
```
Open an issue or PR if you are encountering significant performance issues when analyzing a device with MVT.
+157
View File
@@ -0,0 +1,157 @@
# Plugin Configuration
Plugin packages that add [custom CLI commands](custom_commands.md) or
[modules](index.md#installed-module-packages) often need to store their
own settings, such as an API key, a server URL or the timestamp of the last
synchronization. MVT provides a namespaced settings base class so each plugin
keeps its configuration in its own file, and a data folder for anything else a
plugin needs to keep on disk.
!!! warning
Do not write plugin settings to MVT's own `config.yaml`. MVT rewrites that
file with the settings it knows about every time it starts, so any other
section is deleted.
## Where Settings Are Stored
Each plugin gets one YAML file in a `plugins` folder next to MVT's own
configuration:
```
~/.config/mvt/plugins/<plugin name>.yaml
```
The exact parent folder follows the platform convention used for MVT's
`config.yaml` (for example `~/Library/Application Support/mvt` on macOS). Use
`plugin_config_path()` from `mvt.plugin` instead of building the path by hand.
Plugin names must be lowercase and may only contain letters, digits and dashes,
matching the `mvt-plugin-<name>` package naming convention. MVT creates the
`plugins` folder with `0700` permissions and writes the settings files with
`0600` permissions, because they commonly hold credentials. Files are written
through a temporary file and moved into place, so an interrupted save never
leaves a partially written settings file behind.
## Plugin Data Folder
Everything else a plugin keeps on disk, such as a cache, a downloaded artifact
or synchronization state, belongs in the folder returned by the `data_folder()`
class method of the plugin's settings class, or by `plugin_data_folder()` from
`mvt.plugin`, called with the plugin name if the plugin has no settings class:
```
~/.local/share/mvt/plugin-data/<plugin name>/ # Linux
~/Library/Application Support/mvt/plugin-data/<plugin name>/ # macOS
```
The folder sits beside MVT's own data, such as the downloaded indicators. It is
created if it is missing, with `0700` permissions. Asking for it again returns
the same path and leaves the contents alone, so a plugin can ask for it every
time it needs the folder. `ExamplePluginSettings` below is the settings class
defined in the next section:
```python
import os
def cache_path() -> str:
folder = ExamplePluginSettings.data_folder()
return os.path.join(folder, "virustotal_lookups_cache.json")
```
A plugin which has no settings class calls
`plugin_data_folder("example-plugin")` instead.
Do not fall back on a path of your own such as `~/.cache/example-plugin`: it
is a Linux-only convention, and MVT will not create it for you.
## Defining Plugin Settings
Subclass `MVTPluginSettings`, set `plugin_name` and declare typed fields with
defaults:
```python
from typing import Optional
from mvt.plugin import MVTPluginSettings
class ExamplePluginSettings(MVTPluginSettings):
plugin_name = "example-plugin"
API_KEY: Optional[str] = None
MAX_RESULTS: int = 25
LAST_SYNC: Optional[str] = None
```
`load()` returns the current settings and `save()` writes them back:
```python
from datetime import datetime, timezone
import click
from mvt.plugin import plugin_env_prefix
def sync():
settings = ExamplePluginSettings.load()
if not settings.API_KEY:
prefix = plugin_env_prefix(settings.plugin_name)
raise click.ClickException(
f"No API key configured. Set {prefix}API_KEY or "
"run 'example-plugin configure'."
)
settings.LAST_SYNC = datetime.now(timezone.utc).isoformat()
settings.save()
```
The message builds the variable name with `plugin_env_prefix()`, see
[Environment Variables](#environment-variables).
A missing settings file is not an error: the plugin then runs on the field
defaults and on whatever the environment provides. `save()` only persists the
values that differ from the defaults, and it never touches MVT's `config.yaml`.
A settings file that cannot be parsed, or that does not hold a mapping of
setting names to values, raises a `PluginConfigLoadError` naming the file.
Every subclass that sets its own `plugin_name` gets its own file and its own
environment namespace. A subclass that does not redefine `plugin_name` inherits
it, and therefore shares the file and the environment variables of its parent
class.
## Environment Variables
Every field can also be set with an environment variable. The prefix is
`MVT_PLUGIN_`, followed by the plugin name upper-cased with dashes replaced by
underscores, followed by the field name. For the example above:
```bash
export MVT_PLUGIN_EXAMPLE_PLUGIN_API_KEY=...
export MVT_PLUGIN_EXAMPLE_PLUGIN_MAX_RESULTS=50
```
Do not repeat the plugin name in a field name: a plugin named `example-scanner`
with a `SCANNER_API_KEY` field asks the user for
`MVT_PLUGIN_EXAMPLE_SCANNER_SCANNER_API_KEY`. Name the field `API_KEY`.
Settings resolve in this order, from highest to lowest priority:
1. Arguments passed to the settings class directly, such as
`ExamplePluginSettings(API_KEY="...")`
2. Environment variables
3. The plugin's YAML file
4. The field defaults declared on the settings class
!!! tip
On shared or multi-user machines, prefer passing API keys through
environment variables rather than saving them to the plugin file. `save()`
skips every value that the environment currently supplies, so a credential
provided that way is not copied into the settings file when a plugin saves
an unrelated setting.
Unknown keys in a plugin's YAML file are ignored, so a settings file written by
a newer version of a plugin does not break an older one.
+116
View File
@@ -0,0 +1,116 @@
# Managing Plugins
Plugin packages extend MVT with additional
[forensic modules](index.md#custom-modules) and
[CLI commands](custom_commands.md). Because installed packages load
automatically, `mvt plugins` audits what is installed and checks whether
updates are available. The command lives on `mvt` only, although the packages
it lists extend `mvt-ios` and `mvt-android` too.
## List Installed Plugins
```bash
mvt plugins list
```
```
Installed MVT plugins
┏━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━┳━━━━━━━━━━━┓
┃ Name ┃ Version ┃ Origin ┃ Modules ┃ Commands ┃
┡━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━╇━━━━━━━━━━━┩
│ mvt-plugin-example │ 1.2.0 │ pypi │ 4 │ summarize │
│ mvt-plugin-research │ 0.1.0 │ git+3f9a1c7d │ 2 │ - │
│ mvt-plugin-local │ 0.0.1 │ local │ 1 │ triage │
└─────────────────────┴─────────┴──────────────┴─────────┴───────────┘
```
The origin records where each package was installed from: `pypi` for a package
installed from a package index, `git+<commit>` for a package installed directly
from a repository, and `local` for a package installed from a local folder or
archive rather than from an index, including an editable development install.
The last two columns show how many forensic modules the package contributes and
which CLI commands it adds.
A plugin whose modules cannot be imported is listed with `error` in the
`Modules` column rather than breaking the listing.
## Check for Updates
```bash
mvt plugins check-updates
```
```
Plugin updates available:
mvt-plugin-example 1.2.0 → 1.3.0
Upgrade with: pip install -U mvt-plugin-example
MVT does not install plugin updates. Run the command above when you decide to
upgrade.
```
Packages installed from a package index are compared against the latest release
published for them. A package which was never published, for example a plugin
distributed only within an organization, is skipped silently.
!!! note
Packages shown with the `pypi` origin are compared against
[PyPI](https://pypi.org), whichever index they were installed from. A
plugin installed from a private index under a name which also exists on
PyPI is therefore compared against the unrelated public package of that
name. Give plugins published to a private index a name which is not taken
on PyPI, and treat an unexpected update suggestion as a reason to check
where the package would come from.
!!! warning
MVT never installs or upgrades a plugin itself, it only prints the command
which does. Upgrading a plugin in the middle of an investigation changes
the modules producing the results, and a plugin runs as trusted code inside
the MVT process, so pulling in a new version is a decision for the analyst
to make deliberately and not a side effect of running a check.
## Automatic Update Checks
MVT also reports available plugin updates in the banner printed when a command
starts:
```
MVT - Mobile Verification Toolkit
https://mvt.re
Version: 2026.7.29
Plugin updates available:
mvt-plugin-example 1.2.0 → 1.3.0 (pip install -U mvt-plugin-example)
```
This check runs at most once every 12 hours. In between checks MVT prints the
findings of the latest check without contacting anything, so a plugin update
stays visible without a lookup on every command. The
`mvt plugins check-updates` command checks immediately, regardless of when the
last check happened.
The automatic check is skipped when the `--disable-update-check` option is
used, when `NETWORK_ACCESS_ALLOWED` is disabled in the MVT configuration, and
when no plugins are installed.
## Plugins Installed From a Repository
A plugin installed with `pip install "mvt-plugin-example @ git+<url>"` is
checked by asking the remote repository which commit the installed revision
points at now. MVT runs git and ssh in batch mode, so a repository which needs
credentials MVT does not already have fails the check instead of prompting for
them. The check is skipped silently when git is not available, when the
repository cannot be reached, and when access to it is denied.
How the plugin was installed decides what an update means:
- A plugin installed from a branch is reported as outdated when the branch has
moved past the installed commit.
- A plugin installed from a specific commit or a tag is pinned. It is never
reported as outdated, however far the branch it came from moves on.
Pinning a plugin to a commit or a tag is therefore the way to keep the modules
used across an investigation stable.
+37 -2
View File
@@ -1,4 +1,6 @@
Using Docker simplifies having all the required dependencies and tools (including most recent versions of [libimobiledevice](https://libimobiledevice.org)) readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
Using Docker simplifies running MVT with its dependencies readily installed. Note that this requires a Linux host, as Docker for Windows and Mac [doesn't support passing through USB devices](https://docs.docker.com/desktop/faqs/#can-i-pass-through-a-usb-device-to-a-container).
The main and iOS Docker images include pymobiledevice3 for [creating iOS backups](ios/backup/pymobiledevice3.md). You do not need to install it separately on the host. Older image tags may still include libimobiledevice instead; build from the updated source if pymobiledevice3 is unavailable.
Install Docker following the [official documentation](https://docs.docker.com/get-docker/).
@@ -31,4 +33,37 @@ Test if the image was created successfully:
docker run -it mvt
```
If a prompt is spawned successfully, you can close it with `exit`.
If a prompt is spawned successfully, you can close it with `exit`.
## Access an iOS device from Docker
On the Linux host, install and start [usbmuxd](https://github.com/libimobiledevice/usbmuxd), then connect and unlock the iOS device. The daemon exposes the device through the `/var/run/usbmuxd` socket.
Bind that socket into the container to let pymobiledevice3 communicate with the device. Also mount a local directory so acquired backups persist after the container exits:
```bash
mkdir -p "$PWD/cases"
docker run --rm -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
--mount type=bind,source="$PWD/cases",target=/home/cases \
ghcr.io/mvt-project/mvt
```
Inside the container, verify connectivity:
```bash
pymobiledevice3 lockdown info
```
Accept the trust prompt on the unlocked device if requested. Then follow the [backup instructions](ios/backup/pymobiledevice3.md), using a destination under `/home/cases`, such as `/home/cases/backup`.
The iOS-only image defaults to running `mvt-ios`. To use pymobiledevice3 instead, override its entrypoint:
```bash
docker run --rm -it \
--mount type=bind,source=/var/run/usbmuxd,target=/var/run/usbmuxd \
--entrypoint pymobiledevice3 \
ghcr.io/mvt-project/mvt:latest-ios lockdown info
```
If you built the image from source, replace `ghcr.io/mvt-project/mvt` with `mvt`.
+1 -1
View File
@@ -9,7 +9,7 @@ Mobile Verification Toolkit (MVT) is a tool to facilitate the [consensual forens
It has been developed and released by the [Amnesty International Security Lab](https://securitylab.amnesty.org) in July 2021 in the context of the [Pegasus Project](https://forbiddenstories.org/about-the-pegasus-project/) along with [a technical forensic methodology](https://www.amnesty.org/en/latest/research/2021/07/forensic-methodology-report-how-to-catch-nso-groups-pegasus/). It continues to be maintained by Amnesty International and other contributors.
In this documentation you will find instructions on how to install and run the `mvt-ios` and `mvt-android` commands, and guidance on how to interpret the extracted results.
In this documentation you will find instructions on how to install and run the `mvt-ios`, `mvt-android` and `mvt` commands, and guidance on how to interpret the extracted results.
## Resources
+4 -4
View File
@@ -1,6 +1,6 @@
# Installation
Before proceeding, please note that MVT requires Python 3.6+ to run. While it should be available on most operating systems, please make sure of that before proceeding.
Before proceeding, please note that MVT requires Python 3.10 or newer to run. While it should be available on most operating systems, please make sure of that before proceeding.
## Dependencies on Linux
@@ -64,7 +64,7 @@ It is recommended to try installing and running MVT from [Windows Subsystem Linu
pipx install mvt
```
You now should have the `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed. If you run into problems with these commands not being found, ensure you have run `pipx ensurepath` and opened a new terminal window.
### Installing from PyPI directly into a virtual environment
You can use `pipenv`, `poetry` etc. for your virtual environment, but the provided example is with the built-in `venv` tool:
@@ -84,7 +84,7 @@ source env/bin/activate
pip install mvt
```
The `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
The `mvt`, `mvt-ios` and `mvt-android` utilities should now be available as commands whenever the virtual environment is active.
### Installing from git source with pipx
If you want to have the latest features in development, you can install MVT directly from the source code in git.
@@ -93,7 +93,7 @@ If you want to have the latest features in development, you can install MVT dire
pipx install --force git+https://github.com/mvt-project/mvt.git
```
You now should have the `mvt-ios` and `mvt-android` utilities installed.
You now should have the `mvt`, `mvt-ios` and `mvt-android` utilities installed.
**Notes:**
1. The `--force` flag is necessary to force the reinstallation of the package.
+5 -4
View File
@@ -37,8 +37,10 @@ export MVT_STIX2="/home/user/IOC1.stix2:/home/user/IOC2.stix2"
## Network Access
When checking URL indicators, MVT follows recognized shortened URLs with an
HTTP `HEAD` request. The following environment variables control these
requests:
HTTP `HEAD` request. URL checks are deduplicated and run concurrently, with at
most 20 requests in progress at a time. Redirects within an individual URL
chain are still followed sequentially. The following environment variables
control these requests:
- `MVT_NETWORK_ACCESS_ALLOWED` enables or disables network requests. It defaults
to `true`. Set it to `false` to prevent MVT from attempting to resolve
@@ -69,8 +71,7 @@ So far MVT implements only a subset of [STIX2 specifications](https://docs.oasis
- [This repository](https://github.com/Te-k/stalkerware-indicators) contains IOCs for Android stalkerware including [a STIX MVT-compatible file](https://raw.githubusercontent.com/Te-k/stalkerware-indicators/master/generated/stalkerware.stix2).
- We are also maintaining [a list of IOCs](https://github.com/mvt-project/mvt-indicators) in STIX format from public spyware campaigns.
You can automaticallly download the latest public indicator files with the command `mvt-ios download-iocs` or `mvt-android download-iocs`. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
You can automatically download the latest public indicator files with the command `mvt download-iocs`. The per-platform forms `mvt-ios download-iocs` and `mvt-android download-iocs` do the same thing. These commands download the list of indicators from the [mvt-indicators](https://github.com/mvt-project/mvt-indicators/blob/main/indicators.yaml) repository and store them in the [appdir](https://pypi.org/project/appdirs/) folder. They are then loaded automatically by MVT.
Please [open an issue](https://github.com/mvt-project/mvt/issues/) to suggest new sources of STIX-formatted IOCs.
+1 -17
View File
@@ -1,19 +1,3 @@
# Backup with libimobiledevice
If you have correctly [installed libimobiledevice](../install.md) you can easily generate an iTunes backup using the `idevicebackup2` tool included in the suite. First, you might want to ensure that backup encryption is enabled (**note: encrypted backup contain more data than unencrypted backups**):
```bash
idevicebackup2 -i encryption on
```
Note that if a backup password was previously set on this device, you might need to use the same or change it. You can try changing password using `idevicebackup2 -i changepw`, or by turning off encryption (`idevicebackup2 -i encryption off`) and turning it back on again.
If you are not able to recover or change the password, you should try to disable encryption and obtain an unencrypted backup.
If all else fails, as a *last resort* you can try resetting the password by [resetting all the settings through the iPhone's Settings app](https://support.apple.com/en-us/HT205220), via `Settings » General » Reset » Reset All Settings`. Note that resetting the settings through the iPhone's Settings app will wipe some of the files that contain useful forensic traces, so try the options explained above first.
Once ready, you can proceed performing the backup:
```bash
idevicebackup2 backup --full /path/to/backup/
```
We now recommend pymobiledevice3 for iOS backups. See [Backup with pymobiledevice3](pymobiledevice3.md) for the current instructions.
+37
View File
@@ -0,0 +1,37 @@
# Backup with pymobiledevice3
After [installing pymobiledevice3](../install.md) and pairing your device, you can generate an iTunes-compatible backup using its `backup2` commands.
## Enable backup encryption
We recommend encrypted backups because they contain more data than unencrypted backups. If encryption is not already enabled, enable it with:
```bash
pymobiledevice3 backup2 encryption on 'YOUR_BACKUP_PASSWORD'
```
Replace `YOUR_BACKUP_PASSWORD` with a strong password and keep it safe: you will need it to decrypt the backup for analysis. Passwords supplied on the command line may be saved in shell history or visible to other processes.
If a backup password was previously set, use that password. To change a known password:
```bash
pymobiledevice3 backup2 change-password 'CURRENT_PASSWORD' 'NEW_PASSWORD'
```
You can also disable encryption with `pymobiledevice3 backup2 encryption off 'CURRENT_PASSWORD'` and then enable it again. Disabling encryption requires the current password; it is not a way to bypass an unknown password.
!!! warning
If you cannot recover the password, resetting it through [Reset All Settings in the iPhone's Settings app](https://support.apple.com/en-us/HT205220) should be a last resort. Resetting settings can remove files containing useful forensic traces.
## Create a backup
Choose a new destination directory to avoid overwriting an earlier acquisition:
```bash
mkdir -p /path/to/backup/
pymobiledevice3 backup2 backup --full /path/to/backup/
```
The backup is saved in a subdirectory named after the device's UDID, such as `/path/to/backup/udid/`. Follow [Check a Backup with mvt-ios](check.md) to decrypt and analyze that directory.
For additional options, see the [pymobiledevice3 backup2 reference](https://doronz88.github.io/pymobiledevice3/cli/backup2/).
+20 -31
View File
@@ -1,55 +1,44 @@
# Install libimobiledevice
# Install pymobiledevice3
Before proceeding with doing any acquisition of iOS devices we recommend installing [libimobiledevice](https://libimobiledevice.org/) utilities. These utilities will become useful when extracting crash logs and generating iTunes backups. Because the utilities and its libraries are subject to frequent changes in response to new versions of iOS, you might want to consider compiling libimobiledevice utilities from sources. Otherwise, if available, you can try installing packages available in your distribution:
Before acquiring data from an iOS device, we recommend installing [pymobiledevice3](https://github.com/doronz88/pymobiledevice3). It provides command-line tools for generating iTunes-compatible backups and extracting crash logs.
## Installation
Install `pipx` following the [MVT installation instructions](../install.md), then run:
```bash
sudo apt install libimobiledevice-utils
pipx install pymobiledevice3
```
On Mac, you can try installing it from brew:
To update an existing installation:
```bash
brew install --HEAD libimobiledevice
pipx upgrade pymobiledevice3
```
If you have a reasonably recent version of libimobiledevice in your package manager, it might work straight out of the box. Try connecting your iOS device to your computer via USB and run:
On Linux, you also need `usbmuxd` to communicate with devices over USB. On Debian and Ubuntu, install it with:
```bash
ideviceinfo
sudo apt install usbmuxd
```
If you encounter unexpected issues, uninstall the packages and try compiling libimobiledevcice from sources.
macOS includes the required USB device service. For other platforms and additional requirements, see the [pymobiledevice3 installation documentation](https://doronz88.github.io/pymobiledevice3/installation/).
## Compile libimobiledevice from sources
## Verify connectivity
!!! warning
The following instructions are a best effort. The installation from source requires several steps, and it is likely some have been forgotten here and that won't work for you. You will likely need to fiddle around a bit before getting this right.
Make sure you have uninstalled all the libimobiledevice tools from your package manage:
Connect the iOS device to your computer with a USB cable and unlock it. Accept the **Trust This Computer** prompt and enter the device passcode if requested. Then run:
```bash
sudo apt remove --purge libimobiledevice-utils libimobiledevice-dev libimobiledevice6 libplist-dev libplist3 libusbmuxd-dev libusbmuxd-tools libusbmuxd4 libusbmuxd6 usbmuxd
pymobiledevice3 usbmux list
pymobiledevice3 lockdown info
```
Firstly you need to install [libplist](https://github.com/libimobiledevice/libplist). Then you can install [libusbmuxd](https://github.com/libimobiledevice/libusbmuxd).
Now you should be able to to download and install the actual suite of tools at [https://github.com/libimobiledevice/libimobiledevice](https://github.com/libimobiledevice/libimobiledevice).
You can now also build and install [usbmuxd](https://github.com/libimobiledevice/usbmuxd).
## Making sure everything works fine.
Once the idevice tools are available you can check if everything works fine by connecting your iOS device and running:
These commands should list the connected device and display its details. If pairing is needed, run:
```bash
ideviceinfo
pymobiledevice3 lockdown pair
```
This should show many details on the connected iOS device. If you are connecting the device to your laptop for the first time, it will require to unlock and enter the PIN code on the mobile device. If it complains that no device is connected and the mobile device is indeed plugged in through the USB cable, you might need to do this first, although typically the pairing is automatically done when connecting the device:
If no device is found, check the USB connection, make sure the device is unlocked, and, on Linux, check that `usbmuxd` is running. See the [upstream troubleshooting guide](https://doronz88.github.io/pymobiledevice3/guides/troubleshooting/) for further help.
```bash
sudo usbmuxd -f -v
idevicepair pair
```
Again, it will ask to unlock the phone and enter the PIN code.
Once connected, follow the [backup instructions](backup/pymobiledevice3.md).
+36 -1
View File
@@ -204,6 +204,10 @@ This JSON file is created by mvt-ios' `Manifest` module. The module extracts rec
If indicators are provided through the command-line, they are checked against the original relative path in case. In some cases, there might be records of files created containing a domain name in their name, for example in the case of browser cache folders. Any matches are stored in *manifest_detected.json*.
An incomplete backup still lists in its manifest the files it failed to acquire. Those records carry a `"missing": true` field, and the module reports how many of them it found. Use it to tell a module which returned nothing because the artifact was not acquired apart from one which found nothing on a device that never had it.
Files must be stored at their expected paths inside the backup folder. If the module cannot finish listing the backup files, it logs a warning and skips the missing-file check; the manifest metadata is still extracted.
---
### `os_analytics_ad_daily.json`
@@ -312,6 +316,15 @@ If indicators are provided through the command-line, they are checked against th
---
### `urls.json`
This JSON file collects URLs extracted from SMS, iMessage, and WhatsApp
messages. Each entry contains the original URL, its expanded destination when
MVT resolved a shortened URL during indicator checking, the message timestamp,
and its `sms` or `whatsapp` source.
---
### `sms_attachments.json`
!!! info "Availability"
@@ -408,7 +421,29 @@ If indicators are provided through the command-line, they are checked against th
Backup: :material-check:
Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*.
This JSON file is created by mvt-ios' `WhatsApp` module. The module extracts a list of WhatsApp messages from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ChatStorage.sqlite*, along with one record per chat session (marked with `"record_type": "chat_session"`) containing the first and last interaction dates of each conversation. Chat sessions produce `chat_first_message` and `chat_last_message` timeline events, and group chats additionally produce a `group_created` event. A chat session's last-message date can postdate its newest stored message when the most recent messages in the chat were deleted.
Recent WhatsApp versions key 1:1 chat sessions by an opaque LID identifier rather than the contact's phone number. The module resolves these using the `ZWAPHONENUMBERLIDPAIR` table from the *LID.sqlite* database in the same app group (or from *ChatStorage.sqlite* itself in versions that store it there), populating `partner_resolved_phone_number` on chat session records and using the phone number in timeline events. Each LID-phone number pair is also extracted as a record (`"record_type": "lid_phone_number_pair"`) and produces a `lid_pair_recorded` timeline event marking when WhatsApp learned the association.
If indicators are provided through the command-line, they are checked against the extracted HTTP links. Any matches are stored in *whatsapp_detected.json*.
---
### `whatsapp_contacts.json`
!!! info "Availability"
Backup: :material-check:
Full filesystem dump: :material-check:
This JSON file is created by mvt-ios' `WhatsappContacts` module. The module extracts WhatsApp contact records from the SQLite database located at *private/var/mobile/Containers/Shared/AppGroup/\*/ContactsV2.sqlite*, including each contact's phone number, WhatsApp and LID identifiers, and the per-contact disappearing messages timer, which is not recorded in *ChatStorage.sqlite*. Each timestamp stored on a contact record produces a timeline event: `disappearing_mode_set` (when the disappearing messages timer was last changed), `about_changed` (when the contact last changed their "about" text), `about_expiration` (when a timed "about" is scheduled to expire) and `contact_last_updated` (when the contact record was last updated).
This database is often missing from incremental backups. When it cannot be found, the module logs a warning and produces no results, in which case the disappearing messages state of chats cannot be determined from the backup.
---
## Records extracted by `check-sysdiagnose`
### `sysdiagnose_info.json`
This JSON file is created by mvt-ios' `SysdiagnoseInfo` module. The module extracts details about the device and the sysdiagnose itself: the product type and model, iOS version and build, serial number, IMEI, MEID and UDID from the remotectl dump state and the mobile activation request, the Apple account name and email from the App Store daemon database (no longer part of a sysdiagnose on newer iOS versions, still read from older archives), and the original file name and creation time of the archive from *sysdiagnose.log*.
+57
View File
@@ -0,0 +1,57 @@
# Check an iOS Sysdiagnose
`mvt-ios check-sysdiagnose` analyzes an iOS sysdiagnose archive. MVT's own
`SysdiagnoseInfo` module extracts details about the device and the archive
(see [`sysdiagnose_info.json`](records.md#sysdiagnose_infojson)); the checks
come from the modules of the installed
[plugin packages](../development/index.md#installed-module-packages) which
declare support for the command. Without any such module the command still
records the device details, and warns that no forensic sysdiagnose modules
have been loaded so that the run cannot pass for a clean analysis.
The command accepts either an extracted sysdiagnose directory or the original
gzip-compressed tar archive.
```bash
mvt-ios check-sysdiagnose --output ./results \
./sysdiagnose_2024.01.02_03-04-05+0200.tar.gz
```
Use `--hashes` to include hashes for analyzed files in `info.json`, and
`--list-modules` to display the eligible modules without running them.
## Writing a custom module
Extend `SysdiagnoseExtraction` from `mvt.plugin`, see
[Writing a module](../development/index.md#writing-a-module). The module reads
the archive the same way whether MVT was given a folder or a tar archive. It
declares the command in `supported_commands`. While writing one,
[load it from its file](../development/index.md#developing-modules-locally).
```python
from mvt.plugin import SysdiagnoseExtraction
class ExampleSysdiagnoseModule(SysdiagnoseExtraction):
supported_commands = (("ios", "check-sysdiagnose"),)
slug = "example_sysdiagnose"
def run(self):
paths = self._get_files_by_pattern("*/example.log")
if paths:
content = self._get_file_content(paths[0]).decode("utf-8", "replace")
self.results = [{"content": content}]
def check_indicators(self):
pass
def serialize(self, result):
return None
```
MVT extracts a tar archive first. It calls `from_sysdiagnose_folder()` on each
module before `run()`. `ips_files` lists the IPS crash reports.
`_get_files_by_pattern()` and `_get_file_content()` are internal helpers of the
base class. Use them to read the archive. Their names and signatures can change
between releases. See `src/mvt/ios/modules/sysdiagnose/base.py`.
+12 -3
View File
@@ -3,6 +3,9 @@ repo_url: https://github.com/mvt-project/mvt
edit_uri: edit/main/docs/
copyright: Copyright &copy; 2021-2023 MVT Project Developers
site_description: Mobile Verification Toolkit Documentation
not_in_nav: |
android/adb.md
ios/backup/libimobiledevice.md
markdown_extensions:
- attr_list
- admonition
@@ -28,22 +31,28 @@ nav:
- Welcome: "index.md"
- Introduction: "introduction.md"
- Installation: "install.md"
- Command Completion: "command_completion.md"
- Using Docker: "docker.md"
- MVT for iOS:
- iOS Forensic Methodology: "ios/methodology.md"
- Install libimobiledevice: "ios/install.md"
- Install pymobiledevice3: "ios/install.md"
- Check an iTunes Backup:
- Backup with iTunes app: "ios/backup/itunes.md"
- Backup with libimobiledevice: "ios/backup/libimobiledevice.md"
- Backup with pymobiledevice3: "ios/backup/pymobiledevice3.md"
- Check a Backup with mvt-ios: "ios/backup/check.md"
- Check a Filesystem Dump:
- Dumping the filesystem: "ios/filesystem/dump.md"
- Check a Filesystem Dump with mvt-ios: "ios/filesystem/check.md"
- Check a Sysdiagnose: "ios/sysdiagnose.md"
- Records extracted by mvt-ios: "ios/records.md"
- MVT for Android:
- Android Forensic Methodology: "android/methodology.md"
- Check an Android Backup (SMS messages): "android/backup.md"
- Check Android Intrusion Logs: "android/intrusion_logs.md"
- Indicators of Compromise: "iocs.md"
- Development: "development.md"
- Development:
- Development Instructions: "development/index.md"
- Custom CLI Commands: "development/custom_commands.md"
- Plugin Configuration: "development/plugin_configuration.md"
- Managing Plugins: "development/plugins.md"
- License: "license.md"
+21 -20
View File
@@ -17,25 +17,24 @@ classifiers = [
"Programming Language :: Python",
]
dependencies = [
"click==8.3.3",
"rich==14.3.3",
"click==8.4.2",
"rich==15.0.0",
"tld==0.13.2",
"requests==2.33.1",
"simplejson==3.20.2",
"packaging==26.2",
"requests==2.34.2",
"simplejson==4.1.1",
"packaging==26.3",
"appdirs==1.4.4",
"iOSbackup==0.9.925",
"adb-shell[usb]==0.4.4",
"libusb1==3.3.1",
"cryptography==47.0.0",
"iphone_backup_decrypt==0.10.0",
"pycryptodome>=3.20.0",
"cryptography==50.0.0",
"PyYAML>=6.0.2",
"pyahocorasick==2.3.1",
"betterproto2==0.9.1",
"pydantic==2.13.3",
"pydantic-settings==2.14.0",
"betterproto2==0.10.0",
"pydantic==2.13.4",
"pydantic-settings==2.15.0",
"NSKeyedUnArchiver==1.5.2",
"python-dateutil==2.9.0.post0",
"tzdata==2026.2",
"tzdata==2026.3",
]
requires-python = ">= 3.10"
@@ -44,8 +43,9 @@ homepage = "https://docs.mvt.re/en/latest/"
repository = "https://github.com/mvt-project/mvt"
[project.scripts]
mvt-ios = "mvt.ios:cli"
mvt-android = "mvt.android:cli"
mvt = "mvt.cli:main"
mvt-ios = "mvt.ios:main"
mvt-android = "mvt.android:main"
[dependency-groups]
dev = [
@@ -65,13 +65,13 @@ dev = [
docs = [
"mkdocs==1.6.1",
"mkdocs-autorefs==1.4.4",
"mkdocs-material==9.7.6",
"mkdocs-material==9.7.7",
"mkdocs-material-extensions==1.3.1",
"mkdocstrings==1.0.4",
"mkdocstrings==1.0.6",
]
[build-system]
requires = ["setuptools>=61.0"]
requires = ["setuptools>=61.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[tool.coverage.run]
@@ -120,5 +120,6 @@ where = ["src"]
[tool.setuptools.package-data]
mvt = ["ios/data/*.json"]
[tool.setuptools.dynamic]
version = { attr = "mvt.common.version.MVT_VERSION" }
[tool.setuptools_scm]
# The version is the latest v* tag; ignore the archive/* tags.
git_describe_command = "git describe --dirty --tags --long --match 'v*'"
+1 -1
View File
@@ -3,4 +3,4 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from .cli import cli
from .cli import cli, main
@@ -4,13 +4,51 @@
# https://license.mvt.re/1.1/
import re
from typing import Any
from .artifact import AndroidArtifact
class DumpsysAccessibilityArtifact(AndroidArtifact):
# One list for both record shapes — a service record and a count-only
# record must stay the same shape.
_FIELDS = (
"user_id",
"component",
"package_name",
"service_name",
"installed",
"enabled",
"binding",
"bound",
"crashed",
"accessibility_tool",
"installed_service_count",
"unnamed_service_count",
)
def check_indicators(self) -> None:
for result in self.results:
# A stated count the dump does not back with component names is a
# coverage statement, not a service: low, but not silent.
if not result.get("component"):
stated = result["installed_service_count"]
unnamed = result["unnamed_service_count"]
if unnamed == stated:
detail = "does not list their component names"
else:
detail = (
f"lists the component names of only {stated - unnamed} "
f"of them ({unnamed} unnamed)"
)
self.alertstore.low(
f"The accessibility dump states {stated} installed "
f"service(s) for user {result['user_id']} but {detail}",
"",
result,
)
continue
if self.indicators:
ioc_match = self.indicators.check_app_id(result["package_name"])
if ioc_match:
@@ -19,11 +57,22 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
)
continue
self.alertstore.medium(
f'Found accessibility service: "{result["service"]}"',
"",
result,
)
# Installed is not enabled. A service can sit installed for years
# without ever being switched on, and that is the difference this
# channel is read for — an alert that says only "found" makes every
# device look equally exposed. A service the dump says is switched
# OFF is reported LOW, so it still reaches the analyst without
# competing with one that is actually running; a dump that does not
# state the enabled state stays MEDIUM, because "not stated" is not
# "not enabled".
message = (
f'Found accessibility service: "{result["component"]}" '
f"({self._describe_state(result)})"
)
if result.get("enabled") is False and not result.get("bound"):
self.alertstore.low(message, "", result)
else:
self.alertstore.medium(message, "", result)
def parse(self, content: str) -> None:
"""
@@ -33,41 +82,122 @@ class DumpsysAccessibilityArtifact(AndroidArtifact):
:param content: content of the accessibility section (string)
"""
# "Old" syntax
in_services = False
self.results: list[dict[str, Any]] = []
services: dict[tuple[int | None, str], dict] = {}
# Which state sections the dump printed, per user: one user's printed
# `enabled services` says nothing about another user's.
seen_states: dict[int | None, set[str]] = {}
# `installedServiceCount=N` from the user's `attributes:{…}` line is on
# most builds the only statement about installed services in the dump:
# few print the `installed services: {…}` block.
installed_counts: dict[int | None, int] = {}
user_id: int | None = None
state: str | None = None
for line in content.splitlines():
if line.strip().startswith("installed services:"):
in_services = True
continue
user_match = re.search(r"attributes:\{id=(\d+)", line)
if user_match:
user_id = int(user_match.group(1))
if not in_services:
continue
count_match = re.search(r"installedServiceCount=(\d+)", line)
if count_match:
installed_counts[user_id] = int(count_match.group(1))
if line.strip() == "}":
# At end of installed services
break
service = line.split(":")[1].strip()
self.results.append(
{
"package_name": service.split("/")[0],
"service": service,
}
stripped = line.strip()
state_match = re.match(
r"(?i)(installed|enabled|binding|bound|crashed) services\s*:\s*\{(.*)",
stripped,
)
# "New" syntax - AOSP >= 14 (?)
# Looks like:
# Enabled services:{{com.azure.authenticator/com.microsoft.brooklyn.module.accessibility.BrooklynAccessibilityService}, {com.agilebits.onepassword/com.agilebits.onepassword.filling.accessibility.FillingAccessibilityService}}
for line in content.splitlines():
if line.strip().startswith("Enabled services:"):
matches = re.finditer(r"{([^{]+?)}", line)
for match in matches:
# Each match is in format: <package_name>/<service>
package_name, _, service = match.group(1).partition("/")
self.results.append(
{"package_name": package_name, "service": service}
if state_match:
state = state_match.group(1).lower()
seen_states.setdefault(user_id, set()).add(self._state_field(state))
inline = state_match.group(2)
for component in re.findall(
r"\{?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?\}?", inline
):
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
)
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in inline
continue
if not state:
continue
if stripped == "}" or stripped.startswith("AccessibilityInputFilter"):
state = None
continue
component_match = re.search(
r"(?:\d+\s*:\s*)?([\w.$-]+/[\w.$-]+)(?:\s+\(A11yTool\))?",
stripped,
)
if component_match:
component = component_match.group(1)
service = services.setdefault(
(user_id, component), self._new_service(component, user_id)
)
service[self._state_field(state)] = True
service["accessibility_tool"] = "(A11yTool)" in stripped
# A section that was never printed is NOT the same as one printed
# empty: the first says nothing, the second says nothing is enabled.
# Defaulting every flag to False would turn "not stated" into "not
# enabled". Flags for sections this dump never printed stay None.
named: dict[int | None, int] = {}
for (service_user, _component), service in services.items():
printed = seen_states.get(service_user, set())
for state in ("installed", "enabled", "binding", "bound", "crashed"):
if self._state_field(state) not in printed:
service[self._state_field(state)] = None
service["installed_service_count"] = installed_counts.get(service_user)
named[service_user] = named.get(service_user, 0) + 1
self.results.extend(services.values())
# A stated count the named services do not add up to would leave no
# trace of the rest: the module would log "a total of 0" about a dump
# that said five, or "a total of 1" about one that said two.
for count_user, count in installed_counts.items():
unnamed = count - named.get(count_user, 0)
if unnamed > 0:
self.results.append(self._new_unlisted(count_user, count, unnamed))
@staticmethod
def _describe_state(result: dict) -> str:
if result.get("bound"):
return "enabled and bound"
if result.get("enabled"):
return "enabled"
if result.get("enabled") is None:
return "installed, enabled state not stated"
return "installed, not enabled"
@staticmethod
def _state_field(state: str) -> str:
return {"binding": "binding", "bound": "bound"}.get(state, state)
@staticmethod
def _new_unlisted(user_id: int | None, count: int, unnamed: int) -> dict:
"""The dump's own count for a user, and how many of those services it
did not name.
Every other field stays unknown: the dump named no service to carry it.
"""
record: dict[str, Any] = dict.fromkeys(DumpsysAccessibilityArtifact._FIELDS)
record["user_id"] = user_id
record["installed_service_count"] = count
record["unnamed_service_count"] = unnamed
return record
@staticmethod
def _new_service(component: str, user_id: int | None) -> dict:
record: dict[str, Any] = dict.fromkeys(
DumpsysAccessibilityArtifact._FIELDS, False
)
record["user_id"] = user_id
record["component"] = component
record["package_name"], record["service_name"] = component.split("/", 1)
# Filled in after parsing: the count is per user.
record["installed_service_count"] = None
record["unnamed_service_count"] = None
return record
+112 -9
View File
@@ -6,6 +6,7 @@
import base64
import binascii
import hashlib
import re
from .artifact import AndroidArtifact
@@ -13,6 +14,13 @@ from .artifact import AndroidArtifact
class DumpsysADBArtifact(AndroidArtifact):
multiline_fields = ["user_keys", "keystore"]
@staticmethod
def is_structural_line(key: str, vals: list) -> bool:
if key == "}":
return True
# XML keystore continuations also split on "=", but never into an identifier.
return len(vals) == 2 and key.isidentifier()
def indented_dump_parser(self, dump_data):
"""
Parse the indented dumpsys output, generated by DualDumpOutputStream in Android.
@@ -21,11 +29,26 @@ class DumpsysADBArtifact(AndroidArtifact):
stack = [res]
cur_indent = 0
in_multiline = False
for line in dump_data.strip(b"\n").split(b"\n"):
for line in dump_data.strip(b"\r\n").split(b"\n"):
line = line.removesuffix(b"\r")
# Track the level of indentation
indent = len(line) - len(line.lstrip())
if indent < cur_indent:
# A closing brace is dedented from the block it closes, and the
# brace handler below pops that block. Popping on the dedent as
# well would close two levels for one brace. A blank line has no
# indentation to compare: it ends a multiline value (handled
# below) and is otherwise skipped.
if indent < cur_indent and line.strip() not in (b"", b"}"):
# If the current line is less indented than the previous one, back out
if len(stack) <= 1:
# Dedenting below the outermost level means this is not the
# well-formed block the parser expects. Stop here rather than
# raise IndexError out of the module on the next line.
self.log.error(
"Unexpected indentation in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop()
cur_indent = indent
else:
@@ -41,12 +64,29 @@ class DumpsysADBArtifact(AndroidArtifact):
if key == "":
# If the line is empty, it's the terminator for the multiline value
in_multiline = False
stack.pop()
else:
if isinstance(stack[-1], list):
stack.pop()
continue
if not self.is_structural_line(key, vals):
current_dict.append(line.lstrip())
continue
in_multiline = False
if isinstance(stack[-1], list):
stack.pop()
current_dict = stack[-1]
if not line.strip():
continue
if key == "}":
if len(stack) <= 1:
self.log.error(
"Unbalanced closing brace in ADB manager state, "
"stopping the parse of this section"
)
break
stack.pop()
continue
@@ -83,6 +123,34 @@ class DumpsysADBArtifact(AndroidArtifact):
return keystore
def parse_binary_xml(self, data: bytes) -> list[dict]:
"""Recover ADB key records from Android binary XML (ABX).
Some dumpstate implementations embed ABX in a text stream and replace
binary token bytes. The public key remains intact, while unavailable
numeric metadata is represented as ``None`` rather than corrupt text.
"""
keystore = []
seen = set()
for match in re.finditer(
rb"(?<![A-Za-z0-9+/])([A-Za-z0-9+/]{300,}={0,2})"
rb"(?: ([A-Za-z0-9_.@-]+))?",
data,
):
key = match.group(1)
try:
base64.b64decode(key, validate=True)
except (binascii.Error, ValueError):
continue
if key in seen:
continue
seen.add(key)
full_key = key + (b" " + match.group(2) if match.group(2) else b"")
key_info = self.calculate_key_info(full_key)
key_info["last_connected"] = None
keystore.append(key_info)
return keystore
@staticmethod
def calculate_key_info(user_key: bytes) -> dict:
if b" " in user_key:
@@ -103,7 +171,7 @@ class DumpsysADBArtifact(AndroidArtifact):
return {
"user": user.decode("utf-8"),
"fingerprint": key_fingerprint_colon,
"key": key_base64,
"key": key_base64.decode("ascii", errors="replace"),
}
def check_indicators(self) -> None:
@@ -117,6 +185,26 @@ class DumpsysADBArtifact(AndroidArtifact):
f"'{user_key['fingerprint']}'"
)
@staticmethod
def _find_state_end(content: bytes, open_brace: int) -> int:
"""Find the unindented line closing the ADB manager state, or -1.
Braces inside key comments or embedded keystore data are values, while
nested structural closing braces are indented.
"""
line_start = open_brace
while line_start < len(content):
line_end = content.find(b"\n", line_start)
if line_end == -1:
line_end = len(content)
line = content[line_start:line_end].removesuffix(b"\r")
if line_start > open_brace and line == b"}":
return line_start
if line.startswith((b"---------", b"DUMP OF SERVICE ")):
break
line_start = line_end + 1
return -1
def parse(self, content: bytes) -> None:
"""
Parse the Dumpsys ADB section
@@ -136,12 +224,16 @@ class DumpsysADBArtifact(AndroidArtifact):
self.log.error("Unable to find ADB manager state in dumpsys output")
return
end_of_json = content.rfind(b"}\n")
if end_of_json == -1 or end_of_json <= start_of_json:
self.log.error("Unable to find complete ADB manager state in dumpsys output")
end_of_json = self._find_state_end(content, start_of_json + 1)
if end_of_json == -1:
self.log.error(
"Unable to find complete ADB manager state in dumpsys output"
)
return
json_content = content[start_of_json + 2 : end_of_json - 2].rstrip()
# The brace that opens the state and the one that closes it are not part
# of the indented body.
json_content = content[start_of_json + 2 : end_of_json].rstrip()
parsed = self.indented_dump_parser(json_content)
if parsed.get("debugging_manager") is None:
@@ -160,12 +252,23 @@ class DumpsysADBArtifact(AndroidArtifact):
# Keystore is in XML format on some devices and we need to parse it
if keystore_data and keystore_data.startswith(b"<?xml"):
parsed["debugging_manager"]["keystore"] = self.parse_xml(keystore_data)
elif keystore_data and keystore_data.startswith(b"ABX\x00"):
parsed["debugging_manager"]["keystore"] = self.parse_binary_xml(
keystore_data
)
else:
# Keystore is not XML format
parsed["debugging_manager"]["keystore"] = keystore_data
parsed = parsed["debugging_manager"]
for key, value in list(parsed.items()):
if isinstance(value, bytes):
decoded = value.decode("utf-8", errors="replace")
parsed[key] = (
decoded == "true" if decoded in ("true", "false") else decoded
)
# Calculate key fingerprints for better readability
key_info = []
for user_key in parsed.get("user_keys", []):
+122 -81
View File
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from datetime import datetime
import re
from typing import Any
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -27,14 +28,14 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue
for entry in perm["entries"]:
if "timestamp" in entry:
if entry.get("timestamp"):
records.append(
{
"timestamp": entry["timestamp"],
"module": self.__class__.__name__,
"event": entry["access"],
"event": entry["event"],
"data": f"{result['package_name']} access to "
f"{perm['name']}: {entry['access']}",
f"{perm['name']}: {entry['event']}",
}
)
@@ -51,7 +52,7 @@ class DumpsysAppopsArtifact(AndroidArtifact):
continue
# We use a placeholder entry to create a basic alert even without permission entries.
placeholder_entry = {"access": "Unknown", "timestamp": ""}
placeholder_entry = {"event": "unknown", "timestamp": ""}
for perm in result["permissions"]:
if (
@@ -60,12 +61,12 @@ class DumpsysAppopsArtifact(AndroidArtifact):
):
for entry in sorted(
perm["entries"] or [placeholder_entry],
key=lambda x: x["timestamp"],
key=lambda x: x.get("timestamp") or "",
):
cleaned_result = result.copy()
cleaned_result["permissions"] = [perm]
self.alertstore.medium(
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['access']}' at {entry['timestamp']}",
f"Package '{result['package_name']}' had risky permission '{perm['name']}' set to '{entry['event']}' at {entry['timestamp']}",
entry["timestamp"],
cleaned_result,
)
@@ -73,111 +74,151 @@ class DumpsysAppopsArtifact(AndroidArtifact):
elif result["package_name"] in RISKY_PACKAGES:
for entry in sorted(
perm["entries"] or [placeholder_entry],
key=lambda x: x["timestamp"],
key=lambda x: x.get("timestamp") or "",
):
cleaned_result = result.copy()
cleaned_result["permissions"] = [perm]
self.alertstore.medium(
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['access']}' at {entry['timestamp']}",
f"Risky package '{result['package_name']}' had '{perm['name']}' permission set to '{entry['event']}' at {entry['timestamp']}",
entry["timestamp"],
cleaned_result,
)
def parse(self, output: str) -> None:
# self.results: List[Dict[str, Any]] = []
perm: dict[str, Any] = {}
package: dict[str, Any] = {}
entry: dict[str, Any] = {}
uid = None
self.results: list[dict[str, Any]] = []
permission: dict[str, Any] | None = None
package: dict[str, Any] | None = None
uid: str | None = None
uid_details: dict[str, Any] = {}
attribution: str | None = None
in_packages = False
def finish_permission() -> None:
nonlocal permission
if package is not None and permission is not None:
package["permissions"].append(permission)
permission = None
def finish_package() -> None:
nonlocal package
finish_permission()
if package is not None:
self.results.append(package)
package = None
for line in output.splitlines():
if line.startswith(" Uid 0:"):
uid_match = re.match(r"^ Uid ([^:]+):$", line)
if uid_match:
in_packages = True
finish_package()
uid = uid_match.group(1)
uid_details = {
"uid_state": None,
"capability": None,
"app_widget_visible": None,
"default_modes": {},
}
continue
if not in_packages:
continue
if line.startswith(" Uid "):
uid = line[6:-1]
if entry:
perm["entries"].append(entry)
entry = {}
if package:
if perm:
package["permissions"].append(perm)
uid_property = re.match(
r"^ (state|capability|appWidgetVisible)=(.*)$", line
)
if uid_property:
key = {
"state": "uid_state",
"appWidgetVisible": "app_widget_visible",
}.get(uid_property.group(1), uid_property.group(1))
value: Any = uid_property.group(2)
if value in ("true", "false"):
value = value == "true"
uid_details[key] = value
continue
perm = {}
self.results.append(package)
package = {}
default_mode = re.match(r"^ ([A-Z0-9_]+): mode=([^\s]+)", line)
if default_mode and package is None:
uid_details["default_modes"][default_mode.group(1)] = (
default_mode.group(2)
)
continue
if line.startswith(" Package "):
if entry:
perm["entries"].append(entry)
entry = {}
if package:
if perm:
package["permissions"].append(perm)
perm = {}
self.results.append(package)
finish_package()
package = {
"package_name": line[12:-1],
"permissions": [],
"uid": uid,
**uid_details,
}
continue
if package and line.startswith(" ") and line[6] != " ":
if entry:
perm["entries"].append(entry)
entry = {}
if perm:
package["permissions"].append(perm)
perm = {}
perm["name"] = line.split()[0]
perm["entries"] = []
if len(line.split()) > 1:
perm["access"] = line.split()[1][1:-2]
operation_match = re.match(
r"^ ([A-Z0-9_]+)(?: \(([^)]+)\))?:\s*$", line
)
if package is not None and operation_match:
finish_permission()
permission = {
"name": operation_match.group(1),
"mode": operation_match.group(2),
"entries": [],
}
attribution = None
continue
if line.startswith(" "):
# Permission entry like:
# Reject: [fg-s]2021-05-19 22:02:52.054 (-314d1h25m2s33ms)
access_type = line.split(":")[0].strip()
if access_type not in ["Access", "Reject"]:
# Skipping invalid access type. Some entries are not in the format we expect
continue
attribution_match = re.match(r"^\s{8,}([^=]+)=\[$", line)
if attribution_match:
attribution = attribution_match.group(1).strip()
continue
if line.strip() == "]":
attribution = None
continue
if entry:
perm["entries"].append(entry)
entry = {}
entry["access"] = access_type
entry["type"] = line[line.find("[") + 1 : line.find("]")]
try:
entry["timestamp"] = convert_datetime_to_iso(
datetime.strptime(
line[line.find("]") + 1 : line.find("(")].strip(),
"%Y-%m-%d %H:%M:%S.%f",
)
if permission is None:
continue
event_match = re.match(
r"^\s*(Access|Reject):\s*\[([^]]+)\]\s*"
r"(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+)\s*"
r"(\([^)]*\))?(?:\s+duration=([^\s]+))?",
line,
)
running_match = re.match(
r"^\s*Running start at:\s*(\S+(?: \S+)?)",
line,
)
if event_match:
entry = {
"event": event_match.group(1).lower(),
"access": event_match.group(1),
"uid_state": event_match.group(2),
"timestamp": convert_datetime_to_iso(
datetime.strptime(event_match.group(3), "%Y-%m-%d %H:%M:%S.%f")
),
"relative_time": event_match.group(4),
"duration": event_match.group(5),
"attribution": attribution,
}
permission["entries"].append(entry)
elif running_match:
raw_start = running_match.group(1)
timestamp = None
if re.fullmatch(r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", raw_start):
timestamp = convert_datetime_to_iso(
datetime.strptime(raw_start, "%Y-%m-%d %H:%M:%S.%f")
)
except ValueError:
# Invalid date format
pass
permission["entries"].append(
{
"event": "running",
"access": "Running",
"uid_state": None,
"timestamp": timestamp,
"relative_time": raw_start
if raw_start.startswith("+")
else None,
"duration": None,
"attribution": attribution,
}
)
if line.strip() == "":
break
if entry:
perm["entries"].append(entry)
if perm:
package["permissions"].append(perm)
if package:
self.results.append(package)
finish_package()
@@ -18,18 +18,18 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
action = record.get("action", "update")
package_name = record["package_name"]
vers = record["vers"]
vers = record["version_code"]
if vers == "0":
if vers == 0:
data = f"Recorded uninstall of package {package_name} (vers 0)"
elif action == "downgrade":
prev_vers = record.get("previous_vers", "unknown")
prev_vers = record.get("previous_version_code", "unknown")
data = f"Recorded downgrade of package {package_name} from vers {prev_vers} to vers {vers}"
else:
data = f"Recorded update of package {package_name} with vers {vers}"
return {
"timestamp": record["from"],
"timestamp": record["period_start"],
"module": self.__class__.__name__,
"event": "battery_daily",
"data": data,
@@ -48,6 +48,7 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
continue
def parse(self, output: str) -> None:
self.results = []
daily = None
daily_updates: list[dict[str, Any]] = []
records: list[dict[str, Any]] = []
@@ -59,7 +60,10 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
timeframe = line[13:].strip()
date_from, date_to = timeframe.strip(":").split(" to ", 1)
daily = {"from": date_from[0:10], "to": date_to[0:10]}
daily = {
"period_start": self._format_daily_timestamp(date_from),
"period_end": self._format_daily_timestamp(date_to),
}
continue
if not daily:
@@ -69,22 +73,35 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
continue
line = line.strip().replace("Update ", "")
package_name, vers = line.split(" ", 1)
vers_nr = vers.split("=", 1)[1]
# A truncated or vendor-specific line must not abort the parse and
# lose every record after it.
package_name, _, vers = line.partition(" ")
vers_raw = vers.partition("=")[2]
if not package_name or not vers_raw:
continue
try:
version_code: int | str = int(vers_raw)
except ValueError:
version_code = vers_raw
already_seen = False
for update in daily_updates:
if package_name == update["package_name"] and vers_nr == update["vers"]:
if (
package_name == update["package_name"]
and version_code == update["version_code"]
):
update["occurrences"] += 1
already_seen = True
break
if not already_seen:
update_record: dict[str, Any] = {
"action": "update",
"from": daily["from"],
"to": daily["to"],
"period_start": daily["period_start"],
"period_end": daily["period_end"],
"package_name": package_name,
"vers": vers_nr,
"version_code": version_code,
"occurrences": 1,
}
daily_updates.append(update_record)
@@ -95,26 +112,31 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
self._detect_uninstalls_and_downgrades(records)
self.results.extend(records)
def _detect_uninstalls_and_downgrades(
self, records: list[dict[str, Any]]
) -> None:
@staticmethod
def _format_daily_timestamp(value: str) -> str:
if len(value) >= 19 and value[10] == "-":
return f"{value[:10]} {value[11:].replace('-', ':')}"
return value
def _detect_uninstalls_and_downgrades(self, records: list[dict[str, Any]]) -> None:
package_versions: dict[str, int] = {}
for record in sorted(
records,
key=lambda record: (
record["from"],
record["to"],
record["period_start"],
record["period_end"],
record["package_name"],
),
):
package_name = record["package_name"]
vers_nr = record["vers"]
vers_nr = record["version_code"]
if vers_nr == "0":
if vers_nr == 0:
record["action"] = "uninstall"
self.alertstore.medium(
f"Detected uninstall of package {package_name} (vers 0)",
record["from"],
record["period_start"],
record,
)
package_versions.pop(package_name, None)
@@ -128,11 +150,11 @@ class DumpsysBatteryDailyArtifact(AndroidArtifact):
previous_vers = package_versions.get(package_name)
if previous_vers is not None and current_vers < previous_vers:
record["action"] = "downgrade"
record["previous_vers"] = str(previous_vers)
record["previous_version_code"] = previous_vers
self.alertstore.medium(
f"Detected downgrade of package {package_name} "
f"from vers {previous_vers} to vers {current_vers}",
record["from"],
record["period_start"],
record,
)
@@ -1,79 +1,177 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import datetime
import re
from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact
class DumpsysBatteryHistoryArtifact(AndroidArtifact):
"""
Parser for dumpsys dattery history events.
"""
"""Parser for package-related events in dumpsys batterystats history."""
def check_indicators(self) -> None:
if not self.indicators:
return
for result in self.results:
ioc_match = self.indicators.check_app_id(result["package_name"])
package_name = result.get("package_name")
if not package_name:
continue
ioc_match = self.indicators.check_app_id(package_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue
@staticmethod
def _parse_wall_time(value: str) -> datetime.datetime | None:
if re.fullmatch(r"\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+", value):
value = f"1900-{value}"
for date_format in (
"%Y-%m-%d-%H-%M-%S-%f",
"%Y-%m-%d-%H-%M-%S",
"%Y-%m-%d %H:%M:%S.%f",
):
try:
return datetime.datetime.strptime(value, date_format)
except ValueError:
pass
return None
@staticmethod
def _elapsed_seconds(value: str) -> float | None:
if not value.startswith("+"):
return None
units = {"d": 86400, "h": 3600, "m": 60, "s": 1, "ms": 0.001}
total = 0.0
for number, unit in re.findall(r"(\d+)(ms|d|h|m|s)", value):
total += int(number) * units[unit]
return total
@staticmethod
def _package_from_name(name: str) -> str | None:
clean = name.removeprefix("*walarm*:").removeprefix("*alarm*:")
slash_parts = clean.split("/")
if len(slash_parts) > 1:
first = slash_parts[0].lstrip("@")
if first.startswith(("com.", "org.", "net.")):
return first
for part in reversed(slash_parts[1:]):
candidate = part.lstrip("@").split(":", 1)[0]
if candidate.startswith(("com.", "org.", "net.")):
return candidate
parts = clean.split(".")
package_parts = []
for part in parts:
if part and part[0].islower():
package_parts.append(part)
else:
break
return ".".join(package_parts) if len(package_parts) >= 2 else None
@staticmethod
def _normalize_service(service: str) -> str:
# WorkManager decorates jobs with one or more scheduler prefixes.
if "@" in service:
candidates = [part for part in service.split("@") if "/" in part]
if candidates:
return candidates[-1]
return service
def parse(self, data: str) -> None:
self.results: list[dict[str, str | None]] = []
anchor_time: datetime.datetime | None = None
anchor_elapsed = 0.0
has_history_heading = any(
line.startswith("Battery History") for line in data.splitlines()
)
in_history = not has_history_heading
for line in data.splitlines():
if line.startswith("Battery History "):
stripped = line.strip()
if line.startswith("Battery History"):
if in_history:
break
in_history = True
continue
if line.strip() == "":
if not in_history:
continue
if has_history_heading and not stripped:
break
time_elapsed = line.strip().split(" ", 1)[0]
event = ""
if line.find("+job") > 0:
event = "start_job"
uid = line[line.find("+job") + 5 : line.find(":")]
service = line[line.find(":") + 1 :].strip('"')
package_name = service.split("/")[0]
elif line.find("-job") > 0:
event = "end_job"
uid = line[line.find("-job") + 5 : line.find(":")]
service = line[line.find(":") + 1 :].strip('"')
package_name = service.split("/")[0]
elif line.find("+running +wake_lock=") > 0:
uid = line[line.find("+running +wake_lock=") + 21 : line.find(":")]
event = "wake"
service = (
line[line.find("*walarm*:") + 9 :].split(" ")[0].strip('"').strip()
)
if service == "" or "/" not in service:
continue
package_name = service.split("/")[0]
elif (line.find("+top=") > 0) or (line.find("-top") > 0):
if line.find("+top=") > 0:
event = "start_top"
top_pos = line.find("+top=")
else:
event = "end_top"
top_pos = line.find("-top=")
colon_pos = top_pos + line[top_pos:].find(":")
uid = line[top_pos + 5 : colon_pos]
service = ""
package_name = line[colon_pos + 1 :].strip('"')
else:
reset_match = re.search(r"(?:RESET:)?TIME:\s*(\S+)", stripped)
if reset_match:
parsed_time = self._parse_wall_time(reset_match.group(1))
if parsed_time is not None:
elapsed_token = stripped.split()[0]
anchor_elapsed = self._elapsed_seconds(elapsed_token) or 0.0
anchor_time = parsed_time
continue
self.results.append(
{
"time_elapsed": time_elapsed,
"event": event,
"uid": uid,
"package_name": package_name,
"service": service,
}
)
fields = stripped.split()
if not fields:
continue
if len(fields) > 1 and re.fullmatch(r"\d{2}-\d{2}", fields[0]):
time_elapsed = " ".join(fields[:2])
line_time = self._parse_wall_time(time_elapsed)
elapsed = None
else:
time_elapsed = fields[0]
elapsed = self._elapsed_seconds(time_elapsed)
line_time = None
timestamp = line_time
if timestamp is None and anchor_time is not None and elapsed is not None:
timestamp = anchor_time + datetime.timedelta(
seconds=elapsed - anchor_elapsed
)
def add(
event: str, uid: str, service: str, package_name: str | None
) -> None:
self.results.append(
{
"time_elapsed": time_elapsed,
"timestamp": convert_datetime_to_iso(timestamp)
if timestamp
else None,
"event": event,
"uid": uid,
"package_name": package_name,
"service": service,
}
)
for sign, uid, raw_service in re.findall(
r"([+-])job=([^:\s]+):\"([^\"]+)\"", line
):
service = self._normalize_service(raw_service)
add(
"start_job" if sign == "+" else "end_job",
uid,
service,
self._package_from_name(service) or service.split("/", 1)[0],
)
for sign, uid, package_name in re.findall(
r"([+-])top=([^:\s]+):\"([^\"]+)\"", line
):
add(
"start_top" if sign == "+" else "end_top",
uid,
"",
package_name,
)
wake_match = re.search(r"\+wake_lock=([^:\s]+):\"([^\"]+)\"", line)
if wake_match:
wake_name = wake_match.group(2)
add(
"wake",
wake_match.group(1),
wake_name,
self._package_from_name(wake_name),
)
+32 -32
View File
@@ -29,17 +29,27 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
def parse(self, output: str) -> None:
rxp = re.compile(
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\].*\[Pid:\((\d+)\)\](\w+).*sql\=\"(.+?)\""
) # pylint: disable=line-too-long
rxp_no_pid = re.compile(
r".*\[([0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3})\][ ]{1}(\w+).*sql\=\"(.+?)\""
) # pylint: disable=line-too-long
r"^\s*\d+:\s*\[((?:\d{4}-)?\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3})\]\s*"
r"(?:\[Pid:\((\d+)\)\])?\s*([\w-]+) took (\d+)ms - ([^,]+),"
r"\s*sql=\"(.*)\"(?:, path=(.*))?$"
)
pool = None
pool: str | None = None
connection_number: int | None = None
is_primary: bool | None = None
in_operations = False
for line in output.splitlines():
if line.startswith("Connection pool for "):
pool = line.replace("Connection pool for ", "").rstrip(":")
in_operations = False
connection_match = re.match(r"\s+Connection #(\d+):", line)
if connection_match:
connection_number = int(connection_match.group(1))
is_primary = None
if line.strip().startswith("isPrimaryConnection:"):
is_primary = line.strip().split(":", 1)[1].strip() == "true"
if not pool:
continue
@@ -53,32 +63,22 @@ class DumpsysDBInfoArtifact(AndroidArtifact):
if not line.startswith(" "):
in_operations = False
pool = None
continue
matches = rxp.findall(line)
if not matches:
matches = rxp_no_pid.findall(line)
if not matches:
continue
match = rxp.match(line)
if not match:
continue
match = matches[0]
self.results.append(
{
"isodate": match[0],
"action": match[1],
"sql": match[2],
"path": pool,
}
)
else:
match = matches[0]
self.results.append(
{
"isodate": match[0],
"pid": match[1],
"action": match[2],
"sql": match[3],
"path": pool,
}
)
result = {
"timestamp": match.group(1),
"pid": int(match.group(2)) if match.group(2) else None,
"action": match.group(3),
"duration_ms": int(match.group(4)),
"status": match.group(5),
"sql": match.group(6),
"path": match.group(7) or pool,
"pool_path": pool,
"connection_number": connection_number,
"is_primary": is_primary,
}
self.results.append(result)
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
class DumpsysPackageActivitiesArtifact(AndroidArtifact):
@@ -19,67 +20,11 @@ class DumpsysPackageActivitiesArtifact(AndroidArtifact):
)
continue
def parse(self, content: str):
def parse(self, content: str) -> None:
"""
Parse the Dumpsys Package section for activities
Adds results to self.results
:param content: content of the package section (string)
"""
self.results = []
in_activity_resolver_table = False
in_non_data_actions = False
intent = None
for line in content.splitlines():
if line.startswith("Activity Resolver Table:"):
in_activity_resolver_table = True
continue
if not in_activity_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
activity = line.strip().split(" ")[1]
package_name = activity.split("/")[0]
self.results.append(
{
"intent": intent,
"package_name": package_name,
"activity": activity,
}
)
self.results = parse_resolver_table(content, "Activity")
+206 -158
View File
@@ -1,10 +1,10 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
from typing import Any, Dict, List
from typing import Any
from mvt.android.utils import ROOT_PACKAGES
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
@@ -12,196 +12,244 @@ from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from .artifact import AndroidArtifact
def _value(raw: str) -> Any:
if raw == "null":
return None
if raw in ("true", "false"):
return raw == "true"
try:
return int(raw)
except ValueError:
return raw
class DumpsysPackagesArtifact(AndroidArtifact):
def check_indicators(self) -> None:
alerted_root_packages = set()
for result in self.results:
if result["package_name"] in ROOT_PACKAGES:
if result["package_name"] in alerted_root_packages:
continue
alerted_root_packages.add(result["package_name"])
package_name = result["package_name"]
if (
package_name in ROOT_PACKAGES
and package_name not in alerted_root_packages
):
alerted_root_packages.add(package_name)
self.alertstore.medium(
f'Found an installed package related to rooting/jailbreaking: "{result["package_name"]}"',
f'Found an installed package related to rooting/jailbreaking: "{package_name}"',
"",
result,
)
continue
if not self.indicators:
continue
ioc_match = self.indicators.check_app_id(result.get("package_name", ""))
ioc_match = self.indicators.check_app_id(package_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
def serialize(self, record: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
timestamps = [
{"event": "package_install", "timestamp": record["timestamp"]},
("package_install", record.get("timestamp")),
("package_last_update", record.get("last_update_time")),
]
timestamps.extend(
("package_first_install", user.get("first_install_time"))
for user in record.get("users", [])
)
return [
{
"event": "package_first_install",
"timestamp": record["first_install_time"],
},
{"event": "package_last_update", "timestamp": record["last_update_time"]},
"timestamp": timestamp,
"module": self.__class__.__name__,
"event": event,
"data": f"Install or update of package {record['package_name']}",
}
for event, timestamp in timestamps
if timestamp
]
for timestamp in timestamps:
records.append(
{
"timestamp": timestamp["timestamp"],
"module": self.__class__.__name__,
"event": timestamp["event"],
"data": f"Install or update of package {record['package_name']}",
}
)
return records
@staticmethod
def parse_dumpsys_package_for_details(output: str) -> Dict[str, Any]:
"""
Parse one entry of a dumpsys package information
"""
details: Dict[str, Any] = {
"uid": "",
"version_name": "",
"version_code": "",
"timestamp": "",
"first_install_time": "",
"last_update_time": "",
"permissions": list(),
"requested_permissions": list(),
def _permission(line: str, permission_type: str) -> dict:
name, _, details = line.strip().partition(":")
granted_match = re.search(r"granted=(true|false)", details)
flags_match = re.search(r"flags=\[\s*([^]]*)\]", details)
return {
"name": name,
"type": permission_type,
"granted": granted_match.group(1) == "true" if granted_match else None,
"flags": [
flag.strip()
for flag in (flags_match.group(1).split("|") if flags_match else [])
if flag.strip()
],
}
in_install_permissions = False
in_runtime_permissions = False
in_declared_permissions = False
in_requested_permissions = True
@classmethod
def parse_dumpsys_package_for_details(cls, output: str) -> dict[str, Any]:
details: dict[str, Any] = {
"app_id": None,
"version_name": None,
"version_code": None,
"min_sdk": None,
"target_sdk": None,
"timestamp": None,
"last_update_time": None,
"installer": None,
"system": False,
"permissions": [],
"requested_permissions": [],
"users": [],
}
permission_section: str | None = None
current_user: dict[str, Any] | None = None
legacy_first_install: str | None = None
for line in output.splitlines():
if in_install_permissions:
if line.startswith(" " * 4) and not line.startswith(" " * 6):
in_install_permissions = False
else:
lineinfo = line.strip().split(":")
permission = lineinfo[0]
granted = None
if "granted=" in lineinfo[1]:
granted = "granted=true" in lineinfo[1]
stripped = line.strip()
user_match = re.match(r"User (\d+):\s*(.*)", stripped)
if user_match:
current_user = {"user_id": int(user_match.group(1)), "permissions": []}
for key, raw in re.findall(r"(\w+)=([^\s]+)", user_match.group(2)):
clean_key = {
"notLaunched": "not_launched",
"installReason": "install_reason",
"uninstallReason": "uninstall_reason",
"dataDir": "data_dir",
}.get(key, re.sub(r"(?<!^)(?=[A-Z])", "_", key).lower())
current_user[clean_key] = _value(raw)
details["users"].append(current_user)
permission_section = None
continue
details["permissions"].append(
{"name": permission, "granted": granted, "type": "install"}
)
if in_runtime_permissions:
if not line.startswith(" " * 8):
in_runtime_permissions = False
else:
lineinfo = line.strip().split(":")
permission = lineinfo[0]
granted = None
if "granted=" in lineinfo[1]:
granted = "granted=true" in lineinfo[1]
header = stripped.lower()
if header in {
"declared permissions:",
"install permissions:",
"requested permissions:",
"runtime permissions:",
}:
permission_section = header.split()[0]
continue
details["permissions"].append(
{"name": permission, "granted": granted, "type": "runtime"}
)
if in_declared_permissions:
if not line.startswith(" " * 6):
in_declared_permissions = False
else:
permission = line.strip().split(":")[0]
details["permissions"].append(
{"name": permission, "type": "declared"}
)
if in_requested_permissions:
if not line.startswith(" " * 6):
in_requested_permissions = False
else:
details["requested_permissions"].append(line.strip())
if line.strip().startswith("userId="):
details["uid"] = line.split("=")[1].strip()
elif line.strip().startswith("versionName="):
details["version_name"] = line.split("=")[1].strip()
elif line.strip().startswith("versionCode="):
details["version_code"] = line.split("=", 1)[1].strip()
elif line.strip().startswith("timeStamp="):
details["timestamp"] = line.split("=")[1].strip()
elif line.strip().startswith("firstInstallTime="):
details["first_install_time"] = line.split("=")[1].strip()
elif line.strip().startswith("lastUpdateTime="):
details["last_update_time"] = line.split("=")[1].strip()
elif line.strip() == "install permissions:":
in_install_permissions = True
elif line.strip() == "runtime permissions:":
in_runtime_permissions = True
elif line.strip() == "declared permissions:":
in_declared_permissions = True
elif line.strip() == "requested permissions:":
in_requested_permissions = True
return details
def parse_dumpsys_packages(self, output: str) -> List[Dict[str, Any]]:
"""
Parse the dumpsys package service data
"""
pkg_rxp = re.compile(r" Package \[(.+?)\].*")
results = []
package_name = None
package = {}
lines: list[str] = []
for line in output.splitlines():
if line.startswith(" Package ["):
if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
lines = []
package = {}
matches = pkg_rxp.findall(line)
if not matches:
if current_user is not None:
user_property = re.match(
r"(installReason|uninstallReason|dataDir|firstInstallTime)=(.*)",
stripped,
)
if user_property:
key = {
"installReason": "install_reason",
"uninstallReason": "uninstall_reason",
"dataDir": "data_dir",
"firstInstallTime": "first_install_time",
}[user_property.group(1)]
current_user[key] = _value(user_property.group(2))
continue
package_name = matches[0]
package["package_name"] = package_name
if permission_section == "requested" and line.startswith(" "):
details["requested_permissions"].append(stripped)
continue
if permission_section in ("declared", "install") and line.startswith(
" "
):
details["permissions"].append(
cls._permission(stripped, permission_section)
)
continue
if (
permission_section == "runtime"
and line.startswith(" ")
and current_user is not None
):
current_user["permissions"].append(cls._permission(stripped, "runtime"))
continue
if not package_name:
simple_match = re.match(
r"(appId|userId|versionName|timeStamp|lastUpdateTime|installerPackageName)=(.*)",
stripped,
)
if simple_match:
key = {
"appId": "app_id",
"userId": "app_id",
"versionName": "version_name",
"timeStamp": "timestamp",
"lastUpdateTime": "last_update_time",
"installerPackageName": "installer",
}[simple_match.group(1)]
raw_value = simple_match.group(2)
details[key] = (
_value(raw_value)
if key == "app_id" or raw_value == "null"
else raw_value
)
continue
if stripped.startswith("pkgFlags="):
details["system"] = "SYSTEM" in stripped.split("=", 1)[1].split()
continue
version_match = re.match(
r"versionCode=([^\s]+)(?:\s+minSdk=([^\s]+))?(?:\s+targetSdk=([^\s]+))?",
stripped,
)
if version_match:
details["version_code"] = _value(version_match.group(1))
details["min_sdk"] = (
_value(version_match.group(2)) if version_match.group(2) else None
)
details["target_sdk"] = (
_value(version_match.group(3)) if version_match.group(3) else None
)
elif stripped.startswith("firstInstallTime="):
legacy_first_install = stripped.split("=", 1)[1]
lines.append(line)
if legacy_first_install:
user_zero = next(
(user for user in details["users"] if user["user_id"] == 0), None
)
if user_zero is None:
user_zero = {"user_id": 0, "permissions": []}
details["users"].append(user_zero)
user_zero.setdefault("first_install_time", legacy_first_install)
return details
if len(lines) > 0:
details = self.parse_dumpsys_package_for_details("\n".join(lines))
package.update(details)
results.append(package)
def parse(self, content: str) -> None:
self.results: list[dict[str, Any]] = []
category: str | None = None
package: dict[str, Any] | None = None
block: list[str] = []
return results
def finish() -> None:
nonlocal package, block
if package is not None:
package.update(self.parse_dumpsys_package_for_details("\n".join(block)))
self.results.append(package)
package = None
block = []
def parse(self, content: str):
"""
Parse the Dumpsys Package section for activities
Adds results to self.results
:param content: content of the package section (string)
"""
self.results = []
package = []
in_package_list = False
for line in content.splitlines():
if line.startswith("Packages:"):
in_package_list = True
if line == "Packages:":
finish()
category = "active"
continue
if not in_package_list:
if line == "Hidden system packages:":
finish()
category = "hidden_system"
continue
if line.strip() == "":
break
package.append(line)
self.results = self.parse_dumpsys_packages("\n".join(package))
package_match = re.match(r"^ Package \[([^]]+)\]", line)
if package_match and category:
finish()
package = {
"package_name": package_match.group(1),
"package_type": category,
}
continue
if (
category
and line
and not line.startswith(" ")
and not line.endswith(" overlay paths:")
):
finish()
category = None
continue
if package is not None:
block.append(line)
finish()
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
from .artifact import AndroidArtifact
@@ -25,19 +27,37 @@ class DumpsysPlatformCompatArtifact(AndroidArtifact):
def parse(self, data: str) -> None:
for line in data.splitlines():
if not line.startswith("ChangeId(168419799; name=DOWNSCALED;"):
match = re.match(r"ChangeId\((\d+);\s*(.*)\)$", line.strip())
if not match or "rawOverrides={" not in line:
continue
if line.strip() == "":
break
# Look for rawOverrides field
if "rawOverrides={" in line:
# Extract the content inside the braces for rawOverrides
overrides_field = line.split("rawOverrides={", 1)[1].split("};", 1)[0]
for entry in overrides_field.split(", "):
# Extract app name
uninstall_app = entry.split("=")[0].strip()
self.results.append({"package_name": uninstall_app})
body = match.group(2)
name_match = re.search(r"(?:^|;\s*)name=([^;]+)", body)
state = (
"enabled"
if re.search(r"(?:^|;\s*)enabled(?:;|$)", body)
else "disabled"
)
overridable = bool(re.search(r"(?:^|;\s*)overridable(?:;|$)", body))
overrides_field = body.split("rawOverrides={", 1)[1].split("}", 1)[0]
for entry in overrides_field.split(","):
package_name, separator, raw_value = entry.strip().partition("=")
if not separator:
continue
value: bool | int | str
if raw_value in ("true", "false"):
value = raw_value == "true"
else:
try:
value = int(raw_value)
except ValueError:
value = raw_value
self.results.append(
{
"change_id": int(match.group(1)),
"change_name": name_match.group(1) if name_match else None,
"change_state": state,
"overridable": overridable,
"package_name": package_name,
"override_value": value,
}
)
+39 -96
View File
@@ -4,6 +4,7 @@
# https://license.mvt.re/1.1/
from .artifact import AndroidArtifact
from .package_resolvers import parse_resolver_table
INTENT_NEW_OUTGOING_SMS = "android.provider.Telephony.NEW_OUTGOING_SMS"
INTENT_SMS_RECEIVED = "android.provider.Telephony.SMS_RECEIVED"
@@ -18,103 +19,45 @@ class DumpsysReceiversArtifact(AndroidArtifact):
"""
def check_indicators(self) -> None:
for intent, receivers in self.results.items():
for receiver in receivers:
if intent == INTENT_NEW_OUTGOING_SMS:
self.log.info(
'Found a receiver to intercept outgoing SMS messages: "%s"',
receiver["receiver"],
)
elif intent == INTENT_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming SMS messages: "%s"',
receiver["receiver"],
)
elif intent == INTENT_DATA_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming data SMS message: "%s"',
receiver["receiver"],
)
elif intent == INTENT_PHONE_STATE:
self.log.info(
"Found a receiver monitoring "
'telephony state/incoming calls: "%s"',
receiver["receiver"],
)
elif intent == INTENT_NEW_OUTGOING_CALL:
self.log.info(
'Found a receiver monitoring outgoing calls: "%s"',
receiver["receiver"],
)
for receiver in self.results:
intent = receiver["key"]
if intent == INTENT_NEW_OUTGOING_SMS:
self.log.info(
'Found a receiver to intercept outgoing SMS messages: "%s"',
receiver["component"],
)
elif intent == INTENT_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming SMS messages: "%s"',
receiver["component"],
)
elif intent == INTENT_DATA_SMS_RECEIVED:
self.log.info(
'Found a receiver to intercept incoming data SMS message: "%s"',
receiver["component"],
)
elif intent == INTENT_PHONE_STATE:
self.log.info(
'Found a receiver monitoring telephony state/incoming calls: "%s"',
receiver["component"],
)
elif intent == INTENT_NEW_OUTGOING_CALL:
self.log.info(
'Found a receiver monitoring outgoing calls: "%s"',
receiver["component"],
)
if not self.indicators:
continue
if not self.indicators:
continue
ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
{intent: receiver},
matched_indicator=ioc_match.ioc,
)
continue
ioc_match = self.indicators.check_app_id(receiver["package_name"])
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
receiver,
matched_indicator=ioc_match.ioc,
)
def parse(self, output: str) -> None:
self.results: dict[str, list[dict[str, str]]] = {}
in_receiver_resolver_table = False
in_non_data_actions = False
intent = None
for line in output.splitlines():
if line.startswith("Receiver Resolver Table:"):
in_receiver_resolver_table = True
continue
if not in_receiver_resolver_table:
continue
if line.startswith(" Non-Data Actions:"):
in_non_data_actions = True
continue
if not in_non_data_actions:
continue
# If we hit an empty line, the Non-Data Actions section should be
# finished.
if line.strip() == "":
break
# We detect the action name.
if (
line.startswith(" " * 6)
and not line.startswith(" " * 8)
and ":" in line
):
intent = line.strip().replace(":", "")
self.results[intent] = []
continue
# If we are not in an intent block yet, skip.
if not intent:
continue
# If we are in a block but the line does not start with 8 spaces
# it means the block ended a new one started, so we reset and
# continue.
if not line.startswith(" " * 8):
intent = None
continue
# If we got this far, we are processing receivers for the
# activities we are interested in.
receiver = line.strip().split(" ")[1]
package_name = receiver.split("/")[0]
self.results[intent].append(
{
"package_name": package_name,
"receiver": receiver,
}
)
self.results = parse_resolver_table(output, "Receiver")
+9 -12
View File
@@ -28,19 +28,16 @@ INTERESTING_PROPERTIES = [
class GetProp(AndroidArtifact):
def parse(self, entry: str) -> None:
self.results: List[Dict[str, str]] = []
rxp = re.compile(r"\[(.+?)\]: \[(.+?)\]")
# A property value may span several lines: persist.sys.boot.reason.history
# prints one boot per line. Matching the whole section instead of line by
# line lets a value run to the first closing bracket that ends a line.
rxp = re.compile(
r"^[ \t]*\[([^]]+)\]: \[(.*?)\][ \t\r]*$",
re.MULTILINE | re.DOTALL,
)
for line in entry.splitlines():
line = line.strip()
if line == "":
continue
matches = re.findall(rxp, line)
if not matches or len(matches[0]) != 2:
continue
prop_entry = {"name": matches[0][0], "value": matches[0][1]}
self.results.append(prop_entry)
for name, value in rxp.findall(entry):
self.results.append({"name": name, "value": value})
def get_device_timezone(self) -> str | None:
"""
+57 -1
View File
@@ -117,6 +117,55 @@ class Mounts(AndroidArtifact):
# Skip lines that don't match expected format
continue
@staticmethod
def parse_mountinfo(entry: str, process_id: int) -> list[dict[str, Any]]:
"""Parse Linux /proc/PID/mountinfo records."""
results = []
for line in entry.splitlines():
fields = line.split()
if "-" not in fields:
continue
separator = fields.index("-")
if separator < 6 or len(fields) < separator + 4:
continue
try:
mount_id = int(fields[0])
parent_id = int(fields[1])
except ValueError:
continue
mount_options = fields[5].split(",")
super_options = fields[separator + 3].split(",")
options = list(dict.fromkeys(mount_options + super_options))
# fields[5] are the per-mount (VFS) flags, the field after the
# separator the superblock's; a mount is writable only if both
# allow it. The merged list made every read-only overlay a HIGH.
is_read_write = "rw" in mount_options and "rw" in super_options
mount_point = fields[4].replace("\\040", " ")
device = fields[separator + 2].replace("\\040", " ")
filesystem_type = fields[separator + 1]
is_system = mount_point in SUSPICIOUS_MOUNT_POINTS or any(
mount_point.startswith(f"{prefix}/")
for prefix in SUSPICIOUS_MOUNT_POINTS
)
results.append(
{
"mount_id": mount_id,
"parent_id": parent_id,
"major_minor": fields[2],
"root": fields[3].replace("\\040", " "),
"mount_point": mount_point,
"device": device,
"filesystem_type": filesystem_type,
"mount_options": ",".join(options),
"options_list": options,
"optional_fields": fields[6:separator],
"is_system_partition": is_system,
"is_read_write": is_read_write,
"process_ids": [process_id],
}
)
return results
def check_indicators(self) -> None:
"""
Check for suspicious mount configurations that may indicate root access
@@ -146,7 +195,14 @@ class Mounts(AndroidArtifact):
)
# Check for other suspicious mount options
suspicious_opts = [opt for opt in options if opt in SUSPICIOUS_OPTIONS]
# `rw` sitting in the merged option list is not evidence of
# writability (see is_read_write in parse_mountinfo); every other
# suspicious option is meaningful in either list.
suspicious_opts = [
opt
for opt in options
if opt in SUSPICIOUS_OPTIONS and (opt != "rw" or mount["is_read_write"])
]
if suspicious_opts and mount["is_system_partition"]:
if (
"noatime" in mount["mount_options"]
@@ -0,0 +1,70 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
RESOLVER_TYPES = {
"Full MIME Types": "full_mime_type",
"Base MIME Types": "base_mime_type",
"Wild MIME Types": "wild_mime_type",
"Schemes": "scheme",
"Non-Data Actions": "non_data_action",
"MIME Typed Actions": "mime_typed_action",
}
ENTRY_RE = re.compile(
r"^\s{8,}[0-9a-fA-F]+\s+(?P<component>\S+)"
r"(?:\s+\((?P<filters>\d+)\s+filters?\))?\s*$"
)
def parse_resolver_table(content: str, table_name: str) -> list[dict]:
"""Parse every resolver category from a dumpsys package table."""
results: list[dict] = []
in_table = False
resolver_type: str | None = None
key: str | None = None
for line in content.splitlines():
if line.startswith(f"{table_name} Resolver Table:"):
in_table = True
continue
if not in_table:
continue
if line and not line.startswith(" "):
break
section_match = re.match(r"^ {2}([^ ].*):\s*$", line)
if section_match:
resolver_type = RESOLVER_TYPES.get(section_match.group(1))
key = None
continue
if resolver_type is None:
continue
key_match = re.match(r"^ {6,}([^ ].*):\s*$", line)
if key_match:
key = key_match.group(1)
continue
if key is None:
continue
entry_match = ENTRY_RE.match(line)
if not entry_match:
continue
component = entry_match.group("component")
results.append(
{
"resolver_type": resolver_type,
"key": key,
"package_name": component.split("/", 1)[0],
"component": component,
"filter_count": int(entry_match.group("filters") or 1),
}
)
return results
+73 -54
View File
@@ -1,72 +1,91 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from .artifact import AndroidArtifact
FIELD_NAMES = {
"LABEL": "label",
"USER": "user",
"PID": "pid",
"TID": "tid",
"PPID": "ppid",
"VSZ": "virtual_memory_size",
"RSS": "resident_set_size",
"WCHAN": "wchan",
"ADDR": "address",
"S": "state",
"PRI": "priority",
"NI": "nice",
"RTPRIO": "realtime_priority",
"SCH": "scheduler",
"PCY": "policy",
"TIME": "cpu_time",
"CMD": "command",
"NAME": "command",
}
INTEGER_FIELDS = {
"pid",
"tid",
"ppid",
"virtual_memory_size",
"resident_set_size",
"priority",
"nice",
}
class Processes(AndroidArtifact):
def parse(self, entry: str) -> None:
for line in entry.splitlines()[1:]:
proc = line.split()
self.results = []
lines = [line for line in entry.splitlines() if line.strip()]
if not lines:
return
headers = lines[0].split()
if not all(header in FIELD_NAMES for header in headers):
return
# Skip empty lines
if len(proc) == 0:
for line in lines[1:]:
values = line.split(None, len(headers) - 1)
if len(values) != len(headers):
continue
# Sometimes WCHAN is empty.
if len(proc) == 8:
proc = proc[:5] + [""] + proc[5:]
# Sometimes there is the security label.
if proc[0].startswith("u:r"):
label = proc[0]
proc = proc[1:]
else:
label = ""
# Sometimes there is no WCHAN.
if len(proc) < 9:
proc = proc[:5] + [""] + proc[5:]
self.results.append(
{
"user": proc[0],
"pid": int(proc[1]),
"ppid": int(proc[2]),
"virtual_memory_size": int(proc[3]),
"resident_set_size": int(proc[4]),
"wchan": proc[5],
"aprocress": proc[6],
"stat": proc[7],
"proc_name": proc[8].strip("[]"),
"label": label,
}
)
result = {}
valid = True
for header, raw in zip(headers, values):
key = FIELD_NAMES[header]
value: str | int = raw.strip("[]") if key == "command" else raw
if key in INTEGER_FIELDS:
try:
value = int(value)
except ValueError:
valid = False
break
result[key] = value
if valid:
self.results.append(result)
def check_indicators(self) -> None:
if not self.indicators:
return
for result in self.results:
proc_name = result.get("proc_name", "")
if not proc_name:
command = result.get("command", "")
if not isinstance(command, str):
continue
# Skipping this process because of false positives.
if result["proc_name"] == "gatekeeperd":
process_name = command.rsplit("/", 1)[-1]
if not process_name or process_name == "gatekeeperd":
continue
ioc_match = self.indicators.check_app_id(proc_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
continue
ioc_match = self.indicators.check_process(proc_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", result, matched_indicator=ioc_match.ioc
)
for checker in (
self.indicators.check_app_id,
self.indicators.check_process,
):
ioc_match = checker(process_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
result,
matched_indicator=ioc_match.ioc,
)
break
+247 -17
View File
@@ -3,6 +3,13 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import re
from datetime import datetime
from typing import Optional, Sequence
from mvt.common.module_types import ModuleAtomicResult, ModuleSerializedResult
from mvt.common.utils import convert_datetime_to_iso
from .artifact import AndroidArtifact
ANDROID_DANGEROUS_SETTINGS = [
@@ -58,23 +65,246 @@ ANDROID_DANGEROUS_SETTINGS = [
},
]
# dumpsys prints the fields of a setting record, and of a change history entry,
# always in this order and separated by a single space. After the value come
# `default:` and `defaultSystemSet:` when a default is recorded, then `tag:`;
# some vendor builds add whether the value survives a restore, either as
# `isValuePreservedInRestore:` or as a bare `notPreservedInRestore` token.
SETTING_FIELDS = (
"_id",
"name",
"pkg",
"value",
"default",
"defaultSystemSet",
"tag",
"isValuePreservedInRestore",
)
HISTORY_FIELDS = ("time", "mode", "oldValue", "newValue", "package")
NAMESPACE_PATTERN = re.compile(
r"^(CONFIG|GLOBAL|SECURE|SYSTEM) SETTINGS \(user (\d+)\)$"
)
SECTION_END_PATTERN = re.compile(r"ending at: (\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})")
class Settings(AndroidArtifact):
"""Parser for the `dumpsys settings` output.
Every row of the settings provider becomes one result, keeping the fields
dumpsys prints alongside the value: the row id, the package which recorded
the setting, the default, the tag, and the change history. A setting name
can appear more than once within a namespace, so results are a list rather
than a mapping.
"""
def serialize(self, result: ModuleAtomicResult) -> ModuleSerializedResult:
records = []
for entry in result.get("history", []):
if not entry.get("timestamp"):
continue
records.append(
{
"timestamp": entry["timestamp"],
"module": self.__class__.__name__,
"event": "settings_change",
"data": (
f"{result.get('namespace')} setting "
f'"{result.get("name")}" changed from '
f'"{entry.get("oldValue")}" to "{entry.get("newValue")}" '
f"by {entry.get('pkg')}"
),
}
)
return records
def check_indicators(self) -> None:
for namespace, settings in self.results.items():
for key, value in settings.items():
for danger in ANDROID_DANGEROUS_SETTINGS:
# Check if one of the dangerous settings is using an unsafe
# value (different than the one specified).
if danger["key"] == key and danger["safe_value"] != value:
self.alertstore.medium(
f'Found suspicious "{namespace}" setting "{key} = {value}" ({danger["description"]})',
"",
{
"namespace": namespace,
"key": key,
"value": value,
"description": danger["description"],
},
)
break
for result in self.results:
name = result.get("name")
value = result.get("value")
for danger in ANDROID_DANGEROUS_SETTINGS:
# Check if one of the dangerous settings is using an unsafe
# value (different than the one specified).
if danger["key"] != name or danger["safe_value"] == value:
continue
history = result.get("history") or []
self.alertstore.medium(
f'Found suspicious "{result.get("namespace")}" setting '
f'"{name} = {value}" ({danger["description"]})',
history[-1]["timestamp"] if history else "",
result,
)
break
def parse(self, content: str) -> None:
self.results: list[ModuleAtomicResult] = []
section_end = self._parse_section_end(content)
namespace: Optional[str] = None
user: Optional[str] = None
record_lines: list[str] = []
history_lines: list[str] = []
in_history = False
def flush() -> None:
nonlocal record_lines, history_lines, in_history
if record_lines:
self.results.append(
self._build_record(
namespace, user, record_lines, history_lines, section_end
)
)
record_lines = []
history_lines = []
in_history = False
for line in content.splitlines():
heading = NAMESPACE_PATTERN.match(line.strip())
if heading:
flush()
namespace = heading.group(1).lower()
user = heading.group(2)
continue
if line.startswith("--------- "):
# dumpsys closes every section with a duration trailer.
flush()
namespace = None
continue
if namespace is None:
continue
if not line.strip():
# dumpsys prints a blank line after every namespace block and
# after a change history, and other dumps such as the
# generation registry follow the last block, so a blank line
# closes the record being read.
flush()
continue
if line.startswith("_id:"):
flush()
record_lines = [line]
continue
if not record_lines:
continue
stripped = line.strip()
if stripped.startswith("History ("):
in_history = True
continue
if in_history:
if stripped.startswith("time:"):
history_lines.append(stripped)
elif stripped and history_lines:
# A history entry can be wrapped over several lines.
history_lines[-1] += " " + stripped
continue
# Anything else continues the value of the record being read.
record_lines.append(line)
flush()
@staticmethod
def _split_fields(text: str, keys: Sequence[str]) -> dict[str, str]:
"""Split the `key:value` fields of one record.
Values are free-form and may contain spaces and newlines, so a field
runs up to the start of the next key which is actually present. Keys
dumpsys did not print are skipped.
"""
fields: dict[str, str] = {}
key = keys[0]
if not text.startswith(f"{key}:"):
return fields
remainder = text[len(key) + 1 :]
for next_key in keys[1:]:
value, separator, rest = remainder.partition(f" {next_key}:")
if separator:
fields[key] = value
key, remainder = next_key, rest
fields[key] = remainder
return fields
@staticmethod
def _parse_section_end(content: str) -> Optional[datetime]:
"""Return the time the settings section was dumped, if reported."""
match = SECTION_END_PATTERN.search(content)
if not match:
return None
try:
return datetime.strptime(match.group(1), "%Y-%m-%d %H:%M:%S")
except ValueError:
return None
@staticmethod
def _resolve_timestamp(
value: str, section_end: Optional[datetime]
) -> Optional[str]:
"""Add the missing year to a `MM-DD HH:MM:SS.mmm` history timestamp.
dumpsys prints the change history without a year, so it is resolved
against the time the section was dumped: the most recent matching date
at or before that time.
The year is parsed together with the value, because strptime() would
otherwise parse it in 1900, which has no 29 February. The most recent
matching leap day can be eight years earlier when a century year is
not a leap year and the current year's leap day is still in the future.
"""
if section_end is None:
return None
for year in range(section_end.year, section_end.year - 9, -1):
try:
timestamp = datetime.strptime(f"{year}-{value}", "%Y-%m-%d %H:%M:%S.%f")
except ValueError:
continue
if timestamp <= section_end:
return convert_datetime_to_iso(timestamp)
return None
def _parse_history(
self, line: str, section_end: Optional[datetime]
) -> ModuleAtomicResult:
fields = self._split_fields(line, HISTORY_FIELDS)
return {
"timestamp": self._resolve_timestamp(fields.get("time", ""), section_end),
"oldValue": fields.get("oldValue"),
"newValue": fields.get("newValue"),
"pkg": fields.get("package"),
}
def _build_record(
self,
namespace: Optional[str],
user: Optional[str],
record_lines: list[str],
history_lines: list[str],
section_end: Optional[datetime],
) -> ModuleAtomicResult:
text = "\n".join(record_lines).rstrip()
# The bare `notPreservedInRestore` token has no `key:` shape and is
# printed last, so peel it off before splitting the fields.
head = text.removesuffix(" notPreservedInRestore")
record: ModuleAtomicResult = {"namespace": namespace, "user": user}
record.update(self._split_fields(head, SETTING_FIELDS))
if head != text:
record["isValuePreservedInRestore"] = "false"
record["history"] = [
self._parse_history(entry, section_end) for entry in history_lines
]
return record
+41 -10
View File
@@ -34,6 +34,7 @@ TOMBSTONE_TEXT_KEY_MAPPINGS = {
"signal": "signal_info",
"code": "code",
"Cause": "cause",
"Abort message": "abort_message",
}
@@ -67,6 +68,8 @@ class TombstoneCrashResult(pydantic.BaseModel):
uid: int
signal_info: SignalInfo
cause: Optional[str] = None
causes: Optional[List[dict]] = None
abort_message: Optional[str] = None
extra: Optional[str] = None
@@ -128,6 +131,13 @@ class TombstoneCrashArtifact(AndroidArtifact):
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> None:
"""Parse Android tombstone crash files from a protobuf object."""
self.results.append(self.parse_protobuf_record(file_name, file_timestamp, data))
def parse_protobuf_record(
self, file_name: str, file_timestamp: datetime.datetime, data: bytes
) -> dict:
if not data:
raise ValueError("empty protobuf tombstone")
tombstone_pb = Tombstone().parse(data)
tombstone_dict = tombstone_pb.to_dict(
casing=betterproto2.Casing.SNAKE, include_default_values=True
@@ -140,20 +150,31 @@ class TombstoneCrashArtifact(AndroidArtifact):
tombstone_dict["file_name"] = file_name
tombstone_dict["file_timestamp"] = convert_datetime_to_iso(file_timestamp)
tombstone_dict["process_name"] = self._proccess_name_from_thread(tombstone_dict)
if isinstance(tombstone_dict.get("selinux_label"), str):
tombstone_dict["selinux_label"] = tombstone_dict["selinux_label"].rstrip(
"\x00"
)
# Confirm the tombstone is valid, and matches the output model
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
self.results.append(tombstone.model_dump())
return tombstone.model_dump()
def parse(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> None:
"""Parse text Android tombstone crash files."""
self.results.append(self.parse_text_record(file_name, file_timestamp, content))
def parse_text_record(
self, file_name: str, file_timestamp: datetime.datetime, content: bytes
) -> dict:
if not content:
raise ValueError("empty plaintext tombstone")
tombstone_dict = {
"file_name": file_name,
"file_timestamp": convert_datetime_to_iso(file_timestamp),
}
lines = content.decode("utf-8").splitlines()
lines = content.decode("utf-8", errors="replace").splitlines()
for line_num, line in enumerate(lines, 1):
if not line.strip() or TOMBSTONE_DELIMITER in line:
continue
@@ -168,7 +189,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
# Validate the tombstone and add it to the results
tombstone = TombstoneCrashResult.model_validate(tombstone_dict)
self.results.append(tombstone.model_dump())
return tombstone.model_dump()
def _parse_tombstone_line(
self, line: str, key: str, destination_key: str, tombstone: dict
@@ -188,11 +209,18 @@ class TombstoneCrashArtifact(AndroidArtifact):
def _load_key_value_line(
self, line: str, key: str, destination_key: str, tombstone: dict
) -> bool:
line_key, value = line.split(":", 1)
if line_key != key:
raise ValueError(f"Expected key {key}, got {line_key}")
# The caller matched the key as a bare prefix, so a longer word starting
# with it arrives here: `Caused by: …` inside an abort message reaches
# the `Cause` key. That is a different line, not a broken file — say so
# by declining it, and let the remaining keys have their turn. Raising
# here discarded the whole tombstone, crash and stack trace included.
line_key, separator, value = line.partition(":")
if not separator or line_key != key:
return False
value_clean = value.strip().strip("'")
value_clean = value.strip()
if len(value_clean) >= 2 and value_clean[0] == value_clean[-1] == "'":
value_clean = value_clean[1:-1]
if destination_key == "uid":
tombstone[destination_key] = int(value_clean)
elif destination_key == "process_uptime":
@@ -206,6 +234,11 @@ class TombstoneCrashArtifact(AndroidArtifact):
return True
def _load_pid_line(self, line: str, tombstone: dict) -> bool:
# The first pid line identifies the crashing thread. Full text tombstones
# contain additional pid lines for the other threads in the process.
if "pid" in tombstone:
return True
try:
parts = line.split(" >>> ") if " >>> " in line else line.split(">>>")
process_info = parts[0]
@@ -261,9 +294,7 @@ class TombstoneCrashArtifact(AndroidArtifact):
@staticmethod
def _parse_timestamp_string(timestamp: str) -> str:
timestamp_parsed = parser.parse(timestamp)
# Preserve the source wall-clock time while returning the project-wide ISO format.
local_timestamp = timestamp_parsed.replace(tzinfo=datetime.timezone.utc)
return convert_datetime_to_iso(local_timestamp)
return convert_datetime_to_iso(timestamp_parsed)
@staticmethod
def _proccess_name_from_thread(tombstone_dict: dict) -> str:
+123 -35
View File
@@ -4,10 +4,16 @@
# https://license.mvt.re/1.1/
import logging
from zipfile import BadZipFile
import click
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from mvt.common.cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
MVT_ANDROID_CUSTOM_COMMANDS_ENV,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.help import (
HELP_MSG_ANDROID_BACKUP_PASSWORD,
HELP_MSG_CHECK_ADB_REMOVED,
@@ -17,10 +23,12 @@ from mvt.common.help import (
HELP_MSG_CHECK_BUGREPORT,
HELP_MSG_CHECK_IOCS,
HELP_MSG_CHECK_INTRUSION_LOGS,
HELP_MSG_DELAY_CHECKS,
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_HASHES,
HELP_MSG_IOC,
HELP_MSG_JOBS,
HELP_MSG_LIST_MODULES,
HELP_MSG_LOAD_MODULE,
HELP_MSG_MODULE,
@@ -28,22 +36,16 @@ from mvt.common.help import (
HELP_MSG_OUTPUT,
HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERBOSE_COMMAND,
HELP_MSG_VERSION,
HELP_MSG_VIRUS_TOTAL,
)
from mvt.common.logo import logo
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .cmd_check_backup import CmdAndroidCheckBackup
from .cmd_check_bugreport import CmdAndroidCheckBugreport
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.backup import BACKUP_MODULES
from .modules.backup.helpers import cli_load_android_backup_password
from .modules.bugreport import BUGREPORT_MODULES
# The commands import what they run only when they are invoked. This module is
# imported at every start of mvt-android, including by shell completion on
# every keystroke, so importing it must do no more than build the command tree:
# the forensic modules, the backup parsers and the update checks stay out of it.
init_logging()
log = logging.getLogger("mvt")
@@ -61,7 +63,14 @@ def _get_disable_flags(ctx):
)
def _get_verbose(ctx):
"""Return whether --verbose was passed to the CLI itself."""
return bool(ctx.obj and ctx.obj.get("verbose", False))
def _load_custom_modules(load_module):
from mvt.common.module_loader import CustomModuleLoadError, load_custom_modules
try:
return load_custom_modules(load_module)
except CustomModuleLoadError as exc:
@@ -72,6 +81,7 @@ def _load_custom_modules(load_module):
# Main
# ==============================================================================
@click.group(invoke_without_command=False)
@load_cli_commands_option
@click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
)
@@ -80,11 +90,17 @@ def _load_custom_modules(load_module):
is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check):
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
from mvt.common.logo import logo
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
@@ -94,7 +110,7 @@ def cli(ctx, disable_update_check, disable_indicator_update_check):
# ==============================================================================
# Command: version
# ==============================================================================
@cli.command("version", help=HELP_MSG_VERSION)
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
def version():
return
@@ -135,7 +151,20 @@ def check_adb(ctx):
default=[],
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option(
"--timezone",
"-t",
default=None,
help=(
"IANA timezone name for the device, for example 'Europe/Paris'. "
"Bugreport file timestamps are the device's wall clock; by default the "
"zone is read from persist.sys.timezone in the bugreport itself."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("BUGREPORT_PATH", type=click.Path(exists=True))
@click.pass_context
def check_bugreport(
@@ -145,30 +174,42 @@ def check_bugreport(
list_modules,
module,
load_module,
timezone,
verbose,
jobs,
bugreport_path,
):
set_verbose_logging(verbose)
from .cmd_check_bugreport import CmdAndroidCheckBugreport
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
module_options = {}
if timezone:
module_options["device_timezone"] = timezone
# Always generate hashes as bug reports are small.
cmd = CmdAndroidCheckBugreport(
target_path=bugreport_path,
results_path=output,
ioc_files=iocs,
module_name=module,
module_options=module_options if module_options else None,
hashes=True,
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
jobs=jobs,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking Android bug report at path: %s", bugreport_path)
cmd.run()
try:
cmd.run()
except BadZipFile as exc:
raise click.ClickException(f"Invalid bugreport archive: {exc}") from exc
cmd.show_alerts_brief()
cmd.show_support_message()
@@ -200,7 +241,10 @@ def check_bugreport(
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("BACKUP_PATH", type=click.Path(exists=True))
@click.pass_context
def check_backup(
@@ -212,9 +256,13 @@ def check_backup(
non_interactive,
backup_password,
verbose,
jobs,
backup_path,
):
set_verbose_logging(verbose)
from .cmd_check_backup import CmdAndroidCheckBackup
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
# Always generate hashes as backups are generally small.
@@ -230,14 +278,13 @@ def check_backup(
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
jobs=jobs,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking Android backup at path: %s", backup_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
@@ -268,9 +315,16 @@ def check_backup(
help=HELP_MSG_LOAD_MODULE,
)
@click.option("--hashes", "-H", is_flag=True, help=HELP_MSG_HASHES)
@click.option("--virustotal", "-V", is_flag=True, help=HELP_MSG_VIRUS_TOTAL)
@click.option(
"--delay", "-d", type=click.IntRange(min=0), default=16, help=HELP_MSG_DELAY_CHECKS
)
@click.option("--non-interactive", "-n", is_flag=True, help=HELP_MSG_NONINTERACTIVE)
@click.option("--backup-password", "-p", help=HELP_MSG_ANDROID_BACKUP_PASSWORD)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("ANDROIDQF_PATH", type=click.Path(exists=True))
@click.pass_context
def check_androidqf(
@@ -281,12 +335,18 @@ def check_androidqf(
module,
load_module,
hashes,
virustotal,
delay,
non_interactive,
backup_password,
verbose,
jobs,
androidqf_path,
):
set_verbose_logging(verbose)
from .cmd_check_androidqf import CmdAndroidCheckAndroidQF
from .modules.backup.helpers import cli_load_android_backup_password
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
cmd = CmdAndroidCheckAndroidQF(
@@ -298,18 +358,19 @@ def check_androidqf(
module_options={
"interactive": not non_interactive,
"backup_password": cli_load_android_backup_password(log, backup_password),
"virustotal": virustotal,
"virustotal_delay": delay,
},
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
jobs=jobs,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking AndroidQF acquisition at path: %s", androidqf_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_disable_adb_warning()
@@ -352,7 +413,10 @@ def check_androidqf(
"time instead of UTC."
),
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE_COMMAND)
@click.option(
"--jobs", type=click.IntRange(min=1), default=4, show_default=True, help=HELP_MSG_JOBS
)
@click.argument("LOGS_PATH", type=click.Path(exists=True))
@click.pass_context
def check_intrusion_logs(
@@ -364,9 +428,12 @@ def check_intrusion_logs(
load_module,
timezone,
verbose,
jobs,
logs_path,
):
set_verbose_logging(verbose)
from .cmd_check_intrusion_logs import CmdAndroidCheckIntrusionLogs
set_verbose_logging(verbose or _get_verbose(ctx))
custom_modules = _load_custom_modules(load_module)
module_options = {}
@@ -382,14 +449,13 @@ def check_intrusion_logs(
disable_version_check=_get_disable_flags(ctx)[0],
disable_indicator_check=_get_disable_flags(ctx)[1],
custom_modules=custom_modules,
jobs=jobs,
)
if list_modules:
cmd.list_modules()
return
log.info("Checking intrusion logs at path: %s", logs_path)
cmd.run()
cmd.show_alerts_brief()
cmd.show_support_message()
@@ -419,6 +485,10 @@ def check_intrusion_logs(
@click.argument("FOLDER", type=click.Path(exists=True))
@click.pass_context
def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
from mvt.common.cmd_check_iocs import CmdCheckIOCS
from .command_modules import ANDROID_CHECK_IOCS_MODULES
custom_modules = _load_custom_modules(load_module)
cmd = CmdCheckIOCS(
target_path=folder,
@@ -429,9 +499,7 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
custom_modules=custom_modules,
platform="android",
)
cmd.modules = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
cmd.modules = ANDROID_CHECK_IOCS_MODULES
if list_modules:
cmd.list_modules()
@@ -447,5 +515,25 @@ def check_iocs(ctx, iocs, list_modules, module, load_module, folder):
# ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_indicators():
from mvt.common.updates import IndicatorsUpdates
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
# ==============================================================================
# Entry point of the mvt-android console script
# ==============================================================================
def main() -> None:
"""Register the external commands and run the mvt-android CLI.
External commands are registered here rather than when this module is
imported, so that importing MVT never runs third-party code and a plugin
importing from MVT cannot re-enter a module that is still initializing.
"""
register_cli_plugins(
cli,
entry_point_group=ANDROID_CLI_PLUGIN_GROUP,
environment_variable=MVT_ANDROID_CUSTOM_COMMANDS_ENV,
)
cli()
+63 -6
View File
@@ -3,6 +3,7 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import json
import logging
import os
import shutil
@@ -52,6 +53,7 @@ class CmdAndroidCheckAndroidQF(Command):
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None:
super().__init__(
target_path=target_path,
@@ -67,6 +69,7 @@ class CmdAndroidCheckAndroidQF(Command):
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
jobs=jobs,
)
self.platform = "android"
@@ -81,6 +84,8 @@ class CmdAndroidCheckAndroidQF(Command):
if not self.target_path:
raise NoAndroidQFTargetPath
self.log.info("Checking AndroidQF acquisition at path: %s", self.target_path)
if os.path.isdir(self.target_path):
self.__format = "dir"
parent_path = Path(self.target_path).absolute().parent.as_posix()
@@ -94,6 +99,47 @@ class CmdAndroidCheckAndroidQF(Command):
self.__zip = zipfile.ZipFile(self.target_path)
self.__files = self.__zip.namelist()
self._load_acquisition_context()
def _load_acquisition_context(self) -> None:
"""Pass AndroidQF acquisition metadata to nested commands and modules."""
context = {}
metadata_files = [
file_path
for file_path in self.__files
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "acquisition.json"
]
for file_path in metadata_files:
try:
metadata = json.loads(self._get_file_content(file_path))
if isinstance(metadata, dict):
context["started"] = metadata.get("started")
context["adb_host_public_key"] = metadata.get("adb_host_public_key")
break
except (json.JSONDecodeError, OSError, TypeError, UnicodeDecodeError):
self.log.warning(
'Unable to read AndroidQF acquisition metadata "%s"', file_path
)
if not context.get("adb_host_public_key"):
key_files = [
file_path
for file_path in self.__files
if file_path.replace("\\", "/").rsplit("/", 1)[-1] == "adb_host_key.pub"
]
for file_path in key_files:
try:
context["adb_host_public_key"] = self._get_file_content(
file_path
).decode("utf-8")
break
except (OSError, UnicodeDecodeError):
self.log.warning(
'Unable to read AndroidQF ADB host key "%s"', file_path
)
self.module_options["androidqf_acquisition"] = context
def module_init(self, module: AndroidQFModule) -> None: # type: ignore[override]
if self.__format == "zip" and self.__zip:
module.from_zip(self.__zip, self.__files)
@@ -215,11 +261,13 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
jobs=self.jobs,
)
cmd.from_zip(bugreport)
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
finally:
if bugreport:
@@ -245,18 +293,18 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
jobs=self.jobs,
)
try:
cmd.from_ab(backup)
except InvalidAndroidBackup as exc:
self.log.warning(
"Skipping backup modules as backup.ab is malformed: %s", exc
)
self.log.warning("Skipping backup modules: %s", exc)
return False
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
return True
@@ -296,6 +344,7 @@ class CmdAndroidCheckAndroidQF(Command):
elif self.__format == "zip" and self.__zip:
temp_dir = tempfile.mkdtemp(prefix="mvt_intrusion_logs_")
temp_root = Path(temp_dir).resolve()
for entry in intrusion_log_files:
normalized = entry.replace("\\", "/")
idx = normalized.find("intrusion_logs/")
@@ -303,9 +352,15 @@ class CmdAndroidCheckAndroidQF(Command):
if not relative or relative.endswith("/"):
continue
target = os.path.join(temp_dir, relative)
os.makedirs(os.path.dirname(target), exist_ok=True)
with self.__zip.open(entry) as src, open(target, "wb") as dst:
target = (temp_root / relative).resolve()
if not target.is_relative_to(temp_root):
self.log.warning(
"Skipping unsafe intrusion log archive entry: %s", entry
)
continue
target.parent.mkdir(parents=True, exist_ok=True)
with self.__zip.open(entry) as src, target.open("wb") as dst:
dst.write(src.read())
intrusion_logs_path = temp_dir
@@ -325,10 +380,12 @@ class CmdAndroidCheckAndroidQF(Command):
hashes=self.hashes,
sub_command=True,
custom_modules=self.custom_modules,
jobs=self.jobs,
)
cmd.run()
self.timeline.extend(cmd.timeline)
self.url_results.extend(cmd.url_results)
self.alertstore.extend(cmd.alertstore.alerts)
return True
+7
View File
@@ -47,6 +47,7 @@ class CmdAndroidCheckBackup(Command):
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None:
super().__init__(
target_path=target_path,
@@ -62,6 +63,7 @@ class CmdAndroidCheckBackup(Command):
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
jobs=jobs,
)
self.platform = "android"
@@ -87,11 +89,15 @@ class CmdAndroidCheckBackup(Command):
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(log, self.module_options)
if not password:
if self.sub_command:
raise InvalidAndroidBackup("No backup password provided")
log.critical("No backup password provided.")
sys.exit(1)
try:
tardata = parse_backup_file(ab_file_bytes, password=password)
except InvalidBackupPassword:
if self.sub_command:
raise InvalidAndroidBackup("Invalid backup password")
log.critical("Invalid backup password")
sys.exit(1)
except AndroidBackupParsingError as exc:
@@ -125,6 +131,7 @@ class CmdAndroidCheckBackup(Command):
assert self.target_path is not None # type: ignore[has-type]
# Use a different local variable name to avoid any scoping issues
backup_path: str = self.target_path # type: ignore[has-type]
self.log.info("Checking Android backup at path: %s", backup_path)
if os.path.isfile(backup_path):
self.__type = "ab"
+101 -7
View File
@@ -3,13 +3,19 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import fnmatch
import io
import logging
import os
from pathlib import Path
from typing import List, Optional
from zipfile import ZipFile
from zipfile import BadZipFile, ZipFile
from mvt.android.modules.bugreport.base import BugReportModule
from mvt.android.artifacts.getprop import GetProp
from mvt.android.modules.bugreport.base import (
DUMPSTATE_ENTRY_POINTS,
BugReportModule,
)
from mvt.common.command import Command
from mvt.common.indicators import Indicators
from mvt.common.module import MVTModule
@@ -34,6 +40,7 @@ class CmdAndroidCheckBugreport(Command):
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None:
super().__init__(
target_path=target_path,
@@ -49,6 +56,7 @@ class CmdAndroidCheckBugreport(Command):
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
jobs=jobs,
)
self.platform = "android"
@@ -87,14 +95,100 @@ class CmdAndroidCheckBugreport(Command):
for file_name in self.__zip.namelist():
self.__files.append(file_name)
if not self._has_dumpstate(self.__files):
nested = self._nested_bugreport(bugreport_zip)
if nested:
self.__zip = nested
self.__files = list(nested.namelist())
@staticmethod
def _has_dumpstate(file_names: List[str]) -> bool:
"""Whether these members carry any of the entry points the bug report
modules read (see `BugReportModule._get_dumpstate_file`)."""
return any(
fnmatch.filter(file_names, pattern) for pattern in DUMPSTATE_ENTRY_POINTS
)
def _nested_bugreport(self, outer: ZipFile) -> Optional[ZipFile]:
"""Descend one level into an OEM wrapper archive.
MIUI / HyperOS hands out a zip of app logs, ANR traces and tcpdump
captures with the real `bugreport-<device>-<timestamp>.zip` nested
inside. Without this descent the outer archive has no entry point,
every module reports it found no files, and the command still exits 0
with an empty result.
"""
candidates = [
name for name in outer.namelist() if name.lower().endswith(".zip")
]
candidates.sort(
key=lambda name: (
"bugreport" not in name.lower() and "dumpstate" not in name.lower()
)
)
for name in candidates:
try:
inner = ZipFile(io.BytesIO(outer.read(name)))
except (BadZipFile, OSError):
continue
if self._has_dumpstate(inner.namelist()):
log.info("Found the bug report nested inside the archive: %s", name)
return inner
inner.close()
return None
def init(self) -> None:
if not self.target_path:
if self.target_path:
self.log.info("Checking Android bug report at path: %s", self.target_path)
if os.path.isfile(self.target_path):
self.from_zip(ZipFile(self.target_path))
elif os.path.isdir(self.target_path):
self.from_dir(self.target_path)
self.log.warning(
"Analysing an unpacked bugreport: file timestamps come from "
"the extraction, not from the device. Analyse the original "
"zip to keep the device's file timestamps."
)
if self.__format:
self._resolve_device_timezone()
def _resolve_device_timezone(self) -> None:
"""Name the device's timezone in module_options unless it is known already.
A bugreport's SYSTEM PROPERTIES section carries persist.sys.timezone.
Zip entry times are the device's wall clock, and modules read them in
this zone; --timezone or check-androidqf's own reading takes precedence.
"""
if self.module_options.get("device_timezone"):
self.log.info("Device timezone: %s", self.module_options["device_timezone"])
return
if os.path.isfile(self.target_path):
self.from_zip(ZipFile(self.target_path))
elif os.path.isdir(self.target_path):
self.from_dir(self.target_path)
probe = BugReportModule(log=self.log)
self.module_init(probe)
timezone = None
try:
dumpstate = probe._get_dumpstate_file()
except Exception as exc:
self.log.warning("Could not read the bugreport's dumpstate: %s", exc)
dumpstate = None
if dumpstate:
properties = GetProp()
properties.parse(
BugReportModule.extract_command_section(
dumpstate.decode("utf-8", errors="replace"),
"------ SYSTEM PROPERTIES",
)
)
timezone = properties.get_device_timezone()
if timezone:
self.log.info("Device timezone identified from the bugreport: %s", timezone)
self.module_options["device_timezone"] = timezone
else:
self.log.warning(
"persist.sys.timezone not found in the bugreport; file timestamps "
"are the device's wall clock without a timezone. Pass --timezone "
"to name it."
)
def module_init(self, module: BugReportModule) -> None: # type: ignore[override]
if self.__format == "zip":
@@ -37,6 +37,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None:
super().__init__(
target_path=target_path,
@@ -52,6 +53,7 @@ class CmdAndroidCheckIntrusionLogs(Command):
disable_version_check=disable_version_check,
disable_indicator_check=disable_indicator_check,
custom_modules=custom_modules,
jobs=jobs,
)
self.platform = "android"
@@ -63,6 +65,8 @@ class CmdAndroidCheckIntrusionLogs(Command):
if not self.target_path:
raise ValueError("No target path specified")
self.log.info("Checking intrusion logs at path: %s", self.target_path)
if not os.path.isdir(self.target_path) and not (
os.path.isfile(self.target_path)
and self.target_path.lower().endswith(".zip")
+23
View File
@@ -0,0 +1,23 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
"""Module lists an mvt-android command composes from more than one family.
Commands whose modules are one family read that family directly. check-iocs
re-checks stored results, so it has to know every module that could have
written one, and both the CLI and any other code needing that answer share
the list from here rather than each concatenating their own.
"""
from mvt.common.module import MVTModule
from .modules.androidqf import ANDROIDQF_MODULES
from .modules.backup import BACKUP_MODULES
from .modules.bugreport import BUGREPORT_MODULES
from .modules.intrusion_logs import INTRUSION_LOGS_MODULES
ANDROID_CHECK_IOCS_MODULES: list[type[MVTModule]] = (
BACKUP_MODULES + BUGREPORT_MODULES + ANDROIDQF_MODULES + INTRUSION_LOGS_MODULES
)
@@ -3,22 +3,24 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .aqf_files import AQFFiles
from .aqf_getprop import AQFGetProp
from .aqf_log_timestamps import AQFLogTimestamps
from .aqf_packages import AQFPackages
from .aqf_processes import AQFProcesses
from .aqf_settings import AQFSettings
from .mounts import Mounts
from .root_binaries import RootBinaries
from .sms import SMS
ANDROIDQF_MODULES = [
ANDROIDQF_MODULES: list[type[MVTModule]] = [
AQFPackages,
AQFProcesses,
AQFGetProp,
AQFSettings,
AQFFiles,
SMS,
AQFLogTimestamps,
RootBinaries,
Mounts,
]
@@ -5,6 +5,7 @@
import json
import logging
import time
from typing import Optional
from mvt.android.utils import (
@@ -15,7 +16,8 @@ from mvt.android.utils import (
SYSTEM_UPDATE_PACKAGES,
THIRD_PARTY_STORE_INSTALLERS,
)
from mvt.common.module_types import ModuleResults
from mvt.common.module_types import ModuleAtomicResult, ModuleResults
from mvt.common.virustotal import VTNoKey, VTQuotaExceeded, virustotal_lookup
from .base import AndroidQFModule
@@ -125,6 +127,64 @@ class AQFPackages(AndroidQFModule):
)
break
if self.module_options.get("virustotal", False):
self.check_virustotal(
delay=self.module_options.get("virustotal_delay", 0)
)
def check_virustotal(self, delay: int = 0) -> None:
files_by_hash: dict[
str, list[tuple[ModuleAtomicResult, ModuleAtomicResult]]
] = {}
for package in self.results:
if package.get("system", False):
continue
for package_file in package.get("files", []):
file_hash = package_file.get("sha256")
if not file_hash:
continue
files_by_hash.setdefault(file_hash, []).append((package, package_file))
total_hashes = len(files_by_hash)
if total_hashes == 0:
return
self.log.info("Looking up %d package files on VirusTotal...", total_hashes)
for index, file_hash in enumerate(files_by_hash):
self.log.debug("VirusTotal lookup %d/%d", index + 1, total_hashes)
try:
results = virustotal_lookup(file_hash)
except VTNoKey as exc:
self.log.warning("%s", exc)
return
except VTQuotaExceeded as exc:
self.log.warning("Unable to continue VirusTotal lookups: %s", exc)
break
if index < total_hashes - 1 and delay > 0:
time.sleep(delay)
if not results:
continue
attributes = results.get("attributes", {})
stats = attributes.get("last_analysis_stats", {})
positives = stats.get("malicious", 0)
total = len(attributes.get("last_analysis_results", {}))
detection = f"{positives}/{total}"
for package, package_file in files_by_hash[file_hash]:
package_file["virustotal"] = detection
if positives > 0:
self.alertstore.high(
f'VirusTotal flagged package "{package["name"]}" file '
f'"{package_file["path"]}" with {detection} detections',
"",
package,
)
def run(self) -> None:
packages = self._get_files_by_pattern("*/packages.json")
if not packages:
@@ -3,11 +3,7 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import logging
from typing import Optional
from mvt.android.artifacts.settings import Settings as SettingsArtifact
from mvt.common.module_types import ModuleResults
from .base import AndroidQFModule
@@ -15,43 +11,23 @@ from .base import AndroidQFModule
class AQFSettings(SettingsArtifact, AndroidQFModule):
"""This module analyse setting files"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[ModuleResults] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
self.results: dict = results if results is not None else {}
def run(self) -> None:
for setting_file in self._get_files_by_pattern("*/settings_*.txt"):
namespace = setting_file[setting_file.rfind("_") + 1 : -4]
self.results[namespace] = {}
data = self._get_file_content(setting_file)
for line in data.decode("utf-8").splitlines():
line = line.strip()
try:
key, value = line.split("=", 1)
except ValueError:
name, separator, value = line.strip().partition("=")
if not separator:
continue
try:
self.results[namespace][key] = value
except IndexError:
continue
self.results.append(
{
"namespace": namespace,
"user": None,
"name": name,
"value": value,
}
)
self.log.info(
"Identified %d settings", sum([len(val) for val in self.results.values()])
)
self.log.info("Identified %d settings", len(self.results))
+8 -7
View File
@@ -81,12 +81,13 @@ class AndroidQFModule(MVTModule):
return None
def _get_file_content(self, file_path):
if self.archive:
handle = self.archive.open(file_path)
else:
handle = open(os.path.join(self.parent_path, file_path), "rb")
with self.resource_lock:
if self.archive:
handle = self.archive.open(file_path)
else:
handle = open(os.path.join(self.parent_path, file_path), "rb")
data = handle.read()
handle.close()
data = handle.read()
handle.close()
return data
return data
-105
View File
@@ -1,105 +0,0 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1
import logging
from typing import Optional
from mvt.android.modules.backup.helpers import prompt_or_load_android_backup_password
from mvt.android.parsers.backup import (
AndroidBackupParsingError,
InvalidBackupPassword,
parse_ab_header,
parse_backup_file,
parse_tar_for_sms,
)
from .base import AndroidQFModule
class SMS(AndroidQFModule):
"""
This module analyse SMS file in backup
"""
def __init__(
self,
file_path: Optional[str] = None,
target_path: Optional[str] = None,
results_path: Optional[str] = None,
module_options: Optional[dict] = None,
log: logging.Logger = logging.getLogger(__name__),
results: Optional[list] = None,
) -> None:
super().__init__(
file_path=file_path,
target_path=target_path,
results_path=results_path,
module_options=module_options,
log=log,
results=results,
)
def check_indicators(self) -> None:
if not self.indicators:
return
for message in self.results:
if "body" not in message:
continue
ioc_match = self.indicators.check_domains(message.get("links", []))
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
def parse_backup(self, data):
header = parse_ab_header(data)
if not header["backup"]:
self.log.warning("Invalid backup format, backup.ab was not analysed")
return
password = None
if header["encryption"] != "none":
password = prompt_or_load_android_backup_password(
self.log, self.module_options
)
if not password:
self.log.critical("No backup password provided.")
return
try:
tardata = parse_backup_file(data, password=password)
except InvalidBackupPassword:
self.log.critical("Invalid backup password")
return
except AndroidBackupParsingError:
self.log.warning(
"Impossible to parse this backup file, please use"
" Android Backup Extractor instead"
)
return
if not tardata:
return
try:
self.results = parse_tar_for_sms(tardata)
except AndroidBackupParsingError:
self.log.info(
"Impossible to read SMS from the Android Backup, "
"please extract the SMS and try extracting it with "
"Android Backup Extractor"
)
return
def run(self) -> None:
files = self._get_files_by_pattern("*/backup.ab")
if not files:
self.log.info("No backup data found")
return
self.parse_backup(self._get_file_content(files[0]))
self.log.info("Identified %d SMS in backup data", len(self.results))
+3 -1
View File
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .sms import SMS
BACKUP_MODULES = [SMS]
BACKUP_MODULES: list[type[MVTModule]] = [SMS]
+17 -16
View File
@@ -55,21 +55,22 @@ class BackupModule(MVTModule):
return fnmatch.filter(self.files, pattern)
def _get_file_content(self, file_path: str) -> bytes:
handle = None
if self.tar:
try:
member = self.tar.getmember(file_path)
handle = self.tar.extractfile(member)
if not handle:
raise ValueError(f"Could not extract file: {file_path}")
except KeyError:
raise FileNotFoundError(f"File not found in tar: {file_path}")
elif self.backup_path:
handle = open(os.path.join(self.backup_path, file_path), "rb")
else:
raise ValueError("No backup path or tar file provided")
with self.resource_lock:
handle = None
if self.tar:
try:
member = self.tar.getmember(file_path)
handle = self.tar.extractfile(member)
if not handle:
raise ValueError(f"Could not extract file: {file_path}")
except KeyError:
raise FileNotFoundError(f"File not found in tar: {file_path}")
elif self.backup_path:
handle = open(os.path.join(self.backup_path, file_path), "rb")
else:
raise ValueError("No backup path or tar file provided")
data = handle.read()
handle.close()
data = handle.read()
handle.close()
return data
return data
+2 -3
View File
@@ -4,9 +4,8 @@
# https://license.mvt.re/1.1/
from rich.prompt import Prompt
from mvt.common.config import settings
from mvt.common.password import prompt_password
MVT_ANDROID_BACKUP_PASSWORD = "MVT_ANDROID_BACKUP_PASSWORD"
@@ -49,7 +48,7 @@ def prompt_or_load_android_backup_password(log, module_options):
# The default is to allow interactivity
elif module_options.get("interactive", True):
backup_password = Prompt.ask(prompt="Enter backup password", password=True)
backup_password = prompt_password("Enter backup password: ")
else:
log.critical(
"Cannot decrypt backup because interactivity"
+13 -2
View File
@@ -36,6 +36,8 @@ class SMS(BackupModule):
if not self.indicators:
return
messages = []
url_batches = []
for message in self.results:
if "body" not in message:
continue
@@ -44,12 +46,21 @@ class SMS(BackupModule):
if message_links == []:
message_links = check_for_links(message.get("text", ""))
ioc_match = self.indicators.check_urls(message_links)
messages.append(message)
url_batches.append(message_links)
for message, ioc_match in zip(
messages, self.indicators.check_url_batches(url_batches)
):
if ioc_match:
self.alertstore.critical(
ioc_match.message, "", message, matched_indicator=ioc_match.ioc
)
continue
def collect_url_results(self) -> None:
for message in self.results:
for url in message.get("links", []):
self.add_url_result(url, message.get("isodate"), "sms")
def run(self) -> None:
sms_path = "apps/com.android.providers.telephony/d_f/*_sms_backup"
@@ -3,6 +3,8 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .dumpsys_accessibility import DumpsysAccessibility
from .dumpsys_activities import DumpsysActivities
from .dumpsys_appops import DumpsysAppops
@@ -16,8 +18,11 @@ from .dumpsys_receivers import DumpsysReceivers
from .dumpsys_adb_state import DumpsysADBState
from .fs_timestamps import BugReportTimestamps
from .tombstones import Tombstones
from .mounts import Mounts
from .processes import Processes
from .settings import Settings
BUGREPORT_MODULES = [
BUGREPORT_MODULES: list[type[MVTModule]] = [
DumpsysAccessibility,
DumpsysActivities,
DumpsysAppops,
@@ -31,4 +36,7 @@ BUGREPORT_MODULES = [
DumpsysADBState,
BugReportTimestamps,
Tombstones,
Processes,
Settings,
Mounts,
]
+228 -33
View File
@@ -4,14 +4,45 @@
# https://github.com/mvt-project/mvt/blob/main/LICENSE
import datetime
import fnmatch
import io
import logging
import os
import re
from pathlib import Path
from typing import List, Optional
from zipfile import ZipFile
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from mvt.android.artifacts.artifact import AndroidArtifact
from mvt.common.module import ModuleResults, MVTModule
# `------ 0.101s was the duration of 'SOME SECTION' ------`, printed when that
# section finishes and not necessarily between two sections.
SECTION_DURATION = re.compile(r"^-{3,}\s*[0-9.]+s was the duration of", re.IGNORECASE)
# The members a bug report archive can be entered through, in the order
# _get_dumpstate_file() tries them. An archive carrying none of them is not a
# bug report at this level (see CmdAndroidCheckBugreport._has_dumpstate).
DUMPSTATE_ENTRY_POINTS = ("main_entry.txt", "dumpState_*.log", "*/dumpsys.txt")
_DUMPSTATE_BYTES_CACHE_KEY = ("android_bugreport", "dumpstate", "bytes")
_DUMPSTATE_SECTIONS_CACHE_KEY = ("android_bugreport", "dumpstate", "sections")
_SYSTEM_PROPERTIES_SECTION = b"------ SYSTEM PROPERTIES"
_DUMPSYS_SEPARATORS = {
b"DUMP OF SERVICE accessibility:",
b"DUMP OF SERVICE adb:",
b"DUMP OF SERVICE appops:",
b"DUMP OF SERVICE batterystats:",
b"DUMP OF SERVICE dbinfo:",
b"DUMP OF SERVICE package:",
b"DUMP OF SERVICE platform_compat:",
}
_DUMPSTATE_DELIMITER = (
b"------------------------------------------------------------------------------"
)
_GETPROP_LINE_RE = re.compile(rb"\[(.+?)\]: \[(.+?)\]")
class BugReportModule(MVTModule):
"""This class provides a base for all Android Bug Report modules."""
@@ -66,46 +97,210 @@ class BugReportModule(MVTModule):
return []
def _get_file_content(self, file_path: str) -> bytes:
if self.zip_archive:
handle = self.zip_archive.open(file_path)
else:
if not self.extract_path:
raise ValueError("extract_path is not set")
joined = os.path.join(self.extract_path, file_path)
if not Path(joined).resolve().is_relative_to(Path(self.extract_path).resolve()):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
with self.resource_lock:
if self.zip_archive:
handle = self.zip_archive.open(file_path)
else:
if not self.extract_path:
raise ValueError("extract_path is not set")
joined = os.path.join(self.extract_path, file_path)
if (
not Path(joined)
.resolve()
.is_relative_to(Path(self.extract_path).resolve())
):
raise ValueError("unsafe file_path")
handle = open(joined, "rb")
data = handle.read()
handle.close()
data = handle.read()
handle.close()
return data
return data
def _get_dumpstate_file(self) -> Optional[bytes]:
main = self._get_files_by_pattern("main_entry.txt")
if main:
main_content = self._get_file_content(main[0])
try:
return self._get_file_content(main_content.decode().strip())
except KeyError:
return None
with self.resource_lock:
if _DUMPSTATE_BYTES_CACHE_KEY in self.resource_cache:
return self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY]
dumpstate_logs = self._get_files_by_pattern("dumpState_*.log")
if dumpstate_logs:
return self._get_file_content(dumpstate_logs[0])
content = None
main_entry, dumpstate_log, dumpsys_txt = DUMPSTATE_ENTRY_POINTS
main = self._get_files_by_pattern(main_entry)
if main:
main_content = self._get_file_content(main[0])
try:
content = self._get_file_content(main_content.decode().strip())
except KeyError:
pass
else:
dumpstate_logs = self._get_files_by_pattern(dumpstate_log)
if dumpstate_logs:
content = self._get_file_content(dumpstate_logs[0])
else:
dumpsys_files = self._get_files_by_pattern(dumpsys_txt)
if dumpsys_files:
content = self._get_file_content(dumpsys_files[0])
dumpsys_files = self._get_files_by_pattern("*/dumpsys.txt")
if dumpsys_files:
return self._get_file_content(dumpsys_files[0])
self.resource_cache[_DUMPSTATE_BYTES_CACHE_KEY] = content
return content
return None
def _get_dumpstate_sections(self) -> dict[bytes, bytes]:
with self.resource_lock:
if _DUMPSTATE_SECTIONS_CACHE_KEY in self.resource_cache:
return self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY]
section_lines: dict[bytes, list[bytes]] = {
separator: [] for separator in _DUMPSYS_SEPARATORS
}
section_lines[_SYSTEM_PROPERTIES_SECTION] = []
current_section = None
completed_sections = set()
in_system_properties = False
content = self._get_dumpstate_file()
if content:
for raw_line in io.BytesIO(content):
line = raw_line.rstrip(b"\r\n")
stripped = line.strip()
if stripped.startswith(_SYSTEM_PROPERTIES_SECTION):
in_system_properties = True
elif in_system_properties:
if stripped == b"------":
in_system_properties = False
elif _GETPROP_LINE_RE.search(line):
section_lines[_SYSTEM_PROPERTIES_SECTION].append(line)
if stripped in _DUMPSYS_SEPARATORS:
if current_section is not None:
completed_sections.add(current_section)
current_section = (
None if stripped in completed_sections else stripped
)
continue
if stripped.startswith(_DUMPSTATE_DELIMITER):
if current_section is not None:
completed_sections.add(current_section)
current_section = None
continue
if current_section is not None:
section_lines[current_section].append(line)
sections = {
separator: b"\n".join(lines)
for separator, lines in section_lines.items()
}
self.resource_cache[_DUMPSTATE_SECTIONS_CACHE_KEY] = sections
return sections
def _get_dumpsys_section_bytes(self, separator: bytes) -> Optional[bytes]:
sections = self._get_dumpstate_sections()
if separator in sections:
return sections[separator]
content = self._get_dumpstate_file()
if not content:
return None
return AndroidArtifact.extract_dumpsys_section(content, separator, binary=True)
def _get_dumpsys_section(
self, separator: str, errors: str = "replace"
) -> Optional[str]:
cache_key = (
"android_bugreport",
"dumpstate",
"section",
separator,
errors,
)
with self.resource_lock:
if cache_key in self.resource_cache:
return self.resource_cache[cache_key]
section_bytes = self._get_dumpsys_section_bytes(separator.encode("utf-8"))
section = (
section_bytes.decode("utf-8", errors=errors)
if section_bytes is not None
else None
)
self.resource_cache[cache_key] = section
return section
def _get_system_properties_text(self, errors: str = "ignore") -> Optional[str]:
cache_key = (
"android_bugreport",
"dumpstate",
"system_properties",
errors,
)
with self.resource_lock:
if cache_key in self.resource_cache:
return self.resource_cache[cache_key]
sections = self._get_dumpstate_sections()
section_bytes = sections.get(_SYSTEM_PROPERTIES_SECTION)
section = (
section_bytes.decode("utf-8", errors=errors)
if section_bytes is not None
else None
)
self.resource_cache[cache_key] = section
return section
@staticmethod
def extract_command_section(content: str, heading: str) -> str:
"""Return a bugreport command section without consuming the next one.
Bugreport separators include timing text, so looking for a line equal to
``------`` is not sufficient and can accidentally feed the remainder of
dumpstate to a parser.
"""
lines: list[str] = []
in_section = False
for line in content.splitlines():
stripped = line.strip()
if not in_section:
if stripped.startswith(heading):
in_section = True
continue
if stripped.startswith("------"):
# dumpstate prints a section's timing line when that section
# finishes, which can land in the middle of the one being
# written. Treating it as a boundary truncates the section at
# an arbitrary point, silently.
if SECTION_DURATION.match(stripped):
continue
break
lines.append(line)
return "\n".join(lines)
def _device_timezone(self) -> Optional[datetime.tzinfo]:
"""The device's timezone named in module_options, or None when unknown."""
name = self.module_options.get("device_timezone")
if not name:
return None
try:
return ZoneInfo(name)
except ZoneInfoNotFoundError:
self.log.warning("Unknown device timezone %s", name)
return None
def _get_file_modification_time(self, file_path: str) -> datetime.datetime:
"""When the file was last modified.
A zip entry carries the device's wall clock, so it is returned in the
device's timezone when the bugreport names one and naive otherwise.
An unpacked bugreport's mtime is whatever the extraction left, an
instant returned in UTC.
"""
if self.zip_archive:
file_timetuple = self.zip_archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple)
else:
if not self.extract_path:
raise ValueError("extract_path is not set")
file_stat = os.stat(os.path.join(self.extract_path, file_path))
return datetime.datetime.fromtimestamp(file_stat.st_mtime)
with self.resource_lock:
file_timetuple = self.zip_archive.getinfo(file_path).date_time
return datetime.datetime(*file_timetuple, tzinfo=self._device_timezone())
if not self.extract_path:
raise ValueError("extract_path is not set")
file_stat = os.stat(os.path.join(self.extract_path, file_path))
return datetime.datetime.fromtimestamp(
file_stat.st_mtime, tz=datetime.timezone.utc
)
@@ -34,25 +34,34 @@ class DumpsysAccessibility(DumpsysAccessibilityArtifact, BugReportModule):
)
def run(self) -> None:
full_dumpsys = self._get_dumpstate_file()
if not full_dumpsys:
content = self._get_dumpsys_section(
"DUMP OF SERVICE accessibility:", errors="ignore"
)
if content is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
content = self.extract_dumpsys_section(
full_dumpsys.decode("utf-8", errors="ignore"),
"DUMP OF SERVICE accessibility:",
)
self.parse(content)
listed = unnamed = 0
for result in self.results:
self.log.info(
'Found installed accessibility service "%s"', result.get("service")
)
if result.get("component"):
listed += 1
self.log.info(
'Found accessibility service "%s"', result.get("component")
)
continue
# The operator gets this per user as a LOW alert from
# check_indicators(); here it only has to survive into the summary,
# so that a stated count never reads as "a total of 0".
unnamed += result.get("unnamed_service_count") or 0
self.log.info(
"Identified a total of %d accessibility services", len(self.results)
"Identified a total of %d accessibility services (%d more stated by "
"the dump without a component name)",
listed,
unnamed,
)
@@ -38,19 +38,14 @@ class DumpsysActivities(DumpsysPackageActivitiesArtifact, BugReportModule):
self.results = results if results else []
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
section = self._get_dumpsys_section("DUMP OF SERVICE package:", errors="ignore")
if section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
# Extract package section
section = self.extract_dumpsys_section(
content.decode("utf-8", errors="ignore"), "DUMP OF SERVICE package:"
)
# Parse
self.parse(section)
@@ -3,11 +3,15 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import base64
import binascii
import datetime
import logging
from typing import Optional
from mvt.android.artifacts.dumpsys_adb import DumpsysADBArtifact
from mvt.common.module_types import ModuleResults
from mvt.common.utils import convert_datetime_to_iso
from .base import BugReportModule
@@ -34,22 +38,136 @@ class DumpsysADBState(DumpsysADBArtifact, BugReportModule):
)
def run(self) -> None:
full_dumpsys = self._get_dumpstate_file()
if not full_dumpsys:
content = self._get_dumpsys_section_bytes(b"DUMP OF SERVICE adb:")
if content is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
content = self.extract_dumpsys_section(
full_dumpsys,
b"DUMP OF SERVICE adb:",
binary=True,
)
self.parse(content)
if self.results:
self.log.info(
"Identified a total of %d trusted ADB keys",
len(self.results[0].get("user_keys", [])),
)
@staticmethod
def _key_material(public_key: object) -> str:
if isinstance(public_key, bytes):
public_key = public_key.decode("utf-8", errors="replace")
if not isinstance(public_key, str):
return ""
return public_key.strip().split(" ", 1)[0]
@staticmethod
def _is_valid_key(public_key: str) -> bool:
if not public_key:
return False
try:
return bool(base64.b64decode(public_key, validate=True))
except (binascii.Error, ValueError):
return False
@staticmethod
def _parse_acquisition_time(value: object) -> Optional[datetime.datetime]:
if not isinstance(value, str):
return None
try:
timestamp = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
if not timestamp.tzinfo:
timestamp = timestamp.replace(tzinfo=datetime.timezone.utc)
return timestamp.astimezone(datetime.timezone.utc)
@staticmethod
def _parse_last_connected(value: object) -> Optional[datetime.datetime]:
try:
return datetime.datetime.fromtimestamp(
int(str(value)) / 1000,
tz=datetime.timezone.utc,
)
except (OSError, OverflowError, TypeError, ValueError):
return None
def _trusted_keys(self) -> list[dict]:
"""Return unique trusted keys, preferring keystore connection metadata."""
trusted_keys = []
seen = set()
for result in self.results:
keystore = result.get("keystore", [])
candidates = keystore if isinstance(keystore, list) else []
candidates = [*candidates, *result.get("user_keys", [])]
for candidate in candidates:
if not isinstance(candidate, dict):
continue
key = self._key_material(candidate.get("key"))
identity = key or repr(candidate)
if identity in seen:
continue
seen.add(identity)
trusted_keys.append(candidate)
return trusted_keys
def check_indicators(self) -> None:
if "androidqf_acquisition" not in self.module_options:
return super().check_indicators()
context = self.module_options.get("androidqf_acquisition")
if not isinstance(context, dict):
context = {}
acquisition_key = self._key_material(context.get("adb_host_public_key"))
if acquisition_key and not self._is_valid_key(acquisition_key):
acquisition_key = ""
acquisition_time = self._parse_acquisition_time(context.get("started"))
cutoff = (
acquisition_time - datetime.timedelta(days=1) if acquisition_time else None
)
for trusted_key in self._trusted_keys():
key = self._key_material(trusted_key.get("key"))
fingerprint = trusted_key.get("fingerprint") or "<unknown key>"
user = trusted_key.get("user") or "unknown user"
description = f"{fingerprint} ({user})"
last_connected = self._parse_last_connected(
trusted_key.get("last_connected")
)
event_time = (
convert_datetime_to_iso(last_connected) if last_connected else ""
)
if not self._is_valid_key(key):
self.alertstore.low(
f"Found an invalid trusted ADB host key: {description}",
event_time,
trusted_key,
)
continue
if not acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key, but the AndroidQF acquisition "
f"does not include its host key: {description}",
event_time,
trusted_key,
)
continue
if key != acquisition_key:
self.alertstore.low(
"Found a trusted ADB host key different from the AndroidQF "
f"acquisition host: {description}",
event_time,
trusted_key,
)
continue
if cutoff and last_connected and last_connected <= cutoff:
self.alertstore.info(
"Found a trusted ADB host key last connected at least one day "
f"before the AndroidQF acquisition: {description}",
event_time,
trusted_key,
)
@@ -34,17 +34,14 @@ class DumpsysAppops(DumpsysAppopsArtifact, BugReportModule):
)
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
section = self._get_dumpsys_section("DUMP OF SERVICE appops:")
if section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE appops:"
)
self.parse(section)
self.log.info(
@@ -34,17 +34,14 @@ class DumpsysBatteryDaily(DumpsysBatteryDailyArtifact, BugReportModule):
)
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
if dumpsys_section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
self.parse(dumpsys_section)
self.log.info("Extracted a total of %d battery daily stats", len(self.results))
@@ -34,17 +34,14 @@ class DumpsysBatteryHistory(DumpsysBatteryHistoryArtifact, BugReportModule):
)
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE batterystats:")
if dumpsys_section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE batterystats:"
)
self.parse(dumpsys_section)
self.log.info(
@@ -36,18 +36,14 @@ class DumpsysDBInfo(DumpsysDBInfoArtifact, BugReportModule):
)
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
section = self._get_dumpsys_section("DUMP OF SERVICE dbinfo:", errors="ignore")
if section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="ignore"), "DUMP OF SERVICE dbinfo:"
)
self.parse(section)
self.log.info(
"Extracted a total of %d database connection pool records",
@@ -36,29 +36,13 @@ class DumpsysGetProp(GetPropArtifact, BugReportModule):
self.results = [] if not results else results
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
content = self._get_system_properties_text()
if content is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
lines = []
in_getprop = False
for line in content.decode(errors="ignore").splitlines():
if line.strip().startswith("------ SYSTEM PROPERTIES"):
in_getprop = True
continue
if not in_getprop:
continue
if line.strip() == "------":
break
lines.append(line)
self.parse("\n".join(lines))
self.parse(content)
self.log.info("Extracted %d Android system properties", len(self.results))
@@ -35,22 +35,22 @@ class DumpsysPackages(DumpsysPackagesArtifact, BugReportModule):
)
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
content = self._get_dumpsys_section("DUMP OF SERVICE package:")
if content is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
content = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(content)
for result in self.results:
dangerous_permissions_count = 0
for perm in result["permissions"]:
permissions = list(result["permissions"])
for user in result.get("users", []):
permissions.extend(user.get("permissions", []))
for perm in permissions:
if perm["name"] in DANGEROUS_PERMISSIONS:
dangerous_permissions_count += 1
@@ -13,7 +13,7 @@ from mvt.common.module_types import ModuleResults
class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
"""This module extracts details on uninstalled apps."""
"""This module extracts raw per-package compatibility overrides."""
def __init__(
self,
@@ -34,18 +34,14 @@ class DumpsysPlatformCompat(DumpsysPlatformCompatArtifact, BugReportModule):
)
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
content = self._get_dumpsys_section("DUMP OF SERVICE platform_compat:")
if content is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
decoded_data = data.decode("utf-8", errors="replace")
content = self.extract_dumpsys_section(
decoded_data, "DUMP OF SERVICE platform_compat:"
)
self.parse(content)
self.log.info("Found %d uninstalled apps", len(self.results))
self.log.info("Found %d package compatibility overrides", len(self.results))
@@ -33,38 +33,16 @@ class DumpsysReceivers(DumpsysReceiversArtifact, BugReportModule):
results=results,
)
self.results = results if results else {}
def check_indicators(self) -> None:
for result in self.results:
if self.indicators:
receiver_name = self.results[result][0]["receiver"]
# return IoC if the stix2 process name a substring of the receiver name
ioc_match = self.indicators.check_receiver_prefix(receiver_name)
if ioc_match:
self.alertstore.critical(
ioc_match.message,
"",
self.results[result][0],
matched_indicator=ioc_match.ioc,
)
continue
self.results = results if results else []
def run(self) -> None:
content = self._get_dumpstate_file()
if not content:
dumpsys_section = self._get_dumpsys_section("DUMP OF SERVICE package:")
if dumpsys_section is None:
self.log.error(
"Unable to find dumpstate file. "
"Did you provide a valid bug report archive?"
)
return
dumpsys_section = self.extract_dumpsys_section(
content.decode("utf-8", errors="replace"), "DUMP OF SERVICE package:"
)
self.parse(dumpsys_section)
self.log.info("Extracted receivers for %d intents", len(self.results))
self.log.info("Extracted %d package receivers", len(self.results))
@@ -40,13 +40,29 @@ class BugReportTimestamps(FileTimestampsArtifact, BugReportModule):
self.results = []
for file in filesystem_files:
# Only the modification time is available in the zip file metadata.
# The timezone is the local timezone of the machine the phone.
# A zip entry keeps the device's wall clock, read in the device's
# timezone when the command found one (see CmdAndroidCheckBugreport);
# an unpacked bugreport's mtime is read as a UTC instant.
modification_time = self._get_file_modification_time(file)
self.results.append(
{
"path": file,
"modified_time": convert_datetime_to_iso(modification_time),
"modified_time": convert_datetime_to_iso(
modification_time.replace(tzinfo=None)
),
"modified_time_utc": (
convert_datetime_to_iso(modification_time)
if modification_time.tzinfo
else None
),
"timezone": (
self.module_options.get("device_timezone")
if self.zip_archive
else "UTC"
),
"timestamp_source": (
"zip_metadata" if self.zip_archive else "filesystem_metadata"
),
}
)
@@ -0,0 +1,41 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
import re
from mvt.android.artifacts.mounts import Mounts as MountsArtifact
from .base import BugReportModule
class Mounts(MountsArtifact, BugReportModule):
"""Extract and deduplicate process mount namespaces from mountinfo."""
def run(self) -> None:
mount_files = self._get_files_by_pattern("FS/proc/*/mountinfo")
if not mount_files:
mount_files = self._get_files_by_pattern("*/proc/*/mountinfo")
unique: dict[tuple, dict] = {}
for file_path in mount_files:
pid_match = re.search(r"/proc/(\d+)/mountinfo$", file_path)
if not pid_match:
continue
entries = self.parse_mountinfo(
self._get_file_content(file_path).decode("utf-8", errors="replace"),
int(pid_match.group(1)),
)
for entry in entries:
identity = (
entry["major_minor"],
entry["root"],
entry["mount_point"],
entry["device"],
entry["filesystem_type"],
entry["mount_options"],
)
if identity in unique:
unique[identity]["process_ids"].extend(entry["process_ids"])
else:
unique[identity] = entry
self.results = list(unique.values())
self.log.info("Extracted %d unique mount records", len(self.results))
@@ -0,0 +1,22 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.processes import Processes as ProcessesArtifact
from .base import BugReportModule
class Processes(ProcessesArtifact, BugReportModule):
"""Extract the process and thread table from dumpstate."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_command_section(
data.decode("utf-8", errors="replace"),
"------ PROCESSES AND THREADS",
)
self.parse(section)
self.log.info("Identified %d running process threads", len(self.results))
@@ -0,0 +1,21 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
from mvt.android.artifacts.settings import Settings as SettingsArtifact
from .base import BugReportModule
class Settings(SettingsArtifact, BugReportModule):
"""Extract all SettingsProvider namespaces and users."""
def run(self) -> None:
data = self._get_dumpstate_file()
if not data:
self.log.error("Unable to find dumpstate file")
return
section = self.extract_dumpsys_section(
data.decode("utf-8", errors="replace"), "DUMP OF SERVICE settings:"
)
self.parse(section)
self.log.info("Identified %d Android settings", len(self.results))
+68 -13
View File
@@ -43,21 +43,76 @@ class Tombstones(TombstoneCrashArtifact, BugReportModule):
)
return
for tombstone_file in sorted(tombstone_files):
tombstone_filename = tombstone_file.split("/")[-1]
modification_time = self._get_file_modification_time(tombstone_file)
tombstone_data = self._get_file_content(tombstone_file)
grouped: dict[str, dict[str, str]] = {}
for file_path in tombstone_files:
file_name = file_path.rsplit("/", 1)[-1]
source = "protobuf" if file_name.endswith(".pb") else "text"
crash_id = file_name.removesuffix(".pb")
grouped.setdefault(crash_id, {})[source] = file_path
try:
if tombstone_file.endswith(".pb"):
self.parse_protobuf(
tombstone_filename, modification_time, tombstone_data
for crash_id, paths in sorted(grouped.items()):
parsed_sources: dict[str, dict] = {}
source_records: dict[str, dict] = {}
for source in ("text", "protobuf"):
file_path = paths.get(source)
if file_path is None:
continue
file_name = file_path.rsplit("/", 1)[-1]
file_timestamp = self._get_file_modification_time(file_path)
source_info = {
"file_name": file_name,
"file_timestamp": file_timestamp.isoformat(),
"parsed": False,
"error": None,
"record": None,
}
try:
data = self._get_file_content(file_path)
if source == "protobuf":
record = self.parse_protobuf_record(
file_name, file_timestamp, data
)
else:
record = self.parse_text_record(file_name, file_timestamp, data)
source_info["parsed"] = True
source_info["record"] = record
source_records[source] = record
except Exception as exc:
source_info["error"] = str(exc)
self.log.error(
"Error parsing tombstone file %s: %s", file_path, exc
)
else:
self.parse(tombstone_filename, modification_time, tombstone_data)
except ValueError as e:
# Catch any exceptions raised during parsing or validation.
self.log.error(f"Error parsing tombstone file {tombstone_file}: {e}")
parsed_sources[source] = source_info
if not source_records:
continue
preferred = source_records.get("protobuf") or source_records["text"]
canonical = dict(preferred)
text_record = source_records.get("text")
if text_record:
for key, value in text_record.items():
if canonical.get(key) in (None, "", [], {}):
canonical[key] = value
differences = {}
protobuf_record = source_records.get("protobuf")
if text_record and protobuf_record:
for key in text_record.keys() & protobuf_record.keys():
if key in ("file_name", "file_timestamp"):
continue
if text_record[key] != protobuf_record[key]:
differences[key] = {
"text": text_record[key],
"protobuf": protobuf_record[key],
}
canonical.update(
{
"crash_id": crash_id,
"sources": parsed_sources,
"differences": differences,
}
)
self.results.append(canonical)
self.log.info(
"Extracted a total of %d tombstone files",
@@ -3,11 +3,13 @@
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from mvt.common.module import MVTModule
from .connect_event import ConnectEvent
from .dns_event import DnsEvent
from .security_event import SecurityEvent
INTRUSION_LOGS_MODULES = [
INTRUSION_LOGS_MODULES: list[type[MVTModule]] = [
DnsEvent,
ConnectEvent,
SecurityEvent,
@@ -270,6 +270,11 @@ SECURITY_EVENT_METADATA_KEYS = {
"timestamp",
}
# Known platform failures that remain in the timeline but do not need a warning.
KNOWN_BENIGN_KEY_GENERATION_FAILURES = {
("PinStorage_crossReboot_key", 1001),
}
class SecurityEvent(IntrusionLogsModule):
"""This module analyzes security events from intrusion logs."""
@@ -400,10 +405,17 @@ class SecurityEvent(IntrusionLogsModule):
independent of any loaded indicators."""
# Flag failed cryptographic operations as potentially suspicious
if "key_generated" in result:
if not result["key_generated"].get("success", True):
key_info = result["key_generated"]
key_id = key_info.get("key_id", "unknown")
uid = key_info.get("uid")
is_known_benign_failure = (
key_id,
uid,
) in KNOWN_BENIGN_KEY_GENERATION_FAILURES
if not key_info.get("success", True) and not is_known_benign_failure:
self.log.warning(
"Failed key generation detected for key_id: %s",
result["key_generated"].get("key_id", "unknown"),
key_id,
)
# Flag certificate validation failures
+22 -17
View File
@@ -131,20 +131,23 @@ def decrypt_backup_data(encrypted_backup, password, encryption_algo, format_vers
if password is None:
raise InvalidBackupPassword()
[
user_salt,
checksum_salt,
pbkdf2_rounds,
user_iv,
master_key_blob,
encrypted_data,
] = encrypted_backup.split(b"\n", 5)
try:
[
user_salt,
checksum_salt,
pbkdf2_rounds,
user_iv,
master_key_blob,
encrypted_data,
] = encrypted_backup.split(b"\n", 5)
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
pbkdf2_rounds = int(pbkdf2_rounds)
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
user_salt = bytes.fromhex(user_salt.decode("utf-8"))
checksum_salt = bytes.fromhex(checksum_salt.decode("utf-8"))
pbkdf2_rounds = int(pbkdf2_rounds)
user_iv = bytes.fromhex(user_iv.decode("utf-8"))
master_key_blob = bytes.fromhex(master_key_blob.decode("utf-8"))
except (UnicodeDecodeError, ValueError) as exc:
raise AndroidBackupParsingError("Invalid encrypted backup header") from exc
# Derive decryption master key from password.
master_key, master_iv = decrypt_master_key(
@@ -174,10 +177,12 @@ def parse_backup_file(data, password=None):
if not data.startswith(b"ANDROID BACKUP"):
raise AndroidBackupParsingError("Invalid file header")
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
version = int(version)
is_compressed = int(is_compressed)
try:
[_, version, is_compressed, encryption_algo, tar_data] = data.split(b"\n", 4)
version = int(version)
is_compressed = int(is_compressed)
except ValueError as exc:
raise AndroidBackupParsingError("Invalid file header") from exc
if encryption_algo != b"none":
tar_data = decrypt_backup_data(
+110
View File
@@ -0,0 +1,110 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import click
from mvt.common.cli_plugins import (
MVT_CUSTOM_COMMANDS_ENV,
NEUTRAL_CLI_PLUGIN_GROUP,
load_cli_commands_option,
register_cli_plugins,
)
from mvt.common.cmd_plugins import plugins
from mvt.common.completion import completion
from mvt.common.help import (
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
HELP_MSG_DISABLE_UPDATE_CHECK,
HELP_MSG_STIX2,
HELP_MSG_VERBOSE,
HELP_MSG_VERSION,
)
from mvt.common.logo import logo
from mvt.common.updates import IndicatorsUpdates
from mvt.common.utils import init_logging, set_verbose_logging
init_logging()
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
# ==============================================================================
# Main
# ==============================================================================
@click.group(invoke_without_command=True)
@load_cli_commands_option
@click.option(
"--disable-update-check", is_flag=True, help=HELP_MSG_DISABLE_UPDATE_CHECK
)
@click.option(
"--disable-indicator-update-check",
is_flag=True,
help=HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK,
)
@click.option("--verbose", "-v", is_flag=True, help=HELP_MSG_VERBOSE)
@click.pass_context
def cli(ctx, disable_update_check, disable_indicator_update_check, verbose):
"""Mobile Verification Toolkit.
The 'mvt-ios' and 'mvt-android' CLI commands are used to perform
forensic analysis on IOS and Android devices.
"""
ctx.ensure_object(dict)
ctx.obj["disable_version_check"] = disable_update_check
ctx.obj["disable_indicator_check"] = disable_indicator_update_check
ctx.obj["verbose"] = verbose
set_verbose_logging(verbose)
if ctx.invoked_subcommand != "completion":
logo(
disable_version_check=disable_update_check,
disable_indicator_check=disable_indicator_update_check,
)
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ==============================================================================
# Command: download-iocs
# ==============================================================================
@cli.command("download-iocs", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_STIX2)
def download_iocs():
ioc_updates = IndicatorsUpdates()
ioc_updates.update()
# ==============================================================================
# Command: completion
# ==============================================================================
cli.add_command(completion)
# ==============================================================================
# Command: version
# ==============================================================================
@cli.command("version", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_VERSION)
def version():
return
# The plugins command is registered as a built-in command, before any external
# command, so that an installed package can never replace it.
cli.add_command(plugins)
# ==============================================================================
# Entry point of the mvt console script
# ==============================================================================
def main() -> None:
"""Register the external commands and run the mvt CLI.
External commands are registered here rather than when this module is
imported, so that importing MVT never runs third-party code and a plugin
importing from MVT cannot re-enter a module that is still initializing.
"""
register_cli_plugins(
cli,
entry_point_group=NEUTRAL_CLI_PLUGIN_GROUP,
environment_variable=MVT_CUSTOM_COMMANDS_ENV,
)
cli()
+317
View File
@@ -0,0 +1,317 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import hashlib
import importlib.metadata
import importlib.util
import logging
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Iterable
import click
# This module is imported by every CLI at start-up, and by shell completion on
# every keystroke, so it must stay cheap: nothing here may import the module
# machinery (mvt.common.module_loader and what it pulls in).
IOS_CLI_PLUGIN_GROUP = "mvt.ios.cli_plugins"
ANDROID_CLI_PLUGIN_GROUP = "mvt.android.cli_plugins"
# Commands in this group are registered on the platform-neutral mvt command only.
NEUTRAL_CLI_PLUGIN_GROUP = "mvt.cli_plugins"
MVT_CUSTOM_COMMANDS_ENV = "MVT_CUSTOM_COMMANDS"
MVT_IOS_CUSTOM_COMMANDS_ENV = "MVT_IOS_CUSTOM_COMMANDS"
MVT_ANDROID_CUSTOM_COMMANDS_ENV = "MVT_ANDROID_CUSTOM_COMMANDS"
# Prefix of the import name given to a command file loaded from a path. Shared
# with module_loader, which recognises such files when naming their loggers.
CUSTOM_COMMAND_MODULE_PREFIX = "_mvt_custom_command_"
log = logging.getLogger(__name__)
class CustomCommandLoadError(Exception):
pass
class BrokenPluginCommand(click.Command):
"""A placeholder for an installed or configured command that failed to load."""
def __init__(self, name: str, source: str, exception: BaseException):
super().__init__(
name,
help=(
f"Unable to load external command from {source}.\n\n"
f"{type(exception).__name__}: {exception}"
),
short_help="Warning: external command could not be loaded.",
)
self.source = source
self.exception = exception
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
return args
def invoke(self, ctx: click.Context) -> None:
raise click.ClickException(
f"Unable to load external command '{self.name}' from {self.source}: "
f"{type(self.exception).__name__}: {self.exception}"
)
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"{CUSTOM_COMMAND_MODULE_PREFIX}{path.stem}_{digest}"
def _iter_command_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomCommandLoadError(
f"Custom command file is not a Python file: {path}"
)
yield path
return
if path.is_dir():
for child in sorted(path.iterdir()):
if child.name.startswith(".") or child.name == "__init__.py":
continue
if child.is_file() and child.suffix == ".py":
yield child
return
raise CustomCommandLoadError(f"Custom command path does not exist: {path}")
def _load_python_file(path: Path) -> ModuleType:
module_name = _module_name_for_path(path)
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CustomCommandLoadError(f"Unable to load custom command file: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
try:
spec.loader.exec_module(module)
except (Exception, SystemExit) as exc:
raise CustomCommandLoadError(
f"Unable to import custom command {path}: {exc}"
) from exc
return module
def load_cli_command_file(path: Path) -> click.Command:
module = _load_python_file(path)
command = getattr(module, "cli", None)
if not isinstance(command, click.Command):
raise CustomCommandLoadError(
f"Custom command {path} must export a Click command or group named 'cli'"
)
if command.name is None:
raise CustomCommandLoadError(f"Custom command {path} has no command name")
return command
def _register_command(
group: click.Group,
command: click.Command,
*,
name: str,
source: str,
collision_is_error: bool,
) -> bool:
if name in group.commands:
registered_sources = getattr(group, "_mvt_external_command_sources", {})
if registered_sources.get(name) == source:
return False
message = (
f"Unable to register external command '{name}' from {source}: "
"the command name is already registered"
)
if collision_is_error:
raise CustomCommandLoadError(message)
log.warning(message)
return False
group.add_command(command, name=name)
registered_sources = getattr(group, "_mvt_external_command_sources", {})
registered_sources[name] = source
setattr(group, "_mvt_external_command_sources", registered_sources)
return True
def register_cli_commands_from_path(
group: click.Group,
path: str | Path,
*,
collision_is_error: bool = False,
failures_are_errors: bool = False,
) -> list[str]:
resolved_path = Path(path).expanduser().resolve()
registered: list[str] = []
try:
command_files = list(_iter_command_files(resolved_path))
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = resolved_path.stem.replace("_", "-") or "custom-command"
if _register_command(
group,
BrokenPluginCommand(name, str(resolved_path), exc),
name=name,
source=str(resolved_path),
collision_is_error=collision_is_error,
):
registered.append(name)
return registered
for command_file in command_files:
try:
command = load_cli_command_file(command_file)
except CustomCommandLoadError as exc:
if failures_are_errors:
raise
name = command_file.stem.replace("_", "-")
command = BrokenPluginCommand(name, str(command_file), exc)
command_name = command.name
if command_name is None:
raise CustomCommandLoadError(
f"Custom command {command_file} has no command name"
)
if _register_command(
group,
command,
name=command_name,
source=str(command_file),
collision_is_error=collision_is_error,
):
registered.append(command_name)
return registered
def _entry_point_source(entry_point: importlib.metadata.EntryPoint) -> str:
try:
distribution = getattr(entry_point, "dist", None)
if distribution is None:
return f"entry point {entry_point.value}"
name = distribution.metadata.get("Name", "unknown distribution")
version = getattr(distribution, "version", None)
if version:
return f"{name} {version} ({entry_point.value})"
return f"{name} ({entry_point.value})"
except Exception:
return f"entry point {entry_point.value}"
def register_installed_cli_commands(
group: click.Group,
entry_point_group: str,
) -> list[str]:
try:
entry_points = importlib.metadata.entry_points(group=entry_point_group)
except Exception as exc:
log.warning(
"Unable to discover external commands in entry-point group %s: %s",
entry_point_group,
exc,
)
return []
ordered_entry_points = sorted(
entry_points,
key=lambda entry_point: (
entry_point.name,
_entry_point_source(entry_point),
entry_point.value,
),
)
registered: list[str] = []
for entry_point in ordered_entry_points:
source = _entry_point_source(entry_point)
try:
command = entry_point.load()
if not isinstance(command, click.Command):
raise TypeError(
f"entry point must resolve to a Click command or group, "
f"not {type(command).__name__}"
)
except (Exception, SystemExit) as exc:
command = BrokenPluginCommand(entry_point.name, source, exc)
if _register_command(
group,
command,
name=entry_point.name,
source=source,
collision_is_error=False,
):
registered.append(entry_point.name)
return registered
def register_cli_plugins(
group: click.Group,
*,
entry_point_group: str,
environment_variable: str,
) -> None:
"""Register the external commands of one CLI on its group.
Each CLI has one entry-point group and one environment variable of its
own, so a command package chooses the CLIs its commands are added to.
:param group: CLI group to register the external commands on.
:param entry_point_group: Entry-point group of the CLI.
:param environment_variable: Name of the environment variable holding a
path to load commands from.
"""
environment_path = os.environ.get(environment_variable)
if environment_path:
register_cli_commands_from_path(group, environment_path)
register_installed_cli_commands(group, entry_point_group)
def _load_command_option_callback(
ctx: click.Context,
param: click.Parameter,
paths: tuple[str, ...],
) -> tuple[str, ...]:
if not isinstance(ctx.command, click.Group):
raise click.ClickException("--load-command requires a Click command group")
for path in paths:
try:
register_cli_commands_from_path(
ctx.command,
path,
collision_is_error=True,
failures_are_errors=True,
)
except CustomCommandLoadError as exc:
raise click.BadParameter(str(exc), ctx=ctx, param=param) from exc
return paths
load_cli_commands_option = click.option(
"--load-command",
"load_commands",
type=click.Path(exists=True),
multiple=True,
expose_value=False,
is_eager=True,
callback=_load_command_option_callback,
help=(
"Load a custom CLI command from a Python file or folder "
"(can be invoked multiple times)"
),
)
+8 -3
View File
@@ -9,6 +9,7 @@ from typing import Optional
from mvt.common.command import Command
from mvt.common.module import MVTModule
from mvt.common.module_loader import get_module_logger
from mvt.common.utils import exec_or_profile
log = logging.getLogger(__name__)
@@ -55,6 +56,10 @@ class CmdCheckIOCS(Command):
if entry not in all_modules:
all_modules.append(entry)
# Read the indicators once, so that a missing indicators file is
# reported even when no stored result matches a module.
iocs = self.iocs
log.info("Checking stored results against provided indicators...")
total_detections = 0
@@ -76,14 +81,14 @@ class CmdCheckIOCS(Command):
)
m = iocs_module.from_json(
file_path, log=logging.getLogger(iocs_module.__module__)
file_path, log=get_module_logger(iocs_module)
)
if not m:
log.warning("No result from this module, skipping it")
continue
if self.iocs.total_ioc_count > 0:
m.indicators = self.iocs
if iocs.total_ioc_count > 0:
m.indicators = iocs
m.indicators.log = m.log
try:
+205
View File
@@ -0,0 +1,205 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2026 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
import importlib.metadata
import logging
from typing import Optional
import click
from rich.console import Console
from rich.table import Table
from .cli_plugins import (
ANDROID_CLI_PLUGIN_GROUP,
IOS_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
from .config import settings
from .help import (
HELP_MSG_PLUGINS,
HELP_MSG_PLUGINS_CHECK_UPDATES,
HELP_MSG_PLUGINS_LIST,
)
from .module import MVTModule
from .module_loader import MODULES_ENTRY_POINT_GROUP, distribution_direct_url
from .updates import (
SHORT_COMMIT_LENGTH,
PluginUpdates,
installed_plugin_distributions,
)
log = logging.getLogger(__name__)
CLI_PLUGIN_GROUPS = (
IOS_CLI_PLUGIN_GROUP,
ANDROID_CLI_PLUGIN_GROUP,
NEUTRAL_CLI_PLUGIN_GROUP,
)
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
def _entry_points(group: str) -> list[importlib.metadata.EntryPoint]:
try:
return list(importlib.metadata.entry_points(group=group))
except Exception as exc:
log.warning("Unable to discover the entry points in group %s: %s", group, exc)
return []
def _entry_point_distribution(
entry_point: importlib.metadata.EntryPoint,
) -> Optional[str]:
dist = getattr(entry_point, "dist", None)
if dist is None:
return None
try:
return dist.name
except Exception:
return None
def _distribution_version(dist: importlib.metadata.Distribution) -> str:
try:
return dist.version or "unknown"
except Exception:
return "unknown"
def _distribution_origin(dist: importlib.metadata.Distribution) -> str:
"""Describe where a plugin package was installed from."""
direct_url = distribution_direct_url(dist)
if direct_url is None:
return "pypi"
vcs_info = direct_url.get("vcs_info")
if isinstance(vcs_info, dict):
commit = vcs_info.get("commit_id") or ""
if commit:
return f"git+{commit[:SHORT_COMMIT_LENGTH]}"
return "git"
return "local"
def _contributed_modules(
entry_points: list[importlib.metadata.EntryPoint], distribution: str
) -> str:
"""Count the forensic modules a plugin package contributes.
Entry points are resolved the way MVT resolves them when it loads
modules, but a broken entry point is reported instead of raising: listing
the installed plugins must work even when one of them is faulty.
"""
count = 0
broken = False
for entry_point in entry_points:
if _entry_point_distribution(entry_point) != distribution:
continue
try:
loaded = entry_point.load()
if callable(loaded) and not isinstance(loaded, type):
loaded = loaded()
count += sum(
1
for module in loaded
if isinstance(module, type) and issubclass(module, MVTModule)
)
except (Exception, SystemExit) as exc:
log.debug(
"Unable to load the modules of entry point %s (%s): %s",
entry_point.name,
entry_point.value,
exc,
)
broken = True
if broken:
return f"{count} (error)" if count else "error"
return str(count)
def _contributed_commands(
entry_points: list[importlib.metadata.EntryPoint], distribution: str
) -> str:
names = {
entry_point.name
for entry_point in entry_points
if _entry_point_distribution(entry_point) == distribution
}
return ", ".join(sorted(names)) if names else "-"
@click.group("plugins", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS)
def plugins() -> None:
pass
@plugins.command("list", context_settings=CONTEXT_SETTINGS, help=HELP_MSG_PLUGINS_LIST)
def list_plugins() -> None:
distributions = installed_plugin_distributions()
if not distributions:
click.echo("No MVT plugins are installed.")
return
module_entry_points = _entry_points(MODULES_ENTRY_POINT_GROUP)
command_entry_points = []
for group in CLI_PLUGIN_GROUPS:
command_entry_points.extend(_entry_points(group))
table = Table(title="Installed MVT plugins")
table.add_column("Name", style="bold")
table.add_column("Version")
table.add_column("Origin")
table.add_column("Modules", justify="right")
table.add_column("Commands")
for dist in distributions:
name = dist.name
table.add_row(
name,
_distribution_version(dist),
_distribution_origin(dist),
_contributed_modules(module_entry_points, name),
_contributed_commands(command_entry_points, name),
)
Console().print(table)
@plugins.command(
"check-updates",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_PLUGINS_CHECK_UPDATES,
short_help="Check the installed plugins for updates",
)
def check_plugin_updates() -> None:
if not settings.NETWORK_ACCESS_ALLOWED:
click.echo(
"Network access is disabled, cannot check for plugin updates. "
"Enable NETWORK_ACCESS_ALLOWED in the MVT configuration to check."
)
return
if not installed_plugin_distributions():
click.echo("No MVT plugins are installed.")
return
findings = PluginUpdates().check()
if not findings:
click.echo("All plugins are up to date.")
return
click.echo("Plugin updates available:")
for finding in findings:
click.echo(f" {finding['name']} {finding['installed']} → {finding['latest']}")
click.echo(f" Upgrade with: {finding['upgrade_command']}")
click.echo(
"\nMVT does not install plugin updates. Run the command above when you "
"decide to upgrade."
)
+632 -63
View File
@@ -6,8 +6,12 @@
import json
import logging
import os
import queue
import sys
import threading
from concurrent.futures import Future, ThreadPoolExecutor
from datetime import datetime
from functools import cached_property
from heapq import heappop, heappush
from typing import Any, Optional
@@ -18,9 +22,15 @@ from rich.text import Text
from .alerts import AlertLevel, AlertStore
from .config import settings
from .indicators import Indicators
from .log import MVTLogHandler, finish_module_log_buffer, start_module_log_buffer
from .module import EncryptedBackupError, MVTModule, run_module, save_timeline
from .module_loader import module_supports_command
from .module_types import ModuleTimeline
from .module_loader import (
ModuleOrigin,
get_module_logger,
get_module_origin,
module_supports_command,
)
from .module_types import ModuleTimeline, URLResult
from .utils import (
CustomJSONEncoder,
convert_datetime_to_iso,
@@ -46,6 +56,7 @@ class Command:
disable_version_check: bool = False,
disable_indicator_check: bool = False,
custom_modules: Optional[list[type[MVTModule]]] = None,
jobs: int = 4,
) -> None:
self.name = ""
self.platform = ""
@@ -61,31 +72,41 @@ class Command:
self.sub_command = sub_command
self.disable_version_check = disable_version_check
self.disable_indicator_check = disable_indicator_check
if jobs < 1:
raise ValueError("jobs must be at least 1")
self.jobs = jobs
self._resource_lock = threading.RLock()
self._resource_cache: dict[object, Any] = {}
# This dictionary can contain options that will be passed down from
# the Command to all modules. This can for example be used to pass
# down a password to decrypt a backup or flags which are need by some modules.
self.module_options = module_options if module_options else {}
# This dictionary maps the modules which were replaced by a module
# declaring `replaces` to the module which took their place.
self.module_replacements: dict[type[MVTModule], type[MVTModule]] = {}
# This list will contain all executed modules.
# We can use this to reference e.g. self.executed[0].results.
self.executed: list[MVTModule] = []
self.hashes = hashes
self.hash_values: list[dict[str, Any]] = []
self.timeline: ModuleTimeline = []
# Load IOCs
self._create_storage()
self._setup_logging()
self.url_results: list[URLResult] = []
if iocs is not None:
self.iocs = iocs
else:
self.iocs = Indicators(self.log)
self.iocs.load_indicators_files(self.ioc_files)
self.alertstore = AlertStore()
@cached_property
def iocs(self) -> Indicators:
"""Load indicators on first use. Nested commands share their parent's."""
iocs = Indicators(self.log)
iocs.load_indicators_files(self.ioc_files)
return iocs
def _create_storage(self) -> None:
if self.results_path and not os.path.exists(self.results_path):
try:
@@ -150,6 +171,14 @@ class Command:
with open(alerts_path, "w+", encoding="utf-8") as handle:
json.dump(alerts, handle, indent=4, cls=CustomJSONEncoder)
def _store_urls(self) -> None:
if not self.results_path or not self.url_results:
return
urls_path = os.path.join(self.results_path, "urls.json")
with open(urls_path, "w", encoding="utf-8") as handle:
json.dump(self.url_results, handle, indent=4, cls=CustomJSONEncoder)
def _store_alerts_timeline(self) -> None:
if not self.results_path:
return
@@ -201,10 +230,34 @@ class Command:
for file in generate_hashes_from_path(self.target_path, self.log):
self.hash_values.append(file)
@staticmethod
def _modules_by_origin(
modules: list[type[MVTModule]],
) -> dict[ModuleOrigin, list[str]]:
origins: dict[ModuleOrigin, list[str]] = {}
for module in modules:
origins.setdefault(get_module_origin(module), []).append(module.__name__)
return origins
def list_modules(self) -> None:
self.log.info("Following is the list of available %s modules:", self.name)
for module in self._available_modules():
self.log.info(" - %s", module.__name__)
for origin, module_names in self._modules_by_origin(
self._available_modules()
).items():
self.log.info(
" - Modules from %s: %s", origin.label, ", ".join(module_names)
)
def _log_loaded_modules(self, modules: list[type[MVTModule]]) -> None:
"""Record the loaded modules and their origins for auditability."""
for origin, module_names in self._modules_by_origin(modules).items():
self.log.info(
"Loaded %d %s modules from %s: %s",
len(module_names),
self.name,
origin.label,
", ".join(module_names),
)
def _available_modules(self) -> list[type[MVTModule]]:
modules = list(self.modules)
@@ -219,7 +272,174 @@ class Command:
if module not in deduplicated:
deduplicated.append(module)
return deduplicated
available = self._apply_replacements(deduplicated)
self._warn_about_slug_collisions(available)
return available
def _warn_about_slug_collisions(self, modules: list[type[MVTModule]]) -> None:
"""Report modules writing their results to the same file.
Results are stored in a file named after the module slug, so two
modules sharing one silently overwrite each other. Replacements are
already resolved here, so a module deliberately taking over the slug
of the module it replaces is not reported: the module it replaced is
no longer part of the run.
"""
modules_by_slug: dict[str, type[MVTModule]] = {}
for module in modules:
slug = module.get_slug()
first = modules_by_slug.setdefault(slug, module)
if first is module:
continue
self.log.warning(
"Modules %s from %s and %s from %s both use the slug %s. If "
"both run, whichever runs last overwrites the results of the "
"other in %s.json.",
first.__name__,
get_module_origin(first).label,
module.__name__,
get_module_origin(module).label,
slug,
slug,
)
def _declared_replacements(
self, modules: list[type[MVTModule]]
) -> dict[type[MVTModule], type[MVTModule]]:
"""Return the replacements declared by the given modules."""
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for module in modules:
replaced = module.replaces
if replaced is None or replaced is module:
continue
if not module.enabled:
# Replacing a module must not disable it, as a disabled
# replacement never runs in its place.
self.log.debug(
"Module %s is disabled and does not replace module %s.",
module.__name__,
replaced.__name__,
)
continue
if replaced not in modules:
# A module can support several commands while the module it
# replaces is only available in some of them.
self.log.debug(
"Module %s replaces module %s, which is not available "
"for the %s command.",
module.__name__,
replaced.__name__,
self.name,
)
continue
if replaced in replacements:
self.log.warning(
"Modules %s and %s both replace module %s. Both of them "
"will run, %s will not, and modules depending on %s will "
"use the results of %s. Replacements which share the slug "
"of %s overwrite each other's results file.",
replacements[replaced].__name__,
module.__name__,
replaced.__name__,
replaced.__name__,
replaced.__name__,
replacements[replaced].__name__,
replaced.__name__,
)
continue
replacements[replaced] = module
return replacements
def _drop_replacement_cycles(
self, replacements: dict[type[MVTModule], type[MVTModule]]
) -> None:
"""Undo the replacements between modules which replace each other."""
cyclic: set[type[MVTModule]] = set()
for replaced in replacements:
walked: list[type[MVTModule]] = []
module = replaced
while module in replacements and module not in cyclic:
if module in walked:
cyclic.update(walked[walked.index(module) :])
break
walked.append(module)
module = replacements[module]
if not cyclic:
return
self.log.warning(
"Modules %s replace each other in a cycle. None of them replaces "
"anything and all of them will run.",
", ".join(sorted(module.__name__ for module in cyclic)),
)
for module in cyclic:
replacements.pop(module, None)
def _log_replacement(
self,
replaced: type[MVTModule],
module: type[MVTModule],
replacement: type[MVTModule],
) -> None:
"""Report an applied replacement, and any problem with it."""
if not issubclass(module, replaced):
self.log.warning(
"Module %s replaces module %s but is not a subclass of it. "
"Its results might not be compatible with what modules "
"depending on %s expect.",
module.__name__,
replaced.__name__,
replaced.__name__,
)
if replaced in module.dependencies:
self.log.warning(
"Module %s depends on module %s, which it also replaces. The "
"dependency cannot be satisfied: a replacement has to produce "
"that data itself.",
module.__name__,
replaced.__name__,
)
self.log.info(
"Module %s from %s replaces module %s from %s.",
replacement.__name__,
get_module_origin(replacement).label,
replaced.__name__,
get_module_origin(replaced).label,
)
def _apply_replacements(
self, modules: list[type[MVTModule]]
) -> list[type[MVTModule]]:
"""Drop the modules superseded by a module declaring `replaces`."""
declared = self._declared_replacements(modules)
self._drop_replacement_cycles(declared)
replacements: dict[type[MVTModule], type[MVTModule]] = {}
for replaced, module in declared.items():
# Follow chains of replacements, so that a dependency on a
# replaced module always resolves to a module which is part of
# the run.
replacement = module
while replacement in declared:
replacement = declared[replacement]
# Only the replacements which are applied are reported, so that
# the record matches the modules which actually run.
self._log_replacement(replaced, module, replacement)
replacements[replaced] = replacement
self.module_replacements = replacements
return [module for module in modules if module not in replacements]
def init(self) -> None:
raise NotImplementedError
@@ -279,42 +499,196 @@ class Command:
console.print("")
console.print(panel)
def _module_dependencies(
self, module: type[MVTModule]
) -> list[tuple[type[MVTModule], type[MVTModule]]]:
"""Return the (declared, resolved) dependencies of a module.
A dependency on a module which was replaced is resolved to the module
which took its place. A module which replaces one of its own
dependencies is not made to depend on itself, while a module which
declares itself as a dependency is left alone and still fails the
circular dependency check.
"""
dependencies = []
for dependency in module.dependencies:
resolved = self.module_replacements.get(dependency, dependency)
if resolved is module and dependency is not module:
continue
dependencies.append((dependency, resolved))
return dependencies
@staticmethod
def _dependency_name(
declared: type[MVTModule], resolved: type[MVTModule]
) -> str:
"""Return how a dependency is named in the messages about it.
A dependency is named as the module which declared it wrote it, and,
when that module was replaced, as the module which runs in its place.
"""
if declared is resolved:
return declared.__name__
return f"{resolved.__name__} (replacing module {declared.__name__})"
def _selected_modules(
self, modules: list[type[MVTModule]]
) -> Optional[list[type[MVTModule]]]:
"""Return the modules explicitly requested, or all the enabled ones.
Returns None when a module was requested by name and no module of
that name can be run.
"""
if not self.module_name:
return [module for module in modules if module.enabled]
selected = [
module for module in modules if module.__name__ == self.module_name
]
# A module replacing another one does not have to keep its name, so
# the name of a replaced module selects its replacement.
if not selected:
for replaced, replacement in self.module_replacements.items():
if replaced.__name__ != self.module_name or replacement in selected:
continue
self.log.info(
"Module %s was replaced by module %s, which is run "
"in its place.",
replaced.__name__,
replacement.__name__,
)
selected.append(replacement)
if not selected:
self.log.warning(
"No module named %s is available for the %s command. "
"No modules will be run.",
self.module_name,
self.name,
)
return None
return selected
def _skipped_modules(
self,
required: list[type[MVTModule]],
module_indexes: dict[type[MVTModule], int],
) -> dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]]:
"""Return the modules to drop because a dependency is unavailable.
A module declaring a dependency this command cannot provide is unable
to run, and so is every module depending on it. Dropping only those
keeps a single wrong declaration - in a module scoped to several
commands, for example - from silencing an entire analysis.
The returned mapping gives, for each skipped module, the module which
is missing a dependency and the dependency it is missing.
"""
skipped: dict[type[MVTModule], tuple[type[MVTModule], type[MVTModule]]] = {}
# Skipping the one module a run was asked for leaves nothing to run,
# which the caller reports instead.
remainder = (
"" if self.module_name else " The rest of the analysis will still run."
)
# Skipping one module can skip the modules depending on it, which the
# pass over the module list may already have gone past, so repeat the
# pass until nothing changes.
changed = True
while changed:
changed = False
for module in required:
if module in skipped:
continue
for declared, dependency in self._module_dependencies(module):
if dependency not in module_indexes:
# A replaced dependency always resolves to a module of
# this command, so an unavailable one is always the
# module class the author declared.
skipped[module] = (module, declared)
changed = True
self.log.warning(
"Module %s will be SKIPPED: it depends on module "
"%s, which is not available in this command.%s",
module.__name__,
declared.__name__,
remainder,
)
break
if dependency in skipped:
root, missing = skipped[dependency]
skipped[module] = (root, missing)
changed = True
if dependency is root:
self.log.warning(
"Module %s will be SKIPPED: it depends on "
"module %s, itself skipped for depending on "
"unavailable module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
missing.__name__,
remainder,
)
else:
self.log.warning(
"Module %s will be SKIPPED: it depends on "
"skipped module %s, in a chain starting at "
"module %s, which depends on unavailable "
"module %s.%s",
module.__name__,
self._dependency_name(declared, dependency),
root.__name__,
missing.__name__,
remainder,
)
break
return skipped
def _ordered_modules(self) -> Optional[list[type[MVTModule]]]:
"""Return enabled modules in stable topological order."""
modules = self._available_modules()
module_indexes = {module: index for index, module in enumerate(modules)}
if self.module_name:
selected = [
module for module in modules if module.__name__ == self.module_name
]
else:
selected = [module for module in modules if module.enabled]
selected = self._selected_modules(modules)
if selected is None:
return None
required = set(selected)
required: set[type[MVTModule]] = set()
pending = list(selected)
while pending:
module = pending.pop()
for dependency in module.dependencies:
if dependency not in module_indexes:
self.log.warning(
"Module %s depends on unavailable module %s. "
"No modules will be run.",
module.__name__,
dependency.__name__,
)
return None
if dependency not in required:
required.add(dependency)
if module in required:
continue
required.add(module)
for _, dependency in self._module_dependencies(module):
# Unavailable dependencies are reported by _skipped_modules().
if dependency in module_indexes:
pending.append(dependency)
ordered_required = sorted(required, key=lambda module: module_indexes[module])
skipped = self._skipped_modules(ordered_required, module_indexes)
runnable = [module for module in ordered_required if module not in skipped]
if skipped and not runnable:
self.log.warning(
"Every selected module was skipped for an unavailable "
"dependency. No modules will be run."
)
return None
dependents: dict[type[MVTModule], list[type[MVTModule]]] = {
module: [] for module in required
module: [] for module in runnable
}
indegree = {module: 0 for module in required}
for module in required:
for dependency in module.dependencies:
if dependency not in required:
indegree = {module: 0 for module in runnable}
for module in runnable:
for _, dependency in self._module_dependencies(module):
if dependency not in indegree:
continue
dependents[dependency].append(module)
indegree[module] += 1
@@ -333,7 +707,7 @@ class Command:
if indegree[dependent] == 0:
heappush(ready, (module_indexes[dependent], dependent))
if len(ordered) != len(required):
if len(ordered) != len(runnable):
cyclic_modules = sorted(
(module.__name__ for module, count in indegree.items() if count > 0)
)
@@ -346,35 +720,36 @@ class Command:
return ordered
def run(self) -> None:
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
def _run_module(
self,
module: type[MVTModule],
executed_by_type: dict[type[MVTModule], MVTModule],
capture_logs: bool,
) -> tuple[MVTModule, list[logging.LogRecord], bool]:
"""Initialize and run one module, optionally buffering console logs."""
token = start_module_log_buffer() if capture_logs else None
records: list[logging.LogRecord] = []
encrypted = False
try:
self.init()
except NotImplementedError:
pass
executed_by_type: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
module_logger = logging.getLogger(module.__module__)
m = module(
target_path=self.target_path,
results_path=self.results_path,
module_options=self.module_options,
log=module_logger,
log=get_module_logger(module),
)
# Dependencies are keyed by the module class they declare, even
# when it was replaced, so that a module asking for the results
# of a replaced module receives those of its replacement.
m.dependency_modules = {
dependency: executed_by_type[dependency]
for dependency in module.dependencies
dependency: executed_by_type[resolved]
for dependency, resolved in self._module_dependencies(module)
}
m.resource_lock = self._resource_lock
m.resource_cache = self._resource_cache
if self.iocs.total_ioc_count:
# IOC collections are shared read-only during module execution.
m.indicators = self.iocs
m.indicators.log = m.log
if self.serial:
m.serial = self.serial
@@ -387,22 +762,215 @@ class Command:
try:
run_module(m)
except EncryptedBackupError:
self.log.critical(
"The backup appears to be encrypted. "
"Please decrypt it first using `mvt-ios decrypt-backup`."
)
return
encrypted = True
finally:
if token is not None:
records = finish_module_log_buffer(token)
self.executed.append(m)
executed_by_type[module] = m
self.timeline.extend(m.timeline)
self.alertstore.extend(m.alertstore.alerts)
return m, records, encrypted
@staticmethod
def _console_handler() -> Optional[MVTLogHandler]:
for handler in reversed(logging.getLogger("mvt").handlers):
if isinstance(handler, MVTLogHandler):
return handler
return None
def _publish_completed_modules(
self,
ordered_modules: list[type[MVTModule]],
completed: dict[type[MVTModule], MVTModule],
) -> None:
"""Expose a stable snapshot of completed modules to initialization hooks."""
self.executed = [
completed[module] for module in ordered_modules if module in completed
]
def _aggregate_modules(
self,
ordered_modules: list[type[MVTModule]],
completed: dict[type[MVTModule], MVTModule],
) -> None:
"""Aggregate results in stable topological order."""
for module in ordered_modules:
if module not in completed:
continue
instance = completed[module]
self.timeline.extend(instance.timeline)
self.url_results.extend(instance.url_results)
self.alertstore.extend(instance.alertstore.alerts)
def _run_sequential(
self, ordered_modules: list[type[MVTModule]]
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
completed: dict[type[MVTModule], MVTModule] = {}
for module in ordered_modules:
instance, _, encrypted = self._run_module(module, completed, False)
if encrypted:
return completed, True
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
return completed, False
def _run_parallel(
self, ordered_modules: list[type[MVTModule]], jobs: int
) -> tuple[dict[type[MVTModule], MVTModule], bool]:
"""Run a stable module DAG with bounded worker threads."""
completed: dict[type[MVTModule], MVTModule] = {}
scheduled: set[type[MVTModule]] = set()
running: dict[Future, type[MVTModule]] = {}
completion_queue: queue.Queue[Future] = queue.Queue()
handler = self._console_handler()
status = None
fatal = False
def ready_modules() -> list[type[MVTModule]]:
return [
module
for module in ordered_modules
if module not in scheduled
and all(
dependency in completed
for _, dependency in self._module_dependencies(module)
)
]
def update_status() -> None:
if status is None:
return
names = ", ".join(module.__name__ for module in running.values())
status.update(
f"Modules: {len(completed)}/{len(ordered_modules)} complete"
+ (f"; running {names}" if names else "")
)
if handler and handler.console.is_terminal:
status = handler.console.status("")
status.start()
try:
with ThreadPoolExecutor(max_workers=jobs) as executor:
while len(completed) < len(ordered_modules):
ready = ready_modules()
# An unsafe module is a scheduler barrier: it starts only
# after active workers drain and blocks later ready work.
if ready and not ready[0].parallel_safe:
if running:
future = completion_queue.get()
module = running.pop(future)
instance, records, encrypted = future.result()
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
continue
module = ready[0]
scheduled.add(module)
if status is not None:
status.update(
f"Modules: {len(completed)}/{len(ordered_modules)} "
f"complete; running {module.__name__}"
)
instance, records, encrypted = self._run_module(
module, completed, True
)
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
continue
for module in ready:
if len(running) >= jobs or not module.parallel_safe:
break
scheduled.add(module)
future = executor.submit(
self._run_module, module, dict(completed), True
)
running[future] = module
future.add_done_callback(completion_queue.put)
update_status()
if not running:
break
future = completion_queue.get()
module = running.pop(future)
instance, records, encrypted = future.result()
if handler:
handler.emit_module_records(module.__name__, records)
if encrypted:
fatal = True
break
completed[module] = instance
self._publish_completed_modules(ordered_modules, completed)
update_status()
if fatal:
for future in running:
future.cancel()
# The executor context waits for already-active work. Its
# results are intentionally discarded after the fatal error.
finally:
if status is not None:
status.stop()
return completed, fatal
def run(self) -> None:
self._create_storage()
self._setup_logging()
ordered_modules = self._ordered_modules()
if ordered_modules is None:
return
# Load shared indicators before touching the acquisition.
self.iocs
try:
self.init()
except NotImplementedError:
pass
self._log_loaded_modules(ordered_modules)
jobs = self.jobs
if settings.PROFILE and jobs > 1:
self.log.warning(
"MVT_PROFILE is enabled; forcing sequential module execution."
)
jobs = 1
if jobs == 1:
completed, fatal = self._run_sequential(ordered_modules)
else:
completed, fatal = self._run_parallel(ordered_modules, jobs)
self._aggregate_modules(ordered_modules, completed)
try:
self.finish()
except NotImplementedError:
pass
if fatal:
self.log.critical(
"The backup appears to be encrypted. "
"Please decrypt it first using `mvt-ios decrypt-backup`."
)
return
# We only store the timeline from the parent/main command
if self.sub_command:
return
@@ -410,4 +978,5 @@ class Command:
self._store_timeline()
self._store_alerts_timeline()
self._store_alerts()
self._store_urls()
self._store_info()
+151
View File
@@ -0,0 +1,151 @@
# Mobile Verification Toolkit (MVT)
# Copyright (c) 2021-2023 The MVT Authors.
# Use of this software is governed by the MVT License 1.1 that can be found at
# https://license.mvt.re/1.1/
from pathlib import Path
import shlex
import click
from click.shell_completion import get_completion_class
from .help import HELP_MSG_COMPLETION
SUPPORTED_SHELLS = ("bash", "zsh", "fish")
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
COMPLETION_INSTRUCTIONS = """Shell completion for mvt, mvt-ios and mvt-android
Print one completion script covering the three commands:
mvt completion bash > ~/.mvt-complete.bash
mvt completion zsh > ~/.mvt-complete.zsh
mkdir -p ~/.config/fish/conf.d
mvt completion fish > ~/.config/fish/conf.d/mvt-completion.fish
Load the generated Bash script from ~/.bashrc:
[ -f ~/.mvt-complete.bash ] && . ~/.mvt-complete.bash
Load the generated Zsh script from ~/.zshrc:
[ -f ~/.mvt-complete.zsh ] && . ~/.mvt-complete.zsh
Fish loads the files in ~/.config/fish/conf.d automatically.
To write these files and update the Bash/Zsh shell configuration automatically:
mvt completion bash --install
mvt completion zsh --install
mvt completion fish --install
"""
def _mvt_programs() -> list[tuple[str, click.Command]]:
"""Return the console script name and CLI group of every MVT program.
The three CLIs are imported here rather than at module level: mvt.cli
imports this module while it is being defined, and generating a completion
script should not make the start-up of `mvt` import the platform CLIs.
"""
from mvt.android.cli import cli as android_cli
from mvt.cli import cli as mvt_cli
from mvt.ios.cli import cli as ios_cli
return [("mvt", mvt_cli), ("mvt-ios", ios_cli), ("mvt-android", android_cli)]
@click.command(
"completion",
context_settings=CONTEXT_SETTINGS,
help=HELP_MSG_COMPLETION,
short_help="Generate or install shell completion",
)
@click.argument("shell", required=False, type=click.Choice(SUPPORTED_SHELLS))
@click.option(
"--install",
is_flag=True,
help="Write completion files and update shell configuration.",
)
def completion(shell, install):
if shell is None:
if install:
raise click.UsageError("A shell is required when using --install.")
click.echo(COMPLETION_INSTRUCTIONS)
return
if install:
script_path = install_completion_script(shell)
click.echo(
f"Installed {shell} completion for mvt, mvt-ios and mvt-android "
f"to {script_path}"
)
if shell in ("bash", "zsh"):
click.echo(f"Updated ~/.{shell}rc")
else:
click.echo("Fish loads the files in ~/.config/fish/conf.d automatically.")
return
click.echo(generate_mvt_completion_script(shell))
def generate_completion_script(cli: click.Command, program_name: str, shell: str) -> str:
completion_class = get_completion_class(shell)
if completion_class is None:
raise click.ClickException(f"Unsupported shell: {shell}")
complete_var = f"_{program_name.upper().replace('-', '_')}_COMPLETE"
return completion_class(cli, {}, program_name, complete_var).source()
def generate_mvt_completion_script(shell: str) -> str:
"""Return one script completing every MVT command.
Click names the completion function of each program after the program, so
the scripts of the three commands can simply be concatenated.
"""
scripts = [
generate_completion_script(cli, program_name, shell).strip("\n")
for program_name, cli in _mvt_programs()
]
return "\n\n".join(scripts)
def install_completion_script(shell: str) -> Path:
script = generate_mvt_completion_script(shell)
script_path = _completion_script_path(shell)
script_path.parent.mkdir(parents=True, exist_ok=True)
script_path.write_text(f"{script}\n", encoding="utf-8")
if shell in ("bash", "zsh"):
_install_shell_source_line(shell, script_path)
return script_path
def _completion_script_path(shell: str) -> Path:
home = Path.home()
if shell == "fish":
# conf.d is sourced when the shell starts, unlike the completions
# folder, whose files fish loads on demand by command name.
return home / ".config" / "fish" / "conf.d" / "mvt-completion.fish"
return home / f".mvt-complete.{shell}"
def _install_shell_source_line(shell: str, script_path: Path) -> None:
shell_config_path = Path.home() / f".{shell}rc"
source_line = (
f"[ -f {shlex.quote(str(script_path))} ] && "
f". {shlex.quote(str(script_path))}"
)
block = f"# MVT shell completion\n{source_line}\n"
if shell_config_path.exists():
shell_config = shell_config_path.read_text(encoding="utf-8")
if source_line in shell_config:
return
else:
shell_config = ""
separator = "" if not shell_config or shell_config.endswith("\n") else "\n"
with shell_config_path.open("a", encoding="utf-8") as handle:
handle.write(f"{separator}{block}")
+11 -6
View File
@@ -12,7 +12,9 @@ from pydantic_settings import (
YamlConfigSettingsSource,
)
MVT_CONFIG_FOLDER = user_config_dir("mvt")
# MVT_CONFIG_FOLDER in the environment relocates the settings file, so that
# a test run or a scripted install never touches the user's own.
MVT_CONFIG_FOLDER = os.environ.get("MVT_CONFIG_FOLDER") or user_config_dir("mvt")
MVT_CONFIG_PATH = os.path.join(MVT_CONFIG_FOLDER, "config.yaml")
@@ -59,13 +61,16 @@ class MVTSettings(BaseSettings):
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> Tuple[PydanticBaseSettingsSource, ...]:
yaml_source = YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH)
sources: Tuple[PydanticBaseSettingsSource, ...] = (
yaml_source,
YamlConfigSettingsSource(settings_cls, MVT_CONFIG_PATH),
init_settings,
)
# Always load env variables by default
sources = (env_settings,) + sources
# Load env variables only when asked to. initialise() constructs the
# settings once without them so that what gets written back to
# config.yaml never includes values taken from the environment.
# init_settings() returns the keyword arguments passed to the constructor.
if init_settings().get("load_env", True):
sources = (env_settings,) + sources
return sources
def save_settings(
@@ -92,7 +97,7 @@ class MVTSettings(BaseSettings):
Afterwards we load the settings again, this time including the env variables.
"""
# Set invalid env prefix to avoid loading env variables.
# Construct the settings without env variables so they are not persisted.
settings = cls(load_env=False)
settings.save_settings()
+18 -1
View File
@@ -8,7 +8,7 @@ HELP_MSG_VERSION = "Show the currently installed version of MVT"
HELP_MSG_OUTPUT = "Specify a path to a folder where you want to store JSON results"
HELP_MSG_IOC = "Path to indicators file (can be invoked multiple time)"
HELP_MSG_FAST = "Avoid running time/resource consuming features"
HELP_MSG_LIST_MODULES = "Print list of available modules and exit"
HELP_MSG_LIST_MODULES = "Print list of available modules and their source, then exit"
HELP_MSG_MODULE = "Name of a single module you would like to run instead of all"
HELP_MSG_LOAD_MODULE = (
"Load custom MVT module(s) from a Python file or folder "
@@ -17,16 +17,30 @@ HELP_MSG_LOAD_MODULE = (
HELP_MSG_NONINTERACTIVE = "Don't ask interactive questions during processing"
HELP_MSG_HASHES = "Generate hashes of all the files analyzed"
HELP_MSG_VERBOSE = "Verbose mode"
HELP_MSG_JOBS = "Number of extraction modules to run concurrently"
HELP_MSG_VERBOSE_COMMAND = (
"Verbose mode (kept for compatibility, pass --verbose before the command "
"name instead)"
)
HELP_MSG_CHECK_IOCS = "Compare stored JSON results to provided indicators"
HELP_MSG_STIX2 = "Download public STIX2 indicators"
HELP_MSG_DISABLE_UPDATE_CHECK = "Disable MVT version update check"
HELP_MSG_DISABLE_INDICATOR_UPDATE_CHECK = "Disable indicators update check"
HELP_MSG_COMPLETION = (
"Generate or install shell completion for mvt, mvt-ios and mvt-android"
)
HELP_MSG_PLUGINS = "Inspect the installed MVT plugin packages"
HELP_MSG_PLUGINS_LIST = "List the installed plugins and what they contribute to MVT"
HELP_MSG_PLUGINS_CHECK_UPDATES = (
"Check the installed plugins for updates without installing them"
)
# IOS Specific
HELP_MSG_DECRYPT_BACKUP = "Decrypt an encrypted iTunes backup"
HELP_MSG_BACKUP_DESTINATION = (
"Path to the folder where the decrypted backup should be stored"
)
HELP_MSG_DECRYPT_JOBS = "Number of files to decrypt concurrently"
HELP_MSG_IOS_BACKUP_PASSWORD = (
"Password to use to decrypt the backup (or, set the {MVT_IOS_BACKUP_PASSWORD} "
"environment variable)"
@@ -37,6 +51,7 @@ HELP_MSG_BACKUP_KEYFILE = (
HELP_MSG_EXTRACT_KEY = "Extract decryption key from an iTunes backup"
HELP_MSG_CHECK_IOS_BACKUP = "Extract artifacts from an iTunes backup"
HELP_MSG_CHECK_FS = "Extract artifacts from a full filesystem dump"
HELP_MSG_CHECK_SYSDIAGNOSE = "Extract artifacts from an iOS sysdiagnose archive"
# Android Specific
HELP_MSG_ANDROID_BACKUP_PASSWORD = "The backup password to use for an Android backup"
@@ -52,3 +67,5 @@ HELP_MSG_CHECK_BUGREPORT = "Check an Android Bug Report"
HELP_MSG_CHECK_ANDROID_BACKUP = "Check an Android Backup"
HELP_MSG_CHECK_ANDROIDQF = "Check data collected with AndroidQF"
HELP_MSG_CHECK_INTRUSION_LOGS = "Check Android Intrusion Logging files"
HELP_MSG_VIRUS_TOTAL = "Check package APK hashes on VirusTotal"
HELP_MSG_DELAY_CHECKS = "Delay in seconds between VirusTotal requests"
+56 -32
View File
@@ -7,9 +7,10 @@ import glob
import json
import logging
import os
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Iterator, List, Optional
from typing import Any, Dict, Iterator, List, Optional, Sequence
import ahocorasick
from appdirs import user_data_dir
@@ -17,11 +18,15 @@ from appdirs import user_data_dir
from .config import settings
from .url import URL
MVT_DATA_FOLDER = user_data_dir("mvt")
# MVT_DATA_FOLDER in the environment relocates the downloaded indicators and
# the update-check state kept next to them.
MVT_DATA_FOLDER = os.environ.get("MVT_DATA_FOLDER") or user_data_dir("mvt")
MVT_INDICATORS_FOLDER = os.path.join(MVT_DATA_FOLDER, "indicators")
logger = logging.getLogger(__name__)
URL_CHECK_MAX_WORKERS = 20
@dataclass
class Indicator:
@@ -46,6 +51,7 @@ class Indicators:
self.log = log
self.ioc_collections: List[Dict[str, Any]] = []
self.total_ioc_count = 0
self.resolved_urls: Dict[str, str] = {}
def _load_downloaded_indicators(self) -> None:
if not os.path.isdir(MVT_INDICATORS_FOLDER):
@@ -67,7 +73,9 @@ class Indicators:
if os.path.isfile(path) and path.lower().endswith(".stix2"):
self.parse_stix2(path)
elif os.path.isdir(path):
for file in glob.glob(os.path.join(path, "**", "*.stix2"), recursive=True):
for file in glob.glob(
os.path.join(path, "**", "*.stix2"), recursive=True
):
self.parse_stix2(file)
else:
self.log.error(
@@ -436,9 +444,14 @@ class Indicators:
orig_url.url,
dest_url.url,
)
return self.check_url(dest_url.url)
match = self.check_url(dest_url.url)
self.resolved_urls[url] = self.resolved_urls.get(
dest_url.url, dest_url.url
)
return match
final_url = dest_url
self.resolved_urls[url] = final_url.url
else:
# If it's not shortened, we just use the original URL object.
final_url = orig_url
@@ -479,6 +492,13 @@ class Indicators:
return None
def get_expanded_url(self, url: str) -> Optional[str]:
"""Return the final URL recorded while checking a shortened URL."""
expanded_url = self.resolved_urls.get(url)
if expanded_url and expanded_url != url:
return expanded_url
return None
def check_urls(self, urls: list) -> Optional[IndicatorMatch]:
"""Check a list of URLs against the provided list of domain indicators.
@@ -490,12 +510,39 @@ class Indicators:
if not urls:
return None
for url in urls:
check = self.check_url(url)
if check:
return check
return self.check_url_batches([urls])[0]
return None
def check_url_batches(
self, url_batches: Sequence[Optional[Sequence[str]]]
) -> List[Optional[IndicatorMatch]]:
"""Check batches of URLs concurrently while preserving batch order.
URLs are deduplicated across batches before checking. Each returned item
is the first indicator match from the corresponding input batch, using
the original URL order.
"""
batches = [list(urls) if urls else [] for urls in url_batches]
unique_urls = list(dict.fromkeys(url for urls in batches for url in urls))
if not unique_urls:
return [None] * len(batches)
if settings.NETWORK_ACCESS_ALLOWED and len(unique_urls) > 1:
worker_count = min(URL_CHECK_MAX_WORKERS, len(unique_urls))
with ThreadPoolExecutor(max_workers=worker_count) as executor:
url_matches = dict(
zip(unique_urls, executor.map(self.check_url, unique_urls))
)
else:
url_matches = {url: self.check_url(url) for url in unique_urls}
return [
next(
(url_matches[url] for url in urls if url_matches[url] is not None),
None,
)
for urls in batches
]
def check_process(self, process: str) -> Optional[IndicatorMatch]:
"""Check the provided process name against the list of process
@@ -727,29 +774,6 @@ class Indicators:
return None
def check_receiver_prefix(
self, receiver_name: str
) -> Optional[IndicatorMatch]:
"""Check the provided receiver name against the list of indicators.
An IoC match is detected when a substring of the receiver matches the indicator.
:param receiver_name: Receiver name to check against app ID indicators
:type receiver_name: str
:returns: IndicatorMatch if matched, otherwise None
"""
if not receiver_name:
return None
for ioc in self.get_iocs("app_ids"):
if ioc.value.lower() in receiver_name.lower():
return IndicatorMatch(
ioc=ioc,
message=f'Found a known suspicious receiver with name "{receiver_name}" matching indicators from "{ioc.name}"',
)
return None
def check_android_property_name(
self, property_name: str
) -> Optional[IndicatorMatch]:
+41 -1
View File
@@ -4,6 +4,8 @@
# https://license.mvt.re/1.1/
import logging
import threading
from contextvars import ContextVar, Token
from rich.console import Console
from rich.logging import RichHandler
from typing import Optional
@@ -27,16 +29,33 @@ logging.addLevelName(HIGH_ALERT, "HIGH")
logging.addLevelName(CRITICAL_ALERT, "CRITICAL")
_module_log_buffer: ContextVar[Optional[list[logging.LogRecord]]] = ContextVar(
"mvt_module_log_buffer", default=None
)
def start_module_log_buffer() -> Token:
"""Capture console records in the current module execution context."""
return _module_log_buffer.set([])
def finish_module_log_buffer(token: Token) -> list[logging.LogRecord]:
records = _module_log_buffer.get() or []
_module_log_buffer.reset(token)
return records
class MVTLogHandler(RichHandler):
def __init__(self, console: Optional[Console] = None, level: int = logging.DEBUG):
super().__init__(console=console, level=level)
self._console_lock = threading.RLock()
def __add_prefix_space(self, level: str) -> str:
max_length = len("CRITICAL ALERT")
space = max_length - len(level)
return f"{level}{' ' * space}"
def emit(self, record: logging.LogRecord):
def _emit_record(self, record: logging.LogRecord) -> None:
try:
msg = rf"[grey50]\[{record.name}][/] {self.format(record)}"
@@ -63,3 +82,24 @@ class MVTLogHandler(RichHandler):
except Exception:
self.handleError(record)
def emit(self, record: logging.LogRecord):
buffer = _module_log_buffer.get()
if buffer is not None:
# More than one CLI can be imported in-process (notably in tests),
# which can install multiple console handlers for the same record.
if not buffer or buffer[-1] is not record:
buffer.append(record)
return
with self._console_lock:
self._emit_record(record)
def emit_module_records(
self, module_name: str, records: list[logging.LogRecord]
) -> None:
"""Print one module's captured records without interleaving."""
with self._console_lock:
self.console.print(f"[bold cyan]--- {module_name} ---[/bold cyan]")
for record in records:
self._emit_record(record)
+125 -62
View File
@@ -8,10 +8,125 @@ import logging
import requests
from rich import print as rich_print
from .updates import IndicatorsUpdates, MVTUpdates
from .config import settings
from .updates import (
IndicatorsUpdates,
MVTUpdates,
PluginUpdates,
installed_plugin_distributions,
)
from .version import MVT_VERSION
def _check_version_updates(log: logging.Logger) -> None:
try:
mvt_updates = MVTUpdates()
latest_version = mvt_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for MVT updates.[/bold] "
"You may be working offline. Please update MVT regularly."
)
except Exception as e:
log.error("Error encountered when trying to check latest MVT version: %s", e)
else:
if latest_version:
rich_print(
f"\t[bold]Version {latest_version} is available! "
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
)
def _check_indicator_updates(log: logging.Logger) -> None:
ioc_updates = IndicatorsUpdates()
# Before proceeding, we check if we have downloaded an indicators index.
# If not, there's no point in proceeding with the updates check.
if ioc_updates.get_latest_update() == 0:
rich_print(
"\t[bold]You have not yet downloaded any indicators, check "
"the `download-iocs` command![/bold]"
)
return
# We only perform this check at a fixed frequency, in order to not
# overburden the user with too many lookups if the command is being run
# multiple times.
should_check, hours = ioc_updates.should_check()
if not should_check:
rich_print(
f"\tIndicators updates checked recently, next automatic check "
f"in {int(hours)} hours"
)
return
try:
ioc_to_update = ioc_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for indicator updates.[/bold] "
"You may be working offline. Please update MVT indicators regularly."
)
except Exception as e:
log.error("Error encountered when trying to check latest MVT indicators: %s", e)
else:
if ioc_to_update:
rich_print(
"\t[bold]There are updates to your indicators files! "
"Run the `download-iocs` command to update![/bold]"
)
else:
rich_print("\tYour indicators files seem to be up to date.")
def _print_plugin_updates(findings: list) -> None:
if not findings:
return
rich_print("\t[bold]Plugin updates available:[/bold]")
for finding in findings:
rich_print(
f"\t {finding['name']} {finding['installed']} → "
f"{finding['latest']} ({finding['upgrade_command']})"
)
def _check_plugin_updates(log: logging.Logger) -> None:
if not settings.NETWORK_ACCESS_ALLOWED:
return
# This runs on every command, so nothing here, including reading back what
# the latest check stored, may ever interrupt MVT.
try:
distributions = installed_plugin_distributions()
# There is nothing to check when MVT was not extended with any plugin.
if not distributions:
return
plugin_updates = PluginUpdates()
# We only perform this check at a fixed frequency, in order to not
# overburden the user (and the plugin repositories) with too many
# lookups. In between checks we print the findings of the latest one,
# leaving out those which no longer apply to what is installed.
should_check, _ = plugin_updates.should_check()
if not should_check:
_print_plugin_updates(plugin_updates.current_findings(distributions))
return
findings = plugin_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t[bold]Note: Could not check for plugin updates.[/bold] "
"You may be working offline. Please update your MVT plugins regularly."
)
except Exception as e:
log.error("Error encountered when trying to check MVT plugin updates: %s", e)
else:
_print_plugin_updates(findings)
def check_updates(
disable_version_check: bool = False, disable_indicator_check: bool = False
) -> None:
@@ -19,77 +134,25 @@ def check_updates(
# First we check for MVT version updates.
if not disable_version_check:
try:
mvt_updates = MVTUpdates()
latest_version = mvt_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t\t[bold]Note: Could not check for MVT updates.[/bold] "
"You may be working offline. Please update MVT regularly."
)
except Exception as e:
log.error(
"Error encountered when trying to check latest MVT version: %s", e
)
else:
if latest_version:
rich_print(
f"\t\t[bold]Version {latest_version} is available! "
"Upgrade mvt with `pip3 install -U mvt` or with `pipx upgrade mvt`[/bold]"
)
_check_version_updates(log)
# Then we check for indicators files updates.
if not disable_indicator_check:
ioc_updates = IndicatorsUpdates()
_check_indicator_updates(log)
# Before proceeding, we check if we have downloaded an indicators index.
# If not, there's no point in proceeding with the updates check.
if ioc_updates.get_latest_update() == 0:
rich_print(
"\t\t[bold]You have not yet downloaded any indicators, check "
"the `download-iocs` command![/bold]"
)
return
# We only perform this check at a fixed frequency, in order to not
# overburden the user with too many lookups if the command is being run
# multiple times.
should_check, hours = ioc_updates.should_check()
if not should_check:
rich_print(
f"\t\tIndicators updates checked recently, next automatic check "
f"in {int(hours)} hours"
)
return
try:
ioc_to_update = ioc_updates.check()
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
rich_print(
"\t\t[bold]Note: Could not check for indicator updates.[/bold] "
"You may be working offline. Please update MVT indicators regularly."
)
except Exception as e:
log.error(
"Error encountered when trying to check latest MVT indicators: %s", e
)
else:
if ioc_to_update:
rich_print(
"\t\t[bold]There are updates to your indicators files! "
"Run the `download-iocs` command to update![/bold]"
)
else:
rich_print("\t\tYour indicators files seem to be up to date.")
# Finally we check for updates to the installed plugin packages. MVT never
# installs an update itself, it only reports the command which does.
if not disable_version_check:
_check_plugin_updates(log)
def logo(
disable_version_check: bool = False, disable_indicator_check: bool = False
) -> None:
rich_print("\n")
rich_print("\t[bold]MVT[/bold] - Mobile Verification Toolkit")
rich_print("\t\thttps://mvt.re")
rich_print(f"\t\tVersion: {MVT_VERSION}")
rich_print("\t[bold]MVT - Mobile Verification Toolkit[/bold]\n")
rich_print("\thttps://mvt.re")
rich_print(f"\tVersion: {MVT_VERSION}\n")
check_updates(disable_version_check, disable_indicator_check)
+52 -8
View File
@@ -8,6 +8,7 @@ import json
import logging
import os
import re
import threading
from dataclasses import asdict, is_dataclass
from typing import Any, Dict, Optional, Sequence
@@ -18,6 +19,7 @@ from .module_types import (
ModuleResults,
ModuleSerializedResult,
ModuleTimeline,
URLResult,
)
from .utils import CustomJSONEncoder, exec_or_profile
@@ -45,6 +47,12 @@ class MVTModule:
slug: Optional[str] = None
dependencies: Sequence[type["MVTModule"]] = ()
supported_commands: Sequence[tuple[str, str]] = ()
parallel_safe: bool = True
# A custom module can name a module class it supersedes, usually a
# built-in one. When both are available to a command, the named module is
# dropped from the run and this module takes its place, including in the
# dependencies of any other module.
replaces: Optional[type["MVTModule"]] = None
def __init__(
self,
@@ -82,7 +90,14 @@ class MVTModule:
self.results: ModuleResults = results if results is not None else []
self.timeline: ModuleTimeline = []
self.url_results: list[URLResult] = []
self.dependency_modules: Dict[type["MVTModule"], "MVTModule"] = {}
# Commands replace this with a command-scoped lock for modules sharing
# archive handles or other non-thread-safe read resources.
self.resource_lock = threading.RLock()
# Commands also replace this with a command-scoped cache. Cached values
# must be immutable, because parallel modules can read them concurrently.
self.resource_cache: Dict[object, Any] = {}
def get_dependency_results(
self, module_class: type["MVTModule"]
@@ -110,6 +125,23 @@ class MVTModule:
def check_indicators(self) -> None:
raise NotImplementedError
def collect_url_results(self) -> None:
"""Collect URL records exposed by this module."""
def add_url_result(self, url: str, timestamp: Optional[str], source: str) -> None:
expanded_url = None
if self.indicators:
expanded_url = self.indicators.get_expanded_url(url)
self.url_results.append(
{
"url": url,
"expanded_url": expanded_url,
"timestamp": timestamp,
"source": source,
}
)
def save_to_json(self) -> None:
if not self.results_path:
return
@@ -161,17 +193,20 @@ class MVTModule:
"""
timeline_set = set()
deduplicated = []
for record in timeline:
timeline_set.add(
json.dumps(
asdict(record)
if is_dataclass(record) and not isinstance(record, type)
else record,
sort_keys=True,
)
serialized = json.dumps(
asdict(record)
if is_dataclass(record) and not isinstance(record, type)
else record,
sort_keys=True,
)
if serialized in timeline_set:
continue
timeline_set.add(serialized)
deduplicated.append(json.loads(serialized))
return [json.loads(record) for record in timeline_set]
return deduplicated
def to_timeline(self) -> None:
"""Convert results into a timeline."""
@@ -249,6 +284,15 @@ def run_module(module: MVTModule) -> None:
"The %s module produced no detections!", module.__class__.__name__
)
try:
module.collect_url_results()
except Exception as exc:
module.log.exception(
"Error when collecting URLs from module %s: %s",
module.__class__.__name__,
exc,
)
try:
module.to_timeline()
except NotImplementedError:
+293 -6
View File
@@ -4,32 +4,141 @@
# https://license.mvt.re/1.1/
import hashlib
import importlib.metadata
import importlib.util
import inspect
import json
import logging
import os
import re
import sys
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from types import ModuleType
from typing import Iterable, Optional
from .cli_plugins import CUSTOM_COMMAND_MODULE_PREFIX
from .module import MVTModule
from .version import MVT_VERSION
MVT_CUSTOM_MODULES_ENV = "MVT_CUSTOM_MODULES"
MODULES_ENTRY_POINT_GROUP = "mvt.modules"
EXTERNAL_LOGGER_NAMESPACE = "mvt.ext"
PLUGIN_PACKAGE_PREFIX = "mvt_plugin_"
_ORIGIN_ATTRIBUTE = "_mvt_module_origin"
_PATH_MODULE_PREFIX = "_mvt_custom_module_"
_LOADED_FILE_DIGEST = re.compile(r"_[0-9a-f]{16}$")
log = logging.getLogger(__name__)
class CustomModuleLoadError(Exception):
pass
@dataclass(frozen=True)
class ModuleOrigin:
"""Describes where a loaded module came from, for auditability.
``kind`` is one of ``builtin`` (shipped with MVT), ``package`` (loaded
from an installed package) or ``path`` (loaded from a file passed with
``--load-module`` or the environment variable).
"""
kind: str
name: str
version: Optional[str] = None
commit: Optional[str] = None
file_sha256: Optional[str] = None
@property
def label(self) -> str:
label = self.name
if self.version:
label += f"@{self.version}"
label = f"'{label}'"
if self.commit:
label += f" (commit {self.commit})"
if self.file_sha256:
label += f" (sha256: {self.file_sha256})"
return label
def _module_name_for_path(path: Path) -> str:
digest = hashlib.sha256(str(path).encode("utf-8")).hexdigest()[:16]
return f"_mvt_custom_module_{path.stem}_{digest}"
return f"{_PATH_MODULE_PREFIX}{path.stem}_{digest}"
def _is_builtin_logger_name(name: str) -> bool:
return name == "mvt" or name.startswith("mvt.")
def _loaded_file_stem(name: str, prefix: str) -> str:
"""Recover a loaded file's name from the import name MVT gave it."""
return _LOADED_FILE_DIGEST.sub("", name[len(prefix) :])
def _external_logger_name(name: str) -> str:
"""Return the "mvt.ext" logger name external code logs under."""
top_level, separator, rest = name.partition(".")
if top_level.startswith(PLUGIN_PACKAGE_PREFIX) and len(top_level) > len(
PLUGIN_PACKAGE_PREFIX
):
name = top_level[len(PLUGIN_PACKAGE_PREFIX) :] + separator + rest
return f"{EXTERNAL_LOGGER_NAMESPACE}.{name}"
def get_module_logger(module_class: type[MVTModule]) -> logging.Logger:
"""Return the logger a module's records should be emitted through.
Modules loaded from installed packages or file paths live outside the
"mvt" logger hierarchy, so their records would never reach the handlers
attached to the "mvt" logger and instead fall through to
logging.lastResort (which prints bare messages and drops anything below
WARNING). Their loggers are parented under the "mvt.ext" namespace,
keeping external module names from colliding with MVT's own logger
tree. File-path modules are named after their file instead of the
mangled internal import name, and packages following the recommended
"mvt_plugin_<name>" naming convention log under "mvt.ext.<name>".
"""
name = module_class.__module__
if _is_builtin_logger_name(name):
return logging.getLogger(name)
if name.startswith(_PATH_MODULE_PREFIX):
file_name = Path(get_module_origin(module_class).name).stem
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{file_name}")
return logging.getLogger(_external_logger_name(name))
def get_plugin_logger(name: str) -> logging.Logger:
"""Return a general logger for use in custom MVT plugins.
Call it with ``__name__``. The logger sits under "mvt.ext". That is
where get_module_logger() puts module classes. A file loaded with
--load-module or --load-command is named after the file.
"""
if _is_builtin_logger_name(name):
return logging.getLogger(name)
# A file loaded with --load-module or --load-command is imported under a
# mangled name. Log it under the file it came from. get_module_logger()
# does the same for the module classes such a file defines.
for prefix in (_PATH_MODULE_PREFIX, CUSTOM_COMMAND_MODULE_PREFIX):
if name.startswith(prefix):
stem = _loaded_file_stem(name, prefix)
return logging.getLogger(f"{EXTERNAL_LOGGER_NAMESPACE}.{stem}")
return logging.getLogger(_external_logger_name(name))
def _iter_module_files(path: Path) -> Iterable[Path]:
if path.is_file():
if path.suffix != ".py":
raise CustomModuleLoadError(f"Custom module file is not a Python file: {path}")
raise CustomModuleLoadError(
f"Custom module file is not a Python file: {path}"
)
yield path
return
@@ -57,7 +166,9 @@ def _load_python_file(path: Path) -> ModuleType:
try:
spec.loader.exec_module(module)
except Exception as exc:
raise CustomModuleLoadError(f"Unable to import custom module {path}: {exc}") from exc
raise CustomModuleLoadError(
f"Unable to import custom module {path}: {exc}"
) from exc
return module
@@ -82,17 +193,171 @@ def load_custom_modules_from_path(path: str) -> list[type[MVTModule]]:
resolved_path = Path(path).expanduser().resolve()
for module_file in _iter_module_files(resolved_path):
file_sha256 = hashlib.sha256(module_file.read_bytes()).hexdigest()
loaded_module = _load_python_file(module_file)
origin = ModuleOrigin(
kind="path", name=str(module_file), file_sha256=file_sha256
)
for module_class in discover_mvt_modules(loaded_module):
key = (str(module_file), module_class.__qualname__)
if key in seen:
continue
seen.add(key)
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
custom_modules.append(module_class)
return custom_modules
def _module_key(module_class: type[MVTModule]) -> tuple[str, str]:
try:
source = str(Path(inspect.getfile(module_class)).resolve())
except (OSError, TypeError):
source = module_class.__module__
return (source, module_class.__qualname__)
def distribution_direct_url(dist: importlib.metadata.Distribution) -> Optional[dict]:
"""Return the PEP 610 direct URL metadata of a distribution, if recorded.
Packages installed from an index have no direct URL metadata, while
packages installed directly from a repository or from a local folder
record where they were installed from in ``direct_url.json``.
"""
try:
direct_url_text = dist.read_text("direct_url.json")
if not direct_url_text:
return None
direct_url = json.loads(direct_url_text)
return direct_url if isinstance(direct_url, dict) else None
except Exception:
return None
def _distribution_commit(dist: importlib.metadata.Distribution) -> Optional[str]:
"""Return the VCS commit a distribution was installed from, if recorded.
Packages installed directly from a repository (``pip install git+...``)
record the commit in ``direct_url.json`` (PEP 610).
"""
vcs_info = (distribution_direct_url(dist) or {}).get("vcs_info")
if not isinstance(vcs_info, dict):
return None
commit = vcs_info.get("commit_id")
return commit if isinstance(commit, str) else None
def _entry_point_origin(entry_point: importlib.metadata.EntryPoint) -> ModuleOrigin:
name = entry_point.name
version = None
commit = None
# Manually constructed entry points have no associated distribution.
dist = getattr(entry_point, "dist", None)
if dist is not None:
try:
name = dist.name or name
version = dist.version
except Exception:
pass
commit = _distribution_commit(dist)
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
@lru_cache(maxsize=1)
def _packages_distributions() -> dict[str, list[str]]:
try:
return dict(importlib.metadata.packages_distributions())
except Exception:
return {}
def get_module_origin(module_class: type[MVTModule]) -> ModuleOrigin:
"""Return the origin of a module class for auditing purposes."""
origin = module_class.__dict__.get(_ORIGIN_ATTRIBUTE)
if isinstance(origin, ModuleOrigin):
return origin
top_level = module_class.__module__.partition(".")[0]
if top_level == "mvt":
return ModuleOrigin(kind="builtin", name="mvt", version=MVT_VERSION)
distributions = _packages_distributions().get(top_level)
if distributions:
name = distributions[0]
version = None
commit = None
try:
dist = importlib.metadata.distribution(name)
version = dist.version
commit = _distribution_commit(dist)
except Exception:
pass
return ModuleOrigin(kind="package", name=name, version=version, commit=commit)
try:
source = str(Path(inspect.getfile(module_class)).resolve())
except (OSError, TypeError):
source = module_class.__module__
return ModuleOrigin(kind="path", name=source)
def load_installed_modules() -> list[type[MVTModule]]:
"""Load MVT modules registered by installed packages.
Packages register modules in the ``mvt.modules`` entry-point group. Each
entry point must resolve to an iterable of MVTModule subclasses, or to a
callable which returns one. A broken entry point is skipped with a
warning so that a faulty plugin package cannot break MVT.
"""
try:
entry_points = importlib.metadata.entry_points(group=MODULES_ENTRY_POINT_GROUP)
except Exception as exc:
log.warning(
"Unable to discover installed module packages in entry-point group %s: %s",
MODULES_ENTRY_POINT_GROUP,
exc,
)
return []
installed_modules: list[type[MVTModule]] = []
ordered_entry_points = sorted(
entry_points, key=lambda entry_point: (entry_point.name, entry_point.value)
)
for entry_point in ordered_entry_points:
try:
loaded = entry_point.load()
if callable(loaded) and not isinstance(loaded, type):
loaded = loaded()
module_classes = list(loaded)
except (Exception, SystemExit) as exc:
log.warning(
"Unable to load modules from entry point %s (%s): %s",
entry_point.name,
entry_point.value,
exc,
)
continue
origin = _entry_point_origin(entry_point)
for module_class in module_classes:
if not (
isinstance(module_class, type) and issubclass(module_class, MVTModule)
):
log.warning(
"Entry point %s (%s) provided %r which is not an "
"MVTModule subclass",
entry_point.name,
entry_point.value,
module_class,
)
continue
setattr(module_class, _ORIGIN_ATTRIBUTE, origin)
installed_modules.append(module_class)
return installed_modules
def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTModule]]:
search_paths: list[str] = []
env_path = os.environ.get(MVT_CUSTOM_MODULES_ENV)
@@ -103,10 +368,17 @@ def load_custom_modules(paths: Optional[Iterable[str]] = None) -> list[type[MVTM
custom_modules: list[type[MVTModule]] = []
seen: set[tuple[str, str]] = set()
for module_class in load_installed_modules():
key = _module_key(module_class)
if key in seen:
continue
seen.add(key)
custom_modules.append(module_class)
for path in search_paths:
for module_class in load_custom_modules_from_path(path):
source = Path(inspect.getfile(module_class)).resolve()
key = (str(source), module_class.__qualname__)
key = _module_key(module_class)
if key in seen:
continue
seen.add(key)
@@ -122,6 +394,21 @@ def module_supports_command(
) -> bool:
supported_commands = getattr(module_class, "supported_commands", None)
if not supported_commands:
log.warning(
"Custom module %s has no supported_commands and will not be run. "
"Declare the platform/command pairs it supports.",
module_class.__name__,
)
return False
pairs = {tuple(entry) for entry in supported_commands}
if (platform, command) in pairs:
return True
return (platform, command) in {tuple(entry) for entry in supported_commands}
# A module which implements check_indicators() is re-checked by check-iocs
# for its platform. It does not need to declare the check-iocs pair.
return (
command == "check-iocs"
and platform in {entry[0] for entry in pairs if entry}
and module_class.check_indicators is not MVTModule.check_indicators
)
+8 -1
View File
@@ -4,7 +4,7 @@
# https://license.mvt.re/1.1/
from dataclasses import dataclass
from typing import Any, Dict, List, Union
from typing import Any, Dict, List, Optional, TypedDict, Union
# ModuleAtomicResult is a flexible dictionary that can contain any data.
@@ -22,6 +22,13 @@ ModuleAtomicResult = Dict[str, Any]
ModuleResults = Any
class URLResult(TypedDict):
url: str
expanded_url: Optional[str]
timestamp: Optional[str]
source: str
@dataclass
class ModuleAtomicTimeline:
timestamp: str
Loaded 100 of 216 files, more files were not shown because too many files have changed in this diff. Show more