Skip to content

Commit

Permalink
graphql: Add tech detection for GraphQL Engines
Browse files Browse the repository at this point in the history
- CHANGELOG > Added note.
- build file > Added dependency.
- GraphQlFingerprinter > Updated to add Technology matches when GraphQL
engines are fingerprinted.
- ExtensionTechDetection > Added to facilitate optional dependence.
- ExtensionTechDetectionUnitTest > Tests :)
- Messages.properties > Added key/value pairs to support the optional
dependency.
- alert.html > Added note about the new functionality.

Signed-off-by: kingthorin <[email protected]>
# Conflicts:
#	addOns/graphql/CHANGELOG.md
# Conflicts:
#	addOns/graphql/CHANGELOG.md
  • Loading branch information
kingthorin committed Nov 11, 2024
1 parent e70e940 commit 2ddbab3
Show file tree
Hide file tree
Showing 10 changed files with 269 additions and 21 deletions.
1 change: 1 addition & 0 deletions addOns/graphql/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- tailcall
- Hot Chocolate
- Support for importing an introspection query response from a file (Issue 8569).
- If the Tech Detection (Wappalyzer) add-on is installed and a GraphQL engine is successfully finger printed it is added to the Technology tab/data.

## [0.25.0] - 2024-09-24
### Changed
Expand Down
14 changes: 14 additions & 0 deletions addOns/graphql/graphql.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ zapAddOn {
}
}
}

register("org.zaproxy.addon.graphql.techdetection.ExtensionTechDetection") {
classnames {
allowed.set(listOf("org.zaproxy.addon.graphql.techdetection"))
}
dependencies {
addOns {
register("wappalyzer") {
version.set(">= 21.43.0")
}
}
}
}
}
}

Expand All @@ -61,6 +74,7 @@ dependencies {
zapAddOn("automation")
zapAddOn("commonlib")
zapAddOn("spider")
zapAddOn("wappalyzer")

implementation("com.graphql-java:graphql-java:22.3")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
import org.parosproxy.paros.model.Session;
import org.parosproxy.paros.network.HttpSender;
import org.zaproxy.addon.commonlib.ExtensionCommonlib;
import org.zaproxy.addon.graphql.GraphQlFingerprinter.Engine;
import org.zaproxy.zap.extension.alert.ExampleAlertProvider;
import org.zaproxy.zap.extension.script.ExtensionScript;
import org.zaproxy.zap.model.ValueGenerator;
Expand Down Expand Up @@ -297,6 +298,6 @@ public boolean handleFile(File file) {
public List<Alert> getExampleAlerts() {
return List.of(
GraphQlParser.createIntrospectionAlert().build(),
GraphQlFingerprinter.createFingerprintingAlert("example").build());
GraphQlFingerprinter.createFingerprintingAlert(new Engine("example")).build());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.BiConsumer;
import java.util.function.BooleanSupplier;
import org.apache.commons.httpclient.URI;
import org.apache.logging.log4j.LogManager;
Expand All @@ -44,12 +45,14 @@ public class GraphQlFingerprinter {
CommonAlertTag.toMap(CommonAlertTag.WSTG_V42_INFO_02_FINGERPRINT_WEB_SERVER);
private static final Logger LOGGER = LogManager.getLogger(GraphQlFingerprinter.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final BiConsumer<URI, Engine> DEFAULT_APP_CONSUMER = (site, app) -> {};

private final Requestor requestor;
private final Map<String, HttpMessage> queryCache;

private HttpMessage lastQueryMsg;
private String matchedString;
private static BiConsumer<URI, Engine> appConsumer = DEFAULT_APP_CONSUMER;

public GraphQlFingerprinter(URI endpointUrl) {
requestor = new Requestor(endpointUrl, HttpSender.MANUAL_REQUEST_INITIATOR);
Expand Down Expand Up @@ -97,7 +100,9 @@ public void fingerprint() {
for (var fingerprinter : fingerprinters.entrySet()) {
try {
if (fingerprinter.getValue().getAsBoolean()) {
raiseFingerprintingAlert(fingerprinter.getKey());
Engine engine = new Engine(fingerprinter.getKey());
raiseFingerprintingAlert(engine);
tryConsumingTechDetection(lastQueryMsg.getRequestHeader().getURI(), engine);
break;
}
} catch (Exception e) {
Expand All @@ -107,6 +112,14 @@ public void fingerprint() {
queryCache.clear();
}

private static void tryConsumingTechDetection(URI uri, Engine engine) {
try {
appConsumer.accept(uri, engine);
} catch (Exception ex) {
LOGGER.warn("Unable to add consume: {}", engine.getName());
}
}

void sendQuery(String query) {
lastQueryMsg =
queryCache.computeIfAbsent(
Expand Down Expand Up @@ -149,18 +162,17 @@ boolean errorContains(String substring, String errorField) {
return false;
}

static Alert.Builder createFingerprintingAlert(String engineId) {
final String enginePrefix = "graphql.engine." + engineId + ".";
static Alert.Builder createFingerprintingAlert(Engine engine) {
return Alert.builder()
.setPluginId(ExtensionGraphQl.TOOL_ALERT_ID)
.setAlertRef(FINGERPRINTING_ALERT_REF)
.setName(Constant.messages.getString("graphql.fingerprinting.alert.name"))
.setDescription(
Constant.messages.getString(
"graphql.fingerprinting.alert.desc",
Constant.messages.getString(enginePrefix + "name"),
Constant.messages.getString(enginePrefix + "technologies")))
.setReference(Constant.messages.getString(enginePrefix + "docsUrl"))
engine.getName(),
engine.getTechnologies()))
.setReference(engine.getDocsUrl())
.setConfidence(Alert.CONFIDENCE_HIGH)
.setRisk(Alert.RISK_INFO)
.setCweId(205)
Expand All @@ -169,14 +181,15 @@ static Alert.Builder createFingerprintingAlert(String engineId) {
.setTags(FINGERPRINTING_ALERT_TAGS);
}

private void raiseFingerprintingAlert(String engineId) {
private void raiseFingerprintingAlert(Engine engine) {
var extAlert =
Control.getSingleton().getExtensionLoader().getExtension(ExtensionAlert.class);
if (extAlert == null) {
return;
}

Alert alert =
createFingerprintingAlert(engineId)
createFingerprintingAlert(engine)
.setEvidence(matchedString)
.setMessage(lastQueryMsg)
.setUri(requestor.getEndpointUrl().toString())
Expand Down Expand Up @@ -600,4 +613,36 @@ private boolean checkWpGraphQlEngine() {
}
return false;
}

public static void setAppConsumer(BiConsumer<URI, Engine> consumer) {
appConsumer = consumer == null ? DEFAULT_APP_CONSUMER : consumer;
}

public static class Engine {
private static final String PREFIX = "graphql.engine.";
private String enginePrefix;
private String name;
private String docsUrl;
private String technologies;

public Engine(String engineId) {
this.enginePrefix = PREFIX + engineId + ".";

this.name = Constant.messages.getString(enginePrefix + "name");
this.docsUrl = Constant.messages.getString(enginePrefix + "docsUrl");
this.technologies = Constant.messages.getString(enginePrefix + "technologies");
}

public String getName() {
return name;
}

public String getDocsUrl() {
return docsUrl;
}

public String getTechnologies() {
return technologies;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Zed Attack Proxy (ZAP) and its related class files.
*
* ZAP is an HTTP/HTTPS proxy for assessing web application security.
*
* Copyright 2024 The ZAP Development Team
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.zaproxy.addon.graphql.techdetection;

import java.util.List;
import org.apache.commons.httpclient.URI;
import org.parosproxy.paros.Constant;
import org.parosproxy.paros.control.Control;
import org.parosproxy.paros.extension.Extension;
import org.parosproxy.paros.extension.ExtensionAdaptor;
import org.parosproxy.paros.extension.ExtensionHook;
import org.zaproxy.addon.graphql.GraphQlFingerprinter;
import org.zaproxy.addon.graphql.GraphQlFingerprinter.Engine;
import org.zaproxy.zap.extension.wappalyzer.Application;
import org.zaproxy.zap.extension.wappalyzer.ApplicationMatch;
import org.zaproxy.zap.extension.wappalyzer.ExtensionWappalyzer;

public class ExtensionTechDetectionGraphQl extends ExtensionAdaptor {

public static final String NAME = "ExtensionTechDetectionGraphQl";

private static final List<Class<? extends Extension>> DEPENDENCIES =
List.of(ExtensionWappalyzer.class);

private static ExtensionWappalyzer extTech;

public ExtensionTechDetectionGraphQl() {
super(NAME);
}

@Override
public String getUIName() {
return Constant.messages.getString("graphql.techdetection.name");
}

@Override
public String getDescription() {
return Constant.messages.getString("graphql.techdetection.desc");
}

@Override
public List<Class<? extends Extension>> getDependencies() {
return DEPENDENCIES;
}

@Override
public void hook(ExtensionHook extensionHook) {
super.hook(extensionHook);
GraphQlFingerprinter.setAppConsumer(
(site, engine) -> ExtensionTechDetectionGraphQl.addApp(site, engine));
}

@Override
public boolean canUnload() {
return true;
}

@Override
public void unload() {
GraphQlFingerprinter.setAppConsumer(null);
}

private static ApplicationMatch getAppForEngine(Engine engine) {
Application gqlEgine = new Application();
gqlEgine.setName(engine.getName());
gqlEgine.setCategories(List.of("GraphQL Engine"));
gqlEgine.setWebsite(engine.getDocsUrl());
gqlEgine.setImplies(List.of(engine.getTechnologies()));

return new ApplicationMatch(gqlEgine);
}

private static void addApp(URI uri, Engine engine) {
getExtTech().addApplicationsToSite(uri, getAppForEngine(engine));
}

private static ExtensionWappalyzer getExtTech() {
if (extTech == null) {
extTech =
Control.getSingleton()
.getExtensionLoader()
.getExtension(ExtensionWappalyzer.class);
}
return extTech;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ <h1 id="id-50007">GraphQL Alerts</h1>
<td><a href="https://www.zaproxy.org/docs/alerts/50007-2/">50007-2</a>
<td>GraphQL Server Implementation Identified
<td>This alert is raised when the GraphQL implementation used by the server is identified. It utilises
fingerprinting techniques adapted from the tool <a href="https://github.com/dolevf/graphw00f">graphw00f</a>.
fingerprinting techniques adapted from the tool <a href="https://github.com/dolevf/graphw00f">graphw00f</a>.<br>
<strong>Note:</strong> If the Tech Detection (Wappalyzer) add-on is installed the finger printer will also add identified GraphQL Engines to the Technology tab/data.
<td><a href="https://github.com/zaproxy/zap-extensions/tree/main/addOns/graphql/src/main/java/org/zaproxy/addon/graphql/GraphQlFingerprinter.java">GraphQlFingerprinter.java</a>
</table>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,5 +254,8 @@ graphql.options.value.split.rootField = Each Field of an Operation
graphql.spider.desc = GraphQL Spider Integration
graphql.spider.name = GraphQL Spider

graphql.techdetection.desc = GraphQL Technology Detection Integration
graphql.techdetection.name = GraphQL Tech Detection

graphql.topmenu.import.importgraphql = Import a GraphQL Schema
graphql.topmenu.import.importgraphql.tooltip = Specify a GraphQL endpoint and optionally a GraphQL schema file to import.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
import org.parosproxy.paros.core.scanner.Alert;
import org.parosproxy.paros.extension.ExtensionLoader;
import org.parosproxy.paros.model.Model;
import org.zaproxy.addon.graphql.GraphQlFingerprinter.Engine;
import org.zaproxy.zap.extension.alert.ExtensionAlert;
import org.zaproxy.zap.testutils.NanoServerHandler;
import org.zaproxy.zap.testutils.StaticContentServerHandler;
Expand Down Expand Up @@ -185,7 +186,7 @@ static Stream<Arguments> fingerprintData() {
arguments(
"Apollo",
errorResponse("Directive \\\"@deprecated\\\" may not be used on QUERY.")),
arguments("AWS", errorResponse("MisplacedDirective")),
arguments("AWS AppSync", errorResponse("MisplacedDirective")),
arguments("Hasura", "{ \"data\": { \"__typename\":\"query_root\" } }"),
arguments(
"Hasura",
Expand Down Expand Up @@ -232,33 +233,34 @@ static Stream<Arguments> fingerprintData() {
errorResponse(
"Validation error of type UnknownDirective: Unknown directive deprecated @ '__typename'")),
arguments(
"ruby",
"graphql-ruby",
errorResponse(
"'@skip' can't be applied to queries (allowed: fields, fragment spreads, inline fragments)")),
arguments(
"ruby",
"graphql-ruby",
errorResponse("Directive 'skip' is missing required arguments: if")),
arguments("ruby", errorResponse("'@deprecated' can't be applied to queries")),
arguments("ruby", errorResponse("Parse error on \\\"}\\\" (RCURLY)")),
arguments(
"ruby",
"graphql-ruby", errorResponse("'@deprecated' can't be applied to queries")),
arguments("graphql-ruby", errorResponse("Parse error on \\\"}\\\" (RCURLY)")),
arguments(
"graphql-ruby",
errorResponse("Directive 'skip' is missing required arguments: if")),
arguments(
"PHP",
"graphql-php",
errorResponse(
"Directive \\\"deprecated\\\" may not be used on \\\"QUERY\\\".")),
arguments("gqlgen", errorResponse("expected at least one definition")),
arguments("gqlgen", errorResponse("Expected Name, found <Invalid>")),
arguments("Go", errorResponse("Unexpected empty IN")),
arguments("Go", errorResponse("Must provide an operation.")),
arguments("Go", "{ \"data\": { \"__typename\":\"RootQuery\" } }"),
arguments("graphql-go", errorResponse("Unexpected empty IN")),
arguments("graphql-go", errorResponse("Must provide an operation.")),
arguments("graphql-go", "{ \"data\": { \"__typename\":\"RootQuery\" } }"),
arguments("Juniper", errorResponse("Unexpected \\\"queryy\\\"")),
arguments("Juniper", errorResponse("Unexpected end of input")),
arguments(
"Sangria",
"{ \"syntaxError\" : \"Syntax error while parsing GraphQL query. Invalid input \\\"queryy\\\", expected ExecutableDefinition or TypeSystemDefinition\" }"),
arguments(
"Flutter",
"graphql-flutter",
errorResponse("Directive \\\"deprecated\\\" may not be used on FIELD.")),
arguments(
"Diana.jl",
Expand Down Expand Up @@ -309,13 +311,20 @@ private static String errorResponse(String error, String field, boolean data) {
+ " }";
}

@SuppressWarnings("null")
@ParameterizedTest
@MethodSource("fingerprintData")
void shouldFingerprintValidData(String graphqlImpl, String response) throws Exception {
// Given
ExtensionAlert extensionAlert = mockExtensionAlert();
nano.addHandler(new GraphQlResponseHandler(response));
var fp = new GraphQlFingerprinter(UrlBuilder.build(endpointUrl));
Object[] arguments = new Object[2];
GraphQlFingerprinter.setAppConsumer(
(site, engine) -> {
arguments[0] = site.toString();
arguments[1] = engine;
});
// When
fp.fingerprint();
// Then
Expand All @@ -324,6 +333,9 @@ void shouldFingerprintValidData(String graphqlImpl, String response) throws Exce
Alert alert = alertArgCaptor.getValue();
assertThat(alert, is(notNullValue()));
assertThat(alert.getDescription(), containsString(graphqlImpl));
// Check "consumed" values
assertThat((String) arguments[0], is(equalTo(endpointUrl)));
assertThat(graphqlImpl, is(equalTo(((Engine) arguments[1]).getName())));
}

private static ExtensionAlert mockExtensionAlert() {
Expand Down
Loading

0 comments on commit 2ddbab3

Please sign in to comment.