@lowwattlabs/clawsec 2.2.1 → 2.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/api/src/routes.js CHANGED
@@ -27,6 +27,34 @@ function sanitizeSlug(slug) {
27
27
  return slug;
28
28
  }
29
29
 
30
+ // Strip execute permissions from all files in a directory tree.
31
+ // Security: downloaded skills are READ ONLY during scanning — never executed.
32
+ function hardenSkillDir(dir) {
33
+ try {
34
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
35
+ for (const entry of entries) {
36
+ const fullPath = path.join(dir, entry.name);
37
+ if (entry.isDirectory()) {
38
+ hardenSkillDir(fullPath);
39
+ } else {
40
+ try {
41
+ const mode = fs.statSync(fullPath).mode;
42
+ // Remove all execute bits (user, group, other)
43
+ fs.chmodSync(fullPath, mode & ~0o111);
44
+ } catch {}
45
+ }
46
+ }
47
+ } catch {}
48
+ }
49
+
50
+ // Create a restricted temp directory for skill downloads.
51
+ // Returns the path to the temp dir.
52
+ function createScanTempDir() {
53
+ const tmpDir = path.join(os.tmpdir(), 'clawsec-scan-' + uuidv4().slice(0, 8));
54
+ fs.mkdirSync(tmpDir, { recursive: true, mode: 0o700 });
55
+ return tmpDir;
56
+ }
57
+
30
58
  router.post('/scan', (req, res) => {
31
59
  const { slug, content, path: reqPath } = req.body;
32
60
 
@@ -44,42 +72,50 @@ router.post('/scan', (req, res) => {
44
72
  if (reqPath && fs.existsSync(reqPath)) {
45
73
  targetDir = reqPath;
46
74
  } else if (slug) {
47
- // P1-4: Validate slug before passing to clawhub install
75
+ // Validate slug before passing to clawhub install
48
76
  const safeSlug = sanitizeSlug(slug);
49
77
  if (!safeSlug) {
50
78
  return res.status(400).json({ error: 'Invalid slug: must be alphanumeric with hyphens/underscores, max 128 chars' });
51
79
  }
52
- // Try to install from ClawHub
53
- const tmpDir = '/tmp/clawsec-scan-' + uuidv4().slice(0, 8);
80
+ // Download from ClawHub into restricted temp directory
81
+ const tmpDir = createScanTempDir();
54
82
  try {
55
- execFileSync('clawhub', ['install', safeSlug, '--dir', tmpDir], {
56
- timeout: 60000, encoding: 'utf8'
83
+ // SECURITY: suppress npm postinstall scripts from the downloaded skill
84
+ execFileSync('clawhub', ['install', safeSlug, '--dir', tmpDir, '--no-input'], {
85
+ timeout: 60000,
86
+ encoding: 'utf8',
87
+ env: { ...process.env, npm_config_ignore_scripts: 'true' }
57
88
  });
58
- // Find the installed skill
89
+ // Find the installed skill directory
59
90
  const dirs = fs.readdirSync(tmpDir);
60
91
  if (dirs.length > 0) {
61
92
  targetDir = path.join(tmpDir, dirs[0]);
62
- cleanup = true;
63
93
  }
94
+ // SECURITY: strip execute permissions from all downloaded files
95
+ if (targetDir) {
96
+ hardenSkillDir(targetDir);
97
+ }
98
+ cleanup = true;
64
99
  } catch (e) {
100
+ // Clean up on failure
101
+ try { fs.rmSync(tmpDir, { recursive: true }); } catch {}
65
102
  return res.status(404).json({ error: 'Skill not found: ' + slug });
66
103
  }
67
104
  } else if (content) {
68
105
  // Write content to temp dir
69
- const tmpDir = '/tmp/clawsec-scan-' + uuidv4().slice(0, 8);
70
- fs.mkdirSync(tmpDir, { recursive: true });
106
+ const tmpDir = createScanTempDir();
71
107
 
72
108
  if (typeof content === 'string') {
73
109
  fs.writeFileSync(path.join(tmpDir, 'SKILL.md'), content);
74
110
  } else if (typeof content === 'object') {
75
111
  // Object with file contents
76
112
  for (const [filename, fileContent] of Object.entries(content)) {
77
- // P0: Sanitize filename against path traversal
113
+ // Sanitize filename against path traversal
78
114
  const safeName = path.basename(filename).replace(/\.\./g, '');
79
115
  if (safeName !== filename || filename.includes('..')) {
80
116
  return res.status(400).json({ error: 'Invalid filename: ' + filename });
81
117
  }
82
- // P0: Ensure resolved path stays within tmpDir
118
+ // Ensure resolved path stays within tmpDir
83
119
  const filePath = path.resolve(tmpDir, filename);
84
120
  if (!filePath.startsWith(path.resolve(tmpDir) + path.sep)) {
85
121
  return res.status(400).json({ error: 'Path traversal in filename: ' + filename });
@@ -136,8 +172,13 @@ router.post('/scan', (req, res) => {
136
172
  res.json({ report_id: reportId, ...result });
137
173
 
138
174
  } finally {
139
- if (cleanup && targetDir && targetDir.startsWith('/tmp/clawsec-scan-')) {
175
+ if (cleanup && targetDir) {
140
176
  try { fs.rmSync(targetDir, { recursive: true }); } catch {}
177
+ // Also try cleaning parent if it's a scan temp dir
178
+ const parentDir = path.dirname(targetDir);
179
+ if (parentDir.includes('clawsec-scan-')) {
180
+ try { fs.rmSync(parentDir, { recursive: true }); } catch {}
181
+ }
141
182
  }
142
183
  }
143
184
  });
@@ -181,4 +222,4 @@ router.get('/status', (req, res) => {
181
222
  res.json(manifest);
182
223
  });
183
224
 
184
- module.exports = router;
225
+ module.exports = router;
package/api/src/server.js CHANGED
@@ -39,7 +39,7 @@ app.use('/api/v1', routes);
39
39
 
40
40
  // Health check
41
41
  app.get('/health', (req, res) => {
42
- res.json({ status: 'ok', version: '2.0.0', uptime: process.uptime() });
42
+ res.json({ status: 'ok', version: '2.3.1', uptime: process.uptime() });
43
43
  });
44
44
 
45
45
  // Error handler
@@ -53,6 +53,6 @@ app.use((err, req, res, next) => {
53
53
 
54
54
  // Start
55
55
  app.listen(PORT, '0.0.0.0', () => {
56
- console.log("⚡ ClawSec API v2.0.0 listening on 0.0.0.0:" + PORT);
56
+ console.log("⚡ ClawSec API v2.3.1 listening on 0.0.0.0:" + PORT);
57
57
  fs.mkdirSync(REPORTS_DIR, { recursive: true });
58
58
  });
package/cli/clawsec.py CHANGED
@@ -15,13 +15,14 @@ import argparse
15
15
  import json
16
16
  import os
17
17
  import shutil
18
+ import stat
18
19
  import subprocess
19
20
  import sys
20
21
  import tempfile
21
22
  import time
22
23
  from pathlib import Path
23
24
 
24
- VERSION = "2.2.0"
25
+ VERSION = "2.3.1"
25
26
  CLAWSEC_DIR = os.environ.get("CLAWSEC_HOME", os.path.expanduser("~/.clawsec"))
26
27
  INTEL_DIR = os.environ.get("CLAWSEC_INTEL_DIR", os.path.join(CLAWSEC_DIR, "intel"))
27
28
  REPORTS_DIR = os.environ.get("CLAWSEC_REPORTS_DIR", os.path.join(CLAWSEC_DIR, "reports"))
@@ -40,6 +41,7 @@ BOLD = "\033[1m"
40
41
  DIM = "\033[2m"
41
42
  RESET = "\033[0m"
42
43
 
44
+
43
45
  def banner():
44
46
  print(f"""{BOLD}
45
47
  ╔═════════════════════════════════════════╗
@@ -48,6 +50,7 @@ def banner():
48
50
  ╚═════════════════════════════════════════╝{RESET}
49
51
  """)
50
52
 
53
+
51
54
  def is_slug(target):
52
55
  """Check if target looks like a ClawHub slug (no path separators, no dots, not a local path)."""
53
56
  if os.path.exists(target):
@@ -59,13 +62,45 @@ def is_slug(target):
59
62
  return False
60
63
  return True
61
64
 
65
+
66
+ def harden_skill_dir(skill_dir):
67
+ """
68
+ Remove execute permissions from all files in the skill directory.
69
+ This prevents any scripts from being executed accidentally before or during scanning.
70
+ We only need to READ these files, never execute them.
71
+ """
72
+ for root, dirs, files in os.walk(skill_dir):
73
+ for f in files:
74
+ fpath = os.path.join(root, f)
75
+ try:
76
+ current_mode = os.stat(fpath).st_mode
77
+ # Remove all execute bits (user, group, other)
78
+ os.chmod(fpath, current_mode & ~(stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH))
79
+ except OSError:
80
+ pass
81
+
82
+
62
83
  def download_slug(slug):
63
- """Download a skill from ClawHub by slug. Returns the path or None."""
84
+ """
85
+ Download a skill from ClawHub by slug.
86
+
87
+ Security measures:
88
+ - Downloads to a restricted temp directory (0700 permissions)
89
+ - Strips execute permissions from all downloaded files before returning
90
+ - Uses --no-input flag to prevent any interactive prompts
91
+ - Does NOT run any postinstall scripts from the downloaded skill
92
+
93
+ Returns (skill_path, cleanup_dir) or None on failure.
94
+ """
95
+ # Create temp dir with restricted permissions (owner only)
64
96
  tmpdir = tempfile.mkdtemp(prefix="clawsec-scan-")
97
+ os.chmod(tmpdir, stat.S_IRWXU) # 0700 — owner read/write/exec only
98
+
65
99
  try:
66
100
  result = subprocess.run(
67
- ["clawhub", "install", slug, "--dir", tmpdir],
68
- capture_output=True, text=True, timeout=120
101
+ ["clawhub", "install", slug, "--dir", tmpdir, "--no-input"],
102
+ capture_output=True, text=True, timeout=120,
103
+ env={**os.environ, "npm_config_ignore_scripts": "true"} # Never run skill's postinstall
69
104
  )
70
105
  if result.returncode != 0:
71
106
  print(f"{R}Error:{RESET} Failed to install '{slug}' from ClawHub", file=sys.stderr)
@@ -73,19 +108,28 @@ def download_slug(slug):
73
108
  print(f" {DIM}{result.stderr.strip()}{RESET}", file=sys.stderr)
74
109
  shutil.rmtree(tmpdir, ignore_errors=True)
75
110
  return None
111
+
76
112
  # Find the installed skill directory
77
113
  entries = list(Path(tmpdir).iterdir())
78
114
  if not entries:
79
115
  print(f"{R}Error:{RESET} ClawHub install produced no output for '{slug}'", file=sys.stderr)
80
116
  shutil.rmtree(tmpdir, ignore_errors=True)
81
117
  return None
82
- # If only one directory, use it directly
118
+
119
+ # Determine skill path
83
120
  if len(entries) == 1 and entries[0].is_dir():
84
- return str(entries[0]), tmpdir
85
- # Multiple entries — use the tmpdir itself
86
- return tmpdir, tmpdir
121
+ skill_path = str(entries[0])
122
+ else:
123
+ skill_path = tmpdir
124
+
125
+ # SECURITY: Strip execute permissions from ALL downloaded files.
126
+ # We only need to read them for scanning — never execute them.
127
+ harden_skill_dir(skill_path)
128
+
129
+ return skill_path, tmpdir
130
+
87
131
  except FileNotFoundError:
88
- print(f"{R}Error:{RESET} 'clawhub' CLI not found. Install it with: npm install -g @anthropic/clawhub", file=sys.stderr)
132
+ print(f"{R}Error:{RESET} 'clawhub' CLI not found. Install it with: npm install -g clawhub", file=sys.stderr)
89
133
  shutil.rmtree(tmpdir, ignore_errors=True)
90
134
  return None
91
135
  except subprocess.TimeoutExpired:
@@ -93,6 +137,7 @@ def download_slug(slug):
93
137
  shutil.rmtree(tmpdir, ignore_errors=True)
94
138
  return None
95
139
 
140
+
96
141
  def cmd_scan(args):
97
142
  """Run verification against a skill path or ClawHub slug."""
98
143
  target = args.target
@@ -137,6 +182,7 @@ def cmd_scan(args):
137
182
 
138
183
  sys.exit(result.returncode)
139
184
 
185
+
140
186
  def cmd_sync(args):
141
187
  """Run intel sync."""
142
188
  # Ensure intel directories exist
@@ -156,6 +202,7 @@ def cmd_sync(args):
156
202
  result = subprocess.run(cmd, text=True)
157
203
  sys.exit(result.returncode)
158
204
 
205
+
159
206
  def cmd_status(args):
160
207
  """Show cache status."""
161
208
  manifest_py = os.path.join(PKG_ROOT, "lib", "intel-sync", "manifest.py")
@@ -202,6 +249,7 @@ def cmd_status(args):
202
249
  pass
203
250
  print(f" Last full sync: {updated}")
204
251
 
252
+
205
253
  def cmd_report(args):
206
254
  """View a saved report."""
207
255
  report_id = args.report_id
@@ -257,6 +305,7 @@ def cmd_report(args):
257
305
 
258
306
  print()
259
307
 
308
+
260
309
  def main():
261
310
  parser = argparse.ArgumentParser(
262
311
  prog="clawsec",
@@ -314,5 +363,6 @@ def main():
314
363
  parser.print_help()
315
364
  sys.exit(1)
316
365
 
366
+
317
367
  if __name__ == "__main__":
318
368
  main()
@@ -3,7 +3,7 @@
3
3
  # Runs all 7 security checks against a skill, produces JSON report
4
4
  set -euo pipefail
5
5
 
6
- VERSION="2.0.0"
6
+ VERSION="2.3.1"
7
7
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
8
  CHECKS_DIR="${SCRIPT_DIR}/checks"
9
9
 
@@ -260,7 +260,8 @@ end_time=$(date +%s%N)
260
260
  elapsed_ms=$(( (end_time - start_time) / 1000000 ))
261
261
 
262
262
  # Generate report via safe temp file approach
263
- results_tmpfile=$(mktemp /tmp/clawsec-results.XXXXXX.json)
263
+ results_tmpfile=$(mktemp ${TMPDIR:-/tmp}/clawsec-results.XXXXXX.json)
264
+ trap "rm -f $results_tmpfile" EXIT INT TERM
264
265
  echo "$check_results" > "$results_tmpfile"
265
266
 
266
267
  report_json=$(python3 -c "
@@ -282,7 +283,7 @@ if [[ -z "$report_json" ]]; then
282
283
  --arg verdict "$verdict" \
283
284
  --arg path "$skill_path" \
284
285
  --argjson duration "$elapsed_ms" \
285
- '{schema_version:"2.0.0",version:"2.0.0",verdict:$verdict,skill_path:$path,checks:.,scan_duration_ms:$duration}')
286
+ '{schema_version:"2.3.0",version:"2.3.0",verdict:$verdict,skill_path:$path,checks:.,scan_duration_ms:$duration}')
286
287
  fi
287
288
 
288
289
  verdict=$(echo "$report_json" | jq -r '.verdict')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowwattlabs/clawsec",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "ClawSec - Security Verification for ClawHub Skills",
5
5
  "bin": {
6
6
  "clawsec": "./bin/clawsec.js",
@@ -11,8 +11,7 @@
11
11
  "cli/",
12
12
  "lib/",
13
13
  "api/",
14
- "requirements.txt",
15
- "setup.sh"
14
+ "requirements.txt"
16
15
  ],
17
16
  "scripts": {
18
17
  "start": "node api/src/server.js",
package/setup.sh DELETED
@@ -1,200 +0,0 @@
1
- #!/usr/bin/env bash
2
- # ⚡ ClawSec v2 Dependency Setup
3
- # Installs all required tools for intel-sync and skill-verify
4
- set -euo pipefail
5
-
6
- VERSION="2.0.0"
7
- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
- source "${SCRIPT_DIR}/lib/common/config.sh"
9
- INTEL_DIR="${CLAWSEC_INTEL_DIR}"
10
- CLAWSEC_USER="$(whoami)"
11
-
12
- RED='\033[0;31m'
13
- GREEN='\033[0;32m'
14
- YELLOW='\033[0;33m'
15
- BLUE='\033[0;34m'
16
- BOLD='\033[1m'
17
- RESET='\033[0m'
18
-
19
- log_info() { echo -e "${BLUE}[INFO]${RESET} $*"; }
20
- log_ok() { echo -e "${GREEN}[ OK ]${RESET} $*"; }
21
- log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; }
22
- log_err() { echo -e "${RED}[ERR ]${RESET} $*"; }
23
-
24
- banner() {
25
- echo -e "${BOLD}"
26
- echo " ╔═══════════════════════════════════════╗"
27
- echo " ║ ClawSec v${VERSION} Setup ║"
28
- echo " ║ ⚡ Security Verification for Skills ║"
29
- echo " ╚═══════════════════════════════════════╝"
30
- echo -e "${RESET}"
31
- }
32
-
33
- check_cmd() {
34
- if command -v "$1" &>/dev/null; then
35
- log_ok "$1 already installed: $(command -v "$1")"
36
- return 0
37
- else
38
- return 1
39
- fi
40
- }
41
-
42
- install_system_deps() {
43
- log_info "Installing system dependencies..."
44
- local needed=()
45
- for pkg in curl wget git jq python3 python3-pip python3-venv libyara-dev yara; do
46
- if ! dpkg -l "$pkg" &>/dev/null 2>&1; then
47
- needed+=("$pkg")
48
- fi
49
- done
50
-
51
- if [[ ${#needed[@]} -gt 0 ]]; then
52
- sudo apt-get update -qq
53
- sudo apt-get install -y -qq "${needed[@]}"
54
- log_ok "System packages installed: ${needed[*]}"
55
- else
56
- log_ok "All system packages already installed"
57
- fi
58
- }
59
-
60
- install_semgrep() {
61
- if check_cmd semgrep; then return 0; fi
62
- log_info "Installing Semgrep..."
63
- pip3 install --user semgrep 2>/dev/null || pip install --user semgrep 2>/dev/null
64
- export PATH="$HOME/.local/bin:$PATH"
65
- if check_cmd semgrep; then
66
- log_ok "Semgrep installed"
67
- else
68
- log_warn "Semgrep pip install failed, trying direct binary..."
69
- curl -fsSL https://raw.githubusercontent.com/returntocorp/semgrep/main/install.sh | bash
70
- log_ok "Semgrep installed via script"
71
- fi
72
- }
73
-
74
- install_gitleaks() {
75
- if check_cmd gitleaks; then return 0; fi
76
- log_info "Installing Gitleaks..."
77
- local arch="$(uname -m)"
78
- local gitleaks_arch="x64"
79
- [[ "$arch" == "aarch64" ]] && gitleaks_arch="arm64"
80
-
81
- local latest
82
- latest=$(curl -fsSL https://api.github.com/repos/gitleaks/gitleaks/releases/latest | jq -r '.tag_name')
83
- local url="https://github.com/gitleaks/gitleaks/releases/download/${latest}/gitleaks_${latest:1}_linux_${gitleaks_arch}.tar.gz"
84
-
85
- local tmpdir
86
- tmpdir=$(mktemp -d)
87
- curl -fsSL "$url" | tar -xz -C "$tmpdir"
88
- mkdir -p "$HOME/.local/bin"
89
- mv "$tmpdir/gitleaks" "$HOME/.local/bin/gitleaks"
90
- chmod +x "$HOME/.local/bin/gitleaks"
91
- rm -rf "$tmpdir"
92
- log_ok "Gitleaks ${latest} installed"
93
- }
94
-
95
- install_yara_python() {
96
- log_info "Checking yara-python..."
97
- if python3 -c "import yara" 2>/dev/null; then
98
- log_ok "yara-python already available"
99
- return 0
100
- fi
101
- pip3 install --user yara-python 2>/dev/null || pip install --user yara-python 2>/dev/null
102
- if python3 -c "import yara" 2>/dev/null; then
103
- log_ok "yara-python installed"
104
- else
105
- log_warn "yara-python install failed — YARA scans may not work"
106
- fi
107
- }
108
-
109
- setup_dirs() {
110
- log_info "Setting up directory structure at ${CLAWSEC_HOME}..."
111
- mkdir -p "${INTEL_DIR}"/{cisa-kev,osv,epss,malwarebazaar,urlhaus,threatfox,feodo,yara-rules,semgrep-rules}
112
- mkdir -p "${CLAWSEC_HOME}/reports"
113
- mkdir -p "${CLAWSEC_HOME}/venv"
114
- log_ok "Directory structure ready at ${CLAWSEC_HOME}"
115
- }
116
-
117
- clone_rule_repos() {
118
- log_info "Cloning/pulling rule repos..."
119
-
120
- # YARA rules - Neo23x0/signature-base
121
- local yara_dir="${INTEL_DIR}/yara-rules/repo"
122
- if [[ -d "$yara_dir/.git" ]]; then
123
- git -C "$yara_dir" pull --quiet 2>/dev/null && log_ok "YARA rules updated" || log_warn "YARA rules pull failed"
124
- else
125
- rm -rf "$yara_dir"
126
- git clone --depth 1 https://github.com/Neo23x0/signature-base.git "$yara_dir" 2>/dev/null && log_ok "YARA rules cloned" || log_warn "YARA rules clone failed"
127
- fi
128
-
129
- # Semgrep rules
130
- local semgrep_dir="${INTEL_DIR}/semgrep-rules/repo"
131
- if [[ -d "$semgrep_dir/.git" ]]; then
132
- git -C "$semgrep_dir" pull --quiet 2>/dev/null && log_ok "Semgrep rules updated" || log_warn "Semgrep rules pull failed"
133
- else
134
- rm -rf "$semgrep_dir"
135
- git clone --depth 1 https://github.com/returntocorp/semgrep-rules.git "$semgrep_dir" 2>/dev/null && log_ok "Semgrep rules cloned" || log_warn "Semgrep rules clone failed"
136
- fi
137
- }
138
-
139
- setup_python_env() {
140
- log_info "Setting up Python virtual environment..."
141
- local venv_dir="${CLAWSEC_HOME}/venv"
142
- if [[ ! -d "$venv_dir" ]] || [[ ! -f "$venv_dir/bin/python3" ]]; then
143
- python3 -m venv "$venv_dir"
144
- fi
145
- source "$venv_dir/bin/activate"
146
- pip install --quiet --upgrade pip
147
- if [[ -f "${SCRIPT_DIR}/requirements.txt" ]]; then
148
- pip install --quiet -r "${SCRIPT_DIR}/requirements.txt"
149
- fi
150
- deactivate
151
- log_ok "Python venv ready at $venv_dir"
152
- }
153
-
154
- verify_install() {
155
- echo ""
156
- log_info "Verifying installations..."
157
- echo ""
158
- local all_ok=true
159
-
160
- for cmd in python3 jq curl git; do
161
- if check_cmd "$cmd"; then :; else
162
- log_err "$cmd NOT found"
163
- all_ok=false
164
- fi
165
- done
166
-
167
- for cmd in semgrep gitleaks yara; do
168
- if check_cmd "$cmd"; then :; else
169
- log_warn "$cmd NOT found — some checks will be unavailable"
170
- fi
171
- done
172
-
173
- echo ""
174
- if $all_ok; then
175
- log_ok "Core dependencies verified"
176
- else
177
- log_err "Some core dependencies missing — review above"
178
- fi
179
- }
180
-
181
- main() {
182
- banner
183
-
184
- export PATH="$HOME/.local/bin:$PATH"
185
-
186
- install_system_deps
187
- install_semgrep
188
- install_gitleaks
189
- install_yara_python
190
- setup_dirs
191
- clone_rule_repos
192
- setup_python_env
193
- verify_install
194
-
195
- echo ""
196
- log_ok "Setup complete. Run: clawsec scan <path> (to verify a skill)"
197
- log_ok " clawsec sync (to populate intel cache)"
198
- }
199
-
200
- main "$@"