scripts/ilapfuncs.py holds the helpers an artifact module calls to find files, read
SQLite and other stores, check in media, convert timestamps and log. ALEAPP, iLEAPP, RLEAPP,
VLEAPP and DLEAPP each carry their own copy. Most helpers are the same in all five. Where a
core differs, the table at the end says how.
This page is the same file in all five repositories. If you change it, change it in all five.
For the __artifacts_v2__ block itself, see the Contributing section of the README. For
test cases, see testing/create_module_test_cases.md in this folder.
Modules in the five cores use the same pattern: an @artifact_processor function
that takes one argument, context, and returns headers, rows and the source path. The
processor writes the HTML, TSV, timeline, KML and LAVA output from what you return.
__artifacts_v2__ = {
"get_example_photos": { # must match the function name
"name": "Example App Photos",
"description": "Photos sent in Example App chats",
"author": "@YourHandle",
"creation_date": "2026-10-14",
"last_update_date": "2026-10-14",
"requirements": "none",
"category": "Example App",
"notes": "",
"paths": (
'*/com.example.app/databases/chat.db*',
'*/com.example.app/files/photos/*',
),
"output_types": "standard",
"artifact_icon": "photo",
}
}
from scripts.ilapfuncs import (
artifact_processor, check_in_media, convert_unix_ts_to_utc,
get_sqlite_db_records, logfunc)
@artifact_processor
def get_example_photos(context):
db_path = ''
for file_found in context.get_files_found():
if file_found.endswith('chat.db'):
db_path = file_found
break
if not db_path:
return (), [], ''
query = 'SELECT sent_at, sender, photo_name FROM messages WHERE photo_name IS NOT NULL'
data_list = []
for row in get_sqlite_db_records(db_path, query):
sent_at, sender, photo_name = row[0], row[1], row[2]
photo = check_in_media(f'files/photos/{photo_name}', name=photo_name)
if photo is None:
logfunc(f'Photo {photo_name} is referenced but was not found')
data_list.append((convert_unix_ts_to_utc(sent_at), sender, photo_name, photo))
data_headers = (('Sent', 'datetime'), 'Sender', 'File Name', ('Photo', 'media'))
return data_headers, data_list, db_pathThe decorator reads the artifact block whose key matches the function name, sets up
context, calls your function and writes the output. If data_list is empty it logs
No data found for <artifact name> and writes no table.
Column types go in the header as a tuple: ('Sent', 'datetime'), ('Day', 'date'),
('Number', 'phonenumber') and ('Photo', 'media'). Plain strings are text columns.
context is the Context class in scripts/context.py. The decorator fills it in for
your artifact before calling your function. These are the methods a module uses:
get_source_file_path looks only in this artifact's files found, never in the whole
extraction. It matches on the file name. When only one found file has that name, that file
is returned even if the folders you gave do not match. When several do, it returns the one
whose path ends in partial_path, or None if none does.
get_file_path(files_found, filename, skip=False) returns the first path in
files_found that matches filename with pathlib.Path.match, or None. Pass a string as
skip to pass over any path that contains it.
db_path = get_file_path(context.get_files_found(), 'chat.db')Path.match matches from the right, so 'chat.db' and 'databases/chat.db' both match
.../databases/chat.db.
Open evidence databases read-only. These helpers do that for you.
| Helper | What it does |
|---|---|
get_sqlite_db_records(path, query, attach_query=None) |
Runs query and returns its rows. Returns [] and logs the error if the query fails. |
open_sqlite_db_readonly(path) |
Returns a read-only sqlite3 connection, or None. Close it when you are done. |
attach_sqlite_db_readonly(path, db_name) |
Returns an ATTACH DATABASE ... AS db_name statement that attaches path read-only. Pass it as attach_query. |
does_table_exist_in_db(path, table_name) |
True if the table exists. |
does_view_exist_in_db(path, table_name) |
True if a view named table_name exists. |
does_column_exist_in_db(path, table_name, col_name) |
True if the column exists. The column name is compared case-insensitively. |
null_absent_columns(path, query) |
Returns query with every column the database lacks replaced by NULL. Use it when an app added columns between versions. |
The rows get_sqlite_db_records returns are not the same in every core:
- iLEAPP, RLEAPP, VLEAPP: a one-pass iterator of
sqlite3.Row. Read a value by position (row[0]) or by column name (row['sender']). It has nolen(), and a second loop over it gets nothing. - DLEAPP: a list of
sqlite3.Row. - ALEAPP: a one-pass iterator of plain tuples. Read values by position only. It logs
and returns
[]for anOperationalErrororProgrammingError. Any othersqlite3.DatabaseError, such as a file that is not a database, reaches your module.
Code that has to run the same in every core reads values by position and wraps the result
in list() when it needs a count or a second pass:
rows = list(get_sqlite_db_records(db_path, query))
if not rows:
return (), [], db_pathFor an app whose schema changed between versions:
query = null_absent_columns(db_path, 'SELECT sent_at, sender, edited_at FROM messages')A column missing from this copy of the database comes back as NULL, and the helper logs
which columns it replaced.
get_txt_file_content(file_path) reads the file as UTF-8 and returns a list of its lines,
each ending in a newline except possibly the last. It returns [] and logs the error if
the file cannot be read, including when it is not valid UTF-8.
get_plist_file_content(file_path) and get_plist_content(data) are iLEAPP helpers. They
read a plist file or plist bytes and decode an NSKeyedArchiver plist. They return {} and
log on failure. RLEAPP, VLEAPP and DLEAPP define both names, but they raise
NotImplementedError, and ALEAPP has neither. In those cores use plistlib directly.
get_binary_file_content(file_path) is ALEAPP only. It returns the file's bytes, or empty
bytes on failure.
Media goes into the report through two helpers. Both return a media reference id, a string
you put in a column typed media. Both return None when nothing was checked in.
check_in_media(file_path, name="", converted_file_path=False, force_type=None,
force_extension=None, force_creation_date=None, force_modification_date=None)file_pathis the media file: either a staged path the seeker returned, from this artifact's files found or from its owncontext.get_seeker().search(), or a path or partial path of one of this artifact's files found, looked up the same way ascontext.get_source_file_path. If nothing matches, the helper logsNo matching file found for "<file_path>"and returnsNone.nameis the label stored with the reference. The copy in the report takes its extension fromforce_extension, else fromnamewhen the text after its last dot is four characters or fewer, else from the source file's name by the same rule, else from the type guessed from the bytes.force_typesets the MIME type. Without it, the type is guessed from the first 8,192 bytes of the file. Set it when you know the type and the guess could miss, for example a cache file with no extension.force_extensionsets the extension of the copy, with or without the dot.force_creation_dateandforce_modification_datereplace the file system dates the seeker recorded for the file. The modules that use them pass Unix seconds.converted_file_pathis a file your module wrote, copied into the report in place of the original. The source path and dates recorded are still the original file's, and the type is still guessed from the original's bytes, so passforce_typeandforce_extensionwith it. No module in the five cores uses this option yet.
check_in_embedded_media(source_file, data, name="", force_type=None, force_extension=None,
force_creation_date=None, force_modification_date=None)source_fileis the file the bytes came from, usually the database. It is recorded as the media's source.datais the media bytes. Empty orNonedata returnsNone.- The other options work as they do for
check_in_media. There are no file system dates for embedded media, so the dates are empty unless you pass them.
for row in get_sqlite_db_records(db_path, 'SELECT sent_at, thumbnail FROM messages'):
thumb = check_in_embedded_media(db_path, row[1], name='thumbnail.jpg')
data_list.append((convert_unix_ts_to_utc(row[0]), thumb))- Both helpers read the context
@artifact_processorsets up. A module without the decorator sees whatever context the artifact before it left behind, socheck_in_medialooks in that artifact's files found and records media under that artifact's name. - The same file is copied into the report once, however many rows or artifacts refer to it. A file in the extraction is identified by its evidence path. Embedded media is identified by the SHA-1 of its bytes, so identical blobs are stored once.
- The first check-in of a file or blob decides its copy, type, extension and dates. A later
check-in of the same file, with any
nameor options, adds a reference and changes nothing else. - The copy goes to
media/in the report, with a second copy or hard link under_HTML/media/for the HTML pages. - A media cell can hold one reference id or a list of them. The HTML report shows each
item. The TSV and timeline exports show the copy's path in the report, joined with
|for a list. LAVA stores a list as a JSON array of ids. - An empty or
Nonecell shows as blank. - A third element in the header sets the CSS style of images in the HTML report:
('Photo', 'media', 'max-height:100px;'). - In the HTML report,
image/*types show as images,video/*andaudio/*as players, and anything else as a link.
Return timestamps as timezone-aware datetime objects in UTC, in a column typed
datetime. These helpers produce them.
| Helper | Input | Notes |
|---|---|---|
convert_unix_ts_to_utc(ts) |
Unix time in seconds, milliseconds, microseconds or nanoseconds | The unit is taken from the size of the number. Values within about four months of 1 January 1970 are read as the next coarser unit, so convert those yourself if you know the unit. A falsy value is returned as given. |
convert_ts_int_to_utc(ts) |
Unix seconds only | No unit detection. |
convert_cocoa_core_data_ts_to_utc(ts) |
Seconds since 1 January 2001 (Cocoa, Core Data, Mac absolute time) | Not in ALEAPP. |
webkit_timestampsconv(ts) |
Seconds since 1 January 2001 | Despite the name, it adds the 2001 offset. It does not handle WebKit or Chrome time, which counts microseconds from 1601. ALEAPP has the same function as timestampsconv. |
convert_human_ts_to_utc(ts) and convert_ts_human_to_utc(ts) |
Text YYYY-MM-DD HH:MM:SS |
Drops any fraction of a second and labels the result UTC. It does not convert from local time, so the text must already be UTC. |
convert_plist_date_to_utc(dt) |
A datetime read from a plist |
Labels it UTC and drops the fraction of a second. Not in ALEAPP. |
convert_time_obj_to_utc(dt) |
A naive datetime that is already UTC |
Labels it UTC. It does not convert. |
In ALEAPP, convert_unix_ts_to_utc raises an error for text that is not a whole number.
In the other four cores it returns that text unchanged.
logfunc(message) writes a line to the console, the GUI log and the run's screen log. Use
it for anything an examiner should see in the log, such as a referenced file that is
missing. Do not use print.
device_info(category, label, value, source_file="") records a device value, such as an
IMEI or account name, for the report's device information page:
device_info('Device Information', 'IMEI', imei, db_path)It works in iLEAPP, ALEAPP and VLEAPP. iLEAPP stores the source path relative to the
extraction. ALEAPP and VLEAPP store it as given, which for a found file includes the
examiner's own folders, so pass context.get_relative_path(path) there. In RLEAPP and
DLEAPP it raises NotImplementedError.
As of the commit that added this page.
check_in_media, check_in_embedded_media, get_file_path, get_txt_file_content,
open_sqlite_db_readonly, attach_sqlite_db_readonly, the does_*_exist_in_db helpers,
null_absent_columns and logfunc are the same in all five.
