fix(security): remove .env from git, add .env.example

This commit is contained in:
Brad Groux 2026-01-28 12:05:17 -06:00
parent 0c42d0b125
commit 5aa31115ae
13 changed files with 210 additions and 28 deletions

5
.gitignore vendored
View file

@ -21,10 +21,13 @@ Thumbs.db
npm-debug.log*
pnpm-debug.log*
# Env
# Env — never commit real secrets
.env
.env.local
.env.*.local
# But DO track .env.example files
!*.env.example
!.env.example
# Task data (track structure, not content)
tasks/active/*.md

View file

@ -1725,3 +1725,6 @@
{"id": "evt_yXsmTC6q2-ah", "type": "run.started", "timestamp": "2026-01-28T17:24:18.997Z", "taskId": "task_20260128_frXVnb", "project": "veritas-kanban", "agent": "veritas", "model": "claude-opus-4-5", "attemptId": "attempt_8iHDP2NJ"}
{"id": "evt_OSNLg9gwRczN", "type": "run.tokens", "timestamp": "2026-01-28T17:24:18.997Z", "taskId": "task_20260128_frXVnb", "project": "veritas-kanban", "agent": "veritas", "model": "claude-opus-4-5", "attemptId": "attempt_8iHDP2NJ", "inputTokens": 15150, "outputTokens": 3072, "cacheTokens": 6592, "totalTokens": 18222, "cost": 0.4577}
{"id": "evt_o08MkHms6jGB", "type": "run.completed", "timestamp": "2026-01-28T17:24:18.997Z", "taskId": "task_20260128_frXVnb", "project": "veritas-kanban", "agent": "veritas", "attemptId": "attempt_8iHDP2NJ", "success": true, "durationMs": 201000}
{"type":"task.status_changed","taskId":"task_20260128_u73mT3","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_EjShEPw7YS3u","timestamp":"2026-01-28T18:04:06.746Z"}
{"type":"task.status_changed","taskId":"task_20260128_VVqEG1","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_X4p02S1uaswP","timestamp":"2026-01-28T18:04:06.932Z"}
{"type":"task.status_changed","taskId":"task_20260128_xu0DY5","project":"veritas-kanban","status":"in-progress","previousStatus":"todo","id":"evt_j4vRMJuwSzya","timestamp":"2026-01-28T18:04:07.737Z"}

View file

@ -48,6 +48,13 @@ A local-first task management and AI agent orchestration platform. Built for dev
git clone https://github.com/dm-bradgroux/veritas-kanban.git
cd veritas-kanban
# Set up environment variables
cp server/.env.example server/.env
# Edit server/.env — at minimum, change VERITAS_ADMIN_KEY
# (Optional) Set up web env if you need a custom API URL
# cp web/.env.example web/.env
# Install
pnpm install
@ -57,6 +64,8 @@ pnpm dev
Open http://localhost:3000
> **Note:** Never commit `.env` files. Use `.env.example` as a template — it contains safe placeholder values and documentation for every variable.
## Tech Stack
| Layer | Technology |

9
pnpm-lock.yaml generated
View file

@ -115,6 +115,9 @@ importers:
gray-matter:
specifier: ^4.0.3
version: 4.0.3
helmet:
specifier: ^8.1.0
version: 8.1.0
jsonwebtoken:
specifier: ^9.0.3
version: 9.0.3
@ -2597,6 +2600,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
hermes-estree@0.25.1:
resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==}
@ -6516,6 +6523,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.1.0: {}
hermes-estree@0.25.1: {}
hermes-parser@0.25.1:

View file

@ -1,5 +1,6 @@
# Veritas Kanban Server - Environment Variables
# Copy to .env and customize
# Copy to .env and customize:
# cp .env.example .env
# Server port
PORT=3001
@ -26,6 +27,27 @@ VERITAS_ADMIN_KEY=your-secret-admin-key-here
# Example:
VERITAS_API_KEYS=veritas-agent:vk_agent123:agent,dashboard:vk_dashboard456:read-only
# ═══════════════════════════════════════════════════════════════════════════════
# CORS & NETWORKING
# ═══════════════════════════════════════════════════════════════════════════════
# Allowed CORS origins (comma-separated)
CORS_ORIGINS=http://localhost:3000,http://localhost:5173,http://127.0.0.1:3000,http://127.0.0.1:5173
# ═══════════════════════════════════════════════════════════════════════════════
# DATA & STORAGE
# ═══════════════════════════════════════════════════════════════════════════════
# Data directory for tasks, config, logs (default: .veritas-kanban in project root)
# VERITAS_DATA_DIR=.veritas-kanban
# ═══════════════════════════════════════════════════════════════════════════════
# INTEGRATION
# ═══════════════════════════════════════════════════════════════════════════════
# Clawdbot gateway URL for AI agent orchestration
# CLAWDBOT_GATEWAY=http://127.0.0.1:18789
# ═══════════════════════════════════════════════════════════════════════════════
# ROLE PERMISSIONS
# ═══════════════════════════════════════════════════════════════════════════════

View file

@ -1,4 +1,37 @@
[
{
"id": "activity_1769623447737_ioe02qo88",
"type": "status_changed",
"taskId": "task_20260128_xu0DY5",
"taskTitle": "SECURITY: Encrypt security config file or move to env vars",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T18:04:07.737Z"
},
{
"id": "activity_1769623446933_zsztan3h6",
"type": "status_changed",
"taskId": "task_20260128_VVqEG1",
"taskTitle": "SECURITY: Add Content Security Policy (CSP) headers with Helmet",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T18:04:06.933Z"
},
{
"id": "activity_1769623446746_26xrzwcwv",
"type": "status_changed",
"taskId": "task_20260128_u73mT3",
"taskTitle": "SECURITY: Remove .env from git and create .env.example",
"details": {
"from": "todo",
"status": "in-progress"
},
"timestamp": "2026-01-28T18:04:06.746Z"
},
{
"id": "activity_1769622035872_xdoj9wnyj",
"type": "comment_added",

View file

@ -1,4 +1,12 @@
[
{
"id": "status_1769623414045_xz4xmeaz8",
"timestamp": "2026-01-28T18:03:34.045Z",
"previousStatus": "idle",
"newStatus": "sub-agent",
"subAgentCount": 0,
"durationMs": 2355036
},
{
"id": "status_1769621059009_mq2mkr5m1",
"timestamp": "2026-01-28T17:24:19.009Z",

View file

@ -24,6 +24,7 @@
"exceljs": "^4.4.0",
"express": "^4.21.0",
"gray-matter": "^4.0.3",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.3",
"mammoth": "^1.11.0",
"mime-types": "^3.0.2",

View file

@ -30,6 +30,9 @@ let cachedConfig: SecurityConfig | null = null;
let lastLoadTime = 0;
const CACHE_TTL_MS = 1000; // Reload every second in dev
// In-memory JWT secret (generated at runtime if not in env or config)
let runtimeJwtSecret: string | null = null;
/**
* Load security config from disk
*/
@ -60,6 +63,37 @@ export function getSecurityConfig(): SecurityConfig {
return cachedConfig;
}
/**
* Get JWT signing secret.
* Priority: VERITAS_JWT_SECRET env var > security.json > runtime-generated
*
* If using env var, the secret is never written to disk.
* If falling back to generated, it's stored in memory only (sessions
* invalidate on restart unless env var or config file provides persistence).
*/
export function getJwtSecret(): string {
// 1. Environment variable (preferred — never touches disk)
const envSecret = process.env.VERITAS_JWT_SECRET;
if (envSecret) {
return envSecret;
}
// 2. security.json (legacy / fallback for existing installs)
const config = getSecurityConfig();
if (config.jwtSecret) {
return config.jwtSecret;
}
// 3. Runtime-generated (ephemeral — sessions won't survive restart)
if (!runtimeJwtSecret) {
runtimeJwtSecret = crypto.randomBytes(64).toString('hex');
console.warn(
'JWT secret generated at runtime. Set VERITAS_JWT_SECRET env var for persistence across restarts.'
);
}
return runtimeJwtSecret;
}
/**
* Save security config to disk
*/
@ -126,6 +160,7 @@ export function resetSecurityConfig(): void {
authEnabled: false,
};
saveSecurityConfig(newConfig);
runtimeJwtSecret = null;
console.log('Security config reset. Next load will show setup screen.');
}

View file

@ -1,5 +1,6 @@
import 'dotenv/config';
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import { WebSocketServer, WebSocket } from 'ws';
@ -44,6 +45,58 @@ import type { AgentOutput } from './services/clawdbot-agent-service.js';
const app = express();
const PORT = process.env.PORT || 3001;
// ============================================
// Security: HTTP Headers (Helmet)
// ============================================
// Helmet sets various HTTP headers to help protect the app.
// Content-Security-Policy (CSP) restricts which resources the browser
// is allowed to load, mitigating XSS and data-injection attacks.
//
// CSP Directives:
// defaultSrc - Fallback for all resource types: only same-origin
// scriptSrc - Scripts: same-origin + inline (needed for Vite HMR in dev)
// styleSrc - Styles: same-origin + inline (Tailwind/JSX inline styles)
// connectSrc - XHR/fetch/WebSocket: same-origin + ws://localhost for dev WS
// imgSrc - Images: same-origin + data: URIs (inline SVGs, base64 images)
// fontSrc - Fonts: same-origin
// objectSrc - Plugins (Flash, etc.): blocked entirely
// frameSrc - Iframes: blocked entirely
// baseUri - <base> tag: only same-origin
// formAction - Form submissions: only same-origin
// upgradeInsecureRequests - Auto-upgrade HTTP → HTTPS in production
//
// In development, connectSrc includes ws://localhost:* for WebSocket hot-reload.
// In production, tighten scriptSrc (remove 'unsafe-inline') and use nonces.
const isDev = process.env.NODE_ENV !== 'production';
app.use(
helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", ...(isDev ? ["'unsafe-inline'", "'unsafe-eval'"] : [])],
styleSrc: ["'self'", "'unsafe-inline'"],
connectSrc: [
"'self'",
...(isDev
? ['ws://localhost:*', 'ws://127.0.0.1:*', 'http://localhost:*', 'http://127.0.0.1:*']
: []),
],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
...(isDev ? {} : { upgradeInsecureRequests: [] }),
},
},
// Cross-Origin-Embedder-Policy can break loading of cross-origin resources;
// disable it for now since we serve an API, not embedded content.
crossOriginEmbedderPolicy: false,
})
);
// ============================================
// Security: CORS Configuration
// ============================================

View file

@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from 'express';
import { WebSocket } from 'ws';
import { IncomingMessage } from 'http';
import jwt from 'jsonwebtoken';
import { getSecurityConfig } from '../config/security.js';
import { getSecurityConfig, getJwtSecret } from '../config/security.js';
// === Types ===

View file

@ -5,6 +5,7 @@ import crypto from 'crypto';
import { asyncHandler } from '../middleware/async-handler.js';
import {
getSecurityConfig,
getJwtSecret,
saveSecurityConfig,
SecurityConfig,
generateRecoveryKey,
@ -18,17 +19,6 @@ const SALT_ROUNDS = 12;
const JWT_EXPIRY_DEFAULT = '24h';
const JWT_EXPIRY_REMEMBER = '30d';
// Get or generate JWT secret
function getJwtSecret(): string {
const config = getSecurityConfig();
if (!config.jwtSecret) {
const secret = crypto.randomBytes(64).toString('hex');
saveSecurityConfig({ ...config, jwtSecret: secret });
return secret;
}
return config.jwtSecret;
}
// Rate limiting for login attempts (in-memory, resets on restart)
const loginAttempts = new Map<string, { count: number; lastAttempt: number }>();
const MAX_ATTEMPTS = 5;
@ -144,18 +134,22 @@ router.post('/setup', asyncHandler(async (req: Request, res: Response) => {
const recoveryKey = generateRecoveryKey();
const recoveryKeyHash = await hashRecoveryKey(recoveryKey);
// Generate JWT secret
const jwtSecret = crypto.randomBytes(64).toString('hex');
// Save config
saveSecurityConfig({
// Build config to save — only persist jwtSecret to file if env var is NOT set
const updatedConfig: SecurityConfig = {
...config,
passwordHash,
recoveryKeyHash,
jwtSecret,
authEnabled: true,
setupCompletedAt: new Date().toISOString(),
});
};
// If no env var, generate and persist a JWT secret to the config file
if (!process.env.VERITAS_JWT_SECRET) {
updatedConfig.jwtSecret = crypto.randomBytes(64).toString('hex');
}
// Save config
saveSecurityConfig(updatedConfig);
// Return recovery key (only time it's shown in plaintext)
res.json({
@ -314,17 +308,22 @@ router.post('/recover', asyncHandler(async (req: Request, res: Response) => {
const newRecoveryKey = generateRecoveryKey();
const newRecoveryKeyHash = await hashRecoveryKey(newRecoveryKey);
// Generate new JWT secret (invalidates all existing sessions)
const jwtSecret = crypto.randomBytes(64).toString('hex');
// Save config
saveSecurityConfig({
// Build config — rotate jwtSecret in file only if env var is NOT set
const updatedConfig: SecurityConfig = {
...config,
passwordHash,
recoveryKeyHash: newRecoveryKeyHash,
jwtSecret,
lastPasswordChange: new Date().toISOString(),
});
};
// Rotate file-based secret if no env var (invalidates all existing sessions)
if (!process.env.VERITAS_JWT_SECRET) {
updatedConfig.jwtSecret = crypto.randomBytes(64).toString('hex');
}
// Note: if using env var, session invalidation requires changing the env var
// Save config
saveSecurityConfig(updatedConfig);
res.json({
success: true,

7
web/.env.example Normal file
View file

@ -0,0 +1,7 @@
# Veritas Kanban Web - Environment Variables
# Copy this file to .env and update the values:
# cp .env.example .env
# API base URL (default: /api — uses Vite proxy in dev)
# Set this if running the server on a different host/port
# VITE_API_URL=http://localhost:3001/api