Provenance Graphs from LimaCharlie Telemetry
The provenance graph in Bifract v0.0.3 runs on whatever endpoint telemetry you normalize to the correct fields, and LimaCharlie is a natural fit: a single lightweight agent for Linux and Windows that ships the process, network, and file events the graph is built on. This post is a complete setup guide, ending with pgr() running against real EDR data.
The pipeline is short, LimaCharlie outputs batched JSON straight to Bifract’s ingest endpoint, and a normalizer maps the fields on arrival.
Versions used here are LimaCharlie sensor v5.3.3, LimaCharlie CLI v5.5.4, and Bifract v0.0.3.
If you don’t have Bifract running yet, the setup wizard installs it on a single Linux host and handles SSL, passwords, Docker Compose, and database initialization:
curl -sfL https://docs.bifract.io/install.sh | sh
Install a sensor
Create an installation key:
limacharlie installation-key create \
--description "bifract linux" \
--get
Take the key from the response and install on the host:
curl -sL https://downloads.limacharlie.io/sensor/linux/64 -o /usr/local/bin/lc_sensor
chmod +x /usr/local/bin/lc_sensor
/usr/local/bin/lc_sensor -i '<INSTALLATION_KEY>'
Confirm your sensor shows as online:
limacharlie sensor list --fields sid,hostname,plat,is_online
Configure LimaCharlie logs
The Linux default collection profile already sends everything the provenance graph needs. Windows leaves out REMOTE_PROCESS_HANDLE and NEW_REMOTE_THREAD, so add those.
Exfil rules control which event types a sensor sends to the cloud. They live in a hive, LimaCharlie’s config store, so pull the record down, add your rules, and push it back:
limacharlie hive get --hive-name extension_config --key ext-exfil > ext-exfil.json
# add your rules under data.exfil_rules.list, then:
limacharlie hive set --hive-name extension_config \
--key ext-exfil --input-file ext-exfil.json --enabled
The rule to add alongside the existing default-* entries:
"bifract-pgr-windows": {
"events": ["REMOTE_PROCESS_HANDLE", "NEW_REMOTE_THREAD"],
"filters": { "platforms": ["windows"], "tags": [] }
}
Restart the sensors for the changes to apply.
How LimaCharlie identifies processes
Every LimaCharlie event has the same outer shape, an event object with the type-specific payload and a routing object with metadata. Inside routing are atoms, stable identifiers that connect events.
{
"event": {
"COMMAND_LINE": "/usr/sbin/sshd -D -R",
"FILE_PATH": "/usr/sbin/sshd",
"PARENT": {
"FILE_PATH": "/usr/sbin/sshd",
"PROCESS_ID": 7137,
"THIS_ATOM": "e0037075fab0ade4a1f519376a6671a7"
},
"PROCESS_ID": 9454,
"USER_NAME": "root"
},
"routing": {
"event_time": 1785098792869,
"event_type": "NEW_PROCESS",
"hostname": "test-linux-sensor",
"parent": "e0037075fab0ade4a1f519376a6671a7",
"this": "b985029847dc8a82b46fccc96a667228"
}
}
- On
NEW_PROCESS,routing.thisis the process androuting.parentis its parent process. - On every other event type,
routing.thisis the event androuting.parentis the acting process.
To handle this, we split the events across four normalizers.
| Normalizer | Events | Process atom | FILE_PATH |
|---|---|---|---|
| Process | NEW_PROCESS, EXISTING_PROCESS |
routing.this → process_guid |
process image |
| Network | NETWORK_CONNECTIONS, CODE_IDENTITY |
routing.parent → process_guid |
process image |
| Activity | NEW_DOCUMENT, DNS_REQUEST, TERMINATE_PROCESS |
routing.parent → process_guid |
written file |
| Injection | REMOTE_PROCESS_HANDLE, NEW_REMOTE_THREAD |
routing.parent → source_process_guid |
nested under SOURCE/TARGET |
Write the normalizers
Here is the normalizer for process creation events. The others are similar, and all four are available here.
name: LimaCharlie EDR - Process
transforms:
- flatten_full
field_mappings:
- sources: [routing_this]
target: process_guid
- sources: [routing_parent]
target: parent_process_guid
- sources: [routing_hostname]
target: computer_name
- sources: [routing_event_type]
target: event_type
- sources: [event_USER_NAME]
target: user
- sources: [event_FILE_PATH]
target: image
- sources: [event_COMMAND_LINE]
target: commandline
- sources: [event_HASH]
target: hash
- sources: [event_PARENT_FILE_PATH]
target: parent_image
- sources: [event_PARENT_COMMAND_LINE]
target: parent_commandline
value_mappings:
- from_field: event_type
to_field: bifract_category
map:
NEW_PROCESS: process_creation
EXISTING_PROCESS: process_creation
timestamp_fields:
- field: routing_event_time
format: unixmilli
Import and wire up
Import each normalizer and create an ingest token bound to it.
Then point LimaCharlie outputs at Bifract.
# out-process.yaml
dest_host: https://<host>:8443/api/v1/ingest
auth_header_name: Authorization
auth_header_value: Bearer bifract_ingest_...
event_white_list: |
NEW_PROCESS
EXISTING_PROCESS
limacharlie output create --name bifract-process \
--module webhook_bulk --type event --input-file out-process.yaml
Repeat for the other three normalizers, each with its own token and event_white_list. Verify the categories are populating with BQL:
groupBy(bifract_category, event_type, function=count())
| table(bifract_category, event_type, _count)
| sort(_count, desc)
What builds graph edges
These are the categories that build pgr() edges:
| Category | Event | Platform |
|---|---|---|
process_creation |
NEW_PROCESS, EXISTING_PROCESS |
Linux, Windows |
network_connect |
NETWORK_CONNECTIONS |
Linux, Windows |
NEW_DOCUMENT and DNS_REQUEST are missing from that list on purpose. Both carry the acting process atom in routing.parent but not its image, so those categories don’t form edges yet. Resolving atoms back to their process is on my list.
Running pgr()
Seed pgr() with an atom, which Bifract stores as process_guid on every row:
pgr(start="{ATOM}") | pgraph()
Bifract needs history before the provenance graph builds a meaningful baseline, so on a fresh deployment everything looks unusual.
Wrapping up
That’s the whole pipeline. LimaCharlie telemetry now sits alongside everything else in the fractal, searchable with BQL, scoped by Sigma rules, and traversable with pgr(). Most of the work was mapping the atom model onto the fields pgr() reads.
The docs cover normalizers in more depth, and the source is on GitHub.