Skip to content
Open
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: 2 additions & 1 deletion addOns/graphql/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ All notable changes to this add-on will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Changed
- Added specificity handling to GraphQL engine fingerprinting to improve performance in some circumstances and reduce false positives.

## [0.30.0] - 2026-02-03
### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
Expand All @@ -40,6 +41,30 @@

public class GraphQlFingerprinter {

/**
* A fingerprint check with its reliability score (0-100, higher = more specific).
*
* @param check The fingerprinting function that returns true if framework matches
* @param specificityScore Reliability score: 90-95 highly specific, 60-69 generic
*/
private record FingerprintCheck(BooleanSupplier check, int specificityScore) {

private static final int MIN_SCORE = 0;
private static final int MAX_SCORE = 100;

/**
* Creates a fingerprint check with score validation.
*
* @throws IllegalArgumentException if specificityScore is not in range [0, 100]
*/
public FingerprintCheck {
if (specificityScore < MIN_SCORE || specificityScore > MAX_SCORE) {
throw new IllegalArgumentException(
"Specificity score must be in range [0, 100], got: " + specificityScore);
}
}
}

private static final String FINGERPRINTING_ALERT_REF = ExtensionGraphQl.TOOL_ALERT_ID + "-2";
private static final Map<String, String> FINGERPRINTING_ALERT_TAGS =
CommonAlertTag.toMap(CommonAlertTag.WSTG_V42_INFO_02_FINGERPRINT_WEB_SERVER);
Expand All @@ -62,61 +87,128 @@ public GraphQlFingerprinter(URI endpointUrl, Requestor requestor) {
queryCache = new HashMap<>();
}

/**
* Performs GraphQL framework fingerprinting using pattern-based detection.
*
* <p>Sends malformed queries and analyzes error responses to identify framework-specific
* patterns. Framework checks are ordered by specificity score, and the first successful match
* is used.
*
* @see #performPatternBasedDetection()
*/
public void fingerprint() {
Map<String, BooleanSupplier> fingerprinters = new LinkedHashMap<>();
// TODO: Check whether the order of the fingerprint checks matters.
fingerprinters.put("lighthouse", this::checkLighthouseEngine);
fingerprinters.put("caliban", this::checkCalibanEngine);
fingerprinters.put("lacinia", this::checkLaciniaEngine);
fingerprinters.put("jaal", this::checkJaalEngine);
fingerprinters.put("morpheus", this::checkMorpheusEngine);
fingerprinters.put("mercurius", this::checkMercuriusEngine);
fingerprinters.put("graphql-yoga", this::checkGraphQlYogaEngine);
fingerprinters.put("agoo", this::checkAgooEngine);
fingerprinters.put("dgraph", this::checkDgraphEngine);
fingerprinters.put("graphene", this::checkGrapheneEngine);
fingerprinters.put("ariadne", this::checkAriadneEngine);
fingerprinters.put("apollo", this::checkApolloEngine);
fingerprinters.put("aws-appsync", this::checkAwsAppSyncEngine);
fingerprinters.put("hasura", this::checkHasuraEngine);
fingerprinters.put("wpgraphql", this::checkWpGraphQlEngine);
fingerprinters.put("graphql-by-pop", this::checkGraphQlByPopEngine);
fingerprinters.put("graphql-java", this::checkGraphQlJavaEngine);
fingerprinters.put("hypergraphql", this::checkHyperGraphQlEngine);
fingerprinters.put("graphql-ruby", this::checkGraphQlRubyEngine);
fingerprinters.put("graphql-php", this::checkGraphQlPhpEngine);
fingerprinters.put("gqlgen", this::checkGqlGenEngine);
fingerprinters.put("graphql-go", this::checkGraphQlGoEngine);
fingerprinters.put("juniper", this::checkJuniperEngine);
fingerprinters.put("sangria", this::checkSangriaEngine);
fingerprinters.put("graphql-flutter", this::checkFlutterEngine);
fingerprinters.put("dianajl", this::checkDianajlEngine);
fingerprinters.put("strawberry", this::checkStrawberryEngine);
fingerprinters.put("tartiflette", this::checkTartifletteEngine);
fingerprinters.put("directus", this::checkDirectusEngine);
fingerprinters.put("absinthe", this::checkAbsintheEngine);
fingerprinters.put("graphql-dotnet", this::checkGraphqlDotNetEngine);
fingerprinters.put("pg_graphql", this::checkPgGraphqlEngine);
fingerprinters.put("tailcall", this::checkTailcallEngine);
fingerprinters.put("hotchocolate", this::checkHotchocolateEngine);
fingerprinters.put("inigo", this::checkInigoEngine);

for (var fingerprinter : fingerprinters.entrySet()) {
String detectedFramework = performPatternBasedDetection();

if (detectedFramework != null) {
raiseAlertForFramework(detectedFramework);
}

matchedString = null;
queryCache.clear();
}
Comment thread
kingthorin marked this conversation as resolved.

/**
* Performs pattern-based detection using error message analysis.
*
* <p>Sends malformed queries and analyzes error responses to identify framework-specific
* patterns. Frameworks are checked in descending specificity order based on their specificity
* scores, and the first framework whose check succeeds is returned. The specificity scores are
* used only to determine the order of evaluation; any successful check is treated as a match.
*
* @return The detected framework name, or {@code null} if no framework matches
*/
private String performPatternBasedDetection() {
Map<String, FingerprintCheck> fingerprinters = new LinkedHashMap<>();

// Register checks with specificity scores (higher = more specific/reliable)
// Scores range from 50 (generic errors) to 95 (highly unique patterns)

// Tier A: Highly specific patterns (90-95)
fingerprinters.put("tartiflette", new FingerprintCheck(this::checkTartifletteEngine, 95));
fingerprinters.put("hasura", new FingerprintCheck(this::checkHasuraEngine, 90));
fingerprinters.put("dgraph", new FingerprintCheck(this::checkDgraphEngine, 90));
fingerprinters.put("directus", new FingerprintCheck(this::checkDirectusEngine, 90));
fingerprinters.put("inigo", new FingerprintCheck(this::checkInigoEngine, 90));

// Tier B: Very specific patterns (80-89)
fingerprinters.put(
"graphql-by-pop", new FingerprintCheck(this::checkGraphQlByPopEngine, 85));
fingerprinters.put("wpgraphql", new FingerprintCheck(this::checkWpGraphQlEngine, 85));
fingerprinters.put("absinthe", new FingerprintCheck(this::checkAbsintheEngine, 80));
fingerprinters.put("lacinia", new FingerprintCheck(this::checkLaciniaEngine, 80));
fingerprinters.put("sangria", new FingerprintCheck(this::checkSangriaEngine, 80));

// Tier C: Moderately specific patterns (70-79)
fingerprinters.put("caliban", new FingerprintCheck(this::checkCalibanEngine, 75));
fingerprinters.put("strawberry", new FingerprintCheck(this::checkStrawberryEngine, 75));
fingerprinters.put("ariadne", new FingerprintCheck(this::checkAriadneEngine, 75));
fingerprinters.put("graphql-java", new FingerprintCheck(this::checkGraphQlJavaEngine, 70));
fingerprinters.put(
"graphql-dotnet", new FingerprintCheck(this::checkGraphqlDotNetEngine, 70));
fingerprinters.put("graphql-ruby", new FingerprintCheck(this::checkGraphQlRubyEngine, 70));
fingerprinters.put("graphql-php", new FingerprintCheck(this::checkGraphQlPhpEngine, 70));
fingerprinters.put("gqlgen", new FingerprintCheck(this::checkGqlGenEngine, 70));
fingerprinters.put("graphql-go", new FingerprintCheck(this::checkGraphQlGoEngine, 70));
fingerprinters.put("juniper", new FingerprintCheck(this::checkJuniperEngine, 70));
fingerprinters.put("hotchocolate", new FingerprintCheck(this::checkHotchocolateEngine, 70));
fingerprinters.put("pg_graphql", new FingerprintCheck(this::checkPgGraphqlEngine, 70));
fingerprinters.put("tailcall", new FingerprintCheck(this::checkTailcallEngine, 70));

// Tier D: Generic patterns (60-69)
fingerprinters.put("graphene", new FingerprintCheck(this::checkGrapheneEngine, 65));
fingerprinters.put("graphql-yoga", new FingerprintCheck(this::checkGraphQlYogaEngine, 65));
fingerprinters.put("aws-appsync", new FingerprintCheck(this::checkAwsAppSyncEngine, 65));
fingerprinters.put("hypergraphql", new FingerprintCheck(this::checkHyperGraphQlEngine, 65));
fingerprinters.put("graphql-flutter", new FingerprintCheck(this::checkFlutterEngine, 65));
fingerprinters.put("dianajl", new FingerprintCheck(this::checkDianajlEngine, 65));
fingerprinters.put("morpheus", new FingerprintCheck(this::checkMorpheusEngine, 65));
fingerprinters.put("apollo", new FingerprintCheck(this::checkApolloEngine, 60));
fingerprinters.put("mercurius", new FingerprintCheck(this::checkMercuriusEngine, 60));
fingerprinters.put("jaal", new FingerprintCheck(this::checkJaalEngine, 60));
fingerprinters.put("agoo", new FingerprintCheck(this::checkAgooEngine, 65));

// Tier E: Very generic patterns (50-59) - prone to false positives
fingerprinters.put("lighthouse", new FingerprintCheck(this::checkLighthouseEngine, 50));

// Iterate checks in descending score order and return on first match
// This ensures we check high-confidence patterns first and can early-exit
var sortedFingerprinters =
fingerprinters.entrySet().stream()
.sorted(
Map.Entry.comparingByValue(
Comparator.comparingInt(FingerprintCheck::specificityScore)
.reversed()))
.toList();

for (var fingerprinter : sortedFingerprinters) {
try {
if (fingerprinter.getValue().getAsBoolean()) {
DiscoveredGraphQlEngine discoveredGraphQlEngine =
new DiscoveredGraphQlEngine(
fingerprinter.getKey(),
lastQueryMsg.getRequestHeader().getURI());
handleDetectedEngine(discoveredGraphQlEngine);
raiseFingerprintingAlert(discoveredGraphQlEngine);
break;
if (fingerprinter.getValue().check().getAsBoolean()) {
String framework = fingerprinter.getKey();
LOGGER.debug(
"Detected GraphQL engine: {} (specificity score: {})",
framework,
fingerprinter.getValue().specificityScore());
return framework;
}
} catch (Exception e) {
LOGGER.warn("Failed to fingerprint GraphQL engine: {}", fingerprinter.getKey(), e);
}
}
queryCache.clear();

LOGGER.debug("No framework match found");
return null;
}

/**
* Helper method to raise fingerprinting alert for detected framework.
*
* @param framework The detected framework name
*/
private void raiseAlertForFramework(String framework) {
DiscoveredGraphQlEngine discoveredGraphQlEngine =
new DiscoveredGraphQlEngine(framework, lastQueryMsg.getRequestHeader().getURI());
handleDetectedEngine(discoveredGraphQlEngine);
raiseFingerprintingAlert(discoveredGraphQlEngine);
}

private static void handleDetectedEngine(DiscoveredGraphQlEngine discoveredGraphQlEngine) {
Expand Down Expand Up @@ -564,6 +656,9 @@ private boolean checkSangriaEngine() {

private boolean checkStrawberryEngine() {
sendQuery("query @deprecated {__typename}");
if (lastQueryMsg == null || !lastQueryMsg.getResponseHeader().isJson()) {
return false;
}
String response = lastQueryMsg.getResponseBody().toString();
try {
return errorContains("Directive '@deprecated' may not be used on query.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
Expand Down Expand Up @@ -60,6 +61,7 @@
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.ArgumentCaptor;
import org.mockito.quality.Strictness;
import org.parosproxy.paros.control.Control;
import org.parosproxy.paros.core.scanner.Alert;
Expand Down Expand Up @@ -184,9 +186,9 @@ void shouldFingerprintWithInvalidData() throws Exception {
var fp = buildFingerprinter(endpointUrl);
// When
fp.fingerprint();
// Then
assertThat(nano.getRequestedUris(), hasSize(27));
// Then - no framework detected, and no errors logged
verifyNoInteractions(extensionAlert);
assertNoLoggedErrors(writer.toString());
}

static Stream<Arguments> fingerprintData() {
Expand Down Expand Up @@ -400,10 +402,93 @@ void shouldStaticallyAddHandlerWithoutException() throws Exception {
assertDoesNotThrow(() -> GraphQlFingerprinter.addEngineHandler(handler::add));
}

@Test
void shouldPreferHigherSpecificityFrameworkWhenMultipleMatch() throws Exception {
// Given
ExtensionAlert extensionAlert = mockExtensionAlert();
// Handler returns responses that match BOTH Tartiflette (score 95) and Lighthouse (score
// 50)
nano.addHandler(
new NanoServerHandler("/graphql") {
@Override
protected Response serve(IHTTPSession session) {
String body = getBody(session);
// Tartiflette check sends "query @doesnotexist {__typename}"
if (body != null && body.contains("@doesnotexist")) {
return newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
"{\"errors\": [{\"message\": \"Unknow Directive < @doesnotexist >.\"}]}");
}
// Lighthouse check sends "{__typename @include(if: falsee)}"
if (body != null && body.contains("@include(if: falsee)")) {
return newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
"{\"errors\": [{\"message\": \"Internal server error\"}]}");
}
// Default response - use a non-matching typename to avoid Dgraph detection
return newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
"{\"data\": {\"__typename\": \"RootQuery\"}}");
}
});
var fp = buildFingerprinter(endpointUrl);
List<DiscoveredGraphQlEngine> discoveredEngines = new ArrayList<>(1);
GraphQlFingerprinter.addEngineHandler(discoveredEngines::add);
// When
fp.fingerprint();
// Then - tartiflette (score 95) should be detected, not Lighthouse (score 50)
assertNoLoggedErrors(writer.toString());
assertThat(discoveredEngines, hasSize(1));
assertThat(discoveredEngines.get(0).getName(), is(equalTo("tartiflette")));
// Verify the alert was raised with Tartiflette evidence
ArgumentCaptor<Alert> alertCaptor = ArgumentCaptor.forClass(Alert.class);
verify(extensionAlert).alertFound(alertCaptor.capture(), isNull());
assertThat(
alertCaptor.getValue().getEvidence(),
is(equalTo("Unknow Directive < @doesnotexist >.")));
}

@Test
void shouldDetectJuniperViaPatternDetection() throws Exception {
// Given
ExtensionAlert extensionAlert = mockExtensionAlert();
nano.addHandler(
new NanoServerHandler("/graphql") {
@Override
protected Response serve(IHTTPSession session) {
consumeBody(session);
return newFixedLengthResponse(
NanoHTTPD.Response.Status.OK,
"application/json",
"{\"errors\": [{\"message\": \"Unexpected \\\"queryy\\\"\"}]}");
}
});
var fp = buildFingerprinter(endpointUrl);
List<DiscoveredGraphQlEngine> discoveredEngine = new ArrayList<>(1);
GraphQlFingerprinter.addEngineHandler(discoveredEngine::add);
// When
fp.fingerprint();
// Then - Juniper detected via pattern, no errors
assertNoLoggedErrors(writer.toString());
assertThat(discoveredEngine.get(0).getName(), is(equalTo("Juniper")));
// Verify evidence is from the error message pattern
ArgumentCaptor<Alert> alertCaptor = ArgumentCaptor.forClass(Alert.class);
verify(extensionAlert).alertFound(alertCaptor.capture(), isNull());
assertThat(alertCaptor.getValue().getEvidence(), is(equalTo("Unexpected \"queryy\"")));
}

private static void assertNoErrors(ExtensionAlert extMock, String loggerOutput) {
assertNoLoggedErrors(loggerOutput);
verify(extMock, times(1)).alertFound(any(), isNull());
}

private static void assertNoLoggedErrors(String loggerOutput) {
assertThat(loggerOutput, not(containsString("WARN")));
assertThat(loggerOutput, not(containsString("ERROR")));
assertThat(loggerOutput, not(containsString("Null")));
verify(extMock, times(1)).alertFound(any(), any());
}

private static ExtensionAlert mockExtensionAlert() {
Expand Down