bhgraph

package module
v0.2.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 6, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

README

bhgraph

Tests Lint Security Go Reference

Go library for BloodHound structured OpenGraph. It builds payloads, describes extension definition schemas, and talks to the API with signed requests.

Only structured graphs take part in the BloodHound UI's pathfinding. No importable Go library covers extension definition schemas or the /api/v2/extensions endpoint, so every collector that wants pathfinding writes that part itself. The ones that have, including SpecterOps' own MSSQLHound, keep it under internal/, where nobody else can import it.

If all you need is a generic payload, gopengraph already does that well and is the smaller dependency. bhgraph covers the schema and the API side.

No dependencies outside the standard library.

go get github.com/saluc28/bhgraph

Quick start

Build a payload and write it to disk. No credentials and no server are involved, and BloodHound accepts a .json upload from the UI, so this is already a usable workflow:

g := bhgraph.Graph{}
g.AddNode(bhgraph.Node{ID: "alice", Kinds: []string{"EX_Person"},
    Properties: map[string]any{"name": "ALICE@EXAMPLE.TEST"}})
g.AddNode(bhgraph.Node{ID: "repo", Kinds: []string{"EX_Repo"},
    Properties: map[string]any{"name": "INFRA@EXAMPLE.TEST"}})
g.AddEdge(bhgraph.Edge{Kind: "EX_CanWrite",
    Start: bhgraph.NodeRef("alice"), End: bhgraph.NodeRef("repo")})

g.UppercaseIDs()
if err := g.Validate(); err != nil {
    return err
}
_, err := g.WriteTo(file)

To install a schema and upload over the API, see examples/with-extension.

Getting a token

The API uses signed requests rather than bearer tokens, so you need a token ID and a token key. In BloodHound, go to Settings, then My Profile, then API Key Management, then Create Token. Copy both values. The key is only shown at creation: if you lose it, delete the token and make a new one.

c, err := client.New("http://127.0.0.1:8080", tokenID, tokenKey)

The signature is a chain of three HMAC-SHA-256 digests over the method, the URI, the request date truncated to the hour, and the body. client.Sign is exported, so you can sign a request this library does not wrap.

Signatures are valid for at most two hours, so the client and the server need roughly agreeing clocks.

The extensions endpoint is behind a feature flag

/api/v2/extensions is gated by opengraph_extension_management, which is off by default. With the flag off the route is not registered at all, so the response is 404 resource not found. That sends you looking for the mistake in the path, the method, or the signature. The Community tag on the endpoint means it exists in CE, not that it is enabled.

Turn it on under Administration, then Early Access Features, which is where BloodHound's extension management documentation points. Over the API:

PUT /api/v2/features/{id}/toggle

after finding the id in GET /api/v2/features. This library turns that 404 into an error that names the flag, since the response body does not. Features and FeatureEnabled read that endpoint, so a collector can ask instead of inferring the answer from a failure.

The flag governs more than the endpoint. GetShortestPath branches on it (pathfinding.go:156), and with the flag off the server answers from the built-in AD and Azure kinds alone, so a path across the edges of an installed schema comes back as 404 path not found. A correct graph and a switched off feature give the same answer, which is why asking is worth more than re-reading the payload.

Two behaviours worth knowing

The signature covers the query string, and BloodHound's own Go helper does not. The server validates against request.RequestURI, which includes the query, while the signing helper in the same repository signs request.URL.Path, which does not. The two agree until a request carries a parameter, and then the server answers 401 signature digest mismatch. The Python client published with BloodHound's documentation signs the full URI and agrees with the server, so the Go helper is the one that differs. This library follows the server, since the server performs the check. Reported as SpecterOps/BloodHound#3098.

Object ids are uppercased on ingest, and the documented list of uppercased values does not mention it. BloodHound's node rules page lists name, operatingsystem, distinguishedname and environmentid as property values that ingest uppercases. A node's id, which becomes its objectid, is uppercased too, on the generic ingest path in ConvertGenericNode (convertors.go:36). Reusing that id in its original case returns 500 not found, which reads like a missing node rather than a case mismatch. The use_raw_object_id feature flag would keep the original case, but it ships disabled and is not user updatable, so on v9.5.1 the uppercasing applies. Graph.UppercaseIDs() normalizes before the upload so both sides agree.

Validation

A failed ingest job reports errors that are poor and asynchronous. The upload is accepted, and whatever went wrong surfaces later, if at all. Validate and ValidateAgainst run before anything is sent, and report every problem rather than stopping at the first:

  • nodes have a non-empty id, at least one kind, and no duplicate ids
  • edges have a kind, and id-matched endpoints exist among the nodes in the payload
  • kind names carry the namespace declared by the schema
  • every kind used in the payload is declared in the schema

The last check matters more than it looks. An undeclared kind is accepted by the ingest endpoint and then fails to appear in the UI, with nothing to indicate why.

Verified end to end

Against BloodHound CE v9.5.1 on 2026-08-03, with one edge kind declared non-traversable:

alice --MemberOf--> platform --CanWrite--> repo-infra     both traversable
alice --Watches--> repo-secret                            not traversable
query result
alice -> repo-infra, only_traversable=true path found, 3 nodes
alice -> repo-secret, only_traversable=true no path
alice -> repo-secret, only_traversable=false path found, 2 nodes

The same edge is visible or invisible to pathfinding depending on a single flag in the schema. Declaring that flag is what installing an extension schema is for, and these three queries are how you confirm it took effect.

Signature test vectors

client/sign_test.go pins the signature against fixed vectors. They were not produced by running this library and freezing the output, which would only prove that the code agrees with itself. They come from executing BloodHound's own reference implementation (cmd/api/src/api/signature.go, v9.5.1, Apache-2.0), which is the function the server runs to verify incoming requests. A live server then accepted requests signed with them.

Memory on large graphs

The signature covers the request body, so the body has to be readable in full before the request goes out. Building it in memory would make the peak allocation proportional to the graph, and this library would end up deciding how large a graph you can ingest.

UploadGraph serializes the graph once, into an io.MultiWriter feeding both a spool and a BodySigner. Payloads below WithSpoolThreshold (8 MiB by default) stay in memory. Larger ones spill to a temporary file that is removed when the upload finishes. BloodHound's server does the same thing on the receiving side.

BodySigner is exported, so a caller streaming a payload this library does not build can sign it the same way.

Status

v0.1.0, a v0 release: the API can still change, and the CHANGELOG says when it does.

Everything described above is implemented and tested. That includes a round trip of MSSQLHound's own schema.json through the Extension types, which is the check that would catch a field SpecterOps declares and this library does not model.

Three things are missing on purpose, because adding them now would freeze a behaviour picked by guessing:

  • There is no retry. POST /file-upload/start creates a job, so retrying it blindly leaves orphans behind, and a policy worth having has to be decided per endpoint.
  • There is no client-side rate limiting. BloodHound runs rate limit middleware, but its effective limits are undocumented and have not been measured here, and a throttle picked at random is an invented limit imposed on the caller.
  • Token expiry is not handled. It is governed by the api_key_expiration_support feature flag, which was off on the instance this was built against, so reporting that error clearly needs an instance where it is on.

If you hit one of these, an issue describing the actual case is more useful than a patch.

License

Apache-2.0. See LICENSE and NOTICE.

Documentation

Overview

Package bhgraph builds and validates BloodHound OpenGraph payloads and extension definition schemas.

This package has no dependencies outside the standard library and performs no network I/O. To upload data to a BloodHound instance, see the client subpackage.

Example

Building a payload takes no credentials and no server: BloodHound accepts a .json upload from the UI, so this is already a complete workflow.

package main

import (
	"fmt"
	"os"

	"github.com/saluc28/bhgraph"
)

func main() {
	g := bhgraph.Graph{}
	g.AddNode(bhgraph.Node{
		ID:         "alice",
		Kinds:      []string{"EX_Person"},
		Properties: map[string]any{"name": "ALICE@EXAMPLE.TEST"},
	})
	g.AddNode(bhgraph.Node{
		ID:         "infra",
		Kinds:      []string{"EX_Repo"},
		Properties: map[string]any{"name": "INFRA@EXAMPLE.TEST"},
	})
	g.AddEdge(bhgraph.Edge{
		Kind:  "EX_CanWrite",
		Start: bhgraph.NodeRef("alice"),
		End:   bhgraph.NodeRef("infra"),
	})

	// BloodHound uppercases object ids on ingest, so do it here and keep both
	// sides using the same ids.
	g.UppercaseIDs()

	if err := g.Validate(); err != nil {
		fmt.Println(err)
		return
	}
	if _, err := g.WriteTo(os.Stdout); err != nil {
		fmt.Println(err)
	}
}
Output:
{"graph":{"nodes":[{"id":"ALICE","kinds":["EX_Person"],"properties":{"name":"ALICE@EXAMPLE.TEST"}},{"id":"INFRA","kinds":["EX_Repo"],"properties":{"name":"INFRA@EXAMPLE.TEST"}}],"edges":[{"kind":"EX_CanWrite","start":{"value":"ALICE","match_by":"id"},"end":{"value":"INFRA","match_by":"id"}}]}}

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Edge

type Edge struct {
	Kind       string         `json:"kind"`
	Start      Ref            `json:"start"`
	End        Ref            `json:"end"`
	Properties map[string]any `json:"properties,omitempty"`
}

Edge is a directed relationship between two nodes.

Whether an edge participates in BloodHound's pathfinding is not decided here: it is decided by IsTraversable on the matching RelationshipKind of the installed extension schema.

type Environment

type Environment struct {
	EnvironmentKind string   `json:"environment_kind"`
	SourceKind      string   `json:"source_kind"`
	PrincipalKinds  []string `json:"principal_kinds"`
}

Environment scopes findings and risk metrics to a set of principal kinds.

Findings and risk metrics are BloodHound Enterprise features. On Community Edition this field is filled in for schema correctness, and no automatic findings should be expected from it.

type Extension

type Extension struct {
	Schema               SchemaMeta         `json:"schema"`
	NodeKinds            []NodeKind         `json:"node_kinds"`
	RelationshipKinds    []RelationshipKind `json:"relationship_kinds"`
	Environments         []Environment      `json:"environments"`
	RelationshipFindings []any              `json:"relationship_findings"`
}

Extension is a BloodHound extension definition schema, introduced in BloodHound CE v9.0.0 and installed with PUT /api/v2/extensions.

Declaring a schema is what makes a graph "structured" rather than "generic", and structured graphs are the only ones whose edges take part in the UI's pathfinding. The mechanism is a single flag: IsTraversable on each RelationshipKind.

Example

Declaring a schema is what makes a graph structured rather than generic, and only structured graphs take part in the UI's pathfinding. IsTraversable is the whole mechanism: EX_Watches connects two nodes but grants nothing, so marking it traversable would invent a path that does not exist.

package main

import (
	"fmt"

	"github.com/saluc28/bhgraph"
)

func main() {
	schema := bhgraph.Extension{
		Schema: bhgraph.SchemaMeta{
			Name:        "example",
			DisplayName: "Example",
			Version:     "v0.0.1",
			Namespace:   "EX",
		},
		NodeKinds: []bhgraph.NodeKind{
			{Name: "EX_Person", DisplayName: "Person", IsDisplayKind: true, Icon: "user", Color: "#4287f5"},
			{Name: "EX_Repo", DisplayName: "Repository", IsDisplayKind: true, Icon: "code-branch", Color: "#f5a742"},
		},
		RelationshipKinds: []bhgraph.RelationshipKind{
			{Name: "EX_CanWrite", Description: "Can push to the repository", IsTraversable: true},
			{Name: "EX_Watches", Description: "Subscribed to notifications", IsTraversable: false},
		},
		Environments: []bhgraph.Environment{
			{EnvironmentKind: "EX_Repo", SourceKind: "EX", PrincipalKinds: []string{"EX_Person"}},
		},
		RelationshipFindings: []any{},
	}

	if err := schema.Validate(); err != nil {
		fmt.Println(err)
		return
	}
	fmt.Println("pathfinding will walk:", schema.TraversableKinds())
}
Output:
pathfinding will walk: [EX_CanWrite]

func (Extension) TraversableKinds

func (e Extension) TraversableKinds() []string

TraversableKinds returns the names of the relationship kinds that take part in pathfinding. Useful in tests and in reviewing a schema before installing it, since this set is the entire difference between a graph the UI can walk and one it cannot.

Example

Reading the traversable kinds back is the cheapest way to check a schema before installing it, since that set is the entire difference between a graph the UI can walk and one it cannot.

package main

import (
	"fmt"

	"github.com/saluc28/bhgraph"
)

func main() {
	schema := bhgraph.Extension{
		RelationshipKinds: []bhgraph.RelationshipKind{
			{Name: "EX_MemberOf", IsTraversable: true},
			{Name: "EX_Watches", IsTraversable: false},
			{Name: "EX_CanWrite", IsTraversable: true},
		},
	}

	fmt.Println(schema.TraversableKinds())
}
Output:
[EX_MemberOf EX_CanWrite]

func (Extension) Validate

func (e Extension) Validate() error

Validate reports problems in the extension schema itself.

Every kind name must carry the declared namespace as a prefix. That is a convention rather than a server-side requirement, but it is the convention SpecterOps follows in its own collectors, and kind names end up both in ingested data and in saved Cypher queries: renaming them later means re-ingesting everything.

type Graph

type Graph struct {
	Nodes []Node
	Edges []Edge
}

Graph is a set of nodes and edges destined for a single ingest job.

Methods that change the graph take a pointer receiver, methods that only read it take a value. MarshalJSON has to be one of the readers: on a pointer receiver, json.Marshal of a Graph value would quietly ignore it and emit the Go field names instead of the shape BloodHound expects.

func (*Graph) AddEdge

func (g *Graph) AddEdge(e Edge)

AddEdge appends an edge to the payload.

func (*Graph) AddNode

func (g *Graph) AddNode(n Node)

AddNode appends a node to the payload.

func (Graph) MarshalJSON

func (g Graph) MarshalJSON() ([]byte, error)

MarshalJSON renders the graph in the shape BloodHound's file-upload endpoint expects, which is the same for generic and structured graphs:

{"graph": {"nodes": [...], "edges": [...]}}

A Ref with an empty MatchBy is emitted as MatchByID.

func (*Graph) UppercaseIDs

func (g *Graph) UppercaseIDs()

UppercaseIDs rewrites every node ID and every id-matched edge endpoint to upper case, so that what you send matches what BloodHound stores.

BloodHound uppercases object ids during ingest, verified against CE v9.5.1: a node sent as "bhgs-alice" comes back as "BHGS-ALICE". That matters as soon as the id is used again, for pathfinding, a Cypher lookup, or correlating a second ingest. Passing it back in the case you sent it answers "not found", which reads like a missing node rather than a case mismatch.

Endpoints matched by name are left alone: only ids are normalized.

Example

Object ids are uppercased during ingest, which matters as soon as an id is used again: reusing it in its original case reads as a missing node rather than as a case mismatch. Endpoints matched by name are left alone, since their case is resolved server side.

package main

import (
	"fmt"

	"github.com/saluc28/bhgraph"
)

func main() {
	g := bhgraph.Graph{
		Nodes: []bhgraph.Node{{ID: "bhgs-alice", Kinds: []string{"EX_Person"}}},
		Edges: []bhgraph.Edge{{
			Kind:  "EX_MemberOf",
			Start: bhgraph.NodeRef("bhgs-alice"),
			End:   bhgraph.Ref{Value: "platform@example.test", MatchBy: bhgraph.MatchByName},
		}},
	}

	g.UppercaseIDs()

	fmt.Println(g.Nodes[0].ID)
	fmt.Println(g.Edges[0].Start.Value)
	fmt.Println(g.Edges[0].End.Value)
}
Output:
BHGS-ALICE
BHGS-ALICE
platform@example.test

func (Graph) Validate

func (g Graph) Validate() error

Validate reports structural problems in the graph itself, without reference to any schema.

It checks that every node has a non-empty ID and at least one kind, that IDs are unique, that every edge has a kind, and that edges matching by ID point at nodes present in the payload. That last check is the one that catches the common mistake: an edge to a node the collector forgot to emit is accepted by BloodHound and silently produces nothing.

Example

An edge endpoint that no node in the payload matches is accepted by the ingest endpoint and then silently produces nothing. Every problem is reported, not just the first, so one round trip is enough to fix them all.

package main

import (
	"fmt"

	"github.com/saluc28/bhgraph"
)

func main() {
	g := bhgraph.Graph{
		Nodes: []bhgraph.Node{
			{ID: "alice", Kinds: []string{"EX_Person"}},
			{ID: "", Kinds: []string{"EX_Repo"}},
		},
		Edges: []bhgraph.Edge{
			{Kind: "EX_CanWrite", Start: bhgraph.NodeRef("alice"), End: bhgraph.NodeRef("ghost")},
		},
	}

	fmt.Println(g.Validate())
}
Output:
bhgraph: 2 validation problems:
  - node 1: empty id
  - edge 0 (EX_CanWrite): end "ghost" is not a node in this payload

func (Graph) ValidateAgainst

func (g Graph) ValidateAgainst(ext Extension) error

ValidateAgainst checks the graph against a schema, on top of the structural checks Validate performs.

Every kind used by a node or an edge must be declared. An undeclared kind is accepted by the ingest endpoint and then does not appear in the UI as expected, which is a slow and confusing way to find a typo.

Example

A kind the schema does not declare is accepted by the ingest endpoint and then fails to appear in the UI, with nothing to say why. Checking against the schema turns that into an error before anything is sent.

package main

import (
	"fmt"

	"github.com/saluc28/bhgraph"
)

func main() {
	schema := bhgraph.Extension{
		Schema:            bhgraph.SchemaMeta{Name: "example", Version: "v0.0.1", Namespace: "EX"},
		NodeKinds:         []bhgraph.NodeKind{{Name: "EX_Person", IsDisplayKind: true}},
		RelationshipKinds: []bhgraph.RelationshipKind{{Name: "EX_CanWrite", IsTraversable: true}},
	}

	g := bhgraph.Graph{
		Nodes: []bhgraph.Node{{ID: "alice", Kinds: []string{"EX_Persno"}}}, // typo
	}

	fmt.Println(g.ValidateAgainst(schema))
}
Output:
bhgraph: node "alice": kind "EX_Persno" is not declared in the schema

func (Graph) WriteTo

func (g Graph) WriteTo(w io.Writer) (int64, error)

WriteTo streams the graph to w without holding the whole serialized payload in memory. Nodes and edges are encoded one at a time, so the peak allocation is one element rather than the entire graph. The document is assembled by hand for that reason: encoding it as a single value would defeat the point.

It reports the number of bytes written, satisfying io.WriterTo.

type MatchStrategy

type MatchStrategy string

MatchStrategy tells BloodHound how to resolve an edge endpoint to a node.

const (
	MatchByID   MatchStrategy = "id"
	MatchByName MatchStrategy = "name"
)

Strategies for resolving an edge endpoint. MatchByID is the fastest, and is what a zero MatchStrategy means.

type Node

type Node struct {
	ID         string         `json:"id"`
	Kinds      []string       `json:"kinds"`
	Properties map[string]any `json:"properties,omitempty"`
}

Node is a single vertex of the graph.

Kinds carries the node's types, which must be declared in the extension schema when one is installed. The first kind is conventionally the display kind. Properties are free-form; BloodHound gives special meaning to "name" and "displayname" in its UI.

ID becomes the node's objectid, and BloodHound uppercases it on ingest. See UppercaseIDs, which keeps the id you hold and the id the server stores from drifting apart.

type NodeKind

type NodeKind struct {
	Name          string `json:"name"`
	DisplayName   string `json:"display_name"`
	Description   string `json:"description"`
	IsDisplayKind bool   `json:"is_display_kind"`
	Icon          string `json:"icon"`
	Color         string `json:"color"`
}

NodeKind declares one node type and how the UI should draw it.

Icon takes a Font Awesome free solid icon name. Declaring Icon and Color here makes POST /api/v2/custom-nodes redundant.

type Ref

type Ref struct {
	Value   string        `json:"value"`
	MatchBy MatchStrategy `json:"match_by"`
}

Ref points at one endpoint of an edge.

A zero MatchBy is serialized as MatchByID, which is what callers almost always want and what BloodHound resolves fastest.

func NodeRef

func NodeRef(id string) Ref

NodeRef returns a Ref matching a node by its ID.

type RelationshipKind

type RelationshipKind struct {
	Name          string `json:"name"`
	Description   string `json:"description"`
	IsTraversable bool   `json:"is_traversable"`
}

RelationshipKind declares one edge type.

IsTraversable governs pathfinding and Attack Path detection. Marking an edge traversable when it does not represent a capability produces false paths in the UI, so it is a semantic decision rather than a cosmetic one.

type SchemaMeta

type SchemaMeta struct {
	Name        string `json:"name"`
	DisplayName string `json:"display_name"`
	Version     string `json:"version"`
	Namespace   string `json:"namespace"`
}

SchemaMeta identifies the extension.

Namespace is the prefix every kind name is expected to carry, following the convention SpecterOps uses in its own collectors (MSSQL_Database, MSSQL_AddMember). Validate enforces it.

type ValidationError

type ValidationError struct {
	Problems []string
}

ValidationError collects everything wrong with a graph or a schema, rather than stopping at the first problem.

A failed ingest job reports errors that are poor and asynchronous: the upload is accepted, and what went wrong surfaces later, if at all. Catching problems before sending is the reason this library exists rather than writing the JSON by hand.

func (*ValidationError) Error

func (v *ValidationError) Error() string

Directories

Path Synopsis
Package client signs and sends requests to a BloodHound CE instance.
Package client signs and sends requests to a BloodHound CE instance.
examples
minimal command
Command minimal builds a small OpenGraph payload and writes it to disk.
Command minimal builds a small OpenGraph payload and writes it to disk.
with-extension command
Command with-extension installs an extension definition schema, ingests a graph, and then proves that the schema took effect by asking BloodHound for a path.
Command with-extension installs an extension definition schema, ingests a graph, and then proves that the schema took effect by asking BloodHound for a path.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL