Skip to content

Commit

Permalink
Merge branch 'main' of github.com:SpecterOps/BloodHound into esc9b-ed…
Browse files Browse the repository at this point in the history
…ge-composition
  • Loading branch information
maffkipp committed Feb 9, 2024
2 parents 2e84d78 + bbe3aa0 commit 6188f71
Show file tree
Hide file tree
Showing 12 changed files with 502 additions and 66 deletions.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,33 @@ original BloodHound was created by [@_wald0](https://www.twitter.com/_wald0), [@

The easiest way to get up and running is to use our pre-configured Docker Compose setup. The following steps will get BloodHound CE up and running with the least amount of effort.

1. Install Docker Compose. This should be included with the [Docker Desktop](https://www.docker.com/products/docker-desktop/) installation
1. Install Docker Compose and ensure Docker is running. This should be included with the [Docker Desktop](https://www.docker.com/products/docker-desktop/) installation
2. Run `curl -L https://ghst.ly/getbhce | docker compose -f - up`
3. Locate the randomly generated password in the terminal output of Docker Compose
4. In a browser, navigate to `http://localhost:8080/ui/login`. Login with a username of `admin` and the randomly generated password from the logs

NOTE: going forward, the default `docker-compose.yml` example binds only to localhost (127.0.0.1). If you want to access BloodHound outside of localhost,
you'll need to follow the instructions in [examples/docker-compose/README.md](examples/docker-compose/README.md) to configure the host binding for the container.

## Installation Error Handling

<i> Affects: Windows Users </i>

- If you encounter a "failed to get console mode for stdin: The handle is invalid." ensure Docker Desktop (and associated Engine is running). Docker Desktop does not automatically register as a startup entry. <img width="302" alt="image" src="[cmd/ui/public/img/Docker-Engine-Running.png]">

- If you encounter an "Error response from daemon: Ports are not available: exposing port TCP 127.0.0.1:7474 -> 0.0.0.0:0: listen tcp 127.0.0.1:7474: bind: Only one usage of each socket address (protocol/network address/port) is normally permitted." this is normally attributed to the "Neo4J Graph Database - neo4j" service already running on your local system. Please stop or delete the service to continue.

```
# Verify if Docker Engine is Running
docker info
# Attempt to stop Neo4j Service if running
Stop-Service "Neo4j" -ErrorAction SilentlyContinue
```
- A succesfull installation of BloodHound CE would look like below:

https://github.com/SpecterOps/BloodHound/tree/main/cmd/ui/public/img/BloodHoundCE-Deployment-Experience-Windows.mp4

## Useful Links

- [BloodHound Slack](https://ghst.ly/BHSlack)
Expand Down
113 changes: 113 additions & 0 deletions cmd/api/src/daemons/datapipe/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// 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

package datapipe

//go:generate go run go.uber.org/mock/mockgen -copyright_file=../../../../../LICENSE.header -destination=./mocks/cleanup.go -package=mocks . FileOperations

import (
"context"
"github.com/specterops/bloodhound/log"
"os"
"path/filepath"
"sync"
)

// FileOperations is an interface for describing filesystem actions. This implementation exists due to deficiencies in
// the fs package where the fs.FS interface does not support destructive operations like RemoveAll.
type FileOperations interface {
ReadDir(path string) ([]os.DirEntry, error)
RemoveAll(path string) error
}

// osFileOperations is a passthrough implementation of the FileOperations interface that uses the os package
// functions.
type osFileOperations struct{}

func NewOSFileOperations() FileOperations {
return osFileOperations{}
}

func (s osFileOperations) ReadDir(path string) ([]os.DirEntry, error) {
return os.ReadDir(path)
}

func (s osFileOperations) RemoveAll(path string) error {
return os.RemoveAll(path)
}

// OrphanFileSweeper is a file cleanup utility that allows only one goroutine to attempt file cleanup at a time.
type OrphanFileSweeper struct {
lock *sync.Mutex
fileOps FileOperations
tempDirectoryRootPath string
}

func NewOrphanFileSweeper(fileOps FileOperations, tempDirectoryRootPath string) *OrphanFileSweeper {
return &OrphanFileSweeper{
lock: &sync.Mutex{},
fileOps: fileOps,
tempDirectoryRootPath: tempDirectoryRootPath,
}
}

// Clear takes a context and a list of expected file names. The function will list all directory entries within the
// configured tempDirectoryRootPath that the sweeper was instantiated with and compare the resulting list against the
// passed expected file names. The function will then call RemoveAll on each directory entry not found in the expected
// file name slice.
func (s *OrphanFileSweeper) Clear(ctx context.Context, expectedFileNames []string) {
// Only allow one background thread to run for clearing orphaned data/
if !s.lock.TryLock() {
return
}

// Release the lock once finished
defer s.lock.Unlock()

if dirEntries, err := s.fileOps.ReadDir(s.tempDirectoryRootPath); err != nil {
log.Errorf("Failed reading work directory %s: %v", s.tempDirectoryRootPath, err)
} else {
numDeleted := 0

// Remove expected files from the deletion list
for _, expectedFileName := range expectedFileNames {
for idx, dirEntry := range dirEntries {
if expectedFileName == dirEntry.Name() {
dirEntries = append(dirEntries[:idx], dirEntries[idx+1:]...)
}
}
}

for _, orphanedDirEntry := range dirEntries {
// Check for context cancellation before each file deletion
if ctx.Err() != nil {
break
}

fullPath := filepath.Join(s.tempDirectoryRootPath, orphanedDirEntry.Name())

if err := s.fileOps.RemoveAll(fullPath); err != nil {
log.Errorf("Failed removing orphaned file %s: %v", fullPath, err)
}

numDeleted += 1
}

if numDeleted > 0 {
log.Infof("Finished removing %d orphaned ingest files", numDeleted)
}
}
}
159 changes: 159 additions & 0 deletions cmd/api/src/daemons/datapipe/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// 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

package datapipe_test

import (
"context"
"github.com/specterops/bloodhound/src/daemons/datapipe"
"github.com/specterops/bloodhound/src/daemons/datapipe/mocks"
"go.uber.org/mock/gomock"
"io/fs"
"os"
"path/filepath"
"sync"
"testing"
)

type dirEntry struct {
name string
isDir bool
mode fs.FileMode
info fs.FileInfo
infoErr error
}

func (s dirEntry) Name() string {
return s.name
}

func (s dirEntry) IsDir() bool {
return s.isDir
}

func (s dirEntry) Type() fs.FileMode {
return s.mode
}

func (s dirEntry) Info() (fs.FileInfo, error) {
return s.info, s.infoErr
}

func TestOrphanFileSweeper_Clear(t *testing.T) {
const workDir = "/fake/work/dir"

t.Run("Allow Only One Goroutine", func(t *testing.T) {
var (
mockCtrl = gomock.NewController(t)
mockFileOps = mocks.NewMockFileOperations(mockCtrl)
sweeper = datapipe.NewOrphanFileSweeper(mockFileOps, workDir)
wgCoordination = &sync.WaitGroup{}
wgReadDir = &sync.WaitGroup{}
)

defer mockCtrl.Finish()

// Prep the wait groups for coordination
wgCoordination.Add(1)
wgReadDir.Add(1)

mockFileOps.EXPECT().ReadDir(workDir).DoAndReturn(func(path string) ([]os.DirEntry, error) {
// Release the coordination wait group
wgCoordination.Done()

// Block on the readDir wait group
wgReadDir.Wait()

return nil, nil
})

// Launch the clear function in a goroutine. The wait group will cause this call to block
go sweeper.Clear(context.Background(), []string{})

// Wait for the go routine to reach the ReadDir function
wgCoordination.Wait()

// Run the clear function in the current thread context as this should exit without blocking
sweeper.Clear(context.Background(), []string{})

// Release the wait group to complete the test
wgReadDir.Done()
})

t.Run("Clear Orphan Files", func(t *testing.T) {
var (
mockCtrl = gomock.NewController(t)
mockFileOps = mocks.NewMockFileOperations(mockCtrl)
sweeper = datapipe.NewOrphanFileSweeper(mockFileOps, workDir)
)

defer mockCtrl.Finish()

mockFileOps.EXPECT().ReadDir(workDir).Return([]os.DirEntry{
dirEntry{
name: "1",
},
}, nil)

mockFileOps.EXPECT().RemoveAll(filepath.Join(workDir, "1")).Return(nil)

sweeper.Clear(context.Background(), []string{})
})

t.Run("Exclude Expected Files", func(t *testing.T) {
var (
mockCtrl = gomock.NewController(t)
mockFileOps = mocks.NewMockFileOperations(mockCtrl)
sweeper = datapipe.NewOrphanFileSweeper(mockFileOps, workDir)
)

defer mockCtrl.Finish()

mockFileOps.EXPECT().ReadDir(workDir).Return([]os.DirEntry{
dirEntry{
name: "1",
},
}, nil)

// This one is a negative assertion. Because we're passing in "1" to be excluded the listed dirEntries will be
// empty and therefore the sweeper MUST NOT call RemoveAll() on the FileOperations mock. If RemoveAll() is
// called then this test MUST fail.
sweeper.Clear(context.Background(), []string{"1"})
})

t.Run("Exit on Context Cancellation", func(t *testing.T) {
var (
mockCtrl = gomock.NewController(t)
mockFileOps = mocks.NewMockFileOperations(mockCtrl)
sweeper = datapipe.NewOrphanFileSweeper(mockFileOps, workDir)
)

defer mockCtrl.Finish()

mockFileOps.EXPECT().ReadDir(workDir).Return([]os.DirEntry{
dirEntry{
name: "1",
},
}, nil)

// Create a cancellable context and cancel it right away
ctx, done := context.WithCancel(context.Background())
done()

// When passed in with the cancelled context the sweeper should not call os.RemoveAll("1")
sweeper.Clear(ctx, []string{})
})
}
Loading

0 comments on commit 6188f71

Please sign in to comment.