Root cause
Injection & unsafe execution introduced
feat: ZenClaw Discord Integration Infrastructure
AI-assisted change: ChatGPT/Codex
How AI contributed: Direct introductionPublished Mar 20, 2026 in
shadd0wtaka/zen-ai-pentest
Critical CWE-78 shell injection in GitHub Actions workflow .github/workflows/zenclaw-discord.yml (ZenClaw Discord Integration, 'Prepare Notification' step, issues:[opened] trigger). The issue title is interpolated directly into a bash run block: DESCRIPTION="${{ github.event.issue.title }}" inside a case block. GitHub Actions resolves the template at compile time, embedding the raw (untrusted, zero-privilege) issue title into the script; bash then evaluates $(...) / ... subshells during assignment, before the later tr/cut sanitization, enabling arbitrary command execution and exfiltration of the DISCORD_WEBHOOK_URL secret. Secondary $GITHUB_OUTPUT injection via embedded newlines.
Root cause
feat: ZenClaw Discord Integration Infrastructure
AI-assisted change: ChatGPT/Codex
Fix
security(workflows): fix script injection vulnerabilities (CodeQL alerts)
Fix by SHAdd0WTAka · no AI marker found
Case facts
@@ -0,0 +1,61 @@+#!/usr/bin/env python3+"""Validate Discord integration setup for ZenClaw"""++import os+import sys+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))++from github_app_auth import get_installation_token, get_headers, REPO_OWNER, REPO_NAME+import requests++def check_discord_secret():+ """Check if Discord webhook secret exists"""+ print("=" * 60)+ print(" ZENCLAW DISCORD VALIDATION")+ print("=" * 60)+ + # Note: We can't actually read the secret value (that's good!)+ # But we can check if the workflow would find it+ print("\n[1/3] Checking GitHub Secrets...")+ print(" Secret Name: DISCORD_WEBHOOK_URL")+ print(" Status: Must be set manually in GitHub UI")+ print(" URL: https://github.com/SHAdd0WTAka/Zen-Ai-Pentest/settings/secrets/actions")+ print(" [WARN] This cannot be checked via API (security feature)")+ + print("\n[2/3] Checking Workflow File...")+ workflow_path = ".github/workflows/zenclaw-discord.yml"+ if os.path.exists(workflow_path):+ print(f" [OK] Workflow exists: {workflow_path}")+ with open(workflow_path, 'r') as f:+ content = f.read()+ if "DISCORD_WEBHOOK_URL" in content:+ print(" [OK] References DISCORD_WEBHOOK_URL secret")+ if "secrets.DISCORD_WEBHOOK" in content:+ print(" [OK] Uses secrets context (secure)")+ else:+ print(f" [FAIL] Workflow not found: {workflow_path}")+ + print("\n[3/3] Checking Documentation...")+ doc_path = "docs/DISCORD_SETUP_ZENCLAW.md"+ if os.path.exists(doc_path):+ print(f" [OK] Documentation exists: {doc_path}")+ else:+ print(f" [FAIL] Documentation not found: {doc_path}")+ + print("\n" + "=" * 60)+ print(" VALIDATION COMPLETE")+ print("=" * 60)+ + print("\n NEXT STEPS:")+ print("1. Create Discord Webhook (see docs/DISCORD_SETUP_ZENCLAW.md)")+ print("2. Add DISCORD_WEBHOOK_URL to GitHub Secrets")+ print("3. Run workflow test via GitHub Actions")+ print("4. Verify message appears in Discord")+ + print("\n SECURITY REMINDER:")+ print("• Never commit webhook URLs")+ print("• Never log webhook URLs")+ print("• Only store in GitHub Secrets")++if __name__ == "__main__":+ check_discord_secret()@@ -0,0 +1,72 @@+#!/usr/bin/env python3+"""Trigger a GitHub Actions workflow manually"""++import os+import sys+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))++from github_app_auth import get_installation_token, get_headers, REPO_OWNER, REPO_NAME+import requests+import json++def trigger_workflow(workflow_id, branch="main"):+ """Trigger a workflow dispatch event"""+ token = get_installation_token()+ headers = get_headers(token)+ headers["Accept"] = "application/vnd.github.v3+json"+ + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/workflows/{workflow_id}/dispatches"+ + data = {+ "ref": branch+ }+ + response = requests.post(url, headers=headers, json=data)+ + if response.status_code == 204:+ print(f"[OK] Workflow '{workflow_id}' triggered successfully on branch '{branch}'")+ print(f" Check status at: https://github.com/{REPO_OWNER}/{REPO_NAME}/actions")+ return True+ else:+ print(f"[FAIL] Failed to trigger workflow: {response.status_code}")+ print(response.text)+ return False++def list_workflows():+ """List available workflows"""+ token = get_installation_token()+ headers = get_headers(token)+ headers["Accept"] = "application/vnd.github.v3+json"+ + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/workflows"+ + response = requests.get(url, headers=headers)+ + if response.status_code == 200:+ workflows = response.json().get('workflows', [])+ print(f"\nAvailable workflows ({len(workflows)}):")+ print("-" * 60)+ for wf in workflows:+ state = "✓" if wf.get('state') == "active" else "✗"+ print(f"{state} {wf.get('name')}")+ print(f" ID: {wf.get('id')}")+ print(f" File: {wf.get('path', '').replace('.github/workflows/', '')}")+ print()+ else:+ print(f"Failed to list workflows: {response.status_code}")++if __name__ == "__main__":+ import argparse+ parser = argparse.ArgumentParser()+ parser.add_argument("workflow", nargs="?", help="Workflow ID or filename")+ parser.add_argument("--branch", default="main", help="Branch to run on")+ parser.add_argument("--list", action="store_true", help="List workflows")+ args = parser.parse_args()+ + if args.list:+ list_workflows()+ elif args.workflow:+ trigger_workflow(args.workflow, args.branch)+ else:+ # Default: trigger coverage workflow+ trigger_workflow("coverage.yml", "main")@@ -0,0 +1,31 @@+#!/usr/bin/env python3+"""Trigger workflow by ID"""++import os+import sys+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))++from github_app_auth import get_installation_token, get_headers, REPO_OWNER, REPO_NAME+import requests++def trigger_by_id(workflow_id, branch="main"):+ """Trigger workflow using ID"""+ token = get_installation_token()+ headers = get_headers(token)+ headers["Accept"] = "application/vnd.github.v3+json"+ + url = f"https://api.github.com/repos/{REPO_OWNER}/{REPO_NAME}/actions/workflows/{workflow_id}/dispatches"+ + data = {"ref": branch}+ + response = requests.post(url, headers=headers, json=data)+ + print(f"Status: {response.status_code}")+ if response.status_code == 204:+ print(f"[OK] Workflow {workflow_id} triggered!")+ else:+ print(f"Response: {response.text}")++if __name__ == "__main__":+ # Coverage workflow ID from earlier+ trigger_by_id(229137469, "main")@@ -68,6 +68,13 @@ def list_secrets() -> list: return secrets +def mask_sensitive_data(value: str) -> str:+ """Mask sensitive data for logging - only show first/last 4 chars"""+ if not value or len(value) <= 8:+ return "***"+ return f"{value[:4]}...{value[-4:]}"++ def main(): """MCP Server main loop""" print("Obsidian MCP Server started", file=os.sys.stderr)@@ -83,7 +90,13 @@ def main(): name = params.get("name") value = get_secret(name) if value:+ # Return full value to client, but log masked version response = {"result": value, "error": None}+ # Log to stderr (not stdout which is MCP protocol)+ print(+ f"Secret '{name}' retrieved (masked: {mask_sensitive_data(value)})",+ file=os.sys.stderr+ ) else: msg = f"Secret '{name}' not found" response = {"result": None, "error": msg}@@ -106,6 +119,8 @@ def main(): else: response = {"error": f"Unknown method: {method}"} + # Output JSON response to stdout (MCP protocol)+ # Note: Secret values are masked in logs but returned full to client print(json.dumps(response)) except EOFError:@@ -42,8 +42,10 @@ jobs: - name: Check Discord Webhook Secret id: check-secret+ env:+ DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} run: |- if [ -n "${{ secrets.DISCORD_WEBHOOK_URL }}" ]; then+ if [ -n "$DISCORD_WEBHOOK" ]; then echo "has_secret=true" >> $GITHUB_OUTPUT echo "✅ Discord webhook secret is configured" else@@ -60,8 +62,22 @@ jobs: - name: Prepare Notification id: prep if: steps.check-secret.outputs.has_secret == 'true'+ # SECURITY: All github.event values are passed via env to prevent script injection+ # See: https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable+ env:+ EVENT_NAME: ${{ github.event_name }}+ # Push event+ COMMIT_MESSAGE: ${{ github.event.head_commit.message }}+ # Issues event+ ISSUE_ACTION: ${{ github.event.action }}+ ISSUE_TITLE: ${{ github.event.issue.title }}+ # Workflow run event+ WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}+ WORKFLOW_NAME: ${{ github.event.workflow_run.name }}+ # Workflow dispatch event+ INPUT_MESSAGE: ${{ github.event.inputs.message }} run: |- EVENT="${{ github.event_name }}"+ EVENT="$EVENT_NAME" COLOR="3447003" TITLE="ZenClaw Alert" DESCRIPTION=""@@ -70,32 +86,32 @@ jobs: push) COLOR="3066993" TITLE="🚀 Push to main"- DESCRIPTION="${{ github.event.head_commit.message }}"+ DESCRIPTION="$COMMIT_MESSAGE" ;; issues)- if [ "${{ github.event.action }}" == "opened" ]; then+ if [ "$ISSUE_ACTION" == "opened" ]; then COLOR="15158332" TITLE="🐛 New Issue" else COLOR="3066993" TITLE="✅ Issue Resolved" fi- DESCRIPTION="${{ github.event.issue.title }}"+ DESCRIPTION="$ISSUE_TITLE" ;; workflow_run)- if [ "${{ github.event.workflow_run.conclusion }}" == "success" ]; then+ if [ "$WORKFLOW_CONCLUSION" == "success" ]; then COLOR="3066993" TITLE="✅ Workflow Success" else COLOR="15158332" TITLE="❌ Workflow Failed" fi- DESCRIPTION="${{ github.event.workflow_run.name }}"+ DESCRIPTION="$WORKFLOW_NAME" ;; workflow_dispatch) COLOR="3447003" TITLE="🧪 Test Message"- DESCRIPTION="${{ github.event.inputs.message }}"+ DESCRIPTION="$INPUT_MESSAGE" ;; esac @@ -163,10 +179,14 @@ jobs: - name: Summary if: always()+ env:+ PREP_EVENT: ${{ steps.prep.outputs.event }}+ HAS_SECRET: ${{ steps.check-secret.outputs.has_secret }}+ EVENT_NAME: ${{ github.event_name }} run: | echo "## ZenClaw Discord Notification" >> $GITHUB_STEP_SUMMARY- echo "Event: ${{ steps.prep.outputs.event || github.event_name }}" >> $GITHUB_STEP_SUMMARY- if [ "${{ steps.check-secret.outputs.has_secret }}" == "true" ]; then+ echo "Event: ${PREP_EVENT:-$EVENT_NAME}" >> $GITHUB_STEP_SUMMARY+ if [ "$HAS_SECRET" == "true" ]; then echo "Status: Webhook configured" >> $GITHUB_STEP_SUMMARY@@ -36,63 +36,71 @@ jobs: env: TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }} TELEGRAM_CHAT_ID: ${{ secrets.TELEGRAM_CHAT_ID }}+ # SECURITY: All event data passed via env to prevent script injection+ EVENT_NAME: ${{ github.event_name }}+ # Workflow run event+ WORKFLOW_NAME: ${{ github.event.workflow_run.name }}+ WORKFLOW_CONCLUSION: ${{ github.event.workflow_run.conclusion }}+ WORKFLOW_BRANCH: ${{ github.event.workflow_run.head_branch }}+ WORKFLOW_URL: ${{ github.event.workflow_run.html_url }}+ # Issues event+ ISSUE_TITLE: ${{ github.event.issue.title }}+ ISSUE_URL: ${{ github.event.issue.html_url }}+ ISSUE_ACTION: ${{ github.event.action }}+ # PR event+ PR_TITLE: ${{ github.event.pull_request.title }}+ PR_URL: ${{ github.event.pull_request.html_url }}+ PR_ACTION: ${{ github.event.action }}+ # Workflow dispatch+ INPUT_MESSAGE: ${{ github.event.inputs.message }}+ # Common+ REPO: ${{ github.repository }}+ ACTOR: ${{ github.actor }}+ REF_NAME: ${{ github.ref_name }} steps: - name: Send Telegram Notification if: ${{ env.TELEGRAM_BOT_TOKEN != '' && env.TELEGRAM_CHAT_ID != '' }} run: | # Determine notification type and message- if [ "${{ github.event_name }}" == "workflow_run" ]; then- WORKFLOW_NAME="${{ github.event.workflow_run.name }}"- CONCLUSION="${{ github.event.workflow_run.conclusion }}"- BRANCH="${{ github.event.workflow_run.head_branch }}"- URL="${{ github.event.workflow_run.html_url }}"- - if [ "$CONCLUSION" == "success" ]; then+ if [ "$EVENT_NAME" == "workflow_run" ]; then+ if [ "$WORKFLOW_CONCLUSION" == "success" ]; then STATUS="✅ SUCCESS"- elif [ "$CONCLUSION" == "failure" ]; then+ elif [ "$WORKFLOW_CONCLUSION" == "failure" ]; then STATUS="❌ FAILED" else- STATUS="⚠️ $CONCLUSION"+ STATUS="⚠️ $WORKFLOW_CONCLUSION" fi - MESSAGE="<b>Workflow $STATUS</b> - Name: $WORKFLOW_NAME, Branch: $BRANCH, Repo: ${{ github.repository }} - $URL"+ MESSAGE="<b>Workflow $STATUS</b> - Name: $WORKFLOW_NAME, Branch: $WORKFLOW_BRANCH, Repo: $REPO - $WORKFLOW_URL" - elif [ "${{ github.event_name }}" == "issues" ]; then- ISSUE_TITLE="${{ github.event.issue.title }}"- ISSUE_URL="${{ github.event.issue.html_url }}"- ACTION="${{ github.event.action }}"- - if [ "$ACTION" == "opened" ]; then+ elif [ "$EVENT_NAME" == "issues" ]; then+ if [ "$ISSUE_ACTION" == "opened" ]; then ICON="📋"- elif [ "$ACTION" == "closed" ]; then+ elif [ "$ISSUE_ACTION" == "closed" ]; then ICON="✅" else ICON="📝" fi - MESSAGE="<b>$ICON Issue $ACTION</b> - $ISSUE_TITLE - ${{ github.repository }} - $ISSUE_URL"+ MESSAGE="<b>$ICON Issue $ISSUE_ACTION</b> - $ISSUE_TITLE - $REPO - $ISSUE_URL" - elif [ "${{ github.event_name }}" == "pull_request" ]; then- PR_TITLE="${{ github.event.pull_request.title }}"- PR_URL="${{ github.event.pull_request.html_url }}"- ACTION="${{ github.event.action }}"- - if [ "$ACTION" == "opened" ]; then+ elif [ "$EVENT_NAME" == "pull_request" ]; then+ if [ "$PR_ACTION" == "opened" ]; then ICON="🔀"- elif [ "$ACTION" == "closed" ]; then+ elif [ "$PR_ACTION" == "closed" ]; then ICON="✅" else ICON="📝" fi - MESSAGE="<b>$ICON PR $ACTION</b> - $PR_TITLE - ${{ github.repository }} - $PR_URL"+ MESSAGE="<b>$ICON PR $PR_ACTION</b> - $PR_TITLE - $REPO - $PR_URL" AI-assisted change 81da6aa66df54f3fc0c4f275e6236284b182ff878cd812bfe68563521c404237 · Fix 30aac68137de993d86a012e198befdc5a73db4ea15da9167ed43b9bce09991e9