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.
Files changed (26) hide show
  1. {clawlock-2.2.2 → clawlock-2.3.1}/PKG-INFO +20 -5
  2. {clawlock-2.2.2 → clawlock-2.3.1}/README.md +19 -4
  3. clawlock-2.3.1/clawlock/__init__.py +7 -0
  4. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/__main__.py +44 -5
  5. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/hardening/__init__.py +320 -14
  6. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/integrations/__init__.py +25 -5
  7. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/reporters/__init__.py +9 -4
  8. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/__init__.py +83 -34
  9. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/agent_scan.py +0 -1
  10. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/utils/__init__.py +18 -15
  11. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/PKG-INFO +20 -5
  12. {clawlock-2.2.2 → clawlock-2.3.1}/pyproject.toml +1 -1
  13. {clawlock-2.2.2 → clawlock-2.3.1}/tests/test_clawlock.py +9 -9
  14. clawlock-2.2.2/clawlock/__init__.py +0 -7
  15. {clawlock-2.2.2 → clawlock-2.3.1}/LICENSE +0 -0
  16. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/adapters/__init__.py +0 -0
  17. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/i18n.py +0 -0
  18. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/integrations/promptfoo.py +0 -0
  19. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/scanners/mcp_deep.py +0 -0
  20. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock/updates.py +0 -0
  21. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/SOURCES.txt +0 -0
  22. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/dependency_links.txt +0 -0
  23. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/entry_points.txt +0 -0
  24. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/requires.txt +0 -0
  25. {clawlock-2.2.2 → clawlock-2.3.1}/clawlock.egg-info/top_level.txt +0 -0
  26. {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.2.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
- Important current behavior:
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 # 104 tests
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
- Important current behavior:
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 # 104 tests
271
+ pytest tests/test_clawlock.py -v # 110 tests
257
272
  ```
258
273
 
259
274
  ## Contributing
@@ -0,0 +1,7 @@
1
+ """
2
+ ClawLock v2.3.1 — Comprehensive security scanner, red-teamer & hardening toolkit
3
+ for Claw-based AI agent deployments.
4
+ """
5
+
6
+ __version__ = "2.3.1"
7
+
@@ -1,4 +1,4 @@
1
- """ClawLock v2.2.2 CLI - 12 commands."""
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.2.2 - 面向 Claw 平台的安全扫描与加固工具",
173
- "ClawLock v2.2.2 - security scan and hardening for Claw platforms",
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
- run_hardening(get_adapter(adapter).name, auto=auto, auto_fix=auto_fix)
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
- fix_file_permission(f, private=True)
158
- _g(f"{t('已收紧', 'Tightened')}: {f}")
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
- lambda: _guide('"sessionRetentionDays": 7, "logLevel": "warn"'),
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
- lambda: _guide(
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=False,
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
- lambda: _guide('"approvalMode": "always"'),
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(adapter_name: str, auto: bool = False, auto_fix: bool = False):
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.2 integrations — cloud intelligence,
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
- params: dict = {"name": product}
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
- params["version"] = version
61
- try:
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
- data = r.json()
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.2.2 report renderer - Rich terminal + JSON + HTML output."""
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
- record_scan(adapter_name, score, nc, nw, len(all_f))
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.2.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.2.2 生成", "Generated by ClawLock v2.2.2")
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.2 core scanners — Finding model, config audit, skill supply-chain (55+ patterns),
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
- WARN,
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
- WARN,
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 pat, label in SECRET_PATTERNS:
188
- if re.search(pat, obj):
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
- CRIT,
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
- "(?i)(?:ANTHROPIC_API_KEY|OPENAI_API_KEY|sk-[A-Za-z0-9]{32,}|sk-ant-[A-Za-z0-9\\-]{20,})",
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("疑似平台访问令牌", "Suspected platform access token"),
682
- t("发现 GitHub / Google / Slack / npm / Xiaomi MiMo Token Plan 等平台访问令牌格式字符串。", "Detected a token-like string for GitHub, Google, Slack, npm, Xiaomi MiMo Token Plan, or another platform."),
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*\\(", WARN, t("使用 eval()", "eval() usage"), t("动态代码执行。", "Dynamic code execution.")),
745
- ("\\bexec\\s*\\(", WARN, t("使用 exec()", "exec() usage"), t("exec() 执行任意代码。", "exec() executes arbitrary code.")),
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
- WARN,
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
- WARN,
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
- WARN,
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
- WARN,
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
- WARN,
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 pattern, level, title, detail in MALICIOUS_PATTERNS:
932
- if re.search(pattern, candidate):
933
- key = f"{pattern}:{f}:{i}"
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+/]{60,}={0,2})", WARN, t("长 base64 字符串", "Long base64 string")),
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 pattern, level, title in SOUL_INJECTION_PATTERNS:
1095
- if re.search(pattern, line):
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 pat, lv, title, detail in _MCP_ITP:
1316
- if re.search(pat, text, re.MULTILINE):
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 pattern, level, title, detail in MALICIOUS_PATTERNS:
1348
- if re.search(pattern, candidate):
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(
@@ -741,7 +741,6 @@ def scan_agent(
741
741
  if enable_llm:
742
742
  layers_used.append(t("LLM 评估", "LLM Assessment"))
743
743
 
744
- _none = t("无", "None")
745
744
  unique.insert(
746
745
  0,
747
746
  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, score: int, critical: int, warning: int, findings_total: int
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
- records.append(
310
- {
311
- "time": datetime.now().isoformat(),
312
- "adapter": adapter,
313
- "device": device_fingerprint(),
314
- "score": score,
315
- "critical": critical,
316
- "warning": warning,
317
- "total": findings_total,
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.2.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
- Important current behavior:
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 # 104 tests
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.2.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.2.2" in result.stdout
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.2.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.3.0"}}
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.3.0"
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.3.0"
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.2.2"
87
- assert payload["skill"]["latest_version"] == "2.3.0"
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.2.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(
@@ -1,7 +0,0 @@
1
- """
2
- ClawLock v2.2.2 — Comprehensive security scanner, red-teamer & hardening toolkit
3
- for Claw-based AI agent deployments.
4
- """
5
-
6
- __version__ = "2.2.2"
7
-
File without changes
File without changes
File without changes
File without changes