Skip to content
5 changes: 5 additions & 0 deletions sast-engine/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ Examples:
} else {
// Initialize Go stdlib loader and type inference engine
builder.InitGoStdlibLoader(goRegistry, projectPath, logger)

// Initialize Go third-party type loader (vendor/ + GOMODCACHE).
// Pass refreshRules so --refresh-rules also flushes the go-thirdparty disk cache.
builder.InitGoThirdPartyLoader(goRegistry, projectPath, refreshRules, logger)

goTypeEngine := resolution.NewGoTypeInferenceEngine(goRegistry)

goCG, err := builder.BuildGoCallGraph(codeGraph, goRegistry, goTypeEngine)
Expand Down
20 changes: 19 additions & 1 deletion sast-engine/graph/callgraph/builder/go_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -506,14 +506,32 @@ func resolveGoCallTarget(
}
}

// Check 2.5: Validate method via ThirdPartyLoader (vendor/GOMODCACHE)
if registry != nil && registry.ThirdPartyLoader != nil {
importPath, typeName, ok := splitGoTypeFQN(typeFQN)
if ok {
// Skip if already checked as stdlib
isStdlib := registry.StdlibLoader != nil &&
registry.StdlibLoader.ValidateStdlibImport(importPath)
if !isStdlib && registry.ThirdPartyLoader.ValidateImport(importPath) {
tpType, err := registry.ThirdPartyLoader.GetType(importPath, typeName)
if err == nil && tpType != nil {
if _, hasMethod := tpType.Methods[callSite.FunctionName]; hasMethod {
return methodFQN, true, false // resolved via third-party
}
}
}
}
}

// Check 3: Promoted method via struct embedding
if promotedFQN, resolved, isStdlib := resolvePromotedMethod(
typeFQN, callSite.FunctionName, registry,
); resolved {
return promotedFQN, true, isStdlib
}

// Check 4: Third-party / unvalidated — accept with best-effort FQN
// Check 4: Unvalidated — accept with best-effort FQN
return methodFQN, true, false
}
}
Expand Down
180 changes: 180 additions & 0 deletions sast-engine/graph/callgraph/builder/go_builder_thirdparty_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package builder

import (
"os"
"path/filepath"
"testing"

"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/resolution"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestThirdPartyResolution_Check25_MethodValidation tests the full pipeline:
// go.mod dependency → vendor/ source → tree-sitter extraction → Pattern 1b Check 2.5 resolution.
func TestThirdPartyResolution_Check25_MethodValidation(t *testing.T) {
tmpDir := t.TempDir()

// 1. Create go.mod with gorm dependency
goMod := `module testapp

go 1.21

require gorm.io/gorm v1.25.7
`
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644)
require.NoError(t, err)

// 2. Create vendor/gorm.io/gorm/ with type metadata source
vendorDir := filepath.Join(tmpDir, "vendor", "gorm.io", "gorm")
err = os.MkdirAll(vendorDir, 0755)
require.NoError(t, err)

gormSrc := `package gorm

type DB struct {
Error error
}

func (db *DB) Where(query interface{}, args ...interface{}) *DB {
return db
}

func (db *DB) Raw(sql string, values ...interface{}) *DB {
return db
}

func (db *DB) Exec(sql string, values ...interface{}) *DB {
return db
}

func Open(dialector interface{}) (*DB, error) {
return nil, nil
}
`
err = os.WriteFile(filepath.Join(vendorDir, "gorm.go"), []byte(gormSrc), 0644)
require.NoError(t, err)

// 3. Create user code that uses gorm
mainSrc := `package main

import "gorm.io/gorm"

func handler(db *gorm.DB) {
input := "user input"
db.Raw(input)
db.Where(input)
db.Exec(input)
}
`
err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainSrc), 0644)
require.NoError(t, err)

// 4. Build code graph and call graph
codeGraph := graph.Initialize(tmpDir, nil)
require.NotNil(t, codeGraph)

goRegistry, err := resolution.BuildGoModuleRegistry(tmpDir)
require.NoError(t, err)

// Initialize third-party loader (this is what scan.go would do)
InitGoThirdPartyLoader(goRegistry, tmpDir, false, nil)
require.NotNil(t, goRegistry.ThirdPartyLoader, "ThirdPartyLoader should be initialized")

goTypeEngine := resolution.NewGoTypeInferenceEngine(goRegistry)

callGraph, err := BuildGoCallGraph(codeGraph, goRegistry, goTypeEngine)
require.NoError(t, err)
require.NotNil(t, callGraph)

// 5. Verify that third-party methods resolved correctly via Check 2.5
// Look for call sites from handler function
handlerFQN := "testapp.handler"
callSites, ok := callGraph.CallSites[handlerFQN]
require.True(t, ok, "handler function should have call sites")

resolvedTargets := make(map[string]bool)
for _, cs := range callSites {
if cs.Resolved {
resolvedTargets[cs.TargetFQN] = true
}
}

// These should be resolved via Check 2.5 (third-party vendor/)
assert.True(t, resolvedTargets["gorm.io/gorm.DB.Raw"],
"db.Raw() should resolve to gorm.io/gorm.DB.Raw via Check 2.5")
assert.True(t, resolvedTargets["gorm.io/gorm.DB.Where"],
"db.Where() should resolve to gorm.io/gorm.DB.Where via Check 2.5")
assert.True(t, resolvedTargets["gorm.io/gorm.DB.Exec"],
"db.Exec() should resolve to gorm.io/gorm.DB.Exec via Check 2.5")
}

// TestThirdPartyResolution_SubpackagePath tests resolution for subpackages
// within a third-party module (e.g., github.com/gin-gonic/gin/binding).
func TestThirdPartyResolution_SubpackagePath(t *testing.T) {
tmpDir := t.TempDir()

goMod := `module testapp

go 1.21

require github.com/gin-gonic/gin v1.9.1
`
err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goMod), 0644)
require.NoError(t, err)

// Create vendor with gin Context
vendorDir := filepath.Join(tmpDir, "vendor", "github.com", "gin-gonic", "gin")
err = os.MkdirAll(vendorDir, 0755)
require.NoError(t, err)

ginSrc := `package gin

type Context struct{}

func (c *Context) Query(key string) string { return "" }
func (c *Context) Param(key string) string { return "" }

type Engine struct{}

func Default() *Engine { return nil }
`
err = os.WriteFile(filepath.Join(vendorDir, "gin.go"), []byte(ginSrc), 0644)
require.NoError(t, err)

mainSrc := `package main

import "github.com/gin-gonic/gin"

func handler(c *gin.Context) {
q := c.Query("search")
_ = q
}
`
err = os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainSrc), 0644)
require.NoError(t, err)

codeGraph := graph.Initialize(tmpDir, nil)
goRegistry, err := resolution.BuildGoModuleRegistry(tmpDir)
require.NoError(t, err)

InitGoThirdPartyLoader(goRegistry, tmpDir, false, nil)
goTypeEngine := resolution.NewGoTypeInferenceEngine(goRegistry)

callGraph, err := BuildGoCallGraph(codeGraph, goRegistry, goTypeEngine)
require.NoError(t, err)

handlerFQN := "testapp.handler"
callSites := callGraph.CallSites[handlerFQN]

resolvedTargets := make(map[string]bool)
for _, cs := range callSites {
if cs.Resolved {
resolvedTargets[cs.TargetFQN] = true
}
}

assert.True(t, resolvedTargets["github.com/gin-gonic/gin.Context.Query"],
"c.Query() should resolve to github.com/gin-gonic/gin.Context.Query")
}
24 changes: 24 additions & 0 deletions sast-engine/graph/callgraph/builder/go_version.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,27 @@ func initGoStdlibLoaderWithBase(reg *core.GoModuleRegistry, projectPath string,
logger.Progress("Loaded Go %s stdlib manifest (%d packages)", version, remote.PackageCount())
reg.StdlibLoader = remote
}

// InitGoThirdPartyLoader initializes the third-party type loader for Go dependencies.
// Parses go.mod require directives and lazily loads type metadata from vendor/ or GOMODCACHE.
//
// When refreshCache is true (triggered by --refresh-rules on the CLI), the on-disk
// go-thirdparty extraction cache for this project is flushed and rebuilt.
func InitGoThirdPartyLoader(reg *core.GoModuleRegistry, projectPath string, refreshCache bool, logger *output.Logger) {
if reg == nil {
return
}

loader := registry.NewGoThirdPartyLocalLoader(projectPath, refreshCache, logger)
if loader.PackageCount() == 0 {
if logger != nil {
logger.Debug("No Go third-party dependencies found in go.mod")
}
return
}

reg.ThirdPartyLoader = loader
if logger != nil {
logger.Progress("Go third-party loader ready (%d dependencies from go.mod)", loader.PackageCount())
}
}
55 changes: 55 additions & 0 deletions sast-engine/graph/callgraph/builder/go_version_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -290,3 +290,58 @@ func TestInitGoStdlibLoader_PublicAPI_CallsInner(t *testing.T) {

require.NotNil(t, reg.StdlibLoader)
}

// -----------------------------------------------------------------------------
// InitGoThirdPartyLoader
// -----------------------------------------------------------------------------

func TestInitGoThirdPartyLoader_NilReg(t *testing.T) {
// Must not panic when reg is nil.
InitGoThirdPartyLoader(nil, t.TempDir(), false, nil)
}

func TestInitGoThirdPartyLoader_NoDependencies(t *testing.T) {
// go.mod with no require directives → PackageCount == 0 → ThirdPartyLoader stays nil.
tmpDir := t.TempDir()
writeTempFile(t, tmpDir, "go.mod", "module github.com/example/app\n\ngo 1.21\n")

reg := core.NewGoModuleRegistry()
logger := newGoVersionTestLogger()
InitGoThirdPartyLoader(reg, tmpDir, false, logger)

assert.Nil(t, reg.ThirdPartyLoader)
}

func TestInitGoThirdPartyLoader_WithDependencies(t *testing.T) {
// go.mod with one require + vendored source → ThirdPartyLoader is set.
tmpDir := t.TempDir()
writeTempFile(t, tmpDir, "go.mod",
"module github.com/example/app\n\ngo 1.21\n\nrequire example.com/lib v1.0.0\n")

vendorDir := filepath.Join(tmpDir, "vendor", "example.com", "lib")
require.NoError(t, os.MkdirAll(vendorDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(vendorDir, "lib.go"),
[]byte("package lib\ntype Client struct{}\n"), 0o644))

reg := core.NewGoModuleRegistry()
logger := newGoVersionTestLogger()
InitGoThirdPartyLoader(reg, tmpDir, false, logger)

assert.NotNil(t, reg.ThirdPartyLoader)
}

func TestInitGoThirdPartyLoader_RefreshCache(t *testing.T) {
// refreshCache=true should not panic and should still set the loader.
tmpDir := t.TempDir()
writeTempFile(t, tmpDir, "go.mod",
"module github.com/example/app\n\ngo 1.21\n\nrequire example.com/lib v1.0.0\n")

vendorDir := filepath.Join(tmpDir, "vendor", "example.com", "lib")
require.NoError(t, os.MkdirAll(vendorDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(vendorDir, "lib.go"),
[]byte("package lib\ntype Client struct{}\n"), 0o644))

reg := core.NewGoModuleRegistry()
InitGoThirdPartyLoader(reg, tmpDir, true, nil)
assert.NotNil(t, reg.ThirdPartyLoader)
}
6 changes: 6 additions & 0 deletions sast-engine/graph/callgraph/core/go_stdlib_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ type GoStdlibType struct {
IsGeneric bool `json:"is_generic"`
TypeParams []*GoTypeParam `json:"type_params"`
Docstring string `json:"docstring"`

// Embeds lists type names embedded by this interface or struct.
// For interfaces: embedded interface names (e.g., "io.Closer", "EnqueueClient").
// For structs: embedded struct names (e.g., "*sql.DB").
// Used by the third-party loader to flatten embedded methods post-extraction.
Embeds []string `json:"embeds,omitempty"`
}

// GoStructField represents a single exported field in a struct type declaration.
Expand Down
21 changes: 21 additions & 0 deletions sast-engine/graph/callgraph/core/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,10 @@ type GoModuleRegistry struct {
// It is initialized lazily from the CDN registry during call graph construction.
// Nil when stdlib registry loading is disabled or unavailable.
StdlibLoader GoStdlibLoader

// ThirdPartyLoader provides type metadata for Go third-party libraries.
// Parses from vendor/ or GOMODCACHE. Nil when unavailable.
ThirdPartyLoader GoThirdPartyLoader
}

// NewGoModuleRegistry creates an initialized GoModuleRegistry.
Expand Down Expand Up @@ -530,6 +534,23 @@ type GoStdlibLoader interface {
PackageCount() int
}

// GoThirdPartyLoader provides access to Go third-party library type metadata.
// Mirrors GoStdlibLoader and reuses the same GoStdlibType/GoStdlibFunction structs.
// Implemented by registry.GoThirdPartyLocalLoader.
type GoThirdPartyLoader interface {
// ValidateImport reports whether the given import path is a known third-party package.
ValidateImport(importPath string) bool

// GetFunction returns the metadata for a named function in the given third-party package.
GetFunction(importPath, funcName string) (*GoStdlibFunction, error)

// GetType returns the metadata for a named type in the given third-party package.
GetType(importPath, typeName string) (*GoStdlibType, error)

// PackageCount returns the total number of third-party packages available.
PackageCount() int
}

// Helper function to extract the last component of a dotted path.
// Example: "myapp.utils.helpers" → "helpers".
func extractShortName(modulePath string) string {
Expand Down
Loading
Loading