Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ RATE_LIMIT_MAX=100
# Admin endpoints: stricter limits (default window mirrors RATE_LIMIT_WINDOW_MS)
RATE_LIMIT_ADMIN_WINDOW_MS=60000
RATE_LIMIT_ADMIN_MAX=20

# --- IP Whitelist for admin endpoints (optional) ---
# Comma-separated list of allowed IPs or CIDR ranges
# If empty or unset, IP whitelist is disabled (all IPs allowed)
ADMIN_IP_WHITELIST=
# Set to "false" to NOT bypass private/internal networks (default: true bypasses them)
ADMIN_IP_WHITELIST_BYPASS_PRIVATE=true
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,45 @@ jobs:

- name: Test
run: bun run test

dependency-audit:
name: Dependency Vulnerability Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run npm audit (high and critical)
run: npm audit --audit-level=high
continue-on-error: true
id: audit

- name: Run npm audit (all severities for reporting)
run: |
echo "## Dependency Audit Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
npm audit --json > audit-report.json || true
if [ -s audit-report.json ]; then
CRITICAL=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.critical || 0)")
HIGH=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.high || 0)")
MODERATE=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.moderate || 0)")
LOW=$(cat audit-report.json | node -e "const d=require('fs').readFileSync(0,'utf8');const j=JSON.parse(d);console.log(j.metadata?.vulnerabilities?.low || 0)")
echo "| Severity | Count |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Critical | $CRITICAL |" >> $GITHUB_STEP_SUMMARY
echo "| High | $HIGH |" >> $GITHUB_STEP_SUMMARY
echo "| Moderate | $MODERATE |" >> $GITHUB_STEP_SUMMARY
echo "| Low | $LOW |" >> $GITHUB_STEP_SUMMARY
else
echo "No vulnerabilities found." >> $GITHUB_STEP_SUMMARY
fi
if: always()

- name: Fail on critical vulnerabilities
if: steps.audit.outcome == 'failure'
run: exit 1
158 changes: 158 additions & 0 deletions .github/workflows/security-audit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
name: Security Audit

on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: read
security-events: write

jobs:
dependency-audit:
name: Dependency Vulnerability Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Run npm audit
run: npm audit --json > npm-audit-report.json || true

- name: Parse audit results
run: |
echo "## Dependency Audit Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Date:** $(date -u +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [ -s npm-audit-report.json ]; then
node -e '
const fs = require("fs");
const report = JSON.parse(fs.readFileSync("npm-audit-report.json", "utf8"));
const v = report.metadata?.vulnerabilities || {};
console.log("| Severity | Count |");
' | tee -a $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
node -e '
const fs = require("fs");
const report = JSON.parse(fs.readFileSync("npm-audit-report.json", "utf8"));
const v = report.metadata?.vulnerabilities || {};
["critical","high","moderate","low","info"].forEach(sev => {
console.log(`| ${sev.charAt(0).toUpperCase()+sev.slice(1)} | ${v[sev] || 0} |`);
});
' >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
CRITICAL=$(node -e 'const r=JSON.parse(require("fs").readFileSync("npm-audit-report.json","utf8"));console.log(r.metadata?.vulnerabilities?.critical||0)')
if [ "$CRITICAL" -gt 0 ]; then
echo "::error::Found $CRITICAL critical vulnerabilities in dependencies"
fi
else
echo "No dependencies scanned or no vulnerabilities found." >> $GITHUB_STEP_SUMMARY
fi

- name: Upload audit report
uses: actions/upload-artifact@v4
with:
name: security-audit-report
path: npm-audit-report.json
retention-days: 90

code-vulnerability-scan:
name: Code Vulnerability Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Run Trivy vulnerability scanner (filesystem)
uses: aquasecurity/trivy-action@master
with:
scan-type: fs
scan-ref: .
severity: CRITICAL,HIGH
format: table
exit-code: 1

secret-detection:
name: Secret Detection
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

license-compliance:
name: License Compliance
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Install bun
uses: oven-sh/setup-bun@v2

- name: Install dependencies
run: bun install --frozen-lockfile

- name: Check license compliance
run: |
echo "## License Compliance Report" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Date:** $(date -u +'%Y-%m-%d')" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
npx license-checker --json --out licenses.json 2>/dev/null || true
if [ -s licenses.json ]; then
echo "| Package | License |" >> $GITHUB_STEP_SUMMARY
echo "|---------|---------|" >> $GITHUB_STEP_SUMMARY
node -e '
const fs = require("fs");
const licenses = JSON.parse(fs.readFileSync("licenses.json", "utf8"));
const entries = Object.entries(licenses).slice(0, 50);
entries.forEach(([pkg, info]) => {
console.log(`| ${pkg} | ${info.licenses || "UNKNOWN"} |`);
});
if (Object.keys(licenses).length > 50) {
console.log(`| ... | ... (${Object.keys(licenses).length - 50} more) |`);
}
' >> $GITHUB_STEP_SUMMARY
else
echo "No license data available." >> $GITHUB_STEP_SUMMARY
fi

- name: Upload license report
uses: actions/upload-artifact@v4
with:
name: license-report
path: licenses.json
retention-days: 90

security-headers-check:
name: Security Headers Audit
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- name: Verify security headers configuration
run: |
echo "## Security Headers Audit" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Checking middleware/securityHeaders.ts..." >> $GITHUB_STEP_SUMMARY
if grep -q "helmet" src/middleware/securityHeaders.ts 2>/dev/null; then
echo "- [x] Helmet security headers configured" >> $GITHUB_STEP_SUMMARY
else
echo "- [ ] Helmet security headers NOT configured" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Verifying CSP, HSTS, X-Frame-Options, X-Content-Type-Options..." >> $GITHUB_STEP_SUMMARY
grep -E "contentSecurityPolicy|hsts|frameguard|noSniff" src/middleware/securityHeaders.ts | while read -r line; do
echo "- \`$line\`" >> $GITHUB_STEP_SUMMARY
done
178 changes: 178 additions & 0 deletions src/__tests__/apiKeysRotation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import express from "express";
import request from "supertest";
import {
generateApiKey,
rotateApiKey,
validateApiKey,
clearApiKeys,
checkScheduledRotations,
getRotationStatus,
onRotation,
} from "../lib/apiKeys";
import apiKeysRouter from "../routes/apiKeys";

describe("API Key Rotation", () => {
let originalAdminKey: string | undefined;

beforeAll(() => {
originalAdminKey = process.env.ADMIN_API_KEY;
process.env.ADMIN_API_KEY = "admin-secret-key";
});

afterAll(() => {
process.env.ADMIN_API_KEY = originalAdminKey;
});

beforeEach(() => {
clearApiKeys();
});

describe("Scheduled Rotation", () => {
it("should set next_rotation_at when rotation_interval_days is provided", () => {
const key = generateApiKey("Consumer", 100, 30);
expect(key.rotation_interval_days).toBe(30);
expect(key.next_rotation_at).toBeDefined();
expect(key.next_rotation_at).toBeGreaterThan(Date.now());
});

it("should not set rotation schedule when rotation_interval_days is not provided", () => {
const key = generateApiKey("Consumer", 100);
expect(key.rotation_interval_days).toBeUndefined();
expect(key.next_rotation_at).toBeUndefined();
});

it("should rotate keys that have passed their rotation time", () => {
const key = generateApiKey("Consumer", 100, 1);
const oldKey = key.key;

// Simulate time passing by setting next_rotation_at to past
key.next_rotation_at = Date.now() - 1000;

const rotated = checkScheduledRotations();
expect(rotated.length).toBe(1);
expect(rotated[0].key).not.toBe(oldKey);
});

it("should not rotate keys that are still within rotation interval", () => {
generateApiKey("Consumer", 100, 30);

const rotated = checkScheduledRotations();
expect(rotated.length).toBe(0);
});

it("should return correct rotation status", () => {
generateApiKey("Consumer1", 100, 30);
generateApiKey("Consumer2", 100, 7);
generateApiKey("Consumer3", 100);

const status = getRotationStatus();
expect(status.keys_with_scheduled_rotation).toBe(2);
expect(status.keys_pending_rotation).toBe(0);
expect(status.next_rotation_at).toBeDefined();
});
});

describe("Grace Period Rotation", () => {
it("should accept old key during grace period", () => {
const key = generateApiKey("Consumer", 100);
const oldKey = key.key;
const gracePeriodMs = 60 * 60 * 1000; // 1 hour

rotateApiKey(key.id, gracePeriodMs);

// Old key should still be valid
expect(validateApiKey(oldKey)).not.toBeNull();
// New key should also be valid
const updatedKey = validateApiKey(key.key);
expect(updatedKey).not.toBeNull();
});

it("should reject old key after grace period expires", () => {
const key = generateApiKey("Consumer", 100);
const oldKey = key.key;

// Rotate with 0ms grace period (effectively no grace)
rotateApiKey(key.id, 0);

// Old key should be invalid immediately
expect(validateApiKey(oldKey)).toBeNull();
});
});

describe("Rotation Notifications", () => {
it("should emit rotation notification", () => {
const notifications: any[] = [];
onRotation((n) => notifications.push(n));

const key = generateApiKey("Consumer", 100);
rotateApiKey(key.id);

expect(notifications.length).toBe(1);
expect(notifications[0].key_id).toBe(key.id);
expect(notifications[0].consumer_name).toBe("Consumer");
});
});

describe("API Routes with Rotation", () => {
const app = express();
app.use(express.json());
app.use("/admin/api-keys", apiKeysRouter);

it("should create key with rotation interval", async () => {
const res = await request(app)
.post("/admin/api-keys")
.set("Authorization", "Bearer admin-secret-key")
.send({ consumer_name: "Test Consumer", rotation_interval_days: 30 });

expect(res.status).toBe(201);
expect(res.body.rotation_interval_days).toBe(30);
expect(res.body.next_rotation_at).toBeDefined();
});

it("should return rotation status", async () => {
generateApiKey("Consumer 1", 100, 30);
generateApiKey("Consumer 2", 100, 7);

const res = await request(app)
.get("/admin/api-keys/rotation/status")
.set("Authorization", "Bearer admin-secret-key");

expect(res.status).toBe(200);
expect(res.body.keys_with_scheduled_rotation).toBe(2);
});

it("should trigger manual rotation", async () => {
const res = await request(app)
.post("/admin/api-keys/rotation/trigger")
.set("Authorization", "Bearer admin-secret-key");

expect(res.status).toBe(200);
expect(res.body.rotated).toBe(0);
});

it("should rotate key with grace period", async () => {
const key = generateApiKey("Consumer", 100);

const res = await request(app)
.post(`/admin/api-keys/${key.id}/rotate`)
.set("Authorization", "Bearer admin-secret-key")
.send({ grace_period_ms: 3600000 });

expect(res.status).toBe(200);
expect(res.body.old_key).toBeDefined();
expect(res.body.new_key_expires_at).toBeDefined();
});

it("should include rotation info in key details", async () => {
const key = generateApiKey("Consumer", 100, 30);

const res = await request(app)
.get(`/admin/api-keys/${key.id}/usage`)
.set("Authorization", "Bearer admin-secret-key");

expect(res.status).toBe(200);
expect(res.body.rotation_interval_days).toBe(30);
expect(res.body.next_rotation_at).toBeDefined();
});
});
});
Loading
Loading