Skip to content

Commit e7ee09b

Browse files
shivasuryaclaude
andauthored
enhancement(ruleset): add support for category-level ruleset expansion with docker/all syntax (#471)
* enhancement(ruleset): add support for category-level ruleset expansion with docker/all syntax Add support for `docker/all` syntax to automatically expand to all bundles in a category, eliminating the need to manually specify each bundle. Changes: - Add ManifestProvider interface for dependency injection and testing - Update ParseSpec() to detect "all" keyword and mark for expansion - Add GetAllBundleNames() helper to Manifest for bundle listing - Extract expandBundleSpecs() function with interface-based design - Add 11 comprehensive mock-based unit tests with 100% code coverage - Support mixed usage: --ruleset docker/all --ruleset python/django Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * chore: bump version to 1.2.1 (patch release) --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 8f77843 commit e7ee09b

File tree

9 files changed

+355
-6
lines changed

9 files changed

+355
-6
lines changed

python-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "codepathfinder"
7-
version = "1.2.0"
7+
version = "1.2.1"
88
description = "Python SDK for code-pathfinder static analysis for modern security teams"
99
readme = "README.md"
1010
requires-python = ">=3.8"

sast-engine/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.0
1+
1.2.1

sast-engine/cmd/scan.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,11 +561,21 @@ func prepareRules(localRulesPath string, rulesetSpecs []string, refresh bool, lo
561561
// This is a rule ID (e.g., docker/DOCKER-BP-007)
562562
ruleIDSpecs = append(ruleIDSpecs, spec)
563563
} else {
564-
// This is a bundle (e.g., docker/security)
564+
// This is a bundle (e.g., docker/security) or category expansion (e.g., docker/all)
565565
bundleSpecs = append(bundleSpecs, spec)
566566
}
567567
}
568568

569+
// Expand "category/all" specs to individual bundle specs
570+
if len(bundleSpecs) > 0 {
571+
manifestLoader := ruleset.NewManifestLoader("https://assets.codepathfinder.dev/rules", getCacheDir())
572+
expanded, err := expandBundleSpecs(bundleSpecs, manifestLoader, logger)
573+
if err != nil {
574+
return "", "", err
575+
}
576+
bundleSpecs = expanded
577+
}
578+
569579
// Download remote bundles
570580
var downloadedPaths []string
571581
if len(bundleSpecs) > 0 {
@@ -736,6 +746,46 @@ func copyRules(src, dest, subdir string) error {
736746
return nil
737747
}
738748

749+
// expandBundleSpecs expands "category/all" specs into individual bundle specs.
750+
// This function is extracted for testability with mock manifest providers.
751+
func expandBundleSpecs(bundleSpecs []string, manifestProvider ruleset.ManifestProvider, logger *output.Logger) ([]string, error) {
752+
expandedBundleSpecs := make([]string, 0, len(bundleSpecs))
753+
754+
for _, spec := range bundleSpecs {
755+
parsed, err := ruleset.ParseSpec(spec)
756+
if err != nil {
757+
return nil, fmt.Errorf("invalid ruleset spec %s: %w", spec, err)
758+
}
759+
760+
// Check if this is a category expansion (bundle == "*")
761+
if parsed.Bundle == "*" {
762+
// Load category manifest to get all bundle names
763+
manifest, err := manifestProvider.LoadCategoryManifest(parsed.Category)
764+
if err != nil {
765+
return nil, fmt.Errorf("failed to load manifest for category %s: %w", parsed.Category, err)
766+
}
767+
768+
// Expand to all bundles in category
769+
bundleNames := manifest.GetAllBundleNames()
770+
if len(bundleNames) == 0 {
771+
logger.Warning("Category %s has no bundles", parsed.Category)
772+
continue
773+
}
774+
775+
logger.Progress("Expanding %s/all to %d bundles: %v", parsed.Category, len(bundleNames), bundleNames)
776+
777+
for _, bundleName := range bundleNames {
778+
expandedBundleSpecs = append(expandedBundleSpecs, fmt.Sprintf("%s/%s", parsed.Category, bundleName))
779+
}
780+
} else {
781+
// Regular bundle spec, keep as-is
782+
expandedBundleSpecs = append(expandedBundleSpecs, spec)
783+
}
784+
}
785+
786+
return expandedBundleSpecs, nil
787+
}
788+
739789
// copyFile copies a single file from src to dest.
740790
func copyFile(src, dest string) error {
741791
sourceFile, err := os.Open(src)

sast-engine/cmd/scan_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package cmd
22

33
import (
44
"bytes"
5+
"fmt"
56
"io"
67
"os"
78
"testing"
89

910
"github.com/shivasurya/code-pathfinder/sast-engine/dsl"
1011
"github.com/shivasurya/code-pathfinder/sast-engine/graph"
1112
"github.com/shivasurya/code-pathfinder/sast-engine/graph/callgraph/core"
13+
"github.com/shivasurya/code-pathfinder/sast-engine/output"
14+
"github.com/shivasurya/code-pathfinder/sast-engine/ruleset"
1215
"github.com/stretchr/testify/assert"
1316
"github.com/stretchr/testify/require"
1417
)
@@ -407,3 +410,195 @@ line 7`
407410
assert.Equal(t, 0, len(snippet.Lines))
408411
})
409412
}
413+
414+
// mockManifestProvider is a mock implementation of ruleset.ManifestProvider for testing.
415+
type mockManifestProvider struct {
416+
manifests map[string]*ruleset.Manifest
417+
errors map[string]error
418+
}
419+
420+
func newMockManifestProvider() *mockManifestProvider {
421+
return &mockManifestProvider{
422+
manifests: make(map[string]*ruleset.Manifest),
423+
errors: make(map[string]error),
424+
}
425+
}
426+
427+
func (m *mockManifestProvider) LoadCategoryManifest(category string) (*ruleset.Manifest, error) {
428+
if err, exists := m.errors[category]; exists {
429+
return nil, err
430+
}
431+
if manifest, exists := m.manifests[category]; exists {
432+
return manifest, nil
433+
}
434+
return nil, fmt.Errorf("category not found: %s", category)
435+
}
436+
437+
func (m *mockManifestProvider) addManifest(category string, bundleNames []string) {
438+
manifest := &ruleset.Manifest{
439+
Category: category,
440+
Bundles: make(map[string]*ruleset.Bundle),
441+
}
442+
for _, name := range bundleNames {
443+
manifest.Bundles[name] = &ruleset.Bundle{Name: name}
444+
}
445+
m.manifests[category] = manifest
446+
}
447+
448+
func (m *mockManifestProvider) addError(category string, err error) {
449+
m.errors[category] = err
450+
}
451+
452+
func TestExpandBundleSpecs(t *testing.T) {
453+
t.Run("expands docker/all to multiple bundles", func(t *testing.T) {
454+
mock := newMockManifestProvider()
455+
mock.addManifest("docker", []string{"security", "best-practice", "performance"})
456+
logger := output.NewLogger(output.VerbosityDefault)
457+
458+
specs := []string{"docker/all"}
459+
expanded, err := expandBundleSpecs(specs, mock, logger)
460+
461+
require.NoError(t, err)
462+
assert.Equal(t, 3, len(expanded))
463+
assert.Contains(t, expanded, "docker/best-practice")
464+
assert.Contains(t, expanded, "docker/performance")
465+
assert.Contains(t, expanded, "docker/security")
466+
})
467+
468+
t.Run("expands python/all to multiple bundles", func(t *testing.T) {
469+
mock := newMockManifestProvider()
470+
mock.addManifest("python", []string{"deserialization", "django", "flask"})
471+
logger := output.NewLogger(output.VerbosityDefault)
472+
473+
specs := []string{"python/all"}
474+
expanded, err := expandBundleSpecs(specs, mock, logger)
475+
476+
require.NoError(t, err)
477+
assert.Equal(t, 3, len(expanded))
478+
assert.Contains(t, expanded, "python/deserialization")
479+
assert.Contains(t, expanded, "python/django")
480+
assert.Contains(t, expanded, "python/flask")
481+
})
482+
483+
t.Run("keeps regular bundle specs unchanged", func(t *testing.T) {
484+
mock := newMockManifestProvider()
485+
logger := output.NewLogger(output.VerbosityDefault)
486+
487+
specs := []string{"docker/security", "python/django"}
488+
expanded, err := expandBundleSpecs(specs, mock, logger)
489+
490+
require.NoError(t, err)
491+
assert.Equal(t, 2, len(expanded))
492+
assert.Equal(t, "docker/security", expanded[0])
493+
assert.Equal(t, "python/django", expanded[1])
494+
})
495+
496+
t.Run("mixes category expansion with regular specs", func(t *testing.T) {
497+
mock := newMockManifestProvider()
498+
mock.addManifest("docker", []string{"security", "best-practice"})
499+
logger := output.NewLogger(output.VerbosityDefault)
500+
501+
specs := []string{"docker/all", "python/django"}
502+
expanded, err := expandBundleSpecs(specs, mock, logger)
503+
504+
require.NoError(t, err)
505+
assert.Equal(t, 3, len(expanded))
506+
assert.Contains(t, expanded, "docker/best-practice")
507+
assert.Contains(t, expanded, "docker/security")
508+
assert.Contains(t, expanded, "python/django")
509+
})
510+
511+
t.Run("handles category with single bundle", func(t *testing.T) {
512+
mock := newMockManifestProvider()
513+
mock.addManifest("docker", []string{"security"})
514+
logger := output.NewLogger(output.VerbosityDefault)
515+
516+
specs := []string{"docker/all"}
517+
expanded, err := expandBundleSpecs(specs, mock, logger)
518+
519+
require.NoError(t, err)
520+
assert.Equal(t, 1, len(expanded))
521+
assert.Equal(t, "docker/security", expanded[0])
522+
})
523+
524+
t.Run("handles category with no bundles", func(t *testing.T) {
525+
mock := newMockManifestProvider()
526+
mock.addManifest("docker", []string{}) // Empty bundles
527+
logger := output.NewLogger(output.VerbosityDefault)
528+
529+
specs := []string{"docker/all"}
530+
expanded, err := expandBundleSpecs(specs, mock, logger)
531+
532+
require.NoError(t, err)
533+
assert.Equal(t, 0, len(expanded))
534+
})
535+
536+
t.Run("returns error when category manifest fails to load", func(t *testing.T) {
537+
mock := newMockManifestProvider()
538+
mock.addError("nonexistent", fmt.Errorf("HTTP 404: not found"))
539+
logger := output.NewLogger(output.VerbosityDefault)
540+
541+
specs := []string{"nonexistent/all"}
542+
expanded, err := expandBundleSpecs(specs, mock, logger)
543+
544+
require.Error(t, err)
545+
assert.Nil(t, expanded)
546+
assert.Contains(t, err.Error(), "failed to load manifest for category nonexistent")
547+
})
548+
549+
t.Run("returns error for invalid spec format", func(t *testing.T) {
550+
mock := newMockManifestProvider()
551+
logger := output.NewLogger(output.VerbosityDefault)
552+
553+
specs := []string{"invalid-spec-no-slash"}
554+
expanded, err := expandBundleSpecs(specs, mock, logger)
555+
556+
require.Error(t, err)
557+
assert.Nil(t, expanded)
558+
assert.Contains(t, err.Error(), "invalid ruleset spec")
559+
})
560+
561+
t.Run("handles multiple category expansions", func(t *testing.T) {
562+
mock := newMockManifestProvider()
563+
mock.addManifest("docker", []string{"security", "best-practice"})
564+
mock.addManifest("python", []string{"django", "flask"})
565+
logger := output.NewLogger(output.VerbosityDefault)
566+
567+
specs := []string{"docker/all", "python/all"}
568+
expanded, err := expandBundleSpecs(specs, mock, logger)
569+
570+
require.NoError(t, err)
571+
assert.Equal(t, 4, len(expanded))
572+
assert.Contains(t, expanded, "docker/best-practice")
573+
assert.Contains(t, expanded, "docker/security")
574+
assert.Contains(t, expanded, "python/django")
575+
assert.Contains(t, expanded, "python/flask")
576+
})
577+
578+
t.Run("handles empty input specs", func(t *testing.T) {
579+
mock := newMockManifestProvider()
580+
logger := output.NewLogger(output.VerbosityDefault)
581+
582+
specs := []string{}
583+
expanded, err := expandBundleSpecs(specs, mock, logger)
584+
585+
require.NoError(t, err)
586+
assert.Equal(t, 0, len(expanded))
587+
})
588+
589+
t.Run("preserves order for mixed specs", func(t *testing.T) {
590+
mock := newMockManifestProvider()
591+
mock.addManifest("docker", []string{"security"})
592+
logger := output.NewLogger(output.VerbosityDefault)
593+
594+
specs := []string{"python/django", "docker/all", "java/security"}
595+
expanded, err := expandBundleSpecs(specs, mock, logger)
596+
597+
require.NoError(t, err)
598+
assert.Equal(t, 3, len(expanded))
599+
// Order should be: python/django, docker/security (expanded), java/security
600+
assert.Equal(t, "python/django", expanded[0])
601+
assert.Equal(t, "docker/security", expanded[1])
602+
assert.Equal(t, "java/security", expanded[2])
603+
})
604+
}

sast-engine/ruleset/manifest.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"sort"
910
"time"
1011
)
1112

@@ -68,3 +69,15 @@ func (m *Manifest) GetBundle(bundleName string) (*Bundle, error) {
6869
}
6970
return bundle, nil
7071
}
72+
73+
// GetAllBundleNames returns a sorted list of all bundle names in this category.
74+
// Used for expanding "category/all" specs to all available bundles.
75+
func (m *Manifest) GetAllBundleNames() []string {
76+
names := make([]string, 0, len(m.Bundles))
77+
for name := range m.Bundles {
78+
names = append(names, name)
79+
}
80+
// Sort for consistent ordering across runs
81+
sort.Strings(names)
82+
return names
83+
}

sast-engine/ruleset/manifest_test.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,57 @@ func TestManifestGetBundle(t *testing.T) {
111111
t.Errorf("expected error for non-existent bundle, got nil")
112112
}
113113
}
114+
115+
func TestManifestGetAllBundleNames(t *testing.T) {
116+
tests := []struct {
117+
name string
118+
manifest *Manifest
119+
want []string
120+
}{
121+
{
122+
name: "multiple bundles",
123+
manifest: &Manifest{
124+
Bundles: map[string]*Bundle{
125+
"security": {Name: "Security Rules"},
126+
"best-practice": {Name: "Best Practices"},
127+
"performance": {Name: "Performance Rules"},
128+
},
129+
},
130+
want: []string{"best-practice", "performance", "security"},
131+
},
132+
{
133+
name: "single bundle",
134+
manifest: &Manifest{
135+
Bundles: map[string]*Bundle{
136+
"security": {Name: "Security Rules"},
137+
},
138+
},
139+
want: []string{"security"},
140+
},
141+
{
142+
name: "empty bundles",
143+
manifest: &Manifest{
144+
Bundles: map[string]*Bundle{},
145+
},
146+
want: []string{},
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
got := tt.manifest.GetAllBundleNames()
153+
154+
if len(got) != len(tt.want) {
155+
t.Errorf("expected %d bundles, got %d", len(tt.want), len(got))
156+
return
157+
}
158+
159+
// Check each expected bundle name
160+
for i, name := range tt.want {
161+
if got[i] != name {
162+
t.Errorf("expected bundle[%d] = %s, got %s", i, name, got[i])
163+
}
164+
}
165+
})
166+
}
167+
}

0 commit comments

Comments
 (0)