From 444aaf021a381070af3173ed0ff243417783b186 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Tue, 21 Oct 2025 12:14:58 -0400 Subject: [PATCH 1/2] Flag unencrypted communications (#168) --- packages/backend/src/checks/index.ts | 3 + .../unencrypted-communications/index.spec.ts | 70 +++++++++++++++++++ .../unencrypted-communications/index.ts | 56 +++++++++++++++ packages/backend/src/stores/config.ts | 8 +++ 4 files changed, 137 insertions(+) create mode 100644 packages/backend/src/checks/unencrypted-communications/index.spec.ts create mode 100644 packages/backend/src/checks/unencrypted-communications/index.ts diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index 1c28c38..e9b4e63 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -29,6 +29,7 @@ import { basicReflectedXSSScan } from "./reflected-xss"; import robotsTxtScan from "./robots-txt"; import { mysqlErrorBased } from "./sql-injection"; import sqlStatementInParams from "./sql-statement-in-params"; +import unencryptedCommunicationsScan from "./unencrypted-communications"; import ssnDisclosureScan from "./ssn-disclosure"; import sstiScan from "./ssti"; import suspectTransformScan from "./suspect-transform"; @@ -71,6 +72,7 @@ export const Checks = { SQL_STATEMENT_IN_PARAMS: "sql-statement-in-params", SSN_DISCLOSURE: "ssn-disclosure", SUSPECT_TRANSFORM: "suspect-transform", + UNENCRYPTED_COMMUNICATIONS: "unencrypted-communications", // MYSQL_TIME_BASED_SQLI: "mysql-time-based-sqli" - TODO: fix false positives } as const; @@ -111,5 +113,6 @@ export const checks = [ sqlStatementInParams, ssnDisclosureScan, suspectTransformScan, + unencryptedCommunicationsScan, // mysqlTimeBased, ] as const; diff --git a/packages/backend/src/checks/unencrypted-communications/index.spec.ts b/packages/backend/src/checks/unencrypted-communications/index.spec.ts new file mode 100644 index 0000000..e82c41e --- /dev/null +++ b/packages/backend/src/checks/unencrypted-communications/index.spec.ts @@ -0,0 +1,70 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import unencryptedCheck from "./index"; + +describe("Unencrypted communications check", () => { + it("raises finding for HTTP requests", async () => { + const request = createMockRequest({ + id: "req-http", + host: "example.com", + method: "GET", + path: "/", + tls: false, + }); + + const response = createMockResponse({ + id: "res-http", + code: 200, + headers: { "content-type": ["text/html"] }, + body: "OK", + }); + + const executionHistory = await runCheck(unencryptedCheck, [ + { request, response }, + ]); + + expect(executionHistory).toMatchObject([ + { + checkId: "unencrypted-communications", + targetRequestId: "req-http", + steps: [ + { + stepName: "detectUnencrypted", + findings: [ + { + name: "Unencrypted HTTP communication", + severity: "high", + }, + ], + }, + ], + }, + ]); + }); + + it("does not flag HTTPS traffic", async () => { + const request = createMockRequest({ + id: "req-https", + host: "example.com", + method: "GET", + path: "/", + tls: true, + }); + + const response = createMockResponse({ + id: "res-https", + code: 200, + headers: { "content-type": ["text/html"] }, + body: "OK", + }); + + const executionHistory = await runCheck(unencryptedCheck, [ + { request, response }, + ]); + + const lastStep = + executionHistory[0]?.steps[executionHistory[0].steps.length - 1]; + expect(lastStep?.findings ?? []).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/unencrypted-communications/index.ts b/packages/backend/src/checks/unencrypted-communications/index.ts new file mode 100644 index 0000000..ed068e1 --- /dev/null +++ b/packages/backend/src/checks/unencrypted-communications/index.ts @@ -0,0 +1,56 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils"; + +export default defineCheck(({ step }) => { + step("detectUnencrypted", (state, context) => { + const { request } = context.target; + + if (request.getTls()) { + return done({ state }); + } + + const host = request.getHost(); + const description = [ + "The request was observed over an unencrypted HTTP connection.", + "", + `**Host:** \`${host}\``, + "", + "Sensitive information transmitted over HTTP can be intercepted or modified by attackers on the network.", + "", + "**Recommendation:** Serve this content over HTTPS and enforce HSTS to ensure clients always use TLS.", + ].join("\n"); + + return done({ + state, + findings: [ + { + name: "Unencrypted HTTP communication", + description, + severity: Severity.HIGH, + correlation: { + requestID: request.getId(), + locations: [], + }, + }, + ], + }); + }); + + return { + metadata: { + id: "unencrypted-communications", + name: "Unencrypted communications", + description: + "Alerts when HTTP requests are observed without TLS protection", + type: "passive", + tags: [Tags.TLS, Tags.SECURE], + severities: [Severity.HIGH], + aggressivity: { minRequests: 0, maxRequests: 0 }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPort().build(), + when: () => true, + }; +}); diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index 6b716b7..fe2057e 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -188,6 +188,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.UNENCRYPTED_COMMUNICATIONS, + enabled: false, + }, ], }, { @@ -371,6 +375,10 @@ export class ConfigStore { checkID: Checks.MISSING_CONTENT_TYPE, enabled: true, }, + { + checkID: Checks.UNENCRYPTED_COMMUNICATIONS, + enabled: true, + }, ], }, { From 94ca6be4c72b9e5257ae53bec098caa8bfa27353 Mon Sep 17 00:00:00 2001 From: Joseph Thacker Date: Thu, 23 Oct 2025 14:02:25 -0400 Subject: [PATCH 2/2] feat: detect graphql introspection (#95) --- .../graphql-introspection/index.spec.ts | 69 ++++++++++ .../src/checks/graphql-introspection/index.ts | 121 ++++++++++++++++++ packages/backend/src/checks/index.ts | 5 +- packages/backend/src/stores/config.ts | 4 + 4 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 packages/backend/src/checks/graphql-introspection/index.spec.ts create mode 100644 packages/backend/src/checks/graphql-introspection/index.ts diff --git a/packages/backend/src/checks/graphql-introspection/index.spec.ts b/packages/backend/src/checks/graphql-introspection/index.spec.ts new file mode 100644 index 0000000..46e1ec4 --- /dev/null +++ b/packages/backend/src/checks/graphql-introspection/index.spec.ts @@ -0,0 +1,69 @@ +import { createMockRequest, createMockResponse, runCheck } from "engine"; +import { describe, expect, it } from "vitest"; + +import graphqlIntrospectionCheck from "./index"; + +const executeCheck = async (body: string): Promise => { + const request = createMockRequest({ + id: "req-graphql", + host: "example.com", + method: "POST", + path: "/graphql", + headers: { Host: ["example.com"], "Content-Type": ["application/json"] }, + }); + + const response = createMockResponse({ + id: "res-graphql", + code: 200, + headers: { "content-type": ["application/json"] }, + body, + }); + + const execution = await runCheck(graphqlIntrospectionCheck, [ + { request, response }, + ]); + + return execution[0]?.steps[execution[0].steps.length - 1]?.findings ?? []; +}; + +describe("GraphQL introspection check", () => { + it("detects __schema introspection responses", async () => { + const findings = await executeCheck( + JSON.stringify({ + data: { + __schema: { + queryType: { name: "Query" }, + }, + }, + }), + ); + + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + name: "GraphQL introspection enabled", + severity: "medium", + }); + }); + + it("detects __type introspection responses", async () => { + const findings = await executeCheck( + JSON.stringify({ + data: { + __type: { + name: "User", + }, + }, + }), + ); + + expect(findings).toHaveLength(1); + }); + + it("ignores non-introspection responses", async () => { + const findings = await executeCheck( + JSON.stringify({ data: { users: [] } }), + ); + + expect(findings).toHaveLength(0); + }); +}); diff --git a/packages/backend/src/checks/graphql-introspection/index.ts b/packages/backend/src/checks/graphql-introspection/index.ts new file mode 100644 index 0000000..cd697e4 --- /dev/null +++ b/packages/backend/src/checks/graphql-introspection/index.ts @@ -0,0 +1,121 @@ +import { defineCheck, done, Severity } from "engine"; + +import { Tags } from "../../types"; +import { keyStrategy } from "../../utils/key"; + +type FindingDetails = { + type: "__schema" | "__type"; +}; + +const detectFromJson = (body: string): FindingDetails | undefined => { + try { + const parsed = JSON.parse(body) as Record; + if (parsed === null || typeof parsed !== "object") { + return undefined; + } + + const data = parsed.data; + if (data === null || data === undefined) { + return undefined; + } + + if (typeof data === "object") { + if ("__schema" in (data as Record)) { + return { type: "__schema" }; + } + + if ("__type" in (data as Record)) { + return { type: "__type" }; + } + } + } catch { + // Ignore JSON parse errors + } + + return undefined; +}; + +const FALLBACK_REGEX = /"__schema"\s*:/; + +const buildDescription = (details: FindingDetails): string => { + const subject = details.type === "__schema" ? "`__schema`" : "`__type`"; + + return [ + "The GraphQL endpoint responded to an introspection query.", + "", + `The response includes the ${subject} field, indicating that schema introspection is enabled.`, + "", + "Exposed schema metadata can significantly aid attackers in enumerating operations and crafting targeted attacks. Disable introspection on production environments or protect the endpoint behind authentication.", + ].join("\n"); +}; + +export default defineCheck>(({ step }) => { + step("detectIntrospection", (state, context) => { + const { response } = context.target; + + if (response === undefined) { + return done({ state }); + } + + const bodyText = response.getBody()?.toText(); + if (bodyText === undefined || bodyText.length === 0) { + return done({ state }); + } + + const details = detectFromJson(bodyText); + if (details !== undefined) { + return done({ + state, + findings: [ + { + name: "GraphQL introspection enabled", + description: buildDescription(details), + severity: Severity.MEDIUM, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + } + + if (FALLBACK_REGEX.test(bodyText)) { + return done({ + state, + findings: [ + { + name: "GraphQL introspection enabled", + description: buildDescription({ type: "__schema" }), + severity: Severity.MEDIUM, + correlation: { + requestID: context.target.request.getId(), + locations: [], + }, + }, + ], + }); + } + + return done({ state }); + }); + + return { + metadata: { + id: "graphql-introspection-enabled", + name: "GraphQL introspection enabled", + description: + "Detects GraphQL responses that disclose schema metadata via introspection.", + type: "passive", + tags: [Tags.INFORMATION_DISCLOSURE], + severities: [Severity.MEDIUM], + aggressivity: { + minRequests: 0, + maxRequests: 0, + }, + }, + initState: () => ({}), + dedupeKey: keyStrategy().withHost().withPath().build(), + when: (target) => target.response !== undefined, + }; +}); diff --git a/packages/backend/src/checks/index.ts b/packages/backend/src/checks/index.ts index e9b4e63..c938f5b 100644 --- a/packages/backend/src/checks/index.ts +++ b/packages/backend/src/checks/index.ts @@ -17,6 +17,7 @@ import directoryListingScan from "./directory-listing"; import emailDisclosureScan from "./email-disclosure"; import exposedEnvScan from "./exposed-env"; import gitConfigScan from "./git-config"; +import graphqlIntrospectionScan from "./graphql-introspection"; import hashDisclosureScan from "./hash-disclosure"; import jsonHtmlResponseScan from "./json-html-response"; import missingContentTypeScan from "./missing-content-type"; @@ -29,10 +30,10 @@ import { basicReflectedXSSScan } from "./reflected-xss"; import robotsTxtScan from "./robots-txt"; import { mysqlErrorBased } from "./sql-injection"; import sqlStatementInParams from "./sql-statement-in-params"; -import unencryptedCommunicationsScan from "./unencrypted-communications"; import ssnDisclosureScan from "./ssn-disclosure"; import sstiScan from "./ssti"; import suspectTransformScan from "./suspect-transform"; +import unencryptedCommunicationsScan from "./unencrypted-communications"; export type CheckID = (typeof Checks)[keyof typeof Checks]; export const Checks = { @@ -58,6 +59,7 @@ export const Checks = { EXPOSED_ENV: "exposed-env", GIT_CONFIG: "git-config", HASH_DISCLOSURE: "hash-disclosure", + GRAPHQL_INTROSPECTION_ENABLED: "graphql-introspection-enabled", JSON_HTML_RESPONSE: "json-html-response", MISSING_CONTENT_TYPE: "missing-content-type", OPEN_REDIRECT: "open-redirect", @@ -99,6 +101,7 @@ export const checks = [ exposedEnvScan, gitConfigScan, hashDisclosureScan, + graphqlIntrospectionScan, jsonHtmlResponseScan, missingContentTypeScan, openRedirectScan, diff --git a/packages/backend/src/stores/config.ts b/packages/backend/src/stores/config.ts index fe2057e..fedbf1b 100644 --- a/packages/backend/src/stores/config.ts +++ b/packages/backend/src/stores/config.ts @@ -379,6 +379,10 @@ export class ConfigStore { checkID: Checks.UNENCRYPTED_COMMUNICATIONS, enabled: true, }, + { + checkID: Checks.GRAPHQL_INTROSPECTION_ENABLED, + enabled: true, + }, ], }, {