diff --git a/.clawpinch-ignore.json.example b/.clawpinch-ignore.json.example new file mode 100644 index 0000000..fe052a3 --- /dev/null +++ b/.clawpinch-ignore.json.example @@ -0,0 +1,53 @@ +{ + "$schema": "https://clawpinch.dev/schemas/ignore.json", + "_comment": "This file configures finding suppression for ClawPinch security scans", + "_documentation": { + "purpose": "Suppress accepted-risk findings to prevent CI/CD pipeline failures", + "location": "Copy this file to .clawpinch-ignore.json in your project root", + "fields": { + "id": "(required) Check ID to suppress (e.g., CHK-CFG-001)", + "reason": "(required) Justification for suppressing this finding", + "expires": "(optional) ISO 8601 timestamp when suppression expires and finding reactivates", + "suppressed_by": "(optional) Email or identifier of person who approved suppression", + "suppressed_at": "(optional) ISO 8601 timestamp when suppression was created" + }, + "behavior": { + "suppressed_findings": "Moved to 'suppressed' array in JSON output, excluded from severity counts", + "expired_suppressions": "Automatically reactivated if expires date is in the past", + "flags": { + "--show-suppressed": "Include suppressed findings in output with [SUPPRESSED] marker", + "--no-ignore": "Disable all suppressions for full audit scans" + } + } + }, + "suppressions": [ + { + "id": "CHK-CFG-001", + "reason": "Dev environment - open gateway is intentional for local testing", + "expires": "2025-12-31T23:59:59Z", + "suppressed_by": "devops@example.com", + "suppressed_at": "2024-01-15T10:30:00Z" + }, + { + "id": "CHK-SEC-003", + "reason": "Test API key in example config - not used in production deployments" + }, + { + "id": "CHK-NET-004", + "reason": "WebSocket endpoint exposed for real-time collaboration feature", + "expires": "2025-06-30T00:00:00Z", + "suppressed_by": "security-team@example.com" + }, + { + "id": "CHK-PRM-002", + "reason": "Wildcard permission required for dynamic skill loader - reviewed by security", + "suppressed_by": "ciso@example.com", + "suppressed_at": "2024-02-20T14:30:00Z" + }, + { + "id": "CHK-CRN-001", + "reason": "Cron job needs elevated permissions for system maintenance tasks", + "expires": "2025-03-01T00:00:00Z" + } + ] +} diff --git a/CLAUDE.md b/CLAUDE.md index d0f911a..29989c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,6 +16,12 @@ bash clawpinch.sh --remediate # Deep scan bash clawpinch.sh --deep + +# Show suppressed findings +bash clawpinch.sh --show-suppressed + +# Disable all suppressions (full audit) +bash clawpinch.sh --no-ignore ``` ## Architecture @@ -32,6 +38,7 @@ clawpinch/ │ │ ├── report.sh # Terminal UI: banner, finding cards, summary dashboard, spinner │ │ ├── redact.sh # Secret redaction: redact_value(), redact_line(), redact_json_secrets() │ │ ├── safe_exec.sh # Safe command execution: whitelist-based validation, replaces eval() +│ │ ├── suppression.sh # Finding suppression: load_suppressions(), filter_findings(), expiration handling │ │ └── interactive.sh # Post-scan menu: review, auto-fix, handoff export, AI remediation │ ├── scan_config.sh # CHK-CFG-001..010 — gateway, TLS, auth, CORS │ ├── scan_secrets.py # CHK-SEC-001..008 — API keys, passwords, tokens @@ -46,6 +53,7 @@ clawpinch/ │ ├── malicious-patterns.json # Known bad skill hashes │ ├── check-catalog.md # Full check documentation │ └── threat-model.md # Threat model for OpenClaw +├── .clawpinch-ignore.json.example # Example suppression config with documentation ├── package.json # npm package metadata ├── SKILL.md # AI-readable skill documentation ├── CLAUDE.md # This file — project context for Claude Code @@ -72,6 +80,19 @@ Every scanner emits findings via `emit_finding()` from `common.sh`: Severity order: `critical` > `warn` > `info` > `ok`. +### Output Format with Suppressions + +When suppressions are enabled (via `.clawpinch-ignore.json`), the JSON output contains two arrays: + +```json +{ + "findings": [ /* active findings only */ ], + "suppressed": [ /* suppressed findings */ ] +} +``` + +Suppressed findings do not count toward severity totals or exit codes. Use `--show-suppressed` to include them in terminal output (marked with `[SUPPRESSED]`), or `--no-ignore` to disable all suppressions for full audits. + ## How to Add a New Check 1. Choose the appropriate scanner file in `scripts/` (or create a new `scan_*.sh`) @@ -80,6 +101,39 @@ Severity order: `critical` > `warn` > `info` > `ok`. 4. Call `emit_finding "CHK-XXX-NNN" "severity" "title" "description" "evidence" "remediation" "auto_fix"` 5. Add the check to `references/check-catalog.md` and `SKILL.md` category table +## Finding Suppression + +Findings can be suppressed by creating a `.clawpinch-ignore.json` file in the project root: + +```json +{ + "suppressions": [ + { + "id": "CHK-CFG-001", + "reason": "Dev environment - open gateway is intentional", + "expires": "2025-12-31T23:59:59Z", + "suppressed_by": "devops@example.com", + "suppressed_at": "2024-01-15T10:30:00Z" + } + ] +} +``` + +**Behavior:** +- Suppressed findings move to `suppressed` array in JSON output +- Suppressed findings do not count toward severity totals or exit codes +- Expired suppressions (past `expires` date) automatically reactivate +- `--show-suppressed` includes suppressed findings in output with `[SUPPRESSED]` marker +- `--no-ignore` disables all suppressions for full audit scans + +**Use cases:** +- Accepted risks in development environments +- Findings under gradual remediation with expiration tracking +- Security-reviewed exceptions with documented justifications +- CI/CD pipelines that fail on active findings only + +See `.clawpinch-ignore.json.example` for a fully documented template. + ## Conventions - All scanners output a JSON array to stdout diff --git a/README.md b/README.md index 6f29957..5209af3 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,12 @@ bash clawpinch.sh --config-dir /path/to/openclaw/config # Print auto-fix commands (read-only -- does not execute them) bash clawpinch.sh --fix + +# Show suppressed findings in output +bash clawpinch.sh --show-suppressed + +# Disable all suppressions for full audit +bash clawpinch.sh --no-ignore ``` --- @@ -241,6 +247,128 @@ bash clawpinch.sh --sequential --- +## Suppressing Findings + +ClawPinch allows you to suppress specific findings by check ID -- useful for accepted risks in development environments or findings that have been reviewed and approved by your security team. Suppressed findings are still scanned but reported separately and excluded from severity counts and exit codes. + +### Creating a Suppression File + +Create a `.clawpinch-ignore.json` file in your project root: + +```json +{ + "suppressions": [ + { + "id": "CHK-CFG-001", + "reason": "Dev environment - open gateway is intentional for local testing" + }, + { + "id": "CHK-SEC-003", + "reason": "Test API key in example config - not used in production", + "expires": "2025-12-31T23:59:59Z", + "suppressed_by": "security-team@example.com" + } + ] +} +``` + +### Suppression Fields + +- **`id`** (required) -- The check ID to suppress (e.g., `CHK-CFG-001`, `CHK-NET-004`) +- **`reason`** (required) -- Justification for why this finding is being suppressed +- **`expires`** (optional) -- ISO 8601 timestamp when the suppression expires and the finding reactivates +- **`suppressed_by`** (optional) -- Email or identifier of the person who approved the suppression +- **`suppressed_at`** (optional) -- ISO 8601 timestamp when the suppression was created + +### How Suppressions Work + +1. **Automatic Filtering** -- Suppressed findings are moved from the main `findings` array to a separate `suppressed` array in JSON output +2. **Severity Exclusion** -- Suppressed findings do not count toward severity totals or affect exit codes +3. **Expiration** -- If a suppression has an `expires` date in the past, it is automatically reactivated and the finding appears in normal output +4. **Visibility** -- Use `--show-suppressed` to include suppressed findings in output (marked with `[SUPPRESSED]`) +5. **Audit Mode** -- Use `--no-ignore` to disable all suppressions for full audit scans + +### Example Workflow + +```bash +# 1. Run initial scan and identify accepted risks +npx clawpinch --json > findings.json + +# 2. Create .clawpinch-ignore.json with suppressions +cat > .clawpinch-ignore.json <= 4.0, `jq` @@ -139,5 +197,7 @@ npx clawpinch --quiet --no-interactive | Code | Meaning | |------|---------| -| 0 | No critical findings | -| 1 | One or more critical findings detected | +| 0 | No active critical findings (suppressed findings excluded) | +| 1 | One or more active critical findings detected | + +**Note:** Suppressed findings do not affect exit codes. Use `--no-ignore` to include suppressed findings in exit code calculation. diff --git a/clawpinch.sh b/clawpinch.sh index 3f45e26..a28c249 100755 --- a/clawpinch.sh +++ b/clawpinch.sh @@ -14,6 +14,7 @@ source "$HELPERS_DIR/common.sh" source "$HELPERS_DIR/report.sh" source "$HELPERS_DIR/redact.sh" source "$HELPERS_DIR/interactive.sh" +source "$HELPERS_DIR/suppression.sh" # ─── Signal trap for animation cleanup ────────────────────────────────────── @@ -29,6 +30,11 @@ NO_INTERACTIVE=0 REMEDIATE=0 PARALLEL_SCANNERS=1 CONFIG_DIR="" +SHOW_SUPPRESSED=0 +NO_IGNORE=0 + +# Reusable jq severity ordering function (used in sort and merge operations) +JQ_SEV_ORDER_FUNC='def sev_order: if . == "critical" then 0 elif . == "warn" then 1 elif . == "info" then 2 elif . == "ok" then 3 else 4 end;' # ─── Usage ─────────────────────────────────────────────────────────────────── @@ -45,6 +51,8 @@ Options: --no-interactive Disable interactive post-scan menu --remediate Run scan then pipe findings to Claude for AI remediation --config-dir PATH Explicit path to openclaw config directory + --show-suppressed Include suppressed findings in normal output + --no-ignore Disable all suppressions for full audit scan -h, --help Show this help message Exit codes: @@ -65,6 +73,8 @@ while [[ $# -gt 0 ]]; do --sequential) PARALLEL_SCANNERS=0; shift ;; --no-interactive) NO_INTERACTIVE=1; shift ;; --remediate) REMEDIATE=1; NO_INTERACTIVE=1; shift ;; + --show-suppressed) SHOW_SUPPRESSED=1; shift ;; + --no-ignore) NO_IGNORE=1; shift ;; --config-dir) if [[ -z "${2:-}" ]]; then log_error "--config-dir requires a path argument" @@ -86,6 +96,8 @@ done export CLAWPINCH_DEEP="$DEEP" export CLAWPINCH_SHOW_FIX="$SHOW_FIX" export CLAWPINCH_CONFIG_DIR="$CONFIG_DIR" +export CLAWPINCH_SHOW_SUPPRESSED="$SHOW_SUPPRESSED" +export CLAWPINCH_NO_IGNORE="$NO_IGNORE" export QUIET # ─── Validate security config (early check for --remediate) ────────────────── @@ -332,29 +344,52 @@ fi # ─── Sort findings by severity ─────────────────────────────────────────────── # Order: critical > warn > info > ok -SORTED_FINDINGS="$(echo "$ALL_FINDINGS" | jq ' - def sev_order: - if . == "critical" then 0 - elif . == "warn" then 1 - elif . == "info" then 2 - elif . == "ok" then 3 - else 4 - end; - sort_by(.severity | sev_order) -')" +SORTED_FINDINGS="$(echo "$ALL_FINDINGS" | jq "${JQ_SEV_ORDER_FUNC} sort_by(.severity | sev_order)")" + +# ─── Apply suppression filtering ───────────────────────────────────────────── + +ACTIVE_FINDINGS="$SORTED_FINDINGS" +SUPPRESSED_FINDINGS="[]" + +# Apply filtering unless --no-ignore is set +if [[ "$NO_IGNORE" -eq 0 ]]; then + # Look for .clawpinch-ignore.json in the OpenClaw config directory or current directory + ignore_file=".clawpinch-ignore.json" + if [[ -n "$OPENCLAW_CONFIG" ]] && [[ -f "$(dirname "$OPENCLAW_CONFIG")/.clawpinch-ignore.json" ]]; then + ignore_file="$(dirname "$OPENCLAW_CONFIG")/.clawpinch-ignore.json" + fi + + # Filter findings into active and suppressed + if [[ -f "$ignore_file" ]]; then + filtered_result="$(echo "$SORTED_FINDINGS" | filter_findings "$ignore_file")" + ACTIVE_FINDINGS="$(echo "$filtered_result" | jq -c '.active')" + SUPPRESSED_FINDINGS="$(echo "$filtered_result" | jq -c '.suppressed')" + fi +fi + +# For --show-suppressed mode, merge suppressed back into active for display +DISPLAY_FINDINGS="$ACTIVE_FINDINGS" +if [[ "$SHOW_SUPPRESSED" -eq 1 ]]; then + # Mark suppressed findings with a "suppressed": true field before merging + MARKED_SUPPRESSED="$(echo "$SUPPRESSED_FINDINGS" | jq '[.[] | . + {suppressed: true}]')" + DISPLAY_FINDINGS="$(echo "$ACTIVE_FINDINGS" "$MARKED_SUPPRESSED" | jq -s "${JQ_SEV_ORDER_FUNC} .[0] + .[1] | sort_by(.severity | sev_order)")" +fi # ─── Count by severity ────────────────────────────────────────────────────── +# Count only active findings (not suppressed) for exit code calculation -count_critical="$(echo "$SORTED_FINDINGS" | jq '[.[] | select(.severity == "critical")] | length')" -count_warn="$(echo "$SORTED_FINDINGS" | jq '[.[] | select(.severity == "warn")] | length')" -count_info="$(echo "$SORTED_FINDINGS" | jq '[.[] | select(.severity == "info")] | length')" -count_ok="$(echo "$SORTED_FINDINGS" | jq '[.[] | select(.severity == "ok")] | length')" +count_critical="$(echo "$ACTIVE_FINDINGS" | jq '[.[] | select(.severity == "critical")] | length')" +count_warn="$(echo "$ACTIVE_FINDINGS" | jq '[.[] | select(.severity == "warn")] | length')" +count_info="$(echo "$ACTIVE_FINDINGS" | jq '[.[] | select(.severity == "info")] | length')" +count_ok="$(echo "$ACTIVE_FINDINGS" | jq '[.[] | select(.severity == "ok")] | length')" +count_suppressed="$(echo "$SUPPRESSED_FINDINGS" | jq 'length')" # ─── Output ────────────────────────────────────────────────────────────────── if [[ "$JSON_OUTPUT" -eq 1 ]]; then - # Pure JSON output (compact for piping efficiency) - echo "$SORTED_FINDINGS" | jq -c . + # Pure JSON output with findings and suppressed arrays + jq -n -c --argjson findings "$ACTIVE_FINDINGS" --argjson suppressed "$SUPPRESSED_FINDINGS" \ + '{findings: $findings, suppressed: $suppressed}' else if [[ "$QUIET" -eq 0 ]]; then # Determine if interactive mode is available @@ -365,13 +400,13 @@ else if [[ "$_is_interactive" -eq 1 ]]; then # Compact grouped table (new v1.1 display) - print_findings_compact "$SORTED_FINDINGS" + print_findings_compact "$DISPLAY_FINDINGS" else # Non-interactive fallback: full card display (v1.0 behavior) - total="$(echo "$SORTED_FINDINGS" | jq 'length')" + total="$(echo "$DISPLAY_FINDINGS" | jq 'length')" if (( total > 0 )); then for i in $(seq 0 $((total - 1))); do - finding="$(echo "$SORTED_FINDINGS" | jq -c ".[$i]")" + finding="$(echo "$DISPLAY_FINDINGS" | jq -c ".[$i]")" print_finding "$finding" done else @@ -381,14 +416,14 @@ else fi # Always print summary dashboard (animated when TTY) - print_summary_animated "$count_critical" "$count_warn" "$count_info" "$count_ok" "$scanner_count" "$_scan_elapsed" + print_summary_animated "$count_critical" "$count_warn" "$count_info" "$count_ok" "$scanner_count" "$_scan_elapsed" "$count_suppressed" # Contextual completion message print_completion_message "$count_critical" "$count_warn" # Launch interactive menu if TTY and not disabled if [[ "$QUIET" -eq 0 ]] && [[ "$NO_INTERACTIVE" -eq 0 ]] && [[ -t 0 ]]; then - interactive_menu "$SORTED_FINDINGS" "$scanner_count" "$_scan_elapsed" + interactive_menu "$DISPLAY_FINDINGS" "$scanner_count" "$_scan_elapsed" fi # ─── AI Remediation pipeline ───────────────────────────────────────────── @@ -405,7 +440,7 @@ else if [[ -z "$_claude_bin" ]]; then log_error "Claude CLI not found. Install it or set CLAWPINCH_CLAUDE_BIN." else - _non_ok_findings="$(echo "$SORTED_FINDINGS" | jq -c '[.[] | select(.severity != "ok")]')" + _non_ok_findings="$(echo "$ACTIVE_FINDINGS" | jq -c '[.[] | select(.severity != "ok")]')" _non_ok_count="$(echo "$_non_ok_findings" | jq 'length')" if (( _non_ok_count > 0 )); then diff --git a/scripts/helpers/interactive.sh b/scripts/helpers/interactive.sh index 1089b5e..c48e756 100644 --- a/scripts/helpers/interactive.sh +++ b/scripts/helpers/interactive.sh @@ -233,10 +233,16 @@ print_findings_compact() { for (( i=0; i 0) + if (( suppressed > 0 )); then + local supp_text + supp_text="$(printf 'Suppressed: %d findings' "$suppressed")" + local supp_len=${#supp_text} + local supp_lpad=2 + local supp_rpad=$(( inner - supp_lpad - supp_len )) + if (( supp_rpad < 0 )); then supp_rpad=0; fi + printf " %b%s%b " "$_CLR_BOX" "$_HBOX_V" "$_CLR_RST" + printf '%*s%b%s%b' "$supp_lpad" '' "$_CLR_DIM" "$supp_text" "$_CLR_RST" + printf '%*s' "$supp_rpad" '' + printf " %b%s%b\n" "$_CLR_BOX" "$_HBOX_V" "$_CLR_RST" + fi + # Empty line printf " %b%s%b " "$_CLR_BOX" "$_HBOX_V" "$_CLR_RST" printf '%*s' "$inner" '' diff --git a/scripts/helpers/suppression.sh b/scripts/helpers/suppression.sh new file mode 100644 index 0000000..759239a --- /dev/null +++ b/scripts/helpers/suppression.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ─── ClawPinch suppression helpers ────────────────────────────────────────── +# Manage finding suppressions from .clawpinch-ignore.json +# Source this file from the main orchestrator: +# source "$(dirname "$0")/scripts/helpers/suppression.sh" + +# Global variable to store loaded suppressions (JSON array) +_CLAWPINCH_SUPPRESSIONS="[]" + +# ─── Load suppressions from ignore file ───────────────────────────────────── +# Usage: load_suppressions +# Returns: 0 on success (including when file doesn't exist), 1 on invalid JSON + +load_suppressions() { + local ignore_file="${1:-.clawpinch-ignore.json}" + + # Reset suppressions + _CLAWPINCH_SUPPRESSIONS="[]" + + # If file doesn't exist, that's OK - no suppressions to load + if [[ ! -f "$ignore_file" ]]; then + return 0 + fi + + # Require jq for JSON parsing + if ! command -v jq &>/dev/null; then + # Fallback: if jq not available, disable suppressions + # This is a graceful degradation - better than failing the whole scan + return 0 + fi + + # Parse the JSON file and extract suppressions array + local parsed + if ! parsed="$(jq -c '.suppressions // []' "$ignore_file" 2>/dev/null)"; then + # Invalid JSON - log warning and continue with no suppressions + if [[ -n "${_CLR_YLW:-}" ]]; then + printf "${_CLR_YLW}[warn]${_CLR_RST} Invalid JSON in %s - ignoring suppressions\n" "$ignore_file" >&2 + else + printf "[warn] Invalid JSON in %s - ignoring suppressions\n" "$ignore_file" >&2 + fi + return 1 + fi + + # Validate that we got an array + if ! echo "$parsed" | jq -e 'type == "array"' >/dev/null 2>&1; then + if [[ -n "${_CLR_YLW:-}" ]]; then + printf "${_CLR_YLW}[warn]${_CLR_RST} .suppressions is not an array in %s\n" "$ignore_file" >&2 + else + printf "[warn] .suppressions is not an array in %s\n" "$ignore_file" >&2 + fi + return 1 + fi + + _CLAWPINCH_SUPPRESSIONS="$parsed" + return 0 +} + +# ─── Filter findings into active and suppressed arrays ────────────────────── +# Usage: filter_findings < findings.json +# Reads findings JSON array from stdin +# Outputs: {"active": [...], "suppressed": [...]} + +filter_findings() { + local ignore_file="${1:-.clawpinch-ignore.json}" + + # Load suppressions if not already loaded + if [[ "$_CLAWPINCH_SUPPRESSIONS" == "[]" ]] && [[ -f "$ignore_file" ]]; then + load_suppressions "$ignore_file" + fi + + # Require jq + if ! command -v jq &>/dev/null; then + # Fallback: all findings are active + local findings + findings="$(cat)" + printf '{"active": %s, "suppressed": []}\n' "$findings" + return 0 + fi + + # Get current timestamp + local now + now="$(date -u +'%Y-%m-%dT%H:%M:%SZ' 2>/dev/null || true)" + + # Read findings from stdin and filter + local findings + findings="$(cat)" + + # Use jq to split findings into active and suppressed + if [[ -n "$now" ]]; then + # With expiration checking + printf '%s\n' "$findings" | jq -c --argjson suppressions "$_CLAWPINCH_SUPPRESSIONS" --arg now "$now" ' + ($suppressions | map(select(.id != null) | {(.id): .}) | add // {}) as $smap | + reduce .[] as $f ({active: [], suppressed: []}; + $smap[$f.id] as $s | + if $s then + if $s.expires and $s.expires <= $now then + .active += [$f] + else + .suppressed += [$f + {suppression: ($s | del(.id))}] + end + else + .active += [$f] + end + ) + ' + else + # Without expiration checking (treat all as unexpired) + printf '%s\n' "$findings" | jq -c --argjson suppressions "$_CLAWPINCH_SUPPRESSIONS" ' + ($suppressions | map(select(.id != null) | {(.id): .}) | add // {}) as $smap | + reduce .[] as $f ({active: [], suppressed: []}; + $smap[$f.id] as $s | + if $s then + .suppressed += [$f + {suppression: ($s | del(.id))}] + else + .active += [$f] + end + ) + ' + fi +}