diff --git a/.github/workflows/poll-nvd-cves.yml b/.github/workflows/poll-nvd-cves.yml index 80bc4db..3d50019 100644 --- a/.github/workflows/poll-nvd-cves.yml +++ b/.github/workflows/poll-nvd-cves.yml @@ -202,9 +202,79 @@ jobs: .cve.metrics.cvssMetricV2[0]?.cvssData.baseScore // null; + def nvd_category_raw: + ( + [.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)] + | unique + | map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other")) + | .[0] + ); + + def cwe_id: + ( + nvd_category_raw + | if . == null then null + else (try (capture("^CWE-(?[0-9]+)$").id) catch null) + end + ); + + def cwe_name_map($id): + ({ + "20": "improper_input_validation", + "22": "path_traversal", + "77": "command_injection", + "78": "os_command_injection", + "79": "cross_site_scripting", + "89": "sql_injection", + "94": "code_injection", + "119": "memory_buffer_bounds_violation", + "120": "classic_buffer_overflow", + "125": "out_of_bounds_read", + "134": "format_string_vulnerability", + "200": "exposure_of_sensitive_information", + "250": "execution_with_unnecessary_privileges", + "269": "improper_privilege_management", + "284": "improper_access_control", + "285": "improper_authorization", + "287": "improper_authentication", + "295": "improper_certificate_validation", + "306": "missing_authentication_for_critical_function", + "319": "cleartext_transmission_of_sensitive_information", + "326": "inadequate_encryption_strength", + "327": "risky_cryptographic_algorithm", + "352": "cross_site_request_forgery", + "362": "race_condition", + "400": "uncontrolled_resource_consumption", + "416": "use_after_free", + "434": "unrestricted_file_upload", + "502": "deserialization_of_untrusted_data", + "601": "open_redirect", + "611": "xml_external_entity_injection", + "639": "insecure_direct_object_reference", + "668": "exposure_of_resource_to_wrong_sphere", + "669": "incorrect_resource_transfer_between_spheres", + "732": "incorrect_permission_assignment", + "787": "out_of_bounds_write", + "798": "hard_coded_credentials", + "862": "missing_authorization", + "863": "incorrect_authorization", + "918": "server_side_request_forgery", + "922": "insecure_storage_of_sensitive_information" + }[$id]); + + def nvd_category_name: + ( + cwe_id as $id + | if $id == null then "unspecified_weakness" + else (cwe_name_map($id) // ("unknown_cwe_" + $id)) + end + ); + [.[] | { id: .cve.id, severity: (get_cvss_score | map_severity), + type: nvd_category_name, + nvd_category_id: nvd_category_raw, cvss_score: get_cvss_score, description: (.cve.descriptions[] | select(.lang == "en") | .value), title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)), @@ -225,6 +295,8 @@ jobs: if $existing_entry then # Compare key fields if ($existing_entry.severity != $nvd_entry.severity) or + ($existing_entry.type != $nvd_entry.type) or + ($existing_entry.nvd_category_id != $nvd_entry.nvd_category_id) or ($existing_entry.cvss_score != $nvd_entry.cvss_score) or ($existing_entry.description != $nvd_entry.description) then { @@ -232,11 +304,15 @@ jobs: changes: ( [] + (if $existing_entry.severity != $nvd_entry.severity then ["severity: \($existing_entry.severity) → \($nvd_entry.severity)"] else [] end) + + (if $existing_entry.type != $nvd_entry.type then ["type: \($existing_entry.type // "null") → \($nvd_entry.type // "null")"] else [] end) + + (if $existing_entry.nvd_category_id != $nvd_entry.nvd_category_id then ["nvd_category_id: \($existing_entry.nvd_category_id // "null") → \($nvd_entry.nvd_category_id // "null")"] else [] end) + (if $existing_entry.cvss_score != $nvd_entry.cvss_score then ["cvss_score: \($existing_entry.cvss_score // "null") → \($nvd_entry.cvss_score // "null")"] else [] end) + (if $existing_entry.description != $nvd_entry.description then ["description updated"] else [] end) ), updated_fields: { severity: $nvd_entry.severity, + type: $nvd_entry.type, + nvd_category_id: $nvd_entry.nvd_category_id, cvss_score: $nvd_entry.cvss_score, description: $nvd_entry.description, title: $nvd_entry.title, @@ -282,13 +358,82 @@ jobs: .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV2[0]?.cvssData.baseScore // null; + + def nvd_category_raw: + ( + [.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)] + | unique + | map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other")) + | .[0] + ); + + def cwe_id: + ( + nvd_category_raw + | if . == null then null + else (try (capture("^CWE-(?[0-9]+)$").id) catch null) + end + ); + + def cwe_name_map($id): + ({ + "20": "improper_input_validation", + "22": "path_traversal", + "77": "command_injection", + "78": "os_command_injection", + "79": "cross_site_scripting", + "89": "sql_injection", + "94": "code_injection", + "119": "memory_buffer_bounds_violation", + "120": "classic_buffer_overflow", + "125": "out_of_bounds_read", + "134": "format_string_vulnerability", + "200": "exposure_of_sensitive_information", + "250": "execution_with_unnecessary_privileges", + "269": "improper_privilege_management", + "284": "improper_access_control", + "285": "improper_authorization", + "287": "improper_authentication", + "295": "improper_certificate_validation", + "306": "missing_authentication_for_critical_function", + "319": "cleartext_transmission_of_sensitive_information", + "326": "inadequate_encryption_strength", + "327": "risky_cryptographic_algorithm", + "352": "cross_site_request_forgery", + "362": "race_condition", + "400": "uncontrolled_resource_consumption", + "416": "use_after_free", + "434": "unrestricted_file_upload", + "502": "deserialization_of_untrusted_data", + "601": "open_redirect", + "611": "xml_external_entity_injection", + "639": "insecure_direct_object_reference", + "668": "exposure_of_resource_to_wrong_sphere", + "669": "incorrect_resource_transfer_between_spheres", + "732": "incorrect_permission_assignment", + "787": "out_of_bounds_write", + "798": "hard_coded_credentials", + "862": "missing_authorization", + "863": "incorrect_authorization", + "918": "server_side_request_forgery", + "922": "insecure_storage_of_sensitive_information" + }[$id]); + + def nvd_category_name: + ( + cwe_id as $id + | if $id == null then "unspecified_weakness" + else (cwe_name_map($id) // ("unknown_cwe_" + $id)) + end + ); [.[] | select(.cve.id as $id | $existing | index($id) | not) | { id: .cve.id, severity: (get_cvss_score | map_severity), - type: "vulnerable_skill", + type: nvd_category_name, + nvd_category_id: nvd_category_raw, title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)), description: (.cve.descriptions[] | select(.lang == "en") | .value), affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5], @@ -324,7 +469,7 @@ jobs: ($updates[0] | map(select(.id == $adv.id)) | first) as $update | if $update then # Merge updated fields - $adv * $update.updated_fields + ($adv * $update.updated_fields) else $adv end diff --git a/advisories/feed.json b/advisories/feed.json index a8b5442..c01ad5a 100644 --- a/advisories/feed.json +++ b/advisories/feed.json @@ -1,12 +1,13 @@ { - "version": "0.0.2", - "updated": "2026-02-08T06:16:28Z", + "version": "0.0.3", + "updated": "2026-02-08T18:42:58Z", "description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.", "advisories": [ { "id": "CVE-2026-25593", "severity": "high", - "type": "vulnerable_skill", + "type": "missing_authentication_for_critical_function", + "nvd_category_id": "CWE-306", "title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...", "description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.", "affected": [], @@ -21,7 +22,8 @@ { "id": "CVE-2026-25475", "severity": "medium", - "type": "vulnerable_skill", + "type": "exposure_of_sensitive_information", + "nvd_category_id": "CWE-200", "title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...", "description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.", "affected": [], @@ -36,7 +38,8 @@ { "id": "CVE-2026-25157", "severity": "high", - "type": "vulnerable_skill", + "type": "os_command_injection", + "nvd_category_id": "CWE-78", "title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...", "description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.", "affected": [], @@ -51,7 +54,8 @@ { "id": "CVE-2026-24763", "severity": "high", - "type": "vulnerable_skill", + "type": "os_command_injection", + "nvd_category_id": "CWE-78", "title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....", "description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.", "affected": [], @@ -68,7 +72,8 @@ { "id": "CVE-2026-25253", "severity": "high", - "type": "vulnerable_skill", + "type": "incorrect_resource_transfer_between_spheres", + "nvd_category_id": "CWE-669", "title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...", "description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.", "affected": [], diff --git a/scripts/populate-local-feed.sh b/scripts/populate-local-feed.sh index bc574d7..2756943 100755 --- a/scripts/populate-local-feed.sh +++ b/scripts/populate-local-feed.sh @@ -141,8 +141,11 @@ jq --arg kw "$KEYWORDS_PATTERN" --arg gh "$GITHUB_REF_PATTERN" ' FILTERED=$(jq 'length' "$TEMP_DIR/filtered_cves.json") echo "Filtered CVEs (matching criteria): $FILTERED" -# Get existing advisory IDs -if [ -f "$FEED_PATH" ]; then +# Get existing advisory IDs (unless force mode) +if [ "$FORCE" = "true" ]; then + echo "Force mode: ignoring existing advisory IDs during transform" + EXISTING_IDS="" +elif [ -f "$FEED_PATH" ]; then EXISTING_IDS=$(jq -r '.advisories[]?.id // empty' "$FEED_PATH" | sort -u) else EXISTING_IDS="" @@ -165,13 +168,82 @@ jq --argjson existing "$EXISTING_JSON" ' .cve.metrics.cvssMetricV30[0]?.cvssData.baseScore // .cve.metrics.cvssMetricV2[0]?.cvssData.baseScore // null; + + def nvd_category_raw: + ( + [.cve.weaknesses[]?.description[]? | select(.lang == "en") | .value | strings | select(length > 0)] + | unique + | map(select(. != "NVD-CWE-noinfo" and . != "NVD-CWE-Other")) + | .[0] + ); + + def cwe_id: + ( + nvd_category_raw + | if . == null then null + else (try (capture("^CWE-(?[0-9]+)$").id) catch null) + end + ); + + def cwe_name_map($id): + ({ + "20": "improper_input_validation", + "22": "path_traversal", + "77": "command_injection", + "78": "os_command_injection", + "79": "cross_site_scripting", + "89": "sql_injection", + "94": "code_injection", + "119": "memory_buffer_bounds_violation", + "120": "classic_buffer_overflow", + "125": "out_of_bounds_read", + "134": "format_string_vulnerability", + "200": "exposure_of_sensitive_information", + "250": "execution_with_unnecessary_privileges", + "269": "improper_privilege_management", + "284": "improper_access_control", + "285": "improper_authorization", + "287": "improper_authentication", + "295": "improper_certificate_validation", + "306": "missing_authentication_for_critical_function", + "319": "cleartext_transmission_of_sensitive_information", + "326": "inadequate_encryption_strength", + "327": "risky_cryptographic_algorithm", + "352": "cross_site_request_forgery", + "362": "race_condition", + "400": "uncontrolled_resource_consumption", + "416": "use_after_free", + "434": "unrestricted_file_upload", + "502": "deserialization_of_untrusted_data", + "601": "open_redirect", + "611": "xml_external_entity_injection", + "639": "insecure_direct_object_reference", + "668": "exposure_of_resource_to_wrong_sphere", + "669": "incorrect_resource_transfer_between_spheres", + "732": "incorrect_permission_assignment", + "787": "out_of_bounds_write", + "798": "hard_coded_credentials", + "862": "missing_authorization", + "863": "incorrect_authorization", + "918": "server_side_request_forgery", + "922": "insecure_storage_of_sensitive_information" + }[$id]); + + def nvd_category_name: + ( + cwe_id as $id + | if $id == null then "unspecified_weakness" + else (cwe_name_map($id) // ("unknown_cwe_" + $id)) + end + ); [.[] | select(.cve.id as $id | $existing | index($id) | not) | { id: .cve.id, severity: (get_cvss_score | map_severity), - type: "vulnerable_skill", + type: nvd_category_name, + nvd_category_id: nvd_category_raw, title: (.cve.descriptions[] | select(.lang == "en") | .value | .[0:100] + (if length > 100 then "..." else "" end)), description: (.cve.descriptions[] | select(.lang == "en") | .value), affected: [.cve.configurations[]?.nodes[]?.cpeMatch[]?.criteria // empty] | unique | .[0:5], @@ -207,7 +279,20 @@ NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ) if [ -f "$FEED_PATH" ]; then jq --argjson new "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" ' .updated = $now | - .advisories = (.advisories + $new | sort_by(.published) | reverse) + # Merge by advisory ID so force mode can refresh existing CVEs without duplicates + .advisories = ( + reduce (.advisories + $new)[] as $adv + ({}; + if ($adv.id // "") == "" then + . + else + .[$adv.id] = $adv + end + ) + | [.[]] + | sort_by(.published) + | reverse + ) ' "$FEED_PATH" > "$TEMP_DIR/updated_feed.json" else jq -n --argjson advisories "$(cat "$TEMP_DIR/new_advisories.json")" --arg now "$NOW" '{ diff --git a/scripts/release-skill.sh b/scripts/release-skill.sh index e478711..7b251fd 100755 --- a/scripts/release-skill.sh +++ b/scripts/release-skill.sh @@ -1,27 +1,44 @@ #!/bin/bash -# Usage: ./scripts/release-skill.sh +# Usage: ./scripts/release-skill.sh [--force-tag] # Example: ./scripts/release-skill.sh clawsec-feed 1.1.0 # # This script ensures version consistency by: # 1. Updating skill.json with the new version # 2. Updating any hardcoded version URLs in skill.json and SKILL.md # 3. Committing the changes -# 4. Creating the git tag +# 4. Creating the git tag (only on main/master branch) # -# After running, push your current branch and tag: -# git push origin -# git push origin +# Branch-aware workflow: +# Feature branch: Updates versions, commits, pushes → CI validates build +# Main branch: Updates versions, commits, creates tag → push triggers release +# +# Use --force-tag to create a tag even when not on main/master. set -euo pipefail -if [ "$#" -ne 2 ]; then - echo "Usage: $0 " +# Parse arguments +FORCE_TAG=false +POSITIONAL_ARGS=() + +for arg in "$@"; do + case $arg in + --force-tag) + FORCE_TAG=true + ;; + *) + POSITIONAL_ARGS+=("$arg") + ;; + esac +done + +if [ "${#POSITIONAL_ARGS[@]}" -ne 2 ]; then + echo "Usage: $0 [--force-tag]" echo "Example: $0 clawsec-feed 1.1.0" exit 1 fi -SKILL_NAME="$1" -VERSION="$2" +SKILL_NAME="${POSITIONAL_ARGS[0]}" +VERSION="${POSITIONAL_ARGS[1]}" SKILL_PATH="skills/$SKILL_NAME" # Ensure we're on a branch (not detached HEAD) so release flow works from feature branches @@ -31,6 +48,12 @@ if [ -z "$CURRENT_BRANCH" ]; then exit 1 fi +# Determine if we're on a release branch (main/master) +IS_RELEASE_BRANCH=false +if [[ "$CURRENT_BRANCH" == "main" || "$CURRENT_BRANCH" == "master" ]]; then + IS_RELEASE_BRANCH=true +fi + # Security: Validate skill name to prevent path injection # Only allow lowercase alphanumeric characters and hyphens if ! [[ "$SKILL_NAME" =~ ^[a-z0-9-]+$ ]]; then @@ -52,12 +75,6 @@ fi TAG="${SKILL_NAME}-v${VERSION}" -# Check if tag already exists -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "Error: Tag $TAG already exists" - exit 1 -fi - # Check for uncommitted changes in skill directory if ! git diff --quiet "$SKILL_PATH/" 2>/dev/null; then echo "Error: $SKILL_PATH/ has uncommitted changes. Please commit or stash them first." @@ -66,6 +83,11 @@ fi echo "Releasing $SKILL_NAME version $VERSION" echo "Branch: $CURRENT_BRANCH" +if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then + echo "Mode: Full release (will create tag)" +else + echo "Mode: Prep only (tag deferred until merge to main)" +fi echo "=======================================" # Create a temporary directory for atomic operations @@ -195,37 +217,62 @@ if ! git commit -m "chore($SKILL_NAME): bump version to $VERSION"; then exit 1 fi -# Save commit SHA for recovery (in case tag creation fails) +# Save commit SHA for recovery COMMIT_SHA=$(git rev-parse HEAD) echo "Committed: $COMMIT_SHA" -# Create annotated tag -echo "Creating tag: $TAG" -if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then - echo "Error: Failed to create tag $TAG" >&2 - echo "" >&2 - echo "The commit has been created but NOT tagged:" >&2 - echo " Commit: $COMMIT_SHA" >&2 - echo "" >&2 - echo "Recovery options:" >&2 - echo " 1. Fix the issue and tag manually:" >&2 - echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2 - echo "" >&2 - echo " 2. Investigate why tagging failed:" >&2 - echo " - Check if tag exists: git tag -l '$TAG'" >&2 - echo " - Check permissions: ls -ld .git/refs/tags" >&2 - echo "" >&2 - echo " 3. To rollback the commit (if desired):" >&2 - echo " git reset --hard HEAD~1" >&2 - echo "" >&2 - echo "The commit has NOT been pushed. Fix the issue before pushing." >&2 - exit 1 -fi +# Create tag only on release branches (or if forced) +if [[ "$IS_RELEASE_BRANCH" == "true" || "$FORCE_TAG" == "true" ]]; then + # Check if tag already exists (only matters when we're creating one) + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "Error: Tag $TAG already exists; rolling back last commit" + git reset --hard HEAD~1 + exit 1 + fi -echo "" -echo "Done! To release, push the commit and tag:" -echo " git push origin $CURRENT_BRANCH" -echo " git push origin $TAG" -echo "" -echo "Or to undo:" -echo " git reset --hard HEAD~1 && git tag -d $TAG" + echo "Creating tag: $TAG" + if ! git tag -a "$TAG" -m "$SKILL_NAME version $VERSION"; then + echo "Error: Failed to create tag $TAG" >&2 + echo "" >&2 + echo "The commit has been created but NOT tagged:" >&2 + echo " Commit: $COMMIT_SHA" >&2 + echo "" >&2 + echo "Recovery options:" >&2 + echo " 1. Fix the issue and tag manually:" >&2 + echo " git tag -a '$TAG' -m '$SKILL_NAME version $VERSION' $COMMIT_SHA" >&2 + echo "" >&2 + echo " 2. Investigate why tagging failed:" >&2 + echo " - Check if tag exists: git tag -l '$TAG'" >&2 + echo " - Check permissions: ls -ld .git/refs/tags" >&2 + echo "" >&2 + echo " 3. To rollback the commit (if desired):" >&2 + echo " git reset --hard HEAD~1" >&2 + echo "" >&2 + echo "The commit has NOT been pushed. Fix the issue before pushing." >&2 + exit 1 + fi + + echo "" + echo "Done! To release, push the commit and tag:" + echo " git push origin $CURRENT_BRANCH" + echo " git push origin $TAG" + echo "" + echo "Or to undo:" + echo " git reset --hard HEAD~1 && git tag -d $TAG" +else + # Feature branch: skip tagging, instruct user on next steps + echo "" + echo "Done! Version updated and committed (tag deferred)." + echo "" + echo "Next steps:" + echo " 1. Push your branch for CI validation:" + echo " git push origin $CURRENT_BRANCH" + echo "" + echo " 2. After CI passes and PR is merged to main, create the tag:" + echo " git checkout main && git pull" + echo " git tag -a '$TAG' $COMMIT_SHA -m '$SKILL_NAME version $VERSION'" + echo " git push origin $TAG" + echo "" + echo "Or to undo the version bump:" + echo " git reset --hard HEAD~1" +fi diff --git a/skills/clawsec-suite/HEARTBEAT.md b/skills/clawsec-suite/HEARTBEAT.md index 289833b..c090d41 100644 --- a/skills/clawsec-suite/HEARTBEAT.md +++ b/skills/clawsec-suite/HEARTBEAT.md @@ -1,13 +1,12 @@ -# ClawSec Suite — Cron Check +# ClawSec Suite — Heartbeat Check -Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be portable: it assumes only POSIX shell + curl + a SHA tool. +Run this periodically (cron/systemd/CI/agent scheduler). It assumes POSIX shell, `curl`, and `jq`. ## Goals -1) Check whether ClawSec Suite has an update available -2) Verify integrity of the installed suite package - -> Design note: Uses the **checksums.json** file from the latest release, which contains version info and SHA256 hashes. Avoids reliance on a separate catalog manifest. +1. Check whether `clawsec-suite` has an update available. +2. Poll the advisory feed. +3. Report new advisories, highlight affected installed skills, and require approval before removal actions. --- @@ -17,6 +16,9 @@ Run this on a schedule (cron/systemd/CI/agent scheduler). It is written to be po INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}" SUITE_DIR="$INSTALL_ROOT/clawsec-suite" CHECKSUMS_URL="${CHECKSUMS_URL:-https://clawsec.prompt.security/releases/latest/download/checksums.json}" +FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}" +STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}" +MIN_FEED_INTERVAL_SECONDS="${MIN_FEED_INTERVAL_SECONDS:-300}" ``` --- @@ -29,100 +31,171 @@ set -euo pipefail test -d "$SUITE_DIR" test -f "$SUITE_DIR/skill.json" -echo "=== ClawSec update Check ===" -echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)" -echo "Where: $SUITE_DIR" +echo "=== ClawSec Suite Heartbeat ===" +echo "When: $(date -u +%Y-%m-%dT%H:%M:%SZ)" +echo "Suite: $SUITE_DIR" ``` --- -## Step 1 — Verify the currently installed suite files (local integrity) - -This step is only meaningful if you ship a checksums file *inside* the suite directory (recommended). - -If present, verify it: - -```bash -if [ -f "$SUITE_DIR/checksums.txt" ]; then - echo "Verifying local checksums.txt" - cd "$SUITE_DIR" - if command -v shasum >/dev/null 2>&1; then - shasum -a 256 -c checksums.txt - else - sha256sum -c checksums.txt - fi -else - echo "NOTE: No local checksums.txt shipped; skipping local integrity verification" -fi -``` - ---- - -## Step 1.5 — Verify Bundled Components - -Check that bundled security skills are properly deployed: - -```bash -INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}" -SUITE_DIR="$INSTALL_ROOT/clawsec-suite" - -# Function to check bundled skill -check_bundled_skill() { - local skill_name="$1" - local skill_dir="$INSTALL_ROOT/$skill_name" - local bundled_dir="$SUITE_DIR/bundled/$skill_name" - - if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then - SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json") - echo "✓ $skill_name v${SKILL_VERSION} is installed" - elif [ -d "$bundled_dir" ] && [ -f "$bundled_dir/skill.json" ]; then - echo "⚠ $skill_name bundled but not deployed" - echo " Deploy with: cp -r '$bundled_dir' '$skill_dir'" - else - echo "✗ $skill_name not found" - fi -} - -echo "=== Bundled Skills Status ===" -check_bundled_skill "clawsec-feed" -check_bundled_skill "openclaw-audit-watchdog" -check_bundled_skill "soul-guardian" -``` - ---- - -## Step 2 — Check for updates (using checksums.json) - -Fetch the latest checksums.json from the release mirror. This file contains version info and SHA256 hashes for all release assets. +## Step 1 — Check suite version updates ```bash TMP="$(mktemp -d)" -cd "$TMP" - -curl -fsSLo checksums.json "$CHECKSUMS_URL" +trap 'rm -rf "$TMP"' EXIT +curl -fsSLo "$TMP/checksums.json" "$CHECKSUMS_URL" INSTALLED_VER="$(jq -r '.version // ""' "$SUITE_DIR/skill.json" 2>/dev/null || true)" -LATEST_VER="$(jq -r '.version // ""' checksums.json 2>/dev/null || true)" +LATEST_VER="$(jq -r '.version // ""' "$TMP/checksums.json" 2>/dev/null || true)" echo "Installed suite: ${INSTALLED_VER:-unknown}" echo "Latest suite: ${LATEST_VER:-unknown}" if [ -n "$LATEST_VER" ] && [ "$LATEST_VER" != "$INSTALLED_VER" ]; then echo "UPDATE AVAILABLE: clawsec-suite ${INSTALLED_VER:-unknown} -> $LATEST_VER" - echo "(Implement your runtime-specific update action here.)" else echo "Suite appears up to date." fi ``` -If your runtime does not have `jq`, you can parse the version line with grep/sed, or we can publish a simpler `latest.txt` endpoint. +--- + +## Step 2 — Initialize advisory state + +```bash +mkdir -p "$(dirname "$STATE_FILE")" + +if [ ! -f "$STATE_FILE" ]; then + echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE" + chmod 600 "$STATE_FILE" +fi + +if ! jq -e '.schema_version and .known_advisories' "$STATE_FILE" >/dev/null 2>&1; then + echo "WARNING: Invalid state file, resetting: $STATE_FILE" + cp "$STATE_FILE" "${STATE_FILE}.bak.$(date -u +%Y%m%d%H%M%S)" 2>/dev/null || true + echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE" + chmod 600 "$STATE_FILE" +fi +``` --- -## Output +## Step 3 — Advisory feed check (embedded clawsec-feed) -This heartbeat should print a short report suitable for being copied into an alert message: +```bash +now_epoch="$(date -u +%s)" +last_check="$(jq -r '.last_feed_check // "1970-01-01T00:00:00Z"' "$STATE_FILE")" +last_epoch="$(date -u -d "$last_check" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "$last_check" +%s 2>/dev/null || echo 0)" -- suite version status -- integrity status \ No newline at end of file +if [ $((now_epoch - last_epoch)) -lt "$MIN_FEED_INTERVAL_SECONDS" ]; then + echo "Feed check skipped (rate limit: ${MIN_FEED_INTERVAL_SECONDS}s)." +else + FEED_TMP="$TMP/feed.json" + FEED_SOURCE="$FEED_URL" + + if ! curl -fsSLo "$FEED_TMP" "$FEED_URL"; then + if [ -f "$SUITE_DIR/advisories/feed.json" ]; then + cp "$SUITE_DIR/advisories/feed.json" "$FEED_TMP" + FEED_SOURCE="$SUITE_DIR/advisories/feed.json (local fallback)" + echo "WARNING: Remote feed unavailable, using local fallback." + else + echo "ERROR: Remote feed unavailable and no local fallback feed found." + exit 1 + fi + fi + + if ! jq -e '.version and (.advisories | type == "array")' "$FEED_TMP" >/dev/null 2>&1; then + echo "ERROR: Advisory feed has invalid format." + exit 1 + fi + + echo "Feed source: $FEED_SOURCE" + echo "Feed updated: $(jq -r '.updated // "unknown"' "$FEED_TMP")" + + NEW_IDS_FILE="$TMP/new_ids.txt" + jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$FEED_TMP" > "$NEW_IDS_FILE" + + if [ -s "$NEW_IDS_FILE" ]; then + echo "New advisories:" + while IFS= read -r id; do + [ -z "$id" ] && continue + jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$FEED_TMP" + jq -r --arg id "$id" '.advisories[] | select(.id == $id) | " Action: \(.action // "Review advisory details")"' "$FEED_TMP" + done < "$NEW_IDS_FILE" + else + echo "FEED_OK - no new advisories" + fi + + echo "Affected installed skills (if any):" + found_affected=0 + removal_recommended=0 + for skill_path in "$INSTALL_ROOT"/*; do + [ -d "$skill_path" ] || continue + skill_name="$(basename "$skill_path")" + + skill_hits="$(jq -r --arg skill_prefix "${skill_name}@" ' + [.advisories[] + | select(any(.affected[]?; startswith($skill_prefix))) + | "- [\(.severity | ascii_upcase)] \(.id): \(.title)\n Action: \(.action // "Review advisory details")" + ] | .[]? + ' "$FEED_TMP")" + + if [ -n "$skill_hits" ]; then + found_affected=1 + echo "- $skill_name is referenced by advisory feed entries" + printf "%s\n" "$skill_hits" + + if jq -e --arg skill_prefix "${skill_name}@" ' + any( + .advisories[]; + any(.affected[]?; startswith($skill_prefix)) + and ( + ((.type // "" | ascii_downcase) == "malicious_skill") + or ((.title // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer"))) + or ((.description // "" | ascii_downcase | test("malicious|exfiltrat|backdoor|trojan|stealer"))) + or ((.action // "" | ascii_downcase | test("remove|uninstall|disable|do not use|quarantine"))) + ) + ) + ' "$FEED_TMP" >/dev/null 2>&1; then + removal_recommended=1 + fi + fi + done + + if [ "$found_affected" -eq 0 ]; then + echo "- none" + fi + + if [ "$removal_recommended" -eq 1 ]; then + echo "Approval required: ask the user for explicit approval before removing any skill." + echo "Double-confirmation policy: install request is first intent; require a second explicit confirmation with advisory context." + fi + + # Persist state + current_utc="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + state_tmp="$TMP/state.json" + + jq --arg t "$current_utc" --arg updated "$(jq -r '.updated // ""' "$FEED_TMP")" --argfile feed "$FEED_TMP" ' + .last_feed_check = $t + | .last_feed_updated = (if $updated == "" then .last_feed_updated else $updated end) + | .known_advisories = ((.known_advisories // []) + [$feed.advisories[]?.id] | map(select(. != null)) | unique) + ' "$STATE_FILE" > "$state_tmp" + + mv "$state_tmp" "$STATE_FILE" + chmod 600 "$STATE_FILE" +fi +``` + +--- + +## Output Summary + +Heartbeat output should include: +- suite version status, +- advisory feed status, +- new advisory list (if any), +- installed skills that appear in advisory `affected` lists, +- and a double-confirmation reminder before risky install/remove actions. + +If your runtime sends alerts, treat `critical` and `high` advisories affecting installed skills as immediate notifications. diff --git a/skills/clawsec-suite/SKILL.md b/skills/clawsec-suite/SKILL.md index 3ea883b..7867671 100644 --- a/skills/clawsec-suite/SKILL.md +++ b/skills/clawsec-suite/SKILL.md @@ -1,311 +1,251 @@ --- name: clawsec-suite -version: 0.0.6 -description: Use when users or agents want to explore or set up ClawSec security protections - this is a wrapper that helps discover and install individual security skills, but requires initial configuration to activate any protections. +version: 0.0.8 +description: ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills. homepage: https://clawsec.prompt.security clawdis: emoji: "📦" requires: - bins: [curl, shasum] + bins: [curl, jq, shasum] --- # ClawSec Suite -## Installation steps +This means `clawsec-suite` can: +- monitor the ClawSec advisory feed, +- track which advisories are new since last check, +- cross-reference advisories against locally installed skills, +- recommend removal for malicious-skill advisories and require explicit user approval first, +- and still act as the setup/management entrypoint for other ClawSec protections. -**IMPORTANT**: ClawSec Suite includes bundled security skills that provide crucial threat intelligence and monitoring capabilities. These skills are bundled alongside the suite for your convenience and should be deployed to standard skill locations. +## Included vs Optional Protections -The suite includes the following bundled skills: -- **clawsec-feed**: Provides crucial CVE and security advisory information -- **openclaw-audit-watchdog**: Provides crucial automated security audit capabilities -- **soul-guardian**: Provides file integrity and drift detection +### Built into clawsec-suite +- Embedded feed seed file: `advisories/feed.json` +- Portable heartbeat workflow in `HEARTBEAT.md` +- Advisory polling + state tracking + affected-skill checks +- OpenClaw advisory guardian hook package: `hooks/clawsec-advisory-guardian/` +- Setup scripts for hook and optional cron scheduling: `scripts/` +- Guarded installer: `scripts/guarded_skill_install.mjs` -Finalize deployment with post-script after suite installation: +### installed separately +- `openclaw-audit-watchdog` +- `soul-guardian` +- `clawtributor` (explicit opt-in) + +## Installation + +### Option A: Via clawhub (recommended) ```bash -#!/bin/bash -# BUNDLED SKILLS DEPLOYMENT -# These skills are bundled alongside the suite to provide crucial security information -set -euo pipefail - -INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}" -SUITE_DIR="$INSTALL_ROOT/clawsec-suite" - -echo "=== Deploying bundled security skills ===" - -# Function to deploy a bundled skill -deploy_skill() { - local skill_name="$1" - local skill_dir="$INSTALL_ROOT/$skill_name" - local bundled_dir="$SUITE_DIR/bundled/$skill_name" - - # Check if skill already exists - if [ -d "$skill_dir" ] && [ -f "$skill_dir/skill.json" ]; then - INSTALLED_VERSION=$(jq -r '.version // "unknown"' "$skill_dir/skill.json" 2>/dev/null) - echo "$skill_name already installed (v${INSTALLED_VERSION}), preserving existing installation" - return 0 - fi - - # Check if bundled version exists - if [ ! -d "$bundled_dir" ] || [ ! -f "$bundled_dir/skill.json" ]; then - echo "Bundled $skill_name not found, skipping" - return 0 - fi - - # Deploy bundled skill - echo "Deploying bundled $skill_name..." - mkdir -p "$skill_dir" - cp -r "$bundled_dir"/* "$skill_dir/" - - # Set proper permissions - chmod 600 "$skill_dir/skill.json" - find "$skill_dir" -type f ! -name "skill.json" -exec chmod 644 {} \; - - # Verify installation - if [ -f "$skill_dir/skill.json" ]; then - SKILL_VERSION=$(jq -r '.version' "$skill_dir/skill.json") - echo "✓ $skill_name v${SKILL_VERSION} deployed successfully" - else - echo "✗ $skill_name deployment verification failed" - return 1 - fi -} - -# Deploy each bundled skill -deploy_skill "clawsec-feed" -deploy_skill "openclaw-audit-watchdog" -deploy_skill "soul-guardian" - -echo "" -echo "✓ ClawSec Suite deployment complete" +npx clawhub@latest install clawsec-suite ``` -### ✅ On Successful Installation (IMPORTANT) - -On success the user should know: which skills were installed, that protections are active, and where to learn more if interested. -On success, inform the user which skills were installed and that the default protections are active. (add an encouraging call - like Congrats, you are safe!) - - -On partial success or errors, list what succeeded, what failed with reasons, and suggest retry or troubleshooting -## Contract (what this skill is) - -ClawSec Suite is a small "manager" package that helps an agent runtime: - -- install ClawSec security components -- verify integrity of installed files (checksums + optional signatures) -- check for updates from a signed checksums.json manifest -- run a periodic "health check" (security skill updates + chain of supply integrity + advisories) - -This document is written to be **portable**: it does not assume a specific agent framework or CLI. However it was tested to work best with OpenClaw, Moltbot, Clawdbot and variants - -## 🎯 Let's Get You Protected - -Great! You've installed the ClawSec Suite wrapper. Now let's get you set up with the security protections you came here for. - -**Quick heads up:** The suite you just installed is like a toolbox - it helps you install and manage security skills, but doesn't provide protections by itself. Think of it as your security skill manager. - -**Here's how to activate your protections:** - -1. Browse the available security skills (see below, or browse to https://clawsec.prompt.security/) -2. Choose which protections match your needs -3. Install your selected skills using the installation guide -4. Auto Configure defaults for each skill for your environment. - -Each skill protects against different threats - from security advisories to file tampering. Let's explore your options and get you secured. - -## Available Security Skills - -The ClawSec suite includes these security skills: - -| Skill | Protection Type | Installation | -|-------|----------------|-------------| -| **clawsec-feed** | Security advisory monitoring from NVD CVE database | ✓ Included by default | -| **openclaw-audit-watchdog** | Automated daily security audits with email reporting | ✓ Included by default | -| **soul-guardian** | File integrity & drift detection for agent workspace files | ✗ Optional | -| **clawtributor** | Community incident reporting (shares anonymized data)* | ✗ Optional (Opt-in) | - -**Clawtributor requires explicit consent** as it shares anonymized data with the community for collective threat intelligence. - -## Getting Started - -### Step 1: Review Available Skills -Take a moment to review the security skills table above. Each skill provides a different layer of protection: - -- **Threat awareness** (clawsec-feed): Stay informed about vulnerabilities [Included by default] -- **Scheduled audits** (openclaw-audit-watchdog): Daily automated security reviews [Included by default] -- **File integrity** (soul-guardian): Detect unauthorized changes to workspace files [Optional] -- **Community intelligence** (clawtributor): Share and receive threat data [Optional - Opt-in] - -### Step 2: Choose Your Protection Level - -**default protections** (automatically installed): -- clawsec-feed -- openclaw-audit-watchdog - -**Optional protections** (install as needed): -- soul-guardian (file integrity and drift detection) -- clawtributor (community threat intelligence - requires explicit consent for data sharing) - -### Step 3: Bundled Security Skills - -Good news! The following security skills are bundled alongside ClawSec Suite: - -✓ **clawsec-feed** - Provides crucial CVE and threat intelligence information -✓ **openclaw-audit-watchdog** - Provides automated security audit capabilities -✓ **soul-guardian** - Provides file integrity and drift detection - -These skills are included in the suite package for your convenience. - -**Deployment options:** -- If you don't already have these skills installed, they will be deployed from the bundled copies -- If you already have any of these skills, your existing installations will be preserved -- Each skill is installed to its standard location: `~/.openclaw/skills/{skill-name}/` - -**What this means:** -- Convenient setup - no separate downloads needed if you don't have them -- Standard installation - follows skill conventions -- Independent management - each skill can be configured separately -- Always current - feed data is fetched online (always up-to-date) - -### Step 4: Optional Opt-In Component - -**clawtributor** (Community threat intelligence): -- Share and receive anonymized threat data -- Requires explicit opt-in and user consent -- Not bundled with suite (available as separate download) -- Installation instructions: https://clawsec.prompt.security/skills/clawtributor - -### Step 5: Configure Each Skill - -After installation, each skill requires configuration, to reduce security fatigue start with the defaults for soul guardian (and dont use email sending for audit reports in watchdog): - -- **clawsec-feed**: Configure update frequency and alert preferences -- **openclaw-audit-watchdog**: Set email addresses for audit reports -- **soul-guardian**: Set baseline checksums for protected files -- **clawtributor**: Provide explicit consent and configure data sharing preferences (if installed) - -Refer to each skill's SKILL.md file for detailed configuration instructions. if the user wants to change the defaults. - -## Trust, provenance, and safety - -Agents should review scripts before execution! - -**Do not install anything you cannot verify.** A secure installer must: - -1) download artifacts from a stable origin -2) verify integrity (checksum) -3) ideally verify authenticity (signature) -4) fail closed (stop on any mismatch) - -If your project does not yet publish signatures, publish at least: - -- `checksums.json` (sha256 hashes + version metadata) - -### Canonical release origin - -All releases are published at: - -- `https://clawsec.prompt.security/releases/download/-v/...` - -Example for clawsec-suite v0.0.9: - -- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.6/checksums.json` -- `https://clawsec.prompt.security/releases/download/clawsec-suite-v0.0.6/clawsec-suite.skill` - -## Installation (generic, version-pinned, verified) - -### Requirements - -- `curl` -- `jq` (for parsing checksums.json) -- `unzip` -- a SHA-256 tool (`shasum -a 256` on macOS, or `sha256sum` on Linux) - -### Install steps - -Pick a stable install root: - -- `INSTALL_ROOT` default: `~/.openclaw/skills` - -> If your agent runtime has its own skills directory, set `INSTALL_ROOT` accordingly. +### Option B: Manual download with verification ```bash set -euo pipefail -VERSION="${SKILL_VERSION}" +VERSION="${SKILL_VERSION:?Set SKILL_VERSION (e.g. 0.0.8)}" INSTALL_ROOT="${INSTALL_ROOT:-$HOME/.openclaw/skills}" DEST="$INSTALL_ROOT/clawsec-suite" +BASE="https://github.com/prompt-security/clawsec/releases/download/clawsec-suite-v${VERSION}" -BASE="https://clawsec.prompt.security/releases/download/clawsec-suite-v${VERSION}" +TEMP_DIR="$(mktemp -d)" +DOWNLOAD_DIR="$TEMP_DIR/downloads" +trap 'rm -rf "$TEMP_DIR"' EXIT +mkdir -p "$DOWNLOAD_DIR" -mkdir -p "$DEST" -cd "$(mktemp -d)" - -# 1) Download checksums.json and artifact -curl -fsSL "$BASE/checksums.json" -o checksums.json -curl -fsSL "$BASE/SKILL.md" -o SKILL.md - -# 2) Extract expected checksum from checksums.json -EXPECTED_SHA256=$(jq -r '.files["clawsec-suite.skill"].sha256' checksums.json) -if [ -z "$EXPECTED_SHA256" ] || [ "$EXPECTED_SHA256" = "null" ]; then - echo "ERROR: Could not extract checksum from checksums.json" >&2 - exit 2 -fi - -# 3) Compute actual checksum -if command -v shasum >/dev/null 2>&1; then - ACTUAL_SHA256=$(shasum -a 256 clawsec-suite.skill | awk '{print $1}') -else - ACTUAL_SHA256=$(sha256sum SKILL.md | awk '{print $1}') -fi - -# 4) Verify checksum (fail closed) -if [ "$EXPECTED_SHA256" != "$ACTUAL_SHA256" ]; then - echo "ERROR: Checksum mismatch!" >&2 - echo " Expected: $EXPECTED_SHA256" >&2 - echo " Actual: $ACTUAL_SHA256" >&2 +# 1) Download checksums manifest +curl -fsSL "$BASE/checksums.json" -o "$TEMP_DIR/checksums.json" +if ! jq -e '.skill and .version and .files' "$TEMP_DIR/checksums.json" >/dev/null 2>&1; then + echo "ERROR: Invalid checksums.json format" >&2 exit 1 fi -echo "Checksum verified: $ACTUAL_SHA256" -# 5) Install -rm -rf "$DEST"/* -#download specific files by checksum list, or .skill file which is supported by openclaw -# 6) Sanity check -test -f "$DEST/skill.json" -test -f "$DEST/SKILL.md" -test -f "$DEST/HEARTBEAT.md" +# 2) Download every file listed in checksums and verify immediately +DOWNLOAD_FAILED=0 +for file in $(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json"); do + FILE_URL="$(jq -r --arg f "$file" '.files[$f].url' "$TEMP_DIR/checksums.json")" + EXPECTED="$(jq -r --arg f "$file" '.files[$f].sha256' "$TEMP_DIR/checksums.json")" -echo "Installed ClawSec Suite v${VERSION} to: $DEST" + if ! curl -fsSL "$FILE_URL" -o "$DOWNLOAD_DIR/$file"; then + echo "ERROR: Download failed for $file" >&2 + DOWNLOAD_FAILED=1 + continue + fi + + if command -v shasum >/dev/null 2>&1; then + ACTUAL="$(shasum -a 256 "$DOWNLOAD_DIR/$file" | awk '{print $1}')" + else + ACTUAL="$(sha256sum "$DOWNLOAD_DIR/$file" | awk '{print $1}')" + fi + + if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "ERROR: Checksum mismatch for $file" >&2 + DOWNLOAD_FAILED=1 + else + echo "Verified: $file" + fi +done + +if [ "$DOWNLOAD_FAILED" -eq 1 ]; then + echo "ERROR: One or more files failed verification" >&2 + exit 1 +fi + +# 3) Install files using paths from checksums.json +while IFS= read -r file; do + [ -z "$file" ] && continue + REL_PATH="$(jq -r --arg f "$file" '.files[$f].path // $f' "$TEMP_DIR/checksums.json")" + SRC_PATH="$DOWNLOAD_DIR/$file" + DST_PATH="$DEST/$REL_PATH" + + mkdir -p "$(dirname "$DST_PATH")" + cp "$SRC_PATH" "$DST_PATH" +done < <(jq -r '.files | keys[]' "$TEMP_DIR/checksums.json") + +chmod 600 "$DEST/skill.json" +find "$DEST" -type f ! -name "skill.json" -exec chmod 644 {} \; + +echo "Installed clawsec-suite v${VERSION} to: $DEST" +echo "Next step (OpenClaw): node \"$DEST/scripts/setup_advisory_hook.mjs\"" ``` -### What this does (disclosure) +## OpenClaw Automation (Hook + Optional Cron) -**Installing clawsec-suite:** -- Writes only under: `$DEST` (default `~/.openclaw/skills/clawsec-suite`) -- Makes network requests only to fetch the suite artifact + checksums (and optionally signatures) -- Does **not** provide any security protections by itself - it's just the wrapper/manager -- Does **not** auto-install any security skills - you choose which skills to install -- Does **not** auto-enable telemetry/community reporting -- Does **not** schedule anything automatically +After installing the suite, enable the advisory guardian hook: -**To get actual security protections**, you need to install and configure individual security skills (see "Getting Started" above). +```bash +SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite" +node "$SUITE_DIR/scripts/setup_advisory_hook.mjs" +``` -## Update checking (portable design) +Optional: create/update a periodic cron nudge (default every `6h`) that triggers a main-session advisory scan: -Each release publishes a `checksums.json` file that contains version info and SHA256 hashes for all artifacts: +```bash +SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite" +node "$SUITE_DIR/scripts/setup_advisory_cron.mjs" +``` -- `https://clawsec.prompt.security/releases/download/clawsec-suite-v/checksums.json` +What this adds: +- scan on `agent:bootstrap` and `/new` (`command:new`), +- compare advisory `affected` entries against installed skills, +- notify when new matches appear, +- and ask for explicit user approval before any removal flow. +Restart the OpenClaw gateway after enabling the hook. Then run `/new` once to force an immediate scan in the next session context. -To check for updates, compare the installed version against the latest `checksums.json`. See `HEARTBEAT.md` for the upgrade check procedure. +## Guarded Skill Install Flow (Double Confirmation) -## Platform adapters (optional sections) +When the user asks to install a skill, treat that as the first request and run a guarded install check: -If you want this to work well everywhere, add short adapter sections that only map: +```bash +SUITE_DIR="${INSTALL_ROOT:-$HOME/.openclaw/skills}/clawsec-suite" +node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1 +``` -- install directory -- scheduler integration -- message/alert delivery integration +Behavior: +- If no advisory match is found, install proceeds. +- If advisory match is found, the script prints advisory context and exits with code `42`. +- Then require an explicit second confirmation from the user and rerun with `--confirm-advisory`: -Keep the core verify/install/update logic identical. +```bash +node "$SUITE_DIR/scripts/guarded_skill_install.mjs" --skill helper-plus --version 1.0.1 --confirm-advisory +``` + +This enforces: +1. First confirmation: user asked to install. +2. Second confirmation: user explicitly approves install after seeing advisory details. + +## Embedded Advisory Feed Behavior + +The embedded feed logic uses these defaults: + +- Remote feed URL: `https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json` +- Local seed fallback: `~/.openclaw/skills/clawsec-suite/advisories/feed.json` +- State file: `~/.openclaw/clawsec-suite-feed-state.json` +- Hook rate-limit env (OpenClaw hook): `CLAWSEC_HOOK_INTERVAL_SECONDS` (default `300`) + +### Quick feed check + +```bash +FEED_URL="${CLAWSEC_FEED_URL:-https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json}" +STATE_FILE="${CLAWSEC_SUITE_STATE_FILE:-$HOME/.openclaw/clawsec-suite-feed-state.json}" + +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +if ! curl -fsSLo "$TMP/feed.json" "$FEED_URL"; then + echo "ERROR: Failed to fetch advisory feed" + exit 1 +fi + +if ! jq -e '.version and (.advisories | type == "array")' "$TMP/feed.json" >/dev/null; then + echo "ERROR: Invalid advisory feed format" + exit 1 +fi + +mkdir -p "$(dirname "$STATE_FILE")" +if [ ! -f "$STATE_FILE" ]; then + echo '{"schema_version":"1.0","known_advisories":[],"last_feed_check":null,"last_feed_updated":null}' > "$STATE_FILE" + chmod 600 "$STATE_FILE" +fi + +NEW_IDS_FILE="$TMP/new_ids.txt" +jq -r --argfile state "$STATE_FILE" '($state.known_advisories // []) as $known | [.advisories[]?.id | select(. != null and ($known | index(.) | not))] | .[]?' "$TMP/feed.json" > "$NEW_IDS_FILE" + +if [ -s "$NEW_IDS_FILE" ]; then + echo "New advisories detected:" + while IFS= read -r id; do + [ -z "$id" ] && continue + jq -r --arg id "$id" '.advisories[] | select(.id == $id) | "- [\(.severity | ascii_upcase)] \(.id): \(.title)"' "$TMP/feed.json" + done < "$NEW_IDS_FILE" +else + echo "FEED_OK - no new advisories" +fi +``` + +## Heartbeat Integration + +Use the suite heartbeat script as the single periodic security check entrypoint: + +- `skills/clawsec-suite/HEARTBEAT.md` + +It handles: +- suite update checks, +- feed polling, +- new-advisory detection, +- affected-skill cross-referencing, +- approval-gated response guidance for malicious/removal advisories, +- and persistent state updates. + +## Approval-Gated Response Contract + +If an advisory indicates a malicious or removal-recommended skill and that skill is installed: + +1. Notify the user immediately with advisory details and severity. +2. Recommend removing or disabling the affected skill. +3. Treat the original install request as first intent only. +4. Ask for explicit second confirmation before deletion/disable action (or before proceeding with risky install). +5. Only proceed after that second confirmation. + +The suite hook and heartbeat guidance are intentionally non-destructive by default. + +## Optional Skill Installation + +Install additional protections as needed: + +```bash +npx clawhub@latest install openclaw-audit-watchdog +npx clawhub@latest install soul-guardian +# opt-in only: +npx clawhub@latest install clawtributor +``` + +## Security Notes + +- Always verify checksums before installing files manually. +- Keep advisory polling rate-limited (at least 5 minutes between checks). +- Treat `critical` and `high` advisories affecting installed skills as immediate action items. +- If you migrate off standalone `clawsec-feed`, keep one canonical state file to avoid duplicate notifications. diff --git a/skills/clawsec-suite/advisories/feed.json b/skills/clawsec-suite/advisories/feed.json new file mode 100644 index 0000000..f270a5c --- /dev/null +++ b/skills/clawsec-suite/advisories/feed.json @@ -0,0 +1,106 @@ +{ + "version": "0.0.2", + "updated": "2026-02-08T06:16:28Z", + "description": "Community-driven security advisory feed for ClawSec. Automatically updated with OpenClaw-related CVEs from NVD and community-reported security incidents.", + "advisories": [ + { + "id": "CVE-2026-25593", + "severity": "high", + "type": "vulnerable_skill", + "title": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use t...", + "description": "OpenClaw is a personal AI assistant. Prior to 2026.1.20, an unauthenticated local client could use the Gateway WebSocket API to write config via config.apply and set unsafe cliPath values that were later used for command discovery, enabling command injection as the gateway user. This vulnerability is fixed in 2026.1.20.", + "affected": [], + "action": "Review and update affected components. See NVD for remediation details.", + "published": "2026-02-06T21:16:17.790", + "references": [ + "https://github.com/openclaw/openclaw/security/advisories/GHSA-g55j-c2v4-pjcg" + ], + "cvss_score": 8.4, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25593" + }, + { + "id": "CVE-2026-25475", + "severity": "medium", + "type": "vulnerable_skill", + "title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/...", + "description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.30, the isValidMedia() function in src/media/parse.ts allows arbitrary file paths including absolute paths, home directory paths, and directory traversal sequences. An agent can read any file on the system by outputting MEDIA:/path/to/file, exfiltrating sensitive data to the user/channel. This issue has been patched in version 2026.1.30.", + "affected": [], + "action": "Review and update affected components. See NVD for remediation details.", + "published": "2026-02-04T20:16:07.287", + "references": [ + "https://github.com/openclaw/openclaw/security/advisories/GHSA-r8g4-86fx-92mq" + ], + "cvss_score": 6.5, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25475" + }, + { + "id": "CVE-2026-25157", + "severity": "high", + "type": "vulnerable_skill", + "title": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vu...", + "description": "OpenClaw is a personal AI assistant. Prior to version 2026.1.29, there is an OS command injection vulnerability via the Project Root Path in sshNodeCommand. The sshNodeCommand function constructed a shell script without properly escaping the user-supplied project path in an error message. When the cd command failed, the unescaped path was interpolated directly into an echo statement, allowing arbitrary command execution on the remote SSH host. The parseSSHTarget function did not validate that SSH target strings could not begin with a dash. An attacker-supplied target like -oProxyCommand=... would be interpreted as an SSH configuration flag rather than a hostname, allowing arbitrary command execution on the local machine. This issue has been patched in version 2026.1.29.", + "affected": [], + "action": "Review and update affected components. See NVD for remediation details.", + "published": "2026-02-04T20:16:06.577", + "references": [ + "https://github.com/openclaw/openclaw/security/advisories/GHSA-q284-4pvr-m585" + ], + "cvss_score": 7.7, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25157" + }, + { + "id": "CLAW-2026-0001", + "severity": "high", + "type": "prompt_injection", + "title": "Data exfiltration attempt via helper-plus skill", + "description": "The helper-plus skill was observed sending conversation data to an external server (suspicious-domain.com) on every invocation. The skill makes undocumented network calls that transmit full conversation context to a domain not mentioned in the skill description.", + "affected": [ + "helper-plus@1.0.0", + "helper-plus@1.0.1" + ], + "action": "Remove helper-plus immediately. Do not use versions 1.0.0 or 1.0.1. Wait for a verified patched version.", + "published": "2026-02-04T09:30:00Z", + "references": [], + "source": "Community Report", + "github_issue_url": "https://github.com/prompt-security/clawsec/issues/1", + "reporter": { + "agent_name": "SecurityBot", + "opener_type": "agent" + } + }, + { + "id": "CVE-2026-24763", + "severity": "high", + "type": "vulnerable_skill", + "title": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026....", + "description": "OpenClaw (formerly Clawdbot) is a personal AI assistant you run on your own devices. Prior to 2026.1.29, a command injection vulnerability existed in OpenClaw’s Docker sandbox execution mechanism due to unsafe handling of the PATH environment variable when constructing shell commands. An authenticated user able to control environment variables could influence command execution within the container context. This vulnerability is fixed in 2026.1.29.", + "affected": [], + "action": "Review and update affected components. See NVD for remediation details.", + "published": "2026-02-02T23:16:08.593", + "references": [ + "https://github.com/openclaw/openclaw/commit/771f23d36b95ec2204cc9a0054045f5d8439ea75", + "https://github.com/openclaw/openclaw/releases/tag/v2026.1.29", + "https://github.com/openclaw/openclaw/security/advisories/GHSA-mc68-q9jw-2h3v" + ], + "cvss_score": 8.8, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-24763" + }, + { + "id": "CVE-2026-25253", + "severity": "high", + "type": "vulnerable_skill", + "title": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string a...", + "description": "OpenClaw (aka clawdbot or Moltbot) before 2026.1.29 obtains a gatewayUrl value from a query string and automatically makes a WebSocket connection without prompting, sending a token value.", + "affected": [], + "action": "Review and update affected components. See NVD for remediation details.", + "published": "2026-02-01T23:15:49.717", + "references": [ + "https://depthfirst.com/post/1-click-rce-to-steal-your-moltbot-data-and-keys", + "https://ethiack.com/news/blog/one-click-rce-moltbot", + "https://github.com/openclaw/openclaw/security/advisories/GHSA-g8p2-7wf7-98mq" + ], + "cvss_score": 8.8, + "nvd_url": "https://nvd.nist.gov/vuln/detail/CVE-2026-25253" + } + ] +} diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md new file mode 100644 index 0000000..1681728 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/HOOK.md @@ -0,0 +1,31 @@ +--- +name: clawsec-advisory-guardian +description: Detect advisory matches for installed skills and require explicit user approval before any removal action. +metadata: { "openclaw": { "events": ["agent:bootstrap", "command:new"] } } +--- + +# ClawSec Advisory Guardian Hook + +This hook checks the ClawSec advisory feed against locally installed skills on: + +- `agent:bootstrap` +- `command:new` + +When it detects an advisory affecting an installed skill, it posts an alert message. +If the advisory looks malicious or removal-oriented, it explicitly recommends removal +and asks for user approval first. + +## Safety Contract + +- The hook does not delete or modify skills. +- It only reports findings and requests explicit approval before removal. +- Alerts are deduplicated using `~/.openclaw/clawsec-suite-feed-state.json`. + +## Optional Environment Variables + +- `CLAWSEC_FEED_URL`: override remote feed URL. +- `CLAWSEC_LOCAL_FEED`: override local fallback feed file. +- `CLAWSEC_SUITE_STATE_FILE`: override state file path. +- `CLAWSEC_INSTALL_ROOT`: override installed skills root. +- `CLAWSEC_SUITE_DIR`: override clawsec-suite install path. +- `CLAWSEC_HOOK_INTERVAL_SECONDS`: minimum interval between hook scans (default `300`). diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts new file mode 100644 index 0000000..ceaffb4 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/handler.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { uniqueStrings } from "./lib/utils.mjs"; +import { isValidFeedPayload, loadRemoteFeed } from "./lib/feed.mjs"; +import type { HookEvent, FeedPayload, AdvisoryMatch } from "./lib/types.ts"; +import { loadState, persistState } from "./lib/state.ts"; +import { discoverInstalledSkills, findMatches, matchKey, buildAlertMessage } from "./lib/matching.ts"; + +const DEFAULT_FEED_URL = + "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; +const DEFAULT_SCAN_INTERVAL_SECONDS = 300; + +function expandHome(inputPath: string): string { + if (!inputPath) return inputPath; + if (inputPath === "~") return os.homedir(); + if (inputPath.startsWith("~/")) return path.join(os.homedir(), inputPath.slice(2)); + return inputPath; +} + +function parsePositiveInteger(value: string | undefined, fallback: number): number { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return fallback; + } + return parsed; +} + +function toEventName(event: HookEvent): string { + const eventType = String(event.type ?? "").trim(); + const action = String(event.action ?? "").trim(); + if (!eventType || !action) return ""; + return `${eventType}:${action}`; +} + +function shouldHandleEvent(event: HookEvent): boolean { + const eventName = toEventName(event); + return eventName === "agent:bootstrap" || eventName === "command:new"; +} + +function epochMs(isoTimestamp: string | null): number { + if (!isoTimestamp) return 0; + const parsed = Date.parse(isoTimestamp); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function scannedRecently(lastScan: string | null, minIntervalSeconds: number): boolean { + const sinceMs = Date.now() - epochMs(lastScan); + return sinceMs >= 0 && sinceMs < minIntervalSeconds * 1000; +} + +async function loadFeed(feedUrl: string, localFeedPath: string): Promise { + const remoteFeed = await loadRemoteFeed(feedUrl); + if (remoteFeed) return remoteFeed; + + const fallbackRaw = await fs.readFile(localFeedPath, "utf8"); + const fallbackPayload = JSON.parse(fallbackRaw); + if (!isValidFeedPayload(fallbackPayload)) { + throw new Error(`Invalid advisory feed format in fallback file: ${localFeedPath}`); + } + return fallbackPayload; +} + +const handler = async (event: HookEvent): Promise => { + if (!shouldHandleEvent(event)) return; + + const installRoot = expandHome( + process.env.CLAWSEC_INSTALL_ROOT || process.env.INSTALL_ROOT || path.join(os.homedir(), ".openclaw", "skills"), + ); + const suiteDir = expandHome(process.env.CLAWSEC_SUITE_DIR || path.join(installRoot, "clawsec-suite")); + const localFeedPath = expandHome(process.env.CLAWSEC_LOCAL_FEED || path.join(suiteDir, "advisories", "feed.json")); + const stateFile = expandHome( + process.env.CLAWSEC_SUITE_STATE_FILE || path.join(os.homedir(), ".openclaw", "clawsec-suite-feed-state.json"), + ); + const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL; + const scanIntervalSeconds = parsePositiveInteger( + process.env.CLAWSEC_HOOK_INTERVAL_SECONDS, + DEFAULT_SCAN_INTERVAL_SECONDS, + ); + + const forceScan = toEventName(event) === "command:new"; + const state = await loadState(stateFile); + if (!forceScan && scannedRecently(state.last_hook_scan, scanIntervalSeconds)) { + return; + } + + let feed: FeedPayload; + try { + feed = await loadFeed(feedUrl, localFeedPath); + } catch (error) { + console.warn(`[clawsec-advisory-guardian] failed to load advisory feed: ${String(error)}`); + return; + } + + const nowIso = new Date().toISOString(); + state.last_hook_scan = nowIso; + state.last_feed_check = nowIso; + + if (typeof feed.updated === "string" && feed.updated.trim()) { + state.last_feed_updated = feed.updated; + } + + const advisoryIds = feed.advisories + .map((advisory) => advisory.id) + .filter((id): id is string => typeof id === "string" && id.trim() !== ""); + state.known_advisories = uniqueStrings([...state.known_advisories, ...advisoryIds]); + + const installedSkills = await discoverInstalledSkills(installRoot); + const matches = findMatches(feed, installedSkills); + + if (matches.length === 0) { + await persistState(stateFile, state); + return; + } + + const unseenMatches: AdvisoryMatch[] = []; + for (const match of matches) { + const key = matchKey(match); + if (state.notified_matches[key]) { + continue; + } + unseenMatches.push(match); + state.notified_matches[key] = nowIso; + } + + if (unseenMatches.length > 0 && Array.isArray(event.messages)) { + event.messages.push(buildAlertMessage(unseenMatches, installRoot)); + } + + await persistState(stateFile, state); +}; + +export default handler; diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs new file mode 100644 index 0000000..6603ba2 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/feed.mjs @@ -0,0 +1,58 @@ +import { isObject } from "./utils.mjs"; + +/** + * @param {string} rawSpecifier + * @returns {{ name: string; versionSpec: string } | null} + */ +export function parseAffectedSpecifier(rawSpecifier) { + const specifier = String(rawSpecifier ?? "").trim(); + if (!specifier) return null; + + const atIndex = specifier.lastIndexOf("@"); + if (atIndex <= 0) { + return { name: specifier, versionSpec: "*" }; + } + + return { + name: specifier.slice(0, atIndex), + versionSpec: specifier.slice(atIndex + 1), + }; +} + +/** + * @param {unknown} raw + * @returns {raw is import("./types.ts").FeedPayload} + */ +export function isValidFeedPayload(raw) { + if (!isObject(raw)) return false; + if (!Array.isArray(raw.advisories)) return false; + return true; +} + +/** + * @param {string} feedUrl + * @returns {Promise} + */ +export async function loadRemoteFeed(feedUrl) { + const fetchFn = /** @type {{ fetch?: Function }} */ (globalThis).fetch; + if (typeof fetchFn !== "function") return null; + + const controller = new globalThis.AbortController(); + const timeout = globalThis.setTimeout(() => controller.abort(), 10000); + try { + const response = await fetchFn(feedUrl, { + method: "GET", + signal: controller.signal, + headers: { accept: "application/json" }, + }); + + if (!response.ok) return null; + const payload = await response.json(); + if (!isValidFeedPayload(payload)) return null; + return payload; + } catch { + return null; + } finally { + globalThis.clearTimeout(timeout); + } +} diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/matching.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/matching.ts new file mode 100644 index 0000000..177c551 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/matching.ts @@ -0,0 +1,152 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { isObject, normalizeSkillName, uniqueStrings } from "./utils.mjs"; +import { versionMatches } from "./version.mjs"; +import { parseAffectedSpecifier } from "./feed.mjs"; +import type { Advisory, FeedPayload, InstalledSkill, AdvisoryMatch } from "./types.ts"; + +export async function discoverInstalledSkills(installRoot: string): Promise { + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(installRoot, { withFileTypes: true }); + } catch { + return []; + } + + const skills: InstalledSkill[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const fallbackName = entry.name; + const skillDir = path.join(installRoot, entry.name); + const skillJsonPath = path.join(skillDir, "skill.json"); + + let skillName = fallbackName; + let version: string | null = "unknown"; + + try { + const rawSkillJson = await fs.readFile(skillJsonPath, "utf8"); + const parsedSkillJson = JSON.parse(rawSkillJson); + if (isObject(parsedSkillJson) && typeof parsedSkillJson.name === "string" && parsedSkillJson.name.trim()) { + skillName = parsedSkillJson.name.trim(); + } + if ( + isObject(parsedSkillJson) && + typeof parsedSkillJson.version === "string" && + parsedSkillJson.version.trim() + ) { + version = parsedSkillJson.version.trim(); + } + } catch { + // best-effort scan: keep fallback directory name when skill.json is missing or invalid + } + + skills.push({ name: skillName, dirName: entry.name, version }); + } + + return skills; +} + +export function affectedSpecifierMatchesSkill(rawSpecifier: string, skill: InstalledSkill): boolean { + const parsed = parseAffectedSpecifier(rawSpecifier); + if (!parsed) return false; + + const specName = normalizeSkillName(parsed.name); + const skillName = normalizeSkillName(skill.name); + if (specName !== skillName) return false; + + return versionMatches(skill.version, parsed.versionSpec); +} + +export function advisoryMatchesSkill(advisory: Advisory, skill: InstalledSkill): string[] { + const affected = Array.isArray(advisory.affected) ? advisory.affected : []; + const matches = affected.filter((specifier) => affectedSpecifierMatchesSkill(specifier, skill)); + return uniqueStrings(matches); +} + +export function findMatches(feed: FeedPayload, installedSkills: InstalledSkill[]): AdvisoryMatch[] { + const matches: AdvisoryMatch[] = []; + + for (const advisory of feed.advisories) { + const affected = Array.isArray(advisory.affected) ? advisory.affected : []; + if (affected.length === 0) continue; + + for (const skill of installedSkills) { + const matchedAffected = advisoryMatchesSkill(advisory, skill); + if (matchedAffected.length === 0) continue; + matches.push({ advisory, skill, matchedAffected }); + } + } + + return matches; +} + +export function matchKey(match: AdvisoryMatch): string { + const normalizedSkillName = normalizeSkillName(match.skill.name); + const version = match.skill.version ?? "unknown"; + const advisoryId = + match.advisory.id ?? + `${match.advisory.title ?? "untitled"}::${match.advisory.published ?? match.advisory.updated ?? "unknown-ts"}`; + return `${advisoryId}::${normalizedSkillName}@${version}`; +} + +export function looksMalicious(advisory: Advisory): boolean { + const type = String(advisory.type ?? "").toLowerCase(); + const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase(); + + if (type === "malicious_skill" || type === "malicious_plugin") return true; + if (/\b(malicious|exfiltrat(e|ion)|backdoor|trojan|credential theft|stealer)\b/.test(combined)) return true; + return false; +} + +export function looksRemovalRecommended(advisory: Advisory): boolean { + const combined = `${advisory.action ?? ""} ${advisory.title ?? ""} ${advisory.description ?? ""}`.toLowerCase(); + return /\b(remove|uninstall|delete|disable|do not use|quarantine)\b/.test(combined); +} + +export function buildAlertMessage(matches: AdvisoryMatch[], installRoot: string): string { + const lines: string[] = []; + lines.push("CLAWSEC ALERT: advisory feed matches installed skill(s)."); + lines.push("Affected skill advisories:"); + + const MAX_LISTED = 8; + for (const match of matches.slice(0, MAX_LISTED)) { + const severity = String(match.advisory.severity ?? "unknown").toUpperCase(); + const advisoryId = match.advisory.id ?? "unknown-id"; + const version = match.skill.version ?? "unknown"; + const matched = match.matchedAffected.join(", "); + lines.push( + `- [${severity}] ${advisoryId} -> ${match.skill.name}@${version}` + + (matched ? ` (matched: ${matched})` : ""), + ); + if (match.advisory.action) { + lines.push(` Action: ${match.advisory.action}`); + } + } + + if (matches.length > MAX_LISTED) { + lines.push(`- ... ${matches.length - MAX_LISTED} additional match(es) not shown`); + } + + const removalMatches = matches.filter((entry) => looksMalicious(entry.advisory) || looksRemovalRecommended(entry.advisory)); + if (removalMatches.length > 0) { + const impactedSkills = uniqueStrings(removalMatches.map((entry) => entry.skill.name)); + const impactedDirs = uniqueStrings(removalMatches.map((entry) => entry.skill.dirName)); + lines.push(""); + lines.push("Recommendation: one or more matches indicate potentially malicious or unsafe skills."); + lines.push("Best practice: remove or disable affected skills only after explicit user approval."); + lines.push( + "Double-confirmation policy: treat the install request as first intent and require an additional explicit confirmation with this advisory context.", + ); + lines.push(`Approval needed: ask the user to approve removal of: ${impactedSkills.join(", ")}.`); + lines.push("Candidate removal paths:"); + for (const dir of impactedDirs) { + lines.push(`- ${path.join(installRoot, dir)}`); + } + } else { + lines.push(""); + lines.push("Recommendation: review advisories and update/remove affected skills as directed."); + } + + return lines.join("\n"); +} diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/state.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/state.ts new file mode 100644 index 0000000..ebed7ae --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/state.ts @@ -0,0 +1,74 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { isObject, uniqueStrings } from "./utils.mjs"; +import type { AdvisoryState } from "./types.ts"; + +export const DEFAULT_STATE: AdvisoryState = { + schema_version: "1.1", + known_advisories: [], + last_feed_check: null, + last_feed_updated: null, + last_hook_scan: null, + notified_matches: {}, +}; + +export function normalizeState(raw: unknown): AdvisoryState { + if (!isObject(raw)) { + return { ...DEFAULT_STATE }; + } + + const knownAdvisories = Array.isArray(raw.known_advisories) + ? uniqueStrings(raw.known_advisories.filter((value): value is string => typeof value === "string" && value.trim() !== "")) + : []; + + const notifiedMatches: Record = {}; + if (isObject(raw.notified_matches)) { + for (const [key, value] of Object.entries(raw.notified_matches)) { + if (typeof value === "string" && value.trim()) { + notifiedMatches[key] = value; + } + } + } + + return { + schema_version: "1.1", + known_advisories: knownAdvisories, + last_feed_check: typeof raw.last_feed_check === "string" ? raw.last_feed_check : null, + last_feed_updated: typeof raw.last_feed_updated === "string" ? raw.last_feed_updated : null, + last_hook_scan: typeof raw.last_hook_scan === "string" ? raw.last_hook_scan : null, + notified_matches: notifiedMatches, + }; +} + +export async function loadState(stateFile: string): Promise { + try { + const raw = await fs.readFile(stateFile, "utf8"); + return normalizeState(JSON.parse(raw)); + } catch { + return { ...DEFAULT_STATE }; + } +} + +export async function persistState(stateFile: string, state: AdvisoryState): Promise { + const normalized = normalizeState(state); + await fs.mkdir(path.dirname(stateFile), { recursive: true }); + const tmpFile = `${stateFile}.tmp-${process.pid}-${Date.now()}`; + await fs.writeFile(tmpFile, `${JSON.stringify(normalized, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await fs.rename(tmpFile, stateFile); + try { + await fs.chmod(stateFile, 0o600); + } catch (err: unknown) { + const code = err instanceof Error && "code" in err ? (err as { code: string }).code : undefined; + if (code === "ENOTSUP" || code === "EPERM") { + console.warn( + `Warning: chmod 0600 failed for ${stateFile} (${code}). ` + + "File permissions may not be enforced on this platform/filesystem.", + ); + } else { + throw err; + } + } +} diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts new file mode 100644 index 0000000..9ef2054 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/types.ts @@ -0,0 +1,43 @@ +export type HookEvent = { + type?: string; + action?: string; + messages?: string[]; +}; + +export type Advisory = { + id?: string; + severity?: string; + type?: string; + title?: string; + description?: string; + action?: string; + published?: string; + updated?: string; + affected?: string[]; +}; + +export type FeedPayload = { + updated?: string; + advisories: Advisory[]; +}; + +export type InstalledSkill = { + name: string; + dirName: string; + version: string | null; +}; + +export type AdvisoryMatch = { + advisory: Advisory; + skill: InstalledSkill; + matchedAffected: string[]; +}; + +export type AdvisoryState = { + schema_version: string; + known_advisories: string[]; + last_feed_check: string | null; + last_feed_updated: string | null; + last_hook_scan: string | null; + notified_matches: Record; +}; diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/utils.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/utils.mjs new file mode 100644 index 0000000..b81b666 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/utils.mjs @@ -0,0 +1,25 @@ +/** + * @param {unknown} value + * @returns {value is Record} + */ +export function isObject(value) { + return typeof value === "object" && value !== null; +} + +/** + * @param {string} value + * @returns {string} + */ +export function normalizeSkillName(value) { + return String(value ?? "") + .trim() + .toLowerCase(); +} + +/** + * @param {string[]} values + * @returns {string[]} + */ +export function uniqueStrings(values) { + return Array.from(new Set(values)); +} diff --git a/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/version.mjs b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/version.mjs new file mode 100644 index 0000000..4ede009 --- /dev/null +++ b/skills/clawsec-suite/hooks/clawsec-advisory-guardian/lib/version.mjs @@ -0,0 +1,100 @@ +/** + * @param {string} version + * @returns {[number, number, number] | null} + */ +export function parseSemver(version) { + const cleaned = String(version ?? "") + .trim() + .replace(/^v/i, "") + .split("-")[0]; + const parts = cleaned.split("."); + if (parts.length === 0) return null; + + const normalized = parts.slice(0, 3).map((part) => Number.parseInt(part, 10)); + while (normalized.length < 3) { + normalized.push(0); + } + + if (normalized.some((part) => Number.isNaN(part))) { + return null; + } + return /** @type {[number, number, number]} */ (normalized); +} + +/** + * @param {string} left + * @param {string} right + * @returns {number | null} + */ +export function compareSemver(left, right) { + const a = parseSemver(left); + const b = parseSemver(right); + if (!a || !b) return null; + + for (let index = 0; index < 3; index += 1) { + if (a[index] > b[index]) return 1; + if (a[index] < b[index]) return -1; + } + return 0; +} + +/** + * @param {string} value + * @returns {string} + */ +export function escapeRegex(value) { + return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * @param {string | null} version + * @param {string} rawSpec + * @returns {boolean} + */ +export function versionMatches(version, rawSpec) { + const spec = String(rawSpec ?? "").trim(); + if (!spec || spec === "*" || spec.toLowerCase() === "any") return true; + if (!version || String(version).trim().toLowerCase() === "unknown") return false; + + const normalizedVersion = String(version).trim().replace(/^v/i, ""); + + if (spec.includes("*")) { + const regex = new RegExp(`^${escapeRegex(spec).replace(/\\\*/g, ".*")}$`); + return regex.test(normalizedVersion); + } + + const comparatorMatch = spec.match(/^(>=|<=|>|<|=)\s*(.+)$/); + if (comparatorMatch) { + const operator = comparatorMatch[1]; + const targetVersion = comparatorMatch[2].trim(); + const compared = compareSemver(normalizedVersion, targetVersion); + if (compared === null) return false; + if (operator === ">=") return compared >= 0; + if (operator === "<=") return compared <= 0; + if (operator === ">") return compared > 0; + if (operator === "<") return compared < 0; + return compared === 0; + } + + if (spec.startsWith("^")) { + const target = parseSemver(spec.slice(1)); + const current = parseSemver(normalizedVersion); + if (!target || !current) return false; + if (current[0] !== target[0]) return false; + if (target[0] === 0 && current[1] !== target[1]) return false; + return compareSemver(normalizedVersion, spec.slice(1)) !== -1; + } + + if (spec.startsWith("~")) { + const target = parseSemver(spec.slice(1)); + const current = parseSemver(normalizedVersion); + if (!target || !current) return false; + return ( + current[0] === target[0] && + current[1] === target[1] && + compareSemver(normalizedVersion, spec.slice(1)) !== -1 + ); + } + + return normalizedVersion === spec || normalizedVersion === spec.replace(/^v/i, ""); +} diff --git a/skills/clawsec-suite/scripts/guarded_skill_install.mjs b/skills/clawsec-suite/scripts/guarded_skill_install.mjs new file mode 100644 index 0000000..0b8b20e --- /dev/null +++ b/skills/clawsec-suite/scripts/guarded_skill_install.mjs @@ -0,0 +1,222 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { normalizeSkillName, uniqueStrings } from "../hooks/clawsec-advisory-guardian/lib/utils.mjs"; +import { versionMatches } from "../hooks/clawsec-advisory-guardian/lib/version.mjs"; +import { parseAffectedSpecifier, isValidFeedPayload, loadRemoteFeed } from "../hooks/clawsec-advisory-guardian/lib/feed.mjs"; + +const DEFAULT_FEED_URL = + "https://raw.githubusercontent.com/prompt-security/clawsec/main/advisories/feed.json"; +const DEFAULT_SUITE_DIR = path.join(os.homedir(), ".openclaw", "skills", "clawsec-suite"); +const DEFAULT_LOCAL_FEED = path.join(DEFAULT_SUITE_DIR, "advisories", "feed.json"); +const EXIT_CONFIRM_REQUIRED = 42; + +function printUsage() { + process.stderr.write( + [ + "Usage:", + " node scripts/guarded_skill_install.mjs --skill [--version ] [--confirm-advisory] [--dry-run]", + "", + "Examples:", + " node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1", + " node scripts/guarded_skill_install.mjs --skill helper-plus --version 1.0.1 --confirm-advisory", + "", + "Exit codes:", + " 0 success / no advisory block", + " 42 advisory matched and second confirmation is required", + " 1 error", + "", + ].join("\n"), + ); +} + +function parseArgs(argv) { + const parsed = { + skill: "", + version: "", + confirmAdvisory: false, + dryRun: false, + }; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + + if (token === "--skill") { + parsed.skill = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + if (token === "--version") { + parsed.version = String(argv[i + 1] ?? "").trim(); + i += 1; + continue; + } + if (token === "--confirm-advisory") { + parsed.confirmAdvisory = true; + continue; + } + if (token === "--dry-run") { + parsed.dryRun = true; + continue; + } + if (token === "--help" || token === "-h") { + printUsage(); + process.exit(0); + } + + throw new Error(`Unknown argument: ${token}`); + } + + if (!parsed.skill) { + throw new Error("Missing required argument: --skill"); + } + if (!/^[a-z0-9-]+$/.test(parsed.skill)) { + throw new Error("Invalid --skill value. Use lowercase letters, digits, and hyphens only."); + } + + return parsed; +} + +function affectedSpecifierMatches(specifier, skillName, version) { + const parsed = parseAffectedSpecifier(specifier); + if (!parsed) return false; + if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false; + return versionMatches(version, parsed.versionSpec); +} + +function affectedSpecifierMatchesNameOnly(specifier, skillName) { + const parsed = parseAffectedSpecifier(specifier); + if (!parsed) return false; + if (normalizeSkillName(parsed.name) !== normalizeSkillName(skillName)) return false; + const vs = parsed.versionSpec.trim(); + return !vs || vs === "*" || vs.toLowerCase() === "any"; +} + +function advisoryLooksHighRisk(advisory) { + const type = String(advisory.type ?? "").toLowerCase(); + const severity = String(advisory.severity ?? "").toLowerCase(); + const combined = `${advisory.title ?? ""} ${advisory.description ?? ""} ${advisory.action ?? ""}`.toLowerCase(); + if (type === "malicious_skill" || type === "malicious_plugin") return true; + if (/\b(malicious|exfiltrate|exfiltration|backdoor|trojan|stealer|credential theft)\b/.test(combined)) return true; + if (/\b(remove|uninstall|disable|do not use|quarantine)\b/.test(combined)) return true; + if (severity === "critical") return true; + return false; +} + +async function loadFeed() { + const feedUrl = process.env.CLAWSEC_FEED_URL || DEFAULT_FEED_URL; + const localFeedPath = process.env.CLAWSEC_LOCAL_FEED || DEFAULT_LOCAL_FEED; + + const remoteFeed = await loadRemoteFeed(feedUrl); + if (remoteFeed) return { feed: remoteFeed, source: `remote:${feedUrl}` }; + + const raw = await fs.readFile(localFeedPath, "utf8"); + const payload = JSON.parse(raw); + if (!isValidFeedPayload(payload)) { + throw new Error(`Invalid fallback advisory feed format: ${localFeedPath}`); + } + return { feed: payload, source: `local:${localFeedPath}` }; +} + +function findMatches(feed, skillName, version) { + const advisories = Array.isArray(feed.advisories) ? feed.advisories : []; + const matches = []; + + for (const advisory of advisories) { + const affected = Array.isArray(advisory.affected) ? advisory.affected : []; + if (affected.length === 0) continue; + + const matchedAffected = uniqueStrings( + affected.filter((specifier) => + version + ? affectedSpecifierMatches(specifier, skillName, version) + : affectedSpecifierMatchesNameOnly(specifier, skillName), + ), + ); + + if (matchedAffected.length > 0) { + matches.push({ advisory, matchedAffected }); + } + } + + return matches; +} + +function printMatches(matches, skillName, version) { + process.stdout.write("Advisory matches detected for requested install target.\n"); + process.stdout.write(`Target: ${skillName}${version ? `@${version}` : ""}\n`); + + for (const entry of matches) { + const advisory = entry.advisory; + const severity = String(advisory.severity ?? "unknown").toUpperCase(); + const advisoryId = advisory.id ?? "unknown-id"; + const title = advisory.title ?? "Untitled advisory"; + process.stdout.write(`- [${severity}] ${advisoryId}: ${title}\n`); + process.stdout.write(` matched: ${entry.matchedAffected.join(", ")}\n`); + if (advisory.action) { + process.stdout.write(` action: ${advisory.action}\n`); + } + } +} + +function runInstall(skillName, version) { + const target = version ? `${skillName}@${version}` : skillName; + process.stdout.write(`Install target: ${target}\n`); + + const result = spawnSync("npx", ["clawhub@latest", "install", target], { + stdio: "inherit", + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const { feed, source } = await loadFeed(); + const matches = findMatches(feed, args.skill, args.version); + const highRisk = matches.some((entry) => advisoryLooksHighRisk(entry.advisory)); + + process.stdout.write(`Advisory source: ${source}\n`); + + if (matches.length > 0) { + printMatches(matches, args.skill, args.version); + + process.stdout.write("\n"); + process.stdout.write("Install request recognized as first confirmation.\n"); + process.stdout.write("Additional explicit confirmation is required with advisory context.\n"); + + if (!args.confirmAdvisory) { + process.stdout.write( + "Re-run with --confirm-advisory to proceed after the user explicitly confirms.\n", + ); + process.exit(EXIT_CONFIRM_REQUIRED); + } + process.stdout.write("Second confirmation provided via --confirm-advisory.\n"); + } + + if (args.dryRun) { + process.stdout.write("Dry run only; install command was not executed.\n"); + return; + } + + if (highRisk) { + process.stdout.write( + "High-risk advisory context acknowledged. Proceeding only because --confirm-advisory was provided.\n", + ); + } + + runInstall(args.skill, args.version); +} + +main().catch((error) => { + process.stderr.write(`${String(error)}\n`); + process.exit(1); +}); diff --git a/skills/clawsec-suite/scripts/setup_advisory_cron.mjs b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs new file mode 100644 index 0000000..8d1fba4 --- /dev/null +++ b/skills/clawsec-suite/scripts/setup_advisory_cron.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; + +const JOB_NAME = process.env.CLAWSEC_ADVISORY_CRON_NAME?.trim() || "ClawSec Advisory Scan"; +const JOB_EVERY = process.env.CLAWSEC_ADVISORY_CRON_EVERY?.trim() || "6h"; +const JOB_DESCRIPTION = + "Trigger a periodic ClawSec advisory scan in the main session and ask for approval before removing flagged skills."; +const SYSTEM_EVENT = + "Run ClawSec advisory scan. If installed skills are flagged as malicious or removal is recommended, notify the user and request explicit approval before any removal."; + +function sh(cmd, args) { + const result = spawnSync(cmd, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const details = (result.stderr || result.stdout || "").trim(); + throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`); + } + + return result.stdout; +} + +function requireOpenClawCli() { + try { + sh("openclaw", ["--version"]); + } catch (error) { + throw new Error( + "openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " + + `Original error: ${String(error)}`, + ); + } +} + +function findExistingJobId(jobsPayload) { + if (!jobsPayload || !Array.isArray(jobsPayload.jobs)) return null; + const existing = jobsPayload.jobs.find((job) => job && job.name === JOB_NAME); + return existing?.id ?? null; +} + +function addJob() { + const out = sh("openclaw", [ + "cron", + "add", + "--name", + JOB_NAME, + "--description", + JOB_DESCRIPTION, + "--every", + JOB_EVERY, + "--session", + "main", + "--system-event", + SYSTEM_EVENT, + "--wake", + "now", + "--json", + ]); + + try { + const payload = JSON.parse(out); + return payload?.id ?? null; + } catch { + return null; + } +} + +function editJob(jobId) { + sh("openclaw", [ + "cron", + "edit", + jobId, + "--name", + JOB_NAME, + "--description", + JOB_DESCRIPTION, + "--enable", + "--every", + JOB_EVERY, + "--session", + "main", + "--system-event", + SYSTEM_EVENT, + "--wake", + "now", + ]); +} + +function main() { + requireOpenClawCli(); + + const jobsOut = sh("openclaw", ["cron", "list", "--json"]); + const jobsPayload = JSON.parse(jobsOut); + const existingJobId = findExistingJobId(jobsPayload); + + if (existingJobId) { + editJob(existingJobId); + process.stdout.write(`Updated cron job ${existingJobId}: ${JOB_NAME}\n`); + } else { + const createdId = addJob(); + if (createdId) { + process.stdout.write(`Created cron job ${createdId}: ${JOB_NAME}\n`); + } else { + process.stdout.write(`Created cron job: ${JOB_NAME}\n`); + } + } + + process.stdout.write(`Schedule: every ${JOB_EVERY}\n`); + process.stdout.write("Session target: main (system event + wake now)\n"); +} + +try { + main(); +} catch (error) { + process.stderr.write(`${String(error)}\n`); + process.exit(1); +} diff --git a/skills/clawsec-suite/scripts/setup_advisory_hook.mjs b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs new file mode 100644 index 0000000..497f5cc --- /dev/null +++ b/skills/clawsec-suite/scripts/setup_advisory_hook.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HOOK_NAME = "clawsec-advisory-guardian"; +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const SUITE_DIR = path.resolve(SCRIPT_DIR, ".."); +const SOURCE_HOOK_DIR = path.join(SUITE_DIR, "hooks", HOOK_NAME); +const HOOKS_ROOT = path.join(os.homedir(), ".openclaw", "hooks"); +const TARGET_HOOK_DIR = path.join(HOOKS_ROOT, HOOK_NAME); + +function sh(cmd, args) { + const result = spawnSync(cmd, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + + if (result.error) { + throw result.error; + } + if (result.status !== 0) { + const details = (result.stderr || result.stdout || "").trim(); + throw new Error(`${cmd} ${args.join(" ")} failed${details ? `: ${details}` : ""}`); + } + + return result.stdout; +} + +function requireOpenClawCli() { + try { + sh("openclaw", ["--version"]); + } catch (error) { + throw new Error( + "openclaw CLI is required. Install OpenClaw and ensure `openclaw` is available in PATH. " + + `Original error: ${String(error)}`, + ); + } +} + +function assertSourceHookExists() { + const requiredFiles = [ + "HOOK.md", + "handler.ts", + "lib/utils.mjs", + "lib/version.mjs", + "lib/feed.mjs", + ]; + for (const file of requiredFiles) { + const fullPath = path.join(SOURCE_HOOK_DIR, file); + if (!fs.existsSync(fullPath)) { + throw new Error(`Missing required hook file: ${fullPath}`); + } + } +} + +function installHookFiles() { + fs.mkdirSync(HOOKS_ROOT, { recursive: true }); + fs.rmSync(TARGET_HOOK_DIR, { recursive: true, force: true }); + fs.cpSync(SOURCE_HOOK_DIR, TARGET_HOOK_DIR, { recursive: true }); +} + +function enableHook() { + sh("openclaw", ["hooks", "enable", HOOK_NAME]); +} + +function main() { + assertSourceHookExists(); + requireOpenClawCli(); + installHookFiles(); + enableHook(); + + process.stdout.write(`Installed hook files to: ${TARGET_HOOK_DIR}\n`); + process.stdout.write(`Enabled hook: ${HOOK_NAME}\n`); + process.stdout.write("Restart your OpenClaw gateway process so the hook is loaded.\n"); + process.stdout.write("After restart, run /new once to trigger an immediate advisory scan.\n"); +} + +try { + main(); +} catch (error) { + process.stderr.write(`${String(error)}\n`); + process.exit(1); +} diff --git a/skills/clawsec-suite/skill.json b/skills/clawsec-suite/skill.json index f9d6362..993e40d 100644 --- a/skills/clawsec-suite/skill.json +++ b/skills/clawsec-suite/skill.json @@ -1,7 +1,7 @@ { "name": "clawsec-suite", - "version": "0.0.6", - "description": "Use when users want to explore or set up ClawSec security protections - this is a wrapper that helps discover and install individual security skills, but requires initial configuration to activate any protections.", + "version": "0.0.8", + "description": "ClawSec suite manager with embedded advisory-feed monitoring, approval-gated malicious-skill response, and guided setup for additional security skills.", "author": "prompt-security", "license": "MIT", "homepage": "https://clawsec.prompt.security/", @@ -11,9 +11,13 @@ "catalog", "installer", "integrity", + "advisory", + "feed", + "threat-intel", + "hooks", + "approval", "agents", "ai", - "guardian", "suite", "openclaw" ], @@ -27,53 +31,81 @@ { "path": "HEARTBEAT.md", "required": true, - "description": "update checks and integrity verification" + "description": "Portable heartbeat and update-check procedure" }, { - "path": "bundled/clawsec-feed/skill.json", + "path": "advisories/feed.json", "required": true, - "description": "Bundled feed metadata" + "description": "Embedded advisory feed seed (merged from clawsec-feed)" }, { - "path": "bundled/clawsec-feed/SKILL.md", + "path": "hooks/clawsec-advisory-guardian/HOOK.md", "required": true, - "description": "Bundled feed documentation" + "description": "OpenClaw hook metadata for advisory-driven malicious-skill checks" }, { - "path": "bundled/clawsec-feed/advisories/feed.json", + "path": "hooks/clawsec-advisory-guardian/handler.ts", "required": true, - "description": "Bundled security advisory feed data" + "description": "OpenClaw hook handler for approval-gated advisory actions" }, { - "path": "bundled/openclaw-audit-watchdog/skill.json", + "path": "hooks/clawsec-advisory-guardian/lib/utils.mjs", "required": true, - "description": "Bundled audit watchdog metadata" + "description": "Shared utility functions (isObject, normalizeSkillName, uniqueStrings)" }, { - "path": "bundled/openclaw-audit-watchdog/SKILL.md", + "path": "hooks/clawsec-advisory-guardian/lib/version.mjs", "required": true, - "description": "Bundled audit watchdog documentation" + "description": "Shared semver parsing and version matching logic" }, { - "path": "bundled/soul-guardian/skill.json", + "path": "hooks/clawsec-advisory-guardian/lib/feed.mjs", "required": true, - "description": "Bundled soul guardian metadata" + "description": "Shared advisory feed loading and validation" }, { - "path": "bundled/soul-guardian/SKILL.md", + "path": "scripts/setup_advisory_hook.mjs", "required": true, - "description": "Bundled soul guardian documentation" + "description": "Installer script for enabling the advisory guardian hook" + }, + { + "path": "scripts/setup_advisory_cron.mjs", + "required": true, + "description": "Installer script for optional periodic advisory scan cron" + }, + { + "path": "scripts/guarded_skill_install.mjs", + "required": true, + "description": "Two-step confirmation installer that blocks risky skill installs until explicit second approval" } ] }, + "embedded_components": { + "clawsec-feed": { + "source_skill": "clawsec-feed", + "source_version": "0.0.4", + "paths": [ + "advisories/feed.json" + ], + "capabilities": [ + "advisory-feed monitoring", + "new-advisory detection", + "affected-skill cross-reference", + "approval-gated malicious-skill removal recommendations", + "double-confirmation gating for risky skill installs" + ], + "standalone_available": true, + "deprecation_plan": "standalone skill may be retired after suite migration is verified" + } + }, "catalog": { - "description": "Available skills in the ClawSec security suite", - "base_url": "https://ClawSec.prompt.security/releases/download", + "description": "Available protections in the ClawSec suite", + "base_url": "https://clawsec.prompt.security/releases/download", "skills": { "clawsec-feed": { - "description": "Security advisory feed monitoring", - "default_install": true, - "required": true, + "description": "Advisory monitoring is now embedded in clawsec-suite", + "integrated_in_suite": true, + "standalone_available": true, "compatible": [ "openclaw", "moltbot", @@ -81,6 +113,16 @@ "other" ] }, + "openclaw-audit-watchdog": { + "description": "Automated daily audits with email reporting", + "default_install": true, + "compatible": [ + "openclaw", + "moltbot", + "clawdbot" + ], + "note": "Tailored for OpenClaw/MoltBot family" + }, "soul-guardian": { "description": "Drift detection and file integrity guard", "default_install": false, @@ -92,7 +134,7 @@ ] }, "clawtributor": { - "description": "Community incident reporting (may share anonymized data)", + "description": "Community incident reporting (shares anonymized data)", "default_install": false, "requires_explicit_consent": true, "compatible": [ @@ -101,60 +143,33 @@ "clawdbot", "other" ] - }, - "openclaw-audit-watchdog": { - "description": "Automated daily audits with email reporting", - "default_install": true, - "required": true, - "compatible": [ - "openclaw", - "moltbot", - "clawdbot" - ], - "note": "Tailored for OpenClaw/MoltBot family only" } } }, - "bundled_skills": { - "clawsec-feed": { - "description": "Security advisory feed (bundled for convenient deployment)", - "default": true, - "standalone_available": true, - "rationale": "Provides crucial CVE and threat intelligence information" - }, - "openclaw-audit-watchdog": { - "description": "Daily security audits (bundled for convenient deployment)", - "default": true, - "standalone_available": true, - "rationale": "Provides crucial automated security audit capabilities" - }, - "soul-guardian": { - "description": "File integrity monitoring (bundled for convenient deployment)", - "default": false, - "standalone_available": true, - "rationale": "Provides important file integrity and drift detection" - } - }, "openclaw": { "emoji": "📦", "category": "security", "requires": { "bins": [ "curl", + "jq", "shasum" ] }, "triggers": [ - "install skills", - "install security skills", "clawsec suite", - "skill catalog", + "security suite", + "security advisories", + "malicious skill alert", + "remove malicious skills", + "safe skill install", + "confirm skill install", + "check advisories", + "advisory feed", + "install security skills", "verify skills", "check skill integrity", - "update skills", - "list available skills", - "install clawsec", - "security suite" + "update skills" ] } } diff --git a/types.ts b/types.ts index f11b79b..5463d0d 100644 --- a/types.ts +++ b/types.ts @@ -16,11 +16,23 @@ export interface FeedItem { description: string; } +export type AdvisoryType = + | 'malicious_skill' + | 'vulnerable_skill' + | 'prompt_injection' + | 'attack_pattern' + | 'best_practice' + | 'tampering_attempt' + // NVD CVE advisories use normalized weakness names (for example: + // "missing_authentication_for_critical_function", "os_command_injection"). + // Keep this open for new categories without requiring type updates. + | string; + // Full advisory type from NVD CVE feed or community reports export interface Advisory { id: string; severity: 'low' | 'medium' | 'high' | 'critical'; - type: 'malicious_skill' | 'vulnerable_skill' | 'prompt_injection' | 'attack_pattern' | 'best_practice' | 'tampering_attempt'; + type: AdvisoryType; title: string; description: string; affected?: string[]; @@ -107,4 +119,4 @@ export interface SkillJson { }; triggers: string[]; }; -} \ No newline at end of file +}