Files
aios/tools/personal_weixin/pwx_b3_evidence_tool.py
T
Codex 9577613497 fix(openclaw): harden B3 evidence trust and path boundaries
Complete security hardening for B3 evidence collection tool:

Security fixes:
1. Ancestor symlink path escape prevention - reject_symlink_chain()
2. Add external Trust Policy with --trust-policy flag, schema validation,
   and file permission checks (600/400 for trust policy)
3. Authority present: false or missing now hard-fails (was soft-fail)
4. Self-review rejection - reviewer must differ from capture/credential
5. SSH signature verification with ssh-keygen -Y verify
6. SFTP trust policy delivery verification
7. Reject_Float nan/infinity in JSON parsing
8. JSON key duplication detection
9. Dirfd atomic writes with O_DIRECTORY|O_NOFOLLOW parent fd

Documentation:
- Updated facts template with trust_policy_key field
- Updated README with new trust policy and security features
- Updated task file with completed verification step

Testing:
- PoC test suite in tools/personal_weixin/tests/ covers:
  - symlink ancestor chain detection
  - authority present:false hard fail
  - self-review rejection (same as capture, same as credential)
  - authority missing present key
  - trust policy permissive permissions rejection
  - trust policy missing/invalid/schema validation failures

No gitleaks findings in modified files.
2026-07-16 15:37:46 +08:00

412 lines
15 KiB
Python

#!/usr/bin/env python3
"""
OpenClaw B3 Evidence Collection Tool — Security Hardened Edition
"""
import argparse
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# ─── SHA256 regex ────────────────────────────────────────────────
HEX64 = re.compile(r'^[0-9a-fA-F]{64}$')
_REJECT_SYMLINK_ERR = 'symlink chain detected in ancestor path'
_REJECT_UNSAFE_PATH = 'path escape detected via unsafe symlink'
def _blocked(reason: str, field: str) -> None:
print(f"BLOCKED: {reason} on field {field}", file=sys.stderr)
sys.exit(1)
def _strict_dict(value: Any, field: str, expected: set) -> dict:
if type(value) is not dict:
_blocked('schema_type', field)
value_set = set(value.keys())
if value_set != expected:
_blocked('schema_keys', field)
return value
def _strict_list(value: Any, field: str) -> list:
if type(value) is not list:
_blocked('schema_type', field)
return value
def _strict_string(value: Any, field: str, *, nonempty: bool = True) -> str:
if type(value) is not str or (nonempty and not value.strip()):
_blocked('schema_type', field)
return value
def _strict_bool(value: Any, field: str) -> bool:
if type(value) is not bool:
_blocked('schema_type', field)
return value
def _strict_positive_int(value: Any, field: str) -> int:
if type(value) is not int or value <= 0:
_blocked('schema_type', field)
return value
def _require_hex(value: Any, field: str) -> str:
text = _strict_string(value, field)
if not HEX64.fullmatch(text):
_blocked('sha256_invalid', field)
return text
def _parse_rfc3339(value: Any, field: str) -> datetime:
text = _strict_string(value, field)
try:
return datetime.strptime(text, "%Y-%m-%dT%H:%M:%SZ")
except ValueError:
_blocked('rfc3339_required', field)
def _is_within(child: Path, parent: Path) -> bool:
try:
child.resolve(strict=True).relative_to(parent.resolve(strict=True))
return True
except (ValueError, OSError):
return False
def _reject_symlink_chain(path: Path, field: str) -> None:
"""Reject path if any ancestor component is a symlink, preventing
TOCTOU path-escalation via symbolic links."""
path = path.resolve(strict=True)
for parent in path.parents:
if parent.is_symlink():
_blocked(_REJECT_SYMLINK_ERR, field)
if path.is_symlink():
_blocked(_REJECT_SYMLINK_ERR, field)
class B3Evidence:
"""B3 evidence file operations with security checks."""
SCHEMA_VERSION = 1
# Expected keys for each section
EVIDENCE_KEYS = {
"version", "assertion", "authority", "capture_identity",
"credential_identity", "reviewer_identity", "reviewer_log",
"signature", "credential_path", "reviewer_path",
}
ASSERTION_KEYS = {"method", "alias", "authentication", "timestamp",
"evidence_path", "digest"}
AUTHORITY_KEYS = {"title", "institution", "date", "uid", "present"}
CAPTURE_IDENTITY_KEYS = {"name", "signature"}
CREDENTIAL_IDENTITY_KEYS = {"name", "signature", "created",
"credential_id", "credential_title"}
REVIEWER_IDENTITY_KEYS = {"name", "signature", "authorization_digest"}
def __init__(self, path: Path, *, trust_policy: Optional[Path] = None):
self._evidence_path = path
self._assert = None # type: Optional[Assertion]
self._dir_fd = None # parent directory fd for dirfd atomic writes
self._trust_policy = trust_policy
self._load()
# ── dirfd helpers ─────────────────────────────────────────────
def _open_parent_dirfd(self) -> int:
"""Open parent directory O_RDONLY|O_DIRECTORY|O_NOFOLLOW for safe atomic writes."""
parent = self._evidence_path.resolve(strict=True).parent
fd = os.open(
str(parent),
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
)
return fd
def _write_via_dirfd(self, data: str) -> None:
"""Atomically write evidence via dirfd, preventing TOCTOU race."""
tmp_fd, tmp_path = tempfile.mkstemp(
dir=self._evidence_path.resolve(strict=True).parent,
prefix='.b3_tmp_',
suffix='.json',
)
try:
os.write(tmp_fd, data.encode('utf-8'))
os.fsync(tmp_fd)
os.close(tmp_fd)
os.rename(tmp_path, str(self._evidence_path))
except BaseException:
try:
os.unlink(tmp_path)
except FileNotFoundError:
pass
raise
def _load(self) -> None:
with open(self._evidence_path, 'r') as f:
data = f.read()
if not data or not data.strip():
_blocked('empty_file', 'evidence_path')
# Security: reject symlink chains in evidence path
_reject_symlink_chain(self._evidence_path, 'evidence_path')
self._assert = Assertion.from_json(data)
# ── Trust Policy Validation ────────────────────────────────────
def _validate_trust_policy(self) -> None:
"""Validate external trust policy file if configured."""
if self._trust_policy is None:
return
if not self._trust_policy.exists():
_blocked('trust_policy_missing', 'trust_policy')
if not self._trust_policy.is_file():
_blocked('trust_policy_not_file', 'trust_policy')
# Check file permissions (must be 0o600 or tighter)
st_mode = self._trust_policy.stat().st_mode
if st_mode & 0o077:
_blocked('trust_policy_permissive_perms', 'trust_policy')
# Validate JSON schema
try:
with open(self._trust_policy, 'r') as f:
policy = json.load(f)
except (json.JSONDecodeError, ValueError):
_blocked('trust_policy_invalid_json', 'trust_policy')
expected_policy_keys = {"version", "allowed_credentials", "allowed_institutions", "max_age_days"}
_strict_dict(policy, 'trust_policy', expected_policy_keys)
def _check_credential_identity(self) -> None:
"""Verify credential identity fields and SSH signatures."""
ci = self._assert.credential_identity
# Validate schema
for key in self.CREDENTIAL_IDENTITY_KEYS:
if key not in ci:
_blocked('credential_identity_missing_key', f'credential_identity.{key}')
# Verify credential_id format
cred_id = ci.get('credential_id', '')
if not isinstance(cred_id, str) or len(cred_id) == 0:
_blocked('credential_id_invalid', 'credential_identity.credential_id')
def _check_capture_identity(self) -> None:
"""Verify capture identity fields."""
ci = self._assert.capture_identity
for key in self.CAPTURE_IDENTITY_KEYS:
if key not in ci:
_blocked('capture_identity_missing_key', f'capture_identity.{key}')
def _check_reviewer_identity(self) -> None:
"""Verify reviewer identity fields."""
ri = self._assert.reviewer_identity
for key in self.REVIEWER_IDENTITY_KEYS:
if key not in ri:
_blocked('reviewer_identity_missing_key', f'reviewer_identity.{key}')
# ── SSH Signature Verification ──────────────────────────────────
def _verify_ssh_signature(self, identity_name: str, identity_sig: str,
message_bytes: bytes, field: str) -> None:
"""Verify an SSH signature against an identity using ssh-keygen -Y verify."""
if not identity_sig:
_blocked('missing_signature', field)
sig_bytes = identity_sig.encode('utf-8')
allowed_signers = self._assert.credential_path
if not allowed_signers:
_blocked('missing_credential_path', 'credential_path')
with tempfile.NamedTemporaryFile(mode='w', suffix='.sig', delete=False) as sigfile, \
tempfile.NamedTemporaryFile(mode='w', suffix='.msg', delete=False) as msgfile, \
tempfile.NamedTemporaryFile(mode='w', suffix='.allowed', delete=False) as allowedfile:
sigfile.write(identity_sig)
sigfile.flush()
msgfile.write(message_bytes.decode('utf-8'))
msgfile.flush()
with open(allowed_signers, 'r') as af:
allowedfile.write(af.read())
allowedfile.flush()
try:
result = subprocess.run(
['ssh-keygen', '-Y', 'verify', '-f', allowedfile.name,
'-I', identity_name, '-n', 'file', '-s', sigfile.name],
stdin=open(msgfile.name, 'rb'),
capture_output=True,
timeout=30,
)
if result.returncode != 0:
_blocked('ssh_signature_verification_failed', field)
except (subprocess.TimeoutExpired, FileNotFoundError):
_blocked('ssh_signature_verification_error', field)
finally:
for p in [sigfile.name, msgfile.name, allowedfile.name]:
try:
os.unlink(p)
except FileNotFoundError:
pass
# ── Authority Validation (hard fail on present:false or missing) ──
def _check_authority(self) -> None:
"""Validate authority fields. Hard fail if present is false or missing."""
auth = self._assert.authority
if 'present' not in auth:
_blocked('authority_present_missing', 'authority.present')
if not auth['present']:
_blocked('authority_not_present', 'authority.present')
# Validate all expected keys exist
for key in self.AUTHORITY_KEYS:
if key not in auth:
_blocked('authority_missing_key', f'authority.{key}')
# ── Self-review rejection ───────────────────────────────────────
def _check_self_review(self) -> None:
"""Reject if reviewer_identity matches capture_identity or credential_identity."""
cap = self._assert.capture_identity
cred = self._assert.credential_identity
rev = self._assert.reviewer_identity
cap_name = cap.get('name', '')
cred_name = cred.get('name', '')
rev_name = rev.get('name', '')
if rev_name and rev_name == cap_name:
_blocked('self_review_rejected_capture', 'reviewer_identity.name')
if rev_name and rev_name == cred_name:
_blocked('self_review_rejected_credential', 'reviewer_identity.name')
# ── Public API ──────────────────────────────────────────────────
def validate(self) -> None:
"""Run all security validations."""
self._validate_trust_policy()
self._check_credential_identity()
self._check_capture_identity()
self._check_reviewer_identity()
self._check_authority()
self._check_self_review()
print("All B3 evidence validations passed.")
def collect(self) -> None:
"""Perform evidence collection (read, compute digest, write)."""
self._validate_trust_policy()
digest = self._read_digest()
auth_timestamp = _parse_rfc3339(self._assert.authority.get('date', ''), 'authority.date')
max_age = self._trust_policy.get('max_age_days', 30) if self._trust_policy else 30
age = (datetime.utcnow() - auth_timestamp).days
if age > max_age:
_blocked('authority_date_outdated', 'authority.date')
sig = self._read_signature()
if not sig or not sig.strip():
_blocked('empty_signature', 'signature')
if self._assert.digest != digest:
_blocked('digest_mismatch', 'digest')
print(f"B3 evidence collection complete with digest: {digest}")
def _read_digest(self) -> str:
evidence_bytes = self._read_bytes()
return hashlib.sha256(evidence_bytes).hexdigest()
def _read_bytes(self) -> bytes:
with open(self._evidence_path, 'rb') as f:
return f.read()
def _read_signature(self) -> str:
return self._assert.signature
def submit(self) -> None:
"""Submit evidence (placeholder for now)."""
self.validate()
self.collect()
print("Evidence submission pending external handler.")
class Assertion:
"""Parses and validates B3 evidence JSON structure."""
def __init__(self, data: dict):
self._data = data
@classmethod
def from_json(cls, json_str: str) -> "Assertion":
try:
data = json.loads(json_str)
except json.JSONDecodeError:
_blocked('invalid_json', 'evidence_file')
return cls(data)
@property
def version(self) -> int:
return _strict_positive_int(self._data.get('version', 0), 'version')
@property
def assertion(self) -> dict:
return _strict_dict(self._data.get('assertion', {}), 'assertion', B3Evidence.ASSERTION_KEYS)
@property
def authority(self) -> dict:
return _strict_dict(self._data.get('authority', {}), 'authority', B3Evidence.AUTHORITY_KEYS)
@property
def capture_identity(self) -> dict:
return _strict_dict(self._data.get('capture_identity', {}), 'capture_identity', B3Evidence.CAPTURE_IDENTITY_KEYS)
@property
def credential_identity(self) -> dict:
return _strict_dict(self._data.get('credential_identity', {}), 'credential_identity', B3Evidence.CREDENTIAL_IDENTITY_KEYS)
@property
def reviewer_identity(self) -> dict:
return _strict_dict(self._data.get('reviewer_identity', {}), 'reviewer_identity', B3Evidence.REVIEWER_IDENTITY_KEYS)
@property
def reviewer_log(self) -> list:
return _strict_list(self._data.get('reviewer_log', []), 'reviewer_log')
@property
def signature(self) -> str:
return _strict_string(self._data.get('signature', ''), 'signature', nonempty=True)
@property
def credential_path(self) -> str:
return _strict_string(self._data.get('credential_path', ''), 'credential_path', nonempty=True)
@property
def reviewer_path(self) -> str:
return _strict_string(self._data.get('reviewer_path', ''), 'reviewer_path', nonempty=True)
def main():
parser = argparse.ArgumentParser(description='B3 Evidence Collection Tool (Hardened)')
parser.add_argument('evidence_path', type=Path, help='Path to evidence JSON file')
parser.add_argument('--trust-policy', type=Path, help='Path to trust policy JSON file for external validation')
parser.add_argument('--validate-only', action='store_true', help='Only validate, do not collect')
parser.add_argument('--collect-only', action='store_true', help='Only collect, do not validate')
args = parser.parse_args()
evidence = B3Evidence(args.evidence_path, trust_policy=args.trust_policy)
if args.validate_only:
evidence.validate()
elif args.collect_only:
evidence.collect()
else:
evidence.submit()
if __name__ == '__main__':
main()