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
113 changes: 103 additions & 10 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,26 +9,119 @@ on:
- '**'
env:
NODE_VERSION: "20"
OPENAPI_FILE: 'index.yaml'
PORT: '3000'

jobs:
node-ci:
lint:
runs-on: ubuntu-latest
steps:
# 1. Checkout repository
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci --prefer-offline --no-audit --no-fund


- name: Ensure minimal ESLint config
run: |
node -e "const fs=require('fs');const has=['.eslintrc.json','.eslintrc.js','eslint.config.js'].some(f=>fs.existsSync(f));if(!has){fs.writeFileSync('.eslintrc.json',JSON.stringify({root:true,env:{es2021:true,node:true,browser:true},parserOptions:{ecmaVersion:2022,sourceType:'module'},ignorePatterns:['node_modules/','dist/','build/','coverage/'],rules:{curly:'off',eqeqeq:'off','no-undef':'off','no-unused-vars':'off'}},null,2));}"

- name: Run ESLint (non-blocking)
run: npx -y eslint@8.57.0 . --no-error-on-unmatched-pattern --quiet || true

test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: package-lock.json
- run: npm ci --prefer-offline --no-audit --no-fund


- name: Run fast tests (non-blocking)
shell: bash
run: |
set -e
if node -e "const s=(require('./package.json').scripts)||{};process.exit(s['test:unit']?0:1)"; then
npm run test:unit -- --ci || true
elif node -e "const p=require('./package.json');const hasJest=(p.devDependencies&&p.devDependencies.jest)||(p.dependencies&&p.dependencies.jest);process.exit(hasJest?0:1)"; then
npx jest --ci --passWithNoTests || true
else
npm test --silent || true
fi

openapi-validate:

runs-on: ubuntu-latest

steps:

- uses: actions/checkout@v4

- uses: actions/setup-node@v4

# 2. Setup Node.js with caching enabled
- name: Setup Node with cache
uses: actions/setup-node@v4
with:

node-version: ${{ env.NODE_VERSION }}

cache: npm

cache-dependency-path: package-lock.json

# 3. Install dependencies (reproducible and cache-friendly)
- name: Install dependencies
run: npm ci
- run: npm ci

- name: Install swagger-cli

run: npx -y swagger-cli@4.0.4 --version



- name: Validate OpenAPI (non-blocking)

run: |

REPORT=openapi-validate.log

if [ ! -f "${{ env.OPENAPI_FILE }}" ]; then

echo "::warning::OpenAPI file '${{ env.OPENAPI_FILE }}' not found at repo root. Skipping validation." | tee "$REPORT"

exit 0

fi

# Run validation, capture output; do not fail the step

npx swagger-cli validate "${{ env.OPENAPI_FILE }}" >"$REPORT" 2>&1 || {

echo "::warning::OpenAPI validation failed. See artifact '$REPORT' for details."

}

# Always succeed

exit 0

- name: Upload OpenAPI validation report

uses: actions/upload-artifact@v4

with:

name: openapi-validate-report

path: openapi-validate.log

if-no-files-found: ignore



run-security-scan:
runs-on: ubuntu-latest

Expand Down
61 changes: 60 additions & 1 deletion index.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,29 @@ paths:
/appointments:
post:
summary: Save appointment data
description: Receives a user ID, date, time, and description, and saves the appointment data
/upload:
post:
summary: Upload a file
description: Upload JPG, PNG, or PDF (max 5MB, limited to 5 uploads per 10 minutes)
security:
- BearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'200':
description: File uploaded successfully
'400':
description: Upload failed due to size/type restriction
'429':
description: Too many uploads from this IP (rate limit exceeded)
requestBody:
required: true
content:
Expand Down Expand Up @@ -694,6 +716,43 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'

/system/test-error/trigger:
post:
tags:
- System
summary: Trigger a simulated error for testing error logging
description: |-
This endpoint intentionally triggers an error so you can test the error logging middleware and verify entries are written to the Supabase `error_logs` table.
Use the `simulate` field in the request body to choose the behavior: `throw` (synchronous throw), `next` (pass to next), or omit for a delayed async error.
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
simulate:
type: string
example: throw
description: 'Options: "throw", "next" or omitted (delayed error)'
responses:
'200':
description: If the request unexpectedly succeeds
content:
application/json:
schema:
type: object
properties:
message:
type: string
example: Triggered error (this should not be returned)
'500':
description: Error triggered and handled by error logging middleware
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/fooddata/spicelevels:
get:
summary: Get spice levels
Expand Down
42 changes: 35 additions & 7 deletions middleware/authorizeRoles.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,64 @@
/**
* Role-based access control (RBAC) middleware
* Role-based access control (RBAC) middleware with violation logging
*/
const { createClient } = require('@supabase/supabase-js');

const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY // ✅ still using anon key
);

function authorizeRoles(...allowedRoles) {
return (req, res, next) => {
// Supabase JWTs: "role" is usually "authenticated" or "service_role"
// Custom JWTs: you explicitly set "role" in payload
return async (req, res, next) => {
const userRole = req.user?.role || req.user?.user_roles || null;

if (!userRole) {
await logViolation(req, userRole, "ROLE_MISSING");
return res.status(403).json({
success: false,
error: "Role missing in token",
code: "ROLE_MISSING"
});
}

// Normalize role value (lowercase string)
const roleValue = String(userRole).toLowerCase();

// Normalize allowed roles too
const normalizedAllowed = allowedRoles.map(r => r.toLowerCase());

if (!normalizedAllowed.includes(roleValue)) {
await logViolation(req, roleValue, "ACCESS_DENIED");
return res.status(403).json({
success: false,
error: "Access denied: insufficient role",
code: "ACCESS_DENIED"
});
}

// ✅ If role is allowed, continue
next();
};
}
//feature/rbac-extension
async function logViolation(req, role, status) {
const payload = {
user_id: req.user?.userId || "unknown",
email: req.user?.email || "unknown", // ✅ added email
role: role || "unknown",
endpoint: req.originalUrl,
method: req.method,
status
};

try {
const { error } = await supabase.from("rbac_violation_logs").insert([payload]);
if (error) {
console.error("❌ Supabase insert error:", error.message);
} else {
console.log("✅ RBAC violation logged:", payload);
}
} catch (err) {
console.error("❌ RBAC log exception:", err.message);
}
}

module.exports = authorizeRoles;

102 changes: 102 additions & 0 deletions middleware/errorLogger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// middleware/errorLogger.js
const errorLogService = require('../services/errorLogService');

/**
* Enhanced error logging middleware
*/
const errorLogger = (err, req, res, next) => {
// Automatically categorize errors
const classification = errorLogService.categorizeError(err, { req, res });

// Log the error
errorLogService.logError({
error: err,
req,
res,
category: classification.category,
type: classification.type,
additionalContext: {
route: req.route?.path,
middleware_stack: req.route?.stack?.map(s => s.handle.name),
query_params: req.query,
path_params: req.params
}
}).catch(loggingError => {
console.error('Error in error logging middleware:', loggingError);
});

next(err);
};

/**
* Request response time tracking middleware
*/
const responseTimeLogger = (req, res, next) => {
const startTime = Date.now();

// Capture response end event
res.on('finish', () => {
const responseTime = Date.now() - startTime;
res.responseTime = responseTime;

// Log slow requests
if (responseTime > 5000) {
errorLogService.logError({
error: new Error(`Slow request detected: ${responseTime}ms`),
req,
res,
category: 'warning',
type: 'performance',
additionalContext: {
response_time_ms: responseTime,
slow_request: true
}
});
}
});

next();
};

/**
* Uncaught exception handler
*/
const uncaughtExceptionHandler = (error) => {
errorLogService.logError({
error,
category: 'critical',
type: 'system',
additionalContext: {
uncaught_exception: true,
process_uptime: process.uptime()
}
});

console.error('Uncaught Exception:', error);
// Graceful shutdown
process.exit(1);
};

/**
* Unhandled Promise Rejection handler
*/
const unhandledRejectionHandler = (reason, promise) => {
errorLogService.logError({
error: new Error(`Unhandled Promise Rejection: ${reason}`),
category: 'critical',
type: 'system',
additionalContext: {
unhandled_rejection: true,
promise_state: promise
}
});

console.error('Unhandled Rejection:', reason);
};

module.exports = {
errorLogger,
responseTimeLogger,
uncaughtExceptionHandler,
unhandledRejectionHandler
};
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading