juice-shop
165 строк · 8.7 Кб
1/*
2* Copyright (c) 2014-2024 Bjoern Kimminich & the OWASP Juice Shop contributors.
3* SPDX-License-Identifier: MIT
4*/
5
6import config from 'config'
7import colors from 'colors/safe'
8import { retrieveCodeSnippet } from '../routes/vulnCodeSnippet'
9import { readFixes } from '../routes/vulnCodeFixes'
10import { type Challenge } from '../data/types'
11import { getCodeChallenges } from './codingChallenges'
12import logger from './logger'
13import { type NextFunction, type Request, type Response } from 'express'
14import * as utils from './utils'
15// @ts-expect-error FIXME due to non-existing type definitions for median
16import median from 'median'
17
18const coupledChallenges = { // TODO prevent also near-identical challenges (e.g. all null byte file access or dom xss + bonus payload etc.) from counting as cheating
19loginAdminChallenge: ['weakPasswordChallenge'],
20nullByteChallenge: ['easterEggLevelOneChallenge', 'forgottenDevBackupChallenge', 'forgottenBackupChallenge', 'misplacedSignatureFileChallenge'],
21deprecatedInterfaceChallenge: ['uploadTypeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge'],
22uploadSizeChallenge: ['uploadTypeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge'],
23uploadTypeChallenge: ['uploadSizeChallenge', 'xxeFileDisclosureChallenge', 'xxeDosChallenge']
24}
25const trivialChallenges = ['errorHandlingChallenge', 'privacyPolicyChallenge', 'closeNotificationsChallenge']
26
27const solves: Array<{ challenge: any, phase: string, timestamp: Date, cheatScore: number }> = [{ challenge: {}, phase: 'server start', timestamp: new Date(), cheatScore: 0 }] // seed with server start timestamp
28
29const preSolveInteractions: Array<{ challengeKey: any, urlFragments: string[], interactions: boolean[] }> = [
30{ challengeKey: 'missingEncodingChallenge', urlFragments: ['/assets/public/images/uploads/%F0%9F%98%BC-'], interactions: [false] },
31{ challengeKey: 'directoryListingChallenge', urlFragments: ['/ftp'], interactions: [false] },
32{ challengeKey: 'easterEggLevelOneChallenge', urlFragments: ['/ftp', '/ftp/eastere.gg'], interactions: [false, false] },
33{ challengeKey: 'easterEggLevelTwoChallenge', urlFragments: ['/ftp', '/gur/qrif/ner/fb/shaal/gurl/uvq/na/rnfgre/rtt/jvguva/gur/rnfgre/rtt'], interactions: [false, false] },
34{ challengeKey: 'forgottenDevBackupChallenge', urlFragments: ['/ftp', '/ftp/package.json.bak'], interactions: [false, false] },
35{ challengeKey: 'forgottenBackupChallenge', urlFragments: ['/ftp', '/ftp/coupons_2013.md.bak'], interactions: [false, false] },
36{ challengeKey: 'loginSupportChallenge', urlFragments: ['/ftp', '/ftp/incident-support.kdbx'], interactions: [false, false] },
37{ challengeKey: 'misplacedSignatureFileChallenge', urlFragments: ['/ftp', '/ftp/suspicious_errors.yml'], interactions: [false, false] },
38{ challengeKey: 'recChallenge', urlFragments: ['/api-docs', '/b2b/v2/orders'], interactions: [false, false] },
39{ challengeKey: 'rceOccupyChallenge', urlFragments: ['/api-docs', '/b2b/v2/orders'], interactions: [false, false] }
40]
41
42export const checkForPreSolveInteractions = () => ({ url }: Request, res: Response, next: NextFunction) => {
43preSolveInteractions.forEach((preSolveInteraction) => {
44for (let i = 0; i < preSolveInteraction.urlFragments.length; i++) {
45if (utils.endsWith(url, preSolveInteraction.urlFragments[i])) {
46preSolveInteraction.interactions[i] = true
47}
48}
49})
50next()
51}
52
53export const calculateCheatScore = (challenge: Challenge) => {
54const timestamp = new Date()
55let cheatScore = 0
56let timeFactor = 2
57timeFactor *= (config.get('challenges.showHints') ? 1 : 1.5)
58timeFactor *= (challenge.tutorialOrder && config.get('hackingInstructor.isEnabled') ? 0.5 : 1)
59if (areCoupled(challenge, previous().challenge) || isTrivial(challenge)) {
60timeFactor = 0
61}
62
63const minutesExpectedToSolve = challenge.difficulty * timeFactor
64const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
65cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))
66
67const preSolveInteraction = preSolveInteractions.find((preSolveInteraction) => preSolveInteraction.challengeKey === challenge.key)
68let percentPrecedingInteraction = -1
69if (preSolveInteraction) {
70percentPrecedingInteraction = preSolveInteraction.interactions.filter(Boolean).length / (preSolveInteraction.interactions.length)
71const multiplierForMissingExpectedInteraction = 1 + Math.max(0, 1 - percentPrecedingInteraction) / 2
72cheatScore *= multiplierForMissingExpectedInteraction
73cheatScore = Math.min(1, cheatScore)
74}
75
76logger.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()))}`)
77solves.push({ challenge, phase: 'hack it', timestamp, cheatScore })
78return cheatScore
79}
80
81export const calculateFindItCheatScore = async (challenge: Challenge) => {
82const timestamp = new Date()
83let timeFactor = 0.001
84timeFactor *= (challenge.key === 'scoreBoardChallenge' && config.get('hackingInstructor.isEnabled') ? 0.5 : 1)
85let cheatScore = 0
86
87const codeSnippet = await retrieveCodeSnippet(challenge.key)
88if (codeSnippet == null) {
89return 0
90}
91const { snippet, vulnLines } = codeSnippet
92timeFactor *= vulnLines.length
93const identicalSolved = await checkForIdenticalSolvedChallenge(challenge)
94if (identicalSolved) {
95timeFactor = 0.8 * timeFactor
96}
97const minutesExpectedToSolve = Math.ceil(snippet.length * timeFactor)
98const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
99cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))
100
101logger.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()))}`)
102solves.push({ challenge, phase: 'find it', timestamp, cheatScore })
103
104return cheatScore
105}
106
107export const calculateFixItCheatScore = async (challenge: Challenge) => {
108const timestamp = new Date()
109let cheatScore = 0
110
111const { fixes } = readFixes(challenge.key)
112const minutesExpectedToSolve = Math.floor(fixes.length / 2)
113const minutesSincePreviousSolve = (timestamp.getTime() - previous().timestamp.getTime()) / 60000
114cheatScore += Math.max(0, 1 - (minutesSincePreviousSolve / minutesExpectedToSolve))
115
116logger.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()))}`)
117solves.push({ challenge, phase: 'fix it', timestamp, cheatScore })
118return cheatScore
119}
120
121export const totalCheatScore = () => {
122return solves.length > 1 ? median(solves.map(({ cheatScore }) => cheatScore)) : 0
123}
124
125function areCoupled (challenge: Challenge, previousChallenge: Challenge) {
126// @ts-expect-error FIXME any type issues
127return coupledChallenges[challenge.key]?.indexOf(previousChallenge.key) > -1 || coupledChallenges[previousChallenge.key]?.indexOf(challenge.key) > -1
128}
129
130function isTrivial (challenge: Challenge) {
131return trivialChallenges.includes(challenge.key)
132}
133
134function previous () {
135return solves[solves.length - 1]
136}
137
138const checkForIdenticalSolvedChallenge = async (challenge: Challenge): Promise<boolean> => {
139const codingChallenges = await getCodeChallenges()
140if (!codingChallenges.has(challenge.key)) {
141return false
142}
143
144const codingChallengesToCompareTo = codingChallenges.get(challenge.key)
145if (!codingChallengesToCompareTo?.snippet) {
146return false
147}
148const snippetToCompareTo = codingChallengesToCompareTo.snippet
149
150for (const [challengeKey, { snippet }] of codingChallenges.entries()) {
151if (challengeKey === challenge.key) {
152// don't compare to itself
153continue
154}
155
156if (snippet === snippetToCompareTo) {
157for (const solvedChallenges of solves) {
158if (solvedChallenges.phase === 'find it') {
159return true
160}
161}
162}
163}
164return false
165}
166