feat(security): close out Wave B hardening with mTLS binding, pack integrity guards, DoS lifecycle controls, and release provenance verification

This commit is contained in:
rookiestar28
2026-02-18 01:23:02 +08:00
parent 2520f7426a
commit db7fe477f6
18 changed files with 705 additions and 78 deletions
+10
View File
@@ -65,6 +65,16 @@ npm test
---
## Gate C: Supply Chain Provenance (R100)
**Goal**: Ensure integrity and traceability of release artifacts.
- [ ] **Provenance Generation**: Run `python scripts/generate_provenance.py dist/ dist/provenance.json` to create manifest and SBOM.
- [ ] **Verification**: Run `python scripts/verify_provenance.py dist/ dist/provenance.json` on staging environment to verify integrity and completeness.
- [ ] **Completeness**: Ensure `provenance.json` contains SHA256 for all distributed wheels/zips and correct git commit hash.
---
## Release Metadata
- [ ] **Version**: `pyproject.toml` version matches git tag.
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
R100: Generate Release Provenance
Generates a JSON manifest containing:
1. SHA256 checksums of all artifacts in the target directory.
2. Git commit hash (provenance).
3. Build timestamp.
4. SBOM (pip list).
Usage:
python scripts/generate_provenance.py <artifact_dir> <output_json>
"""
import argparse
import hashlib
import json
import os
import subprocess
import time
from typing import Any, Dict
def compute_sha256(file_path: str) -> str:
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def get_git_commit() -> str:
try:
return (
subprocess.check_output(["git", "rev-parse", "HEAD"])
.decode("utf-8")
.strip()
)
except Exception:
return "unknown"
def get_sbom() -> Any:
try:
# Simple SBOM: pip list
output = subprocess.check_output(["pip", "list", "--format=json"]).decode(
"utf-8"
)
return json.loads(output)
except Exception:
return []
def main():
parser = argparse.ArgumentParser(description="Generate Release Provenance")
parser.add_argument("artifact_dir", help="Directory containing release artifacts")
parser.add_argument("output_json", help="Path to write provenance.json")
args = parser.parse_args()
artifacts = {}
if os.path.isdir(args.artifact_dir):
for root, _, files in os.walk(args.artifact_dir):
for file in files:
if file == "provenance.json":
continue
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, args.artifact_dir).replace(
"\\", "/"
)
artifacts[rel_path] = compute_sha256(full_path)
provenance = {
"meta": {
"timestamp": time.time(),
"git_commit": get_git_commit(),
"generator": "scripts/generate_provenance.py",
},
"artifacts": artifacts,
"sbom": get_sbom(),
}
with open(args.output_json, "w", encoding="utf-8") as f:
json.dump(provenance, f, indent=2, sort_keys=True)
print(f"Provenance generated at {args.output_json}")
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""
R100: Verify Release Provenance
Verifies that artifacts in the directory match the SHA256 checksums in provenance.json.
Fail-safe: Any file existing in dir but missing from provenance is an ERROR.
Usage:
python scripts/verify_provenance.py <artifact_dir> <provenance_json>
"""
import argparse
import hashlib
import json
import os
import sys
def compute_sha256(file_path: str) -> str:
sha256_hash = hashlib.sha256()
with open(file_path, "rb") as f:
for byte_block in iter(lambda: f.read(4096), b""):
sha256_hash.update(byte_block)
return sha256_hash.hexdigest()
def main():
parser = argparse.ArgumentParser(description="Verify Release Provenance")
parser.add_argument("artifact_dir", help="Directory containing release artifacts")
parser.add_argument("provenance_json", help="Path to provenance.json")
args = parser.parse_args()
if not os.path.exists(args.provenance_json):
print(f"ERROR: Provenance file not found: {args.provenance_json}")
sys.exit(1)
with open(args.provenance_json, "r", encoding="utf-8") as f:
provenance = json.load(f)
expected_artifacts = provenance.get("artifacts", {})
# 1. Verify existence and checksums
for rel_path, expected_sha in expected_artifacts.items():
full_path = os.path.join(args.artifact_dir, rel_path)
if not os.path.exists(full_path):
print(f"ERROR: Missing artifact: {rel_path}")
sys.exit(1)
actual_sha = compute_sha256(full_path)
if actual_sha != expected_sha:
print(f"ERROR: Checksum mismatch for {rel_path}")
print(f" Expected: {expected_sha}")
print(f" Actual: {actual_sha}")
sys.exit(1)
# 2. Verify no unlisted files (Completeness)
if os.path.isdir(args.artifact_dir):
for root, _, files in os.walk(args.artifact_dir):
for file in files:
if file == "provenance.json":
continue # provenance might be in the same dir
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, args.artifact_dir).replace(
"\\", "/"
)
# If provenance is inside the dir, we might skip it, but generally provenance is separate or included.
# If checking external provenance file against a dir, provenance file itself isn't checked.
if os.path.abspath(full_path) == os.path.abspath(args.provenance_json):
continue
if rel_path not in expected_artifacts:
print(
f"ERROR: Unlisted artifact found: {rel_path} (Not in provenance)"
)
sys.exit(1)
print("SUCCESS: specific artifacts verified against provenance.")
print(f" Verified {len(expected_artifacts)} artifacts.")
print(f" Git Commit: {provenance.get('meta', {}).get('git_commit')}")
if __name__ == "__main__":
main()
+4 -1
View File
@@ -247,6 +247,10 @@ class IdempotencyStore:
S50: Delegates to durable backend when available.
strict_mode: fail-closed if durable backend is unavailable.
"""
# R101: Ensure cleanup runs periodically to prevent storage DoS
# This uses an internal timer (300s) to avoid excessive cleanup calls
self._cleanup()
# S50: strict_mode fail-closed
if self._strict_mode and not self._durable:
raise IdempotencyStoreError(
@@ -269,7 +273,6 @@ class IdempotencyStore:
) from e
# In-memory path
self._cleanup()
now = time.time()
with self._store_lock:
+13 -5
View File
@@ -2,6 +2,7 @@ import json
import os
import shutil
import tempfile
import unicodedata
import zipfile
from typing import Optional
@@ -151,13 +152,20 @@ class PackArchive:
# 2. Safety Check
for info in infos:
# S53: Unicode normalization to prevent homoglyph attacks (e.g. fullwidth dots)
# Normalize to NFKC to catch compatibility characters like '..' -> '..'
norm_name = unicodedata.normalize("NFKC", info.filename)
if (
info.filename.startswith("/")
or ".." in info.filename
or "\\" in info.filename
or any(c < " " for c in info.filename) # Control chars
norm_name.startswith("/")
or ".." in norm_name
or "\\" in norm_name
or ":" in norm_name # Block drive-relative paths (C:foo)
or any(c < " " for c in norm_name) # Control chars
):
raise PackError(f"Unsafe filename: {info.filename}")
raise PackError(
f"Unsafe filename: {info.filename} (normalized: {norm_name})"
)
# Check for symlinks (S_IFLNK - 0xA000)
# ZipInfo.external_attr: upper 16 bits are Unix permissions
+16
View File
@@ -60,8 +60,10 @@ def validate_manifest_integrity(base_dir: str, manifest: PackManifest) -> List[s
if len(manifest.get("files", [])) > MAX_MANIFEST_FILES:
return [f"Manifest exceeds maximum file count ({MAX_MANIFEST_FILES})"]
manifest_paths = set()
for item in manifest.get("files", []):
rel_path = item.get("path")
manifest_paths.add(rel_path)
expected_hash = item.get("sha256")
# S4: Path Traversal Check (Redundant but critical)
@@ -89,6 +91,20 @@ def validate_manifest_integrity(base_dir: str, manifest: PackManifest) -> List[s
except Exception as e:
errors.append(f"Error reading {rel_path}: {str(e)}")
# S52: Manifest Completeness Check
# Ensure no files on disk are missing from manifest (except metadata files)
allowed_extras = {"manifest.json", "pack.json"}
for root, _, files in os.walk(base_dir):
for file in files:
full_path = os.path.join(root, file)
rel_path = os.path.relpath(full_path, base_dir).replace("\\", "/")
if rel_path in allowed_extras:
continue
if rel_path not in manifest_paths:
errors.append(f"Unlisted file found: {rel_path} (S52 Violations)")
return errors
+3
View File
@@ -58,6 +58,9 @@ class RateLimiter:
"logs": (60, 60.0 / 60.0), # 60 req/min
"admin": (20, 20.0 / 60.0), # 20 req/min
"bridge": (20, 20.0 / 60.0), # 20 req/min
# R101: New Quotas
"connector": (20, 20.0 / 60.0), # 20 req/min (aligned with bridge)
"trigger": (60, 60.0 / 60.0), # 60 req/min (higher due to automation)
}
def check(self, limit_type: str, ip: str) -> bool:
+70 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import hmac
import logging
import os
from typing import Optional, Set, Tuple
from typing import Dict, Optional, Set, Tuple
try:
from aiohttp import web # type: ignore
@@ -26,6 +26,11 @@ ENV_BRIDGE_DEVICE_TOKEN = "OPENCLAW_BRIDGE_DEVICE_TOKEN"
LEGACY_ENV_BRIDGE_DEVICE_TOKEN = "MOLTBOT_BRIDGE_DEVICE_TOKEN"
ENV_BRIDGE_ALLOWED_DEVICE_IDS = "OPENCLAW_BRIDGE_ALLOWED_DEVICE_IDS"
LEGACY_ENV_BRIDGE_ALLOWED_DEVICE_IDS = "MOLTBOT_BRIDGE_ALLOWED_DEVICE_IDS"
# R104: mTLS Contract
ENV_BRIDGE_MTLS_ENABLED = "OPENCLAW_BRIDGE_MTLS_ENABLED"
ENV_BRIDGE_DEVICE_CERT_MAP = (
"OPENCLAW_BRIDGE_DEVICE_CERT_MAP" # device_id:fingerprint,...
)
# Headers
HEADER_DEVICE_ID = "X-OpenClaw-Device-Id"
@@ -34,6 +39,8 @@ HEADER_DEVICE_TOKEN = "X-OpenClaw-Device-Token"
LEGACY_HEADER_DEVICE_TOKEN = "X-Moltbot-Device-Token"
HEADER_SCOPES = "X-OpenClaw-Scopes"
LEGACY_HEADER_SCOPES = "X-Moltbot-Scopes"
# R104: mTLS Headers
HEADER_CLIENT_CERT_HASH = "X-Client-Cert-Hash" # SHA256 fingerprint from proxy
def _env_get(primary: str, legacy: str, default: str = "") -> str:
@@ -72,6 +79,62 @@ def get_allowed_device_ids() -> Optional[Set[str]]:
return set(id.strip() for id in ids_str.split(",") if id.strip())
def is_mtls_enabled() -> bool:
"""Check if mTLS enforcement is enabled."""
return os.environ.get(ENV_BRIDGE_MTLS_ENABLED, "false").lower() in (
"true",
"1",
"yes",
"on",
)
def get_device_cert_map() -> Dict[str, str]:
"""
Get map of device_id -> certificate fingerprint.
Format: device_id:fingerprint,device_id2:fingerprint2
"""
mapping_str = os.environ.get(ENV_BRIDGE_DEVICE_CERT_MAP, "")
if not mapping_str:
return {}
result = {}
for entry in mapping_str.split(","):
if ":" in entry:
parts = entry.split(":", 1)
result[parts[0].strip()] = parts[1].strip()
return result
def validate_mtls_binding(request: web.Request, device_id: str) -> Tuple[bool, str]:
"""
R104: Validate mTLS certificate binding for the device.
"""
if not is_mtls_enabled():
return True, ""
cert_hash = request.headers.get(HEADER_CLIENT_CERT_HASH, "")
if not cert_hash:
# Strict mode: mTLS enabled but no cert header -> fail
return False, "Missing client certificate header (mTLS required)"
cert_map = get_device_cert_map()
expected_hash = cert_map.get(device_id)
if not expected_hash:
# Strict mode: mTLS enabled implies explicit device binding
return False, f"Device not bound to a certificate (Device ID: {device_id})"
# Constant-time comparison not strictly required for public fingerprints but good practice
if not hmac.compare_digest(cert_hash, expected_hash):
logger.warning(
f"mTLS violation: Device {device_id} presented {cert_hash}, expected {expected_hash}"
)
return False, "Certificate fingerprint mismatch"
return True, ""
def validate_device_token(
request: web.Request, required_scope: Optional[BridgeScope] = None
) -> Tuple[bool, str, Optional[str]]:
@@ -138,6 +201,12 @@ def validate_device_token(
)
return False, f"Missing required scope: {required_scope}", None
# R104: mTLS Binding Check
is_mtls_valid, mtls_error = validate_mtls_binding(request, device_id)
if not is_mtls_valid:
logger.warning(f"mTLS validation failed for {device_id}: {mtls_error}")
return False, mtls_error, None
return True, "", device_id
+13 -4
View File
@@ -76,10 +76,19 @@ class SandboxProfile:
return []
violations: List[str] = []
for p in paths:
resolved = os.path.abspath(p)
if not any(
resolved.startswith(os.path.abspath(allowed)) for allowed in allowlist
):
# S54: Canonicalize path to resolve symlinks and '..'
resolved = os.path.realpath(p)
# Check against allowed prefixes
is_allowed = False
for allowed in allowlist:
allowed_abs = os.path.realpath(allowed)
# Ensure we match directory boundary (exact match or subdir)
if resolved == allowed_abs or resolved.startswith(allowed_abs + os.sep):
is_allowed = True
break
if not is_allowed:
mode = "write" if write else "read"
violations.append(
f"Path '{resolved}' not in allow_fs_{mode}: {allowlist}"
+45 -34
View File
@@ -1,46 +1,58 @@
import unittest
from unittest.mock import AsyncMock, MagicMock, patch
from connector.transport_contract import RelayResponseClassifier, RelayStatus
from connector.platforms.whatsapp_webhook import WhatsAppWebhookServer
from connector.platforms.line_webhook import LINEWebhookServer
from connector.platforms.wechat_webhook import WeChatWebhookServer
from connector.platforms.whatsapp_webhook import WhatsAppWebhookServer
from connector.router import CommandRouter
from connector.transport_contract import RelayResponseClassifier, RelayStatus
class TestRelayResponseClassifier(unittest.TestCase):
def test_auth_invalid_codes(self):
"""Test that 401 and 410 are classified as auth_invalid."""
print(f"DEBUG: classify(401) = {RelayResponseClassifier.classify(401)}")
print(f"DEBUG: AUTH_INVALID_CODES = {RelayResponseClassifier.AUTH_INVALID_CODES}")
print(
f"DEBUG: AUTH_INVALID_CODES = {RelayResponseClassifier.AUTH_INVALID_CODES}"
)
self.assertTrue(RelayResponseClassifier.is_auth_invalid(401))
self.assertTrue(RelayResponseClassifier.is_auth_invalid(410))
self.assertEqual(RelayResponseClassifier.classify(401), RelayStatus.AUTH_INVALID)
self.assertEqual(RelayResponseClassifier.classify(410), RelayStatus.AUTH_INVALID)
self.assertEqual(
RelayResponseClassifier.classify(401), RelayStatus.AUTH_INVALID
)
self.assertEqual(
RelayResponseClassifier.classify(410), RelayStatus.AUTH_INVALID
)
def test_other_codes(self):
"""Test that other codes are not auth_invalid."""
self.assertFalse(RelayResponseClassifier.is_auth_invalid(200))
self.assertFalse(RelayResponseClassifier.is_auth_invalid(403)) # Forbidden != Auth Invalid (usually)
self.assertFalse(
RelayResponseClassifier.is_auth_invalid(403)
) # Forbidden != Auth Invalid (usually)
self.assertFalse(RelayResponseClassifier.is_auth_invalid(500))
# Use .value to avoid enum identity issues if any
self.assertEqual(RelayResponseClassifier.classify(200).value, RelayStatus.OK.value)
self.assertEqual(
RelayResponseClassifier.classify(200).value, RelayStatus.OK.value
)
# 500 is TRANSIENT in OpenClaw (retryable), not SERVER_ERROR (fatal)
self.assertEqual(RelayResponseClassifier.classify(500).value, RelayStatus.TRANSIENT.value)
self.assertEqual(
RelayResponseClassifier.classify(500).value, RelayStatus.TRANSIENT.value
)
class TestWebhookSessionInvalidation(unittest.IsolatedAsyncioTestCase):
async def test_whatsapp_session_invalidation(self):
"""Test WhatsApp session invalidation on 401."""
server = WhatsAppWebhookServer(
config=MagicMock(),
router=MagicMock(spec=CommandRouter)
config=MagicMock(), router=MagicMock(spec=CommandRouter)
)
server.session = MagicMock()
# Mock response context manager
mock_response = AsyncMock()
mock_response.status = 401
# Mock post context manager
post_ctx = AsyncMock()
post_ctx.__aenter__.return_value = mock_response
@@ -49,10 +61,10 @@ class TestWebhookSessionInvalidation(unittest.IsolatedAsyncioTestCase):
# First call should trigger invalidation
await server.send_message("123", "test")
self.assertTrue(server._session_invalid)
# Reset mock to verify no further calls
server.session.post.reset_mock()
# Second call should be blocked
await server.send_message("123", "test")
server.session.post.assert_not_called()
@@ -60,24 +72,23 @@ class TestWebhookSessionInvalidation(unittest.IsolatedAsyncioTestCase):
async def test_line_session_invalidation(self):
"""Test LINE session invalidation on 401."""
server = LINEWebhookServer(
config=MagicMock(),
router=MagicMock(spec=CommandRouter)
config=MagicMock(), router=MagicMock(spec=CommandRouter)
)
server.session = MagicMock()
mock_response = AsyncMock()
mock_response.status = 401
post_ctx = AsyncMock()
post_ctx.__aenter__.return_value = mock_response
server.session.post.return_value = post_ctx
# Mock _get_header to avoid errors
server._get_header = MagicMock(return_value={})
await server._reply_message("token", "test")
self.assertTrue(server._session_invalid)
server.session.post.reset_mock()
await server._reply_message("token", "test")
server.session.post.assert_not_called()
@@ -85,15 +96,14 @@ class TestWebhookSessionInvalidation(unittest.IsolatedAsyncioTestCase):
async def test_wechat_session_invalidation(self):
"""Test WeChat session invalidation on 401 (token fetch)."""
server = WeChatWebhookServer(
config=MagicMock(),
router=MagicMock(spec=CommandRouter)
config=MagicMock(), router=MagicMock(spec=CommandRouter)
)
server.session = MagicMock()
# Mock token fetch response
mock_response = AsyncMock()
mock_response.status = 401
get_ctx = AsyncMock()
get_ctx.__aenter__.return_value = mock_response
server.session.get.return_value = get_ctx
@@ -101,39 +111,40 @@ class TestWebhookSessionInvalidation(unittest.IsolatedAsyncioTestCase):
token = await server._get_access_token()
self.assertIsNone(token)
self.assertTrue(server._session_invalid)
# Reset mock and try again
server.session.get.reset_mock()
# Second call should be blocked by _session_invalid check (which must be inside access_token logic)
# Wait - _get_access_token doesn't check _session_invalid at the start?
# Let's check implementation. If not, we should better add it or test assumes it.
# But wait, self._session_invalid = True was set.
# If the implementation doesn't check it at start of _get_access_token, it will try to fetch again?
# The test expects assert_not_called.
# Let's enforce it in the test by ensuring the method respects the flag OR
# duplicate the session invalidation check in _get_access_token (which is smart).
# If we didn't add the check in _get_access_token, let's just assert that it Returns None immediately?
# Actually proper R93 implies *locking* the connector.
# Let's see... if I look at the previous step, I only added the classification logic.
# I did not add "if self._session_invalid: return None" at the top of _get_access_token.
# So it probably TRIED to fetch again.
# We should probably add that check to _get_access_token too for completeness of R93.
# But for now let's fix the test expectation if we want to rely on the *result* being None
# But for now let's fix the test expectation if we want to rely on the *result* being None
# (and maybe it fetches again? No, we want to STOP traffic).
# Let's update the test to be realistic or update the code.
# R93 says "Connector platforms should stop retrying".
# So _get_access_token SHOULD check the flag.
# I will update the code in next step. For now let's update test to expect it to be blocked.
token2 = await server._get_access_token()
server.session.get.assert_not_called()
self.assertIsNone(token2)
if __name__ == "__main__":
unittest.main()
+19 -17
View File
@@ -1,19 +1,19 @@
import os
import shutil
import sqlite3
import tempfile
import time
import unittest
import tempfile
import shutil
from unittest.mock import MagicMock, patch
from services.idempotency_store import (
IdempotencyStore,
SQLiteDurableBackend,
DurableBackend,
IdempotencyStoreError
IdempotencyStore,
IdempotencyStoreError,
SQLiteDurableBackend,
)
class TestSQLiteDurableBackend(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
@@ -25,7 +25,7 @@ class TestSQLiteDurableBackend(unittest.TestCase):
self.backend.close()
except:
pass
# Retry cleanup for Windows file locking
for _ in range(5):
try:
@@ -45,18 +45,18 @@ class TestSQLiteDurableBackend(unittest.TestCase):
backend2 = SQLiteDurableBackend(self.db_path)
# If key exists, it returns True (fresh) if expired, or False (dup) if valid?
# check_and_record returns (True, pid) if existing
# WAIT: Protocol says:
# WAIT: Protocol says:
# Check if key exists; if not, record it. Returns (not_exists, existing_prompt_id) ??
# Docstring: "Returns (is_dup, existing_prompt_id)." in Protocol.
# Impl: if row: return True, existing_pid (meaning IS DUP).
# Impl: if not row: insert, return False, None (meaning NOT DUP).
# So duplicate -> True.
is_dup, pid = backend2.check_and_record("key1", 3600)
self.assertTrue(is_dup)
self.assertEqual(pid, "prompt1")
finally:
if 'backend2' in locals():
if "backend2" in locals():
backend2.close()
def test_ttl_expiry(self):
@@ -64,15 +64,16 @@ class TestSQLiteDurableBackend(unittest.TestCase):
# Insert with short TTL
self.backend.check_and_record("key_ttl", 1)
time.sleep(2) # Wait for expiry
# Cleanup should remove it
self.backend.cleanup()
# Should be fresh again
# Impl: if fresh, returns (False, None) -> is_dup=False
is_dup, pid = self.backend.check_and_record("key_ttl", 3600)
self.assertFalse(is_dup)
class TestIdempotencyStoreS50(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
@@ -88,11 +89,11 @@ class TestIdempotencyStoreS50(unittest.TestCase):
"""Test fail-closed behavior in strict mode."""
mock_backend = MagicMock(spec=DurableBackend)
mock_backend.check_and_record.side_effect = Exception("Disk failure")
# Singleton instantiation
store = IdempotencyStore()
store.configure_durable(backend=mock_backend, strict_mode=True)
with self.assertRaises(IdempotencyStoreError):
store.check_and_record("test_key", ttl=60)
@@ -100,17 +101,17 @@ class TestIdempotencyStoreS50(unittest.TestCase):
"""Test fallback behavior in lenient mode."""
mock_backend = MagicMock(spec=DurableBackend)
mock_backend.check_and_record.side_effect = Exception("Disk failure")
store = IdempotencyStore()
store.configure_durable(backend=mock_backend, strict_mode=False)
# Should NOT raise, but log error and fallback (or just return False? or True?)
# Implementation of IdempotencyStore in strict_mode=False absorbs errors?
# Let's check implementation behavior assumption:
# If backend fails, and not strict, it might default to "allow" (True) or "deny"?
# Actually existing implementation likely just logs and returns True (allow execution) or False?
# Typically fail-open for availability means returning True (fresh).
# Taking a peek at IdempotencyStore implementation would help, but assuming fail-open for now based on standard patterns.
# If it raises, I'll fix the test.
try:
@@ -120,5 +121,6 @@ class TestIdempotencyStoreS50(unittest.TestCase):
except IdempotencyStoreError:
self.fail("Should not raise IdempotencyStoreError in lenient mode")
if __name__ == "__main__":
unittest.main()
+18 -16
View File
@@ -1,44 +1,45 @@
import unittest
from services.safe_io import (
OutboundPolicy,
validate_outbound_url,
SSRFError,
STANDARD_OUTBOUND_POLICY,
STRICT_OUTBOUND_POLICY,
STANDARD_OUTBOUND_POLICY
OutboundPolicy,
SSRFError,
validate_outbound_url,
)
class TestOutboundPolicyS51(unittest.TestCase):
def test_strict_policy(self):
"""Test STRICT_OUTBOUND_POLICY enforcement."""
policy = STRICT_OUTBOUND_POLICY
# HTTPS 443 -> OK
self.assertIsNone(policy.validate("https", 443))
# HTTP -> Fail
self.assertIsNotNone(policy.validate("http", 80))
# Custom Port -> Fail
self.assertIsNotNone(policy.validate("https", 8443))
def test_standard_policy(self):
"""Test STANDARD_OUTBOUND_POLICY enforcement."""
policy = STANDARD_OUTBOUND_POLICY
# HTTP 80 -> OK
self.assertIsNone(policy.validate("http", 80))
# HTTPS 443 -> OK
self.assertIsNone(policy.validate("https", 443))
# 8080/8443 -> OK
self.assertIsNone(policy.validate("http", 8080))
self.assertIsNone(policy.validate("https", 8443))
# Ollama 11434 -> OK (as updated)
self.assertIsNone(policy.validate("http", 11434))
# Random port -> Fail
self.assertIsNotNone(policy.validate("http", 9999))
@@ -48,7 +49,7 @@ class TestOutboundPolicyS51(unittest.TestCase):
scheme, host, port, ips = validate_outbound_url(
"https://google.com",
allow_any_public_host=True,
policy=STRICT_OUTBOUND_POLICY
policy=STRICT_OUTBOUND_POLICY,
)
self.assertEqual(scheme, "https")
self.assertEqual(port, 443)
@@ -58,7 +59,7 @@ class TestOutboundPolicyS51(unittest.TestCase):
validate_outbound_url(
"http://google.com",
allow_any_public_host=True,
policy=STRICT_OUTBOUND_POLICY
policy=STRICT_OUTBOUND_POLICY,
)
self.assertIn("S51", str(cm.exception))
@@ -67,9 +68,10 @@ class TestOutboundPolicyS51(unittest.TestCase):
validate_outbound_url(
"http://google.com:9999",
allow_any_public_host=True,
policy=STANDARD_OUTBOUND_POLICY
policy=STANDARD_OUTBOUND_POLICY,
)
self.assertIn("S51", str(cm.exception))
if __name__ == "__main__":
unittest.main()
+74
View File
@@ -0,0 +1,74 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
class TestR100Provenance(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="wave_b_r100_")
self.artifacts = os.path.join(self.tmp, "dist")
os.makedirs(self.artifacts, exist_ok=True)
self.provenance_json = os.path.join(self.artifacts, "provenance.json")
with open(
os.path.join(self.artifacts, "artifact_a.txt"), "w", encoding="utf-8"
) as f:
f.write("artifact-a")
repo_root = Path(__file__).resolve().parents[1]
self.generate_script = str(repo_root / "scripts" / "generate_provenance.py")
self.verify_script = str(repo_root / "scripts" / "verify_provenance.py")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def _run(self, *args):
return subprocess.run(
[sys.executable, *args],
text=True,
capture_output=True,
check=False,
)
def test_provenance_lifecycle(self):
generate = self._run(self.generate_script, self.artifacts, self.provenance_json)
self.assertEqual(generate.returncode, 0, generate.stderr + generate.stdout)
self.assertTrue(os.path.exists(self.provenance_json))
with open(self.provenance_json, "r", encoding="utf-8") as f:
payload = json.load(f)
self.assertIn("artifact_a.txt", payload.get("artifacts", {}))
verify_ok = self._run(self.verify_script, self.artifacts, self.provenance_json)
self.assertEqual(verify_ok.returncode, 0, verify_ok.stderr + verify_ok.stdout)
with open(
os.path.join(self.artifacts, "artifact_a.txt"), "w", encoding="utf-8"
) as f:
f.write("tampered")
verify_tampered = self._run(
self.verify_script, self.artifacts, self.provenance_json
)
self.assertNotEqual(verify_tampered.returncode, 0)
self.assertIn("Checksum mismatch", verify_tampered.stdout)
def test_unlisted_file_detection(self):
generate = self._run(self.generate_script, self.artifacts, self.provenance_json)
self.assertEqual(generate.returncode, 0, generate.stderr + generate.stdout)
with open(
os.path.join(self.artifacts, "artifact_b.txt"), "w", encoding="utf-8"
) as f:
f.write("not listed")
verify = self._run(self.verify_script, self.artifacts, self.provenance_json)
self.assertNotEqual(verify.returncode, 0)
self.assertIn("Unlisted artifact found", verify.stdout)
if __name__ == "__main__":
unittest.main()
+35
View File
@@ -0,0 +1,35 @@
import unittest
from unittest.mock import MagicMock
from services.idempotency_store import IdempotencyStore
from services.rate_limit import RateLimiter
class TestR101StorageDoS(unittest.TestCase):
def setUp(self):
IdempotencyStore.reset_singleton()
def tearDown(self):
IdempotencyStore.reset_singleton()
def test_cleanup_called_in_durable_mode_eventually(self):
backend = MagicMock()
backend.check_and_record.return_value = (False, None)
store = IdempotencyStore()
store.configure_durable(backend=backend, strict_mode=False)
store.check_and_record("wave_b_key", ttl=60)
backend.cleanup.assert_called()
backend.check_and_record.assert_called_once()
def test_connector_and_trigger_quotas_exist(self):
limiter = RateLimiter()
self.assertIn("connector", limiter.defaults)
self.assertIn("trigger", limiter.defaults)
self.assertEqual(limiter.defaults["connector"][0], 20)
self.assertEqual(limiter.defaults["trigger"][0], 60)
if __name__ == "__main__":
unittest.main()
+81
View File
@@ -0,0 +1,81 @@
import os
import unittest
from unittest.mock import MagicMock
import services.sidecar.auth as auth_module
class TestR104MTLS(unittest.TestCase):
def setUp(self):
self.env_keys = [
"OPENCLAW_BRIDGE_ENABLED",
"OPENCLAW_BRIDGE_DEVICE_TOKEN",
"OPENCLAW_BRIDGE_ALLOWED_DEVICE_IDS",
"OPENCLAW_BRIDGE_MTLS_ENABLED",
"OPENCLAW_BRIDGE_DEVICE_CERT_MAP",
"MOLTBOT_BRIDGE_ENABLED",
"MOLTBOT_BRIDGE_DEVICE_TOKEN",
"MOLTBOT_BRIDGE_ALLOWED_DEVICE_IDS",
]
for key in self.env_keys:
os.environ.pop(key, None)
def tearDown(self):
for key in self.env_keys:
os.environ.pop(key, None)
def _base_request(self):
req = MagicMock()
req.headers = {
"X-OpenClaw-Device-Id": "dev1",
"X-OpenClaw-Device-Token": "token123",
}
return req
def _enable_base_auth(self):
os.environ["OPENCLAW_BRIDGE_ENABLED"] = "1"
os.environ["OPENCLAW_BRIDGE_DEVICE_TOKEN"] = "token123"
def test_mtls_disabled_by_default(self):
self._enable_base_auth()
req = self._base_request()
valid, err, device = auth_module.validate_device_token(req)
self.assertTrue(valid)
self.assertEqual(err, "")
self.assertEqual(device, "dev1")
def test_mtls_enforced_when_enabled(self):
self._enable_base_auth()
os.environ["OPENCLAW_BRIDGE_MTLS_ENABLED"] = "1"
req = self._base_request()
valid, err, _ = auth_module.validate_device_token(req)
self.assertFalse(valid)
self.assertIn("mTLS required", err)
def test_mtls_fingerprint_mismatch(self):
self._enable_base_auth()
os.environ["OPENCLAW_BRIDGE_MTLS_ENABLED"] = "1"
os.environ["OPENCLAW_BRIDGE_DEVICE_CERT_MAP"] = "dev1:sha256_expected"
req = self._base_request()
req.headers["X-Client-Cert-Hash"] = "sha256_actual"
valid, err, _ = auth_module.validate_device_token(req)
self.assertFalse(valid)
self.assertIn("fingerprint mismatch", err.lower())
def test_mtls_strict_unbound_device(self):
self._enable_base_auth()
os.environ["OPENCLAW_BRIDGE_MTLS_ENABLED"] = "1"
os.environ["OPENCLAW_BRIDGE_DEVICE_CERT_MAP"] = "dev2:sha256_for_other_device"
req = self._base_request()
req.headers["X-Client-Cert-Hash"] = "sha256_actual"
valid, err, _ = auth_module.validate_device_token(req)
self.assertFalse(valid)
self.assertIn("not bound", err.lower())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,41 @@
import hashlib
import os
import shutil
import tempfile
import unittest
from services.packs.pack_manifest import validate_manifest_integrity
class TestS52ManifestCompleteness(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="wave_b_s52_")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_unlisted_files_rejected(self):
pack_json_path = os.path.join(self.tmp, "pack.json")
with open(pack_json_path, "w", encoding="utf-8") as f:
f.write('{"name":"demo"}')
with open(pack_json_path, "rb") as f:
pack_hash = hashlib.sha256(f.read()).hexdigest()
with open(os.path.join(self.tmp, "evil.txt"), "w", encoding="utf-8") as f:
f.write("hidden payload")
manifest = {
"files": [
{
"path": "pack.json",
"sha256": pack_hash,
}
]
}
errors = validate_manifest_integrity(self.tmp, manifest)
self.assertTrue(any("Unlisted file found: evil.txt" in e for e in errors))
if __name__ == "__main__":
unittest.main()
+38
View File
@@ -0,0 +1,38 @@
import os
import shutil
import tempfile
import unittest
import zipfile
from services.packs.pack_archive import PackArchive, PackError
class TestS53ZipSlip(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix="wave_b_s53_")
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_unicode_traversal(self):
zip_path = os.path.join(self.tmp, "unicode_traversal.zip")
# U+FF0E FULLWIDTH FULL STOP x2 normalizes to ".." under NFKC.
unsafe_name = "\uFF0E\uFF0E/evil.txt"
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr(unsafe_name, "payload")
with self.assertRaisesRegex(PackError, "Unsafe filename"):
PackArchive.extract_pack(zip_path, os.path.join(self.tmp, "out"))
def test_drive_relative_path_rejected(self):
zip_path = os.path.join(self.tmp, "drive_relative.zip")
with zipfile.ZipFile(zip_path, "w") as zf:
zf.writestr("C:evil.txt", "payload")
with self.assertRaisesRegex(PackError, "Unsafe filename"):
PackArchive.extract_pack(zip_path, os.path.join(self.tmp, "out2"))
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -0,0 +1,53 @@
import os
import shutil
import tempfile
import unittest
from services.tool_runner import SandboxProfile
class TestS54PathTraversal(unittest.TestCase):
def setUp(self):
self.root = tempfile.mkdtemp(prefix="wave_b_s54_")
self.allowed = os.path.join(self.root, "allowed")
self.sibling = os.path.join(self.root, "allowed_evil")
os.makedirs(self.allowed, exist_ok=True)
os.makedirs(self.sibling, exist_ok=True)
self.profile = SandboxProfile(
network=False,
allow_fs_read=[self.allowed],
allow_fs_write=[self.allowed],
allow_network_hosts=[],
)
def tearDown(self):
shutil.rmtree(self.root, ignore_errors=True)
def test_prefix_bypass(self):
nested_ok = os.path.join(self.allowed, "safe.txt")
sibling_bad = os.path.join(self.sibling, "escape.txt")
ok = self.profile.validate_fs_access([nested_ok], write=False)
bad = self.profile.validate_fs_access([sibling_bad], write=False)
self.assertEqual(ok, [])
self.assertTrue(any("not in allow_fs_read" in item for item in bad))
def test_symlink_escape(self):
outside_file = os.path.join(self.root, "outside.txt")
with open(outside_file, "w", encoding="utf-8") as f:
f.write("outside")
link_path = os.path.join(self.allowed, "link_to_outside.txt")
try:
os.symlink(outside_file, link_path)
except (AttributeError, NotImplementedError, OSError):
self.skipTest("Symlink creation not available in this environment")
violations = self.profile.validate_fs_access([link_path], write=False)
self.assertTrue(any("not in allow_fs_read" in item for item in violations))
if __name__ == "__main__":
unittest.main()