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.this is the process and routing.parent is its parent process.
  • On every other event type, routing.this is the event and routing.parent is 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.thisprocess_guid process image
Network NETWORK_CONNECTIONS, CODE_IDENTITY routing.parentprocess_guid process image
Activity NEW_DOCUMENT, DNS_REQUEST, TERMINATE_PROCESS routing.parentprocess_guid written file
Injection REMOTE_PROCESS_HANDLE, NEW_REMOTE_THREAD routing.parentsource_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.

Normalizer per LimaCharlie event type

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()
A provenance graph drawn from LimaCharlie telemetry on a Linux host

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.