diff --git a/sentinel/README.md b/sentinel/README.md index c7cbd09..d460d66 100644 --- a/sentinel/README.md +++ b/sentinel/README.md @@ -31,6 +31,33 @@ Integration with AgenTrust Sentinel fills the documented gap: "no dedicated behavioral anomaly detection or agent quarantine tooling." +## Security + +Sentinel fails closed. Two controls are configured by environment variable: + +- **Trace verification gate.** Incoming traces are scored and enforced only + after their Ed25519 signature is verified against a trusted key supplied in + `TRACE_TRUSTED_JWK` (an OKP/Ed25519 public JWK as JSON). Unsigned traces, + bad signatures, or a missing trusted key are rejected. To run against + unsigned demo data, set `SENTINEL_ALLOW_UNVERIFIED=1` โ€” this bypasses + verification and logs a loud warning on every use, and must not be used in + production. +- **Incident report signatures.** Exported incident reports are signed with + HMAC-SHA256 using the secret in `SENTINEL_SIGNING_KEY`. If the key is unset + the report is marked `"signature_status": "unsigned"` and carries no + signature (it is never emitted with a value that merely looks signed). The + `/verify` endpoint checks the keyed HMAC in constant time and returns + `UNVERIFIABLE` when no key is configured. + +```bash +# Example: run the demo against unsigned sample data +SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli sample_trace.json --output report.json + +# Production: verify traces and sign incidents +export TRACE_TRUSTED_JWK='{"kty":"OKP","crv":"Ed25519","x":""}' +export SENTINEL_SIGNING_KEY='' +``` + License MIT diff --git a/sentinel/integration.yaml b/sentinel/integration.yaml index 81d8304..4c51408 100644 --- a/sentinel/integration.yaml +++ b/sentinel/integration.yaml @@ -22,9 +22,12 @@ spec: usage: command: | pip install -r requirements.txt - python -m src.cli sample_trace.json --output report.json + # Sentinel fails closed: traces are verified before scoring. For signed + # production traces set TRACE_TRUSTED_JWK. To run the unsigned demo trace, + # set SENTINEL_ALLOW_UNVERIFIED=1 (logs a loud warning, dev/demo only). + SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli sample_trace.json --output report.json # For fleet evaluation: - # python -m src.cli fleet_trace.json --fleet --output fleet_report.json + # SENTINEL_ALLOW_UNVERIFIED=1 python -m src.cli fleet_trace.json --fleet --output fleet_report.json evidence: - type: manual description: | diff --git a/sentinel/requirements.txt b/sentinel/requirements.txt index bc86adf..821b46f 100644 --- a/sentinel/requirements.txt +++ b/sentinel/requirements.txt @@ -6,4 +6,5 @@ httpx==0.28.1 jinja2==3.1.4 pandas==2.2.3 numpy==1.26.4 -scikit-learn==1.5.2 \ No newline at end of file +scikit-learn==1.5.2 +cryptography==44.0.0 \ No newline at end of file diff --git a/sentinel/src/cli.py b/sentinel/src/cli.py index 87634c6..04d4d33 100644 --- a/sentinel/src/cli.py +++ b/sentinel/src/cli.py @@ -3,6 +3,7 @@ from src.trace_ingester import ingest_trace from src.risk_engine import RiskEngine from src.models import SentinelInput +from src.trace_verification import verify_trace, TraceVerificationError @click.command() @click.argument('trace_path', type=click.Path(exists=True)) @@ -15,6 +16,12 @@ def main(trace_path, output, fleet): if fleet or "agents" in data: # Fleet mode + # Verification gate: refuse to score/enforce on unverified trace input. + try: + for agent_data in data.get("agents", []): + verify_trace(agent_data) + except TraceVerificationError as e: + raise click.ClickException(f"Trace verification failed: {e}") engine = RiskEngine() inputs = [] for agent_data in data.get("agents", []): diff --git a/sentinel/src/models.py b/sentinel/src/models.py index 39ea3e9..e9c5582 100644 --- a/sentinel/src/models.py +++ b/sentinel/src/models.py @@ -166,5 +166,6 @@ class IncidentReport(BaseModel): evidence_export: Dict[str, Any] receipt: Optional[Receipt] = None signature: Optional[str] = None + signature_status: str = "unsigned" # "signed" or "unsigned" (fail closed) claim_hash: Optional[str] = None incident_hash: Optional[str] = None \ No newline at end of file diff --git a/sentinel/src/server.py b/sentinel/src/server.py index 9702f7d..69ea7e2 100644 --- a/sentinel/src/server.py +++ b/sentinel/src/server.py @@ -8,11 +8,13 @@ ) from src.risk_engine import RiskEngine from src.replay_engine import ReplayEngine +from src.signing import sign_payload, verify_payload, is_signing_configured, SigningKeyMissing +from src.trace_verification import verify_trace, TraceVerificationError import traceback import uuid import json import hashlib -import base64 +import hmac from datetime import datetime app = FastAPI(title="Agent Sentinel") @@ -44,11 +46,6 @@ def log_enforcement(action: str, claim_id: str, result: dict, status: str = "SUC print(f"Result: {result.get('message', result)}") print(f"Status: {status}\n") -def sign_payload(payload: dict) -> str: - data = json.dumps(payload, sort_keys=True).encode('utf-8') - hash_digest = hashlib.sha256(data).digest() - return base64.b64encode(hash_digest + b"signed").decode('utf-8') - def hash_payload(payload: dict) -> str: data = json.dumps(payload, sort_keys=True).encode('utf-8') return hashlib.sha256(data).hexdigest() @@ -66,6 +63,16 @@ async def evaluate(request: Request): if not agents_list: return JSONResponse(content={"error": "No agents provided"}, status_code=400) + # Verification gate: refuse to score/enforce on unverified trace input. + try: + for agent_data in agents_list: + verify_trace(agent_data) + except TraceVerificationError as e: + return JSONResponse( + content={"error": f"Trace verification failed: {str(e)}"}, + status_code=403, + ) + inputs = [] for agent_data in agents_list: inp = SentinelInput( @@ -120,6 +127,14 @@ async def evaluate(request: Request): } return JSONResponse(content=serializable) else: + # Verification gate: refuse to score/enforce on unverified trace input. + try: + verify_trace(data) + except TraceVerificationError as e: + return JSONResponse( + content={"error": f"Trace verification failed: {str(e)}"}, + status_code=403, + ) try: inp = SentinelInput(**data) result = engine.evaluate(inp) @@ -286,12 +301,25 @@ async def export_incident(claim_id: str, request: Request): if claim_id in receipt_store: report.receipt = receipt_store[claim_id] - # Generate hashes and signature for the report (without the signature and hash fields) - report_dict = report.model_dump(mode='json', exclude={'signature', 'claim_hash', 'incident_hash'}) + # Generate hashes and a keyed signature for the report (excluding the + # signature / hash fields and the signature_status marker). + report_dict = report.model_dump( + mode='json', + exclude={'signature', 'claim_hash', 'incident_hash', 'signature_status'}, + ) claim_data = {"claim_id": claim_id, "agent_id": agent_id, "detection_type": detection_type, "risk_score": risk_score} report.claim_hash = hash_payload(claim_data) report.incident_hash = hash_payload(report_dict) - report.signature = sign_payload(report_dict) + + # Fail closed: only emit a signature when a signing key is configured. + # Otherwise mark the report explicitly unsigned rather than emitting a + # value that merely looks signed. + try: + report.signature = sign_payload(report_dict) + report.signature_status = "signed" + except SigningKeyMissing: + report.signature = None + report.signature_status = "unsigned" return JSONResponse(content=report.model_dump(mode='json')) except Exception as e: @@ -318,8 +346,23 @@ async def verify_incident(claim_id: str, request: Request): if not report_data: return JSONResponse(content={"error": "Missing report data"}, status_code=400) - # Recompute hashes and signature from the report (excluding signature and hash fields) - report_copy = {k: v for k, v in report_data.items() if k not in ["signature", "claim_hash", "incident_hash"]} + # Fail closed: a keyed signature is required to verify. Without a + # configured signing key there is nothing to verify against. + if not is_signing_configured(): + return JSONResponse(content={ + "claim_id": claim_id, + "status": "UNVERIFIABLE", + "details": { + "reason": "No signing key configured (SENTINEL_SIGNING_KEY). " + "Cannot verify incident signatures.", + } + }) + + # Recompute integrity hashes; verify the keyed signature with HMAC. + report_copy = { + k: v for k, v in report_data.items() + if k not in ["signature", "claim_hash", "incident_hash", "signature_status"] + } recomputed_claim_hash = hash_payload({ "claim_id": claim_id, "agent_id": report_data.get("agent_id"), @@ -327,11 +370,11 @@ async def verify_incident(claim_id: str, request: Request): "risk_score": report_data.get("risk_score") }) recomputed_incident_hash = hash_payload(report_copy) - recomputed_signature = sign_payload(report_copy) - valid_claim_hash = recomputed_claim_hash == report_data.get("claim_hash") - valid_incident_hash = recomputed_incident_hash == report_data.get("incident_hash") - valid_signature = recomputed_signature == report_data.get("signature") + valid_claim_hash = hmac.compare_digest(recomputed_claim_hash, report_data.get("claim_hash") or "") + valid_incident_hash = hmac.compare_digest(recomputed_incident_hash, report_data.get("incident_hash") or "") + # Keyed HMAC verification with constant-time comparison. + valid_signature = verify_payload(report_copy, report_data.get("signature") or "") status = "VERIFIED" if (valid_claim_hash and valid_incident_hash and valid_signature) else "TAMPERED" return JSONResponse(content={ diff --git a/sentinel/src/signing.py b/sentinel/src/signing.py new file mode 100644 index 0000000..c91c66e --- /dev/null +++ b/sentinel/src/signing.py @@ -0,0 +1,60 @@ +"""Keyed signing for Sentinel incident reports. + +Incident reports are signed with HMAC-SHA256 using a secret loaded from the +``SENTINEL_SIGNING_KEY`` environment variable. Signing and verification both +FAIL CLOSED when the key is unset: ``sign_payload`` raises and ``verify_payload`` +returns ``False`` rather than emitting or accepting a value that merely looks +signed. +""" + +import hashlib +import hmac +import json +import os +from typing import Any, Dict + +SIGNING_KEY_ENV = "SENTINEL_SIGNING_KEY" + + +class SigningKeyMissing(RuntimeError): + """Raised when an incident must be signed but no signing key is configured.""" + + +def _signing_key() -> bytes: + key = os.environ.get(SIGNING_KEY_ENV) + if not key: + raise SigningKeyMissing( + f"{SIGNING_KEY_ENV} is not set. Refusing to emit an incident " + "signature without a secret key (fail closed). Set " + f"{SIGNING_KEY_ENV} to a high-entropy secret to enable signing." + ) + return key.encode("utf-8") + + +def _canonical_bytes(payload: Dict[str, Any]) -> bytes: + return json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def is_signing_configured() -> bool: + """Return True if a signing key is configured.""" + return bool(os.environ.get(SIGNING_KEY_ENV)) + + +def sign_payload(payload: Dict[str, Any]) -> str: + """Return a hex HMAC-SHA256 signature over the canonical JSON of *payload*. + + Raises ``SigningKeyMissing`` if no signing key is configured. + """ + return hmac.new(_signing_key(), _canonical_bytes(payload), hashlib.sha256).hexdigest() + + +def verify_payload(payload: Dict[str, Any], signature: str) -> bool: + """Return True iff *signature* is a valid HMAC for *payload* under the key. + + Fails closed: returns False if no key is configured or *signature* is empty. + Uses a constant-time comparison. + """ + if not signature or not is_signing_configured(): + return False + expected = sign_payload(payload) + return hmac.compare_digest(expected, signature) diff --git a/sentinel/src/templates/dashboard.html b/sentinel/src/templates/dashboard.html index 465313c..031c7a7 100644 --- a/sentinel/src/templates/dashboard.html +++ b/sentinel/src/templates/dashboard.html @@ -135,6 +135,19 @@

๐Ÿ”„ Policy Replay

let currentReplayResults = {}; let claimActionMap = {}; + // Escape any string that originates from posted trace data before it is + // placed into innerHTML. Prevents DOM-XSS from agent_id, reason, + // description, detection_type, claim_id, etc. + function esc(value) { + if (value === null || value === undefined) return ''; + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + function getRiskLevel(agent) { if (agent && agent.risk_level) { const level = agent.risk_level.toLowerCase(); @@ -180,9 +193,9 @@

๐Ÿ”„ Policy Replay

} let summaryHtml = ''; for (const [type, count] of Object.entries(detectionCounts)) { - summaryHtml += `
${count}
${type.replace(/_/g, ' ').toUpperCase()}
`; + summaryHtml += `
${esc(count)}
${esc(type.replace(/_/g, ' ').toUpperCase())}
`; } - summaryHtml += `
${totalClaims}
TRACE CLAIMS
`; + summaryHtml += `
${esc(totalClaims)}
TRACE CLAIMS
`; summaryContainer.innerHTML = summaryHtml; const agentContainer = document.getElementById('agentCards'); @@ -196,14 +209,15 @@

๐Ÿ”„ Policy Replay

if (d.action === "escalate") { action = "ESCALATE"; actionClass = "action-escalate"; badge = "badge-escalate"; break; } if (d.action === "block") { action = "BLOCK"; actionClass = "action-block"; badge = "badge-block"; break; } } - const topDets = agent.detections.slice(0, 2).map(d => d.detection_type.replace(/_/g, ' ').toUpperCase()).join(', '); + const topDets = agent.detections.slice(0, 2).map(d => esc(d.detection_type.replace(/_/g, ' ').toUpperCase())).join(', '); + const riskLevelClass = getRiskLevel(agent); agentHtml += `
- ${agent.agent_id} + ${esc(agent.agent_id)} ${action} -
Risk: ${(agent.risk_score * 100).toFixed(0)}% +
Risk: ${(agent.risk_score * 100).toFixed(0)}%
${topDets || 'No detections'} - ${agent.quarantine_action ? `
๐Ÿ”’ ${agent.quarantine_action.blocked_tools.join(', ')} blocked` : ''} + ${agent.quarantine_action ? `
๐Ÿ”’ ${esc(agent.quarantine_action.blocked_tools.join(', '))} blocked` : ''}
`; } @@ -221,24 +235,33 @@

๐Ÿ”„ Policy Replay

const actionFromClaim = claim.enforcement_action || 'monitor'; claimActionMap[claim.claim_id] = actionFromClaim.toLowerCase(); + // All trace-derived strings are HTML-escaped before insertion. + // Untrusted values are carried on data-* attributes (also + // escaped) and read by delegated listeners attached below -- + // never interpolated into inline on* handlers. claimsHtml += ` -
+
- ${claim.claim_id} - ${status.replace(/_/g, ' ').toUpperCase()} - ${decision} + ${esc(claim.claim_id)} + ${esc(status.replace(/_/g, ' ').toUpperCase())} + ${esc(decision)}
-
Agent: ${claim.agent_id} | Detection: ${claim.detection_type.replace(/_/g, ' ').toUpperCase()} | Risk: ${(claim.risk_score * 100).toFixed(0)}%
- ${claim.reason ? `
Reason: ${claim.reason}
` : ''} +
Agent: ${esc(claim.agent_id)} | Detection: ${esc(claim.detection_type.replace(/_/g, ' ').toUpperCase())} | Risk: ${(claim.risk_score * 100).toFixed(0)}%
+ ${claim.reason ? `
Reason: ${esc(claim.reason)}
` : ''}
- - - - - - + + + + + +
- +
`; } @@ -247,18 +270,40 @@

๐Ÿ”„ Policy Replay

} claimsContainer.innerHTML = claimsHtml; + // Attach listeners instead of inline on* handlers. The untrusted + // claim values live on data-* attributes, so they never become code. + claimsContainer.querySelectorAll('.claim-box button[data-action]').forEach(btn => { + btn.addEventListener('click', () => { + const box = btn.closest('.claim-box'); + const claimId = box.dataset.claimId; + const agentId = box.dataset.agentId; + const detectionType = box.dataset.detectionType; + const riskScore = parseFloat(box.dataset.riskScore) || 0.0; + const claimRiskLevel = box.dataset.riskLevel || 'low'; + switch (btn.dataset.action) { + case 'escalate': enforceClaim(claimId, 'escalate', agentId); break; + case 'quarantine': enforceClaim(claimId, 'quarantine', agentId); break; + case 'block': enforceClaim(claimId, 'block', agentId); break; + case 'replay': openReplay(claimId); break; + case 'export': exportIncident(claimId, agentId, detectionType, riskScore, claimRiskLevel); break; + case 'verify': verifyIncident(claimId, agentId, detectionType, riskScore, claimRiskLevel); break; + } + }); + }); + const timelineContainer = document.getElementById('timelineContainer'); let timelineHtml = ''; if (data.timeline && data.timeline.length > 0) { for (const event of data.timeline) { const time = new Date(event.timestamp).toLocaleTimeString(); - const severityClass = event.severity ? `risk-${event.severity}` : ''; + const sev = (event.severity || '').toString().toLowerCase(); + const severityClass = ['low', 'medium', 'high', 'critical'].includes(sev) ? `risk-${sev}` : ''; timelineHtml += `
- ${time} - ${event.agent_id} - ${event.event_type.replace(/_/g, ' ').toUpperCase()} - - ${event.description} + ${esc(time)} + ${esc(event.agent_id)} + ${esc((event.event_type || '').replace(/_/g, ' ').toUpperCase())} + - ${esc(event.description)}
`; } @@ -328,26 +373,30 @@

๐Ÿ”„ Policy Replay

let receiptHtml = `

๐Ÿงพ Enforcement Receipt

-
Claim: ${claimId}
-
Action: ${result.action.toUpperCase()}
-
Timestamp: ${new Date(result.timestamp).toLocaleString()}
-
Agent: ${result.agent_id}
+
Claim: ${esc(claimId)}
+
Action: ${esc((result.action || '').toUpperCase())}
+
Timestamp: ${esc(new Date(result.timestamp).toLocaleString())}
+
Agent: ${esc(result.agent_id)}
`; for (const [key, value] of Object.entries(result.details)) { if (key === 'message') continue; let displayValue = value; if (typeof value === 'object') displayValue = JSON.stringify(value); - receiptHtml += `
${key.replace(/_/g, ' ').toUpperCase()}: ${displayValue}
`; + receiptHtml += `
${esc(key.replace(/_/g, ' ').toUpperCase())}: ${esc(displayValue)}
`; } receiptHtml += ` -
Outcome: ${result.details.message || 'Applied'}
+
Outcome: ${esc(result.details.message || 'Applied')}
Evidence: trace.jwt
- +
`; logDiv.innerHTML = receiptHtml; + const exportBtn = logDiv.querySelector('button[data-export-receipt]'); + if (exportBtn) { + exportBtn.addEventListener('click', () => exportReceipt(claimId)); + } } const claimBox = document.getElementById(`claim-${claimId}`); @@ -455,15 +504,9 @@

๐Ÿงพ Enforcement Receipt

} } - async function verifyIncident(claimId) { + async function verifyIncident(claimId, agentId, detectionType, riskScore, riskLevel) { try { - const claimBox = document.getElementById(`claim-${claimId}`); - const infoDiv = claimBox.querySelector('div:nth-child(2)'); - const agentId = infoDiv.innerText.split('Agent: ')[1].split('|')[0].trim(); - const detectionType = infoDiv.innerText.split('Detection: ')[1].split('|')[0].trim(); - const riskScore = parseFloat(infoDiv.innerText.split('Risk: ')[1].replace('%', '')) / 100; - const riskLevel = 'low'; - + riskLevel = riskLevel || 'low'; const exportResponse = await fetch(`/export/incident/${claimId}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -491,6 +534,8 @@

๐Ÿงพ Enforcement Receipt

const verifyResult = await verifyResponse.json(); if (verifyResult.status === 'VERIFIED') { alert('โœ… Signature verified! Incident is authentic.'); + } else if (verifyResult.status === 'UNVERIFIABLE') { + alert('โš ๏ธ Cannot verify: ' + (verifyResult.details && verifyResult.details.reason ? verifyResult.details.reason : 'no signing key configured.')); } else { alert('โŒ Signature verification failed: ' + JSON.stringify(verifyResult.details)); } @@ -526,7 +571,7 @@

๐Ÿงพ Enforcement Receipt

}); const replayResults = await response.json(); if (replayResults.error) { - resultsDiv.innerHTML = `Error: ${replayResults.error}`; + resultsDiv.innerHTML = `Error: ${esc(replayResults.error)}`; return; } currentReplayResults[claimId] = replayResults; @@ -540,18 +585,18 @@

๐Ÿงพ Enforcement Receipt

const decisionColor = r.decision === 'DENY' ? '#f85149' : '#3fb950'; tableHtml += ` - ${r.policy_version} + ${esc(r.policy_version)} ${(r.risk_score * 100).toFixed(0)}% - ${r.risk_level} - ${r.decision} - ${r.reason} + ${esc(r.risk_level)} + ${esc(r.decision)} + ${esc(r.reason)} `; } tableHtml += ''; resultsDiv.innerHTML = tableHtml; } catch (e) { - resultsDiv.innerHTML = `Error: ${e.message}`; + resultsDiv.innerHTML = `Error: ${esc(e.message)}`; } } diff --git a/sentinel/src/trace_ingester.py b/sentinel/src/trace_ingester.py index 30541a0..6352e21 100644 --- a/sentinel/src/trace_ingester.py +++ b/sentinel/src/trace_ingester.py @@ -1,11 +1,17 @@ import json from src.models import SentinelInput, SentinelOutput # <-- added SentinelOutput from src.risk_engine import RiskEngine +from src.trace_verification import verify_trace def ingest_trace(trace_path: str) -> SentinelOutput: with open(trace_path, 'r') as f: data = json.load(f) + # Verification gate: refuse to score/enforce on unverified trace input. + # Raises TraceVerificationError unless the trace is signed by the configured + # trusted key (or SENTINEL_ALLOW_UNVERIFIED=1 is explicitly set). + verify_trace(data) + steps = data.get("steps", []) if not steps: raise ValueError("No steps found in trace") diff --git a/sentinel/src/trace_verification.py b/sentinel/src/trace_verification.py new file mode 100644 index 0000000..79937e7 --- /dev/null +++ b/sentinel/src/trace_verification.py @@ -0,0 +1,127 @@ +"""Verification gate for incoming TRACE claims. + +Sentinel must not score or enforce on trace data it has not authenticated. +This module verifies the Ed25519 signature on a trace against a *configured +trusted key* (never the key embedded in the record itself) before the trace is +allowed downstream. + +Behaviour (fail closed): + +* The trusted public key is taken from ``TRACE_TRUSTED_JWK`` (a JWK JSON object). +* If the ``agentrust_trace`` package is importable, its ``verify_record`` is + used; otherwise a minimal Ed25519 check over the canonical JSON is performed. + Both verify against the configured trusted key, not ``record["cnf"]["jwk"]``. +* Unsigned traces, traces that fail verification, or traces presented with no + trusted key configured are REJECTED. +* The only way to bypass verification is to set ``SENTINEL_ALLOW_UNVERIFIED=1``, + which logs a loud warning on every use. +""" + +import base64 +import json +import logging +import os +from typing import Any, Dict, Optional + +TRUSTED_JWK_ENV = "TRACE_TRUSTED_JWK" +ALLOW_UNVERIFIED_ENV = "SENTINEL_ALLOW_UNVERIFIED" + +logger = logging.getLogger("sentinel.trace_verification") + + +class TraceVerificationError(ValueError): + """Raised when a trace cannot be verified and must be rejected.""" + + +def _allow_unverified() -> bool: + return os.environ.get(ALLOW_UNVERIFIED_ENV, "").strip() in ("1", "true", "True", "yes") + + +def _load_trusted_jwk() -> Optional[Dict[str, Any]]: + raw = os.environ.get(TRUSTED_JWK_ENV) + if not raw: + return None + try: + jwk = json.loads(raw) + except (json.JSONDecodeError, TypeError) as exc: + raise TraceVerificationError( + f"{TRUSTED_JWK_ENV} is set but is not valid JSON: {exc}" + ) + if not isinstance(jwk, dict) or not jwk.get("x"): + raise TraceVerificationError(f"{TRUSTED_JWK_ENV} is not a valid OKP/Ed25519 JWK") + return jwk + + +def _canonical_bytes(record: Dict[str, Any]) -> bytes: + return json.dumps(record, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode() + + +def _b64url_decode(value: str) -> bytes: + pad = (-len(value)) % 4 + return base64.urlsafe_b64decode(value + "=" * pad) + + +def _minimal_verify(record: Dict[str, Any], jwk: Dict[str, Any]) -> None: + """Minimal Ed25519 verification against an explicit trusted JWK. + + Raises TraceVerificationError on any failure (no signature, bad key, bad sig). + """ + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + sig_b64 = record.get("signature") + if not sig_b64: + raise TraceVerificationError("trace has no 'signature' field") + try: + pub = Ed25519PublicKey.from_public_bytes(_b64url_decode(jwk["x"])) + sig = _b64url_decode(sig_b64) + except Exception as exc: # malformed key or signature encoding + raise TraceVerificationError(f"could not decode trusted key or signature: {exc}") + body = _canonical_bytes({k: v for k, v in record.items() if k != "signature"}) + try: + pub.verify(sig, body) + except InvalidSignature: + raise TraceVerificationError("trace signature does not verify against trusted key") + + +def verify_trace(record: Dict[str, Any]) -> None: + """Verify *record* against the configured trusted key, or raise. + + On success returns None. On any failure (no trusted key, unsigned trace, + bad signature) raises ``TraceVerificationError`` -- unless the explicit + ``SENTINEL_ALLOW_UNVERIFIED=1`` opt-out is set, in which case a loud warning + is logged and verification is skipped. + """ + if _allow_unverified(): + logger.warning( + "%s is set: SKIPPING trace verification. Traces are being scored and " + "enforced WITHOUT authentication. Do NOT use this in production.", + ALLOW_UNVERIFIED_ENV, + ) + return + + if not isinstance(record, dict): + raise TraceVerificationError("trace must be a JSON object to be verified") + + trusted_jwk = _load_trusted_jwk() + if trusted_jwk is None: + raise TraceVerificationError( + f"No trusted key configured. Set {TRUSTED_JWK_ENV} to the trusted " + f"Ed25519 public JWK, or set {ALLOW_UNVERIFIED_ENV}=1 to bypass " + "verification (not recommended)." + ) + + try: + from agentrust_trace import verify_record as _verify_record + except ImportError: + _verify_record = None + + if _verify_record is not None: + try: + # Verify against the CONFIGURED trusted key, never record["cnf"]["jwk"]. + _verify_record(record, trusted_jwk) + return + except Exception as exc: + raise TraceVerificationError(f"trace verification failed: {exc}") + + _minimal_verify(record, trusted_jwk) diff --git a/sentinel/tests/test_security.py b/sentinel/tests/test_security.py new file mode 100644 index 0000000..20928e6 --- /dev/null +++ b/sentinel/tests/test_security.py @@ -0,0 +1,269 @@ +"""Security tests for Sentinel hardening. + +Covers: +1. Keyed incident signatures (HMAC-SHA256) verify; tampering or a wrong key + fails; signing with no key set fails closed. +2. The trace verification gate: unsigned / invalid traces are rejected by + ingest (not scored); a properly-signed trace is accepted. +3. The dashboard render path HTML-escapes a