From 8bcfe8762f6c70b918188b8cb072c6e642270ff1 Mon Sep 17 00:00:00 2001 From: Jesse Merhi <79823012+jesse-merhi@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:31:06 +1000 Subject: [PATCH] fix(profiles): restrict user-defined scanner IDs to lowercase Scanner evidence is written to a file named after the scanner ID, and that name is lowercased. Allowing uppercase IDs let two case-distinct IDs (Foo and foo) pass validation yet collide on the same evidence file, silently overwriting each other. Restrict IDs to lowercase and cap length at 64 characters, since IDs are used as file names. --- internal/profiles/resolver.go | 15 ++++++++++-- internal/profiles/resolver_test.go | 37 +++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index 0ae5561..ca69a59 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -310,9 +310,17 @@ type cliIntent struct { } var judgePathPlaceholderPattern = regexp.MustCompile(`\{\{\s*(prompt|output_schema):([^}]+)\}\}`) -var scannerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`) + +// scannerIDPattern restricts user-defined scanner IDs to lowercase. IDs are +// lowercased when used as evidence file names, so allowing uppercase would let +// two case-distinct IDs (Foo and foo) collide on the same output file. +var scannerIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) var scannerTargetPlaceholderPattern = regexp.MustCompile(`\{\{\s*target\s*\}\}`) +// maxScannerIDLength bounds user-defined scanner IDs so .json evidence file +// names stay within filesystem limits. +const maxScannerIDLength = 64 + type ResolvedRunSet struct { Options []runner.Options OutputPath string @@ -952,7 +960,10 @@ func validateProfile(name string, profile Profile) error { return fmt.Errorf("User-defined scanner in profile %s must include a non-empty id", name) } if scanner.custom && !scannerIDPattern.MatchString(scanner.ID) { - return fmt.Errorf("User-defined scanner %s in profile %s has invalid id; use letters, digits, underscores, and hyphens, starting with a letter or digit", scanner.ID, name) + return fmt.Errorf("User-defined scanner %s in profile %s has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit", scanner.ID, name) + } + if scanner.custom && len(scanner.ID) > maxScannerIDLength { + return fmt.Errorf("User-defined scanner id in profile %s is %d characters; scanner IDs are used as file names and must be at most %d characters", name, len(scanner.ID), maxScannerIDLength) } if scanner.custom && strings.TrimSpace(scanner.Command) == "" { return fmt.Errorf("User-defined scanner %s in profile %s must include a non-empty command", scanner.ID, name) diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index accb291..4c8ca2d 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -1078,7 +1078,42 @@ profiles: `) _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) - if err == nil || err.Error() != "User-defined scanner foo=bar in profile review has invalid id; use letters, digits, underscores, and hyphens, starting with a letter or digit" { + if err == nil || err.Error() != "User-defined scanner foo=bar in profile review has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit" { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsRejectsUppercaseUserDefinedScannerID(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: Foo + command: scanner {{target}} +`) + + _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err == nil || err.Error() != "User-defined scanner Foo in profile review has invalid id; use lowercase letters, digits, underscores, and hyphens, starting with a letter or digit" { + t.Fatalf("err = %v", err) + } +} + +func TestResolveArgsRejectsOversizedUserDefinedScannerID(t *testing.T) { + dir := t.TempDir() + config := filepath.Join(dir, ".clawscan.yml") + longID := strings.Repeat("a", 65) + writeFile(t, config, `version: 1 +profiles: + review: + scanners: + - id: `+longID+` + command: scanner {{target}} +`) + + _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir) + if err == nil || err.Error() != "User-defined scanner id in profile review is 65 characters; scanner IDs are used as file names and must be at most 64 characters" { t.Fatalf("err = %v", err) } }