Raport z audytu bezpieczeństwa — Arc OS

Data: 2026-04-23 Audytor: Sentinel (Super DevOps / Security Auditor) Faza projektu: 40.18 Zakres: Backend (Bun :19210), Nginx (:18888/:443), NotebookLM (:19213), Vault, Multi-tenancy


Podsumowanie wykonawcze

Przeprowadzono audyt bezpieczeństwa systemu Arc OS. Znaleziono 3 krytyczne podatności multi-tenancy, umożliwiające uwierzytelnionemu użytkownikowi dostęp do zasobów cudzych projektów (logi, terminal, wiki, zgłoszenia). Projekt nie przechowuje aktualnie poufnych danych wielu klientów, więc ryzyko eksploatacji jest niskie, ale architektonicznie system nie jest gotowy do multi-user production.

Werdykt: ŻÓŁTY — natychmiastowe łatanie C1–C3, zanim zaprosi się prawdziwych użytkowników spoza CEO.


🔴 KRYTYCZNE PODATNOŚCI (3)

CVE-C1 — Strumienie SSE bez weryfikacji owner_id

Severity: High CWE: CWE-285 (Improper Authorization) Pliki:

Opis: Funkcja routeSseRequest(pathname, query, registry) nie otrzymuje chatId i nie wywołuje canAccessProject. Weryfikowane jest wyłącznie:

  1. JWT przez crmAuthMiddleware
  2. isValidProjectName — tylko żeby nie było path traversal

Eksploatacja:

# Użytkownik A uzyskał swój token przez /api/auth/login
# Wie, że istnieje projekt "victim-project" (z DB lub zgadywania)
curl -N "https://arc-os.co/api/sse/logs/victim-project?token=$MY_TOKEN"
# → otrzymuje strumień JSONL logów cudzego projektu w czasie rzeczywistym
# To samo dla /api/sse/consultant/:name

Zalecana łatka:

// shared/crm-routes.ts:2613
export function routeSseRequest(
  pathname: string,
  query: URLSearchParams,
  registry: Registry,
  chatId: string | null,  // ← dodaj
): Response | null {
  const logsMatch = pathname.match(/^\/api\/sse\/logs\/([^/]+)$/);
  if (logsMatch) {
    const name = decodeURIComponent(logsMatch[1]);
    if (!isValidProjectName(name)) { ... }
    if (!canAccessProject(registry, chatId, name)) {
      return Response.json({ error: "Forbidden" }, { status: 403 });
    }
    return handleSseLogs(name, registry, query);
  }
  // to samo dla /api/sse/consultant/:name
}

// master-bot/api-server.ts:406
const chatId = extractChatId(req);  // eksportuj z crm-routes
const sseResponse = routeSseRequest(url.pathname, url.searchParams, ctx.registry, chatId);

CVE-C2 — Terminal WebSocket bez owner_id

Severity: Critical CWE: CWE-285 Plik: master-bot/api-server.ts:250-283

Opis: /ws/terminal/:name weryfikuje JWT + isValidProjectName, ale nie sprawdza własności. Każdy uwierzytelniony użytkownik uzyskuje dostęp do sesji tmux cudzego projektu. W trybie ?mode=interactive to interaktywny shell.

Komentarz na :270 sam się przyznaje:

const interactive = url.searchParams.get("mode") === "interactive";
// TODO: [debt-7] Gate behind admin-scoped token, not just query param

Eksploatacja:

ws://arc-os.co/ws/terminal/victim-project?token=MY_TOKEN&mode=interactive
→ interaktywna sesja shell w tmux cudzego projektu
→ pełny odczyt/zapis w systemie plików VPS jako root

Zalecana łatka:

// master-bot/api-server.ts:258
const projectName = decodeURIComponent(wsMatch[1]);
if (!isValidProjectName(projectName)) { ... }

// DODAJ:
const chatId = verifyToken(token).chatId;
if (!canAccessProject(ctx.registry, chatId, projectName)) {
  return Response.json({ error: "Forbidden" }, { status: 403 });
}

// Tryb interactive — tylko admin
if (interactive) {
  const user = userQueries.findById(chatId);
  const isCeo = String(ctx.registry.master.ceo_chat_id) === chatId;
  if (!isCeo && user?.role !== "admin") {
    return Response.json({ error: "Admin required" }, { status: 403 });
  }
}

CVE-C3 — /api/cli/* + /api/mcp/* bez owner_id (12+ endpointów)

Severity: High CWE: CWE-285 Plik: master-bot/api-server.ts:913-1084

Opis: Cały blok /api/cli/* i /api/mcp/* weryfikuje tylko JWT (crmAuthMiddleware) + isValidProjectName. Żadnego canAccessProject. Dotknięte endpointy:

Endpoint Metoda Konsekwencja
/api/cli/init/:project/:mode GET Pobranie CLAUDE.md cudzego projektu
/api/cli/chat-log/:project POST Wstawianie wiadomości do cudzego chatu
/api/mcp/skills/:project POST/GET Zmiana skilli cudzego projektu
/api/mcp/report/:project POST Wysłanie raportu w imieniu cudzego
/api/mcp/learnings/:project GET Odczyt learnings.md cudzego
/api/mcp/issues/:project POST/GET CRUD zgłoszeń cudzego projektu
/api/mcp/issues/:project/:id PUT Zmiana cudzych zgłoszeń
/api/mcp/issues/:project/:id/log POST Zapis w activity trail
/api/mcp/wiki/:project PUT Nadpisanie wiki cudzego projektu
/api/mcp/roadmap/:project GET/PUT Zmiana roadmapy

Zalecana łatka:

// master-bot/api-server.ts:914 (zaraz po bloku if)
if (url.pathname.startsWith("/api/cli/") || url.pathname.startsWith("/api/mcp/")) {
  const preflight = handleCorsPreflightIfNeeded(req);
  if (preflight) return preflight;
  const denied = crmAuthMiddleware(req);
  if (denied) { ... }

  // DODAJ TEN BLOK:
  const projectMatch = url.pathname.match(/^\/api\/(cli|mcp)\/[^/]+\/([^/]+)/);
  if (projectMatch) {
    const project = decodeURIComponent(projectMatch[2]);
    if (isValidProjectName(project)) {
      const chatId = extractChatId(req);
      if (!canAccessProject(ctx.registry, chatId, project)) {
        const headers = corsHeaders(req.headers.get("Origin") || undefined);
        return Response.json({ error: "Forbidden" }, { status: 403, headers });
      }
    }
  }

  // ... istniejący router ...
}

⚠️ Uwaga: trasy download (/api/cli/download/...) i device-code (/api/cli/device/*) NIE MAJĄ projektu w URL — nie powinny być blokowane. Sprawdź kolejność warunków.


🟡 POWAŻNE PODATNOŚCI (3)

CVE-S1 — Bun.serve nasłuchuje na 0.0.0.0:19210

Severity: Medium (defense-in-depth) CWE: CWE-668 (Exposure of Resource to Wrong Sphere) Plik: master-bot/api-server.ts:159-161

Opis:

const server = Bun.serve({
  port: ctx.config.HEALTH_PORT,  // brak hostname → domyślnie 0.0.0.0
  ...
});

Na VPS ss -tlnp pokazuje:

LISTEN *:19210  users:(("bun",pid=578729,fd=15))

Bun nasłuchuje na wszystkich interfejsach. Aktualnie UFW blokuje :19210 zewnętrznie (timeout HTTP 000 z laptopa), ale:

  1. curl http://62.171.128.248:19210/api/internal/bridges z samego VPS → HTTP 200 (loopback na publicznym IP)
  2. Każdy z kontenera na VPS może kontaktować się z /api/internal/* BEZ auth
  3. Jedna błędna komenda ufw allow 19210 — natychmiastowy globalny wyciek
  4. Migracja na inny VPS bez UFW — natychmiastowy wyciek

/api/internal/bridges, /api/internal/chat/save, /api/internal/relay/:project/tool WSZYSTKIE bez auth (komentarz: „localhost-only, not exposed via nginx").

Zalecana łatka:

const server = Bun.serve({
  hostname: "127.0.0.1",  // ← dodaj
  port: ctx.config.HEALTH_PORT,
  ...
});

⚠️ Nginx na VPS proksuje 127.0.0.1:19210 — łatka nie zepsuje ruchu publicznego.


CVE-S2 — /api/internal/chat/save przyjmuje dowolny project_name

Severity: Medium CWE: CWE-20 (Improper Input Validation) Plik: master-bot/api-server.ts:376-398

Opis: Endpoint wstawia do chat_messages bez walidacji:

Zalecana łatka:

if (!body.project_name || !isValidProjectName(body.project_name)) {
  return Response.json({ error: "Invalid project_name" }, { status: 400 });
}

CVE-S3 — Tryb interactive WebSocket przez query-param

Severity: Medium Plik: master-bot/api-server.ts:270

Opis: ?mode=interactive otwiera interaktywny shell dla każdego ważnego tokenu, nie admin-scoped. Autor kodu sam oznaczył TODO: debt-7. Zobacz łatkę w CVE-C2.


🟢 DROBNE USTALENIA

M1 — verifyToken: proste porównanie string signature

Plik: shared/auth.ts:270

if (signature !== expected) { return { valid: false, ...}; }

Lepiej: crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)). HMAC-SHA256 256-bit — praktycznie bezpieczne, ale best practice.

M2 — Nginx: /config/ niezablokowany

Plik: infra/nginx/citadel-crm.conf:179-180, 355-360

location ~ /\. { deny all; }
location ~ ^/(state|scripts)/ { deny all; }

CLAUDE.md mówi: „blocked paths (/.*, /config/, /state/)" — dokumentacja rozchodzi się z rzeczywistością. Aktualnie niekrytyczne (ruch / idzie do Dockera, nie na dysk), ale:

M3 — safePath() TODO

Plik: shared/crm-routes.ts:279

TODO: [debt-7] Apply to all endpoints, not just /files

Znalezione przez sam projekt. Rewizja: sprawdź, które handlery przyjmują ścieżki użytkownika i nie wywołują safePath.


✅ CO DZIAŁA DOSKONALE

Sprawdzenie Status Dowód
Vault nie w git git check-ignore config/vault.json → matched .gitignore:30
vault-key nie w git .gitignore:31 + chmod 600
data/citadel.db nie w git .gitignore:35
.env nie w git .gitignore:16
NotebookLM bridge tylko localhost --host 127.0.0.1 w jednostce systemd
NotebookLM nieproksowany przez Nginx Brak location /notebook* w konfigu
Regex isValidProjectName /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, maks. 64 znaki
canAccessProject dla /api/crm/projects/:name/* Bramka w punkcie wejścia (:5689-5695)
Multi-tenancy handleGetProjects Filtrowanie przez owner_id (DB jako SSOT)
Vault AES-256-GCM createCipheriv("aes-256-gcm"...)
Atomiczne zapisy tmp.${pid} + mv w writeVaultFile
JWT TTL 24h TOKEN_TTL_SEC = 24 * 60 * 60
Token CSRF OAuth TTL 10 min, jednorazowy (auth.ts:51-71)
TTL resetu hasła 30 min RESET_TTL_MS
TTL weryfikacji email 24h VERIFY_TTL_MS
TTL kodu urządzenia 10 min DEVICE_CODE_TTL_MS
Backup owner_id w vps-sync.sh Backup przed git pull, przywracanie po
Test dymny zdrowia w vps-sync.sh Oczekuje 401 przy CRM bez auth
Test path traversal w vps-sync.sh Oczekuje 401/403 na .hidden-traversal
UFW blokuje :19210/:19213/:19200 Test z laptopa: HTTP 000 timeout
Whitelist CORS env CRM_ALLOWED_ORIGINS
Nagłówki CORS na odpowiedziach błędów Lekcja w CLAUDE.md
Tryb WAL SQLite PRAGMA journal_mode = WAL
safePath() dla endpointów /files resolve + startsWith + null przy naruszeniu
Ochrona przed path traversal przy pobieraniu Path traversal blocked → 403

📋 Plan łatek (priorytetowo)

Dzisiaj (blokada dla multi-user)

Ten tydzień

Backlog


📎 Dodatkowe komendy do testów regresji

# Test dymny: SSE powinno zwracać 403 dla cudzego projektu
TOKEN_A=$(curl -s -X POST https://arc-os.co/api/auth/login -d '{"email":"a@test","password":"..."}' | jq -r .token)
curl -N "https://arc-os.co/api/sse/logs/b-project?token=$TOKEN_A"
# Oczekiwane: 403 Forbidden (aktualnie: 200 OK — PODATNOŚĆ)

# Test dymny: terminal WebSocket cudzego projektu
websocat "wss://arc-os.co/ws/terminal/b-project?token=$TOKEN_A"
# Oczekiwane: 403 (aktualnie: upgrade OK — PODATNOŚĆ)

# Test dymny: aktualizacja wiki MCP cudzego projektu
curl -X PUT "https://arc-os.co/api/mcp/wiki/b-project" \
  -H "Authorization: Bearer $TOKEN_A" \
  -d '{"file":"README","content":"pwned"}'
# Oczekiwane: 403 (aktualnie: 200 OK — PODATNOŚĆ)

# Weryfikacja bindowania portu po łatce CVE-S1:
ssh VPS "ss -tlnp | grep 19210"
# Oczekiwane: 127.0.0.1:19210 (aktualnie: *:19210)

🔄 Ponowny audyt (2026-04-23, po Phase 42)

Zamknięte: 11/11 ustaleń + 1 bonus (V0: path-traversal handleSaveSkill — znalazł deweloper).

Weryfikacja łatek

CVE Plik:Linia Status
C1 crm-routes.ts:2617, 2627, 2641
C2 api-server.ts:274-288 (guard + interactive CEO/admin)
C3 api-server.ts:950-968 (regex bramki wejścia + skipGuard)
S1 api-server.ts:161 + prod ss -tlnp: 127.0.0.1:19210
S2 api-server.ts:404
M1 auth.ts:272 (timingSafeEqual + sprawdzenie długości przed compare)
M2 citadel-crm.conf:180, 360 (lista deny rozszerzona)
M3 crm-routes.ts:4097, 4107, 4115 (regex + safePath belt+suspenders)
V0 (bonus) crm-routes.ts:4097

Produkcyjne testy dymne

ss -tlnp | grep 19210      # → 127.0.0.1:19210 (było *:19210) ✅
curl 62.171.128.248:19210/api/internal/bridges --max-time 3  # → 000 timeout ✅
curl localhost:18888/api/crm/projects             # → 401 ✅

Nowe ustalenia ponownego audytu

ID Severity Plik:Linia Opis
FN-1 Medium api-server.ts:956-967 Bramka wejścia C3 ma wzorzec fail-open — jeśli isValidProjectName === false, guard jest pomijany. Aktualnie bezpieczne (handlery sprawdzają samodzielnie), ale fail-closed jest architektonicznie silniejszy.
FN-2 Low api-server.ts:955 Martwa referencja do /api/cli/chat-save w skipGuard — endpoint nie istnieje. Higiena.
FN-3 Low api-server.ts:376-391 /api/internal/bridge-event/:project bez isValidProjectName. Chronione przez S1 + UFW, ale belt+suspenders.

Ocena jakości

Zgodność Karpathy: 8,5/10

Zadania do wykonania

ID Priorytet
SEC-FN1 P2 — fail-closed w bramce wejścia C3
SEC-FN2 P3 — usuń martwą referencję /api/cli/chat-save
SEC-V1 P2 — isValidProjectName + guard assertLocalhost w /api/internal/bridge-event/:project
SEC-V2 P2 — rewizja handleCliInit pod kątem wycieku env przez podstawianie szablonów
SEC-V3 P3 — test regresji path-traversal w vps-sync.sh

🔐 Podpisano

Standard Sentinel: Karpathy — chirurgiczna precyzja, minimalizm, bezwzględna krytyczność.

Status: ZAMKNIĘTY — audyt pierwotny + audyt ponowny ukończone. Werdykt ponownego audytu: 🟢 ZIELONY — praca przyjęta.