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
35 changes: 35 additions & 0 deletions internal/profiles/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,11 @@ var judgePathPlaceholderPattern = regexp.MustCompile(`\{\{\s*(prompt|output_sche
var scannerIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
var scannerTargetPlaceholderPattern = regexp.MustCompile(`\{\{\s*target\s*\}\}`)

// envVarNamePattern matches a bare environment variable name. Declared scanner
// env entries must be names only; an inline value (API_TOKEN=secret) would leak
// into requirement diagnostics, the artifact env map, and the Docker sandbox.
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

// maxScannerIDLength bounds user-defined scanner IDs so <id>.json evidence file
// names stay within filesystem limits.
const maxScannerIDLength = 64
Expand Down Expand Up @@ -953,6 +958,31 @@ func resolveJudgePaths(command string, configDir string) string {
})
}

// invalidDeclaredEnvName returns the sanitized name of the first declared env
// entry that is not a bare, valid variable name, or "" if all entries are
// valid. It returns only the portion before any "=", never the value, so an
// inline secret (API_TOKEN=sk-live) is not echoed into diagnostics.
func invalidDeclaredEnvName(env []string) string {
for _, entry := range env {
if envVarNamePattern.MatchString(entry) {
continue
}
name := entry
if i := strings.IndexByte(entry, '='); i >= 0 {
name = entry[:i]
}
name = strings.TrimSpace(name)
if i := strings.IndexByte(name, ' '); i >= 0 {
name = name[:i]
}
if name == "" {
return "(empty)"
}
return name
}
return ""
}

func validateProfile(name string, profile Profile) error {
seen := map[string]bool{}
for _, scanner := range profile.Scanners {
Expand All @@ -968,6 +998,11 @@ func validateProfile(name string, profile Profile) error {
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)
}
if scanner.custom {
if bad := invalidDeclaredEnvName(scanner.Env); bad != "" {
return fmt.Errorf("User-defined scanner %s in profile %s has an invalid env entry %q; declare bare variable names and set values in the environment, not inline", scanner.ID, name, bad)
}
}
if scanner.custom && !scannerTargetPlaceholdersAreUnquoted(scanner.Command) {
return fmt.Errorf("User-defined scanner %s in profile %s must use {{target}} outside shell quotes", scanner.ID, name)
}
Expand Down
65 changes: 65 additions & 0 deletions internal/profiles/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,71 @@ profiles:
}
}

func TestResolveArgsRejectsInlineUserDefinedScannerEnvValue(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, `version: 1
profiles:
review:
scanners:
- id: my-scanner
command: my-scanner {{target}}
env:
- API_TOKEN=sk-live-secret
`)

_, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir)
want := `User-defined scanner my-scanner in profile review has an invalid env entry "API_TOKEN"; declare bare variable names and set values in the environment, not inline`
if err == nil || err.Error() != want {
t.Fatalf("err = %v, want %q", err, want)
}
if err != nil && strings.Contains(err.Error(), "sk-live-secret") {
t.Fatalf("error leaked the inline env value: %v", err)
}
}

func TestResolveArgsAllowsBareUserDefinedScannerEnvName(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
writeFile(t, config, `version: 1
profiles:
review:
scanners:
- id: my-scanner
command: my-scanner {{target}}
env:
- API_TOKEN
`)

if _, err := ResolveArgs([]string{"./skill", "--config", config, "--profile", "review"}, dir); err != nil {
t.Fatalf("bare env name rejected: %v", err)
}
}

func TestInvalidDeclaredEnvNameUsesPortableShellIdentifiers(t *testing.T) {
for _, entry := range []string{"API_TOKEN", "_PRIVATE", "token2", "a", "_"} {
if bad := invalidDeclaredEnvName([]string{entry}); bad != "" {
t.Errorf("valid shell variable name %q rejected as %q", entry, bad)
}
}

tests := []struct {
entry string
want string
}{
{entry: "2TOKEN", want: "2TOKEN"},
{entry: "API-TOKEN", want: "API-TOKEN"},
{entry: "API TOKEN", want: "API"},
{entry: "API_TOKEN=value", want: "API_TOKEN"},
{entry: "", want: "(empty)"},
}
for _, test := range tests {
if bad := invalidDeclaredEnvName([]string{test.entry}); bad != test.want {
t.Errorf("invalid shell variable name %q reported as %q, want %q", test.entry, bad, test.want)
}
}
}

func TestResolveArgsRejectsQuotedUserDefinedScannerTargetPlaceholder(t *testing.T) {
dir := t.TempDir()
config := filepath.Join(dir, ".clawscan.yml")
Expand Down
Loading