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 ¶
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 ¶
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 ¶
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) MarshalJSON ¶
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 ¶
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 ¶
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 ¶
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.
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. |