@@ -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
169186func 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)
593694func (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