diff --git a/README.md b/README.md index 9c1b88e..ba269fa 100644 --- a/README.md +++ b/README.md @@ -572,6 +572,21 @@ export BLOODHOUND_TOKEN_KEY= --bloodhound-url https://bloodhound.contoso.com \ --token-id --token-key \ --upload-schema-only --skip-collection + +# Install the bundled MSSQL Cypher saved queries (existing names PUT-updated, new ones POST-created) +./mssqlhound \ + -B ':@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 ':@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 ':@https://bloodhound.contoso.com' \ + --no-upload-queries ``` ### Possible Edge Options @@ -692,13 +707,17 @@ mssqlhound completion powershell | Out-String | Invoke-Expression | Flag | Env Var | Description | |------|---------|-------------| -| `-B, --bloodhound` | | Upload to BloodHound CE: `:@` (uploads schema + results) | +| `-B, --bloodhound` | | Upload to BloodHound CE: `:@` (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 diff --git a/cmd/mssqlhound/main.go b/cmd/mssqlhound/main.go index 8c69d65..dffc65c 100644 --- a/cmd/mssqlhound/main.go +++ b/cmd/mssqlhound/main.go @@ -66,6 +66,10 @@ var ( tokenKey string uploadSchemaOnly bool uploadResultsOnly bool + uploadQueriesOnly bool + uploadQueries bool + noUploadQueries bool + queriesDir string skipCollection bool ) @@ -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", @@ -157,11 +165,11 @@ 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 @@ -169,7 +177,7 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int 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 } @@ -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 @@ -438,6 +474,8 @@ func run(cmd *cobra.Command, args []string) error { TokenKey: tokenKey, UploadSchema: uploadSchema, UploadResults: uploadResults, + UploadQueries: uploadQueriesEffective, + QueriesDir: queriesDir, SkipCollection: skipCollection, } diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 013787d..9c04935 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -5,6 +5,7 @@ import ( "archive/zip" "context" "encoding/hex" + "errors" "fmt" "io" "log/slog" @@ -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) } @@ -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) } @@ -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 { @@ -6370,23 +6377,34 @@ 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 @@ -6394,10 +6412,43 @@ func (c *Collector) uploadToBloodHound(zipPath string) error { 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 } diff --git a/internal/uploader/client.go b/internal/uploader/client.go index 6f47c54..9636cc6 100644 --- a/internal/uploader/client.go +++ b/internal/uploader/client.go @@ -30,6 +30,13 @@ const ( // extensionsPath is the BH CE API endpoint for custom schema/type definitions. extensionsPath = "/api/v2/extensions" + + // savedQueriesPath is the BH CE API endpoint for saved Cypher queries. + savedQueriesPath = "/api/v2/saved-queries" + // listSavedQueriesPageSize is the per-page limit used when paginating the + // saved-queries listing. 1000 keeps the paginate-loop short while staying + // well under any reasonable BH CE response budget. + listSavedQueriesPageSize = 1000 ) // Client communicates with the BloodHound CE file upload API. @@ -68,7 +75,7 @@ func NewClient(baseURL string, auth Authenticator) *Client { }).DialContext, } return &Client{ - BaseURL: strings.TrimRight(baseURL, "/"), + BaseURL: NormalizeBaseURL(baseURL), Auth: auth, HTTPClient: &http.Client{ Timeout: defaultHTTPTimeout, @@ -79,6 +86,21 @@ func NewClient(baseURL string, auth Authenticator) *Client { } } +// NormalizeBaseURL trims trailing slashes and a trailing "/ui" segment from +// baseURL. Users frequently paste the BloodHound CE URL straight from their +// browser address bar (e.g. "https://bh.example.com/ui"), but the API lives +// at "/api/v2/..." on the same origin — not under "/ui". Without this fix, +// the SPA's HTML fallback intercepts API calls and the JSON decode fails +// with "invalid character '<' looking for beginning of value", or worse, +// silently returns 200 OK on writes that never reached the API. +func NormalizeBaseURL(baseURL string) string { + s := strings.TrimRight(baseURL, "/") + if strings.HasSuffix(strings.ToLower(s), "/ui") { + s = s[:len(s)-len("/ui")] + } + return strings.TrimRight(s, "/") +} + // startUploadResponse is the JSON response from POST /api/v2/file-upload/start. type startUploadResponse struct { Data struct { @@ -231,6 +253,14 @@ func (c *Client) doRequest(ctx context.Context, method, path string, body []byte continue } + // Catch the SPA-fallback case: the server returned a successful HTML + // response, which means the request never reached the API. Do not + // retry — the URL is wrong, not transient. + if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(strings.ToLower(ct), "text/html") { + resp.Body.Close() + return nil, fmt.Errorf("BloodHound API returned text/html (HTTP %d) for %s — check that --bloodhound-url points at the API root, not the /ui SPA path", resp.StatusCode, path) + } + return resp, nil } @@ -247,6 +277,97 @@ func (c *Client) readError(resp *http.Response, operation string) error { return fmt.Errorf("%s failed (HTTP %d): %s", operation, resp.StatusCode, msg) } +// savedQueryListResponse is the JSON shape returned by GET /api/v2/saved-queries. +// Only the fields we need are decoded; BH CE returns more. +type savedQueryListResponse struct { + Data []struct { + ID int64 `json:"id"` + Name string `json:"name"` + } `json:"data"` +} + +// ListSavedQueries enumerates every saved query visible to the authenticated +// account, paginating through /api/v2/saved-queries until the server returns +// fewer rows than requested. The returned map is keyed by query Name (case- +// sensitive, as BH CE treats names) with the BH-assigned id as the value. +func (c *Client) ListSavedQueries(ctx context.Context) (map[string]int64, error) { + out := make(map[string]int64) + skip := 0 + for { + path := fmt.Sprintf("%s?skip=%d&limit=%d", savedQueriesPath, skip, listSavedQueriesPageSize) + resp, err := c.doRequest(ctx, http.MethodGet, path, nil, "application/json") + if err != nil { + return nil, fmt.Errorf("failed to list saved queries: %w", err) + } + + if resp.StatusCode != http.StatusOK { + err := c.readError(resp, "list saved queries") + resp.Body.Close() + return nil, err + } + + var page savedQueryListResponse + decodeErr := json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + if decodeErr != nil { + return nil, fmt.Errorf("failed to parse saved-query list response: %w", decodeErr) + } + + for _, q := range page.Data { + if q.Name != "" { + out[q.Name] = q.ID + } + } + + if len(page.Data) < listSavedQueriesPageSize { + return out, nil + } + skip += len(page.Data) + } +} + +// CreateSavedQuery POSTs a new saved query to /api/v2/saved-queries. Success +// is HTTP 201 (per the BH CE OpenAPI spec and the gophlare reference impl). +func (c *Client) CreateSavedQuery(ctx context.Context, q SavedQuery) error { + body, err := json.Marshal(q) + if err != nil { + return fmt.Errorf("marshal saved query %q: %w", q.Name, err) + } + + resp, err := c.doRequest(ctx, http.MethodPost, savedQueriesPath, body, "application/json") + if err != nil { + return fmt.Errorf("create saved query %q: %w", q.Name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return c.readError(resp, fmt.Sprintf("create saved query %q", q.Name)) + } + return nil +} + +// UpdateSavedQuery PUTs to /api/v2/saved-queries/{id} to overwrite an existing +// query in-place, preserving its id, ownership, and any sharing settings. +// Success is HTTP 200 or 204. +func (c *Client) UpdateSavedQuery(ctx context.Context, id int64, q SavedQuery) error { + body, err := json.Marshal(q) + if err != nil { + return fmt.Errorf("marshal saved query %q: %w", q.Name, err) + } + + path := fmt.Sprintf("%s/%d", savedQueriesPath, id) + resp, err := c.doRequest(ctx, http.MethodPut, path, body, "application/json") + if err != nil { + return fmt.Errorf("update saved query %q: %w", q.Name, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + return c.readError(resp, fmt.Sprintf("update saved query %q", q.Name)) + } + return nil +} + // truncate returns s truncated to maxLen characters with "..." appended if needed. func truncate(s string, maxLen int) string { s = strings.TrimSpace(s) diff --git a/internal/uploader/client_test.go b/internal/uploader/client_test.go index 0aeee90..2062510 100644 --- a/internal/uploader/client_test.go +++ b/internal/uploader/client_test.go @@ -2,6 +2,7 @@ package uploader import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -253,6 +254,218 @@ func TestClient_RetryOn429(t *testing.T) { } } +func TestClient_ListSavedQueries_Pagination(t *testing.T) { + // Two pages: page 1 returns 1000 (full page), page 2 returns 2. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/saved-queries" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + skip := r.URL.Query().Get("skip") + w.WriteHeader(http.StatusOK) + switch skip { + case "0": + data := make([]map[string]any, 0, 1000) + for i := range 1000 { + data = append(data, map[string]any{"id": i + 1, "name": fmt.Sprintf("Query%04d", i+1)}) + } + _ = json.NewEncoder(w).Encode(map[string]any{"data": data}) + case "1000": + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": 1001, "name": "QueryA"}, + {"id": 1002, "name": "QueryB"}, + }, + }) + default: + t.Errorf("unexpected skip=%s", skip) + } + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + got, err := c.ListSavedQueries(context.Background()) + if err != nil { + t.Fatalf("ListSavedQueries() error: %v", err) + } + if len(got) != 1002 { + t.Errorf("got %d queries, want 1002", len(got)) + } + if got["QueryA"] != 1001 { + t.Errorf("QueryA id = %d, want 1001", got["QueryA"]) + } + if got["QueryB"] != 1002 { + t.Errorf("QueryB id = %d, want 1002", got["QueryB"]) + } +} + +func TestClient_CreateSavedQuery_Success(t *testing.T) { + var receivedBody []byte + var receivedMethod, receivedAuth string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v2/saved-queries" { + t.Errorf("unexpected path: %s", r.URL.Path) + } + receivedMethod = r.Method + receivedAuth = r.Header.Get("Authorization") + receivedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + q := SavedQuery{Name: "Smoke", Query: "MATCH (n) RETURN n", Description: "doc"} + if err := c.CreateSavedQuery(context.Background(), q); err != nil { + t.Fatalf("CreateSavedQuery() error: %v", err) + } + + if receivedMethod != http.MethodPost { + t.Errorf("method = %s, want POST", receivedMethod) + } + if receivedAuth == "" { + t.Error("expected Authorization header to be set by Authenticator") + } + want := `{"name":"Smoke","query":"MATCH (n) RETURN n","description":"doc"}` + if string(receivedBody) != want { + t.Errorf("body =\n %s\nwant\n %s", receivedBody, want) + } +} + +func TestClient_CreateSavedQuery_NonCreatedFails(t *testing.T) { + // BH CE returns 200 instead of 201 — should be treated as failure to + // match the documented contract and the gophlare reference. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"data":{}}`) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + err := c.CreateSavedQuery(context.Background(), SavedQuery{Name: "X", Query: "MATCH (n) RETURN n"}) + if err == nil { + t.Fatal("expected error when server returns 200 instead of 201") + } + if !strings.Contains(err.Error(), "200") { + t.Errorf("error should mention HTTP 200: %v", err) + } +} + +func TestClient_UpdateSavedQuery_Success(t *testing.T) { + var receivedPath, receivedMethod string + var receivedBody []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedMethod = r.Method + receivedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + q := SavedQuery{Name: "Smoke", Query: "MATCH (n) RETURN n LIMIT 1", Description: "v2"} + if err := c.UpdateSavedQuery(context.Background(), 42, q); err != nil { + t.Fatalf("UpdateSavedQuery() error: %v", err) + } + + if receivedMethod != http.MethodPut { + t.Errorf("method = %s, want PUT", receivedMethod) + } + if receivedPath != "/api/v2/saved-queries/42" { + t.Errorf("path = %s, want /api/v2/saved-queries/42", receivedPath) + } + if !strings.Contains(string(receivedBody), `"description":"v2"`) { + t.Errorf("body missing description: %s", receivedBody) + } +} + +func TestClient_UpdateSavedQuery_NoContent(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + q := SavedQuery{Name: "X", Query: "MATCH (n) RETURN n"} + if err := c.UpdateSavedQuery(context.Background(), 7, q); err != nil { + t.Fatalf("expected 204 to be accepted, got %v", err) + } +} + +func TestClient_ListSavedQueries_ErrorBubblesUp(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + fmt.Fprint(w, `{"errors":[{"message":"forbidden"}]}`) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + if _, err := c.ListSavedQueries(context.Background()); err == nil { + t.Fatal("expected error on 403, got nil") + } +} + +func TestNormalizeBaseURL(t *testing.T) { + tests := []struct { + in, want string + }{ + {"http://x.com", "http://x.com"}, + {"http://x.com/", "http://x.com"}, + {"http://x.com//", "http://x.com"}, + {"http://x.com/ui", "http://x.com"}, + {"http://x.com/ui/", "http://x.com"}, + {"http://x.com/UI", "http://x.com"}, + {"http://x.com:8001/ui", "http://x.com:8001"}, + {"https://bh.example.com/ui", "https://bh.example.com"}, + {"https://bh.example.com/bloodhound/ui", "https://bh.example.com/bloodhound"}, + // Genuine path that just happens to contain "ui" mid-segment is left alone. + {"https://bh.example.com/build", "https://bh.example.com/build"}, + } + for _, tc := range tests { + got := NormalizeBaseURL(tc.in) + if got != tc.want { + t.Errorf("NormalizeBaseURL(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestNewClient_StripsUiSuffix(t *testing.T) { + c := NewClient("https://bh.example.com/ui", &fakeAuth{}) + if c.BaseURL != "https://bh.example.com" { + t.Errorf("BaseURL = %q, want %q", c.BaseURL, "https://bh.example.com") + } +} + +func TestClient_RejectsHTMLResponse(t *testing.T) { + // Simulate the SPA-fallback case: a 200 OK with HTML body that would + // otherwise read as JSON-decode failure (or worse, silent success). + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `SPA`) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + c.MaxRetries = 0 + + _, err := c.ListSavedQueries(context.Background()) + if err == nil { + t.Fatal("expected error on HTML response, got nil") + } + if !strings.Contains(err.Error(), "text/html") { + t.Errorf("error should mention text/html: %v", err) + } + if !strings.Contains(err.Error(), "/ui") { + t.Errorf("error should mention the /ui hint: %v", err) + } + + // Schema upload should also reject the SPA fallback now. + if err := c.UploadSchema(context.Background(), []byte(`{}`)); err == nil { + t.Fatal("expected schema upload to reject HTML response, got nil") + } +} + func TestTruncate(t *testing.T) { tests := []struct { input string diff --git a/internal/uploader/queries.go b/internal/uploader/queries.go new file mode 100644 index 0000000..532ea10 --- /dev/null +++ b/internal/uploader/queries.go @@ -0,0 +1,161 @@ +package uploader + +import ( + "encoding/json" + "fmt" + "io/fs" + "log/slog" + "os" + "path/filepath" + "sort" + "strings" + + savedqueries "github.com/SpecterOps/MSSQLHound/saved_queries" +) + +// SavedQuery is the in-memory representation of a BloodHound CE saved Cypher +// query as accepted by POST/PUT /api/v2/saved-queries. +type SavedQuery struct { + Name string + Query string + Description string +} + +// savedQueryFile is the JSON shape for both the embedded saved_queries/*.json +// and any user-supplied --queries-dir/*.json files. +type savedQueryFile struct { + Name string `json:"name"` + Query string `json:"query"` + Description string `json:"description,omitempty"` +} + +// MarshalJSON renders a SavedQuery as the BloodHound CE request body. +func (q SavedQuery) MarshalJSON() ([]byte, error) { + return json.Marshal(savedQueryFile{ + Name: q.Name, + Query: q.Query, + Description: q.Description, + }) +} + +// LoadEmbeddedQueries returns every saved query embedded into the binary at +// build time. The returned slice is sorted by Name for deterministic ordering. +func LoadEmbeddedQueries() ([]SavedQuery, error) { + entries, err := savedqueries.FS.ReadDir(".") + if err != nil { + return nil, fmt.Errorf("read embedded saved_queries: %w", err) + } + + queries := make([]SavedQuery, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + continue + } + data, readErr := fs.ReadFile(savedqueries.FS, e.Name()) + if readErr != nil { + return nil, fmt.Errorf("read embedded saved query %s: %w", e.Name(), readErr) + } + q, parseErr := parseQueryBytes(data) + if parseErr != nil { + return nil, fmt.Errorf("parse embedded saved query %s: %w", e.Name(), parseErr) + } + queries = append(queries, q) + } + + sortByName(queries) + return queries, nil +} + +// LoadQueriesFromDir reads every *.json file in dir and returns those that +// parse as valid SavedQuery records. Invalid or unreadable files are logged +// at Warn level and skipped so a single malformed file does not block the +// whole upload. Non-.json entries are ignored. +func LoadQueriesFromDir(dir string, logger *slog.Logger) ([]SavedQuery, error) { + if dir == "" { + return nil, nil + } + if logger == nil { + logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) + } + + info, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("stat queries dir %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("--queries-dir %s is not a directory", dir) + } + + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("read queries dir %s: %w", dir, err) + } + + queries := make([]SavedQuery, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") { + continue + } + path := filepath.Join(dir, e.Name()) + data, readErr := os.ReadFile(path) + if readErr != nil { + logger.Warn("Skipped saved query file (read failed)", "file", path, "error", readErr) + continue + } + q, parseErr := parseQueryBytes(data) + if parseErr != nil { + logger.Warn("Skipped saved query file (invalid)", "file", path, "error", parseErr) + continue + } + queries = append(queries, q) + } + + sortByName(queries) + return queries, nil +} + +// MergeQueries combines bundled and custom-dir queries. On a name collision +// the custom-dir entry replaces the bundled one, so operators can override +// shipped queries without forking the binary. Output is sorted by Name. +func MergeQueries(bundled, custom []SavedQuery) []SavedQuery { + merged := make(map[string]SavedQuery, len(bundled)+len(custom)) + for _, q := range bundled { + merged[q.Name] = q + } + for _, q := range custom { + merged[q.Name] = q + } + + out := make([]SavedQuery, 0, len(merged)) + for _, q := range merged { + out = append(out, q) + } + sortByName(out) + return out +} + +// parseQueryBytes decodes a single saved-query JSON document and validates +// that the required fields are populated. +func parseQueryBytes(data []byte) (SavedQuery, error) { + var f savedQueryFile + if err := json.Unmarshal(data, &f); err != nil { + return SavedQuery{}, fmt.Errorf("unmarshal: %w", err) + } + f.Name = strings.TrimSpace(f.Name) + f.Query = strings.TrimSpace(f.Query) + if f.Name == "" { + return SavedQuery{}, fmt.Errorf("missing required field: name") + } + if f.Query == "" { + return SavedQuery{}, fmt.Errorf("missing required field: query") + } + return SavedQuery{ + Name: f.Name, + Query: f.Query, + Description: f.Description, + }, nil +} + +func sortByName(qs []SavedQuery) { + sort.Slice(qs, func(i, j int) bool { return qs[i].Name < qs[j].Name }) +} diff --git a/internal/uploader/queries_test.go b/internal/uploader/queries_test.go new file mode 100644 index 0000000..4a26729 --- /dev/null +++ b/internal/uploader/queries_test.go @@ -0,0 +1,150 @@ +package uploader + +import ( + "io" + "log/slog" + "os" + "path/filepath" + "testing" +) + +func TestLoadEmbeddedQueries(t *testing.T) { + queries, err := LoadEmbeddedQueries() + if err != nil { + t.Fatalf("LoadEmbeddedQueries() error: %v", err) + } + if len(queries) < 11 { + t.Fatalf("expected at least 11 embedded queries, got %d", len(queries)) + } + for _, q := range queries { + if q.Name == "" { + t.Errorf("embedded query has empty Name: %+v", q) + } + if q.Query == "" { + t.Errorf("embedded query %q has empty Query", q.Name) + } + } + + // Sorted by name. + for i := 1; i < len(queries); i++ { + if queries[i-1].Name > queries[i].Name { + t.Errorf("embedded queries not sorted: %q before %q", queries[i-1].Name, queries[i].Name) + } + } +} + +func TestLoadQueriesFromDir(t *testing.T) { + tmp := t.TempDir() + + good := `{"name":"Test One","query":"MATCH (n) RETURN n LIMIT 1","description":"smoke"}` + missingName := `{"query":"MATCH (n) RETURN n"}` + missingQuery := `{"name":"NoQuery"}` + bogus := `not json at all` + + mustWrite := func(name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(tmp, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + mustWrite("good.json", good) + mustWrite("missing_name.json", missingName) + mustWrite("missing_query.json", missingQuery) + mustWrite("bogus.json", bogus) + mustWrite("readme.md", "# not a json file") + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + queries, err := LoadQueriesFromDir(tmp, logger) + if err != nil { + t.Fatalf("LoadQueriesFromDir() error: %v", err) + } + if len(queries) != 1 { + t.Fatalf("expected 1 valid query (3 invalid + 1 non-json skipped), got %d", len(queries)) + } + if queries[0].Name != "Test One" { + t.Errorf("Name = %q, want %q", queries[0].Name, "Test One") + } + if queries[0].Description != "smoke" { + t.Errorf("Description = %q, want %q", queries[0].Description, "smoke") + } +} + +func TestLoadQueriesFromDir_EmptyPath(t *testing.T) { + got, err := LoadQueriesFromDir("", nil) + if err != nil { + t.Fatalf("expected nil error for empty dir, got %v", err) + } + if got != nil { + t.Errorf("expected nil slice for empty dir, got %v", got) + } +} + +func TestLoadQueriesFromDir_NotADirectory(t *testing.T) { + tmp := t.TempDir() + file := filepath.Join(tmp, "regular.txt") + if err := os.WriteFile(file, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadQueriesFromDir(file, nil); err == nil { + t.Fatal("expected error when --queries-dir points at a file, got nil") + } +} + +func TestMergeQueries_CustomOverridesBundled(t *testing.T) { + bundled := []SavedQuery{ + {Name: "A", Query: "bundled-a", Description: "bundled"}, + {Name: "B", Query: "bundled-b"}, + } + custom := []SavedQuery{ + {Name: "B", Query: "custom-b", Description: "ops"}, + {Name: "C", Query: "custom-c"}, + } + + merged := MergeQueries(bundled, custom) + if len(merged) != 3 { + t.Fatalf("len = %d, want 3", len(merged)) + } + + // Sorted by Name; index by Name for assertions. + got := map[string]SavedQuery{} + for _, q := range merged { + got[q.Name] = q + } + if got["A"].Query != "bundled-a" { + t.Errorf("A.Query = %q, want %q", got["A"].Query, "bundled-a") + } + if got["B"].Query != "custom-b" { + t.Errorf("B.Query = %q, want %q (custom should override)", got["B"].Query, "custom-b") + } + if got["B"].Description != "ops" { + t.Errorf("B.Description = %q, want %q", got["B"].Description, "ops") + } + if got["C"].Query != "custom-c" { + t.Errorf("C.Query = %q, want %q", got["C"].Query, "custom-c") + } +} + +func TestSavedQuery_MarshalJSON(t *testing.T) { + q := SavedQuery{Name: "X", Query: "MATCH (n) RETURN n", Description: "doc"} + body, err := q.MarshalJSON() + if err != nil { + t.Fatal(err) + } + want := `{"name":"X","query":"MATCH (n) RETURN n","description":"doc"}` + if string(body) != want { + t.Errorf("MarshalJSON =\n %s\nwant\n %s", body, want) + } +} + +func TestSavedQuery_MarshalJSON_OmitsEmptyDescription(t *testing.T) { + q := SavedQuery{Name: "X", Query: "MATCH (n) RETURN n"} + body, err := q.MarshalJSON() + if err != nil { + t.Fatal(err) + } + want := `{"name":"X","query":"MATCH (n) RETURN n"}` + if string(body) != want { + t.Errorf("MarshalJSON =\n %s\nwant\n %s", body, want) + } +} diff --git a/internal/uploader/uploader.go b/internal/uploader/uploader.go index ebef384..2b1816d 100644 --- a/internal/uploader/uploader.go +++ b/internal/uploader/uploader.go @@ -17,6 +17,18 @@ type UploadSummary struct { Errors []error } +// SavedQueryUploadSummary holds the aggregate result of uploading saved queries. +type SavedQueryUploadSummary struct { + // Created is the count of queries that were newly POSTed to BH CE. + Created int + // Updated is the count of queries whose existing record was PUT-updated. + Updated int + // Failed is the count of queries that errored on either path. + Failed int + // Errors contains the per-query errors encountered, in submission order. + Errors []error +} + // Uploader manages uploading collector output files to BloodHound CE. type Uploader struct { // Client is the BloodHound CE API client. @@ -46,6 +58,10 @@ func NewUploader(url, tokenID, tokenKey string, logger *slog.Logger) *Uploader { return nil } + if normalized := NormalizeBaseURL(url); normalized != url && logger != nil { + logger.Info("Normalized BloodHound URL", "from", url, "to", normalized) + } + return &Uploader{ Client: NewClient(url, auth), Logger: logger, @@ -102,3 +118,59 @@ func (u *Uploader) UploadFiles(ctx context.Context, files []string) UploadSummar return summary } + +// UploadSavedQueries pushes each query to BloodHound CE as a saved Cypher +// query. A single ListSavedQueries() call up front builds a name->id index, +// so existing entries are PUT-updated (preserving id, ownership, and any +// sharing) and new entries are POST-created. Per-query failures are captured +// in the summary; the loop never aborts early. +func (u *Uploader) UploadSavedQueries(ctx context.Context, queries []SavedQuery) SavedQueryUploadSummary { + var summary SavedQueryUploadSummary + + if len(queries) == 0 { + return summary + } + + u.Logger.Info("Uploading saved queries to BloodHound", "queries", len(queries)) + + existing, err := u.Client.ListSavedQueries(ctx) + if err != nil { + summary.Failed = len(queries) + summary.Errors = append(summary.Errors, fmt.Errorf("list saved queries: %w", err)) + u.Logger.Warn("Failed to enumerate existing saved queries; aborting upload", "error", err) + return summary + } + + for _, q := range queries { + if ctx.Err() != nil { + summary.Failed += len(queries) - summary.Created - summary.Updated - summary.Failed + summary.Errors = append(summary.Errors, ctx.Err()) + break + } + + if id, ok := existing[q.Name]; ok { + u.Logger.Debug("Updating saved query", "name", q.Name, "id", id) + if err := u.Client.UpdateSavedQuery(ctx, id, q); err != nil { + summary.Failed++ + summary.Errors = append(summary.Errors, fmt.Errorf("%s: %w", q.Name, err)) + u.Logger.Warn("Failed to update saved query", "name", q.Name, "error", err) + continue + } + summary.Updated++ + u.Logger.Info("Updated saved query", "name", q.Name) + continue + } + + u.Logger.Debug("Creating saved query", "name", q.Name) + if err := u.Client.CreateSavedQuery(ctx, q); err != nil { + summary.Failed++ + summary.Errors = append(summary.Errors, fmt.Errorf("%s: %w", q.Name, err)) + u.Logger.Warn("Failed to create saved query", "name", q.Name, "error", err) + continue + } + summary.Created++ + u.Logger.Info("Created saved query", "name", q.Name) + } + + return summary +} diff --git a/internal/uploader/uploader_test.go b/internal/uploader/uploader_test.go index 333d03a..bcf71db 100644 --- a/internal/uploader/uploader_test.go +++ b/internal/uploader/uploader_test.go @@ -2,6 +2,7 @@ package uploader import ( "context" + "encoding/json" "fmt" "io" "log/slog" @@ -134,6 +135,204 @@ func TestUploader_UploadFiles_StartUploadFailure(t *testing.T) { } } +func TestUploader_UploadSavedQueries_CreateAndUpdate(t *testing.T) { + var listCalls, postCalls, putCalls atomic.Int32 + var lastPutPath string + var lastPostBody, lastPutBody []byte + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v2/saved-queries": + listCalls.Add(1) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(map[string]any{ + "data": []map[string]any{ + {"id": 11, "name": "Existing"}, + }, + }) + case r.Method == http.MethodPost && r.URL.Path == "/api/v2/saved-queries": + postCalls.Add(1) + lastPostBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/api/v2/saved-queries/"): + putCalls.Add(1) + lastPutPath = r.URL.Path + lastPutBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + u := &Uploader{ + Client: NewClient(srv.URL, &fakeAuth{}), + Logger: discardLogger(), + } + + queries := []SavedQuery{ + {Name: "New", Query: "MATCH (a) RETURN a", Description: "fresh"}, + {Name: "Existing", Query: "MATCH (b) RETURN b", Description: "rev2"}, + } + + summary := u.UploadSavedQueries(context.Background(), queries) + + if summary.Created != 1 { + t.Errorf("Created = %d, want 1", summary.Created) + } + if summary.Updated != 1 { + t.Errorf("Updated = %d, want 1", summary.Updated) + } + if summary.Failed != 0 { + t.Errorf("Failed = %d, want 0", summary.Failed) + } + if listCalls.Load() != 1 { + t.Errorf("list calls = %d, want 1", listCalls.Load()) + } + if postCalls.Load() != 1 { + t.Errorf("post calls = %d, want 1", postCalls.Load()) + } + if putCalls.Load() != 1 { + t.Errorf("put calls = %d, want 1", putCalls.Load()) + } + if lastPutPath != "/api/v2/saved-queries/11" { + t.Errorf("PUT path = %s, want /api/v2/saved-queries/11", lastPutPath) + } + if !strings.Contains(string(lastPostBody), `"name":"New"`) { + t.Errorf("POST body missing new query name: %s", lastPostBody) + } + if !strings.Contains(string(lastPutBody), `"description":"rev2"`) { + t.Errorf("PUT body missing updated description: %s", lastPutBody) + } +} + +func TestUploader_UploadSavedQueries_EmptyList(t *testing.T) { + u := &Uploader{ + Client: NewClient("http://unused", &fakeAuth{}), + Logger: discardLogger(), + } + summary := u.UploadSavedQueries(context.Background(), nil) + if summary.Created != 0 || summary.Updated != 0 || summary.Failed != 0 { + t.Errorf("expected zero summary, got %+v", summary) + } +} + +func TestUploader_UploadSavedQueries_PerQueryFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"data":[]}`)) + case r.Method == http.MethodPost: + body, _ := io.ReadAll(r.Body) + if strings.Contains(string(body), `"name":"FailMe"`) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"errors":[{"message":"bad cypher"}]}`)) + return + } + w.WriteHeader(http.StatusCreated) + default: + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + c.MaxRetries = 0 // 4xx is not retried, but keep tests fast. + u := &Uploader{Client: c, Logger: discardLogger()} + + queries := []SavedQuery{ + {Name: "Good", Query: "MATCH (a) RETURN a"}, + {Name: "FailMe", Query: "INVALID SYNTAX"}, + {Name: "AlsoGood", Query: "MATCH (b) RETURN b"}, + } + + summary := u.UploadSavedQueries(context.Background(), queries) + if summary.Created != 2 { + t.Errorf("Created = %d, want 2", summary.Created) + } + if summary.Failed != 1 { + t.Errorf("Failed = %d, want 1", summary.Failed) + } + if len(summary.Errors) != 1 { + t.Errorf("len(Errors) = %d, want 1", len(summary.Errors)) + } + if !strings.Contains(summary.Errors[0].Error(), "FailMe") { + t.Errorf("error should mention FailMe: %v", summary.Errors[0]) + } +} + +func TestUploader_UploadSavedQueries_SucceedsAfterSchema404(t *testing.T) { + // Reproduces the deployed-BH-without-/api/v2/extensions case: schema PUT + // returns 404, but saved-queries CRUD should still work end-to-end. The + // uploader doesn't call schema itself — phase orchestration is in the + // collector — but this verifies that a separately-failed phase doesn't + // pollute the queries client state. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/api/v2/extensions" && r.Method == http.MethodPut: + w.WriteHeader(http.StatusNotFound) + fmt.Fprint(w, `{"errors":[{"message":"resource not found"}]}`) + case r.URL.Path == "/api/v2/saved-queries" && r.Method == http.MethodGet: + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `{"data":[]}`) + case r.URL.Path == "/api/v2/saved-queries" && r.Method == http.MethodPost: + w.WriteHeader(http.StatusCreated) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + c.MaxRetries = 0 + u := &Uploader{Client: c, Logger: discardLogger()} + + // Schema fails (the orchestrator would log+continue). + if err := c.UploadSchema(context.Background(), []byte(`{}`)); err == nil { + t.Fatal("expected schema upload to fail with 404") + } + + // Queries phase still works. + summary := u.UploadSavedQueries(context.Background(), []SavedQuery{ + {Name: "Q1", Query: "MATCH (n) RETURN n"}, + }) + if summary.Created != 1 || summary.Failed != 0 { + t.Errorf("queries phase summary = %+v, want Created=1 Failed=0", summary) + } +} + +func TestUploader_UploadSavedQueries_ListFailureAborts(t *testing.T) { + var postCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + w.WriteHeader(http.StatusUnauthorized) + return + } + postCalls.Add(1) + w.WriteHeader(http.StatusCreated) + })) + defer srv.Close() + + c := NewClient(srv.URL, &fakeAuth{}) + c.MaxRetries = 0 + u := &Uploader{Client: c, Logger: discardLogger()} + + queries := []SavedQuery{ + {Name: "X", Query: "MATCH (n) RETURN n"}, + {Name: "Y", Query: "MATCH (n) RETURN n"}, + } + summary := u.UploadSavedQueries(context.Background(), queries) + if summary.Failed != 2 { + t.Errorf("Failed = %d, want 2 (every query attributed to the list error)", summary.Failed) + } + if postCalls.Load() != 0 { + t.Errorf("expected zero POST calls when list fails; got %d", postCalls.Load()) + } +} + func TestUploader_UploadFiles_MultipleFiles(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { diff --git a/saved_queries/Longest Shortest Path.json b/saved_queries/Longest Shortest Path.json index dea476a..d0cc414 100644 --- a/saved_queries/Longest Shortest Path.json +++ b/saved_queries/Longest Shortest Path.json @@ -1 +1 @@ -{"query":"MATCH p = shortestPath((n)-[*1..]-\u003e(m))\nWHERE NOT n = m\nRETURN p\nORDER BY LENGTH(p) DESC\nLIMIT 1","name":"Longest Shortest Path","description":"Identifies the shortest path between any two nodes that has the most traversable edges between them"} \ No newline at end of file +{"query":"MATCH p = shortestPath((n)-[*1..]->(m))\nWHERE NOT n = m\nRETURN p\nORDER BY LENGTH(p) DESC\nLIMIT 1","name":"Longest Shortest Path","description":"Identifies the shortest path between any two nodes that has the most traversable edges between them"} diff --git a/saved_queries/MSSQL_ Databases With Server-Level Execution Privileges.json b/saved_queries/MSSQL_ Databases With Server-Level Execution Privileges.json index e8bf0ef..39675ec 100644 --- a/saved_queries/MSSQL_ Databases With Server-Level Execution Privileges.json +++ b/saved_queries/MSSQL_ Databases With Server-Level Execution Privileges.json @@ -1 +1 @@ -{"query":"MATCH p = ()-[:MSSQL_ExecuteAsOwner]-() RETURN p","name":"MSSQL: Databases With Server-Level Execution Privileges","description":"Allows database principals to escalate to server-level privileges through EXECUTE AS OWNER when database has TRUSTWORTHY ON, database owner has high privileges (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN), and principal can create or modify stored procedures, functions, or CLR assemblies in the database"} \ No newline at end of file +{"query":"MATCH p = ()-[:MSSQL_ExecuteAsOwner]-() RETURN p","name":"MSSQL: Databases With Server-Level Execution Privileges","description":"Allows database principals to escalate to server-level privileges through EXECUTE AS OWNER when database has TRUSTWORTHY ON, database owner has high privileges (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN), and principal can create or modify stored procedures, functions, or CLR assemblies in the database"} diff --git a/saved_queries/MSSQL_ Domain Principals that Can Coerce and Relay to MSSQL.json b/saved_queries/MSSQL_ Domain Principals that Can Coerce and Relay to MSSQL.json index bfbfbda..737890a 100644 --- a/saved_queries/MSSQL_ Domain Principals that Can Coerce and Relay to MSSQL.json +++ b/saved_queries/MSSQL_ Domain Principals that Can Coerce and Relay to MSSQL.json @@ -1 +1 @@ -{"query":"MATCH p = (:Base)-[:MSSQL_CoerceAndRelayToMSSQL]-\u003e() RETURN p","name":"MSSQL: Domain Principals that Can Coerce and Relay to MSSQL","description":"Paths from domain computer principals to their corresponding SQL Server logins on servers where EPA is not required"} \ No newline at end of file +{"query":"MATCH p = (:Base)-[:MSSQL_CoerceAndRelayToMSSQL]->() RETURN p","name":"MSSQL: Domain Principals that Can Coerce and Relay to MSSQL","description":"Paths from domain computer principals to their corresponding SQL Server logins on servers where EPA is not required"} diff --git a/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Databases.json b/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Databases.json index fbabacb..9e58999 100644 --- a/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Databases.json +++ b/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Databases.json @@ -1 +1 @@ -{"query":"MATCH control =\n (:Base)-[\n :MSSQL_HasLogin\n |MSSQL_CoerceAndRelayToMSSQL\n |MSSQL_IsMappedTo\n |MSSQL_ControlServer\n |MSSQL_Contains\n |MSSQL_ControlDB\n |MSSQL_MemberOf\n |MSSQL_AddMember\n |MSSQL_ExecuteAs\n |MSSQL_ChangePassword\n |MSSQL_Owns\n |MSSQL_ExecuteAsOwner\n |MSSQL_LinkedAsAdmin\n |MSSQL_TakeOwnership\n *..4]-\u003e(:MSSQL_Database)\nRETURN control","name":"MSSQL: Domain Principals with Control of MSSQL Databases","description":""} \ No newline at end of file +{"query":"MATCH control =\n (:Base)-[\n :MSSQL_HasLogin\n |MSSQL_CoerceAndRelayToMSSQL\n |MSSQL_IsMappedTo\n |MSSQL_ControlServer\n |MSSQL_Contains\n |MSSQL_ControlDB\n |MSSQL_MemberOf\n |MSSQL_AddMember\n |MSSQL_ExecuteAs\n |MSSQL_ChangePassword\n |MSSQL_Owns\n |MSSQL_ExecuteAsOwner\n |MSSQL_LinkedAsAdmin\n |MSSQL_TakeOwnership\n *..4]->(:MSSQL_Database)\nRETURN control","name":"MSSQL: Domain Principals with Control of MSSQL Databases","description":""} diff --git a/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Servers.json b/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Servers.json index 883e423..0062fe7 100644 --- a/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Servers.json +++ b/saved_queries/MSSQL_ Domain Principals with Control of MSSQL Servers.json @@ -1 +1 @@ -{"query":"MATCH control =\n (:Base)-[\n :MSSQL_HasLogin\n |MSSQL_CoerceAndRelayToMSSQL\n |MSSQL_IsMappedTo\n |MSSQL_ControlServer\n |MSSQL_ControlDB\n |MSSQL_MemberOf\n |MSSQL_AddMember\n |MSSQL_ExecuteAs\n |MSSQL_ChangePassword\n |MSSQL_Owns\n |MSSQL_ExecuteAsOwner\n |MSSQL_LinkedAsAdmin\n |MSSQL_TakeOwnership\n |MSSQL_GrantAnyPermission\n |MSSQL_ImpersonateAnyLogin\n |MSSQL_ExecuteOnHost\n *..7]-\u003e(:MSSQL_Server)\nRETURN control ","name":"MSSQL: Domain Principals with Control of MSSQL Servers","description":""} \ No newline at end of file +{"query":"MATCH control =\n (:Base)-[\n :MSSQL_HasLogin\n |MSSQL_CoerceAndRelayToMSSQL\n |MSSQL_IsMappedTo\n |MSSQL_ControlServer\n |MSSQL_ControlDB\n |MSSQL_MemberOf\n |MSSQL_AddMember\n |MSSQL_ExecuteAs\n |MSSQL_ChangePassword\n |MSSQL_Owns\n |MSSQL_ExecuteAsOwner\n |MSSQL_LinkedAsAdmin\n |MSSQL_TakeOwnership\n |MSSQL_GrantAnyPermission\n |MSSQL_ImpersonateAnyLogin\n |MSSQL_ExecuteOnHost\n *..7]->(:MSSQL_Server)\nRETURN control ","name":"MSSQL: Domain Principals with Control of MSSQL Servers","description":""} diff --git a/saved_queries/MSSQL_ Domain Principals with MSSQL Logins.json b/saved_queries/MSSQL_ Domain Principals with MSSQL Logins.json index 0f1320d..54b5ad3 100644 --- a/saved_queries/MSSQL_ Domain Principals with MSSQL Logins.json +++ b/saved_queries/MSSQL_ Domain Principals with MSSQL Logins.json @@ -1 +1 @@ -{"query":"MATCH p = (:Base)-[:MSSQL_HasLogin]-\u003e() RETURN p","name":"MSSQL: Domain Principals with MSSQL Logins","description":""} \ No newline at end of file +{"query":"MATCH p = (:Base)-[:MSSQL_HasLogin]->() RETURN p","name":"MSSQL: Domain Principals with MSSQL Logins","description":""} diff --git a/saved_queries/MSSQL_ Domain Principals with Path to SQL Servers.json b/saved_queries/MSSQL_ Domain Principals with Path to SQL Servers.json index c2a5015..71b771e 100644 --- a/saved_queries/MSSQL_ Domain Principals with Path to SQL Servers.json +++ b/saved_queries/MSSQL_ Domain Principals with Path to SQL Servers.json @@ -1 +1 @@ -{"query":"MATCH p = (:Base)-[:MSSQL_CoerceAndRelayToMSSQL|MSSQL_GetTGS|MSSQL_GetAdminTGS|MSSQL_HasLogin|MSSQL_HostFor|MSSQL_ServiceAccountFor]-\u003e() RETURN p","name":"MSSQL: Domain Principals with Path to SQL Servers","description":""} \ No newline at end of file +{"query":"MATCH p = (:Base)-[:MSSQL_CoerceAndRelayToMSSQL|MSSQL_GetTGS|MSSQL_GetAdminTGS|MSSQL_HasLogin|MSSQL_HostFor|MSSQL_ServiceAccountFor]->() RETURN p","name":"MSSQL: Domain Principals with Path to SQL Servers","description":""} diff --git a/saved_queries/MSSQL_ Linked SQL Servers.json b/saved_queries/MSSQL_ Linked SQL Servers.json index 14049fc..562cfa4 100644 --- a/saved_queries/MSSQL_ Linked SQL Servers.json +++ b/saved_queries/MSSQL_ Linked SQL Servers.json @@ -1 +1 @@ -{"query":"MATCH p = (:MSSQL_Server)-[:MSSQL_LinkedAsAdmin|:MSSQL_LinkedTo]-\u003e(:MSSQL_Server) RETURN p","name":"MSSQL: Linked SQL Servers","description":""} \ No newline at end of file +{"query":"MATCH p = (:MSSQL_Server)-[:MSSQL_LinkedAsAdmin|:MSSQL_LinkedTo]->(:MSSQL_Server) RETURN p","name":"MSSQL: Linked SQL Servers","description":""} diff --git a/saved_queries/MSSQL_ Paths from MSSQL to Active Directory.json b/saved_queries/MSSQL_ Paths from MSSQL to Active Directory.json index 3f5b9df..929f167 100644 --- a/saved_queries/MSSQL_ Paths from MSSQL to Active Directory.json +++ b/saved_queries/MSSQL_ Paths from MSSQL to Active Directory.json @@ -1 +1 @@ -{"query":"MATCH p = ()-[:MSSQL_ExecuteOnHost|MSSQL_HasDBScopedCred|MSSQL_HasMappedCred|MSSQL_HasProxyCred]-\u003e(:Base) RETURN p","name":"MSSQL: Paths from MSSQL to Active Directory","description":""} \ No newline at end of file +{"query":"MATCH p = ()-[:MSSQL_ExecuteOnHost|MSSQL_HasDBScopedCred|MSSQL_HasMappedCred|MSSQL_HasProxyCred]->(:Base) RETURN p","name":"MSSQL: Paths from MSSQL to Active Directory","description":""} diff --git a/saved_queries/MSSQL_ Service Account Paths to MSSQL Servers.json b/saved_queries/MSSQL_ Service Account Paths to MSSQL Servers.json index 763b320..a610695 100644 --- a/saved_queries/MSSQL_ Service Account Paths to MSSQL Servers.json +++ b/saved_queries/MSSQL_ Service Account Paths to MSSQL Servers.json @@ -1 +1 @@ -{"query":"MATCH p = (:Base)-[:MSSQL_GetTGS|MSSQL_GetAdminTGS|MSSQL_ServiceAccountFor]-\u003e() RETURN p","name":"MSSQL: Service Account Paths to MSSQL Servers","description":""} \ No newline at end of file +{"query":"MATCH p = (:Base)-[:MSSQL_GetTGS|MSSQL_GetAdminTGS|MSSQL_ServiceAccountFor]->() RETURN p","name":"MSSQL: Service Account Paths to MSSQL Servers","description":""} diff --git a/saved_queries/README.md b/saved_queries/README.md index 691b32a..5f5291b 100644 --- a/saved_queries/README.md +++ b/saved_queries/README.md @@ -1 +1,18 @@ -Please not that you must ingest the `seed_data.json` file in order to successfully run the prebuilt Cypher queries in this directory if any of the edge classes are not already present in your BloodHound graph database. \ No newline at end of file +Please note that you must ingest the `seed_data.json` file in order to successfully run the prebuilt Cypher queries in this directory if any of the edge classes are not already present in your BloodHound graph database. + +## Automated upload + +These queries are also embedded into the `mssqlhound` binary at build time and can be installed directly into a BloodHound CE instance via the API — no manual import required: + +```bash +# Install just the bundled queries (PUT-updates existing names, POST-creates new ones) +./mssqlhound -B ':@https://bloodhound.example.com' --upload-queries-only + +# Install bundled queries plus your own *.json files from a directory (additive) +./mssqlhound -B ':@https://bloodhound.example.com' \ + --upload-queries-only --queries-dir /path/to/extra/queries +``` + +When `-B` is set without an `--upload-*-only` flag, queries are uploaded alongside schema and results. Use `--no-upload-queries` to suppress this. + +Custom-directory JSON files use the same shape as the files here: `{"name": "...", "query": "...", "description": "..."}`. diff --git a/saved_queries/Shortest Path from Domain Principal to MSSQL Server.json b/saved_queries/Shortest Path from Domain Principal to MSSQL Server.json index be53c96..e05a5c7 100644 --- a/saved_queries/Shortest Path from Domain Principal to MSSQL Server.json +++ b/saved_queries/Shortest Path from Domain Principal to MSSQL Server.json @@ -1 +1 @@ -{"query":"MATCH p = shortestPath((n:Base)-[*1..]-\u003e(m:MSSQL_Server))\nWHERE NOT n = m\nRETURN p\nORDER BY LENGTH(p) DESC\nLIMIT 1","name":"Shortest Path from Domain Principal to MSSQL Server","description":""} \ No newline at end of file +{"query":"MATCH p = shortestPath((n:Base)-[*1..]->(m:MSSQL_Server))\nWHERE NOT n = m\nRETURN p\nORDER BY LENGTH(p) DESC\nLIMIT 1","name":"Shortest Path from Domain Principal to MSSQL Server","description":""} diff --git a/saved_queries/embed.go b/saved_queries/embed.go new file mode 100644 index 0000000..d21f107 --- /dev/null +++ b/saved_queries/embed.go @@ -0,0 +1,14 @@ +// Package savedqueries embeds the prebuilt BloodHound CE Cypher queries that +// ship with MSSQLHound so they can be uploaded to a BloodHound CE instance via +// POST /api/v2/saved-queries without any external file dependencies. +// +// The directory name (saved_queries) keeps its underscore for documentation +// and back-compat; this package directive uses a valid Go identifier. +package savedqueries + +import "embed" + +// FS contains every *.json file colocated with this source file. +// +//go:embed *.json +var FS embed.FS