From 664fd1a7853bf98300527ab9621e8308dc2044fd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 20:59:10 +0000 Subject: [PATCH 1/2] Add optional Read/Write/Delete database edges Introduce three non-traversable, opt-in edges that surface principals who can read, write, or delete data in a database (previously only the traversable MSSQL_ControlDB edge connected principals to databases): - MSSQL_ReadDB <- SELECT on the database / db_datareader fixed role - MSSQL_WriteDB <- INSERT or UPDATE on the database / db_datawriter - MSSQL_DeleteDB <- DELETE on the database / db_datawriter Creation is gated behind the new --enable-data-access-edges flag (off by default) so existing output is unchanged. Sources are explicit DATABASE-scoped grants in sys.database_permissions and the db_datareader/db_datawriter fixed roles. Registers the edge kinds, marks them non-traversable, adds property generators, schema and seed-data entries, config plumbing, unit tests (positive, negative, and flag-off cases), and README docs. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01K5TQ7KrD4DuRdFvQ56ChbM --- .claude/plans/data-access-edges.md | 95 ++++++++++++++++++++ README.md | 16 ++++ cmd/mssqlhound/main.go | 15 ++-- internal/bloodhound/edges.go | 58 +++++++++++- internal/bloodhound/schema.json | 15 ++++ internal/bloodhound/seed_data.json | 5 +- internal/bloodhound/writer.go | 6 ++ internal/collector/collector.go | 75 ++++++++++++++++ internal/collector/edge_test_data_test.go | 25 ++++++ internal/collector/edge_test_helpers_test.go | 17 ++-- internal/collector/edge_unit_test.go | 64 +++++++++++++ 11 files changed, 378 insertions(+), 13 deletions(-) create mode 100644 .claude/plans/data-access-edges.md diff --git a/.claude/plans/data-access-edges.md b/.claude/plans/data-access-edges.md new file mode 100644 index 0000000..27cc7e2 --- /dev/null +++ b/.claude/plans/data-access-edges.md @@ -0,0 +1,95 @@ +# Plan: Optional Read / Write / Delete database edges + +## Context +Today the only edge that terminates on an `MSSQL_Database` node representing +elevated access is `MSSQL_ControlDB` (traversable, created from `CONTROL` on the +database or the `db_owner` fixed role). Meatbag wants to also surface principals +that can **read, write, or delete** data in a database — a weaker but still +security-relevant capability. These new edges must be **non-traversable** (they +are informational, not privilege-escalation paths) and their creation must be +**opt-in** via a new flag so existing output is unchanged by default. + +## Decisions (confirmed with user) +- **Three edges**, all `→ MSSQL_Database`, all non-traversable: + - `MSSQL_ReadDB` ← `SELECT` on the database + - `MSSQL_WriteDB` ← `INSERT` or `UPDATE` on the database + - `MSSQL_DeleteDB` ← `DELETE` on the database +- **Opt-in flag** `--enable-data-access-edges` (default `false`). +- **Sources**: explicit DB-scoped grants (`sys.database_permissions`, class + `DATABASE`) **and** the `db_datareader` / `db_datawriter` fixed roles. + (`db_datareader` → Read; `db_datawriter` → Write **and** Delete.) + +## Implementation + +### 1. Register the three edge kinds — `internal/bloodhound/writer.go` +- Add `ReadDB`, `WriteDB`, `DeleteDB` fields to the `EdgeKinds` struct and its + literal, with values `"MSSQL_ReadDB"`, `"MSSQL_WriteDB"`, `"MSSQL_DeleteDB"`. + +### 2. Mark them non-traversable + add properties — `internal/bloodhound/edges.go` +- Add the three kinds to the `case` list in `IsTraversableEdge` (return `false`). +- Add three entries to `edgePropertyGenerators` (mirror the `ControlDB` / + `Connect` generator style: General / WindowsAbuse / LinuxAbuse / Opsec / + References). Abuse text = connect as `ctx.SourceName` to `ctx.SQLServerName`, + `USE ;` then `SELECT` / `INSERT`+`UPDATE` / `DELETE` example statements. + +### 3. Schema + seed data (so BloodHound registers the kinds) +- `internal/bloodhound/schema.json`: add 3 `relationship_kinds` entries with + `"is_traversable": false` (one per new kind). +- `internal/bloodhound/seed_data.json`: add one self-loop edge per new kind + (matches the existing one-edge-per-kind convention). + +### 4. Config plumbing +- `internal/collector/collector.go` `Config`: add `EnableDataAccessEdges bool`. +- `cmd/mssqlhound/main.go`: add package var `enableDataAccessEdges`, register + `rootCmd.Flags().BoolVar(... "enable-data-access-edges", false, ...)`, add it + to the `"Collection"` group annotation slice, and set + `EnableDataAccessEdges: enableDataAccessEdges` in the `Config` literal in `run`. + +### 5. Emit the edges +All new edge creation is guarded by `if c.config.EnableDataAccessEdges`. + +- **Explicit grants** — `createDatabasePermissionEdges` + (`internal/collector/collector.go`, ~line 5910). Add `case "SELECT"`, + `case "INSERT"`, `case "UPDATE"`, `case "DELETE"` (only when + `perm.ClassDesc == "DATABASE"`), each creating the corresponding edge from + `principal.ObjectIdentifier` → `db.ObjectIdentifier` via `c.createEdge`, + reusing `c.getDatabasePrincipalType(principal.TypeDescription)` for + `SourceType`. `INSERT` and `UPDATE` both emit `WriteDB` (edge-dedup in the + writer's `seenEdges` collapses the duplicate when both are granted). +- **Fixed roles** — `createFixedRoleEdges` DB-role loop + (`internal/collector/collector.go`, ~line 5192). Add `case "db_datareader":` + (emit `ReadDB`) and `case "db_datawriter":` (emit `WriteDB` + `DeleteDB`), + using `IsFixedRole: true` in the `EdgeContext`, following the `db_owner` + pattern. + +Because `createEdge` already returns `nil` for non-traversable edges when +`DisableNontraversableEdges` is set, the new edges also correctly disappear +under `--disable-nontraversable-edges`. + +### 6. Tests — `internal/collector/edge_unit_test.go` + `edge_test_data_test.go` +- Add `buildDataAccessTestData()` with principals holding `SELECT` / `INSERT` / + `UPDATE` / `DELETE` DATABASE grants and `db_datareader` / `db_datawriter` + fixed roles. +- Add `readDBTestCases` / `writeDBTestCases` / `deleteDBTestCases` and a + `TestDataAccessEdges` that runs `runEdgeCreation`. **Note**: the default test + config does not set `EnableDataAccessEdges`; add a variant helper (or extend + `runEdgeCreation`) so the test enables the flag, and add one negative test + asserting the edges are absent when the flag is off. +- Append the new case slices into the aggregate `all` slice in + `edge_test_data_test.go` (the `allEdgeTestCases` builder ~line 696). + +### 7. Docs — `README.md` +- Add the flag row to the **Collection** flag table (~line 674) and a short + usage example near **Possible Edge Options** (~line 579). +- Add the three edges to the edge index (~line 100) and the edge-properties + table (~line 1290) as "No unique edge properties". + +## Verification +- `go test ./...` (project rule 10) — new `TestDataAccessEdges` plus existing + suite must pass. +- `go build ./cmd/mssqlhound` → binary in repo root; run + `./mssqlhound --help` and confirm `--enable-data-access-edges` appears in the + Collection group. +- Sanity-check schema validity: the collector's schema-upload path reads + `bloodhound.SchemaJSON`; a malformed JSON edit would fail `go test`'s + `json.Unmarshal` in schema-dependent tests. diff --git a/README.md b/README.md index 94cb2cd..f1ad7d0 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ Run MSSQLHound with a few common collection patterns: - [`MSSQL_Control`](#mssql_control) - [`MSSQL_ControlDB`](#mssql_controldb) - [`MSSQL_ControlServer`](#mssql_controlserver) + - [`MSSQL_DeleteDB`](#mssql_deletedb) - [`MSSQL_ExecuteAs`](#mssql_executeas) - [`MSSQL_ExecuteAsOwner`](#mssql_executeasowner) - [`MSSQL_ExecuteOnHost`](#mssql_executeonhost) @@ -130,8 +131,10 @@ Run MSSQLHound with a few common collection patterns: - [`MSSQL_LinkedTo`](#mssql_linkedto) - [`MSSQL_MemberOf`](#mssql_memberof) - [`MSSQL_Owns`](#mssql_owns) + - [`MSSQL_ReadDB`](#mssql_readdb) - [`MSSQL_ServiceAccountFor`](#mssql_serviceaccountfor) - [`MSSQL_TakeOwnership`](#mssql_takeownership) + - [`MSSQL_WriteDB`](#mssql_writedb) # Overview Collects BloodHound OpenGraph compatible data from one or more MSSQL servers into individual temporary files, then zips them in the current directory @@ -586,6 +589,12 @@ export BLOODHOUND_TOKEN_KEY= ./mssqlhound -t sql.contoso.com --skip-ad-nodes ``` +# Add non-traversable data-access edges (read/write/delete) to databases +# Draws MSSQL_ReadDB (SELECT), MSSQL_WriteDB (INSERT/UPDATE), and MSSQL_DeleteDB +# (DELETE) edges from principals with explicit DATABASE-scoped grants or the +# db_datareader / db_datawriter fixed roles. Off by default. +./mssqlhound -t sql.contoso.com --enable-data-access-edges + ### Linked Server Options ```bash @@ -680,6 +689,7 @@ mssqlhound completion powershell | Out-String | Invoke-Expression | `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes | | `--disable-nontraversable-edges` | false | Disable non-traversable edges | | `--disable-possible-edges` | false | Disable possible edges (makes them non-traversable in schema and edge data) | +| `--enable-data-access-edges` | false | Create non-traversable `MSSQL_ReadDB`/`MSSQL_WriteDB`/`MSSQL_DeleteDB` edges for principals that can read, write, or delete data in a database | | `-w, --workers` | 0 | Number of concurrent workers (0 = sequential processing) | ### Output / Storage @@ -1291,6 +1301,8 @@ All edges based on permissions may contain the `With Grant` property, which mean | **`MSSQL_ControlDB`** | • No unique edge properties | | **`MSSQL_ControlServer`** | • No unique edge properties | + +| **`MSSQL_DeleteDB`** | • No unique edge properties | | **`MSSQL_ExecuteAs`** | • No unique edge properties | @@ -1319,10 +1331,14 @@ All edges based on permissions may contain the `With Grant` property, which mean | **`MSSQL_MemberOf`** | • No unique edge properties | | **`MSSQL_Owns`** | • No unique edge properties | + +| **`MSSQL_ReadDB`** | • No unique edge properties | | **`MSSQL_ServiceAccountFor`** | • No unique edge properties | | **`MSSQL_TakeOwnership`** | • No unique edge properties | + +| **`MSSQL_WriteDB`** | • No unique edge properties | # Credits diff --git a/cmd/mssqlhound/main.go b/cmd/mssqlhound/main.go index c4a8417..feddeb7 100644 --- a/cmd/mssqlhound/main.go +++ b/cmd/mssqlhound/main.go @@ -18,7 +18,7 @@ import ( ) var ( - version = "2.0.3" + version = "2.0.3" // Shared connection options (persistent - inherited by subcommands) serverInstance string @@ -54,6 +54,7 @@ var ( skipADNodeCreation bool disableNontraversableEdges bool disablePossibleEdges bool + enableDataAccessEdges bool skipIPDedupe bool scanAllComputerPorts string @@ -138,6 +139,7 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int rootCmd.Flags().BoolVar(&skipADNodeCreation, "skip-ad-nodes", false, "Skip creating User, Group, Computer nodes") rootCmd.Flags().BoolVar(&disableNontraversableEdges, "disable-nontraversable-edges", false, "Disable non-traversable edges") rootCmd.Flags().BoolVar(&disablePossibleEdges, "disable-possible-edges", false, "Disable possible edges (makes them non-traversable in schema and edge data)") + rootCmd.Flags().BoolVar(&enableDataAccessEdges, "enable-data-access-edges", false, "Create non-traversable ReadDB/WriteDB/DeleteDB edges for principals that can read, write, or delete data in a database") rootCmd.Flags().BoolVar(&skipIPDedupe, "skip-ip-dedupe", false, "Skip DNS-based target deduplication (keeps all targets even if they resolve to the same IP)") rootCmd.Flags().StringVar(&scanAllComputerPorts, "scan-all-computer-ports", "1433", "Comma-separated TCP ports to scan for --scan-all-computers targets") rootCmd.Flags().IntVar(&linkedServerTimeout, "linked-timeout", 300, "Linked server enumeration timeout (seconds)") @@ -162,11 +164,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", "scan-all-computer-ports"} { - 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", "enable-data-access-edges", "skip-ip-dedupe", "scan-all-computer-ports"} { + rootCmd.Flags().SetAnnotation(name, "group", []string{"Collection"}) //nolint:errcheck + } for _, name := range []string{"linked-timeout", "workers", "file-size-limit", "port-check-timeout", "memory-threshold", "size-update-interval"} { rootCmd.Flags().SetAnnotation(name, "group", []string{"Performance"}) //nolint:errcheck @@ -439,6 +441,7 @@ func run(cmd *cobra.Command, args []string) error { SkipADNodeCreation: skipADNodeCreation, DisableNontraversableEdges: disableNontraversableEdges, DisablePossibleEdges: disablePossibleEdges, + EnableDataAccessEdges: enableDataAccessEdges, SkipIPDedupe: skipIPDedupe, LinkedServerTimeout: linkedServerTimeout, PortCheckTimeout: time.Duration(portCheckTimeout) * time.Second, diff --git a/internal/bloodhound/edges.go b/internal/bloodhound/edges.go index 5e1cec1..faa25c1 100644 --- a/internal/bloodhound/edges.go +++ b/internal/bloodhound/edges.go @@ -111,7 +111,10 @@ func IsTraversableEdge(kind string) bool { EdgeKinds.AlterDBRole, EdgeKinds.AlterServerRole, EdgeKinds.ImpersonateDBUser, - EdgeKinds.ImpersonateLogin: + EdgeKinds.ImpersonateLogin, + EdgeKinds.ReadDB, + EdgeKinds.WriteDB, + EdgeKinds.DeleteDB: return false default: return true @@ -255,6 +258,59 @@ var edgePropertyGenerators = map[string]func(*EdgeContext) EdgeProperties{ } }, + EdgeKinds.ReadDB: func(ctx *EdgeContext) EdgeProperties { + abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and read data from the " + ctx.TargetName + " database:\n" + + "USE " + ctx.TargetName + "; \n" + + "-- List readable tables \n" + + "SELECT s.name AS SchemaName, t.name AS TableName FROM sys.tables t JOIN sys.schemas s ON t.schema_id = s.schema_id; \n" + + "-- Read data \n" + + "SELECT * FROM [schema].[table]; " + return EdgeProperties{ + General: "The source " + ctx.SourceType + " has SELECT permission on the " + ctx.TargetName + " database (granted directly or through the db_datareader fixed role). This allows reading data from all tables and views in the database, which may expose sensitive information such as credentials, personal data, or business secrets. This is a non-traversable, informational edge.", + WindowsAbuse: abuse, + LinuxAbuse: abuse, + Opsec: "SELECT statements are not logged by SQL Server's default trace. Data access auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" + + "Reading data is generally low-risk from a detection standpoint unless dedicated database activity monitoring is in place.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles", + } + }, + + EdgeKinds.WriteDB: func(ctx *EdgeContext) EdgeProperties { + abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and modify data in the " + ctx.TargetName + " database:\n" + + "USE " + ctx.TargetName + "; \n" + + "-- Insert data \n" + + "INSERT INTO [schema].[table] (column1) VALUES ('value'); \n" + + "-- Update data \n" + + "UPDATE [schema].[table] SET column1 = 'value' WHERE ; " + return EdgeProperties{ + General: "The source " + ctx.SourceType + " has INSERT and/or UPDATE permission on the " + ctx.TargetName + " database (granted directly or through the db_datawriter fixed role). This allows modifying data in tables, which could be abused to tamper with application data, escalate privileges within an application, or plant malicious content. This is a non-traversable, informational edge.", + WindowsAbuse: abuse, + LinuxAbuse: abuse, + Opsec: "INSERT and UPDATE statements are not logged by SQL Server's default trace. Data modification auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" + + "Triggers on the target tables may generate side effects or log entries.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles", + } + }, + + EdgeKinds.DeleteDB: func(ctx *EdgeContext) EdgeProperties { + abuse := "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " and delete data from the " + ctx.TargetName + " database:\n" + + "USE " + ctx.TargetName + "; \n" + + "-- Delete data \n" + + "DELETE FROM [schema].[table] WHERE ; \n" + + "WARNING: Deleting data may cause data loss and application outages. Do not run destructive statements against production systems without authorization." + return EdgeProperties{ + General: "The source " + ctx.SourceType + " has DELETE permission on the " + ctx.TargetName + " database (granted directly or through the db_datawriter fixed role). This allows deleting rows from tables, which could be abused to destroy data, cause application outages, or remove evidence of prior activity. This is a non-traversable, informational edge.", + WindowsAbuse: abuse, + LinuxAbuse: abuse, + Opsec: "DELETE statements are not logged by SQL Server's default trace. Data modification auditing requires SQL Server Audit or Extended Events sessions to be explicitly configured. \n" + + "Deleting data is destructive and may be noticed through application errors or data integrity checks even without dedicated monitoring.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles", + } + }, + EdgeKinds.Impersonate: func(ctx *EdgeContext) EdgeProperties { var windowsAbuse, linuxAbuse, opsec string if ctx.DatabaseName != "" { diff --git a/internal/bloodhound/schema.json b/internal/bloodhound/schema.json index 2b6eb54..f7f1106 100755 --- a/internal/bloodhound/schema.json +++ b/internal/bloodhound/schema.json @@ -238,6 +238,21 @@ "name": "MSSQL_TakeOwnership", "description": "", "is_traversable": false + }, + { + "name": "MSSQL_ReadDB", + "description": "", + "is_traversable": false + }, + { + "name": "MSSQL_WriteDB", + "description": "", + "is_traversable": false + }, + { + "name": "MSSQL_DeleteDB", + "description": "", + "is_traversable": false } ], "environments": [ diff --git a/internal/bloodhound/seed_data.json b/internal/bloodhound/seed_data.json index 4f1ce5b..63d7108 100644 --- a/internal/bloodhound/seed_data.json +++ b/internal/bloodhound/seed_data.json @@ -26,6 +26,7 @@ { "kind": "MSSQL_Control", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ControlDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ControlServer", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_DeleteDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ExecuteAs", "start": { "value": "dbuser-6cfe3d9a-9c2e-4d73-bc70-8a53f8bb5d61" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ExecuteAsOwner", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ExecuteOnHost", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, @@ -46,8 +47,10 @@ { "kind": "MSSQL_LinkedTo", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_MemberOf", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_Owns", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ReadDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, { "kind": "MSSQL_ServiceAccountFor", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, - { "kind": "MSSQL_TakeOwnership", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } } + { "kind": "MSSQL_TakeOwnership", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_WriteDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } } ] } } diff --git a/internal/bloodhound/writer.go b/internal/bloodhound/writer.go index 9429a3e..76d3f71 100644 --- a/internal/bloodhound/writer.go +++ b/internal/bloodhound/writer.go @@ -367,6 +367,9 @@ var EdgeKinds = struct { ControlDB string ControlDBRole string ControlDBUser string + ReadDB string + WriteDB string + DeleteDB string ControlLogin string ControlServerRole string Impersonate string @@ -419,6 +422,9 @@ var EdgeKinds = struct { ControlDB: "MSSQL_ControlDB", ControlDBRole: "MSSQL_ControlDBRole", ControlDBUser: "MSSQL_ControlDBUser", + ReadDB: "MSSQL_ReadDB", + WriteDB: "MSSQL_WriteDB", + DeleteDB: "MSSQL_DeleteDB", ControlLogin: "MSSQL_ControlLogin", ControlServerRole: "MSSQL_ControlServerRole", Impersonate: "MSSQL_Impersonate", diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 3061e09..5fde360 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -65,6 +65,7 @@ type Config struct { SkipADNodeCreation bool DisableNontraversableEdges bool DisablePossibleEdges bool + EnableDataAccessEdges bool // Emit non-traversable ReadDB/WriteDB/DeleteDB edges to databases SkipIPDedupe bool // Skip DNS-based IP deduplication of targets // Timeouts and limits @@ -5364,6 +5365,23 @@ func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, ser // db_accessadmin does NOT have any special permissions that create edges // Its role is to manage database access (adding users), which is handled // through its membership in the database, not through explicit permissions + + case "db_datareader": + // db_datareader has implicit SELECT on all tables/views in the database. + // Emit a non-traversable ReadDB edge (opt-in via --enable-data-access-edges). + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.ReadDB, &principal, &db, serverInfo, "SELECT", true); err != nil { + return err + } + + case "db_datawriter": + // db_datawriter has implicit INSERT/UPDATE/DELETE on all tables in the + // database. Emit non-traversable WriteDB and DeleteDB edges (opt-in). + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.WriteDB, &principal, &db, serverInfo, "INSERT, UPDATE", true); err != nil { + return err + } + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.DeleteDB, &principal, &db, serverInfo, "DELETE", true); err != nil { + return err + } } } } @@ -6096,6 +6114,32 @@ func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWr } } break + + // Data-access permissions (opt-in, non-traversable). writeDataAccessEdge + // is a no-op unless --enable-data-access-edges is set. INSERT and UPDATE + // both map to the WriteDB edge; the writer's edge-dedup collapses the + // duplicate when a principal holds both. + case "SELECT": + if perm.ClassDesc == "DATABASE" { + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.ReadDB, &principal, db, serverInfo, perm.Permission, false); err != nil { + return err + } + } + break + case "INSERT", "UPDATE": + if perm.ClassDesc == "DATABASE" { + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.WriteDB, &principal, db, serverInfo, perm.Permission, false); err != nil { + return err + } + } + break + case "DELETE": + if perm.ClassDesc == "DATABASE" { + if err := c.writeDataAccessEdge(writer, bloodhound.EdgeKinds.DeleteDB, &principal, db, serverInfo, perm.Permission, false); err != nil { + return err + } + } + break case "ALTER": if perm.ClassDesc == "DATABASE" { // ALTER on the database itself - use MSSQL_Alter to match PowerShell @@ -6524,6 +6568,37 @@ func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWr return nil } +// writeDataAccessEdge emits a non-traversable data-access edge (ReadDB/WriteDB/ +// DeleteDB) from a database principal to its database. These edges are opt-in and +// only created when the --enable-data-access-edges flag is set; when the flag is +// off this is a no-op so existing output is unchanged. +func (c *Collector) writeDataAccessEdge(writer *bloodhound.StreamingWriter, kind string, principal *types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo, permission string, isFixedRole bool) error { + if !c.config.EnableDataAccessEdges { + return nil + } + edge := c.createEdge( + principal.ObjectIdentifier, + db.ObjectIdentifier, + kind, + &bloodhound.EdgeContext{ + SourceName: principal.Name, + SourceType: c.getDatabasePrincipalType(principal.TypeDescription), + TargetName: db.Name, + TargetType: bloodhound.NodeKinds.Database, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + DatabaseName: db.Name, + Permission: permission, + IsFixedRole: isFixedRole, + }, + ) + if edge == nil { + return nil + } + return writer.WriteEdge(edge) +} + // createEdge creates a BloodHound edge with properties. // Returns nil if the edge is non-traversable and DisableNontraversableEdges is true. func (c *Collector) createEdge(sourceID, targetID, kind string, ctx *bloodhound.EdgeContext) *bloodhound.Edge { diff --git a/internal/collector/edge_test_data_test.go b/internal/collector/edge_test_data_test.go index 17d1da2..9592b30 100644 --- a/internal/collector/edge_test_data_test.go +++ b/internal/collector/edge_test_data_test.go @@ -673,6 +673,28 @@ var takeOwnershipTestCases = []edgeTestCase{ {EdgeType: "MSSQL_TakeOwnership", Description: "User without TAKE OWNERSHIP permission has no edge", SourcePattern: "TakeOwnershipTest_User_NoPermission@*", TargetPattern: "*", Negative: true, Reason: "No TAKE OWNERSHIP permission granted"}, } +// --------------------------------------------------------------------------- +// MSSQL_ReadDB / MSSQL_WriteDB / MSSQL_DeleteDB (opt-in data-access edges) +// --------------------------------------------------------------------------- + +var readDBTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ReadDB", Description: "DatabaseUser with SELECT on database", SourcePattern: "DataAccessTest_User_HasSelect@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_ReadDB", Description: "DatabaseRole with SELECT on database", SourcePattern: "DataAccessTest_DbRole_HasSelect@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_ReadDB", Description: "db_datareader has implicit SELECT", SourcePattern: "db_datareader@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_ReadDB", Description: "DatabaseUser with SELECT denied has no edge", SourcePattern: "DataAccessTest_User_DeniedSelect@*\\EdgeTest_DataAccess", TargetPattern: "*", Negative: true, Reason: "SELECT is denied"}, +} + +var writeDBTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_WriteDB", Description: "DatabaseUser with INSERT on database", SourcePattern: "DataAccessTest_User_HasInsert@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_WriteDB", Description: "DatabaseUser with UPDATE on database", SourcePattern: "DataAccessTest_User_HasUpdate@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_WriteDB", Description: "db_datawriter has implicit INSERT/UPDATE", SourcePattern: "db_datawriter@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, +} + +var deleteDBTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_DeleteDB", Description: "DatabaseUser with DELETE on database", SourcePattern: "DataAccessTest_User_HasDelete@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, + {EdgeType: "MSSQL_DeleteDB", Description: "db_datawriter has implicit DELETE", SourcePattern: "db_datawriter@*\\EdgeTest_DataAccess", TargetPattern: "*\\EdgeTest_DataAccess"}, +} + // --------------------------------------------------------------------------- // allTestCases aggregates all edge type test cases into a single slice. // This is used for coverage analysis and integration test validation. @@ -695,6 +717,9 @@ var allTestCases = func() []edgeTestCase { all = append(all, controlTestCases...) all = append(all, controlDBTestCases...) all = append(all, controlServerTestCases...) + all = append(all, readDBTestCases...) + all = append(all, writeDBTestCases...) + all = append(all, deleteDBTestCases...) all = append(all, executeAsTestCases...) all = append(all, executeAsOwnerTestCases...) all = append(all, executeOnHostTestCases...) diff --git a/internal/collector/edge_test_helpers_test.go b/internal/collector/edge_test_helpers_test.go index f9f22a9..8901b03 100644 --- a/internal/collector/edge_test_helpers_test.go +++ b/internal/collector/edge_test_helpers_test.go @@ -216,6 +216,17 @@ type edgeTestResult struct { // back the resulting nodes and edges. func runEdgeCreation(t *testing.T, serverInfo *types.ServerInfo, includeNontraversable bool) edgeTestResult { t.Helper() + return runEdgeCreationWithConfig(t, serverInfo, &Config{ + Domain: "domain.com", + DisableNontraversableEdges: !includeNontraversable, + }) +} + +// runEdgeCreationWithConfig runs edge creation with a caller-supplied Config, +// allowing tests to toggle options such as EnableDataAccessEdges. TempDir is +// overwritten with a fresh per-test temp directory. +func runEdgeCreationWithConfig(t *testing.T, serverInfo *types.ServerInfo, config *Config) edgeTestResult { + t.Helper() tmpDir, err := os.MkdirTemp("", "mssqlhound-edge-test") if err != nil { @@ -223,11 +234,7 @@ func runEdgeCreation(t *testing.T, serverInfo *types.ServerInfo, includeNontrave } t.Cleanup(func() { os.RemoveAll(tmpDir) }) - config := &Config{ - TempDir: tmpDir, - Domain: "domain.com", - DisableNontraversableEdges: !includeNontraversable, - } + config.TempDir = tmpDir c, _ := New(config) outputPath := filepath.Join(tmpDir, "test-output.json") diff --git a/internal/collector/edge_unit_test.go b/internal/collector/edge_unit_test.go index 95c9aa2..a6d2f09 100644 --- a/internal/collector/edge_unit_test.go +++ b/internal/collector/edge_unit_test.go @@ -1695,6 +1695,67 @@ func TestServiceAccountForEdges(t *testing.T) { runTestCases(t, result.Edges,serviceAccountForTestCases) } +// ============================================================================= +// READDB / WRITEDB / DELETEDB (opt-in data-access edges) +// ============================================================================= + +func buildDataAccessTestData() *types.ServerInfo { + info := baseServerInfo() + db := addDatabase(info, "EdgeTest_DataAccess") + + // Explicit DATABASE-scoped grants + addDatabaseUser(db, "DataAccessTest_User_HasSelect", + withDBPrincipalPermissions(perm("SELECT", "GRANT", "DATABASE"))) + addDatabaseUser(db, "DataAccessTest_User_HasInsert", + withDBPrincipalPermissions(perm("INSERT", "GRANT", "DATABASE"))) + addDatabaseUser(db, "DataAccessTest_User_HasUpdate", + withDBPrincipalPermissions(perm("UPDATE", "GRANT", "DATABASE"))) + addDatabaseUser(db, "DataAccessTest_User_HasDelete", + withDBPrincipalPermissions(perm("DELETE", "GRANT", "DATABASE"))) + + // Custom (non-fixed) database role with SELECT + addDatabaseRole(db, "DataAccessTest_DbRole_HasSelect", false, + withDBPrincipalPermissions(perm("SELECT", "GRANT", "DATABASE"))) + + // DENY must not produce an edge + addDatabaseUser(db, "DataAccessTest_User_DeniedSelect", + withDBPrincipalPermissions(perm("SELECT", "DENY", "DATABASE"))) + + // Fixed roles: db_datareader -> ReadDB; db_datawriter -> WriteDB + DeleteDB + addDatabaseRole(db, "db_datareader", true) + addDatabaseRole(db, "db_datawriter", true) + + return info +} + +// TestDataAccessEdges verifies the opt-in Read/Write/Delete edges are created +// when EnableDataAccessEdges is set. +func TestDataAccessEdges(t *testing.T) { + info := buildDataAccessTestData() + result := runEdgeCreationWithConfig(t, info, &Config{ + Domain: "domain.com", + EnableDataAccessEdges: true, + }) + runTestCases(t, result.Edges, readDBTestCases) + runTestCases(t, result.Edges, writeDBTestCases) + runTestCases(t, result.Edges, deleteDBTestCases) +} + +// TestDataAccessEdgesDisabledByDefault verifies that without the opt-in flag no +// data-access edges are emitted (default behavior is unchanged). +func TestDataAccessEdgesDisabledByDefault(t *testing.T) { + info := buildDataAccessTestData() + // runEdgeCreation does not set EnableDataAccessEdges; includeNontraversable + // is true so absence is due to the flag, not non-traversable filtering. + result := runEdgeCreation(t, info, true) + for _, e := range result.Edges { + switch e.Kind { + case bloodhound.EdgeKinds.ReadDB, bloodhound.EdgeKinds.WriteDB, bloodhound.EdgeKinds.DeleteDB: + t.Errorf("data-access edge %s created without --enable-data-access-edges (%s -> %s)", e.Kind, e.Start.Value, e.End.Value) + } + } +} + // ============================================================================= // COVERAGE TEST: Verify all edge types have test cases // ============================================================================= @@ -1742,6 +1803,9 @@ func TestAllEdgeTypesHaveCoverage(t *testing.T) { bloodhound.EdgeKinds.Owns, bloodhound.EdgeKinds.ServiceAccountFor, bloodhound.EdgeKinds.TakeOwnership, + bloodhound.EdgeKinds.ReadDB, + bloodhound.EdgeKinds.WriteDB, + bloodhound.EdgeKinds.DeleteDB, } for _, kind := range allKinds { From f14d0a425ab6052ae9dd0d4adb87d153c0092acf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 16:50:57 +0000 Subject: [PATCH 2/2] Fix README: move data-access usage example inside code fence The --enable-data-access-edges example was placed after the closing code fence, causing the shell-comment lines to render as markdown headings. Move it inside the fenced bash block with the other examples. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01K5TQ7KrD4DuRdFvQ56ChbM --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index f1ad7d0..4daf4a8 100644 --- a/README.md +++ b/README.md @@ -587,13 +587,13 @@ export BLOODHOUND_TOKEN_KEY= # Skip AD node creation (collect only MSSQL nodes, no User/Group/Computer nodes) ./mssqlhound -t sql.contoso.com --skip-ad-nodes -``` # Add non-traversable data-access edges (read/write/delete) to databases # Draws MSSQL_ReadDB (SELECT), MSSQL_WriteDB (INSERT/UPDATE), and MSSQL_DeleteDB # (DELETE) edges from principals with explicit DATABASE-scoped grants or the # db_datareader / db_datawriter fixed roles. Off by default. ./mssqlhound -t sql.contoso.com --enable-data-access-edges +``` ### Linked Server Options