Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions appsec/tests/integration/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ dependencies {
implementation 'io.javalin:javalin:6.1.4'

implementation platform('org.testcontainers:testcontainers-bom:1.19.8')
implementation 'org.testcontainers:mysql'
implementation "org.testcontainers:junit-jupiter"
implementation 'com.flipkart.zjsonpatch:zjsonpatch:0.4.16'
implementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
implementation 'org.junit.jupiter:junit-jupiter-params:5.9.2'

testRuntimeOnly 'mysql:mysql-connector-java:8.0.28'
}

test {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,29 @@
package com.datadog.appsec.php.docker

import com.datadog.appsec.php.mock_agent.MockDatadogAgent
import org.testcontainers.containers.GenericContainer

class InspectContainerHelper {
static run(AppSecContainer container) {
static run(AppSecContainer container, GenericContainer... extraContainers) {
MockDatadogAgent agent = new MockDatadogAgent()
agent.start()

for (GenericContainer extraContainer : extraContainers) {
extraContainer.start()
}

container.start()
System.sleep 1_000 // so our output more likely shows at the bottom
System.out.println "Server available at ${container.buildURI('/')}"
System.out.println "Inspect container with docker exec -it ${container.getContainerId()} /bin/bash"
System.out.println "Press ENTER to stop container"
System.in.read()
container.stop()

for (GenericContainer extraContainer : extraContainers) {
extraContainer.stop()
}

agent.stop()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.datadog.appsec.php.model

import com.fasterxml.jackson.annotation.JsonProperty
import groovy.json.JsonSlurper

class Span {
@JsonProperty("trace_id")
Expand All @@ -23,4 +24,9 @@ class Span {
Map<String, String> meta
Map<String, Double> metrics
Map<String, String> meta_struct

Map<String, ?> getParsedAppsecJson() {
def s = meta?.get('_dd.appsec.json')
s ? new JsonSlurper().parseText(s) : null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
package com.datadog.appsec.php.integration

import com.datadog.appsec.php.docker.AppSecContainer
import com.datadog.appsec.php.docker.FailOnUnmatchedTraces
import com.datadog.appsec.php.docker.InspectContainerHelper
import com.datadog.appsec.php.model.Span
import groovy.util.logging.Slf4j
import org.junit.jupiter.api.MethodOrderer
import org.junit.jupiter.api.Order
import org.junit.jupiter.api.TestMethodOrder
import org.junit.jupiter.api.condition.EnabledIf
import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource
import org.testcontainers.containers.MySQLContainer
import org.testcontainers.containers.Network
import org.testcontainers.containers.wait.strategy.Wait
import org.testcontainers.junit.jupiter.Container
import org.testcontainers.junit.jupiter.Testcontainers

import java.net.http.HttpResponse
import java.util.stream.Stream

import static com.datadog.appsec.php.integration.TestParams.getPhpVersion
import static com.datadog.appsec.php.integration.TestParams.getVariant
import static com.datadog.appsec.php.integration.TestParams.phpVersionAtLeast
import static org.junit.jupiter.api.Assumptions.assumeTrue

@Testcontainers
@Slf4j
@TestMethodOrder(MethodOrderer.OrderAnnotation)
@EnabledIf('isEnabled')
class RaspSqliTests {
static boolean enabled = variant.contains('zts') && phpVersion == '8.4' ||
!variant.contains('zts') && phpVersion == '7.0'

private static Network network = Network.newNetwork()

@Container
@FailOnUnmatchedTraces
public static final AppSecContainer CONTAINER =
new AppSecContainer(
workVolume: this.name,
baseTag: 'nginx-fpm-php',
phpVersion: phpVersion,
phpVariant: variant,
www: 'base',
)
.withNetwork(network) as AppSecContainer


@Container
private static MySQLContainer MYSQL = new MySQLContainer('mysql:5.7')
.withDatabaseName('testdb')
.withUsername('testuser')
.withPassword('testpass')
.withInitScript('init.sql')
.withNetwork(network)
.withNetworkAliases('mysql')
.waitingFor(Wait.forLogMessage(".*ready for connections.*", 1)) as MySQLContainer

static void main(String[] args) {
InspectContainerHelper.run(CONTAINER, MYSQL)
}

static Stream<Arguments> getPdoFunctions() {
return Stream.of(
Arguments.of("query", "1", false),
Arguments.of("query", "1 OR 1=1", true),
Arguments.of("prepare", "x", false),
Arguments.of("prepare", "x' OR '1'='1", true),
Arguments.of("exec", "1", false),
Arguments.of("exec", "1; UPDATE users SET status='hacked'", true)
)
}

@ParameterizedTest
@MethodSource('getPdoFunctions')
@Order(1)
void 'test PDO SQL injection detection'(String function, String userInput, boolean isMalicious) {
String dsn = "mysql:host=mysql;dbname=" + MYSQL.databaseName
String username = MYSQL.username
String password = MYSQL.password

def encodedInput = URLEncoder.encode(userInput, "UTF-8")
def url = "/rasp_sqli_pdo.php?dsn=${dsn}&username=${username}&password=${password}&function=${function}&user_input=${encodedInput}"

def trace = CONTAINER.traceFromRequest(url) { HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
def content = resp.body().text
assert content.contains('OK')
log.info('Response content: {}', content)
}

Span span = trace.first()
assert span.metrics.'_dd.appsec.enabled' == 1.0d
assert span.metrics.'_dd.appsec.waf.duration' > 0.0d
assert span.metrics."_dd.appsec.rasp.rule.eval" >= 1.0d

if (!isMalicious) {
assert !span.meta.containsKey('_dd.appsec.json')
} else {
assert span.meta.containsKey('appsec.event') && span.meta.'appsec.event' == 'true'
assert span.meta_struct.containsKey("_dd.stack")

def appSecJson = span.parsedAppsecJson

assert appSecJson.triggers[0].rule_matches[0].operator == 'sqli_detector'
assert appSecJson.triggers[0].rule_matches[0].parameters[0].resource.value.size() > 1
assert appSecJson.triggers[0].rule_matches[0].parameters[0].params.value == userInput
assert appSecJson.triggers[0].rule_matches[0].parameters[0].db_type.value == 'mysql'
}
}


static Stream<Arguments> getMysqliFunctions() {
Stream.of(
Arguments.of("query", "1", false),
Arguments.of("query", "1 OR 1=1", true),
Arguments.of("real_query", "1", false),
Arguments.of("real_query", "1 UNION SELECT * FROM users", true),
Arguments.of("prepare", "x", false),
Arguments.of("prepare", "x' OR '1'='1", true),
Arguments.of("procedural", "1", false),
Arguments.of("procedural", "1 OR 1=1", true),
Arguments.of("execute_query", "1", false),
Arguments.of("execute_query", "1 OR 1=1", true),
Arguments.of("multi_query", "1", false),
Arguments.of("multi_query", "1; SELECT * FROM information_schema.tables", true)
)
}

@ParameterizedTest
@MethodSource('getMysqliFunctions')
@Order(2)
void 'test MySQLi SQL injection detection'(String function, String userInput, boolean isMalicious) {
if (function == 'execute_query') {
assumeTrue phpVersionAtLeast('8.2')
}

String host = "mysql"
String dbname = MYSQL.databaseName
String username = MYSQL.username
String password = MYSQL.password

def encodedInput = URLEncoder.encode(userInput, "UTF-8")
def url = "/rasp_sqli_mysqli.php?host=${host}&dbname=${dbname}&username=${username}&password=${password}&function=${function}&user_input=${encodedInput}"

def trace = CONTAINER.traceFromRequest(url) { HttpResponse<InputStream> resp ->
assert resp.statusCode() == 200
def content = resp.body().text
assert content.contains('OK')
log.info('Response content: {}', content)
}

Span span = trace.first()
assert span.metrics."_dd.appsec.enabled" == 1.0d
assert span.metrics."_dd.appsec.waf.duration" > 0.0d
assert span.metrics."_dd.appsec.rasp.rule.eval" >= 1.0d

if (!isMalicious) {
assert !span.meta.containsKey('_dd.appsec.json')
} else {
assert span.meta.containsKey('appsec.event') && span.meta.'appsec.event' == 'true'
assert span.meta_struct.containsKey("_dd.stack")
def appSecJson = span.parsedAppsecJson
assert appSecJson.triggers[0].rule_matches[0].operator == 'sqli_detector'
assert appSecJson.triggers[0].rule_matches[0].parameters[0].resource.value.size() > 1
assert appSecJson.triggers[0].rule_matches[0].parameters[0].params.value == userInput
assert appSecJson.triggers[0].rule_matches[0].parameters[0].db_type.value == 'mysql'
}
}
}
13 changes: 13 additions & 0 deletions appsec/tests/integration/src/test/resources/init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(255),
status VARCHAR(50) DEFAULT 'inactive',
last_login DATETIME
);

INSERT INTO users (username, password, email, status) VALUES
('admin', 'adminpass', '[email protected]', 'active'),
('user1', 'user1pass', '[email protected]', 'active'),
('user2', 'user2pass', '[email protected]', 'inactive');
Loading
Loading