Documentation
¶
Overview ¶
Package client signs and sends requests to a BloodHound CE instance.
It covers only what a collector needs: installing an extension schema, running an ingest job, and issuing Cypher queries. Wrapping the whole BloodHound API would age badly with every release.
Index ¶
- Constants
- func Sign(tokenKey, method, uri string, at time.Time, body io.Reader) (string, error)
- type APIError
- type BodySigner
- type Client
- func (c *Client) Cypher(ctx context.Context, query string) ([]byte, error)
- func (c *Client) DeleteExtension(ctx context.Context, id string) error
- func (c *Client) EndJob(ctx context.Context, id JobID) error
- func (c *Client) FeatureEnabled(ctx context.Context, key string) (bool, error)
- func (c *Client) Features(ctx context.Context) (map[string]Feature, error)
- func (c *Client) Get(ctx context.Context, path string) ([]byte, error)
- func (c *Client) Ingest(ctx context.Context, g bhgraph.Graph) (JobID, error)
- func (c *Client) InstallExtension(ctx context.Context, e bhgraph.Extension) error
- func (c *Client) JobStatus(ctx context.Context, id JobID) ([]byte, error)
- func (c *Client) ListExtensions(ctx context.Context) ([]byte, error)
- func (c *Client) Post(ctx context.Context, path string, body []byte) ([]byte, error)
- func (c *Client) Put(ctx context.Context, path string, body []byte) ([]byte, error)
- func (c *Client) ShortestPath(ctx context.Context, startID, endID string, onlyTraversable bool) ([]byte, error)
- func (c *Client) StartJob(ctx context.Context) (JobID, error)
- func (c *Client) UploadGraph(ctx context.Context, id JobID, g bhgraph.Graph) error
- type Feature
- type JobID
- type Option
- type SignError
Examples ¶
Constants ¶
const ( HeaderAuthorization = "Authorization" HeaderRequestDate = "RequestDate" HeaderSignature = "Signature" )
Header names required on every signed request.
const AuthorizationScheme = "bhesignature"
Authorization scheme used by the Authorization header.
const DefaultSpoolThreshold = 8 << 20 // 8 MiB
DefaultSpoolThreshold is the payload size above which an ingest is spooled to a temporary file instead of being held in memory.
Below it, a buffer is cheaper than a file and the allocation is trivial. Above it, the buffer is the thing that would decide how large a graph the caller can ingest, which is not a limit a library should impose.
const FeatureFlagExtensions = "opengraph_extension_management"
FeatureFlagExtensions is the BloodHound feature flag that governs /api/v2/extensions.
It is OFF by default, verified on CE v9.5.1. With it off the route is not registered at all, so requests come back 404 "resource not found" rather than with anything pointing at the cause. Being tagged Community means the endpoint is available in CE, not that it is enabled.
Turn it on in the UI under Administration, then Early Access Features, which is where BloodHound's extension management documentation points, or with PUT /api/v2/features/{id}/toggle after finding the id in GET /api/v2/features.
const FeatureFlagRawObjectIDs = "use_raw_object_id"
FeatureFlagRawObjectIDs governs whether ingest keeps object ids in the case they were sent in.
It is off by default and not user updatable on CE v9.5.1, so ingest uppercases object ids and a lower case id asked for later is not found. See bhgraph.Graph.UppercaseIDs.
Variables ¶
This section is empty.
Functions ¶
func Sign ¶
Sign computes the BloodHound request signature.
The scheme is a chain of three HMAC-SHA-256 digests, each one keyed by the digest before it:
- method and URI concatenated with no delimiter, keyed by the token key
- the RFC3339 datetime truncated to the hour, keyed by digest 1
- the request body, keyed by digest 2
The result is base64-encoded for the Signature header.
Two details are easy to get wrong and are covered by the vectors in sign_test.go. The truncation is a slice of the first 13 bytes of the formatted string, not a parse-and-reformat: "2026-08-03T14:31:07Z" becomes "2026-08-03T14". And the concatenation in step 1 has no separator, so the signature does not record where the method ends and the URI begins.
uri must be the full request target, path and query string, which is what Go exposes as URL.RequestURI() rather than URL.Path.
BloodHound's own code disagrees with itself here, verified against CE v9.5.1 on 2026-08-03. The server validates with request.RequestURI (cmd/api/src/api/auth.go), which includes the query. The client helper it ships signs request.URL.Path (cmd/api/src/api/signature.go), which does not. For requests without a query the two are identical and nothing shows; add one query parameter and 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. We follow the server, and the mismatch is reported as SpecterOps/BloodHound#3098.
A nil body and an empty body produce the same signature: the third digest is computed either way, with nothing written to it.
Signatures are time-sensitive; BloodHound accepts them for at most two hours.
Example ¶
Sign is exported so that a request this library does not wrap can still be signed. The uri must be the full request target, path and query: the server validates against request.RequestURI, so signing the path alone answers 401 as soon as a request carries a parameter.
The datetime is truncated to the hour, so every signature in the same hour over the same request is identical.
package main
import (
"fmt"
"log"
"time"
"github.com/saluc28/bhgraph/client"
)
func main() {
at := time.Date(2026, 8, 3, 14, 31, 7, 0, time.UTC)
sig, err := client.Sign("bhgraph-test-key", "GET", "/api/v2/extensions", at, nil)
if err != nil {
log.Fatal(err)
}
fmt.Println(sig)
}
Output: znoFJpYtxzcwG9oy54kD+jUgMr+aKbLMiy8g8LBYlRI=
Types ¶
type BodySigner ¶
type BodySigner struct {
// contains filtered or unexported fields
}
BodySigner computes a request signature while the body is being produced, instead of after it exists.
The first two links of the chain, the operation key and the date key, depend only on the method, the URI and the timestamp, so they can be computed up front. The third link is keyed by the second and consumes the body, which means the body can be streamed into it as it is generated.
This is what lets an ingest keep memory flat: serialize the graph once into an io.MultiWriter that feeds both the outgoing payload and the signer, rather than building the whole document in memory so it can be hashed and then sent.
A BodySigner is single-use. Writing nothing is valid and produces the signature of an empty body.
func NewBodySigner ¶
func NewBodySigner(tokenKey, method, uri string, at time.Time) (*BodySigner, error)
NewBodySigner starts a signature for the given request, ready to accept the body through Write.
uri must be the full request target, path and query. See Sign.
func (*BodySigner) Signature ¶
func (s *BodySigner) Signature() string
Signature returns the base64-encoded signature for everything written so far.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client talks to a BloodHound CE instance, signing every request with the bhesignature scheme.
A Client is safe for concurrent use if the underlying http.Client is.
func New ¶
New returns a Client for the BloodHound instance at baseURL.
tokenID and tokenKey come from a BloodHound API token: Settings, My Profile, API Key Management. The key is shown once at creation.
Example ¶
The API uses signed requests rather than bearer tokens. Get the token id and key from Settings, then My Profile, then API Key Management; the key is shown only once, at creation.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New("http://127.0.0.1:8080", os.Getenv("BH_TOKEN_ID"), os.Getenv("BH_TOKEN_KEY"))
if err != nil {
log.Fatal(err)
}
// Every call takes a context and hands back the raw JSON body.
body, err := c.ListExtensions(context.Background())
if err != nil {
log.Fatal(err)
}
fmt.Println(len(body), "bytes of installed schemas")
}
Output:
func (*Client) Cypher ¶
Cypher runs a read-only Cypher query and returns the raw JSON response.
This is how you check that an ingest produced what you expected, and how you confirm that traversable edges actually connect: a path that the UI finds is a path this query finds too.
func (*Client) DeleteExtension ¶
DeleteExtension removes an installed extension schema by id.
func (*Client) FeatureEnabled ¶ added in v0.2.0
FeatureEnabled reports whether the named flag is on.
A key this instance does not report is an error rather than false. A missing flag usually means a typo or a BloodHound older than the flag, and answering false would be indistinguishable from a flag that is genuinely switched off.
Each call reads the whole set from the server. Nothing is cached, because a flag can be toggled while a collector runs and a stale answer here is worse than a second request. Call Features once if you need several.
Example ¶
Two feature flags decide whether the rest of this library behaves the way its documentation says, and both are worth checking before blaming the payload. With opengraph_extension_management off, a shortest path across your own edges comes back empty even when the graph is right, because the server answers from the built-in AD and Azure kinds and never looks at an installed schema.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New("http://127.0.0.1:8080", os.Getenv("BH_TOKEN_ID"), os.Getenv("BH_TOKEN_KEY"))
if err != nil {
log.Fatal(err)
}
on, err := c.FeatureEnabled(context.Background(), client.FeatureFlagExtensions)
if err != nil {
log.Fatal(err)
}
if !on {
log.Fatalf("turn %s on under Administration, then Early Access Features", client.FeatureFlagExtensions)
}
fmt.Println("pathfinding will consider the edges of an installed schema")
}
Output:
func (*Client) Features ¶ added in v0.2.0
Features returns the feature flags the instance reports, keyed by flag key.
Two of them decide whether the rest of this library behaves the way its documentation says. FeatureFlagExtensions governs whether /api/v2/extensions is routed at all, and it also governs whether pathfinding considers the edges of an installed schema: with it off the server answers a shortest path query from the built-in AD and Azure kinds only. FeatureFlagRawObjectIDs governs whether ingest uppercases object ids.
Reading flags needs a token whose role can read the application configuration. A 403 here means the token is too narrow, not that the flags are absent.
func (*Client) Get ¶
Get issues a signed GET against an arbitrary path and returns the raw body.
Get, Post and Put are exported because this library deliberately does not wrap the whole BloodHound API: a caller who needs an endpoint we do not model should not have to reimplement the signature to reach it.
func (*Client) Ingest ¶
Ingest uploads a graph and closes the job, returning its id.
Ingest is three calls, and all three matter:
- POST /api/v2/file-upload/start creates the job
- POST /api/v2/file-upload/{id} uploads the payload
- POST /api/v2/file-upload/{id}/end closes it and starts processing
Skipping the third leaves the job open and nothing is processed, which looks exactly like a successful upload that quietly did nothing.
The graph is validated before the first call. If an extension schema is available, prefer validating with ValidateAgainst beforehand: an undeclared kind is accepted here and then fails to appear in the UI.
Example ¶
Ingest is three calls, and all three matter: skipping the last one leaves the job open and nothing is processed, which looks exactly like an upload that succeeded and quietly did nothing.
Processing is asynchronous. A successful Ingest means the payload was accepted, not that the graph is queryable yet.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/saluc28/bhgraph"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New("http://127.0.0.1:8080", os.Getenv("BH_TOKEN_ID"), os.Getenv("BH_TOKEN_KEY"))
if err != nil {
log.Fatal(err)
}
g := bhgraph.Graph{}
g.AddNode(bhgraph.Node{ID: "alice", Kinds: []string{"EX_Person"}})
g.AddNode(bhgraph.Node{ID: "infra", Kinds: []string{"EX_Repo"}})
g.AddEdge(bhgraph.Edge{
Kind: "EX_CanWrite",
Start: bhgraph.NodeRef("alice"),
End: bhgraph.NodeRef("infra"),
})
g.UppercaseIDs()
jobID, err := c.Ingest(context.Background(), g)
if err != nil {
log.Fatal(err)
}
fmt.Printf("job %d accepted\n", jobID)
}
Output:
func (*Client) InstallExtension ¶
InstallExtension installs or updates an extension definition schema.
The verb is PUT, not POST: the operation is an upsert. Installing a schema is what turns a generic graph into a structured one, and structured graphs are the only ones whose edges take part in the UI's pathfinding.
The extension is validated locally first. Sending a schema BloodHound rejects produces errors that are harder to read than the ones Validate gives.
Example ¶
Installing a schema is what turns a generic graph into a structured one, and only structured graphs take part in the UI's pathfinding.
The endpoint sits behind the opengraph_extension_management feature flag, which is off by default: with it off the route is not registered at all and the answer is a bare 404. This library says so in the error.
package main
import (
"context"
"log"
"os"
"github.com/saluc28/bhgraph"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New("http://127.0.0.1:8080", os.Getenv("BH_TOKEN_ID"), os.Getenv("BH_TOKEN_KEY"))
if err != nil {
log.Fatal(err)
}
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},
},
}
if err := c.InstallExtension(context.Background(), schema); err != nil {
log.Fatal(err)
}
}
Output:
func (*Client) JobStatus ¶
JobStatus returns the raw JSON of completed ingest tasks.
Processing is asynchronous: a successful Ingest means the payload was accepted, not that the graph is queryable yet.
func (*Client) ListExtensions ¶
ListExtensions returns the raw JSON of the installed extension schemas.
The response shape is not modelled on purpose: the endpoint is experimental, and pinning a struct to it would break on a field rename that callers may not even care about.
func (*Client) Post ¶
Post issues a signed POST. A nil body sends no content, which is what several BloodHound endpoints expect.
func (*Client) ShortestPath ¶
func (c *Client) ShortestPath(ctx context.Context, startID, endID string, onlyTraversable bool) ([]byte, error)
ShortestPath returns the shortest path graph between two nodes, identified by their object ids.
The ids are the ones from the ingested payload, UPPERCASED, because BloodHound uppercases them on ingest. Sending one in its original case answers 500 "not found". See bhgraph.Graph.UppercaseIDs.
onlyTraversable is the reason this method is here in a library that otherwise does not wrap the BloodHound API. A schema is easy to install and hard to verify: with onlyTraversable set BloodHound walks only the kinds the schema marked traversable, which is the same search the UI performs, and that is what separates "I declared the edge traversable" from "the edge is traversable".
An empty result is a normal answer, not an error: it means no path exists under the constraints given. It can also mean FeatureFlagExtensions is off, because the server then answers from the built-in AD and Azure kinds and never looks at the edges of an installed schema. Client.FeatureEnabled separates a graph with no path from an instance that cannot see your edges.
Example ¶
A schema is easy to install and hard to verify. This is the call that tells "I declared the edge traversable" apart from "the edge is traversable": with onlyTraversable set, BloodHound walks only the kinds the schema marked traversable, which is the same search the UI performs.
The ids are the ones from the payload, uppercased, because BloodHound uppercases them on ingest.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New("http://127.0.0.1:8080", os.Getenv("BH_TOKEN_ID"), os.Getenv("BH_TOKEN_KEY"))
if err != nil {
log.Fatal(err)
}
// An empty result is a normal answer: it means no path exists under the
// constraints given, which for a non-traversable edge is the point.
path, err := c.ShortestPath(context.Background(), "ALICE", "INFRA", true)
if err != nil {
log.Fatal(err)
}
fmt.Println(len(path), "bytes of path graph")
}
Output:
func (*Client) UploadGraph ¶
UploadGraph sends the payload for an open job.
Content-Type must be application/json (or application/zip); BloodHound rejects the upload without it. X-File-Upload-Name is optional but makes server-side errors readable, which is worth the one header.
The graph is serialized exactly once, into an io.MultiWriter feeding both a spool and a BodySigner. That is what keeps memory flat: the signature covers the body, so the body must exist in full before the request goes out, but "in full" can mean a temporary file rather than a buffer. Payloads under WithSpoolThreshold never touch disk.
Without this, Graph.WriteTo would stream while the upload consuming it buffered everything, which would undo the streaming one call later.
type Feature ¶ added in v0.2.0
type Feature struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
UserUpdatable bool `json:"user_updatable"`
}
Feature is one BloodHound feature flag.
The identifier and timestamps the endpoint also returns are left out: the id is only useful for the toggle endpoint, which this library does not call because turning a flag on is an operator's decision and not a collector's.
type Option ¶
type Option func(*Client)
Option configures a Client.
func WithHTTPClient ¶
WithHTTPClient supplies the http.Client used for all requests. Use it to set timeouts, proxies, or a custom TLS configuration.
func WithSpoolThreshold ¶
WithSpoolThreshold sets the payload size in bytes above which an ingest is written to a temporary file rather than held in memory. Zero selects DefaultSpoolThreshold.
The signature covers the request body, so the body must be fully available before the request goes out. This is the knob that decides whether "fully available" means in RAM or on disk.
Example ¶
WithSpoolThreshold decides where a payload waits while it is signed. The signature covers the body, so the body has to be readable in full before the request goes out; below the threshold that means memory, above it a temporary file that is removed when the upload finishes.
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/saluc28/bhgraph"
"github.com/saluc28/bhgraph/client"
)
func main() {
c, err := client.New(
"http://127.0.0.1:8080",
os.Getenv("BH_TOKEN_ID"),
os.Getenv("BH_TOKEN_KEY"),
client.WithSpoolThreshold(1<<20), // spill above 1 MiB instead of the default 8
)
if err != nil {
log.Fatal(err)
}
// Ingest is the only call it affects: the others have no body worth
// spooling.
jobID, err := c.Ingest(context.Background(), collectGraph())
if err != nil {
log.Fatal(err)
}
fmt.Printf("job %d accepted\n", jobID)
}
// collectGraph stands in for whatever a collector builds.
func collectGraph() bhgraph.Graph {
g := bhgraph.Graph{}
g.AddNode(bhgraph.Node{ID: "ALICE", Kinds: []string{"EX_Person"}})
return g
}
Output: