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
2 changes: 1 addition & 1 deletion cmd/clawscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ func printProfileCatalog(w io.Writer, catalog profiles.ProfileCatalog, cwd strin
if info.Profile.Judge != nil {
judge = "configured"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, displayProfileSource(info.Source, cwd), strings.Join(info.Profile.Scanners, ", "), judge)
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", info.ID, displayProfileSource(info.Source, cwd), strings.Join(info.Profile.ScannerIDs(), ", "), judge)
}
_ = tw.Flush()
}
Expand Down
47 changes: 47 additions & 0 deletions docs/scanners.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,53 @@ clawscan scanners
clawscan scanners skillspector
```

## User-defined scanners

A trusted config can mix built-in scanner IDs with user-defined command
scanners. The config schema uses the existing `profiles.<name>.scanners` list:

```yaml
version: 1

profiles:
review:
scanners:
- clawscan-static
- id: my-scanner
command: my-scanner --json {{target}}
env:
- MY_SCANNER_TOKEN
targets:
- skill
- plugin
```

String entries select built-in scanners. Object entries define a scanner for
that config-backed run and accept these fields:

| Field | Required | Meaning |
| --- | --- | --- |
| `id` | yes | Scanner ID using letters, digits, `_`, and `-`, starting with a letter or digit. It must not match a built-in scanner ID. |
| `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. |
| `targets` | no | Supported target kinds: `skill`, `plugin`, and/or `url`. Defaults to `skill` and `url`. |

The command must write JSON to stdout. ClawScan preserves valid stdout as the
scanner's raw evidence; empty or non-JSON stdout produces a failed scanner
result. Required environment variables are checked before any scanner starts.
Artifacts record each requirement as only `present` or `missing`.

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
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
scanners` catalog.

> **Trust boundary:** only load user-defined scanners from config files you
> control. A scanner entry is executable code. The default sandbox limits its
> host access, but does not make an untrusted command safe to run.

## Target kinds

Clawscan classifies each explicit target before dispatching scanners and records
Expand Down
4 changes: 2 additions & 2 deletions internal/profiles/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ func NewProfileRegistry(profiles map[string]resolvedProfile) (ProfileRegistry, e
return ProfileRegistry{}, err
}
for _, scanner := range profile.profile.Scanners {
if !runner.DefaultScannerRegistry().Contains(scanner) {
return ProfileRegistry{}, unknownScannerInProfileError(id, scanner)
if !scanner.custom && !runner.DefaultScannerRegistry().Contains(scanner.ID) {
return ProfileRegistry{}, unknownScannerInProfileError(id, scanner.ID)
}
}
registry.profiles[id] = profile
Expand Down
12 changes: 6 additions & 6 deletions internal/profiles/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (

func TestProfileRegistryReturnsSortedIDs(t *testing.T) {
registry, err := NewProfileRegistry(map[string]resolvedProfile{
"review": {profile: Profile{Scanners: []string{"snyk"}}},
"clawhub": {profile: Profile{Scanners: []string{"skillspector"}}},
"review": {profile: Profile{Scanners: []ProfileScanner{{ID: "snyk"}}}},
"clawhub": {profile: Profile{Scanners: []ProfileScanner{{ID: "skillspector"}}}},
})
if err != nil {
t.Fatal(err)
Expand All @@ -26,7 +26,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(clawhub.profile.Scanners, ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.profile.Scanners), ","); got != "skillspector,clawscan-static" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.configDir != "clawhub" {
Expand All @@ -46,7 +46,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub-aig profile")
}
if got := strings.Join(candidate.profile.Scanners, ","); got != "skillspector,aig" {
if got := strings.Join(profileScannerIDs(candidate.profile.Scanners), ","); got != "skillspector,aig" {
t.Fatalf("clawhub-aig scanners = %q", got)
}
if candidate.configDir != "clawhub" {
Expand All @@ -71,7 +71,7 @@ func TestDefaultProfileRegistryContainsEmbeddedBuiltIns(t *testing.T) {

func TestProfileRegistryRejectsUnknownScannerReferences(t *testing.T) {
_, err := NewProfileRegistry(map[string]resolvedProfile{
"bad": {profile: Profile{Scanners: []string{"missing-scanner"}}},
"bad": {profile: Profile{Scanners: []ProfileScanner{{ID: "missing-scanner"}}}},
})
if err == nil || err.Error() != "Profile bad references unknown scanner: missing-scanner" {
t.Fatalf("err = %v", err)
Expand All @@ -91,7 +91,7 @@ func TestInspectProfilesReturnsBuiltIns(t *testing.T) {
if !ok {
t.Fatal("missing clawhub profile")
}
if got := strings.Join(clawhub.Profile.Scanners, ","); got != "skillspector,clawscan-static" {
if got := strings.Join(profileScannerIDs(clawhub.Profile.Scanners), ","); got != "skillspector,clawscan-static" {
t.Fatalf("clawhub scanners = %q", got)
}
if clawhub.Source != "built-in" {
Expand Down
193 changes: 186 additions & 7 deletions internal/profiles/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,103 @@ type Config struct {
}

type Profile struct {
Scanners []string `yaml:"scanners"`
Scanners []ProfileScanner `yaml:"scanners"`
ScannerResults map[string]string `yaml:"scannerResults,omitempty"`
Output string `yaml:"output,omitempty"`
JSON bool `yaml:"json,omitempty"`
Sandbox *Sandbox `yaml:"sandbox,omitempty"`
Judge *Judge `yaml:"judge,omitempty"`
}

func (profile Profile) ScannerIDs() []string {
return profileScannerIDs(profile.Scanners)
}

type ProfileScanner struct {
ID string
Command string
Env []string
Targets []string
custom bool
}

func (scanner *ProfileScanner) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
if err := node.Decode(&scanner.ID); err != nil {
return err
}
return nil
case yaml.MappingNode:
for index := 0; index < len(node.Content); index += 2 {
switch node.Content[index].Value {
case "id", "command", "env", "targets":
default:
return fmt.Errorf("field %s not found in type profiles.ProfileScanner", node.Content[index].Value)
}
}
var value struct {
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
Targets []string `yaml:"targets,omitempty"`
}
if err := node.Decode(&value); err != nil {
return err
}
scanner.ID = value.ID
scanner.Command = value.Command
scanner.Env = value.Env
scanner.Targets = value.Targets
scanner.custom = true
return nil
default:
return fmt.Errorf("scanner entry must be a string or object")
}
}

func (scanner ProfileScanner) MarshalYAML() (interface{}, error) {
if !scanner.custom {
return scanner.ID, nil
}
return struct {
ID string `yaml:"id"`
Command string `yaml:"command"`
Env []string `yaml:"env,omitempty"`
Targets []string `yaml:"targets,omitempty"`
}{scanner.ID, scanner.Command, scanner.Env, scanner.Targets}, nil
}

func profileScannerIDs(scanners []ProfileScanner) []string {
ids := make([]string, 0, len(scanners))
for _, scanner := range scanners {
ids = append(ids, scanner.ID)
}
return ids
}

func profileScannerRegistry(scanners []ProfileScanner) (runner.ScannerRegistry, error) {
registry := runner.DefaultScannerRegistry()
for _, scanner := range scanners {
if !scanner.custom {
continue
}
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,
})
var err error
registry, err = registry.WithAdapters(adapter)
if err != nil {
return runner.ScannerRegistry{}, err
}
}
return registry, nil
}

type Sandbox struct {
Mode string `yaml:"mode,omitempty"`
Image string `yaml:"image,omitempty"`
Expand Down Expand Up @@ -90,6 +179,8 @@ type cliIntent struct {
}

var judgePathPlaceholderPattern = regexp.MustCompile(`\{\{\s*(prompt|output_schema):([^}]+)\}\}`)
var scannerIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
var scannerTargetPlaceholderPattern = regexp.MustCompile(`\{\{\s*target\s*\}\}`)

type ResolvedRunSet struct {
Options []runner.Options
Expand Down Expand Up @@ -182,7 +273,11 @@ func resolveRunSetIntent(intent cliIntent, cwd string) (ResolvedRunSet, error) {
if err != nil {
return ResolvedRunSet{}, err
}
opts, err := runner.ParseArgs(finalArgs)
scannerRegistry, err := profileScannerRegistry(selected.profile.Scanners)
if err != nil {
return ResolvedRunSet{}, err
}
opts, err := runner.ParseArgsWithRegistry(finalArgs, scannerRegistry)
if err != nil {
return ResolvedRunSet{}, err
}
Expand Down Expand Up @@ -279,7 +374,11 @@ func resolveAllConfigProfiles(intent cliIntent, cwd string) (ResolvedRunSet, err
if err != nil {
return ResolvedRunSet{}, err
}
opts, err := runner.ParseArgs(finalArgs)
scannerRegistry, err := profileScannerRegistry(selected.profile.Scanners)
if err != nil {
return ResolvedRunSet{}, err
}
opts, err := runner.ParseArgsWithRegistry(finalArgs, scannerRegistry)
if err != nil {
return ResolvedRunSet{}, err
}
Expand Down Expand Up @@ -610,7 +709,7 @@ func buildRunnerArgs(intent cliIntent, selected resolvedProfile, profileName str
}

profile := selected.profile
scanners := append([]string{}, profile.Scanners...)
scanners := profileScannerIDs(profile.Scanners)
if len(intent.scanners) > 0 {
scanners = append([]string{}, intent.scanners...)
}
Expand Down Expand Up @@ -716,14 +815,94 @@ func resolveJudgePaths(command string, configDir string) string {
func validateProfile(name string, profile Profile) error {
seen := map[string]bool{}
for _, scanner := range profile.Scanners {
if seen[scanner] {
return fmt.Errorf("Duplicate scanner in profile %s: %s", name, scanner)
if scanner.custom && strings.TrimSpace(scanner.ID) == "" {
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)
}
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 && !scannerTargetPlaceholdersAreUnquoted(scanner.Command) {
return fmt.Errorf("User-defined scanner %s in profile %s must use {{target}} outside shell quotes", scanner.ID, name)
}
if scanner.custom && runner.DefaultScannerRegistry().Contains(scanner.ID) {
return fmt.Errorf("User-defined scanner %s collides with a built-in scanner ID", scanner.ID)
}
for _, target := range scanner.Targets {
switch target {
case "skill", "plugin", "url":
default:
return fmt.Errorf("User-defined scanner %s in profile %s has unsupported target kind: %s", scanner.ID, name, target)
}
}
if seen[scanner.ID] {
return fmt.Errorf("Duplicate scanner in profile %s: %s", name, scanner.ID)
}
seen[scanner] = true
seen[scanner.ID] = true
}
return nil
}

func scannerTargetPlaceholdersAreUnquoted(command string) bool {
matches := scannerTargetPlaceholderPattern.FindAllStringIndex(command, -1)
matchIndex := 0
quote := byte(0)
escaped := false
comment := false
for index := 0; index < len(command); index++ {
if matchIndex < len(matches) && index == matches[matchIndex][0] {
if !comment && quote != 0 {
return false
}
index = matches[matchIndex][1] - 1
matchIndex++
continue
}
character := command[index]
if comment {
if character == '\n' {
comment = false
}
continue
}
if escaped {
escaped = false
continue
}
if character == '\\' && quote != '\'' {
escaped = true
continue
}
if quote == 0 {
if character == '#' && shellCommentCanStart(command, index) {
comment = true
continue
}
switch character {
case '\'', '"', '`':
quote = character
}
} else if character == quote {
quote = 0
}
}
return true
}

func shellCommentCanStart(command string, index int) bool {
if index == 0 {
return true
}
switch command[index-1] {
case ' ', '\t', '\r', '\n', ';', '|', '&', '(', ')':
return true
default:
return false
}
}

func unknownProfileError(profile string, available []string) error {
return fmt.Errorf("Unknown profile: %s (available: %s)", profile, strings.Join(available, ", "))
}
Expand Down
Loading
Loading