diff --git a/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.test.ts b/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.test.ts new file mode 100644 index 0000000000000..fd945574f7ac9 --- /dev/null +++ b/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.test.ts @@ -0,0 +1,107 @@ +import { HogFunctionInvocationGlobals } from '../../../types' +import { TemplateTester } from '../../test/test-helpers' +import { template } from './ip-anonymization.template' + +describe('ip-anonymization.template', () => { + const tester = new TemplateTester(template) + let mockGlobals: HogFunctionInvocationGlobals + + beforeEach(async () => { + await tester.beforeEach() + }) + + it('should anonymize IPv4 address by zeroing last octet', async () => { + mockGlobals = tester.createGlobals({ + event: { + properties: { + $ip: '89.160.20.129', + }, + }, + }) + + const response = await tester.invoke({}, mockGlobals) + + expect(response.finished).toBe(true) + expect(response.error).toBeUndefined() + expect(response.execResult).toMatchObject({ + properties: { + $ip: '89.160.20.0', + }, + }) + }) + + it('should handle event with no IP address', async () => { + mockGlobals = tester.createGlobals({ + event: { + properties: {}, + }, + }) + + const response = await tester.invoke({}, mockGlobals) + + expect(response.finished).toBe(true) + expect(response.error).toBeUndefined() + expect(response.execResult).toMatchObject({ + properties: {}, + }) + }) + + it('should handle invalid IP address format', async () => { + const invalidIPs = [ + '89.160.20', // missing octet + '', // empty string + 'abc.def.ghi.jkl', // non-numeric + '256.256.256.256', // values > 255 + '1.2.3.4.5', // too many octets + 'not an ip', + ] + + for (const invalidIP of invalidIPs) { + mockGlobals = tester.createGlobals({ + event: { + properties: { + $ip: invalidIP, + }, + }, + }) + + const response = await tester.invoke({}, mockGlobals) + + expect(response.finished).toBe(true) + expect(response.error).toBeUndefined() + expect(response.execResult).toMatchObject({ + properties: { + $ip: invalidIP, + }, + }) + } + }) + + it('should handle various IPv4 formats', async () => { + const testCases = [ + { input: '192.168.1.1', expected: '192.168.1.0' }, + { input: '10.0.0.255', expected: '10.0.0.0' }, + { input: '172.16.254.1', expected: '172.16.254.0' }, + ] + + for (const testCase of testCases) { + mockGlobals = tester.createGlobals({ + event: { + properties: { + $ip: testCase.input, + }, + }, + }) + + const response = await tester.invoke({}, mockGlobals) + + expect(response.finished).toBe(true) + expect(response.error).toBeUndefined() + expect(response.execResult).toMatchObject({ + properties: { + $ip: testCase.expected, + }, + }) + } + }) +}) diff --git a/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.ts b/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.ts new file mode 100644 index 0000000000000..54cee73e617f1 --- /dev/null +++ b/plugin-server/src/cdp/templates/_transformations/ip-anonymization/ip-anonymization.template.ts @@ -0,0 +1,46 @@ +import { HogFunctionTemplate } from '../../types' + +export const template: HogFunctionTemplate = { + free: true, + status: 'alpha', + type: 'transformation', + id: 'template-ip-anonymization', + name: 'IP Anonymization', + description: + 'This transformation sets the last octet of an IP address to zero (e.g., 12.214.31.144 → 12.214.31.0), protecting user privacy and reducing disclosure risk.', + icon_url: '/static/hedgehog/builder-hog-01.png', + category: ['Custom'], + hog: ` +// Check if the event has an IP address +if (empty(event.properties?.$ip)) { + print('No IP address found in event') + return event +} + +let ip := event.properties.$ip +let parts := splitByString('.', ip) + +// Check if we have exactly 4 parts for IPv4 +if (length(parts) != 4) { + print('Invalid IP address format: wrong number of octets') + return event +} + +// Validate each octet is a number between 0 and 255 +for (let i := 1; i <= 4; i := i + 1) { + let octet := toInt(parts[i]) + if (octet = null or octet < 0 or octet > 255) { + print('Invalid IP address: octets must be numbers between 0 and 255') + return event + } +} + +// Replace the last octet with '0' +let anonymizedIp := concat(parts[1], '.', parts[2], '.', parts[3], '.0') + +let returnEvent := event +returnEvent.properties.$ip := anonymizedIp +return returnEvent + `, + inputs_schema: [], +} diff --git a/plugin-server/src/cdp/templates/index.ts b/plugin-server/src/cdp/templates/index.ts index 9038b57969157..d130c79c2eb74 100644 --- a/plugin-server/src/cdp/templates/index.ts +++ b/plugin-server/src/cdp/templates/index.ts @@ -2,6 +2,7 @@ import { DESTINATION_PLUGINS, TRANSFORMATION_PLUGINS } from '../legacy-plugins' import { template as webhookTemplate } from './_destinations/webhook/webhook.template' import { template as defaultTransformationTemplate } from './_transformations/default/default.template' import { template as geoipTemplate } from './_transformations/geoip/geoip.template' +import { template as ipAnonymizationTemplate } from './_transformations/ip-anonymization/ip-anonymization.template' import { HogFunctionTemplate } from './types' export const HOG_FUNCTION_TEMPLATES_DESTINATIONS: HogFunctionTemplate[] = [webhookTemplate] @@ -9,6 +10,7 @@ export const HOG_FUNCTION_TEMPLATES_DESTINATIONS: HogFunctionTemplate[] = [webho export const HOG_FUNCTION_TEMPLATES_TRANSFORMATIONS: HogFunctionTemplate[] = [ defaultTransformationTemplate, geoipTemplate, + ipAnonymizationTemplate, ] export const HOG_FUNCTION_TEMPLATES_DESTINATIONS_DEPRECATED: HogFunctionTemplate[] = DESTINATION_PLUGINS.map(