Skip to content
Open
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
9 changes: 6 additions & 3 deletions docs/scanners.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ profiles:
command: my-scanner --json {{target}}
env:
- MY_SCANNER_TOKEN
secretEnv:
- MY_SECRET_KEY
targets:
- skill
- plugin
Expand All @@ -47,7 +49,8 @@ that config-backed run and accept these fields:
| --- | --- | --- |
| `id` | yes | Scanner ID using lowercase letters, digits, `_`, and `-`, starting with a letter or digit, at most 64 characters. It must not match a built-in scanner ID or a reserved Windows device name such as `con`, `nul`, or `com1`. |
| `command` | yes | Shell command to execute. Unquoted `{{target}}` is replaced with the safely passed resolved target; do not wrap the placeholder in shell quotes. |
| `env` | no | Required environment variable names. Values stay in the process environment and are never stored in the config or artifact. |
| `env` | no | Environment variable names passed through to the scanner. Values are shown in output unless the name itself looks like a credential (e.g., ends with `_TOKEN` or `_PASSWORD`), in which case the heuristic backstop redacts them. |
| `secretEnv` | no | Environment variable names passed through to the scanner and always redacted from persisted output, regardless of name. Use this for credentials and sensitive values. |
| `targets` | no | Supported target kinds: `skill`, `plugin`, and/or `url`. Defaults to `skill` and `url`. |
| `gate` | no | Exit-code policy with optional `blockOnExitCode` and `warnOnExitCode` rules. |

Expand Down Expand Up @@ -90,10 +93,10 @@ value if they must be redacted. Required environment
variables are checked before any scanner starts. Artifacts record each
requirement as only `present` or `missing`.

Scanner credentials should be supplied through environment variables declared under `env:` (or the sandbox env allowlist). ClawScan can only redact values it was told about via declared env vars; a secret written directly into the command — whether as an inline `NAME=value` assignment or as a flag value such as `--token sk-live` — is outside every redaction scope and can leak into saved evidence if the scanner echoes its arguments. ClawScan does not block either form; keeping all credentials in declared environment variables is the operator's responsibility.
Scanner credentials should be supplied through environment variables. Use `secretEnv:` to declare environment variables that must always be redacted, even if their names don't look like credentials. Use `env:` for non-secret configuration that should appear in output; credential-named variables (e.g., `MY_TOKEN`, `API_KEY`) in `env:` are still redacted as a safety backstop. ClawScan can only redact values it was told about via declared env vars; a secret written directly into the command — whether as an inline `NAME=value` assignment or as a flag value such as `--token sk-live` — is outside every redaction scope and can leak into saved evidence if the scanner echoes its arguments. ClawScan does not block either form; keeping all credentials in declared environment variables is the operator's responsibility.

User-defined scanners use the same execution path as built-in command-backed
scanners. They run in the Docker sandbox by default, and declared `env` names
scanners. They run in the Docker sandbox by default, and declared `env` and `secretEnv` names
are added to its environment allowlist. Use `--sandbox off` only when you
intentionally want the command to run on the host. User-defined scanners are
local to the resolved config and do not appear in the built-in `clawscan
Expand Down
59 changes: 33 additions & 26 deletions internal/profiles/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,13 @@ func (profile Profile) ScannerIDs() []string {
}

type ProfileScanner struct {
ID string
Command string
Env []string
Targets []string
Gate *ProfileScannerGate
custom bool
ID string
Command string
Env []string
SecretEnv []string
Targets []string
Gate *ProfileScannerGate
custom bool
}

type ProfileScannerGate struct {
Expand Down Expand Up @@ -153,41 +154,43 @@ func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error {
case yaml.MappingNode:
for index := 0; index < len(node.Content); index += 2 {
switch node.Content[index].Value {
case "id", "command", "env", "targets", "gate":
case "id", "command", "env", "secretEnv", "targets", "gate":
default:
return fmt.Errorf("field %s not found in type profiles.ProfileScanner", node.Content[index].Value)
}
}
for index := 0; index < len(node.Content); index += 2 {
if node.Content[index].Value != "env" {
if node.Content[index].Value != "env" && node.Content[index].Value != "secretEnv" {
continue
}
envNode := node.Content[index+1]
nullScalar := envNode.Kind == yaml.ScalarNode && (envNode.Tag == "!!null" || envNode.Value == "")
if envNode.Kind != yaml.SequenceNode && !nullScalar {
return errors.New("scanner env must be a list of variable names")
return fmt.Errorf("scanner %s must be a list of variable names", node.Content[index].Value)
}
if envNode.Kind == yaml.SequenceNode {
for entryIndex, entry := range envNode.Content {
if entry.Kind != yaml.ScalarNode {
return fmt.Errorf("scanner env entry #%d must be a variable name", entryIndex+1)
return fmt.Errorf("scanner %s entry #%d must be a variable name", node.Content[index].Value, entryIndex+1)
}
}
}
}
var value struct {
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
Targets []string `yaml:"targets,omitempty"`
Gate *ProfileScannerGate `yaml:"gate,omitempty"`
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
SecretEnv []string `yaml:"secretEnv,omitempty"`
Targets []string `yaml:"targets,omitempty"`
Gate *ProfileScannerGate `yaml:"gate,omitempty"`
}
if err := node.Decode(&value); err != nil {
return err
}
scanner.ID = value.ID
scanner.Command = value.Command
scanner.Env = value.Env
scanner.SecretEnv = value.SecretEnv
scanner.Targets = value.Targets
scanner.Gate = value.Gate
scanner.custom = true
Expand All @@ -202,12 +205,13 @@ func (scanner ProfileScanner) MarshalYAML() (interface{}, error) {
return scanner.ID, nil
}
return struct {
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
Targets []string `yaml:"targets,omitempty"`
Gate *ProfileScannerGate `yaml:"gate,omitempty"`
}{scanner.ID, scanner.Command, scanner.Env, scanner.Targets, scanner.Gate}, nil
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
SecretEnv []string `yaml:"secretEnv,omitempty"`
Targets []string `yaml:"targets,omitempty"`
Gate *ProfileScannerGate `yaml:"gate,omitempty"`
}{scanner.ID, scanner.Command, scanner.Env, scanner.SecretEnv, scanner.Targets, scanner.Gate}, nil
}

func profileScannerIDs(scanners []ProfileScanner) []string {
Expand All @@ -227,12 +231,15 @@ func profileScannerRegistry(scanners []ProfileScanner) (runner.ScannerRegistry,
if bad := runner.InvalidUserDefinedEnvName(scanner.Env); bad != "" {
return runner.ScannerRegistry{}, fmt.Errorf("scanner %s env entry %s is not a variable name; declare bare names and set values in the environment", scanner.ID, bad)
}
if bad := runner.InvalidUserDefinedEnvName(scanner.SecretEnv); bad != "" {
return runner.ScannerRegistry{}, fmt.Errorf("scanner %s secretEnv entry %s is not a variable name; declare bare names and set values in the environment", scanner.ID, bad)
}
targets := append([]string(nil), scanner.Targets...)
if len(targets) == 0 {
targets = []string{"skill", "url"}
}
adapter := runner.NewUserDefinedScanner(runner.UserDefinedScannerConfig{
ID: scanner.ID, Command: scanner.Command, Env: scanner.Env, Targets: targets,
ID: scanner.ID, Command: scanner.Command, Env: scanner.Env, SecretEnv: scanner.SecretEnv, Targets: targets,
})
var err error
registry, err = registry.WithAdapters(adapter)
Expand All @@ -244,7 +251,7 @@ func profileScannerRegistry(scanners []ProfileScanner) (runner.ScannerRegistry,
}

// declaredEnvNames unions the env var names every profile in the registry
// declares (scanner env plus sandbox passthrough). A single-profile run
// declares (scanner secretEnv plus sandbox passthrough). A single-profile run
// with --sandbox off inherits the full host environment, so a blandly
// named credential declared only by a sibling profile in the same config
// must still be redacted from persisted output.
Expand All @@ -260,9 +267,9 @@ func (registry ProfileRegistry) declaredEnvNames() []string {
}
for _, resolved := range registry.profiles {
for _, scanner := range resolved.profile.Scanners {
// scanner env: entries are credentials by declaration whatever
// their spelling.
for _, name := range scanner.Env {
// scanner secretEnv: entries are credentials by declaration
// whatever their spelling.
for _, name := range scanner.SecretEnv {
add(name)
}
}
Expand Down
2 changes: 1 addition & 1 deletion internal/profiles/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ profiles:
scanners:
- id: alpha
command: alpha {{target}}
env: [SHARED_ACCESS]
secretEnv: [SHARED_ACCESS]
profile-b:
scanners:
- id: beta
Expand Down
20 changes: 10 additions & 10 deletions internal/runner/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1381,7 +1381,7 @@ func TestRunHostRedactionCoversSkippedScannersEnv(t *testing.T) {
ID: "alpha", Command: "alpha {{target}}", Targets: []string{"skill"},
})
pluginOnly := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "plugin-only", Command: "plugin-only {{target}}", Env: []string{"BETA_LICENSE"}, Targets: []string{"plugin"},
ID: "plugin-only", Command: "plugin-only {{target}}", SecretEnv: []string{"BETA_LICENSE"}, Targets: []string{"plugin"},
})
registry, err := NewScannerRegistry(alpha, pluginOnly)
if err != nil {
Expand Down Expand Up @@ -1421,7 +1421,7 @@ func TestRunHostRedactionCoversUnselectedRegistryScannersEnv(t *testing.T) {
t.Fatal(err)
}
alpha := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
})
beta := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "beta", Command: "beta {{target}}", Targets: []string{"skill"},
Expand Down Expand Up @@ -1467,7 +1467,7 @@ func TestRunRedactsDeclaredCredentialsFromFixtureResults(t *testing.T) {
t.Fatal(err)
}
alpha := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(alpha)
if err != nil {
Expand Down Expand Up @@ -1500,7 +1500,7 @@ func TestRunScannerRedactsDeclaredCredentialsFromBuiltinAdapters(t *testing.T) {
// built-in scanner exposes the credential to both, and the built-in's
// stdout/stderr must be scrubbed too.
alpha := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := DefaultScannerRegistry().WithAdapters(alpha)
if err != nil {
Expand Down Expand Up @@ -1559,7 +1559,7 @@ func TestRedactionEnvNamesDockerExcludesUnexposedSiblingCredentials(t *testing.T
// enters the container; scrubbing its value (ALPHA_ACCESS=clean) would
// rewrite legitimate "clean" verdicts without preventing a leak.
alpha := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
})
beta := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "beta", Command: "beta {{target}}", Targets: []string{"skill"},
Expand Down Expand Up @@ -1605,7 +1605,7 @@ func TestRunDockerRedactionSkipsNonRunnableScannerCredentials(t *testing.T) {
ID: "alpha", Command: "alpha {{target}}", Targets: []string{"skill"},
})
pluginOnly := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "plugin-only", Command: "plugin-only {{target}}", Env: []string{"BETA_LICENSE"}, Targets: []string{"plugin"},
ID: "plugin-only", Command: "plugin-only {{target}}", SecretEnv: []string{"BETA_LICENSE"}, Targets: []string{"plugin"},
})
registry, err := NewScannerRegistry(alpha, pluginOnly)
if err != nil {
Expand Down Expand Up @@ -1773,7 +1773,7 @@ func TestRunProfileBatchRedactsSiblingProfileCredentials(t *testing.T) {
t.Fatal(err)
}
alpha := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"ALPHA_ACCESS"}, Targets: []string{"skill"},
})
alphaRegistry, err := NewScannerRegistry(alpha)
if err != nil {
Expand Down Expand Up @@ -4777,7 +4777,7 @@ func TestRunJudgeRedactsDeclaredScannerEnvFromResult(t *testing.T) {
t.Fatal(err)
}
custom := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "custom", Command: "custom {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "custom", Command: "custom {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := DefaultScannerRegistry().WithAdapters(custom)
if err != nil {
Expand Down Expand Up @@ -4824,7 +4824,7 @@ func TestRunJudgeRedactsNumericDeclaredCredentialScalar(t *testing.T) {
t.Fatal(err)
}
custom := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "custom", Command: "custom {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "custom", Command: "custom {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := DefaultScannerRegistry().WithAdapters(custom)
if err != nil {
Expand Down Expand Up @@ -4875,7 +4875,7 @@ func TestRunFixtureScannerEnvStillRedactedOnHost(t *testing.T) {
t.Fatal(err)
}
fixtureScanner := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "fixture-scanner", Command: "fixture {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "fixture-scanner", Command: "fixture {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
live := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "live-scanner", Command: "live {{target}}", Targets: []string{"skill"},
Expand Down
36 changes: 36 additions & 0 deletions internal/runner/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,9 +432,19 @@ func redactionEnvNames(opts Options, env map[string]string, sandboxMode string)
// credentials — except explicit user-defined env: declarations, which
// are credentials whatever their name.
declared := declaredCredentialEnvNames(opts)
// Plain env: declarations are operator-chosen configuration, shown in
// evidence. They reach the redaction sweep via the reachability set
// (Requirements/RequiredEnv union env+secretEnv), so exempt them from the
// fail-closed CredentialEnvName default below. A plain name that is also a
// credential by declaration (declared) or by spelling (isSecretEnvKey) is
// not exempt: it stays redacted as a backstop.
plain := declaredNonCredentialEnvNames(opts)
names := collected[:0]
seen := map[string]bool{}
for _, name := range collected {
if plain[name] && !declared[name] && !isSecretEnvKey(name) {
continue
}
if declared[name] || CredentialEnvName(name) {
names = append(names, name)
seen[name] = true
Expand Down Expand Up @@ -480,6 +490,32 @@ func declaredCredentialEnvNames(opts Options) map[string]bool {
return declared
}

// declaredNonCredentialEnvNames collects plain env: declarations across the
// resolved registry. These names are exempt from redaction's fail-closed
// default so their values stay visible in evidence; see redactionEnvNames.
func declaredNonCredentialEnvNames(opts Options) map[string]bool {
type nonCredentialDeclarer interface {
DeclaredNonCredentialEnv() []string
}
plain := map[string]bool{}
registry := registryForOptions(opts)
for _, id := range registry.IDs() {
adapter, ok := registry.Adapter(id)
if !ok {
continue
}
if declarer, ok := adapter.(nonCredentialDeclarer); ok {
for _, name := range declarer.DeclaredNonCredentialEnv() {
name = strings.TrimSpace(name)
if name != "" {
plain[name] = true
}
}
}
}
return plain
}

// collectEnvNames gathers env var names in scope for a run. wholeRegistry
// widens the sweep to every adapter's declared credentials for host-mode
// redaction; false yields the executing-scanner passthrough set used both
Expand Down
12 changes: 6 additions & 6 deletions internal/runner/scanner_registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ func TestUserDefinedScannerSkipsExistenceCheckForURLTargets(t *testing.T) {

func TestUserDefinedScannerRedactsDeclaredEnvOnFailure(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "demo", Command: "demo {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "demo", Command: "demo {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(adapter)
if err != nil {
Expand All @@ -311,7 +311,7 @@ func TestUserDefinedScannerRedactsDeclaredEnvOnFailure(t *testing.T) {

func TestUserDefinedScannerRedactsDeclaredEnvInRawJSON(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "demo", Command: "demo {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "demo", Command: "demo {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(adapter)
if err != nil {
Expand Down Expand Up @@ -951,7 +951,7 @@ func TestEnvValueForNameFindsNonEmptyExactMatch(t *testing.T) {
func TestUserDefinedScannerInfoSanitizesMalformedEnvEntries(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "scanner {{target}}",
Env: []string{"API_TOKEN=sk-live-info-leak", "=sk-live-eqzero", "GOOD_NAME"}, Targets: []string{"skill"},
SecretEnv: []string{"API_TOKEN=sk-live-info-leak", "=sk-live-eqzero", "GOOD_NAME"}, Targets: []string{"skill"},
})
want := []string{"API_TOKEN", "GOOD_NAME"}
if got := adapter.Info().RequiredEnv; !reflect.DeepEqual(got, want) {
Expand Down Expand Up @@ -1226,7 +1226,7 @@ func TestUserDefinedScannerRedactsEscapedUndeclaredSecretsFromErrors(t *testing.

func TestUserDefinedScannerRedactsAlternateEncodedSecretsFromErrors(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(adapter)
if err != nil {
Expand Down Expand Up @@ -1368,7 +1368,7 @@ func TestRedactScannerStdoutMarkerSubstringSecrets(t *testing.T) {

func TestUserDefinedScannerMarkerSubstringSecretInErrors(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "alpha", Command: "alpha {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "alpha", Command: "alpha {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(adapter)
if err != nil {
Expand Down Expand Up @@ -1526,7 +1526,7 @@ func TestJSONSecretLeavesIncludesNumericCredential(t *testing.T) {

func TestUserDefinedScannerRedactsNumericJSONCredentialLeaf(t *testing.T) {
adapter := NewUserDefinedScanner(UserDefinedScannerConfig{
ID: "demo", Command: "demo {{target}}", Env: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
ID: "demo", Command: "demo {{target}}", SecretEnv: []string{"SCANNER_ACCESS"}, Targets: []string{"skill"},
})
registry, err := NewScannerRegistry(adapter)
if err != nil {
Expand Down
Loading
Loading