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
19 changes: 19 additions & 0 deletions .github/workflows/vitest-supertest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ on:
branches: [ dev, main ]

jobs:
security-audit:
name: Security audit (high/critical)
timeout-minutes: 10
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run npm audit policy
run: npx --yes audit-ci@7 --config security/audit-ci.json

test:
timeout-minutes: 20
runs-on: ubuntu-latest
Expand Down
16 changes: 16 additions & 0 deletions security/AUDIT_EXCEPTIONS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Excepciones de npm audit

Este archivo documenta las excepciones permitidas para el control de seguridad en CI.

- Política activa: el pipeline falla con vulnerabilidades `high` o `critical`.
- Estado actual: no hay excepciones aprobadas.

Si en el futuro se requiere una excepción, debe:

1. Agregarse en `security/audit-ci.json` dentro de `allowlist`.
2. Documentarse aquí con:
- advisory/module afectado
- justificación
- fecha de aprobación
- fecha de expiración
- responsable
5 changes: 5 additions & 0 deletions security/audit-ci.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json",
"high": true,
"allowlist": []
}
2 changes: 2 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import cartRoutes from "./modules/users/cart/cart.routes.js";
import { orderRouter, userOrderRouter } from "./modules/users/orders/order.routes.js";

import { errorHandler } from "./middlewares/errorHandler.js";
import { securityResponseLogger } from "./middlewares/security-log.middleware.js";
import { NotFoundError } from "./lib/errors.js";

import { setupSwagger } from "./config/swagger.config.js";
Expand Down Expand Up @@ -76,6 +77,7 @@ app.use((req, res, next) => {
app.use(morgan('dev'));
app.use(express.json());
app.use(cookieParser());
app.use(securityResponseLogger);

const allowedOrigins = process.env.CORS_ORIGIN
? process.env.CORS_ORIGIN.split(',').map(o => o.trim())
Expand Down
147 changes: 147 additions & 0 deletions src/lib/security-logger.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
const SENSITIVE_KEY_NAMES = [
"password",
"password_hash",
"token",
"user_token",
"authorization",
"cookie",
"cookies",
"secret",
"jwt",
"refresh_token",
"access_token",
];

const JWT_PATTERN = /eyJ[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]*/g;
const BEARER_PATTERN = /Bearer\s+\S+/gi;
const CONTROL_CHARS_PATTERN = /[\u0000-\u001F\u007F]/g;
const MAX_LOG_LINE_LENGTH = 8192;

/**
* Normaliza nombres de clave (camelCase, kebab-case) a snake_case en minúsculas.
*/
export function normalizeKey(key) {
if (typeof key !== "string") return "";
return key
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/-/g, "_")

Check warning on line 27 in src/lib/security-logger.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `String#replaceAll()` over `String#replace()`.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ6IWrvq--9EgTHUa_85&open=AZ6IWrvq--9EgTHUa_85&pullRequest=189
.toLowerCase();
}

const SENSITIVE_KEYS = new Set(SENSITIVE_KEY_NAMES.map(normalizeKey));

function isSensitiveKey(key) {
return SENSITIVE_KEYS.has(normalizeKey(key));
}

function sanitizeLogString(str) {
return str
.replace(CONTROL_CHARS_PATTERN, "")
.replace(JWT_PATTERN, "[REDACTED_JWT]")
.replace(BEARER_PATTERN, "Bearer [REDACTED]");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Elimina datos sensibles antes de escribir en logs (tokens, passwords, etc.).
*/
export function sanitizeForLog(value, depth = 0) {
if (depth > 6) return "[MaxDepth]";
if (value == null) return value;

if (typeof value === "string") {
return sanitizeLogString(value);
}

if (typeof value === "number" || typeof value === "boolean") {
return value;
}

if (Array.isArray(value)) {
return value.map((item) => sanitizeForLog(item, depth + 1));
}

if (typeof value === "object") {
const sanitized = {};
for (const [key, val] of Object.entries(value)) {
if (isSensitiveKey(key)) {
sanitized[key] = "[REDACTED]";
} else {
sanitized[key] = sanitizeForLog(val, depth + 1);
}
}
return sanitized;
}

return String(value);
}

function stripReservedDetailKeys(details) {
if (details == null || typeof details !== "object" || Array.isArray(details)) {
return {};
}

const safe = { ...details };
delete safe.event;
delete safe.timestamp;
return safe;
}

/**
* Construye la línea JSON del evento tras sanitizar y validar el contenido.
*/
export function buildSecurityLogLine(eventType, details = {}) {
const payload = sanitizeForLog({
...stripReservedDetailKeys(details),
timestamp: new Date().toISOString(),
event: eventType,
});

return JSON.stringify(payload);
}

function isValidSecurityLogLine(line) {
if (typeof line !== "string" || line.length === 0 || line.length > MAX_LOG_LINE_LENGTH) {
return false;
}

if (CONTROL_CHARS_PATTERN.test(line)) {
return false;
}

try {
JSON.parse(line);
return true;
} catch {
return false;
}
}

function writeSecurityLogLine(line) {
process.stdout.write(`[SECURITY] ${line}\n`);
}

/**
* Registra un evento de seguridad en stdout (JSON). Nunca incluir passwords ni tokens.
* No lanza errores: fallos de logging no afectan el flujo de negocio.
*/
export function logSecurityEvent(eventType, details = {}) {
try {
if (typeof eventType !== "string" || eventType.length === 0) {
return;
}

const line = buildSecurityLogLine(eventType, details);
if (!isValidSecurityLogLine(line)) {
console.error("[SECURITY] Discarded invalid log line for event:", eventType);
return;
}

writeSecurityLogLine(line);
} catch (err) {
console.error(
"[SECURITY] Failed to log security event:",
eventType,
err?.message ?? err
);
}
}
23 changes: 23 additions & 0 deletions src/middlewares/security-log.middleware.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { logSecurityEvent } from "../lib/security-logger.js";

/**
* Registra respuestas HTTP 403 al finalizar la petición (cubre AppError y errores legacy en controllers).
*/
export const securityResponseLogger = (req, res, next) => {
res.on("finish", () => {
if (res.statusCode !== 403) return;

try {
logSecurityEvent("FORBIDDEN", {
method: req.method,
path: req.originalUrl || req.path,
userId: req.user?.id_user ?? null,
role: req.user?.role ?? null,
});
} catch (err) {
console.error("[SECURITY] Failed to log FORBIDDEN event:", err?.message ?? err);
}
});

next();
};
4 changes: 3 additions & 1 deletion src/middlewares/validate.middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
}

if (section === "query") {
req.validated = result.data;
req.validated = { ...(req.validated ?? {}), ...result.data };

Check warning on line 23 in src/middlewares/validate.middleware.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The empty object is useless.

See more on https://sonarcloud.io/project/issues?id=CrisNAC_BackendMarketplace&issues=AZ6QN6TPnnu90AjfWlju&open=AZ6QN6TPnnu90AjfWlju&pullRequest=189
} else if (section === "params") {
req.params = { ...req.params, ...result.data };
} else {
req[section] = result.data;
}
Expand Down
Loading
Loading