From 726ed86518f976c7cc66cdac2fd2c45a693c4a98 Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Fri, 17 Oct 2025 18:02:58 -0500 Subject: [PATCH 1/9] Add severity breakdown and match export to Rules Introduces severity badge display and per-organization severity breakdowns in the Rules component, with supporting CSS for both light and dark themes. Adds a new 'Export All Matches' button to download all matches as consolidated JSON, and enhances timestamp display with instant tooltips showing local and relative time. Also includes utility/test updates and adds Vitest as a dev dependency. --- package-lock.json | 445 ++++++++++++++++++ package.json | 1 + src/assets/styles.css | 161 +++++++ src/components/Rules.vue | 333 ++++++++++++- src/utils/__tests__/drValidation.spec.ts | 211 +++++++++ .../__tests__/fixtures/validDetectRules.ts | 184 ++++++++ src/utils/drConstants.ts | 2 + src/utils/drSchema.ts | 30 +- src/utils/drValidation.ts | 55 ++- 9 files changed, 1396 insertions(+), 26 deletions(-) create mode 100644 src/utils/__tests__/drValidation.spec.ts create mode 100644 src/utils/__tests__/fixtures/validDetectRules.ts diff --git a/package-lock.json b/package-lock.json index 4d95d4b..8a60652 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "vite": "^6.2.4", "vite-plugin-pwa": "^1.0.1", "vite-plugin-vue-devtools": "^7.7.2", + "vitest": "^3.2.4", "vue-tsc": "^2.2.8" }, "optionalDependencies": { @@ -3236,6 +3237,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/chai": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", + "integrity": "sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*" + } + }, "node_modules/@types/codemirror": { "version": "5.60.16", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.16.tgz", @@ -3246,6 +3257,13 @@ "@types/tern": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/dompurify": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", @@ -3570,6 +3588,131 @@ "vue": "^3.2.25" } }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.15", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", @@ -4026,6 +4169,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -4234,6 +4387,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -4315,6 +4478,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4332,6 +4512,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", + "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/codemirror": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", @@ -4557,6 +4747,16 @@ } } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -4811,6 +5011,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -5230,6 +5437,16 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/expect-type": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", + "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6694,6 +6911,13 @@ "dev": true, "license": "MIT" }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -7171,6 +7395,16 @@ "dev": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -7914,6 +8148,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -8010,6 +8251,20 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", @@ -8162,6 +8417,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/style-mod": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz", @@ -8283,6 +8558,20 @@ "node": ">=10" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -8331,6 +8620,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -8792,6 +9111,29 @@ "vite": "^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0" } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite-plugin-inspect": { "version": "0.8.9", "resolved": "https://registry.npmjs.org/vite-plugin-inspect/-/vite-plugin-inspect-0.8.9.tgz", @@ -8929,6 +9271,92 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -9162,6 +9590,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", diff --git a/package.json b/package.json index 3a1cea7..a3b6ce3 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "vite": "^6.2.4", "vite-plugin-pwa": "^1.0.1", "vite-plugin-vue-devtools": "^7.7.2", + "vitest": "^3.2.4", "vue-tsc": "^2.2.8" }, "optionalDependencies": { diff --git a/src/assets/styles.css b/src/assets/styles.css index f3d0aec..4b43946 100644 --- a/src/assets/styles.css +++ b/src/assets/styles.css @@ -3955,6 +3955,51 @@ body.config { background: var(--bg-tertiary); padding: 4px 8px; border-radius: var(--radius-sm); + cursor: help; + position: relative; +} + +/* Custom instant tooltip */ +.match-timestamp[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-8px); + background: rgba(30, 30, 35, 0.98); + color: #ffffff; + padding: 10px 14px; + border-radius: 8px; + font-size: 12px; + white-space: pre-line; + opacity: 0; + pointer-events: none; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + z-index: 1000; + transition: opacity 0s, transform 0s; + text-align: center; + line-height: 1.6; + backdrop-filter: blur(8px); +} + +.match-timestamp[data-tooltip]::before { + content: ''; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-2px); + border: 6px solid transparent; + border-top-color: rgba(30, 30, 35, 0.98); + opacity: 0; + pointer-events: none; + z-index: 1001; + transition: opacity 0s; +} + +.match-timestamp[data-tooltip]:hover::after, +.match-timestamp[data-tooltip]:hover::before { + opacity: 1; } .match-hostname { @@ -3971,6 +4016,80 @@ body.config { font-weight: 500; } +/* Severity badges for detection matches */ +.severity-badge { + padding: 4px 10px; + border-radius: var(--radius-xl); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + margin-left: 6px; + display: inline-block; +} + +.severity-critical { + background: #fee; + color: #c00; + border: 1px solid #fcc; +} + +.severity-high { + background: #ffebe6; + color: #d64400; + border: 1px solid #ffd1c2; +} + +.severity-medium { + background: #fff7e6; + color: #b8860b; + border: 1px solid #ffe4a3; +} + +.severity-low { + background: #e3f2fd; + color: #1976d2; + border: 1px solid #bbdefb; +} + +.severity-info, +.severity-informational { + background: #e8f5e9; + color: #2e7d32; + border: 1px solid #c8e6c9; +} + +/* Dark mode severity badges */ +[data-theme="dark"] .severity-critical { + background: rgba(220, 38, 38, 0.2); + color: #fca5a5; + border-color: rgba(220, 38, 38, 0.3); +} + +[data-theme="dark"] .severity-high { + background: rgba(249, 115, 22, 0.2); + color: #fdba74; + border-color: rgba(249, 115, 22, 0.3); +} + +[data-theme="dark"] .severity-medium { + background: rgba(234, 179, 8, 0.2); + color: #fde047; + border-color: rgba(234, 179, 8, 0.3); +} + +[data-theme="dark"] .severity-low { + background: rgba(59, 130, 246, 0.2); + color: #93c5fd; + border-color: rgba(59, 130, 246, 0.3); +} + +[data-theme="dark"] .severity-info, +[data-theme="dark"] .severity-informational { + background: rgba(34, 197, 94, 0.2); + color: #86efac; + border-color: rgba(34, 197, 94, 0.3); +} + .match-toggle { font-size: var(--font-sm); color: var(--text-muted); @@ -4233,6 +4352,48 @@ body.config { transition: color var(--transition-speed) ease; } +/* Severity-colored stat values */ +.org-stat .stat-value.severity-critical { + color: #c00; +} + +.org-stat .stat-value.severity-high { + color: #d64400; +} + +.org-stat .stat-value.severity-medium { + color: #b8860b; +} + +.org-stat .stat-value.severity-low { + color: #1976d2; +} + +.org-stat .stat-value.severity-info { + color: #2e7d32; +} + +/* Dark mode severity stat values */ +[data-theme="dark"] .org-stat .stat-value.severity-critical { + color: #fca5a5; +} + +[data-theme="dark"] .org-stat .stat-value.severity-high { + color: #fdba74; +} + +[data-theme="dark"] .org-stat .stat-value.severity-medium { + color: #fde047; +} + +[data-theme="dark"] .org-stat .stat-value.severity-low { + color: #93c5fd; +} + +[data-theme="dark"] .org-stat .stat-value.severity-info { + color: #86efac; +} + .org-matches { margin-top: 15px; } diff --git a/src/components/Rules.vue b/src/components/Rules.vue index 767a8bb..acf7301 100644 --- a/src/components/Rules.vue +++ b/src/components/Rules.vue @@ -926,14 +926,23 @@ {{ backtestProgress.total }} organizations completed - +
+ + +
@@ -1253,6 +1262,55 @@ + +
+
+ Matches by Severity +
+
+ {{ + getSeverityCounts(orgResult.results)!.critical.toLocaleString() + }} + Critical +
+
+ {{ + getSeverityCounts(orgResult.results)!.high.toLocaleString() + }} + High +
+
+ {{ + getSeverityCounts(orgResult.results)!.medium.toLocaleString() + }} + Medium +
+
+ {{ + getSeverityCounts(orgResult.results)!.low.toLocaleString() + }} + Low +
+
+ {{ + getSeverityCounts(orgResult.results)!.info.toLocaleString() + }} + Info +
+
+
- {{ - formatTimestamp(result.data.detect.ts) - }} + {{ formatTimestamp(result.data.detect.ts) }} {{ result.data.detect.routing.hostname }} {{ result.action }}: {{ result.data.cat }} + + {{ result.data.detect_mtd.level }} +
{{ @@ -3060,6 +3128,7 @@ const expandedMatches = ref(new Set()) // Changed to string to support o const activeMatchTab = ref>({}) // Changed to string keys const expandedOrgResults = ref(new Set()) // Track which org results are expanded by OID const orgDisplayedResults = ref>({}) // Track displayed results per org by OID +const timestampTooltips = ref>({}) // Store tooltip content for each timestamp // Cursor-based pagination state const orgCursors = ref>({}) // Track cursors per org by OID @@ -3328,6 +3397,36 @@ function formatCost(cost: number): string { return `$${cost.toFixed(2)}` } +// Calculate severity counts from match results +function getSeverityCounts(results: BacktestMatch[] | undefined): Record | null { + if (!results || results.length === 0) return null + + const counts: Record = { + critical: 0, + high: 0, + medium: 0, + low: 0, + info: 0, + } + + let hasSeverityData = false + + results.forEach((result) => { + const level = result.data.detect_mtd?.level?.toLowerCase() + if (level) { + hasSeverityData = true + // Handle both 'info' and 'informational' + const normalizedLevel = level === 'informational' ? 'info' : level + if (counts[normalizedLevel] !== undefined) { + counts[normalizedLevel]++ + } + } + }) + + // Only return counts if at least one result had severity data + return hasSeverityData ? counts : null +} + interface UnitTest { id: string name: string @@ -3446,6 +3545,10 @@ interface BacktestMatch { ts: string } detect_id: string + detect_mtd?: { + level?: string + [key: string]: unknown + } gen_time: number link?: string mtd: Record @@ -6280,7 +6383,38 @@ ${results.orgResults return `| ${org.orgName} | ${statusIcon} ${org.status} | ${matchCount} | ${billed} | ${free} | ${cost} | ${duration} | ${retries} |` }) .join('\n')} +${ + // Add severity breakdown section if any org has severity data + (() => { + const orgsWithSeverity = results.orgResults.filter((org) => { + const severityCounts = getSeverityCounts(org.results) + return severityCounts !== null + }) + + if (orgsWithSeverity.length === 0) return '' + + let severitySection = '\n## Severity Breakdown by Organization\n\n' + orgsWithSeverity.forEach((org) => { + const severityCounts = getSeverityCounts(org.results) + if (!severityCounts) return + + severitySection += `**${org.orgName}** (${org.results?.length || 0} total matches)\n` + + // Only show severity levels that have matches + const severityOrder = ['critical', 'high', 'medium', 'low', 'info'] + severityOrder.forEach((level) => { + if (severityCounts[level] > 0) { + severitySection += `- ${level.charAt(0).toUpperCase() + level.slice(1)}: ${severityCounts[level].toLocaleString()}\n` + } + }) + + severitySection += '\n' + }) + + return severitySection + })() +} --- *Generated by DetectionForge on ${new Date().toISOString()}*` @@ -6296,6 +6430,82 @@ ${results.orgResults }) } +function exportAllMatches() { + if (!backtestResults.value) return + + // Filter organizations with matches + const orgsWithMatches = backtestResults.value.orgResults.filter( + (org) => org.status === 'success' && org.results && org.results.length > 0, + ) + + if (orgsWithMatches.length === 0) { + appStore.addNotification('info', 'No matches found in any organization') + return + } + + // Consolidate all matches from all organizations + const allMatches: Array = [] + let totalMatches = 0 + + orgsWithMatches.forEach((org) => { + if (org.results) { + org.results.forEach((match) => { + allMatches.push({ + ...match, + _metadata: { + oid: org.oid, + orgName: org.orgName, + }, + }) + totalMatches++ + }) + } + }) + + const exportData = { + backtest_metadata: { + rule_name: currentRule.name, + exported_at: new Date().toISOString(), + backtest_completed_at: backtestResults.value.completedAt, + timeframe: backtestResults.value.timeframe, + total_organizations_with_matches: orgsWithMatches.length, + total_organizations_tested: backtestResults.value.orgResults.length, + total_matches: totalMatches, + execution_stats: backtestResults.value.executionStats, + billing_summary: { + total_billed: backtestResults.value.totalStats.n_billed, + total_free: backtestResults.value.totalStats.n_free, + actual_cost: calculateCost(backtestResults.value.totalStats.n_billed), + saved_cost: calculateCost(backtestResults.value.totalStats.n_free), + cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), + saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), + }, + organizations: orgsWithMatches.map((org) => ({ + oid: org.oid, + name: org.orgName, + match_count: org.results?.length || 0, + stats: org.stats, + })), + }, + matches: allMatches, + } + + const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `backtest-all-matches-${currentRule.name.replace(/[^a-z0-9]/gi, '-')}-${new Date().toISOString().split('T')[0]}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + appStore.addNotification( + 'success', + `Exported ${totalMatches.toLocaleString()} matches from ${orgsWithMatches.length} organization${orgsWithMatches.length !== 1 ? 's' : ''}`, + ) +} + function exportUnitTestSummaryAsMarkdown() { if (!overallTestResults.value || unitTests.value.length === 0) return @@ -6511,9 +6721,104 @@ function getOrgName(oid: string): string { } function formatTimestamp(timestamp: string | number): string { - const date = typeof timestamp === 'string' ? new Date(timestamp) : new Date(timestamp * 1000) - // Return UTC timestamp with 'Z' suffix to clearly indicate UTC timezone - return date.toISOString() + if (typeof timestamp === 'string') { + // If the timestamp is a string without timezone info, treat it as UTC + // Replace space with 'T' for ISO 8601 format and add 'Z' if not present + let utcTimestamp = timestamp + if (!timestamp.endsWith('Z')) { + // Replace space with 'T' and add 'Z' to indicate UTC + utcTimestamp = timestamp.replace(' ', 'T') + 'Z' + } + return new Date(utcTimestamp).toISOString() + } + // For numeric timestamps (Unix epoch in seconds), convert to milliseconds + return new Date(timestamp * 1000).toISOString() +} + +function formatTimestampToLocal(timestamp: string | number): string { + let date: Date + if (typeof timestamp === 'string') { + // If the timestamp is a string without timezone info, treat it as UTC + let utcTimestamp = timestamp + if (!timestamp.endsWith('Z')) { + // Replace space with 'T' and add 'Z' to indicate UTC + utcTimestamp = timestamp.replace(' ', 'T') + 'Z' + } + date = new Date(utcTimestamp) + } else { + // For numeric timestamps (Unix epoch in seconds), convert to milliseconds + date = new Date(timestamp * 1000) + } + + // Format as local time with timezone offset + const localString = date.toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false + }) + + // Get timezone offset in hours and minutes + const offset = -date.getTimezoneOffset() + const offsetHours = Math.floor(Math.abs(offset) / 60) + const offsetMinutes = Math.abs(offset) % 60 + const offsetSign = offset >= 0 ? '+' : '-' + const timezone = `UTC${offsetSign}${offsetHours.toString().padStart(2, '0')}:${offsetMinutes.toString().padStart(2, '0')}` + + return `${localString} (${timezone})` +} + +function formatRelativeTime(timestamp: string | number): string { + let date: Date + if (typeof timestamp === 'string') { + // If the timestamp is a string without timezone info, treat it as UTC + let utcTimestamp = timestamp + if (!timestamp.endsWith('Z')) { + // Replace space with 'T' and add 'Z' to indicate UTC + utcTimestamp = timestamp.replace(' ', 'T') + 'Z' + } + date = new Date(utcTimestamp) + } else { + // For numeric timestamps (Unix epoch in seconds), convert to milliseconds + date = new Date(timestamp * 1000) + } + + const now = Date.now() + const diffMs = now - date.getTime() + + if (diffMs < 0) { + return 'in the future' + } + + const days = Math.floor(diffMs / (1000 * 60 * 60 * 24)) + const hours = Math.floor((diffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)) + const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) + + const parts: string[] = [] + if (days > 0) parts.push(`${days}d`) + if (hours > 0) parts.push(`${hours}h`) + if (minutes > 0 || parts.length === 0) parts.push(`${minutes}m`) + + return `${parts.join(' ')} ago` +} + +function updateTimestampTooltip(timestamp: string | number, matchId: string) { + const localTime = formatTimestampToLocal(timestamp) + const relativeTime = formatRelativeTime(timestamp) + timestampTooltips.value[matchId] = `${localTime}\n${relativeTime}` +} + +function getTimestampTooltip(timestamp: string | number, matchId: string): string { + // Return cached tooltip if exists, otherwise compute initial one + if (timestampTooltips.value[matchId]) { + return timestampTooltips.value[matchId] + } + // Compute and cache initial tooltip + updateTimestampTooltip(timestamp, matchId) + return timestampTooltips.value[matchId] } function formatDate(dateString: string): string { diff --git a/src/utils/__tests__/drValidation.spec.ts b/src/utils/__tests__/drValidation.spec.ts new file mode 100644 index 0000000..8fba6f6 --- /dev/null +++ b/src/utils/__tests__/drValidation.spec.ts @@ -0,0 +1,211 @@ +import yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' + +import { validateDetectLogic } from '../drValidation' +import { validDetectRuleFixtures } from './fixtures/validDetectRules' + +describe('validateDetectLogic operator-specific behaviour', () => { + it("allows 'name' for 'is platform' operator", () => { + const rule = `event: USER_OBSERVED\nop: and\nrules:\n - op: exists\n path: event/USER_NAME\n - op: is platform\n name: windows\n` + + expect(validateDetectLogic(rule)).toBeNull() + }) + + it('still enforces required fields for operator schemas', () => { + const rule = `event: USER_OBSERVED\nop: is platform\n` + + expect(validateDetectLogic(rule)).toBe("Operator 'is platform' requires a 'name' field.") + }) + + it('allows exists truthy toggle', () => { + const rule = `event: NEW_PROCESS\nop: exists\npath: event/PARENT\ntruthy: true\n` + + expect(validateDetectLogic(rule)).toBeNull() + }) + + it('rejects non-boolean truthy property', () => { + const rule = `event: NEW_PROCESS\nop: exists\npath: event/PARENT\ntruthy: yes\n` + + expect(validateDetectLogic(rule)).toBe( + "Property 'truthy' must be a boolean (true or false).", + ) + }) + + it('allows architecture checks without a path', () => { + const rule = `event: NEW_PROCESS\nop: and\nrules:\n - op: is 64 bit\n - op: is arm\n not: true\n` + + expect(validateDetectLogic(rule)).toBeNull() + }) +}) + +describe('validateDetectLogic real-world fixtures', () => { + for (const fixture of validDetectRuleFixtures) { + it(`accepts sanitized rule: ${fixture.name}`, () => { + expect(validateDetectLogic(fixture.yaml)).toBeNull() + }) + } + + it('flags lookup rules missing resource or lookup', () => { + const fixture = validDetectRuleFixtures.find((item) => + item.name.includes('Poor Reputation IP'), + ) + + expect(fixture).toBeDefined() + + const parsed = yaml.load(fixture!.yaml) as any + const mutated = JSON.parse(JSON.stringify(parsed)) + const lookupOrRules: any[] = mutated.rules[1].rules[1].rules + let mutatedEntry: any | undefined + for (const candidate of lookupOrRules) { + if (candidate.op === 'lookup') { + delete candidate.resource + delete candidate.lookup + mutatedEntry = candidate + break + } + } + + expect(mutatedEntry).toBeDefined() + + const invalid = yaml.dump(mutated) + + expect(validateDetectLogic(invalid)).toBe( + "Operator 'lookup' requires either a 'resource' or 'lookup' field.", + ) + }) + + it('validates metadata_rules payloads recursively', () => { + const fixture = validDetectRuleFixtures.find((item) => + item.name.includes('Poor Reputation IP'), + ) + + expect(fixture).toBeDefined() + + const parsed = yaml.load(fixture!.yaml) as any + + const missingOp = JSON.parse(JSON.stringify(parsed)) + const lookupVariant = missingOp.rules[1].rules[1].rules.find( + (entry: any) => entry.op === 'lookup' && entry.metadata_rules, + ) + expect(lookupVariant).toBeDefined() + delete lookupVariant.metadata_rules.op + + expect(validateDetectLogic(yaml.dump(missingOp))).toContain( + "metadata_rules[0]: Operation missing 'op' field.", + ) + + const emptyRules = JSON.parse(JSON.stringify(parsed)) + const lookupEmpty = emptyRules.rules[1].rules[1].rules.find( + (entry: any) => entry.op === 'lookup' && entry.metadata_rules, + ) + expect(lookupEmpty).toBeDefined() + lookupEmpty.metadata_rules = [] + + expect(validateDetectLogic(yaml.dump(emptyRules))).toBe( + "Property 'metadata_rules' must contain at least one rule.", + ) + }) +}) + +describe('validateDetectLogic aligns with LimaCharlie docs', () => { + const baseEvent = 'event: TEST_EVENT' + + it('enforces list requirements for and/or', () => { + const valid = `${baseEvent}\nop: and\nrules:\n - op: exists\n path: event/FOO\n - op: exists\n path: event/BAR\n` + expect(validateDetectLogic(valid)).toBeNull() + + const invalid = `${baseEvent}\nop: or\nrules:\n - op: exists\n path: event/ONLY\n` + expect(validateDetectLogic(invalid)).toBe("'and' and 'or' op require at least 2 rules.") + }) + + it('requires path and value for is/contains family', () => { + const valid = `${baseEvent}\nop: is\npath: event/PROCESS_ID\nvalue: 9999\ncase sensitive: false\n` + expect(validateDetectLogic(valid)).toBeNull() + + const missingValue = `${baseEvent}\nop: contains\npath: event/COMMAND_LINE\n` + expect(validateDetectLogic(missingValue)).toBe("Operator 'contains' requires a 'value' field.") + + const missingPath = `${baseEvent}\nop: starts with\nvalue: powershell\n` + expect(validateDetectLogic(missingPath)).toBe("Operator 'starts with' requires a 'path' field.") + }) + + it('requires regex for matches operator', () => { + const valid = `${baseEvent}\nop: matches\npath: event/COMMAND_LINE\nre: \\b(cmd|powershell)\\b\n` + expect(validateDetectLogic(valid)).toBeNull() + + const invalid = `${baseEvent}\nop: matches\npath: event/COMMAND_LINE\n` + expect(validateDetectLogic(invalid)).toBe("Operator 'matches' requires a 're' field.") + }) + + it('requires path/value/max for string distance', () => { + const valid = `${baseEvent}\nop: string distance\npath: event/DOMAIN_NAME\nvalue:\n - example.com\nmax: 2\n` + expect(validateDetectLogic(valid)).toBeNull() + + const invalid = `${baseEvent}\nop: string distance\npath: event/DOMAIN_NAME\nmax: 2\n` + expect(validateDetectLogic(invalid)).toBe("Operator 'string distance' requires a 'value' field.") + }) + + it('covers platform and architecture operators', () => { + const platformValid = `${baseEvent}\nop: is platform\nname: windows\n` + expect(validateDetectLogic(platformValid)).toBeNull() + + const platformInvalid = `${baseEvent}\nop: is platform\n` + expect(validateDetectLogic(platformInvalid)).toBe("Operator 'is platform' requires a 'name' field.") + + const archValid = `${baseEvent}\nop: and\nrules:\n - op: is 32 bit\n - op: is 64 bit\n not: true\n` + expect(validateDetectLogic(archValid)).toBeNull() + }) + + it('requires path-related operands for cidr and address checks', () => { + const cidrValid = `${baseEvent}\nop: cidr\npath: event/IP\ncidr: 10.0.0.0/24\n` + expect(validateDetectLogic(cidrValid)).toBeNull() + + const cidrMissing = `${baseEvent}\nop: cidr\npath: event/IP\n` + expect(validateDetectLogic(cidrMissing)).toBe("Operator 'cidr' requires a 'cidr' field.") + + const publicValid = `${baseEvent}\nop: is public address\npath: event/IP\n` + expect(validateDetectLogic(publicValid)).toBeNull() + + const publicMissing = `${baseEvent}\nop: is private address\n` + expect(validateDetectLogic(publicMissing)).toBe("Operator 'is private address' requires a 'path' field.") + }) + + it('enforces lookup resource and metadata rules shape', () => { + const lookupValid = `${baseEvent}\nop: lookup\npath: event/DOMAIN\nresource: hive://lookups/sample\ncase sensitive: false\n` + expect(validateDetectLogic(lookupValid)).toBeNull() + + const lookupMissing = `${baseEvent}\nop: lookup\npath: event/DOMAIN\n` + expect(validateDetectLogic(lookupMissing)).toBe( + "Operator 'lookup' requires either a 'resource' or 'lookup' field.", + ) + + const lookupMetadata = `${baseEvent}\nop: lookup\npath: event/DOMAIN\nresource: hive://lookups/sample\nmetadata_rules:\n op: contains\n path: event/geoip/city_name\n value: Test\n` + expect(validateDetectLogic(lookupMetadata)).toBeNull() + + const lookupMetadataEmpty = `${baseEvent}\nop: lookup\npath: event/DOMAIN\nresource: hive://lookups/sample\nmetadata_rules: []\n` + expect(validateDetectLogic(lookupMetadataEmpty)).toBe( + "Property 'metadata_rules' must contain at least one rule.", + ) + }) + + it('requires scope path and nested rule', () => { + const scopeValid = `${baseEvent}\nop: scope\npath: event/NETWORK_ACTIVITY\nrule:\n op: is\n path: event/DESTINATION/PORT\n value: 443\n` + expect(validateDetectLogic(scopeValid)).toBeNull() + + const scopeMissingRule = `${baseEvent}\nop: scope\npath: event/NETWORK_ACTIVITY\n` + expect(validateDetectLogic(scopeMissingRule)).toBe("Operator 'scope' requires a 'rule' field.") + }) + + it('requires temporal operands to provide seconds', () => { + const valid = `${baseEvent}\nop: is older than\npath: routing/event_time\nseconds: 3600\n` + expect(validateDetectLogic(valid)).toBeNull() + + const invalid = `${baseEvent}\nop: is older than\npath: routing/event_time\n` + expect(validateDetectLogic(invalid)).toBe("Operator 'is older than' requires a 'seconds' field.") + }) + + it('permits documented transforms and times modifiers', () => { + const rule = `${baseEvent}\nop: ends with\npath: event/FILE_PATH\nvalue: chrome.exe\ncase sensitive: false\nfile name: true\ntimes:\n - day_of_week_start: 2\n day_of_week_end: 6\n time_of_day_start: 2200\n time_of_day_end: 2359\n tz: America/Los_Angeles\n` + expect(validateDetectLogic(rule)).toBeNull() + }) +}) diff --git a/src/utils/__tests__/fixtures/validDetectRules.ts b/src/utils/__tests__/fixtures/validDetectRules.ts new file mode 100644 index 0000000..cbbe4da --- /dev/null +++ b/src/utils/__tests__/fixtures/validDetectRules.ts @@ -0,0 +1,184 @@ +export interface DetectRuleFixture { + name: string + yaml: string +} + +const trim = (input: string) => { + const withoutBlankEdges = input.replace(/^\n+|\n+$/g, '') + const lines = withoutBlankEdges.split('\n') + const indents = lines + .filter((line) => line.trim().length > 0) + .map((line) => line.match(/^\s*/)?.[0].length ?? 0) + const minIndent = indents.length > 0 ? Math.min(...indents) : 0 + return lines + .map((line) => line.slice(minIndent)) + .join('\n') +} + +export const validDetectRuleFixtures: DetectRuleFixture[] = [ + { + name: 'Windows Sdclt Child Process', + yaml: trim(` + events: + - NEW_PROCESS + - EXISTING_PROCESS + op: and + rules: + - op: is platform + name: windows + - case sensitive: false + op: is + path: event/FILE_PATH + value: sdclt.exe + `), + }, + { + name: 'Suspicious PowerShell Parent Chain', + yaml: trim(` + event: WEL + op: and + rules: + - op: and + rules: + - case sensitive: false + op: is + path: event/EVENT/System/_event_id + value: '1' + - case sensitive: false + op: is + path: event/EVENT/System/Channel + value: Microsoft-Windows-Sysmon/Operational + - op: and + rules: + - op: or + rules: + - case sensitive: false + op: ends with + path: event/EVENT/EventData/ParentImage + value: \\wscript.exe + - case sensitive: false + op: ends with + path: event/EVENT/EventData/ParentImage + value: \\cscript.exe + - case sensitive: false + op: ends with + path: event/EVENT/EventData/Image + value: \\powershell.exe + `), + }, + { + name: 'O365 Login From Poor Reputation IP', + yaml: trim(` + op: and + rules: + - op: is tagged + tag: sanitized_o365 + - op: and + rules: + - case sensitive: false + op: is + path: event/Operation + value: UserLoggedIn + - op: or + rules: + - op: lookup + path: event/ClientIP + resource: lcr://lookup/sanitized-bad-ips + - op: lookup + path: event/ClientIP + resource: lcr://lookup/sanitized-tor + - metadata_rules: + case sensitive: false + op: contains + path: event/geoip/city_name + value: SanitizedCity + op: lookup + path: event/ClientIP + resource: lcr://api/sanitized-geo + `), + }, + { + name: 'O365 Inbox Rule From Address Scope', + yaml: trim(` + op: and + rules: + - op: is tagged + tag: sanitized_o365 + - op: and + rules: + - op: or + rules: + - case sensitive: false + op: is + path: event/Operation + value: New-InboxRule + - case sensitive: false + op: is + path: event/Operation + value: Set-InboxRule + - op: scope + path: event/Parameters + rule: + op: and + rules: + - op: is + path: event/Name + value: FromAddressContainsWords + - op: exists + path: event/Value + `), + }, + { + name: 'Non-RFC1918 Windows Logon', + yaml: trim(` + event: WEL + op: and + rules: + - case sensitive: false + op: is + path: event/EVENT/System/Channel + value: Security + - op: and + rules: + - case sensitive: false + op: is + path: event/EVENT/System/_event_id + value: '4624' + - op: is public address + path: event/EVENT/EventData/IpAddress + - cidr: 0.0.0.0/0 + op: cidr + path: event/EVENT/EventData/IpAddress + - not: true + op: or + rules: + - op: or + rules: + - case sensitive: false + op: is + path: event/EVENT/EventData/IpAddress + value: 'null' + - case sensitive: false + op: is + path: event/EVENT/EventData/TargetUserName + value: Anonymous Logon + - op: or + rules: + - case sensitive: false + op: is + path: event/EVENT/EventData/IpAddress + value: 127.0.0.1 + - cidr: 169.254.0.0/16 + op: cidr + path: event/EVENT/EventData/IpAddress + - case sensitive: false + op: is + path: event/EVENT/EventData/IpAddress + value: 0.0.0.0 + - case sensitive: false + op: ends with + path: event/EVENT/EventData/ProcessName + value: \\inetsrv\\w3wp.exe + `), + }, +] diff --git a/src/utils/drConstants.ts b/src/utils/drConstants.ts index 49a631c..9fcc347 100644 --- a/src/utils/drConstants.ts +++ b/src/utils/drConstants.ts @@ -142,6 +142,7 @@ export const VALID_DETECT_PROPERTIES = [ 'length of', 'times', 'with child', + 'truthy', // Transform modifiers (can be used as boolean fields with operators) 'file name', @@ -151,6 +152,7 @@ export const VALID_DETECT_PROPERTIES = [ 'cidr', 'lookup', 'scope', + 'metadata_rules', ] as const // ============================================================================ diff --git a/src/utils/drSchema.ts b/src/utils/drSchema.ts index e44d3b7..5a3e7e8 100644 --- a/src/utils/drSchema.ts +++ b/src/utils/drSchema.ts @@ -122,7 +122,10 @@ export const OPERATOR_SCHEMAS: Record = { name: 'exists', description: 'Check if field exists', requiredFields: ['path'], - optionalFields: [{ name: 'not', type: 'boolean', description: 'Invert the result' }], + optionalFields: [ + { name: 'not', type: 'boolean', description: 'Invert the result' }, + { name: 'truthy', type: 'boolean', description: 'Treat null/empty strings as missing' }, + ], examples: ['op: exists\npath: event/NETWORK_ACTIVITY'], category: 'numeric', }, @@ -194,9 +197,9 @@ export const OPERATOR_SCHEMAS: Record = { 'is platform': { name: 'is platform', description: 'Check sensor platform', - requiredFields: ['path', 'value'], + requiredFields: ['name'], optionalFields: [{ name: 'not', type: 'boolean', description: 'Invert the result' }], - examples: ['op: is platform\npath: routing/platform\nvalue: windows'], + examples: ['op: is platform\nname: windows'], validTargets: ['edr'], category: 'system', }, @@ -214,9 +217,9 @@ export const OPERATOR_SCHEMAS: Record = { 'is 32 bit': { name: 'is 32 bit', description: 'Check if architecture is 32-bit', - requiredFields: ['path'], + requiredFields: [], optionalFields: [{ name: 'not', type: 'boolean', description: 'Invert the result' }], - examples: ['op: is 32 bit\npath: routing/arch'], + examples: ['op: is 32 bit'], validTargets: ['edr'], category: 'system', }, @@ -224,9 +227,9 @@ export const OPERATOR_SCHEMAS: Record = { 'is 64 bit': { name: 'is 64 bit', description: 'Check if architecture is 64-bit', - requiredFields: ['path'], + requiredFields: [], optionalFields: [{ name: 'not', type: 'boolean', description: 'Invert the result' }], - examples: ['op: is 64 bit\npath: routing/arch'], + examples: ['op: is 64 bit'], validTargets: ['edr'], category: 'system', }, @@ -234,9 +237,9 @@ export const OPERATOR_SCHEMAS: Record = { 'is arm': { name: 'is arm', description: 'Check if architecture is ARM', - requiredFields: ['path'], + requiredFields: [], optionalFields: [{ name: 'not', type: 'boolean', description: 'Invert the result' }], - examples: ['op: is arm\npath: routing/arch'], + examples: ['op: is arm'], validTargets: ['edr'], category: 'system', }, @@ -302,8 +305,15 @@ export const OPERATOR_SCHEMAS: Record = { lookup: { name: 'lookup', description: 'Threat intelligence lookup', - requiredFields: ['path', 'lookup'], + requiredFields: ['path'], optionalFields: [ + { name: 'resource', type: 'string', description: 'Lookup resource URI' }, + { name: 'lookup', type: 'string', description: 'Legacy lookup table name' }, + { + name: 'metadata_rules', + type: 'object', + description: 'Additional rule applied to lookup metadata', + }, { name: 'min_confidence', type: 'number', description: 'Minimum confidence threshold' }, { name: 'not', type: 'boolean', description: 'Invert the result' }, ], diff --git a/src/utils/drValidation.ts b/src/utils/drValidation.ts index efffb1a..6a6ec53 100644 --- a/src/utils/drValidation.ts +++ b/src/utils/drValidation.ts @@ -229,15 +229,33 @@ export function validateDetectLogic( } } + if (normalizedOp === 'lookup') { + const hasResource = 'resource' in rule + const hasLookup = 'lookup' in rule + if (!hasResource && !hasLookup) { + return "Operator 'lookup' requires either a 'resource' or 'lookup' field." + } + } + // Validate unknown properties using shared constants - catch typos and invalid fields const validProperties = new Set(VALID_DETECT_PROPERTIES) + // Allow operator-specific fields defined in the schema without duplicating them in globals + if (operatorSchema) { + for (const requiredField of operatorSchema.requiredFields) { + validProperties.add(requiredField) + } + for (const optionalField of operatorSchema.optionalFields) { + validProperties.add(optionalField.name) + } + } + // Check all properties in the rule const ruleKeys = Object.keys(rule) for (const key of ruleKeys) { if (!validProperties.has(key)) { // Try to find similar property names to suggest - const suggestions = VALID_DETECT_PROPERTIES.filter( + const suggestions = Array.from(validProperties).filter( (prop) => // Levenshtein-like fuzzy matching for common typos prop.toLowerCase().includes(key.toLowerCase()) || @@ -250,12 +268,45 @@ export function validateDetectLogic( if (suggestions.length > 0) { errorMsg += ` Did you mean: ${suggestions.map((s) => `'${s}'`).join(', ')}?` } else { - errorMsg += ` Valid properties include: ${VALID_DETECT_PROPERTIES.slice(0, 10).join(', ')}, etc.` + errorMsg += ` Valid properties include: ${Array.from(validProperties) + .slice(0, 10) + .join(', ')}, etc.` } return errorMsg } } + if ('metadata_rules' in rule) { + const metadataRulesRaw = rule.metadata_rules + + const metadataRuleList = Array.isArray(metadataRulesRaw) + ? metadataRulesRaw + : [metadataRulesRaw] + + if (metadataRuleList.length === 0) { + return "Property 'metadata_rules' must contain at least one rule." + } + + for (const [index, metadataRule] of metadataRuleList.entries()) { + if (!metadataRule || typeof metadataRule !== 'object' || Array.isArray(metadataRule)) { + return `metadata_rules[${index}] must be an object describing a rule.` + } + + const nestedError = validateDetectLogic( + yaml.dump(metadataRule), + false, + depth + 1, + ) + if (nestedError) { + return `metadata_rules[${index}]: ${nestedError}` + } + } + } + + if ('truthy' in rule && typeof rule.truthy !== 'boolean') { + return "Property 'truthy' must be a boolean (true or false)." + } + // Validate 'not' property value - must be true if present if ('not' in rule) { if (rule.not !== true) { From b7ae925f9811b871fa51fe954d29bc27a9a89aef Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Tue, 4 Nov 2025 16:46:15 -0600 Subject: [PATCH 2/9] Add suppression analytics and UI enhancements Introduces suppression analytics to backtest results, including actual alerts, suppressed counts, and breakdowns per organization. Adds UI components for suppression summaries, alert distribution sparkline, and improved statistics display. Updates export functions to include suppression metadata and refines sorting and display logic for organization results. --- src/__tests__/suppression.spec.ts | 133 +++++ src/assets/styles.css | 246 +++++++- src/components/Rules.vue | 915 ++++++++++++++++++++++++++++-- src/utils/suppression.ts | 896 +++++++++++++++++++++++++++++ 4 files changed, 2134 insertions(+), 56 deletions(-) create mode 100644 src/__tests__/suppression.spec.ts create mode 100644 src/utils/suppression.ts diff --git a/src/__tests__/suppression.spec.ts b/src/__tests__/suppression.spec.ts new file mode 100644 index 0000000..1b506b2 --- /dev/null +++ b/src/__tests__/suppression.spec.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest' + +import { + applySuppressionToMatches, + parseSuppressionFromRespondLogic, + type SuppressionConfig, + type SuppressionMatch, +} from '../utils/suppression' + +const buildMatch = (eventTimeMs: number, extra?: Partial): SuppressionMatch => ({ + action: 'report', + data: { + detect: { + event: {}, + routing: { + event_time: eventTimeMs, + }, + ts: new Date(eventTimeMs).toISOString(), + }, + routing: {}, + gen_time: eventTimeMs, + ...extra?.data, + }, + ...extra, +}) + +describe('suppression parsing', () => { + it('parses suppression config from respond logic', () => { + const respondLogic = [ + { + action: 'report', + name: 'test report', + suppression: { + max_count: 3, + min_count: 2, + period: '1h', + is_global: false, + keys: ['constant-key'], + }, + }, + ] + + const parsed = parseSuppressionFromRespondLogic(respondLogic) + + expect(parsed).not.toBeNull() + expect(parsed?.config.periodMs).toBe(60 * 60 * 1000) + expect(parsed?.config.minCount).toBe(2) + expect(parsed?.config.maxCount).toBe(3) + expect(parsed?.config.keys).toEqual(['constant-key']) + }) +}) + +describe('suppression application', () => { + const baseConfig: SuppressionConfig = { + periodMs: 10 * 60 * 1000, + minCount: 2, + maxCount: 2, + isGlobal: false, + keys: ['constant'], + } + + it('respects threshold and max alert limits', () => { + const matches = [0, 1, 2, 3].map((offsetMinutes) => + buildMatch(Date.UTC(2024, 0, 1, 0, offsetMinutes)), + ) + + const result = applySuppressionToMatches(baseConfig, matches, { + organizationId: 'test-oid', + organizationName: 'Test Org', + }) + + expect(result.summary.actualAlerts).toBe(2) + expect(result.summary.suppressedPreThreshold).toBe(1) + expect(result.summary.suppressedPostThreshold).toBe(1) + + expect(result.matches[0].detectionforge_suppression?.status).toBe('suppressed-pre-threshold') + expect(result.matches[1].detectionforge_suppression?.status).toBe('actual-alert') + expect(result.matches[2].detectionforge_suppression?.status).toBe('actual-alert') + expect(result.matches[3].detectionforge_suppression?.status).toBe('suppressed-post-threshold') + }) + + it('treats all matches as actual when templates fail', () => { + const config: SuppressionConfig = { + ...baseConfig, + keys: ['{{ bogus .event }}'], + } + + const matches = [ + buildMatch(Date.UTC(2024, 0, 1, 0, 0)), + buildMatch(Date.UTC(2024, 0, 1, 0, 1)), + ] + + const result = applySuppressionToMatches(config, matches, { + organizationId: 'oid', + }) + + expect(result.summary.actualAlerts).toBe(2) + expect(result.summary.issues.length).toBeGreaterThan(0) + expect( + result.matches.every( + (match) => match.detectionforge_suppression?.status === 'evaluation-error', + ), + ).toBe( + true, + ) + }) + + it('handles consecutive windows when period expires', () => { + const config: SuppressionConfig = { + periodMs: 60 * 1000, + minCount: 1, + maxCount: 1, + isGlobal: true, + keys: ['constant'], + } + + const matches = [ + buildMatch(Date.UTC(2024, 0, 1, 0, 0, 0)), + buildMatch(Date.UTC(2024, 0, 1, 0, 0, 30)), + buildMatch(Date.UTC(2024, 0, 1, 0, 2, 0)), + ] + + const result = applySuppressionToMatches(config, matches, { + organizationId: 'oid', + }) + + expect(result.matches.map((match) => match.detectionforge_suppression?.status)).toEqual([ + 'actual-alert', + 'suppressed-post-threshold', + 'actual-alert', + ]) + }) +}) diff --git a/src/assets/styles.css b/src/assets/styles.css index 4b43946..a5e58c8 100644 --- a/src/assets/styles.css +++ b/src/assets/styles.css @@ -3358,6 +3358,13 @@ body.config { box-sizing: border-box; } +.match-count-badge .actual-alert-count, +.match-count-badge .suppressed-count { + font-size: var(--font-xs); + margin-left: 6px; + color: var(--text-muted); +} + .match-count-green { background: rgba(40, 167, 69, 0.2); color: #28a745; @@ -3659,6 +3666,37 @@ body.config { transition: all var(--transition-speed) ease; } +.suppression-summary-banner { + margin-bottom: var(--space-lg); + padding: var(--space-md) 16px; + background: var(--info-bg); + border: 1px solid var(--info-border); + border-left: 4px solid var(--info); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: var(--font-sm); + display: flex; + flex-direction: column; + gap: 0.5rem; + transition: all var(--transition-speed) ease; +} + +.suppression-summary-primary strong { + color: var(--info); +} + +.suppression-summary-primary span { + margin-left: 0.35rem; +} + +.suppression-summary-issues { + font-size: var(--font-xs); + color: var(--warning-text); + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + .performance-tip { margin-top: 12px; padding: var(--space-md) 16px; @@ -3844,6 +3882,20 @@ body.config { box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } +.stat-card--primary { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.12), rgba(14, 116, 144, 0.12)); + border-color: rgba(59, 130, 246, 0.4); + box-shadow: 0 6px 18px rgba(59, 130, 246, 0.18); +} + +.stat-card--primary .stat-number { + color: var(--info); +} + +.stat-card--primary .stat-label { + color: var(--text-primary); +} + .stat-number { font-size: 28px; font-weight: 700; @@ -3859,6 +3911,117 @@ body.config { transition: color var(--transition-speed) ease; } +.suppressed-breakdown { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + gap: 8px; + justify-content: center; +} + +.suppressed-pill { + font-size: var(--font-xs); + color: var(--text-tertiary); + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-xl); + padding: 4px 10px; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.suppressed-pill strong { + color: var(--text-primary); + font-weight: 600; +} + +.alerts-sparkline-card { + margin-bottom: 8px; + padding: 6px 10px; + background: var(--bg-tertiary); + border: 1px solid var(--border-secondary); + border-radius: var(--radius-lg); + transition: background-color var(--transition-speed) ease, border-color var(--transition-speed) ease; +} + +.alerts-sparkline-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 8px; +} + +.alerts-sparkline-header h5 { + margin: 0; + color: var(--text-primary); +} + +.alerts-sparkline-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + font-size: var(--font-xs); + color: var(--text-tertiary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.alerts-sparkline-axes { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 12px; +} + +.alerts-sparkline-wrapper { + position: relative; +} + +.alerts-sparkline-yaxis { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 36px; + font-size: var(--font-xs); + color: var(--text-tertiary); +} + +.alerts-sparkline-yaxis span { + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + opacity: 0.85; +} + +.alerts-sparkline-chart { + width: 100%; + aspect-ratio: 5 / 1.1; + height: auto; +} + +.alerts-sparkline-xaxis { + display: flex; + justify-content: space-between; + margin-top: 8px; + font-size: var(--font-xs); + color: var(--text-tertiary); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; +} + +.alerts-sparkline-tooltip { + position: absolute; + padding: 6px 10px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(96, 165, 250, 0.25); + color: #e2e8f0; + font-size: var(--font-xs); + border-radius: var(--radius-sm); + pointer-events: none; + transform: translate(-50%, -140%); + white-space: nowrap; + box-shadow: 0 4px 12px rgba(15, 23, 42, 0.45); + z-index: 2; +} + .timeframe-info { background: var(--bg-tertiary); border: 1px solid var(--border-secondary); @@ -3943,9 +4106,16 @@ body.config { .match-info { display: flex; - gap: 20px; + gap: 12px; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; + min-width: 0; +} + +@media (max-width: 1100px) { + .match-info { + flex-wrap: wrap; + } } .match-timestamp { @@ -4005,6 +4175,7 @@ body.config { .match-hostname { font-weight: 500; color: var(--text-primary); transition: color var(--transition-speed) ease; + flex: 0 0 auto; } .match-action { @@ -4014,6 +4185,16 @@ body.config { border-radius: var(--radius-xl); font-size: 13px; font-weight: 500; + flex: 1 1 auto; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.match-info .severity-badge, +.match-info .suppression-badge { + flex: 0 0 auto; } /* Severity badges for detection matches */ @@ -4058,6 +4239,67 @@ body.config { border: 1px solid #c8e6c9; } +.suppression-badge { + padding: 3px 10px; + border-radius: var(--radius-xl); + font-size: 11px; + font-weight: 600; + margin-left: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all var(--transition-speed) ease; + border: 1px solid transparent; +} + +.suppression-badge.actual-alert { + color: #2f855a; + background: rgba(76, 175, 80, 0.12); + border-color: rgba(76, 175, 80, 0.24); +} + +.suppression-badge.suppressed-pre-threshold { + color: #b7791f; + background: rgba(255, 193, 7, 0.12); + border-color: rgba(255, 193, 7, 0.24); +} + +.suppression-badge.suppressed-post-threshold { + color: #c53030; + background: rgba(220, 53, 69, 0.12); + border-color: rgba(220, 53, 69, 0.24); +} + +.suppression-badge.evaluation-error { + color: #2b6cb0; + background: rgba(96, 165, 250, 0.12); + border-color: rgba(96, 165, 250, 0.24); +} + +[data-theme="dark"] .suppression-badge.actual-alert { + color: #9ae6b4; + background: rgba(74, 222, 128, 0.08); + border-color: rgba(74, 222, 128, 0.18); +} + +[data-theme="dark"] .suppression-badge.suppressed-pre-threshold { + color: #fcd34d; + background: rgba(251, 191, 36, 0.08); + border-color: rgba(251, 191, 36, 0.18); +} + +[data-theme="dark"] .suppression-badge.suppressed-post-threshold { + color: #feb2b2; + background: rgba(248, 113, 113, 0.08); + border-color: rgba(248, 113, 113, 0.18); +} + +[data-theme="dark"] .suppression-badge.evaluation-error { + color: #bfdbfe; + background: rgba(96, 165, 250, 0.08); + border-color: rgba(96, 165, 250, 0.18); +} + /* Dark mode severity badges */ [data-theme="dark"] .severity-critical { background: rgba(220, 38, 38, 0.2); diff --git a/src/components/Rules.vue b/src/components/Rules.vue index acf7301..54672fb 100644 --- a/src/components/Rules.vue +++ b/src/components/Rules.vue @@ -946,6 +946,36 @@
+
+
+ Suppression Applied: + + {{ suppressionOverviewForDisplay.config?.actionName || 'Report action' }} · Period: + {{ formatSuppressionPeriod(suppressionOverviewForDisplay.config?.periodMs) }} · + Threshold: {{ suppressionOverviewForDisplay.config?.minCount ?? 1 }} · + Max Alerts: + {{ + suppressionOverviewForDisplay.config?.maxCount !== undefined + ? suppressionOverviewForDisplay.config?.maxCount + : '∞' + }} · Keys: + {{ suppressionOverviewForDisplay.config?.keys.length || 0 }} + +
+
+ ⚠️ {{ suppressionOverviewForDisplay.summary?.issues.length }} evaluation warning{{ + suppressionOverviewForDisplay.summary?.issues.length === 1 ? '' : 's' + }} +
+
+
+
+
+ {{ backtestResults.totalStats.suppressedTotal.toLocaleString() }} +
+
+ Suppressed Matches +
+
+ + Threshold + {{ + backtestResults.totalStats.suppressedPreThreshold.toLocaleString() + }} + + + Post-limit + {{ + backtestResults.totalStats.suppressedPostThreshold.toLocaleString() + }} + +
+
+
+
+ {{ backtestResults.totalStats.actualAlerts.toLocaleString() }} +
+
+ Actual Alerts +
+
+ + Reduction + + {{ + ((1 - + backtestResults.totalStats.actualAlerts / + backtestResults.totalStats.totalMatches) * + 100 + ).toFixed(1) + }}% + + +
+
+
+
+ {{ backtestResults.completionStats.avgAlertsPerDay.toFixed(1) }} +
+
+ Avg Alerts per Day +
+
{{ backtestResults.totalStats.wall_time.toFixed(2) }}s @@ -1067,6 +1155,14 @@ Avg Matches per Org
+
+
+ {{ backtestResults.completionStats.avgActualAlertsPerOrg.toFixed(1) }} +
+
+ Avg Actual Alerts per Org +
+
{{ backtestResults.totalStats.n_billed.toLocaleString() }} @@ -1137,11 +1233,126 @@ {{ formatTimestamp(backtestResults.timeframe.endTime) }}
+
+
+ +
+
+
Alert Distribution
+
+ + Peak {{ alertsSparkline.maxCount.toLocaleString() }}/{{ + alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' + }} + + + Avg {{ (backtestResults?.completionStats.avgAlertsPerDay ?? 0).toFixed(1) }}/day + + + {{ alertsSparkline.points.length }} + {{ alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' }}{{ + alertsSparkline.points.length === 1 ? '' : 's' + }} range +
+
+
+
+ {{ alertsSparkline.maxCount.toLocaleString() }} + + {{ alertsSparkline.midTick.toLocaleString() }} + + 0 +
+ + + + + + + + + + + + + + + + + + +
+ {{ sparklineTooltip.label }} +
+
+
+
+ {{ alertsSparklineXAxis.start }} + {{ alertsSparklineXAxis.mid }} + {{ alertsSparklineXAxis.end }} +
+
- -
+ +
Results by Organization ({{ backtestResults @@ -1200,6 +1411,24 @@ > {{ orgResult.results.length }} {{ orgResult.results.length === 1 ? 'match' : 'matches' }} + + (Actual: + {{ + orgResult.suppressionSummary + ? orgResult.suppressionSummary.actualAlerts + : orgResult.results.length + }}) + + + • Suppressed: + {{ orgResult.suppressionSummary.suppressedTotal }} + @@ -1236,6 +1465,38 @@ }} Matches
+
+ {{ + ( + orgResult.suppressionSummary + ? orgResult.suppressionSummary.actualAlerts + : orgResult.results?.length || 0 + ).toLocaleString() + }} + Actual Alerts +
+
+ + {{ orgResult.suppressionSummary.suppressedTotal.toLocaleString() }} + + (Threshold: + {{ + orgResult.suppressionSummary.suppressedPreThreshold.toLocaleString() + }} + · Post-limit: + {{ + orgResult.suppressionSummary.suppressedPostThreshold.toLocaleString() + }}) + + + Suppressed +
{{ orgResult.stats.wall_time.toFixed(2) }}s {{ result.data.detect_mtd.level }} + + {{ getSuppressionStatusLabel(result) }} +
{{ @@ -2769,6 +3036,14 @@ import { bracketMatching } from '@codemirror/language' import { linter, lintGutter } from '@codemirror/lint' import * as yaml from 'js-yaml' import { DRCompletionEngine } from '../utils/drCompletionEngine' +import { + applySuppressionToMatches, + parseSuppressionFromRespondLogic, + type MatchSuppressionMetadata, + type ParsedSuppressionConfig, + type SuppressionSummaryPerKey, + type SuppressionComputationSummary, +} from '../utils/suppression' const appStore = useAppStore() const api = useApi() @@ -3130,6 +3405,8 @@ const expandedOrgResults = ref(new Set()) // Track which org results are const orgDisplayedResults = ref>({}) // Track displayed results per org by OID const timestampTooltips = ref>({}) // Store tooltip content for each timestamp +const sparklineTooltip = ref<{ left: string; top: string; label: string } | null>(null) + // Cursor-based pagination state const orgCursors = ref>({}) // Track cursors per org by OID const orgHasMore = ref>({}) // Track if more results available per org by OID @@ -3219,6 +3496,7 @@ watch(isEstimateValid, (valid) => { } }) + // Check if start date is beyond 30-day free period const isBeyond30DayFreePeriod = computed(() => { if (!backtestConfig.startDateTime) return false @@ -3281,9 +3559,15 @@ const sortedOrgResults = computed(() => { // Create a copy to avoid mutating the original array return [...results].sort((a, b) => { - // First priority: Sort by match count (descending) - highest matches first - const aMatches = a.status === 'success' && a.results ? a.results.length : 0 - const bMatches = b.status === 'success' && b.results ? b.results.length : 0 + // First priority: Sort by actual alerts (descending) + const aMatches = + a.status === 'success' && a.results + ? a.suppressionSummary?.actualAlerts ?? a.results.length + : 0 + const bMatches = + b.status === 'success' && b.results + ? b.suppressionSummary?.actualAlerts ?? b.results.length + : 0 if (aMatches !== bMatches) { return bMatches - aMatches } @@ -3301,6 +3585,220 @@ const sortedOrgResults = computed(() => { }) }) +const suppressionOverviewForDisplay = computed(() => backtestResults.value?.suppressionOverview) + +const alertsSparklineGranularity = computed<'daily' | 'hourly'>(() => { + if (!backtestResults.value) return 'daily' + const startMs = new Date(backtestResults.value.timeframe.startTime).getTime() + const endMs = new Date(backtestResults.value.timeframe.endTime).getTime() + const diff = Math.max(endMs - startMs, 0) + const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000 + return diff < THREE_DAYS_MS ? 'hourly' : 'daily' +}) + +const alertsSparklineData = computed(() => { + if (!backtestResults.value) return [] as Array<{ timestamp: number; count: number }> + + const granularity = alertsSparklineGranularity.value + const bucketCounts = new Map() + const hourMs = 60 * 60 * 1000 + const dayMs = 24 * 60 * 60 * 1000 + + backtestResults.value.orgResults.forEach((orgResult) => { + if (orgResult.status !== 'success' || !orgResult.results) return + + orgResult.results.forEach((match) => { + if (!isActualAlert(match)) return + + const timestamp = getMatchTimestampMs(match) + const date = new Date(timestamp) + const bucketTimestamp = + granularity === 'hourly' + ? Date.UTC( + date.getUTCFullYear(), + date.getUTCMonth(), + date.getUTCDate(), + date.getUTCHours(), + ) + : Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + bucketCounts.set(bucketTimestamp, (bucketCounts.get(bucketTimestamp) || 0) + 1) + }) + }) + + const timeframe = backtestResults.value.timeframe + const startDate = new Date(timeframe.startTime) + const endDate = new Date(timeframe.endTime) + const step = granularity === 'hourly' ? hourMs : dayMs + + const startBucket = + granularity === 'hourly' + ? Date.UTC( + startDate.getUTCFullYear(), + startDate.getUTCMonth(), + startDate.getUTCDate(), + startDate.getUTCHours(), + ) + : Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate()) + + const endBucket = + granularity === 'hourly' + ? Date.UTC( + endDate.getUTCFullYear(), + endDate.getUTCMonth(), + endDate.getUTCDate(), + endDate.getUTCHours(), + ) + : Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate()) + + const data: Array<{ timestamp: number; count: number }> = [] + + if (endBucket < startBucket) { + data.push({ timestamp: startBucket, count: bucketCounts.get(startBucket) || 0 }) + return data + } + + for (let ts = startBucket; ts <= endBucket; ts += step) { + data.push({ timestamp: ts, count: bucketCounts.get(ts) || 0 }) + } + + return data +}) + +const alertsSparkline = computed(() => { + const data = alertsSparklineData.value + if (data.length === 0) { + return { + points: [] as Array<{ x: number; y: number; timestamp: number; count: number }>, + path: '', + fillPath: '', + baselinePath: '', + maxCount: 0, + totalAlerts: 0, + width: 220, + height: 36, + hasPositive: false, + midTick: 0, + } + } + + const width = 220 + const height = 20 + const paddingX = 8 + const paddingTop = 4 + const paddingBottom = 4 + + const maxCount = data.reduce((max, item) => Math.max(max, item.count), 0) + const totalAlerts = data.reduce((sum, item) => sum + item.count, 0) + + const timestamps = data.map((item) => item.timestamp) + let minTimestamp = Math.min(...timestamps) + let maxTimestamp = Math.max(...timestamps) + if (maxTimestamp === minTimestamp) { + maxTimestamp = minTimestamp + 24 * 60 * 60 * 1000 + } + + const range = maxTimestamp - minTimestamp || 1 + + const coordinates = data.map((item) => { + const position = (item.timestamp - minTimestamp) / range + const x = paddingX + position * (width - paddingX * 2) + const normalized = maxCount > 0 ? item.count / maxCount : 0 + const y = height - paddingBottom - normalized * (height - paddingTop - paddingBottom) + return { ...item, x, y } + }) + + const linePath = coordinates + .map((point, idx) => `${idx === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`) + .join(' ') + + const lastPoint = coordinates[coordinates.length - 1] + const firstPoint = coordinates[0] + const fillPath = `${linePath} L ${lastPoint.x.toFixed(2)} ${height - paddingBottom} L ${firstPoint.x.toFixed(2)} ${height - paddingBottom} Z` + const baselinePath = `M ${paddingX} ${height - paddingBottom} L ${width - paddingX} ${height - paddingBottom}` + const hasPositive = coordinates.some((point) => point.count > 0) + const midTick = maxCount > 1 ? Math.ceil(maxCount / 2) : 0 + + return { + points: coordinates, + path: linePath, + fillPath, + baselinePath, + maxCount, + totalAlerts, + width, + height, + hasPositive, + midTick, + } +}) + +watch(alertsSparklineData, () => { + sparklineTooltip.value = null +}) + +const alertsSparklineXAxis = computed(() => { + const data = alertsSparklineData.value + if (data.length === 0) { + return { start: '', mid: '', end: '' } + } + + const granularity = alertsSparklineGranularity.value + const formatter = + granularity === 'hourly' + ? new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: false, + }) + : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }) + + const startDate = new Date(data[0].timestamp) + const endDate = new Date(data[data.length - 1].timestamp) + let midLabel = '' + if (data.length > 2) { + const midIndex = Math.floor(data.length / 2) + const midDate = new Date(data[midIndex].timestamp) + midLabel = formatter.format(midDate) + } + + return { + start: formatter.format(startDate), + mid: midLabel, + end: formatter.format(endDate), + } +}) + +function showSparklineTooltip(point: { x: number; y: number; timestamp: number; count: number }) { + const sparkline = alertsSparkline.value + if (!sparkline) return + + const left = `${((point.x / sparkline.width) * 100).toFixed(2)}%` + const top = `${((point.y / sparkline.height) * 100).toFixed(2)}%` + + const formatter = + alertsSparklineGranularity.value === 'hourly' + ? new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + hour12: false, + }) + : new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric' }) + + const label = `${formatter.format(new Date(point.timestamp))} · ${point.count.toLocaleString()} alert${ + point.count === 1 ? '' : 's' + }` + + sparklineTooltip.value = { + left, + top, + label, + } +} + // Computed property to check if credentials are configured const hasCredentials = computed(() => { return !!(storage.credentials.value && storage.organizations.value.length > 0) @@ -3427,6 +3925,70 @@ function getSeverityCounts(results: BacktestMatch[] | undefined): Record 0 + ) { + return match.detectionforge_suppression.reasons.join('\n') + } + return undefined +} + +function isActualAlert(match: BacktestMatch): boolean { + const status = match.detectionforge_suppression?.status + + if (!status) { + return true + } + + return status === 'actual-alert' || status === 'evaluation-error' +} + +function getMatchTimestampMs(match: BacktestMatch): number { + const routingEventTime = match.data?.detect?.routing?.event_time + if (typeof routingEventTime === 'number') { + return routingEventTime > 10 ** 12 ? routingEventTime : routingEventTime * 1000 + } + + const detectTs = match.data?.detect?.ts + if (typeof detectTs === 'string' && detectTs.trim().length > 0) { + const sanitized = detectTs.includes('T') ? detectTs : detectTs.replace(' ', 'T') + const timestamp = Date.parse(sanitized.endsWith('Z') ? sanitized : `${sanitized}Z`) + if (!Number.isNaN(timestamp)) { + return timestamp + } + } + + const genTime = match.data?.gen_time + if (typeof genTime === 'number') { + return genTime > 10 ** 12 ? genTime : genTime * 1000 + } + + return Date.now() +} + interface UnitTest { id: string name: string @@ -3462,6 +4024,20 @@ interface DetectionRule { unitTests?: UnitTest[] } +interface BacktestSuppressionOverview { + enabled: boolean + config?: { + actionName?: string + periodMs: number + minCount?: number + maxCount?: number + isGlobal: boolean + keys: string[] + } + configIssues: string[] + summary?: SuppressionComputationSummary +} + interface BacktestResults { completedAt: string orgResults: BacktestOrgResult[] @@ -3472,6 +4048,10 @@ interface BacktestResults { wall_time: number n_billed: number n_free: number + actualAlerts: number + suppressedPreThreshold: number + suppressedPostThreshold: number + suppressedTotal: number } timeframe: { startTime: string @@ -3492,7 +4072,10 @@ interface BacktestResults { wasCancelled: boolean orgsWithZeroHits: number avgMatchesPerOrg: number + avgActualAlertsPerOrg: number + avgAlertsPerDay: number } + suppressionOverview?: BacktestSuppressionOverview } interface BacktestResponse { @@ -3533,6 +4116,7 @@ interface BacktestOrgResult { results?: BacktestMatch[] did_match?: boolean is_dry_run?: boolean + suppressionSummary?: SuppressionComputationSummary } interface BacktestMatch { @@ -3556,6 +4140,7 @@ interface BacktestMatch { source: string source_rule: string } + detectionforge_suppression?: MatchSuppressionMetadata } // Initialize editors on mount @@ -5360,6 +5945,32 @@ async function executeBacktest() { const durationMs = endTime.getTime() - startTime.getTime() const durationDays = Math.round((durationMs / (1000 * 60 * 60 * 24)) * 100) / 100 // Round to 2 decimal places + let parsedSuppressionConfig: ParsedSuppressionConfig | null = null + let suppressionOverview: BacktestSuppressionOverview | undefined + + if (currentRule.respondLogic.trim()) { + try { + const parsedRespondLogic = yaml.load(currentRule.respondLogic) as unknown + parsedSuppressionConfig = parseSuppressionFromRespondLogic(parsedRespondLogic) + if (parsedSuppressionConfig) { + suppressionOverview = { + enabled: true, + config: { + actionName: parsedSuppressionConfig.config.sourceActionName, + periodMs: parsedSuppressionConfig.config.periodMs, + minCount: parsedSuppressionConfig.config.minCount, + maxCount: parsedSuppressionConfig.config.maxCount, + isGlobal: parsedSuppressionConfig.config.isGlobal, + keys: parsedSuppressionConfig.config.keys, + }, + configIssues: [...parsedSuppressionConfig.issues], + } + } + } catch (error) { + logger.warn('Failed to parse respond logic for suppression metadata', error) + } + } + appStore.addNotification( 'info', `Starting ${backtestConfig.runInParallel ? 'parallel' : 'sequential'} backtest for ${backtestSelectedOids.value.length} organization(s)... This may take several minutes for large time ranges.`, @@ -5372,8 +5983,15 @@ async function executeBacktest() { wall_time: 0, n_billed: 0, n_free: 0, + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + suppressedTotal: 0, } + let aggregateSuppressionSummary: SuppressionComputationSummary | null = null + const aggregateSuppressionPerKey = new Map() + // Initialize progress tracking backtestProgress.value = { current: 0, @@ -5691,13 +6309,16 @@ async function executeBacktest() { orgHasMore.value[oid] = response.has_more || false } + const orgName = auth.getOrgName(oid) + const matches = (response.results || []) as BacktestMatch[] + // Success! Add successful result const result = { oid, - orgName: auth.getOrgName(oid), + orgName, status: 'success' as const, stats: response.stats, - results: response.results || [], + results: matches, did_match: response.did_match, is_dry_run: response.is_dry_run, retryCount: retryAttempt > 0 ? retryAttempt : undefined, @@ -5803,6 +6424,80 @@ async function executeBacktest() { } } + if (parsedSuppressionConfig) { + const updatedOrgResults: BacktestOrgResult[] = [] + + orgResults.forEach((orgResult) => { + if ( + orgResult.status !== 'success' || + !orgResult.results || + orgResult.results.length === 0 + ) { + updatedOrgResults.push(orgResult) + return + } + + let matches = orgResult.results + let summary = orgResult.suppressionSummary + + const alreadyAnnotated = matches.every((match) => !!match.detectionforge_suppression) + if (!alreadyAnnotated || !summary) { + const suppressionResult = applySuppressionToMatches( + parsedSuppressionConfig.config, + matches, + { + organizationId: orgResult.oid, + organizationName: orgResult.orgName, + }, + ) + matches = suppressionResult.matches + summary = suppressionResult.summary + } + + if (suppressionOverview) { + suppressionOverview.configIssues.push(...summary.issues) + } + + if (!aggregateSuppressionSummary) { + aggregateSuppressionSummary = { + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + suppressedTotal: 0, + issues: [], + perKey: [], + } + } + + aggregateSuppressionSummary.actualAlerts += summary.actualAlerts + aggregateSuppressionSummary.suppressedPreThreshold += summary.suppressedPreThreshold + aggregateSuppressionSummary.suppressedPostThreshold += summary.suppressedPostThreshold + aggregateSuppressionSummary.suppressedTotal += summary.suppressedTotal + aggregateSuppressionSummary.issues.push(...summary.issues) + + summary.perKey.forEach((perKey) => { + const existing = aggregateSuppressionPerKey.get(perKey.key) || { + key: perKey.key, + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + } + existing.actualAlerts += perKey.actualAlerts + existing.suppressedPreThreshold += perKey.suppressedPreThreshold + existing.suppressedPostThreshold += perKey.suppressedPostThreshold + aggregateSuppressionPerKey.set(perKey.key, existing) + }) + + updatedOrgResults.push({ + ...orgResult, + results: matches, + suppressionSummary: summary, + }) + }) + + orgResults = updatedOrgResults + } + // Calculate total stats from all successful results orgResults.forEach((result) => { if (result.status === 'success' && result.stats) { @@ -5815,6 +6510,38 @@ async function executeBacktest() { } }) + if (aggregateSuppressionSummary) { + const summary: SuppressionComputationSummary = aggregateSuppressionSummary + summary.perKey = Array.from(aggregateSuppressionPerKey.values()) + totalStats.actualAlerts = summary.actualAlerts + totalStats.suppressedPreThreshold = summary.suppressedPreThreshold + totalStats.suppressedPostThreshold = summary.suppressedPostThreshold + totalStats.suppressedTotal = summary.suppressedTotal + if (suppressionOverview) { + suppressionOverview.summary = { + ...summary, + perKey: summary.perKey, + issues: [...new Set(summary.issues)], + } + suppressionOverview.configIssues = [...new Set(suppressionOverview.configIssues)] + } + } else { + totalStats.actualAlerts = totalStats.totalMatches + totalStats.suppressedPreThreshold = 0 + totalStats.suppressedPostThreshold = 0 + totalStats.suppressedTotal = 0 + if (suppressionOverview) { + suppressionOverview.summary = { + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + suppressedTotal: 0, + issues: [...suppressionOverview.configIssues], + perKey: [], + } + } + } + // Calculate execution timing const backtestEndTime = Date.now() const backtestCompletedTimestamp = new Date().toISOString() @@ -5831,6 +6558,16 @@ async function executeBacktest() { ) const avgMatchesPerOrg = successfulOrgs.length > 0 ? totalMatchesAcrossOrgs / successfulOrgs.length : 0 + const totalActualAlertsAcrossOrgs = successfulOrgs.reduce((sum, org) => { + const actual = org.suppressionSummary + ? org.suppressionSummary.actualAlerts + : org.results?.length || 0 + return sum + actual + }, 0) + const avgActualAlertsPerOrg = + successfulOrgs.length > 0 ? totalActualAlertsAcrossOrgs / successfulOrgs.length : 0 + const durationDaysForAverage = durationDays > 0 ? durationDays : 1 + const avgAlertsPerDay = totalActualAlertsAcrossOrgs / durationDaysForAverage // Calculate completion stats const completionStats = { @@ -5842,6 +6579,8 @@ async function executeBacktest() { wasCancelled: isCancellingBacktest.value, orgsWithZeroHits, avgMatchesPerOrg, + avgActualAlertsPerOrg, + avgAlertsPerDay, } // Store results with completion timestamp @@ -5860,6 +6599,7 @@ async function executeBacktest() { totalExecutionTime: totalExecutionTime, }, completionStats, + suppressionOverview, } // Reset display state @@ -5882,10 +6622,14 @@ async function executeBacktest() { if (issues.length > 0) { message += ` ${issues.join(', ')}.` } - message += ` Found ${totalStats.totalMatches} total matches.` + message += ` Found ${totalStats.totalMatches} total matches (${totalStats.actualAlerts} actual alert${ + totalStats.actualAlerts === 1 ? '' : 's' + }).` appStore.addNotification('warning', message) } else { - message = `Backtest completed! Found ${totalStats.totalMatches} total matches out of ${totalStats.n_proc.toLocaleString()} events processed across ${completedOrgs.length} organization(s).` + message = `Backtest completed! Found ${totalStats.totalMatches} total matches (${totalStats.actualAlerts} actual alert${ + totalStats.actualAlerts === 1 ? '' : 's' + }) out of ${totalStats.n_proc.toLocaleString()} events processed across ${completedOrgs.length} organization(s).` const issues = [] if (errorOrgs.length > 0) issues.push(`${errorOrgs.length} failed`) if (timedOutOrgs.length > 0) issues.push(`${timedOutOrgs.length} timed out`) @@ -6220,28 +6964,39 @@ function exportOrgBacktestResults(orgResult: BacktestOrgResult) { if (!orgResult.results || orgResult.status !== 'success') return const exportData = { - backtest_metadata: { - rule_name: currentRule.name, - organization: orgResult.orgName, - oid: orgResult.oid, - completed_at: backtestResults.value?.completedAt, - timeframe: backtestResults.value?.timeframe, - stats: orgResult.stats, - billing: { - n_billed: orgResult.stats?.n_billed || 0, - n_free: orgResult.stats?.n_free || 0, - actual_cost: orgResult.stats?.n_billed ? calculateCost(orgResult.stats.n_billed) : 0, - saved_cost: orgResult.stats?.n_free ? calculateCost(orgResult.stats.n_free) : 0, - cost_formatted: orgResult.stats?.n_billed - ? formatCost(calculateCost(orgResult.stats.n_billed)) - : '$0.00', - saved_formatted: orgResult.stats?.n_free - ? formatCost(calculateCost(orgResult.stats.n_free)) - : '$0.00', + backtest_metadata: { + rule_name: currentRule.name, + organization: orgResult.orgName, + oid: orgResult.oid, + completed_at: backtestResults.value?.completedAt, + timeframe: backtestResults.value?.timeframe, + stats: orgResult.stats, + billing: { + n_billed: orgResult.stats?.n_billed || 0, + n_free: orgResult.stats?.n_free || 0, + actual_cost: orgResult.stats?.n_billed ? calculateCost(orgResult.stats.n_billed) : 0, + saved_cost: orgResult.stats?.n_free ? calculateCost(orgResult.stats.n_free) : 0, + cost_formatted: orgResult.stats?.n_billed + ? formatCost(calculateCost(orgResult.stats.n_billed)) + : '$0.00', + saved_formatted: orgResult.stats?.n_free + ? formatCost(calculateCost(orgResult.stats.n_free)) + : '$0.00', + }, + detectionforge_suppression: orgResult.suppressionSummary + ? { + actual_alerts: orgResult.suppressionSummary.actualAlerts, + suppressed_pre_threshold: orgResult.suppressionSummary.suppressedPreThreshold, + suppressed_post_threshold: orgResult.suppressionSummary.suppressedPostThreshold, + suppressed_total: orgResult.suppressionSummary.suppressedTotal, + issues: orgResult.suppressionSummary.issues, + per_key: orgResult.suppressionSummary.perKey, + config: backtestResults.value?.suppressionOverview?.config, + } + : undefined, }, - }, - matches: orgResult.results, - } + matches: orgResult.results, + } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -6258,27 +7013,28 @@ function _exportBacktestResults() { if (!backtestResults.value) return const exportData = { - backtest_metadata: { - rule_name: currentRule.name, - completed_at: backtestResults.value.completedAt, - timeframe: backtestResults.value.timeframe, - total_stats: backtestResults.value.totalStats, - execution_stats: backtestResults.value.executionStats, - completion_stats: backtestResults.value.completionStats, - organizations: backtestResults.value.orgResults.length, - billing_summary: { - total_billed: backtestResults.value.totalStats.n_billed, - total_free: backtestResults.value.totalStats.n_free, - actual_cost: calculateCost(backtestResults.value.totalStats.n_billed), - saved_cost: calculateCost(backtestResults.value.totalStats.n_free), - cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), - saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), - cost_per_block: 0.01, - events_per_block: 200000, + backtest_metadata: { + rule_name: currentRule.name, + completed_at: backtestResults.value.completedAt, + timeframe: backtestResults.value.timeframe, + total_stats: backtestResults.value.totalStats, + execution_stats: backtestResults.value.executionStats, + completion_stats: backtestResults.value.completionStats, + organizations: backtestResults.value.orgResults.length, + billing_summary: { + total_billed: backtestResults.value.totalStats.n_billed, + total_free: backtestResults.value.totalStats.n_free, + actual_cost: calculateCost(backtestResults.value.totalStats.n_billed), + saved_cost: calculateCost(backtestResults.value.totalStats.n_free), + cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), + saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), + cost_per_block: 0.01, + events_per_block: 200000, + }, + detectionforge_suppression_overview: backtestResults.value.suppressionOverview, }, - }, - org_results: backtestResults.value.orgResults, - } + org_results: backtestResults.value.orgResults, + } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -6347,6 +7103,10 @@ function exportBacktestSummaryAsMarkdown() { | Total Events Processed | ${results.totalStats.n_proc.toLocaleString()} | | Total Rule Evaluations | ${results.totalStats.n_eval.toLocaleString()} | | Total Matches Found | ${results.totalStats.totalMatches.toLocaleString()} | +| Actual Alerts | ${results.totalStats.actualAlerts.toLocaleString()} | +| Suppressed Matches | ${results.totalStats.suppressedTotal.toLocaleString()} | +| Suppressed (Threshold) | ${results.totalStats.suppressedPreThreshold.toLocaleString()} | +| Suppressed (Post-limit) | ${results.totalStats.suppressedPostThreshold.toLocaleString()} | | Billed Events | ${results.totalStats.n_billed.toLocaleString()} | | Free Events | ${results.totalStats.n_free.toLocaleString()} | | Actual Cost | ${formatCost(calculateCost(results.totalStats.n_billed))} | @@ -6358,14 +7118,22 @@ function exportBacktestSummaryAsMarkdown() { | Cancelled / Timeout Orgs | ${cancelledTimeoutCount} | | Organizations with 0 Hits | ${results.completionStats.orgsWithZeroHits} | | Average Matches per Org | ${results.completionStats.avgMatchesPerOrg.toFixed(1)} | +| Average Actual Alerts per Org | ${results.completionStats.avgActualAlertsPerOrg.toFixed(1)} | +| Average Alerts per Day | ${results.completionStats.avgAlertsPerDay.toFixed(1)} | ## Organization Breakdown -| Organization | Status | Matches | Billed | Free | Cost | Duration | Retries | -|-------------|--------|---------|--------|------|------|----------|---------| +| Organization | Status | Matches | Actual Alerts | Suppressed | Billed | Free | Cost | Duration | Retries | +|-------------|--------|---------|---------------|-----------|--------|------|------|----------|---------| ${results.orgResults .map((org) => { const matchCount = org.results?.length || 0 + const actualAlerts = org.suppressionSummary + ? org.suppressionSummary.actualAlerts + : matchCount + const suppressedSummary = org.suppressionSummary + ? `${org.suppressionSummary.suppressedTotal.toLocaleString()} (T=${org.suppressionSummary.suppressedPreThreshold.toLocaleString()}, M=${org.suppressionSummary.suppressedPostThreshold.toLocaleString()})` + : '0' const billed = org.stats?.n_billed !== undefined ? org.stats.n_billed.toLocaleString() : 'N/A' const free = org.stats?.n_free !== undefined ? org.stats.n_free.toLocaleString() : 'N/A' const cost = @@ -6380,7 +7148,7 @@ ${results.orgResults : org.status === 'cancelled' ? '⏹️' : '❌' - return `| ${org.orgName} | ${statusIcon} ${org.status} | ${matchCount} | ${billed} | ${free} | ${cost} | ${duration} | ${retries} |` + return `| ${org.orgName} | ${statusIcon} ${org.status} | ${matchCount} | ${actualAlerts} | ${suppressedSummary} | ${billed} | ${free} | ${cost} | ${duration} | ${retries} |` }) .join('\n')} ${ @@ -6446,6 +7214,8 @@ function exportAllMatches() { // Consolidate all matches from all organizations const allMatches: Array = [] let totalMatches = 0 + let totalActualAlerts = 0 + let totalSuppressed = 0 orgsWithMatches.forEach((org) => { if (org.results) { @@ -6459,6 +7229,11 @@ function exportAllMatches() { }) totalMatches++ }) + const actualAlerts = org.suppressionSummary + ? org.suppressionSummary.actualAlerts + : org.results.length + totalActualAlerts += actualAlerts + totalSuppressed += org.suppressionSummary?.suppressedTotal ?? 0 } }) @@ -6471,6 +7246,8 @@ function exportAllMatches() { total_organizations_with_matches: orgsWithMatches.length, total_organizations_tested: backtestResults.value.orgResults.length, total_matches: totalMatches, + total_actual_alerts: totalActualAlerts, + total_suppressed: totalSuppressed, execution_stats: backtestResults.value.executionStats, billing_summary: { total_billed: backtestResults.value.totalStats.n_billed, @@ -6480,10 +7257,17 @@ function exportAllMatches() { cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), }, + detectionforge_suppression_overview: backtestResults.value.suppressionOverview, organizations: orgsWithMatches.map((org) => ({ oid: org.oid, name: org.orgName, match_count: org.results?.length || 0, + actual_alerts: org.suppressionSummary + ? org.suppressionSummary.actualAlerts + : org.results?.length || 0, + detectionforge_suppressed_total: org.suppressionSummary?.suppressedTotal || 0, + detectionforge_suppressed_pre_threshold: org.suppressionSummary?.suppressedPreThreshold || 0, + detectionforge_suppressed_post_threshold: org.suppressionSummary?.suppressedPostThreshold || 0, stats: org.stats, })), }, @@ -6502,7 +7286,9 @@ function exportAllMatches() { appStore.addNotification( 'success', - `Exported ${totalMatches.toLocaleString()} matches from ${orgsWithMatches.length} organization${orgsWithMatches.length !== 1 ? 's' : ''}`, + `Exported ${totalMatches.toLocaleString()} matches (${totalActualAlerts.toLocaleString()} actual alert${ + totalActualAlerts === 1 ? '' : 's' + }) from ${orgsWithMatches.length} organization${orgsWithMatches.length !== 1 ? 's' : ''}`, ) } @@ -6838,6 +7624,27 @@ function formatDuration(durationMs: number): string { return `${remainingSeconds}s` } +function formatSuppressionPeriod(periodMs?: number): string { + if (!periodMs || periodMs <= 0) return 'n/a' + + const seconds = Math.round(periodMs / 1000) + const units = [ + { label: 'day', seconds: 86400 }, + { label: 'hour', seconds: 3600 }, + { label: 'minute', seconds: 60 }, + { label: 'second', seconds: 1 }, + ] + + for (const unit of units) { + if (seconds % unit.seconds === 0) { + const value = seconds / unit.seconds + return `${value} ${unit.label}${value === 1 ? '' : 's'}` + } + } + + return `${seconds} seconds` +} + function formatSearchDuration(startTime: string | number, endTime: string | number): string { const start = typeof startTime === 'string' ? new Date(startTime) : new Date(startTime * 1000) const end = typeof endTime === 'string' ? new Date(endTime) : new Date(endTime * 1000) diff --git a/src/utils/suppression.ts b/src/utils/suppression.ts new file mode 100644 index 0000000..68b6eb7 --- /dev/null +++ b/src/utils/suppression.ts @@ -0,0 +1,896 @@ +import { logger } from './logger' + +interface TemplateContext { + [key: string]: unknown +} + +export type SuppressionStatus = + | 'actual-alert' + | 'suppressed-pre-threshold' + | 'suppressed-post-threshold' + | 'evaluation-error' + +export interface MatchSuppressionMetadata { + status: SuppressionStatus + keySignature: string + reasons?: string[] +} + +export interface SuppressionSummaryPerKey { + key: string + actualAlerts: number + suppressedPreThreshold: number + suppressedPostThreshold: number +} + +export interface SuppressionComputationSummary { + actualAlerts: number + suppressedPreThreshold: number + suppressedPostThreshold: number + suppressedTotal: number + issues: string[] + perKey: SuppressionSummaryPerKey[] +} + +export interface SuppressionConfig { + periodMs: number + maxCount?: number + minCount?: number + isGlobal: boolean + keys: string[] + sourceActionName?: string + sourceActionIndex?: number +} + +export interface ParsedSuppressionConfig { + config: SuppressionConfig + issues: string[] +} + +export interface SuppressionMatch { + action: string + data: { + detect?: { + event?: Record + routing?: Record + ts?: string + [key: string]: unknown + } + routing?: Record + gen_time?: number + [key: string]: unknown + } +} + +export interface ApplySuppressionResult { + matches: Array + summary: SuppressionComputationSummary +} + +const PERIOD_MULTIPLIERS: Record = { + s: 1000, + m: 60 * 1000, + h: 60 * 60 * 1000, + d: 24 * 60 * 60 * 1000, + w: 7 * 24 * 60 * 60 * 1000, +} + +const isWhitespace = (character: string) => character === ' ' || character === '\t' || character === '\n' || character === '\r' + +const TEMPLATE_PATTERN = /{{\s*([^{}]+?)\s*}}/g + +const isEmptyValue = (value: unknown) => { + if (value === null || value === undefined) return true + if (typeof value === 'string') return value.trim().length === 0 + if (Array.isArray(value)) return value.length === 0 + if (typeof value === 'object') return Object.keys(value as Record).length === 0 + return false +} + +const toNumber = (value: unknown): number | null => { + if (typeof value === 'number' && !Number.isNaN(value)) return value + if (typeof value === 'string') { + const trimmed = value.trim() + if (trimmed.length === 0) return null + const parsed = Number(trimmed) + return Number.isNaN(parsed) ? null : parsed + } + return null +} + +const ensureString = (value: unknown): string => { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + if (typeof value === 'object') { + try { + return JSON.stringify(value) + } catch (error) { + logger.warn('Failed to stringify object while rendering template', error) + return String(value) + } + } + return String(value) +} + +const encodeWithBtoa = (value: string): string | null => { + if (typeof globalThis.btoa !== 'function') return null + + try { + const latin1 = encodeURIComponent(value).replace(/%([0-9A-F]{2})/g, (_, hex) => + String.fromCharCode(Number.parseInt(hex, 16)), + ) + return globalThis.btoa(latin1) + } catch (error) { + logger.warn('Failed to base64 encode via btoa', error) + return null + } +} + +const decodeWithAtob = (value: string): string | null => { + if (typeof globalThis.atob !== 'function') return null + + try { + const binary = globalThis.atob(value) + const percentEncoded = Array.from(binary) + .map((char) => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`) + .join('') + return decodeURIComponent(percentEncoded) + } catch (error) { + logger.warn('Failed to base64 decode via atob', error) + return null + } +} + +const getNodeBuffer = () => + (globalThis as unknown as { + Buffer?: { + from: (input: string, encoding: string) => { toString: (encoding: string) => string } + } + }).Buffer + +const cloneMatchWithSuppression = ( + match: TMatch, + suppression: MatchSuppressionMetadata, +): TMatch & { detectionforge_suppression?: MatchSuppressionMetadata } => { + return { + ...match, + detectionforge_suppression: suppression, + } +} + +const parseTemplateTokens = (segment: string): string[] => { + const tokens: string[] = [] + let current = '' + let inQuotes = false + let quoteChar = '' + for (let i = 0; i < segment.length; i++) { + const char = segment[i] + if ((char === '"' || char === "'") && segment[i - 1] !== '\\') { + if (!inQuotes) { + inQuotes = true + quoteChar = char + current += char + continue + } + if (quoteChar === char) { + inQuotes = false + quoteChar = '' + current += char + continue + } + } + + if (!inQuotes && isWhitespace(char)) { + if (current) { + tokens.push(current) + current = '' + } + continue + } + + current += char + } + + if (current) { + tokens.push(current) + } + + return tokens +} + +const splitPipelineSegments = (expression: string): string[] => { + const segments: string[] = [] + let current = '' + let inQuotes = false + let quoteChar = '' + + for (let i = 0; i < expression.length; i++) { + const char = expression[i] + if ((char === '"' || char === "'") && expression[i - 1] !== '\\') { + if (!inQuotes) { + inQuotes = true + quoteChar = char + current += char + continue + } + if (quoteChar === char) { + inQuotes = false + quoteChar = '' + current += char + continue + } + } + + if (char === '|' && !inQuotes) { + if (current.trim().length > 0) { + segments.push(current.trim()) + } + current = '' + continue + } + + current += char + } + + if (current.trim().length > 0) { + segments.push(current.trim()) + } + + return segments +} + +const unquote = (value: string): string => { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + try { + return JSON.parse(value) + } catch (error) { + logger.warn('Failed to parse quoted string in template expression', { + value, + error, + }) + return value.slice(1, -1) + } + } + return value +} + +const resolvePath = (identifier: string, context: TemplateContext): unknown => { + const trimmed = identifier.trim() + if (trimmed === '.' || trimmed === '') return context + + const parts = trimmed.replace(/^\./, '').split('.') + let current: unknown = context + + for (const part of parts) { + if (current === null || current === undefined) { + return undefined + } + + if (Array.isArray(current)) { + const index = Number(part) + if (Number.isNaN(index) || index < 0 || index >= current.length) { + return undefined + } + current = current[index] + continue + } + + if (typeof current === 'object') { + const record = current as Record + current = record[part] + continue + } + + return undefined + } + + return current +} + +const parseLiteral = (token: string): unknown => { + if (token === 'true') return true + if (token === 'false') return false + if (token === 'nil' || token === 'null') return null + if (token === 'undefined') return undefined + + const numeric = toNumber(token) + if (numeric !== null) { + return numeric + } + + return unquote(token) +} + +type TransformFunction = (input: unknown, ...args: unknown[]) => unknown + +const transformLibrary: Record = { + lower: (input: unknown) => ensureString(input).toLowerCase(), + upper: (input: unknown) => ensureString(input).toUpperCase(), + title: (input: unknown) => ensureString(input).replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()), + trim: (input: unknown, cutset?: unknown) => { + const value = ensureString(input) + if (cutset === undefined) return value.trim() + const cutString = ensureString(cutset) + const regex = new RegExp(`^[${cutString}]+|[${cutString}]+$`, 'g') + return value.replace(regex, '') + }, + trimPrefix: (input: unknown, prefix: unknown) => { + const value = ensureString(input) + const prefixStr = ensureString(prefix) + return value.startsWith(prefixStr) ? value.slice(prefixStr.length) : value + }, + trimSuffix: (input: unknown, suffix: unknown) => { + const value = ensureString(input) + const suffixStr = ensureString(suffix) + return value.endsWith(suffixStr) ? value.slice(0, -suffixStr.length) : value + }, + split: (input: unknown, separator?: unknown) => { + const value = ensureString(input) + const sep = separator === undefined ? '' : ensureString(separator) + return value.split(sep) + }, + join: (input: unknown, separator?: unknown) => { + const array = Array.isArray(input) ? input : [input] + const sep = separator === undefined ? '' : ensureString(separator) + return array.map((item) => ensureString(item)).join(sep) + }, + index: (input: unknown, ...args: unknown[]) => { + if (args.length === 0) return undefined + let current = input + const indices = args + for (const rawIndex of indices) { + if (Array.isArray(current)) { + const idx = toNumber(rawIndex) + if (idx === null || idx < 0 || idx >= current.length) { + return undefined + } + current = current[idx] + } else if (typeof current === 'object' && current !== null) { + const key = ensureString(rawIndex) + current = (current as Record)[key] + } else { + return undefined + } + } + return current + }, + replace: (input: unknown, oldValue: unknown, newValue: unknown, count?: unknown) => { + const value = ensureString(input) + const oldStr = ensureString(oldValue) + const newStr = ensureString(newValue) + if (count === undefined) { + return value.split(oldStr).join(newStr) + } + const limit = toNumber(count) ?? -1 + if (limit < 0) { + return value.split(oldStr).join(newStr) + } + let replaced = 0 + let result = value + while (replaced < limit && result.includes(oldStr)) { + result = result.replace(oldStr, newStr) + replaced += 1 + } + return result + }, + substr: (input: unknown, start: unknown, length?: unknown) => { + const value = ensureString(input) + const startIndex = toNumber(start) ?? 0 + if (startIndex >= value.length) return '' + if (length === undefined) { + return value.slice(startIndex) + } + const lengthNumber = toNumber(length) ?? value.length + return value.slice(startIndex, startIndex + lengthNumber) + }, + atoi: (input: unknown) => { + const value = ensureString(input) + const parsed = parseInt(value, 10) + return Number.isNaN(parsed) ? 0 : parsed + }, + default: (input: unknown, fallback?: unknown, candidateOverride?: unknown) => { + const candidate = candidateOverride !== undefined ? candidateOverride : input + if (isEmptyValue(candidate)) { + return fallback + } + return candidate + }, + json: (input: unknown) => { + try { + return JSON.stringify(input) + } catch (error) { + logger.warn('Failed to stringify value for json transform', error) + return '' + } + }, + prettyjson: (input: unknown) => { + try { + return JSON.stringify(input, null, 2) + } catch (error) { + logger.warn('Failed to stringify value for prettyjson transform', error) + return '' + } + }, + base64enc: (input: unknown) => { + const value = ensureString(input) + const encoded = encodeWithBtoa(value) + if (encoded !== null) return encoded + + const buffer = getNodeBuffer() + if (buffer) { + try { + return buffer.from(value, 'utf-8').toString('base64') + } catch (error) { + logger.warn('Failed to base64 encode value via Buffer', error) + } + } + + logger.warn('Base64 encoding not supported in this environment') + return '' + }, + base64dec: (input: unknown) => { + const value = ensureString(input) + const decoded = decodeWithAtob(value) + if (decoded !== null) return decoded + + const buffer = getNodeBuffer() + if (buffer) { + try { + return buffer.from(value, 'base64').toString('utf-8') + } catch (error) { + logger.warn('Failed to base64 decode value via Buffer', error) + } + } + + logger.warn('Base64 decoding not supported in this environment') + return '' + }, + urlenc: (input: unknown) => encodeURIComponent(ensureString(input)), + urldec: (input: unknown) => { + try { + return decodeURIComponent(ensureString(input)) + } catch (error) { + logger.warn('Failed to decode URI component', error) + return '' + } + }, + escape: (input: unknown) => ensureString(input).replace(/(["'\\])/g, '\\$1'), + unescape: (input: unknown) => ensureString(input).replace(/\\(["'\\])/g, '$1'), + len: (input: unknown) => { + if (typeof input === 'string' || Array.isArray(input)) return input.length + if (input && typeof input === 'object') return Object.keys(input as Record).length + return 0 + }, + hasPrefix: (input: unknown, prefix: unknown) => ensureString(input).startsWith(ensureString(prefix)), + hasSuffix: (input: unknown, suffix: unknown) => ensureString(input).endsWith(ensureString(suffix)), + contains: (input: unknown, substr: unknown) => ensureString(input).includes(ensureString(substr)), +} + +const applyTransform = (name: string, input: unknown, args: unknown[]): unknown => { + const transform = transformLibrary[name] + if (!transform) { + throw new Error(`Unsupported template transform: ${name}`) + } + return transform(input, ...args) +} + +const evaluateExpression = (expression: string, context: TemplateContext) => { + const segments = splitPipelineSegments(expression) + const errors: string[] = [] + if (segments.length === 0) { + return { value: '', errors } + } + + let currentValue: unknown + + segments.forEach((segment, index) => { + if (errors.length > 0) { + return + } + + const tokens = parseTemplateTokens(segment) + if (tokens.length === 0) { + errors.push('Empty template segment encountered') + return + } + + if (index === 0) { + // First segment can be literal, identifier, or function call + if (tokens[0].startsWith('.') || tokens[0].includes('.')) { + currentValue = resolvePath(tokens[0], context) + if (tokens.length > 1) { + // Remaining tokens treated as function invocation with resolved value as input + const fnName = tokens[1] + const args = tokens.slice(2).map((token) => + token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token), + ) + try { + currentValue = applyTransform(fnName, currentValue, args) + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)) + } + } + } else { + const fnName = tokens[0] + const args = tokens + .slice(1) + .map((token) => (token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token))) + try { + currentValue = applyTransform(fnName, undefined, args) + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)) + } + } + return + } + + const fnName = tokens[0] + const args = tokens + .slice(1) + .map((token) => (token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token))) + + try { + currentValue = applyTransform(fnName, currentValue, args) + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)) + } + }) + + return { + value: currentValue, + errors, + } +} + +const renderTemplate = (template: string, context: TemplateContext) => { + const errors: string[] = [] + let output = template + + output = output.replace(TEMPLATE_PATTERN, (_, expression) => { + const evaluation = evaluateExpression(expression, context) + if (evaluation.errors.length > 0) { + errors.push(...evaluation.errors) + return '' + } + return ensureString(evaluation.value) + }) + + return { + value: output, + errors, + } +} + +const parsePeriod = (rawPeriod: unknown): number | null => { + if (typeof rawPeriod === 'number' && Number.isFinite(rawPeriod)) { + return rawPeriod >= 0 ? rawPeriod * 1000 : null + } + + if (typeof rawPeriod !== 'string') { + return null + } + + const trimmed = rawPeriod.trim().toLowerCase() + if (trimmed.length === 0) return null + + const match = trimmed.match(/^(\d+)([a-z]+)$/) + if (!match) { + return null + } + + const amount = Number(match[1]) + const unit = match[2] + const multiplier = PERIOD_MULTIPLIERS[unit] + if (!multiplier) return null + + return amount * multiplier +} + +const findFirstReportAction = (respondLogic: unknown) => { + if (!respondLogic) return null + + if (Array.isArray(respondLogic)) { + for (let index = 0; index < respondLogic.length; index++) { + const entry = respondLogic[index] + if (entry && typeof entry === 'object' && (entry as Record).action === 'report') { + return { action: entry as Record, index } + } + } + return null + } + + if (typeof respondLogic === 'object') { + const record = respondLogic as Record + if (record.action === 'report') { + return { action: record, index: 0 } + } + if (record.respond && Array.isArray(record.respond)) { + return findFirstReportAction(record.respond) + } + } + + return null +} + +export const parseSuppressionFromRespondLogic = ( + respondLogic: unknown, +): ParsedSuppressionConfig | null => { + const reportActionEntry = findFirstReportAction(respondLogic) + if (!reportActionEntry) { + return null + } + + const suppression = reportActionEntry.action.suppression as Record | undefined + if (!suppression) { + return null + } + + const issues: string[] = [] + + const rawPeriod = suppression.period + const periodMs = parsePeriod(rawPeriod) + if (periodMs === null) { + issues.push('Suppression period is missing or invalid; suppression metrics will be skipped.') + return null + } + + const rawMaxCount = suppression.max_count + const rawMinCount = suppression.min_count + + const maxCount = rawMaxCount === undefined ? undefined : toNumber(rawMaxCount) ?? undefined + const minCount = rawMinCount === undefined ? undefined : toNumber(rawMinCount) ?? undefined + + const isGlobal = Boolean(suppression.is_global) + const rawKeys = Array.isArray(suppression.keys) + ? (suppression.keys as unknown[]).map((key) => ensureString(key)) + : [] + + if (maxCount !== undefined && maxCount <= 0) { + issues.push('Suppression max_count must be greater than 0; ignoring max_count.') + } + + if (minCount !== undefined && minCount <= 0) { + issues.push('Suppression min_count must be greater than 0; ignoring min_count.') + } + + const config: SuppressionConfig = { + periodMs, + maxCount: maxCount !== undefined && maxCount > 0 ? maxCount : undefined, + minCount: minCount !== undefined && minCount > 0 ? minCount : undefined, + isGlobal, + keys: rawKeys, + sourceActionName: ensureString(reportActionEntry.action.name ?? ''), + sourceActionIndex: reportActionEntry.index, + } + + if (config.minCount && config.maxCount && config.minCount > config.maxCount) { + issues.push('Suppression min_count is greater than max_count; min_count will be clamped to max_count.') + config.minCount = config.maxCount + } + + return { + config, + issues, + } +} + +interface SuppressionWindowEntry { + timestamp: number + isAlert: boolean +} + +interface SuppressionWindowState { + entries: SuppressionWindowEntry[] + alertCountInWindow: number +} + +const getMatchTimestamp = (match: SuppressionMatch): number => { + const routingEventTime = match.data?.detect?.routing?.event_time + if (typeof routingEventTime === 'number') { + return routingEventTime > 10 ** 12 ? routingEventTime : routingEventTime * 1000 + } + + const detectTs = match.data?.detect?.ts + if (typeof detectTs === 'string' && detectTs.trim().length > 0) { + const isoLike = detectTs.includes('T') ? detectTs : detectTs.replace(' ', 'T') + const timestamp = Date.parse(isoLike.endsWith('Z') ? isoLike : `${isoLike}Z`) + if (!Number.isNaN(timestamp)) { + return timestamp + } + } + + const genTime = match.data?.gen_time + if (typeof genTime === 'number') { + return genTime > 10 ** 12 ? genTime : genTime * 1000 + } + + return Date.now() +} + +const buildTemplateContext = ( + match: SuppressionMatch, + organizationId: string, + organizationName?: string, +): TemplateContext => { + return { + event: match.data?.detect?.event ?? {}, + routing: match.data?.detect?.routing ?? {}, + detect: match.data?.detect ?? {}, + report: match.data ?? {}, + org: { + oid: organizationId, + name: organizationName, + }, + match, + } +} + +const buildKeySignature = ( + keys: string[], + match: SuppressionMatch, + organizationId: string, + issues: string[], + organizationName?: string, +): { keySignature: string; evaluationIssues: string[] } => { + if (!keys || keys.length === 0) { + return { keySignature: '__suppression::default__', evaluationIssues: [] } + } + + const context = buildTemplateContext(match, organizationId, organizationName) + const values: string[] = [] + const evaluationIssues: string[] = [] + + keys.forEach((keyTemplate) => { + const result = renderTemplate(keyTemplate, context) + if (result.errors.length > 0) { + evaluationIssues.push( + `Failed to evaluate suppression key template "${keyTemplate}": ${result.errors.join('; ')}`, + ) + values.push('__suppression::error__') + } else { + values.push(result.value) + } + }) + + if (evaluationIssues.length > 0) { + issues.push(...evaluationIssues) + } + + return { + keySignature: values.join('::'), + evaluationIssues, + } +} + +export const applySuppressionToMatches = ( + config: SuppressionConfig, + matches: readonly TMatch[], + options: { organizationId: string; organizationName?: string }, +): ApplySuppressionResult => { + const issues: string[] = [] + const windowStates = new Map() + const annotatedMatches: Array< + TMatch & { detectionforge_suppression?: MatchSuppressionMetadata } + > = new Array(matches.length) + const perKeySummary = new Map() + + const indexedMatches = matches.map((match, index) => ({ match, index })) + const sortedMatches = [...indexedMatches].sort( + (a, b) => getMatchTimestamp(a.match) - getMatchTimestamp(b.match), + ) + + const minCount = config.minCount ?? 1 + const maxCount = config.maxCount ?? Number.POSITIVE_INFINITY + + sortedMatches.forEach(({ match, index }) => { + const timestamp = getMatchTimestamp(match) + const { keySignature, evaluationIssues } = buildKeySignature( + config.keys, + match, + options.organizationId, + issues, + options.organizationName, + ) + + const state = windowStates.get(keySignature) ?? { entries: [], alertCountInWindow: 0 } + + // Remove expired entries + while (state.entries.length > 0) { + const entry = state.entries[0] + if (timestamp - entry.timestamp > config.periodMs) { + state.entries.shift() + if (entry.isAlert) { + state.alertCountInWindow = Math.max(0, state.alertCountInWindow - 1) + } + } else { + break + } + } + + let status: SuppressionStatus = 'actual-alert' + let reasons: string[] | undefined + let isAlert = true + + if (evaluationIssues.length > 0) { + status = 'evaluation-error' + reasons = evaluationIssues + } else { + const windowCount = state.entries.length + 1 + if (windowCount < minCount) { + status = 'suppressed-pre-threshold' + isAlert = false + reasons = [`Threshold requires at least ${minCount} matches within ${config.periodMs / 1000}s`] + } else if (state.alertCountInWindow >= maxCount) { + status = 'suppressed-post-threshold' + isAlert = false + reasons = [`Maximum of ${maxCount} alerts reached within suppression period`] + } + } + + state.entries.push({ + timestamp, + isAlert, + }) + + if (isAlert) { + state.alertCountInWindow += 1 + } + + windowStates.set(keySignature, state) + + const summary = + perKeySummary.get(keySignature) ?? + ({ + key: keySignature, + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + } as SuppressionSummaryPerKey) + + if (status === 'actual-alert' || status === 'evaluation-error') { + summary.actualAlerts += 1 + } else if (status === 'suppressed-pre-threshold') { + summary.suppressedPreThreshold += 1 + } else if (status === 'suppressed-post-threshold') { + summary.suppressedPostThreshold += 1 + } + + perKeySummary.set(keySignature, summary) + + const annotated = cloneMatchWithSuppression(match, { + status, + keySignature, + reasons, + }) + annotatedMatches[index] = annotated + }) + + const summary: SuppressionComputationSummary = { + actualAlerts: 0, + suppressedPreThreshold: 0, + suppressedPostThreshold: 0, + suppressedTotal: 0, + issues, + perKey: Array.from(perKeySummary.values()), + } + + summary.perKey.forEach((perKey) => { + summary.actualAlerts += perKey.actualAlerts + summary.suppressedPreThreshold += perKey.suppressedPreThreshold + summary.suppressedPostThreshold += perKey.suppressedPostThreshold + }) + + summary.suppressedTotal = summary.suppressedPreThreshold + summary.suppressedPostThreshold + + const normalizedMatches = annotatedMatches.filter(Boolean) as Array< + TMatch & { detectionforge_suppression?: MatchSuppressionMetadata } + > + + return { + matches: normalizedMatches, + summary, + } +} From 0bc9bfe8ff920cd0bdbe5a2dd574984f37f6d1fe Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Tue, 4 Nov 2025 20:03:30 -0600 Subject: [PATCH 3/9] security: fix credential exposure in JWT authentication CRITICAL SECURITY FIX: Updated JWT authentication to send credentials in POST request body instead of URL query parameters. Previously, the application was sending the organization ID (oid), user ID (uid), and API key (secret) as URL query parameters in GET requests to jwt.limacharlie.io. This is insecure as credentials in URLs are: - Logged in browser history - Captured in server access logs - Visible in network monitoring tools - Exposed in developer tools - May be cached by proxies/CDNs Changes made: src/composables/useAuth.ts: - generateFreshJWT(): Changed from GET with URL params to POST with form-encoded body (line 97-104) - generateJWTForOrg(): Changed from GET with URL params to POST with form-encoded body (line 137-144) - testCredentials(): Changed from GET with URL params to POST with form-encoded body (line 175-182) src/components/Config.vue: - generateJWTForOrg(): Changed from GET with URL params to POST with form-encoded body (line 1190-1197) All four functions now: - Use POST method instead of GET - Send credentials in request body with Content-Type: application/x-www-form-urlencoded - Include all three required parameters: oid, uid, and secret - Properly URL-encode values with encodeURIComponent() This implementation follows the LimaCharlie API documentation at https://docs.limacharlie.io/docs/api-keys and industry security best practices for handling sensitive credentials. --- src/components/Config.vue | 8 ++++---- src/composables/useAuth.ts | 24 ++++++++++++------------ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/components/Config.vue b/src/components/Config.vue index 1db3ba5..4a799ae 100644 --- a/src/components/Config.vue +++ b/src/components/Config.vue @@ -1187,13 +1187,13 @@ const generateJWTForOrg = async (oid: string) => { throw new Error('Incomplete credentials - missing UID or API Key') } - const url = `https://jwt.limacharlie.io?oid=${oid}&uid=${credentials.uid}&secret=${credentials.apiKey}` - - const response = await fetch(url, { - method: 'GET', + const response = await fetch('https://jwt.limacharlie.io', { + method: 'POST', headers: { + 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, + body: `oid=${encodeURIComponent(oid)}&uid=${encodeURIComponent(credentials.uid)}&secret=${encodeURIComponent(credentials.apiKey)}`, }) if (!response.ok) { diff --git a/src/composables/useAuth.ts b/src/composables/useAuth.ts index 6c27ff9..4a721d2 100644 --- a/src/composables/useAuth.ts +++ b/src/composables/useAuth.ts @@ -94,13 +94,13 @@ export function useAuth() { const currentOid = primaryOid.value try { isAuthenticating.value = true - const url = `https://jwt.limacharlie.io?oid=${currentOid}&uid=${credentials.uid}&secret=${credentials.apiKey}` - - const response = await fetch(url, { - method: 'GET', + const response = await fetch('https://jwt.limacharlie.io', { + method: 'POST', headers: { + 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, + body: `oid=${encodeURIComponent(currentOid)}&uid=${encodeURIComponent(credentials.uid)}&secret=${encodeURIComponent(credentials.apiKey)}`, }) if (!response.ok) { @@ -134,13 +134,13 @@ export function useAuth() { } try { - const url = `https://jwt.limacharlie.io?oid=${oid}&uid=${credentials.uid}&secret=${credentials.apiKey}` - - const response = await fetch(url, { - method: 'GET', + const response = await fetch('https://jwt.limacharlie.io', { + method: 'POST', headers: { + 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, + body: `oid=${encodeURIComponent(oid)}&uid=${encodeURIComponent(credentials.uid)}&secret=${encodeURIComponent(credentials.apiKey)}`, }) if (!response.ok) { @@ -172,13 +172,13 @@ export function useAuth() { try { isAuthenticating.value = true - const url = `https://jwt.limacharlie.io?oid=${targetOid}&uid=${uid}&secret=${apiKey}` - - const response = await fetch(url, { - method: 'GET', + const response = await fetch('https://jwt.limacharlie.io', { + method: 'POST', headers: { + 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, + body: `oid=${encodeURIComponent(targetOid)}&uid=${encodeURIComponent(uid)}&secret=${encodeURIComponent(apiKey)}`, }) if (!response.ok) { From dad97c02e8950d1cbd4088ecb5096e8f529b49e8 Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Tue, 4 Nov 2025 20:09:36 -0600 Subject: [PATCH 4/9] Refactor code formatting and improve readability This commit applies consistent formatting and indentation across multiple files, including test specs, utility functions, and the Rules.vue component. It improves code readability by reducing unnecessary line breaks, simplifying expressions, and clarifying nested function calls. No functional changes are introduced. --- src/__tests__/suppression.spec.ts | 9 +- src/components/Rules.vue | 401 +++++++++--------- src/utils/__tests__/drValidation.spec.ts | 28 +- .../__tests__/fixtures/validDetectRules.ts | 4 +- src/utils/drValidation.ts | 6 +- src/utils/suppression.ts | 58 ++- 6 files changed, 265 insertions(+), 241 deletions(-) diff --git a/src/__tests__/suppression.spec.ts b/src/__tests__/suppression.spec.ts index 1b506b2..a68b203 100644 --- a/src/__tests__/suppression.spec.ts +++ b/src/__tests__/suppression.spec.ts @@ -85,10 +85,7 @@ describe('suppression application', () => { keys: ['{{ bogus .event }}'], } - const matches = [ - buildMatch(Date.UTC(2024, 0, 1, 0, 0)), - buildMatch(Date.UTC(2024, 0, 1, 0, 1)), - ] + const matches = [buildMatch(Date.UTC(2024, 0, 1, 0, 0)), buildMatch(Date.UTC(2024, 0, 1, 0, 1))] const result = applySuppressionToMatches(config, matches, { organizationId: 'oid', @@ -100,9 +97,7 @@ describe('suppression application', () => { result.matches.every( (match) => match.detectionforge_suppression?.status === 'evaluation-error', ), - ).toBe( - true, - ) + ).toBe(true) }) it('handles consecutive windows when period expires', () => { diff --git a/src/components/Rules.vue b/src/components/Rules.vue index 54672fb..daaa720 100644 --- a/src/components/Rules.vue +++ b/src/components/Rules.vue @@ -946,22 +946,21 @@
-
+
Suppression Applied: - {{ suppressionOverviewForDisplay.config?.actionName || 'Report action' }} · Period: + {{ suppressionOverviewForDisplay.config?.actionName || 'Report action' }} · + Period: {{ formatSuppressionPeriod(suppressionOverviewForDisplay.config?.periodMs) }} · - Threshold: {{ suppressionOverviewForDisplay.config?.minCount ?? 1 }} · - Max Alerts: + Threshold: {{ suppressionOverviewForDisplay.config?.minCount ?? 1 }} · Max + Alerts: {{ suppressionOverviewForDisplay.config?.maxCount !== undefined ? suppressionOverviewForDisplay.config?.maxCount : '∞' - }} · Keys: + }} + · Keys: {{ suppressionOverviewForDisplay.config?.keys.length || 0 }}
@@ -1015,10 +1014,7 @@ Total Matches Found
-
+
{{ backtestResults.totalStats.suppressedTotal.toLocaleString() }}
@@ -1055,9 +1051,10 @@ Reduction {{ - ((1 - - backtestResults.totalStats.actualAlerts / - backtestResults.totalStats.totalMatches) * + ( + (1 - + backtestResults.totalStats.actualAlerts / + backtestResults.totalStats.totalMatches) * 100 ).toFixed(1) }}% @@ -1233,126 +1230,132 @@ {{ formatTimestamp(backtestResults.timeframe.endTime) }}
-
- - -
-
-
Alert Distribution
-
- - Peak {{ alertsSparkline.maxCount.toLocaleString() }}/{{ - alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' - }} - - - Avg {{ (backtestResults?.completionStats.avgAlertsPerDay ?? 0).toFixed(1) }}/day - - - {{ alertsSparkline.points.length }} - {{ alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' }}{{ - alertsSparkline.points.length === 1 ? '' : 's' - }} range -
-
-
-
- {{ alertsSparkline.maxCount.toLocaleString() }} - - {{ alertsSparkline.midTick.toLocaleString() }} - - 0 + +
+
+
Alert Distribution
+
+ + Peak {{ alertsSparkline.maxCount.toLocaleString() }}/{{ + alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' + }} + + + Avg + {{ (backtestResults?.completionStats.avgAlertsPerDay ?? 0).toFixed(1) }}/day + + + {{ alertsSparkline.points.length }} + {{ alertsSparklineGranularity === 'hourly' ? 'hour' : 'day' + }}{{ alertsSparkline.points.length === 1 ? '' : 's' }} range + +
- - - - - - - - - - - - - - - -
+
+ {{ alertsSparkline.maxCount.toLocaleString() }} + + {{ alertsSparkline.midTick.toLocaleString() }} + + 0 +
+ - - - + + + + + + + + + + + + + + + - - - -
- {{ sparklineTooltip.label }} + +
+ {{ sparklineTooltip.label }} +
+
+ {{ alertsSparklineXAxis.start }} + {{ alertsSparklineXAxis.mid }} + {{ alertsSparklineXAxis.end }} +
-
- {{ alertsSparklineXAxis.start }} - {{ alertsSparklineXAxis.mid }} - {{ alertsSparklineXAxis.end }} -
-
- -
+ +
Results by Organization ({{ backtestResults @@ -1467,10 +1470,9 @@
{{ - ( - orgResult.suppressionSummary - ? orgResult.suppressionSummary.actualAlerts - : orgResult.results?.length || 0 + (orgResult.suppressionSummary + ? orgResult.suppressionSummary.actualAlerts + : orgResult.results?.length || 0 ).toLocaleString() }} Actual Alerts @@ -1605,8 +1607,18 @@
{{ formatTimestamp(result.data.detect.ts) }} {{ @@ -3496,7 +3508,6 @@ watch(isEstimateValid, (valid) => { } }) - // Check if start date is beyond 30-day free period const isBeyond30DayFreePeriod = computed(() => { if (!backtestConfig.startDateTime) return false @@ -3562,11 +3573,11 @@ const sortedOrgResults = computed(() => { // First priority: Sort by actual alerts (descending) const aMatches = a.status === 'success' && a.results - ? a.suppressionSummary?.actualAlerts ?? a.results.length + ? (a.suppressionSummary?.actualAlerts ?? a.results.length) : 0 const bMatches = b.status === 'success' && b.results - ? b.suppressionSummary?.actualAlerts ?? b.results.length + ? (b.suppressionSummary?.actualAlerts ?? b.results.length) : 0 if (aMatches !== bMatches) { return bMatches - aMatches @@ -3691,7 +3702,7 @@ const alertsSparkline = computed(() => { const totalAlerts = data.reduce((sum, item) => sum + item.count, 0) const timestamps = data.map((item) => item.timestamp) - let minTimestamp = Math.min(...timestamps) + const minTimestamp = Math.min(...timestamps) let maxTimestamp = Math.max(...timestamps) if (maxTimestamp === minTimestamp) { maxTimestamp = minTimestamp + 24 * 60 * 60 * 1000 @@ -6964,39 +6975,39 @@ function exportOrgBacktestResults(orgResult: BacktestOrgResult) { if (!orgResult.results || orgResult.status !== 'success') return const exportData = { - backtest_metadata: { - rule_name: currentRule.name, - organization: orgResult.orgName, - oid: orgResult.oid, - completed_at: backtestResults.value?.completedAt, - timeframe: backtestResults.value?.timeframe, - stats: orgResult.stats, - billing: { - n_billed: orgResult.stats?.n_billed || 0, - n_free: orgResult.stats?.n_free || 0, - actual_cost: orgResult.stats?.n_billed ? calculateCost(orgResult.stats.n_billed) : 0, - saved_cost: orgResult.stats?.n_free ? calculateCost(orgResult.stats.n_free) : 0, - cost_formatted: orgResult.stats?.n_billed - ? formatCost(calculateCost(orgResult.stats.n_billed)) - : '$0.00', - saved_formatted: orgResult.stats?.n_free - ? formatCost(calculateCost(orgResult.stats.n_free)) - : '$0.00', - }, - detectionforge_suppression: orgResult.suppressionSummary - ? { - actual_alerts: orgResult.suppressionSummary.actualAlerts, - suppressed_pre_threshold: orgResult.suppressionSummary.suppressedPreThreshold, - suppressed_post_threshold: orgResult.suppressionSummary.suppressedPostThreshold, - suppressed_total: orgResult.suppressionSummary.suppressedTotal, - issues: orgResult.suppressionSummary.issues, - per_key: orgResult.suppressionSummary.perKey, - config: backtestResults.value?.suppressionOverview?.config, - } - : undefined, + backtest_metadata: { + rule_name: currentRule.name, + organization: orgResult.orgName, + oid: orgResult.oid, + completed_at: backtestResults.value?.completedAt, + timeframe: backtestResults.value?.timeframe, + stats: orgResult.stats, + billing: { + n_billed: orgResult.stats?.n_billed || 0, + n_free: orgResult.stats?.n_free || 0, + actual_cost: orgResult.stats?.n_billed ? calculateCost(orgResult.stats.n_billed) : 0, + saved_cost: orgResult.stats?.n_free ? calculateCost(orgResult.stats.n_free) : 0, + cost_formatted: orgResult.stats?.n_billed + ? formatCost(calculateCost(orgResult.stats.n_billed)) + : '$0.00', + saved_formatted: orgResult.stats?.n_free + ? formatCost(calculateCost(orgResult.stats.n_free)) + : '$0.00', }, - matches: orgResult.results, - } + detectionforge_suppression: orgResult.suppressionSummary + ? { + actual_alerts: orgResult.suppressionSummary.actualAlerts, + suppressed_pre_threshold: orgResult.suppressionSummary.suppressedPreThreshold, + suppressed_post_threshold: orgResult.suppressionSummary.suppressedPostThreshold, + suppressed_total: orgResult.suppressionSummary.suppressedTotal, + issues: orgResult.suppressionSummary.issues, + per_key: orgResult.suppressionSummary.perKey, + config: backtestResults.value?.suppressionOverview?.config, + } + : undefined, + }, + matches: orgResult.results, + } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -7013,28 +7024,28 @@ function _exportBacktestResults() { if (!backtestResults.value) return const exportData = { - backtest_metadata: { - rule_name: currentRule.name, - completed_at: backtestResults.value.completedAt, - timeframe: backtestResults.value.timeframe, - total_stats: backtestResults.value.totalStats, - execution_stats: backtestResults.value.executionStats, - completion_stats: backtestResults.value.completionStats, - organizations: backtestResults.value.orgResults.length, - billing_summary: { - total_billed: backtestResults.value.totalStats.n_billed, - total_free: backtestResults.value.totalStats.n_free, - actual_cost: calculateCost(backtestResults.value.totalStats.n_billed), - saved_cost: calculateCost(backtestResults.value.totalStats.n_free), - cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), - saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), - cost_per_block: 0.01, - events_per_block: 200000, - }, - detectionforge_suppression_overview: backtestResults.value.suppressionOverview, + backtest_metadata: { + rule_name: currentRule.name, + completed_at: backtestResults.value.completedAt, + timeframe: backtestResults.value.timeframe, + total_stats: backtestResults.value.totalStats, + execution_stats: backtestResults.value.executionStats, + completion_stats: backtestResults.value.completionStats, + organizations: backtestResults.value.orgResults.length, + billing_summary: { + total_billed: backtestResults.value.totalStats.n_billed, + total_free: backtestResults.value.totalStats.n_free, + actual_cost: calculateCost(backtestResults.value.totalStats.n_billed), + saved_cost: calculateCost(backtestResults.value.totalStats.n_free), + cost_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_billed)), + saved_formatted: formatCost(calculateCost(backtestResults.value.totalStats.n_free)), + cost_per_block: 0.01, + events_per_block: 200000, }, - org_results: backtestResults.value.orgResults, - } + detectionforge_suppression_overview: backtestResults.value.suppressionOverview, + }, + org_results: backtestResults.value.orgResults, + } const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' }) const url = URL.createObjectURL(blob) @@ -7128,9 +7139,7 @@ function exportBacktestSummaryAsMarkdown() { ${results.orgResults .map((org) => { const matchCount = org.results?.length || 0 - const actualAlerts = org.suppressionSummary - ? org.suppressionSummary.actualAlerts - : matchCount + const actualAlerts = org.suppressionSummary ? org.suppressionSummary.actualAlerts : matchCount const suppressedSummary = org.suppressionSummary ? `${org.suppressionSummary.suppressedTotal.toLocaleString()} (T=${org.suppressionSummary.suppressedPreThreshold.toLocaleString()}, M=${org.suppressionSummary.suppressedPostThreshold.toLocaleString()})` : '0' @@ -7266,8 +7275,10 @@ function exportAllMatches() { ? org.suppressionSummary.actualAlerts : org.results?.length || 0, detectionforge_suppressed_total: org.suppressionSummary?.suppressedTotal || 0, - detectionforge_suppressed_pre_threshold: org.suppressionSummary?.suppressedPreThreshold || 0, - detectionforge_suppressed_post_threshold: org.suppressionSummary?.suppressedPostThreshold || 0, + detectionforge_suppressed_pre_threshold: + org.suppressionSummary?.suppressedPreThreshold || 0, + detectionforge_suppressed_post_threshold: + org.suppressionSummary?.suppressedPostThreshold || 0, stats: org.stats, })), }, @@ -7544,7 +7555,7 @@ function formatTimestampToLocal(timestamp: string | number): string { hour: '2-digit', minute: '2-digit', second: '2-digit', - hour12: false + hour12: false, }) // Get timezone offset in hours and minutes diff --git a/src/utils/__tests__/drValidation.spec.ts b/src/utils/__tests__/drValidation.spec.ts index 8fba6f6..110a8cb 100644 --- a/src/utils/__tests__/drValidation.spec.ts +++ b/src/utils/__tests__/drValidation.spec.ts @@ -26,9 +26,7 @@ describe('validateDetectLogic operator-specific behaviour', () => { it('rejects non-boolean truthy property', () => { const rule = `event: NEW_PROCESS\nop: exists\npath: event/PARENT\ntruthy: yes\n` - expect(validateDetectLogic(rule)).toBe( - "Property 'truthy' must be a boolean (true or false).", - ) + expect(validateDetectLogic(rule)).toBe("Property 'truthy' must be a boolean (true or false).") }) it('allows architecture checks without a path', () => { @@ -46,9 +44,7 @@ describe('validateDetectLogic real-world fixtures', () => { } it('flags lookup rules missing resource or lookup', () => { - const fixture = validDetectRuleFixtures.find((item) => - item.name.includes('Poor Reputation IP'), - ) + const fixture = validDetectRuleFixtures.find((item) => item.name.includes('Poor Reputation IP')) expect(fixture).toBeDefined() @@ -75,9 +71,7 @@ describe('validateDetectLogic real-world fixtures', () => { }) it('validates metadata_rules payloads recursively', () => { - const fixture = validDetectRuleFixtures.find((item) => - item.name.includes('Poor Reputation IP'), - ) + const fixture = validDetectRuleFixtures.find((item) => item.name.includes('Poor Reputation IP')) expect(fixture).toBeDefined() @@ -142,7 +136,9 @@ describe('validateDetectLogic aligns with LimaCharlie docs', () => { expect(validateDetectLogic(valid)).toBeNull() const invalid = `${baseEvent}\nop: string distance\npath: event/DOMAIN_NAME\nmax: 2\n` - expect(validateDetectLogic(invalid)).toBe("Operator 'string distance' requires a 'value' field.") + expect(validateDetectLogic(invalid)).toBe( + "Operator 'string distance' requires a 'value' field.", + ) }) it('covers platform and architecture operators', () => { @@ -150,7 +146,9 @@ describe('validateDetectLogic aligns with LimaCharlie docs', () => { expect(validateDetectLogic(platformValid)).toBeNull() const platformInvalid = `${baseEvent}\nop: is platform\n` - expect(validateDetectLogic(platformInvalid)).toBe("Operator 'is platform' requires a 'name' field.") + expect(validateDetectLogic(platformInvalid)).toBe( + "Operator 'is platform' requires a 'name' field.", + ) const archValid = `${baseEvent}\nop: and\nrules:\n - op: is 32 bit\n - op: is 64 bit\n not: true\n` expect(validateDetectLogic(archValid)).toBeNull() @@ -167,7 +165,9 @@ describe('validateDetectLogic aligns with LimaCharlie docs', () => { expect(validateDetectLogic(publicValid)).toBeNull() const publicMissing = `${baseEvent}\nop: is private address\n` - expect(validateDetectLogic(publicMissing)).toBe("Operator 'is private address' requires a 'path' field.") + expect(validateDetectLogic(publicMissing)).toBe( + "Operator 'is private address' requires a 'path' field.", + ) }) it('enforces lookup resource and metadata rules shape', () => { @@ -201,7 +201,9 @@ describe('validateDetectLogic aligns with LimaCharlie docs', () => { expect(validateDetectLogic(valid)).toBeNull() const invalid = `${baseEvent}\nop: is older than\npath: routing/event_time\n` - expect(validateDetectLogic(invalid)).toBe("Operator 'is older than' requires a 'seconds' field.") + expect(validateDetectLogic(invalid)).toBe( + "Operator 'is older than' requires a 'seconds' field.", + ) }) it('permits documented transforms and times modifiers', () => { diff --git a/src/utils/__tests__/fixtures/validDetectRules.ts b/src/utils/__tests__/fixtures/validDetectRules.ts index cbbe4da..9066fe2 100644 --- a/src/utils/__tests__/fixtures/validDetectRules.ts +++ b/src/utils/__tests__/fixtures/validDetectRules.ts @@ -10,9 +10,7 @@ const trim = (input: string) => { .filter((line) => line.trim().length > 0) .map((line) => line.match(/^\s*/)?.[0].length ?? 0) const minIndent = indents.length > 0 ? Math.min(...indents) : 0 - return lines - .map((line) => line.slice(minIndent)) - .join('\n') + return lines.map((line) => line.slice(minIndent)).join('\n') } export const validDetectRuleFixtures: DetectRuleFixture[] = [ diff --git a/src/utils/drValidation.ts b/src/utils/drValidation.ts index 6a6ec53..7dafcb2 100644 --- a/src/utils/drValidation.ts +++ b/src/utils/drValidation.ts @@ -292,11 +292,7 @@ export function validateDetectLogic( return `metadata_rules[${index}] must be an object describing a rule.` } - const nestedError = validateDetectLogic( - yaml.dump(metadataRule), - false, - depth + 1, - ) + const nestedError = validateDetectLogic(yaml.dump(metadataRule), false, depth + 1) if (nestedError) { return `metadata_rules[${index}]: ${nestedError}` } diff --git a/src/utils/suppression.ts b/src/utils/suppression.ts index 68b6eb7..afc3fd4 100644 --- a/src/utils/suppression.ts +++ b/src/utils/suppression.ts @@ -75,7 +75,8 @@ const PERIOD_MULTIPLIERS: Record = { w: 7 * 24 * 60 * 60 * 1000, } -const isWhitespace = (character: string) => character === ' ' || character === '\t' || character === '\n' || character === '\r' +const isWhitespace = (character: string) => + character === ' ' || character === '\t' || character === '\n' || character === '\r' const TEMPLATE_PATTERN = /{{\s*([^{}]+?)\s*}}/g @@ -142,11 +143,13 @@ const decodeWithAtob = (value: string): string | null => { } const getNodeBuffer = () => - (globalThis as unknown as { - Buffer?: { - from: (input: string, encoding: string) => { toString: (encoding: string) => string } + ( + globalThis as unknown as { + Buffer?: { + from: (input: string, encoding: string) => { toString: (encoding: string) => string } + } } - }).Buffer + ).Buffer const cloneMatchWithSuppression = ( match: TMatch, @@ -309,7 +312,11 @@ type TransformFunction = (input: unknown, ...args: unknown[]) => unknown const transformLibrary: Record = { lower: (input: unknown) => ensureString(input).toLowerCase(), upper: (input: unknown) => ensureString(input).toUpperCase(), - title: (input: unknown) => ensureString(input).replace(/\w\S*/g, (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase()), + title: (input: unknown) => + ensureString(input).replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.slice(1).toLowerCase(), + ), trim: (input: unknown, cutset?: unknown) => { const value = ensureString(input) if (cutset === undefined) return value.trim() @@ -461,11 +468,14 @@ const transformLibrary: Record = { unescape: (input: unknown) => ensureString(input).replace(/\\(["'\\])/g, '$1'), len: (input: unknown) => { if (typeof input === 'string' || Array.isArray(input)) return input.length - if (input && typeof input === 'object') return Object.keys(input as Record).length + if (input && typeof input === 'object') + return Object.keys(input as Record).length return 0 }, - hasPrefix: (input: unknown, prefix: unknown) => ensureString(input).startsWith(ensureString(prefix)), - hasSuffix: (input: unknown, suffix: unknown) => ensureString(input).endsWith(ensureString(suffix)), + hasPrefix: (input: unknown, prefix: unknown) => + ensureString(input).startsWith(ensureString(prefix)), + hasSuffix: (input: unknown, suffix: unknown) => + ensureString(input).endsWith(ensureString(suffix)), contains: (input: unknown, substr: unknown) => ensureString(input).includes(ensureString(substr)), } @@ -504,9 +514,11 @@ const evaluateExpression = (expression: string, context: TemplateContext) => { if (tokens.length > 1) { // Remaining tokens treated as function invocation with resolved value as input const fnName = tokens[1] - const args = tokens.slice(2).map((token) => - token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token), - ) + const args = tokens + .slice(2) + .map((token) => + token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token), + ) try { currentValue = applyTransform(fnName, currentValue, args) } catch (error) { @@ -517,7 +529,9 @@ const evaluateExpression = (expression: string, context: TemplateContext) => { const fnName = tokens[0] const args = tokens .slice(1) - .map((token) => (token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token))) + .map((token) => + token.startsWith('.') ? resolvePath(token, context) : parseLiteral(token), + ) try { currentValue = applyTransform(fnName, undefined, args) } catch (error) { @@ -595,7 +609,11 @@ const findFirstReportAction = (respondLogic: unknown) => { if (Array.isArray(respondLogic)) { for (let index = 0; index < respondLogic.length; index++) { const entry = respondLogic[index] - if (entry && typeof entry === 'object' && (entry as Record).action === 'report') { + if ( + entry && + typeof entry === 'object' && + (entry as Record).action === 'report' + ) { return { action: entry as Record, index } } } @@ -640,8 +658,8 @@ export const parseSuppressionFromRespondLogic = ( const rawMaxCount = suppression.max_count const rawMinCount = suppression.min_count - const maxCount = rawMaxCount === undefined ? undefined : toNumber(rawMaxCount) ?? undefined - const minCount = rawMinCount === undefined ? undefined : toNumber(rawMinCount) ?? undefined + const maxCount = rawMaxCount === undefined ? undefined : (toNumber(rawMaxCount) ?? undefined) + const minCount = rawMinCount === undefined ? undefined : (toNumber(rawMinCount) ?? undefined) const isGlobal = Boolean(suppression.is_global) const rawKeys = Array.isArray(suppression.keys) @@ -667,7 +685,9 @@ export const parseSuppressionFromRespondLogic = ( } if (config.minCount && config.maxCount && config.minCount > config.maxCount) { - issues.push('Suppression min_count is greater than max_count; min_count will be clamped to max_count.') + issues.push( + 'Suppression min_count is greater than max_count; min_count will be clamped to max_count.', + ) config.minCount = config.maxCount } @@ -822,7 +842,9 @@ export const applySuppressionToMatches = ( if (windowCount < minCount) { status = 'suppressed-pre-threshold' isAlert = false - reasons = [`Threshold requires at least ${minCount} matches within ${config.periodMs / 1000}s`] + reasons = [ + `Threshold requires at least ${minCount} matches within ${config.periodMs / 1000}s`, + ] } else if (state.alertCountInWindow >= maxCount) { status = 'suppressed-post-threshold' isAlert = false From 9ba2e0f3c5ca79c3a822e7e951ea637b287afb83 Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Tue, 4 Nov 2025 20:13:44 -0600 Subject: [PATCH 5/9] Add Validation Test Suite documentation Documents the new validation test suite for DetectionForge, including its location, usage, and guidelines for maintaining alignment with LimaCharlie detection logic operators. Provides instructions for running the suite and updating fixtures and documentation as new validation behaviors are introduced. --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index 23cf889..f20dd57 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ A comprehensive detection engineering environment built for crafting, validating - [Security Considerations](#security-considerations) - [LimaCharlie Integration](#limacharlie-integration) - [Documentation & Resources](#documentation--resources) +- [Validation Test Suite](#validation-test-suite) - [Contributing](#contributing) - [License](#license) @@ -416,3 +417,16 @@ This means you can: **However**, if you modify DetectionForge and serve it where others can access it (including through a web interface), you **must** make your modified source code available under the same license. This ensures that improvements to DetectionForge always benefit the security community. For the complete license terms, see [LICENSE](LICENSE) or visit . +- [Test Harness for Detection Logic Validation](#validation-test-suite) +- LimaCharlie Operator Reference: [Detection Logic Operators](https://docs.limacharlie.io/docs/detection-logic-operators) +## Validation Test Suite + +DetectionForge includes a growing unit test harness that mirrors LimaCharlie’s detection logic specification. The suite lives under `src/utils/__tests__/` and is backed by sanitised rule fixtures in `src/utils/__tests__/fixtures/`. + +- `drValidation.spec.ts` keeps the validator aligned with the LimaCharlie operator contract. Each expectation maps to the official [Detection Logic Operators](https://docs.limacharlie.io/docs/detection-logic-operators) guide and covers both happy paths and invalid permutations. +- `fixtures/validDetectRules.ts` captures representative “good” rules derived from production playbooks. The fixtures are stripped of sensitive identifiers and can be expanded as we add more coverage. +- When adding operators or validation rules, drop new fixtures and assertions into the spec. Aim to encode every documented requirement (required fields, optional modifiers, transforms, and nested constructs) so regressions surface immediately. + +Run the suite with `npx vitest run src/utils/__tests__/drValidation.spec.ts`. For broader regression checks, execute `npx vitest run` or elevate the spec into the default test pipeline once we introduce additional suites. + +If you introduce new validation behaviours, update both the fixtures and this section with links or notes that reference the LimaCharlie docs. This keeps the harness maintainable as we add more real-world rules and variant operators over time. From 0377ff802e14d71588736c263995829581ab71bf Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Mon, 10 Nov 2025 21:10:49 -0600 Subject: [PATCH 6/9] Add metadata support to tag actions in schema Introduces an optional 'metadata' field to both 'add tag' and 'remove tag' action schemas, allowing custom metadata to be attached. Updates example usages to demonstrate the new field. --- src/utils/drSchema.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/utils/drSchema.ts b/src/utils/drSchema.ts index 5a3e7e8..ae813da 100644 --- a/src/utils/drSchema.ts +++ b/src/utils/drSchema.ts @@ -397,8 +397,12 @@ export const ACTION_SCHEMAS: Record = { optionalFields: [ { name: 'ttl', type: 'number', description: 'Time to live in seconds', min: 0 }, { name: 'entire_device', type: 'boolean', description: 'Tag entire device' }, + { name: 'metadata', type: 'object', description: 'Custom metadata' }, + ], + examples: [ + '- action: add tag\n tag: compromised\n ttl: 3600\n entire_device: true', + '- action: add tag\n tag: needs-sysmon\n metadata:\n id: d3f7b9a2-4e8c-4d1f-9b3e-5c2a7f8d1e4b', ], - examples: ['- action: add tag\n tag: compromised\n ttl: 3600\n entire_device: true'], category: 'core', }, @@ -408,8 +412,12 @@ export const ACTION_SCHEMAS: Record = { requiredFields: [{ name: 'tag', type: 'string', description: 'Tag name' }], optionalFields: [ { name: 'entire_device', type: 'boolean', description: 'Remove from entire device' }, + { name: 'metadata', type: 'object', description: 'Custom metadata' }, + ], + examples: [ + '- action: remove tag\n tag: clean', + '- action: remove tag\n tag: suspicious\n metadata:\n id: a1b2c3d4-5e6f-7g8h-9i0j-k1l2m3n4o5p6', ], - examples: ['- action: remove tag\n tag: clean'], category: 'core', }, From 9a17ccd780051a9e3ece261bdf17691374b86ce3 Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Mon, 10 Nov 2025 22:07:59 -0600 Subject: [PATCH 7/9] Release version 1.8.0 with analytics enhancements Bumps version to 1.8.0 and updates the changelog and README with new features: severity breakdown visualization, suppression tracking, match export, and a critical security fix for JWT credential handling. Also includes documentation and code formatting improvements. --- README.md | 3 +++ package.json | 2 +- src/utils/version.ts | 24 ++++++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f20dd57..6436c22 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,9 @@ DetectionForge is a **production-ready** detection engineering platform with com - **Unit Testing**: Built-in framework with preset event samples and custom test data support - **Historical Backtesting**: Multi-organization testing against historical telemetry via LimaCharlie's replay API - **Detection Impact Analysis**: Analyze rule effectiveness and potential false positives +- **Severity Analytics**: Color-coded severity breakdown with badges (critical/high/medium/low/info) and per-severity hit counts +- **Suppression Tracking**: Monitor actual vs suppressed alerts with sparkline visualization +- **Match Export**: Export all detection matches across organizations for comprehensive analysis - **Auto-Draft System**: Automatic saving of work-in-progress with recovery capabilities - **Event Schema Explorer**: Browse and explore LimaCharlie event schemas with field type information diff --git a/package.json b/package.json index a3b6ce3..da8d824 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lc-detectionforge", "description": "A comprehensive detection engineering environment for crafting, validating, and testing LimaCharlie detection rules", - "version": "1.7.0", + "version": "1.8.0", "private": true, "type": "module", "license": "AGPL-3.0-or-later", diff --git a/src/utils/version.ts b/src/utils/version.ts index cf7c42d..5ac8663 100644 --- a/src/utils/version.ts +++ b/src/utils/version.ts @@ -36,6 +36,30 @@ export interface ChangelogEntry { } export const CHANGELOG: ChangelogEntry[] = [ + { + version: '1.8.0', + date: '2025-11-10', + description: + 'Enhanced backtest analytics with severity breakdown, suppression tracking, and critical security fix', + changes: { + added: [ + 'Severity breakdown visualization with color-coded badges (critical/high/medium/low/info) and per-severity hit counts in backtest results', + 'Suppression analytics tracking actual vs suppressed alerts with sparkline visualization of alert patterns', + 'Export all matches functionality for consolidated JSON export of detection matches across organizations', + 'Validation Test Suite documentation in README with comprehensive test coverage details', + ], + changed: [ + 'Code formatting improvements for better readability and maintainability across multiple files', + ], + fixed: [ + 'Operator schema validation: Corrected field requirements for exists, is platform, architecture operators (is 32 bit, is 64 bit, is arm), and lookup operator', + 'Tag action schema now correctly supports metadata field for enhanced tagging capabilities', + ], + security: [ + 'JWT authentication credential exposure - Changed from GET with URL parameters to POST with form-encoded body, preventing credential exposure in browser history, server logs, network monitoring, and proxy caches', + ], + }, + }, { version: '1.7.0', date: '2025-10-15', From 42fa40746c60ad46dbcf035cefcc673eba0d196a Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Mon, 10 Nov 2025 22:13:05 -0600 Subject: [PATCH 8/9] Update src/utils/suppression.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/utils/suppression.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/utils/suppression.ts b/src/utils/suppression.ts index afc3fd4..8304a1e 100644 --- a/src/utils/suppression.ts +++ b/src/utils/suppression.ts @@ -1,5 +1,16 @@ import { logger } from './logger' +/** + * Represents the context object passed to template evaluation functions. + * + * This interface is used throughout the template rendering system to provide + * dynamic data for template interpolation. Valid contexts typically include + * event data, routing information, timestamps, and other key-value pairs + * relevant to the template being rendered. + * + * Keys are strings representing context variable names, and values can be of any type. + * Consumers should document expected keys and value types for each template. + */ interface TemplateContext { [key: string]: unknown } From 0514f00dd5778980979756b55127dca48ffa34d2 Mon Sep 17 00:00:00 2001 From: Eric Capuano Date: Mon, 10 Nov 2025 22:18:35 -0600 Subject: [PATCH 9/9] Update drValidation.spec.ts fix: replace any types with Record in test file --- src/utils/__tests__/drValidation.spec.ts | 36 +++++++++++++++--------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/src/utils/__tests__/drValidation.spec.ts b/src/utils/__tests__/drValidation.spec.ts index 110a8cb..fc2f051 100644 --- a/src/utils/__tests__/drValidation.spec.ts +++ b/src/utils/__tests__/drValidation.spec.ts @@ -48,10 +48,12 @@ describe('validateDetectLogic real-world fixtures', () => { expect(fixture).toBeDefined() - const parsed = yaml.load(fixture!.yaml) as any - const mutated = JSON.parse(JSON.stringify(parsed)) - const lookupOrRules: any[] = mutated.rules[1].rules[1].rules - let mutatedEntry: any | undefined + const parsed = yaml.load(fixture!.yaml) as Record + const mutated = JSON.parse(JSON.stringify(parsed)) as Record + const lookupOrRules = ( + (mutated.rules as Record[])[1].rules as Record[] + )[1].rules as Record[] + let mutatedEntry: Record | undefined for (const candidate of lookupOrRules) { if (candidate.op === 'lookup') { delete candidate.resource @@ -75,23 +77,29 @@ describe('validateDetectLogic real-world fixtures', () => { expect(fixture).toBeDefined() - const parsed = yaml.load(fixture!.yaml) as any + const parsed = yaml.load(fixture!.yaml) as Record - const missingOp = JSON.parse(JSON.stringify(parsed)) - const lookupVariant = missingOp.rules[1].rules[1].rules.find( - (entry: any) => entry.op === 'lookup' && entry.metadata_rules, - ) + const missingOp = JSON.parse(JSON.stringify(parsed)) as Record + const lookupVariant = ( + ((missingOp.rules as Record[])[1].rules as Record[])[1] + .rules as Record[] + ).find( + (entry: Record) => entry.op === 'lookup' && entry.metadata_rules, + ) as Record expect(lookupVariant).toBeDefined() - delete lookupVariant.metadata_rules.op + delete (lookupVariant.metadata_rules as Record).op expect(validateDetectLogic(yaml.dump(missingOp))).toContain( "metadata_rules[0]: Operation missing 'op' field.", ) - const emptyRules = JSON.parse(JSON.stringify(parsed)) - const lookupEmpty = emptyRules.rules[1].rules[1].rules.find( - (entry: any) => entry.op === 'lookup' && entry.metadata_rules, - ) + const emptyRules = JSON.parse(JSON.stringify(parsed)) as Record + const lookupEmpty = ( + ((emptyRules.rules as Record[])[1].rules as Record[])[1] + .rules as Record[] + ).find( + (entry: Record) => entry.op === 'lookup' && entry.metadata_rules, + ) as Record expect(lookupEmpty).toBeDefined() lookupEmpty.metadata_rules = []