Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
9d767cc
wip: start abstraction
mykeelium Apr 3, 2026
02f8ca8
wip: starting to refine localstore
mykeelium Apr 22, 2026
cfe8518
fix: refine the logic for localstore
mykeelium Apr 23, 2026
1119be2
wip: cleanup storage interface
mykeelium May 1, 2026
acfc81f
fix: close directory on error for Get
mykeelium May 1, 2026
37268b1
feat: pipe file service resolver around resources
mykeelium May 6, 2026
540f470
feat: add get and move to FileService
mykeelium May 7, 2026
8b4cc0b
fix: centralize ingest logic, add fileservice to datapipe
mykeelium May 7, 2026
23f408a
fix: alter cleanup logic to handle orphaned files
mykeelium May 7, 2026
aebea94
feat: add retained file service
mykeelium May 7, 2026
d61a594
fix: remove temp prefix from ingest
mykeelium May 8, 2026
a12ec84
feat: add write from reader, as well as move between services
mykeelium May 11, 2026
acc6bc8
fix: localstore.go fix for recursive List if "" is passed
mykeelium May 14, 2026
4d32ea0
feat: adding RuntimeDependencies and moving FileService to earlier st…
mykeelium May 14, 2026
82b7c2a
chore: clean up typos
mykeelium May 14, 2026
d7ab5c2
test: bring existing tests inline with new FileService
mykeelium May 15, 2026
9d4910d
chore: linting cleanup
mykeelium May 15, 2026
6edc207
chore: pfc
mykeelium May 15, 2026
6944b2c
chore: pfc
mykeelium May 15, 2026
4571b73
chore: comment cleanup
mykeelium May 18, 2026
01dd7e3
test: add localstorage tests
mykeelium May 18, 2026
698faa6
test: add storage_test.go
mykeelium May 18, 2026
8f8eeb6
test: add tests for new functionality
mykeelium May 19, 2026
e1a9086
chore: minor cleanup of todos and flatten files for temp
mykeelium May 19, 2026
c85404e
chore: rename LocalFileService to StorageFileService and typo fixes
mykeelium May 19, 2026
cd0604e
chore: pfc
mykeelium May 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cmd/api/src/api/registration/registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
"github.com/specterops/bloodhound/cmd/api/src/model/appcfg"
"github.com/specterops/bloodhound/cmd/api/src/queries"
"github.com/specterops/bloodhound/cmd/api/src/services/dogtags"
"github.com/specterops/bloodhound/cmd/api/src/services/storage"
"github.com/specterops/bloodhound/cmd/api/src/services/upload"
"github.com/specterops/bloodhound/packages/go/cache"
"github.com/specterops/dawgs/graph"
Expand Down Expand Up @@ -68,6 +69,7 @@ func RegisterFossRoutes(
authenticator api.Authenticator,
authorizer auth.Authorizer,
ingestSchema upload.IngestSchema,
fileServiceResolver storage.FileServiceResolver,
dogtagsService dogtags.Service,
openGraphSchemaService v2.OpenGraphSchemaService,
) {
Expand All @@ -88,6 +90,6 @@ func RegisterFossRoutes(
routerInst.PathPrefix("/ui", static.AssetHandler),
)

var resources = v2.NewResources(rdms, graphDB, cfg, apiCache, graphQuery, collectorManifests, authorizer, authenticator, ingestSchema, dogtagsService, openGraphSchemaService)
var resources = v2.NewResources(rdms, graphDB, cfg, apiCache, graphQuery, collectorManifests, authorizer, authenticator, ingestSchema, fileServiceResolver, dogtagsService, openGraphSchemaService)
NewV2API(resources, routerInst)
}
209 changes: 89 additions & 120 deletions cmd/api/src/api/tools/ingest.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,30 +23,30 @@ import (
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"path"
"strings"
"sync"
"time"

"github.com/specterops/bloodhound/cmd/api/src/api"
"github.com/specterops/bloodhound/cmd/api/src/config"
"github.com/specterops/bloodhound/cmd/api/src/database/types"
"github.com/specterops/bloodhound/cmd/api/src/model/appcfg"
"github.com/specterops/bloodhound/cmd/api/src/services/storage"
"github.com/specterops/bloodhound/packages/go/bhlog/attr"
"github.com/specterops/bloodhound/packages/go/headers"
)

type IngestControl struct {
cfg config.Configuration
retainedFileLock *sync.RWMutex
parameterService appcfg.ParameterService
retainedFileLock *sync.RWMutex
parameterService appcfg.ParameterService
retainedFileService storage.FileService
}

func NewIngestControlTool(cfg config.Configuration, parameterService appcfg.ParameterService) IngestControl {
func NewIngestControlTool(parameterService appcfg.ParameterService, retainedFileService storage.FileService) IngestControl {
return IngestControl{
cfg: cfg,
retainedFileLock: &sync.RWMutex{},
parameterService: parameterService,
retainedFileLock: &sync.RWMutex{},
parameterService: parameterService,
retainedFileService: retainedFileService,
}
}

Expand All @@ -69,6 +69,39 @@ func (s *IngestControl) setIngestFileRetention(ctx context.Context, parameter ap
return nil
}

func retainedArchiveName(filePath string) string {
filePath = path.Clean(strings.TrimPrefix(filePath, "/"))
if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "../") {
return path.Base(filePath)
}
Comment on lines +72 to +76

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Go's path package, what are the return values of path.Base(".") and path.Base(".."), and are "."/".." safe as tar header names?

💡 Result:

In Go's path package (and the related path/filepath package), the function path.Base returns the last element of a path [1][2][3]. For the inputs "." and "..", the return values are as follows: - path.Base(".") returns "." [3] - path.Base("..") returns ".." [3] Regarding their safety as tar header names, "." and ".." are generally considered unsafe or problematic when used as file paths within tar archives [4]. Including these as path components in a tar header can lead to directory traversal vulnerabilities during extraction [4]. If an archive contains entries with ".." in their paths, a naive extraction process might write files outside of the intended target directory [4]. Many modern tar implementations, including GNU tar, have built-in protections that may refuse to extract entries containing ".." or attempt to sanitize them to prevent such security risks [4]. Consequently, it is best practice to avoid using "." or ".." in tar header names to ensure both security and portability [5][4].

Citations:


🏁 Script executed:

cat -n cmd/api/src/api/tools/ingest.go | sed -n '70,85p'

Repository: SpecterOps/BloodHound

Length of output: 704


🏁 Script executed:

# Check if there are other path sanitization or tar safety measures in the file
rg -n "writeRetainedFileToTar|retainedArchiveName" cmd/api/src/api/tools/ingest.go -A 5 -B 2

Repository: SpecterOps/BloodHound

Length of output: 1102


Harden tar entry sanitization for "." / ".." names.

retainedArchiveName can still emit "." or ".." entries for tar archives, which are unsafe and can lead to directory traversal vulnerabilities during extraction. The current logic on line 74–75 attempts to handle these cases but path.Base(".") returns "." and path.Base("..") returns "..", leaving the vulnerability unpatched.

Proposed fix
 func retainedArchiveName(filePath string) string {
 	filePath = path.Clean(strings.TrimPrefix(filePath, "/"))
-	if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "../") {
-		return path.Base(filePath)
+	if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "../") {
+		baseName := path.Base(filePath)
+		if baseName == "." || baseName == ".." || baseName == "/" || baseName == "" {
+			return "retained-file"
+		}
+		return baseName
 	}
 	return filePath
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func retainedArchiveName(filePath string) string {
filePath = path.Clean(strings.TrimPrefix(filePath, "/"))
if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "../") {
return path.Base(filePath)
}
func retainedArchiveName(filePath string) string {
filePath = path.Clean(strings.TrimPrefix(filePath, "/"))
if filePath == "." || filePath == ".." || strings.HasPrefix(filePath, "../") {
baseName := path.Base(filePath)
if baseName == "." || baseName == ".." || baseName == "/" || baseName == "" {
return "retained-file"
}
return baseName
}
return filePath
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/api/src/api/tools/ingest.go` around lines 72 - 76, The
retainedArchiveName function can still return "." or ".." which are unsafe;
modify retainedArchiveName to sanitize further by computing base :=
path.Base(filePath) after cleaning/trimPrefix and if base is "." or base is ".."
(or filePath starts with "../") return an empty string (or otherwise signal the
entry should be skipped) instead of returning path.Base(filePath) directly;
update callers that rely on retainedArchiveName to skip entries when it returns
an empty string so tar entries with "."/".." are dropped.

return filePath
}

func (s *IngestControl) writeRetainedFileToTar(ctx context.Context, tarWriter *tar.Writer, filePath string) error {
reader, fileInfo, err := s.retainedFileService.GetFile(ctx, filePath)
if err != nil {
return fmt.Errorf("opening retained file: %w", err)
}
defer reader.Close()

tarHeader := &tar.Header{
Name: retainedArchiveName(fileInfo.Path),
Size: fileInfo.Size,
Mode: 0o600,
ModTime: fileInfo.LastModified,
}

if err := tarWriter.WriteHeader(tarHeader); err != nil {
return fmt.Errorf("writing tar header: %w", err)
}

if _, err := io.Copy(tarWriter, reader); err != nil {
return fmt.Errorf("copying retained file: %w", err)
}

return nil
}

func (s *IngestControl) FetchRetainedIngestFiles(response http.ResponseWriter, request *http.Request) {
if !s.retainedFileLock.TryRLock() {
// Unable to acquire a write lock at this time - notify of a conflict. User is expected to retry
Expand All @@ -85,101 +118,38 @@ func (s *IngestControl) FetchRetainedIngestFiles(response http.ResponseWriter, r
return
}

retainedFilesDirectory := s.cfg.RetainedFilesDirectory()

if retainedFilesDirEntries, err := os.ReadDir(retainedFilesDirectory); err != nil {
// Unable to stat the retained files directory. Log a warning and inform the user that the
// operation failed.
slog.WarnContext(
request.Context(),
"Failed reading retained files directory",
slog.String("directory", retainedFilesDirectory),
attr.Error(err),
)
files, err := s.retainedFileService.ListFiles(request.Context(), "", storage.ListOptions{Recursive: true})
if err != nil {
slog.WarnContext(request.Context(), "Failed listing retained files", attr.Error(err))
response.WriteHeader(http.StatusInternalServerError)
} else {
// Author the response
response.Header().Set(headers.ContentType.String(), "application/gzip")
response.Header().Set(headers.ContentEncoding.String(), "gzip")
response.Header().Set(headers.ContentDisposition.String(), fmt.Sprintf(`attachment; filename="retained-ingest-%s.tar.gz"`, time.Now().UTC().Format("20060102T150405Z")))
response.WriteHeader(http.StatusOK)
return
}

var (
gzipWriter = gzip.NewWriter(response)
tarWriter = tar.NewWriter(gzipWriter)
)
response.Header().Set(headers.ContentType.String(), "application/gzip")
response.Header().Set(headers.ContentEncoding.String(), "gzip")
response.Header().Set(headers.ContentDisposition.String(), fmt.Sprintf(`attachment; filename="retained-ingest-%s.tar.gz"`, time.Now().UTC().Format("20060102T150405Z")))
response.WriteHeader(http.StatusOK)

for _, retainedFilesDirEntry := range retainedFilesDirEntries {
retainedFilePath := filepath.Join(retainedFilesDirectory, retainedFilesDirEntry.Name())
gzipWriter := gzip.NewWriter(response)
defer gzipWriter.Close()

if retainedFilesDirEntry.IsDir() {
// Log a warning for directory entries and skip reading it.
slog.WarnContext(
request.Context(),
"Unexpected directory in retained files directory",
slog.String("path", retainedFilePath),
slog.String("directory", retainedFilesDirectory),
)
continue
}
tarWriter := tar.NewWriter(gzipWriter)
defer tarWriter.Close()

if fileInfo, err := retainedFilesDirEntry.Info(); err != nil {
slog.WarnContext(
request.Context(),
"Unable to stat file",
slog.String("path", retainedFilePath),
attr.Error(err),
)
break
} else {
if tarHeader, err := tar.FileInfoHeader(fileInfo, ""); err != nil {
slog.WarnContext(
request.Context(),
"Unable to convert file info for file",
slog.String("path", retainedFilePath),
attr.Error(err),
)
break
} else if err := tarWriter.WriteHeader(tarHeader); err != nil {
slog.WarnContext(
request.Context(),
"Failed writing tar file header to response",
slog.String("path", retainedFilePath),
attr.Error(err),
)
break
}

if fin, err := os.Open(retainedFilePath); err != nil {
slog.WarnContext(
request.Context(),
"Failed to open retained file",
slog.String("path", retainedFilePath),
slog.String("directory", retainedFilesDirectory),
attr.Error(err),
)
break
} else {
// Copy and inspect the error only after closing the file.
_, copyErr := io.Copy(tarWriter, fin)
fin.Close()

if copyErr != nil {
slog.WarnContext(
request.Context(),
"Failed writing file content from to response",
slog.String("path", retainedFilePath),
attr.Error(copyErr),
)
break
}
}
}
for _, file := range files {
if file.IsDir {
continue
}

// Attempt to flush and close the tar.gz writers - best effort
tarWriter.Close()
gzipWriter.Close()
if err := s.writeRetainedFileToTar(request.Context(), tarWriter, file.Path); err != nil {
slog.WarnContext(
request.Context(),
"Failed writing retained file to response",
slog.String("path", file.Path),
attr.Error(err),
)
break
}
}
}

Expand Down Expand Up @@ -215,6 +185,7 @@ func (s *IngestControl) DisableIngestFileRetention(response http.ResponseWriter,
if err := s.setIngestFileRetention(request.Context(), appcfg.RetainIngestedFilesParameter{
Enabled: false,
}); err != nil {
s.retainedFileLock.Unlock()
api.WriteErrorResponse(request.Context(), api.BuildErrorResponse(http.StatusInternalServerError, err.Error(), request), response)
return
}
Expand All @@ -223,31 +194,29 @@ func (s *IngestControl) DisableIngestFileRetention(response http.ResponseWriter,

// Clear all retained files in a goroutine that releases the lock once finished. This is best effort as
// it is still possible for a file to make it into the retained directory even after the parameter is set.
go func() {
cleanupCtx := context.WithoutCancel(request.Context())

go func(ctx context.Context) {
defer s.retainedFileLock.Unlock()

retainedFilesDirectory := s.cfg.RetainedFilesDirectory()
files, err := s.retainedFileService.ListFiles(ctx, "", storage.ListOptions{Recursive: true})
if err != nil {
slog.WarnContext(ctx, "Failed listing retained files", attr.Error(err))
}

if retainedFilesDirEntries, err := os.ReadDir(retainedFilesDirectory); err != nil {
slog.WarnContext(
request.Context(),
"Failed reading retained files directory",
slog.String("directory", retainedFilesDirectory),
attr.Error(err),
)
} else {
for _, retainedFilesDirEntry := range retainedFilesDirEntries {
retainedFilePath := filepath.Join(retainedFilesDirectory, retainedFilesDirEntry.Name())

if err := os.RemoveAll(retainedFilePath); err != nil {
slog.WarnContext(
request.Context(),
"Failed removing retained file",
slog.String("path", retainedFilePath),
attr.Error(err),
)
}
for _, file := range files {
if file.IsDir {
continue
}

if err := s.retainedFileService.DeleteFile(ctx, file.Path); err != nil {
slog.WarnContext(
ctx,
"Failed removing retained file",
slog.String("path", file.Path),
attr.Error(err),
)
}
}
}()
}(cleanupCtx)
}
Loading
Loading