juice-shop/juice-shop

View on GitHub
lib/antiCheat.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
94%
/*
 * Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors.
 * SPDX-License-Identifier: MIT
 */

import config from 'config'
import colors from 'colors/safe'
import { retrieveCodeSnippet } from '../routes/vulnCodeSnippet'
import { readFixes } from '../routes/vulnCodeFixes'
import { type Challenge } from '../data/types'
import { getCodeChallenges } from './codingChallenges'
import logger from './logger'
import { type NextFunction, type Request, type Response } from 'express'
import * as utils from './utils'
// @ts-expect-error FIXME due to non-existing type definitions for median
import median from 'median'

const coupledChallenges = { // TODO prevent also near-identical challenges (e.g. all null byte file access or dom xss + bonus payload etc.) from counting as cheating
  loginAdminChallenge: ['weakPasswordChallenge'],
  nullByteChallenge: ['easterEggLevelOneChallenge', 'forgottenDevBackupChallenge', 'forgottenBackupChallenge', 'misplacedSignatureFileChallenge'],
  deprecatedInterfaceChallenge: ['uploadTypeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge'],
  uploadSizeChallenge: ['uploadTypeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge'],
  uploadTypeChallenge: ['uploadSizeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge']
}
const trivialChallenges = ['errorHandlingChallenge', 'privacyPolicyChallenge', 'closeNotificationsChallenge']

const solves: Array<{ challenge: any, phase: string, timestamp: Date, cheatScore: number }> = [{ challenge: {}, phase: 'server start', timestamp: new Date(), cheatScore: 0 }] // seed with server start timestamp

const preSolveInteractions: Array<{ challengeKey: any, urlFragments: string[], interactions: boolean[] }> = [
  { challengeKey: 'missingEncodingChallenge', urlFragments: ['/assets/public/images/uploads/%F0%9F%98%BC-'], interactions: [false] },
  { challengeKey: 'directoryListingChallenge', urlFragments: ['/ftp'], interactions: [false] },
  { challengeKey: 'easterEggLevelOneChallenge', urlFragments: ['/ftp', '/ftp/eastere.gg'], interactions: [false, false] },
  { challengeKey: 'easterEggLevelTwoChallenge', urlFragments: ['/ftp', '/gur/qrif/ner/fb/shaal/gurl/uvq/na/rnfgre/rtt/jvguva/gur/rnfgre/rtt'], interactions: [false, false] },
  { challengeKey: 'forgottenDevBackupChallenge', urlFragments: ['/ftp', '/ftp/package.json.bak'], interactions: [false, false] },
  { challengeKey: 'forgottenBackupChallenge', urlFragments: ['/ftp', '/ftp/coupons_2013.md.bak'], interactions: [false, false] },
  { challengeKey: 'loginSupportChallenge', urlFragments: ['/ftp', '/ftp/incident-support.kdbx'], interactions: [false, false] },
  { challengeKey: 'misplacedSignatureFileChallenge', urlFragments: ['/ftp', '/ftp/suspicious_errors.yml'], interactions: [false, false] },
  { challengeKey: 'recChallenge', urlFragments: ['/api-docs', '/b2b/v2/orders'], interactions: [false, false] },
  { challengeKey: 'rceOccupyChallenge', urlFragments: ['/api-docs', '/b2b/v2/orders'], interactions: [false, false] }
]

export const checkForPreSolveInteractions = () => ({ url }: Request, res: Response, next: NextFunction) => {
  preSolveInteractions.forEach((preSolveInteraction) => {
    for (let i = 0; i < preSolveInteraction.urlFragments.length; i++) {
      if (utils.endsWith(url, preSolveInteraction.urlFragments[i])) {
        preSolveInteraction.interactions[i] = true
      }
    }
  })
  next()
}

export const calculateCheatScore = (challenge: Challenge) => {
  const timestamp = new Date()
  let cheatScore = 0
  let timeFactor = 2
  timeFactor *= (config.get('challenges.showHints') ? 1 : 1.5)
  timeFactor *= (challenge.tutorialOrder && config.get('hackingInstructor.isEnabled') ? 0.5 : 1)
  if (areCoupled(challenge, previous().challenge) || isTrivial(challenge)) {
    timeFactor = 0
  }

  const minutesExpectedToSolve = challenge.difficulty * timeFactor
  const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
  cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))

  const preSolveInteraction = preSolveInteractions.find((preSolveInteraction) => preSolveInteraction.challengeKey === challenge.key)
  let percentPrecedingInteraction = -1
  if (preSolveInteraction) {
    percentPrecedingInteraction = preSolveInteraction.interactions.filter(Boolean).length / (preSolveInteraction.interactions.length)
    const multiplierForMissingExpectedInteraction = 1 + Math.max(0, 1 - percentPrecedingInteraction) / 2
    cheatScore *= multiplierForMissingExpectedInteraction
    cheatScore = Math.min(1, cheatScore)
  }

  logger.info(`Cheat score for ${areCoupled(challenge, previous().challenge) ? 'coupled ' : (isTrivial(challenge) ? 'trivial ' : '')}${challenge.tutorialOrder ? 'tutorial ' : ''}${colors.cyan(challenge.key)} solved in ${Math.round(minutesSincePreviousSolve)}min (expected ~${minutesExpectedToSolve}min) with${config.get('challenges.showHints') ? '' : 'out'} hints allowed${percentPrecedingInteraction > -1 ? (' and ' + percentPrecedingInteraction * 100 + '% expected preceding URL interaction') : ''}: ${cheatScore < 0.33 ? colors.green(cheatScore.toString()) : (cheatScore < 0.66 ? colors.yellow(cheatScore.toString()) : colors.red(cheatScore.toString()))}`)
  solves.push({ challenge, phase: 'hack it', timestamp, cheatScore })
  return cheatScore
}

export const calculateFindItCheatScore = async (challenge: Challenge) => {
  const timestamp = new Date()
  let timeFactor = 0.001
  timeFactor *= (challenge.key === 'scoreBoardChallenge' && config.get('hackingInstructor.isEnabled') ? 0.5 : 1)
  let cheatScore = 0

  const codeSnippet = await retrieveCodeSnippet(challenge.key)
  if (codeSnippet == null) {
    return 0
  }
  const { snippet, vulnLines } = codeSnippet
  timeFactor *= vulnLines.length
  const identicalSolved = await checkForIdenticalSolvedChallenge(challenge)
  if (identicalSolved) {
    timeFactor = 0.8 * timeFactor
  }
  const minutesExpectedToSolve = Math.ceil(snippet.length * timeFactor)
  const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
  cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))

  logger.info(`Cheat score for "Find it" phase of ${challenge.key === 'scoreBoardChallenge' && config.get('hackingInstructor.isEnabled') ? 'tutorial ' : ''}${colors.cyan(challenge.key)} solved in ${Math.round(minutesSincePreviousSolve)}min (expected ~${minutesExpectedToSolve}min): ${cheatScore < 0.33 ? colors.green(cheatScore.toString()) : (cheatScore < 0.66 ? colors.yellow(cheatScore.toString()) : colors.red(cheatScore.toString()))}`)
  solves.push({ challenge, phase: 'find it', timestamp, cheatScore })

  return cheatScore
}

export const calculateFixItCheatScore = async (challenge: Challenge) => {
  const timestamp = new Date()
  let cheatScore = 0

  const { fixes } = readFixes(challenge.key)
  const minutesExpectedToSolve = Math.floor(fixes.length / 2)
  const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
  cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))

  logger.info(`Cheat score for "Fix it" phase of ${colors.cyan(challenge.key)} solved in ${Math.round(minutesSincePreviousSolve)}min (expected ~${minutesExpectedToSolve}min): ${cheatScore < 0.33 ? colors.green(cheatScore.toString()) : (cheatScore < 0.66 ? colors.yellow(cheatScore.toString()) : colors.red(cheatScore.toString()))}`)
  solves.push({ challenge, phase: 'fix it', timestamp, cheatScore })
  return cheatScore
}

export const totalCheatScore = () => {
  return solves.length > 1 ? median(solves.map(({ cheatScore }) => cheatScore)) : 0
}

function areCoupled (challenge: Challenge, previousChallenge: Challenge) {
  // @ts-expect-error FIXME any type issues
  return coupledChallenges[challenge.key]?.indexOf(previousChallenge.key) > -1 || coupledChallenges[previousChallenge.key]?.indexOf(challenge.key) > -1
}

function isTrivial (challenge: Challenge) {
  return trivialChallenges.includes(challenge.key)
}

function previous () {
  return solves[solves.length - 1]
}

const checkForIdenticalSolvedChallenge = async (challenge: Challenge): Promise<boolean> => {
  const codingChallenges = await getCodeChallenges()
  if (!codingChallenges.has(challenge.key)) {
    return false
  }

  const codingChallengesToCompareTo = codingChallenges.get(challenge.key)
  if (!codingChallengesToCompareTo?.snippet) {
    return false
  }
  const snippetToCompareTo = codingChallengesToCompareTo.snippet

  for (const [challengeKey, { snippet }] of codingChallenges.entries()) {
    if (challengeKey === challenge.key) {
      // don't compare to itself
      continue
    }

    if (snippet === snippetToCompareTo) {
      for (const solvedChallenges of solves) {
        if (solvedChallenges.phase === 'find it') {
          return true
        }
      }
    }
  }
  return false
}