diff --git a/docs/A2H2A_TICKETING_SPEC.md b/docs/A2H2A_TICKETING_SPEC.md new file mode 100644 index 0000000..1304131 --- /dev/null +++ b/docs/A2H2A_TICKETING_SPEC.md @@ -0,0 +1,212 @@ +# OSVxCC A2H2A Ticketing System Specification + +## 1. Introduksjon + +A2H2A (Agent-to-Human-to-Agent) er en uomgåelig Approval Gate mellom autonome observatører/analytikere og operative eksekveringsagenter. Ingen destruktive eller privilegerte handlinger skal utføres i GCP-infrastrukturen uten at autorisert operatør eksplisitt har validert og godkjent handlingen via et kryptografisk signert grensesnitt. + +## Implementation Status + +**Current prototype — proposal recording only; approval and execution disabled.** + +- The prototype may accept an allow-listed ticket proposal and store it as `PENDING`. +- Google Chat is notification-only. It does not verify or prove the identity of an approver. +- The current `/a2h2a/review/{ticket_id}` link is conceptual only. +- No working approval or rejection endpoint currently exists. +- No one-time action token currently exists. +- No execution worker currently exists. +- No proposed tool is executable through A2H2A at this stage. + +## 2. JSON Datamodell (a2h2a-ticket-v1.json) + +```json +{ + "ticket_id": "A2H2A-20260904-8B3D", + "timestamp": "2026-09-04T14:20:05Z", + "severity": "CRITICAL", + "category": "SECURITY", + "source": { + "reporter": "tyr-compliance-agent", + "trigger": "Secret scanned in public repository", + "affected_service": "mcp-server-key", + "project_id": "propane-will-491900-m5", + "region": "global" + }, + "context": { + "summary": "En aktiv Secret Manager-nøkkel ble funnet i en offentlig Gitea-commit.", + "evidence_logs": [ + "gitea.commit.url: https://git.vauco.no/chris/OSVauco/commit/a1b2c3d4...", + "secret.name: projects/357036551735/secrets/mcp-server-key/versions/2" + ] + }, + "proposed_action": { + "action_type": "AUTOMATED_REMEDIATION", + "runbook_reference": "docs/RUNBOOK.md#hendelse-eksponert-hemmelighet", + "execution_tool": "opax.secrets.rotate", + "parameters": { + "secret_name": "mcp-server-key", + "disable_old_versions": true, + "notify_channel": "#security-alerts" + }, + "parameter_hash": "sha256:f4a2...d8e0", + "rollback_plan": "Manuell re-aktivering av forrige hemmelighetsversjon via `gcloud secrets versions enable` etter verifisering." + }, + "governance": { + "approval_status": "PENDING", + "authorized_approver": "chris.christiansen@vauco.no", + "requires_mfa": true, + "timeout_minutes": 15 + } +} +``` + +`The ticket model is a proposal model. Fields required for secure human approval—such as approval_token_hash, verified_approver_identity, approval timestamp, token_used_at, and immutable audit-event references—are target-design fields and are not implemented in the current prototype.` + +## 3. Sikkerhetsgarantier + +### 3.1 Constant-Time Authentication +- Sammenligning av alle signaturer og tokens **skal** utføres med `secrets.compare_digest` eller tilsvarende for å forhindre timing-angrep. + +### 3.2 Anti-Tampering +- En SHA-256 hash av `proposed_action.parameters` **skal** beregnes før ticketen sendes til operatør. +- Før eksekvering **skal** OPAX verifisere at hashen av parameterne den mottok for eksekvering stemmer overens med `parameter_hash` i den godkjente ticketen. + +### 3.3 Replay-Beskyttelse +- Hver callback fra Slack/Discord **skal** inneholde et timestamp. +- Serveren **skal** avvise callbacks hvor `current_time - callback_timestamp > 300` sekunder for å forhindre gjenbruk av gamle godkjenninger. + +### 3.4 Time-To-Live (TTL) +- Ubehandlede tickets utløper automatisk basert på alvorlighetsgrad: + - **CRITICAL:** 15 minutter + - **HIGH:** 60 minutter + - **MEDIUM/LOW:** 24 timer + +## 4. API-Endepunkter (Prototype) + +### 4.1 POST /api/v1/a2h2a/tickets +1. Motta ticket-payload fra en autorisert intern agent (f.eks. TYR). +2. Valider payloaden mot en Pydantic-modell basert på schemaet over. +3. Beregn `parameter_hash` server-side. +4. Lagre ticketen i en Firestore-collection (`a2h2a_tickets`) med status `PENDING`. +5. Formater og send en Google Chat-melding med et konseptuelt link til en fremtidig godkjennings-UI. + +`Google Chat delivers a notification and review link only; it must never be used as evidence of the approver's identity or as the authority to execute a tool.` + +## Target Approval Architecture — Required Before Production + +The eventual review URL may use: +`/a2h2a/review/{ticket_id}?token=` + +- The token is generated with a cryptographically secure random generator. +- The raw token is returned once in the notification link and is never persisted. +- Only the SHA-256 token hash is stored server-side. +- Approval/rejection backend verifies IAP-verified identity, PENDING state, TTL, unused matching token hash, authorized approver, server-side canonical parameter hash, and tool allow-list. +- Approval only updates the ticket state. +- A separate worker later executes only server-stored, immutable, approved actions. +- Every audit event is a target requirement; do not claim it is already implemented. + +## Target audit requirements +`Immutable audit events for proposal, approval, rejection, expiry, execution start, execution success, execution failure, and rollback are required before production activation; they are not implemented in the current prototype.` + +## 5. Google Chat / Slack Block Kit Payload (Konseptuelt Eksempel) + +```json +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "🚨 A2H2A Godkjenning Kreves: CRITICAL" + } + }, + { + "type": "section", + "fields": [ + {"type": "mrkdwn", "text": "*Kategori:* +SECURITY"}, + {"type": "mrkdwn", "text": "*Tjeneste:* +mcp-server-key"}, + {"type": "mrkdwn", "text": "*Handling:* +`opax.secrets.rotate`"}, + {"type": "mrkdwn", "text": "*Tidsfrist:* +15 minutter"} + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Sammendrag:* +En aktiv Secret Manager-nøkkel ble funnet i en offentlig Gitea-commit." + } + }, + { + "type": "actions", + "elements": [ + { + "type": "button", + "text": { + "type": "plain_text", + "text": "✅ Godkjenn", + "emoji": true + }, + "style": "primary", + "value": "approve_A2H2A-20260904-8B3D" + }, + { + "type": "button", + "text": { + "type": "plain_text", + "text": "❌ Avvis", + "emoji": true + }, + "style": "danger", + "value": "reject_A2H2A-20260904-8B3D" + } + ] + } + ] +} +``` + +## 6. Eksempel-Ticket (SECRET_EXPOSURE) + +```json +{ + "ticket_id": "A2H2A-20260904-1C4F", + "timestamp": "2026-09-04T18:05:10Z", + "severity": "CRITICAL", + "category": "SECURITY", + "source": { + "reporter": "tyr-scanner-agent", + "trigger": "Audit log anomaly: Unexpected access to mcp-server-key v2", + "affected_service": "mcp-server-key", + "project_id": "propane-will-491900-m5", + "region": "global" + }, + "context": { + "summary": "Mistenkelig tilgang til mcp-server-key v2 fra en ukjent IP-adresse (35.22.88.101) utenfor GCP-nettverket.", + "evidence_logs": [ + "log_explorer_link: https://console.cloud.google.com/logs/query;query=..." + ] + }, + "proposed_action": { + "action_type": "AUTOMATED_REMEDIATION", + "runbook_reference": "docs/RUNBOOK.md#hendelse-eksponert-hemmelighet", + "execution_tool": "opax.secrets.rotate", + "parameters": { + "secret_name": "mcp-server-key", + "disable_old_versions": true, + "notify_channel": "#security-alerts" + }, + "parameter_hash": "sha256:f4a26901a511e6cb716b5a86552631521a0e71383921034293739886313ad8e0", + "rollback_plan": "Manuell re-aktivering av forrige hemmelighetsversjon (v3) via `gcloud secrets versions enable` etter verifisering." + }, + "governance": { + "approval_status": "PENDING", + "authorized_approver": "chris.christiansen@vauco.no", + "requires_mfa": true, + "timeout_minutes": 15 + } +} +``` diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index 970e0ad..7562fec 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -229,3 +229,17 @@ En Cloud Build-pipeline feiler på det siste "deploy to Cloud Run"-steget. Feilm ### Status 🟡 **MODERAT TIL AVKLART.** Vanligvis en operasjonell feil forårsaket av en compliance-endring. + +--- + +## 7. Sikkerhet og Hendelseshåndtering + +Se følgende dokumenter for detaljerte prosedyrer: + +- `docs/SECURITY.md`: Overordnet sikkerhetsarkitektur. +- `docs/INCIDENT_RESPONSE.md`: Prosedyrer for håndtering av sikkerhetshendelser. +- `docs/SECRET_MANAGEMENT.md`: Retningslinjer for håndtering av secrets. + +### Kritisk Sikkerhetsprinsipp for A2H2A + +**No privileged action may be authorized by a notification, a URL parameter, or a client-supplied identity. Approval requires a verified identity boundary and server-side validation of an immutable ticket.** diff --git a/opax-mcp/server.py b/opax-mcp/server.py index b732ce3..0ecc447 100644 --- a/opax-mcp/server.py +++ b/opax-mcp/server.py @@ -48,10 +48,57 @@ except Exception as e: from email.mime.text import MIMEText from email.mime.text import MIMEText from datetime import datetime, timezone, timedelta -from typing import Any, Optional +from typing import Any, Optional, Dict, List import logging -from pydantic import BaseModel +from pydantic import BaseModel, Field +import hashlib +# --- A2H2A Pydantic Models --- +class TicketSource(BaseModel): + reporter: str + trigger: str + affected_service: str + project_id: str + region: str + +class TicketContext(BaseModel): + summary: str + evidence_logs: List[str] + +class ProposedAction(BaseModel): + action_type: str + runbook_reference: str + execution_tool: str + parameters: Dict + parameter_hash: Optional[str] = None + rollback_plan: str + +class Governance(BaseModel): + approval_status: str = "PENDING" + authorized_approver: str + requires_mfa: bool = True + timeout_minutes: int = 15 + +class A2H2ATicket(BaseModel): + ticket_id: str + timestamp: datetime + severity: str # CRITICAL, HIGH, MEDIUM, LOW + category: str + source: TicketSource + context: TicketContext + proposed_action: ProposedAction + governance: Governance + + def calculate_parameter_hash(self) -> str: + """Beregn SHA-256 hash av parameters i kanonisk form.""" + param_json = json.dumps(self.proposed_action.parameters, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(param_json.encode()).hexdigest() + + def is_expired(self) -> bool: + """Sjekk om ticket er utløpt""" + from datetime import datetime, timedelta + expiry = self.timestamp + timedelta(minutes=self.governance.timeout_minutes) + return datetime.utcnow() > expiry.replace(tzinfo=None) class ConfirmationRequired(BaseModel): message: str @@ -97,6 +144,118 @@ async def sse_endpoint(request: Request): """Handles Server-Sent Events for external clients like Perplexity.""" return StreamingResponse(sse_event_stream(request), media_type="text/event-stream") +# --- A2H2A API Endpoints --- + +# --- A2H2A Secure Design Placeholder --- +# +# The following logic is a placeholder for a secure A2H2A (Agent-to-Human-to-Agent) +# approval workflow. The key principles are: +# +# 1. NOTIFICATION, NOT ACTION: Google Chat messages are for notification only. They +# do not carry any identity or authorization guarantee. +# 2. VERIFIED IDENTITY: Human approval must happen through a protected surface that +# verifies the user's identity (e.g., a web app protected by Google IAP). +# 3. ATOMIC, IMMUTABLE ACTIONS: The ticket stored in the backend (Firestore) is the +# source of truth. The proposed action, its parameters, and the server-generated +# parameter hash are immutable. +# 4. ONE-TIME USE TOKENS: The approval UI should be accessed via a link containing a +# cryptographically random, single-use token that is invalidated after the first +# approval or rejection, or after the ticket's TTL expires. +# 5. DECOUPLED EXECUTION: The approval action only changes the ticket's state to +# 'APPROVED'. A separate, secure worker process will poll for approved tickets and +# execute them, ensuring a clean separation of concerns. This worker validates the +# ticket's integrity (non-expired, parameter hash match) before execution. +# +# This implementation only covers the safe recording of a proposed ticket. +# Execution is explicitly disabled. + +# A strict allow-list of tools that can be proposed in an A2H2A ticket. +# `execution_enabled: False` means the tool can be proposed but not executed. +A2H2A_TOOL_ALLOW_LIST = { + "example_tool_1": {"execution_enabled": False, "description": "A sample tool that does something harmless."}, + "example_tool_2": {"execution_enabled": False, "description": "Another sample tool."}, + # Add real, vetted tools here with execution_enabled: True ONLY after extensive security review. +} + +@app.post("/api/v1/a2h2a/tickets") +async def create_a2h2a_ticket(ticket: A2H2ATicket, request: Request): + """ + Opprett ny A2H2A ticket og send til Google Chat for godkjenning. + Dette er kun en prototype for sikker registrering, ingen verktøy blir kjørt. + """ + try: + await _verify_auth(request) + + # Security check: Reject if caller provides a parameter_hash. + if ticket.proposed_action.parameter_hash is not None: + raise HTTPException(status_code=400, detail="Do not provide a parameter_hash. The server will generate it.") + + # Security check: Ensure the proposed tool is in the allow-list. + tool_name = ticket.proposed_action.execution_tool + if tool_name not in A2H2A_TOOL_ALLOW_LIST: + raise HTTPException(status_code=400, detail=f"Tool '{tool_name}' is not on the approved A2H2A allow-list.") + + # Generate parameter hash server-side for integrity. + ticket.proposed_action.parameter_hash = ticket.calculate_parameter_hash() + + from google.cloud import firestore + db = firestore.Client(project=GOOGLE_CLOUD_PROJECT) + ticket_data = ticket.model_dump(mode='json') + ticket_data['created_at'] = datetime.utcnow().isoformat() + ticket_data['status'] = 'PENDING' + doc_ref = db.collection('a2h2a_tickets').document(ticket.ticket_id) + doc_ref.set(ticket_data) + + # The approval URL now points to a conceptual, IAP-protected UI. + approval_ui_url = f"https://opax.vauco.no/a2h2a/review/{ticket.ticket_id}" + + google_chat_webhook = os.getenv('A2H2A_GOOGLE_CHAT_WEBHOOK') + if google_chat_webhook: + chat_payload = { + "cards": [ + { + "header": { + "title": f"🚨 A2H2A Proposal: {ticket.severity}", + "subtitle": "OSVxCC Security (Prototype - No Execution)", + "image": {"imageUrl": "https://www.gstatic.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png"} + }, + "sections": [ + { + "widgets": [ + { + "textParagraph": { + "text": f"Category: {ticket.category}
" + f"Service: {ticket.source.affected_service}
" + f"Tool: {ticket.proposed_action.execution_tool}
" + f"Summary: {ticket.context.summary}" + } + }, + { + "buttons": [ + { + "textButton": { + "text": "REVIEW IN APPROVAL UI", + "onClick": {"openLink": {"url": approval_ui_url}} + } + } + ] + } + ] + } + ] + } + ] + } + import httpx + async with httpx.AsyncClient() as client: + await client.post(google_chat_webhook, json=chat_payload) + + return {"status": "success", "ticket_id": ticket.ticket_id, "parameter_hash": ticket.proposed_action.parameter_hash} + except Exception as e: + logger.error(f"Feil ved opprettelse av A2H2A ticket: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + # ── Service discovery ─────────────────────────────────────────────────────── OSVAUCO_AGENT_URL = os.environ.get("OSVAUCO_AGENT_URL", "") # f.eks. https://osvauco-agent-....run.app MCP_SECRET = os.environ.get("MCP_SECRET", "")