Architettura di sicurezza¶
Postura di sicurezza completa per Open-Jarvis: VPS cloud + dispositivi personali (PC, smartphone, smartwatch, occhiali AR, wearable medicali).
1. VPS hardening¶
1.1 SSH hardening¶
# /etc/ssh/sshd_config
Port 2222
AddressFamily inet
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 20
ClientAliveInterval 300
ClientAliveCountMax 2
AllowUsers jarvis
X11Forwarding no
Ciphers chacha20-poly1305@openssh.com,aes256-gcm@openssh.com
MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-256-etm@openssh.com
KexAlgorithms curve25519-sha256,diffie-hellman-group16-sha512
ssh-keygen -t ed25519 -f /etc/ssh/ssh_host_ed25519_key
rm -f /etc/ssh/ssh_host_rsa_key /etc/ssh/ssh_host_dsa_key
systemctl restart sshd
fail2ban v1.1.x:
# /etc/fail2ban/jail.d/jarvis-ssh.conf
[sshd]
enabled = true
port = 2222
maxretry = 3
bantime = 3600
findtime = 600
backend = systemd
1.2 Firewall (default-deny)¶
Solo 3 porte esposte: 443 HTTPS, 2222 SSH, 51820 WireGuard.
ufw default deny incoming
ufw default allow outgoing
ufw allow 2222/tcp comment "SSH hardened"
ufw allow 443/tcp comment "HTTPS TLS 1.3"
ufw allow 51820/udp comment "WireGuard VPN"
ufw limit 2222/tcp
ufw enable
1.3 Reverse proxy: Caddy 2.9 (TLS 1.3 auto)¶
{
email admin@yourdomain.com
servers {
protocols h1 h2 h3
}
}
jarvis.yourdomain.com {
tls {
protocols tls1.3
curves x25519
}
header {
Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'"
X-Frame-Options "DENY"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(self), camera=(self)"
-Server
-X-Powered-By
}
rate_limit {
zone api_global {
key {remote_host}
events 100
window 1m
}
}
reverse_proxy /api/* localhost:8000
reverse_proxy /* localhost:3000
}
1.4 Container security¶
# Distroless multi-stage
FROM python:3.13-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
FROM gcr.io/distroless/python3-debian12:nonroot
COPY --from=builder /root/.local /root/.local
COPY --chown=nonroot:nonroot ./app /app
USER nonroot
WORKDIR /app
EXPOSE 8000
ENTRYPOINT ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose hardened
services:
api:
image: ghcr.io/open-jarvis/api:latest
user: "65532:65532"
read_only: true
tmpfs: ["/tmp:size=64m,mode=1777"]
security_opt:
- apparmor:jarvis-api
- no-new-privileges:true
- seccomp:./seccomp/api.json
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
Trivy → Grype 2026
Trivy rimosso dalla pipeline CI dopo supply chain attacks marzo 2026. Sostituito con Syft (SBOM) + Grype (vulnerability scan).
syft ghcr.io/open-jarvis/api:latest -o cyclonedx-json > sbom.json
grype ghcr.io/open-jarvis/api:latest --fail-on high
1.5 AppArmor profile¶
# /etc/apparmor.d/jarvis-api
#include <tunables/global>
profile jarvis-api flags=(attach_disconnected,mediate_deleted) {
#include <abstractions/base>
network inet tcp,
network inet udp,
deny network raw,
/app/** r,
/tmp/ rw,
deny /etc/shadow r,
deny /proc/*/mem rw,
capability net_bind_service,
deny capability sys_admin,
}
1.6 Audit log: auditd + Loki¶
# /etc/audit/rules.d/jarvis.rules
-w /etc/ssh/sshd_config -p wa -k jarvis_ssh_config
-w /app/server/auth/ -p rwxa -k jarvis_auth
-a always,exit -F arch=b64 -S execve -k jarvis_exec
-a always,exit -F arch=b64 -S open -F exit=-EACCES -k jarvis_access_denied
-w /etc/passwd -p wa -k jarvis_identity
Forwarding via Promtail → Loki per query.
2. Network security VPS ↔ devices¶
WireGuard hub-and-spoke¶
# /etc/wireguard/wg0.conf — VPS hub
[Interface]
Address = 10.10.0.1/24
ListenPort = 51820
PrivateKey = <VPS_PRIVATE_KEY>
PostUp = iptables -A FORWARD -i wg0 -j ACCEPT; iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
PostDown = iptables -D FORWARD -i wg0 -j ACCEPT; iptables -t nat -D POSTROUTING -o eth0 -j MASQUERADE
[Peer] # Desktop
PublicKey = <DESKTOP_PUB>
AllowedIPs = 10.10.0.2/32
[Peer] # Mobile
PublicKey = <MOBILE_PUB>
AllowedIPs = 10.10.0.3/32
[Peer] # Watch
PublicKey = <WATCH_PUB>
AllowedIPs = 10.10.0.4/32
Tailscale (managed alternative)¶
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up --authkey=tskey-... --advertise-tags=tag:jarvis-node
ACL JSON:
{
"tagOwners": { "tag:jarvis-node": ["autogroup:admin"] },
"acls": [
{ "action": "accept",
"src": ["tag:jarvis-node"],
"dst": ["tag:jarvis-node:8000"] }
]
}
mTLS inter-service¶
import httpx
import ssl
def build_mtls_client(cert: str, key: str, ca: str) -> httpx.Client:
ctx = ssl.create_default_context(ssl.Purpose.SERVER_AUTH, cafile=ca)
ctx.load_cert_chain(certfile=cert, keyfile=key)
ctx.minimum_version = ssl.TLSVersion.TLSv1_3
ctx.verify_mode = ssl.CERT_REQUIRED
return httpx.Client(verify=ctx, timeout=10.0)
Cloudflare Tunnel (zero-trust)¶
# cloudflared/config.yml
tunnel: <TUNNEL_ID>
credentials-file: /etc/cloudflared/credentials.json
ingress:
- hostname: api.jarvis.yourdomain.com
service: http://localhost:8000
- service: http_status:404
Cloudflare Zero Trust Access aggiunge SSO davanti.
IP allowlist admin¶
3. Application-layer security¶
OWASP Top 10 2025 mitigations¶
| Rischio | Mitigazione Jarvis |
|---|---|
| A01 Broken Access Control | RBAC + IDOR checks su ogni query DB |
| A02 Crypto Failures | TLS 1.3 forzato, bcrypt cost=12, AES-256-GCM at-rest |
| A03 Injection | Pydantic strict + SQLAlchemy ORM, no raw SQL |
| A04 Insecure Design | Threat model STRIDE per ogni componente |
| A05 Misconfig | Hardening checklist in CI, Semgrep |
| A07 Auth Failures | JWT ES256 + refresh rotation + family revocation |
| A10 SSRF | Whitelist URL scraping, blocco IMDS 169.254.169.254 |
Rate limiting (slowapi + Redis)¶
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.middleware import SlowAPIMiddleware
limiter = Limiter(
key_func=get_remote_address,
storage_uri="redis://localhost:6379",
default_limits=["200/minute", "2000/hour"],
)
def create_app() -> FastAPI:
app = FastAPI(docs_url=None, redoc_url=None) # disabilita /docs in prod
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
return app
@router.post("/chat")
@limiter.limit("30/minute")
async def chat(request: Request, body: ChatRequest):
...
Input validation strict¶
from pydantic import BaseModel, Field, field_validator
from typing import Annotated
import re
ALLOWED_DEVICES = frozenset({"desktop", "mobile", "watch", "glasses", "vr"})
class ChatRequest(BaseModel):
model_config = {"strict": True, "extra": "forbid"}
message: Annotated[str, Field(min_length=1, max_length=4096)]
device_id: Annotated[str, Field(pattern=r"^[a-zA-Z0-9\-]{8,64}$")]
device_type: str
session_id: Annotated[str, Field(pattern=r"^[a-f0-9\-]{36}$")]
@field_validator("message")
@classmethod
def sanitize(cls, v: str) -> str:
cleaned = re.sub(r"[\x00-\x08\x0b-\x1f\x7f]", "", v)
if not cleaned:
raise ValueError("Empty after sanitization")
return cleaned
@field_validator("device_type")
@classmethod
def valid_device(cls, v: str) -> str:
if v not in ALLOWED_DEVICES:
raise ValueError(f"device_type must be one of {ALLOWED_DEVICES}")
return v
Secret management: age + sops¶
# .sops.yaml
creation_rules:
- path_regex: infra/secrets/.*\.ya?ml
age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
sops --encrypt --age $(cat ~/.config/sops/age/keys.txt | grep "public key" | awk '{print $NF}') \
infra/secrets/api_keys.yaml > infra/secrets/api_keys.enc.yaml
HashiCorp Vault (enterprise dynamic secrets)¶
import hvac
from functools import lru_cache
@lru_cache(maxsize=1)
def get_vault_client() -> hvac.Client:
client = hvac.Client(url="https://vault.internal:8200")
client.auth.approle.login(
role_id=os.environ["VAULT_ROLE_ID"],
secret_id=os.environ["VAULT_SECRET_ID"],
)
return client
def get_llm_api_key(provider: str) -> str:
client = get_vault_client()
secret = client.secrets.kv.v2.read_secret_version(
path=f"jarvis/llm/{provider}", mount_point="secret"
)
return secret["data"]["data"]["api_key"]
4. API security¶
OAuth 2.1 + PKCE obbligatorio¶
import hashlib
import secrets
import base64
from dataclasses import dataclass
@dataclass(frozen=True)
class PKCEChallenge:
verifier: str
challenge: str
method: str = "S256"
def generate_pkce_challenge() -> PKCEChallenge:
verifier = secrets.token_urlsafe(64) # 86 chars ≈ 512 bit
digest = hashlib.sha256(verifier.encode()).digest()
return PKCEChallenge(
verifier=verifier,
challenge=base64.urlsafe_b64encode(digest).rstrip(b"=").decode(),
)
JWT ES256 + family revocation¶
from datetime import datetime, timedelta, timezone
from jose import jwt
import uuid
ACCESS_TTL = timedelta(minutes=15)
REFRESH_TTL = timedelta(days=30)
def create_access_token(user_id: str, device_id: str, private_key: str) -> str:
now = datetime.now(timezone.utc)
payload = {
"sub": user_id, "did": device_id,
"iat": now, "exp": now + ACCESS_TTL,
"jti": str(uuid.uuid4()),
"iss": "open-jarvis", "aud": "jarvis-api",
}
return jwt.encode(payload, private_key, algorithm="ES256")
def create_refresh_token(user_id: str, family_id: str, private_key: str) -> str:
now = datetime.now(timezone.utc)
return jwt.encode(
{
"sub": user_id, "family": family_id,
"iat": now, "exp": now + REFRESH_TTL,
"jti": str(uuid.uuid4()), "type": "refresh",
},
private_key,
algorithm="ES256",
)
Webhook signature verification¶
import hashlib
import hmac
import time
from fastapi import HTTPException, Header, Request
WEBHOOK_MAX_AGE = 300 # 5 min
async def verify_webhook(
request: Request,
x_signature: str = Header(..., alias="X-Jarvis-Signature"),
x_timestamp: str = Header(..., alias="X-Jarvis-Timestamp"),
secret: str = "",
) -> bytes:
ts = int(x_timestamp)
if abs(time.time() - ts) > WEBHOOK_MAX_AGE:
raise HTTPException(400, "Timestamp expired")
body = await request.body()
expected = hmac.new(
secret.encode(), f"{x_timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
if not hmac.compare_digest(f"sha256={expected}", x_signature):
raise HTTPException(401, "Invalid signature")
return body
5. Threat modeling STRIDE¶
| Componente | Spoofing | Tampering | Info Disclosure | DoS | Elevation |
|---|---|---|---|---|---|
| API Gateway | JWT forgery | Body injection | Response leaks | HTTP flood | SSRF → IMDS |
| WireGuard | Peer impersonation | Rogue peer | Traffic analysis | UDP flood | Pivot LAN |
| Memory (Qdrant) | Unauth access | Vector poisoning | Memory exfil | Bulk insert OOM | Admin API |
| LLM Router | Provider impersonation | Prompt injection | Prompt leak | Token budget | Tool abuse |
| Mobile Agent | Device cloning | Binary tampering | Keystroke capture | Battery drain | Root via exploit |
| Auth | Token replay | DB tamper | Hash leak | Login flood | Privilege escalation JWT |
Defense in depth¶
Livello 1 — Perimetro: Cloudflare WAF + DDoS
Livello 2 — Network: nftables default-deny + WireGuard
Livello 3 — Transport: TLS 1.3 + mTLS inter-service
Livello 4 — Application: JWT ES256 + OAuth 2.1 PKCE + Rate Limit
Livello 5 — Data: Pydantic strict + parameterized queries
Livello 6 — Secrets: Vault dynamic + sops at-rest
Livello 7 — Runtime: Distroless + rootless + seccomp + AppArmor
Livello 8 — Audit: auditd + Loki + Wazuh alerting
6. Detect & respond¶
Wazuh 4.11 SIEM self-hosted¶
services:
wazuh-manager:
image: wazuh/wazuh-manager:4.11.0
volumes:
- wazuh_data:/var/ossec/data
- ./custom_rules:/var/ossec/etc/rules/local_rules.xml:ro
ports:
- "1514:1514/udp"
- "55000:55000"
Honeypot endpoint¶
from fastapi import APIRouter, Request
import logging
router = APIRouter()
hp = logging.getLogger("jarvis.honeypot")
@router.get("/api/v0/admin")
@router.post("/api/v0/admin")
@router.get("/.env")
@router.get("/wp-admin")
async def honeypot_trap(request: Request):
hp.warning("HONEYPOT_HIT", extra={
"ip": request.client.host,
"path": request.url.path,
"ua": request.headers.get("user-agent", ""),
"method": request.method,
})
return {"detail": "Not Found"}
Incident response runbook¶
IR-001: JWT compromise
- CONTAIN — Revoca family refresh:
Redis DEL family:<id> - IDENTIFY — Grep jti in Loki ultima ora
- ERADICATE — Ruota chiave ES256, ri-emetti tutti i token
- RECOVER — Notifica utente via canale out-of-band
- LESSONS — Aggiorna fail2ban + soglie rate limit
IR-002: Container escape
- ISOLATE —
docker stop+ snapshot volume - FORENSIC — Core dump + auditd export
- REBUILD — Da zero, nuovo base image verificato
- HARDEN — Aggiorna seccomp profile, blocklist syscall
7. GDPR compliance¶
Privacy by design (Art. 25)¶
| Principio | Implementazione |
|---|---|
| Minimizzazione | Solo campi necessari in ChatRequest, no fingerprinting passivo |
| Limitazione finalità | Memoria per personalizzazione, mai per training |
| Integrità/riservatezza | AES-256-GCM Qdrant at-rest, TLS 1.3 in transit |
| Responsabilità | Tutti gli accessi loggati con user_id + timestamp |
Right to erasure (Art. 17)¶
@router.delete("/gdpr/users/{user_id}/data")
async def right_to_erasure(user_id: str, current=Depends(get_current_user)):
if current.sub != user_id:
raise HTTPException(403)
deleted_vectors = await qdrant.delete_by_user(user_id)
deleted_db = await db.execute(
"DELETE FROM user_data WHERE user_id = $1 RETURNING count(*)", user_id
)
await token_store.revoke_all_for_user(user_id)
audit_log.info("GDPR_ERASURE", user_id=user_id, vectors=deleted_vectors)
return {
"status": "erased",
"deleted_vectors": deleted_vectors,
"deleted_records": deleted_db,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
DPIA template¶
| Dato | Base giuridica | Retention | Misure |
|---|---|---|---|
| Messaggi chat | Legittimo interesse | 90gg config | AES-256-GCM + TLS |
| Biometrici (HRV, CGM) | Consenso esplicito | 365gg | FHIR vault separato |
| Pattern comportamentali | Legittimo interesse | 30gg | Anonim. dopo 7gg |
| Log accesso | Obbligo NIS2 | 12 mesi | Read-only utente |
8. Supply chain security¶
SBOM con Syft + CycloneDX¶
# .github/workflows/sbom.yml
name: Generate SBOM
on: [push, release]
jobs:
sbom:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin
- run: syft dir:. -o cyclonedx-json=sbom.json
- run: syft ghcr.io/open-jarvis/api:${{ github.sha }} -o cyclonedx-json=sbom-image.json
- uses: actions/upload-artifact@v4
with: { name: sbom, path: sbom*.json }
SAST/DAST¶
# .github/workflows/security.yml
jobs:
semgrep:
steps:
- uses: returntocorp/semgrep-action@v1
with:
config: |
p/python
p/secrets
p/owasp-top-ten
p/fastapi
codeql:
steps:
- uses: github/codeql-action/init@v3
with: { languages: python }
- uses: github/codeql-action/analyze@v3
grype:
steps:
- uses: anchore/scan-action@v4
with:
image: ghcr.io/open-jarvis/api:${{ github.sha }}
severity-cutoff: high
fail-build: true
Signed commits + SLSA Level 3¶
# SSH signing (raccomandato 2026)
git config --global gpg.format ssh
git config --global user.signingkey ~/.ssh/id_ed25519.pub
git config --global commit.gpgsign true
# Release con SLSA provenance
provenance:
needs: [build]
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2
with:
image: ghcr.io/open-jarvis/api
digest: ${{ needs.build.outputs.digest }}
permissions:
id-token: write
contents: write
actions: read
Dependabot/Renovate¶
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended", "security:openssf-scorecard"],
"vulnerabilityAlerts": { "enabled": true, "labels": ["security"] },
"packageRules": [
{ "matchUpdateTypes": ["patch"], "automerge": true }
]
}
Appendice: pre-deploy checklist¶
VPS¶
- SSH: porta cambiata, root disabilitato, key-only
- fail2ban attivo SSH + API
- ufw/nftables: solo 443, 2222, 51820
- Caddy: TLS 1.3, HSTS, CSP verificati
- AppArmor enforced
- Container distroless, nonroot, read-only
- auditd + Loki pipeline
Network¶
- WireGuard AllowedIPs strict per peer
- mTLS certificati da CA interna
- Cloudflare Tunnel (no porte aperte VPS)
Application¶
- Pydantic strict + extra=forbid
- JWT ES256 TTL 15 min + refresh rotation
- Rate limiting Redis-backed
- Webhook HMAC + anti-replay
- sops + age + Vault, no secret in chiaro
CI/CD¶
- Semgrep + CodeQL verde
- Grype: no HIGH/CRITICAL
- SBOM generato e firmato
- Commit signed
- SLSA Level 3 provenance
- Dependabot/Renovate attivo
Stack versioni mag 2026¶
| Tool | Versione | Ruolo |
|---|---|---|
| Caddy | 2.9 | Reverse proxy |
| WireGuard | kernel 6.17 built-in | VPN mesh |
| Wazuh | 4.11 | SIEM/XDR |
| Syft | 1.x | SBOM generation |
| Grype | 0.9x | Vulnerability scan |
| sops | 3.9 | Secret encryption |
| slowapi | 0.1.9 | Rate limiting |