Graph Database
The db builtins embed a property graph database in the mutant runtime for modeling entities and the relationships between them: people and documents in a knowledge graph, or processes, files, and…
The db_* builtins embed a property graph database in the mutant runtime for
modeling entities and the relationships between them: people and documents in
a knowledge graph, or processes, files, and network endpoints in a forensic
artifact graph built up while investigating an incident. Graphs are opened
either in memory or on disk, referenced afterward by an integer handle, and
support typed nodes and edges, named relations, forensic artifact nodes with
indexed attributes, reading any of it back by value, breadth-first traversal,
shortest-path search, a schema and index check, and storage statistics.
All db_* builtins are fallible and follow the language convention of
returning (result, err); destructure with let value, err = ... and check
err before using value.
Capability table#
| Builtin | Signature | Returns |
|---|---|---|
db_open |
() |
in-memory graph database handle |
db_open_disk |
(path, opts?) |
disk-backed graph database handle; opts is {memory_budget, discover_memory_budget, verify_on_open, read_only} |
db_close |
(db) |
true on success |
db_add_node |
(db, nodeType?) |
new node ID |
db_add_artifact |
(db, type, attrs?) |
{node_id, artifact_type, indexed_props} |
db_add_edge |
(db, from, to, edgeType?) |
new edge ID |
db_add_relation |
(db, from, to, relation, attrs?) |
{edge_id, src, dst, relation, created, indexed_props} |
db_index_prop |
(db, nodeID, key, value) |
true on success |
db_query |
(db) |
array of DATA-type node IDs (alias for db_query_nodes with no filter) |
db_query_nodes |
(db, nodeType?) |
array of node IDs, optionally filtered to one type |
db_find |
(db, key, value, nodeType?) |
{nodes, count, key_indexed} |
db_node |
(db, id) |
{id, labels, properties, property_count, multi_valued, blob_bytes} |
db_relations |
(db, node, direction) |
{node, direction, relations, count, unlabelled} |
db_schema |
(db) |
{nodes, edges, node_types, edge_types, node_keys, edge_keys, relations} |
db_verify |
(db) |
{checked, consistent, problem, values_checked} |
db_bfs |
(db, origin, depth, direction) |
{nodes, edges} — arrays of IDs reached, every edge crossed |
db_shortest_path |
(db, from, to) |
array of node IDs from from to to, empty when not connected |
db_stats |
(db) |
{nodes, edges, has_storage, read_only, lock_free, ...} |
db_compact |
(db) |
{has_storage, delta_records_before, delta_records_after, snapshot_root, prev_root} |
db_timeline |
(db) |
array of timeline event hashes |
nodeType and edgeType are optional integer/enum values in the range
0–127; 0 (the DATA tag, if you declare an enum) is the default used
when omitted.
1. Creating a graph#
db_open() opens an in-memory graph — fastest option, gone when the handle
is closed or the process exits, good for one-off analysis and tests.
db_open_disk(path) opens or creates a graph rooted at path, backed by a
write-ahead log and a delta layer that periodically merges into an on-disk
CSR store; every other db_* builtin works identically against either
backend. Always release a handle with db_close when done with it; a disk
store the program forgets is closed at its end and named on stderr.
db_open_disk reads the directory's graphene.labels before opening it,
because graphene registers a store's label names for the whole process. It
refuses a table that names any of the custom labels 0-127 that db_* writes
without names -- a symbol graph from mutant graph export is one, and is read
with mutant graph query -- or that gives a label of the disclosure ledger's
schema another name, or that is torn. A forensic ledger is refused as well.
db_open_disk(path, {"read_only": true}) opens an existing store without
changing it. It never creates a store -- a mistyped path is an error, not an
empty graph -- runs beside other readers, and is refused while a writer holds
the store rather than reading it mid-change. Every write through the handle is
an error. A store with no graphene.lock, which is how graphene's own backups
arrive, or one on write-protected media, is read without taking a lock rather
than given one, and db_stats(db)["lock_free"] says which and why; it is empty
when the read took a lock.
let db, err = db_open();
if (err) { putln("[error] db_open: ", err); };let disk_db, err = db_open_disk("./investigation.graphdb");
if (err) { putln("[error] db_open_disk: ", err); };
// ... work with db / disk_db ...
let closed, err = db_close(db);
if (err) { putln("[error] db_close: ", err); };
let disk_closed, err = db_close(disk_db);
if (err) { putln("[error] db_close disk: ", err); };
Compacting a disk-backed store rewrites it in a newer on-disk format that older mutant builds cannot open — see Statistics & maintenance below.
2. Modeling entities & relationships#
Plain nodes and edges#
db_add_node and db_add_edge are the primitives. A node optionally carries
an integer/enum type; an edge optionally carries one the same way. Neither
takes a property hash — use db_add_artifact for indexed node attributes, or
db_index_prop to attach one property at a time to any node.
enum NodeType { DATA, PERSON, DOCUMENT };
enum EdgeType { DATA, KNOWS, AUTHORED };let db, err = db_open();
if (err) { putln("[error] db_open: ", err); };
let person_a, err = db_add_node(db, NodeType.PERSON);
let person_b, err = db_add_node(db, NodeType.PERSON);
let doc, err = db_add_node(db, NodeType.DOCUMENT);
if (err) { putln("[error] db_add_node: ", err); };
let e1, err = db_add_edge(db, person_a, person_b, EdgeType.KNOWS);
let e2, err = db_add_edge(db, person_a, doc, EdgeType.AUTHORED);
if (err) { putln("[error] db_add_edge: ", err); };
Forensic artifacts#
db_add_artifact(db, type, attrs?) is the entity-modeling shortcut: it adds
a node, tags it with a string type, indexes every key of the optional
attrs hash, and returns a hash instead of a bare ID:
let proc, err = db_add_artifact(db, "process", {
"pid": 4021,
"name": "powershell.exe",
"user": "CORP\\svc_backup"
});
if (err) { putln("[error] db_add_artifact: ", err); };
let proc_id = proc["node_id"]; // proc also carries artifact_type, indexed_props
Named relations#
db_add_relation(db, from, to, relation, attrs?) links two existing node IDs —
plain nodes or an artifact's node_id — with an edge that carries a string
relation label. The edge and its label land in one transaction: the label is
indexed on the edge under relation, which is where db_relations and
db_schema read it back, and each key of the optional attrs hash is stored
beside it as attr_<key>, exactly as db_add_artifact stores a node's. It
returns a hash rather than a bare edge ID:
let file, err = db_add_artifact(db, "file", {"path": "C:/Users/Public/updater.ps1"});
if (err) { putln("[error] db_add_artifact: ", err); };
let file_id = file["node_id"];let rel, err = db_add_relation(db, proc_id, file_id, "writes", {"bytes": 4096});
if (err) { putln("[error] db_add_relation: ", err); };
putln("relation: ", rel["relation"], " created=", rel["created"]);
An empty relation, or one that is not valid UTF-8, is refused, and so is a node
that does not exist; nothing is written in any of those cases. attrs refuses
classified plaintext from record_read, as db_add_artifact's does, and a key
in either hash that is not a string is an error rather than skipped.
Stores written before 2.6.0 carry no relation labels. db_add_relation
used to add a plain edge and send the label only to db_timeline's journal,
which lives in the process and is gone at db_close. Those labels cannot be
recovered from the store. db_relations reports such an edge with
relation: null and counts it in unlabelled, the same as an edge db_add_edge
wrote.
3. Indexing properties for lookup#
db_index_prop(db, nodeID, key, value) attaches a single indexed
key=value pair to any node — plain, typed, or artifact. All four arguments
are required, and value must be a string; stringify non-string values
first (json_stringify for numbers, hashes, and the like):
let ip_node, err = db_add_node(db);
if (err) { putln("[error] db_add_node: ", err); };
let _, err = db_index_prop(db, ip_node, "ip", "192.168.1.4");
if (err) { putln("[error] db_index_prop: ", err); };let count_json, err = json_stringify(3);
if (err) { putln("[error] json_stringify: ", err); };
let _, err = db_index_prop(db, ip_node, "failed_logins", count_json);
if (err) { putln("[error] db_index_prop: ", err); };
db_add_artifact's attrs hash is indexed the same way automatically, so
artifacts rarely need an extra db_index_prop call on top of it.
db_index_prop runs in a transaction that fails, and indexes nothing, when the
node does not exist. Indexing a key a second time adds a value rather than
replacing the first: after db_index_prop(db, n, "status", "open") and then
"closed", the node holds both, and is found under either.
4. Reading a graph back#
Everything above is indexed, and five builtins read it.
db_find(db, key, value, nodeType?) returns the nodes whose indexed key holds
exactly value, in ascending ID order, optionally restricted to one node type:
let found, err = db_find(db, "ip", "192.168.1.4");
if (err) { putln("[error] db_find: ", err); };
if (found["key_indexed"] == false) {
putln("nothing in this store was ever indexed under ip");
};
putf("%d node(s) hold that ip\n", found["count"]);
key_indexed is the field to read before trusting an empty answer. graphene
answers a lookup on a key it never indexed with nothing and no error, so
"ip" misspelled as "IP" would otherwise look exactly like "no host has
that address".
db_node(db, id) reads one node back: its labels (the type numbers given to
db_add_node) and a properties hash of everything indexed on it, by
db_add_artifact, db_index_prop or anything else. Values come back as
strings, or as BYTES when they are not valid UTF-8. A key indexed more than once
holds an array of every value and is listed in multi_valued, rather than one
value being picked and the other hidden:
let node, err = db_node(db, proc_id);
if (err) { putln("[error] db_node: ", err); };
putln("pid: ", node["properties"]["attr_pid"]);
db_relations(db, node, direction) lists one node's edges in a direction --
"out", "in" or "both" -- in the order they were added, each with the
relation it carries (or null), its endpoints, its type and its attr_
properties. unlabelled counts the edges with no relation.
db_schema(db) says what a store holds: node and edge counts by type, the
property keys its index has held, and how many edges carry each relation.
db_verify(db) cross-checks the indexes against the records and reports
checked and consistent separately, so a store that cannot check itself never
reads as a pass. values_checked is always false: the check covers the index's
structure, not whether an indexed value still says what the script meant.
5. Traversal & pathfinding#
db_bfs(db, origin, depth, direction) walks outward from origin up to
depth hops; direction is "in", "out", or "both", and any other word
is an error. depth 0 is the origin alone, and a negative depth is an error.
It returns a hash of two arrays — nodes, in the order reached, origin first,
and edges — holding every ID it reached and every edge it crossed, parallel
edges included: three relations from one process to one file are three edges.
db_shortest_path(db, from, to) returns the path with the fewest edges as an
ordered array of node IDs from from to to, and an empty array when the two
are not connected. It walks edges in either direction, so a path can run
against an edge; db_bfs with "out" answers what is reachable going forward.
A node that does not exist is an error for both.
Both run under the language's fixed traversal budget -- the limits are in LIMITS_REFERENCE.md -- and a walk that reaches it is refused rather than cut short, so an answer is never a partial walk that reads like a complete one.
Consider a small incident-response graph: a process that spawned a child process, wrote a file, and the child that reached out to a network endpoint.
let db, err = db_open();
if (err) { putln("[error] db_open: ", err); };let proc, err = db_add_artifact(db, "process", {"pid": 4021, "name": "powershell.exe"});
let child, err = db_add_artifact(db, "process", {"pid": 4102, "name": "cmd.exe"});
let file, err = db_add_artifact(db, "file", {"path": "C:/Users/Public/updater.ps1"});
let endpoint, err = db_add_artifact(db, "network", {"dst": "198.51.100.25", "port": 443});
if (err) { putln("[error] db_add_artifact: ", err); };
let proc_id = proc["node_id"];
let child_id = child["node_id"];
let file_id = file["node_id"];
let endpoint_id = endpoint["node_id"];
let _, err = db_add_relation(db, proc_id, child_id, "spawned");
let _, err = db_add_relation(db, proc_id, file_id, "writes");
let _, err = db_add_relation(db, child_id, endpoint_id, "connects_to");
if (err) { putln("[error] db_add_relation: ", err); };
putln("=== BFS outbound from root process, depth 2 ===");
let bfs, err = db_bfs(db, proc_id, 2, "out");
if (err) { putln("[error] db_bfs: ", err); };
putf("nodes reachable: ", len(bfs["nodes"]), " edges traversed: ", len(bfs["edges"]));
putln("");
putln("=== Shortest path: process -> network endpoint ===");
let path, err = db_shortest_path(db, proc_id, endpoint_id);
if (err) {
putln("[error] db_shortest_path: ", err);
} else if (len(path) == 0) {
putln("not connected");
} else {
putf("hops: ", len(path) - 1);
putln("");
};
Relation edges are ordinary edges of the default type, so they show up in
db_bfs and db_shortest_path the same as plain edges do.
6. Statistics & maintenance#
db_stats(db) always reports nodes, edges, and has_storage
(false for in-memory graphs, which have no delta layer, WAL, or
compaction). Disk-backed graphs report more:
| Field | Meaning |
|---|---|
nodes / edges |
current live counts |
has_storage |
true for disk-backed graphs |
delta_records |
writes sitting in the uncompacted delta layer |
csr_records |
records already folded into the compact CSR store |
deleted_nodes / deleted_edges |
tombstoned entries not yet reclaimed |
wal_bytes |
bytes in the write-ahead log awaiting merge |
commit_seq |
monotonically increasing commit counter |
last_compact |
timestamp of the last compaction |
read_only |
true for a store opened with {"read_only": true} (every handle reports it) |
lock_free |
why a read-only store was read without a lock; empty when it took one |
let stats, err = db_stats(db);
if (err) { putln("[error] db_stats: ", err); };
putf("nodes: ", stats["nodes"], " edges: ", stats["edges"]);
putln("");
delta_records and wal_bytes that keep climbing across runs of a
long-running disk-backed graph mean compaction is overdue: everything
written since the last compaction stays in memory and is replayed on every
open, so an uncompacted store degrades in memory use, open time, and read
speed with no error to signal it.
db_compact(db) is what acts on those figures. It merges the delta layer
into the compact store and truncates the write-ahead log, which is what
gives the memory back.
let stats, err = db_stats(db);
if (err) { putln("[error] db_stats: ", err); };
if (stats["has_storage"] && stats["delta_records"] > 100000) {
let report, err = db_compact(db);
if (err) { putln("[error] db_compact: ", err); };
putf("compacted: ", report["delta_records_before"], " -> ", report["delta_records_after"]);
putln("");
};
It reports what it moved rather than just that it ran, because a compaction
that merged nothing and one that merged a million records both return
successfully. It also reports snapshot_root, the Merkle root of the image it
wrote, and prev_root, the root of the one it replaced: the store's identity at
that moment, which a script can record and a later check can be held to. On an in-memory handle it is a no-op and reports
has_storage: false, so a script that compacts on a schedule does not have
to know which kind of handle it was given.
When a store is compacted it is rewritten in a newer on-disk format that older mutant builds cannot open, so account for that in any deployment that mixes build versions against the same graph file. Reading is unaffected: this build opens a store written by any earlier one.
db_timeline(db) returns the chronological list of timeline events recorded
for the graph — but only events from db_add_artifact ({action: "add_artifact", node_id, artifact_type}) and db_add_relation ({action: "add_relation", src, dst, relation, edge_id}). Plain db_add_node /
db_add_edge calls are not recorded on the timeline. The timeline lives in the
process, not the store: it is empty after a reopen, and db_close clears it.
The store itself is read with the builtins in Reading a graph back.
let timeline, err = db_timeline(db);
if (err) { putln("[error] db_timeline: ", err); };
putf("timeline events recorded: ", len(timeline));
putln("");
Putting it together: a small investigation graph#
Build a disk-backed graph, model a process/file/network chain as artifacts and relations, index an extra property, traverse it, and print stats:
let check_err = fn(err, op) {
if (err) {
putln("[error] ", op, ": ", err);
};
};putln("=== investigation graph ===");
let db, err = db_open_disk("./example_output/investigation_graph");
check_err(err, "db_open_disk");
let proc, err = db_add_artifact(db, "process", {
"pid": 4021,
"name": "powershell.exe",
"user": "CORP\svc_backup"
});
check_err(err, "db_add_artifact process");
let proc_id = proc["node_id"];
let file, err = db_add_artifact(db, "file", {
"path": "C:/Users/Public/updater.ps1",
"sha256": "3fa2c1d9e4b7"
});
check_err(err, "db_add_artifact file");
let file_id = file["node_id"];
let endpoint, err = db_add_artifact(db, "network", {
"dst": "198.51.100.25",
"port": 443
});
check_err(err, "db_add_artifact endpoint");
let endpoint_id = endpoint["node_id"];
let _, err = db_add_relation(db, proc_id, file_id, "writes");
check_err(err, "db_add_relation writes");
let _, err = db_add_relation(db, proc_id, endpoint_id, "connects_to");
check_err(err, "db_add_relation connects_to");
let _, err = db_index_prop(db, proc_id, "hostname", "WORKSTATION07");
check_err(err, "db_index_prop hostname");
putln("");
putln("=== BFS outbound from process, depth 2 ===");
let bfs, err = db_bfs(db, proc_id, 2, "out");
check_err(err, "db_bfs");
putf("nodes reachable: ", len(bfs["nodes"]), " edges traversed: ", len(bfs["edges"]));
putln("");
putln("");
putln("=== Shortest path: process -> network endpoint ===");
let path, err = db_shortest_path(db, proc_id, endpoint_id);
check_err(err, "db_shortest_path");
putf("hops: ", len(path) - 1);
putln("");
putln("");
putln("=== What the process touched ===");
let touched, err = db_relations(db, proc_id, "out");
check_err(err, "db_relations");
for (rel in touched["relations"]) {
putln(rel["relation"], " -> node ", rel["dst"]);
};
putln("");
putln("=== Stats & timeline ===");
let stats, err = db_stats(db);
check_err(err, "db_stats");
putf("nodes: ", stats["nodes"], " edges: ", stats["edges"], " wal_bytes: ", stats["wal_bytes"]);
putln("");
let timeline, err = db_timeline(db);
check_err(err, "db_timeline");
putf("timeline events recorded: ", len(timeline));
putln("");
let closed, err = db_close(db);
check_err(err, "db_close");
if (closed) {
putln("graph closed cleanly");
};
Notes & limits#
- No property hashes on plain nodes or edges.
db_add_nodeanddb_add_edgeaccept only a type, not attributes. Reach fordb_add_artifactwhen a node needs indexed attributes, or attach them one at a time afterward withdb_index_prop(values must be strings). - No query-expression language.
db_findlooks nodes up by one key and one exact value;db_query/db_query_nodesfilter by type. There is no filter or predicate syntax, no range or prefix match, and no conjunction of keys: narrow a result set in the script, withdb_nodeanddb_relationsover the IDsdb_findreturned. A script that wants its own index can keep one in a hash --ip_index["192.168.1.4"] = ip_node;. db_timelineonly seesdb_add_artifactanddb_add_relation, and only in the process that wrote them. Plaindb_add_node/db_add_edgecalls do not produce timeline entries.- Compaction is a script's call.
db_statsexposes the figures that show a disk-backed store is due (delta_records,wal_bytes), anddb_compactacts on them. A compacted store is rewritten in a newer on-disk format that older mutant builds cannot open.