Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,12 +314,13 @@ If all three methods fail, a warning is logged: `Could not determine service acc
mssql-{hostname}_{port}.json Non-default port
mssql-{hostname}_{port}_{instance}.json Named instance
mssql-{hostname}.log Per-server log (only if per-target logging enabled)
computers.json AD computer nodes (unless --skip-ad-nodes)
users.json AD user nodes (unless --skip-ad-nodes)
groups.json AD group nodes (unless --skip-ad-nodes)

{current directory or --zip-dir}/
mssql-bloodhound-YYYYMMDD-HHMMSS.zip Final output (contains all JSON files above)
computers.json AD computer nodes (unless --skip-ad-nodes)
users.json AD user nodes (unless --skip-ad-nodes)
groups.json AD group nodes (unless --skip-ad-nodes)
ad_edges.json Edges touching AD nodes, without source_kind metadata

{current directory or --zip-dir}/
mssql-bloodhound-YYYYMMDD-HHMMSS.zip Final output (contains all JSON files above)
mssql-logs-YYYYMMDD-HHMMSS.zip Log archive (only if per-target logging enabled)
```

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

# Skip AD node creation (collect only MSSQL nodes, no User/Group/Computer nodes)
./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
```

### Linked Server Options
Expand Down Expand Up @@ -677,7 +678,7 @@ mssqlhound completion powershell | Out-String | Invoke-Expression
| `--skip-linked-servers` | false | Don't enumerate linked servers |
| `--collect-from-linked` | false | Queue discovered linked servers as additional direct targets and collect them in later passes |
| `--linked-timeout` | 300 | Linked server enumeration timeout (seconds) |
| `--skip-ad-nodes` | false | Skip creating `User`, `Group`, `Computer` nodes |
| `--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) |
| `-w, --workers` | 0 | Number of concurrent workers (0 = sequential processing) |
Expand Down
146 changes: 132 additions & 14 deletions internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,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

// Aggregate node/edge counts across all output files for the end-of-run summary
totalNodesByKind map[string]int
totalEdgesByKind map[string]int
Expand Down Expand Up @@ -165,6 +170,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
}

// New creates a new collector
func New(config *Config) (*Collector, error) {
if config.Logger == nil {
Expand Down Expand Up @@ -412,6 +429,10 @@ func (c *Collector) Run() error {
}
}

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
Expand Down Expand Up @@ -589,6 +610,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
}

// addLogFile adds a per-target log file to the list (thread-safe)
func (c *Collector) addLogFile(path string) {
c.logFilesMu.Lock()
Expand Down Expand Up @@ -2473,7 +2574,12 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri
if err != nil {
return err
}
defer writer.Close()
writerClosed := false
defer func() {
if !writerClosed {
writer.Close()
}
}()

// Create server node
serverNode := c.createServerNode(serverInfo)
Expand Down Expand Up @@ -2562,16 +2668,15 @@ func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile stri
}
}

// Collect AD nodes (User, Group, Computer) if not skipped.
// These are accumulated across servers and written to separate files (computers.json, users.json, groups.json).
if !c.config.SkipADNodeCreation {
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
if err := c.createEdges(writer, serverInfo); err != nil {
edgeRouter := c.newADEdgeRouter(writer)
if err := c.createEdges(edgeRouter, serverInfo); err != nil {
return err
}

Expand Down Expand Up @@ -2607,6 +2712,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
}

return nil
}

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

if c.config.SkipADNodeCreation {
return
}

// Categorize by primary kind (first element)
switch node.Kinds[0] {
case bloodhound.NodeKinds.Computer:
Expand Down Expand Up @@ -3024,7 +3142,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.Domain != "" {
if !c.config.SkipADNodeCreation && c.config.Domain != "" {
adClient := c.newADClient(c.config.Domain)
if adClient != nil {
for _, principal := range serverInfo.ServerPrincipals {
Expand Down Expand Up @@ -3346,7 +3464,7 @@ func (c *Collector) createADNodes(serverInfo *types.ServerInfo) error {
}

// createEdges creates all edges for the server
func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
func (c *Collector) createEdges(writer edgeSink, serverInfo *types.ServerInfo) error {
// =========================================================================
// CONTAINS EDGES
// =========================================================================
Expand Down Expand Up @@ -3979,7 +4097,7 @@ func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *
// 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 serverInfo.LocalGroupsWithLogins != nil {
if len(serverInfo.LocalGroupsWithLogins) > 0 {
for _, groupInfo := range serverInfo.LocalGroupsWithLogins {
if groupInfo.Principal == nil || groupInfo.Principal.SecurityIdentifier == "" {
continue
Expand Down Expand Up @@ -4997,7 +5115,7 @@ func (c *Collector) processLinkedServersQueue(processedServers map[string]bool)
}

// createFixedRoleEdges creates edges for fixed server and database role capabilities
func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, 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 {
Expand Down Expand Up @@ -5372,7 +5490,7 @@ func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, ser
}

// createServerPermissionEdges creates edges based on server-level permissions
func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWriter, 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]
Expand Down Expand Up @@ -5908,7 +6026,7 @@ func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWrit
}

// createDatabasePermissionEdges creates edges based on database-level permissions
func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWriter, 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]
Expand Down
Loading
Loading