Skip to content

Commit 21ab85e

Browse files
committed
Move AD edges to separate file so selective deletion by source_kind does not apply to AD nodes
1 parent 484183c commit 21ab85e

8 files changed

Lines changed: 368 additions & 74 deletions

README.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -314,12 +314,13 @@ If all three methods fail, a warning is logged: `Could not determine service acc
314314
mssql-{hostname}_{port}.json Non-default port
315315
mssql-{hostname}_{port}_{instance}.json Named instance
316316
mssql-{hostname}.log Per-server log (only if per-target logging enabled)
317-
computers.json AD computer nodes (unless --skip-ad-nodes)
318-
users.json AD user nodes (unless --skip-ad-nodes)
319-
groups.json AD group nodes (unless --skip-ad-nodes)
320-
321-
{current directory or --zip-dir}/
322-
mssql-bloodhound-YYYYMMDD-HHMMSS.zip Final output (contains all JSON files above)
317+
computers.json AD computer nodes (unless --skip-ad-nodes)
318+
users.json AD user nodes (unless --skip-ad-nodes)
319+
groups.json AD group nodes (unless --skip-ad-nodes)
320+
ad_edges.json Edges touching AD nodes, without source_kind metadata
321+
322+
{current directory or --zip-dir}/
323+
mssql-bloodhound-YYYYMMDD-HHMMSS.zip Final output (contains all JSON files above)
323324
mssql-logs-YYYYMMDD-HHMMSS.zip Log archive (only if per-target logging enabled)
324325
```
325326

@@ -582,8 +583,8 @@ export BLOODHOUND_TOKEN_KEY=<token-key>
582583
# Disable possible edges (stricter pathfinding, fewer false positives)
583584
./mssqlhound -t sql.contoso.com --disable-possible-edges
584585

585-
# Skip AD node creation (collect only MSSQL nodes, no User/Group/Computer nodes)
586-
./mssqlhound -t sql.contoso.com --skip-ad-nodes
586+
# Skip AD node creation (still emits AD-touching edges in ad_edges.json)
587+
./mssqlhound -t sql.contoso.com --skip-ad-nodes
587588
```
588589

589590
### Linked Server Options
@@ -677,7 +678,7 @@ mssqlhound completion powershell | Out-String | Invoke-Expression
677678
| `--skip-linked-servers` | false | Don't enumerate linked servers |
678679
| `--collect-from-linked` | false | Queue discovered linked servers as additional direct targets and collect them in later passes |
679680
| `--linked-timeout` | 300 | Linked server enumeration timeout (seconds) |
680-
| `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes |
681+
| `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes; AD-touching edges are still emitted to `ad_edges.json` |
681682
| `--disable-nontraversable-edges` | false | Disable non-traversable edges |
682683
| `--disable-possible-edges` | false | Disable possible edges (makes them non-traversable in schema and edge data) |
683684
| `-w, --workers` | 0 | Number of concurrent workers (0 = sequential processing) |

internal/collector/collector.go

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,11 @@ type Collector struct {
120120
adSeenNodes map[string]bool // Dedup AD nodes by ID across servers
121121
adNodesMu sync.Mutex // Protects adComputers, adUsers, adGroups, adSeenNodes
122122

123+
// Accumulated AD-touching edges across all servers for sourceless output.
124+
adEdgesWriter *bloodhound.StreamingWriter
125+
adEdgesPath string
126+
adEdgesMu sync.Mutex // Protects adEdgesWriter and adEdgesPath
127+
123128
// Aggregate node/edge counts across all output files for the end-of-run summary
124129
totalNodesByKind map[string]int
125130
totalEdgesByKind map[string]int
@@ -165,6 +170,18 @@ type ServerSPNInfo struct {
165170
AccountSID string
166171
}
167172

173+
const adEdgesFilename = "ad_edges.json"
174+
175+
type edgeSink interface {
176+
WriteEdge(*bloodhound.Edge) error
177+
}
178+
179+
type adEdgeRouter struct {
180+
collector *Collector
181+
primary edgeSink
182+
adEdges []*bloodhound.Edge
183+
}
184+
168185
// New creates a new collector
169186
func New(config *Config) (*Collector, error) {
170187
if config.Logger == nil {
@@ -412,6 +429,10 @@ func (c *Collector) Run() error {
412429
}
413430
}
414431

432+
if err := c.closeADEdgesFile(); err != nil {
433+
return fmt.Errorf("failed to write AD edge file: %w", err)
434+
}
435+
415436
// Create zip file
416437
if len(c.outputFiles) > 0 {
417438
var err error
@@ -589,6 +610,86 @@ func (c *Collector) mergeTypeStats(nodesByKind, edgesByKind map[string]int) {
589610
}
590611
}
591612

613+
func (c *Collector) newADEdgeRouter(primary edgeSink) *adEdgeRouter {
614+
return &adEdgeRouter{
615+
collector: c,
616+
primary: primary,
617+
}
618+
}
619+
620+
func (r *adEdgeRouter) WriteEdge(edge *bloodhound.Edge) error {
621+
if edge == nil {
622+
return r.primary.WriteEdge(edge)
623+
}
624+
if r.collector.edgeTouchesADNode(edge) {
625+
r.adEdges = append(r.adEdges, edge)
626+
return nil
627+
}
628+
return r.primary.WriteEdge(edge)
629+
}
630+
631+
func (r *adEdgeRouter) FlushADEdges() error {
632+
return r.collector.writeADEdges(r.adEdges)
633+
}
634+
635+
func (c *Collector) edgeTouchesADNode(edge *bloodhound.Edge) bool {
636+
if edge == nil {
637+
return false
638+
}
639+
c.adNodesMu.Lock()
640+
defer c.adNodesMu.Unlock()
641+
return c.adSeenNodes[edge.Start.Value] || c.adSeenNodes[edge.End.Value]
642+
}
643+
644+
func (c *Collector) writeADEdges(edges []*bloodhound.Edge) error {
645+
if len(edges) == 0 {
646+
return nil
647+
}
648+
649+
c.adEdgesMu.Lock()
650+
defer c.adEdgesMu.Unlock()
651+
652+
if c.adEdgesWriter == nil {
653+
filePath := filepath.Join(c.tempDir, adEdgesFilename)
654+
writer, err := bloodhound.NewStreamingWriterNoSourceKind(filePath)
655+
if err != nil {
656+
return fmt.Errorf("failed to create %s: %w", adEdgesFilename, err)
657+
}
658+
c.adEdgesWriter = writer
659+
c.adEdgesPath = filePath
660+
}
661+
662+
for _, edge := range edges {
663+
if err := c.adEdgesWriter.WriteEdge(edge); err != nil {
664+
return fmt.Errorf("failed to write edge to %s: %w", adEdgesFilename, err)
665+
}
666+
}
667+
return nil
668+
}
669+
670+
func (c *Collector) closeADEdgesFile() error {
671+
c.adEdgesMu.Lock()
672+
defer c.adEdgesMu.Unlock()
673+
674+
if c.adEdgesWriter == nil {
675+
return nil
676+
}
677+
678+
if err := c.adEdgesWriter.Close(); err != nil {
679+
return fmt.Errorf("failed to close %s: %w", adEdgesFilename, err)
680+
}
681+
682+
c.addOutputFile(c.adEdgesPath)
683+
_, edges := c.adEdgesWriter.Stats()
684+
nodesByKind, edgesByKind := c.adEdgesWriter.TypeStats()
685+
c.mergeTypeStats(nodesByKind, edgesByKind)
686+
c.config.Logger.Info("Wrote AD edge file", "edges", edges, "file", adEdgesFilename)
687+
688+
c.adEdgesWriter = nil
689+
c.adEdgesPath = ""
690+
return nil
691+
}
692+
592693
// addLogFile adds a per-target log file to the list (thread-safe)
593694
func (c *Collector) addLogFile(path string) {
594695
c.logFilesMu.Lock()
@@ -2473,7 +2574,12 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri
24732574
if err != nil {
24742575
return err
24752576
}
2476-
defer writer.Close()
2577+
writerClosed := false
2578+
defer func() {
2579+
if !writerClosed {
2580+
writer.Close()
2581+
}
2582+
}()
24772583

24782584
// Create server node
24792585
serverNode := c.createServerNode(serverInfo)
@@ -2562,16 +2668,15 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri
25622668
}
25632669
}
25642670

2565-
// Collect AD nodes (User, Group, Computer) if not skipped.
2566-
// These are accumulated across servers and written to separate files (computers.json, users.json, groups.json).
2567-
if !c.config.SkipADNodeCreation {
2568-
if err := c.createADNodes(serverInfo); err != nil {
2569-
return err
2570-
}
2671+
// Collect AD nodes into the internal endpoint index for edge routing.
2672+
// When --skip-ad-nodes is set, the nodes are indexed but not written to AD node files.
2673+
if err := c.createADNodes(serverInfo); err != nil {
2674+
return err
25712675
}
25722676

25732677
// Create edges
2574-
if err := c.createEdges(writer, serverInfo); err != nil {
2678+
edgeRouter := c.newADEdgeRouter(writer)
2679+
if err := c.createEdges(edgeRouter, serverInfo); err != nil {
25752680
return err
25762681
}
25772682

@@ -2607,6 +2712,15 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri
26072712
c.config.Logger.Info("Node and edge counts by type", args...)
26082713
c.mergeTypeStats(nodesByKind, edgesByKind)
26092714

2715+
if err := writer.Close(); err != nil {
2716+
return err
2717+
}
2718+
writerClosed = true
2719+
2720+
if err := edgeRouter.FlushADEdges(); err != nil {
2721+
return err
2722+
}
2723+
26102724
return nil
26112725
}
26122726

@@ -2943,6 +3057,10 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error {
29433057
}
29443058
c.adSeenNodes[node.ID] = true
29453059

3060+
if c.config.SkipADNodeCreation {
3061+
return
3062+
}
3063+
29463064
// Categorize by primary kind (first element)
29473065
switch node.Kinds[0] {
29483066
case bloodhound.NodeKinds.Computer:
@@ -3024,7 +3142,7 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error {
30243142
// Resolve domain login SIDs via LDAP for AD enrichment (matching PowerShell behavior).
30253143
// This provides properties like SAMAccountName, distinguishedName, DNSHostName, etc.
30263144
resolvedPrincipals := make(map[string]*types.DomainPrincipal)
3027-
if c.config.Domain != "" {
3145+
if !c.config.SkipADNodeCreation && c.config.Domain != "" {
30283146
adClient := c.newADClient(c.config.Domain)
30293147
if adClient != nil {
30303148
for _, principal := range serverInfo.ServerPrincipals {
@@ -3346,7 +3464,7 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error {
33463464
}
33473465

33483466
// createEdges creates all edges for the server
3349-
func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
3467+
func (c *Collector) createEdges(writer edgeSink, serverInfo *types.ServerInfo) error {
33503468
// =========================================================================
33513469
// CONTAINS EDGES
33523470
// =========================================================================
@@ -3979,7 +4097,7 @@ func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *
39794097
// Create HasLogin edges for local groups that have SQL logins
39804098
// This processes ALL local groups (not just BUILTIN S-1-5-32-*), matching PowerShell behavior.
39814099
// LocalGroupsWithLogins contains groups collected via WMI/net localgroup enumeration.
3982-
if serverInfo.LocalGroupsWithLogins != nil {
4100+
if len(serverInfo.LocalGroupsWithLogins) > 0 {
39834101
for _, groupInfo := range serverInfo.LocalGroupsWithLogins {
39844102
if groupInfo.Principal == nil || groupInfo.Principal.SecurityIdentifier == "" {
39854103
continue
@@ -4997,7 +5115,7 @@ func (c *Collector) processLinkedServersQueue(processedServers map[string]bool)
49975115
}
49985116

49995117
// createFixedRoleEdges creates edges for fixed server and database role capabilities
5000-
func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
5118+
func (c *Collector) createFixedRoleEdges(writer edgeSink, serverInfo *types.ServerInfo) error {
50015119
// Fixed server roles with special capabilities
50025120
for _, principal := range serverInfo.ServerPrincipals {
50035121
if principal.TypeDescription != "SERVER_ROLE" || !principal.IsFixedRole {
@@ -5372,7 +5490,7 @@ func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, ser
53725490
}
53735491

53745492
// createServerPermissionEdges creates edges based on server-level permissions
5375-
func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
5493+
func (c *Collector) createServerPermissionEdges(writer edgeSink, serverInfo *types.ServerInfo) error {
53765494
principalMap := make(map[int]*types.ServerPrincipal)
53775495
for i := range serverInfo.ServerPrincipals {
53785496
principalMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i]
@@ -5908,7 +6026,7 @@ func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWrit
59086026
}
59096027

59106028
// createDatabasePermissionEdges creates edges based on database-level permissions
5911-
func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWriter, db *types.Database, serverInfo *types.ServerInfo) error {
6029+
func (c *Collector) createDatabasePermissionEdges(writer edgeSink, db *types.Database, serverInfo *types.ServerInfo) error {
59126030
principalMap := make(map[int]*types.DatabasePrincipal)
59136031
for i := range db.DatabasePrincipals {
59146032
principalMap[db.DatabasePrincipals[i].PrincipalID] = &db.DatabasePrincipals[i]

0 commit comments

Comments
 (0)