Parse Microsoft 365 Unified Audit Log (UAL) CSV exports into a single SQLite database optimised for digital-forensic and business-email-compromise (BEC) investigations.
Every row of every CSV export is preserved verbatim, the JSON auditData
blob is stored both raw (byte-for-byte) and in canonical form, and a wide
set of BEC-relevant fields are promoted into indexed and normalized
columns for fast querying. Re-ingesting the same data is safe: full-row
SHA-256 hashes are used as the deduplication key.
The companion script bec-triage consumes
this database to produce a BEC triage report. See the bec-triage repo here
- Requirements
- Installation
- Quick start
- CLI reference
- Output: database files
- Database schema
--reextract: in-place promoter refresh- Migrating from v1
- Coverage diagnostic
- Methodology
- Caveats and known limitations
- Companion script: bec-triage
- Companion script: ual-normalize
- Example queries
- Python 3.13+ (uses
from __future__ import annotationsand modern stdlib features only - no third-party dependencies) - SQLite 3.35+ (any modern Linux/macOS/Windows ships this)
uvuvis my prefered Python package and project manager. I encourage you to consider using it also. Since I useuvthe installation structions will assumeuvis used to create and sync the Python venv.
git clone https://github.com/ForensicFoundry/ualforge.git
cd ualforge
uv venv --python 3.13 ./.venv
uv sync
chmod +x ualforge
sed -i "1c#!$(pwd)/.venv/bin/python" ualforge
# no runtime dependencies; nothing else to install# Ingest all UAL CSVs found anywhere under ./input/ into ./out/ualforge-YYYYMMDD.sqlite
./ualforge -o ./out ./input/
# Same, but also write a ualforge-YYYYMMDD-HHMMSS.log next to the database
./ualforge -o ./out -l ./input/
# Append more CSVs into a pre-existing ualforge database
./ualforge -a ./out/ualforge-20260427.sqlite ./more-input/If you re-run the same input on the same day, rows are deduplicated and the on-disk database is unchanged for any row already present (see Deduplication).
usage: ualforge [-h] [-v] (-o DIR | -a DB | --reextract DB) [-l] [INPUT_DIR]
positional arguments:
INPUT_DIR directory to recursively scan for UAL CSV files
(required for -o / -a; ignored for --reextract)
options:
-h, --help show this help message and exit
-v, --version print version and exit
target (mutually exclusive, exactly one required):
-o, --output DIR write to <DIR>/ualforge-YYYYMMDD.sqlite (creates DIR if needed;
same-day re-runs append to the same file)
-a, --append DB append to a pre-existing ualforge database at PATH (must be a
valid ualforge database, verified via PRAGMA application_id +
ualforge_meta table + user_version)
--reextract DB rebuild promoted columns + child tables in place from the
preserved auditData JSON, without re-reading any source CSV.
Used to upgrade a v1 database to v2, or to refresh promoted
values after a future promoter improvement.
logging:
-l, --log write a ualforge-YYYYMMDD-HHMMSS.log next to the database
capturing all stdout output for the run
Exit codes:
0success (even if some rows had JSON parse errors; those are recorded inparse_errors)1runtime error (cannot read input, cannot write database, etc.)2invalid CLI usage / append target failed verification
./out/ualforge-YYYYMMDD.sqlite is a standard SQLite database with WAL mode
enabled. Open it with:
- the
sqlite3CLI - DB Browser for SQLite (
sqlitebrowser) - any client that supports SQLite (DBeaver, JetBrains DataGrip, DuckDB, etc.)
- the companion
bec-triagescript - See thebec-triagerepohere
Same-day re-runs append into the same file; the date in the filename is when
the first ingest happened. Use -a / --append to add data to a database
created on a different day.
The database carries an SQLite application_id of 0x55414C48 (ASCII UALH)
and a user_version of 2 (since v2026.05.01; v1 databases can be upgraded
in place via --reextract). These are checked in append mode and by
bec-triage.
The main table, ~70 columns. Every CSV row produces exactly one event row.
| Group | Columns | Notes |
|---|---|---|
| Provenance | row_hash (PK), source_file, source_line, export_batch, ingested_at, ingest_run_id, parse_error |
row_hash is the SHA-256 dedup key. source_file is the path relative to INPUT_DIR. export_batch is the export-run folder name (e.g. 20260417-222228). |
| CSV columns (verbatim) | id, created_at_raw, created_at_utc, audit_log_record_type, operation, organization_id, user_type, user_id, service, object_id, user_principal_name, user_principal_name_raw (v2), client_ip_csv, administrative_units |
Exact M365 UAL column names, snake-cased. created_at_utc is normalised (see below); created_at_raw is the original string. In v2 user_principal_name is lowercased for join correctness; the original casing is preserved verbatim in user_principal_name_raw. |
| Raw + canonical JSON | audit_data_raw, audit_data_canonical |
_raw is byte-for-byte; _canonical is json.dumps(json.loads(raw), sort_keys=True, separators=(",",":")) for deterministic hashing and clean json_extract(). |
| Promoted (universal) | workload, client_ip, client_ip_raw (v2), user_agent, geo_location, event_source, result_status, result_status_detail (v2), correlation_id, application_id, user_key, record_type_int |
Pulled from the JSON for indexing. In v2 client_ip is port-stripped and IPv6-bracket-stripped (so 1.2.3.4:54321 and [2603:10a6:610:254::16]:51112 both store as just the address); the original verbatim value is preserved in client_ip_raw. user_agent is now resolved from UserAgent, ExtendedProperties[Name='UserAgent'], and ActorInfoString -- closing the gap on MailItemsAccessed events that previously had a NULL UA. application_id falls back through AppId and ClientAppId. |
| Promoted (Mail / Exchange) | mailbox_owner_upn, mailbox_guid, logon_type, client_info_string, internet_message_id, item_subject, item_parent_folder_path, mail_item_count (v2) |
Surfaces mail-related TTPs (BEC core). mail_item_count is auditData.OperationCount for MailItemsAccessed rows -- the number of items aggregated into that single audit row (see mail_items_accessed for the unrolled per-item view). |
| Promoted (Auth / sign-in) | session_id, logon_user_sid, authentication_type, request_type (v2), user_authentication_method (v2), device_properties (raw JSON), extended_properties (v2; canonical JSON), actor_ip_address |
For tracing token acquisition / replay. request_type and user_authentication_method are pulled from ExtendedProperties for AAD STS-logon events (MFA factor, OAuth flow type). |
| Promoted (Inbox/transport rule changes) | modified_properties, parameters, operation_properties |
Stored as JSON text; query via json_extract(). |
| Promoted (Azure AD) | actor_upn, target_upn |
Flattened from Actor[].ID and Target[].ID arrays. Lowercased in v2. |
| Promoted (SharePoint / OneDrive) | site, web_id, list_id, list_item_unique_id, source_file_name, source_relative_url, item_type |
For exfil reconstruction. |
| Promoted (Client app fingerprint) | client_app_id, client_app_name, browser_name, browser_version, platform, is_managed_device, device_display_name |
Useful for attribution and OAuth-app misuse detection. |
If auditData JSON failed to parse, every promoted column is NULL and
parse_error carries a one-line description. The row is still inserted
(see Error handling).
CREATE TABLE mail_items_accessed (
mia_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_row_hash TEXT NOT NULL REFERENCES events(row_hash) ON DELETE CASCADE,
event_id TEXT,
ingest_run_id INTEGER,
-- denormalized from the parent event for fast filtering
user_principal_name TEXT, -- lowercased
client_ip TEXT, -- port + IPv6-brackets stripped
created_at_utc TEXT,
operation TEXT,
mailbox_owner_upn TEXT,
-- per-item fields from auditData.Folders[].FolderItems[]
folder_id TEXT,
folder_path TEXT,
item_internal_id TEXT,
internet_message_id TEXT,
subject TEXT,
size_in_bytes INTEGER,
client_request_id TEXT,
immutable_id TEXT
);A single MailItemsAccessed (RecordType 50) audit row aggregates many
mail accesses inside auditData.Folders[].FolderItems[]. This child
table unrolls those into one row per individual message accessed, so the
analyst can pivot directly on internet_message_id / subject /
folder_path and answer questions that on v1 required custom SQL against
audit_data_canonical:
- "Which specific InternetMessageIds did the threat actor read?"
- "How many distinct messages did each candidate-compromised UPN access?"
- "Are there messages that were accessed from multiple IPs (token replay)?"
- "Which folders were targeted, in what order?"
A NULL-safe unique index on
(event_row_hash, COALESCE(folder_id,''), COALESCE(item_internal_id, internet_message_id, ''))
makes INSERT OR IGNORE correctly idempotent across re-ingests of the
same source CSV (a real-world scenario when M365's paged exports
overlap).
CREATE TABLE consent_grants (
cg_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_row_hash TEXT NOT NULL REFERENCES events(row_hash) ON DELETE CASCADE,
event_id TEXT,
ingest_run_id INTEGER,
-- denormalized from parent event
user_principal_name TEXT,
client_ip TEXT,
created_at_utc TEXT,
operation TEXT,
-- target app being granted access
app_id TEXT,
app_display_name TEXT,
-- consent details
consent_type TEXT, -- 'Principal' / 'AllPrincipals' / 'AdminConsent'
is_admin_consent INTEGER, -- 0 / 1 / NULL
permission_scope TEXT, -- '; '-joined permission strings
granted_to_id TEXT,
granted_to_name TEXT
);One row per (event, target app) for the AAD audit operations through which an actor typically establishes an OAuth-token foothold:
Consent to application.
Add OAuth2PermissionGrant.
Add delegated permission grant.
Add app role assignment grant to user.
Add app role assignment to service principal.
Add service principal.
Add service principal credentials.
Update application - Certificates and secrets management
app_id, app_display_name, consent_type, is_admin_consent, and
permission_scope are first-class indexed columns instead of buried
inside a stringified ModifiedProperties array, so bec-triage's
consent-timeline section (Section 14) and any analyst-written SQL can
just SELECT from this table directly.
CREATE TABLE ualforge_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);Holds app=ualforge, created_by_version, schema_version, created_at.
Used by bec-triage and by --append to verify the file is a real ualforge
database before writing.
One row per ualforge invocation. Tracks start/end timestamps, version,
input dir, output mode (create|append (same-day)|append (explicit)|reextract),
files processed, rows inserted / duplicate / errored, child-table emit
counts (mia_rows_emitted, consent_rows_emitted), the
coverage diagnostic (as coverage_json), and final
status.
One row per CSV file ingested. Tracks path (relative), sha256 (file
content), mtime, byte size, row counts (read/inserted/duplicate/errors),
and links to ingest_runs(run_id). Re-ingesting the same file (same sha256)
is detected and skipped per-file inside a run.
One row for every CSV row that could not be parsed cleanly (bad JSON, malformed
CSV cell, etc.). Stores the run id, source file, source line, and the error
message. Cross-reference with the events.row_hash of the same row (the row
is still inserted with parse_error populated).
Indexed columns on events include: id, created_at_utc,
user_principal_name, user_id, operation, audit_log_record_type,
record_type_int (v2), service, client_ip, correlation_id,
internet_message_id, mailbox_owner_upn, session_id, application_id,
actor_upn, target_upn, export_batch, source_file, plus composite
(user_principal_name, created_at_utc) for timeline queries and (v2)
(user_principal_name, operation) for op-mix queries.
mail_items_accessed is indexed on event_row_hash, user_principal_name,
internet_message_id, subject, and created_at_utc, with a NULL-safe
unique index for dedup. consent_grants is indexed on event_row_hash,
user_principal_name, app_id, and created_at_utc, with a NULL-safe
unique index on (event_row_hash, COALESCE(app_id, app_display_name, '')).
./ualforge --reextract /path/to/ualforge-YYYYMMDD.sqliteWalks every event row, re-runs extract_promoted() against the canonical
JSON that ualforge has always preserved in audit_data_canonical, and
rebuilds the mail_items_accessed and consent_grants child tables from
scratch. No source CSV is re-read.
Use cases:
- Upgrade a v1 database to v2 -
ALTER TABLE events ADD COLUMN ...for the new columns happens automatically; child tables are created fresh and populated. After a successful run,PRAGMA user_versionis2andbec-triageis happy to run against the database. - Refresh promoted columns after a future promoter improvement - same script, same database, takes minutes (vs hours to re-ingest from a multi-GB CSV tree).
- Diagnose a coverage regression - re-extracting on a known-good database should never lower coverage; if it does, the promoter has regressed.
Performance notes (the implementation is tuned for big DBs):
- Two SQLite connections (read + write) so the cursor and the writer don't contend for page-cache.
- One
BEGIN IMMEDIATE ... COMMITaround the whole rebuild + temporaryPRAGMA synchronous = OFFfor the duration (safe -- a crash rolls back the entire transaction; the original database is untouched). - Secondary child-table indexes are dropped and rebuilt at the end.
The two NULL-safe unique indexes (
uq_mia_event_item,uq_cg_event_app) are intentionally kept during the bulk insert, because they are the dedup mechanism that letsINSERT OR IGNOREcollapse near-duplicate rows Microsoft emits when the same item appears in multiple folders or the same event is paged across export files.
Real-world throughput: a 9 GB / 965 k-event database completes in ~9
minutes on a workstation, producing ~2 M mail_items_accessed rows.
bec-triage v2 enforces PRAGMA user_version = 2 at startup and refuses
to run against a v1 database. You have two choices:
# Option 1 (recommended for big cases): re-extract in place
./ualforge --reextract ./out/ualforge-20260427.sqlite
# Option 2 (for fresh cases or when you want the cleanest dedup):
# delete the v1 DB and re-ingest from the source CSVs
rm ./out/ualforge-20260427.sqlite*
./ualforge -o ./out ./inputEither path produces an identical v2 database. --reextract uses the
canonical JSON stored in audit_data_canonical -- which has been part
of the schema since v1 -- so no data is lost or invented along the way.
At the end of every ingest / --reextract run, ualforge computes:
| Metric | Why it matters |
|---|---|
events_total |
sanity-check the row count against your CSV totals |
client_ip_pct |
how often the actor's source IP could be promoted; >90% is healthy on most modern tenants, lower indicates a tenant-logging gap |
user_agent_pct |
proportion of events with an extractable UA; lower than client_ip_pct is normal -- service-token / app-only events legitimately have no browser UA |
application_id_pct |
proportion of events with an extractable AppId |
user_principal_name_pct |
proportion of events with a UPN; low means lots of system-driven activity (admin operations, audit-pump events) which is fine |
mia_events, mia_rows, mia_avg_items |
how many MailItemsAccessed events ingested and how many per-message rows they produced |
consent_events, consent_rows |
how many consent / SP-grant events ingested and how many consent_grants rows they produced |
The numbers are echoed at the end of the run, persisted in
ingest_runs.coverage_json, and surfaced by bec-triage's report banner.
ualforge walks INPUT_DIR recursively and applies two strategies in order:
- Plan A (preferred): files matching the regex
.*UnifiedAuditLog.*part\d+\.csv$. This is the canonical filename pattern produced by Get-UnifiedAuditLog / the M365 Compliance portal. - Plan B (fallback): any other
*.csvfile is opened and its header is validated against the exact 13-column UAL header. Files that match are ingested; files that don't are silently skipped.
This means you can point ualforge at a messy export directory containing
report PDFs, README.txt, screenshots, etc. and only the actual UAL CSVs will
be ingested.
Plan A and Plan B both require the CSV header to be exactly:
id, createdDateTime, auditLogRecordType, operation, organizationId, userType,
userId, service, objectId, userPrincipalName, clientIp, administrativeUnits,
auditData
(in that order, BOM-tolerant). Anything else is rejected with a logged warning.
Got a non-canonical export? Legacy exports from the PowerShell
Search-UnifiedAuditLogcmdlet, Splunk re-exports of that PowerShell output, and rawAuditData-only dumps all use a different surrounding column set and will be rejected here. Run them through theual-normalizecompanion script first; it auto-detects the source format and emits canonical CSVs that this validator accepts.
Microsoft's createdDateTime is parsed (multiple formats accepted, including
M/D/YYYY h:mm:ss AM/PM) and stored twice:
created_at_raw: original string verbatimcreated_at_utc: ISO-8601 with explicit+00:00UTC suffix (e.g.2026-04-11T09:28:45+00:00)
bec-triage and all examples in this README use created_at_utc.
The auditData JSON blob is parsed once and re-serialised with
sort_keys=True, separators=(",", ":") into audit_data_canonical. This:
- Makes the dedup hash stable regardless of Microsoft's whitespace/key-order variations.
- Makes
json_extract(audit_data_canonical, '$.GeoLocation')etc. robust.
The original bytes are preserved in audit_data_raw.
Rather than force every query to use json_extract(), BEC-critical fields
are pulled out at ingest time and stored in indexed columns. See the
events schema table for the full list.
Promotion is best-effort: missing keys produce NULL, never errors.
The primary key is row_hash, defined as:
SHA-256(
id || createdDateTime || auditLogRecordType || operation ||
organizationId || userType || userId || service || objectId ||
userPrincipalName || clientIp || administrativeUnits ||
audit_data_canonical
)
(NUL-separated.) Inserts use INSERT OR IGNORE, so re-ingesting the same
data is a no-op at the row level. The id GUID alone is not unique
(Microsoft can re-emit a row with a corrected clientIp, for example), which
is why we hash the full row.
Every event row carries:
source_file- path relative toINPUT_DIR(e.g.UnifiedAuditLog/20260301/tenant90DayUal_user@example.com-UnifiedAuditLog-part12.csv)source_line- 1-based line number inside that CSVexport_batch- the immediate parent folder of the CSV (typically the Microsoft export run timestamp like20260301-111118)ingested_at- UTC ISO-8601 of the insertingest_run_id- FK toingest_runs
Plus per-file in ingest_files (path, sha256, mtime, size, counts) and
per-run in ingest_runs (start/end, totals, status). This is enough to
fully reconstruct what was ingested when, from where, by which version of
the script.
-a / --append <DB> requires <DB> to:
- Exist and open as a SQLite database
- Have
PRAGMA application_id == 0x55414C48(UALH) - Contain a
ualforge_metatable withapp == 'ualforge'
If any check fails, ualforge aborts with exit code 2 - it will not
overwrite or "convert" a foreign SQLite file. Append also requires
PRAGMA user_version == SCHEMA_VERSION (currently 2); a v1 database
must be upgraded first via --reextract.
Rows whose auditData cannot be parsed as JSON are still inserted into
events with:
- All 13 CSV columns intact
audit_data_rawpopulated (the bytes Microsoft sent)audit_data_canonicalset to NULL- All 38 promoted columns set to NULL
parse_errorpopulated with a one-line error description
Plus a row in parse_errors for the audit trail.
This means a single bad row never aborts the file or the run, and you never lose data - you can still query and audit problem rows.
- Schema is single-tenant in spirit. Nothing prevents you from ingesting
multiple tenants' data into one DB; just be aware that grouping queries
(and
bec-triage) will conflate them unless you filter byorganization_id. client_ipis normalized at ingest in v2. Microsoft emits some IPv4 inbox-rule and IPv6 sign-in events with:portsuffixes (and IPv6 addresses in[brackets]). v2 strips the port and brackets at ingest time and stores the normalized address inclient_ip; the original verbatim value is preserved inclient_ip_rawfor forensic fidelity. If you upgrade an older database with--reextract, the normalization happens then.geo_locationinaudit_data_canonicalis Microsoft's tenant region code (NAM,EUR,GBR,APC, ...), not the actor's geographic location. For real geolocation, enrichclient_ipwith an external geo-IP data source.- No automatic compression / archiving. WAL files can be large during
long ingests;
ualforgecheckpoints at the end of each run. - Single-process. No concurrent appends; SQLite locks the file.
- No rotation of old
ingest_runs/parse_errors. These accumulate; prune manually if needed.
bec-triage consumes the database produced by ualforge to generate a
colorised, BEC triage report covering: caveats / methodology,
tooling fingerprints, behavioural anomaly screening (UA-independent
heuristics), candidate compromised UPNs, OAuth application abuse,
source-IP analysis, auth pivots, exfil timeline, persistence (inbox-rule,
transport-rule, and mailbox-permission) indicators, OAuth consent-grant
timeline, per-message exfil deep-dive, and an optional --per-upn focused
report.
bec-triage v2 expects a v2 database (PRAGMA user_version == 2) and
will refuse to run against a v1 database with a clear error pointing at
--reextract.
See bec-triage's own README for usage and methodology.
ualforge enforces a strict 13-column header (the canonical Microsoft 365 /
Purview Audit portal export schema). See the ual-normalize repo here
UAL evidence in the wild often arrives in other shapes:
| Source format | Typical origin | What it looks like |
|---|---|---|
| Canonical (13 cols) | Purview portal CSV download / Microsoft Graph audit export converter | id, createdDateTime, ..., auditData |
| PowerShell (~13 cols) | Search-UnifiedAuditLog cmdlet output piped to Export-Csv |
RunspaceId, PSComputerName, CreationDate, UserIds, Operations, RecordType, AuditData, ResultIndex, ResultCount, Identity, IsValid, ObjectState |
| Splunk re-export (40+ cols) | Splunk outputcsv of an indexed PowerShell UAL feed |
PowerShell columns + _bkt, _cd, _raw, _si, _sourcetype, _time, splunk_server, ... |
| AuditData-only | Custom tooling, third-party SIEM dump | A single AuditData column (or that plus arbitrary unrelated columns) |
All of these embed the same per-event JSON in an AuditData column, and that
JSON is what ualforge's promoted columns and bec-triage's analytics
actually depend on. ual-normalize reads any of the four shapes, re-projects
each row into the canonical 13 columns (deriving id, createdDateTime,
auditLogRecordType, operation, organizationId, userType, userId,
service, objectId, userPrincipalName, and clientIp from the JSON when
the surrounding columns are missing or differently named), and writes a clean
CSV that ualforge accepts unchanged.
# Single file
./ual-normalize ./o365_dataset/auditrecords.csv -O ./normalized
# Whole directory tree (mirrors layout under -O)
./ual-normalize ./incoming-ual -O ./normalized -l
# Then ingest the normalized output as if it were a native UAL portal export
./ualforge -o ./out ./normalizedual-normalize [-h] [-v] [-l] [-O DIR] [-f] INPUT
positional:
INPUT CSV file or directory to recursively normalize
options:
-O, --out-dir DIR destination directory for normalized CSVs (default: ./normalized)
-f, --force overwrite existing normalized output files
-l, --log write a ualforge-yyyymmdd-hhmmss.log next to OUT_DIR
-v, --version show version and exit
- Auto-detection. Reads the header of each
*.csvand classifies it ascanonical,powershell,splunk,auditdata, orunknownbased on the set of column names present (case-insensitive). Anything without anAuditDatacolumn is rejected (the source format truly is incompatible); everything else is convertible. - Canonical-format pass-through. Files already in the canonical 13-column
shape are streamed through unchanged (just renamed to
*.normalized.csv) so a directory mix of formats can be normalized in one shot. - JSON-driven re-projection. For non-canonical inputs, every row is
rewritten by parsing its
AuditDataJSON and pulling out:Id->idCreationTime->createdDateTimeRecordType->auditLogRecordType(well-known integers are mapped to names, e.g.1->ExchangeAdmin,15->AzureActiveDirectoryStsLogon,8->AzureActiveDirectory; unknown integers pass through as strings)Operation->operationOrganizationId->organizationIdUserType->userType(also int->name where known:2->Admin,3->DcAdmin, etc.)UserId->userId, and copied touserPrincipalNamewhen it actually looks like a UPN (MailboxOwnerUPNis consulted as a fallback)Workload->serviceObjectId->objectIdClientIP->clientIp(withClientIPAddressandActorIpAddressas fallbacks)administrativeUnitsis left blank (older shapes don't carry it; this is fine -ualforge's queries don't depend on it)- The original
AuditDatatext is preserved verbatim inauditDataso the full evidence trail is intact andualforge's SHA-256 dedup still works.
- Robust to malformed evidence. Rows with blank
AuditDataare skipped (and counted). Rows with unparseableAuditDataJSON are passed through with empty derived columns;ualforgewill then ingest them and log them to itsparse_errorstable - the original bytes are not lost. - Output filename convention. Outputs are named
*.normalized.csvsoualforge's Plan B structural scan picks them up automatically and won't confuse them with future raw inputs.
- It is not a JSON-to-CSV converter for arbitrary audit feeds. If the input
has no
AuditDatacolumn anywhere, it is rejected. - It does not invent fields that weren't in the source JSON. Older
PowerShell exports often have empty
ClientIPfor service-principal events; those rows will still have emptyclientIpafter normalization (correctly). - It does not deduplicate.
ualforgedoes that downstream via the SHA-256 row hash, which works correctly across normalized and native files alike.
Following are some example queries executed by bec-triage.
-- All FileDownloaded events for a UPN, sorted by time
SELECT created_at_utc, client_ip, user_agent, object_id
FROM events
WHERE user_principal_name = 'user@example.com'
AND operation = 'FileDownloaded'
ORDER BY created_at_utc;
-- Top user agents by event count
SELECT user_agent, COUNT(*) AS hits
FROM events
WHERE user_agent IS NOT NULL
GROUP BY user_agent ORDER BY hits DESC LIMIT 20;
-- Inbox rule changes per UPN per day
SELECT user_principal_name,
substr(created_at_utc, 1, 10) AS day,
operation,
COUNT(*) AS hits
FROM events
WHERE operation IN ('New-InboxRule','Set-InboxRule','Remove-InboxRule')
GROUP BY user_principal_name, day, operation
ORDER BY day, hits DESC;
-- Drill into the raw rule contents
SELECT created_at_utc, user_principal_name, operation,
json_extract(audit_data_canonical, '$.Parameters')
FROM events
WHERE operation IN ('New-InboxRule','Set-InboxRule')
ORDER BY created_at_utc DESC LIMIT 50;
-- Verify provenance for a specific row
SELECT source_file, source_line, export_batch, ingested_at, parse_error
FROM events
WHERE id = '...the GUID from the original CSV...';
-- Audit the ingest run history
SELECT run_id, started_at, ended_at,
files_processed, rows_inserted, rows_duplicate, rows_with_errors, status
FROM ingest_runs ORDER BY run_id;GPL-3.0-or-later