diff --git a/README.md b/README.md index 23cf889..6436c22 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) @@ -77,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 @@ -416,3 +420,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. 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..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", @@ -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/__tests__/suppression.spec.ts b/src/__tests__/suppression.spec.ts new file mode 100644 index 0000000..a68b203 --- /dev/null +++ b/src/__tests__/suppression.spec.ts @@ -0,0 +1,128 @@ +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 f3d0aec..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 { @@ -3955,11 +4125,57 @@ 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 { font-weight: 500; color: var(--text-primary); transition: color var(--transition-speed) ease; + flex: 0 0 auto; } .match-action { @@ -3969,6 +4185,151 @@ 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 */ +.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; +} + +.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); + 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 { @@ -4233,6 +4594,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/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/components/Rules.vue b/src/components/Rules.vue index 767a8bb..daaa720 100644 --- a/src/components/Rules.vue +++ b/src/components/Rules.vue @@ -926,17 +926,55 @@ {{ backtestProgress.total }} organizations completed - +
+ + +
+
+
+ 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 @@ -1058,6 +1152,14 @@ Avg Matches per Org
+
+
+ {{ backtestResults.completionStats.avgActualAlertsPerOrg.toFixed(1) }} +
+
+ Avg Actual Alerts per Org +
+
{{ backtestResults.totalStats.n_billed.toLocaleString() }} @@ -1131,6 +1233,127 @@
+
+
+
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 }} +
+
+
@@ -1191,6 +1414,24 @@ > {{ orgResult.results.length }} {{ orgResult.results.length === 1 ? 'match' : 'matches' }} + + (Actual: + {{ + orgResult.suppressionSummary + ? orgResult.suppressionSummary.actualAlerts + : orgResult.results.length + }}) + + + • Suppressed: + {{ orgResult.suppressionSummary.suppressedTotal }} + @@ -1227,6 +1468,37 @@ }} 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
+ +
+
+ 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 }} + + + {{ getSuppressionStatusLabel(result) }} +
{{ @@ -2701,6 +3048,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() @@ -3060,6 +3415,9 @@ 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 + +const sparklineTooltip = ref<{ left: string; top: string; label: string } | null>(null) // Cursor-based pagination state const orgCursors = ref>({}) // Track cursors per org by OID @@ -3212,9 +3570,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 } @@ -3232,6 +3596,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) + const 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) @@ -3328,6 +3906,100 @@ 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 +} + +function getSuppressionStatusLabel(match: BacktestMatch): string { + const status = match.detectionforge_suppression?.status + + switch (status) { + case 'suppressed-pre-threshold': + return 'Suppressed' + case 'suppressed-post-threshold': + return 'Suppressed' + case 'evaluation-error': + return 'Actual Alert' + case 'actual-alert': + default: + return 'Actual Alert' + } +} + +function getSuppressionStatusClass(match: BacktestMatch): string { + const status = match.detectionforge_suppression?.status ?? 'actual-alert' + return `suppression-badge ${status}` +} + +function getSuppressionTooltip(match: BacktestMatch): string | undefined { + if ( + match.detectionforge_suppression?.reasons && + match.detectionforge_suppression.reasons.length > 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 @@ -3363,6 +4035,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[] @@ -3373,6 +4059,10 @@ interface BacktestResults { wall_time: number n_billed: number n_free: number + actualAlerts: number + suppressedPreThreshold: number + suppressedPostThreshold: number + suppressedTotal: number } timeframe: { startTime: string @@ -3393,7 +4083,10 @@ interface BacktestResults { wasCancelled: boolean orgsWithZeroHits: number avgMatchesPerOrg: number + avgActualAlertsPerOrg: number + avgAlertsPerDay: number } + suppressionOverview?: BacktestSuppressionOverview } interface BacktestResponse { @@ -3434,6 +4127,7 @@ interface BacktestOrgResult { results?: BacktestMatch[] did_match?: boolean is_dry_run?: boolean + suppressionSummary?: SuppressionComputationSummary } interface BacktestMatch { @@ -3446,6 +4140,10 @@ interface BacktestMatch { ts: string } detect_id: string + detect_mtd?: { + level?: string + [key: string]: unknown + } gen_time: number link?: string mtd: Record @@ -3453,6 +4151,7 @@ interface BacktestMatch { source: string source_rule: string } + detectionforge_suppression?: MatchSuppressionMetadata } // Initialize editors on mount @@ -5257,6 +5956,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.`, @@ -5269,8 +5994,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, @@ -5588,13 +6320,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, @@ -5700,6 +6435,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) { @@ -5712,6 +6521,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() @@ -5728,6 +6569,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 = { @@ -5739,6 +6590,8 @@ async function executeBacktest() { wasCancelled: isCancellingBacktest.value, orgsWithZeroHits, avgMatchesPerOrg, + avgActualAlertsPerOrg, + avgAlertsPerDay, } // Store results with completion timestamp @@ -5757,6 +6610,7 @@ async function executeBacktest() { totalExecutionTime: totalExecutionTime, }, completionStats, + suppressionOverview, } // Reset display state @@ -5779,10 +6633,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`) @@ -6136,6 +6994,17 @@ function exportOrgBacktestResults(orgResult: BacktestOrgResult) { ? 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, } @@ -6173,6 +7042,7 @@ function _exportBacktestResults() { cost_per_block: 0.01, events_per_block: 200000, }, + detectionforge_suppression_overview: backtestResults.value.suppressionOverview, }, org_results: backtestResults.value.orgResults, } @@ -6244,6 +7114,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))} | @@ -6255,14 +7129,20 @@ 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 = @@ -6277,10 +7157,41 @@ ${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')} +${ + // 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 +7207,102 @@ ${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 + let totalActualAlerts = 0 + let totalSuppressed = 0 + + orgsWithMatches.forEach((org) => { + if (org.results) { + org.results.forEach((match) => { + allMatches.push({ + ...match, + _metadata: { + oid: org.oid, + orgName: org.orgName, + }, + }) + totalMatches++ + }) + const actualAlerts = org.suppressionSummary + ? org.suppressionSummary.actualAlerts + : org.results.length + totalActualAlerts += actualAlerts + totalSuppressed += org.suppressionSummary?.suppressedTotal ?? 0 + } + }) + + 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, + total_actual_alerts: totalActualAlerts, + total_suppressed: totalSuppressed, + 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)), + }, + 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, + })), + }, + 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 (${totalActualAlerts.toLocaleString()} actual alert${ + totalActualAlerts === 1 ? '' : 's' + }) from ${orgsWithMatches.length} organization${orgsWithMatches.length !== 1 ? 's' : ''}`, + ) +} + function exportUnitTestSummaryAsMarkdown() { if (!overallTestResults.value || unitTests.value.length === 0) return @@ -6511,9 +7518,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 { @@ -6533,6 +7635,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/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) { diff --git a/src/utils/__tests__/drValidation.spec.ts b/src/utils/__tests__/drValidation.spec.ts new file mode 100644 index 0000000..fc2f051 --- /dev/null +++ b/src/utils/__tests__/drValidation.spec.ts @@ -0,0 +1,221 @@ +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 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 + 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 Record + + 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 as Record).op + + expect(validateDetectLogic(yaml.dump(missingOp))).toContain( + "metadata_rules[0]: Operation missing 'op' field.", + ) + + 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 = [] + + 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..9066fe2 --- /dev/null +++ b/src/utils/__tests__/fixtures/validDetectRules.ts @@ -0,0 +1,182 @@ +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..ae813da 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' }, ], @@ -387,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', }, @@ -398,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', }, diff --git a/src/utils/drValidation.ts b/src/utils/drValidation.ts index efffb1a..7dafcb2 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,41 @@ 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) { diff --git a/src/utils/suppression.ts b/src/utils/suppression.ts new file mode 100644 index 0000000..8304a1e --- /dev/null +++ b/src/utils/suppression.ts @@ -0,0 +1,929 @@ +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 +} + +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, + } +} 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',