diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10ea1ef..c20856b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,9 @@ jobs: with: node-version: 24 + - name: Set up npm + run: npm install -g npm@^12.0.2 + - name: Check formatting run: | unformatted="$(git ls-files '*.go' | xargs gofmt -l)" diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 1979d06..6e4a486 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -126,7 +126,7 @@ jobs: - name: Ensure npm supports trusted publishing run: | - npm install -g npm@^11.19.1 + npm install -g npm@^12.0.2 node --version npm --version diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8679724..bf2323b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ environments. Prerequisites: -- Go 1.22 or newer. +- Go 1.27.0 or newer (see `go.mod`). - Node.js/npm for scanner adapters that shell out through `npx`. - `make` for docs and release helper targets. - Optional scanner credentials in environment variables, never CLI flags. diff --git a/cmd/clawscan/main.go b/cmd/clawscan/main.go index 1b680c9..86ac01d 100644 --- a/cmd/clawscan/main.go +++ b/cmd/clawscan/main.go @@ -691,27 +691,34 @@ func scannerIssueCount(raw json.RawMessage) int { if err := json.Unmarshal(raw, &decoded); err != nil { return 0 } - return countIssueArrays(decoded) + return countIssues(decoded) } -func countIssueArrays(value interface{}) int { +func countIssues(value interface{}) int { switch typed := value.(type) { case map[string]interface{}: total := 0 for key, nested := range typed { + // Risk-based reports store named findings in a map, including empty maps for clean components. + if key == "risk_indexes" { + if risks, ok := nested.(map[string]interface{}); ok { + total += len(risks) + continue + } + } if isIssueArrayKey(key) { if items, ok := nested.([]interface{}); ok { total += len(items) continue } } - total += countIssueArrays(nested) + total += countIssues(nested) } return total case []interface{}: total := 0 for _, nested := range typed { - total += countIssueArrays(nested) + total += countIssues(nested) } return total default: diff --git a/cmd/clawscan/main_test.go b/cmd/clawscan/main_test.go index 65bc6a4..b491374 100644 --- a/cmd/clawscan/main_test.go +++ b/cmd/clawscan/main_test.go @@ -877,6 +877,27 @@ func TestRunCommandWritesDefaultOutputAndPrintsKeyValueSummary(t *testing.T) { } } +func TestRunCommandSummarizesSnykRiskIndexes(t *testing.T) { + fixture, err := filepath.Abs("testdata/snyk-0.6.json") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + target := filepath.Join(dir, "skill") + writeSkill(t, target, "# Summary\n") + output := filepath.Join(dir, "artifact.json") + stdout := captureStdout(t, func() { + if err := run([]string{target, "--scanner", "snyk", "--scanner-result", "snyk=" + fixture, "--output", output}, []string{}); err != nil { + t.Fatal(err) + } + }) + for _, want := range []string{"scanner_completed: 1", "scanner_failed: 0", "issues_found: 3", "errors: 0"} { + if !strings.Contains(stdout, want) { + t.Fatalf("stdout missing %q:\n%s", want, stdout) + } + } +} + func TestPrintRunSummaryIncludesGateVerdictAndFiredRule(t *testing.T) { exitCode := 3 artifact := runner.Artifact{ diff --git a/cmd/clawscan/testdata/snyk-0.6.json b/cmd/clawscan/testdata/snyk-0.6.json new file mode 100644 index 0000000..ab93f22 --- /dev/null +++ b/cmd/clawscan/testdata/snyk-0.6.json @@ -0,0 +1,26 @@ +{ + "scan_path_responses": [ + { + "path": "/synthetic/skills", + "server_risks": [ + { + "name": "example-server", + "risk_indexes": { + "prompt_injection_tool_desc": {"score": 1000, "evidence": "Synthetic injected instruction."} + } + } + ], + "skill_risks": [ + { + "name": "risky-skill", + "files": [{"name": "SKILL.md", "type": "instruction"}], + "risk_indexes": { + "suspicious_download_url": {"score": 600, "evidence": "Synthetic untrusted download."}, + "unverifiable_dependencies": {"score": 500, "evidence": "Synthetic unverified dependency."} + } + }, + {"name": "clean-skill", "risk_indexes": {}} + ] + } + ] +} diff --git a/docker/clawscan-runtime/Dockerfile b/docker/clawscan-runtime/Dockerfile index 0cb221f..53b4475 100644 --- a/docker/clawscan-runtime/Dockerfile +++ b/docker/clawscan-runtime/Dockerfile @@ -1,13 +1,13 @@ FROM python:3.12-slim -ARG SKILLSPECTOR_REF=8f37cfa1ebc2d5748a58f7005a1101230ce27f87 +ARG SKILLSPECTOR_REF=539c1150b7c6e9a8935679f5ed5108bafdc68515 ARG AIG_SKILL_SCAN_VERSION=0.2.1 -ARG CISCO_AI_SKILL_SCANNER_VERSION=2.0.12 -ARG SNYK_AGENT_SCAN_VERSION=0.5.12 -ARG CLAUDE_CODE_VERSION=2.1.193 -ARG OPENAI_CODEX_VERSION=0.142.3 +ARG CISCO_AI_SKILL_SCANNER_VERSION=2.0.13 +ARG SNYK_AGENT_SCAN_VERSION=0.6.0 +ARG CLAUDE_CODE_VERSION=2.1.251 +ARG OPENAI_CODEX_VERSION=0.151.0 ARG AGENTVERUS_SCANNER_VERSION=0.8.1 -ARG SOCKET_CLI_VERSION=1.1.129 +ARG SOCKET_CLI_VERSION=1.1.162 ENV PATH="/root/.local/bin:${PATH}" \ PIP_DISABLE_PIP_VERSION_CHECK=1 \ diff --git a/docs/scanners.md b/docs/scanners.md index b5618bf..dfd3c80 100644 --- a/docs/scanners.md +++ b/docs/scanners.md @@ -236,3 +236,9 @@ commas, single-quoted strings, and unquoted keys. | `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 | | `socket` | Socket CLI | [repo](https://github.com/SocketDev/socket-cli) | Local file or directory scanner using Socket's public CLI full-scan path. | `SOCKET_CLI_API_TOKEN` | `npm install -g socket` | | `virustotal` | VirusTotal API | [docs](https://docs.virustotal.com/reference/file) | API-backed local file hash lookup. Skill and OpenClaw plugin directories are scanned as deterministic ZIP archives. | `VIRUSTOTAL_API_KEY` | skipped; API-backed | + +Snyk Agent Scan 0.6 reports named findings in `risk_indexes` maps under +`scan_path_responses`. ClawScan preserves that upstream JSON and includes those +findings in `issues_found`; clean components with empty risk maps contribute +zero. Operator-owned gate rules that inspect Snyk's older `issues` arrays need +to use the [current Snyk JSON schema](https://github.com/snyk/agent-scan/blob/v0.6.0/docs/json-output.md#agent-scan-v06-and-later). diff --git a/go.mod b/go.mod index e08f198..3494811 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/openclaw/clawscan -go 1.26.1 +go 1.27.0 require ( github.com/alchemy/json5 v0.2.0 diff --git a/scripts/build-npm-package.mjs b/scripts/build-npm-package.mjs index 54f1280..924ff62 100755 --- a/scripts/build-npm-package.mjs +++ b/scripts/build-npm-package.mjs @@ -167,6 +167,16 @@ async function stagePackage(options) { return { binaryVersion, packageOut, packageVersion, releaseSha }; } +export function parsePackFilename(output) { + const parsed = JSON.parse(output); + // npm 12 keys results by package name; older supported npm versions use an array. + const packed = Array.isArray(parsed) ? parsed[0] : parsed?.["@openclaw/clawscan"]; + if (typeof packed?.filename !== "string" || !packed.filename) { + throw new Error("npm pack did not return a tarball filename."); + } + return packed.filename; +} + async function packPackage(options, packageOut) { const result = run( "npm", @@ -175,10 +185,7 @@ async function packPackage(options, packageOut) { cwd: packageOut, }, ); - const parsed = JSON.parse(result.stdout); - const first = Array.isArray(parsed) ? parsed[0] : undefined; - if (!first?.filename) throw new Error("npm pack did not return a tarball filename."); - return resolve(options.outDir, first.filename); + return resolve(options.outDir, parsePackFilename(result.stdout)); } async function smokePackage(tarballPath, binaryVersion) { diff --git a/scripts/build-npm-package.test.mjs b/scripts/build-npm-package.test.mjs index 0c82291..b38f0ea 100644 --- a/scripts/build-npm-package.test.mjs +++ b/scripts/build-npm-package.test.mjs @@ -7,9 +7,28 @@ import { normalizePackageVersion, npmDistTagForVersion, packageTargets, + parsePackFilename, platformKeyForTarget, } from "./build-npm-package.mjs"; +describe("npm pack output", () => { + const filename = "openclaw-clawscan-1.2.3.tgz"; + + it("reads npm 12 results keyed by package name", () => { + assert.equal(parsePackFilename(JSON.stringify({ "@openclaw/clawscan": { filename } })), filename); + }); + + it("reads the array returned by npm 11 and older", () => { + assert.equal(parsePackFilename(JSON.stringify([{ filename }])), filename); + }); + + it("rejects missing or invalid tarball filenames", () => { + for (const result of [null, {}, [], [{ filename: "" }], { "@openclaw/clawscan": { filename: 42 } }]) { + assert.throws(() => parsePackFilename(JSON.stringify(result)), /did not return a tarball filename/); + } + }); +}); + describe("normalizePackageVersion", () => { it("strips a release tag v-prefix for npm package metadata", () => { assert.equal(normalizePackageVersion("v1.2.3"), "1.2.3");