feat(wave5): CI/CD pipeline + Vault KMS + OKR deploy to port 80/3001

Infrastructure (H3 CI gate, H5 KMS):
- Gitea Actions enabled (GITEA__actions__ENABLED=true)
- act_runner: Docker-outside-of-Docker for deploy job
- Vault Transit RSA-2048 signing keys (casan-audit-key, casan-policy-key)

Vault KMS scripts (H5 governance):
- .specify/scripts/bash/vault-kms.sh — sign/verify/pubkey/ensure-key
- .specify/scripts/bash/sign-audit-head.sh — sign audit chain via Vault
- Updated sign-policy-bundle.sh — Vault path + local fallback
- Updated security-gate.sh — KMS gate added (PASS=11 FAIL=0)

OKR app deployment (port 80/3001):
- Dockerfile.backend — node:22-slim (node:sqlite requires Node 22)
- Dockerfile.frontend — node:20-alpine build + nginx:alpine runtime
- nginx/nginx.conf — React SPA + /api/v1/* proxy to okr-backend:3001
- backend/entrypoint.sh — auto init DB on first run + seed
- .dockerignore

CI pipeline (.gitea/workflows/ci.yml):
- Job 1: Vitest frontend tests (H3)
- Job 2: CASAN security gate + Vault KMS signing (H4/H5)
- Job 3: Deploy OKR → port 80 (runs on push to main after tests pass)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
thanhnv
2026-07-01 12:55:17 +09:00
co-authored by Claude Sonnet 4.6
parent 6e95e929f0
commit 9892e82221
15 changed files with 2528 additions and 6 deletions
+14
View File
@@ -0,0 +1,14 @@
**/node_modules/
**/dist/
*.db
*.sqlite
.git/
.gitea/
.specify/logs/
docs/output/
*.pem
.env
.env.*
backend/test/
**/__pycache__/
*.pyc
@@ -0,0 +1,36 @@
# act_runner configuration for CASAN CI on Oracle Cloud VPS (1 GB RAM)
# Deploy to: /opt/gitea/act-runner-config.yaml on the VPS.
log:
level: info
runner:
# Unique name shown in Gitea → Site Administration → Runners
name: "casan-runner-oracle"
# Only 1 concurrent job — VPS has 1 GB RAM (Gitea ≈ 200 MB + job container ≈ 300 MB)
capacity: 1
# Map "ubuntu-latest" jobs to the catthehacker image that has git, curl, jq,
# openssl, Node.js 18, and Python3 pre-installed. The workflow steps upgrade
# Node.js to 20 via actions/setup-node and add python-is-python3.
labels:
- "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04"
# How often the runner polls Gitea for new jobs
fetch_interval: 5s
fetch_timeout: 30s
# Where to store the runner's working directory (inside the container)
# Default is /workspace — works fine.
container:
network: bridge
# --memory 512m: prevent OOM on 1 GB VPS (build runs in HOST daemon, not job container)
# -v socket: Docker-outside-of-Docker for the deploy job; lets docker CLI in the job
# container talk to the HOST daemon to build + run OKR images.
options: "--memory 512m --cpus 1.5 -v /var/run/docker.sock:/var/run/docker.sock"
valid_volumes:
- "**"
@@ -0,0 +1,158 @@
# OKR Web App — Deploy Runbook
# Oracle Cloud VPS: 161.33.139.73
## What this deploys
| Container | Image | Port | Notes |
|---------------|--------------------|--------------|--------------------------------|
| okr-frontend | nginx:alpine | **80** (public) | React SPA, proxies /api/v1/* → backend |
| okr-backend | node:22-slim | 3001 (internal) | NestJS + Prisma + SQLite |
Network: `okr-net` (bridge, separate from Gitea/Vault)
Volume: `okr-db` → mounted at `/data/okr.db` inside backend
---
## Step 1 — Add JWT_SECRET to Gitea Secrets
1. Open http://161.33.139.73:3000 → your repo → **Settings → Secrets → Actions**
2. Add:
- Name: `JWT_SECRET`
- Value: `$(openssl rand -hex 32)` ← run this locally to generate
3. Save.
---
## Step 2 — Update act_runner config (Docker socket passthrough)
The deploy job needs Docker CLI inside the job container to build and run images.
The updated `act-runner-config.yaml` already includes:
```yaml
container:
options: "--memory 512m --cpus 1.5 -v /var/run/docker.sock:/var/run/docker.sock"
```
Apply the new config on the VPS:
```bash
# Copy updated config
scp .gitea/act-runner-config.yaml ubuntu@161.33.139.73:/opt/gitea/act-runner-config.yaml
# Restart act_runner to pick up the new config
ssh ubuntu@161.33.139.73 "cd /opt/gitea && docker compose restart act-runner"
```
> The `-v /var/run/docker.sock:...` flag passes the HOST Docker socket into each
> job container, enabling Docker-outside-of-Docker (DooD). The socket is already
> in `valid_volumes: ["**"]` so no extra ACL change is needed.
---
## Step 3 — Push to main to trigger CI + deploy
```bash
git add Dockerfile.backend Dockerfile.frontend nginx/ backend/entrypoint.sh \
.dockerignore .gitea/
git commit -m "feat: deploy OKR app to port 80/3001 via CI"
git push gitea main
```
Watch the pipeline: http://161.33.139.73:3000/<USER>/casan5/actions
Expected jobs:
1. **Frontend Tests** — 16 Vitest tests (~1 min)
2. **CASAN Security Gate** — harness + Vault signing (~3 min)
3. **Deploy OKR** — builds images + deploys (~5 min first time, ~2 min cached)
After deploy:
- **App**: http://161.33.139.73 (login: `admin@okr.local` / `Password@123`)
- **Gitea**: http://161.33.139.73:3000 (unchanged)
---
## Manual deploy (without CI)
If you need to deploy manually from the VPS:
```bash
ssh ubuntu@161.33.139.73
# Clone or pull repo
cd /opt/okr-app # or wherever you checked out the repo
# Build images
docker build -t okr-backend:latest -f Dockerfile.backend .
docker build \
--build-arg VITE_API_BASE_URL=/api/v1 \
-t okr-frontend:latest \
-f Dockerfile.frontend .
# Network + volume
docker network create okr-net 2>/dev/null || true
docker volume create okr-db 2>/dev/null || true
# Backend
docker rm -f okr-backend 2>/dev/null || true
docker run -d \
--name okr-backend \
--network okr-net \
-e PORT=3001 \
-e DATABASE_URL=file:/data/okr.db \
-e JWT_SECRET="$(openssl rand -hex 32)" \
-e FRONTEND_ORIGIN="http://161.33.139.73" \
-e NODE_ENV=production \
-v okr-db:/data \
--restart unless-stopped \
okr-backend:latest
# Frontend (nginx)
docker rm -f okr-frontend 2>/dev/null || true
docker run -d \
--name okr-frontend \
--network okr-net \
-p 80:80 \
--restart unless-stopped \
okr-frontend:latest
# Check
sleep 15
curl -sf http://localhost/ | grep -c "html" && echo "FRONTEND_UP"
docker logs okr-backend --tail 10
```
---
## RAM budget (1 GB VPS)
| Service | Idle RAM |
|----------------|-----------|
| Gitea | ~180 MB |
| act_runner | ~50 MB |
| Vault | ~60 MB |
| okr-backend | ~120 MB |
| okr-frontend | ~25 MB |
| **Total idle** | **~435 MB** ✅ |
| During CI build (Docker build on HOST) | +400 MB peak → ~835 MB ✅ |
---
## Troubleshooting
```bash
# Container status
docker ps -a | grep okr
# Backend logs (shows DB init + startup)
docker logs okr-backend --tail 50
# Frontend logs
docker logs okr-frontend --tail 20
# Re-seed database (drops + recreates)
docker exec okr-backend sh -c "rm /data/okr.db && kill 1"
# container restarts → auto re-initialises
# Check nginx proxy config
docker exec okr-frontend nginx -t
```
@@ -0,0 +1,225 @@
# HashiCorp Vault Setup — CASAN H5 KMS
# Oracle Cloud VPS: 161.33.139.73
## What this achieves
Before: private key = file on disk, `verify-audit-chain` → `anchor=unsigned` in CI
After: private key = Vault Transit (never leaves Vault), CI → `anchor=signed`
---
## Step 1 — Add Vault to /opt/gitea/docker-compose.yml
Add the `vault` service and a named volume. The key sections to add:
```yaml
# ── Add to the top-level volumes section ─────────────────────────
volumes:
gitea-data:
act-runner-data:
vault-data: # ← ADD THIS
# ── Add the vault service ─────────────────────────────────────────
vault:
image: hashicorp/vault:latest
container_name: vault
restart: unless-stopped
networks:
- gitea
ports:
- "8200:8200" # expose so you can init from outside
environment:
# Dev mode: auto-init, auto-unseal, in-memory storage.
# Root token is fixed — store it as a Gitea secret after setup.
VAULT_DEV_ROOT_TOKEN_ID: "${VAULT_ROOT_TOKEN}"
VAULT_DEV_LISTEN_ADDRESS: "0.0.0.0:8200"
VAULT_LOG_LEVEL: "warn"
cap_add:
- IPC_LOCK # required by Vault
command: server -dev
```
> Dev mode stores keys in RAM — keys survive container restarts because the token
> and key name are fixed, and Vault re-creates them on each start.
> For production: switch to `server` mode with `file` storage backend.
---
## Step 2 — Add VAULT_ROOT_TOKEN to /opt/gitea/.env
```bash
# Pick a strong token (or generate one)
echo "VAULT_ROOT_TOKEN=casan-vault-$(openssl rand -hex 16)" >> /opt/gitea/.env
# Verify
grep VAULT_ROOT_TOKEN /opt/gitea/.env
```
Save this token — you'll need it for the Gitea secret in Step 5.
---
## Step 3 — Start Vault
```bash
cd /opt/gitea
docker compose pull vault
docker compose up -d vault
# Wait ~5 seconds for Vault to boot, then check:
docker compose logs vault | tail -20
# Look for: "Development mode should NOT be used in production installations!"
# and: "Root Token: <your token>"
```
Verify from outside:
```bash
curl http://161.33.139.73:8200/v1/sys/health
# Expected: {"initialized":true,"sealed":false,...}
```
---
## Step 4 — Enable Transit engine + create signing keys
Run these from anywhere that can reach port 8200.
Replace `<TOKEN>` with your `VAULT_ROOT_TOKEN` value.
```bash
export VAULT_ADDR=http://161.33.139.73:8200
export VAULT_TOKEN=<TOKEN>
# Enable Transit secrets engine
curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"transit"}' \
"$VAULT_ADDR/v1/sys/mounts/transit"
# Create RSA-2048 key for policy bundle signing
curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"rsa-2048","exportable":false}' \
"$VAULT_ADDR/v1/transit/keys/casan-policy-key"
# Create RSA-2048 key for audit chain signing
curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"rsa-2048","exportable":false}' \
"$VAULT_ADDR/v1/transit/keys/casan-audit-key"
echo "Vault Transit keys created."
```
Verify keys exist:
```bash
curl -sf -H "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/transit/keys/casan-policy-key" | python3 -c "
import sys,json; d=json.load(sys.stdin)
print('key type:', d['data']['type'])
print('exportable:', d['data']['exportable'])
"
# Expected: key type: rsa-2048 / exportable: False
```
---
## Step 5 — Add VAULT_TOKEN as Gitea Actions Secret
1. Open Gitea: http://161.33.139.73:3000
2. Go to your repo → Settings → Secrets → Actions
3. Add new secret:
- Name: `VAULT_TOKEN`
- Value: your `VAULT_ROOT_TOKEN` value
4. Save.
The CI workflow references `${{ secrets.VAULT_TOKEN }}` — this injects the token
into the `security-gate` job without exposing it in logs.
---
## Step 6 — Test Vault signing locally (optional)
From macOS with the project checked out:
```bash
export VAULT_ADDR=http://161.33.139.73:8200
export VAULT_TOKEN=<TOKEN>
cd Output_CASAN5_REFINED/AINative_OKR_CASAN5
# Test sign-policy-bundle with Vault
bash .specify/scripts/bash/sign-policy-bundle.sh sign
# Expected: POLICY_BUNDLE_SIGNED ... key_backend=vault-kms
# Test sign-audit-head
bash .specify/scripts/bash/sign-audit-head.sh
# Expected: SIGN_AUDIT_HEAD_OK ... anchor=vault-kms
# Verify audit chain
bash .specify/scripts/bash/verify-audit-chain.sh
# Expected: AUDIT_CHAIN_VALID anchor=signed
```
---
## Step 7 — Push code to trigger CI with Vault
```bash
git add .gitea/ .specify/scripts/bash/vault-kms.sh \
.specify/scripts/bash/sign-audit-head.sh \
.specify/scripts/bash/sign-policy-bundle.sh
git commit -m "feat(H5): Vault KMS for policy + audit chain signing"
git push gitea main
```
Watch the CI run: http://161.33.139.73:3000/<USER>/casan5/actions
Expected security-gate job output:
```
VAULT_KMS_READY
...
SIGN_AUDIT_HEAD_OK head=<hash> anchor=vault-kms
POLICY_BUNDLE_SIGNED ... key_backend=vault-kms
AUDIT_CHAIN_VALID anchor=signed ← changed from "unsigned"
...
== verdict: PASS=7 FAIL=0 SKIP=1 ==
```
---
## Architecture summary
```
CI Job (act_runner container)
│
├── vault-kms.sh sign ──── POST /v1/transit/sign/casan-audit-key ──► Vault container
│ (key never leaves)
├── vault-kms.sh pubkey ──── GET /v1/transit/keys/casan-audit-key ──► export public key
│ as PEM for openssl verify
└── verify-audit-chain.sh ── openssl dgst -verify audit-public.pem
→ AUDIT_CHAIN_VALID anchor=signed ✅
```
Private key:
- Stored only inside Vault's in-memory transit engine
- Never written to disk as `.pem`
- Never visible in CI logs
- Access gated by `VAULT_TOKEN` Gitea secret
---
## RAM footprint on VPS (1 GB)
| Service | Idle RAM |
|---|---|
| Gitea | ~180 MB |
| act_runner | ~50 MB |
| **Vault** | **~60 MB** |
| Total idle | ~290 MB |
| During CI | +300 MB (job container) |
| **Peak** | **~590 MB** ✅ |
Vault in dev mode uses ~60 MB — well within the 1 GB budget.
@@ -0,0 +1,222 @@
# VPS CI/CD Setup Runbook
# Oracle Cloud Tokyo — 161.33.139.73
## Architecture
```
VPS (Ubuntu 24.04, 1 GB RAM)
├── /opt/gitea/
│ ├── docker-compose.yml ← add act-runner service here
│ ├── .env ← add RUNNER_REGISTRATION_TOKEN here
│ ├── act-runner-config.yaml ← copy from .gitea/act-runner-config.yaml
│ └── gitea-data/
```
---
## Step 1 — Enable Gitea Actions
SSH into the VPS:
```bash
ssh ubuntu@161.33.139.73
```
Add the Actions env variable to the Gitea service in docker-compose.yml:
```yaml
# In the gitea service environment section, add:
- GITEA__actions__ENABLED=true
```
Restart Gitea:
```bash
cd /opt/gitea
docker compose restart gitea
```
Verify: Open http://161.33.139.73:3000 → Site Administration → Runners
You should see "Runners" menu item (confirming Actions is enabled).
---
## Step 2 — Get the Runner Registration Token
1. Log into Gitea as admin: http://161.33.139.73:3000
2. Go to: Site Administration (⚙) → Runners → "Create new runner"
3. Copy the **Registration Token** shown (looks like: `xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx`)
---
## Step 3 — Copy act_runner config to VPS
From your local machine:
```bash
scp AINative_OKR_CASAN5/.gitea/act-runner-config.yaml \
ubuntu@161.33.139.73:/opt/gitea/act-runner-config.yaml
```
---
## Step 4 — Add RUNNER_REGISTRATION_TOKEN to /opt/gitea/.env
On the VPS:
```bash
# Replace <TOKEN> with the token copied in Step 2
echo "RUNNER_REGISTRATION_TOKEN=<TOKEN>" >> /opt/gitea/.env
```
---
## Step 5 — Add act_runner service to docker-compose.yml
Edit `/opt/gitea/docker-compose.yml` and add the `act-runner` service.
**Typical Gitea docker-compose.yml after changes:**
```yaml
version: "3"
networks:
gitea:
external: false
volumes:
gitea-data:
act-runner-data:
services:
gitea:
image: gitea/gitea:latest
container_name: gitea
restart: always
networks:
- gitea
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__actions__ENABLED=true # ← ADD THIS LINE
ports:
- "3000:3000"
- "2222:22"
volumes:
- ./gitea-data:/data
act-runner:
image: gitea/act_runner:latest
container_name: act-runner
restart: unless-stopped
networks:
- gitea # same network as gitea
depends_on:
- gitea
environment:
- GITEA_INSTANCE_URL=http://gitea:3000 # internal Docker hostname
- GITEA_RUNNER_REGISTRATION_TOKEN=${RUNNER_REGISTRATION_TOKEN}
- GITEA_RUNNER_NAME=casan-runner-oracle
- CONFIG_FILE=/config/act-runner-config.yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock # DinD for job containers
- ./act-runner-config.yaml:/config/act-runner-config.yaml:ro
- act-runner-data:/data
```
> If your current docker-compose.yml already has a `networks:` or `volumes:` section,
> merge them — don't add duplicate top-level keys.
---
## Step 6 — Start act_runner
```bash
cd /opt/gitea
docker compose pull act-runner
docker compose up -d act-runner
```
Check registration:
```bash
docker compose logs -f act-runner
# Look for: "runner registered" or "connected to Gitea"
```
In Gitea Web UI: Site Administration → Runners → you should see "casan-runner-oracle" with status **Online**.
---
## Step 7 — Pull the catthehacker runner image (one-time)
The first CI run will pull `catthehacker/ubuntu:act-22.04` (~2 GB). Pre-pull to avoid timeout:
```bash
docker pull catthehacker/ubuntu:act-22.04
```
This takes ~2-5 minutes depending on internet speed.
---
## Step 8 — Create repo in Gitea and push the project
On the VPS (or via Gitea web UI), create a new repository:
- URL: http://161.33.139.73:3000
- Name: `casan5` (or any name)
- Make it public or private (your choice)
On your local machine:
```bash
cd Output_CASAN5_REFINED/AINative_OKR_CASAN5
# Add Gitea as remote
git remote add gitea ssh://git@161.33.139.73:2222/<YOUR_USER>/casan5.git
# Push
git push gitea main
```
---
## Step 9 — Verify CI triggered
After push, go to:
http://161.33.139.73:3000/<YOUR_USER>/casan5/actions
You should see a workflow run in progress. Click it to see live logs.
Expected final result:
```
✅ Frontend Tests (H3 gate) — 16/16 PASS
✅ CASAN Security Gate (H4/H5/H2) — PASS=7 SKIP=1 FAIL=0
```
SKIP=1 is expected (Ollama is not on the CI server — this is the model-router/red-team group).
The gate exits 0 because FAIL=0.
---
## Memory Monitoring
```bash
# Watch RAM usage while CI runs
watch -n 2 'free -h && docker stats --no-stream'
```
If OOM occurs, reduce the job container memory limit in act-runner-config.yaml
or add swap:
```bash
sudo fallocate -l 1G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
```
---
## Troubleshooting
| Symptom | Fix |
|---|---|
| Runner shows "Offline" | Check `docker compose logs act-runner`; verify token is correct |
| `GITEA_INSTANCE_URL` unreachable | Ensure gitea and act-runner are on the same Docker network |
| Job stuck "Waiting for runner" | Runner is busy (capacity=1); wait or increase capacity |
| `python: command not found` | The workflow's "Install test tools" step installs `python-is-python3` |
| OOM during npm ci | Switch to `npm ci -w frontend` (already done in workflow) or add swap |
| `catthehacker/ubuntu:act-22.04` pull fails | Run `docker pull` manually on VPS first |
+208
View File
@@ -0,0 +1,208 @@
name: CASAN CI Gate
# Runs on every push/PR to catch regressions (H3) and validate security controls (H4/H5).
on:
push:
branches: [main, develop, "feature/**"]
pull_request:
branches: [main]
# Cancel in-flight runs of the same branch when a newer push arrives.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ──────────────────────────────────────────────────────────────────────────
# Job 1 — Frontend unit tests (fast gate, ~1 min)
# ──────────────────────────────────────────────────────────────────────────
frontend-tests:
name: "Frontend Tests (H3 gate)"
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js 20
uses: actions/setup-node@v3
with:
node-version: "20"
cache: "npm"
- name: Install frontend dependencies
run: npm ci -w frontend
- name: Run Vitest (16 tests)
run: npm test -w frontend
# ──────────────────────────────────────────────────────────────────────────
# Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7)
#
# Steps:
# 1. Install tools
# 2. Run CASAN4 harness (35 tests) — rebuilds audit.jsonl from scratch
# 3. Run adversarial suite (44 tests)
# 4. Sign audit chain head via Vault KMS → anchor=signed (H5 improvement)
# 5. Sign policy bundle via Vault KMS → key_backend=vault-kms (H5)
# 6. security-gate.sh aggregate verdict
# PASS=7 SKIP=1(Ollama) FAIL=0
# ──────────────────────────────────────────────────────────────────────────
security-gate:
name: "CASAN Security Gate + Vault KMS (H4/H5)"
runs-on: ubuntu-latest
env:
# Vault runs as a sibling service on the same Docker network.
# VAULT_TOKEN is stored as a Gitea Actions secret (Settings → Secrets).
# Without Vault, scripts fall back to local key file — anchor=unsigned.
VAULT_ADDR: "http://vault:8200"
VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }}
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup Node.js 20
uses: actions/setup-node@v3
with:
node-version: "20"
- name: Install test tools
run: |
apt-get update -qq 2>/dev/null && \
apt-get install -y -qq jq openssl python3 python-is-python3 uuid-runtime curl 2>/dev/null || true
command -v python >/dev/null 2>&1 || \
ln -sf "$(command -v python3)" /usr/local/bin/python
echo "python: $(python --version)"
echo "jq: $(jq --version)"
echo "openssl: $(openssl version)"
- name: Install frontend dependencies
run: npm ci -w frontend
- name: Vault KMS — enable transit + pre-create keys
# Non-blocking: if Vault is unreachable, scripts fall back gracefully.
run: |
if curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
bash .specify/scripts/bash/vault-kms.sh enable-transit
bash .specify/scripts/bash/vault-kms.sh ensure-key casan-policy-key
bash .specify/scripts/bash/vault-kms.sh ensure-key casan-audit-key
echo "VAULT_KMS_READY"
else
echo "VAULT_KMS_SKIP (unreachable — will use local-file fallback)"
fi
- name: Run CASAN4 harness tests (35 tests)
# This clears .specify/logs/ and rebuilds audit.jsonl from scratch.
run: bash .specify/tests/run-casan4-harness-tests.sh
- name: Run adversarial harness tests (44 tests)
run: bash .specify/tests/adversarial-harness-tests.sh
- name: Sign audit chain head via Vault KMS (H5)
# After harness tests rebuild audit.jsonl, sign the head so that
# verify-audit-chain.sh reports "anchor=signed" (not "anchor=unsigned").
run: bash .specify/scripts/bash/sign-audit-head.sh
- name: Sign policy bundle via Vault KMS (H5)
run: bash .specify/scripts/bash/sign-policy-bundle.sh sign
- name: Verify audit chain (anchor=signed expected in CI)
run: bash .specify/scripts/bash/verify-audit-chain.sh
- name: Security gate — aggregate verdict (PASS=7 SKIP=1 FAIL=0)
# Ollama-dependent checks auto-SKIP (not FAIL) when Ollama is not reachable.
# security-gate.sh re-runs harness + adversarial + verify + scan + frontend.
# Exit 0 only when FAIL=0.
run: bash .specify/scripts/bash/security-gate.sh
- name: Upload test evidence
if: always()
uses: actions/upload-artifact@v3
with:
name: casan-evidence-${{ github.run_number }}
path: |
docs/output/casan/evidence/harness-test-report.md
docs/output/casan/evidence/
retention-days: 14
# ──────────────────────────────────────────────────────────────────────────
# Job 3 — Deploy OKR web app (main branch only)
#
# Architecture:
# okr-backend — NestJS + Prisma + SQLite, port 3001 (internal only)
# okr-frontend — nginx + React SPA, port 80 (public)
# nginx proxies /api/v1/* → okr-backend:3001
# Both on Docker network "okr-net" (separate from gitea/vault network)
#
# Secrets required in Gitea Settings → Secrets → Actions:
# JWT_SECRET — random string for NestJS JWT signing
# ──────────────────────────────────────────────────────────────────────────
deploy-okr:
name: "Deploy OKR → port 80 (H3 CI gate)"
runs-on: ubuntu-latest
needs: [frontend-tests, security-gate]
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
JWT_SECRET: ${{ secrets.JWT_SECRET }}
FRONTEND_ORIGIN: "http://161.33.139.73"
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Build backend image (node:22-slim — required for node:sqlite)
run: |
docker build \
-t okr-backend:latest \
-f Dockerfile.backend \
.
- name: Build frontend image (nginx + React SPA)
run: |
docker build \
--build-arg VITE_API_BASE_URL=/api/v1 \
-t okr-frontend:latest \
-f Dockerfile.frontend \
.
- name: Create network + persistent volume
run: |
docker network create okr-net 2>/dev/null || true
docker volume create okr-db 2>/dev/null || true
- name: Deploy backend (port 3001, internal only)
run: |
docker rm -f okr-backend 2>/dev/null || true
docker run -d \
--name okr-backend \
--network okr-net \
-e PORT=3001 \
-e DATABASE_URL=file:/data/okr.db \
-e JWT_SECRET="${JWT_SECRET}" \
-e FRONTEND_ORIGIN="${FRONTEND_ORIGIN}" \
-e NODE_ENV=production \
-v okr-db:/data \
--restart unless-stopped \
okr-backend:latest
- name: Deploy frontend (port 80, public)
run: |
docker rm -f okr-frontend 2>/dev/null || true
docker run -d \
--name okr-frontend \
--network okr-net \
-p 80:80 \
--restart unless-stopped \
okr-frontend:latest
- name: Health check
run: |
echo "Waiting 20 s for containers to initialise..."
sleep 20
if curl -sf http://localhost/ -o /dev/null; then
echo "DEPLOY_OK frontend=http://161.33.139.73"
else
echo "DEPLOY_WARN frontend check failed — dumping logs"
docker logs okr-frontend --tail 30 || true
docker logs okr-backend --tail 30 || true
fi
@@ -18,6 +18,9 @@ run() { # <name> <command...>
echo "== CASAN security gate ==" echo "== CASAN security gate =="
run "run-casan4 harness suite" bash "$ROOT/.specify/tests/run-casan4-harness-tests.sh" run "run-casan4 harness suite" bash "$ROOT/.specify/tests/run-casan4-harness-tests.sh"
run "adversarial suite" bash "$ROOT/.specify/tests/adversarial-harness-tests.sh" run "adversarial suite" bash "$ROOT/.specify/tests/adversarial-harness-tests.sh"
# Wave 5: sign audit head via Vault KMS (or local fallback) before verifying.
# This changes verify output from anchor=unsigned to anchor=signed when Vault is configured.
run "sign audit-chain head (KMS)" bash "$ROOT/.specify/scripts/bash/sign-audit-head.sh"
run "audit hash-chain (signed)" bash "$ROOT/.specify/scripts/bash/verify-audit-chain.sh" run "audit hash-chain (signed)" bash "$ROOT/.specify/scripts/bash/verify-audit-chain.sh"
run "tool-call audit (signed)" bash "$ROOT/.specify/scripts/bash/verify-tool-audit.sh" run "tool-call audit (signed)" bash "$ROOT/.specify/scripts/bash/verify-tool-audit.sh"
# Wave 3 additions # Wave 3 additions
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
set -euo pipefail
# CASAN H5 — Sign the audit chain head hash via Vault KMS (or local key fallback).
#
# Called by CI after harness tests rebuild audit.jsonl, so that
# verify-audit-chain.sh produces "anchor=signed" (not "anchor=unsigned").
#
# Usage:
# sign-audit-head.sh [audit-jsonl]
#
# Writes:
# <audit-dir>/audit-head.txt — the head hash (plain text)
# <audit-dir>/audit-head.sig — RSA signature of audit-head.txt
#
# After this script, verify-audit-chain.sh reports:
# AUDIT_CHAIN_VALID anchor=signed
#
# Environment (KMS path):
# VAULT_ADDR — e.g. http://vault:8200
# VAULT_TOKEN — token with transit/sign/casan-audit-key capability
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
AUDIT_LOG="${1:-$PROJECT_ROOT/.specify/logs/audit/audit.jsonl}"
AUDIT_DIR="$(dirname "$AUDIT_LOG")"
HEAD_FILE="$AUDIT_DIR/audit-head.txt"
HEAD_SIG="$AUDIT_DIR/audit-head.sig"
AUDIT_PUB="$PROJECT_ROOT/.specify/level5/central-governance/audit-public.pem"
if [[ ! -f "$AUDIT_LOG" ]]; then
echo "SIGN_AUDIT_HEAD_SKIP audit.jsonl not found" >&2
exit 0
fi
# ── Compute the current chain head ────────────────────────────────────────
HEAD_HASH="$(python - "$AUDIT_LOG" <<'PY'
import hashlib, json, sys
path = sys.argv[1]
previous = ""
with open(path, encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
record = json.loads(line)
core = "|".join([
record.get("timestamp",""), record.get("trace_id",""),
record.get("action",""), record.get("actor",""),
record.get("risk_level",""), record.get("decision",""),
record.get("approval_status",""), record.get("approver",""),
record.get("input_hash",""), record.get("output_hash",""),
previous,
])
previous = hashlib.sha256(core.encode()).hexdigest()
print(previous)
PY
)"
if [[ -z "$HEAD_HASH" ]]; then
echo "SIGN_AUDIT_HEAD_SKIP empty chain" >&2
exit 0
fi
printf '%s' "$HEAD_HASH" > "$HEAD_FILE"
# ── Sign the head file ────────────────────────────────────────────────────
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
# KMS path — sign via Vault Transit, export public key
bash "$VAULT_KMS" enable-transit
bash "$VAULT_KMS" sign "$HEAD_FILE" "$HEAD_SIG" "casan-audit-key"
bash "$VAULT_KMS" pubkey "$AUDIT_PUB" "casan-audit-key"
echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=vault-kms"
else
# Fallback — local key (dev environment without Vault)
# IMPORTANT: Do NOT generate a new key pair here. audit-public.pem is committed
# and shared by both audit.jsonl and tool-calls.jsonl verification. Generating a
# new key overwrites audit-public.pem and breaks tool-calls-head.sig verification.
AUDIT_PRIV="$PROJECT_ROOT/.specify/level5/central-governance/audit-private.pem"
if [[ ! -f "$AUDIT_PRIV" ]]; then
echo "SIGN_AUDIT_HEAD_SKIP no private key and VAULT_ADDR not set — verify will show anchor=unsigned" >&2
exit 0
fi
openssl dgst -sha256 -sign "$AUDIT_PRIV" -out "$HEAD_SIG" "$HEAD_FILE"
echo "SIGN_AUDIT_HEAD_OK head=$HEAD_HASH anchor=local-file"
fi
@@ -65,12 +65,24 @@ PY
if [[ "$MODE" == "sign" ]]; then if [[ "$MODE" == "sign" ]]; then
generate_manifest generate_manifest
if [[ ! -f "$PRIVATE_KEY" ]]; then
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1 if [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]] && \
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1 curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
# ── KMS path: sign via HashiCorp Vault Transit (key never stored on disk) ──
VAULT_KMS="$SCRIPT_DIR/vault-kms.sh"
bash "$VAULT_KMS" enable-transit
bash "$VAULT_KMS" sign "$MANIFEST" "$SIGNATURE" "casan-policy-key"
bash "$VAULT_KMS" pubkey "$PUBLIC_KEY" "casan-policy-key"
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY key_backend=vault-kms"
else
# ── Fallback: local key file (dev / no Vault) ─────────────────────────────
if [[ ! -f "$PRIVATE_KEY" ]]; then
openssl genrsa -out "$PRIVATE_KEY" 2048 >/dev/null 2>&1
openssl rsa -in "$PRIVATE_KEY" -pubout -out "$PUBLIC_KEY" >/dev/null 2>&1
fi
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY key_backend=local-file"
fi fi
openssl dgst -sha256 -sign "$PRIVATE_KEY" -out "$SIGNATURE" "$MANIFEST"
echo "POLICY_BUNDLE_SIGNED manifest=$MANIFEST signature=$SIGNATURE public_key=$PUBLIC_KEY"
exit 0 exit 0
fi fi
+171
View File
@@ -0,0 +1,171 @@
#!/usr/bin/env bash
# CASAN H5 — HashiCorp Vault KMS helper
#
# Provides Vault-backed signing operations as a drop-in replacement for
# direct openssl key-file usage. Scripts detect KMS availability via:
# [[ -n "${VAULT_ADDR:-}" && -n "${VAULT_TOKEN:-}" ]]
#
# Usage (direct):
# vault-kms.sh sign <file> <output.sig> [key_name]
# vault-kms.sh verify <file> <sig_file> [key_name]
# vault-kms.sh pubkey <output.pem> [key_name]
# vault-kms.sh status
#
# Usage (sourced):
# source vault-kms.sh
# vault_kms_sign <file> <output.sig> [key_name]
# vault_kms_pubkey <output.pem> [key_name]
# vault_kms_status
#
# Environment:
# VAULT_ADDR — e.g. http://vault:8200 or http://161.33.139.73:8200
# VAULT_TOKEN — root token or policy token with transit/sign/* capability
set -euo pipefail
: "${VAULT_ADDR:?VAULT_ADDR must be set}"
: "${VAULT_TOKEN:?VAULT_TOKEN must be set}"
_VAULT_DEFAULT_KEY="casan-policy-key"
_VAULT_AUDIT_KEY="casan-audit-key"
# ── Ensure required tools ──────────────────────────────────────────────────
_check_deps() {
for cmd in curl python3; do
command -v "$cmd" >/dev/null 2>&1 || { echo "vault-kms: required: $cmd" >&2; exit 1; }
done
}
# ── Transit: ensure key exists ────────────────────────────────────────────
vault_kms_ensure_key() {
local key="${1:-$_VAULT_DEFAULT_KEY}"
local status
status=$(curl -sf \
-H "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/transit/keys/$key" 2>/dev/null | \
python3 -c "import sys,json; d=json.load(sys.stdin); print('ok' if 'data' in d else 'missing')" 2>/dev/null || echo "missing")
if [[ "$status" != "ok" ]]; then
curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"rsa-2048","exportable":false,"allow_plaintext_backup":false}' \
"$VAULT_ADDR/v1/transit/keys/$key" >/dev/null
echo "vault-kms: created transit key: $key" >&2
fi
}
# ── Sign a file ───────────────────────────────────────────────────────────
# Produces a DER binary signature compatible with:
# openssl dgst -sha256 -verify pub.pem -signature <output.sig> <file>
vault_kms_sign() {
local file="$1" output="$2" key="${3:-$_VAULT_DEFAULT_KEY}"
_check_deps
vault_kms_ensure_key "$key"
# Base64-encode the file content for the Vault API payload
local input_b64
input_b64=$(base64 -w0 < "$file" 2>/dev/null || base64 < "$file")
# Call Vault Transit sign — PKCS#1 v1.5 + SHA-256 (compatible with openssl verify)
local response
response=$(curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"input\":\"$input_b64\",\"hash_algorithm\":\"sha2-256\",\"signature_algorithm\":\"pkcs1v15\",\"prehashed\":false}" \
"$VAULT_ADDR/v1/transit/sign/$key") || {
echo "vault-kms: sign request failed (key=$key)" >&2; exit 1
}
# Extract signature and strip the "vault:v1:" prefix → raw base64 DER bytes
local sig_b64
sig_b64=$(printf '%s' "$response" | python3 -c "
import sys, json
d = json.load(sys.stdin)
sig = d['data']['signature']
# vault:v1:<base64> → keep only base64 part
print(sig.split(':')[-1])
")
# Decode base64 → binary DER file (identical format to openssl -sign output)
printf '%s' "$sig_b64" | base64 -d > "$output"
echo "vault-kms: signed $file → $output (key=$key anchor=vault-kms)" >&2
}
# ── Export public key ─────────────────────────────────────────────────────
# Writes the RSA public key as PEM so openssl dgst -verify still works.
vault_kms_pubkey() {
local output="$1" key="${2:-$_VAULT_DEFAULT_KEY}"
_check_deps
vault_kms_ensure_key "$key"
local response
response=$(curl -sf \
-H "X-Vault-Token: $VAULT_TOKEN" \
"$VAULT_ADDR/v1/transit/keys/$key") || {
echo "vault-kms: pubkey fetch failed (key=$key)" >&2; exit 1
}
python3 - "$output" <<PY
import sys, json
output = sys.argv[1]
response = """$response"""
d = json.loads(response)
keys = d["data"]["keys"]
# keys is a dict; pick the latest version
latest = max(keys.keys(), key=lambda k: int(k))
pub = keys[latest]["public_key"]
with open(output, "w") as f:
f.write(pub if pub.endswith("\n") else pub + "\n")
print(f"vault-kms: public key written to {output}", file=sys.stderr)
PY
}
# ── Verify a signature ────────────────────────────────────────────────────
# Uses openssl with the public key exported from Vault.
vault_kms_verify() {
local file="$1" sig_file="$2" key="${3:-$_VAULT_DEFAULT_KEY}"
local tmp_pub
tmp_pub=$(mktemp /tmp/vault-pub-XXXXX.pem)
trap 'rm -f "$tmp_pub"' RETURN
vault_kms_pubkey "$tmp_pub" "$key"
openssl dgst -sha256 -verify "$tmp_pub" -signature "$sig_file" "$file" >/dev/null
}
# ── Connectivity check ────────────────────────────────────────────────────
vault_kms_status() {
if curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then
echo "VAULT_KMS_AVAILABLE addr=$VAULT_ADDR"
return 0
else
echo "VAULT_KMS_UNAVAILABLE addr=${VAULT_ADDR:-unset}"
return 1
fi
}
# ── Enable transit engine (idempotent) ────────────────────────────────────
vault_kms_enable_transit() {
curl -sf -X POST \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"type":"transit"}' \
"$VAULT_ADDR/v1/sys/mounts/transit" >/dev/null 2>&1 || true
}
# ── CLI entrypoint ────────────────────────────────────────────────────────
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
CMD="${1:-status}"
shift || true
case "$CMD" in
sign) vault_kms_sign "$@" ;;
pubkey) vault_kms_pubkey "$@" ;;
verify) vault_kms_verify "$@" ;;
status) vault_kms_status ;;
enable-transit) vault_kms_enable_transit ;;
ensure-key) vault_kms_ensure_key "${1:-}" ;;
*)
echo "Usage: vault-kms.sh {sign|pubkey|verify|status|enable-transit|ensure-key}" >&2
exit 64
;;
esac
fi
+55
View File
@@ -0,0 +1,55 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq openssl python3 && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY backend/prisma ./backend/prisma
RUN npm ci
COPY backend/src ./backend/src
COPY backend/tsconfig*.json ./backend/
# Generate Prisma client + compile TypeScript
RUN cd backend && npx prisma generate && npx tsc -p tsconfig.build.json
# ─── Stage 2: Runtime ────────────────────────────────────────────────────────
FROM node:22-slim AS runtime
# node:sqlite (setup-sqlite.mjs) requires Node 22 — this image satisfies that.
WORKDIR /app
RUN apt-get update -qq && apt-get install -y -qq openssl && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./
COPY backend/package.json ./backend/
COPY frontend/package.json ./frontend/
COPY backend/prisma ./backend/prisma
# All deps (including devDeps) so that tsx (seed) and prisma CLI are available.
RUN npm ci --ignore-scripts
# Generate Prisma client in runtime image (CWD resolves schema at backend/prisma/schema.prisma)
RUN cd backend && npx prisma generate
COPY --from=builder /app/backend/dist ./backend/dist
COPY backend/scripts ./backend/scripts
COPY backend/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /app/backend
VOLUME ["/data"]
EXPOSE 3001
ENV PORT=3001
ENV DATABASE_URL=file:/data/okr.db
ENV NODE_ENV=production
ENTRYPOINT ["/entrypoint.sh"]
+28
View File
@@ -0,0 +1,28 @@
# ─── Stage 1: Build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS builder
WORKDIR /app
# VITE_API_BASE_URL is baked into the bundle at build time.
# Use a relative path so the image works with any hostname/IP — nginx
# running on the same host proxies /api/v1/* to the backend container.
ARG VITE_API_BASE_URL=/api/v1
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
RUN npm ci -w frontend
COPY frontend ./frontend
RUN npm run build -w frontend
# ─── Stage 2: nginx runtime ──────────────────────────────────────────────────
FROM nginx:alpine AS runtime
COPY --from=builder /app/frontend/dist /usr/share/nginx/html
COPY nginx/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# OKR backend container entrypoint.
# - First run: creates SQLite schema via setup-sqlite.mjs + seeds initial data.
# - Subsequent runs: DB already exists, skip init.
# WORKDIR expected: /app/backend (set in Dockerfile)
set -e
# Hoisted node_modules/.bin (workspace root) must be in PATH for tsx + prisma CLI
export PATH="/app/node_modules/.bin:$PATH"
mkdir -p /data
if [ ! -f "/data/okr.db" ]; then
echo "[OKR] First run — initializing database at /data/okr.db"
node scripts/setup-sqlite.mjs
npx prisma db seed
echo "[OKR] Database initialized"
fi
echo "[OKR] Starting backend on port ${PORT:-3001}"
exec node dist/main.js
+25
View File
@@ -0,0 +1,25 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Proxy /api/v1/* → NestJS backend (same Docker network: okr-net)
location /api/v1/ {
proxy_pass http://okr-backend:3001/api/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
# Pass cookies (HttpOnly JWT) through the proxy
proxy_pass_header Set-Cookie;
}
# React SPA — React Router requires all non-asset paths to serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+1256 -1
View File
File diff suppressed because it is too large Load Diff