+
+
+
{{.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 "