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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/npm-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 11 additions & 4 deletions cmd/clawscan/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
21 changes: 21 additions & 0 deletions cmd/clawscan/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
26 changes: 26 additions & 0 deletions cmd/clawscan/testdata/snyk-0.6.json
Original file line number Diff line number Diff line change
@@ -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": {}}
]
}
]
}
12 changes: 6 additions & 6 deletions docker/clawscan-runtime/Dockerfile
Original file line number Diff line number Diff line change
@@ -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 \
Expand Down
6 changes: 6 additions & 0 deletions docs/scanners.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
15 changes: 11 additions & 4 deletions scripts/build-npm-package.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions scripts/build-npm-package.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading