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
2 changes: 1 addition & 1 deletion python-sdk/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "codepathfinder"
version = "1.2.0"
version = "1.2.1"
description = "Python SDK for code-pathfinder static analysis for modern security teams"
readme = "README.md"
requires-python = ">=3.8"
Expand Down
2 changes: 1 addition & 1 deletion sast-engine/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.2.0
1.2.1
52 changes: 51 additions & 1 deletion sast-engine/cmd/scan.go
Original file line number Diff line number Diff line change
Expand Up @@ -561,11 +561,21 @@ func prepareRules(localRulesPath string, rulesetSpecs []string, refresh bool, lo
// This is a rule ID (e.g., docker/DOCKER-BP-007)
ruleIDSpecs = append(ruleIDSpecs, spec)
} else {
// This is a bundle (e.g., docker/security)
// This is a bundle (e.g., docker/security) or category expansion (e.g., docker/all)
bundleSpecs = append(bundleSpecs, spec)
}
}

// Expand "category/all" specs to individual bundle specs
if len(bundleSpecs) > 0 {
manifestLoader := ruleset.NewManifestLoader("https://assets.codepathfinder.dev/rules", getCacheDir())
expanded, err := expandBundleSpecs(bundleSpecs, manifestLoader, logger)
if err != nil {
return "", "", err
}
bundleSpecs = expanded
}

// Download remote bundles
var downloadedPaths []string
if len(bundleSpecs) > 0 {
Expand Down Expand Up @@ -736,6 +746,46 @@ func copyRules(src, dest, subdir string) error {
return nil
}

// expandBundleSpecs expands "category/all" specs into individual bundle specs.
// This function is extracted for testability with mock manifest providers.
func expandBundleSpecs(bundleSpecs []string, manifestProvider ruleset.ManifestProvider, logger *output.Logger) ([]string, error) {
expandedBundleSpecs := make([]string, 0, len(bundleSpecs))

for _, spec := range bundleSpecs {
parsed, err := ruleset.ParseSpec(spec)
if err != nil {
return nil, fmt.Errorf("invalid ruleset spec %s: %w", spec, err)
}

// Check if this is a category expansion (bundle == "*")
if parsed.Bundle == "*" {
// Load category manifest to get all bundle names
manifest, err := manifestProvider.LoadCategoryManifest(parsed.Category)
if err != nil {
return nil, fmt.Errorf("failed to load manifest for category %s: %w", parsed.Category, err)
}

// Expand to all bundles in category
bundleNames := manifest.GetAllBundleNames()
if len(bundleNames) == 0 {
logger.Warning("Category %s has no bundles", parsed.Category)
continue
}

logger.Progress("Expanding %s/all to %d bundles: %v", parsed.Category, len(bundleNames), bundleNames)

for _, bundleName := range bundleNames {
expandedBundleSpecs = append(expandedBundleSpecs, fmt.Sprintf("%s/%s", parsed.Category, bundleName))
}
} else {
// Regular bundle spec, keep as-is
expandedBundleSpecs = append(expandedBundleSpecs, spec)
}
}

return expandedBundleSpecs, nil
}

// copyFile copies a single file from src to dest.
func copyFile(src, dest string) error {
sourceFile, err := os.Open(src)
Expand Down
195 changes: 195 additions & 0 deletions sast-engine/cmd/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package cmd

import (
"bytes"
"fmt"
"io"
"os"
"testing"

"github.com/shivasurya/code-pathfinder/sast-engine/dsl"
"github.com/shivasurya/code-pathfinder/sast-engine/graph"
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/core"
"github.com/shivasurya/code-pathfinder/sast-engine/output"
"github.com/shivasurya/code-pathfinder/sast-engine/ruleset"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -407,3 +410,195 @@ line 7`
assert.Equal(t, 0, len(snippet.Lines))
})
}

// mockManifestProvider is a mock implementation of ruleset.ManifestProvider for testing.
type mockManifestProvider struct {
manifests map[string]*ruleset.Manifest
errors map[string]error
}

func newMockManifestProvider() *mockManifestProvider {
return &mockManifestProvider{
manifests: make(map[string]*ruleset.Manifest),
errors: make(map[string]error),
}
}

func (m *mockManifestProvider) LoadCategoryManifest(category string) (*ruleset.Manifest, error) {
if err, exists := m.errors[category]; exists {
return nil, err
}
if manifest, exists := m.manifests[category]; exists {
return manifest, nil
}
return nil, fmt.Errorf("category not found: %s", category)
}

func (m *mockManifestProvider) addManifest(category string, bundleNames []string) {
manifest := &ruleset.Manifest{
Category: category,
Bundles: make(map[string]*ruleset.Bundle),
}
for _, name := range bundleNames {
manifest.Bundles[name] = &ruleset.Bundle{Name: name}
}
m.manifests[category] = manifest
}

func (m *mockManifestProvider) addError(category string, err error) {
m.errors[category] = err
}

func TestExpandBundleSpecs(t *testing.T) {
t.Run("expands docker/all to multiple bundles", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{"security", "best-practice", "performance"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 3, len(expanded))
assert.Contains(t, expanded, "docker/best-practice")
assert.Contains(t, expanded, "docker/performance")
assert.Contains(t, expanded, "docker/security")
})

t.Run("expands python/all to multiple bundles", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("python", []string{"deserialization", "django", "flask"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"python/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 3, len(expanded))
assert.Contains(t, expanded, "python/deserialization")
assert.Contains(t, expanded, "python/django")
assert.Contains(t, expanded, "python/flask")
})

t.Run("keeps regular bundle specs unchanged", func(t *testing.T) {
mock := newMockManifestProvider()
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/security", "python/django"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 2, len(expanded))
assert.Equal(t, "docker/security", expanded[0])
assert.Equal(t, "python/django", expanded[1])
})

t.Run("mixes category expansion with regular specs", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{"security", "best-practice"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/all", "python/django"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 3, len(expanded))
assert.Contains(t, expanded, "docker/best-practice")
assert.Contains(t, expanded, "docker/security")
assert.Contains(t, expanded, "python/django")
})

t.Run("handles category with single bundle", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{"security"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 1, len(expanded))
assert.Equal(t, "docker/security", expanded[0])
})

t.Run("handles category with no bundles", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{}) // Empty bundles
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 0, len(expanded))
})

t.Run("returns error when category manifest fails to load", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addError("nonexistent", fmt.Errorf("HTTP 404: not found"))
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"nonexistent/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.Error(t, err)
assert.Nil(t, expanded)
assert.Contains(t, err.Error(), "failed to load manifest for category nonexistent")
})

t.Run("returns error for invalid spec format", func(t *testing.T) {
mock := newMockManifestProvider()
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"invalid-spec-no-slash"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.Error(t, err)
assert.Nil(t, expanded)
assert.Contains(t, err.Error(), "invalid ruleset spec")
})

t.Run("handles multiple category expansions", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{"security", "best-practice"})
mock.addManifest("python", []string{"django", "flask"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"docker/all", "python/all"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 4, len(expanded))
assert.Contains(t, expanded, "docker/best-practice")
assert.Contains(t, expanded, "docker/security")
assert.Contains(t, expanded, "python/django")
assert.Contains(t, expanded, "python/flask")
})

t.Run("handles empty input specs", func(t *testing.T) {
mock := newMockManifestProvider()
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 0, len(expanded))
})

t.Run("preserves order for mixed specs", func(t *testing.T) {
mock := newMockManifestProvider()
mock.addManifest("docker", []string{"security"})
logger := output.NewLogger(output.VerbosityDefault)

specs := []string{"python/django", "docker/all", "java/security"}
expanded, err := expandBundleSpecs(specs, mock, logger)

require.NoError(t, err)
assert.Equal(t, 3, len(expanded))
// Order should be: python/django, docker/security (expanded), java/security
assert.Equal(t, "python/django", expanded[0])
assert.Equal(t, "docker/security", expanded[1])
assert.Equal(t, "java/security", expanded[2])
})
}
13 changes: 13 additions & 0 deletions sast-engine/ruleset/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"time"
)

Expand Down Expand Up @@ -68,3 +69,15 @@ func (m *Manifest) GetBundle(bundleName string) (*Bundle, error) {
}
return bundle, nil
}

// GetAllBundleNames returns a sorted list of all bundle names in this category.
// Used for expanding "category/all" specs to all available bundles.
func (m *Manifest) GetAllBundleNames() []string {
names := make([]string, 0, len(m.Bundles))
for name := range m.Bundles {
names = append(names, name)
}
// Sort for consistent ordering across runs
sort.Strings(names)
return names
}
54 changes: 54 additions & 0 deletions sast-engine/ruleset/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,57 @@ func TestManifestGetBundle(t *testing.T) {
t.Errorf("expected error for non-existent bundle, got nil")
}
}

func TestManifestGetAllBundleNames(t *testing.T) {
tests := []struct {
name string
manifest *Manifest
want []string
}{
{
name: "multiple bundles",
manifest: &Manifest{
Bundles: map[string]*Bundle{
"security": {Name: "Security Rules"},
"best-practice": {Name: "Best Practices"},
"performance": {Name: "Performance Rules"},
},
},
want: []string{"best-practice", "performance", "security"},
},
{
name: "single bundle",
manifest: &Manifest{
Bundles: map[string]*Bundle{
"security": {Name: "Security Rules"},
},
},
want: []string{"security"},
},
{
name: "empty bundles",
manifest: &Manifest{
Bundles: map[string]*Bundle{},
},
want: []string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.manifest.GetAllBundleNames()

if len(got) != len(tt.want) {
t.Errorf("expected %d bundles, got %d", len(tt.want), len(got))
return
}

// Check each expected bundle name
for i, name := range tt.want {
if got[i] != name {
t.Errorf("expected bundle[%d] = %s, got %s", i, name, got[i])
}
}
})
}
}
Loading
Loading