diff --git a/internal/profiles/resolver.go b/internal/profiles/resolver.go index ca69a59..3f16767 100644 --- a/internal/profiles/resolver.go +++ b/internal/profiles/resolver.go @@ -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 .json evidence file // names stay within filesystem limits. const maxScannerIDLength = 64 @@ -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 { @@ -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) } diff --git a/internal/profiles/resolver_test.go b/internal/profiles/resolver_test.go index 4c8ca2d..c36f989 100644 --- a/internal/profiles/resolver_test.go +++ b/internal/profiles/resolver_test.go @@ -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")