diff --git a/CHANGELOG.md b/CHANGELOG.md index 2897f55..a7a40d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [1.2.0] - 2026-07-02 + +### Added + +* New `human_get_raw_activities` tool: given a Block ID / Reference ID or an IP address and a short time window (max 4 hours), returns matching raw activity records, the total count, and aggregated traffic metrics to help analyze why traffic is being blocked. Supports `limit` (1-100, default 20) and `offset` for paginating through matching records (sorted newest-first). Key analysis fields surfaced: `filterOriginReason`, `ruleName`, `displayScore`, `incidentTypes`, `trafficTags`, and `blockReference`. +* `searchQuery` support across all traffic data endpoints (`/overtime`, `/metrics`, `/tops/*`) for field-level filtering with boolean logic (AND, OR, NOT, parentheses). Available filter fields include: `socketIp`, `blockReference`, `displayScore`, `domain`, `path`, `filterOriginReason`, `ruleName`, `uaServer`, `knownBot`, `userEmail`, `httpMethod`, `httpStatusCode`, and more. + +### Changed + +* `human_get_traffic_data` tool description updated to document the new `searchQuery` capability, all available filter field keys, and a pointer to `human_get_raw_activities` for request-level records. + ## [1.1.1] - 2026-07-02 ### Fixed diff --git a/package.json b/package.json index e1624dc..b89ee04 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@humansecurity/human-mcp-server", "description": "Model Context Protocol (MCP) server providing comprehensive cybersecurity intelligence from HUMAN Security. Offers real-time attack monitoring, threat detection, fraud prevention, PCI DSS compliance validation, and supply chain security for AI-powered applications.", - "version": "1.1.1", + "version": "1.2.0", "type": "module", "main": "./dist/index.cjs", "bin": "./dist/index.cjs", diff --git a/src/services/cyberfraudService.ts b/src/services/cyberfraudService.ts index cecdb62..2454188 100644 --- a/src/services/cyberfraudService.ts +++ b/src/services/cyberfraudService.ts @@ -11,6 +11,7 @@ import type { CyberfraudCustomRulesResponse, TrafficDataInput, TrafficDataResponse, + RawActivitiesInput, } from '../types/cyberfraud'; const API_BASE = `${HUMAN_API_BASE}/cyberfraud`; @@ -49,9 +50,26 @@ function buildBaseBody(params: TrafficDataInput) { if (params.filters) { body.filters = params.filters; } + if (params.searchQuery && params.searchQuery.length > 0) { + body.searchQuery = params.searchQuery; + } return body; } +const MAX_INVESTIGATE_RANGE_MS = 4 * 60 * 60 * 1000; // 4 hours + +function enforceInvestigateTimeRange(startTime: string, endTime: string): void { + const start = new Date(startTime).getTime(); + const end = new Date(endTime).getTime(); + const diffMs = end - start; + if (diffMs > MAX_INVESTIGATE_RANGE_MS) { + throw new Error( + `The investigation time range must not exceed 4 hours (raw activity queries are expensive). ` + + `Provided range: ${Math.round(diffMs / 60000)} minutes. Please narrow the window to at most 240 minutes.`, + ); + } +} + async function parseApiResponse(res: { json: () => Promise }): Promise { const json = (await res.json()) as ApiEnvelope; if (!json.result) { @@ -159,4 +177,67 @@ export class CyberfraudService { return response; } + + async getRawActivities( + params: Pick & { + limit?: number; + offset?: number; + }, + ): Promise[]> { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); + const to = Math.floor(new Date(clamped.endTime).getTime() / 1000); + const url = buildTrafficDataUrl('/activities', from, to); + const body: Record = { + trafficSource: + params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], + }; + if (params.filters) body.filters = params.filters; + body.searchQuery = params.searchQuery; + if (params.limit !== undefined) body.limit = params.limit; + if (params.offset !== undefined) body.offset = params.offset; + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse<{ results: Record[] }>(res); + return content.results; + } + + async getRawActivitiesCount( + params: Pick, + ): Promise { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + const from = Math.floor(new Date(clamped.startTime).getTime() / 1000); + const to = Math.floor(new Date(clamped.endTime).getTime() / 1000); + const url = buildTrafficDataUrl('/activities/count', from, to); + const body: Record = { + trafficSource: + params.trafficSource && params.trafficSource.length > 0 ? params.trafficSource : ['web', 'mobile'], + }; + if (params.filters) body.filters = params.filters; + body.searchQuery = params.searchQuery; + const res = await this.http.request(url, { method: 'POST', body }); + const content = await parseApiResponse<{ total: number }>(res); + return content.total; + } + + async fetchRawActivities( + params: RawActivitiesInput, + ): Promise<{ count: number; activities: Record[] }> { + const clamped = clampAttackReportingTimes(params.startTime, params.endTime); + enforceInvestigateTimeRange(clamped.startTime, clamped.endTime); + + const baseParams = { + startTime: clamped.startTime, + endTime: clamped.endTime, + trafficSource: params.trafficSource, + filters: params.filters, + searchQuery: params.searchQuery, + }; + + const [activities, count] = await Promise.all([ + this.getRawActivities({ ...baseParams, limit: params.limit ?? 20, offset: params.offset ?? 0 }), + this.getRawActivitiesCount(baseParams), + ]); + + return { count, activities }; + } } diff --git a/src/tools/getRawActivities.ts b/src/tools/getRawActivities.ts new file mode 100644 index 0000000..bc16d5f --- /dev/null +++ b/src/tools/getRawActivities.ts @@ -0,0 +1,92 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { RawActivitiesInputBaseSchema, RawActivitiesOutputSchema } from '../types/cyberfraud'; +import { mcpToolHandler } from '../utils/mcpToolHandler'; +import { makeStructuredResponseSchema } from '../utils/makeStructuredResponseSchema'; +import type { CyberfraudService } from '../services/cyberfraudService'; +import type { RawActivitiesInput } from '../types/cyberfraud'; +import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../utils/constants'; + +export function registerGetRawActivities(server: McpServer, cyberfraudService: CyberfraudService) { + server.registerTool( + 'human_get_raw_activities', + { + description: `Fetches raw activity records (individual request logs) and total count from HUMAN Security for a given search criteria and time window. + +Use this tool to drill into specific traffic at the request level β€” whether investigating a Block ID, an IP, a user email, a domain, or any other supported field. + +⚠️ TIME RANGE CONSTRAINT: Raw activity queries are expensive. The time window MUST be at most 4 hours. +Use short windows like the last 30, 60, or 240 minutes. If the user does not specify, default to the last 1 hour. + +🎯 WHEN TO USE THIS TOOL: +β€’ User wants to investigate a specific Block ID / Reference ID +β€’ User wants to investigate traffic from a specific IP or CIDR +β€’ User wants to find raw activity records for a user email, domain, path, VID, or any other field +β€’ User wants to drill into specific incidents at the request level +β€’ User asks for raw request logs matching a condition + +πŸ“‹ SEARCH QUERY (required, same syntax as human_get_traffic_data): +Use searchQuery for field-level filtering with boolean logic. +Field expression: { type: "field", key: "", operator: "", value: "" } +Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } + +Supported field keys include: blockReference, socketIp, userEmail, domain, path, displayScore, vid, uaServer, knownBot, +incidentTypes, filterOriginReason, httpMethod, httpStatusCode, customRule, +accessTokenName, headerReferer, graphqlOperationName, graphqlOperationType, +customParam1–customParam10, socketIpOrgName, agent, and more. + +πŸ” KEY RESPONSE FIELDS FOR ANALYSIS (in activities[]): +β€’ filterOriginReason β€” the specific reason the request was filtered/blocked +β€’ ruleName β€” which rule triggered the block +β€’ displayScore β€” risk score (0–100); higher = more suspicious +β€’ incidentTypes β€” detected threat signals: "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", "UI Anomaly", "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" +β€’ trafficTags β€” traffic classification (e.g. "Blocked Requests", "Simulated Block") +β€’ blockReference β€” the Block ID associated with this request +β€’ socketIp, domain, path, userEmail, vid, httpMethod, httpStatusCode, country, browserDisplay, osFamily + +πŸ“Š RESPONSE STRUCTURE: +β€’ count: total number of matching records in the time window (always the full total, regardless of limit/offset) +β€’ activities: matching records, newest first β€” up to 'limit' (default 20, max 100) starting at 'offset' + +πŸ“„ PAGINATION: +β€’ limit: how many raw records to return (1-100, default 20). +β€’ offset: how many to skip (records are sorted newest-first). Use count + offset to page through results. + +βœ… USAGE PATTERNS: + +1. INVESTIGATE BY BLOCK ID: + {"searchQuery": [{"type": "field", "key": "blockReference", "operator": "=", "value": "b5e0-b1d1-a54de"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +2. INVESTIGATE BY IP: + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +3. INVESTIGATE BY USER EMAIL: + {"searchQuery": [{"type": "field", "key": "userEmail", "operator": "=", "value": "user@example.com"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +4. INVESTIGATE BY DOMAIN + HIGH RISK: + {"searchQuery": [{"type": "field", "key": "domain", "operator": "contains", "value": "example.com"}, {"type": "operator", "operator": "AND"}, {"type": "field", "key": "displayScore", "operator": ">=", "value": 80}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +5. INVESTIGATE BY VID: + {"searchQuery": [{"type": "field", "key": "vid", "operator": "=", "value": "abc-123-def"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +6. IP WITH AUTOMATION INCIDENT: + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}, {"type": "operator", "operator": "AND"}, {"type": "field", "key": "incidentTypes", "operator": "=", "value": "Automation Tool"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}"} + +7. NARROW TO BLOCKED TRAFFIC ONLY (via filters): + {"searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.10"}], "startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "filters": {"trafficTags": ["blocked", "potentialBlock"]}} + +⚠️ NOTES: +β€’ searchQuery is required and must contain at least one expression. +β€’ trafficSource defaults to ["web", "mobile"] when omitted. +β€’ Use limit/offset to page through more than the default 20 records when count is large. +β€’ For broader traffic analytics (overtime, metrics, tops) without raw records, use human_get_traffic_data instead.`, + inputSchema: RawActivitiesInputBaseSchema.shape, + outputSchema: makeStructuredResponseSchema(RawActivitiesOutputSchema).shape, + annotations: { + title: 'HUMAN Get Raw Activities', + readOnlyHint: true, + openWorldHint: true, + }, + }, + async (params: RawActivitiesInput) => mcpToolHandler(async () => cyberfraudService.fetchRawActivities(params)), + ); +} diff --git a/src/tools/getTrafficData.ts b/src/tools/getTrafficData.ts index acfed2c..3c04604 100644 --- a/src/tools/getTrafficData.ts +++ b/src/tools/getTrafficData.ts @@ -18,7 +18,8 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe β”œβ”€β”€ Need URL path breakdown? β†’ tops: ["path"] β”œβ”€β”€ Need top blocked IP orgs? β†’ tops: ["socketIpOrgName"] β”œβ”€β”€ Security focus only? β†’ filters: { trafficTags: ["blocked", "potentialBlock"] } -└── Multi-platform analysis? β†’ trafficSource: ["web", "mobile"] +β”œβ”€β”€ Multi-platform analysis? β†’ trafficSource: ["web", "mobile"] +└── Need raw request-level records? β†’ use human_get_raw_activities instead βœ… REQUEST MODES (combinable in one call): β€’ overtime: Per-minute time-series counts. Use seriesFields to break out by knownBot, customRule, or accessTokenName. @@ -30,6 +31,39 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe β€’ filters.pageType: User journey (login, checkout, carding_attempt, etc.) β€’ filters.browserFamily / osFamily / country: Device and geo filters +πŸ” SEARCH QUERY (optional, for field-level filtering with boolean logic): +Use searchQuery to filter by any specific field value. Items are either field expressions or logical operators. +Field expression: { type: "field", key: "", operator: "", value: "" } +Logical operator: { type: "operator", operator: "AND" | "OR" | "NOT" | "(" | ")" } + +Available field keys and their operators: +β€’ socketIp (IP/CIDR): =, != +β€’ blockReference (Block ID): =, exists, notExists +β€’ displayScore (risk score 0-100): =, !=, >, <, >=, <= +β€’ incidentTypes: =, !=, exists, notExists + Valid values: "UI Anomaly", "Bot Behavior", "Automation Tool", "Spoof", "Behavioral Anomalies", + "Bad Reputation", "Volumetric Rule", "Volumetric Anomaly", "Missing Sensor Data", + "Cloud Service", "Anonymizing Service", "Denylisted Service", "Custom Denylist", "Captcha Solving Attack" +β€’ knownBot: =, !=, exists, notExists + Example values: "Googlebot", "Bing Bot", "GPTBot", "ClaudeBot", "PerplexityBot", + "Facebook Crawler", "Twitterbot", "Pingdom", "Datadog - Synthetics", "Semrush Bot", "Ahrefs" + (150+ bots total β€” use exists/notExists for broad match) +β€’ domain, path, uaServer, socketIpOrgName, userEmail, headerReferer: =, !=, contains, exists, notExists +β€’ filterOriginReason, vid, accessTokenName: =, !=, exists, notExists +β€’ httpStatusCode: =, !=, >, <, >=, <= +β€’ httpMethod: =, != β€” values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS +β€’ graphqlOperationType: =, !=, exists, notExists β€” values: query, mutation, subscription +β€’ graphqlOperationName: =, !=, exists, notExists +β€’ customRule: =, !=, contains, exists, notExists +β€’ customParam1–customParam10: =, !=, contains, exists, notExists + +Example β€” traffic from a CIDR with high risk score: + "searchQuery": [ + { "type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.0/24" }, + { "type": "operator", "operator": "AND" }, + { "type": "field", "key": "displayScore", "operator": ">=", "value": 80 } + ] + βœ… HIGH-VALUE PATTERNS: 1. EXECUTIVE TRAFFIC SUMMARY: @@ -47,11 +81,15 @@ export function registerCyberfraudGetTrafficData(server: McpServer, cyberfraudSe 5. FULL SECURITY INVESTIGATION (combined): {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "metrics": true, "overtime": true, "tops": ["incidentTypes", "socketIpOrgName"], "filters": {"trafficTags": ["blocked"]}} +6. SEARCH QUERY β€” TOP IPs FROM A SPECIFIC CIDR: + {"startTime": "${DATE_FORMAT_EXAMPLE_START}", "endTime": "${DATE_FORMAT_EXAMPLE_END}", "tops": ["socketIp"], "searchQuery": [{"type": "field", "key": "socketIp", "operator": "=", "value": "203.0.113.0/24"}]} + ⚠️ NOTES: β€’ At least one of overtime, metrics, or tops must be specified. β€’ trafficSource defaults to ["web", "mobile"] when omitted. β€’ app_id is derived from the API token β€” do not pass appIds. -β€’ tops limit defaults to 10 (max 100). Use includeNulls to include null-valued rows.`, +β€’ tops limit defaults to 10 (max 100). Use includeNulls to include null-valued rows. +β€’ searchQuery and filters are additive β€” both are applied when provided.`, inputSchema: TrafficDataInputBaseSchema.shape, outputSchema: makeStructuredResponseSchema(TrafficDataOutputSchema).shape, annotations: { diff --git a/src/tools/index.ts b/src/tools/index.ts index 86b5013..3ca3e6f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -9,6 +9,7 @@ import { registerCodeDefenderGetHeaderInventory } from './codeDefenderGetHeaderI import type { CyberfraudService } from '../services/cyberfraudService'; import type { CodeDefenderService } from '../services/codeDefenderService'; import { registerCyberfraudGetTrafficData } from './getTrafficData'; +import { registerGetRawActivities } from './getRawActivities'; export function registerTools( server: McpServer, @@ -24,6 +25,7 @@ export function registerTools( registerCyberfraudGetCustomRules(server, services.cyberfraudService); registerCyberfraudGetAccountInfo(server, services.cyberfraudService); registerCyberfraudGetTrafficData(server, services.cyberfraudService); + registerGetRawActivities(server, services.cyberfraudService); } // Register Code Defender tools if service is available diff --git a/src/types/cyberfraud/trafficData.ts b/src/types/cyberfraud/trafficData.ts index c9dec62..a08be4e 100644 --- a/src/types/cyberfraud/trafficData.ts +++ b/src/types/cyberfraud/trafficData.ts @@ -1,6 +1,62 @@ import { z } from 'zod'; import { DATE_FORMAT_EXAMPLE_END, DATE_FORMAT_EXAMPLE_START } from '../../utils/constants'; +// ============================================================================= +// Search Query +// ============================================================================= + +export const SearchQueryFieldItemSchema = z.object({ + type: z.literal('field'), + key: z.string().describe('Field key to filter on (e.g. socketIp, blockReference, displayScore, domain, path).'), + operator: z + .enum(['=', '!=', '>', '<', '>=', '<=', 'contains', 'exists', 'notExists']) + .describe( + 'Comparison operator. Use exists/notExists to check presence. Numeric operators for displayScore/httpStatusCode.', + ), + value: z + .union([z.string(), z.number()]) + .optional() + .describe('Value to compare. Omit for exists/notExists operators.'), + caseSensitive: z + .boolean() + .optional() + .describe('When true, comparison is case-sensitive. Defaults to case-insensitive.'), +}); + +export const SearchQueryOperatorItemSchema = z.object({ + type: z.literal('operator'), + operator: z.enum(['AND', 'OR', 'NOT', '(', ')']), +}); + +export const SearchQueryItemSchema = z.discriminatedUnion('type', [ + SearchQueryFieldItemSchema, + SearchQueryOperatorItemSchema, +]); + +export type SearchQueryItem = z.infer; + +export const SearchQuerySchema = z + .array(SearchQueryItemSchema) + .min(1) + .optional() + .describe( + 'Optional structured search query for field-level filtering with boolean logic. ' + + 'Each item is either a field expression ({type:"field", key, operator, value}) or a logical connector ({type:"operator", operator:"AND"|"OR"|"NOT"|"("|")"}). ' + + 'Available field keys and their operators:\n' + + '- socketIp (IP/CIDR): =, !=\n' + + '- blockReference (Block ID): =, exists, notExists\n' + + '- displayScore (risk score 0-100): =, !=, >, <, >=, <=\n' + + '- incidentTypes: =, !=, exists, notExists β€” valid values: "UI Anomaly", "Denylisted Service", "Custom Denylist", "Cloud Service", "Anonymizing Service", "Bot Behavior", "Spoof", "Behavioral Anomalies", "Automation Tool", "Bad Reputation", "Volumetric Rule", "Missing Sensor Data", "Volumetric Anomaly", "Captcha Solving Attack"\n' + + '- knownBot (Known Bot / IP Classification): =, !=, exists, notExists β€” example values: "Googlebot", "Bing Bot", "DuckDuckGo Bot", "GPTBot", "ClaudeBot", "PerplexityBot", "Facebook Crawler", "Twitterbot", "Pingdom", "Datadog - Synthetics", "UptimeRobot", "Semrush Bot", "Ahrefs" (many more exist β€” use exists/notExists for a broad match)\n' + + '- filterOriginReason, vid, accessTokenName: =, !=, exists, notExists\n' + + '- domain, path, uaServer, socketIpOrgName, userEmail, headerReferer: =, !=, contains, exists, notExists\n' + + '- httpMethod: =, != β€” values: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS\n' + + '- httpStatusCode: =, !=, >, <, >=, <=\n' + + '- graphqlOperationName, graphqlOperationType: =, !=, exists, notExists β€” graphqlOperationType values: query, mutation, subscription\n' + + '- customRule: =, !=, contains, exists, notExists\n' + + '- customParam1–customParam10: =, !=, contains, exists, notExists', + ); + export const TrafficDataSourceEnum = z.enum(['web', 'mobile']); export const TrafficDataTrafficTagsEnum = z.enum([ @@ -91,6 +147,7 @@ export const TrafficDataInputBaseSchema = z.object({ filters: TrafficDataFiltersSchema.describe( 'Optional filters for trafficTags, pageType, browserFamily, osFamily, and country.', ), + searchQuery: SearchQuerySchema, overtime: z.boolean().optional().describe('Fetch per-minute time-series traffic counts over the requested range.'), seriesFields: z .array(TrafficDataSeriesFieldsEnum) @@ -143,3 +200,98 @@ export const TrafficDataOutputSchema = z .passthrough(); export type TrafficDataResponse = z.infer; + +// ============================================================================= +// Raw Activities +// ============================================================================= + +const RequiredSearchQuerySchema = z + .array(SearchQueryItemSchema) + .min(1) + .describe( + 'Required structured search query for field-level filtering with boolean logic. ' + + 'Same syntax as human_get_traffic_data. ' + + 'Each item is either a field expression ({type:"field", key, operator, value}) or a logical connector ({type:"operator", operator:"AND"|"OR"|"NOT"|"("|")"}).', + ); + +export const RawActivitiesInputBaseSchema = z.object({ + searchQuery: RequiredSearchQuerySchema, + startTime: z + .string() + .describe( + `Start of the investigation window in ISO 8601 format. Must be within 4 hours of endTime. e.g. "${DATE_FORMAT_EXAMPLE_START}".`, + ), + endTime: z + .string() + .describe( + `End of the investigation window in ISO 8601 format. Must be within 4 hours of startTime. e.g. "${DATE_FORMAT_EXAMPLE_END}".`, + ), + trafficSource: z + .array(TrafficDataSourceEnum) + .optional() + .describe('Platform filter: ["web"], ["mobile"], or both. Defaults to ["web", "mobile"].'), + filters: TrafficDataFiltersSchema.describe('Optional additional filters to narrow the investigation scope.'), + limit: z + .number() + .int() + .min(1) + .max(100) + .optional() + .describe( + 'Max raw activity records to return (1-100). Defaults to 20. The count field always reflects the full total.', + ), + offset: z + .number() + .int() + .min(0) + .optional() + .describe('Number of records to skip for pagination (records are sorted newest-first). Defaults to 0.'), +}); + +export const RawActivitiesInputSchema = RawActivitiesInputBaseSchema; +export type RawActivitiesInput = z.infer; + +const RawActivitySchema = z + .object({ + timestamp: z.string().nullish(), + appId: z.string().nullish(), + appName: z.string().nullish(), + trafficTags: z.string().nullish(), + reqTag: z.string().nullish(), + ruleName: z.string().nullish(), + filterOriginReason: z.string().nullish(), + blockReference: z.string().nullish(), + socketIp: z.string().nullish(), + trafficSource: z.string().nullish(), + displayScore: z.number().nullish(), + incidentTypes: z.array(z.unknown()).nullish(), + vid: z.string().nullish(), + userEmail: z.string().nullish(), + osFamily: z.string().nullish(), + browserDisplay: z.string().nullish(), + country: z.string().nullish(), + socketIpOrgName: z.string().nullish(), + uaServer: z.string().nullish(), + knownBot: z.string().nullish(), + httpMethod: z.string().nullish(), + httpStatusCode: z.string().nullish(), + domain: z.string().nullish(), + path: z.string().nullish(), + headerReferer: z.string().nullish(), + filterId: z.string().nullish(), + accessTokenName: z.string().nullish(), + graphqlOperationName: z.string().nullish(), + graphqlOperationType: z.string().nullish(), + }) + .passthrough(); + +export const RawActivitiesOutputSchema = z.object({ + count: z.number().describe('Total number of matching activity records in the requested time window.'), + activities: z + .array(RawActivitySchema) + .describe( + 'Sample of matching raw activity records. Key fields for analysis: ' + + 'filterOriginReason (why it was filtered), ruleName (which rule triggered), ' + + 'displayScore (risk score 0-100), incidentTypes (detected threats), trafficTags (traffic classification).', + ), +}); diff --git a/test/services/cyberfraudService.test.ts b/test/services/cyberfraudService.test.ts index 0a301f4..ee82733 100644 --- a/test/services/cyberfraudService.test.ts +++ b/test/services/cyberfraudService.test.ts @@ -234,5 +234,174 @@ describe('CyberfraudService', () => { 'network fail', ); }); + + it('includes searchQuery in request body when provided', async () => { + const metricsContent = { results: { total: 500 }, labels: {} }; + httpClient.request.resolves(mockApiResponse(metricsContent)); + + await service.getTrafficData({ + ...baseParams, + metrics: true, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + } as any); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body.searchQuery).to.deep.equal([ + { type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }, + ]); + }); + + it('omits searchQuery from body when not provided', async () => { + const metricsContent = { results: { total: 500 }, labels: {} }; + httpClient.request.resolves(mockApiResponse(metricsContent)); + + await service.getTrafficData({ ...baseParams, metrics: true } as any); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body).to.not.have.property('searchQuery'); + }); + }); + + describe('getRawActivities', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('calls POST /activities with correct URL and body', async () => { + const activitiesContent = { results: [{ socketIp: '1.2.3.4', displayScore: 90 }] }; + httpClient.request.resolves(mockApiResponse(activitiesContent)); + + const result = await service.getRawActivities({ + startTime, + endTime, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + limit: 20, + offset: 0, + }); + + expect(httpClient.request.calledOnce).to.be.true; + const [url, options] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/activities'); + expect(url).to.include('from='); + expect(url).to.include('to='); + expect(options.method).to.equal('POST'); + expect(options.body.limit).to.equal(20); + expect(options.body.offset).to.equal(0); + expect(result).to.deep.equal(activitiesContent.results); + }); + + it('includes searchQuery in body when provided', async () => { + httpClient.request.resolves(mockApiResponse({ results: [] })); + + await service.getRawActivities({ + startTime, + endTime, + searchQuery: [{ type: 'field', key: 'blockReference', operator: '=', value: 'abc123' }], + }); + + const [, options] = httpClient.request.firstCall.args; + expect(options.body.searchQuery).to.deep.equal([ + { type: 'field', key: 'blockReference', operator: '=', value: 'abc123' }, + ]); + }); + }); + + describe('getRawActivitiesCount', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('calls POST /activities/count and returns total', async () => { + httpClient.request.resolves(mockApiResponse({ total: 42 })); + + const result = await service.getRawActivitiesCount({ + startTime, + endTime, + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + }); + + expect(httpClient.request.calledOnce).to.be.true; + const [url] = httpClient.request.firstCall.args; + expect(url).to.include('/cyberfraud/traffic-data/activities/count'); + expect(result).to.equal(42); + }); + }); + + describe('fetchRawActivities', () => { + const now = new Date(); + const startTime = new Date(now.getTime() - 30 * 60 * 1000).toISOString(); + const endTime = now.toISOString(); + const searchQuery = [{ type: 'field' as const, key: 'blockReference', operator: '=' as const, value: 'abc' }]; + + function mockApiResponse(content: unknown) { + return { json: async () => ({ result: true, message: 'success', content }), ok: true }; + } + + it('rejects when time range exceeds 4 hours', async () => { + const wideStart = new Date(now.getTime() - 5 * 60 * 60 * 1000).toISOString(); + await expect(service.fetchRawActivities({ searchQuery, startTime: wideStart, endTime })).to.be.rejectedWith( + 'must not exceed 4 hours', + ); + }); + + it('orchestrates parallel calls and returns combined result', async () => { + const activitiesContent = { results: [{ socketIp: '1.2.3.4' }] }; + const countContent = { total: 5 }; + + httpClient.request.onCall(0).resolves(mockApiResponse(activitiesContent)); + httpClient.request.onCall(1).resolves(mockApiResponse(countContent)); + + const result = await service.fetchRawActivities({ + searchQuery: [{ type: 'field', key: 'blockReference', operator: '=', value: 'b5e0-b1d1' }], + startTime, + endTime, + }); + + expect(httpClient.request.calledTwice).to.be.true; + expect(result.count).to.equal(5); + expect(result.activities).to.deep.equal(activitiesContent.results); + }); + + it('forwards limit and offset to the raw activities request and defaults to 20/0', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + await service.fetchRawActivities({ + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '1.2.3.4' }], + startTime, + endTime, + limit: 50, + offset: 40, + }); + + const activitiesCall = httpClient.request + .getCalls() + .find((c: any) => typeof c.args[0] === 'string' && c.args[0].includes('/activities?')); + expect(activitiesCall).to.exist; + expect(activitiesCall!.args[1].body.limit).to.equal(50); + expect(activitiesCall!.args[1].body.offset).to.equal(40); + }); + + it('passes searchQuery through to both activities and count requests', async () => { + httpClient.request.resolves(mockApiResponse({ results: [], total: 0 })); + + const query = [ + { type: 'field' as const, key: 'userEmail', operator: '=' as const, value: 'test@example.com' }, + ]; + await service.fetchRawActivities({ searchQuery: query, startTime, endTime }).catch(() => {}); + + const calls = httpClient.request.getCalls(); + expect(calls).to.have.lengthOf(2); + for (const call of calls) { + expect(call.args[1].body.searchQuery).to.deep.equal(query); + } + }); }); }); diff --git a/test/tools/getRawActivities.test.ts b/test/tools/getRawActivities.test.ts new file mode 100644 index 0000000..1d98f88 --- /dev/null +++ b/test/tools/getRawActivities.test.ts @@ -0,0 +1,40 @@ +import * as chai from 'chai'; +import sinon from 'sinon'; +import { registerGetRawActivities } from '../../src/tools/getRawActivities'; + +describe('registerGetRawActivities', () => { + const { expect } = chai; + + it('registers tool and handler calls service', async () => { + const server = { registerTool: sinon.stub() }; + const service = { + fetchRawActivities: sinon.stub().resolves({ + count: 5, + activities: [{ timestamp: '2026-07-02T10:00:00Z', socketIp: '203.0.113.10' }], + }), + }; + registerGetRawActivities(server as any, service as any); + expect(server.registerTool.calledOnce).to.be.true; + const [name, config, handler] = server.registerTool.firstCall.args; + expect(name).to.equal('human_get_raw_activities'); + expect(config).to.have.property('description'); + expect(config.description).to.include('blocked'); + + const params = { + searchQuery: [{ type: 'field', key: 'socketIp', operator: '=', value: '203.0.113.10' }], + startTime: '2026-07-02T10:00:00Z', + endTime: '2026-07-02T10:30:00Z', + }; + await handler(params); + expect(service.fetchRawActivities.calledWith(params)).to.be.true; + }); + + it('registers with readOnlyHint and openWorldHint annotations', () => { + const server = { registerTool: sinon.stub() }; + const service = { fetchRawActivities: sinon.stub() }; + registerGetRawActivities(server as any, service as any); + const [, config] = server.registerTool.firstCall.args; + expect(config.annotations.readOnlyHint).to.be.true; + expect(config.annotations.openWorldHint).to.be.true; + }); +});