diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c092a1..4d744ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,6 +101,8 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '20' + cache: 'npm' + - run: npm ci - name: Feed Verification Tests run: node skills/clawsec-suite/test/feed_verification.test.mjs - name: Guarded Install Tests @@ -109,6 +111,10 @@ jobs: run: node skills/clawsec-suite/test/advisory_suppression.test.mjs - name: Path Resolution Tests run: node skills/clawsec-suite/test/path_resolution.test.mjs + - name: Fuzz Property Tests + run: node skills/clawsec-suite/test/fuzz_properties.test.mjs + - name: Semver/Scope/Suppression Fuzz Tests + run: node skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs - name: Advisory Application Scope Tests run: node skills/clawsec-suite/test/advisory_application_scope.test.mjs @@ -120,7 +126,11 @@ jobs: - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0 with: node-version: '20' + cache: 'npm' + - run: npm ci - name: Suppression Config Tests run: node skills/openclaw-audit-watchdog/test/suppression_config.test.mjs + - name: Suppression Config Fuzz Tests + run: node skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs - name: Render Report Suppression Tests run: node skills/openclaw-audit-watchdog/test/render_report_suppression.test.mjs diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6e5e593..e5a6f90 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -5,6 +5,7 @@ on: branches: [main] pull_request: branches: [main] + workflow_dispatch: schedule: - cron: "17 3 * * 1" diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index deda8dd..f96977f 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -30,6 +30,7 @@ jobs: poll-and-update: runs-on: ubuntu-latest permissions: + actions: write contents: write pull-requests: write steps: @@ -654,6 +655,44 @@ jobs: ${{ env.SKILL_FEED_PATH }} ${{ env.SKILL_FEED_SIG_PATH }} + - name: Run CodeQL on generated PR branch + if: steps.create-pr.outputs.pull-request-number != '' + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + BRANCH="${{ steps.create-pr.outputs.pull-request-branch }}" + if [ -z "$BRANCH" ]; then + echo "::error::Missing pull-request-branch output from create-pull-request" + exit 1 + fi + + echo "Dispatching CodeQL for branch: $BRANCH" + gh workflow run codeql.yml --ref "$BRANCH" + + RUN_ID="" + for _ in $(seq 1 30); do + RUN_ID=$(gh run list \ + --workflow "CodeQL" \ + --branch "$BRANCH" \ + --event workflow_dispatch \ + --json databaseId,createdAt \ + --jq 'sort_by(.createdAt) | last | .databaseId // empty') + if [ -n "$RUN_ID" ]; then + break + fi + sleep 5 + done + + if [ -z "$RUN_ID" ]; then + echo "::error::Unable to locate dispatched CodeQL run for branch $BRANCH" + exit 1 + fi + + echo "Waiting for CodeQL run id: $RUN_ID" + gh run watch "$RUN_ID" --exit-status + - name: Summary run: | echo "## NVD CVE Poll Summary" >> $GITHUB_STEP_SUMMARY diff --git a/metadata.json b/metadata.json index fc3565e..7b8b8f2 100644 --- a/metadata.json +++ b/metadata.json @@ -1,4 +1,4 @@ { "name": "ClawSec", - "description": "A security-first skill distribution platform for OpenClaw agents (and some clones), featuring verified audit skills, hardening feeds, and guardian mode protocols." -} \ No newline at end of file + "description": "A security-first skill distribution platform for OpenClaw and NanoClaw agents, featuring verified audit skills, hardening feeds, and guardian mode protocols." +} diff --git a/package-lock.json b/package-lock.json index 49d7c71..161aec6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", + "fast-check": "^4.5.3", "typescript": "~5.8.2", "vite": "^7.3.1" } @@ -2690,6 +2691,28 @@ "version": "3.0.2", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-check": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", + "integrity": "sha512-IE9csY7lnhxBnA8g/WI5eg/hygA6MGWJMSNfFRrBlXUciADEhS1EDB0SIsMSvzubzIlOBbVITSsypCsW717poA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "dependencies": { + "pure-rand": "^7.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4793,6 +4816,22 @@ "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ] + }, "node_modules/react": { "version": "19.2.4", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", diff --git a/package.json b/package.json index 896ea62..8e39ca1 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", + "fast-check": "^4.5.3", "typescript": "~5.8.2", "vite": "^7.3.1" }, diff --git a/skills/clawsec-suite/test/fuzz_properties.js b/skills/clawsec-suite/test/fuzz_properties.js new file mode 100644 index 0000000..7243ecf --- /dev/null +++ b/skills/clawsec-suite/test/fuzz_properties.js @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import fc from "fast-check"; +import { parseAffectedSpecifier } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs"; +import { normalizeSkillName, resolveConfiguredPath, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs"; + +const SAFE_SEGMENT = fc + .array(fc.constantFrom(...("abcdefghijklmnopqrstuvwxyz0123456789-_")), { minLength: 1, maxLength: 24 }) + .map((chars) => chars.join("")); + +/** + * Runs property-based fuzz checks for advisory parsing and utility behavior. + */ +export function runFuzzProperties() { + fc.assert( + fc.property(fc.string(), (raw) => { + const expected = String(raw ?? "") + .trim() + .toLowerCase(); + assert.equal(normalizeSkillName(raw), expected); + }), + { numRuns: 300 }, + ); + + fc.assert( + fc.property(fc.array(fc.string(), { maxLength: 40 }), (values) => { + const deduped = uniqueStrings(values); + assert.deepEqual(deduped, Array.from(new Set(values))); + }), + { numRuns: 200 }, + ); + + fc.assert( + fc.property(fc.string(), fc.string(), (left, right) => { + const rawSpecifier = `${left}@${right}`; + const specifier = rawSpecifier.trim(); + const parsed = parseAffectedSpecifier(rawSpecifier); + assert.ok(parsed !== null); + + const atIndex = specifier.lastIndexOf("@"); + if (atIndex <= 0) { + assert.equal(parsed.name, specifier); + assert.equal(parsed.versionSpec, "*"); + } else { + assert.equal(parsed.name, specifier.slice(0, atIndex)); + assert.equal(parsed.versionSpec, specifier.slice(atIndex + 1)); + } + }), + { numRuns: 300 }, + ); + + fc.assert( + fc.property(SAFE_SEGMENT, (suffix) => { + const fallback = `/tmp/clawsec-suite/${suffix}`; + const resolved = resolveConfiguredPath(`\\$HOME/${suffix}`, fallback, { + label: "FUZZ_PATH", + }); + assert.equal(resolved, path.normalize(fallback)); + }), + { numRuns: 200 }, + ); +} diff --git a/skills/clawsec-suite/test/fuzz_properties.test.mjs b/skills/clawsec-suite/test/fuzz_properties.test.mjs new file mode 100644 index 0000000..1e19e9d --- /dev/null +++ b/skills/clawsec-suite/test/fuzz_properties.test.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node + +/** + * Property-based fuzzing checks for core advisory parsing/path helpers. + * + * Run: node skills/clawsec-suite/test/fuzz_properties.test.mjs + */ + +import { runFuzzProperties } from "./fuzz_properties.js"; + +try { + console.log("=== ClawSec Fast-Check Fuzz Properties ===\n"); + runFuzzProperties(); + console.log("=== Results: all fuzz properties passed ==="); +} catch (error) { + console.error("Fuzz property test failed:"); + console.error(error); + process.exit(1); +} diff --git a/skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs b/skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs new file mode 100644 index 0000000..1a018c9 --- /dev/null +++ b/skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node + +/** + * Property-based fuzz tests for semver matching, advisory scope, and suppression matching. + * + * Run: node skills/clawsec-suite/test/fuzz_semver_scope_suppression.test.mjs + */ + +import assert from "node:assert/strict"; +import fc from "fast-check"; +import { advisoryAppliesToOpenclaw } from "../hooks/clawsec-advisory-guardian/lib/advisory_scope.mjs"; +import { isAdvisorySuppressed } from "../hooks/clawsec-advisory-guardian/lib/suppression.mjs"; +import { compareSemver, parseSemver, versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs"; + +const semverCoreArb = fc.tuple( + fc.integer({ min: 0, max: 999 }), + fc.integer({ min: 0, max: 999 }), + fc.integer({ min: 0, max: 999 }), +); + +const semverArb = semverCoreArb.map(([major, minor, patch]) => `${major}.${minor}.${patch}`); +const idArb = fc.string({ minLength: 1, maxLength: 24 }); +const skillArb = fc.string({ minLength: 1, maxLength: 24 }); + +function runSemverProperties() { + fc.assert( + fc.property(semverCoreArb, ([major, minor, patch]) => { + const version = `v${major}.${minor}.${patch}`; + assert.deepEqual(parseSemver(version), [major, minor, patch]); + }), + { numRuns: 250 }, + ); + + fc.assert( + fc.property(semverArb, semverArb, (left, right) => { + const leftVsRight = compareSemver(left, right); + const rightVsLeft = compareSemver(right, left); + assert.notEqual(leftVsRight, null); + assert.notEqual(rightVsLeft, null); + assert.equal(leftVsRight, -rightVsLeft); + }), + { numRuns: 250 }, + ); + + fc.assert( + fc.property(semverArb, semverArb, (left, right) => { + const compared = compareSemver(left, right); + assert.notEqual(compared, null); + + assert.equal(versionMatches(left, `>=${right}`), compared >= 0); + assert.equal(versionMatches(left, `<=${right}`), compared <= 0); + assert.equal(versionMatches(left, `>${right}`), compared > 0); + assert.equal(versionMatches(left, `<${right}`), compared < 0); + assert.equal(versionMatches(left, `=${right}`), compared === 0); + }), + { numRuns: 250 }, + ); +} + +function runAdvisoryScopeProperties() { + fc.assert( + fc.property(fc.string(), (application) => { + const normalized = application.trim().toLowerCase(); + const expected = normalized === "" || normalized === "openclaw" || normalized === "all"; + assert.equal(advisoryAppliesToOpenclaw({ application }), expected); + }), + { numRuns: 250 }, + ); + + fc.assert( + fc.property(fc.array(fc.string(), { maxLength: 8 }), (applications) => { + const normalized = applications + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + const expected = + normalized.length === 0 || normalized.includes("openclaw") || normalized.includes("all"); + assert.equal(advisoryAppliesToOpenclaw({ application: applications }), expected); + }), + { numRuns: 250 }, + ); + + assert.equal(advisoryAppliesToOpenclaw({}), true); + assert.equal(advisoryAppliesToOpenclaw({ application: null }), true); +} + +function runSuppressionProperties() { + fc.assert( + fc.property(idArb, skillArb, (id, skill) => { + const match = { + advisory: { id }, + skill: { name: skill.toUpperCase() }, + }; + const suppressions = [ + { + checkId: id, + skill: skill.toLowerCase(), + reason: "fuzz", + suppressedAt: "2026-02-25", + }, + ]; + assert.equal(isAdvisorySuppressed(match, suppressions), true); + }), + { numRuns: 250 }, + ); + + fc.assert( + fc.property(idArb, idArb, skillArb, (targetId, otherId, skill) => { + const differentId = targetId === otherId ? `${otherId}-x` : otherId; + const match = { + advisory: { id: targetId }, + skill: { name: skill }, + }; + const suppressions = [ + { + checkId: differentId, + skill, + reason: "fuzz", + suppressedAt: "2026-02-25", + }, + ]; + assert.equal(isAdvisorySuppressed(match, suppressions), false); + }), + { numRuns: 250 }, + ); +} + +try { + console.log("=== ClawSec Semver/Scope/Suppression Fuzz Properties ===\n"); + runSemverProperties(); + runAdvisoryScopeProperties(); + runSuppressionProperties(); + console.log("=== Results: all fuzz properties passed ==="); +} catch (error) { + console.error("Fuzz property test failed:"); + console.error(error); + process.exit(1); +} diff --git a/skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs b/skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs new file mode 100644 index 0000000..17b2d93 --- /dev/null +++ b/skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs @@ -0,0 +1,93 @@ +#!/usr/bin/env node + +/** + * Property-based fuzz tests for openclaw suppression config gating behavior. + * + * Run: node skills/openclaw-audit-watchdog/test/suppression_config_fuzz.test.mjs + */ + +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import fc from "fast-check"; +import { loadSuppressionConfig } from "../scripts/load_suppression_config.mjs"; + +const pipelineArb = fc.constantFrom("audit", "advisory", "watchdog"); + +function makeValidConfig({ pipeline, includePipeline }) { + const enabledFor = includePipeline ? [pipeline.toUpperCase(), "other"] : ["other"]; + return JSON.stringify({ + enabledFor, + suppressions: [ + { + checkId: "SCAN-001", + skill: "soul-guardian", + reason: "fuzz test", + suppressedAt: "2026-02-25", + }, + ], + }); +} + +async function withTempConfig(content, fn) { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "watchdog-fuzz-")); + const configPath = path.join(tmpDir, "suppression.json"); + await fs.writeFile(configPath, content, "utf8"); + try { + await fn(configPath); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } +} + +async function withSilencedStderr(fn) { + const originalWrite = process.stderr.write; + process.stderr.write = () => true; + try { + return await fn(); + } finally { + process.stderr.write = originalWrite; + } +} + +async function runProperties() { + await fc.assert( + fc.asyncProperty(fc.string(), pipelineArb, async (rawPath, pipeline) => { + const result = await loadSuppressionConfig(rawPath, { enabled: false, pipeline }); + assert.equal(result.source, "none"); + assert.deepEqual(result.suppressions, []); + }), + { numRuns: 120 }, + ); + + await fc.assert( + fc.asyncProperty(pipelineArb, fc.boolean(), async (pipeline, includePipeline) => { + const content = makeValidConfig({ pipeline, includePipeline }); + await withTempConfig(content, async (configPath) => { + const result = await withSilencedStderr(() => + loadSuppressionConfig(configPath, { enabled: true, pipeline }), + ); + if (includePipeline) { + assert.equal(result.source, configPath); + assert.equal(result.suppressions.length, 1); + assert.equal(result.suppressions[0].checkId, "SCAN-001"); + } else { + assert.equal(result.source, "none"); + assert.deepEqual(result.suppressions, []); + } + }); + }), + { numRuns: 80 }, + ); +} + +try { + console.log("=== OpenClaw Suppression Config Fuzz Properties ===\n"); + await runProperties(); + console.log("=== Results: all fuzz properties passed ==="); +} catch (error) { + console.error("Fuzz property test failed:"); + console.error(error); + process.exit(1); +}