diff --git a/README.md b/README.md index 2c213e3..6dfb72e 100644 --- a/README.md +++ b/README.md @@ -158,7 +158,7 @@ clawscan scanners skillspector | `agentverus` | AgentVerus | [repo](https://github.com/agentverus/agentverus-scanner) | Local file or directory scanner invoked through agentverus-scanner. | none | `npm install --save-dev agentverus-scanner` | | `aig` | Tencent AI-Infra-Guard | [repo](https://github.com/Tencent/AI-Infra-Guard/tree/main/skill-scan) | Tencent Zhuque Lab's local directory scanner invoked through `aig-skill-scan`. Produces SARIF 2.1.0 with SkillTrustBench T01-T09 evidence. | `LLM_API_KEY` or `OPENAI_API_KEY`
Optional configDEFAULT_MODEL, DEFAULT_BASE_URL, DEFAULT_MODEL_CONTEXT_WINDOW, LOG_LEVEL.
| `pip install aig-skill-scan` | | `cisco` | Cisco AI Defense skill-scanner | [repo](https://github.com/cisco-ai-defense/skill-scanner) | Local file or directory scanner invoked through `skill-scanner` with JSON report output. Optional upstream env vars enable LLM, VirusTotal, and Cisco AI Defense analyzers. | none
Optional configSKILL_SCANNER_LLM_API_KEY, SKILL_SCANNER_LLM_PROVIDER, SKILL_SCANNER_LLM_MODEL, SKILL_SCANNER_LLM_BASE_URL, SKILL_SCANNER_LLM_USER, SKILL_SCANNER_LLM_API_VERSION, SKILL_SCANNER_LLM_FORCE_JSON_OBJECT, SKILL_SCANNER_META_LLM_API_KEY, SKILL_SCANNER_META_LLM_MODEL, SKILL_SCANNER_META_LLM_BASE_URL, SKILL_SCANNER_META_LLM_API_VERSION, AWS_PROFILE, AWS_REGION, GOOGLE_APPLICATION_CREDENTIALS, VIRUSTOTAL_API_KEY, AI_DEFENSE_API_KEY, AI_DEFENSE_API_URL.
| `uv pip install cisco-ai-skill-scanner` | -| `clawscan-static` | ClawScan Static | [repo](https://github.com/openclaw/clawscan) | Built-in deterministic text scanner for high-signal risky skill and OpenClaw plugin patterns. | none | skipped; built in | +| `clawscan-static` | ClawScan Static | [repo](https://github.com/openclaw/clawscan) | Built-in deterministic scanner for high-signal risky skill and OpenClaw plugin patterns; packaged Python bytecode is flagged as opaque executable content. | none | skipped; built in | | `relyable` | Relyable | [repo](https://github.com/veriker/relyable) | Functional re-derivation evidence: does the skill still do what its docs claim, recomputed? Emits the strongest grade that applies. `exogenous`: a declared `rederive.json` property manifest (idempotence / round-trip), with both sides of the relation computed from the skill's own code and the result mutation-tested against vacuity. `self_spec`: re-runs the author's own committed oracle (shipped tests or documented I/O examples). `cold_golden`: when an LLM key is set, a code-blind model infers goldens from SKILL.md alone and abstains unless the docs pin exact behavior; divergences are reported as unconfirmed, never as accusations. `non_rederivable`: the honest floor, never a fabricated pass. Functional axis only; complements the security scanners and does not detect malware or prompt injection. Skill code runs only inside the Docker sandbox (or with an explicit opt-in), in a scrubbed environment, and the scanner fails closed otherwise. Not preinstalled in the `clawscan-runtime` image. | none
Optional configRELYABLE_SCAN_ALLOW_HOST_EXEC — explicit ack that the host is disposable when running with --sandbox off.

RELYABLE_LLM_API_KEY (+ RELYABLE_LLM_PROVIDER anthropic|openai, RELYABLE_LLM_MODEL, RELYABLE_LLM_BASE_URL) — explicit per-scanner opt-in that enables the cold_golden lane; key presence only is ever recorded in the payload. Generic ANTHROPIC_API_KEY/OPENAI_API_KEY are honored by standalone relyable-scan but are deliberately not auto-forwarded by ClawScan.
| `clawscan install relyable` — not preinstalled in the runtime image | | `skillspector` | NVIDIA SkillSpector | [repo](https://github.com/NVIDIA/skillspector) | Local skill or OpenClaw plugin file/directory scanner. Uses LLM mode when provider env vars are set; otherwise runs with `--no-llm`. | none
Optional configSKILLSPECTOR_PROVIDER, SKILLSPECTOR_MODEL, SKILLSPECTOR_MODEL_REGISTRY, SKILLSPECTOR_LOG_LEVEL, SKILLSPECTOR_SSL_VERIFY, NVIDIA_INFERENCE_KEY, OPENAI_API_KEY, OPENAI_BASE_URL, ANTHROPIC_API_KEY, ANTHROPIC_PROXY_ENDPOINT_URL, ANTHROPIC_PROXY_API_KEY, ANTHROPIC_PROXY_API_VERSION.
| `uv tool install git+https://github.com/NVIDIA/skillspector.git` | | `snyk` | Snyk Agent Scan | [repo](https://github.com/snyk/agent-scan) | Local skill scanner invoked through `uvx snyk-agent-scan`. | `SNYK_TOKEN` | verifies `uvx` launcher | diff --git a/docs/scanners.md b/docs/scanners.md index 79a47bc..5fe81b4 100644 --- a/docs/scanners.md +++ b/docs/scanners.md @@ -230,7 +230,7 @@ commas, single-quoted strings, and unquoted keys. | `agentverus` | AgentVerus | [repo](https://github.com/agentverus/agentverus-scanner) | Local file or directory scanner invoked through agentverus-scanner. | none | `npm install --save-dev agentverus-scanner` | | `aig` | Tencent AI-Infra-Guard | [repo](https://github.com/Tencent/AI-Infra-Guard/tree/main/skill-scan) | Tencent Zhuque Lab's local directory scanner invoked through `aig-skill-scan`. Produces SARIF 2.1.0 with SkillTrustBench T01-T09 evidence. | `LLM_API_KEY` or `OPENAI_API_KEY`
Optional configDEFAULT_MODEL, DEFAULT_BASE_URL, DEFAULT_MODEL_CONTEXT_WINDOW, LOG_LEVEL.
| `pip install aig-skill-scan` | | `cisco` | Cisco AI Defense skill-scanner | [repo](https://github.com/cisco-ai-defense/skill-scanner) | Local file or directory scanner invoked through `skill-scanner` with JSON report output. Optional upstream env vars enable LLM, VirusTotal, and Cisco AI Defense analyzers. | none
Optional configSKILL_SCANNER_LLM_API_KEY, SKILL_SCANNER_LLM_PROVIDER, SKILL_SCANNER_LLM_MODEL, SKILL_SCANNER_LLM_BASE_URL, SKILL_SCANNER_LLM_USER, SKILL_SCANNER_LLM_API_VERSION, SKILL_SCANNER_LLM_FORCE_JSON_OBJECT, SKILL_SCANNER_META_LLM_API_KEY, SKILL_SCANNER_META_LLM_MODEL, SKILL_SCANNER_META_LLM_BASE_URL, SKILL_SCANNER_META_LLM_API_VERSION, AWS_PROFILE, AWS_REGION, GOOGLE_APPLICATION_CREDENTIALS, VIRUSTOTAL_API_KEY, AI_DEFENSE_API_KEY, AI_DEFENSE_API_URL.
| `uv pip install cisco-ai-skill-scanner` | -| `clawscan-static` | ClawScan Static | [repo](https://github.com/openclaw/clawscan) | Built-in deterministic text scanner for high-signal risky skill and OpenClaw plugin patterns. | none | skipped; built in | +| `clawscan-static` | ClawScan Static | [repo](https://github.com/openclaw/clawscan) | Built-in deterministic scanner for high-signal risky skill and OpenClaw plugin patterns; packaged Python bytecode is flagged as opaque executable content. | none | skipped; built in | | `relyable` | Relyable | [repo](https://github.com/veriker/relyable) | Functional re-derivation evidence: does the skill still do what its docs claim, recomputed? Emits the strongest grade that applies. `exogenous`: a declared `rederive.json` property manifest (idempotence / round-trip), with both sides of the relation computed from the skill's own code and the result mutation-tested against vacuity. `self_spec`: re-runs the author's own committed oracle (shipped tests or documented I/O examples). `cold_golden`: when an LLM key is set, a code-blind model infers goldens from SKILL.md alone and abstains unless the docs pin exact behavior; divergences are reported as unconfirmed, never as accusations. `non_rederivable`: the honest floor, never a fabricated pass. Functional axis only; complements the security scanners and does not detect malware or prompt injection. Skill code runs only inside the Docker sandbox (or with an explicit opt-in), in a scrubbed environment, and the scanner fails closed otherwise. Not preinstalled in the `clawscan-runtime` image. | none
Optional configRELYABLE_SCAN_ALLOW_HOST_EXEC — explicit ack that the host is disposable when running with --sandbox off.

RELYABLE_LLM_API_KEY (+ RELYABLE_LLM_PROVIDER anthropic|openai, RELYABLE_LLM_MODEL, RELYABLE_LLM_BASE_URL) — explicit per-scanner opt-in that enables the cold_golden lane; key presence only is ever recorded in the payload. Generic ANTHROPIC_API_KEY/OPENAI_API_KEY are honored by standalone relyable-scan but are deliberately not auto-forwarded by ClawScan.
| `clawscan install relyable` — not preinstalled in the runtime image | | `skillspector` | NVIDIA SkillSpector | [repo](https://github.com/NVIDIA/skillspector) | Local skill or OpenClaw plugin file/directory scanner. Uses LLM mode when provider env vars are set; otherwise runs with `--no-llm`. | none
Optional configSKILLSPECTOR_PROVIDER, SKILLSPECTOR_MODEL, SKILLSPECTOR_MODEL_REGISTRY, SKILLSPECTOR_LOG_LEVEL, SKILLSPECTOR_SSL_VERIFY, NVIDIA_INFERENCE_KEY, OPENAI_API_KEY, OPENAI_BASE_URL, ANTHROPIC_API_KEY, ANTHROPIC_PROXY_ENDPOINT_URL, ANTHROPIC_PROXY_API_KEY, ANTHROPIC_PROXY_API_VERSION.
| `uv tool install git+https://github.com/NVIDIA/skillspector.git` | | `snyk` | Snyk Agent Scan | [repo](https://github.com/snyk/agent-scan) | Local skill scanner invoked through `uvx snyk-agent-scan`. | `SNYK_TOKEN` | verifies `uvx` launcher | diff --git a/internal/runner/runner.go b/internal/runner/runner.go index e8a621b..bb4b36d 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -1001,7 +1001,7 @@ func renderTargetFiles(target string) (string, error) { return nil } if entry.IsDir() { - if shouldSkipTargetPath(target, path) { + if shouldSkipTargetDirectory(target, path) { precomputedOmissions = append(precomputedOmissions, targetFileOmission{path: path, reason: "skipped path"}) return filepath.SkipDir } @@ -1040,10 +1040,6 @@ func renderTargetFiles(target string) (string, error) { suppressedOmissions++ } for _, path := range paths { - if shouldSkipTargetPath(target, path) { - addOmission(path, "skipped path") - continue - } info, err := os.Stat(path) if err != nil { return "", err @@ -1126,14 +1122,14 @@ func markdownPathLabel(label string) string { return quoted[1 : len(quoted)-1] } -func shouldSkipTargetPath(root string, path string) bool { +func shouldSkipTargetDirectory(root string, path string) bool { rel, err := filepath.Rel(root, path) if err != nil { return true } for _, part := range strings.Split(rel, string(filepath.Separator)) { switch part { - case ".git", "node_modules", "vendor", ".venv", "__pycache__": + case ".git", "node_modules", "vendor", ".venv": return true } } diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 5e88c3b..845ac29 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -3078,6 +3078,92 @@ func TestStaticScannerFindsDestructiveRmWithForceBeforeRecursive(t *testing.T) { t.Fatalf("missing destructive shell finding: %#v", report.Findings) } +func TestStaticScannerScansGitFileAndFindsProcessExecution(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("# Demo\n"), 0o644); err != nil { + t.Fatal(err) + } + payload := "import os\nos.system(\"whoami > pwn\")\n" + if err := os.WriteFile(filepath.Join(target, ".git"), []byte(payload), 0o644); err != nil { + t.Fatal(err) + } + opts, err := ParseArgs([]string{target, "--scanner", "clawscan-static"}) + if err != nil { + t.Fatal(err) + } + artifact, err := Run(opts, RunContext{Env: map[string]string{}}) + if err != nil { + t.Fatal(err) + } + report := decodeStaticReport(t, artifact.Scanners["clawscan-static"].Raw) + for _, finding := range report.Findings { + if finding.ID == "static.python_process_execution" && finding.Path == ".git" { + prompt, err := RenderPromptTemplate("{{ target.files }}", artifact) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(prompt, "### .git\n```text\n"+strings.TrimSpace(payload)) { + t.Fatalf("judge prompt omitted .git file contents: %s", prompt) + } + return + } + } + t.Fatalf("missing .git process-execution finding: %#v", report.Findings) +} + +func TestPythonBytecodeEvidenceReachesJudgePrompt(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "skill") + cacheDir := filepath.Join(target, "scripts", "rendering", "__pycache__") + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("# Demo\n"), 0o644); err != nil { + t.Fatal(err) + } + bytecodePath := filepath.Join(cacheDir, "output.cpython-312.pyc") + if err := os.WriteFile(bytecodePath, []byte{0xcb, 0x0d, 0x0d, 0x0a, 0x00, 0x00, 0x00, 0x00}, 0o644); err != nil { + t.Fatal(err) + } + opts, err := ParseArgs([]string{target, "--scanner", "clawscan-static"}) + if err != nil { + t.Fatal(err) + } + artifact, err := Run(opts, RunContext{Env: map[string]string{}}) + if err != nil { + t.Fatal(err) + } + report := decodeStaticReport(t, artifact.Scanners["clawscan-static"].Raw) + wantPath := "scripts/rendering/__pycache__/output.cpython-312.pyc" + found := false + for _, finding := range report.Findings { + if finding.ID == "static.python_bytecode" && finding.Path == wantPath { + found = true + break + } + } + if !found { + t.Fatalf("missing Python bytecode finding: %#v", report.Findings) + } + prompt, err := RenderPromptTemplate("Evidence:\n{{ scanners.clawscan-static }}\nFiles:\n{{ target.files }}", artifact) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(prompt, `"id": "static.python_bytecode"`) { + t.Fatalf("judge prompt omitted static bytecode evidence: %s", prompt) + } + if !strings.Contains(prompt, wantPath+"\n[omitted: binary file]") { + t.Fatalf("judge prompt omitted bytecode path marker: %s", prompt) + } + if strings.Contains(prompt, "__pycache__\n[omitted: skipped path]") { + t.Fatalf("judge prompt still skipped __pycache__: %s", prompt) + } +} + func TestStaticScannerRecordsOmittedBinaryAndOversizedFiles(t *testing.T) { dir := t.TempDir() target := filepath.Join(dir, "skill") diff --git a/internal/runner/static_scanner.go b/internal/runner/static_scanner.go index bfb74c8..bb9ee50 100644 --- a/internal/runner/static_scanner.go +++ b/internal/runner/static_scanner.go @@ -98,6 +98,19 @@ var staticRules = []staticRule{ description: "Looks for destructive recursive removal of the filesystem root.", pattern: regexp.MustCompile(`(?i)\brm\s+(?:-[a-z]*(?:r[a-z]*f|f[a-z]*r)[a-z]*|-[a-z]*r[a-z]*\s+-[a-z]*f[a-z]*|-[a-z]*f[a-z]*\s+-[a-z]*r[a-z]*)\s+/(?:\s|$)`), }, + { + id: "static.python_process_execution", + title: "Python process execution", + severity: "high", + description: "Looks for Python APIs that execute shell commands or child processes.", + pattern: regexp.MustCompile(`(?i)\b(?:os\s*\.\s*system|subprocess\s*\.\s*(?:call|check_call|check_output|popen|run))\s*\(`), + }, + { + id: "static.python_bytecode", + title: "Packaged Python bytecode", + severity: "high", + description: "Flags precompiled Python bytecode that can execute without reviewable source.", + }, } type staticFileCandidate struct { @@ -182,6 +195,18 @@ func scanStaticTarget(target string) (staticScannerFiles, []staticFinding, error files.addOmitted(rel, "not regular file", 0) return nil } + rel = filepath.ToSlash(rel) + if strings.EqualFold(filepath.Ext(rel), ".pyc") { + findings = append(findings, staticFinding{ + ID: "static.python_bytecode", + Title: "Packaged Python bytecode", + Severity: "high", + Description: "Precompiled Python bytecode is executable and opaque to source-text review.", + Path: rel, + Line: 1, + Evidence: "Precompiled Python bytecode is included in the scanned target.", + }) + } if info.Size() > maxTargetFileBytes { files.addOmitted(rel, "file exceeds size limit", info.Size()) return nil @@ -200,7 +225,6 @@ func scanStaticTarget(target string) (staticScannerFiles, []staticFinding, error return nil } totalBytes += info.Size() - rel = filepath.ToSlash(rel) files.addScanned(rel, info.Size(), sha256BytesHex(content)) findings = append(findings, scanStaticContent(rel, string(content))...) return nil @@ -216,16 +240,12 @@ func scanStaticTarget(target string) (staticScannerFiles, []staticFinding, error if err != nil { return files.recordWalkError(target, path, entry) } - if shouldSkipTargetPath(target, path) { - rel := relativeManifestPath(target, path) - if entry.IsDir() { + if entry.IsDir() { + if shouldSkipTargetDirectory(target, path) { + rel := relativeManifestPath(target, path) files.addOmitted(rel, "skipped path", 0) return filepath.SkipDir } - files.addOmitted(rel, "skipped path", 0) - return nil - } - if entry.IsDir() { return nil } info, err := entry.Info() @@ -310,6 +330,9 @@ func scanStaticContent(path string, content string) []staticFinding { lines := strings.Split(content, "\n") for lineIndex, line := range lines { for _, rule := range staticRules { + if rule.pattern == nil { + continue + } if !rule.pattern.MatchString(line) { continue }