clawlock 2.2.2__tar.gz → 2.3.1__tar.gz
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.
- {clawlock-2.2.2 → clawlock-2.3.1}/PKG-INFO +20 -5
- {clawlock-2.2.2 → clawlock-2.3.1}/README.md +19 -4
- clawlock-2.3.1/clawlock/__init__.py +7 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/__main__.py +44 -5
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/hardening/__init__.py +320 -14
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/integrations/__init__.py +25 -5
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/reporters/__init__.py +9 -4
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/__init__.py +83 -34
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/agent_scan.py +0 -1
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/utils/__init__.py +18 -15
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/PKG-INFO +20 -5
- {clawlock-2.2.2 → clawlock-2.3.1}/pyproject.toml +1 -1
- {clawlock-2.2.2 → clawlock-2.3.1}/tests/test_clawlock.py +9 -9
- clawlock-2.2.2/clawlock/__init__.py +0 -7
- {clawlock-2.2.2 → clawlock-2.3.1}/LICENSE +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/adapters/__init__.py +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/i18n.py +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/integrations/promptfoo.py +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/mcp_deep.py +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/updates.py +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/SOURCES.txt +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/dependency_links.txt +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/entry_points.txt +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/requires.txt +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/top_level.txt +0 -0
- {clawlock-2.2.2 → clawlock-2.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clawlock
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Comprehensive security scanner, red-teamer & hardening toolkit for Claw-based AI agent deployments
|
|
5
5
|
Author: g0at
|
|
6
6
|
License: Apache-2.0 OR MIT
|
|
@@ -67,6 +67,9 @@ clawlock skill /path/to/skill # Audit one skill
|
|
|
67
67
|
clawlock soul # Check prompt + memory drift
|
|
68
68
|
clawlock harden # Interactive hardening wizard
|
|
69
69
|
clawlock harden --auto-fix # Apply safe local auto-fixes
|
|
70
|
+
clawlock harden --from-scan --auto-fix # Fix only issues found in last scan
|
|
71
|
+
clawlock harden --auto-fix --verify # Auto-fix then verify the result
|
|
72
|
+
clawlock harden --rollback # Undo the last auto-fix
|
|
70
73
|
clawlock mcp-scan ./mcp-server/src # MCP source-code deep scan
|
|
71
74
|
clawlock agent-scan --code ./agent/src # Standalone OWASP ASI agent scan
|
|
72
75
|
clawlock scan --format html -o report.html
|
|
@@ -230,12 +233,24 @@ ClawLock currently ships **18 hardening measures**.
|
|
|
230
233
|
- `clawlock harden`: interactive mode
|
|
231
234
|
- `clawlock harden --auto`: applies safe non-interactive actions and prints guidance for recommendation-only items
|
|
232
235
|
- `clawlock harden --auto-fix`: only performs real safe local auto-fixes
|
|
236
|
+
- `clawlock harden --from-scan`: show only measures relevant to the latest scan findings
|
|
237
|
+
- `clawlock harden --auto-fix --verify`: auto-fix then re-scan to confirm the fix worked
|
|
238
|
+
- `clawlock harden --rollback`: undo the last auto-fix action (restores from backup)
|
|
233
239
|
|
|
234
|
-
|
|
240
|
+
Auto-fixable measures:
|
|
241
|
+
|
|
242
|
+
| ID | Measure | What it does |
|
|
243
|
+
|----|---------|--------------|
|
|
244
|
+
| H003 | Shorten session retention | Sets `sessionRetentionDays` to 7 in config files |
|
|
245
|
+
| H007 | Create prompt baseline | Records SHA-256 baselines for SOUL.md / CLAUDE.md / MEMORY.md |
|
|
246
|
+
| H008 | Enable approval mode | Sets `approvalMode` to `"always"` in config files |
|
|
247
|
+
| H009 | Tighten credential permissions | `chmod 600`/`700` or `icacls` on config dirs and credential files |
|
|
248
|
+
|
|
249
|
+
All config modifications are backed up to `~/.clawlock/backups/` before changes are made.
|
|
250
|
+
|
|
251
|
+
Other behavior:
|
|
235
252
|
|
|
236
253
|
- The wizard groups measures into **Safe to apply now**, **Recommended only**, and **Needs confirmation**
|
|
237
|
-
- Only **`H009`** performs an actual local auto-fix today
|
|
238
|
-
- `H009` tightens permissions on supported config directories and common home credential files such as `.npmrc`, `.pypirc`, and `.netrc`
|
|
239
254
|
- UX-impacting measures still require explicit confirmation in interactive mode
|
|
240
255
|
- Guidance-only measures are no longer reported as if they were applied
|
|
241
256
|
|
|
@@ -282,7 +297,7 @@ cp skill/SKILL.md ~/.openclaw/skills/clawlock/
|
|
|
282
297
|
git clone https://github.com/g1at/clawlock.git
|
|
283
298
|
cd clawlock
|
|
284
299
|
pip install -e ".[dev]"
|
|
285
|
-
pytest tests/test_clawlock.py -v #
|
|
300
|
+
pytest tests/test_clawlock.py -v # 110 tests
|
|
286
301
|
```
|
|
287
302
|
|
|
288
303
|
## Contributing
|
|
@@ -38,6 +38,9 @@ clawlock skill /path/to/skill # Audit one skill
|
|
|
38
38
|
clawlock soul # Check prompt + memory drift
|
|
39
39
|
clawlock harden # Interactive hardening wizard
|
|
40
40
|
clawlock harden --auto-fix # Apply safe local auto-fixes
|
|
41
|
+
clawlock harden --from-scan --auto-fix # Fix only issues found in last scan
|
|
42
|
+
clawlock harden --auto-fix --verify # Auto-fix then verify the result
|
|
43
|
+
clawlock harden --rollback # Undo the last auto-fix
|
|
41
44
|
clawlock mcp-scan ./mcp-server/src # MCP source-code deep scan
|
|
42
45
|
clawlock agent-scan --code ./agent/src # Standalone OWASP ASI agent scan
|
|
43
46
|
clawlock scan --format html -o report.html
|
|
@@ -201,12 +204,24 @@ ClawLock currently ships **18 hardening measures**.
|
|
|
201
204
|
- `clawlock harden`: interactive mode
|
|
202
205
|
- `clawlock harden --auto`: applies safe non-interactive actions and prints guidance for recommendation-only items
|
|
203
206
|
- `clawlock harden --auto-fix`: only performs real safe local auto-fixes
|
|
207
|
+
- `clawlock harden --from-scan`: show only measures relevant to the latest scan findings
|
|
208
|
+
- `clawlock harden --auto-fix --verify`: auto-fix then re-scan to confirm the fix worked
|
|
209
|
+
- `clawlock harden --rollback`: undo the last auto-fix action (restores from backup)
|
|
204
210
|
|
|
205
|
-
|
|
211
|
+
Auto-fixable measures:
|
|
212
|
+
|
|
213
|
+
| ID | Measure | What it does |
|
|
214
|
+
|----|---------|--------------|
|
|
215
|
+
| H003 | Shorten session retention | Sets `sessionRetentionDays` to 7 in config files |
|
|
216
|
+
| H007 | Create prompt baseline | Records SHA-256 baselines for SOUL.md / CLAUDE.md / MEMORY.md |
|
|
217
|
+
| H008 | Enable approval mode | Sets `approvalMode` to `"always"` in config files |
|
|
218
|
+
| H009 | Tighten credential permissions | `chmod 600`/`700` or `icacls` on config dirs and credential files |
|
|
219
|
+
|
|
220
|
+
All config modifications are backed up to `~/.clawlock/backups/` before changes are made.
|
|
221
|
+
|
|
222
|
+
Other behavior:
|
|
206
223
|
|
|
207
224
|
- The wizard groups measures into **Safe to apply now**, **Recommended only**, and **Needs confirmation**
|
|
208
|
-
- Only **`H009`** performs an actual local auto-fix today
|
|
209
|
-
- `H009` tightens permissions on supported config directories and common home credential files such as `.npmrc`, `.pypirc`, and `.netrc`
|
|
210
225
|
- UX-impacting measures still require explicit confirmation in interactive mode
|
|
211
226
|
- Guidance-only measures are no longer reported as if they were applied
|
|
212
227
|
|
|
@@ -253,7 +268,7 @@ cp skill/SKILL.md ~/.openclaw/skills/clawlock/
|
|
|
253
268
|
git clone https://github.com/g1at/clawlock.git
|
|
254
269
|
cd clawlock
|
|
255
270
|
pip install -e ".[dev]"
|
|
256
|
-
pytest tests/test_clawlock.py -v #
|
|
271
|
+
pytest tests/test_clawlock.py -v # 110 tests
|
|
257
272
|
```
|
|
258
273
|
|
|
259
274
|
## Contributing
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""ClawLock v2.
|
|
1
|
+
"""ClawLock v2.3.1 CLI - 12 commands."""
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import concurrent.futures
|
|
@@ -23,7 +23,7 @@ from rich.progress import (
|
|
|
23
23
|
|
|
24
24
|
from . import __version__
|
|
25
25
|
from .adapters import get_adapter, get_claw_version, resolve_cve_lookup
|
|
26
|
-
from .hardening import run_hardening
|
|
26
|
+
from .hardening import run_hardening, rollback_last
|
|
27
27
|
from .i18n import t
|
|
28
28
|
from .reporters import console, render_focus_report, render_scan_report
|
|
29
29
|
from .scanners import (
|
|
@@ -169,8 +169,8 @@ _patch_cli_i18n()
|
|
|
169
169
|
app = typer.Typer(
|
|
170
170
|
name="clawlock",
|
|
171
171
|
help=t(
|
|
172
|
-
"ClawLock v2.
|
|
173
|
-
"ClawLock v2.
|
|
172
|
+
"ClawLock v2.3.1 - 面向 Claw 平台的安全扫描与加固工具",
|
|
173
|
+
"ClawLock v2.3.1 - security scan and hardening for Claw platforms",
|
|
174
174
|
),
|
|
175
175
|
rich_markup_mode="rich",
|
|
176
176
|
no_args_is_help=False,
|
|
@@ -589,9 +589,48 @@ def harden(
|
|
|
589
589
|
"--auto-fix", help=t("自动应用安全修复,例如文件权限", "Auto-apply safe fixes such as file permissions")
|
|
590
590
|
),
|
|
591
591
|
] = False,
|
|
592
|
+
from_scan: Annotated[
|
|
593
|
+
bool,
|
|
594
|
+
typer.Option(
|
|
595
|
+
"--from-scan", help=t("仅展示与最近扫描发现相关的加固措施", "Show only measures relevant to the latest scan findings")
|
|
596
|
+
),
|
|
597
|
+
] = False,
|
|
598
|
+
verify: Annotated[
|
|
599
|
+
bool,
|
|
600
|
+
typer.Option(
|
|
601
|
+
"--verify", help=t("修复后自动验证", "Run verification scan after fixes")
|
|
602
|
+
),
|
|
603
|
+
] = False,
|
|
604
|
+
do_rollback: Annotated[
|
|
605
|
+
bool,
|
|
606
|
+
typer.Option(
|
|
607
|
+
"--rollback", help=t("回滚上一次自动修复", "Rollback the last auto-fix action")
|
|
608
|
+
),
|
|
609
|
+
] = False,
|
|
592
610
|
):
|
|
593
611
|
"""Run the interactive hardening wizard."""
|
|
594
|
-
|
|
612
|
+
if do_rollback:
|
|
613
|
+
n = rollback_last(1)
|
|
614
|
+
if n:
|
|
615
|
+
console.print(f"[green]{t('已还原', 'Restored')} {n} {t('个文件', 'file(s)')}[/green]")
|
|
616
|
+
else:
|
|
617
|
+
console.print(f"[yellow]{t('没有可回滚的操作。', 'No actions to rollback.')}[/yellow]")
|
|
618
|
+
return
|
|
619
|
+
scan_findings = None
|
|
620
|
+
if from_scan:
|
|
621
|
+
from .utils import get_scan_history
|
|
622
|
+
history = get_scan_history(1)
|
|
623
|
+
if history and "findings" in history[-1]:
|
|
624
|
+
scan_findings = history[-1]["findings"]
|
|
625
|
+
else:
|
|
626
|
+
scan_findings = []
|
|
627
|
+
run_hardening(
|
|
628
|
+
get_adapter(adapter).name,
|
|
629
|
+
auto=auto,
|
|
630
|
+
auto_fix=auto_fix,
|
|
631
|
+
from_scan=scan_findings if from_scan else None,
|
|
632
|
+
verify=verify,
|
|
633
|
+
)
|
|
595
634
|
|
|
596
635
|
|
|
597
636
|
@app.command(help=t("运行 promptfoo 红队测试", "Run promptfoo red-team tests."))
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
3
5
|
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
4
7
|
from pathlib import Path
|
|
5
|
-
from typing import Callable, List, Union
|
|
8
|
+
from typing import Callable, Dict, List, Optional, Union
|
|
6
9
|
|
|
7
10
|
from rich.console import Console
|
|
8
11
|
from rich.panel import Panel
|
|
@@ -12,6 +15,72 @@ from ..i18n import t
|
|
|
12
15
|
from ..utils import IS_ANDROID, IS_MACOS, IS_WINDOWS, platform_label
|
|
13
16
|
|
|
14
17
|
console = Console()
|
|
18
|
+
|
|
19
|
+
# ─── Backup / rollback infrastructure ────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
HARDENING_LOG = Path.home() / ".clawlock" / "hardening_log.json"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_hardening_log() -> list:
|
|
25
|
+
try:
|
|
26
|
+
if HARDENING_LOG.exists():
|
|
27
|
+
return json.loads(HARDENING_LOG.read_text())
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
return []
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save_hardening_log(records: list):
|
|
34
|
+
try:
|
|
35
|
+
HARDENING_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
HARDENING_LOG.write_text(json.dumps(records[-200:], ensure_ascii=False, indent=2))
|
|
37
|
+
except Exception:
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _backup_file(path: Path) -> Optional[Path]:
|
|
42
|
+
"""Create a timestamped backup of a file before modification. Returns backup path."""
|
|
43
|
+
if not path.exists():
|
|
44
|
+
return None
|
|
45
|
+
backup_dir = Path.home() / ".clawlock" / "backups"
|
|
46
|
+
backup_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
48
|
+
backup_path = backup_dir / f"{path.name}.{ts}.bak"
|
|
49
|
+
try:
|
|
50
|
+
shutil.copy2(path, backup_path)
|
|
51
|
+
return backup_path
|
|
52
|
+
except Exception:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _record_hardening_action(measure_id: str, files_changed: Dict[str, str]):
|
|
57
|
+
"""Record an auto-fix action with backup paths for rollback."""
|
|
58
|
+
log = _load_hardening_log()
|
|
59
|
+
log.append({
|
|
60
|
+
"time": datetime.now().isoformat(),
|
|
61
|
+
"measure": measure_id,
|
|
62
|
+
"files": files_changed, # {original_path: backup_path}
|
|
63
|
+
})
|
|
64
|
+
_save_hardening_log(log)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def rollback_last(count: int = 1) -> int:
|
|
68
|
+
"""Rollback the last N hardening actions. Returns number of files restored."""
|
|
69
|
+
log = _load_hardening_log()
|
|
70
|
+
restored = 0
|
|
71
|
+
for _ in range(min(count, len(log))):
|
|
72
|
+
entry = log.pop()
|
|
73
|
+
for orig, backup in entry.get("files", {}).items():
|
|
74
|
+
orig_p, backup_p = Path(orig), Path(backup)
|
|
75
|
+
if backup_p.exists():
|
|
76
|
+
try:
|
|
77
|
+
shutil.copy2(backup_p, orig_p)
|
|
78
|
+
restored += 1
|
|
79
|
+
console.print(f" [green]{t('已还原', 'Restored')}: {orig}[/green]")
|
|
80
|
+
except Exception:
|
|
81
|
+
console.print(f" [red]{t('还原失败', 'Restore failed')}: {orig}[/red]")
|
|
82
|
+
_save_hardening_log(log)
|
|
83
|
+
return restored
|
|
15
84
|
TextValue = Union[str, Callable[[], str]]
|
|
16
85
|
|
|
17
86
|
|
|
@@ -130,9 +199,10 @@ def _persistence_guidance() -> bool:
|
|
|
130
199
|
|
|
131
200
|
|
|
132
201
|
def _fix_cred_perms():
|
|
133
|
-
from ..utils import fix_file_permission
|
|
202
|
+
from ..utils import fix_file_permission, check_file_permission
|
|
134
203
|
|
|
135
204
|
fixed = 0
|
|
205
|
+
changed_files: Dict[str, str] = {}
|
|
136
206
|
for d in [
|
|
137
207
|
Path.home() / ".openclaw",
|
|
138
208
|
Path.home() / ".zeroclaw",
|
|
@@ -154,8 +224,10 @@ def _fix_cred_perms():
|
|
|
154
224
|
".env",
|
|
155
225
|
".rc",
|
|
156
226
|
):
|
|
157
|
-
|
|
158
|
-
|
|
227
|
+
world_r, group_r, _ = check_file_permission(f)
|
|
228
|
+
if world_r or group_r:
|
|
229
|
+
fix_file_permission(f, private=True)
|
|
230
|
+
_g(f"{t('已收紧', 'Tightened')}: {f}")
|
|
159
231
|
for f in [
|
|
160
232
|
Path.home() / ".npmrc",
|
|
161
233
|
Path.home() / ".pypirc",
|
|
@@ -164,9 +236,124 @@ def _fix_cred_perms():
|
|
|
164
236
|
if f.exists() and fix_file_permission(f, private=True):
|
|
165
237
|
fixed += 1
|
|
166
238
|
_g(f"{t('已收紧', 'Tightened')}: {f}")
|
|
239
|
+
if fixed:
|
|
240
|
+
_record_hardening_action("H009", changed_files)
|
|
167
241
|
return fixed > 0
|
|
168
242
|
|
|
169
243
|
|
|
244
|
+
def _find_config_files() -> List[Path]:
|
|
245
|
+
"""Find all Claw product config files."""
|
|
246
|
+
configs = []
|
|
247
|
+
for d in [
|
|
248
|
+
Path.home() / ".openclaw",
|
|
249
|
+
Path.home() / ".zeroclaw",
|
|
250
|
+
Path.home() / ".claude",
|
|
251
|
+
Path.home() / ".config" / "openclaw",
|
|
252
|
+
Path.home() / ".config" / "zeroclaw",
|
|
253
|
+
Path.home() / ".config" / "claude",
|
|
254
|
+
]:
|
|
255
|
+
if d.exists():
|
|
256
|
+
for f in d.iterdir():
|
|
257
|
+
if f.is_file() and f.suffix in (".json",):
|
|
258
|
+
configs.append(f)
|
|
259
|
+
return configs
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _patch_json_config(path: Path, key: str, value, measure_id: str) -> bool:
|
|
263
|
+
"""Safely patch a JSON config file: backup → modify → record."""
|
|
264
|
+
try:
|
|
265
|
+
data = json.loads(path.read_text())
|
|
266
|
+
except Exception:
|
|
267
|
+
return False
|
|
268
|
+
# Navigate dotted key
|
|
269
|
+
parts = key.split(".")
|
|
270
|
+
target = data
|
|
271
|
+
for p in parts[:-1]:
|
|
272
|
+
if not isinstance(target, dict):
|
|
273
|
+
return False
|
|
274
|
+
target = target.setdefault(p, {})
|
|
275
|
+
if not isinstance(target, dict):
|
|
276
|
+
return False
|
|
277
|
+
old_val = target.get(parts[-1])
|
|
278
|
+
if old_val == value:
|
|
279
|
+
_g(f"{path.name}: {key} = {value} ({t('已经是目标值', 'already at target value')})")
|
|
280
|
+
return False
|
|
281
|
+
backup_path = _backup_file(path)
|
|
282
|
+
target[parts[-1]] = value
|
|
283
|
+
try:
|
|
284
|
+
path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
|
285
|
+
except Exception:
|
|
286
|
+
return False
|
|
287
|
+
changed = {str(path): str(backup_path)} if backup_path else {}
|
|
288
|
+
_record_hardening_action(measure_id, changed)
|
|
289
|
+
_g(f"{path.name}: {key}: {old_val} → {value}")
|
|
290
|
+
return True
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _fix_session_retention():
|
|
294
|
+
"""H003: Set sessionRetentionDays to 7 in all config files."""
|
|
295
|
+
fixed = 0
|
|
296
|
+
for cfg in _find_config_files():
|
|
297
|
+
try:
|
|
298
|
+
data = json.loads(cfg.read_text())
|
|
299
|
+
except Exception:
|
|
300
|
+
continue
|
|
301
|
+
val = data.get("sessionRetentionDays")
|
|
302
|
+
if isinstance(val, int) and val > 7:
|
|
303
|
+
if _patch_json_config(cfg, "sessionRetentionDays", 7, "H003"):
|
|
304
|
+
fixed += 1
|
|
305
|
+
if fixed:
|
|
306
|
+
return True
|
|
307
|
+
_g(t("未找到需要修改的配置文件。", "No config files needed modification."))
|
|
308
|
+
return False
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _fix_prompt_baseline():
|
|
312
|
+
"""H007: Create SHA-256 baseline for SOUL.md / CLAUDE.md / MEMORY.md."""
|
|
313
|
+
from ..scanners import _load_hashes, _save_hashes
|
|
314
|
+
import hashlib
|
|
315
|
+
|
|
316
|
+
stored = _load_hashes()
|
|
317
|
+
updated = 0
|
|
318
|
+
candidates = []
|
|
319
|
+
for fname in ["CLAUDE.md", "SOUL.md", "MEMORY.md"]:
|
|
320
|
+
candidates.append(Path.cwd() / fname)
|
|
321
|
+
for d in [".openclaw", ".claude", ".zeroclaw"]:
|
|
322
|
+
candidates.append(Path.home() / d / fname)
|
|
323
|
+
for c in candidates:
|
|
324
|
+
if c.exists():
|
|
325
|
+
content = c.read_text(encoding="utf-8", errors="ignore")
|
|
326
|
+
h = hashlib.sha256(content.encode()).hexdigest()
|
|
327
|
+
key = str(c.resolve())
|
|
328
|
+
if stored.get(key) != h:
|
|
329
|
+
stored[key] = h
|
|
330
|
+
updated += 1
|
|
331
|
+
_g(f"{t('已更新基线', 'Baseline updated')}: {c.name}")
|
|
332
|
+
if updated:
|
|
333
|
+
_save_hashes(stored)
|
|
334
|
+
return True
|
|
335
|
+
_g(t("基线已是最新。", "Baselines are already up to date."))
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _fix_approval_mode():
|
|
340
|
+
"""H008: Enable approvalMode in config files."""
|
|
341
|
+
fixed = 0
|
|
342
|
+
for cfg in _find_config_files():
|
|
343
|
+
try:
|
|
344
|
+
data = json.loads(cfg.read_text())
|
|
345
|
+
except Exception:
|
|
346
|
+
continue
|
|
347
|
+
val = data.get("approvalMode")
|
|
348
|
+
if val in (False, "none", "disabled", None):
|
|
349
|
+
if _patch_json_config(cfg, "approvalMode", "always", "H008"):
|
|
350
|
+
fixed += 1
|
|
351
|
+
if fixed:
|
|
352
|
+
return True
|
|
353
|
+
_g(t("未找到需要修改的配置文件。", "No config files needed modification."))
|
|
354
|
+
return False
|
|
355
|
+
|
|
356
|
+
|
|
170
357
|
MEASURES: List[HardenMeasure] = [
|
|
171
358
|
HardenMeasure(
|
|
172
359
|
"H001",
|
|
@@ -201,8 +388,10 @@ MEASURES: List[HardenMeasure] = [
|
|
|
201
388
|
"更早的会话历史将不再可用。",
|
|
202
389
|
"Older session history will no longer be available.",
|
|
203
390
|
),
|
|
204
|
-
|
|
391
|
+
_fix_session_retention,
|
|
205
392
|
[],
|
|
393
|
+
auto_fixable=True,
|
|
394
|
+
guidance_only=False,
|
|
206
395
|
),
|
|
207
396
|
HardenMeasure(
|
|
208
397
|
"H004",
|
|
@@ -244,14 +433,10 @@ MEASURES: List[HardenMeasure] = [
|
|
|
244
433
|
"Record a SHA-256 baseline for SOUL.md / CLAUDE.md / MEMORY.md.",
|
|
245
434
|
),
|
|
246
435
|
"",
|
|
247
|
-
|
|
248
|
-
_tr(
|
|
249
|
-
"运行 `clawlock soul --update-baseline` 保存基线。",
|
|
250
|
-
"Run `clawlock soul --update-baseline` to save a baseline.",
|
|
251
|
-
)
|
|
252
|
-
),
|
|
436
|
+
_fix_prompt_baseline,
|
|
253
437
|
[],
|
|
254
|
-
auto_fixable=
|
|
438
|
+
auto_fixable=True,
|
|
439
|
+
guidance_only=False,
|
|
255
440
|
),
|
|
256
441
|
HardenMeasure(
|
|
257
442
|
"H008",
|
|
@@ -264,8 +449,10 @@ MEASURES: List[HardenMeasure] = [
|
|
|
264
449
|
"高风险操作会暂停等待确认。",
|
|
265
450
|
"High-risk actions will pause for confirmation.",
|
|
266
451
|
),
|
|
267
|
-
|
|
452
|
+
_fix_approval_mode,
|
|
268
453
|
["openclaw", "zeroclaw"],
|
|
454
|
+
auto_fixable=True,
|
|
455
|
+
guidance_only=False,
|
|
269
456
|
),
|
|
270
457
|
HardenMeasure(
|
|
271
458
|
"H009",
|
|
@@ -510,6 +697,74 @@ MEASURES: List[HardenMeasure] = [
|
|
|
510
697
|
]
|
|
511
698
|
|
|
512
699
|
|
|
700
|
+
# ─── Finding → Measure mapping for --from-scan ──────────────────────────────
|
|
701
|
+
|
|
702
|
+
# Maps finding title keywords or config keys to relevant measure IDs.
|
|
703
|
+
_FINDING_TO_MEASURES: Dict[str, List[str]] = {
|
|
704
|
+
"allowedDirectories": ["H001"],
|
|
705
|
+
"allowedPaths": ["H001"],
|
|
706
|
+
"File access scope": ["H001"],
|
|
707
|
+
"文件访问范围": ["H001"],
|
|
708
|
+
"gatewayAuth": ["H002"],
|
|
709
|
+
"Gateway auth": ["H002"],
|
|
710
|
+
"Gateway 鉴权": ["H002"],
|
|
711
|
+
"auth.enabled": ["H002"],
|
|
712
|
+
"sessionRetentionDays": ["H003"],
|
|
713
|
+
"Session log retention": ["H003"],
|
|
714
|
+
"会话日志保留": ["H003"],
|
|
715
|
+
"enableBrowserControl": ["H004"],
|
|
716
|
+
"Browser control": ["H004"],
|
|
717
|
+
"浏览器控制": ["H004"],
|
|
718
|
+
"allowNetworkAccess": ["H005"],
|
|
719
|
+
"Network access": ["H005"],
|
|
720
|
+
"网络访问": ["H005"],
|
|
721
|
+
"MCP": ["H006", "H015"],
|
|
722
|
+
"prompt baseline": ["H007"],
|
|
723
|
+
"Drift": ["H007"],
|
|
724
|
+
"漂移": ["H007"],
|
|
725
|
+
"approvalMode": ["H008"],
|
|
726
|
+
"Operation approval": ["H008"],
|
|
727
|
+
"操作审批": ["H008"],
|
|
728
|
+
"Credential dir": ["H009"],
|
|
729
|
+
"Credential file": ["H009"],
|
|
730
|
+
"凭证目录": ["H009"],
|
|
731
|
+
"凭证文件": ["H009"],
|
|
732
|
+
"rateLimit": ["H010"],
|
|
733
|
+
"Rate limit": ["H010"],
|
|
734
|
+
"速率限制": ["H010"],
|
|
735
|
+
"curl|": ["H011"],
|
|
736
|
+
"wget|": ["H011"],
|
|
737
|
+
"Download-and-execute": ["H011"],
|
|
738
|
+
"下载即执行": ["H011"],
|
|
739
|
+
"LOLBin": ["H012"],
|
|
740
|
+
"persistence": ["H013"],
|
|
741
|
+
"持久化": ["H013"],
|
|
742
|
+
"Cron": ["H013"],
|
|
743
|
+
"schtasks": ["H013"],
|
|
744
|
+
"LaunchAgent": ["H013"],
|
|
745
|
+
"systemd": ["H013"],
|
|
746
|
+
"Termux": ["H013"],
|
|
747
|
+
"tunnel": ["H014"],
|
|
748
|
+
"隧道": ["H014"],
|
|
749
|
+
"ngrok": ["H014"],
|
|
750
|
+
"dynamic module": ["H016"],
|
|
751
|
+
"动态模块": ["H016"],
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _measures_for_findings(findings: list) -> set:
|
|
756
|
+
"""Given a list of finding dicts (from scan history), return relevant measure IDs."""
|
|
757
|
+
relevant = set()
|
|
758
|
+
for f in findings:
|
|
759
|
+
title = f.get("title", "")
|
|
760
|
+
location = f.get("location", "")
|
|
761
|
+
text = f"{title} {location}"
|
|
762
|
+
for keyword, measure_ids in _FINDING_TO_MEASURES.items():
|
|
763
|
+
if keyword in text:
|
|
764
|
+
relevant.update(measure_ids)
|
|
765
|
+
return relevant
|
|
766
|
+
|
|
767
|
+
|
|
513
768
|
def _needs_confirmation(measure: HardenMeasure) -> bool:
|
|
514
769
|
return bool(_text(measure.ux_impact))
|
|
515
770
|
|
|
@@ -533,7 +788,13 @@ def _print_measure(measure: HardenMeasure):
|
|
|
533
788
|
console.print(f" {t('动作', 'Action')}: {_measure_action(measure)}")
|
|
534
789
|
|
|
535
790
|
|
|
536
|
-
def run_hardening(
|
|
791
|
+
def run_hardening(
|
|
792
|
+
adapter_name: str,
|
|
793
|
+
auto: bool = False,
|
|
794
|
+
auto_fix: bool = False,
|
|
795
|
+
from_scan: Optional[list] = None,
|
|
796
|
+
verify: bool = False,
|
|
797
|
+
):
|
|
537
798
|
mode = (
|
|
538
799
|
t("自动修复", "auto-fix")
|
|
539
800
|
if auto_fix
|
|
@@ -557,6 +818,20 @@ def run_hardening(adapter_name: str, auto: bool = False, auto_fix: bool = False)
|
|
|
557
818
|
for m in MEASURES
|
|
558
819
|
if (not m.adapters or adapter_name in m.adapters) and _platform_matches(m)
|
|
559
820
|
]
|
|
821
|
+
# If --from-scan, filter to only measures relevant to actual findings
|
|
822
|
+
if from_scan is not None:
|
|
823
|
+
relevant_ids = _measures_for_findings(from_scan)
|
|
824
|
+
if relevant_ids:
|
|
825
|
+
applicable = [m for m in applicable if m.id in relevant_ids]
|
|
826
|
+
console.print(
|
|
827
|
+
f"[dim]{t('根据扫描结果筛选了', 'Filtered to')} {len(applicable)} "
|
|
828
|
+
f"{t('条相关加固措施', 'relevant measures based on scan findings')}[/dim]\n"
|
|
829
|
+
)
|
|
830
|
+
else:
|
|
831
|
+
console.print(
|
|
832
|
+
f"[green]{t('扫描未发现与加固措施关联的问题。', 'Scan found no issues linked to hardening measures.')}[/green]"
|
|
833
|
+
)
|
|
834
|
+
return
|
|
560
835
|
safe_now = [m for m in applicable if not m.guidance_only and not _needs_confirmation(m)]
|
|
561
836
|
recommended_only = [
|
|
562
837
|
m for m in applicable if m.guidance_only and not _needs_confirmation(m)
|
|
@@ -655,3 +930,34 @@ def run_hardening(adapter_name: str, auto: bool = False, auto_fix: bool = False)
|
|
|
655
930
|
f"{recommended} {t('项仅建议', 'recommended')}, "
|
|
656
931
|
f"{skipped} {t('项已跳过', 'skipped')}."
|
|
657
932
|
)
|
|
933
|
+
|
|
934
|
+
# Post-fix verification: re-scan config + credentials to show improvement
|
|
935
|
+
if verify and applied > 0:
|
|
936
|
+
console.print()
|
|
937
|
+
console.print(f"[bold]{t('修复验证', 'Post-fix Verification')}[/bold]")
|
|
938
|
+
try:
|
|
939
|
+
from ..adapters import get_adapter
|
|
940
|
+
from ..scanners import scan_config, scan_credential_dirs
|
|
941
|
+
|
|
942
|
+
adapter = get_adapter(adapter_name)
|
|
943
|
+
cfg_findings, _ = scan_config(adapter)
|
|
944
|
+
cred_findings = scan_credential_dirs(adapter)
|
|
945
|
+
remaining = [
|
|
946
|
+
f for f in cfg_findings + cred_findings
|
|
947
|
+
if f.level in ("critical", "high")
|
|
948
|
+
]
|
|
949
|
+
if remaining:
|
|
950
|
+
console.print(
|
|
951
|
+
f" [yellow]{t('仍有', 'Still')} {len(remaining)} "
|
|
952
|
+
f"{t('个高危/严重问题待处理', 'critical/high issue(s) remaining')}[/yellow]"
|
|
953
|
+
)
|
|
954
|
+
for rf in remaining[:5]:
|
|
955
|
+
console.print(f" [{rf.level}] {rf.title}")
|
|
956
|
+
else:
|
|
957
|
+
console.print(
|
|
958
|
+
f" [green]{t('配置和凭证扫描未发现高危/严重问题。', 'Config and credential scan found no critical/high issues.')}[/green]"
|
|
959
|
+
)
|
|
960
|
+
except Exception:
|
|
961
|
+
console.print(
|
|
962
|
+
f" [dim]{t('验证扫描跳过。', 'Verification scan skipped.')}[/dim]"
|
|
963
|
+
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
ClawLock v2.
|
|
2
|
+
ClawLock v2.3.1 integrations — cloud intelligence,
|
|
3
3
|
external scanner, and Agent-Scan.
|
|
4
4
|
"""
|
|
5
5
|
|
|
@@ -55,16 +55,36 @@ def _is_version_fixed(current: str, fixed: str) -> bool:
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
async def lookup_cve(product: str = "OpenClaw", version: str = "") -> list[Finding]:
|
|
58
|
-
|
|
58
|
+
# Upstream /advisories 500s when called with {name=<MixedCase>, version=...}
|
|
59
|
+
# but works with lowercased name, and also works when version is omitted.
|
|
60
|
+
# Try the normal call first; on 5xx fall back to lowercase name, then to
|
|
61
|
+
# a version-less query and rely on client-side _is_version_fixed filtering.
|
|
62
|
+
base_params: dict = {"name": product}
|
|
59
63
|
if version:
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
base_params["version"] = version
|
|
65
|
+
|
|
66
|
+
async def _fetch(params: dict) -> dict:
|
|
62
67
|
async with httpx.AsyncClient(timeout=_TIMEOUT) as client:
|
|
63
68
|
r = await client.get(f"{CLOUD_BASE}/advisories", params=params)
|
|
64
69
|
r.raise_for_status()
|
|
65
|
-
|
|
70
|
+
return r.json()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
data = await _fetch(base_params)
|
|
66
74
|
except httpx.TimeoutException:
|
|
67
75
|
return [Finding("cve", INFO, t("CVE 情报查询超时", "CVE intelligence query timed out"), t("建议稍后重试。", "Please retry later."))]
|
|
76
|
+
except httpx.HTTPStatusError as e:
|
|
77
|
+
status = e.response.status_code
|
|
78
|
+
if status >= 500 and version:
|
|
79
|
+
try:
|
|
80
|
+
data = await _fetch({"name": product.lower(), "version": version})
|
|
81
|
+
except Exception:
|
|
82
|
+
try:
|
|
83
|
+
data = await _fetch({"name": product})
|
|
84
|
+
except Exception as e2:
|
|
85
|
+
return [Finding("cve", INFO, t("CVE 情报暂不可用", "CVE intelligence temporarily unavailable"), f"HTTP {status}; fallback: {str(e2)[:80]}")]
|
|
86
|
+
else:
|
|
87
|
+
return [Finding("cve", INFO, t("CVE 情报暂不可用", "CVE intelligence temporarily unavailable"), f"HTTP {status}")]
|
|
68
88
|
except Exception as e:
|
|
69
89
|
return [Finding("cve", INFO, t("CVE 情报暂不可用", "CVE intelligence temporarily unavailable"), f"{str(e)[:100]}")]
|
|
70
90
|
advisories = (
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""ClawLock v2.
|
|
1
|
+
"""ClawLock v2.3.1 report renderer - Rich terminal + JSON + HTML output."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -428,12 +428,17 @@ def render_scan_report(
|
|
|
428
428
|
from ..utils import device_fingerprint, record_scan
|
|
429
429
|
|
|
430
430
|
dev_fp = device_fingerprint()
|
|
431
|
-
|
|
431
|
+
findings_summary = [
|
|
432
|
+
{"title": f.title, "level": f.level, "location": f.location}
|
|
433
|
+
for f in all_f
|
|
434
|
+
if f.level in ("critical", "high", "medium")
|
|
435
|
+
]
|
|
436
|
+
record_scan(adapter_name, score, nc, nw, len(all_f), findings_summary)
|
|
432
437
|
|
|
433
438
|
if output_format == "json":
|
|
434
439
|
out = {
|
|
435
440
|
"tool": "ClawLock",
|
|
436
|
-
"version": "2.
|
|
441
|
+
"version": "2.3.1",
|
|
437
442
|
"time": scan_time,
|
|
438
443
|
"adapter": adapter_name,
|
|
439
444
|
"device": dev_fp,
|
|
@@ -655,7 +660,7 @@ def _render_html(
|
|
|
655
660
|
_title = t("ClawLock 安全报告", "ClawLock Security Report")
|
|
656
661
|
_lbl_domains = t("安全域评级", "Security Domains")
|
|
657
662
|
_lbl_findings = t("详细发现", "Detailed Findings")
|
|
658
|
-
_footer1 = t("由 ClawLock v2.
|
|
663
|
+
_footer1 = t("由 ClawLock v2.3.1 生成", "Generated by ClawLock v2.3.1")
|
|
659
664
|
_footer2 = t("静态分析仅反映当前可见的代码和配置。",
|
|
660
665
|
"Static analysis reflects the currently visible code and config only.")
|
|
661
666
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"""
|
|
2
|
-
ClawLock v2.
|
|
2
|
+
ClawLock v2.3.1 core scanners — Finding model, config audit, skill supply-chain (55+ patterns),
|
|
3
3
|
SOUL.md + memory file drift, MCP exposure + 6 tool poisoning patterns, process detection,
|
|
4
4
|
credential directory audit, installation discovery, risky env vars, skill precheck.
|
|
5
5
|
"""
|
|
@@ -45,7 +45,7 @@ _CONFIG_RULES: Dict[str, List[tuple]] = {
|
|
|
45
45
|
(
|
|
46
46
|
"allowedDirectories",
|
|
47
47
|
lambda v: isinstance(v, list) and "/" in v,
|
|
48
|
-
|
|
48
|
+
HIGH,
|
|
49
49
|
t("文件访问范围包含根目录", "File access scope includes root directory"),
|
|
50
50
|
t("skill 可读写系统任意文件。", "Skills can read/write any file on the system."),
|
|
51
51
|
t("收紧到项目目录。", "Restrict to the project directory."),
|
|
@@ -87,7 +87,7 @@ _CONFIG_RULES: Dict[str, List[tuple]] = {
|
|
|
87
87
|
(
|
|
88
88
|
"filesystem.allowedPaths",
|
|
89
89
|
lambda v: isinstance(v, list) and any((p in ("/", "~") for p in v)),
|
|
90
|
-
|
|
90
|
+
HIGH,
|
|
91
91
|
t("文件访问范围过宽", "File access scope too broad"),
|
|
92
92
|
t("allowedPaths 包含根路径。", "allowedPaths includes root path."),
|
|
93
93
|
t("限制到项目路径。", "Restrict to the project path."),
|
|
@@ -139,7 +139,7 @@ _CONFIG_RULES: Dict[str, List[tuple]] = {
|
|
|
139
139
|
],
|
|
140
140
|
}
|
|
141
141
|
SECRET_PATTERNS = [
|
|
142
|
-
("sk-[A-Za-z0-9]{20,}", "OpenAI API Key"),
|
|
142
|
+
("sk-(?!ant-)[A-Za-z0-9]{20,}", "OpenAI API Key"),
|
|
143
143
|
("ghp_[A-Za-z0-9]{36}", "GitHub PAT"),
|
|
144
144
|
("github_pat_[A-Za-z0-9_]{20,}", "GitHub fine-grained PAT"),
|
|
145
145
|
("gh(?:o|u)_[A-Za-z0-9]{20,}", "GitHub OAuth/User Token"),
|
|
@@ -151,6 +151,7 @@ SECRET_PATTERNS = [
|
|
|
151
151
|
("-----BEGIN (RSA |EC )?PRIVATE KEY-----", "Private Key"),
|
|
152
152
|
("sk-ant-[A-Za-z0-9\\-]{20,}", "Anthropic API Key"),
|
|
153
153
|
]
|
|
154
|
+
_COMPILED_SECRET_PATTERNS = [(re.compile(p), label) for p, label in SECRET_PATTERNS]
|
|
154
155
|
RISKY_ENV_VARS = [
|
|
155
156
|
"NODE_OPTIONS",
|
|
156
157
|
"LD_PRELOAD",
|
|
@@ -162,7 +163,6 @@ RISKY_ENV_VARS = [
|
|
|
162
163
|
"RUBYOPT",
|
|
163
164
|
"NODE_PATH",
|
|
164
165
|
"ELECTRON_RUN_AS_NODE",
|
|
165
|
-
"BROWSER",
|
|
166
166
|
]
|
|
167
167
|
|
|
168
168
|
|
|
@@ -184,8 +184,8 @@ def _check_secrets(obj: Any, path: str) -> List[Finding]:
|
|
|
184
184
|
for i, item in enumerate(obj):
|
|
185
185
|
findings.extend(_check_secrets(item, f"{path}[{i}]"))
|
|
186
186
|
elif isinstance(obj, str):
|
|
187
|
-
for
|
|
188
|
-
if
|
|
187
|
+
for compiled_pat, label in _COMPILED_SECRET_PATTERNS:
|
|
188
|
+
if compiled_pat.search(obj):
|
|
189
189
|
findings.append(
|
|
190
190
|
Finding(
|
|
191
191
|
"config",
|
|
@@ -629,7 +629,7 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
629
629
|
),
|
|
630
630
|
(
|
|
631
631
|
"(?i)(?:chmod\\s+(?:777|a\\+rwx)\\s+|\\bicacls\\b[^\\n]{0,80}/grant\\b[^\\n]{0,40}(?:Everyone|\\*S-1-1-0))",
|
|
632
|
-
|
|
632
|
+
HIGH,
|
|
633
633
|
t("危险权限变更", "Dangerous permission change"),
|
|
634
634
|
t("chmod 777 或 icacls /grant Everyone 将文件设为全局可访问。", "chmod 777 or icacls /grant Everyone makes files world-accessible."),
|
|
635
635
|
),
|
|
@@ -669,17 +669,12 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
669
669
|
t("疑似硬编码凭证", "Suspected hardcoded credential"),
|
|
670
670
|
t("代码中存在凭证字符串。", "Credential string found in code."),
|
|
671
671
|
),
|
|
672
|
+
# Credential token patterns reuse SECRET_PATTERNS as single source of truth.
|
|
672
673
|
(
|
|
673
|
-
"(
|
|
674
|
-
HIGH,
|
|
675
|
-
t("疑似 AI API 密钥", "Suspected AI API key"),
|
|
676
|
-
t("发现 API key 格式字符串。", "API key format string detected."),
|
|
677
|
-
),
|
|
678
|
-
(
|
|
679
|
-
"(?i)(?:github_pat_[A-Za-z0-9_]{20,}|gh(?:o|u)_[A-Za-z0-9]{20,}|tp-[A-Za-z0-9._-]{16,}|AIza[0-9A-Za-z\\-_]{35}|xoxp-[0-9A-Za-z-]{10,}|_authToken\\s*=\\s*[A-Za-z0-9._-]{20,})",
|
|
674
|
+
"|".join(f"(?:{pat})" for pat, _ in SECRET_PATTERNS),
|
|
680
675
|
HIGH,
|
|
681
|
-
t("
|
|
682
|
-
t("
|
|
676
|
+
t("疑似凭证/令牌", "Suspected credential or token"),
|
|
677
|
+
t("发现与已知凭证格式匹配的字符串。", "String matching a known credential format was found."),
|
|
683
678
|
),
|
|
684
679
|
(
|
|
685
680
|
"(?i)(?:export|setenv|ENV)\\s+(?:NODE_OPTIONS|LD_PRELOAD|DYLD_INSERT_LIBRARIES)\\s*=",
|
|
@@ -741,11 +736,11 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
741
736
|
t("Windows LOLBin 执行链", "Windows LOLBin execution chain"),
|
|
742
737
|
t("检测到常见 Windows LOLBin(系统自带执行器)调用。", "Detected use of common Windows LOLBins (built-in execution helpers)."),
|
|
743
738
|
),
|
|
744
|
-
("\\beval\\s*\\(",
|
|
745
|
-
("\\bexec\\s*\\(",
|
|
739
|
+
("\\beval\\s*\\(", INFO, t("使用 eval()", "eval() usage"), t("动态代码执行。", "Dynamic code execution.")),
|
|
740
|
+
("\\bexec\\s*\\(", INFO, t("使用 exec()", "exec() usage"), t("exec() 执行任意代码。", "exec() executes arbitrary code.")),
|
|
746
741
|
(
|
|
747
742
|
"^(?!\\s*#)\\s*\\bsubprocess\\.(?:Popen|call|run|check_output)\\s*\\(",
|
|
748
|
-
|
|
743
|
+
INFO,
|
|
749
744
|
t("调用子进程", "Subprocess invocation"),
|
|
750
745
|
t("执行系统子进程。", "System subprocess execution."),
|
|
751
746
|
),
|
|
@@ -763,13 +758,13 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
763
758
|
),
|
|
764
759
|
(
|
|
765
760
|
"(?i)open\\([\"\\'](?:/etc/passwd|/etc/shadow|~[/\\\\]\\.ssh[/\\\\]|~[/\\\\]\\.aws[/\\\\]credentials|[A-Za-z]:[/\\\\].*System32[/\\\\]config[/\\\\](?:SAM|SYSTEM|SECURITY))",
|
|
766
|
-
|
|
761
|
+
HIGH,
|
|
767
762
|
t("访问系统敏感文件", "Accessing sensitive system file"),
|
|
768
763
|
t("读取敏感系统文件。", "Reading sensitive system file."),
|
|
769
764
|
),
|
|
770
765
|
(
|
|
771
766
|
"(?i)(?:requests|httpx|urllib|aiohttp)\\.(?:get|post|put|delete)\\s*\\(",
|
|
772
|
-
|
|
767
|
+
INFO,
|
|
773
768
|
t("外部网络请求", "External network request"),
|
|
774
769
|
t("发起 HTTP 请求,确认目标是否可控。", "HTTP request made; verify target is not user-controlled."),
|
|
775
770
|
),
|
|
@@ -860,7 +855,7 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
860
855
|
),
|
|
861
856
|
(
|
|
862
857
|
"(?i)(?:fetch|axios|request)\\s*\\([\"\\']https?://(?!localhost|127\\.)",
|
|
863
|
-
|
|
858
|
+
INFO,
|
|
864
859
|
t("向外部 URL 发送请求", "Request to external URL"),
|
|
865
860
|
t("向非本地 URL 发起请求。", "Request to non-local URL."),
|
|
866
861
|
),
|
|
@@ -872,7 +867,7 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
872
867
|
),
|
|
873
868
|
(
|
|
874
869
|
"(?i)\\b(?:npx|uvx|pipx\\s+run|npm\\s+exec)\\b|\\bpip\\s+install\\s+git\\+https?://",
|
|
875
|
-
|
|
870
|
+
INFO,
|
|
876
871
|
t("运行时拉取外部依赖", "Runtime external dependency fetch"),
|
|
877
872
|
t("运行时通过远程源拉取并执行外部依赖,存在供应链风险。", "Fetches and executes external dependencies at runtime, creating supply-chain risk."),
|
|
878
873
|
),
|
|
@@ -888,9 +883,51 @@ MALICIOUS_PATTERNS: List[Tuple[str, str, str, str]] = [
|
|
|
888
883
|
t("配置了外部 Webhook", "External webhook configured"),
|
|
889
884
|
t("数据可能通过 webhook 外传。", "Data may be exfiltrated via webhook."),
|
|
890
885
|
),
|
|
886
|
+
(
|
|
887
|
+
"(?i)\\bos\\.system\\s*\\(",
|
|
888
|
+
INFO,
|
|
889
|
+
t("调用 os.system()", "os.system() invocation"),
|
|
890
|
+
t("通过 os.system 执行 shell 命令。", "Shell command executed via os.system."),
|
|
891
|
+
),
|
|
892
|
+
(
|
|
893
|
+
"(?i)\\b(?:child_process\\.(?:exec|execSync|spawn|spawnSync|execFile|fork)|\\brequire\\s*\\([\"\\']child_process[\"\\']\\))",
|
|
894
|
+
INFO,
|
|
895
|
+
t("调用 Node child_process", "Node child_process invocation"),
|
|
896
|
+
t("通过 child_process 执行系统命令。", "System command executed via child_process."),
|
|
897
|
+
),
|
|
898
|
+
(
|
|
899
|
+
"(?i)powershell(?:\\.exe)?\\s+[^\\n]*-(?:EncodedCommand|enc|ec)\\b",
|
|
900
|
+
HIGH,
|
|
901
|
+
t("PowerShell 编码命令", "PowerShell encoded command"),
|
|
902
|
+
t("使用 -EncodedCommand 执行 base64 编码的 PowerShell 命令,常用于混淆。", "Executes base64-encoded PowerShell via -EncodedCommand, commonly used for obfuscation."),
|
|
903
|
+
),
|
|
904
|
+
(
|
|
905
|
+
"(?i)\\breg(?:\\.exe)?\\s+add\\b",
|
|
906
|
+
HIGH,
|
|
907
|
+
t("写入 Windows 注册表", "Windows registry write"),
|
|
908
|
+
t("通过 reg add 写入注册表,可能用于持久化或配置篡改。", "Writes to registry via reg add; may be used for persistence or config tampering."),
|
|
909
|
+
),
|
|
910
|
+
(
|
|
911
|
+
"(?:chr\\s*\\(\\s*\\d+\\s*\\)\\s*[+.]?\\s*){5,}",
|
|
912
|
+
WARN,
|
|
913
|
+
t("chr() 字符拼接(疑似混淆)", "chr() character concatenation (suspected obfuscation)"),
|
|
914
|
+
t("使用多次 chr() 调用拼接字符串,可能隐藏恶意指令。", "Multiple chr() calls concatenating a string may hide malicious instructions."),
|
|
915
|
+
),
|
|
916
|
+
]
|
|
917
|
+
_COMPILED_MALICIOUS_PATTERNS = [
|
|
918
|
+
(re.compile(p), level, title, detail)
|
|
919
|
+
for p, level, title, detail in MALICIOUS_PATTERNS
|
|
891
920
|
]
|
|
892
921
|
|
|
893
922
|
|
|
923
|
+
_COMMENT_RE = re.compile(r"^\s*(?:#|//|/?\*|<!--)")
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _is_comment_line(line: str) -> bool:
|
|
927
|
+
"""Return True if the line looks like a code comment."""
|
|
928
|
+
return bool(_COMMENT_RE.match(line))
|
|
929
|
+
|
|
930
|
+
|
|
894
931
|
def scan_skill(skill_path: Path) -> List[Finding]:
|
|
895
932
|
findings: List[Finding] = []
|
|
896
933
|
skill_name = skill_path.stem if skill_path.is_file() else skill_path.name
|
|
@@ -927,10 +964,14 @@ def scan_skill(skill_path: Path) -> List[Finding]:
|
|
|
927
964
|
unwrapped = _unwrap_shell_commands(line)
|
|
928
965
|
candidates.extend(unwrapped)
|
|
929
966
|
is_deobfuscated = False
|
|
967
|
+
is_comment = _is_comment_line(line)
|
|
930
968
|
for candidate in candidates:
|
|
931
|
-
for
|
|
932
|
-
|
|
933
|
-
|
|
969
|
+
for compiled_pat, level, title, detail in _COMPILED_MALICIOUS_PATTERNS:
|
|
970
|
+
# Skip non-CRIT patterns on comment lines to reduce false positives
|
|
971
|
+
if is_comment and level != CRIT:
|
|
972
|
+
continue
|
|
973
|
+
if compiled_pat.search(candidate):
|
|
974
|
+
key = f"{compiled_pat.pattern}:{f}:{i}"
|
|
934
975
|
if key in seen:
|
|
935
976
|
continue
|
|
936
977
|
seen.add(key)
|
|
@@ -1018,7 +1059,7 @@ SOUL_INJECTION_PATTERNS: List[Tuple[str, str, str]] = [
|
|
|
1018
1059
|
t("越狱关键词", "Jailbreak keywords"),
|
|
1019
1060
|
),
|
|
1020
1061
|
("(?:\\\\u[0-9a-fA-F]{4}){6,}", HIGH, t("大量 Unicode 转义", "Excessive Unicode escapes")),
|
|
1021
|
-
("(?:[A-Za-z0-9+/]{
|
|
1062
|
+
("(?:[A-Za-z0-9+/]{200,}={0,2})", WARN, t("长 base64 字符串", "Long base64 string")),
|
|
1022
1063
|
(
|
|
1023
1064
|
"(?i)do\\s+not\\s+(?:reveal|disclose|share)\\s+(?:your\\s+)?(?:system\\s+prompt|instructions)",
|
|
1024
1065
|
WARN,
|
|
@@ -1040,6 +1081,10 @@ SOUL_INJECTION_PATTERNS: List[Tuple[str, str, str]] = [
|
|
|
1040
1081
|
t("优先工具调用指令", "Tool-first instruction"),
|
|
1041
1082
|
),
|
|
1042
1083
|
]
|
|
1084
|
+
_COMPILED_SOUL_PATTERNS = [
|
|
1085
|
+
(re.compile(p), level, title)
|
|
1086
|
+
for p, level, title in SOUL_INJECTION_PATTERNS
|
|
1087
|
+
]
|
|
1043
1088
|
HASH_STORE = Path.home() / ".clawlock" / "drift_hashes.json"
|
|
1044
1089
|
_HASH_CACHE: dict | None = None
|
|
1045
1090
|
_HASH_CACHE_PATH: Path | None = None
|
|
@@ -1091,8 +1136,8 @@ def _scan_single_file_drift(filepath: Path, label: str) -> List[Finding]:
|
|
|
1091
1136
|
return findings
|
|
1092
1137
|
content = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
1093
1138
|
for i, line in enumerate(content.splitlines(), 1):
|
|
1094
|
-
for
|
|
1095
|
-
if
|
|
1139
|
+
for compiled_pat, level, title in _COMPILED_SOUL_PATTERNS:
|
|
1140
|
+
if compiled_pat.search(line):
|
|
1096
1141
|
findings.append(
|
|
1097
1142
|
Finding(
|
|
1098
1143
|
"soul",
|
|
@@ -1218,6 +1263,10 @@ _MCP_ITP = [
|
|
|
1218
1263
|
t("工具描述要求模型在回复前优先执行工具调用。", "Tool description instructs the model to invoke a tool before replying."),
|
|
1219
1264
|
),
|
|
1220
1265
|
]
|
|
1266
|
+
_COMPILED_MCP_ITP = [
|
|
1267
|
+
(re.compile(p), lv, title, detail)
|
|
1268
|
+
for p, lv, title, detail in _MCP_ITP
|
|
1269
|
+
]
|
|
1221
1270
|
|
|
1222
1271
|
|
|
1223
1272
|
def scan_mcp(adapter: AdapterSpec, extra_mcp: Optional[str] = None) -> List[Finding]:
|
|
@@ -1312,8 +1361,8 @@ def scan_mcp(adapter: AdapterSpec, extra_mcp: Optional[str] = None) -> List[Find
|
|
|
1312
1361
|
for fn, text in text_fields.items():
|
|
1313
1362
|
if not text:
|
|
1314
1363
|
continue
|
|
1315
|
-
for
|
|
1316
|
-
if
|
|
1364
|
+
for compiled_pat, lv, title, detail in _COMPILED_MCP_ITP:
|
|
1365
|
+
if compiled_pat.search(text):
|
|
1317
1366
|
findings.append(
|
|
1318
1367
|
Finding(
|
|
1319
1368
|
"mcp_itp",
|
|
@@ -1344,8 +1393,8 @@ def precheck_skill_md(skill_md_path: Path) -> Tuple[List[Finding], bool]:
|
|
|
1344
1393
|
candidates = [line]
|
|
1345
1394
|
candidates.extend(_unwrap_shell_commands(line))
|
|
1346
1395
|
for candidate in candidates:
|
|
1347
|
-
for
|
|
1348
|
-
if
|
|
1396
|
+
for compiled_pat, level, title, detail in _COMPILED_MALICIOUS_PATTERNS:
|
|
1397
|
+
if compiled_pat.search(candidate):
|
|
1349
1398
|
suffix = t(" (反混淆后发现)", " (found after deobfuscation)") if candidate is not line else ""
|
|
1350
1399
|
findings.append(
|
|
1351
1400
|
Finding(
|
|
@@ -229,9 +229,6 @@ def find_all_binaries(names: List[str]) -> Dict[str, Optional[str]]:
|
|
|
229
229
|
return {name: shutil.which(name) for name in names}
|
|
230
230
|
|
|
231
231
|
|
|
232
|
-
# ─── Android / Termux specifics ─────────────────────────────────────────────
|
|
233
|
-
|
|
234
|
-
|
|
235
232
|
# ─── Device fingerprint (privacy-preserving) ────────────────────────────────
|
|
236
233
|
|
|
237
234
|
|
|
@@ -300,23 +297,29 @@ def _save_history(records: list):
|
|
|
300
297
|
|
|
301
298
|
|
|
302
299
|
def record_scan(
|
|
303
|
-
adapter: str,
|
|
300
|
+
adapter: str,
|
|
301
|
+
score: int,
|
|
302
|
+
critical: int,
|
|
303
|
+
warning: int,
|
|
304
|
+
findings_total: int,
|
|
305
|
+
findings_summary: list | None = None,
|
|
304
306
|
):
|
|
305
307
|
"""Append a scan result to persistent history."""
|
|
306
308
|
from datetime import datetime
|
|
307
309
|
|
|
308
310
|
records = _load_history()
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
311
|
+
entry = {
|
|
312
|
+
"time": datetime.now().isoformat(),
|
|
313
|
+
"adapter": adapter,
|
|
314
|
+
"device": device_fingerprint(),
|
|
315
|
+
"score": score,
|
|
316
|
+
"critical": critical,
|
|
317
|
+
"warning": warning,
|
|
318
|
+
"total": findings_total,
|
|
319
|
+
}
|
|
320
|
+
if findings_summary is not None:
|
|
321
|
+
entry["findings"] = findings_summary
|
|
322
|
+
records.append(entry)
|
|
320
323
|
_save_history(records)
|
|
321
324
|
|
|
322
325
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clawlock
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.1
|
|
4
4
|
Summary: Comprehensive security scanner, red-teamer & hardening toolkit for Claw-based AI agent deployments
|
|
5
5
|
Author: g0at
|
|
6
6
|
License: Apache-2.0 OR MIT
|
|
@@ -67,6 +67,9 @@ clawlock skill /path/to/skill # Audit one skill
|
|
|
67
67
|
clawlock soul # Check prompt + memory drift
|
|
68
68
|
clawlock harden # Interactive hardening wizard
|
|
69
69
|
clawlock harden --auto-fix # Apply safe local auto-fixes
|
|
70
|
+
clawlock harden --from-scan --auto-fix # Fix only issues found in last scan
|
|
71
|
+
clawlock harden --auto-fix --verify # Auto-fix then verify the result
|
|
72
|
+
clawlock harden --rollback # Undo the last auto-fix
|
|
70
73
|
clawlock mcp-scan ./mcp-server/src # MCP source-code deep scan
|
|
71
74
|
clawlock agent-scan --code ./agent/src # Standalone OWASP ASI agent scan
|
|
72
75
|
clawlock scan --format html -o report.html
|
|
@@ -230,12 +233,24 @@ ClawLock currently ships **18 hardening measures**.
|
|
|
230
233
|
- `clawlock harden`: interactive mode
|
|
231
234
|
- `clawlock harden --auto`: applies safe non-interactive actions and prints guidance for recommendation-only items
|
|
232
235
|
- `clawlock harden --auto-fix`: only performs real safe local auto-fixes
|
|
236
|
+
- `clawlock harden --from-scan`: show only measures relevant to the latest scan findings
|
|
237
|
+
- `clawlock harden --auto-fix --verify`: auto-fix then re-scan to confirm the fix worked
|
|
238
|
+
- `clawlock harden --rollback`: undo the last auto-fix action (restores from backup)
|
|
233
239
|
|
|
234
|
-
|
|
240
|
+
Auto-fixable measures:
|
|
241
|
+
|
|
242
|
+
| ID | Measure | What it does |
|
|
243
|
+
|----|---------|--------------|
|
|
244
|
+
| H003 | Shorten session retention | Sets `sessionRetentionDays` to 7 in config files |
|
|
245
|
+
| H007 | Create prompt baseline | Records SHA-256 baselines for SOUL.md / CLAUDE.md / MEMORY.md |
|
|
246
|
+
| H008 | Enable approval mode | Sets `approvalMode` to `"always"` in config files |
|
|
247
|
+
| H009 | Tighten credential permissions | `chmod 600`/`700` or `icacls` on config dirs and credential files |
|
|
248
|
+
|
|
249
|
+
All config modifications are backed up to `~/.clawlock/backups/` before changes are made.
|
|
250
|
+
|
|
251
|
+
Other behavior:
|
|
235
252
|
|
|
236
253
|
- The wizard groups measures into **Safe to apply now**, **Recommended only**, and **Needs confirmation**
|
|
237
|
-
- Only **`H009`** performs an actual local auto-fix today
|
|
238
|
-
- `H009` tightens permissions on supported config directories and common home credential files such as `.npmrc`, `.pypirc`, and `.netrc`
|
|
239
254
|
- UX-impacting measures still require explicit confirmation in interactive mode
|
|
240
255
|
- Guidance-only measures are no longer reported as if they were applied
|
|
241
256
|
|
|
@@ -282,7 +297,7 @@ cp skill/SKILL.md ~/.openclaw/skills/clawlock/
|
|
|
282
297
|
git clone https://github.com/g1at/clawlock.git
|
|
283
298
|
cd clawlock
|
|
284
299
|
pip install -e ".[dev]"
|
|
285
|
-
pytest tests/test_clawlock.py -v #
|
|
300
|
+
pytest tests/test_clawlock.py -v # 110 tests
|
|
286
301
|
```
|
|
287
302
|
|
|
288
303
|
## Contributing
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "clawlock"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.1"
|
|
8
8
|
description = "Comprehensive security scanner, red-teamer & hardening toolkit for Claw-based AI agent deployments"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "Apache-2.0 OR MIT"}
|
|
@@ -20,7 +20,7 @@ class TestCliEntry:
|
|
|
20
20
|
assert result.exit_code == 0
|
|
21
21
|
assert "Agent Security Enforcement" in result.stdout
|
|
22
22
|
assert "██████╗██╗" in result.stdout
|
|
23
|
-
assert "v2.
|
|
23
|
+
assert "v2.3.1" in result.stdout
|
|
24
24
|
assert "g0at" in result.stdout
|
|
25
25
|
|
|
26
26
|
def test_root_help_still_shows_help(self):
|
|
@@ -39,7 +39,7 @@ class TestCliEntry:
|
|
|
39
39
|
name: clawlock
|
|
40
40
|
metadata:
|
|
41
41
|
clawlock:
|
|
42
|
-
version: "2.
|
|
42
|
+
version: "2.3.1"
|
|
43
43
|
homepage: "https://github.com/g1at/ClawLock"
|
|
44
44
|
---
|
|
45
45
|
""",
|
|
@@ -50,7 +50,7 @@ metadata:
|
|
|
50
50
|
|
|
51
51
|
def _fake_http_get_json(url, timeout=5.0):
|
|
52
52
|
if "pypi.org" in url:
|
|
53
|
-
return {"info": {"version": "2.
|
|
53
|
+
return {"info": {"version": "2.4.0"}}
|
|
54
54
|
raise AssertionError(url)
|
|
55
55
|
|
|
56
56
|
def _fake_http_get_text(url, timeout=5.0):
|
|
@@ -62,7 +62,7 @@ metadata:
|
|
|
62
62
|
name: clawlock
|
|
63
63
|
metadata:
|
|
64
64
|
clawlock:
|
|
65
|
-
version: "2.
|
|
65
|
+
version: "2.4.0"
|
|
66
66
|
---
|
|
67
67
|
"""
|
|
68
68
|
|
|
@@ -81,12 +81,12 @@ metadata:
|
|
|
81
81
|
)
|
|
82
82
|
assert result.exit_code == 0
|
|
83
83
|
payload = json.loads(result.stdout)
|
|
84
|
-
assert payload["package"]["latest_version"] == "2.
|
|
84
|
+
assert payload["package"]["latest_version"] == "2.4.0"
|
|
85
85
|
assert payload["package"]["update_available"] is True
|
|
86
|
-
assert payload["skill"]["local_version"] == "2.
|
|
87
|
-
assert payload["skill"]["latest_version"] == "2.
|
|
86
|
+
assert payload["skill"]["local_version"] == "2.3.1"
|
|
87
|
+
assert payload["skill"]["latest_version"] == "2.4.0"
|
|
88
88
|
assert payload["skill"]["remote_url"] == "https://raw.githubusercontent.com/g1at/ClawLock/main/skill/SKILL.md"
|
|
89
|
-
assert payload["skill"]["installed_package_version"] == "2.
|
|
89
|
+
assert payload["skill"]["installed_package_version"] == "2.3.1"
|
|
90
90
|
assert payload["skill"]["matches_installed_package"] is True
|
|
91
91
|
assert "pip install -U clawlock" in payload["suggested_updates"]
|
|
92
92
|
assert (
|
|
@@ -144,7 +144,7 @@ class TestReports:
|
|
|
144
144
|
)
|
|
145
145
|
monkeypatch.setattr(utils, "device_fingerprint", lambda: "deadbeefcafe")
|
|
146
146
|
monkeypatch.setattr(
|
|
147
|
-
utils, "record_scan", lambda adapter, score, high, warn, total: None
|
|
147
|
+
utils, "record_scan", lambda adapter, score, high, warn, total, findings_summary=None: None
|
|
148
148
|
)
|
|
149
149
|
|
|
150
150
|
reporters.render_scan_report(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|