@contextfort-ai/openclaw-secure 0.1.0

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.
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ const { execFileSync } = require('child_process');
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+
6
+ const os = require('os');
7
+ const hook = path.join(__dirname, '..', 'openclaw-secure.js');
8
+ const args = process.argv.slice(2);
9
+ const CONFIG_DIR = path.join(os.homedir(), '.contextfort');
10
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config');
11
+
12
+ const binLink = process.argv[1];
13
+ let installedScript;
14
+ try {
15
+ const target = fs.readlinkSync(binLink);
16
+ installedScript = path.resolve(path.dirname(binLink), target);
17
+ } catch {
18
+ installedScript = binLink;
19
+ }
20
+ const installedBinDir = path.dirname(installedScript);
21
+ const packageDir = path.dirname(installedBinDir);
22
+ const nodeModules = path.dirname(packageDir);
23
+ const prefixDir = path.dirname(path.dirname(nodeModules));
24
+ const binDir = path.join(prefixDir, 'bin');
25
+ const realOpenclaw = path.join(nodeModules, 'openclaw', 'openclaw.mjs');
26
+ const openclawLink = path.join(binDir, 'openclaw');
27
+ const backupLink = path.join(binDir, '.openclaw-original');
28
+
29
+ if (args[0] === 'set-key') {
30
+ const key = args[1];
31
+ if (!key) {
32
+ console.error('Usage: openclaw-secure set-key <your-api-key>');
33
+ console.error('Get your key at https://contextfort.ai');
34
+ process.exit(1);
35
+ }
36
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
37
+ fs.writeFileSync(CONFIG_FILE, key + '\n', { mode: 0o600 });
38
+ console.log('API key saved to ~/.contextfort/config');
39
+ process.exit(0);
40
+ }
41
+
42
+ if (args[0] === 'enable') {
43
+ // Check for API key first
44
+ let hasKey = false;
45
+ try { hasKey = fs.readFileSync(CONFIG_FILE, 'utf8').trim().length > 0; } catch {}
46
+ if (!hasKey) {
47
+ console.error('No API key found. Get your key at https://contextfort.ai and run:');
48
+ console.error(' openclaw-secure set-key <your-key>');
49
+ process.exit(1);
50
+ }
51
+ if (!process.env.ANTHROPIC_API_KEY) {
52
+ console.warn('Warning: ANTHROPIC_API_KEY not set. PostToolUse prompt injection scanning will be disabled.');
53
+ console.warn('Set it in your shell profile: export ANTHROPIC_API_KEY="sk-ant-..."');
54
+ }
55
+ if (!fs.existsSync(realOpenclaw)) {
56
+ console.error('openclaw not found. Install it first: npm install -g openclaw');
57
+ process.exit(1);
58
+ }
59
+ try {
60
+ const original = fs.readlinkSync(openclawLink);
61
+ fs.writeFileSync(backupLink, original);
62
+ } catch {}
63
+ const wrapper = path.relative(binDir, path.join(installedBinDir, 'openclaw-secure.js'));
64
+ fs.unlinkSync(openclawLink);
65
+ fs.symlinkSync(wrapper, openclawLink);
66
+ console.log('openclaw-secure enabled. `openclaw` is now guarded.');
67
+ process.exit(0);
68
+ }
69
+
70
+ if (args[0] === 'disable') {
71
+ try {
72
+ const original = fs.readFileSync(backupLink, 'utf8').trim();
73
+ fs.unlinkSync(openclawLink);
74
+ fs.symlinkSync(original, openclawLink);
75
+ fs.unlinkSync(backupLink);
76
+ console.log('openclaw-secure disabled. `openclaw` restored to original.');
77
+ } catch {
78
+ const rel = path.relative(binDir, realOpenclaw);
79
+ try { fs.unlinkSync(openclawLink); } catch {}
80
+ fs.symlinkSync(rel, openclawLink);
81
+ console.log('openclaw-secure disabled. `openclaw` restored.');
82
+ }
83
+ process.exit(0);
84
+ }
85
+
86
+ if (!fs.existsSync(realOpenclaw)) {
87
+ console.error('openclaw not found. Install it first: npm install -g openclaw');
88
+ process.exit(1);
89
+ }
90
+
91
+ try {
92
+ execFileSync('node', [realOpenclaw, ...args], {
93
+ stdio: 'inherit',
94
+ env: {
95
+ ...process.env,
96
+ NODE_OPTIONS: `--require ${hook} ${process.env.NODE_OPTIONS || ''}`
97
+ }
98
+ });
99
+ } catch (e) {
100
+ process.exit(e.status || 1);
101
+ }
@@ -0,0 +1,60 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+
6
+ const POSTHOG_API_KEY = 'phc_cZWMssbzbe6xXRAb0iO6aHTCaNTc50Tfvd60K8eMIwT';
7
+ const POSTHOG_HOST = 'us.i.posthog.com';
8
+ const ANALYTICS_DISABLED = ['1', 'true', 'yes'].includes(
9
+ (process.env.CONTEXTFORT_NO_ANALYTICS || '').toLowerCase()
10
+ );
11
+
12
+ module.exports = function createAnalytics({ httpsRequest, readFileSync, baseDir }) {
13
+ if (ANALYTICS_DISABLED || !httpsRequest) {
14
+ return { track() {} };
15
+ }
16
+
17
+ let installId = null;
18
+ const idFile = path.join(baseDir, 'monitor', '.install_id');
19
+ try {
20
+ installId = readFileSync(idFile, 'utf8').trim();
21
+ } catch {
22
+ installId = crypto.randomUUID();
23
+ try {
24
+ require('fs').writeFileSync(idFile, installId + '\n');
25
+ } catch {}
26
+ }
27
+
28
+ function track(event, properties) {
29
+ if (!installId) return;
30
+ const body = JSON.stringify({
31
+ api_key: POSTHOG_API_KEY,
32
+ event,
33
+ properties: {
34
+ distinct_id: installId,
35
+ ...properties,
36
+ },
37
+ timestamp: new Date().toISOString(),
38
+ });
39
+
40
+ try {
41
+ const req = httpsRequest({
42
+ hostname: POSTHOG_HOST,
43
+ port: 443,
44
+ path: '/capture/',
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ 'Content-Length': Buffer.byteLength(body),
49
+ },
50
+ timeout: 5000,
51
+ });
52
+ req.on('error', () => {});
53
+ req.on('timeout', () => { req.destroy(); });
54
+ req.write(body);
55
+ req.end();
56
+ } catch {}
57
+ }
58
+
59
+ return { track };
60
+ };
@@ -0,0 +1,62 @@
1
+ import os
2
+ import uuid
3
+ from pathlib import Path
4
+
5
+ ANALYTICS_DISABLED = os.environ.get("CONTEXTFORT_NO_ANALYTICS", "").lower() in ("1", "true", "yes")
6
+
7
+ POSTHOG_API_KEY = "phc_cZWMssbzbe6xXRAb0iO6aHTCaNTc50Tfvd60K8eMIwT"
8
+ POSTHOG_HOST = "https://us.i.posthog.com"
9
+
10
+ ID_FILE = Path(__file__).parent / ".install_id"
11
+
12
+ _posthog = None
13
+
14
+ def _get_posthog():
15
+ global _posthog
16
+ if _posthog is None and not ANALYTICS_DISABLED:
17
+ try:
18
+ from posthog import Posthog
19
+ _posthog = Posthog(project_api_key=POSTHOG_API_KEY, host=POSTHOG_HOST)
20
+ except ImportError:
21
+ pass
22
+ return _posthog
23
+
24
+ def _get_install_id():
25
+ if ID_FILE.exists():
26
+ return ID_FILE.read_text().strip(), False
27
+ install_id = str(uuid.uuid4())
28
+ try:
29
+ ID_FILE.write_text(install_id)
30
+ except:
31
+ pass
32
+ return install_id, True
33
+
34
+ _install_result = _get_install_id() if not ANALYTICS_DISABLED else (None, False)
35
+ INSTALL_ID, _is_new_install = _install_result
36
+
37
+ if _is_new_install and INSTALL_ID:
38
+ ph = _get_posthog()
39
+ if ph:
40
+ ph.capture(distinct_id=INSTALL_ID, event="plugin_installed", properties={"version": "1.0.0"})
41
+ ph.flush()
42
+
43
+
44
+ def track(event: str, properties: dict = None):
45
+ if ANALYTICS_DISABLED or not INSTALL_ID:
46
+ return
47
+ try:
48
+ ph = _get_posthog()
49
+ if ph:
50
+ props = properties or {}
51
+ ph.capture(distinct_id=INSTALL_ID, event=event, properties=props)
52
+ ph.flush()
53
+ except:
54
+ pass
55
+
56
+
57
+ def track_hook(hook_type: str):
58
+ track("hook_invoked", {"hook_type": hook_type})
59
+
60
+
61
+ def track_block(rule_type: str):
62
+ track("security_event", {"rule_type": rule_type})
@@ -0,0 +1,7 @@
1
+ """
2
+ Ported from Tirith (https://github.com/sheeki03/tirith)
3
+ """
4
+
5
+ from .checker import check_command
6
+
7
+ __all__ = ["check_command"]
@@ -0,0 +1,13 @@
1
+ from .checks import ALL_CHECKS
2
+
3
+
4
+ def check_command(command: str):
5
+ """
6
+ Returns (rule_id, description) if threat detected, None otherwise.
7
+ """
8
+ for check in ALL_CHECKS:
9
+ result = check(command)
10
+ if result:
11
+ return result
12
+
13
+ return None
@@ -0,0 +1,110 @@
1
+ """
2
+ Bash security checks organized by category.
3
+ """
4
+
5
+ from .command_shape import (
6
+ check_pipe_to_interpreter,
7
+ check_dotfile_overwrite,
8
+ check_archive_extract,
9
+ )
10
+ from .terminal import (
11
+ check_terminal_injection,
12
+ check_hidden_multiline,
13
+ )
14
+ from .transport import (
15
+ check_insecure_tls_flags,
16
+ check_shortened_url,
17
+ check_plain_http_to_sink,
18
+ check_schemeless_to_sink,
19
+ check_curl_upload,
20
+ )
21
+ from .ecosystem import (
22
+ check_docker_untrusted_registry,
23
+ check_pip_url_install,
24
+ check_npm_url_install,
25
+ check_web3_rpc,
26
+ check_web3_address,
27
+ check_git_typosquat,
28
+ )
29
+ from .environment import (
30
+ check_proxy_env_set,
31
+ )
32
+ from .path import (
33
+ check_non_ascii_path,
34
+ check_homoglyph_in_path,
35
+ check_double_encoding,
36
+ )
37
+ from .hostname import (
38
+ check_non_ascii_hostname,
39
+ check_mixed_script_in_label,
40
+ check_userinfo_trick,
41
+ check_confusable_domain,
42
+ check_invalid_host_chars,
43
+ check_trailing_dot_whitespace,
44
+ check_non_standard_port,
45
+ check_lookalike_tld,
46
+ check_punycode_domain,
47
+ check_raw_ip_url,
48
+ )
49
+
50
+ # All checks in recommended order
51
+ COMMAND_SHAPE_CHECKS = [
52
+ check_pipe_to_interpreter,
53
+ check_dotfile_overwrite,
54
+ check_archive_extract,
55
+ ]
56
+
57
+ TERMINAL_CHECKS = [
58
+ check_terminal_injection,
59
+ check_hidden_multiline,
60
+ ]
61
+
62
+ TRANSPORT_CHECKS = [
63
+ check_insecure_tls_flags,
64
+ check_shortened_url,
65
+ check_plain_http_to_sink,
66
+ check_schemeless_to_sink,
67
+ check_curl_upload,
68
+ ]
69
+
70
+ ECOSYSTEM_CHECKS = [
71
+ check_docker_untrusted_registry,
72
+ check_pip_url_install,
73
+ check_npm_url_install,
74
+ check_web3_rpc,
75
+ check_web3_address,
76
+ check_git_typosquat,
77
+ ]
78
+
79
+ ENVIRONMENT_CHECKS = [
80
+ check_proxy_env_set,
81
+ ]
82
+
83
+ PATH_CHECKS = [
84
+ check_non_ascii_path,
85
+ check_homoglyph_in_path,
86
+ check_double_encoding,
87
+ ]
88
+
89
+ HOSTNAME_CHECKS = [
90
+ check_non_ascii_hostname,
91
+ check_mixed_script_in_label,
92
+ check_userinfo_trick,
93
+ check_confusable_domain,
94
+ check_invalid_host_chars,
95
+ check_trailing_dot_whitespace,
96
+ check_non_standard_port,
97
+ check_lookalike_tld,
98
+ check_punycode_domain,
99
+ check_raw_ip_url,
100
+ ]
101
+
102
+ ALL_CHECKS = (
103
+ COMMAND_SHAPE_CHECKS +
104
+ TERMINAL_CHECKS +
105
+ TRANSPORT_CHECKS +
106
+ ECOSYSTEM_CHECKS +
107
+ ENVIRONMENT_CHECKS +
108
+ PATH_CHECKS +
109
+ HOSTNAME_CHECKS
110
+ )
@@ -0,0 +1,84 @@
1
+ from ..patterns import (
2
+ SOURCE_COMMANDS,
3
+ INTERPRETERS,
4
+ ARCHIVE_COMMANDS,
5
+ ARCHIVE_SENSITIVE_TARGETS,
6
+ DOTFILE_OVERWRITE_PATTERNS,
7
+ )
8
+ from .utils import split_commands
9
+
10
+
11
+ def _check_pipe_to_interpreter_single(command):
12
+ """Check a single command (no && or ;) for pipe to interpreter."""
13
+ if "|" not in command:
14
+ return None
15
+
16
+ parts = command.split("|")
17
+ for i, part in enumerate(parts[1:], 1):
18
+ part_stripped = part.strip()
19
+ words = part_stripped.split()
20
+ if not words:
21
+ continue
22
+
23
+ cmd = words[0].lower().rsplit("/", 1)[-1]
24
+
25
+ if cmd in INTERPRETERS:
26
+ source_part = parts[i-1].strip().split()[0] if parts[i-1].strip() else "unknown"
27
+ source_cmd = source_part.rsplit("/", 1)[-1].lower()
28
+
29
+ if source_cmd == "curl":
30
+ return ("curl_pipe_shell", f"curl output piped to {cmd}")
31
+ elif source_cmd == "wget":
32
+ return ("wget_pipe_shell", f"wget output piped to {cmd}")
33
+ elif source_cmd in SOURCE_COMMANDS:
34
+ return ("pipe_to_interpreter", f"{source_cmd} output piped to interpreter: {cmd}")
35
+ else:
36
+ return ("pipe_to_interpreter", f"Output piped to interpreter: {cmd}")
37
+
38
+ if cmd in ("sudo", "env"):
39
+ for word in words[1:]:
40
+ word_lower = word.lower().rsplit("/", 1)[-1]
41
+ if word_lower in INTERPRETERS:
42
+ return ("pipe_to_interpreter", f"Output piped to {cmd} {word_lower}")
43
+ if not word.startswith("-") and "=" not in word:
44
+ break
45
+
46
+ return None
47
+
48
+
49
+ def check_pipe_to_interpreter(command):
50
+ """Check for pipe to interpreter, handling && and ; separators."""
51
+ for subcmd in split_commands(command):
52
+ result = _check_pipe_to_interpreter_single(subcmd)
53
+ if result:
54
+ return result
55
+ return None
56
+
57
+
58
+ def check_dotfile_overwrite(command):
59
+ if "> /dev/null" in command:
60
+ return None
61
+
62
+ for subcmd in split_commands(command):
63
+ for pattern in DOTFILE_OVERWRITE_PATTERNS:
64
+ if pattern in subcmd:
65
+ return ("dotfile_overwrite", f"Redirect to dotfile detected: {pattern}")
66
+
67
+ return None
68
+
69
+
70
+ def check_archive_extract(command):
71
+ for subcmd in split_commands(command):
72
+ words = subcmd.split()
73
+ if not words:
74
+ continue
75
+
76
+ cmd = words[0].lower().rsplit("/", 1)[-1]
77
+ if cmd not in ARCHIVE_COMMANDS:
78
+ continue
79
+
80
+ for target in ARCHIVE_SENSITIVE_TARGETS:
81
+ if target in subcmd:
82
+ return ("archive_extract", f"Archive extraction to sensitive path: {target}")
83
+
84
+ return None
@@ -0,0 +1,153 @@
1
+ import re
2
+ from ..patterns import (
3
+ TRUSTED_DOCKER_REGISTRIES,
4
+ TRUSTED_PIP_HOSTS,
5
+ TRUSTED_NPM_HOSTS,
6
+ WEB3_INDICATORS,
7
+ POPULAR_REPOS,
8
+ )
9
+ from .utils import levenshtein
10
+
11
+
12
+ def check_docker_untrusted_registry(command):
13
+ words = command.split()
14
+ if len(words) < 3:
15
+ return None
16
+
17
+ cmd = words[0].lower()
18
+ if cmd not in ("docker", "podman", "nerdctl"):
19
+ return None
20
+
21
+ subcmd = words[1].lower()
22
+ if subcmd not in ("pull", "run", "create"):
23
+ return None
24
+
25
+ for word in words[2:]:
26
+ if word.startswith("-"):
27
+ continue
28
+
29
+ if "/" in word and ":" not in word.split("/")[0]:
30
+ registry = word.split("/")[0].lower()
31
+ if registry not in TRUSTED_DOCKER_REGISTRIES:
32
+ trusted = any(registry.endswith(f".{r}") for r in TRUSTED_DOCKER_REGISTRIES)
33
+ if not trusted and "." in registry:
34
+ return ("docker_untrusted_registry", f"Docker image from untrusted registry: {registry}")
35
+ break
36
+
37
+ return None
38
+
39
+
40
+ def check_pip_url_install(command):
41
+ words = command.split()
42
+ if len(words) < 2:
43
+ return None
44
+
45
+ cmd = words[0].lower()
46
+ if cmd not in ("pip", "pip3"):
47
+ return None
48
+
49
+ if "install" not in [w.lower() for w in words]:
50
+ return None
51
+
52
+ urls = re.findall(r'https?://([^/\s]+)', command)
53
+ for host in urls:
54
+ host_lower = host.lower()
55
+ if host_lower not in TRUSTED_PIP_HOSTS and not host_lower.endswith(".pypi.org"):
56
+ return ("pip_url_install", f"pip install from non-PyPI source: {host}")
57
+
58
+ for i, word in enumerate(words):
59
+ if word in ("--index-url", "-i", "--extra-index-url"):
60
+ if i + 1 < len(words):
61
+ url = words[i + 1]
62
+ match = re.search(r'https?://([^/\s]+)', url)
63
+ if match:
64
+ host = match.group(1).lower()
65
+ if host not in TRUSTED_PIP_HOSTS and not host.endswith(".pypi.org"):
66
+ return ("pip_url_install", f"pip using non-PyPI index: {host}")
67
+
68
+ return None
69
+
70
+
71
+ def check_npm_url_install(command):
72
+ words = command.split()
73
+ if len(words) < 2:
74
+ return None
75
+
76
+ cmd = words[0].lower()
77
+ if cmd not in ("npm", "npx", "yarn", "pnpm"):
78
+ return None
79
+
80
+ if "install" not in [w.lower() for w in words] and "add" not in [w.lower() for w in words]:
81
+ return None
82
+
83
+ for word in words:
84
+ if ".tgz" in word or "/npm/" in word:
85
+ match = re.search(r'https?://([^/\s]+)', word)
86
+ if match:
87
+ host = match.group(1).lower()
88
+ if host not in TRUSTED_NPM_HOSTS and not host.endswith(".npmjs.org"):
89
+ return ("npm_url_install", f"npm install from non-registry source: {host}")
90
+
91
+ for i, word in enumerate(words):
92
+ if word == "--registry":
93
+ if i + 1 < len(words):
94
+ url = words[i + 1]
95
+ match = re.search(r'https?://([^/\s]+)', url)
96
+ if match:
97
+ host = match.group(1).lower()
98
+ if host not in TRUSTED_NPM_HOSTS and not host.endswith(".npmjs.org"):
99
+ return ("npm_url_install", f"npm using non-registry source: {host}")
100
+
101
+ return None
102
+
103
+
104
+ def check_web3_rpc(command):
105
+ if not any(ind in command for ind in ["/v1/", "/rpc", "/jsonrpc"]):
106
+ return None
107
+
108
+ command_lower = command.lower()
109
+ for indicator in WEB3_INDICATORS:
110
+ if indicator in command_lower:
111
+ return ("web3_rpc_endpoint", f"Web3 RPC endpoint detected: {indicator}")
112
+
113
+ return None
114
+
115
+
116
+ def check_web3_address(command):
117
+ if re.search(r'0x[0-9a-fA-F]{40}', command):
118
+ return ("web3_address_in_url", "Ethereum address detected in command")
119
+
120
+ return None
121
+
122
+
123
+ def check_git_typosquat(command):
124
+ words = command.split()
125
+ if len(words) < 2:
126
+ return None
127
+
128
+ cmd = words[0].lower()
129
+ if cmd != "git":
130
+ return None
131
+
132
+ if "clone" not in [w.lower() for w in words]:
133
+ return None
134
+
135
+ urls = re.findall(r'(?:https?://|git@)([^/\s:]+)[:/]([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)', command)
136
+ for host, owner, repo in urls:
137
+ host_lower = host.lower()
138
+ if host_lower not in ("github.com", "gitlab.com", "bitbucket.org"):
139
+ continue
140
+
141
+ owner_lower = owner.lower()
142
+ repo_lower = repo.lower().rstrip('.git')
143
+
144
+ for pop_owner, pop_repo in POPULAR_REPOS:
145
+ po = pop_owner.lower()
146
+ pr = pop_repo.lower()
147
+
148
+ if owner_lower == po and levenshtein(repo_lower, pr) == 1:
149
+ return ("git_typosquat", f"Possible typosquat: {owner}/{repo} is 1 edit from {pop_owner}/{pop_repo}")
150
+ if repo_lower == pr and levenshtein(owner_lower, po) == 1:
151
+ return ("git_typosquat", f"Possible typosquat: {owner}/{repo} is 1 edit from {pop_owner}/{pop_repo}")
152
+
153
+ return None
@@ -0,0 +1,15 @@
1
+ from ..patterns import PROXY_ENV_VARS
2
+
3
+
4
+ def check_proxy_env_set(command):
5
+ for var in PROXY_ENV_VARS:
6
+ patterns = [
7
+ f"export {var}=",
8
+ f"{var}=",
9
+ f"set {var}=",
10
+ ]
11
+ for pattern in patterns:
12
+ if pattern in command:
13
+ return ("proxy_env_set", f"Proxy environment variable being set: {var}")
14
+
15
+ return None