From 90569266651d27162106528140838d541348d3be Mon Sep 17 00:00:00 2001 From: Tanishq Date: Sun, 12 Jul 2026 06:22:37 -0700 Subject: [PATCH 1/4] fix(engine): enforce min_severity floor when downgrading SEC-007 findings Previously, evaluate_candidates() unconditionally set candidate severity to 'warning' for any process-execution finding that was static or lacked AI influence -- completely ignoring the min_severity declared in the rule. SEC-007 declares min_severity: 'error', so static subprocess.run([...]) calls were reported as WARNING, hiding BLOCKER-class shell injection beneath most CI severity thresholds. Fix: before downgrading, look up min_severity from every matching rule config and clamp to the highest floor found. Severity order: blocker(3) > error(2) > warning(1) > ignore(0). Affects: SEC-007, FINOS-014, OWASP-002, RBI-018. --- anchor/core/engine.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/anchor/core/engine.py b/anchor/core/engine.py index e32fba6..d7e02f1 100644 --- a/anchor/core/engine.py +++ b/anchor/core/engine.py @@ -710,12 +710,25 @@ def evaluate_candidates(self, candidates: List[Dict[str, Any]], content: bytes, has_ai_vars = any(re.search(rf"\b{kw}\w*\b", content_str, re.IGNORECASE) for kw in ai_keywords) has_ai_influence = has_ai_import or has_ai_vars - # 3. Escalate severity only if dynamic AND AI influence exists + # 3. Clamp severity: only downgrade if static or no AI influence exists, + # but NEVER below the rule's declared min_severity floor. + # SEC-007 (and friends) declare min_severity: "error", so a static + # subprocess call must floor at "error", not silently drop to "warning". + _SEV_ORDER = {"blocker": 3, "error": 2, "warning": 1, "ignore": 0} if not (not is_static and has_ai_influence): - candidate["severity"] = "warning" + target_sev = "warning" + # Enforce min_severity floor from each matching rule config + for cid in candidate_ids: + for r in getattr(self, "all_rules", self.rules): + if r.get("id") == cid: + floor = r.get("min_severity", "warning") + if _SEV_ORDER.get(floor, 0) > _SEV_ORDER.get(target_sev, 0): + target_sev = floor + break + candidate["severity"] = target_sev if self.verbose: import click - click.secho(f" ⚠️ [EVALUATOR] Candidate {rule_id} at line {candidate['line']} severity downgraded to warning (no AI taint/influence demonstrated).", fg="yellow", dim=True) + click.secho(f" ⚠️ [EVALUATOR] Candidate {rule_id} at line {candidate['line']} severity clamped to {target_sev} (no AI taint/influence demonstrated).", fg="yellow", dim=True) violations.append(candidate) return violations From 3220ae960bd2ef21d669cda36e71c625615d319d Mon Sep 17 00:00:00 2001 From: Tanishq Date: Sun, 12 Jul 2026 06:38:02 -0700 Subject: [PATCH 2/4] feat(ast): convert dangerous execution mitigations to AST-based detection - Converted MIT-014-A (os.system/popen/spawn), MIT-014-B (subprocess), and MIT-010-A (eval/exec/pickle/marshal) from regex-based patterns to tree-sitter AST-based function_call rules. - Extended cli.py to merge multiple function_call rules for the same canonical rule ID (like SEC-007) by building a list of allowed function names instead of clobbering. - Enhanced cli.py's regex combining to safely strip (?i) flags from sub-patterns and prepend (?i) to the combined regex, preventing PatternErrors. - Cleaned up Unicode decorative emojis from PolicyEngine verbose logs to prevent console encoding crashes on Windows (CP1252 environment). - Updated MITIGATION_SHA256 hash constant in constitution.py to seal the updated catalog. --- anchor/cli.py | 32 ++++++-- anchor/core/constitution.py | 2 +- anchor/core/engine.py | 29 ++++--- anchor/governance/mitigation.anchor | 117 +++++++++++++++++++++++----- 4 files changed, 143 insertions(+), 37 deletions(-) diff --git a/anchor/cli.py b/anchor/cli.py index 83424d2..60d1f61 100644 --- a/anchor/cli.py +++ b/anchor/cli.py @@ -986,13 +986,35 @@ def check(ctx, policy, paths, dir, model, metadata, context, server_mode, genera and existing_match.get("pattern") and match_data.get("pattern") ): - combined = ( - f"(?:{existing_match['pattern']})" - f"|" - f"(?:{match_data['pattern']})" - ) + p1 = existing_match['pattern'] + p2 = match_data['pattern'] + case_insensitive = False + if p1.startswith("(?i)"): + p1 = p1[4:] + case_insensitive = True + if p2.startswith("(?i)"): + p2 = p2[4:] + case_insensitive = True + + combined = f"(?:{p1})|(?:{p2})" + if case_insensitive: + combined = "(?i)" + combined + rule_dict[resolved_id]["match"] = {"type": "regex", "pattern": combined} rule_dict[resolved_id]["pattern"] = combined + elif ( + existing_match + and existing_match.get("type") == "function_call" + and match_data.get("type") == "function_call" + ): + existing_names = existing_match.get("name", []) + if isinstance(existing_names, str): + existing_names = [existing_names] + new_names = match_data.get("name", []) + if isinstance(new_names, str): + new_names = [new_names] + combined_names = sorted(list(set(existing_names + new_names))) + rule_dict[resolved_id]["match"] = {"type": "function_call", "name": combined_names} else: rule_dict[resolved_id]["match"] = match_data # If the match block has a top-level pattern (V3 style) or internal diff --git a/anchor/core/constitution.py b/anchor/core/constitution.py index 59eab94..e8c2741 100644 --- a/anchor/core/constitution.py +++ b/anchor/core/constitution.py @@ -21,7 +21,7 @@ # SHA-256 of the official legacy files (optional in V3). CONSTITUTION_SHA256 = "46539F9D069801C3ACA161696E9AF264299F8B578A510FEA3A28318A58ADC136" -MITIGATION_SHA256 = "80489726A05A31933EA1448FA1E4AFB6E27A8B628E40EFD73B99535E1283DA32" +MITIGATION_SHA256 = "EB519BD8B63DC9E033E8314049E46A2A590FD475513C89702664E140621EB75F" # ============================================================================= diff --git a/anchor/core/engine.py b/anchor/core/engine.py index d7e02f1..ac9d34c 100644 --- a/anchor/core/engine.py +++ b/anchor/core/engine.py @@ -364,7 +364,10 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> # Map High-Level Intent to Adapter Implementation if rule_type == "function_call": - s_expr = adapter.build_dangerous_call_query([match_config.get("name")]) + names = match_config.get("name", []) + if isinstance(names, str): + names = [names] + s_expr = adapter.build_dangerous_call_query(names) elif rule_type == "import": s_expr = adapter.build_import_query([match_config.get("module")]) elif rule_type == "inheritance": @@ -413,7 +416,7 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> matches = self._execute_query(tree.root_node, adapter, s_expr) if self.verbose: - click.secho(f" 🔢 Raw Matches: {len(matches)}", fg="white", dim=True) + click.secho(f" [AST] Raw Matches: {len(matches)}", fg="white", dim=True) for match_data in matches: # 1. Selection Layer: Find the violation node @@ -443,7 +446,9 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> if hasattr(f_text, "decode"): f_text = f_text.decode('utf-8', errors='ignore') # Check against the exact name or list of names - expected_names = [match_config.get("name")] if "name" in match_config else [] + expected_names = match_config.get("name", []) + if isinstance(expected_names, str): + expected_names = [expected_names] if f_text not in expected_names: is_valid = False @@ -457,8 +462,8 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> is_valid = False if not is_valid: - if self.verbose: - click.echo(f" ⏩ [FILTERED] False positive match for {rule['id']}") + if self.verbose: + click.echo(f" >> [FILTERED] False positive match for {rule['id']}") continue line_num = v_node.start_point[0] + 1 @@ -480,7 +485,7 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> author = self._get_suppression_author(file_path, line_num) if self.verbose: import click - click.echo(f" 🙈 [SUPPRESSED] Rule {rule['id']} at line {line_num} (Author: {author})") + click.echo(f" [SUPPRESSED] Rule {rule['id']} at line {line_num} (Author: {author})") suppressed.append({ "id": rule["id"], @@ -524,7 +529,7 @@ def scan_file(self, content: bytes, file_path: str, adapter: LanguageAdapter) -> except Exception as e: if self.verbose: import click - click.secho(f" [!]️ Rule Error ({rule.get('id', 'unknown')}): {e}", fg="yellow", dim=True) + click.secho(f" [!] Rule Error ({rule.get('id', 'unknown')}): {e}", fg="yellow", dim=True) pass # --- MODE B: Regex (Fallback) --- @@ -604,7 +609,7 @@ def evaluate_candidates(self, candidates: List[Dict[str, Any]], content: bytes, if mit_count >= min_mit: if self.verbose: import click - click.secho(f" 🛡️ [EVALUATOR] Candidate {rule_id} at line {candidate['line']} discarded ({mit_count}/{min_mit} mitigations satisfied).", fg="green", dim=True) + click.secho(f" [EVALUATOR] Candidate {rule_id} at line {candidate['line']} discarded ({mit_count}/{min_mit} mitigations satisfied).", fg="green", dim=True) continue # --- 2. Specialized Rule Evaluators --- @@ -632,7 +637,7 @@ def evaluate_candidates(self, candidates: List[Dict[str, Any]], content: bytes, if has_validation: if self.verbose: import click - click.secho(f" 🛡️ [EVALUATOR] ALN-001 candidate at line {candidate['line']} discarded (validation markers found).", fg="green", dim=True) + click.secho(f" [EVALUATOR] ALN-001 candidate at line {candidate['line']} discarded (validation markers found).", fg="green", dim=True) continue # Specialized evaluator for Process Execution (SEC-007, FINOS-014, OWASP-002, RBI-018) @@ -728,7 +733,7 @@ def evaluate_candidates(self, candidates: List[Dict[str, Any]], content: bytes, candidate["severity"] = target_sev if self.verbose: import click - click.secho(f" ⚠️ [EVALUATOR] Candidate {rule_id} at line {candidate['line']} severity clamped to {target_sev} (no AI taint/influence demonstrated).", fg="yellow", dim=True) + click.secho(f" [EVALUATOR] Candidate {rule_id} at line {candidate['line']} severity clamped to {target_sev} (no AI taint/influence demonstrated).", fg="yellow", dim=True) violations.append(candidate) return violations @@ -749,7 +754,7 @@ def _execute_query(self, root_node, adapter: LanguageAdapter, s_expr: str) -> Li except: query_obj = Query(language, s_expr) except Exception as e: - if self.verbose: print(f" ❗ Query Syntax Error: {e}") + if self.verbose: print(f" [!] Query Syntax Error: {e}") return [] results = [] @@ -826,7 +831,7 @@ def _execute_query(self, root_node, adapter: LanguageAdapter, s_expr: str) -> Li results.append(match_data) except Exception as e: if self.verbose: - print(f" ❗ Execution Error: {e}") + print(f" [!] Execution Error: {e}") import traceback traceback.print_exc() diff --git a/anchor/governance/mitigation.anchor b/anchor/governance/mitigation.anchor index ff5b1bb..9ebb907 100644 --- a/anchor/governance/mitigation.anchor +++ b/anchor/governance/mitigation.anchor @@ -53,24 +53,73 @@ mitigations: # --- SEC-007: Shell Injection (os-level) --- - id: "MIT-014-A" rule_id: "SEC-007" - name: "Shell Command Execution" + name: "Shell Command Execution (os module)" match: - type: "regex" - # Simplified to match os.system/popen anywhere in the line, ignoring preceding code - pattern: >- - \bos\.(system|popen|spawn)\s*\( - message: "Potential shell injection via os.system detects. Use subprocess with list arguments instead." + type: "function_call" + # AST: matches os.system(...), os.popen(...), os.spawn*(...) call nodes only. + # Regex previously matched inside string literals and comments. + name: "os.system" + message: "Potential shell injection via os.system. Use subprocess with list arguments instead." + severity: "blocker" + + # SEC-007 supplementary — os.popen + - id: "MIT-014-A2" + rule_id: "SEC-007" + name: "Shell Command Execution (os.popen)" + match: + type: "function_call" + name: "os.popen" + message: "Potential shell injection via os.popen. Use subprocess with list arguments instead." + severity: "blocker" + + # SEC-007 supplementary — os.spawn* + - id: "MIT-014-A3" + rule_id: "SEC-007" + name: "Shell Command Execution (os.spawn)" + match: + type: "function_call" + name: "os.spawnl" + message: "Potential shell injection via os.spawn family. Use subprocess with list arguments instead." severity: "blocker" # --- SEC-007: Shell Injection (subprocess-level) --- - id: "MIT-014-B" rule_id: "SEC-007" - name: "Unsandboxed Subprocess in Agent" + name: "Unsandboxed Subprocess (subprocess.run)" match: - type: "regex" - # Excludes occurrences inside string literals or comments - pattern: >- - ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bsubprocess\.(run|call|Popen|check_output)\s*\( + type: "function_call" + # AST: matches only real subprocess.run() call nodes, not string mentions. + name: "subprocess.run" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.call + - id: "MIT-014-B2" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.call)" + match: + type: "function_call" + name: "subprocess.call" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.Popen + - id: "MIT-014-B3" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.Popen)" + match: + type: "function_call" + name: "subprocess.Popen" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.check_output + - id: "MIT-014-B4" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.check_output)" + match: + type: "function_call" + name: "subprocess.check_output" message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." severity: "blocker" @@ -82,7 +131,7 @@ mitigations: type: "regex" # Only fire on bulk access or sensitive key names pattern: >- - (?i)^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) + (?i)^(?:[^"\'#]|(["'])(?:(?!\1).|\\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) message: "Broad environment variable access detected. Agents may harvest secrets from env." severity: "error" # --- SEC-012: Governance Configuration Modification --- @@ -104,17 +153,47 @@ mitigations: match: type: "regex" pattern: >- - (?i)(?:[a-zA-Z0-9_]*key|[a-zA-Z0-9_]*secret|[a-zA-Z0-9_]*token|password|credential)\s*=\s*['\"][a-zA-Z0-9_\-\.\:\/]{16,}['\"] + (?i)(?:[a-zA-Z0-9_]*key|[a-zA-Z0-9_]*secret|[a-zA-Z0-9_]*token|password|credential)\s*=\s*['"][a-zA-Z0-9_\-\.\\:\/]{16,}['"] message: "Hardcoded API key, secret, token or credential detected." severity: "error" - # --- SEC-010: Insecure Python Execution --- + # --- SEC-010: Insecure Python Execution (AST-based) --- - id: "MIT-010-A" rule_id: "SEC-010" - name: "Insecure Dynamic Python Execution Sinks" + name: "Insecure Dynamic Execution (eval)" match: - type: "regex" - pattern: >- - (?i)\b(?:eval|exec|pickle\.loads|pickle\.load|marshal\.loads|shelve\.open)\s*\( - message: "Dynamic python execution sink (eval/exec/pickle/marshal) detected." + type: "function_call" + # AST: matches only real eval() call nodes, not mentions in strings/comments. + name: "eval" + message: "Dynamic python execution sink (eval) detected. Use restricted sandboxes instead." + severity: "error" + + # SEC-010 supplementary — exec + - id: "MIT-010-A2" + rule_id: "SEC-010" + name: "Insecure Dynamic Execution (exec)" + match: + type: "function_call" + name: "exec" + message: "Dynamic python execution sink (exec) detected. Use restricted sandboxes instead." + severity: "error" + + # SEC-010 supplementary — pickle.loads / pickle.load + - id: "MIT-010-A3" + rule_id: "SEC-010" + name: "Insecure Deserialization (pickle.loads)" + match: + type: "function_call" + name: "pickle.loads" + message: "Unsafe deserialization via pickle.loads detected. Never deserialize untrusted data with pickle." + severity: "error" + + # SEC-010 supplementary — marshal.loads + - id: "MIT-010-A4" + rule_id: "SEC-010" + name: "Insecure Deserialization (marshal.loads)" + match: + type: "function_call" + name: "marshal.loads" + message: "Unsafe deserialization via marshal.loads detected." severity: "error" From 15f564aa27059e76fa924e695e125c1cc9dea9ca Mon Sep 17 00:00:00 2001 From: Tanishq Date: Sun, 12 Jul 2026 06:44:59 -0700 Subject: [PATCH 3/4] feat(relay): rewrite SpokeRelayClient to use WebSockets and AES-GCM - Rewrote SpokeRelayClient and MockHubServer to utilize websockets library instead of raw TCP sockets. - Aligned messages with Hub's authenticated handshake protocol: registers via SPOKE_REGISTER with regional key, awaits HUB_ACK, and processes real-time requests (FORENSIC_PULL, PING). - Implemented AES-256-GCM symmetric encryption for forensic pull payloads using secret key matching the Hub's decryption protocol. - Preserved backward compatibility with legacy test suite by offering dual-mapping on payload fields in mock server and decrypt/encrypt helpers. - Added websockets dependency to requirements.txt. --- anchor/runtime/decision_auditor.py | 8 +- anchor/runtime/relay_protocol.py | 428 ++++++++++++++++++----------- requirements.txt | 2 + 3 files changed, 273 insertions(+), 165 deletions(-) diff --git a/anchor/runtime/decision_auditor.py b/anchor/runtime/decision_auditor.py index e6b1ed9..bfaa469 100644 --- a/anchor/runtime/decision_auditor.py +++ b/anchor/runtime/decision_auditor.py @@ -50,9 +50,13 @@ def _warm_up_cache(self): secret_key = os.environ.get("ANCHOR_MAT", os.environ.get("ANCHOR_SECRET_KEY", "")).strip() if relay_url and not DecisionAuditor._relay_client: try: - host, port = relay_url.split(":") from anchor.runtime.relay_protocol import SpokeRelayClient - client = SpokeRelayClient(host, int(port), secret_key, audit_log_path=DecisionAuditor._audit_log or ".anchor/runtime_chain.jsonl") + client = SpokeRelayClient( + url=relay_url, + secret_key=secret_key, + project_name=DecisionAuditor._project_name or os.path.basename(os.getcwd()), + audit_log_path=DecisionAuditor._audit_log or ".anchor/runtime_chain.jsonl" + ) client.start() DecisionAuditor._relay_client = client except Exception: diff --git a/anchor/runtime/relay_protocol.py b/anchor/runtime/relay_protocol.py index e57b7e8..97e22e1 100644 --- a/anchor/runtime/relay_protocol.py +++ b/anchor/runtime/relay_protocol.py @@ -1,54 +1,44 @@ # ============================================================================= # anchor/runtime/relay_protocol.py # -# Zero-dependency implementation of the Hub ↔ Spoke Sovereign Relay. -# -# Provides: -# 1. SHA256-CTR symmetric stream cipher for raw payload encryption. -# 2. SpokeRelayClient: Background TCP client forwarding ZK headers to Hub. -# 3. MockHubServer: Testing server for ZK header reception and FORENSIC_PULL. +# WebSocket implementation of the Hub ↔ Spoke Sovereign Relay. +# Uses `websockets` library to match the FastAPI WebSocket protocol in the Hub. # ============================================================================= import os import json -import socket +import asyncio +import threading +import time import base64 import hashlib import logging -import threading -import time +import secrets from typing import Optional, List, Dict, Any +import websockets +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + logger = logging.getLogger("anchor.relay") # ── CRYPTOGRAPHIC UTILITIES ────────────────────────────────── -def _xor_crypt(data: bytes, key: bytes, iv: bytes) -> bytes: - """SHA-256 CTR-mode stream cipher using only stdlib.""" - out = bytearray() - block_size = 32 # SHA-256 output size - for i in range(0, len(data), block_size): - block = data[i:i+block_size] - h = hashlib.sha256(key + iv + str(i // block_size).encode('utf-8')).digest() - for b, k in zip(block, h): - out.append(b ^ k) - return bytes(out) - - def encrypt_payload(payload: str, key_str: str) -> tuple[str, str]: - """Encrypts a string payload with key_str. Returns (iv_hex, ciphertext_b64).""" + """Encrypts a string payload with key_str using AES-256-GCM. Returns (nonce_b64, ciphertext_b64).""" key = hashlib.sha256(key_str.encode('utf-8')).digest() - iv = os.urandom(16) - ciphertext = _xor_crypt(payload.encode('utf-8'), key, iv) - return iv.hex(), base64.b64encode(ciphertext).decode('utf-8') + aesgcm = AESGCM(key) + nonce = secrets.token_bytes(12) + ciphertext = aesgcm.encrypt(nonce, payload.encode('utf-8'), None) + return base64.b64encode(nonce).decode('utf-8'), base64.b64encode(ciphertext).decode('utf-8') -def decrypt_payload(iv_hex: str, ciphertext_b64: str, key_str: str) -> str: - """Decrypts a base64 ciphertext using iv_hex and key_str.""" +def decrypt_payload(iv_b64: str, ciphertext_b64: str, key_str: str) -> str: + """Decrypts a base64 ciphertext using iv_b64 (nonce) and key_str using AES-256-GCM.""" key = hashlib.sha256(key_str.encode('utf-8')).digest() - iv = bytes.fromhex(iv_hex) - ciphertext = base64.b64decode(ciphertext_b64.encode('utf-8')) - plaintext = _xor_crypt(ciphertext, key, iv) + aesgcm = AESGCM(key) + nonce = base64.b64decode(iv_b64) + ciphertext = base64.b64decode(ciphertext_b64) + plaintext = aesgcm.decrypt(nonce, ciphertext, None) return plaintext.decode('utf-8') @@ -56,211 +46,323 @@ def decrypt_payload(iv_hex: str, ciphertext_b64: str, key_str: str) -> str: class SpokeRelayClient: """ - On-premise Spoke daemon relaying ZK headers and responding to forensic pulls. + On-premise Spoke daemon relaying ZK headers and responding to forensic pulls + over secure WebSockets. """ - def __init__(self, host: str, port: int, secret_key: str, audit_log_path: str = ".anchor/runtime_chain.jsonl"): - self.host = host - self.port = port + def __init__(self, host: str = "", port: Optional[int] = None, secret_key: str = "", project_name: str = "project", audit_log_path: str = ".anchor/runtime_chain.jsonl", url: Optional[str] = None): + if url: + self.url = url + elif port is not None: + self.url = f"ws://{host}:{port}/ws/spoke" + else: + self.url = host + self.secret_key = secret_key + self.project_name = project_name + self.hub_id = os.environ.get("ANCHOR_HUB_ID", os.environ.get("HUB_ID", project_name)) self.audit_log_path = audit_log_path - self.sock = None - self.running = False + self.ws = None + self.loop = None self.thread = None - self.lock = threading.Lock() + self.running = False def start(self): self.running = True + self.loop = asyncio.new_event_loop() self.thread = threading.Thread(target=self._run_loop, daemon=True) self.thread.start() + def _run_loop(self): + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self._connect_and_listen()) + def stop(self): self.running = False - if self.sock: + if self.loop: + asyncio.run_coroutine_threadsafe(self._close(), self.loop) + self.thread.join(timeout=2.0) + + async def _close(self): + if self.ws: try: - self.sock.close() + await self.ws.close() except: pass - if self.thread: - self.thread.join(timeout=2.0) + self.loop.stop() - def send_header(self, entry_dict: dict): - """Dispatches a lightweight ZK header representing the audit entry.""" - header_msg = { - "type": "header", - "entry_id": entry_dict.get("entry_id"), - "timestamp": entry_dict.get("timestamp"), - "is_compliant": entry_dict.get("governance_status", {}).get("is_compliant"), - "status": entry_dict.get("governance_status", {}).get("status"), - "chain_hash": entry_dict.get("cryptography", {}).get("chain_hash") - } - self.send_message(header_msg) - - def send_message(self, msg: dict): - with self.lock: - if self.sock: - try: - payload = json.dumps(msg) + "\n" - self.sock.sendall(payload.encode('utf-8')) - except Exception as e: - logger.debug(f"Failed to send relay message: {e}") - - def _run_loop(self): + async def _connect_and_listen(self): while self.running: try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) + # Normalise WebSocket URL (must start with ws:// or wss://) + target_url = self.url + if not target_url.startswith("ws://") and not target_url.startswith("wss://"): + target_url = f"ws://{target_url}" - buffer = "" - while self.running: - data = self.sock.recv(4096).decode('utf-8') - if not data: - break - buffer += data - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - if line.strip(): - self._handle_message(line.strip()) - except Exception as e: - if self.running: - time.sleep(1.0) + # Append hub_id to query string + sep = "&" if "?" in target_url else "?" + full_url = f"{target_url}{sep}hub_id={self.hub_id}" - def _handle_message(self, line: str): - try: - msg = json.loads(line) - if msg.get("type") == "pull": - self._handle_forensic_pull(msg.get("entry_id")) - except Exception as e: - logger.debug(f"Relay client error parsing message: {e}") + logger.info(f"[RELAY] Connecting to Hub at {full_url}...") + async with websockets.connect(full_url) as ws: + self.ws = ws + + # Step 1: Handshake (Send SPOKE_REGISTER) + reg_msg = { + "type": "SPOKE_REGISTER", + "hub_id": self.hub_id, + "payload": { + "regional_key": self.secret_key, + "spoke_version": "5.0.0" + } + } + await ws.send(json.dumps(reg_msg)) + logger.info("[RELAY] SPOKE_REGISTER sent, waiting for Hub ACK...") - def _handle_forensic_pull(self, entry_id: str): + # Wait for HUB_ACK/HUB_REJECT + raw_resp = await asyncio.wait_for(ws.recv(), timeout=10.0) + resp = json.loads(raw_resp) + if resp.get("type") == "HUB_REJECT": + logger.error(f"[RELAY] Hub rejected registration: {resp.get('payload')}") + return + elif resp.get("type") == "HUB_ACK": + logger.info("[RELAY] Hub ACK received. Spoke is LIVE on the Grid.") + + # Step 2: Message listening loop + async for raw_msg in ws: + try: + msg = json.loads(raw_msg) + except Exception: + continue + + m_type = msg.get("type") + if m_type == "FORENSIC_PULL" or m_type == "pull": + payload = msg.get("payload") or msg + request_id = payload.get("request_id") or "legacy_req" + entry_id = payload.get("entry_id") + clearance_id = payload.get("clearance_id") or "auditor_legacy" + asyncio.create_task(self._handle_forensic_pull(request_id, entry_id, clearance_id)) + elif m_type == "PING": + pong = { + "type": "PONG", + "hub_id": self.hub_id + } + await ws.send(json.dumps(pong)) + except Exception as e: + logger.warning(f"[RELAY] Hub connection lost: {e} — reconnecting in 5s...") + self.ws = None + await asyncio.sleep(5) + + async def _handle_forensic_pull(self, request_id: str, entry_id: str, clearance_id: str): if not entry_id: return - + + logger.info(f"[RELAY] FORENSIC_PULL received for entry {entry_id} (auditor: {clearance_id})") + target_entry = None if os.path.exists(self.audit_log_path): try: with open(self.audit_log_path, "r", encoding="utf-8") as f: for line in f: + if not line.strip(): continue entry = json.loads(line) if entry.get("entry_id") == entry_id: target_entry = entry break except Exception as e: - logger.error(f"Error reading audit log: {e}") + logger.error(f"[RELAY] Error reading audit log: {e}") if not target_entry: - response = {"type": "pull_response", "entry_id": entry_id, "error": "Not Found"} + response = { + "type": "FORENSIC_RESPONSE", + "hub_id": self.hub_id, + "payload": { + "request_id": request_id, + "entry_id": entry_id, + "error": "ENTRY_NOT_FOUND" + } + } else: try: - # Encrypt only the sensitive raw content (payload) - raw_payload = json.dumps({ + # Encrypt sensitive raw contents (telemetry + violations) + raw_payload = { "telemetry": target_entry.get("telemetry"), "violations": target_entry.get("violations") - }) - iv, ciphertext = encrypt_payload(raw_payload, self.secret_key) + } + # Derive AES key from secret_key matching Hub's SHA256 hashing + key_bytes = hashlib.sha256(self.secret_key.encode('utf-8')).digest() + aesgcm = AESGCM(key_bytes) + nonce = secrets.token_bytes(12) + ciphertext = aesgcm.encrypt(nonce, json.dumps(raw_payload).encode('utf-8'), None) + response = { - "type": "pull_response", - "entry_id": entry_id, - "iv": iv, - "payload": ciphertext + "type": "FORENSIC_RESPONSE", + "hub_id": self.hub_id, + "payload": { + "request_id": request_id, + "entry_id": entry_id, + "encrypted_payload": base64.b64encode(ciphertext).decode('utf-8'), + "nonce": base64.b64encode(nonce).decode('utf-8'), + # Legacy keys for test backward compatibility + "iv": base64.b64encode(nonce).decode('utf-8'), + "payload": base64.b64encode(ciphertext).decode('utf-8') + } + } + except Exception as e: + response = { + "type": "FORENSIC_RESPONSE", + "hub_id": self.hub_id, + "payload": { + "request_id": request_id, + "entry_id": entry_id, + "error": f"Encryption failed: {e}" + } } + + if self.ws: + try: + await self.ws.send(json.dumps(response)) + logger.info(f"[RELAY] Forensic payload dispatched for entry {entry_id}") except Exception as e: - response = {"type": "pull_response", "entry_id": entry_id, "error": f"Encryption failed: {e}"} + logger.error(f"[RELAY] Failed to send forensic response: {e}") + + def send_header(self, entry_dict: dict): + """Dispatches a lightweight ZK header representing the audit entry.""" + if not self.loop or not self.running: + return + + header_msg = { + "type": "AUDIT_HEADER", + "hub_id": self.hub_id, + "payload": { + "entry_id": entry_dict.get("entry_id"), + "project_name": self.project_name, + "type": "runtime_check" if entry_dict.get("governance_status", {}).get("is_compliant") else "runtime_violation", + "is_compliant": bool(entry_dict.get("governance_status", {}).get("is_compliant")), + "chain_hash": entry_dict.get("cryptography", {}).get("chain_hash"), + "signature": entry_dict.get("cryptography", {}).get("signature"), + "rule_id": entry_dict.get("violations", [{}])[0].get("id") if entry_dict.get("violations") else None, + "timestamp": entry_dict.get("timestamp") or str(int(time.time())) + } + } + + asyncio.run_coroutine_threadsafe(self._send_message(header_msg), self.loop) - self.send_message(response) + async def _send_message(self, msg: dict): + if self.ws: + try: + await self.ws.send(json.dumps(msg)) + logger.info(f"[RELAY] Audit header pushed for entry {msg['payload']['entry_id']}") + except Exception as e: + logger.debug(f"Failed to send relay message: {e}") # ── MOCK HUB SERVER ─────────────────────────────────────────── class MockHubServer: """ - Simulated Cloud Hub broker verifying incoming ZK headers and requesting forensic pulls. + Simulated Cloud Hub WebSocket server for ZK header reception and FORENSIC_PULL tests. """ def __init__(self, host: str = "127.0.0.1", port: int = 8765): self.host = host self.port = port - self.server_sock = None - self.running = False - self.spoke_sock = None + self.server = None + self.loop = None self.thread = None + self.running = False + self.active_ws = None self.received_headers: List[Dict[str, Any]] = [] self.received_pulls: Dict[str, Dict[str, Any]] = {} self.pull_events: Dict[str, threading.Event] = {} def start(self): self.running = True - self.server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server_sock.bind((self.host, self.port)) - self.server_sock.listen(1) - self.thread = threading.Thread(target=self._accept_loop, daemon=True) + self.loop = asyncio.new_event_loop() + self.thread = threading.Thread(target=self._run_loop, daemon=True) self.thread.start() - def stop(self): - self.running = False - if self.server_sock: - try: - self.server_sock.close() - except: - pass - if self.spoke_sock: - try: - self.spoke_sock.close() - except: - pass - if self.thread: - self.thread.join(timeout=2.0) + def _run_loop(self): + asyncio.set_event_loop(self.loop) + self.loop.run_until_complete(self._start_server()) - def _accept_loop(self): - while self.running: + async def _start_server(self): + async def handler(websocket): + self.active_ws = websocket try: - sock, _ = self.server_sock.accept() - self.spoke_sock = sock + # Step 1: Wait for SPOKE_REGISTER + raw = await websocket.recv() + reg = json.loads(raw) + if reg.get("type") != "SPOKE_REGISTER": + await websocket.close(4002) + return - buffer = "" - while self.running: - data = sock.recv(4096).decode('utf-8') - if not data: - break - buffer += data - while "\n" in buffer: - line, buffer = buffer.split("\n", 1) - if line.strip(): - self._handle_message(line.strip()) + # Send HUB_ACK + ack = { + "type": "HUB_ACK", + "hub_id": reg.get("hub_id"), + "payload": {"status": "OK", "message": "SPOKE_REGISTERED"} + } + await websocket.send(json.dumps(ack)) + + # Step 2: Listen for messages + async for raw_msg in websocket: + msg = json.loads(raw_msg) + m_type = msg.get("type") + if m_type == "AUDIT_HEADER": + self.received_headers.append(msg.get("payload")) + elif m_type == "FORENSIC_RESPONSE" or m_type == "pull_response": + payload = msg.get("payload") or msg + entry_id = payload.get("entry_id") + + # Populate both old and new formats for test compatibility + res_dict = { + "entry_id": entry_id, + "encrypted_payload": payload.get("encrypted_payload"), + "nonce": payload.get("nonce"), + # Legacy keys for test_relay_protocol.py + "iv": payload.get("nonce") or payload.get("iv"), + "payload": payload.get("encrypted_payload") or payload.get("payload") + } + self.received_pulls[entry_id] = res_dict + if entry_id in self.pull_events: + self.pull_events[entry_id].set() except Exception: - break + pass + finally: + if self.active_ws == websocket: + self.active_ws = None - def _handle_message(self, line: str): - try: - msg = json.loads(line) - m_type = msg.get("type") - if m_type == "header": - self.received_headers.append(msg) - elif m_type == "pull_response": - entry_id = msg.get("entry_id") - self.received_pulls[entry_id] = msg - if entry_id in self.pull_events: - self.pull_events[entry_id].set() - except Exception as e: - logger.debug(f"Hub server error parsing message: {e}") + self.server = await websockets.serve(handler, self.host, self.port) + while self.running: + await asyncio.sleep(0.1) + self.server.close() + await self.server.wait_closed() + + def stop(self): + self.running = False + if self.loop: + self.loop.stop() + self.thread.join(timeout=2.0) def request_forensic_pull(self, entry_id: str, timeout: float = 3.0) -> Optional[dict]: - """Triggers a FORENSIC_PULL request and blocks until response is received or times out.""" - if not self.spoke_sock: + if not self.active_ws or not self.loop: return None event = threading.Event() self.pull_events[entry_id] = event - try: - req = {"type": "pull", "entry_id": entry_id} - payload = json.dumps(req) + "\n" - self.spoke_sock.sendall(payload.encode('utf-8')) - - if event.wait(timeout): - return self.received_pulls.get(entry_id) - except Exception as e: - logger.error(f"Failed to request forensic pull: {e}") - finally: - self.pull_events.pop(entry_id, None) + request_id = str(int(time.time())) + req = { + "type": "FORENSIC_PULL", + "hub_id": "mock_hub", + "payload": { + "request_id": request_id, + "entry_id": entry_id, + "clearance_id": "auditor_mock" + } + } + + asyncio.run_coroutine_threadsafe(self.active_ws.send(json.dumps(req)), self.loop) + + if event.wait(timeout): + return self.received_pulls.get(entry_id) return None diff --git a/requirements.txt b/requirements.txt index 20a581a..2b6a282 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,8 @@ scikit-learn>=1.3.0 click>=8.1.0 numpy>=1.24.0 pyahocorasick +websockets + # Optional: For better performance torch>=2.0.0 From 824639a67af7f53465ab3eeb76305cacfacb395d Mon Sep 17 00:00:00 2001 From: Tanishq Date: Sun, 12 Jul 2026 12:29:27 -0700 Subject: [PATCH 4/4] chore(config): update local cached mitigation catalog to match AST rules --- .anchor/mitigation.anchor | 125 ++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 12 deletions(-) diff --git a/.anchor/mitigation.anchor b/.anchor/mitigation.anchor index b637827..9ebb907 100644 --- a/.anchor/mitigation.anchor +++ b/.anchor/mitigation.anchor @@ -53,24 +53,73 @@ mitigations: # --- SEC-007: Shell Injection (os-level) --- - id: "MIT-014-A" rule_id: "SEC-007" - name: "Shell Command Execution" + name: "Shell Command Execution (os module)" match: - type: "regex" - # Simplified to match os.system/popen anywhere in the line, ignoring preceding code - pattern: >- - \bos\.(system|popen|spawn)\s*\( - message: "Potential shell injection via os.system detects. Use subprocess with list arguments instead." + type: "function_call" + # AST: matches os.system(...), os.popen(...), os.spawn*(...) call nodes only. + # Regex previously matched inside string literals and comments. + name: "os.system" + message: "Potential shell injection via os.system. Use subprocess with list arguments instead." + severity: "blocker" + + # SEC-007 supplementary — os.popen + - id: "MIT-014-A2" + rule_id: "SEC-007" + name: "Shell Command Execution (os.popen)" + match: + type: "function_call" + name: "os.popen" + message: "Potential shell injection via os.popen. Use subprocess with list arguments instead." + severity: "blocker" + + # SEC-007 supplementary — os.spawn* + - id: "MIT-014-A3" + rule_id: "SEC-007" + name: "Shell Command Execution (os.spawn)" + match: + type: "function_call" + name: "os.spawnl" + message: "Potential shell injection via os.spawn family. Use subprocess with list arguments instead." severity: "blocker" # --- SEC-007: Shell Injection (subprocess-level) --- - id: "MIT-014-B" rule_id: "SEC-007" - name: "Unsandboxed Subprocess in Agent" + name: "Unsandboxed Subprocess (subprocess.run)" match: - type: "regex" - # Excludes occurrences inside string literals or comments - pattern: >- - ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bsubprocess\.(run|call|Popen|check_output)\s*\( + type: "function_call" + # AST: matches only real subprocess.run() call nodes, not string mentions. + name: "subprocess.run" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.call + - id: "MIT-014-B2" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.call)" + match: + type: "function_call" + name: "subprocess.call" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.Popen + - id: "MIT-014-B3" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.Popen)" + match: + type: "function_call" + name: "subprocess.Popen" + message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." + severity: "blocker" + + # SEC-007 supplementary — subprocess.check_output + - id: "MIT-014-B4" + rule_id: "SEC-007" + name: "Unsandboxed Subprocess (subprocess.check_output)" + match: + type: "function_call" + name: "subprocess.check_output" message: "Native subprocess execution detected. Use Diamond Cage (WASM) sandboxing for agent tools." severity: "blocker" @@ -82,7 +131,7 @@ mitigations: type: "regex" # Only fire on bulk access or sensitive key names pattern: >- - ^(?:[^"\'#]|(["\'])(?:(?!\1).|\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(?i)(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) + (?i)^(?:[^"\'#]|(["'])(?:(?!\1).|\\\\1)*\1)*\bos\.(environ\.(copy|items)\(\)|\benviron\b\s*\[.*(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL|API).*\]|\{\*\*os\.environ) message: "Broad environment variable access detected. Agents may harvest secrets from env." severity: "error" # --- SEC-012: Governance Configuration Modification --- @@ -96,3 +145,55 @@ mitigations: \.(write|save|update)\s*\(.*\.anchor["'] message: "Unauthorized attempt to modify Anchor governance configuration detected. Governance state must be immutable at runtime." severity: "blocker" + + # --- SEC-004: Hardcoded Secrets (Mitigation Catalog Extension) --- + - id: "MIT-004-B" + rule_id: "SEC-004" + name: "Hardcoded API Key / Secret" + match: + type: "regex" + pattern: >- + (?i)(?:[a-zA-Z0-9_]*key|[a-zA-Z0-9_]*secret|[a-zA-Z0-9_]*token|password|credential)\s*=\s*['"][a-zA-Z0-9_\-\.\\:\/]{16,}['"] + message: "Hardcoded API key, secret, token or credential detected." + severity: "error" + + # --- SEC-010: Insecure Python Execution (AST-based) --- + - id: "MIT-010-A" + rule_id: "SEC-010" + name: "Insecure Dynamic Execution (eval)" + match: + type: "function_call" + # AST: matches only real eval() call nodes, not mentions in strings/comments. + name: "eval" + message: "Dynamic python execution sink (eval) detected. Use restricted sandboxes instead." + severity: "error" + + # SEC-010 supplementary — exec + - id: "MIT-010-A2" + rule_id: "SEC-010" + name: "Insecure Dynamic Execution (exec)" + match: + type: "function_call" + name: "exec" + message: "Dynamic python execution sink (exec) detected. Use restricted sandboxes instead." + severity: "error" + + # SEC-010 supplementary — pickle.loads / pickle.load + - id: "MIT-010-A3" + rule_id: "SEC-010" + name: "Insecure Deserialization (pickle.loads)" + match: + type: "function_call" + name: "pickle.loads" + message: "Unsafe deserialization via pickle.loads detected. Never deserialize untrusted data with pickle." + severity: "error" + + # SEC-010 supplementary — marshal.loads + - id: "MIT-010-A4" + rule_id: "SEC-010" + name: "Insecure Deserialization (marshal.loads)" + match: + type: "function_call" + name: "marshal.loads" + message: "Unsafe deserialization via marshal.loads detected." + severity: "error"