Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,21 @@ export BLOODHOUND_TOKEN_KEY=<token-key>
--bloodhound-url https://bloodhound.contoso.com \
--token-id <id> --token-key <key> \
--upload-schema-only --skip-collection

# Install the bundled MSSQL Cypher saved queries (existing names PUT-updated, new ones POST-created)
./mssqlhound \
-B '<token-id>:<token-key>@https://bloodhound.contoso.com' \
--upload-queries-only

# Install bundled queries plus an operator's own JSON files from a directory (additive; same names override bundled)
./mssqlhound \
-B '<token-id>:<token-key>@https://bloodhound.contoso.com' \
--upload-queries-only --queries-dir /opt/engagements/cypher

# Collect, upload schema + results, but suppress saved-query upload
./mssqlhound -t 'sa:password@sql.contoso.com' \
-B '<token-id>:<token-key>@https://bloodhound.contoso.com' \
--no-upload-queries
```

### Possible Edge Options
Expand Down Expand Up @@ -692,13 +707,17 @@ mssqlhound completion powershell | Out-String | Invoke-Expression

| Flag | Env Var | Description |
|------|---------|-------------|
| `-B, --bloodhound` | | Upload to BloodHound CE: `<token-id>:<token-key>@<bloodhound_url>` (uploads schema + results) |
| `-B, --bloodhound` | | Upload to BloodHound CE: `<token-id>:<token-key>@<bloodhound_url>` (uploads schema + results + bundled saved queries) |
| `--bloodhound-url` | `BLOODHOUND_URL` | BloodHound CE instance URL |
| `--token-id` | `BLOODHOUND_TOKEN_ID` | BloodHound API token ID |
| `--token-key` | `BLOODHOUND_TOKEN_KEY` | BloodHound API token key |
| `--upload-schema-only` | | Only upload schema definitions to BloodHound (skip results upload) |
| `--upload-results-only` | | Only upload collection results to BloodHound (skip schema upload) |
| `--skip-collection` | | Skip data collection (use with schema upload or upload-only workflows) |
| `--upload-schema-only` | | Only upload schema definitions to BloodHound (skip results and queries) |
| `--upload-results-only` | | Only upload collection results to BloodHound (skip schema and queries) |
| `--upload-queries-only` | | Only upload bundled saved Cypher queries (implies `--skip-collection`) |
| `--upload-queries` | | Force-upload bundled saved Cypher queries (default when `-B` is set without an `--upload-*-only` flag) |
| `--no-upload-queries` | | Suppress saved-query upload that would otherwise happen via `-B` |
| `--queries-dir` | | Additional directory of saved-query JSON files to upload alongside bundled queries (additive; matching names override bundled) |
| `--skip-collection` | | Skip data collection (use with schema, results, or query upload-only workflows) |

### Diagnostics

Expand Down
66 changes: 52 additions & 14 deletions cmd/mssqlhound/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ var (
tokenKey string
uploadSchemaOnly bool
uploadResultsOnly bool
uploadQueriesOnly bool
uploadQueries bool
noUploadQueries bool
queriesDir string
skipCollection bool
)

Expand Down Expand Up @@ -145,9 +149,13 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
rootCmd.Flags().StringVar(&bloodhoundURL, "bloodhound-url", "", "BloodHound CE instance URL, uses local DNS (env: BLOODHOUND_URL)")
rootCmd.Flags().StringVar(&tokenID, "token-id", "", "BloodHound API token ID (env: BLOODHOUND_TOKEN_ID)")
rootCmd.Flags().StringVar(&tokenKey, "token-key", "", "BloodHound API token key (env: BLOODHOUND_TOKEN_KEY)")
rootCmd.Flags().BoolVar(&uploadSchemaOnly, "upload-schema-only", false, "Only upload schema definitions to BloodHound (skip results upload)")
rootCmd.Flags().BoolVar(&uploadResultsOnly, "upload-results-only", false, "Only upload collection results to BloodHound (skip schema upload)")
rootCmd.Flags().BoolVar(&skipCollection, "skip-collection", false, "Skip data collection (use with -B to only upload schema)")
rootCmd.Flags().BoolVar(&uploadSchemaOnly, "upload-schema-only", false, "Only upload schema definitions to BloodHound (skip results and queries)")
rootCmd.Flags().BoolVar(&uploadResultsOnly, "upload-results-only", false, "Only upload collection results to BloodHound (skip schema and queries)")
rootCmd.Flags().BoolVar(&uploadQueriesOnly, "upload-queries-only", false, "Only upload bundled saved Cypher queries (implies --skip-collection)")
rootCmd.Flags().BoolVar(&uploadQueries, "upload-queries", false, "Upload bundled saved Cypher queries (default when -B is set without --upload-*-only)")
rootCmd.Flags().BoolVar(&noUploadQueries, "no-upload-queries", false, "Suppress saved-query upload that would otherwise happen via -B")
rootCmd.Flags().StringVar(&queriesDir, "queries-dir", "", "Additional directory of saved-query JSON files to upload alongside bundled queries")
rootCmd.Flags().BoolVar(&skipCollection, "skip-collection", false, "Skip data collection (use with -B to only upload schema/queries)")

// Annotate flags with display groups for --help output
for _, name := range []string{"user", "password", "nt-hash", "ldap-user", "ldap-password",
Expand All @@ -157,19 +165,19 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int
for _, name := range []string{"targets", "domain", "dc", "dns-resolver", "proxy"} {
rootCmd.PersistentFlags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck
}
for _, name := range []string{"scan-all-computers", "skip-private-address",
"domain-enum-only", "skip-linked-servers", "collect-from-linked",
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "skip-ip-dedupe"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck
}
for _, name := range []string{"scan-all-computers", "skip-private-address",
"domain-enum-only", "skip-linked-servers", "collect-from-linked",
"skip-ad-nodes", "disable-nontraversable-edges", "disable-possible-edges", "skip-ip-dedupe"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck
}
for _, name := range []string{"linked-timeout", "workers", "file-size-limit",
"memory-threshold", "size-update-interval"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"Performance"}) //nolint:errcheck
}
for _, name := range []string{"temp-dir", "zip-dir", "log-per-target"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"Output"}) //nolint:errcheck
}
for _, name := range []string{"bloodhound", "bloodhound-url", "token-id", "token-key", "upload-results-only", "upload-schema-only", "skip-collection"} {
for _, name := range []string{"bloodhound", "bloodhound-url", "token-id", "token-key", "upload-results-only", "upload-schema-only", "upload-queries-only", "upload-queries", "no-upload-queries", "queries-dir", "skip-collection"} {
rootCmd.Flags().SetAnnotation(name, "group", []string{"BloodHound Upload"}) //nolint:errcheck
}

Expand Down Expand Up @@ -381,17 +389,45 @@ func run(cmd *cobra.Command, args []string) error {
}

// Validate mutually exclusive upload-only flags
if uploadSchemaOnly && uploadResultsOnly {
return fmt.Errorf("--upload-schema-only and --upload-results-only are mutually exclusive")
onlyFlags := 0
if uploadSchemaOnly {
onlyFlags++
}
if uploadResultsOnly {
onlyFlags++
}
if uploadQueriesOnly {
onlyFlags++
}
if onlyFlags > 1 {
return fmt.Errorf("--upload-schema-only, --upload-results-only, and --upload-queries-only are mutually exclusive")
}
if uploadQueriesOnly && noUploadQueries {
return fmt.Errorf("--upload-queries-only and --no-upload-queries are mutually exclusive")
}

// Determine what to upload: default is both schema and results
// Determine what to upload. Default (when -B is set without any
// --upload-*-only flag) is schema + results + bundled queries.
uploadSchema := true
uploadResults := true
if uploadSchemaOnly {
uploadQueriesEffective := true
switch {
case uploadSchemaOnly:
uploadResults = false
} else if uploadResultsOnly {
uploadQueriesEffective = false
case uploadResultsOnly:
uploadSchema = false
uploadQueriesEffective = false
case uploadQueriesOnly:
uploadSchema = false
uploadResults = false
skipCollection = true // queries-only never collects
}
if noUploadQueries {
uploadQueriesEffective = false
}
if uploadQueries {
uploadQueriesEffective = true
}

// Build configuration from flags
Expand Down Expand Up @@ -438,6 +474,8 @@ func run(cmd *cobra.Command, args []string) error {
TokenKey: tokenKey,
UploadSchema: uploadSchema,
UploadResults: uploadResults,
UploadQueries: uploadQueriesEffective,
QueriesDir: queriesDir,
SkipCollection: skipCollection,
}

Expand Down
71 changes: 61 additions & 10 deletions internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"archive/zip"
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
Expand Down Expand Up @@ -88,6 +89,8 @@ type Config struct {
TokenKey string // API token key
UploadSchema bool // Upload schema definitions (SCHEMA.json)
UploadResults bool // Upload results after collection
UploadQueries bool // Upload bundled + --queries-dir saved Cypher queries
QueriesDir string // Optional directory of additional saved-query JSON files
SkipCollection bool // Skip data collection (upload-only mode)
}

Expand Down Expand Up @@ -363,7 +366,7 @@ func (c *Collector) Run() error {
}

// Upload to BloodHound CE if configured
if c.config.BloodHoundURL != "" && (c.config.UploadSchema || (c.config.UploadResults && zipPath != "")) {
if c.config.BloodHoundURL != "" && (c.config.UploadSchema || c.config.UploadQueries || (c.config.UploadResults && zipPath != "")) {
if err := c.uploadToBloodHound(zipPath); err != nil {
c.config.Logger.Warn("BloodHound upload failed", "error", err)
}
Expand Down Expand Up @@ -6360,7 +6363,11 @@ func (c *Collector) writeADFiles() error {
return nil
}

// uploadToBloodHound uploads the results zip (and optionally the schema) to BloodHound CE.
// uploadToBloodHound uploads the results zip, schema, and saved queries to
// BloodHound CE. Each phase runs independently — a failure in one (e.g. an
// older BH deployment that doesn't expose /api/v2/extensions) does not
// prevent the other phases from running. Phase errors are aggregated and
// returned together at the end.
func (c *Collector) uploadToBloodHound(zipPath string) error {
u := uploader.NewUploader(c.config.BloodHoundURL, c.config.TokenID, c.config.TokenKey, c.config.Logger)
if u == nil {
Expand All @@ -6370,34 +6377,78 @@ func (c *Collector) uploadToBloodHound(zipPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()

var phaseErrs []error

// Upload schema if requested — registers custom node/edge kinds with
// BloodHound CE via PUT /api/v2/extensions.
// BloodHound CE via PUT /api/v2/extensions. Older BH CE deployments may
// return 404 for this endpoint; we log and continue rather than aborting.
if c.config.UploadSchema {
c.config.Logger.Info("Uploading schema to BloodHound...")
schemaData := bloodhound.SchemaJSON
schemaReady := true
if c.config.DisablePossibleEdges {
modified, err := bloodhound.SchemaJSONWithDisabledPossibleEdges()
if err != nil {
return fmt.Errorf("failed to modify schema for disabled possible edges: %w", err)
phaseErrs = append(phaseErrs, fmt.Errorf("schema (modify for disabled possible edges): %w", err))
c.config.Logger.Warn("Schema modification failed; skipping schema upload", "error", err)
schemaReady = false
} else {
schemaData = modified
}
schemaData = modified
}
c.config.Logger.Debug("Schema PUT body", "body", string(schemaData))
if err := u.Client.UploadSchema(ctx, schemaData); err != nil {
return fmt.Errorf("schema upload failed: %w", err)
if schemaReady {
c.config.Logger.Debug("Schema PUT body", "body", string(schemaData))
if err := u.Client.UploadSchema(ctx, schemaData); err != nil {
phaseErrs = append(phaseErrs, fmt.Errorf("schema: %w", err))
c.config.Logger.Warn("Schema upload failed; continuing with remaining phases", "error", err)
} else {
c.config.Logger.Info("Schema uploaded successfully")
}
}
c.config.Logger.Info("Schema uploaded successfully")
}

// Upload results zip
if c.config.UploadResults && zipPath != "" {
c.config.Logger.Info("Uploading results to BloodHound...", "file", zipPath)
summary := u.UploadFiles(ctx, []string{zipPath})
if summary.FilesFailed > 0 {
return fmt.Errorf("results upload failed")
phaseErrs = append(phaseErrs, fmt.Errorf("results: %d file(s) failed", summary.FilesFailed))
c.config.Logger.Warn("Results upload had failures; continuing with remaining phases",
"failed", summary.FilesFailed)
}
}

// Upload saved Cypher queries (bundled + --queries-dir, additive).
if c.config.UploadQueries {
bundled, err := uploader.LoadEmbeddedQueries()
if err != nil {
phaseErrs = append(phaseErrs, fmt.Errorf("queries (load bundled): %w", err))
c.config.Logger.Warn("Failed to load bundled saved queries", "error", err)
} else {
var custom []uploader.SavedQuery
if c.config.QueriesDir != "" {
custom, err = uploader.LoadQueriesFromDir(c.config.QueriesDir, c.config.Logger)
if err != nil {
phaseErrs = append(phaseErrs, fmt.Errorf("queries (load %s): %w", c.config.QueriesDir, err))
c.config.Logger.Warn("Failed to load custom saved queries", "dir", c.config.QueriesDir, "error", err)
custom = nil
}
}
queries := uploader.MergeQueries(bundled, custom)
c.config.Logger.Info("Uploading saved queries to BloodHound...",
"bundled", len(bundled), "custom", len(custom), "total", len(queries))
qSummary := u.UploadSavedQueries(ctx, queries)
c.config.Logger.Info("Saved queries upload complete",
"created", qSummary.Created, "updated", qSummary.Updated, "failed", qSummary.Failed)
if qSummary.Failed > 0 {
phaseErrs = append(phaseErrs, fmt.Errorf("queries: %d failure(s)", qSummary.Failed))
}
}
}

if len(phaseErrs) > 0 {
return errors.Join(phaseErrs...)
}
c.config.Logger.Info("BloodHound upload complete")
return nil
}
Expand Down
Loading