Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
83 changes: 60 additions & 23 deletions internal/cli/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,36 +25,61 @@ func (c *CLI) newStartCommand() *cobra.Command {
log.Fatal().Msgf("%v", err)
}

if len(templateID) == 0 {
if err := cmd.Help(); err != nil {
log.Fatal().Msgf("%v", err)
}
return
tagsStr, err := cmd.Flags().GetString("tags")
if err != nil {
log.Fatal().Msgf("%v", err)
}

if templateID == "" && tagsStr == "" {
log.Fatal().Msg("either --id or --tags must be provided")
}
if templateID != "" && tagsStr != "" {
log.Fatal().Msg("--id and --tags are mutually exclusive")
}

provider, ok := c.app.GetProvider(providerName)
if !ok {
log.Fatal().Msgf("provider %s not found", providerName)
}

template, err := tmpl.GetByID(c.app.Templates, templateID)
if err != nil {
log.Fatal().Msgf("%v", err)
}
if templateID != "" {
template, err := tmpl.GetByID(c.app.Templates, templateID)
if err != nil {
log.Fatal().Msgf("%v", err)
}

err = provider.Start(template)
if err != nil {
log.Fatal().Msgf("%v", err)
}
c.startTemplate(provider, template, providerName)
} else {
tags := strings.Split(tagsStr, ",")
templates, err := tmpl.GetByTags(c.app.Templates, tags)
if err != nil {
log.Fatal().Msgf("%v", err)
}

log.Info().Msgf("found %d templates matching tags: %s", len(templates), tagsStr)

var failed []string
for _, template := range templates {
if err := provider.Start(template); err != nil {
log.Error().Msgf("failed to start %s: %v", template.ID, err)
failed = append(failed, template.ID)
continue
}

if len(template.PostInstall) > 0 {
log.Info().Msg("Post-installation instructions:")
for _, instruction := range template.PostInstall {
fmt.Printf(" %s\n", instruction)
if len(template.PostInstall) > 0 {
log.Info().Msgf("Post-installation instructions for %s:", template.ID)
for _, instruction := range template.PostInstall {
fmt.Printf(" %s\n", instruction)
}
}

log.Info().Msgf("%s template is running on %s", template.ID, providerName)
}
}

log.Info().Msgf("%s template is running on %s", templateID, providerName)
if len(failed) > 0 {
log.Warn().Msgf("failed to start %d templates: %s", len(failed), strings.Join(failed, ", "))
}
}
},
}

Expand All @@ -65,13 +90,25 @@ func (c *CLI) newStartCommand() *cobra.Command {
cmd.Flags().String("id", "",
"Specify a template ID for targeted vulnerable environment")

if err := cmd.MarkFlagRequired("provider"); err != nil {
cmd.Flags().StringP("tags", "t", "",
"Specify comma-separated tags to start all matching templates (e.g., --tags sqli,xss)")

return cmd
}

// startTemplate starts a single template and logs the result.
func (c *CLI) startTemplate(provider interface{ Start(*tmpl.Template) error }, template *tmpl.Template, providerName string) {
err := provider.Start(template)
if err != nil {
log.Fatal().Msgf("%v", err)
}

if err := cmd.MarkFlagRequired("id"); err != nil {
log.Fatal().Msgf("%v", err)
if len(template.PostInstall) > 0 {
log.Info().Msg("Post-installation instructions:")
for _, instruction := range template.PostInstall {
fmt.Printf(" %s\n", instruction)
}
}

return cmd
log.Info().Msgf("%s template is running on %s", template.ID, providerName)
}
70 changes: 53 additions & 17 deletions internal/cli/stop.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
func (c *CLI) newStopCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "stop",
Short: "Stop vulnerable environment by template id and provider",
Short: "Stop vulnerable environment by template id or tags",
Run: func(cmd *cobra.Command, _ []string) {
providerName, err := cmd.Flags().GetString("provider")
if err != nil {
Expand All @@ -25,22 +25,63 @@ func (c *CLI) newStopCommand() *cobra.Command {
log.Fatal().Msgf("%v", err)
}

tagsStr, err := cmd.Flags().GetString("tags")
if err != nil {
log.Fatal().Msgf("%v", err)
}

if templateID == "" && tagsStr == "" {
log.Fatal().Msg("either --id or --tags must be provided")
}
if templateID != "" && tagsStr != "" {
log.Fatal().Msg("--id and --tags are mutually exclusive")
}

provider, ok := c.app.GetProvider(providerName)
if !ok {
log.Fatal().Msgf("provider %s not found", providerName)
}

template, err := tmpl.GetByID(c.app.Templates, templateID)
if err != nil {
log.Fatal().Msgf("%v", err)
}
if templateID != "" {
template, err := tmpl.GetByID(c.app.Templates, templateID)
if err != nil {
log.Fatal().Msgf("%v", err)
}

err = provider.Stop(template)
if err != nil {
log.Fatal().Msgf("%v", err)
}
err = provider.Stop(template)
if err != nil {
log.Fatal().Msgf("%v", err)
}

log.Info().Msgf("%s template stopped on %s", templateID, providerName)
} else {
tags := strings.Split(tagsStr, ",")
templates, err := tmpl.GetByTags(c.app.Templates, tags)
if err != nil {
log.Fatal().Msgf("%v", err)
}

log.Info().Msgf("found %d templates matching tags: %s", len(templates), tagsStr)
Comment on lines +58 to +64
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

Sanitize tag tokens to avoid accidental “match all”.
strings.Split can yield empty/whitespace tags (e.g., --tags sqli, or --tags sqli, xss). With substring matching, an empty tag could unintentionally match every template. Trim and filter empties before calling GetByTags, and fail if none remain.

🛠️ Proposed fix
-				tags := strings.Split(tagsStr, ",")
-				templates, err := tmpl.GetByTags(c.app.Templates, tags)
+				rawTags := strings.Split(tagsStr, ",")
+				var tags []string
+				for _, t := range rawTags {
+					t = strings.TrimSpace(t)
+					if t != "" {
+						tags = append(tags, t)
+					}
+				}
+				if len(tags) == 0 {
+					log.Fatal().Msg("no valid tags provided")
+				}
+				templates, err := tmpl.GetByTags(c.app.Templates, tags)
 				if err != nil {
 					log.Fatal().Msgf("%v", err)
 				}
 
-				log.Info().Msgf("found %d templates matching tags: %s", len(templates), tagsStr)
+				log.Info().Msgf("found %d templates matching tags: %s", len(templates), strings.Join(tags, ","))
📝 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
tags := strings.Split(tagsStr, ",")
templates, err := tmpl.GetByTags(c.app.Templates, tags)
if err != nil {
log.Fatal().Msgf("%v", err)
}
log.Info().Msgf("found %d templates matching tags: %s", len(templates), tagsStr)
rawTags := strings.Split(tagsStr, ",")
var tags []string
for _, t := range rawTags {
t = strings.TrimSpace(t)
if t != "" {
tags = append(tags, t)
}
}
if len(tags) == 0 {
log.Fatal().Msg("no valid tags provided")
}
templates, err := tmpl.GetByTags(c.app.Templates, tags)
if err != nil {
log.Fatal().Msgf("%v", err)
}
log.Info().Msgf("found %d templates matching tags: %s", len(templates), strings.Join(tags, ","))
🤖 Prompt for AI Agents
In `@internal/cli/stop.go` around lines 58 - 64, The current splitting of tagsStr
into tags can produce empty or whitespace tokens that will match everything;
instead, after splitting tagsStr, trim each token and filter out empty strings
to produce a cleaned tags slice, then if the cleaned slice is empty fail with an
error (or log.Fatal) rather than calling tmpl.GetByTags; call
tmpl.GetByTags(c.app.Templates, cleanedTags) and update the log message to
report cleanedTags (referencing tagsStr, tags, tmpl.GetByTags and
c.app.Templates).


log.Info().Msgf("%s template stopped on %s", templateID, providerName)
var failed []string
var stopped int
for _, template := range templates {
if err := provider.Stop(template); err != nil {
log.Error().Msgf("failed to stop %s: %v", template.ID, err)
failed = append(failed, template.ID)
continue
}
stopped++
log.Info().Msgf("%s template stopped on %s", template.ID, providerName)
}

if len(failed) > 0 {
log.Warn().Msgf("failed to stop %d templates: %s", len(failed), strings.Join(failed, ", "))
}
if stopped > 0 {
log.Info().Msgf("successfully stopped %d templates", stopped)
}
}
},
}

Expand All @@ -51,13 +92,8 @@ func (c *CLI) newStopCommand() *cobra.Command {
cmd.Flags().String("id", "",
"Specify a template ID for targeted vulnerable environment")

if err := cmd.MarkFlagRequired("provider"); err != nil {
log.Fatal().Msgf("%v", err)
}

if err := cmd.MarkFlagRequired("id"); err != nil {
log.Fatal().Msgf("%v", err)
}
cmd.Flags().StringP("tags", "t", "",
"Specify comma-separated tags to stop all matching templates (e.g., --tags sqli,xss)")

return cmd
}
38 changes: 38 additions & 0 deletions pkg/template/template.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,10 +302,48 @@
return &tmpl, nil
}

// GetByTags retrieves all templates that match any of the given tags.
// Tags are matched case-insensitively and support substring matching.
// Returns an error if no templates match the given tags.
func GetByTags(templates map[string]Template, tags []string) ([]*Template, error) {
if len(tags) == 0 {
return nil, fmt.Errorf("no tags provided")
}

var matched []*Template
for _, tmpl := range templates {
if templateMatchesTags(&tmpl, tags) {
t := tmpl // Create a copy to avoid pointer issues
matched = append(matched, &t)
}
}

if len(matched) == 0 {
return nil, fmt.Errorf("no templates found matching tags: %s", strings.Join(tags, ", "))
}

return matched, nil
}

// templateMatchesTags checks if a template matches any of the given tags.
func templateMatchesTags(tmpl *Template, filterTags []string) bool {
for _, filterTag := range filterTags {
filterTag = strings.TrimSpace(filterTag)
if filterTag == "" {
continue
}
for _, templateTag := range tmpl.Info.Tags {
if strings.EqualFold(templateTag, filterTag) ||
strings.Contains(strings.ToLower(templateTag), strings.ToLower(filterTag)) {
return true
}
}
}
return false
// GetDockerComposePath finds and returns the docker-compose file path for a given template ID.
// It searches through all category directories in the templates repository to locate the template.
// Returns the absolute path to the compose file and the working directory.
func GetDockerComposePath(templateID, repoPath string) (composePath string, workingDir string, err error) {

Check failure on line 346 in pkg/template/template.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name GetDockerComposePath, expected (
// Search for template in all category directories
dirEntries, err := os.ReadDir(repoPath)
if err != nil {
Expand Down Expand Up @@ -371,7 +409,7 @@
}

// findTemplateInCategory recursively searches for a template directory within a category.
func findTemplateInCategory(categoryPath, templateID string) (string, error) {

Check failure on line 412 in pkg/template/template.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name findTemplateInCategory, expected (
var foundPath string
err := filepath.WalkDir(categoryPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
Expand Down
95 changes: 95 additions & 0 deletions pkg/template/template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,103 @@
assert.EqualError(t, err, fmt.Sprintf("template %s not found", noneExistTemplateID))
}

func TestGetByTags(t *testing.T) {
templates := map[string]Template{
"sqli-template": {
ID: "sqli-template",
Info: Info{
Name: "SQL Injection Lab",
Tags: []string{"sqli", "web", "owasp"},
},
},
"xss-template": {
ID: "xss-template",
Info: Info{
Name: "XSS Lab",
Tags: []string{"xss", "web", "owasp"},
},
},
"ssrf-template": {
ID: "ssrf-template",
Info: Info{
Name: "SSRF Lab",
Tags: []string{"ssrf", "web"},
},
},
}

// Test single tag match
matched, err := GetByTags(templates, []string{"sqli"})
assert.NoError(t, err)
assert.Len(t, matched, 1)
assert.Equal(t, "sqli-template", matched[0].ID)

// Test multiple tags (OR logic)
matched, err = GetByTags(templates, []string{"sqli", "xss"})
assert.NoError(t, err)
assert.Len(t, matched, 2)

// Test tag that matches multiple templates
matched, err = GetByTags(templates, []string{"web"})
assert.NoError(t, err)
assert.Len(t, matched, 3)

// Test case-insensitive matching
matched, err = GetByTags(templates, []string{"SQLI"})
assert.NoError(t, err)
assert.Len(t, matched, 1)

// Test substring matching
matched, err = GetByTags(templates, []string{"owa"})
assert.NoError(t, err)
assert.Len(t, matched, 2) // sqli-template and xss-template have "owasp"

// Test no matches
matched, err = GetByTags(templates, []string{"nonexistent"})
assert.Error(t, err)
assert.Nil(t, matched)
assert.Contains(t, err.Error(), "no templates found matching tags")

// Test empty tags
matched, err = GetByTags(templates, []string{})
assert.Error(t, err)
assert.Nil(t, matched)
assert.Contains(t, err.Error(), "no tags provided")

// Test whitespace-only tags are ignored
matched, err = GetByTags(templates, []string{" ", "sqli"})
assert.NoError(t, err)
assert.Len(t, matched, 1)
}

func TestTemplateMatchesTags(t *testing.T) {
tmpl := &Template{
ID: "test-template",
Info: Info{
Tags: []string{"sqli", "XSS", "OWASP-Top10"},
},
}

// Exact match
assert.True(t, templateMatchesTags(tmpl, []string{"sqli"}))

// Case-insensitive match
assert.True(t, templateMatchesTags(tmpl, []string{"SQLI"}))
assert.True(t, templateMatchesTags(tmpl, []string{"xss"}))

// Substring match
assert.True(t, templateMatchesTags(tmpl, []string{"owasp"}))
assert.True(t, templateMatchesTags(tmpl, []string{"top10"}))

// No match
assert.False(t, templateMatchesTags(tmpl, []string{"nonexistent"}))

// Empty filter tags
assert.False(t, templateMatchesTags(tmpl, []string{}))
assert.False(t, templateMatchesTags(tmpl, []string{" ", ""}))

// createTestTemplate creates a template directory with an index.yaml file
func createTestTemplate(t *testing.T, basePath, templateID string) {

Check failure on line 132 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name createTestTemplate, expected (
t.Helper()
templateContent := fmt.Sprintf(`
id: %s
Expand Down Expand Up @@ -62,7 +157,7 @@
assert.NoError(t, err)
}

func TestIsTemplateDirectory(t *testing.T) {

Check failure on line 160 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestIsTemplateDirectory, expected (
tempDir := t.TempDir()

// Create a directory without index.yaml
Expand All @@ -83,7 +178,7 @@
assert.False(t, isTemplateDirectory(filepath.Join(tempDir, "nonexistent")))
}

func TestLoadTemplatesFromCategoryNestedStructure(t *testing.T) {

Check failure on line 181 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromCategoryNestedStructure, expected (
tempDir := t.TempDir()

// Create a nested directory structure:
Expand Down Expand Up @@ -117,7 +212,7 @@
assert.Contains(t, templates, "template-3")
}

func TestLoadTemplatesFromCategorySkipsHiddenDirs(t *testing.T) {

Check failure on line 215 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromCategorySkipsHiddenDirs, expected (
tempDir := t.TempDir()

// Create a visible template
Expand All @@ -137,7 +232,7 @@
assert.NotContains(t, templates, "hidden-template")
}

func TestLoadTemplatesFromCategoryMaxDepth(t *testing.T) {

Check failure on line 235 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromCategoryMaxDepth, expected (
tempDir := t.TempDir()

// Create a deeply nested structure exceeding maxScanDepth
Expand All @@ -154,7 +249,7 @@
assert.Contains(t, err.Error(), "maximum directory depth")
}

func TestLoadTemplatesFromCategoryDuplicateID(t *testing.T) {

Check failure on line 252 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromCategoryDuplicateID, expected (
tempDir := t.TempDir()

// Create two templates with the same ID in different subdirectories
Expand All @@ -171,7 +266,7 @@
assert.Contains(t, err.Error(), "duplicate template id")
}

func TestLoadTemplatesFromCategoryWithSubdirectories(t *testing.T) {

Check failure on line 269 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromCategoryWithSubdirectories, expected (
tempDir := t.TempDir()

// Create category with nested templates
Expand All @@ -197,7 +292,7 @@
assert.Contains(t, templates, "nested-template")
}

func TestLoadTemplatesFromDirectoryWithNestedCategories(t *testing.T) {

Check failure on line 295 in pkg/template/template_test.go

View workflow job for this annotation

GitHub Actions / Lint

syntax error: unexpected name TestLoadTemplatesFromDirectoryWithNestedCategories, expected (
tempDir := t.TempDir()

// Create structure:
Expand Down