Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
116 changes: 68 additions & 48 deletions .github/workflows/security-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@ name: Dependency Security Scanner

on:
push:
branches: [main, master, develop]
branches:
- main
- master
- develop
pull_request:
branches: [main, master, develop]
branches:
- main
- master
- develop
schedule:
# Runs daily at 02:00 UTC (07:30 IST)
- cron: '0 2 * * *'
- cron: "0 2 * * *"
workflow_dispatch:

permissions:
Expand All @@ -17,7 +22,6 @@ permissions:
pull-requests: write

jobs:
# ── 1. npm audit ────────────────────────────────────────────────────────────
npm-audit:
name: npm Audit
runs-on: ubuntu-latest
Expand All @@ -28,8 +32,9 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: npm
cache-dependency-path: backend/package-lock.json

- name: Install dependencies
working-directory: ./backend
Expand All @@ -41,88 +46,94 @@ jobs:
run: |
npm audit --json > npm-audit-report.json || true
npm audit --audit-level=high 2>&1 | tee npm-audit-summary.txt || AUDIT_EXIT=$?
echo "exit_code=${AUDIT_EXIT:-0}" >> $GITHUB_OUTPUT
echo "exit_code=${AUDIT_EXIT:-0}" >> "$GITHUB_OUTPUT"

- name: Upload npm audit report
uses: actions/upload-artifact@v4
if: always()
uses: actions/upload-artifact@v4
with:
name: npm-audit-report
path: |
npm-audit-report.json
npm-audit-summary.txt
backend/npm-audit-report.json
backend/npm-audit-summary.txt
retention-days: 30

- name: Fail on high/critical vulnerabilities
- name: Fail on high or critical vulnerabilities
if: steps.npm_audit.outputs.exit_code != '0'
working-directory: ./backend
run: |
echo "::error::High or critical vulnerabilities found. See npm-audit-report artifact."
cat npm-audit-summary.txt
exit 1

# ── 2. Snyk vulnerability scan ──────────────────────────────────────────────
snyk:
name: Snyk Vulnerability Scan
runs-on: ubuntu-latest
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
steps:
- name: Checkout repository
if: env.SNYK_TOKEN != ''
uses: actions/checkout@v4

- name: Set up Node.js
if: env.SNYK_TOKEN != ''
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
node-version: "20"
cache: npm
cache-dependency-path: backend/package-lock.json

- name: Install dependencies
if: env.SNYK_TOKEN != ''
working-directory: ./backend
run: npm install --legacy-peer-deps --prefer-offline

- name: Run Snyk to check for vulnerabilities
working-directory: ./backend
if: env.SNYK_TOKEN != ''
uses: snyk/actions/node@master
continue-on-error: true # upload SARIF even if vulns found
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
continue-on-error: true
with:
args: >
--file=backend/package.json
--severity-threshold=high
--sarif-file-output=snyk-results.sarif
--json-file-output=snyk-results.json

- name: Upload Snyk SARIF to GitHub Security tab
if: env.SNYK_TOKEN != '' && always() && hashFiles('snyk-results.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
if: always() && hashFiles('snyk-results.sarif') != ''
with:
sarif_file: snyk-results.sarif
category: snyk

- name: Upload Snyk JSON report
if: env.SNYK_TOKEN != '' && always() && hashFiles('snyk-results.json') != ''
uses: actions/upload-artifact@v4
if: always() && hashFiles('snyk-results.json') != ''
with:
name: snyk-report
path: snyk-results.json
retention-days: 30

# ── 3. GitHub Dependency Review (PRs only) ──────────────────────────────────
- name: Skip Snyk scan when token is unavailable
if: env.SNYK_TOKEN == ''
run: echo "SNYK_TOKEN is not configured for this repository. Skipping Snyk scan."
Comment on lines +120 to +122

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Snyk job never reports skipped; summary will show "Passed" when the token is unset.

The snyk job has no job-level if, so when SNYK_TOKEN is empty the real steps skip individually but this echo step still runs and the job completes with result success. Consequently needs.snyk.result is never 'skipped', making the 'Skipped' branches in the summary table (Line 179) and PR comment (Lines 197-198) unreachable — the report will read "Passed" even though Snyk never ran. Gate the whole job instead so it is actually skipped.

🔧 Skip at the job level so the result is `skipped`
   snyk:
     name: Snyk Vulnerability Scan
     runs-on: ubuntu-latest
+    if: ${{ vars.SNYK_ENABLED == 'true' }}  # or another job-level gate; secrets can't be read in job-level if
     env:
       SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}

Note: secrets cannot be referenced directly in a job-level if, so route the toggle through a repo/org variable (or a preceding job output) if you want a true skipped result.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/security-scan.yml around lines 118 - 120, The Snyk
workflow currently only skips individual steps, so the job still ends as success
and never produces a true skipped result. Add a job-level condition on the snyk
job itself, using a repo/org variable or a preceding job output to gate
execution when the token is unavailable, and remove reliance on the “Skip Snyk
scan when token is unavailable” step for status. This should make
needs.snyk.result resolve to skipped so the summary table and PR comment
branches that check for Skipped become reachable and accurate.


dependency-review:
name: GitHub Dependency Review
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
if: github.event_name == 'pull_request' && !github.event.repository.fork

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

Fork guard condition is ineffective for PRs from forks

-    if: github.event_name == 'pull_request' && !github.event.repository.fork
+    if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository

Alternatively, use !github.event.pull_request.head.repo.fork for a direct boolean check.

github.event.repository refers to the base repository, which is not flagged as a fork, so the check passes for PRs originating from external forks. This allows the job to run in environments with restricted GITHUB_TOKEN permissions or missing secrets.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if: github.event_name == 'pull_request' && !github.event.repository.fork
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/security-scan.yml at line 125, The fork protection check
in the workflow job condition is using the base repository flag, so it does not
correctly block pull requests from external forks. Update the conditional on the
relevant job in security-scan.yml to use the pull request head repository fork
boolean instead, keeping the rest of the `github.event_name == 'pull_request'`
logic intact so the guard reliably skips forked PRs.

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Dependency Review
- name: Dependency review
uses: actions/dependency-review-action@v4
with:
fail-on-severity: high
comment-summary-in-pr: always
# Deny packages with known-bad licenses
deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0

# ── 4. OSSAR / OSV Scanner ──────────────────────────────────────────────────
osv-scan:
name: OSV Vulnerability Scan
runs-on: ubuntu-latest
Expand All @@ -140,66 +151,75 @@ jobs:
./

- name: Upload OSV SARIF to GitHub Security tab
if: always() && hashFiles('osv-results.sarif') != ''
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: osv-results.sarif
category: osv-scanner

# ── 5. Aggregate & notify ───────────────────────────────────────────────────
summary:
name: Security Scan Summary
runs-on: ubuntu-latest
needs: [npm-audit, snyk, osv-scan]
if: always()
steps:
- name: Download all artifacts
if: always()
uses: actions/download-artifact@v4
with:
path: scan-results/

- name: Generate summary
run: |
echo "## 🔐 Dependency Security Scan Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Scanner | Status |" >> $GITHUB_STEP_SUMMARY
echo "|---------------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| npm audit | ${{ needs.npm-audit.result == 'success' && 'Passed' || 'Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk | ${{ needs.snyk.result == 'success' && 'Passed' || '⚠️ Issues found' }} |" >> $GITHUB_STEP_SUMMARY
echo "| OSV Scanner | ${{ needs.osv-scan.result == 'success' && 'Passed' || '⚠️ Issues found' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "_Scan triggered by: \`${{ github.event_name }}\` on \`${{ github.ref_name }}\`_" >> $GITHUB_STEP_SUMMARY
echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> $GITHUB_STEP_SUMMARY
echo "## Dependency Security Scan Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Scanner | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|---|---|" >> "$GITHUB_STEP_SUMMARY"
echo "| npm audit | ${{ needs.npm-audit.result == 'success' && 'Passed' || 'Failed' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Snyk | ${{ needs.snyk.result == 'success' && 'Passed' || needs.snyk.result == 'skipped' && 'Skipped' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| OSV Scanner | ${{ needs.osv-scan.result == 'success' && 'Passed' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "_Scan triggered by: \`${{ github.event_name }}\` on \`${{ github.ref_name }}\`_" >> "$GITHUB_STEP_SUMMARY"
echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> "$GITHUB_STEP_SUMMARY"
Comment on lines 174 to +185

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Template injection via github.ref_name in the run block.

${{ github.ref_name }} is expanded into the shell script before execution. Branch/tag names are attacker-controllable (git refs permit characters such as `, $, (, ), ;, |), so a crafted ref name can inject commands into this step and access the job's GITHUB_TOKEN/secrets. Pass the value through an env variable and reference it as a quoted shell variable. (github.event_name is from a fixed set and is safe.)

🔒 Use an env intermediary
       - name: Generate summary
+        env:
+          EVENT_NAME: ${{ github.event_name }}
+          REF_NAME: ${{ github.ref_name }}
         run: |
           echo "## Dependency Security Scan Summary" >> "$GITHUB_STEP_SUMMARY"
           ...
-          echo "_Scan triggered by: \`${{ github.event_name }}\` on \`${{ github.ref_name }}\`_" >> "$GITHUB_STEP_SUMMARY"
+          echo "_Scan triggered by: \`${EVENT_NAME}\` on \`${REF_NAME}\`_" >> "$GITHUB_STEP_SUMMARY"
           echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> "$GITHUB_STEP_SUMMARY"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- name: Generate summary
run: |
echo "## 🔐 Dependency Security Scan Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Scanner | Status |" >> $GITHUB_STEP_SUMMARY
echo "|---------------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| npm audit | ${{ needs.npm-audit.result == 'success' && '✅ Passed' || '❌ Failed' }} |" >> $GITHUB_STEP_SUMMARY
echo "| Snyk | ${{ needs.snyk.result == 'success' && '✅ Passed' || '⚠️ Issues found' }} |" >> $GITHUB_STEP_SUMMARY
echo "| OSV Scanner | ${{ needs.osv-scan.result == 'success' && '✅ Passed' || '⚠️ Issues found' }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "_Scan triggered by: \`${{ github.event_name }}\` on \`${{ github.ref_name }}\`_" >> $GITHUB_STEP_SUMMARY
echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> $GITHUB_STEP_SUMMARY
echo "## Dependency Security Scan Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Scanner | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|---|---|" >> "$GITHUB_STEP_SUMMARY"
echo "| npm audit | ${{ needs.npm-audit.result == 'success' && 'Passed' || 'Failed' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Snyk | ${{ needs.snyk.result == 'success' && 'Passed' || needs.snyk.result == 'skipped' && 'Skipped' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| OSV Scanner | ${{ needs.osv-scan.result == 'success' && 'Passed' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "_Scan triggered by: \`${{ github.event_name }}\` on \`${{ github.ref_name }}\`_" >> "$GITHUB_STEP_SUMMARY"
echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> "$GITHUB_STEP_SUMMARY"
- name: Generate summary
env:
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref_name }}
run: |
echo "## Dependency Security Scan Summary" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Scanner | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|---|---|" >> "$GITHUB_STEP_SUMMARY"
echo "| npm audit | ${{ needs.npm-audit.result == 'success' && 'Passed' || 'Failed' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| Snyk | ${{ needs.snyk.result == 'success' && 'Passed' || needs.snyk.result == 'skipped' && 'Skipped' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "| OSV Scanner | ${{ needs.osv-scan.result == 'success' && 'Passed' || 'Issues found' }} |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "_Scan triggered by: \`${EVENT_NAME}\` on \`${REF_NAME}\`_" >> "$GITHUB_STEP_SUMMARY"
echo "_Run time: $(date -u '+%Y-%m-%d %H:%M UTC')_" >> "$GITHUB_STEP_SUMMARY"
🧰 Tools
🪛 zizmor (1.26.1)

[error] 182-182: code injection via template expansion (template-injection): may expand into attacker-controllable code

(template-injection)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/security-scan.yml around lines 172 - 183, The Generate
summary step is vulnerable because github.ref_name is interpolated directly into
the shell script, allowing command injection from attacker-controlled refs. Move
github.ref_name into an env variable for this step and use the quoted shell
variable inside the run block, keeping the existing github.event_name usage
as-is. Update the Generate summary block in security-scan.yml so the ref name is
never expanded inline in the script.

Source: Linters/SAST tools


- name: Post PR comment with summary
if: github.event_name == 'pull_request'
continue-on-error: true
uses: actions/github-script@v7
with:
script: |
const npmStatus = '${{ needs.npm-audit.result }}' === 'success' ? '✅ Passed' : '❌ Failed';
const snykStatus = '${{ needs.snyk.result }}' === 'success' ? '✅ Passed' : '⚠️ Issues found';
const osvStatus = '${{ needs.osv-scan.result }}' === 'success' ? '✅ Passed' : '⚠️ Issues found';

const body = `## 🔐 Dependency Security Scan Results
const npmStatus = '${{ needs.npm-audit.result }}' === 'success'
? 'Passed'
: 'Failed';
const snykResult = '${{ needs.snyk.result }}';
const snykStatus = snykResult === 'success'
? 'Passed'
: snykResult === 'skipped'
? 'Skipped'
: 'Issues found';
const osvStatus = '${{ needs.osv-scan.result }}' === 'success'
? 'Passed'
: 'Issues found';

const body = `## Dependency Security Scan Results

| Scanner | Status |
|---|---|
| npm audit | ${npmStatus} |
| Snyk | ${snykStatus} |
| OSV Scanner | ${osvStatus} |

> Full reports are available in the [Actions artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and the **Security** tab.`;
> Full reports are available in the [Actions artifacts](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and the Security tab.`;

github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body
body,
});

- name: Fail pipeline if critical scan failed
- name: Fail pipeline if npm audit failed
if: needs.npm-audit.result == 'failure'
run: |
echo "::error::npm audit detected high/critical vulnerabilities."
exit 1
echo "::error::npm audit detected high or critical vulnerabilities."
exit 1
101 changes: 101 additions & 0 deletions backend/src/routes/__tests__/twoFactor.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { ApiError } from '../../middleware/errorHandler.js';
import router, { verifyLoginChallenge } from '../twoFactor.js';

describe('twoFactor routes', () => {
test('registers the verify-login compatibility route', () => {
const verifyLoginRoute = router.stack.find(
(layer) => layer.route?.path === '/verify-login' && layer.route.methods?.post
);

assert.ok(verifyLoginRoute, 'expected POST /verify-login route to be registered');
});
});

describe('verifyLoginChallenge', () => {
test('uses TOTP verification by default', async () => {
const calls = [];
const req = {
user: { uid: 'user-123' },
body: { token: '123456' },
};
const res = {
payload: null,
json(body) {
this.payload = body;
},
};

await verifyLoginChallenge(req, res, {
async verifyTotp(uid, token) {
calls.push(['totp', uid, token]);
return true;
},
async verifyBackupCode() {
calls.push(['backup']);
return true;
},
});

assert.deepEqual(calls, [['totp', 'user-123', '123456']]);
assert.deepEqual(res.payload, { success: true });
});

test('uses backup-code verification when requested', async () => {
const calls = [];
const req = {
user: { uid: 'user-456' },
body: { token: 'ABCD-1234', useBackup: true },
};
const res = {
payload: null,
json(body) {
this.payload = body;
},
};

await verifyLoginChallenge(req, res, {
async verifyTotp() {
calls.push(['totp']);
return true;
},
async verifyBackupCode(uid, code) {
calls.push(['backup', uid, code]);
return true;
},
});

assert.deepEqual(calls, [['backup', 'user-456', 'ABCD-1234']]);
assert.deepEqual(res.payload, { success: true });
});

test('throws the same backup-code error message on invalid backup code', async () => {
const req = {
user: { uid: 'user-789' },
body: { token: 'BAD-CODE', useBackup: true },
};
const res = {
json() {
assert.fail('res.json should not be called on invalid backup code');
},
};

await assert.rejects(
verifyLoginChallenge(req, res, {
async verifyTotp() {
return true;
},
async verifyBackupCode() {
return false;
},
}),
(error) => {
assert.ok(error instanceof ApiError);
assert.equal(error.statusCode, 401);
assert.equal(error.message, 'Invalid backup code');
return true;
}
);
});
});
20 changes: 20 additions & 0 deletions backend/src/routes/twoFactor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,24 @@ import {
enable2FASchema,
tokenOnlySchema,
backupCodeSchema,
verifyLoginSchema,
} from '../schemas/twoFactor.schema.js';

const router = express.Router();

export const verifyLoginChallenge = async (req, res, deps = twoFactor) => {
const { token, useBackup = false } = req.body;
const ok = useBackup
? await deps.verifyBackupCode(req.user.uid, token)
: await deps.verifyTotp(req.user.uid, token);

if (!ok) {
throw new ApiError(401, useBackup ? 'Invalid backup code' : 'Invalid or expired code');
}

res.json({ success: true });
};

// Strict rate limit for code-verification endpoints to prevent brute force
const verifyLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
Expand Down Expand Up @@ -88,6 +102,12 @@ router.post('/verify-backup', verifyToken, verifyLimiter, validate(backupCodeSch
res.json({ success: true });
}));

// POST /api/auth/2fa/verify-login (rate-limited)
// Backwards-compatible login endpoint used by older frontend code paths.
router.post('/verify-login', verifyToken, verifyLimiter, validate(verifyLoginSchema), asyncHandler(async (req, res) => {
await verifyLoginChallenge(req, res);
}));

// POST /api/auth/2fa/backup-codes/regenerate
// Generates a fresh set of backup codes after verifying the current TOTP.
router.post('/backup-codes/regenerate', verifyToken, validate(tokenOnlySchema), asyncHandler(async (req, res) => {
Expand Down
Loading