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.
412 lines
15 KiB
Python
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()
|