Skip to content

Commit

Permalink
BED-4611: Citrix CanRDP Post Processing (#818)
Browse files Browse the repository at this point in the history
* add parameter for citrix rdp support

* post processing logic for citrix CanRDP and tests

* RDP harness was changed so update membership_integration_test

* add migration, cleanup parameter handler

* add test

* license

* pr feedback

* migration for params table

* why

* BED-4363 fix: schema inserts manually providing ids (#835)

* add computers to processRDPWithUra

---------

Co-authored-by: mistahj67 <[email protected]>
  • Loading branch information
brandonshearin and mistahj67 authored Sep 12, 2024
1 parent dbd4400 commit 46a7ccc
Show file tree
Hide file tree
Showing 14 changed files with 1,532 additions and 639 deletions.
4 changes: 2 additions & 2 deletions cmd/api/src/analysis/ad/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import (
"github.com/specterops/bloodhound/graphschema/ad"
)

func Post(ctx context.Context, db graph.Database, adcsEnabled bool) (*analysis.AtomicPostProcessingStats, error) {
func Post(ctx context.Context, db graph.Database, adcsEnabled bool, citrixEnabled bool) (*analysis.AtomicPostProcessingStats, error) {
aggregateStats := analysis.NewAtomicPostProcessingStats()
if stats, err := analysis.DeleteTransitEdges(ctx, db, ad.Entity, ad.Entity, adAnalysis.PostProcessedRelationships()...); err != nil {
return &aggregateStats, err
Expand All @@ -35,7 +35,7 @@ func Post(ctx context.Context, db graph.Database, adcsEnabled bool) (*analysis.A
return &aggregateStats, err
} else if syncLAPSStats, err := adAnalysis.PostSyncLAPSPassword(ctx, db, groupExpansions); err != nil {
return &aggregateStats, err
} else if localGroupStats, err := adAnalysis.PostLocalGroups(ctx, db, groupExpansions, false); err != nil {
} else if localGroupStats, err := adAnalysis.PostLocalGroups(ctx, db, groupExpansions, false, citrixEnabled); err != nil {
return &aggregateStats, err
} else if adcsStats, err := adAnalysis.PostADCS(ctx, db, groupExpansions, adcsEnabled); err != nil {
return &aggregateStats, err
Expand Down
70 changes: 61 additions & 9 deletions cmd/api/src/analysis/analysis_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func TestFetchRDPEnsureNoDescent(t *testing.T) {
require.Nil(t, err)

require.Nil(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchRDPEntityBitmapForComputer(tx, harness.RDPB.Computer.ID, groupExpansions, false)
rdpEnabledEntityIDBitmap, err := analysis.FetchRemoteDesktopUsersBitmapForComputer(tx, harness.RDPB.Computer.ID, groupExpansions, false)
require.Nil(t, err)

// We should expect all groups that have the RIL incoming privilege to the computer
Expand All @@ -55,7 +55,7 @@ func TestFetchRDPEnsureNoDescent(t *testing.T) {
})
}

func TestFetchRDPEntityBitmapForComputer(t *testing.T) {
func TestFetchRemoteDesktopUsersBitmapForComputer(t *testing.T) {
testContext := integration.NewGraphTestContext(t, schema.DefaultGraphSchema())
testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error {
harness.RDP.Setup(testContext)
Expand All @@ -66,34 +66,34 @@ func TestFetchRDPEntityBitmapForComputer(t *testing.T) {

// Enforced URA validation
require.Nil(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchRDPEntityBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, true)
rdpEnabledEntityIDBitmap, err := analysis.FetchRemoteDesktopUsersBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, true)
require.Nil(t, err)

// We should expect all groups that have the RIL incoming privilege to the computer
require.Equal(t, 6, int(rdpEnabledEntityIDBitmap.Cardinality()))
// We should expect all entities that have the RIL incoming privilege to the computer
require.Equal(t, 7, int(rdpEnabledEntityIDBitmap.Cardinality()))

require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DillonUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.IrshadUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.UliUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.EliUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DomainGroupA.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DomainGroupB.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.RohanUser.ID.Uint32()))

require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DomainGroupC.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DomainGroupD.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.DomainGroupE.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.RDPDomainUsersGroup.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.AlyxUser.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.AndyUser.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.RohanUser.ID.Uint32()))
require.False(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.JohnUser.ID.Uint32()))

return nil
}))

// Unenforced URA validation
// Unenforced URA validation. result set should only include first degree members of `Remote Desktop Users` group
require.Nil(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchRDPEntityBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, false)
rdpEnabledEntityIDBitmap, err := analysis.FetchRemoteDesktopUsersBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, false)
require.Nil(t, err)

require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDP.IrshadUser.ID.Uint32()))
Expand Down Expand Up @@ -125,8 +125,9 @@ func TestFetchRDPEntityBitmapForComputer(t *testing.T) {
groupExpansions, err = analysis.ExpandAllRDPLocalGroups(context.Background(), db)
require.Nil(t, err)

// result set should only include first degree members of `Remote Desktop Users` group.
test.RequireNilErr(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchRDPEntityBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, true)
rdpEnabledEntityIDBitmap, err := analysis.FetchRemoteDesktopUsersBitmapForComputer(tx, harness.RDP.Computer.ID, groupExpansions, true)
require.Nil(t, err)

require.Equal(t, 6, int(rdpEnabledEntityIDBitmap.Cardinality()))
Expand All @@ -142,3 +143,54 @@ func TestFetchRDPEntityBitmapForComputer(t *testing.T) {
}))
})
}

func TestFetchRDPEntityBitmapForComputer(t *testing.T) {
testContext := integration.NewGraphTestContext(t, schema.DefaultGraphSchema())
testContext.DatabaseTestWithSetup(func(harness *integration.HarnessDetails) error {
harness.RDPHarnessWithCitrix.Setup(testContext)
return nil
}, func(harness integration.HarnessDetails, db graph.Database) {
groupExpansions, err := analysis.ExpandAllRDPLocalGroups(context.Background(), db)
require.Nil(t, err)

// the Remote Desktop Users group does not have an RIL(Remote Interactive Login) edge to the computer.
require.Nil(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchCanRDPEntityBitmapForComputer(tx, harness.RDPHarnessWithCitrix.Computer.ID, groupExpansions, true, true)
require.Nil(t, err)

// We should expect the intersection of members of `Direct Access Users`, with entities that have the RIL privilege to the computer
require.Equal(t, 4, int(rdpEnabledEntityIDBitmap.Cardinality()))

require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.UliUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.IrshadUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.DillonUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.RohanUser.ID.Uint32()))

return nil
}))

// Create a RemoteInteractiveLogonPrivilege relationship from the RDP local group to the computer to test our most common case
require.Nil(t, db.WriteTransaction(context.Background(), func(tx graph.Transaction) error {
_, err := tx.CreateRelationshipByIDs(harness.RDPHarnessWithCitrix.RDPLocalGroup.ID, harness.RDPHarnessWithCitrix.Computer.ID, ad.RemoteInteractiveLogonPrivilege, graph.NewProperties())
return err
}))

// Recalculate group expansions
groupExpansions, err = analysis.ExpandAllRDPLocalGroups(context.Background(), db)
require.Nil(t, err)

require.Nil(t, db.ReadTransaction(context.Background(), func(tx graph.Transaction) error {
rdpEnabledEntityIDBitmap, err := analysis.FetchCanRDPEntityBitmapForComputer(tx, harness.RDPHarnessWithCitrix.Computer.ID, groupExpansions, true, true)
require.Nil(t, err)

// We should expect the intersection of members of `Direct Access Users,` with entities that are first degree members of the `Remote Desktop Users` group
require.Equal(t, 3, int(rdpEnabledEntityIDBitmap.Cardinality()))

require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.DomainGroupC.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.IrshadUser.ID.Uint32()))
require.True(t, rdpEnabledEntityIDBitmap.Contains(harness.RDPHarnessWithCitrix.UliUser.ID.Uint32()))

return nil
}))
})
}
2 changes: 1 addition & 1 deletion cmd/api/src/analysis/membership_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestResolveAllGroupMemberships(t *testing.T) {
test.RequireNilErr(t, err)

require.Equal(t, 3, int(memberships.Cardinality(harness.RDP.DomainGroupA.ID.Uint32()).Cardinality()))
require.Equal(t, 2, int(memberships.Cardinality(harness.RDP.DomainGroupB.ID.Uint32()).Cardinality()))
require.Equal(t, 1, int(memberships.Cardinality(harness.RDP.DomainGroupB.ID.Uint32()).Cardinality()))
require.Equal(t, 1, int(memberships.Cardinality(harness.RDP.DomainGroupC.ID.Uint32()).Cardinality()))
require.Equal(t, 1, int(memberships.Cardinality(harness.RDP.DomainGroupD.ID.Uint32()).Cardinality()))
require.Equal(t, 2, int(memberships.Cardinality(harness.RDP.DomainGroupE.ID.Uint32()).Cardinality()))
Expand Down
9 changes: 8 additions & 1 deletion cmd/api/src/api/v2/app_config_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,10 @@ func Test_GetAppConfigs(t *testing.T) {
var (
passwordExpirationWindowFound = false
neo4jConfigsFound = false
citrixConfigsFound = false
passwordExpirationValue appcfg.PasswordExpiration
neo4jParametersValue appcfg.Neo4jParameters
citrixConfigValue appcfg.CitrixRDPSupport
testCtx = integration.NewFOSSContext(t)
)

Expand All @@ -56,11 +58,16 @@ func Test_GetAppConfigs(t *testing.T) {
require.Equal(t, neo4j.DefaultBatchWriteSize, neo4jParametersValue.BatchWriteSize)
require.Equal(t, neo4j.DefaultWriteFlushSize, neo4jParametersValue.WriteFlushSize)
neo4jConfigsFound = true
case appcfg.CitrixRDPSupportKey:
mapParameter(t, &citrixConfigValue, parameter)
require.False(t, citrixConfigValue.Enabled)
citrixConfigsFound = true
}
}

require.True(t, passwordExpirationWindowFound, "Failed to find Password Expiration Window in response")
require.True(t, neo4jConfigsFound, "Failed to find Neo4J Configs in response")
require.True(t, citrixConfigsFound, "Failed to find Citrix configs in response")
}

func Test_GetAppConfigWithParameter(t *testing.T) {
Expand Down Expand Up @@ -107,7 +114,7 @@ func Test_PutAppConfig(t *testing.T) {
require.Equal(t, updatedDuration, updatedPasswordExpiration.Duration)
}

func mapParameter[T *appcfg.PasswordExpiration | *appcfg.Neo4jParameters](t *testing.T, value T, parameter appcfg.Parameter) {
func mapParameter[T *appcfg.PasswordExpiration | *appcfg.Neo4jParameters | *appcfg.CitrixRDPSupport](t *testing.T, value T, parameter appcfg.Parameter) {
err := parameter.Value.Map(&value)
require.Nilf(t, err, "Failed to map parameter value to %T type: %v", value, err)
}
2 changes: 1 addition & 1 deletion cmd/api/src/daemons/datapipe/analysis.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func RunAnalysisOperations(ctx context.Context, db database.Database, graphDB gr
// TODO: Cleanup #ADCSFeatureFlag after full launch.
if adcsFlag, err := db.GetFlagByKey(ctx, appcfg.FeatureAdcs); err != nil {
collectedErrors = append(collectedErrors, fmt.Errorf("error retrieving ADCS feature flag: %w", err))
} else if stats, err := ad.Post(ctx, graphDB, adcsFlag.Enabled); err != nil {
} else if stats, err := ad.Post(ctx, graphDB, adcsFlag.Enabled, appcfg.GetCitrixRDPSupport(ctx, db)); err != nil {
collectedErrors = append(collectedErrors, fmt.Errorf("error during ad post: %w", err))
adFailed = true
} else {
Expand Down
18 changes: 18 additions & 0 deletions cmd/api/src/database/migration/migrations/v5.16.0.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
-- Copyright 2024 Specter Ops, Inc.
--
-- Licensed under the Apache License, Version 2.0
-- you may not use this file except in compliance with the License.
-- You may obtain a copy of the License at
--
-- http://www.apache.org/licenses/LICENSE-2.0
--
-- Unless required by applicable law or agreed to in writing, software
-- distributed under the License is distributed on an "AS IS" BASIS,
-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-- See the License for the specific language governing permissions and
-- limitations under the License.
--
-- SPDX-License-Identifier: Apache-2.0

INSERT INTO parameters (key, name, description, value, created_at, updated_at)
VALUES ('analysis.citrix_rdp_support', 'Citrix RDP Support', 'This configuration parameter toggles Citrix support during post-processing. When enabled, computers identified with a ''Direct Access Users'' local group will assume that Citrix is installed and CanRDP edges will require membership of both ''Direct Access Users'' and ''Remote Desktop Users'' local groups on the computer.', '{"enabled": false}',current_timestamp,current_timestamp) ON CONFLICT DO NOTHING;
30 changes: 30 additions & 0 deletions cmd/api/src/model/appcfg/parameter.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ const (
Neo4jConfigsName = "Neo4j Configuration Parameters"
PasswordExpirationWindowDescription = "This configuration parameter sets the local auth password expiry window for users that have valid auth secrets. Values for this configuration must follow the duration specification of ISO-8601."
Neo4jConfigsDescription = "This configuration parameter sets the BatchWriteSize and the BatchFlushSize for Neo4J."

CitrixRDPSupportKey = "analysis.citrix_rdp_support"
CitrixRDPSupportName = "Citrix RDP Support"
CitrixRDPSupportDescription = "This configuration parameter toggles Citrix support during post-processing. When enabled, computers identified with a 'Direct Access Users' local group will assume that Citrix is installed and CanRDP edges will require membership of both 'Direct Access Users' and 'Remote Desktop Users' local groups on the computer."
)

// Parameter is a runtime configuration parameter that can be fetched from the appcfg.ParameterService interface. The
Expand Down Expand Up @@ -95,6 +99,10 @@ func AvailableParameters() (ParameterSet, error) {
WriteFlushSize: neo4j.DefaultWriteFlushSize,
}); err != nil {
return ParameterSet{}, fmt.Errorf("error creating neo4jExpirationValue parameter: %w", err)
} else if citrixRDPSupportValue, err := types.NewJSONBObject(CitrixRDPSupport{
Enabled: false,
}); err != nil {
return ParameterSet{}, fmt.Errorf("error creating CitrixRDPSupport parameter: %w", err)
} else {
return ParameterSet{
PasswordExpirationWindow: {
Expand All @@ -110,6 +118,12 @@ func AvailableParameters() (ParameterSet, error) {
Description: Neo4jConfigsDescription,
Value: neo4jExpirationValue,
},
CitrixRDPSupportKey: {
Key: CitrixRDPSupportKey,
Name: CitrixRDPSupportName,
Description: CitrixRDPSupportDescription,
Value: citrixRDPSupportValue,
},
}, nil
}
}
Expand Down Expand Up @@ -162,3 +176,19 @@ func GetNeo4jParameters(ctx context.Context, service ParameterService) Neo4jPara

return result
}

type CitrixRDPSupport struct {
Enabled bool `json:"enabled,omitempty"`
}

func GetCitrixRDPSupport(ctx context.Context, service ParameterService) bool {
var result CitrixRDPSupport

if cfg, err := service.GetConfigurationParameter(ctx, CitrixRDPSupportKey); err != nil {
log.Warnf("Failed to fetch CitrixRDPSupport configuration; returning default values")
} else if err := cfg.Map(&result); err != nil {
log.Warnf("Invalid CitrixRDPSupport configuration supplied, %v. returning default values.", err)
}

return result.Enabled
}
Loading

0 comments on commit 46a7ccc

Please sign in to comment.