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
54 changes: 49 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,60 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node
- uses: ./.github/actions/turbo-cache
- name: Audit dependencies
run: npm audit --audit-level=high
continue-on-error: true
shell: bash
run: |
npm audit --json > audit.json || true
node - <<'NODE'
const fs = require("node:fs");
const report = JSON.parse(fs.readFileSync("audit.json", "utf8"));
const ignoreGhsa = new Set(["GHSA-v2v4-37r5-5v8g"]);

const vulns = report.vulnerabilities ?? {};
const failing = [];

for (const [name, vuln] of Object.entries(vulns)) {
const severity = vuln?.severity;
if (!["moderate", "high", "critical"].includes(severity)) continue;

const via = Array.isArray(vuln?.via) ? vuln.via : [];
const viaPackages = via
.filter((v) => typeof v === "string")
.map((v) => String(v));
const advisoryUrls = via
.filter((v) => typeof v === "object" && v?.url)
.map((v) => String(v.url));

const hasOnlyIgnoredAdvisories =
advisoryUrls.length > 0 &&
advisoryUrls.every((url) =>
Array.from(ignoreGhsa).some((id) => url.includes(id)),
);

const isExpressRateLimitViaIgnoredIpAddress =
name === "express-rate-limit" && viaPackages.includes("ip-address");

if (hasOnlyIgnoredAdvisories) continue;
if (isExpressRateLimitViaIgnoredIpAddress) continue;
failing.push({ name, severity, via: advisoryUrls });
}

if (failing.length > 0) {
console.error("Blocking vulnerabilities found:");
for (const item of failing) {
console.error(`- ${item.name} (${item.severity})`);
for (const url of item.via) console.error(` ${url}`);
}
process.exit(1);
}

console.log("Security audit passed (known GHSA-v2v4-37r5-5v8g ignored until upstream fix).");
NODE
- name: Review dependency changes
if: github.event_name == 'pull_request'
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
continue-on-error: true
fail-on-severity: moderate

test:
name: Tests
Expand Down
99 changes: 27 additions & 72 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,22 @@ on:

jobs:
build-scan:
name: Build & Scan Images (api/web/bot)
name: Build & Scan (${{ matrix.image_name }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- image_name: api
dockerfile: apps/api/Dockerfile
build_args: ""
- image_name: web
dockerfile: apps/web/Dockerfile
build_args: |
NEXT_PUBLIC_API_URL=/api
- image_name: bot
dockerfile: apps/bot/Dockerfile
build_args: ""
permissions:
contents: read
packages: write
Expand All @@ -33,91 +47,32 @@ jobs:
id: repo
run: echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT"

- name: Build API image
- name: Build ${{ matrix.image_name }} image
uses: docker/build-push-action@v6
with:
context: .
file: apps/api/Dockerfile
file: ${{ matrix.dockerfile }}
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: ${{ matrix.build_args }}
tags: |
ghcr.io/${{ steps.repo.outputs.name }}-api:latest
ghcr.io/${{ steps.repo.outputs.name }}-api:${{ github.sha }}
ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:latest
ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:${{ github.sha }}

- name: Scan API image
- name: Scan ${{ matrix.image_name }} image
uses: aquasecurity/trivy-action@v0.24.0
with:
image-ref: ghcr.io/${{ steps.repo.outputs.name }}-api:${{ github.sha }}
image-ref: ghcr.io/${{ steps.repo.outputs.name }}-${{ matrix.image_name }}:${{ github.sha }}
format: sarif
output: trivy-api.sarif
output: trivy-${{ matrix.image_name }}.sarif
severity: CRITICAL,HIGH
exit-code: "0"

- name: Build Web image
uses: docker/build-push-action@v6
with:
context: .
file: apps/web/Dockerfile
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
NEXT_PUBLIC_API_URL=/api
tags: |
ghcr.io/${{ steps.repo.outputs.name }}-web:latest
ghcr.io/${{ steps.repo.outputs.name }}-web:${{ github.sha }}

- name: Scan Web image
uses: aquasecurity/trivy-action@v0.24.0
with:
image-ref: ghcr.io/${{ steps.repo.outputs.name }}-web:${{ github.sha }}
format: sarif
output: trivy-web.sarif
severity: CRITICAL,HIGH
exit-code: "0"

- name: Build Bot image
uses: docker/build-push-action@v6
with:
context: .
file: apps/bot/Dockerfile
push: ${{ github.event_name == 'push' }}
load: ${{ github.event_name == 'pull_request' }}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: |
ghcr.io/${{ steps.repo.outputs.name }}-bot:latest
ghcr.io/${{ steps.repo.outputs.name }}-bot:${{ github.sha }}

- name: Scan Bot image
uses: aquasecurity/trivy-action@v0.24.0
with:
image-ref: ghcr.io/${{ steps.repo.outputs.name }}-bot:${{ github.sha }}
format: sarif
output: trivy-bot.sarif
severity: CRITICAL,HIGH
exit-code: "0"

- name: Upload API Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-api.sarif
category: api

- name: Upload Web Trivy results
if: always()
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-web.sarif
category: web

- name: Upload Bot Trivy results
if: always()
- name: Upload Trivy results
if: always() && hashFiles(format('trivy-{0}.sarif', matrix.image_name)) != ''
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: trivy-bot.sarif
category: bot
sarif_file: trivy-${{ matrix.image_name }}.sarif
category: ${{ matrix.image_name }}
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pnpm-debug.log*
.cache/

# Other
.claude
.turbo
*.tsbuildinfo
.next/
Expand Down
1 change: 1 addition & 0 deletions apps/api/.env.docker.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ DIRECT_URL="postgresql://fintrack:fintrack@postgres:5432/fintrack"

# JWT/access token
ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here"
CSRF_SECRET="your_csrf_secret_here"

# Google OAuth verification (must match Google Cloud OAuth client)
GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com"
Expand Down
1 change: 1 addition & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ DIRECT_URL="postgresql://postgres:password@localhost:5432/fintrack?schema=public

# JWT/access token
ACCESS_TOKEN_SECRET="your_jwt_access_token_secret_here"
CSRF_SECRET="your_csrf_secret_here"

# Google OAuth verification (must match Google Cloud OAuth client)
GOOGLE_CLIENT_ID="your_google_client_id.apps.googleusercontent.com"
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"bcrypt": "^6.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csrf-csrf": "^4.0.3",
"dotenv": "^16.6.1",
"express": "^4.19.2",
"express-rate-limit": "^8.3.2",
Expand Down
56 changes: 33 additions & 23 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import express from "express";
import type { Request, Response, NextFunction } from "express";
import type { Request, Response } from "express";
import cors from "cors";
import helmet from "helmet";
import cookieParser from "cookie-parser";
Expand Down Expand Up @@ -29,11 +29,21 @@ const allowedOrigins =

app.set("trust proxy", 1);

// Configure Helmet: disable CSP in development to allow Swagger UI
app.use(
helmet({
contentSecurityPolicy: ENV.NODE_ENV === "production" ? true : false,
crossOriginResourcePolicy: { policy: "cross-origin" },
contentSecurityPolicy:
ENV.NODE_ENV === "production"
? true
: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
},
},
crossOriginResourcePolicy: { policy: "same-origin" },
}),
);

Expand All @@ -47,36 +57,36 @@ app.use(
credentials: true,
}),
);

app.use(cookieParser());
app.use(
"/api/donations/webhook",
express.raw({ type: "application/json", limit: "256kb" }),
);
app.use(express.json({ limit: "32kb" }));
app.use(express.urlencoded({ extended: true, limit: "32kb" }));

// Initialize Swagger (defines /api-docs and /api-docs.json)
swaggerDocs(app);

// Initialize CSRF middleware
const csrfMiddleware = csrfProtection(allowedOrigins);

// Apply CSRF protection but skip it for Swagger documentation in development
app.use((req: Request, res: Response, next: NextFunction) => {
const isSwagger =
req.path.startsWith("/api-docs") || req.path.startsWith("/api/auth/login");
const isDev = ENV.NODE_ENV !== "production";
app.use((req, res, next) => {
const skipCsrf =
req.path === "/api-docs.json" ||
req.path.startsWith("/api-docs") ||
req.path.startsWith("/api/donations/webhook");

// In dev, we allow Swagger to bypass CSRF for testing
if (isDev && isSwagger && req.get("referer")?.includes("/api-docs")) {
if (skipCsrf) {
return next();
}

csrfMiddleware(req, res, next);
return cookieParser()(req, res, (cookieErr?: unknown) => {
if (cookieErr) return next(cookieErr);
return csrfProtection(req, res, next);
});
});

app.use("/api", apiRouter);
// Initialize Swagger (defines /api-docs and /api-docs.json)
swaggerDocs(app);

app.use(
"/api",
express.json({ limit: "32kb" }),
express.urlencoded({ extended: true, limit: "32kb" }),
apiRouter,
);

app.use((_req: Request, res: Response) => {
res.status(404).json({ error: "Endpoint not found" });
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ dotenv.config();
const requiredEnvVars = [
"DATABASE_URL",
"ACCESS_TOKEN_SECRET",
"CSRF_SECRET",
"API_KEY_ENCRYPTION_SECRET",
];

Expand Down Expand Up @@ -59,6 +60,7 @@ export const ENV = {
: "verifyIdToken",
DATABASE_URL: process.env.DATABASE_URL as string,
ACCESS_TOKEN_SECRET: process.env.ACCESS_TOKEN_SECRET as string,
CSRF_SECRET: process.env.CSRF_SECRET as string,
GROQAPITOKENS,
API_KEY_ENCRYPTION_SECRET: process.env.API_KEY_ENCRYPTION_SECRET as string,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY ?? "",
Expand Down
Loading
Loading