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
47 changes: 47 additions & 0 deletions .github/workflows/env-secret-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ on:
pull_request:
paths:
- '.env*'
- 'scripts/**'
- '.github/workflows/**'
push:
branches: [main]
paths:
- '.env*'
- 'scripts/**'
- '.github/workflows/**'

jobs:
secret-scan:
Expand Down Expand Up @@ -58,3 +62,46 @@ jobs:
fi

echo "✅ No secrets found in tracked .env files."

script-secret-check:
name: Check scripts and workflows for secret variable logging
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Deny-list secret variable echo patterns
run: |
FOUND=0
# Patterns that would print a secret-bearing variable to stdout/logs
DENY_PATTERNS=(
'echo[[:space:]].*\$DATABASE_URL'
'echo[[:space:]].*\$REDIS_URL'
'echo[[:space:]].*\$SECRET'
'echo[[:space:]].*\$.*_KEY[^_]'
'echo[[:space:]].*\$.*_TOKEN[^_]'
'echo[[:space:]].*\$.*_PASSWORD'
'printf[[:space:]].*\$DATABASE_URL'
'env$'
'printenv$'
)

FILES=$(git ls-files 'scripts/*.sh' 'scripts/*.ts' '.github/workflows/*.yml')

for file in $FILES; do
for pattern in "${DENY_PATTERNS[@]}"; do
matches=$(grep -nE "$pattern" "$file" 2>/dev/null || true)
if [[ -n "$matches" ]]; then
echo "❌ Potential secret logging in $file:"
echo "$matches"
FOUND=1
fi
done
done

if [[ "$FOUND" -eq 1 ]]; then
echo ""
echo "Scripts must not echo secret-bearing variables. Use redacted forms (e.g. strip user:pass with \${VAR##*@})."
exit 1
fi

echo "✅ No secret variable logging found in scripts or workflows."
2 changes: 1 addition & 1 deletion scripts/rehearse-migration-rollback.sh
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ echo "╔═══════════════════════
echo "║ NeuroWealth — Migration Rollback Rehearsal ║"
echo "╠══════════════════════════════════════════════════════════════╣"
echo " Database : ${DB_TARGET}"
echo " Host : ${DATABASE_URL%%\?*}"
_db_safe="${DATABASE_URL##*@}"; echo " Host : ${_db_safe%%\?*}"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""

Expand Down
2 changes: 1 addition & 1 deletion scripts/rollback-migration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ echo "║ NeuroWealth — Migration Rollback ║"
echo "╠══════════════════════════════════════════════════════════════╣"
echo " Migration : ${MIGRATION}"
echo " Rollback : ${ROLLBACK_FILE}"
echo " Database : ${DATABASE_URL%%\?*}"
_db_safe="${DATABASE_URL##*@}"; echo " Database : ${_db_safe%%\?*}"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "WARNING: rollbacks can be destructive. Confirm a recent backup exists."
Expand Down
63 changes: 54 additions & 9 deletions src/controllers/admin-impersonation-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ import { EVENT_TYPE_TOPIC } from '../events/types'
export async function startImpersonation(
adminId: string,
adminName: string,
targetUserId: string
): Promise<{ success: boolean; token?: string; error?: string }> {
targetUserId: string,
options?: { ipAddress?: string; reason?: string; userAgent?: string }
): Promise<{ success: boolean; token?: string; sessionId?: string; error?: string }> {
try {
const targetUser = await db.user.findUnique({
where: { id: targetUserId },
Expand All @@ -22,11 +23,39 @@ export async function startImpersonation(
return { success: false, error: 'Target user not found' }
}

const token = generateImpersonationToken(adminId, adminName, targetUserId)
const token = generateImpersonationToken(adminId, adminName, targetUserId, {
ipAddress: options?.ipAddress,
reason: options?.reason,
})

logger.info(
`[AdminImpersonation] Admin ${adminName} (${adminId}) started impersonating user ${targetUserId}`
)
// Decode sessionId from generated token for audit record
const { sessionId } = JSON.parse(
Buffer.from(token.split('.')[1], 'base64url').toString()
) as { sessionId: string }

await (db as any).adminAuditLog.create({
data: {
action: 'impersonation.start',
target: targetUserId,
result: 'success',
details: {
adminName,
sessionId,
reason: options?.reason ?? null,
ttlMinutes: 30,
},
ipAddress: options?.ipAddress ?? null,
userAgent: options?.userAgent ?? null,
},
})

logger.info('[AdminImpersonation] Session started', {
adminId,
adminName,
targetUserId,
sessionId,
ipAddress: options?.ipAddress,
})

await publishUserEvent(
targetUserId,
Expand All @@ -35,11 +64,12 @@ export async function startImpersonation(
{
adminId,
adminName,
sessionId,
startedAt: new Date().toISOString(),
}
)

return { success: true, token }
return { success: true, token, sessionId }
} catch (error) {
logger.error('[AdminImpersonation] Failed to start impersonation', {
adminId,
Expand All @@ -51,10 +81,25 @@ export async function startImpersonation(
}

export async function endImpersonation(
sessionId: string
sessionId: string,
options?: { adminId?: string; targetUserId?: string; ipAddress?: string }
): Promise<{ success: boolean; error?: string }> {
try {
logger.info(`[AdminImpersonation] Impersonation session ${sessionId} ended`)
await (db as any).adminAuditLog.create({
data: {
action: 'impersonation.end',
target: options?.targetUserId ?? null,
result: 'success',
details: { sessionId },
ipAddress: options?.ipAddress ?? null,
},
})

logger.info('[AdminImpersonation] Session ended', {
sessionId,
adminId: options?.adminId,
targetUserId: options?.targetUserId,
})

return { success: true }
} catch (error) {
Expand Down
73 changes: 72 additions & 1 deletion src/middleware/impersonation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,33 @@ const IMPERSONATION_TTL_MINUTES = 30
const IMPERSONATION_SECRET =
process.env.IMPERSONATION_SECRET || 'impersonation-secret'

/**
* Routes where impersonation is never permitted regardless of admin role.
* Covers withdrawals, erasure, API key management, and treasury operations.
*/
const HIGH_RISK_PATH_PREFIXES = [
'/api/withdrawals',
'/api/wallet/withdraw',
'/api/users/me/erase',
'/api/users/delete',
'/api/api-keys',
'/api/treasury',
'/api/admin/treasury',
'/api/stellar/sign',
'/api/stellar/send',
'/api/mfa',
'/api/totp',
]

export interface ImpersonationContext {
adminId: string
adminName: string
targetUserId: string
sessionId: string
createdAt: Date
expiresAt: Date
ipAddress?: string
reason?: string
}

/**
Expand All @@ -23,7 +43,8 @@ export interface ImpersonationContext {
export function generateImpersonationToken(
adminId: string,
adminName: string,
targetUserId: string
targetUserId: string,
options?: { ipAddress?: string; reason?: string }
): string {
const sessionId = crypto.randomUUID()
const createdAt = new Date()
Expand All @@ -38,6 +59,8 @@ export function generateImpersonationToken(
sessionId,
createdAt,
expiresAt,
ipAddress: options?.ipAddress,
reason: options?.reason,
}

return jwt.sign(payload, IMPERSONATION_SECRET, {
Expand Down Expand Up @@ -85,6 +108,13 @@ export function requireNotImpersonation(
const impersonationContext = verifyImpersonationToken(token)

if (impersonationContext) {
logger.warn('[Impersonation] Write blocked during impersonation session', {
sessionId: impersonationContext.sessionId,
adminId: impersonationContext.adminId,
targetUserId: impersonationContext.targetUserId,
path: req.path,
method: req.method,
})
res.status(403).json({
success: false,
error: 'Write operations are not allowed during impersonation sessions',
Expand All @@ -96,6 +126,47 @@ export function requireNotImpersonation(
next()
}

/**
* Middleware that blocks impersonation tokens from accessing high-risk routes
* (withdrawals, erasure, API key management, treasury operations).
* Apply before any route handler on these paths.
*/
export function blockHighRiskImpersonation(
req: Request,
res: Response,
next: NextFunction
): void {
const authHeader = req.headers.authorization
if (typeof authHeader !== 'string' || !authHeader.startsWith('Bearer ')) {
return next()
}

const token = authHeader.slice(7).trim()
const impersonationContext = verifyImpersonationToken(token)

if (!impersonationContext) return next()

const isHighRisk = HIGH_RISK_PATH_PREFIXES.some((prefix) =>
req.path.startsWith(prefix)
)

if (isHighRisk) {
logger.warn('[Impersonation] High-risk route blocked for impersonation session', {
sessionId: impersonationContext.sessionId,
adminId: impersonationContext.adminId,
targetUserId: impersonationContext.targetUserId,
path: req.path,
})
res.status(403).json({
success: false,
error: 'This operation is not permitted during an impersonation session',
})
return
}

next()
}

/**
* Middleware to extract impersonation context from request.
*/
Expand Down
56 changes: 56 additions & 0 deletions src/routes/support-tickets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,50 @@ import {
} from '../validators/support-ticket-validators'

const router = Router()

const MAX_ATTACHMENT_REFS = 10

/**
* Verify that every ref in `attachmentRefs` is owned by `userId`.
* Rejects if any ref is foreign or the count exceeds the cap.
* NOTE: When a dedicated attachment upload endpoint is added it must also
* enforce MIME allow-list (image/*, application/pdf) and a per-file size cap
* (e.g. 10 MB). Admin download paths must serve with:
* Content-Disposition: attachment; filename="<name>"
* X-Content-Type-Options: nosniff
* and route through a scan/quarantine step before delivery.
*/
async function validateAttachmentRefs(
refs: string[] | undefined,
userId: string
): Promise<{ valid: boolean; error?: string }> {
if (!refs || refs.length === 0) return { valid: true }
if (refs.length > MAX_ATTACHMENT_REFS) {
return { valid: false, error: `Cannot attach more than ${MAX_ATTACHMENT_REFS} files per message` }
}
// Ownership check: each ref must be a TicketAttachment owned by this user.
// `attachmentRefs` stores UUIDs referencing pre-uploaded attachments scoped to the user.
const owned = await (db as any).ticketAttachment?.findMany?.({
where: { id: { in: refs }, userId },
select: { id: true },
}) as { id: string }[] | undefined

if (owned === undefined) {
// TicketAttachment model not yet deployed — skip ownership check but still
// accept the refs. Remove this branch once the model is in production.
return { valid: true }
}

const ownedIds = new Set(owned.map((a) => a.id))
const foreign = refs.filter((r) => !ownedIds.has(r))
if (foreign.length > 0) {
logger.warn('[SupportTicket] IDOR attempt: foreign attachment refs', { userId, foreign })
return { valid: false, error: 'One or more attachment refs are invalid or do not belong to you' }
}

return { valid: true }
}

const handle =
(fn: (req: Request, res: Response) => Promise<void>): RequestHandler =>
(req, res, next) => {
Expand Down Expand Up @@ -39,6 +83,12 @@ router.post(
req.body
const userId = req.auth!.userId

const refCheck = await validateAttachmentRefs(attachmentRefs, userId)
if (!refCheck.valid) {
res.status(400).json({ error: refCheck.error })
return
}

const result = await (db as any).$transaction(async (tx: any) => {
const ticket = await tx.supportTicket.create({
data: {
Expand Down Expand Up @@ -171,6 +221,12 @@ router.post(
return
}

const refCheck = await validateAttachmentRefs(attachmentRefs, userId)
if (!refCheck.valid) {
res.status(400).json({ error: refCheck.error })
return
}

const result = await (db as any).$transaction(async (tx: any) => {
const message = await tx.ticketMessage.create({
data: {
Expand Down
Loading