Skip to content

Commit 9e16465

Browse files
authored
Merge pull request #3186 from DataDog/glopes/rasp-sqli
Detection/blocking of SQL injections through libddwaf
2 parents 0e70253 + ecdd79b commit 9e16465

File tree

10 files changed

+612
-106
lines changed

10 files changed

+612
-106
lines changed

appsec/tests/integration/build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ dependencies {
2929
implementation 'io.javalin:javalin:6.1.4'
3030

3131
implementation platform('org.testcontainers:testcontainers-bom:1.19.8')
32+
implementation 'org.testcontainers:mysql'
3233
implementation "org.testcontainers:junit-jupiter"
3334
implementation 'com.flipkart.zjsonpatch:zjsonpatch:0.4.16'
3435
implementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
3536
implementation 'org.junit.jupiter:junit-jupiter-params:5.9.2'
37+
38+
testRuntimeOnly 'mysql:mysql-connector-java:8.0.28'
3639
}
3740

3841
test {
Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,29 @@
11
package com.datadog.appsec.php.docker
22

33
import com.datadog.appsec.php.mock_agent.MockDatadogAgent
4+
import org.testcontainers.containers.GenericContainer
45

56
class InspectContainerHelper {
6-
static run(AppSecContainer container) {
7+
static run(AppSecContainer container, GenericContainer... extraContainers) {
78
MockDatadogAgent agent = new MockDatadogAgent()
89
agent.start()
10+
11+
for (GenericContainer extraContainer : extraContainers) {
12+
extraContainer.start()
13+
}
14+
915
container.start()
1016
System.sleep 1_000 // so our output more likely shows at the bottom
1117
System.out.println "Server available at ${container.buildURI('/')}"
1218
System.out.println "Inspect container with docker exec -it ${container.getContainerId()} /bin/bash"
1319
System.out.println "Press ENTER to stop container"
1420
System.in.read()
1521
container.stop()
22+
23+
for (GenericContainer extraContainer : extraContainers) {
24+
extraContainer.stop()
25+
}
26+
1627
agent.stop()
1728
}
1829
}

appsec/tests/integration/src/main/groovy/com/datadog/appsec/php/model/Span.groovy

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.datadog.appsec.php.model
22

33
import com.fasterxml.jackson.annotation.JsonProperty
4+
import groovy.json.JsonSlurper
45

56
class Span {
67
@JsonProperty("trace_id")
@@ -23,4 +24,9 @@ class Span {
2324
Map<String, String> meta
2425
Map<String, Double> metrics
2526
Map<String, String> meta_struct
27+
28+
Map<String, ?> getParsedAppsecJson() {
29+
def s = meta?.get('_dd.appsec.json')
30+
s ? new JsonSlurper().parseText(s) : null
31+
}
2632
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
package com.datadog.appsec.php.integration
2+
3+
import com.datadog.appsec.php.docker.AppSecContainer
4+
import com.datadog.appsec.php.docker.FailOnUnmatchedTraces
5+
import com.datadog.appsec.php.docker.InspectContainerHelper
6+
import com.datadog.appsec.php.model.Span
7+
import groovy.util.logging.Slf4j
8+
import org.junit.jupiter.api.MethodOrderer
9+
import org.junit.jupiter.api.Order
10+
import org.junit.jupiter.api.TestMethodOrder
11+
import org.junit.jupiter.api.condition.EnabledIf
12+
import org.junit.jupiter.params.ParameterizedTest
13+
import org.junit.jupiter.params.provider.Arguments
14+
import org.junit.jupiter.params.provider.MethodSource
15+
import org.testcontainers.containers.MySQLContainer
16+
import org.testcontainers.containers.Network
17+
import org.testcontainers.containers.wait.strategy.Wait
18+
import org.testcontainers.junit.jupiter.Container
19+
import org.testcontainers.junit.jupiter.Testcontainers
20+
21+
import java.net.http.HttpResponse
22+
import java.util.stream.Stream
23+
24+
import static com.datadog.appsec.php.integration.TestParams.getPhpVersion
25+
import static com.datadog.appsec.php.integration.TestParams.getVariant
26+
import static com.datadog.appsec.php.integration.TestParams.phpVersionAtLeast
27+
import static org.junit.jupiter.api.Assumptions.assumeTrue
28+
29+
@Testcontainers
30+
@Slf4j
31+
@TestMethodOrder(MethodOrderer.OrderAnnotation)
32+
@EnabledIf('isEnabled')
33+
class RaspSqliTests {
34+
static boolean enabled = variant.contains('zts') && phpVersion == '8.4' ||
35+
!variant.contains('zts') && phpVersion == '7.0'
36+
37+
private static Network network = Network.newNetwork()
38+
39+
@Container
40+
@FailOnUnmatchedTraces
41+
public static final AppSecContainer CONTAINER =
42+
new AppSecContainer(
43+
workVolume: this.name,
44+
baseTag: 'nginx-fpm-php',
45+
phpVersion: phpVersion,
46+
phpVariant: variant,
47+
www: 'base',
48+
)
49+
.withNetwork(network) as AppSecContainer
50+
51+
52+
@Container
53+
private static MySQLContainer MYSQL = new MySQLContainer('mysql:5.7')
54+
.withDatabaseName('testdb')
55+
.withUsername('testuser')
56+
.withPassword('testpass')
57+
.withInitScript('init.sql')
58+
.withNetwork(network)
59+
.withNetworkAliases('mysql')
60+
.waitingFor(Wait.forLogMessage(".*ready for connections.*", 1)) as MySQLContainer
61+
62+
static void main(String[] args) {
63+
InspectContainerHelper.run(CONTAINER, MYSQL)
64+
}
65+
66+
static Stream<Arguments> getPdoFunctions() {
67+
return Stream.of(
68+
Arguments.of("query", "1", false),
69+
Arguments.of("query", "1 OR 1=1", true),
70+
Arguments.of("prepare", "x", false),
71+
Arguments.of("prepare", "x' OR '1'='1", true),
72+
Arguments.of("exec", "1", false),
73+
Arguments.of("exec", "1; UPDATE users SET status='hacked'", true)
74+
)
75+
}
76+
77+
@ParameterizedTest
78+
@MethodSource('getPdoFunctions')
79+
@Order(1)
80+
void 'test PDO SQL injection detection'(String function, String userInput, boolean isMalicious) {
81+
String dsn = "mysql:host=mysql;dbname=" + MYSQL.databaseName
82+
String username = MYSQL.username
83+
String password = MYSQL.password
84+
85+
def encodedInput = URLEncoder.encode(userInput, "UTF-8")
86+
def url = "/rasp_sqli_pdo.php?dsn=${dsn}&username=${username}&password=${password}&function=${function}&user_input=${encodedInput}"
87+
88+
def trace = CONTAINER.traceFromRequest(url) { HttpResponse<InputStream> resp ->
89+
assert resp.statusCode() == 200
90+
def content = resp.body().text
91+
assert content.contains('OK')
92+
log.info('Response content: {}', content)
93+
}
94+
95+
Span span = trace.first()
96+
assert span.metrics.'_dd.appsec.enabled' == 1.0d
97+
assert span.metrics.'_dd.appsec.waf.duration' > 0.0d
98+
assert span.metrics."_dd.appsec.rasp.rule.eval" >= 1.0d
99+
100+
if (!isMalicious) {
101+
assert !span.meta.containsKey('_dd.appsec.json')
102+
} else {
103+
assert span.meta.containsKey('appsec.event') && span.meta.'appsec.event' == 'true'
104+
assert span.meta_struct.containsKey("_dd.stack")
105+
106+
def appSecJson = span.parsedAppsecJson
107+
108+
assert appSecJson.triggers[0].rule_matches[0].operator == 'sqli_detector'
109+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].resource.value.size() > 1
110+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].params.value == userInput
111+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].db_type.value == 'mysql'
112+
}
113+
}
114+
115+
116+
static Stream<Arguments> getMysqliFunctions() {
117+
Stream.of(
118+
Arguments.of("query", "1", false),
119+
Arguments.of("query", "1 OR 1=1", true),
120+
Arguments.of("real_query", "1", false),
121+
Arguments.of("real_query", "1 UNION SELECT * FROM users", true),
122+
Arguments.of("prepare", "x", false),
123+
Arguments.of("prepare", "x' OR '1'='1", true),
124+
Arguments.of("procedural", "1", false),
125+
Arguments.of("procedural", "1 OR 1=1", true),
126+
Arguments.of("execute_query", "1", false),
127+
Arguments.of("execute_query", "1 OR 1=1", true),
128+
Arguments.of("multi_query", "1", false),
129+
Arguments.of("multi_query", "1; SELECT * FROM information_schema.tables", true)
130+
)
131+
}
132+
133+
@ParameterizedTest
134+
@MethodSource('getMysqliFunctions')
135+
@Order(2)
136+
void 'test MySQLi SQL injection detection'(String function, String userInput, boolean isMalicious) {
137+
if (function == 'execute_query') {
138+
assumeTrue phpVersionAtLeast('8.2')
139+
}
140+
141+
String host = "mysql"
142+
String dbname = MYSQL.databaseName
143+
String username = MYSQL.username
144+
String password = MYSQL.password
145+
146+
def encodedInput = URLEncoder.encode(userInput, "UTF-8")
147+
def url = "/rasp_sqli_mysqli.php?host=${host}&dbname=${dbname}&username=${username}&password=${password}&function=${function}&user_input=${encodedInput}"
148+
149+
def trace = CONTAINER.traceFromRequest(url) { HttpResponse<InputStream> resp ->
150+
assert resp.statusCode() == 200
151+
def content = resp.body().text
152+
assert content.contains('OK')
153+
log.info('Response content: {}', content)
154+
}
155+
156+
Span span = trace.first()
157+
assert span.metrics."_dd.appsec.enabled" == 1.0d
158+
assert span.metrics."_dd.appsec.waf.duration" > 0.0d
159+
assert span.metrics."_dd.appsec.rasp.rule.eval" >= 1.0d
160+
161+
if (!isMalicious) {
162+
assert !span.meta.containsKey('_dd.appsec.json')
163+
} else {
164+
assert span.meta.containsKey('appsec.event') && span.meta.'appsec.event' == 'true'
165+
assert span.meta_struct.containsKey("_dd.stack")
166+
def appSecJson = span.parsedAppsecJson
167+
assert appSecJson.triggers[0].rule_matches[0].operator == 'sqli_detector'
168+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].resource.value.size() > 1
169+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].params.value == userInput
170+
assert appSecJson.triggers[0].rule_matches[0].parameters[0].db_type.value == 'mysql'
171+
}
172+
}
173+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
CREATE TABLE users (
2+
id INT AUTO_INCREMENT PRIMARY KEY,
3+
username VARCHAR(255) NOT NULL,
4+
password VARCHAR(255) NOT NULL,
5+
email VARCHAR(255),
6+
status VARCHAR(50) DEFAULT 'inactive',
7+
last_login DATETIME
8+
);
9+
10+
INSERT INTO users (username, password, email, status) VALUES
11+
('admin', 'adminpass', '[email protected]', 'active'),
12+
('user1', 'user1pass', '[email protected]', 'active'),
13+
('user2', 'user2pass', '[email protected]', 'inactive');

0 commit comments

Comments
 (0)