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 9135d17..55c479c 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 @@ -583,8 +586,14 @@ export BLOODHOUND_TOKEN_KEY= # Disable possible edges (stricter pathfinding, fewer false positives) ./mssqlhound -t sql.contoso.com --disable-possible-edges -# Skip AD node creation (still emits AD-touching edges in ad_edges.json) -./mssqlhound -t sql.contoso.com --skip-ad-nodes +# Skip AD node creation (still emits AD-touching edges in ad_edges.json) +./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 @@ -681,6 +690,7 @@ mssqlhound completion powershell | Out-String | Invoke-Expression | `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes; AD-touching edges are still emitted to `ad_edges.json` | | `--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 @@ -1292,6 +1302,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 | @@ -1320,10 +1332,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 101ef20..5891e04 100644 --- a/cmd/mssqlhound/main.go +++ b/cmd/mssqlhound/main.go @@ -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)") @@ -164,7 +166,7 @@ Collects BloodHound OpenGraph compatible data from one or more MSSQL servers int } 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"} { + "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", @@ -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 339862a..3b3c14a 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 @@ -120,11 +121,11 @@ type Collector struct { adSeenNodes map[string]bool // Dedup AD nodes by ID across servers adNodesMu sync.Mutex // Protects adComputers, adUsers, adGroups, adSeenNodes - // Accumulated AD-touching edges across all servers for sourceless output. - adEdgesWriter *bloodhound.StreamingWriter - adEdgesPath string - adEdgesMu sync.Mutex // Protects adEdgesWriter and adEdgesPath - + // Accumulated AD-touching edges across all servers for sourceless output. + adEdgesWriter *bloodhound.StreamingWriter + adEdgesPath string + adEdgesMu sync.Mutex // Protects adEdgesWriter and adEdgesPath + // Aggregate node/edge counts across all output files for the end-of-run summary totalNodesByKind map[string]int totalEdgesByKind map[string]int @@ -170,18 +171,18 @@ type ServerSPNInfo struct { AccountSID string } -const adEdgesFilename = "ad_edges.json" - -type edgeSink interface { - WriteEdge(*bloodhound.Edge) error -} - -type adEdgeRouter struct { - collector *Collector - primary edgeSink - adEdges []*bloodhound.Edge -} - +const adEdgesFilename = "ad_edges.json" + +type edgeSink interface { + WriteEdge(*bloodhound.Edge) error +} + +type adEdgeRouter struct { + collector *Collector + primary edgeSink + adEdges []*bloodhound.Edge +} + // New creates a new collector func New(config *Config) (*Collector, error) { if config.Logger == nil { @@ -429,10 +430,10 @@ func (c *Collector) Run() error { } } - if err := c.closeADEdgesFile(); err != nil { - return fmt.Errorf("failed to write AD edge file: %w", err) - } - + if err := c.closeADEdgesFile(); err != nil { + return fmt.Errorf("failed to write AD edge file: %w", err) + } + // Create zip file if len(c.outputFiles) > 0 { var err error @@ -610,86 +611,86 @@ func (c *Collector) mergeTypeStats(nodesByKind, edgesByKind map[string]int) { } } -func (c *Collector) newADEdgeRouter(primary edgeSink) *adEdgeRouter { - return &adEdgeRouter{ - collector: c, - primary: primary, - } -} - -func (r *adEdgeRouter) WriteEdge(edge *bloodhound.Edge) error { - if edge == nil { - return r.primary.WriteEdge(edge) - } - if r.collector.edgeTouchesADNode(edge) { - r.adEdges = append(r.adEdges, edge) - return nil - } - return r.primary.WriteEdge(edge) -} - -func (r *adEdgeRouter) FlushADEdges() error { - return r.collector.writeADEdges(r.adEdges) -} - -func (c *Collector) edgeTouchesADNode(edge *bloodhound.Edge) bool { - if edge == nil { - return false - } - c.adNodesMu.Lock() - defer c.adNodesMu.Unlock() - return c.adSeenNodes[edge.Start.Value] || c.adSeenNodes[edge.End.Value] -} - -func (c *Collector) writeADEdges(edges []*bloodhound.Edge) error { - if len(edges) == 0 { - return nil - } - - c.adEdgesMu.Lock() - defer c.adEdgesMu.Unlock() - - if c.adEdgesWriter == nil { - filePath := filepath.Join(c.tempDir, adEdgesFilename) - writer, err := bloodhound.NewStreamingWriterNoSourceKind(filePath) - if err != nil { - return fmt.Errorf("failed to create %s: %w", adEdgesFilename, err) - } - c.adEdgesWriter = writer - c.adEdgesPath = filePath - } - - for _, edge := range edges { - if err := c.adEdgesWriter.WriteEdge(edge); err != nil { - return fmt.Errorf("failed to write edge to %s: %w", adEdgesFilename, err) - } - } - return nil -} - -func (c *Collector) closeADEdgesFile() error { - c.adEdgesMu.Lock() - defer c.adEdgesMu.Unlock() - - if c.adEdgesWriter == nil { - return nil - } - - if err := c.adEdgesWriter.Close(); err != nil { - return fmt.Errorf("failed to close %s: %w", adEdgesFilename, err) - } - - c.addOutputFile(c.adEdgesPath) - _, edges := c.adEdgesWriter.Stats() - nodesByKind, edgesByKind := c.adEdgesWriter.TypeStats() - c.mergeTypeStats(nodesByKind, edgesByKind) - c.config.Logger.Info("Wrote AD edge file", "edges", edges, "file", adEdgesFilename) - - c.adEdgesWriter = nil - c.adEdgesPath = "" - return nil -} - +func (c *Collector) newADEdgeRouter(primary edgeSink) *adEdgeRouter { + return &adEdgeRouter{ + collector: c, + primary: primary, + } +} + +func (r *adEdgeRouter) WriteEdge(edge *bloodhound.Edge) error { + if edge == nil { + return r.primary.WriteEdge(edge) + } + if r.collector.edgeTouchesADNode(edge) { + r.adEdges = append(r.adEdges, edge) + return nil + } + return r.primary.WriteEdge(edge) +} + +func (r *adEdgeRouter) FlushADEdges() error { + return r.collector.writeADEdges(r.adEdges) +} + +func (c *Collector) edgeTouchesADNode(edge *bloodhound.Edge) bool { + if edge == nil { + return false + } + c.adNodesMu.Lock() + defer c.adNodesMu.Unlock() + return c.adSeenNodes[edge.Start.Value] || c.adSeenNodes[edge.End.Value] +} + +func (c *Collector) writeADEdges(edges []*bloodhound.Edge) error { + if len(edges) == 0 { + return nil + } + + c.adEdgesMu.Lock() + defer c.adEdgesMu.Unlock() + + if c.adEdgesWriter == nil { + filePath := filepath.Join(c.tempDir, adEdgesFilename) + writer, err := bloodhound.NewStreamingWriterNoSourceKind(filePath) + if err != nil { + return fmt.Errorf("failed to create %s: %w", adEdgesFilename, err) + } + c.adEdgesWriter = writer + c.adEdgesPath = filePath + } + + for _, edge := range edges { + if err := c.adEdgesWriter.WriteEdge(edge); err != nil { + return fmt.Errorf("failed to write edge to %s: %w", adEdgesFilename, err) + } + } + return nil +} + +func (c *Collector) closeADEdgesFile() error { + c.adEdgesMu.Lock() + defer c.adEdgesMu.Unlock() + + if c.adEdgesWriter == nil { + return nil + } + + if err := c.adEdgesWriter.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", adEdgesFilename, err) + } + + c.addOutputFile(c.adEdgesPath) + _, edges := c.adEdgesWriter.Stats() + nodesByKind, edgesByKind := c.adEdgesWriter.TypeStats() + c.mergeTypeStats(nodesByKind, edgesByKind) + c.config.Logger.Info("Wrote AD edge file", "edges", edges, "file", adEdgesFilename) + + c.adEdgesWriter = nil + c.adEdgesPath = "" + return nil +} + // addLogFile adds a per-target log file to the list (thread-safe) func (c *Collector) addLogFile(path string) { c.logFilesMu.Lock() @@ -2574,12 +2575,12 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri if err != nil { return err } - writerClosed := false - defer func() { - if !writerClosed { - writer.Close() - } - }() + writerClosed := false + defer func() { + if !writerClosed { + writer.Close() + } + }() // Create server node serverNode := c.createServerNode(serverInfo) @@ -2668,15 +2669,15 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri } } - // Collect AD nodes into the internal endpoint index for edge routing. - // When --skip-ad-nodes is set, the nodes are indexed but not written to AD node files. - if err := c.createADNodes(serverInfo); err != nil { - return err + // Collect AD nodes into the internal endpoint index for edge routing. + // When --skip-ad-nodes is set, the nodes are indexed but not written to AD node files. + if err := c.createADNodes(serverInfo); err != nil { + return err } // Create edges - edgeRouter := c.newADEdgeRouter(writer) - if err := c.createEdges(edgeRouter, serverInfo); err != nil { + edgeRouter := c.newADEdgeRouter(writer) + if err := c.createEdges(edgeRouter, serverInfo); err != nil { return err } @@ -2712,15 +2713,15 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri c.config.Logger.Info("Node and edge counts by type", args...) c.mergeTypeStats(nodesByKind, edgesByKind) - if err := writer.Close(); err != nil { - return err - } - writerClosed = true - - if err := edgeRouter.FlushADEdges(); err != nil { - return err - } - + if err := writer.Close(); err != nil { + return err + } + writerClosed = true + + if err := edgeRouter.FlushADEdges(); err != nil { + return err + } + return nil } @@ -3057,10 +3058,10 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error { } c.adSeenNodes[node.ID] = true - if c.config.SkipADNodeCreation { - return - } - + if c.config.SkipADNodeCreation { + return + } + // Categorize by primary kind (first element) switch node.Kinds[0] { case bloodhound.NodeKinds.Computer: @@ -3142,7 +3143,7 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error { // Resolve domain login SIDs via LDAP for AD enrichment (matching PowerShell behavior). // This provides properties like SAMAccountName, distinguishedName, DNSHostName, etc. resolvedPrincipals := make(map[string]*types.DomainPrincipal) - if !c.config.SkipADNodeCreation && c.config.Domain != "" { + if !c.config.SkipADNodeCreation && c.config.Domain != "" { adClient := c.newADClient(c.config.Domain) if adClient != nil { for _, principal := range serverInfo.ServerPrincipals { @@ -3464,7 +3465,7 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error { } // createEdges creates all edges for the server -func (c *Collector) createEdges(writer edgeSink, serverInfo *types.ServerInfo) error { +func (c *Collector) createEdges(writer edgeSink, serverInfo *types.ServerInfo) error { // ========================================================================= // CONTAINS EDGES // ========================================================================= @@ -4097,7 +4098,7 @@ func (c *Collector) createEdges(writer edgeSink, serverInfo *types.ServerInfo) e // Create HasLogin edges for local groups that have SQL logins // This processes ALL local groups (not just BUILTIN S-1-5-32-*), matching PowerShell behavior. // LocalGroupsWithLogins contains groups collected via WMI/net localgroup enumeration. - if len(serverInfo.LocalGroupsWithLogins) > 0 { + if len(serverInfo.LocalGroupsWithLogins) > 0 { for _, groupInfo := range serverInfo.LocalGroupsWithLogins { if groupInfo.Principal == nil || groupInfo.Principal.SecurityIdentifier == "" { continue @@ -5115,7 +5116,7 @@ func (c *Collector) processLinkedServersQueue(processedServers map[string]bool) } // createFixedRoleEdges creates edges for fixed server and database role capabilities -func (c *Collector) createFixedRoleEdges(writer edgeSink, serverInfo *types.ServerInfo) error { +func (c *Collector) createFixedRoleEdges(writer edgeSink, serverInfo *types.ServerInfo) error { // Fixed server roles with special capabilities for _, principal := range serverInfo.ServerPrincipals { if principal.TypeDescription != "SERVER_ROLE" || !principal.IsFixedRole { @@ -5482,6 +5483,23 @@ func (c *Collector) createFixedRoleEdges(writer edgeSink, serverInfo *types.Serv // 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 + } } } } @@ -5490,7 +5508,7 @@ func (c *Collector) createFixedRoleEdges(writer edgeSink, serverInfo *types.Serv } // createServerPermissionEdges creates edges based on server-level permissions -func (c *Collector) createServerPermissionEdges(writer edgeSink, serverInfo *types.ServerInfo) error { +func (c *Collector) createServerPermissionEdges(writer edgeSink, serverInfo *types.ServerInfo) error { principalMap := make(map[int]*types.ServerPrincipal) for i := range serverInfo.ServerPrincipals { principalMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i] @@ -6026,7 +6044,7 @@ func (c *Collector) createServerPermissionEdges(writer edgeSink, serverInfo *typ } // createDatabasePermissionEdges creates edges based on database-level permissions -func (c *Collector) createDatabasePermissionEdges(writer edgeSink, db *types.Database, serverInfo *types.ServerInfo) error { +func (c *Collector) createDatabasePermissionEdges(writer edgeSink, db *types.Database, serverInfo *types.ServerInfo) error { principalMap := make(map[int]*types.DatabasePrincipal) for i := range db.DatabasePrincipals { principalMap[db.DatabasePrincipals[i].PrincipalID] = &db.DatabasePrincipals[i] @@ -6214,6 +6232,32 @@ func (c *Collector) createDatabasePermissionEdges(writer edgeSink, db *types.Dat } } 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 @@ -6642,6 +6686,37 @@ func (c *Collector) createDatabasePermissionEdges(writer edgeSink, db *types.Dat 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 edgeSink, 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 721d2c2..6783393 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 dad067a..0594335 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 c530b8b..0157d3f 100644 --- a/internal/collector/edge_unit_test.go +++ b/internal/collector/edge_unit_test.go @@ -1705,6 +1705,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 // ============================================================================= @@ -1752,6 +1813,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 {