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
69 changes: 69 additions & 0 deletions packages/backend/src/checks/graphql-introspection/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown[]> => {
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);
});
});
121 changes: 121 additions & 0 deletions packages/backend/src/checks/graphql-introspection/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>)) {
return { type: "__schema" };
}

if ("__type" in (data as Record<string, unknown>)) {
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<Record<never, never>>(({ 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,
};
});
6 changes: 6 additions & 0 deletions packages/backend/src/checks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +33,7 @@ import sqlStatementInParams from "./sql-statement-in-params";
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 = {
Expand All @@ -57,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",
Expand All @@ -71,6 +74,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;

Expand All @@ -97,6 +101,7 @@ export const checks = [
exposedEnvScan,
gitConfigScan,
hashDisclosureScan,
graphqlIntrospectionScan,
jsonHtmlResponseScan,
missingContentTypeScan,
openRedirectScan,
Expand All @@ -111,5 +116,6 @@ export const checks = [
sqlStatementInParams,
ssnDisclosureScan,
suspectTransformScan,
unencryptedCommunicationsScan,
// mysqlTimeBased,
] as const;
Original file line number Diff line number Diff line change
@@ -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);
});
});
56 changes: 56 additions & 0 deletions packages/backend/src/checks/unencrypted-communications/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
});
12 changes: 12 additions & 0 deletions packages/backend/src/stores/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.UNENCRYPTED_COMMUNICATIONS,
enabled: false,
},
],
},
{
Expand Down Expand Up @@ -371,6 +375,14 @@ export class ConfigStore {
checkID: Checks.MISSING_CONTENT_TYPE,
enabled: true,
},
{
checkID: Checks.UNENCRYPTED_COMMUNICATIONS,
enabled: true,
},
{
checkID: Checks.GRAPHQL_INTROSPECTION_ENABLED,
enabled: true,
},
],
},
{
Expand Down