security: harden production configuration and routes
Run Tests on Branches / Detect Changes (push) Successful in 12s
Run Tests on Branches / Frontend Tests (push) Successful in 2m12s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Successful in 2m2s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Backend Tests (push) Successful in 2m8s
Run Tests on Branches / Detect Changes (push) Successful in 12s
Run Tests on Branches / Frontend Tests (push) Successful in 2m12s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Successful in 2m2s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Backend Tests (push) Successful in 2m8s
This commit is contained in:
@@ -20,6 +20,8 @@ N8N_EMAIL=admin@farmafinder.com
|
|||||||
PARAPHARMACY_API_URL=http://parapharmacy-api:3002
|
PARAPHARMACY_API_URL=http://parapharmacy-api:3002
|
||||||
PARAPHARMACY_CORS_ORIGIN=http://localhost:4000
|
PARAPHARMACY_CORS_ORIGIN=http://localhost:4000
|
||||||
MONGODB_URI=mongodb://mongodb:27017/parapharmacy
|
MONGODB_URI=mongodb://mongodb:27017/parapharmacy
|
||||||
|
INGEST_API_KEY=dev-ingest-key-change-me
|
||||||
|
ADMIN_API_KEY=dev-admin-key-change-me
|
||||||
|
|
||||||
# Expo Push Notifications (mobile)
|
# Expo Push Notifications (mobile)
|
||||||
EXPO_ACCESS_TOKEN=
|
EXPO_ACCESS_TOKEN=
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, expect, test } from '@jest/globals'
|
||||||
|
import { validateProductionEnv } from '../src/config/required-env.js'
|
||||||
|
|
||||||
|
describe('validateProductionEnv', () => {
|
||||||
|
test('rejects a missing production session secret', () => {
|
||||||
|
expect(() => validateProductionEnv({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
CORS_ORIGIN: 'https://app.example.com',
|
||||||
|
PG_URL: 'postgres://app:password@db/app',
|
||||||
|
})).toThrow(/SESSION_SECRET/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects placeholder production configuration', () => {
|
||||||
|
expect(() => validateProductionEnv({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
SESSION_SECRET: 'farma-clic-secret-key-change-in-production',
|
||||||
|
CORS_ORIGIN: 'http://localhost:3000',
|
||||||
|
PG_URL: 'postgres://app:password@db/app',
|
||||||
|
})).toThrow(/placeholder|production/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('accepts complete non-placeholder production configuration', () => {
|
||||||
|
expect(() => validateProductionEnv({
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
SESSION_SECRET: 'a-test-only-long-session-secret',
|
||||||
|
CORS_ORIGIN: 'https://app.example.com',
|
||||||
|
PG_URL: 'postgres://app:password@db/app',
|
||||||
|
})).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -26,6 +26,9 @@ import multer from 'multer';
|
|||||||
import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js';
|
import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js';
|
||||||
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
|
||||||
import { fetchPharmaciesExternal } from '../API/index.js';
|
import { fetchPharmaciesExternal } from '../API/index.js';
|
||||||
|
import { validateProductionEnv } from './src/config/required-env.js';
|
||||||
|
|
||||||
|
validateProductionEnv();
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
@@ -81,11 +84,11 @@ if (PG_URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionConfig = {
|
const sessionConfig = {
|
||||||
secret: process.env.SESSION_SECRET || 'farma-clic-secret-key-change-in-production',
|
secret: process.env.SESSION_SECRET || (process.env.NODE_ENV === 'test' ? 'test-only-session-secret' : undefined),
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.COOKIE_SECURE === 'true',
|
secure: process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
@@ -2648,4 +2651,3 @@ if (process.env.NODE_ENV !== 'test') {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
const PLACEHOLDERS = new Set([
|
||||||
|
'',
|
||||||
|
'change-me-in-production',
|
||||||
|
'farma-clic-secret-key-change-in-production',
|
||||||
|
'replace-me',
|
||||||
|
'dev-ingest-key-change-me',
|
||||||
|
])
|
||||||
|
|
||||||
|
export function validateProductionEnv(env = process.env) {
|
||||||
|
if (env.NODE_ENV !== 'production') return
|
||||||
|
|
||||||
|
const required = [
|
||||||
|
['SESSION_SECRET', env.SESSION_SECRET],
|
||||||
|
['CORS_ORIGIN', env.CORS_ORIGIN],
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [name, value] of required) {
|
||||||
|
if (!value || PLACEHOLDERS.has(value)) {
|
||||||
|
throw new Error(`${name} must be set to a non-placeholder value in production`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (env.CORS_ORIGIN.includes('localhost') || env.CORS_ORIGIN.includes('127.0.0.1')) {
|
||||||
|
throw new Error('CORS_ORIGIN must not point to localhost in production')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!env.PG_URL && (!env.PG_PASSWORD || PLACEHOLDERS.has(env.PG_PASSWORD))) {
|
||||||
|
throw new Error('PG_URL or PG_PASSWORD must be set to a non-placeholder value in production')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
server_tokens off;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:3001;
|
proxy_pass http://backend:3001;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ MONGODB_URI=mongodb://localhost:27017/parapharmacy
|
|||||||
# CORS
|
# CORS
|
||||||
CORS_ORIGIN=http://localhost:3000
|
CORS_ORIGIN=http://localhost:3000
|
||||||
|
|
||||||
|
# Internal credentials (generate unique random values outside local tests)
|
||||||
|
INGEST_API_KEY=dev-ingest-key-change-me
|
||||||
|
ADMIN_API_KEY=dev-admin-key-change-me
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
RATE_LIMIT_WINDOW_MS=60000
|
RATE_LIMIT_WINDOW_MS=60000
|
||||||
RATE_LIMIT_MAX=100
|
RATE_LIMIT_MAX=100
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY apps/parapharmacy-api/package*.json ./
|
COPY apps/parapharmacy-api/package*.json ./
|
||||||
|
|
||||||
# Install dependencies (production only)
|
# Install dependencies from the committed lockfile (production only)
|
||||||
RUN npm install --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY apps/parapharmacy-api/ .
|
COPY apps/parapharmacy-api/ .
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { jest } from '@jest/globals'
|
||||||
|
|
||||||
|
process.env.NODE_ENV = 'test'
|
||||||
|
process.env.INGEST_API_KEY = 'ingest-test-key'
|
||||||
|
process.env.ADMIN_API_KEY = 'admin-test-key'
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../src/models/Product.js', () => ({
|
||||||
|
default: {
|
||||||
|
search: jest.fn(async () => ({ results: [], total: 0, page: 1, pages: 0 })),
|
||||||
|
distinct: jest.fn(async () => []),
|
||||||
|
find: jest.fn(() => ({ sort: () => ({ skip: () => ({ limit: () => ({ lean: async () => [] }) }) }) })),
|
||||||
|
countDocuments: jest.fn(async () => 0),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
jest.unstable_mockModule('../src/scraper.js', () => ({
|
||||||
|
scrapeAll: jest.fn(async () => ({ total: 0 })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { default: supertest } = await import('supertest')
|
||||||
|
const { default: app } = await import('../src/server.js')
|
||||||
|
|
||||||
|
describe('parapharmacy security', () => {
|
||||||
|
test('keeps public product search available without credentials', async () => {
|
||||||
|
const res = await supertest(app).get('/api/products/search?q=cream')
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test.each(['/api/products', '/api/products/bulk', '/api/scrape'])(
|
||||||
|
'rejects unauthenticated mutation route %s',
|
||||||
|
async (path) => {
|
||||||
|
const res = await supertest(app).post(path).send({})
|
||||||
|
expect(res.status).toBe(401)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
test('accepts a valid ingest key for product creation', async () => {
|
||||||
|
const res = await supertest(app)
|
||||||
|
.post('/api/products')
|
||||||
|
.set('Authorization', 'Bearer ingest-test-key')
|
||||||
|
.send({})
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('requires a separate admin key for product deletion', async () => {
|
||||||
|
const res = await supertest(app)
|
||||||
|
.delete('/api/products/507f1f77bcf86cd799439011')
|
||||||
|
.set('Authorization', 'Bearer ingest-test-key')
|
||||||
|
expect(res.status).toBe(403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('does not expose Swagger in production', async () => {
|
||||||
|
const original = process.env.NODE_ENV
|
||||||
|
process.env.NODE_ENV = 'production'
|
||||||
|
const res = await supertest(app).get('/api/docs')
|
||||||
|
process.env.NODE_ENV = original
|
||||||
|
expect(res.status).toBe(404)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
function readPresentedKey(req) {
|
||||||
|
const authorization = req.get('authorization');
|
||||||
|
if (authorization?.startsWith('Bearer ')) return authorization.slice(7);
|
||||||
|
return req.get('x-service-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireServiceKey(environmentVariable) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const expected = process.env[environmentVariable];
|
||||||
|
const supplied = readPresentedKey(req);
|
||||||
|
|
||||||
|
if (!expected) {
|
||||||
|
return res.status(503).json({ error: 'Service authentication is not configured' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!supplied) return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
|
||||||
|
const suppliedBuffer = Buffer.from(supplied);
|
||||||
|
const expectedBuffer = Buffer.from(expected);
|
||||||
|
const valid = suppliedBuffer.length === expectedBuffer.length
|
||||||
|
&& crypto.timingSafeEqual(suppliedBuffer, expectedBuffer);
|
||||||
|
|
||||||
|
if (!valid) return res.status(403).json({ error: 'Invalid service credentials' });
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import Product from '../models/Product.js';
|
import Product from '../models/Product.js';
|
||||||
|
import { requireServiceKey } from '../middleware/service-auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@@ -208,7 +209,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
* 201:
|
* 201:
|
||||||
* description: Product created/updated
|
* description: Product created/updated
|
||||||
*/
|
*/
|
||||||
router.post('/', async (req, res) => {
|
router.post('/', requireServiceKey('INGEST_API_KEY'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
@@ -277,13 +278,16 @@ router.post('/', async (req, res) => {
|
|||||||
* 200:
|
* 200:
|
||||||
* description: Upsert results
|
* description: Upsert results
|
||||||
*/
|
*/
|
||||||
router.post('/bulk', async (req, res) => {
|
router.post('/bulk', requireServiceKey('INGEST_API_KEY'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { products } = req.body;
|
const { products } = req.body;
|
||||||
|
|
||||||
if (!Array.isArray(products)) {
|
if (!Array.isArray(products)) {
|
||||||
return res.status(400).json({ error: 'products must be an array' });
|
return res.status(400).json({ error: 'products must be an array' });
|
||||||
}
|
}
|
||||||
|
if (products.length > 100) {
|
||||||
|
return res.status(413).json({ error: 'products exceeds the maximum batch size of 100' });
|
||||||
|
}
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
created: 0,
|
created: 0,
|
||||||
@@ -337,7 +341,7 @@ router.post('/bulk', async (req, res) => {
|
|||||||
* 404:
|
* 404:
|
||||||
* description: Product not found
|
* description: Product not found
|
||||||
*/
|
*/
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const product = await Product.findByIdAndUpdate(
|
const product = await Product.findByIdAndUpdate(
|
||||||
req.params.id,
|
req.params.id,
|
||||||
@@ -374,7 +378,7 @@ router.put('/:id', async (req, res) => {
|
|||||||
* 404:
|
* 404:
|
||||||
* description: Product not found
|
* description: Product not found
|
||||||
*/
|
*/
|
||||||
router.delete('/:id', async (req, res) => {
|
router.delete('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const product = await Product.findByIdAndDelete(req.params.id);
|
const product = await Product.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { scrapeAll } from '../scraper.js';
|
import { scrapeAll } from '../scraper.js';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import { requireServiceKey } from '../middleware/service-auth.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
const scrapeLimiter = rateLimit({
|
||||||
|
windowMs: 15 * 60 * 1000,
|
||||||
|
max: 5,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Trigger scraping
|
// Trigger scraping
|
||||||
router.post('/scrape', async (req, res) => {
|
router.post('/scrape', scrapeLimiter, requireServiceKey('INGEST_API_KEY'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { queries = ['crema hidratante'], sources = ['promofarma'] } = req.body;
|
const { queries = ['crema hidratante'], sources = ['promofarma'] } = req.body;
|
||||||
|
if (!Array.isArray(queries) || !Array.isArray(sources) || queries.length > 20 || sources.length > 10) {
|
||||||
|
return res.status(400).json({ error: 'queries and sources must be bounded arrays' });
|
||||||
|
}
|
||||||
|
|
||||||
console.log('[Scraper] Starting scrape...');
|
console.log('[Scraper] Starting scrape...');
|
||||||
console.log(`[Scraper] Queries: ${queries.join(', ')}`);
|
console.log(`[Scraper] Queries: ${queries.join(', ')}`);
|
||||||
@@ -19,7 +30,7 @@ router.post('/scrape', async (req, res) => {
|
|||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Scraper] Error:', error.message);
|
console.error('[Scraper] Error:', error.message);
|
||||||
res.status(500).json({ error: 'Scraping failed', message: error.message });
|
res.status(500).json({ error: 'Scraping failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const swaggerSpec = swaggerJsdoc(swaggerOptions);
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors(config.cors));
|
app.use(cors(config.cors));
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '1mb' }));
|
||||||
app.use(morgan('combined'));
|
app.use(morgan('combined'));
|
||||||
|
|
||||||
// Rate limiting
|
// Rate limiting
|
||||||
@@ -66,11 +66,13 @@ const limiter = rateLimit({
|
|||||||
app.use(limiter);
|
app.use(limiter);
|
||||||
|
|
||||||
// Swagger UI
|
// Swagger UI
|
||||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
if (!['production', 'test'].includes(process.env.NODE_ENV)) {
|
||||||
|
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||||
explorer: true,
|
explorer: true,
|
||||||
customCss: '.swagger-ui .topbar { display: none }',
|
customCss: '.swagger-ui .topbar { display: none }',
|
||||||
customSiteTitle: 'FarmaFinder Parapharmacy API',
|
customSiteTitle: 'FarmaFinder Parapharmacy API',
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
app.use('/api/products', productsRouter);
|
app.use('/api/products', productsRouter);
|
||||||
@@ -131,6 +133,6 @@ process.on('SIGINT', async () => {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
start();
|
if (process.env.NODE_ENV !== 'test') start();
|
||||||
|
|
||||||
export default app;
|
export default app;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
APP_NAME=PIP - Pharmacy Integration Platform
|
APP_NAME=PIP - Pharmacy Integration Platform
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=0.1.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
|
DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
|
||||||
DATABASE_POOL_SIZE=20
|
DATABASE_POOL_SIZE=20
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
|
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/pip
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
|
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}@rabbitmq:5672/pip
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
LOG_LEVEL: INFO
|
LOG_LEVEL: INFO
|
||||||
LOG_JSON_FORMAT: "true"
|
LOG_JSON_FORMAT: "true"
|
||||||
CORS_ORIGINS: '["*"]'
|
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
OTEL_TRACES_ENABLED: "true"
|
OTEL_TRACES_ENABLED: "true"
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,9 +45,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pip
|
POSTGRES_DB: pip
|
||||||
POSTGRES_USER: pip
|
POSTGRES_USER: pip
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -73,8 +71,6 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "6380:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -94,11 +90,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: pip
|
RABBITMQ_DEFAULT_USER: pip
|
||||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}
|
||||||
RABBITMQ_DEFAULT_VHOST: pip
|
RABBITMQ_DEFAULT_VHOST: pip
|
||||||
ports:
|
|
||||||
- "5672:5672"
|
|
||||||
- "15672:15672"
|
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -7,14 +7,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
|
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/pip
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
|
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}@rabbitmq:5672/pip
|
||||||
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
|
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
LOG_LEVEL: INFO
|
LOG_LEVEL: INFO
|
||||||
LOG_JSON_FORMAT: "true"
|
LOG_JSON_FORMAT: "true"
|
||||||
CORS_ORIGINS: '["*"]'
|
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
|
||||||
# Re-routed to the shared Grafana Alloy collector on srv84-macos.
|
# Re-routed to the shared Grafana Alloy collector on srv84-macos.
|
||||||
# host.docker.internal resolves to the Docker host gateway from
|
# host.docker.internal resolves to the Docker host gateway from
|
||||||
# inside the container — works because this stack runs on the same
|
# inside the container — works because this stack runs on the same
|
||||||
@@ -53,9 +53,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: pip
|
POSTGRES_DB: pip
|
||||||
POSTGRES_USER: pip
|
POSTGRES_USER: pip
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -81,8 +79,6 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "6380:6379"
|
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -102,11 +98,8 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
RABBITMQ_DEFAULT_USER: pip
|
RABBITMQ_DEFAULT_USER: pip
|
||||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
|
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}
|
||||||
RABBITMQ_DEFAULT_VHOST: pip
|
RABBITMQ_DEFAULT_VHOST: pip
|
||||||
ports:
|
|
||||||
- "5672:5672"
|
|
||||||
- "15672:15672"
|
|
||||||
volumes:
|
volumes:
|
||||||
- rabbitmq_data:/var/lib/rabbitmq
|
- rabbitmq_data:/var/lib/rabbitmq
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ class Settings(BaseSettings):
|
|||||||
APP_NAME: str = "PIP - Pharmacy Integration Platform"
|
APP_NAME: str = "PIP - Pharmacy Integration Platform"
|
||||||
APP_VERSION: str = "0.1.0"
|
APP_VERSION: str = "0.1.0"
|
||||||
DEBUG: bool = False
|
DEBUG: bool = False
|
||||||
|
NODE_ENV: str = "development"
|
||||||
|
|
||||||
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
|
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
|
||||||
DATABASE_POOL_SIZE: int = 20
|
DATABASE_POOL_SIZE: int = 20
|
||||||
@@ -50,6 +52,23 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
HEALTH_CHECK_CACHE_TTL: int = 10
|
HEALTH_CHECK_CACHE_TTL: int = 10
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_production_security(self):
|
||||||
|
if self.NODE_ENV.lower() != "production":
|
||||||
|
return self
|
||||||
|
|
||||||
|
if self.JWT_SECRET_KEY == "change-me-in-production":
|
||||||
|
raise ValueError("JWT_SECRET_KEY must be changed in production")
|
||||||
|
|
||||||
|
default_credentials = ("pip:pip@", "pip-secret")
|
||||||
|
if any(value in self.DATABASE_URL or value in self.RABBITMQ_URL for value in default_credentials):
|
||||||
|
raise ValueError("default database or broker credentials are not allowed in production")
|
||||||
|
|
||||||
|
if self.CORS_ALLOW_CREDENTIALS and "*" in self.CORS_ORIGINS:
|
||||||
|
raise ValueError("wildcard CORS_ORIGINS are not allowed with credentials in production")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def DATABASE_URL_SYNC(self) -> str:
|
def DATABASE_URL_SYNC(self) -> str:
|
||||||
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
|
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from src.infrastructure.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_placeholder_jwt_secret():
|
||||||
|
with pytest.raises(ValueError, match="JWT_SECRET_KEY"):
|
||||||
|
Settings(
|
||||||
|
NODE_ENV="production",
|
||||||
|
JWT_SECRET_KEY="change-me-in-production",
|
||||||
|
_env_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_database_and_broker_credentials():
|
||||||
|
with pytest.raises(ValueError, match="credentials"):
|
||||||
|
Settings(
|
||||||
|
NODE_ENV="production",
|
||||||
|
JWT_SECRET_KEY="a-real-test-secret",
|
||||||
|
DATABASE_URL="postgresql+asyncpg://pip:pip@localhost:5432/pip",
|
||||||
|
RABBITMQ_URL="amqp://pip:pip@localhost:5672/pip",
|
||||||
|
_env_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_wildcard_credentialed_cors():
|
||||||
|
with pytest.raises(ValueError, match="CORS"):
|
||||||
|
Settings(
|
||||||
|
NODE_ENV="production",
|
||||||
|
JWT_SECRET_KEY="a-real-test-secret",
|
||||||
|
DATABASE_URL="postgresql+asyncpg://pip:real-password@db:5432/pip",
|
||||||
|
RABBITMQ_URL="amqp://pip:real-password@rabbitmq:5672/pip",
|
||||||
|
CORS_ORIGINS=["*"],
|
||||||
|
CORS_ALLOW_CREDENTIALS=True,
|
||||||
|
_env_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_development_keeps_local_defaults_usable():
|
||||||
|
settings = Settings(_env_file=None)
|
||||||
|
assert settings.NODE_ENV == "development"
|
||||||
+38
-23
@@ -2,6 +2,8 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
@@ -9,7 +11,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: farmafinder
|
POSTGRES_DB: farmafinder
|
||||||
POSTGRES_USER: farmafinder
|
POSTGRES_USER: farmafinder
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U farmafinder"]
|
test: ["CMD-SHELL", "pg_isready -U farmafinder"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@@ -26,19 +30,17 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file:
|
env_file:
|
||||||
- ./apps/backend/.env
|
- ./apps/backend/.env
|
||||||
ports:
|
|
||||||
- "3001:3001"
|
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://farmacias.hacecalor.net}
|
CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
DATABASE_PATH: /app/data/database.sqlite
|
DATABASE_PATH: /app/data/database.sqlite
|
||||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||||
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
|
PG_URL: postgresql://farmafinder:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/farmafinder
|
||||||
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
|
||||||
OTEL_SERVICE_NAME: farmafinder-backend
|
OTEL_SERVICE_NAME: farmafinder-backend
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
@@ -52,6 +54,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- postgres
|
- postgres
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
||||||
@@ -67,28 +71,31 @@ services:
|
|||||||
- "4000:80"
|
- "4000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
networks:
|
||||||
|
- public
|
||||||
|
- internal
|
||||||
|
|
||||||
# --- Observability exporters (scraped by the shared Prometheus on srv84-macos) ---
|
# --- Observability exporters (scraped by the shared Prometheus on srv84-macos) ---
|
||||||
redis-exporter:
|
redis-exporter:
|
||||||
image: oliver006/redis_exporter:latest
|
image: oliver006/redis_exporter:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "9121:9121"
|
|
||||||
environment:
|
environment:
|
||||||
REDIS_ADDR: redis://redis:6379
|
REDIS_ADDR: redis://redis:6379
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
postgres-exporter:
|
postgres-exporter:
|
||||||
image: prometheuscommunity/postgres-exporter:latest
|
image: prometheuscommunity/postgres-exporter:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "9187:9187"
|
|
||||||
environment:
|
environment:
|
||||||
DATA_SOURCE_NAME: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder?sslmode=disable
|
DATA_SOURCE_NAME: postgresql://farmafinder:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/farmafinder?sslmode=disable
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
# --- Parapharmacy API ---
|
# --- Parapharmacy API ---
|
||||||
parapharmacy-api:
|
parapharmacy-api:
|
||||||
@@ -99,13 +106,13 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- ./apps/parapharmacy-api/.env
|
- ./apps/parapharmacy-api/.env
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "3002:3002"
|
|
||||||
environment:
|
environment:
|
||||||
PORT: "3002"
|
PORT: "3002"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
MONGODB_URI: mongodb://mongodb:27017/parapharmacy
|
MONGODB_URI: mongodb://mongodb:27017/parapharmacy
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://farmacias.hacecalor.net}
|
CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
|
||||||
|
INGEST_API_KEY: ${INGEST_API_KEY:?INGEST_API_KEY must be set}
|
||||||
|
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
|
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -114,37 +121,37 @@ services:
|
|||||||
start_period: 15s
|
start_period: 15s
|
||||||
depends_on:
|
depends_on:
|
||||||
- mongodb
|
- mongodb
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
# --- MongoDB for Parapharmacy ---
|
# --- MongoDB for Parapharmacy ---
|
||||||
mongodb:
|
mongodb:
|
||||||
image: mongo:7
|
image: mongo:7
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "27017:27017"
|
|
||||||
volumes:
|
volumes:
|
||||||
- mongodb_data:/data/db
|
- mongodb_data:/data/db
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
# --- N8N Workflow Automation ---
|
# --- N8N Workflow Automation ---
|
||||||
n8n:
|
n8n:
|
||||||
image: n8nio/n8n:latest
|
image: n8nio/n8n:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
|
||||||
- "5678:5678"
|
|
||||||
environment:
|
environment:
|
||||||
# Owner account (skips /setup)
|
# Owner account (skips /setup)
|
||||||
N8N_USER_MANAGEMENT_DISABLED: "false"
|
N8N_USER_MANAGEMENT_DISABLED: "false"
|
||||||
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
|
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
|
||||||
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:-change-me}
|
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
|
||||||
# Auth
|
# Auth
|
||||||
N8N_BASIC_AUTH_ACTIVE: "true"
|
N8N_BASIC_AUTH_ACTIVE: "true"
|
||||||
N8N_BASIC_AUTH_USER: ${N8N_USER:-admin}
|
N8N_BASIC_AUTH_USER: ${N8N_USER:-admin}
|
||||||
N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-change-me}
|
N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
|
||||||
# Database
|
# Database
|
||||||
DB_TYPE: postgresdb
|
DB_TYPE: postgresdb
|
||||||
DB_POSTGRESDB_HOST: postgres
|
DB_POSTGRESDB_HOST: postgres
|
||||||
DB_POSTGRESDB_DATABASE: farmafinder
|
DB_POSTGRESDB_DATABASE: farmafinder
|
||||||
DB_POSTGRESDB_USER: farmafinder
|
DB_POSTGRESDB_USER: farmafinder
|
||||||
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
|
||||||
# Network
|
# Network
|
||||||
N8N_HOST: localhost
|
N8N_HOST: localhost
|
||||||
N8N_PORT: 5678
|
N8N_PORT: 5678
|
||||||
@@ -163,6 +170,8 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
# --- N8N Init: import + activate workflows BEFORE n8n starts, then seed DB ---
|
# --- N8N Init: import + activate workflows BEFORE n8n starts, then seed DB ---
|
||||||
n8n-init:
|
n8n-init:
|
||||||
@@ -173,9 +182,9 @@ services:
|
|||||||
DB_POSTGRESDB_HOST: postgres
|
DB_POSTGRESDB_HOST: postgres
|
||||||
DB_POSTGRESDB_DATABASE: farmafinder
|
DB_POSTGRESDB_DATABASE: farmafinder
|
||||||
DB_POSTGRESDB_USER: farmafinder
|
DB_POSTGRESDB_USER: farmafinder
|
||||||
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
|
||||||
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
|
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
|
||||||
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:-change-me}
|
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
|
||||||
volumes:
|
volumes:
|
||||||
- n8n_data:/home/node/.n8n
|
- n8n_data:/home/node/.n8n
|
||||||
- ./n8n/workflows:/home/node/workflows
|
- ./n8n/workflows:/home/node/workflows
|
||||||
@@ -189,9 +198,15 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
n8n:
|
n8n:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
mongodb_data:
|
mongodb_data:
|
||||||
n8n_data:
|
n8n_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
public:
|
||||||
|
internal:
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# FarmaFinder Security Audit Report
|
||||||
|
|
||||||
|
**Audit date:** 2026-07-22
|
||||||
|
**Scope:** repository source, configuration, environment files present on disk, Docker Compose files, Git history, Node lockfiles, and Python dependency declarations.
|
||||||
|
**Status:** **Not ready for an unrestricted production deployment.**
|
||||||
|
|
||||||
|
## Executive summary
|
||||||
|
|
||||||
|
The most urgent risks are configuration and access-control issues, not evidence of a secret committed to Git:
|
||||||
|
|
||||||
|
1. Local ignored `.env` files contain credential-shaped production values. The values must be treated as exposed if they were ever copied to a server, shared, or used in a public environment. Rotate the Expo access token and VAPID private key immediately, and rotate any database, session, n8n, or webhook credentials that may have shared the same deployment.
|
||||||
|
2. Production Docker Compose has known-secret fallbacks such as `change-me-in-production`, `change-me`, and `pip-secret`. A deployment can therefore start with predictable credentials when variables are missing.
|
||||||
|
3. The parapharmacy API exposes product create, bulk upsert, update, delete, and scraper-trigger endpoints without authentication. This is a direct integrity and availability risk.
|
||||||
|
4. The backend silently falls back to a hard-coded session secret. A missing secret must fail closed in production.
|
||||||
|
5. The verified npm audit reports 79 advisories in the workspace, including 3 critical and 13 high. The most important direct production-adjacent findings include vulnerable OpenTelemetry packages, `bcrypt`/`tar` transitive issues, and the vulnerable `ws`/`brace-expansion` chains. The frontend test toolchain includes a critical Vitest advisory; it must not be exposed as a server in any environment.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### F-01 — Secrets present in local environment files
|
||||||
|
|
||||||
|
**Severity:** Critical
|
||||||
|
**Evidence:** `apps/backend/.env` and `apps/parapharmacy-api/.env` exist locally and are ignored by Git. The backend file contains a non-empty Expo access token and VAPID private key; it also contains database/session/ingest credential fields. Values are intentionally not reproduced here.
|
||||||
|
|
||||||
|
**Impact:** Push credentials, session signing, database access, or workflow ingestion may be compromised. A VAPID private key can impersonate the application for web push; an Expo token can send notifications through the project account.
|
||||||
|
|
||||||
|
**Action:** Rotate the Expo token and VAPID key now. Rotate all non-development credentials in those files. Review CI, deployment hosts, shell history, password managers, and logs for copies. Keep secrets in the deployment secret store only.
|
||||||
|
|
||||||
|
### F-02 — Predictable production secret fallbacks
|
||||||
|
|
||||||
|
**Severity:** Critical
|
||||||
|
**Evidence:** `docker-compose.yml:12,34,41,89,137,141,147,176,178` uses defaults including `change-me-in-production`, `change-me`, and a derived PostgreSQL URL. `apps/backend/server.js:83-85` falls back to `farma-clic-secret-key-change-in-production`. `apps/pip-platform/src/infrastructure/config/settings.py:16,24,26` contains default database, broker, and JWT credentials. The PIP Compose files also default `JWT_SECRET_KEY` at `apps/pip-platform/docker-compose.yml:13` and `docker-compose.runtime.yml:13`.
|
||||||
|
|
||||||
|
**Impact:** Missing environment injection can enable session forgery, JWT forgery, database access, or n8n takeover.
|
||||||
|
|
||||||
|
**Action:** Remove secret defaults. Add startup validation that rejects `NODE_ENV=production`/production deployments when required secrets are absent or match known placeholders. Use `${VAR:?VAR must be set}` in Compose for required values.
|
||||||
|
|
||||||
|
### F-03 — Unauthenticated parapharmacy write/delete/scrape APIs
|
||||||
|
|
||||||
|
**Severity:** Critical
|
||||||
|
**Evidence:** `apps/parapharmacy-api/src/routes/products.js:280-320,340-390` exposes bulk write, update, and delete routes without auth. `apps/parapharmacy-api/src/routes/scraper.js:7-24` exposes a scraper trigger without auth. `apps/parapharmacy-api/src/server.js:55-70` applies only global CORS, JSON parsing, logging, and rate limiting.
|
||||||
|
|
||||||
|
**Impact:** An unauthenticated caller can poison or delete the catalog, trigger expensive Puppeteer scraping, and cause resource exhaustion.
|
||||||
|
|
||||||
|
**Action:** Protect write/delete/scrape routes with a dedicated ingestion/admin API key or service-to-service authentication. Apply a strict body/item limit and a separate low-rate limiter to scraper execution. Return generic errors without echoing scraper exception messages.
|
||||||
|
|
||||||
|
### F-04 — Hard-coded backend session secret fallback
|
||||||
|
|
||||||
|
**Severity:** High
|
||||||
|
**Evidence:** `apps/backend/server.js:83-85` uses a fixed fallback secret. The session cookie is HTTP-only and SameSite=Lax (`:87-91`), which is good, but `secure` is opt-in at `COOKIE_SECURE=true`.
|
||||||
|
|
||||||
|
**Impact:** Anyone who knows the repository can forge sessions when the deployment omits `SESSION_SECRET`; if `COOKIE_SECURE` is omitted in HTTPS production, cookies may be sent over an accidental HTTP path.
|
||||||
|
|
||||||
|
**Action:** Fail startup when production lacks a strong `SESSION_SECRET`; force secure cookies in production and set `app.set('trust proxy', ...)` only to the known proxy topology.
|
||||||
|
|
||||||
|
### F-05 — Public infrastructure ports and unauthenticated observability/admin surfaces
|
||||||
|
|
||||||
|
**Severity:** High
|
||||||
|
**Evidence:** `docker-compose.yml:29-30,66-67,75-76,86-89,102-103,122-123,131-132` publishes backend, frontend, exporters, parapharmacy API, MongoDB, and n8n ports. Swagger is mounted at `/api/docs` in `apps/parapharmacy-api/src/server.js:64-69` without an environment guard.
|
||||||
|
|
||||||
|
**Impact:** Databases, exporters, n8n, and internal APIs may be reachable from the host network or internet. Swagger reveals mutation endpoints and operational details.
|
||||||
|
|
||||||
|
**Action:** Bind internal services to the Docker network or loopback only. Publish only the reverse proxy/frontend. Restrict n8n and Swagger to an authenticated admin network or disable them in production.
|
||||||
|
|
||||||
|
### F-06 — Verified npm dependency advisories
|
||||||
|
|
||||||
|
**Severity:** Critical/High/Moderate by package and exposure
|
||||||
|
**Evidence:** `npm audit --json` completed with network access on 2026-07-22.
|
||||||
|
|
||||||
|
| Lockfile / scope | Critical | High | Moderate | Low | Total |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Workspace root | 3 | 13 | 60 | 3 | 79 |
|
||||||
|
| Backend | 1 | 11 | 41 | 3 | 56 |
|
||||||
|
| Frontend | 1 | 3 | 18 | 0 | 22 |
|
||||||
|
| Parapharmacy API | 0 | 2 | 0 | 1 | 3 |
|
||||||
|
| Scraper | 0 | 1 | 0 | 0 | 1 |
|
||||||
|
|
||||||
|
Notable verified chains include:
|
||||||
|
|
||||||
|
- backend: `@opentelemetry/auto-instrumentations-node` high-severity Prometheus exporter crash advisory; `bcrypt` through `@mapbox/node-pre-gyp`/`tar`; vulnerable `ws` and `uuid` transitive chains;
|
||||||
|
- frontend: critical `vitest` advisory when its UI server is exposed, plus vulnerable Vite/esbuild/Rollup and Grafana Faro/OpenTelemetry chains;
|
||||||
|
- parapharmacy API: `fast-uri`, `brace-expansion`, and `body-parser` advisories;
|
||||||
|
- scraper: `brace-expansion` high-severity DoS advisory.
|
||||||
|
|
||||||
|
**Action:** Apply targeted non-breaking patches first where available, then upgrade major-version families in isolated branches with tests. Do not use `npm audit fix --force` blindly; the audit indicates major-version changes for several fixes.
|
||||||
|
|
||||||
|
### F-07 — Dependency hygiene and likely-unused candidates
|
||||||
|
|
||||||
|
**Severity:** Medium (maintenance/security surface)
|
||||||
|
**Evidence:** A static repository scan found direct dependencies with no source import/reference outside manifests/lockfiles:
|
||||||
|
|
||||||
|
- backend: `barcode-detector`, `@opentelemetry/exporter-logs-otlp-grpc`, `@opentelemetry/sdk-logs`, `@opentelemetry/sdk-trace-base`;
|
||||||
|
- frontend: `@grafana/faro-web-sdk`;
|
||||||
|
- mobile: `expo-dev-client`, `expo-linking`, `react-native-screens`, `react-native-worklets` (some may be required by Expo/native autolinking);
|
||||||
|
- parapharmacy API: `jest`, `supertest` (test tooling is declared but no tests were found in that package).
|
||||||
|
|
||||||
|
This scan is not proof of unused status because Expo, native autolinking, configuration, and instrumentation can consume packages indirectly. Confirm each candidate with package-manager dependency tracing and a clean build before removal. No package was removed during this audit.
|
||||||
|
|
||||||
|
### F-08 — Build/runtime supply-chain hygiene
|
||||||
|
|
||||||
|
**Severity:** High
|
||||||
|
**Evidence:** `docker-compose.yml:22,57,73,84,95,120,129,169` uses mutable `:latest` image tags. `apps/parapharmacy-api/Dockerfile` uses `npm install --omit=dev` instead of `npm ci`; `apps/backend/Dockerfile` uses `npm ci`.
|
||||||
|
|
||||||
|
**Impact:** Rebuilds are not reproducible and can silently pull changed or compromised images/dependency resolutions.
|
||||||
|
|
||||||
|
**Action:** Pin images by version and digest, use lockfile-enforced `npm ci` for every Node image, and add image/dependency scanning to CI.
|
||||||
|
|
||||||
|
### F-09 — Mobile/API keys and telemetry endpoints need restriction
|
||||||
|
|
||||||
|
**Severity:** Medium
|
||||||
|
**Evidence:** `apps/frontend-mobile/google-services.json:18` contains a Firebase API key. This type of key is normally public, but must be restricted by package/bundle identity and API scope in Google Cloud/Firebase. Mobile and frontend env examples point telemetry at public or host endpoints.
|
||||||
|
|
||||||
|
**Action:** Verify Firebase key restrictions, avoid treating public client configuration as a secret, and enforce collector authentication/rate limits so telemetry endpoints cannot be abused.
|
||||||
|
|
||||||
|
## Positive controls observed
|
||||||
|
|
||||||
|
- Git status was clean before this audit.
|
||||||
|
- `.gitignore` excludes `.env`, dependency directories, build output, and Android signing material.
|
||||||
|
- Git history inspection found environment examples and placeholder values, but no tracked actual `.env` file or the local Expo/VAPID values.
|
||||||
|
- Backend uses parameterized SQL for the inspected user/session paths, bcrypt password hashing, HTTP-only cookies, admin middleware, and route-specific rate limiting.
|
||||||
|
- Production backend and frontend use container builds, and backend uses `npm ci --omit=dev`.
|
||||||
|
|
||||||
|
## Verification and limitations
|
||||||
|
|
||||||
|
- Source/configuration and Git-history checks were read-only.
|
||||||
|
- Online npm audit was executed with network access on 2026-07-22; advisory counts are a point-in-time result and should be rerun in CI.
|
||||||
|
- No production host, running container, database, secret manager, cloud account, or external firewall was inspected.
|
||||||
|
- No Python lockfile exists for `apps/pip-platform`; the Python audit therefore covered declarations and defaults, not resolved CVEs. Add a lockfile and run `pip-audit`/`uv audit` in CI.
|
||||||
|
- The audit did not prove that every endpoint is unreachable externally; exposure depends on deployment networking.
|
||||||
|
|
||||||
|
## Recommended deployment decision
|
||||||
|
|
||||||
|
Do not deploy the current configuration publicly until F-01 through F-05 are addressed. Dependency upgrades and supply-chain pinning should follow immediately. The implementation sequence is documented in [the hotfix plan](../superpowers/plans/2026-07-22-security-hotfixes.md).
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# FarmaFinder Security Dependency and Release Hardening Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remove the remaining high/critical dependency and supply-chain risks, make Python and container builds reproducible, and establish deployment-blocking security verification after the configuration and access-control hotfixes.
|
||||||
|
|
||||||
|
**Architecture:** Upgrade dependencies in isolated package-family batches while preserving the existing runtime security controls. Resolve Python dependencies into a committed `uv.lock`, pin container inputs by immutable digests, and run dependency, image, and secret scans in CI. Any remaining advisory is recorded with its path, exploitability, mitigation, and owner.
|
||||||
|
|
||||||
|
**Tech Stack:** npm workspaces/package-lock, Node.js 20/24, Python 3.13, uv, pip-audit, Docker Compose, Trivy, Gitleaks, Jest, Vitest, pytest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files and responsibilities
|
||||||
|
|
||||||
|
- Create `docs/security/2026-07-22-dependency-hardening.md`: audit baseline, selected versions/digests, residual-advisory decisions, and verification evidence.
|
||||||
|
- Create `apps/pip-platform/uv.lock`: resolved, hash-pinned Python dependency graph.
|
||||||
|
- Modify `apps/backend/package.json`, `apps/parapharmacy-api/package.json`, `apps/frontend/package.json`, `apps/scraper/package.json`, and the root `package-lock.json` for isolated dependency batches.
|
||||||
|
- Modify both Node Dockerfiles, Compose files, and deployment docs for immutable images and lockfile-enforced installs.
|
||||||
|
- Create `.github/workflows/security.yml`: reproducible installs, tests, audits, secret scanning, image scanning, and Compose checks.
|
||||||
|
|
||||||
|
## Baseline to preserve
|
||||||
|
|
||||||
|
The preceding hotfix plan already added production secret validation, service-key middleware, protected parapharmacy mutations, internal Compose networking, production-safe Swagger behavior, and security regression tests. Do not revert those controls. The last online audit reported 56 advisories for the backend/workspace scope and 3 for the parapharmacy API; counts must be refreshed before each batch.
|
||||||
|
|
||||||
|
### Task 1: Establish an auditable dependency baseline
|
||||||
|
|
||||||
|
**Files:** Create `docs/security/2026-07-22-dependency-hardening.md`.
|
||||||
|
|
||||||
|
- [ ] Run `npm ci` from the repository root, then run `npm audit --json`, `npm audit --prefix apps/backend --json`, `npm audit --prefix apps/parapharmacy-api --json`, `npm audit --prefix apps/frontend --json`, and `npm audit --prefix apps/scraper --json`, saving reports only under `/tmp` and never recording secret values.
|
||||||
|
- [ ] Record counts, direct versions, Node/npm/Python/uv/Docker versions, dependency paths, available fixes, and whether each finding reaches a production image.
|
||||||
|
- [ ] Confirm no unrelated lockfile rewrite exists with `git diff --check` and `git status --short`.
|
||||||
|
- [ ] Commit the ledger with `git add docs/security/2026-07-22-dependency-hardening.md && git commit -m "docs: record dependency hardening baseline"`.
|
||||||
|
|
||||||
|
### Task 2: Create and verify the Python lockfile
|
||||||
|
|
||||||
|
**Files:** Create `apps/pip-platform/uv.lock`; update PIP deployment docs if present.
|
||||||
|
|
||||||
|
- [ ] Run `cd apps/pip-platform && uv lock`; do not hand-edit generated lock content.
|
||||||
|
- [ ] Recreate from the lock with `uv sync --frozen --extra dev`, then run `uv run pytest -q` and `uv run pip-audit --strict`.
|
||||||
|
- [ ] If `pip-audit` is absent, add it to the development dependency group, rerun `uv lock`, and repeat the frozen sync. Record each Python advisory with package, path, reachability, and owner.
|
||||||
|
- [ ] Commit with `git add apps/pip-platform/uv.lock && git commit -m "build: lock pip platform dependencies"`.
|
||||||
|
|
||||||
|
### Task 3: Pin runtime container inputs immutably
|
||||||
|
|
||||||
|
**Files:** Root/PIP Compose files, both Node Dockerfiles, and the dependency-hardening ledger.
|
||||||
|
|
||||||
|
- [ ] Replace every runtime `:latest` image with an approved release. Resolve digests with `docker buildx imagetools inspect redis:7-alpine`, `postgres:16-alpine`, `mongo:7`, the approved n8n release, the approved Redis exporter release, and the approved PostgreSQL exporter release.
|
||||||
|
- [ ] Replace each reference with `repository:tag@sha256:` followed by the exact digest returned by `docker buildx imagetools inspect`, record the UTC update date and approver, and verify the digest is for the deployment platform.
|
||||||
|
- [ ] Ensure each Node Dockerfile copies its matching `package.json` and lockfile before `npm ci --omit=dev`; build both images and run a startup/config smoke test.
|
||||||
|
- [ ] Commit the image/Dockerfile batch separately so it can be reverted without undoing dependency code.
|
||||||
|
|
||||||
|
### Task 4: Upgrade backend dependency families independently
|
||||||
|
|
||||||
|
**Files:** `apps/backend/package.json`, root `package-lock.json`, existing backend tests.
|
||||||
|
|
||||||
|
- [ ] Run `npm ci` and `npm test --prefix apps/backend -- --runInBand` before changing versions.
|
||||||
|
- [ ] Upgrade all direct OpenTelemetry packages as one compatible family: API, auto-instrumentations, exporters, instrumentation-pino, resources, SDKs, and semantic conventions. Do not upgrade only `auto-instrumentations-node`.
|
||||||
|
- [ ] Regenerate only through npm, inspect the lockfile for unrelated upgrades, and rerun the backend suite.
|
||||||
|
- [ ] In a separate batch, upgrade `bcrypt` and `sqlite3`; verify native installs under Node 20 and 24, session/database tests, `npm ls tar node-gyp`, and `npm audit --prefix apps/backend --audit-level=high`.
|
||||||
|
- [ ] Never use `npm audit fix --force`; any major upgrade requires a compatibility note and review. Commit each family separately.
|
||||||
|
|
||||||
|
### Task 5: Upgrade parapharmacy and scraper dependency families
|
||||||
|
|
||||||
|
**Files:** `apps/parapharmacy-api/package.json`, `apps/scraper/package.json`, root `package-lock.json`, security tests.
|
||||||
|
|
||||||
|
- [ ] Upgrade Mongoose within its supported major, regenerate the lockfile, and confirm `npm ls fast-uri` resolves outside the audited range.
|
||||||
|
- [ ] Run `npm test --prefix apps/parapharmacy-api -- --runInBand __tests__/security.test.js` and `npm audit --prefix apps/parapharmacy-api --audit-level=high`.
|
||||||
|
- [ ] Upgrade scraper Puppeteer dependencies only after recording the current executable path. Preserve `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true` and `/usr/bin/chromium`; verify with `node -e "console.log(process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium')"`.
|
||||||
|
- [ ] Run scraper module-load/available tests and commit Mongoose and scraper batches separately.
|
||||||
|
|
||||||
|
### Task 6: Upgrade frontend tooling without exposing Vitest UI
|
||||||
|
|
||||||
|
**Files:** `apps/frontend/package.json`, root `package-lock.json`, Vite/Vitest config, CI workflow.
|
||||||
|
|
||||||
|
- [ ] Confirm no production or CI command uses Vitest UI with `rg -n "vitest( --ui|.*ui)" apps/frontend package.json .github`.
|
||||||
|
- [ ] Upgrade Vite, Rollup/esbuild, and Vitest as a compatible toolchain; keep React plugin, jsdom, and testing-library versions compatible with the selected Vitest major.
|
||||||
|
- [ ] Regenerate the lockfile and run `npm test --prefix apps/frontend`, `npm run build --prefix apps/frontend`, and `npm audit --prefix apps/frontend --audit-level=high`.
|
||||||
|
- [ ] Record any development-only residual advisory and commit this batch separately.
|
||||||
|
|
||||||
|
### Task 7: Add deployment-blocking CI gates
|
||||||
|
|
||||||
|
**Files:** Create `.github/workflows/security.yml`.
|
||||||
|
|
||||||
|
- [ ] Add pull-request/protected-branch jobs for `npm ci` plus all Node tests/builds; workspace/app `npm audit --audit-level=high`; `uv sync --frozen`, pytest, and `pip-audit --strict`; Gitleaks full-history scanning; Trivy high/critical image scanning; and root/PIP Compose rendering.
|
||||||
|
- [ ] The Compose job must use fixture values and assert that only intended frontend/API ports are published, no internal database/exporter ports are published, and no production-secret `${...:-placeholder}` fallback remains.
|
||||||
|
- [ ] Build production images with committed lockfiles, scan image digests, upload JSON reports as artifacts, and never upload `.env` files or secret values.
|
||||||
|
- [ ] Pin third-party actions to reviewed immutable commit SHAs and grant read-only repository permissions. Commit with `git add .github/workflows/security.yml && git commit -m "ci: enforce dependency and image security gates"`.
|
||||||
|
|
||||||
|
### Task 8: Run final verification and residual-advisory review
|
||||||
|
|
||||||
|
**Files:** Update the dependency-hardening ledger only if verification exposes a regression or documented exception.
|
||||||
|
|
||||||
|
- [ ] Run `npm ci`, backend tests, parapharmacy security tests, frontend tests/build, `uv sync --frozen --extra dev`, `uv run pytest`, and `uv run pip-audit --strict`.
|
||||||
|
- [ ] Render all Compose files with production-like fixture variables; inspect for placeholders, mutable tags, public database/exporter ports, and missing required secrets.
|
||||||
|
- [ ] Build all production images and black-box test public GETs, 401 unauthenticated mutations, 403 invalid keys, absent production Swagger, and absent host-published internal infrastructure.
|
||||||
|
- [ ] Re-run all audits. Every remaining high/critical item must list package, dependency path, production reachability, exploitability, mitigation, owner, and review/expiry date; unexplained high/critical findings block deployment.
|
||||||
|
- [ ] Run `git diff --check`, confirm no real `.env` is tracked, attach command counts to the ledger, and commit the final evidence.
|
||||||
|
|
||||||
|
## Self-review
|
||||||
|
|
||||||
|
This plan covers the remaining audit findings: dependency-family upgrades, Python locking/auditing, immutable images, lockfile-enforced Docker installs, secret scanning, image scanning, frontend/Vitest exposure, and residual-advisory review. It deliberately avoids forced major upgrades and keeps each risky family in a reversible batch.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# FarmaFinder Security Hotfixes Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Remove production-blocking secret, access-control, network-exposure, and dependency risks identified by the 2026-07-22 security audit.
|
||||||
|
|
||||||
|
**Architecture:** Make production configuration fail closed, centralize service authentication for parapharmacy ingestion operations, and keep internal infrastructure private behind the reverse proxy. Upgrade dependency families in isolated batches with lockfile and runtime verification.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js 20/24, Express, express-session, MongoDB/Mongoose, Docker Compose, npm lockfiles, Python/Pydantic settings, Jest/Vitest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files and responsibilities
|
||||||
|
|
||||||
|
- Modify `apps/backend/server.js`: production configuration validation, secure session cookie defaults, and any shared service-auth helper integration.
|
||||||
|
- Create `apps/backend/src/config/required-env.js` and `apps/backend/src/middleware/service-auth.js`: fail-closed environment validation and constant-time service-key verification.
|
||||||
|
- Modify `apps/parapharmacy-api/src/server.js`, `apps/parapharmacy-api/src/routes/products.js`, and `apps/parapharmacy-api/src/routes/scraper.js`: protect mutation/scrape routes, tighten limits, and hide Swagger in production.
|
||||||
|
- Modify `docker-compose.yml` and `apps/pip-platform/docker-compose*.yml`: remove secret fallbacks, stop publishing internal ports, and pin image/dependency behavior.
|
||||||
|
- Modify `apps/pip-platform/src/infrastructure/config/settings.py`: reject placeholder production secrets and wildcard credentialed CORS.
|
||||||
|
- Modify `apps/backend/.env.example`, `.env.example`, and relevant README/docs: document required secret generation without real values.
|
||||||
|
- Add/extend `apps/backend/__tests__/server.test.js` and create `apps/parapharmacy-api/__tests__/security.test.js`: regression coverage for fail-closed behavior and route protection.
|
||||||
|
- Update relevant `package.json` and lockfiles only through package-manager commands after the security tests are in place.
|
||||||
|
|
||||||
|
### Task 1: Rotate and inventory credentials before code changes
|
||||||
|
|
||||||
|
**Files:** Deployment secret store and local ignored `.env` files; no repository source changes required.
|
||||||
|
|
||||||
|
- [ ] Revoke the Expo access token found in the local environment and issue a replacement with the minimum project scope.
|
||||||
|
- [ ] Generate a new VAPID key pair and replace both public/private values wherever deployed.
|
||||||
|
- [ ] Rotate `SESSION_SECRET`, `PG_PASSWORD`, `REDIS_PASSWORD`, `N8N_PASSWORD`, `INGEST_API_KEY`, MongoDB credentials, and PIP `JWT_SECRET_KEY` if their values were used outside local development.
|
||||||
|
- [ ] Search deployment logs, CI variables, shell history, and backups for the old values; record the revocation date without writing secret values to Git.
|
||||||
|
- [ ] Confirm `git ls-files` contains no actual `.env` file before continuing.
|
||||||
|
|
||||||
|
### Task 2: Add fail-closed production configuration validation
|
||||||
|
|
||||||
|
**Files:** Create `apps/backend/src/config/required-env.js`; modify `apps/backend/server.js`, `apps/pip-platform/src/infrastructure/config/settings.py`, and both PIP Compose files.
|
||||||
|
|
||||||
|
- [ ] Add a backend validator that rejects production startup when `SESSION_SECRET`, `CORS_ORIGIN`, and `PG_URL`/`PG_PASSWORD` are missing or equal to a known placeholder. Use `crypto.timingSafeEqual` only for fixed-length key comparisons; validation itself should compare exact placeholder strings.
|
||||||
|
- [ ] Replace the backend fallback at `server.js:84` with a required value from the validator.
|
||||||
|
- [ ] Set `cookie.secure` to `true` whenever `NODE_ENV === 'production'`, while retaining an explicit development override only for local HTTP.
|
||||||
|
- [ ] Replace every Compose `${SECRET:-placeholder}` expression with `${SECRET:?SECRET must be set}` for production-required secrets.
|
||||||
|
- [ ] In PIP settings, reject `JWT_SECRET_KEY=change-me-in-production`, reject default database/broker credentials in production, and reject `CORS_ORIGINS=['*']` when credentials are enabled.
|
||||||
|
- [ ] Add tests that start configuration with missing/placeholder secrets and assert a clear startup error.
|
||||||
|
|
||||||
|
### Task 3: Authenticate parapharmacy ingestion and admin mutation routes
|
||||||
|
|
||||||
|
**Files:** Create `apps/parapharmacy-api/src/middleware/service-auth.js`; modify `apps/parapharmacy-api/src/server.js`, `src/routes/products.js`, and `src/routes/scraper.js`; create `apps/parapharmacy-api/__tests__/security.test.js`.
|
||||||
|
|
||||||
|
- [ ] Require `INGEST_API_KEY` for `POST /api/products`, `POST /api/products/bulk`, and `POST /api/scrape` using `Authorization: Bearer <key>` or a dedicated internal header. Reject missing/malformed keys with 401 and compare supplied keys in constant time.
|
||||||
|
- [ ] Require a separate admin credential for `PUT /api/products/:id` and `DELETE /api/products/:id`; do not reuse a public frontend session unless the API is intentionally integrated with that session.
|
||||||
|
- [ ] Add a route-specific limiter for `/api/scrape`, cap queries/sources to bounded arrays, cap product bulk size, and lower `express.json` to the smallest limit required by real payloads.
|
||||||
|
- [ ] Return `{ error: 'Scraping failed' }` without `error.message` in production.
|
||||||
|
- [ ] Mount Swagger only when `NODE_ENV !== 'production'` or protect it with the same admin control.
|
||||||
|
- [ ] Test 401 for unauthenticated mutation/scrape requests, 403/401 for invalid keys, and successful behavior for a valid key. Test that public GET search endpoints remain available.
|
||||||
|
|
||||||
|
### Task 4: Close infrastructure network exposure
|
||||||
|
|
||||||
|
**Files:** Modify `docker-compose.yml`, `apps/pip-platform/docker-compose.yml`, `apps/pip-platform/docker-compose.runtime.yml`, and `apps/frontend/nginx.conf` as needed.
|
||||||
|
|
||||||
|
- [ ] Remove host `ports` for Redis, PostgreSQL, MongoDB, exporters, and n8n; use `127.0.0.1:host:container` only when local operator access is explicitly required.
|
||||||
|
- [ ] Publish only the intended frontend/reverse-proxy port and route internal API traffic through the proxy or a private Docker network.
|
||||||
|
- [ ] Add a dedicated internal network and keep database/exporter services off any public-facing network.
|
||||||
|
- [ ] Add production security headers at the reverse proxy, including HSTS only when HTTPS is guaranteed, and verify proxy headers are preserved.
|
||||||
|
- [ ] Disable or protect `/api/docs` in production and verify an external request cannot reach n8n, MongoDB, PostgreSQL, Redis, or exporter ports.
|
||||||
|
|
||||||
|
### Task 5: Make builds reproducible and remove mutable image inputs
|
||||||
|
|
||||||
|
**Files:** `apps/backend/Dockerfile`, `apps/parapharmacy-api/Dockerfile`, `docker-compose.yml`, PIP Compose files, CI workflow files.
|
||||||
|
|
||||||
|
- [ ] Replace `npm install --omit=dev` in the parapharmacy Dockerfile with `npm ci --omit=dev`.
|
||||||
|
- [ ] Pin all runtime image tags to approved versions and digests; record the update date in the deployment documentation.
|
||||||
|
- [ ] Add CI checks for `npm ci`, `npm audit --audit-level=high`, image vulnerability scanning, and secret scanning.
|
||||||
|
- [ ] Generate a Python lockfile and run `pip-audit` or `uv audit` against resolved dependencies.
|
||||||
|
|
||||||
|
### Task 6: Upgrade vulnerable dependency families in batches
|
||||||
|
|
||||||
|
**Files:** `package.json`, `package-lock.json`, each app `package.json`/lockfile touched by the audit.
|
||||||
|
|
||||||
|
- [ ] First update patch/minor-compatible vulnerable transitive packages and regenerate lockfiles with `npm install --package-lock-only`; inspect the diff for unrelated upgrades.
|
||||||
|
- [ ] Upgrade backend OpenTelemetry packages as one compatible family, then `bcrypt`/SQLite-related packages, and rerun backend tests.
|
||||||
|
- [ ] Upgrade parapharmacy API `mongoose`/`fast-uri` and transitive packages, then rerun API security tests.
|
||||||
|
- [ ] Upgrade frontend Vite/Rollup/esbuild and Vitest. Keep Vitest UI disabled in production and verify no UI server is started by CI or deployment.
|
||||||
|
- [ ] Upgrade scraper dependencies and confirm Puppeteer still uses the intended Chromium binary.
|
||||||
|
- [ ] Re-run `npm audit --json` for the workspace and each lockfile; document any accepted residual advisory with package, path, exploitability, and owner.
|
||||||
|
|
||||||
|
### Task 7: Verification gate before deployment
|
||||||
|
|
||||||
|
**Files:** No source changes unless verification exposes a regression.
|
||||||
|
|
||||||
|
- [ ] Run `git diff --check`.
|
||||||
|
- [ ] Run backend tests: `npm test --prefix apps/backend`.
|
||||||
|
- [ ] Run frontend tests: `npm test --prefix apps/frontend`.
|
||||||
|
- [ ] Run the parapharmacy security tests and all available package tests.
|
||||||
|
- [ ] Render Compose configuration with production-like variables and confirm no placeholder values remain: `docker compose config`.
|
||||||
|
- [ ] Build every production image with lockfile-enforced installs.
|
||||||
|
- [ ] Run a black-box smoke test proving public GET endpoints work, protected mutations return 401 without a key, and internal infrastructure is not host-published.
|
||||||
|
- [ ] Re-run secret scanning and `npm audit --audit-level=high`; block deployment on any critical/high issue without an explicit documented exception.
|
||||||
Reference in New Issue
Block a user