diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleApplication.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleApplication.kt new file mode 100644 index 0000000000..75287a6830 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleApplication.kt @@ -0,0 +1,42 @@ +package com.foo.rest.examples.spring.openapi.v3.security.hiddenaccessible + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + + +@SpringBootApplication(exclude = [SecurityAutoConfiguration::class]) +@RequestMapping(path = ["/api"]) +@RestController +open class HiddenAccessibleApplication { + + companion object { + @JvmStatic + fun main(args: Array) { + SpringApplication.run(HiddenAccessibleApplication::class.java, *args) + } + } + + @PostMapping(path = ["/resources"]) + open fun post(): ResponseEntity { + return ResponseEntity.status(200).body("OK") + } + + @GetMapping(path = ["/resources"]) + open fun get(): ResponseEntity { + return ResponseEntity.status(200).body("OK") + } + + @GetMapping(path = ["/resources/{id}"]) + open fun getId(@PathVariable("id") id: Int): ResponseEntity { + return ResponseEntity.status(200).body("OK") + } + + @DeleteMapping(path = ["/resources/{id}"]) + open fun deleteId(@PathVariable("id") id: Int): ResponseEntity { + return ResponseEntity.status(200).body("OK") + } + +} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/resources/static/openapi-hiddenaccessible.yaml b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/resources/static/openapi-hiddenaccessible.yaml new file mode 100644 index 0000000000..9324e12a3d --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/main/resources/static/openapi-hiddenaccessible.yaml @@ -0,0 +1,69 @@ +openapi: 3.0.1 +info: + title: OpenAPI definition + version: v0 +servers: +- url: http://localhost:8080 + description: Generated server url +paths: + /api/resources: +# get: +# tags: +# - hidden-accessible-application +# operationId: get +# responses: +# "200": +# description: OK +# content: +# '*/*': +# schema: +# type: string + post: + tags: + - hidden-accessible-application + operationId: post + responses: + "200": + description: OK + content: + '*/*': + schema: + type: string + /api/resources/{id}: + get: + tags: + - hidden-accessible-application + operationId: getId + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int32 + responses: + "200": + description: OK + content: + '*/*': + schema: + type: string +# delete: +# tags: +# - hidden-accessible-application +# operationId: deleteId +# parameters: +# - name: id +# in: path +# required: true +# schema: +# type: integer +# format: int32 +# responses: +# "200": +# description: OK +# content: +# '*/*': +# schema: +# type: string +components: {} diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleController.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleController.kt new file mode 100644 index 0000000000..b5bb235e5d --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/com/foo/rest/examples/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleController.kt @@ -0,0 +1,16 @@ +package com.foo.rest.examples.spring.openapi.v3.security.hiddenaccessible + +import com.foo.rest.examples.spring.openapi.v3.SpringController +import org.evomaster.client.java.controller.problem.ProblemInfo +import org.evomaster.client.java.controller.problem.RestProblem + +class HiddenAccessibleController : SpringController(HiddenAccessibleApplication::class.java){ + + + override fun getProblemInfo(): ProblemInfo { + return RestProblem( + "http://localhost:$sutPort/openapi-hiddenaccessible.yaml", + null + ) + } +} \ No newline at end of file diff --git a/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleEMTest.kt b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleEMTest.kt new file mode 100644 index 0000000000..71c08d1d06 --- /dev/null +++ b/core-tests/e2e-tests/spring/spring-rest-openapi-v3/src/test/kotlin/org/evomaster/e2etests/spring/openapi/v3/security/hiddenaccessible/HiddenAccessibleEMTest.kt @@ -0,0 +1,56 @@ +package org.evomaster.e2etests.spring.openapi.v3.security.hiddenaccessible + +import com.foo.rest.examples.spring.openapi.v3.security.hiddenaccessible.HiddenAccessibleController +import org.evomaster.core.problem.enterprise.DetectedFaultUtils +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory +import org.evomaster.core.problem.rest.data.HttpVerb +import org.evomaster.e2etests.spring.openapi.v3.SpringTestBase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertNotNull + +class HiddenAccessibleEMTest : SpringTestBase(){ + + companion object { + @BeforeAll + @JvmStatic + fun init() { + initClass(HiddenAccessibleController()) + } + } + + + @Test + fun testRunEM() { + + runTestHandlingFlakyAndCompilation( + "HiddenAccessibleEM", + 20 + ) { args: MutableList -> + + setOption(args, "security", "true") + setOption(args, "useExperimentalOracles", "true") + + val solution = initAndRun(args) + + assertTrue(solution.individuals.size >= 1) + + assertHasAtLeastOne(solution, HttpVerb.POST, 200, "/api/resources", "OK") + assertHasAtLeastOne(solution, HttpVerb.GET, 200, "/api/resources/{id}", "OK") + assertHasAtLeastOne(solution, HttpVerb.OPTIONS, 200, "/api/resources", null) + assertHasAtLeastOne(solution, HttpVerb.OPTIONS, 200, "/api/resources/{id}", null) + + + val faults = DetectedFaultUtils.getDetectedFaults(solution) + assertTrue(faults.size >= 2) + + val hidden = faults.filter{it.category == ExperimentalFaultCategory.HIDDEN_ACCESSIBLE_ENDPOINT} + assertEquals(2, hidden.size) + + assertNotNull(hidden.find { it.operationId == "GET:/api/resources" }) + assertNotNull(hidden.find { it.operationId == "DELETE:/api/resources/{id}" }) + } + } +} diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/IntegrationTestRestBase.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/IntegrationTestRestBase.kt index 0a0b64a66b..f9b88a8228 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/IntegrationTestRestBase.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/IntegrationTestRestBase.kt @@ -5,10 +5,11 @@ import org.evomaster.core.EMConfig import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.rest.data.RestCallAction import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.oracle.RestSecurityOracle import org.evomaster.core.problem.rest.service.RestIndividualBuilder import org.evomaster.core.problem.rest.service.fitness.AbstractRestFitness import org.evomaster.core.problem.rest.service.sampler.AbstractRestSampler -import org.evomaster.core.problem.rest.service.SecurityRest +import org.evomaster.core.problem.rest.service.RestSecurityBuilder import org.evomaster.core.search.EvaluatedIndividual import org.evomaster.core.search.service.Archive import org.evomaster.core.seeding.service.rest.PirToRest @@ -51,12 +52,14 @@ abstract class IntegrationTestRestBase : RestTestBase() { fun getArchive() = injector.getInstance(Archive::class.java) as Archive - fun getSecurityRest() = injector.getInstance(SecurityRest::class.java) as SecurityRest + fun getSecurityRest() = injector.getInstance(RestSecurityBuilder::class.java) as RestSecurityBuilder fun getEMConfig() = injector.getInstance(EMConfig::class.java) fun getBuilder() = injector.getInstance(RestIndividualBuilder::class.java) + fun getSecurityOracle() = injector.getInstance(RestSecurityOracle::class.java) + /** * Create and evaluate an individual */ diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/ForgottenAuthenticationTest.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/ForgottenAuthenticationTest.kt index dea51fa3cc..7a10448985 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/ForgottenAuthenticationTest.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/ForgottenAuthenticationTest.kt @@ -86,7 +86,7 @@ class ForgottenAuthenticationTest: IntegrationTestRestBase() { assertEquals(403, r2.getStatusCode()) assertEquals(200, r3.getStatusCode()) - val faultDetected = RestSecurityOracle.hasForgottenAuthentication(get42NotAuth.getName(), ei.individual, ei.seeResults()) + val faultDetected = getSecurityOracle().hasForgottenAuthentication(get42NotAuth.getName(), ei.individual, ei.seeResults()) assertTrue(faultDetected) //fault should be put on 200 with no authentication @@ -129,7 +129,7 @@ class ForgottenAuthenticationTest: IntegrationTestRestBase() { assertEquals(200, r1.getStatusCode()) // we couldn't say this is forgotten because GET could be open, so we cannot be sure. - val faultDetected = RestSecurityOracle.hasForgottenAuthentication(put42.getName(), ei.individual, ei.seeResults()) + val faultDetected = getSecurityOracle().hasForgottenAuthentication(put42.getName(), ei.individual, ei.seeResults()) assertFalse(faultDetected) assertEquals(0, r0.getFaults().size) diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt index a456e53d71..afda4122a8 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityExistenceLeakageTest.kt @@ -82,7 +82,7 @@ class SecurityExistenceLeakageTest: IntegrationTestRestBase() { assertEquals(403, r1.getStatusCode()) assertEquals(404, r2.getStatusCode()) - val faultDetected = RestSecurityOracle.hasExistenceLeakage(RestPath("/api/resources/{id}"),ei.individual, ei.seeResults(), listOf()) + val faultDetected = getSecurityOracle().hasExistenceLeakage(RestPath("/api/resources/{id}"),ei.individual, ei.seeResults()) assertTrue(faultDetected) //fault should be put on 404 diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityForbiddenOperationTest.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityForbiddenOperationTest.kt index b3588bec59..dd6079a992 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityForbiddenOperationTest.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SecurityForbiddenOperationTest.kt @@ -63,7 +63,7 @@ class SecurityForbiddenOperationTest : IntegrationTestRestBase() { assertEquals(403, (ind.evaluatedMainActions()[1].result as RestCallResult).getStatusCode()) assertEquals(204, (ind.evaluatedMainActions()[2].result as RestCallResult).getStatusCode()) - val faultDetected = RestSecurityOracle.hasForbiddenOperation(HttpVerb.DELETE,ind.individual, ind.seeResults()) + val faultDetected = getSecurityOracle().hasForbiddenOperation(HttpVerb.DELETE,ind.individual, ind.seeResults()) assertTrue(faultDetected) } @@ -94,7 +94,7 @@ class SecurityForbiddenOperationTest : IntegrationTestRestBase() { assertEquals(403, (ind.evaluatedMainActions()[1].result as RestCallResult).getStatusCode()) assertEquals(204, (ind.evaluatedMainActions()[2].result as RestCallResult).getStatusCode()) - val faultDetected = RestSecurityOracle.hasForbiddenOperation(HttpVerb.DELETE,ind.individual, ind.seeResults()) + val faultDetected = getSecurityOracle().hasForbiddenOperation(HttpVerb.DELETE,ind.individual, ind.seeResults()) assertTrue(faultDetected) } diff --git a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SimpleSecurityDeletePutControllerTest.kt b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SimpleSecurityDeletePutControllerTest.kt index 3031b69452..50077d7e80 100644 --- a/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SimpleSecurityDeletePutControllerTest.kt +++ b/core-tests/integration-tests/core-it/src/test/kotlin/org/evomaster/core/problem/rest/securityrestoracle/SimpleSecurityDeletePutControllerTest.kt @@ -4,7 +4,6 @@ import bar.examples.it.spring.simplesecuritydeleteput.SimpleSecurityDeletePutCon import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.rest.data.HttpVerb import org.evomaster.core.problem.rest.IntegrationTestRestBase -import org.evomaster.core.problem.rest.oracle.RestSecurityOracle import org.evomaster.core.problem.rest.param.QueryParam import org.evomaster.core.search.gene.string.StringGene import org.junit.jupiter.api.Assertions.assertTrue @@ -55,7 +54,7 @@ class SimpleSecurityDeletePutControllerTest : IntegrationTestRestBase() { val sampleInd = createIndividual(listOf(action1Ind1, action2Ind1, action3Ind1, action4Ind1, action5Ind1), SampleType.SECURITY) - val testCovered = RestSecurityOracle.hasForbiddenOperation(HttpVerb.DELETE,sampleInd.individual, sampleInd.seeResults() ) + val testCovered = getSecurityOracle().hasForbiddenOperation(HttpVerb.DELETE,sampleInd.individual, sampleInd.seeResults() ) assertTrue(testCovered) } diff --git a/core/src/main/kotlin/org/evomaster/core/Main.kt b/core/src/main/kotlin/org/evomaster/core/Main.kt index 9be8540512..c967064558 100644 --- a/core/src/main/kotlin/org/evomaster/core/Main.kt +++ b/core/src/main/kotlin/org/evomaster/core/Main.kt @@ -1,6 +1,5 @@ package org.evomaster.core -import com.google.inject.Inject import com.google.inject.Injector import com.google.inject.Key import com.google.inject.TypeLiteral @@ -329,15 +328,7 @@ class Main { when (config.problemType) { EMConfig.ProblemType.REST -> { val k = data.find { it.header == Statistics.COVERED_2XX }!!.element.toInt() - val t = if (sampler.getPreDefinedIndividuals().isNotEmpty()) { - /* - FIXME this is a temporary hack... - right now we might have 1 call to Schema that messes up this statistics - */ - n + 1 - } else { - n - } + val t = n assert(k <= t) val p = String.format("%.0f", (k.toDouble() / t) * 100) LoggingUtil.getInfoLogger() @@ -462,7 +453,7 @@ class Main { return when (config.problemType) { EMConfig.ProblemType.REST -> { - val securityRest = injector.getInstance(SecurityRest::class.java) + val securityRest = injector.getInstance(RestSecurityBuilder::class.java) securityRest.applySecurityPhase() } diff --git a/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt index 607d6e9088..15f09faa5b 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/service/HttpWsTestCaseWriter.kt @@ -735,14 +735,7 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() { if (config.enableBasicAssertions && !call.shouldSkipAssertionsOnResponseBody()) { handleResponseAssertions(lines, res, null) } - } - -// else if (partialOracles.generatesExpectation(call, res) -// && format.isJavaOrKotlin()){ -// //FIXME what is this for??? -// lines.add(".then()") -// } } //---------------------------------------------------------------------------------------- @@ -766,6 +759,20 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() { lines.add(".assertThat()") } + val allow = res.getAllow() + if(!allow.isNullOrBlank()){ + val instruction = when { + format.isJavaOrKotlin() -> ".header(\"Allow\", \"$allow\")" + format.isJavaScript() -> + "expect($responseVariableName.header[\"allow\"].startsWith(\"$allow\")).toBe(true);" + format.isPython() -> "assert \"$allow\" in $responseVariableName.headers[\"allow\"]" + else -> throw IllegalStateException("Unsupported format $format") + } + //lines.add(instruction) + //TODO: verb order in Allow header is flaky + lines.addSingleCommentLine(instruction) + } + if (res.getTooLargeBody()) { lines.addSingleCommentLine("the response payload was too large, above the threshold of ${config.maxResponseByteSize} bytes." + " No assertion on it is therefore generated.") @@ -802,7 +809,6 @@ abstract class HttpWsTestCaseWriter : ApiTestCaseWriter() { } else{ lines.addSingleCommentLine(instruction) } - } val type = res.getBodyType() diff --git a/core/src/main/kotlin/org/evomaster/core/output/service/TestCaseWriter.kt b/core/src/main/kotlin/org/evomaster/core/output/service/TestCaseWriter.kt index 7a80fb24af..b36b04d0f4 100644 --- a/core/src/main/kotlin/org/evomaster/core/output/service/TestCaseWriter.kt +++ b/core/src/main/kotlin/org/evomaster/core/output/service/TestCaseWriter.kt @@ -270,7 +270,8 @@ abstract class TestCaseWriter { result.getFaults().sortedBy { it.category.code } .forEach { val cat = it.category - lines.addSingleCommentLine("Fault${cat.code}. ${cat.descriptiveName}. ${it.context}.") + val context = if(it.context != null) " ${it.context}" else "" + lines.addSingleCommentLine("Fault${cat.code}. ${cat.descriptiveName}.$context") if(it.localMessage != null){ lines.append(" ${it.localMessage}") } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt index 2ffccf9836..734143cc91 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/enterprise/ExperimentalFaultCategory.kt @@ -24,7 +24,9 @@ enum class ExperimentalFaultCategory( LEAKED_STACK_TRACES(902, "Leaked Stack Trace", "leakedStackTrace", "TODO"), - + HIDDEN_ACCESSIBLE_ENDPOINT(903, "Hidden Accessible Endpoint", + "hiddenAccessible", + "TODO"), HTTP_INVALID_PAYLOAD_SYNTAX(911, "Invalid Payload Syntax", "rejectedWithInvalidPayloadSyntax", diff --git a/core/src/main/kotlin/org/evomaster/core/problem/httpws/HttpWsCallResult.kt b/core/src/main/kotlin/org/evomaster/core/problem/httpws/HttpWsCallResult.kt index c625964b4b..35c49d1147 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/httpws/HttpWsCallResult.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/httpws/HttpWsCallResult.kt @@ -4,6 +4,7 @@ import com.google.common.annotations.VisibleForTesting import com.google.gson.Gson import com.google.gson.JsonSyntaxException import org.evomaster.core.problem.enterprise.EnterpriseActionResult +import org.evomaster.core.problem.rest.data.HttpVerb import javax.ws.rs.core.MediaType abstract class HttpWsCallResult : EnterpriseActionResult { @@ -25,6 +26,7 @@ abstract class HttpWsCallResult : EnterpriseActionResult { const val TCP_PROBLEM = "TCP_PROBLEM" const val APPLIED_LINK = "APPLIED_LINK" const val LOCATION = "LOCATION" + const val ALLOW = "ALLOW" const val RESPONSE_TIME_MS = "RESPONSE_TIME_MS" const val VULNERABLE_SSRF = "VULNERABLE_SSRF" @@ -49,7 +51,7 @@ abstract class HttpWsCallResult : EnterpriseActionResult { fun setStatusCode(code: Int) { - if (code < 100 || code >= 600) { + if (code !in 100..<600) { throw IllegalArgumentException("Invalid HTTP code $code") } @@ -66,6 +68,32 @@ abstract class HttpWsCallResult : EnterpriseActionResult { fun getLocation(): String? = getResultValue(LOCATION) + fun setAllow(allow: String?){ + if(allow != null) { + addResultValue(ALLOW, allow) + } + } + + fun getAllow(): String? = getResultValue(ALLOW) + + /** + * Return verbs based on "allow" header. + * It can return null to indicate there was no allow header. + */ + fun getAllowedVerbs() : Set?{ + val allow = getAllow() ?: return null + val fromAllow = allow.split(",") + .mapNotNull { + try{ + HttpVerb.valueOf(it.trim()) + } catch (e: IllegalArgumentException){ + //a bug, but we do not check this here + null + } + } + return fromAllow.toSet() + } + fun hasErrorCode() : Boolean = getStatusCode()!=null && getStatusCode()!! >= 500 /* diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt index 5cba1020a9..40d73ea2a9 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/oracle/RestSecurityOracle.kt @@ -1,14 +1,545 @@ package org.evomaster.core.problem.rest.oracle +import com.webfuzzing.commons.faults.DefinedFaultCategory +import com.webfuzzing.commons.faults.FaultCategory import org.apache.http.HttpStatus +import org.evomaster.core.EMConfig +import org.evomaster.core.problem.enterprise.DetectedFault +import org.evomaster.core.problem.enterprise.ExperimentalFaultCategory import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.enterprise.auth.NoAuth +import org.evomaster.core.problem.httpws.HttpWsCallResult import org.evomaster.core.problem.rest.* import org.evomaster.core.problem.rest.builder.CreateResourceUtils import org.evomaster.core.problem.rest.data.* +import org.evomaster.core.problem.rest.service.CallGraphService +import org.evomaster.core.problem.rest.service.RestSecurityBuilder +import org.evomaster.core.problem.security.service.SSRFAnalyser +import org.evomaster.core.search.FitnessValue import org.evomaster.core.search.action.ActionResult +import org.evomaster.core.search.service.IdMapper +import org.evomaster.core.utils.StackTraceUtils +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import javax.inject.Inject + + +/** + * Class in which different oracles to detect security vulnerabilities are implemented. + * The function here verify if an individual contains security vulnerabilities. + * The actual building of individual that might reveal security vulnerabilities + * is done in [org.evomaster.core.problem.rest.service.RestSecurityBuilder] + */ +class RestSecurityOracle { + + companion object{ + private val log: Logger = LoggerFactory.getLogger(RestSecurityOracle::class.java) + + /** + * Simple SQLi payloads. Used to check for SQL Injection vulnerability. + * The payloads are designed to introduce delays in the database response, + * which can be detected by measuring the response time of the application. + */ + val SQLI_PAYLOADS = listOf( + // Simple sleep-based payloads for MySQL + "' OR SLEEP(%.2f)-- -", + "\" OR SLEEP(%.2f)-- -", + "' OR SLEEP(%.2f)=0-- -", + "\" OR SLEEP(%.2f)=0-- -", + // Integer-based delays + "' OR SLEEP(%.0f)-- -", + "\" OR SLEEP(%.0f)-- -", + "' OR SLEEP(%.0f)=0-- -", + "\" OR SLEEP(%.0f)=0-- -", + // Simple sleep-based payloads for PostgreSQL + "' OR select pg_sleep(%.2f)-- -", + "\" OR select pg_sleep(%.2f)-- -", + "' OR (select pg_sleep(%.2f)) IS NULL-- -", + "\' OR (select pg_sleep(%.2f)) IS NULL-- -", + // Integer-based delays + "' OR select pg_sleep(%.0f)-- -", + "\" OR select pg_sleep(%.0f)-- -", + "' OR (select pg_sleep(%.0f)) IS NULL-- -", + "\' OR (select pg_sleep(%.0f)) IS NULL-- -", + ) + + + // Simple XSS payloads inspired by big-list-of-naughty-strings + // https://github.com/minimaxir/big-list-of-naughty-strings/blob/master/blns.txt + val XSS_PAYLOADS = listOf( + "", + "", + "
", + "", + "" + ) + + } + + @Inject + private lateinit var config: EMConfig + + @Inject + private lateinit var idMapper: IdMapper + + @Inject + private lateinit var ssrfAnalyser: SSRFAnalyser + + @Inject + private lateinit var callGraphService: CallGraphService + + @Inject + private lateinit var restSecurityBuilder: RestSecurityBuilder + + /** + * Evaluate all the different security oracles, based on what enabled in configurations, + * and update the fitness value if any fault is found. + * Each security category has its own unique fault identifier. + */ + fun analyzeSecurityProperties( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ){ + handleForbiddenOperation(HttpVerb.DELETE, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) + handleForbiddenOperation(HttpVerb.PUT, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) + handleForbiddenOperation(HttpVerb.PATCH, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) + handleExistenceLeakage(individual,actionResults,fv) + handleNotRecognizedAuthenticated(individual, actionResults, fv) + handleForgottenAuthentication(individual, actionResults, fv) + handleStackTraceCheck(individual, actionResults, fv) + handleSQLiCheck(individual, actionResults, fv) + handleXSSCheck(individual, actionResults, fv) + handleAnonymousWriteCheck(individual, actionResults, fv) + handleHiddenAccessible(individual, actionResults, fv) + handleSsrfFaults(individual, actionResults, fv) + } + + private fun handleSsrfFaults( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!config.isEnabledFaultCategory(DefinedFaultCategory.SSRF)) { + return + } + + individual.seeMainExecutableActions().forEach { + val ar = (actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult?) + if (ar != null) { + if (ar.getResultValue(HttpWsCallResult.VULNERABLE_SSRF).toBoolean()) { + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(DefinedFaultCategory.SSRF, it.getName()) + ) + fv.updateTarget(scenarioId, 1.0, it.positionAmongMainActions()) + + val paramName = ssrfAnalyser.getVulnerableParameterName(it) + ar.addFault(DetectedFault(DefinedFaultCategory.SSRF, it.getName(), paramName)) + } + } + } + } + + private fun handleNotRecognizedAuthenticated( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED)) { + return + } + + if(actionResults.any { it.stopping }){ + return + } + + val notRecognized = individual.seeMainExecutableActions() + .filter { + val ar = actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult? + if(ar == null){ + log.warn("Missing action result with id: ${it.getLocalId()}}") + false + } else { + it.auth !is NoAuth && ar.getStatusCode() == 401 + } + } + .filter { + hasNotRecognizedAuthenticated(it, individual, actionResults) + } + + if(notRecognized.isEmpty()){ + return + } + + notRecognized.forEach { + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED, it.getName()) + ) + fv.updateTarget(scenarioId, 1.0, it.positionAmongMainActions()) + val r = actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult + r.addFault(DetectedFault(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED, it.getName(), null)) + } + } + + private fun handleExistenceLeakage( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE)) { + return + } + + val getPaths = individual.seeMainExecutableActions() + .filter { it.verb == HttpVerb.GET } + .map { it.path } + .toSet() + + val faultyPaths = getPaths.filter { + hasExistenceLeakage(it, individual, actionResults) + } + if(faultyPaths.isEmpty()){ + return + } + + for(index in individual.seeMainExecutableActions().indices){ + val a = individual.seeMainExecutableActions()[index] + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult + + if(a.verb == HttpVerb.GET && faultyPaths.contains(a.path) && r.getStatusCode() == 404){ + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE, a.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + r.addFault(DetectedFault(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE, a.getName(), null)) + } + } + } + + private fun handleSQLiCheck( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!config.isEnabledFaultCategory(DefinedFaultCategory.SQL_INJECTION)) { + return + } + + val foundPair = findSQLiPayloadPair(individual) + + if(foundPair == null){ + //no pair found, cannot do baseline comparison + return + } + + val (actionWithoutPayload, actionWithPayload) = foundPair + val baselineResult = actionResults.find { it.sourceLocalId == actionWithoutPayload.getLocalId() } as? RestCallResult + ?: return + val baselineTime = baselineResult.getResponseTimeMs() ?: return + + val injectedResult = actionResults.find { it.sourceLocalId == actionWithPayload.getLocalId() } as? RestCallResult + ?: return + val injectedTime = injectedResult.getResponseTimeMs() + + + val K = config.sqliBaselineMaxResponseTimeMs // K: maximum allowed baseline response time + val N = config.sqliInjectedSleepDurationMs // N: expected delay introduced by the injected sleep payload + + // Baseline must be fast enough (baseline < K) + val baselineIsFast = baselineTime < K + + // Response after injection must be slow enough (response > N) + var responseIsSlowEnough: Boolean + + if (injectedTime != null) { + responseIsSlowEnough = injectedTime > N + } else if (injectedResult.getTimedout()){ + // if the injected request timed out, we can consider it vulnerable + responseIsSlowEnough = true + } else { + return + } + + // If baseline is fast AND the response after payload is slow enough, + // then we consider this a potential time-based SQL injection vulnerability. + // Otherwise, skip this result. + if (!(baselineIsFast && responseIsSlowEnough)) { + return + } + + // Find the index of the action with payload to report it correctly + val index = individual.seeMainExecutableActions().indexOf(actionWithPayload) + if (index < 0) { + log.warn("Failed to find index of action with SQLi payload") + return + } + + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(DefinedFaultCategory.SQL_INJECTION, actionWithPayload.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + injectedResult.addFault(DetectedFault(DefinedFaultCategory.SQL_INJECTION, actionWithPayload.getName(), null)) + injectedResult.setVulnerableForSQLI(true) + } + + /** + * Finds a pair of actions in the individual that have the same path and verb, + * where one contains a SQLi payload and the other does not. + * + * This is useful for comparing baseline response times (without payload) against + * response times with SQLi payload to detect time-based SQL injection vulnerabilities. + * + * @param individual The test individual to search + * @return A pair of (actionWithoutPayload, actionWithPayload), or null if no such pair exists + */ + private fun findSQLiPayloadPair( + individual: RestIndividual + ): Pair? { + + val actions = individual.seeMainExecutableActions() + .filterIsInstance() + + // Group actions by path and verb + val actionsByPathAndVerb = actions + .groupBy { it.path.toString() to it.verb } + + // Find a pair where one has SQLi payload and one doesn't + for ((pathVerb, actionsForPath) in actionsByPathAndVerb) { + if (actionsForPath.size < 2) continue + + val withPayload = actionsForPath.filter { + hasSQLiPayload(it, config.sqliInjectedSleepDurationMs/1000.0) + } + val withoutPayload = actionsForPath.filter { + !hasSQLiPayload(it, config.sqliInjectedSleepDurationMs/1000.0) + } + + if (withPayload.isNotEmpty() && withoutPayload.isNotEmpty()) { + return Pair(withoutPayload.first(), withPayload.first()) + } + } + + return null + } + + private fun handleStackTraceCheck( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if (!config.isEnabledFaultCategory(ExperimentalFaultCategory.LEAKED_STACK_TRACES)) { + return + } + + for(index in individual.seeMainExecutableActions().indices){ + val a = individual.seeMainExecutableActions()[index] + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult? + //this can happen if an action timeout, or is stopped + ?: continue + + if(r.getStatusCode() == 500 && r.getBody() != null && StackTraceUtils.looksLikeStackTrace(r.getBody()!!)){ + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.LEAKED_STACK_TRACES, a.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + r.addFault(DetectedFault(ExperimentalFaultCategory.LEAKED_STACK_TRACES, a.getName(), null)) + } + } + } + + private fun handleHiddenAccessible( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ){ + if(!config.isEnabledFaultCategory(ExperimentalFaultCategory.HIDDEN_ACCESSIBLE_ENDPOINT)){ + return + } + + for(index in 0 until individual.seeMainExecutableActions().lastIndex) { + val a = individual.seeMainExecutableActions()[index] + if(a.verb != HttpVerb.OPTIONS){ + continue + } + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult? + //this can happen if an action timeout, or is stopped + ?: break + val allowedVerbs = r.getAllowedVerbs() + ?: continue + + val path = a.path + val hidden = restSecurityBuilder.filterHiddenVerbs(path,allowedVerbs) + + val target = individual.seeMainExecutableActions()[index+1] + if(target.path != path || !hidden.contains(target.verb)){ + continue + } + + val data = actionResults.find { it.sourceLocalId == target.getLocalId() } as RestCallResult? + ?: break + + val status = data.getStatusCode() + ?: continue + + if(status !in setOf(405,501,403)){ + // we also consider 403, in case API just give it by default for security reasons + + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.HIDDEN_ACCESSIBLE_ENDPOINT, target.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index+1) + data.addFault(DetectedFault(ExperimentalFaultCategory.HIDDEN_ACCESSIBLE_ENDPOINT, target.getName(), null)) + } + } + } + + + private fun handleXSSCheck( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if(!config.isEnabledFaultCategory(DefinedFaultCategory.XSS)){ + return + } + + // Check if this individual has XSS vulnerability + if(!hasXSS(individual, actionResults)){ + return + } + + // Find the action(s) where XSS payload appears in the response + for(index in individual.seeMainExecutableActions().indices){ + val a = individual.seeMainExecutableActions()[index] + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as? RestCallResult + ?: continue + + if(!StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ + continue + } + + val responseBody = r.getBody() ?: continue + + // Check if any XSS payload is present in this response + for(payload in XSS_PAYLOADS){ + if(responseBody.contains(payload, ignoreCase = false)){ + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(DefinedFaultCategory.XSS, a.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + r.addFault(DetectedFault(DefinedFaultCategory.XSS, a.getName(), null)) + break // Only add one fault per action + } + } + } + } + + private fun handleAnonymousWriteCheck( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + if(!config.isEnabledFaultCategory(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS)){ + return + } + + // Get all write operation paths (PUT, PATCH, DELETE) + val writePaths = individual.seeMainExecutableActions() + .filter { it.verb == HttpVerb.PUT || it.verb == HttpVerb.PATCH || it.verb == HttpVerb.DELETE } + .map { it.path } + .toSet() + + val faultyPaths = writePaths.filter { hasAnonymousWrite(it, individual, actionResults) } + + if(faultyPaths.isEmpty()){ + return + } + + for(index in individual.seeMainExecutableActions().indices){ + val a = individual.seeMainExecutableActions()[index] + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult + + if((a.verb == HttpVerb.PUT || a.verb == HttpVerb.PATCH || a.verb == HttpVerb.DELETE) + && faultyPaths.contains(a.path) + && a.auth is NoAuth + && StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ + + // For PUT, check if it's 201 (resource creation - might be OK) + if(a.verb == HttpVerb.PUT && r.getStatusCode() == 201){ + continue + } + + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS, a.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + r.addFault(DetectedFault(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS, a.getName(), null)) + } + } + } + + private fun handleForgottenAuthentication( + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + + if (!config.isEnabledFaultCategory(ExperimentalFaultCategory.IGNORE_ANONYMOUS)) { + return + } + + val endpoints = individual.seeMainExecutableActions() + .map { it.getName() } + .toSet() + + val faultyEndpoints = endpoints.filter { hasForgottenAuthentication(it, individual, actionResults) } + + if(faultyEndpoints.isEmpty()){ + return + } + + for(index in individual.seeMainExecutableActions().indices){ + val a = individual.seeMainExecutableActions()[index] + val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult + + if(a.auth is NoAuth && faultyEndpoints.contains(a.getName()) && StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.IGNORE_ANONYMOUS, a.getName()) + ) + fv.updateTarget(scenarioId, 1.0, index) + r.addFault(DetectedFault(ExperimentalFaultCategory.IGNORE_ANONYMOUS, a.getName(), null)) + } + } + } + + private fun handleForbiddenOperation( + verb: HttpVerb, + faultCategory: FaultCategory, + individual: RestIndividual, + actionResults: List, + fv: FitnessValue + ) { + + if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION)) { + return + } + + if (hasForbiddenOperation(verb, individual, actionResults)) { + val actionIndex = individual.size() - 1 + val action = individual.seeMainExecutableActions()[actionIndex] + val result = actionResults + .filterIsInstance() + .find { it.sourceLocalId == action.getLocalId() } + ?: return + + val scenarioId = idMapper.handleLocalTarget( + idMapper.getFaultDescriptiveId(faultCategory, action.getName()) + ) + fv.updateTarget(scenarioId, 1.0, actionIndex) + result.addFault(DetectedFault(faultCategory, action.getName(), null)) + } + } -object RestSecurityOracle { private fun verifySampleType(individual: RestIndividual){ if(individual.sampleType != SampleType.SECURITY){ @@ -172,7 +703,6 @@ object RestSecurityOracle { path: RestPath, individual: RestIndividual, actionResults: List, - actionDefinitions: List ): Boolean{ verifySampleType(individual) @@ -213,7 +743,7 @@ object RestSecurityOracle { we need to check for that. */ - val topGet = findStrictTopGETResourceAncestor(path, actionDefinitions) + val topGet = callGraphService.findStrictTopGETResourceAncestor(path) //if null, then for sure the user with 404 does not own a parent resource ?: return true @@ -258,13 +788,6 @@ object RestSecurityOracle { return false } - private fun findStrictTopGETResourceAncestor(path: RestPath, actions: List) : RestCallAction?{ - return actions - .filter { it.verb == HttpVerb.GET } - .filter { it.path.isStrictlyAncestorOf(path)} - .filter { it.path.isLastElementAParameter() } - .minByOrNull { it.path.levels() } - } /** * For example verb target DELETE, @@ -360,44 +883,6 @@ object RestSecurityOracle { } } - /** - * Simple SQLi payloads. Used to check for SQL Injection vulnerability. - * The payloads are designed to introduce delays in the database response, - * which can be detected by measuring the response time of the application. - */ - val SQLI_PAYLOADS = listOf( - // Simple sleep-based payloads for MySQL - "' OR SLEEP(%.2f)-- -", - "\" OR SLEEP(%.2f)-- -", - "' OR SLEEP(%.2f)=0-- -", - "\" OR SLEEP(%.2f)=0-- -", - // Integer-based delays - "' OR SLEEP(%.0f)-- -", - "\" OR SLEEP(%.0f)-- -", - "' OR SLEEP(%.0f)=0-- -", - "\" OR SLEEP(%.0f)=0-- -", - // Simple sleep-based payloads for PostgreSQL - "' OR select pg_sleep(%.2f)-- -", - "\" OR select pg_sleep(%.2f)-- -", - "' OR (select pg_sleep(%.2f)) IS NULL-- -", - "\' OR (select pg_sleep(%.2f)) IS NULL-- -", - // Integer-based delays - "' OR select pg_sleep(%.0f)-- -", - "\" OR select pg_sleep(%.0f)-- -", - "' OR (select pg_sleep(%.0f)) IS NULL-- -", - "\' OR (select pg_sleep(%.0f)) IS NULL-- -", - ) - - - // Simple XSS payloads inspired by big-list-of-naughty-strings - // https://github.com/minimaxir/big-list-of-naughty-strings/blob/master/blns.txt - val XSS_PAYLOADS = listOf( - "
", - "", - "
", - "", - "" - ) /** @@ -451,4 +936,5 @@ object RestSecurityOracle { return false } + } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/resource/RestResourceNode.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/resource/RestResourceNode.kt index 904492a853..bd545e6ae3 100755 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/resource/RestResourceNode.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/resource/RestResourceNode.kt @@ -17,6 +17,7 @@ import org.evomaster.core.problem.rest.resource.dependency.* import org.evomaster.core.problem.util.ParamUtil import org.evomaster.core.problem.rest.util.ParserUtil import org.evomaster.core.problem.util.RestResourceTemplateHandler +import org.evomaster.core.search.Individual import org.evomaster.core.search.action.ActionFilter import org.evomaster.core.search.action.ActionResult import org.evomaster.core.search.gene.Gene @@ -650,8 +651,27 @@ open class RestResourceNode( * this method is to update [actions] in this node based on the updated [action] */ open fun updateActionsWithAdditionalParams(action: RestCallAction){ + val org = actions.find { it.verb == action.verb } - org?:throw IllegalStateException("cannot find the action (${action.getName()}) in the node $path") + + if(org == null){ + if(!action.isMounted()) { + throw IllegalStateException("cannot find the action (${action.getName()}) in the node $path") + } + val sgs = (action.getRoot() as Individual).searchGlobalState + ?: return + if(sgs.epc.isInSearch()){ + throw IllegalStateException("cannot find the action (${action.getName()}) in the node $path") + } else { + /* + in other phases like Security, we might build actions that are not defined in the schema. + so should not crash. + but this shouldn't happen during the search + */ + return + } + } + if (action.parameters.size > org.parameters.size){ originalActions.add(org) actions.remove(org) @@ -801,8 +821,26 @@ open class RestResourceNode( * @return whether there exists any additional parameters by comparing with [action]? */ fun updateAdditionalParams(action: RestCallAction) : Map?{ - (actions.find { it.getName() == action.getName() } - ?: throw IllegalArgumentException("cannot find the action ${action.getName()} in the resource ${getName()}")) as RestCallAction + + val found = (actions.find { it.getName() == action.getName() }) + + if(found == null){ + if(!action.isMounted()) { + throw IllegalStateException("cannot find the action (${action.getName()}) in the resource ${getName()}") + } + val sgs = (action.getRoot() as Individual).searchGlobalState + ?: return null + if(sgs.epc.isInSearch()){ + throw IllegalStateException("cannot find the action (${action.getName()}) in the resource ${getName()}") + } else { + /* + in other phases like Security, we might build actions that are not defined in the schema. + so should not crash. + but this shouldn't happen during the search + */ + return null + } + } val additionParams = action.parameters.filter { p-> paramsInfo[getParamId(action.parameters, p)] == null} if(additionParams.isEmpty()) return null diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt index 0565430881..aaba913f70 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/CallGraphService.kt @@ -62,6 +62,26 @@ class CallGraphService { } } + fun endpointsForPath(path: RestPath): List { + return sampler.seeAvailableActions() + .filterIsInstance() + .filter { it.path == path } + .map { Endpoint(it.verb, it.path) } + } + + fun isDeclared(verb: HttpVerb, path: RestPath): Boolean { + return endpointsForPath(path).any{it.verb == verb} + } + + fun findStrictTopGETResourceAncestor(path: RestPath) : RestCallAction?{ + return sampler.seeAvailableActions() + .filterIsInstance() + .filter { it.verb == HttpVerb.GET } + .filter { it.path.isStrictlyAncestorOf(path)} + .filter { it.path.isLastElementAParameter() } + .minByOrNull { it.path.levels() } + } + /** * Check in the schema if there is any action which is a direct child of [a] and last path element is a parameter */ diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/RestSecurityBuilder.kt similarity index 92% rename from core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt rename to core/src/main/kotlin/org/evomaster/core/problem/rest/service/RestSecurityBuilder.kt index a7c1bb36ea..148a17b65b 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/SecurityRest.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/RestSecurityBuilder.kt @@ -19,8 +19,8 @@ import org.evomaster.core.problem.rest.* import org.evomaster.core.problem.rest.builder.CreateResourceUtils import org.evomaster.core.problem.rest.builder.RestIndividualSelectorUtils import org.evomaster.core.problem.rest.data.* -import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.SQLI_PAYLOADS -import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.XSS_PAYLOADS +import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.Companion.SQLI_PAYLOADS +import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.Companion.XSS_PAYLOADS import org.evomaster.core.problem.rest.param.PathParam import org.evomaster.core.problem.rest.resource.RestResourceCalls import org.evomaster.core.problem.rest.service.sampler.AbstractRestSampler @@ -33,6 +33,7 @@ import org.evomaster.core.search.service.Archive import org.evomaster.core.search.service.FitnessFunction import org.evomaster.core.search.service.IdMapper import org.evomaster.core.search.service.Randomness +import org.evomaster.core.search.service.SearchGlobalState import org.evomaster.core.utils.StackTraceUtils import org.slf4j.Logger import org.slf4j.LoggerFactory @@ -42,13 +43,13 @@ import org.slf4j.LoggerFactory * Service class used to do security testing after the search phase. * * This class can add new test cases to the archive that, by construction, do reveal a security fault. - * But, the actual check if a test indeed finds a fault is in [RestSecurityOracle] + * But, the actual check if a test indeed finds a fault is in [org.evomaster.core.problem.rest.oracle.RestSecurityOracle] * called in the fitness function, and not directly here. */ -class SecurityRest { +class RestSecurityBuilder { companion object { - private val log: Logger = LoggerFactory.getLogger(SecurityRest::class.java) + private val log: Logger = LoggerFactory.getLogger(RestSecurityBuilder::class.java) } /** @@ -60,6 +61,9 @@ class SecurityRest { @Inject private lateinit var sampler: AbstractRestSampler + @Inject + private lateinit var callGraph: CallGraphService + @Inject private lateinit var randomness: Randomness @@ -78,6 +82,9 @@ class SecurityRest { @Inject private lateinit var ssrfAnalyser: SSRFAnalyser + @Inject + protected lateinit var searchGlobalState: SearchGlobalState + /** * All actions that can be defined from the OpenAPI schema */ @@ -305,8 +312,14 @@ class SecurityRest { } else { handleStackTraceCheck() } + + if(config.isEnabledFaultCategory(ExperimentalFaultCategory.HIDDEN_ACCESSIBLE_ENDPOINT)){ + handleHiddenAccessibleEndpoint() + } } + + private fun accessControlBasedOnRESTGuidelines() { if(!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION)){ @@ -445,6 +458,100 @@ class SecurityRest { } } } + + /** + * If we send request on endpoint path with wrong verb, we get 405/501. + * When sending OPTIONS, the response should have `Allow` telling what is there. + * That info might be different from what specified in the schema. + * Should check if mismatches with OpenAPI schemas, eg if an extra verbs. + * Let's make a call to those, with valid path, but no query parameters nor any body payloads. + * If we get anything but 405/501 then we discovered a hidden accessible endpoint + */ + private fun handleHiddenAccessibleEndpoint() { + + val actions = actionDefinitions.distinctBy { it.path } + + mainloop@for (a in actions){ + + /* + first build an action to make a OPTION call on each different path + */ + val pathVariables = a.parameters + .filterIsInstance() + .map { it.copy() } + .toMutableList() + + val verb = HttpVerb.OPTIONS + val path = a.path.copy() + val actionId = "$verb$path" + val authOptions = authSettings.getOfType(HttpWsAuthenticationInfo::class.java) + val auth = if(authOptions.isNotEmpty()){ + authOptions.first() + } else { + HttpWsNoAuth() + } + + val options = RestCallAction(actionId,verb, path, pathVariables, auth) + options.doInitialize(randomness) + + //create individual to make the OPTIONS call + val ind = RestIndividual(mutableListOf(options), SampleType.SECURITY) + ind.doGlobalInitialize(searchGlobalState) + ind.ensureFlattenedStructure() + + val evaluatedIndividual = fitness.computeWholeAchievedCoverageForPostProcessing(ind) + + if (evaluatedIndividual == null) { + log.warn("Failed to evaluate OPTION call") + continue@mainloop + } + archive.addIfNeeded(evaluatedIndividual) + + //now time to check the HTTP response from the OPTIONS call + val em = evaluatedIndividual.evaluatedMainActions() + if(em.isEmpty()){ + log.warn("Failed in evaluation of OPTION call") + continue@mainloop + } + + val response = em.first().result as RestCallResult + val fromAllow = response.getAllowedVerbs() + if(fromAllow == null || fromAllow.isEmpty()){ + continue@mainloop + } + + //is there any difference from what declared in the schema? + val hidden = filterHiddenVerbs(path, fromAllow) + + hidden@for(h in hidden){ + //try to make such calls, after the OPTIONS + val target = RestCallAction("$h:$path",h, path, pathVariables.map { it.copy() }.toMutableList(), auth) + target.resetLocalIdRecursively() + //target.doInitialize(randomness) // we are copying params directly + + val x = RestIndividual(mutableListOf(options.copy(), target), SampleType.SECURITY) + x.doGlobalInitialize(searchGlobalState) + x.ensureFlattenedStructure() + + val ei = fitness.computeWholeAchievedCoverageForPostProcessing(x) + + if (ei == null) { + log.warn("Failed to evaluate OPTIONS+TARGET call") + continue@hidden + } + //whether it is going to be added depends on the response status of the "target" call + archive.addIfNeeded(ei) + } + } + } + + fun filterHiddenVerbs(path: RestPath, allowed: Set) : Set{ + return allowed.filter { it != HttpVerb.OPTIONS + && it != HttpVerb.HEAD + && !callGraph.isDeclared(it, path) + }.toSet() + } + /** * Checks whether any response body contains a stack trace, which would constitute a security issue. * Stack traces expose internal implementation details that can aid attackers in exploiting vulnerabilities. diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt index f90e48d33f..cd95c30020 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/fitness/AbstractRestFitness.kt @@ -31,8 +31,6 @@ import org.evomaster.core.problem.rest.link.RestLinkValueUpdater import org.evomaster.core.problem.rest.oracle.HttpSemanticsOracle import org.evomaster.core.problem.rest.oracle.RestSchemaOracle import org.evomaster.core.problem.rest.oracle.RestSecurityOracle -import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.XSS_PAYLOADS -import org.evomaster.core.problem.rest.oracle.RestSecurityOracle.hasSQLiPayload import org.evomaster.core.problem.rest.param.BodyParam import org.evomaster.core.problem.rest.param.HeaderParam import org.evomaster.core.problem.rest.param.QueryParam @@ -106,6 +104,10 @@ abstract class AbstractRestFitness : HttpWsFitness() { @Inject protected lateinit var executionStats: ExecutionStats + @Inject + protected lateinit var securityOracle: RestSecurityOracle + + //TODO refactor private lateinit var schemaOracle: RestSchemaOracle /** @@ -708,6 +710,7 @@ abstract class AbstractRestFitness : HttpWsFitness() { rcr.setStatusCode(response.status) rcr.setLocation(response.location?.toString()) + rcr.setAllow(response.allowedMethods.joinToString(",")) rcr.setAppliedLink(appliedLink) handlePossibleConnectionClose(response) @@ -1191,11 +1194,7 @@ abstract class AbstractRestFitness : HttpWsFitness() { //TODO likely would need to consider SEEDED as well in future if(config.security && individual.sampleType == SampleType.SECURITY){ - analyzeSecurityProperties(individual,actionResults,fv) - } - - if (config.isEnabledFaultCategory(DefinedFaultCategory.SSRF)) { - handleSsrfFaults(individual, actionResults, fv) + securityOracle.analyzeSecurityProperties(individual,actionResults,fv) } //TODO likely would need to consider SEEDED as well in future @@ -1269,402 +1268,6 @@ abstract class AbstractRestFitness : HttpWsFitness() { } } - private fun analyzeSecurityProperties( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ){ - //TODO the other cases - - handleForbiddenOperation(HttpVerb.DELETE, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) - handleForbiddenOperation(HttpVerb.PUT, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) - handleForbiddenOperation(HttpVerb.PATCH, DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION, individual, actionResults, fv) - handleExistenceLeakage(individual,actionResults,fv) - handleNotRecognizedAuthenticated(individual, actionResults, fv) - handleForgottenAuthentication(individual, actionResults, fv) - handleStackTraceCheck(individual, actionResults, fv) - handleSQLiCheck(individual, actionResults, fv) - handleXSSCheck(individual, actionResults, fv) - handleAnonymousWriteCheck(individual, actionResults, fv) - } - - private fun handleSsrfFaults( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if (!config.isEnabledFaultCategory(DefinedFaultCategory.SSRF)) { - return - } - - individual.seeMainExecutableActions().forEach { - val ar = (actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult?) - if (ar != null) { - if (ar.getResultValue(HttpWsCallResult.VULNERABLE_SSRF).toBoolean()) { - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(DefinedFaultCategory.SSRF, it.getName()) - ) - fv.updateTarget(scenarioId, 1.0, it.positionAmongMainActions()) - - val paramName = ssrfAnalyser.getVulnerableParameterName(it) - ar.addFault(DetectedFault(DefinedFaultCategory.SSRF, it.getName(), paramName)) - } - } - } - } - - private fun handleNotRecognizedAuthenticated( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED)) { - return - } - - if(actionResults.any { it.stopping }){ - return - } - - val notRecognized = individual.seeMainExecutableActions() - .filter { - val ar = actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult? - if(ar == null){ - log.warn("Missing action result with id: ${it.getLocalId()}}") - false - } else { - it.auth !is NoAuth && ar.getStatusCode() == 401 - } - } - .filter { - RestSecurityOracle.hasNotRecognizedAuthenticated(it, individual, actionResults) - } - - if(notRecognized.isEmpty()){ - return - } - - notRecognized.forEach { - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED, it.getName()) - ) - fv.updateTarget(scenarioId, 1.0, it.positionAmongMainActions()) - val r = actionResults.find { r -> r.sourceLocalId == it.getLocalId() } as RestCallResult - r.addFault(DetectedFault(DefinedFaultCategory.SECURITY_NOT_RECOGNIZED_AUTHENTICATED, it.getName(), null)) - } - } - - private fun handleExistenceLeakage( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE)) { - return - } - - val getPaths = individual.seeMainExecutableActions() - .filter { it.verb == HttpVerb.GET } - .map { it.path } - .toSet() - - val faultyPaths = getPaths.filter { - RestSecurityOracle.hasExistenceLeakage(it, individual, actionResults, actionDefinitions) - } - if(faultyPaths.isEmpty()){ - return - } - - for(index in individual.seeMainExecutableActions().indices){ - val a = individual.seeMainExecutableActions()[index] - val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult - - if(a.verb == HttpVerb.GET && faultyPaths.contains(a.path) && r.getStatusCode() == 404){ - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE, a.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - r.addFault(DetectedFault(DefinedFaultCategory.SECURITY_EXISTENCE_LEAKAGE, a.getName(), null)) - } - } - } - - private fun handleSQLiCheck( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if (!config.isEnabledFaultCategory(DefinedFaultCategory.SQL_INJECTION)) { - return - } - - val foundPair = findSQLiPayloadPair(individual) - - if(foundPair == null){ - //no pair found, cannot do baseline comparison - return - } - - val (actionWithoutPayload, actionWithPayload) = foundPair - val baselineResult = actionResults.find { it.sourceLocalId == actionWithoutPayload.getLocalId() } as? RestCallResult - ?: return - val baselineTime = baselineResult.getResponseTimeMs() ?: return - - val injectedResult = actionResults.find { it.sourceLocalId == actionWithPayload.getLocalId() } as? RestCallResult - ?: return - val injectedTime = injectedResult.getResponseTimeMs() - - - val K = config.sqliBaselineMaxResponseTimeMs // K: maximum allowed baseline response time - val N = config.sqliInjectedSleepDurationMs // N: expected delay introduced by the injected sleep payload - - // Baseline must be fast enough (baseline < K) - val baselineIsFast = baselineTime < K - - // Response after injection must be slow enough (response > N) - var responseIsSlowEnough: Boolean - - if (injectedTime != null) { - responseIsSlowEnough = injectedTime > N - } else if (injectedResult.getTimedout()){ - // if the injected request timed out, we can consider it vulnerable - responseIsSlowEnough = true - } else { - return - } - - // If baseline is fast AND the response after payload is slow enough, - // then we consider this a potential time-based SQL injection vulnerability. - // Otherwise, skip this result. - if (!(baselineIsFast && responseIsSlowEnough)) { - return - } - - // Find the index of the action with payload to report it correctly - val index = individual.seeMainExecutableActions().indexOf(actionWithPayload) - if (index < 0) { - log.warn("Failed to find index of action with SQLi payload") - return - } - - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(DefinedFaultCategory.SQL_INJECTION, actionWithPayload.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - injectedResult.addFault(DetectedFault(DefinedFaultCategory.SQL_INJECTION, actionWithPayload.getName(), null)) - injectedResult.setVulnerableForSQLI(true) - } - - /** - * Finds a pair of actions in the individual that have the same path and verb, - * where one contains a SQLi payload and the other does not. - * - * This is useful for comparing baseline response times (without payload) against - * response times with SQLi payload to detect time-based SQL injection vulnerabilities. - * - * @param individual The test individual to search - * @return A pair of (actionWithoutPayload, actionWithPayload), or null if no such pair exists - */ - private fun findSQLiPayloadPair( - individual: RestIndividual - ): Pair? { - - val actions = individual.seeMainExecutableActions() - .filterIsInstance() - - // Group actions by path and verb - val actionsByPathAndVerb = actions - .groupBy { it.path.toString() to it.verb } - - // Find a pair where one has SQLi payload and one doesn't - for ((pathVerb, actionsForPath) in actionsByPathAndVerb) { - if (actionsForPath.size < 2) continue - - val withPayload = actionsForPath.filter { - hasSQLiPayload(it, config.sqliInjectedSleepDurationMs/1000.0) - } - val withoutPayload = actionsForPath.filter { - !hasSQLiPayload(it, config.sqliInjectedSleepDurationMs/1000.0) - } - - if (withPayload.isNotEmpty() && withoutPayload.isNotEmpty()) { - return Pair(withoutPayload.first(), withPayload.first()) - } - } - - return null - } - - private fun handleStackTraceCheck( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if (!config.isEnabledFaultCategory(ExperimentalFaultCategory.LEAKED_STACK_TRACES)) { - return - } - - for(index in individual.seeMainExecutableActions().indices){ - val a = individual.seeMainExecutableActions()[index] - val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult? - //this can happen if an action timeout, or is stopped - ?: continue - - if(r.getStatusCode() == 500 && r.getBody() != null && StackTraceUtils.looksLikeStackTrace(r.getBody()!!)){ - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.LEAKED_STACK_TRACES, a.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - r.addFault(DetectedFault(ExperimentalFaultCategory.LEAKED_STACK_TRACES, a.getName(), null)) - } - } - } - - private fun handleXSSCheck( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if(!config.isEnabledFaultCategory(DefinedFaultCategory.XSS)){ - return - } - - // Check if this individual has XSS vulnerability - if(!RestSecurityOracle.hasXSS(individual, actionResults)){ - return - } - - // Find the action(s) where XSS payload appears in the response - for(index in individual.seeMainExecutableActions().indices){ - val a = individual.seeMainExecutableActions()[index] - val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as? RestCallResult - ?: continue - - if(!StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ - continue - } - - val responseBody = r.getBody() ?: continue - - // Check if any XSS payload is present in this response - for(payload in XSS_PAYLOADS){ - if(responseBody.contains(payload, ignoreCase = false)){ - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(DefinedFaultCategory.XSS, a.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - r.addFault(DetectedFault(DefinedFaultCategory.XSS, a.getName(), null)) - break // Only add one fault per action - } - } - } - } - - private fun handleAnonymousWriteCheck( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - if(!config.isEnabledFaultCategory(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS)){ - return - } - - // Get all write operation paths (PUT, PATCH, DELETE) - val writePaths = individual.seeMainExecutableActions() - .filter { it.verb == HttpVerb.PUT || it.verb == HttpVerb.PATCH || it.verb == HttpVerb.DELETE } - .map { it.path } - .toSet() - - val faultyPaths = writePaths.filter { RestSecurityOracle.hasAnonymousWrite(it, individual, actionResults) } - - if(faultyPaths.isEmpty()){ - return - } - - for(index in individual.seeMainExecutableActions().indices){ - val a = individual.seeMainExecutableActions()[index] - val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult - - if((a.verb == HttpVerb.PUT || a.verb == HttpVerb.PATCH || a.verb == HttpVerb.DELETE) - && faultyPaths.contains(a.path) - && a.auth is NoAuth - && StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ - - // For PUT, check if it's 201 (resource creation - might be OK) - if(a.verb == HttpVerb.PUT && r.getStatusCode() == 201){ - continue - } - - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS, a.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - r.addFault(DetectedFault(ExperimentalFaultCategory.ANONYMOUS_MODIFICATIONS, a.getName(), null)) - } - } - } - - private fun handleForgottenAuthentication( - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - - if (!config.isEnabledFaultCategory(ExperimentalFaultCategory.IGNORE_ANONYMOUS)) { - return - } - - val endpoints = individual.seeMainExecutableActions() - .map { it.getName() } - .toSet() - - val faultyEndpoints = endpoints.filter { RestSecurityOracle.hasForgottenAuthentication(it, individual, actionResults) } - - if(faultyEndpoints.isEmpty()){ - return - } - - for(index in individual.seeMainExecutableActions().indices){ - val a = individual.seeMainExecutableActions()[index] - val r = actionResults.find { it.sourceLocalId == a.getLocalId() } as RestCallResult - - if(a.auth is NoAuth && faultyEndpoints.contains(a.getName()) && StatusGroup.G_2xx.isInGroup(r.getStatusCode())){ - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(ExperimentalFaultCategory.IGNORE_ANONYMOUS, a.getName()) - ) - fv.updateTarget(scenarioId, 1.0, index) - r.addFault(DetectedFault(ExperimentalFaultCategory.IGNORE_ANONYMOUS, a.getName(), null)) - } - } - } - - private fun handleForbiddenOperation( - verb: HttpVerb, - faultCategory: FaultCategory, - individual: RestIndividual, - actionResults: List, - fv: FitnessValue - ) { - - if (!config.isEnabledFaultCategory(DefinedFaultCategory.SECURITY_WRONG_AUTHORIZATION)) { - return - } - - if (RestSecurityOracle.hasForbiddenOperation(verb, individual, actionResults)) { - val actionIndex = individual.size() - 1 - val action = individual.seeMainExecutableActions()[actionIndex] - val result = actionResults - .filterIsInstance() - .find { it.sourceLocalId == action.getLocalId() } - ?: return - - val scenarioId = idMapper.handleLocalTarget( - idMapper.getFaultDescriptiveId(faultCategory, action.getName()) - ) - fv.updateTarget(scenarioId, 1.0, actionIndex) - result.addFault(DetectedFault(faultCategory, action.getName(), null)) - } - } protected fun recordResponseData(individual: RestIndividual, actionResults: List) { diff --git a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/module/RestBaseModule.kt b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/module/RestBaseModule.kt index d579889bd3..0bf5142ba9 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/rest/service/module/RestBaseModule.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/rest/service/module/RestBaseModule.kt @@ -6,11 +6,12 @@ import org.evomaster.core.output.service.TestCaseWriter import org.evomaster.core.output.service.TestSuiteWriter import org.evomaster.core.problem.enterprise.service.EnterpriseModule import org.evomaster.core.problem.rest.data.RestIndividual +import org.evomaster.core.problem.rest.oracle.RestSecurityOracle import org.evomaster.core.problem.rest.service.AIResponseClassifier import org.evomaster.core.problem.rest.service.CallGraphService import org.evomaster.core.problem.rest.service.HttpSemanticsService import org.evomaster.core.problem.rest.service.RestIndividualBuilder -import org.evomaster.core.problem.rest.service.SecurityRest +import org.evomaster.core.problem.rest.service.RestSecurityBuilder import org.evomaster.core.search.service.Archive import org.evomaster.core.search.service.FlakinessDetector import org.evomaster.core.search.service.Minimizer @@ -28,9 +29,6 @@ open class RestBaseModule : EnterpriseModule() { bind(TestSuiteWriter::class.java) .asEagerSingleton() - bind(SecurityRest::class.java) - .asEagerSingleton() - bind(PirToRest::class.java) .asEagerSingleton() @@ -69,5 +67,11 @@ open class RestBaseModule : EnterpriseModule() { bind(CallGraphService::class.java) .asEagerSingleton() + + bind(RestSecurityOracle::class.java) + .asEagerSingleton() + + bind(RestSecurityBuilder::class.java) + .asEagerSingleton() } } diff --git a/core/src/main/kotlin/org/evomaster/core/problem/security/service/SSRFAnalyser.kt b/core/src/main/kotlin/org/evomaster/core/problem/security/service/SSRFAnalyser.kt index 46b1b7ef5a..2b93525b17 100644 --- a/core/src/main/kotlin/org/evomaster/core/problem/security/service/SSRFAnalyser.kt +++ b/core/src/main/kotlin/org/evomaster/core/problem/security/service/SSRFAnalyser.kt @@ -6,6 +6,7 @@ import org.evomaster.core.EMConfig import org.evomaster.core.languagemodel.service.LanguageModelConnector import org.evomaster.core.logging.LoggingUtil import org.evomaster.core.problem.api.param.Param +import org.evomaster.core.problem.enterprise.SampleType import org.evomaster.core.problem.rest.StatusGroup import org.evomaster.core.problem.rest.builder.RestIndividualSelectorUtils import org.evomaster.core.problem.rest.data.RestCallAction @@ -94,8 +95,7 @@ class SSRFAnalyser { /** * newly created individual will be in the archive */ - fun apply(){ //}: Solution { - //LoggingUtil.getInfoLogger().info("Applying {}", SSRFAnalyser::class.simpleName) + fun apply(){ val individualsWith2XX = getIndividualsWithStatus2XX() @@ -107,7 +107,6 @@ class SSRFAnalyser { individualsInSolution = individualsWith2XX + individualsWith4XX if (individualsInSolution.isEmpty()) { - //return archive.extractSolution() return } @@ -127,8 +126,6 @@ class SSRFAnalyser { // evaluate evaluate() - - //return archive.extractSolution() } fun anyCallsMadeToHTTPVerifier( @@ -269,21 +266,23 @@ class SSRFAnalyser { * Run the determined vulnerability class (from the classification) analysers. */ private fun evaluate() { - if (config.problemType == EMConfig.ProblemType.REST) { - - individualsInSolution.forEach { evaluatedIndividual -> - evaluatedIndividual.evaluatedMainActions().forEach { a -> - val action = a.action - if (action is RestCallAction) { - if (actionVulnerabilityMapping.containsKey(action.getName()) - && actionVulnerabilityMapping.getValue(action.getName()).isVulnerable - && evaluatedIndividual.individual is RestIndividual - ) { - val mapping = actionVulnerabilityMapping[action.getName()] - - if (mapping != null) { - handleVulnerableAction(evaluatedIndividual, action) - } + if (config.problemType != EMConfig.ProblemType.REST) { + + return + } + + individualsInSolution.forEach { evaluatedIndividual -> + evaluatedIndividual.evaluatedMainActions().forEach { a -> + val action = a.action + if (action is RestCallAction) { + if (actionVulnerabilityMapping.containsKey(action.getName()) + && actionVulnerabilityMapping.getValue(action.getName()).isVulnerable + && evaluatedIndividual.individual is RestIndividual + ) { + val mapping = actionVulnerabilityMapping[action.getName()] + + if (mapping != null) { + handleVulnerableAction(evaluatedIndividual, action) } } } @@ -296,6 +295,8 @@ class SSRFAnalyser { action: RestCallAction ) { val copy = evaluatedIndividual.individual.copy() as RestIndividual + copy.modifySampleType(SampleType.SECURITY) + // TODO: Need individual callback URL for each param? val callbackURL = httpCallbackVerifier.generateCallbackLink( action.getName() diff --git a/core/src/main/kotlin/org/evomaster/core/search/service/SearchGlobalState.kt b/core/src/main/kotlin/org/evomaster/core/search/service/SearchGlobalState.kt index 838e1aac55..6a77220331 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/service/SearchGlobalState.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/service/SearchGlobalState.kt @@ -37,12 +37,15 @@ class SearchGlobalState { @Inject lateinit var spa: StringSpecializationArchive + private set @Inject(optional = true) lateinit var browser: BrowserController + private set @Inject lateinit var externalServiceHandler: HttpWsExternalServiceHandler + private set @Inject lateinit var dataPool: DataPool @@ -51,4 +54,8 @@ class SearchGlobalState { @Inject lateinit var idMapper: IdMapper private set + + @Inject + lateinit var epc: ExecutionPhaseController + private set } diff --git a/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt b/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt index b1c2c441f6..a6bf1f3d71 100644 --- a/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt +++ b/core/src/main/kotlin/org/evomaster/core/search/service/Statistics.kt @@ -6,7 +6,9 @@ import org.evomaster.client.java.instrumentation.shared.ObjectiveNaming import org.evomaster.core.EMConfig import org.evomaster.core.output.service.PartialOracles import org.evomaster.core.problem.httpws.HttpWsCallResult +import org.evomaster.core.problem.rest.data.RestCallAction import org.evomaster.core.problem.rest.service.AIResponseClassifier +import org.evomaster.core.problem.rest.service.CallGraphService import org.evomaster.core.remote.service.RemoteController import org.evomaster.core.search.Solution import org.evomaster.core.utils.IncrementalAverage @@ -65,6 +67,9 @@ class Statistics : SearchListener { @Inject private lateinit var epc : ExecutionPhaseController + @Inject(optional = true) + private lateinit var callGraphService: CallGraphService + /** * How often test executions did timeout */ @@ -561,6 +566,8 @@ class Statistics : SearchListener { .filter { it.result is HttpWsCallResult && (it.result as HttpWsCallResult).getStatusCode()?.let { c -> c in 200..299 } ?: false } + // in phases like Security we might create calls that do not exist in schema + .filter{ it.action is RestCallAction && callGraphService.isDeclared(it.action.verb,it.action.path)} .map { it.action.getName() } .distinct() .count()