[add] redirectUriCheckController

This commit is contained in:
caterpii 2025-05-31 15:22:25 +09:00
commit 9c4b53a6bc
27 changed files with 1235 additions and 161 deletions

View file

@ -1,20 +1,110 @@
// packages/backend/src/index.ts
// packages/backend/src/controller/PKCECheck.ts
import { RequestSpec } from "caido:utils";
var PKCECheck = class {
async test(sdk, req) {
const method = req.getMethod();
if (method !== "GET") {
sdk.console.log("[PKCEDowngradeCheck] Not a GET request. Skipping.");
return false;
}
const query = req.getQuery();
const searchParams = new URLSearchParams(query);
const requiredKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"];
if (!requiredKeys.every((key) => searchParams.has(key))) {
sdk.console.log("[PKCEDowngradeCheck] Required PKCE parameters missing. Skipping.");
return false;
}
const url = req.getUrl();
const isOpenID = searchParams.get("scope")?.includes("openid") || url.includes("id_token");
const methodVal = searchParams.get("code_challenge_method");
const challengeVal = searchParams.get("code_challenge");
if (!methodVal || !challengeVal) {
sdk.console.log("[PKCEDowngradeCheck] code_challenge or method missing. Skipping.");
await sdk.findings.create({
title: isOpenID ? "[WARN] OpenID Flow PKCE Parameters Missing" : "[WARN] OAuth2 Flow PKCE Parameters Missing",
description: `PKCE parameters are missing or incomplete for ${url}. This may indicate a misconfiguration.`,
request: req,
reporter: "PKCE Checker"
});
return false;
}
if (methodVal === "plain") {
sdk.console.log("[PKCEDowngradeCheck] code_challenge_method is 'plain'. Skipping.");
await sdk.findings.create({
title: isOpenID ? "[WARN] OpenID Flow PKCE Method is 'plain'" : "[WARN] OAuth2 Flow PKCE Method is 'plain'",
description: `PKCE method is set to 'plain' for ${url}. This may indicate a downgrade vulnerability.`,
request: req,
reporter: "PKCE Checker"
});
return false;
}
searchParams.delete("code_challenge");
searchParams.delete("code_challenge_method");
const downgradedQuery = searchParams.toString();
const scheme = req.getUrl().startsWith("https") ? "https" : "http";
const downgradedUrl = `${scheme}://${req.getHost()}:${req.getPort()}${req.getPath()}?${downgradedQuery}`;
sdk.console.log(`${req.getHost()} Original URL: ` + url);
sdk.console.log(`${req.getHost()} Downgraded URL: ` + downgradedUrl);
try {
const spec = new RequestSpec(downgradedUrl);
spec.setBody(req.getBody());
for (const [key, value] of Object.entries(req.getHeaders())) {
if (Array.isArray(value)) {
spec.setHeader(key, value.join(", "));
} else {
spec.setHeader(key, value);
}
}
spec.setHost(req.getHost());
spec.setMethod(req.getMethod());
spec.setPath(req.getPath());
spec.setQuery(downgradedQuery);
spec.setTls(req.getTls());
spec.setPort(req.getPort());
let sendDowngradedRequest = await sdk.requests.send(spec);
if (sendDowngradedRequest.response) {
let domain = spec.getHost();
let port = spec.getPort();
let path2 = spec.getPath();
let query2 = spec.getQuery();
let id = sendDowngradedRequest.response.getId();
let code = sendDowngradedRequest.response.getCode();
sdk.console.log(`REQ ${id}: ${domain}:${port}${path2}${query2} received a status code of ${code}`);
}
if (sendDowngradedRequest.response?.getCode() === 302) {
await sdk.findings.create({
title: isOpenID ? "[CRITICAL] OpenID Flow PKCE Downgrade Vulnerability" : "[CRITICAL] OAuth2 Flow PKCE Downgrade Vulnerability",
description: `The request to ${url} is vulnerable to a PKCE downgrade attack. This may indicate a configuration error.`,
request: req,
reporter: "PKCE Checker"
});
}
} catch (err) {
sdk.console.error(`PKCE downgrade check failed for ${url}: ${String(err)}`);
}
sdk.console.log("[PKCEDowngradeCheck] No PKCE downgrade detected.");
return false;
}
};
// packages/backend/src/controller/redirectUriCheck.ts
import { promises as fs } from "fs";
import * as path from "path";
import os from "os";
var requestMap = /* @__PURE__ */ new Map();
function init(sdk) {
sdk.events.onInterceptRequest(async (sdk2, req) => {
var redirectUriCheck = class {
requestMap = /* @__PURE__ */ new Map();
// constructor(private sdk: SDK<any>) {}
async onRequest(sdk, req) {
try {
const urlString = req.getUrl();
const url = new URL(urlString);
sdk2.console.log(`[OAuthPlugin] Intercepted request: ${urlString}`);
sdk.console.log(`[OAuthPlugin] Intercepted request: ${urlString}`);
if (!url.pathname.includes("/authorize") && !url.pathname.includes("/auth")) return;
const params = new URLSearchParams(url.search);
const redirectUri = params.get("redirect_uri");
if (!redirectUri) return;
const reqId = req.getId();
requestMap.set(reqId, redirectUri);
this.requestMap.set(reqId, redirectUri);
const clientId = params.get("client_id") ?? "(missing)";
const responseType = params.get("response_type") ?? "(missing)";
const isScan = params.has("scan");
@ -29,14 +119,14 @@ function init(sdk) {
const filePath = path.join(os.tmpdir(), "oauth-fuzz-input.json");
await fs.writeFile(filePath, JSON.stringify(output, null, 2));
} catch (err) {
await sdk2.findings.create({
await sdk.findings.create({
title: "[fs] Write Failed",
description: `Could not write to file: ${err}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
}
await sdk2.findings.create({
await sdk.findings.create({
title: "[ ] OAuth2 Authorization Request Collected",
description: `client_id: ${clientId}
redirect_uri: ${redirectUri}
@ -45,10 +135,10 @@ response_type: ${responseType}`,
reporter: "oauth-open-redirect-detector"
});
} catch (err) {
sdk2.console.error(`Error in onInterceptRequest: ${err}`);
sdk.console.error(`Error in onRequest: ${err}`);
}
});
sdk.events.onInterceptResponse(async (sdk2, req, resp) => {
}
async onResponse(sdk, req, resp) {
try {
const reqId = req.getId();
const url = new URL(req.getUrl());
@ -57,26 +147,43 @@ response_type: ${responseType}`,
const params = new URLSearchParams(url.search);
const isScan = params.has("scan");
if (!isScan) {
requestMap.delete(reqId);
this.requestMap.delete(reqId);
return;
}
if (status >= 300 && status < 400 && location) {
const redirectUri = requestMap.get(reqId) ?? "(unknown)";
await sdk2.findings.create({
const redirectUri = this.requestMap.get(reqId) ?? "(unknown)";
await sdk.findings.create({
title: "[+] Redirect URI Misconfiguration Detected",
description: `Status: ${status}
Location: ${location}
Original Redirect URI: ${redirectUri}
Request URL: ${url.href}`,
Request URL: ${url.href}
Redirect URI: ${redirectUri}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
}
requestMap.delete(reqId);
this.requestMap.delete(reqId);
} catch (err) {
sdk2.console.error(`Error in onInterceptResponse: ${err}`);
sdk.console.error(`Error in onResponse: ${err}`);
}
});
}
};
// packages/backend/src/index.ts
var pkceCheckController = new PKCECheck();
var redirectUriCheckController = new redirectUriCheck();
function init(sdk) {
sdk.events.onInterceptRequest(
async (sdk2, req) => {
await redirectUriCheckController.onRequest(sdk2, req);
}
);
sdk.events.onInterceptResponse(
async (sdk2, req, resp) => {
await pkceCheckController.test(sdk2, req);
await redirectUriCheckController.onResponse(sdk2, req, resp);
}
);
}
export {
init

View file

@ -7,4 +7,4 @@
"typecheck": "tsc --noEmit",
"build": "vite build"
}
}
}

View file

@ -0,0 +1,144 @@
import type { SDK } from "caido:plugin";
import { Body, RequestSpec, type Request } from "caido:utils";
export class PKCECheck {
async test(sdk: SDK, req: Request): Promise<boolean> {
const method = req.getMethod();
if (method !== "GET") {
sdk.console.log("[PKCEDowngradeCheck] Not a GET request. Skipping.");
return false;
}
const query = req.getQuery();
const searchParams = new URLSearchParams(query);
const requiredKeys = ["client_id", "response_type", "code_challenge", "code_challenge_method"];
if (!requiredKeys.every((key) => searchParams.has(key))) {
sdk.console.log("[PKCEDowngradeCheck] Required PKCE parameters missing. Skipping.");
return false;
}
const url = req.getUrl();
const isOpenID = searchParams.get("scope")?.includes("openid") || url.includes("id_token");
const methodVal = searchParams.get("code_challenge_method");
const challengeVal = searchParams.get("code_challenge");
if (!methodVal || !challengeVal) {
sdk.console.log("[PKCEDowngradeCheck] code_challenge or method missing. Skipping.");
await sdk.findings.create({
title: isOpenID
? "[WARN] OpenID Flow PKCE Parameters Missing"
: "[WARN] OAuth2 Flow PKCE Parameters Missing",
description: `PKCE parameters are missing or incomplete for ${url}. This may indicate a misconfiguration.`,
request: req,
reporter: "PKCE Checker",
});
return false;
}
if (methodVal === "plain") {
sdk.console.log("[PKCEDowngradeCheck] code_challenge_method is 'plain'. Skipping.");
await sdk.findings.create({
title: isOpenID
? "[WARN] OpenID Flow PKCE Method is 'plain'"
: "[WARN] OAuth2 Flow PKCE Method is 'plain'",
description: `PKCE method is set to 'plain' for ${url}. This may indicate a downgrade vulnerability.`,
request: req,
reporter: "PKCE Checker",
});
return false;
}
// Remove PKCE parameters to simulate a downgraded request
searchParams.delete("code_challenge");
searchParams.delete("code_challenge_method");
const downgradedQuery = searchParams.toString();
const scheme = req.getUrl().startsWith("https") ? "https" : "http";
const downgradedUrl = `${scheme}://${req.getHost()}:${req.getPort()}${req.getPath()}?${downgradedQuery}`;
sdk.console.log(`${req.getHost()} Original URL: ` + url);
sdk.console.log(`${req.getHost()} Downgraded URL: ` + downgradedUrl);
try {
// Use Caido Replay SDK to replay the original request
const spec = new RequestSpec(downgradedUrl);
spec.setBody(req.getBody() as Body);
for (const [key, value] of Object.entries(req.getHeaders())) {
if (Array.isArray(value)) {
spec.setHeader(key, value.join(', ')); // or another suitable delimiter
} else {
spec.setHeader(key, value);
}
}
spec.setHost(req.getHost());
spec.setMethod(req.getMethod());
spec.setPath(req.getPath());
spec.setQuery(downgradedQuery);
spec.setTls(req.getTls());
spec.setPort(req.getPort());
let sendDowngradedRequest = await sdk.requests.send(spec);
if (sendDowngradedRequest.response) {
let domain = spec.getHost();
let port = spec.getPort();
let path = spec.getPath();
let query = spec.getQuery();
let id = sendDowngradedRequest.response.getId();
let code = sendDowngradedRequest.response.getCode();
sdk.console.log(`REQ ${id}: ${domain}:${port}${path}${query} received a status code of ${code}`);
}
if (sendDowngradedRequest.response?.getCode() === 302) {
await sdk.findings.create({
title: isOpenID
? "[CRITICAL] OpenID Flow PKCE Downgrade Vulnerability"
: "[CRITICAL] OAuth2 Flow PKCE Downgrade Vulnerability",
description: `The request to ${url} is vulnerable to a PKCE downgrade attack. This may indicate a configuration error.`,
request: req,
reporter: "PKCE Checker",
});
}
/*
sdk.console.log(`${req.getHost()} Original Status: ` + resOriginal.status);
sdk.console.log(`${req.getHost()} Downgraded Status: ` + resDowngraded.status);
sdk.console.log(`${req.getHost()} Original Headers: ` + JSON.stringify(resOriginal.headers));
sdk.console.log(`${req.getHost()} Downgraded Headers: ` + JSON.stringify(resDowngraded.headers));
// Caido Dev Docs 기준으로, 리다이렉트된 URL은 Response 객체의 url 속성에 저장되어 있음
const locationOriginal = resOriginal.url ?? "";
const locationDowngraded = resDowngraded.url ?? "";
sdk.console.log(`${req.getHost()} Original Location: ` + locationOriginal);
sdk.console.log(`${req.getHost()} Downgraded Location: ` + locationDowngraded);
const statusEqual = resOriginal.status === resDowngraded.status;
const codeInRedirects = locationOriginal.includes("code=") && locationDowngraded.includes("code=");
if (statusEqual && codeInRedirects) {
const title = isOpenID
? "[CRITICAL] OpenID Flow PKCE Downgraded to Plaintext"
: "[CRITICAL] OAuth2 Flow PKCE Downgraded to Plaintext";
const reference = isOpenID
? "https://openid.net/specs/openid-igov-oauth2-1_0-02.html#rfc.section.3.1.7"
: "https://datatracker.ietf.org/doc/html/rfc7636";
await sdk.findings.create({
title,
description: `PKCE downgrade detected for ${url}.\n\nDowngraded URL: ${downgradedUrl}\n\nRedirect contained code=.\n\nReference: ${reference}`,
request: req,
reporter: "",
});
return true;
}*/
} catch (err) {
sdk.console.error(`PKCE downgrade check failed for ${url}: ${String(err)}`);
}
sdk.console.log("[PKCEDowngradeCheck] No PKCE downgrade detected.");
return false;
}
}

View file

@ -0,0 +1,48 @@
import type { Request } from "caido:utils";
export class AuthZCodeGrantController {
constructor() {}
isAuthZReq(req: Request) {
// const path = req.getPath();
const query = req.getQuery();
// const raw = req.getRaw().toString();
// 쿼리에 client_id and response_type=code 포함 확인
if (query.includes("client_id=") && query.includes("response_type=code")) {
return true;
}
return false;
}
isSendCodeToClient(req: Request) {
const path = req.getPath();
const query = req.getQuery();
// code & state 쌍 또는 path에 &code= 또는 쿼리로 code= 유사 일치 확인
if (
(query.includes("code=") && query.includes("state=")) ||
path.includes("&code=") ||
/code=%/i.test(query)
) {
return true;
}
return false;
}
testReq(req: Request) {
if (this.isAuthZReq(req)) {
return "isAuthZReq";
}
if (this.isSendCodeToClient(req)) {
return "isSendCodeToClient";
}
return false;
}
// isAccessTokenReq(req: Response) {
// }
}

View file

@ -0,0 +1,40 @@
import type { Request } from "caido:utils";
export class ImplicitGrantController {
isImplicitGrantReq(req: Request) {
const query = req.getQuery();
// const raw = req.getRaw().toString();
// 쿼리에 client_id and response_type=token 포함 확인
if (query.includes("client_id=") && query.includes("response_type=token")) {
return true;
}
return false;
}
isSendTokenToClient(req: Request) {
const path = req.getPath();
const query = req.getQuery();
if (
(query.includes("access_token=") && query.includes("state=")) ||
path.includes("&access_token=") ||
/access_token=%/i.test(query)
) {
return true;
}
return false;
}
testReq(req: Request) {
if (this.isImplicitGrantReq(req)) {
return "isImplicitGrantReq";
}
if (this.isSendTokenToClient(req)) {
return "isSendTokenToClient";
}
return false;
}
}

View file

@ -0,0 +1,33 @@
import type { Request } from "caido:utils";
import { TokenLeakCheck } from "./tokenLeakCheck";
export class NonceCheckController{
/**
* OIDC(OpenID Connect)
*/
public static isOidcFlow(req: Request): boolean {
if(TokenLeakCheck.extractIdToken(req)) {
return true;
}
return false;
}
public static isNonceCheckRequest(req: Request): boolean {
const id_token = decodeIdToken(req);
// 1. nonce 파라미터가 포함된 요청인지 확인
if (id_token.includes("nonce=")) {
return true;
}
return false;
}
}
function decodeIdToken(req: Request): string {
// Implement actual decoding logic here. For now, return an empty string or mock value.
return "";
}

View file

@ -0,0 +1,95 @@
// oauth-plugin.ts
import type { SDK } from "caido:plugin";
import type { Request, Response } from "caido:utils";
import { promises as fs } from "fs";
import * as path from "path";
import os from "os";
export class redirectUriCheck {
private requestMap = new Map<string, string>();
// constructor(private sdk: SDK<any>) {}
public async onRequest(sdk: SDK, req: Request) {
try {
const urlString = req.getUrl();
const url = new URL(urlString);
sdk.console.log(`[OAuthPlugin] Intercepted request: ${urlString}`);
if (!url.pathname.includes("/authorize") && !url.pathname.includes("/auth")) return;
const params = new URLSearchParams(url.search);
const redirectUri = params.get("redirect_uri");
if (!redirectUri) return;
const reqId = req.getId();
this.requestMap.set(reqId, redirectUri);
const clientId = params.get("client_id") ?? "(missing)";
const responseType = params.get("response_type") ?? "(missing)";
const isScan = params.has("scan");
if (isScan) return;
const output = {
original_url: urlString,
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
};
try {
const filePath = path.join(os.tmpdir(), "oauth-fuzz-input.json");
await fs.writeFile(filePath, JSON.stringify(output, null, 2));
} catch (err) {
await sdk.findings.create({
title: "[fs] Write Failed",
description: `Could not write to file: ${err}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
}
await sdk.findings.create({
title: "[ ] OAuth2 Authorization Request Collected",
description: `client_id: ${clientId}\nredirect_uri: ${redirectUri}\nresponse_type: ${responseType}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
} catch (err) {
sdk.console.error(`Error in onRequest: ${err}`);
}
}
public async onResponse(sdk: SDK, req: Request, resp: Response) {
try {
const reqId = req.getId();
const url = new URL(req.getUrl());
const status = resp.getCode();
const location = resp.getHeader("location")?.[0];
const params = new URLSearchParams(url.search);
const isScan = params.has("scan");
if (!isScan) {
this.requestMap.delete(reqId);
return;
}
if (status >= 300 && status < 400 && location) {
const redirectUri = this.requestMap.get(reqId) ?? "(unknown)";
await sdk.findings.create({
title: "[+] Redirect URI Misconfiguration Detected",
description: `Status: ${status}\nLocation: ${location}\nRequest URL: ${url.href}\nRedirect URI: ${redirectUri}`,
request: req,
reporter: "oauth-open-redirect-detector",
});
}
this.requestMap.delete(reqId);
} catch (err) {
sdk.console.error(`Error in onResponse: ${err}`);
}
}
}

View file

@ -0,0 +1,42 @@
import type { Request } from "caido:utils";
import jwt from "jsonwebtoken";
export class TokenLeakCheck {
public static extractIdToken(req: Request): string | null {
// 1. Authorization 헤더 확인\\
const header = req.getHeaders() as Record<string, string | string[] | undefined>;
const authHeader = header["authorization"] || header["Authorization"];
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
return authHeader.slice(7).trim();
}
// 2. 쿼리 파라미터
const query = req.getQuery();
if (query && typeof query === "object" && "id_token" in query) {
return (query as Record<string, any>).id_token;
}
// 3. POST 바디 안에 id_token이 있을 경우
const rawBody = req.getRaw();
const body = rawBody ? rawBody.toString() : "";
const match = body.match(/id_token=([^&\s]+)/);
if (match && typeof match[1] === "string") {
return decodeURIComponent(match[1]);
}
return null;
}
public static decodeIdToken(req: Request): Record<string, any> | null {
const token = this.extractIdToken(req);
if (!token) return null;
const decoded = jwt.decode(token, { complete: true });
if (!decoded || typeof decoded !== "object") return null;
return {
header: decoded.header,
payload: decoded.payload,
};
}
}

View file

@ -1,94 +1,55 @@
import type { SDK, DefineAPI } from "caido:plugin";
import type { Request, Response } from "caido:utils";
import { promises as fs } from "fs";
import * as path from "path";
import os from "os";
// import { ImplicitGrantController } from "./controller/implictGrant";
// import { AuthZCodeGrantController } from "./controller/authZCodeGrant";
import { PKCECheck } from "./controller/PKCECheck";
import { redirectUriCheck } from "./controller/redirectUriCheck";
export type API = DefineAPI<{}>;
const requestMap = new Map<string, string>();
// const implicitGrantController = new ImplicitGrantController();
// const authZCodeGrantController = new AuthZCodeGrantController();
const pkceCheckController = new PKCECheck();
const redirectUriCheckController = new redirectUriCheck();
export function init(sdk: SDK<API>) {
// sdk.events.onInterceptRequest(async (sdk, req: Request) => {
// const result = csrfCheck.checker(req);
sdk.events.onInterceptRequest(async (sdk, req: Request) => {
try {
const urlString = req.getUrl();
const url = new URL(urlString);
sdk.console.log(`[OAuthPlugin] Intercepted request: ${urlString}`);
if (!url.pathname.includes("/authorize") && !url.pathname.includes("/auth")) return;
const params = new URLSearchParams(url.search);
const redirectUri = params.get("redirect_uri");
if (!redirectUri) return;
const reqId = req.getId();
requestMap.set(reqId, redirectUri);
const clientId = params.get("client_id") ?? "(missing)";
const responseType = params.get("response_type") ?? "(missing)";
const isScan = params.has("scan");
if (isScan) return;
const output = {
original_url: urlString,
client_id: clientId,
redirect_uri: redirectUri,
response_type: responseType,
};
try {
const filePath = path.join(os.tmpdir(), "oauth-fuzz-input.json");
await fs.writeFile(filePath, JSON.stringify(output, null, 2));
} catch (err) {
await sdk.findings.create({
title: "[fs] Write Failed",
description: `Could not write to file: ${err}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
}
await sdk.findings.create({
title: "[ ] OAuth2 Authorization Request Collected",
description: `client_id: ${clientId}\nredirect_uri: ${redirectUri}\nresponse_type: ${responseType}`,
request: req,
reporter: "oauth-open-redirect-detector"
});
} catch (err) {
sdk.console.error(`Error in onInterceptRequest: ${err}`);
// if (result) {
// await sdk.findings.create({
// title: "Possible SSO Request Detected",
// description: `SSO-related parameters detected in request:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`,
// request: req,
// reporter: "",
// });
// }
// });
sdk.events.onInterceptRequest(
async(sdk: SDK<DefineAPI<{}>, {}>, req: Request) => {
await redirectUriCheckController.onRequest(sdk, req);
}
});
);
sdk.events.onInterceptResponse(async (sdk, req: Request, resp: Response) => {
try {
const reqId = req.getId();
const url = new URL(req.getUrl());
const status = resp.getCode();
const location = resp.getHeader("location")?.[0];
sdk.events.onInterceptResponse(
async (sdk: SDK<DefineAPI<{}>, {}>, req: Request, resp: Response) => {
await pkceCheckController.test(sdk, req);
await redirectUriCheckController.onResponse(sdk, req, resp);
const params = new URLSearchParams(url.search);
const isScan = params.has("scan");
// sdk.events.onInterceptRequest(async (sdk, req: Request) => {
// const result =
// authZCodeGrantController.testReq(req) ||
// implicitGrantController.testReq(req);
if (!isScan) {
requestMap.delete(reqId);
return;
}
// if (result) {
// await pkceCheckController.test(sdk, req);
if (status >= 300 && status < 400 && location) {
const redirectUri = requestMap.get(reqId) ?? "(unknown)";
await sdk.findings.create({
title: "[+] Redirect URI Misconfiguration Detected",
description: `Status: ${status}\nLocation: ${location}\nOriginal Redirect URI: ${redirectUri}\nRequest URL: ${url.href}`,
request: req,
reporter: "oauth-open-redirect-detector",
});
}
requestMap.delete(reqId);
} catch (err) {
sdk.console.error(`Error in onInterceptResponse: ${err}`);
// await sdk.findings.create({
// title: "Possible SSO Request Detected",
// description: `SSO-related parameters detected in request:\n\n${req.getMethod()} ${req.getUrl()} : ${result}`,
// request: req,
// reporter: "",
// });
}
});
}
);
}

View file

@ -5,4 +5,4 @@
"lib": ["ESNext", "DOM"]
},
"include": ["./src/**/*.ts"]
}
}