Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
36 changes: 30 additions & 6 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ jobs:
postgrest
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install Go
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: 1.25.x
cache: false # Using custom cache action below for .bin directory
- name: Checkout code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/go/pkg/mod
Expand Down Expand Up @@ -76,13 +76,13 @@ jobs:
--health-retries 5
steps:
- name: Install Go
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: 1.25.x
cache: false # Using custom cache action below for .bin directory
- name: Checkout code
uses: actions/checkout@1d96c772d19495a3b5c517cd2bc0cb401ea0529f # v4.1.3
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/go/pkg/mod
Expand All @@ -99,6 +99,30 @@ jobs:
DUTY_DB_DISABLE_RLS: "true"
LOKI_URL: http://localhost:3100

e2e-blobs:
runs-on: ubuntu-latest
steps:
- name: Install Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: 1.25.x
cache: false
- name: Checkout code
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4.1.6
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/go/pkg/mod
~/.cache/go-build
.bin
key: cache-${{ hashFiles('**/go.sum') }}-${{ hashFiles('.bin/*') }}
restore-keys: |
cache-
- name: E2E Blob Store Tests
run: make test-e2e-blobs
env:
DUTY_DB_DISABLE_RLS: "true"

migrate:
runs-on: ubuntu-latest
strategy:
Expand All @@ -115,11 +139,11 @@ jobs:
go build main.go && ./main --db-url 'postgres://postgres:postgres@localhost:5432/test?sslmode=disable'
steps:
- name: Install Go
uses: buildjet/setup-go@555ce355a95ff01018ffcf8fbbd9c44654db8374 # v5.0.2
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
with:
go-version: 1.25.x
cache: false # Using custom cache action below for .bin directory
- uses: buildjet/cache@3e70d19e31d6a8030aeddf6ed8dbe601f94d09f4 # v4.0.2
- uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: |
~/go/pkg/mod
Expand Down
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ ginkgo:
go install github.com/onsi/ginkgo/v2/ginkgo

test: ginkgo
ginkgo -r --succinct --skip-package=tests/e2e,bench --label-filter "!e2e"
ginkgo -r --succinct --skip-package=tests/e2e,tests/e2e-blobs,bench --label-filter "!e2e"

test-concurrent: ginkgo
ginkgo -r -v --nodes=4 --skip-package=bench --label-filter "!e2e"
Expand All @@ -30,6 +30,10 @@ e2e-services: ## Run e2e test services in foreground with automatic cleanup on e
trap 'docker-compose down -v && docker-compose rm -f' EXIT INT TERM && \
docker-compose up --remove-orphans

.PHONY: test-e2e-blobs
test-e2e-blobs: ginkgo
ginkgo -v --label-filter="e2e" ./tests/e2e-blobs/

.PHONY: bench
bench:
go test -run ^$$ -bench=. -benchtime=10s -timeout 30m github.com/flanksource/duty/bench
Expand Down
110 changes: 110 additions & 0 deletions artifact/blob_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package artifact

import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"

"github.com/flanksource/duty/models"
"github.com/google/uuid"
"gorm.io/gorm"
)

type BlobStore interface {
Write(data Data, artifact *models.Artifact) (*models.Artifact, error)
Read(artifactID uuid.UUID) (*Data, error)
io.Closer
}

type blobStore struct {
fs FilesystemRW
db *gorm.DB
backend string
}

func NewBlobStore(fs FilesystemRW, db *gorm.DB, backend string) BlobStore {
return &blobStore{fs: fs, db: db, backend: backend}
}

func (s *blobStore) Write(data Data, a *models.Artifact) (*models.Artifact, error) {
if a == nil {
a = &models.Artifact{}
}
if data.Content == nil {
return nil, fmt.Errorf("artifact data content is nil")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
defer func() { _ = data.Content.Close() }()

checksum := sha256.New()
mimeReader := io.TeeReader(data.Content, checksum)

mw := &mimeWriter{Max: maxBytesForMimeDetection}
fileReader := io.TeeReader(mimeReader, mw)

info, err := s.fs.Write(s.db.Statement.Context, data.Filename, fileReader)

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how blobStore is instantiated and whether db already has a statement context set
rg -n -A5 "NewBlobStore\(" --type=go

Repository: flanksource/duty

Length of output: 2010


🏁 Script executed:

cat -n artifact/blob_store.go

Repository: flanksource/duty

Length of output: 3442


🏁 Script executed:

fd -t f "\.go$" | xargs rg -l "gorm.Open|\.DB\(\)" | head -5

Repository: flanksource/duty

Length of output: 143


🏁 Script executed:

rg -n "func.*\(\).*\*gorm\.DB" --type=go | head -20

Repository: flanksource/duty

Length of output: 235


🏁 Script executed:

sed -n '400,430p' context/context.go

Repository: flanksource/duty

Length of output: 615


🏁 Script executed:

rg "db\.Statement\.Context" --type=go -C2

Repository: flanksource/duty

Length of output: 661


🏁 Script executed:

rg "Statement\." --type=go | head -20

Repository: flanksource/duty

Length of output: 1155


🏁 Script executed:

rg "type FilesystemRW" -A10 --type=go

Repository: flanksource/duty

Length of output: 434


🏁 Script executed:

web_search
How to get context from gorm.DB instance without executing query

Repository: flanksource/duty

Length of output: 152


🌐 Web query:

GORM db.Statement.Context nil when fresh *gorm.DB created

💡 Result:

Yes, when you create a fresh *gorm.DB instance using gorm.Open, the db.Statement.Context field is nil by default. This is the expected behavior for a newly initialized DB connection, as no context has been explicitly set yet. To use a context with database operations, you must explicitly set it using db.WithContext(ctx) or db.Session(&gorm.Session{Context: ctx}). This creates a new DB session with the context propagated to its Statement. In hooks/callbacks (BeforeCreate, etc.), you can access the context via tx.Statement.Context, but only after a context has been set on the session. This design supports goroutine safety and method chaining - fresh DB instances are reusable until a chain/finisher method initializes the Statement, at which point you need Session/WithContext to create safe new instances.

Citations:


🏁 Script executed:

rg "\.WithContext\(" --type=go -B2 -A2 | head -40

Repository: flanksource/duty

Length of output: 1758


🏁 Script executed:

rg "db\.(WithContext|Statement)" --type=go -B3 -A3 | grep -A3 -B3 "func"

Repository: flanksource/duty

Length of output: 42


🏁 Script executed:

rg "func.*\(s \*\w+Store\)" --type=go -A15 | grep -E "(func|\.db\.|\.DB)" | head -30

Repository: flanksource/duty

Length of output: 1990


🏁 Script executed:

rg "Statement\.Context" --type=go -B5 | grep -E "(Before|After|callback)" -A3 -B3 | head -20

Repository: flanksource/duty

Length of output: 42


🏁 Script executed:

rg "v\.WithContext" --type=go -B5 -A10

Repository: flanksource/duty

Length of output: 679


🏁 Script executed:

# Check if there's any test coverage that shows the actual behavior
rg "blobStore\)" --type=go -A10 | grep -E "(Write|Read)" -B5 -A5 | head -50

Repository: flanksource/duty

Length of output: 1077


🏁 Script executed:

# Check the test files to see if blobStore tests show the actual runtime behavior
cat tests/artifacts_test.go | grep -A30 "NewBlobStore"

Repository: flanksource/duty

Length of output: 2023


🏁 Script executed:

# Check if there are any recent issues or PRs related to this
git log --oneline --all artifact/blob_store.go | head -10

Repository: flanksource/duty

Length of output: 150


Unsafe access to s.db.Statement.Context—context may be nil.

s.db.Statement is only initialized after a query executes. On line 43 in the Write method, no query has run yet, so Statement.Context will be nil. On line 91 in the Read method, while a query runs at line 73, Statement is not reliably available on the receiver s.db after the query completes.

Accept context.Context as a parameter to Write() and Read(), or use s.db.WithContext(ctx) to safely bind a context before these operations.

Also applies to: 91-91

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@artifact/blob_store.go` at line 43, The Write and Read methods currently
access s.db.Statement.Context which can be nil; update these methods to accept a
context.Context parameter (e.g., func (s *BlobStore) Write(ctx context.Context,
... ) and Read(ctx context.Context, ...)) and use s.db.WithContext(ctx) when
calling the filesystem operations and any queries, or alternatively call s.db =
s.db.WithContext(ctx) locally before using s.db.Statement; replace direct uses
of s.db.Statement.Context with the provided ctx (or the WithContext-bound DB) to
ensure a non-nil context is used for the fs.Write/fs.Read and any DB calls.

if err != nil {
return nil, fmt.Errorf("writing artifact %s: %w", data.Filename, err)
}

if data.ContentType == "" {
data.ContentType = mw.Detect().String()
}

// For inline store, the artifact already has content set
if inlineArt := InlineArtifact(info); inlineArt != nil {
a.Content = inlineArt.Content
a.CompressionType = inlineArt.CompressionType
}

a.Path = data.Filename
a.Filename = info.Name()
a.Size = info.Size()
a.ContentType = data.ContentType
a.Checksum = hex.EncodeToString(checksum.Sum(nil))

if err := s.db.Create(a).Error; err != nil {
return nil, fmt.Errorf("saving artifact to db: %w", err)
}

return a, nil
}

func (s *blobStore) Read(artifactID uuid.UUID) (*Data, error) {
var a models.Artifact
if err := s.db.Where("id = ?", artifactID).First(&a).Error; err != nil {
return nil, fmt.Errorf("finding artifact %s: %w", artifactID, err)
}

if a.IsInline() {
content, err := a.GetContent()
if err != nil {
return nil, fmt.Errorf("decompressing inline artifact %s: %w", artifactID, err)
}
return &Data{
Content: io.NopCloser(bytes.NewReader(content)),
ContentLength: a.Size,
Checksum: a.Checksum,
ContentType: a.ContentType,
Filename: a.Filename,
}, nil
}

r, err := s.fs.Read(s.db.Statement.Context, a.Path)
if err != nil {
return nil, fmt.Errorf("reading artifact %s from %s: %w", artifactID, a.Path, err)
}

return &Data{
Content: r,
ContentLength: a.Size,
Checksum: a.Checksum,
ContentType: a.ContentType,
Filename: a.Filename,
}, nil
}

func (s *blobStore) Close() error {
return s.fs.Close()
}
1 change: 1 addition & 0 deletions artifact/clients/aws/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
package aws
48 changes: 48 additions & 0 deletions artifact/clients/aws/fileinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//go:build !fast

package aws

import (
"io/fs"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/flanksource/commons/utils"
"github.com/samber/lo"
)

type S3FileInfo struct {
Object types.Object
}

func (obj S3FileInfo) Name() string {
if obj.Object.Key == nil {
return ""
}
return *obj.Object.Key
}

func (obj S3FileInfo) Size() int64 {
return utils.Deref(obj.Object.Size)
}

func (obj S3FileInfo) Mode() fs.FileMode {
return fs.FileMode(0644)
}

func (obj S3FileInfo) ModTime() time.Time {
return lo.FromPtr(obj.Object.LastModified)
}

func (obj S3FileInfo) FullPath() string {
return *obj.Object.Key
}
Comment on lines +38 to +40

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 | 🟡 Minor

Missing nil check in FullPath() can cause panic.

Name() guards against nil Key, but FullPath() dereferences obj.Object.Key directly. If Key is nil, this will panic.

Suggested fix
 func (obj S3FileInfo) FullPath() string {
+	if obj.Object.Key == nil {
+		return ""
+	}
 	return *obj.Object.Key
 }
📝 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 (obj S3FileInfo) FullPath() string {
return *obj.Object.Key
}
func (obj S3FileInfo) FullPath() string {
if obj.Object.Key == nil {
return ""
}
return *obj.Object.Key
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@artifact/clients/aws/fileinfo.go` around lines 38 - 40, FullPath() currently
dereferences obj.Object.Key without a nil check which can panic; update
S3FileInfo.FullPath to mirror the nil-safe behavior used in Name(): check that
obj.Object and obj.Object.Key are non-nil and return an empty string (or
appropriate default) when nil, otherwise return the string value of
*obj.Object.Key, ensuring consistency with Name()'s guarding logic.


func (obj S3FileInfo) IsDir() bool {
return strings.HasSuffix(obj.Name(), "/")
}
Comment on lines +30 to +44

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

🧩 Analysis chain

🏁 Script executed:

wc -l artifact/clients/aws/fileinfo.go

Repository: flanksource/duty

Length of output: 95


🏁 Script executed:

cat -n artifact/clients/aws/fileinfo.go

Repository: flanksource/duty

Length of output: 1128


🏁 Script executed:

# Search for any imports and interface implementations in the file
head -30 artifact/clients/aws/fileinfo.go

Repository: flanksource/duty

Length of output: 507


🏁 Script executed:

# Check if S3FileInfo implements fs.FileInfo interface
rg -A 2 "type S3FileInfo" artifact/clients/aws/fileinfo.go

Repository: flanksource/duty

Length of output: 107


🏁 Script executed:

# Check Name() method implementation
rg -A 3 "func \(obj S3FileInfo\) Name\(\)" artifact/clients/aws/fileinfo.go

Repository: flanksource/duty

Length of output: 124


🏁 Script executed:

# Search for any usage of S3FileInfo Mode() or IsDir()
rg "Mode\(\)|IsDir\(\)" artifact/clients/aws/ -B 2 -A 2

Repository: flanksource/duty

Length of output: 551


🏁 Script executed:

# Search for usage of S3FileInfo in the codebase
rg "S3FileInfo" --type go -B 2 -A 2

Repository: flanksource/duty

Length of output: 2109


🏁 Script executed:

# Search for patterns checking Mode().IsDir() in the codebase
rg "Mode\(\)\.IsDir|\.IsDir\(\)" --type go -B 2 -A 2 | head -50

Repository: flanksource/duty

Length of output: 625


🏁 Script executed:

# Check if there are any tests for S3FileInfo
fd -e test.go artifact/clients/aws

Repository: flanksource/duty

Length of output: 445


🏁 Script executed:

# Search for tests in artifact/clients/aws directory
fd . artifact/clients/aws --type f

Repository: flanksource/duty

Length of output: 120


🏁 Script executed:

# Check artifact/fs/s3.go to see how S3FileInfo is used
sed -n '1,50p' artifact/fs/s3.go

Repository: flanksource/duty

Length of output: 1049


🏁 Script executed:

# Look for more context on how S3FileInfo is used
rg -B 5 -A 10 "S3FileInfo{" artifact/fs/s3.go

Repository: flanksource/duty

Length of output: 145


🏁 Script executed:

# Check if Mode() is used directly with S3FileInfo anywhere
rg "fileInfo.*Mode|\.Mode\(\)" artifact/ --type go -B 2 -A 2

Repository: flanksource/duty

Length of output: 42


🏁 Script executed:

# Search for S3FileInfo usage with proper escaping
rg "S3FileInfo\{" artifact/ --type go -B 5 -A 5

Repository: flanksource/duty

Length of output: 915


🏁 Script executed:

# Look at the full s3.go to understand usage context
wc -l artifact/fs/s3.go

Repository: flanksource/duty

Length of output: 81


🏁 Script executed:

# Get more of s3.go to see how S3FileInfo is used
sed -n '40,120p' artifact/fs/s3.go

Repository: flanksource/duty

Length of output: 1848


🏁 Script executed:

# Check if there's any code that relies on Mode().IsDir() matching IsDir()
rg "Mode\(\).*IsDir|IsDir.*Mode\(\)" --type go

Repository: flanksource/duty

Length of output: 42


🏁 Script executed:

# Check the artifact.FileInfo type to confirm it extends fs.FileInfo
rg "interface.*FileInfo|type.*FileInfo" artifact/clients/aws/ artifact/fs/ --type go -B 2 -A 5

Repository: flanksource/duty

Length of output: 1229


🏁 Script executed:

# Look at artifact package to understand FileInfo interface
fd . artifact/ -name "*.go" -type f | head -20

Repository: flanksource/duty

Length of output: 290


🏁 Script executed:

# Search for artifact.FileInfo definition
rg "type.*FileInfo|interface.*FileInfo" artifact/ --type go -B 2 -A 5 | head -40

Repository: flanksource/duty

Length of output: 1223


🏁 Script executed:

# Check if IsDir() or Mode() are actually used on S3FileInfo in the codebase
rg "fileInfo.*IsDir|fileInfo.*Mode" artifact/ --type go -B 3 -A 3

Repository: flanksource/duty

Length of output: 42


🏁 Script executed:

# Look at how ReadDir output is used
rg "ReadDir" artifact/ --type go -B 2 -A 5 | head -60

Repository: flanksource/duty

Length of output: 2556


Mode() is inconsistent with IsDir() for directory keys.

For keys ending in /, IsDir() is true but Mode() does not include fs.ModeDir, violating the fs.FileInfo interface contract where Mode().IsDir() should be consistent with IsDir().

Suggested fix
 func (obj S3FileInfo) Mode() fs.FileMode {
-	return fs.FileMode(0644)
+	if obj.IsDir() {
+		return fs.ModeDir | 0o755
+	}
+	return 0o644
 }
📝 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 (obj S3FileInfo) Mode() fs.FileMode {
return fs.FileMode(0644)
}
func (obj S3FileInfo) ModTime() time.Time {
return lo.FromPtr(obj.Object.LastModified)
}
func (obj S3FileInfo) FullPath() string {
return *obj.Object.Key
}
func (obj S3FileInfo) IsDir() bool {
return strings.HasSuffix(obj.Name(), "/")
}
func (obj S3FileInfo) Mode() fs.FileMode {
if obj.IsDir() {
return fs.ModeDir | 0o755
}
return 0o644
}
func (obj S3FileInfo) ModTime() time.Time {
return lo.FromPtr(obj.Object.LastModified)
}
func (obj S3FileInfo) FullPath() string {
return *obj.Object.Key
}
func (obj S3FileInfo) IsDir() bool {
return strings.HasSuffix(obj.Name(), "/")
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@artifact/clients/aws/fileinfo.go` around lines 27 - 41, S3FileInfo.Mode
currently always returns 0644 which contradicts S3FileInfo.IsDir for keys ending
in "/"—update Mode() so it checks IsDir() and sets the directory bit
(fs.ModeDir) for directories (e.g., return fs.FileMode(0755)|fs.ModeDir for
dirs, fs.FileMode(0644) for files); adjust S3FileInfo.Mode implementation to
call S3FileInfo.IsDir() (which uses Name()/FullPath()) and include the
fs.ModeDir flag when appropriate so Mode().IsDir() matches IsDir().


func (obj S3FileInfo) Sys() interface{} {
return obj.Object
}
21 changes: 21 additions & 0 deletions artifact/clients/azure/fileinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package azure

import (
"io/fs"
"time"
)

type BlobFileInfo struct {
BlobName string
BlobSize int64
LastMod time.Time
ContentType string
}

func (f BlobFileInfo) Name() string { return f.BlobName }
func (f BlobFileInfo) Size() int64 { return f.BlobSize }
func (f BlobFileInfo) Mode() fs.FileMode { return 0644 }
func (f BlobFileInfo) ModTime() time.Time { return f.LastMod }
func (f BlobFileInfo) IsDir() bool { return false }
func (f BlobFileInfo) Sys() any { return nil }
func (f BlobFileInfo) FullPath() string { return f.BlobName }
40 changes: 40 additions & 0 deletions artifact/clients/gcp/fileinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package gcp

import (
"io/fs"
"time"

gcs "cloud.google.com/go/storage"
)

type GCSFileInfo struct {
Object *gcs.ObjectAttrs
}

func (GCSFileInfo) IsDir() bool {
return false
}

func (obj GCSFileInfo) ModTime() time.Time {
return obj.Object.Updated
}

func (obj GCSFileInfo) Mode() fs.FileMode {
return fs.FileMode(0644)
}

func (obj GCSFileInfo) Name() string {
return obj.Object.Name
}

func (obj GCSFileInfo) Size() int64 {
return obj.Object.Size
}

func (obj GCSFileInfo) Sys() interface{} {
return obj.Object
}

func (obj GCSFileInfo) FullPath() string {
return obj.Object.Name
}
35 changes: 35 additions & 0 deletions artifact/clients/sftp/sftp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package sftp

import (
"time"

"github.com/pkg/sftp"
"golang.org/x/crypto/ssh"
)

// SSHConnect creates an SFTP client connection.
// NOTE: Uses InsecureIgnoreHostKey because artifact storage targets are
// configured by admins via trusted connection objects, not user input.
func SSHConnect(host, user, password string) (*sftp.Client, error) {
config := &ssh.ClientConfig{
User: user,
Auth: []ssh.AuthMethod{
ssh.Password(password),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(), //nolint:gosec

Check failure

Code scanning / CodeQL

Use of insecure HostKeyCallback implementation High

Configuring SSH ClientConfig with insecure HostKeyCallback implementation from
this source
.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
Comment thread
moshloop marked this conversation as resolved.
Dismissed
Timeout: 30 * time.Second,
}
Comment on lines +14 to +21

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 | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read the relevant lines
if [ -f "artifact/clients/sftp/sftp.go" ]; then
  echo "=== Lines 1-30 of artifact/clients/sftp/sftp.go ==="
  head -30 artifact/clients/sftp/sftp.go | cat -n
else
  echo "File not found, searching for sftp.go files..."
  find . -name "sftp.go" -type f
fi

Repository: flanksource/duty

Length of output: 787


🏁 Script executed:

# Also check total lines in the file to understand context
wc -l artifact/clients/sftp/sftp.go 2>/dev/null || echo "File not accessible"

Repository: flanksource/duty

Length of output: 92


🏁 Script executed:

# Search for any host key validation or certificate pinning elsewhere in the codebase
rg "HostKeyCallback|known_hosts|InsecureIgnoreHostKey" --type go -B 2 -A 2

Repository: flanksource/duty

Length of output: 547


Do not disable SSH host key verification.

ssh.InsecureIgnoreHostKey() disables host authenticity verification and enables man-in-the-middle attacks. Use ssh.FixedHostKey() to pin a known host key, or implement a proper host key callback (e.g., reading from a known_hosts file).

🧰 Tools
🪛 GitHub Check: CodeQL

[failure] 14-14: Use of insecure HostKeyCallback implementation
Configuring SSH ClientConfig with insecure HostKeyCallback implementation from this source.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@artifact/clients/sftp/sftp.go` around lines 9 - 15, The SSH client
configuration currently sets HostKeyCallback to ssh.InsecureIgnoreHostKey(),
which disables host verification; replace this with a proper host-key check by
using either ssh.FixedHostKey(...) with the server's parsed public key or by
building a HostKeyCallback from a known_hosts file (e.g., using
knownhosts.New(knownHostsPath)) and assign that to
ssh.ClientConfig.HostKeyCallback; update the code around the config variable and
ssh.ClientConfig instantiation to parse/load the trusted host key(s) and pass
the resulting callback instead of ssh.InsecureIgnoreHostKey().


conn, err := ssh.Dial("tcp", host, config)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if err != nil {
return nil, err
}

client, err := sftp.NewClient(conn)
if err != nil {
conn.Close()
return nil, err
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

return client, nil
}
Loading
Loading